mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
7b000dba9d
* feat(reef): operator-configurable sharing rules for the guard Adds channels.reef.guard.rules with capped outbound/inbound free-text policy. Rules ride the trusted instruction side of the guard call only, may tighten decisions or explicitly allow otherwise-review cases, and can never override the deny floor or deterministic checks. The rules text is hashed into the effective policy version so audit rows and pending review approvals bind to the exact policy in force. * fix(reef): full rules digest in policy identity; align blank-rule validation ClawSweeper review fixes: use the untruncated sha256 rules digest in the effective policy version so approvalDigest stays collision-resistant, and express identical non-blank (\S) rule validation in the zod schema, the manifest JSON Schemas, and the generated channel metadata instead of a trim-transform mismatch.
206 lines
6.7 KiB
TypeScript
206 lines
6.7 KiB
TypeScript
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
|
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
|
|
import {
|
|
admitGuardAdapter,
|
|
assertGuardRules,
|
|
assertPinnedModel,
|
|
guardInstructions,
|
|
type GuardAdapter,
|
|
type GuardRequest,
|
|
type GuardRules,
|
|
type RawGuardAdapter,
|
|
} from "./guard.js";
|
|
|
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
|
|
interface AdapterOptions {
|
|
apiKey: string;
|
|
pinnedModel: string;
|
|
fetch: FetchLike;
|
|
timeoutMs?: number;
|
|
rules?: GuardRules;
|
|
}
|
|
|
|
const verdictSchema = {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
decision: { type: "string", enum: ["allow", "deny", "review"] },
|
|
category: { type: "string" },
|
|
reason: { type: "string" },
|
|
policyVersion: { type: "string" },
|
|
},
|
|
required: ["decision", "category", "reason", "policyVersion"],
|
|
} as const;
|
|
|
|
export function createOpenAiGuard(options: AdapterOptions): GuardAdapter {
|
|
assertPinnedModel(options.pinnedModel);
|
|
assertGuardRules(options.rules);
|
|
const raw: RawGuardAdapter = {
|
|
providerId: "openai",
|
|
pinnedModel: options.pinnedModel,
|
|
async classifyRaw(request, signal) {
|
|
const response = await options.fetch("https://api.openai.com/v1/responses", {
|
|
method: "POST",
|
|
signal,
|
|
headers: { "content-type": "application/json", authorization: `Bearer ${options.apiKey}` },
|
|
body: JSON.stringify({
|
|
model: options.pinnedModel,
|
|
instructions: instructionFor(request, options.rules),
|
|
input: JSON.stringify(request),
|
|
store: false,
|
|
background: false,
|
|
tools: [],
|
|
text: {
|
|
format: {
|
|
type: "json_schema",
|
|
name: "reef_guard_verdict",
|
|
strict: true,
|
|
schema: verdictSchema,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new Error(`guard HTTP ${response.status}`);
|
|
}
|
|
const envelope = await parseJsonResponse(response);
|
|
if (
|
|
!isRecord(envelope) ||
|
|
typeof envelope.model !== "string" ||
|
|
envelope.model !== options.pinnedModel ||
|
|
envelope.status !== "completed" ||
|
|
!Array.isArray(envelope.output)
|
|
) {
|
|
throw new Error("invalid OpenAI guard response");
|
|
}
|
|
const outputTexts: string[] = [];
|
|
for (const item of envelope.output) {
|
|
if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) {
|
|
continue;
|
|
}
|
|
for (const part of item.content) {
|
|
if (isRecord(part) && part.type === "output_text" && typeof part.text === "string") {
|
|
outputTexts.push(part.text);
|
|
}
|
|
}
|
|
}
|
|
if (outputTexts.length !== 1) {
|
|
throw new Error("guard must return one OpenAI output object");
|
|
}
|
|
return attachProviderModel(parseStrictJson(outputTexts[0]!, true), envelope.model);
|
|
},
|
|
};
|
|
return admitGuardAdapter(raw, options.timeoutMs);
|
|
}
|
|
|
|
export function createAnthropicGuard(options: AdapterOptions): GuardAdapter {
|
|
assertPinnedModel(options.pinnedModel);
|
|
assertGuardRules(options.rules);
|
|
const raw: RawGuardAdapter = {
|
|
providerId: "anthropic",
|
|
pinnedModel: options.pinnedModel,
|
|
async classifyRaw(request, signal) {
|
|
const response = await options.fetch("https://api.anthropic.com/v1/messages", {
|
|
method: "POST",
|
|
signal,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-api-key": options.apiKey,
|
|
"anthropic-version": "2023-06-01",
|
|
},
|
|
body: JSON.stringify({
|
|
model: options.pinnedModel,
|
|
max_tokens: 512,
|
|
system: `${instructionFor(request, options.rules)} The object must exactly match this schema: ${JSON.stringify(verdictSchema)}`,
|
|
output_config: { format: { type: "json_schema", schema: verdictSchema } },
|
|
messages: [{ role: "user", content: JSON.stringify(request) }],
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new Error(`guard HTTP ${response.status}`);
|
|
}
|
|
const envelope = await parseJsonResponse(response);
|
|
if (
|
|
!isRecord(envelope) ||
|
|
typeof envelope.model !== "string" ||
|
|
envelope.model !== options.pinnedModel ||
|
|
!Array.isArray(envelope.content) ||
|
|
envelope.stop_reason !== "end_turn"
|
|
) {
|
|
throw new Error("invalid Anthropic guard response");
|
|
}
|
|
if (envelope.content.length !== 1) {
|
|
throw new Error("invalid Anthropic guard content");
|
|
}
|
|
const part = envelope.content[0];
|
|
if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") {
|
|
throw new Error("missing Anthropic guard output");
|
|
}
|
|
return attachProviderModel(parseStrictJson(part.text, true), envelope.model);
|
|
},
|
|
};
|
|
return admitGuardAdapter(raw, options.timeoutMs);
|
|
}
|
|
|
|
function instructionFor(request: GuardRequest, rules?: GuardRules): string {
|
|
return `${guardInstructions(request.direction, rules)} Set policyVersion to exactly ${JSON.stringify(request.policyVersion)}.`;
|
|
}
|
|
|
|
function attachProviderModel(value: unknown, model: string): unknown {
|
|
if (!isRecord(value) || Object.hasOwn(value, "model")) {
|
|
throw new Error("invalid model guard verdict");
|
|
}
|
|
return { ...value, model };
|
|
}
|
|
|
|
async function parseJsonResponse(response: Response): Promise<unknown> {
|
|
const text = await readProviderTextResponse(response, "Reef guard response", {
|
|
maxBytes: 256 * 1024,
|
|
});
|
|
return parseStrictJson(text);
|
|
}
|
|
|
|
function parseStrictJson(text: string, rejectDuplicateKeys = false): unknown {
|
|
const trimmed = text.trim();
|
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
|
throw new Error("guard returned non-object JSON");
|
|
}
|
|
if (rejectDuplicateKeys && hasDuplicateKeys(trimmed)) {
|
|
throw new Error("guard returned duplicate JSON keys");
|
|
}
|
|
return JSON.parse(trimmed) as unknown;
|
|
}
|
|
|
|
function hasDuplicateKeys(text: string): boolean {
|
|
const keys = new Set<string>();
|
|
for (let index = 0; index < text.length; index++) {
|
|
if (text[index] !== '"') {
|
|
continue;
|
|
}
|
|
const start = index;
|
|
for (index++; index < text.length; index++) {
|
|
if (text[index] === "\\") {
|
|
index++;
|
|
} else if (text[index] === '"') {
|
|
break;
|
|
}
|
|
}
|
|
let next = index + 1;
|
|
while (/\s/.test(text[next] ?? "")) {
|
|
next++;
|
|
}
|
|
if (text[next] !== ":") {
|
|
continue;
|
|
}
|
|
const key = JSON.parse(text.slice(start, index + 1)) as string;
|
|
if (keys.has(key)) {
|
|
return true;
|
|
}
|
|
keys.add(key);
|
|
}
|
|
return false;
|
|
}
|