diff --git a/integrations/openclaw-opencoat-bridge/README.md b/integrations/openclaw-opencoat-bridge/README.md index 4c6ff44..c44d777 100644 --- a/integrations/openclaw-opencoat-bridge/README.md +++ b/integrations/openclaw-opencoat-bridge/README.md @@ -30,7 +30,7 @@ Skipped: `before_message_write`, `tool_result_persist` (sync hot path — cannot | `llm_output` | `after_reasoning` | submit | | `agent_end` / `message_sent` | `after_response` | submit | | `message_sending` | `before_response` | submit + **`cancel`** when BLOCK advice | -| `before_tool_call` | `before_tool_call` | submit + **`block`** / param guard | +| `before_tool_call` | `before_tool_call` | submit + **`block`** / param guard (or **in-proc ReflexMonitor** when enabled) | | `after_tool_call` | `after_tool_call` | submit (DCN activation) | | `queue_before_enqueue` | `queue.before_enqueue` | submit + **`block`** / queue prompt rewrite | | `queue_after_enqueue` | `queue.after_enqueue` | submit (observe) | @@ -296,7 +296,7 @@ Requires **JoinpointDiscovery** (`expand_prompt_surface` on by default). Older d cd /path/to/OpenCOAT/integrations/openclaw-opencoat-bridge npm run build openclaw gateway restart -grep opencoat-bridge ~/.openclaw/logs/gateway.log # expect "registered 28 hooks" + runtime observers +grep opencoat-bridge ~/.openclaw/logs/gateway.log # expect "registered 29 hooks" + runtime observers ``` ## Weaving expectations @@ -314,8 +314,45 @@ heartbeat enabled — see root [`README.md`](../../README.md) § Heartbeat + DCN daemon). Extraction updates the concern store; it does not always add rows to `injections` on that same submit. +## In-proc ReflexMonitor — `tool_guard` TCB prototype (v0.3) + +Optional **authoritative** hot path for `before_tool_call` (no daemon RPC on the +guard decision). Enable in OpenClaw plugin config: + +```json +{ + "plugins": { + "entries": { + "@hyperdustlabs/opencoat-bridge": { + "config": { + "inProcReflexToolGuard": true, + "reflexSyncFromDaemon": true, + "reflexAuditToDaemon": true + } + } + } + } +} +``` + +**Behavior** + +- Policies load from daemon `reflex.policies.export` (hard `TOOL_GUARD` + BLOCK concerns). +- Falls back to built-in `demo-tool-block` needles when export is empty. +- **Fail-closed** on monitor errors (contrast: collaborative path fail-open). +- Optional async `joinpoint.submit` audit (`reflexAuditToDaemon`) for DCN without blocking the hook. + +Gateway log when enabled: + +```text +[opencoat-bridge] in-proc ReflexMonitor tool_guard policies: demo-tool-block, … +``` + +See [v0.3 §10.5](../../docs/design/v0.3-morphogenetic-architecture.md#105-实现分期-2026-05). + ## Limitations (v0.1 bridge) +- **v0.3 gap:** when `inProcReflexToolGuard` is off, guards are **collaborative** (daemon RPC, fail-open on bridge error). Enable in-proc mode for authoritative fail-closed `tool_guard` ([v0.3 §10.5](../../docs/design/v0.3-morphogenetic-architecture.md#105-实现分期-2026-05)). - Prompt folding uses `prependSystemContext` only (not full dotted-path injector parity with Python `OpenClawInjector`). - **`queue.before_enqueue`** sync veto/rewrite requires OpenClaw **fork** (`queue_before_enqueue` hook). Poll fallback in `runtime-observers.ts` is observe-only. - Non-subagent **`task.before_create`** is observe-only (task poll); spawn veto works on `subagent_spawning` only. diff --git a/integrations/openclaw-opencoat-bridge/openclaw.plugin.json b/integrations/openclaw-opencoat-bridge/openclaw.plugin.json index 5d1abe6..61e009d 100644 --- a/integrations/openclaw-opencoat-bridge/openclaw.plugin.json +++ b/integrations/openclaw-opencoat-bridge/openclaw.plugin.json @@ -18,6 +18,26 @@ "logActivations": { "type": "boolean", "description": "Log concern ids when injections are non-empty" + }, + "inProcReflexToolGuard": { + "type": "boolean", + "description": "Run before_tool_call through in-proc ReflexMonitor (v0.3 TCB prototype; fail-closed)" + }, + "reflexSyncFromDaemon": { + "type": "boolean", + "description": "Load reflex.policies.export from daemon on plugin start (default true when in-proc enabled)" + }, + "reflexAuditToDaemon": { + "type": "boolean", + "description": "Async joinpoint.submit after in-proc tool guard for DCN audit" + }, + "reflexIncludeDemoPolicy": { + "type": "boolean", + "description": "Include built-in demo-tool-block spec when daemon export is empty" + }, + "emitRtJsonl": { + "type": "boolean", + "description": "Emit structured r_t records to daemon credit.r_t.append (default on when inProcReflexToolGuard)" } } }, diff --git a/integrations/openclaw-opencoat-bridge/package.json b/integrations/openclaw-opencoat-bridge/package.json index 70b8d63..778bccc 100644 --- a/integrations/openclaw-opencoat-bridge/package.json +++ b/integrations/openclaw-opencoat-bridge/package.json @@ -13,7 +13,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "test": "npm run build && node --test dist/messages.test.js dist/hook-bindings.test.js dist/injector.test.js dist/runtime-observers.test.js", + "test": "npm run build && node --test dist/messages.test.js dist/hook-bindings.test.js dist/injector.test.js dist/runtime-observers.test.js dist/reflex-monitor.test.js dist/reflex-tool-guard.test.js dist/r-t-emit.test.js", "prepare": "npm run build" }, "devDependencies": { diff --git a/integrations/openclaw-opencoat-bridge/src/daemon.ts b/integrations/openclaw-opencoat-bridge/src/daemon.ts index f68bcb9..fe72d9b 100644 --- a/integrations/openclaw-opencoat-bridge/src/daemon.ts +++ b/integrations/openclaw-opencoat-bridge/src/daemon.ts @@ -5,6 +5,10 @@ import type { ConcernInjection, JoinpointWire, } from "./types.js"; +import { + parseReflexPolicyExport, + type ReflexPolicySpec, +} from "./reflex-policy-spec.js"; const JOINPOINT_LEVEL_RUNTIME = 0; const JOINPOINT_LEVEL_LIFECYCLE = 1; @@ -24,9 +28,28 @@ export function resolveConfig(raw: Record | undefined): BridgeC extractOnUserMessage: raw?.extractOnUserMessage === true, runtimeObservers: raw?.runtimeObservers !== false, observerPollMs, + inProcReflexToolGuard: raw?.inProcReflexToolGuard === true, + reflexSyncFromDaemon: raw?.reflexSyncFromDaemon !== false, + reflexAuditToDaemon: raw?.reflexAuditToDaemon !== false, + reflexPolicies: parseInlineReflexPolicies(raw?.reflexPolicies), + reflexIncludeDemoPolicy: raw?.reflexIncludeDemoPolicy !== false, + emitRtJsonl: + raw?.emitRtJsonl === true || + (raw?.emitRtJsonl !== false && raw?.inProcReflexToolGuard === true), }; } +function parseInlineReflexPolicies(raw: unknown): ReflexPolicySpec[] { + const parsed = parseReflexPolicyExport( + raw && typeof raw === "object" && Array.isArray((raw as { policies?: unknown }).policies) + ? { version: "0.1", policies: (raw as { policies: unknown[] }).policies } + : Array.isArray(raw) + ? { version: "0.1", policies: raw } + : null, + ); + return parsed?.policies ?? []; +} + export function runKey(ctx: AgentHookCtx): string { return ctx.runId ?? ctx.sessionId ?? ctx.sessionKey ?? "default"; } diff --git a/integrations/openclaw-opencoat-bridge/src/index.ts b/integrations/openclaw-opencoat-bridge/src/index.ts index 41f99aa..775626b 100644 --- a/integrations/openclaw-opencoat-bridge/src/index.ts +++ b/integrations/openclaw-opencoat-bridge/src/index.ts @@ -1,7 +1,7 @@ /** * OpenCOAT ↔ OpenClaw bridge (daemon-backed). * - * - Plugin hooks (`api.on`): 26/29 async-safe hooks — see hook-bindings.ts. + * - Plugin hooks (`api.on`): 29 async-safe hooks — see hook-bindings.ts. * - Runtime observers: `onAgentEvent`, internal compact hooks, queue/task poll — * see runtime-observers.ts (ADR-0011 MVP queue/reply_run/task observe paths). * @@ -35,6 +35,14 @@ import { toolResultPayload, } from "./payloads.js"; import { createObserveEmitter } from "./emit-joinpoint.js"; +import { loadReflexRuntime, buildReflexRuntime } from "./reflex-policy-sync.js"; +import type { ReflexRuntime } from "./reflex-policy-sync.js"; +import { + buildReflexState, + buildToolCallAction, + failClosedToolGuard, + reflexToolGuardDecision, +} from "./reflex-tool-guard.js"; import { installRuntimeObservers, recordQueueDepthSnapshot, @@ -48,6 +56,23 @@ import type { } from "./types.js"; const pendingByRun = new Map(); +const reflexState: { runtime: ReflexRuntime | null } = { runtime: null }; + +function auditToolGuardJoinpoint( + cfg: BridgeConfig, + api: BridgePluginApi, + binding: (typeof HOOK_BINDINGS)[number], + payload: Record, + c: AgentHookCtx, + block: boolean, + reason?: string, +): void { + if (!cfg.reflexAuditToDaemon || !cfg.enabled) return; + void emit(cfg, api, binding.hook, binding.joinpoint, { + ...payload, + reflex_monitor: { block, reason }, + }, c).catch(() => undefined); +} function rememberInjection(run: string, inj: ConcernInjection | null): void { if (!inj?.injections?.length) return; @@ -218,6 +243,48 @@ async function handleHook( e.params && typeof e.params === "object" ? { ...(e.params as Record) } : {}; + const toolName = typeof e.toolName === "string" ? e.toolName : "tool"; + + if (cfg.inProcReflexToolGuard) { + const runtime = reflexState.runtime; + if (!runtime) { + return failClosedToolGuard( + params, + new Error("ReflexMonitor not initialized"), + ); + } + try { + const action = buildToolCallAction({ toolName, params }); + const decision = reflexToolGuardDecision( + runtime.monitor, + action, + buildReflexState(c), + params, + ); + auditToolGuardJoinpoint( + cfg, + api, + binding, + payload, + c, + decision.block, + decision.blockReason, + ); + if (decision.block) { + return { + block: true, + blockReason: + decision.blockReason ?? + "Blocked by OpenCOAT ReflexMonitor (tool_guard).", + params: decision.params, + }; + } + return decision.params !== params ? { params: decision.params } : {}; + } catch (err) { + return failClosedToolGuard(params, err); + } + } + const inj = await emit(cfg, api, binding.hook, binding.joinpoint, payload, c); const decision = guardToolCall(inj, params); if (!decision.block) { @@ -273,13 +340,40 @@ async function handleHook( err instanceof Error ? err.message : String(err) }`, ); - return binding.kind === "tool_guard" ? {} : undefined; + return binding.kind === "tool_guard" + ? cfg.inProcReflexToolGuard + ? failClosedToolGuard( + {}, + err instanceof Error ? err : new Error(String(err)), + ) + : {} + : undefined; } } export default function register(api: BridgePluginApi): void { const cfg = resolveConfig(api.pluginConfig); + if (cfg.inProcReflexToolGuard) { + reflexState.runtime = buildReflexRuntime(cfg, null); + void loadReflexRuntime(cfg) + .then((runtime) => { + reflexState.runtime = runtime; + api.logger?.info?.( + `[opencoat-bridge] in-proc ReflexMonitor tool_guard policies: ${ + runtime.policyIds.join(", ") || "(none)" + }`, + ); + }) + .catch((err) => { + api.logger?.warn?.( + `[opencoat-bridge] reflex policy sync failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + } + for (const binding of HOOK_BINDINGS) { api.on(binding.hook, (event: unknown, ctx: unknown) => handleHook(api, cfg, binding, event, ctx), @@ -297,6 +391,8 @@ export default function register(api: BridgePluginApi): void { `[opencoat-bridge] registered ${HOOK_BINDINGS.length} hooks ` + `(skipped: ${SKIPPED_HOOKS.join(", ")}; daemon=${ cfg.enabled ? cfg.daemonUrl : "disabled" - }${observerNote})`, + }${observerNote}${ + cfg.inProcReflexToolGuard ? "; in-proc ReflexMonitor tool_guard" : "" + })`, ); } diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-monitor.test.ts b/integrations/openclaw-opencoat-bridge/src/reflex-monitor.test.ts new file mode 100644 index 0000000..32f6762 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-monitor.test.ts @@ -0,0 +1,81 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ReflexMonitor } from "./reflex-monitor.js"; +import { compileReflexPolicies, DEMO_TOOL_BLOCK_SPEC } from "./reflex-policies.js"; +import type { Action, ReflexPolicy, State } from "./reflex-monitor.js"; + +const state: State = { + session_id: "s1", + turn_id: "r1", + features: {}, +}; + +describe("ReflexMonitor", () => { + it("allows when no policy applies", () => { + const monitor = new ReflexMonitor( + compileReflexPolicies([DEMO_TOOL_BLOCK_SPEC]), + ); + const action: Action = { + kind: "tool_call", + name: "read", + args: { path: "/tmp/x" }, + }; + const { decision } = monitor.mediate(action, state); + assert.equal(decision.kind, "allow"); + }); + + it("denies rm -rf tool args (demo-tool-block)", () => { + const monitor = new ReflexMonitor( + compileReflexPolicies([DEMO_TOOL_BLOCK_SPEC]), + ); + const action: Action = { + kind: "tool_call", + name: "shell.exec", + args: { command: "rm -rf /tmp/scratch" }, + }; + const { decision } = monitor.mediate(action, state); + assert.equal(decision.kind, "deny"); + if (decision.kind === "deny") { + assert.equal(decision.policy_id, "demo-tool-block"); + assert.match(decision.reason, /rm -rf/); + } + }); + + it("fail-closes when safety_critical policy throws", () => { + const bad: ReflexPolicy = { + id: "bad-policy", + criticality: "safety_critical", + applies: () => true, + decide: () => { + throw new Error("predicate bug"); + }, + }; + const monitor = new ReflexMonitor([bad]); + const { decision } = monitor.mediate( + { kind: "tool_call", name: "x", args: {} }, + state, + ); + assert.equal(decision.kind, "deny"); + if (decision.kind === "deny") { + assert.match(decision.reason, /fail-closed/i); + } + }); + + it("deny beats allow from multiple policies", () => { + const allowAll: ReflexPolicy = { + id: "allow-a", + criticality: "advisory", + applies: () => true, + decide: () => ({ kind: "allow" }), + }; + const monitor = new ReflexMonitor([ + allowAll, + ...compileReflexPolicies([DEMO_TOOL_BLOCK_SPEC]), + ]); + const { decision } = monitor.mediate( + { kind: "tool_call", name: "shell", args: { cmd: "rm -rf /" } }, + state, + ); + assert.equal(decision.kind, "deny"); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-monitor.ts b/integrations/openclaw-opencoat-bridge/src/reflex-monitor.ts new file mode 100644 index 0000000..7a12609 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-monitor.ts @@ -0,0 +1,148 @@ +/** + * In-proc authoritative reflex monitor (v0.3 §10.2–10.3 TCB prototype). + * + * Pure, synchronous, no I/O. Safety-critical policies fail-closed on error. + */ + +import type { ReflexCriticality } from "./reflex-policy-spec.js"; + +export type ActionKind = "tool_call"; + +export type Action = { + kind: ActionKind; + name: string; + args: Record; + raw?: unknown; +}; + +export type State = { + session_id: string; + turn_id: string; + features: Record; +}; + +export type AllowDecision = { kind: "allow" }; +export type DenyDecision = { + kind: "deny"; + reason: string; + policy_id: string; +}; +export type RewriteDecision = { + kind: "rewrite"; + action: Action; + reason: string; + policy_id: string; +}; + +export type Decision = AllowDecision | DenyDecision | RewriteDecision; + +export type DecisionRecord = { + turn_id: string; + action_kind: ActionKind; + action_name: string; + decision: Decision["kind"]; + policy_id?: string; + reason?: string; + criticality?: ReflexCriticality; +}; + +export type ReflexPolicy = { + id: string; + criticality: ReflexCriticality; + applies: (action: Action, state: State) => boolean; + decide: (action: Action, state: State) => Decision; +}; + +const DECISION_RANK: Record = { + deny: 3, + rewrite: 2, + allow: 1, +}; + +function mergeDecisions(current: Decision, next: Decision): Decision { + const curRank = DECISION_RANK[current.kind]; + const nextRank = DECISION_RANK[next.kind]; + if (nextRank > curRank) return next; + if (nextRank < curRank) return current; + if (current.kind === "deny" && next.kind === "deny") { + return { + kind: "deny", + policy_id: current.policy_id, + reason: [current.reason, next.reason].filter(Boolean).join("\n"), + }; + } + if (current.kind === "rewrite" && next.kind === "rewrite") { + if (current.policy_id <= next.policy_id) return current; + return next; + } + return current; +} + +export class ReflexMonitor { + private readonly policies: ReflexPolicy[]; + private readonly conservedCore: ReadonlySet; + + constructor( + policies: ReflexPolicy[], + options?: { conservedCore?: Iterable }, + ) { + this.policies = [...policies].sort((a, b) => a.id.localeCompare(b.id)); + this.conservedCore = new Set(options?.conservedCore ?? []); + } + + conservedCoreIds(): ReadonlySet { + return this.conservedCore; + } + + mediate(action: Action, state: State): { decision: Decision; record: DecisionRecord } { + let decision: Decision = { kind: "allow" }; + let winningPolicy: ReflexPolicy | undefined; + + for (const policy of this.policies) { + try { + if (!policy.applies(action, state)) continue; + const next = policy.decide(action, state); + if (next.kind === "allow") continue; + decision = winningPolicy + ? mergeDecisions(decision, next) + : next; + if (!winningPolicy || DECISION_RANK[next.kind] >= DECISION_RANK[decision.kind]) { + winningPolicy = policy; + } + } catch (err) { + if (policy.criticality === "safety_critical") { + const reason = + err instanceof Error ? err.message : "Reflex policy evaluation failed"; + return { + decision: { + kind: "deny", + policy_id: policy.id, + reason: `ReflexMonitor fail-closed: ${reason}`, + }, + record: { + turn_id: state.turn_id, + action_kind: action.kind, + action_name: action.name, + decision: "deny", + policy_id: policy.id, + reason, + criticality: policy.criticality, + }, + }; + } + } + } + + const record: DecisionRecord = { + turn_id: state.turn_id, + action_kind: action.kind, + action_name: action.name, + decision: decision.kind, + policy_id: decision.kind === "allow" ? undefined : decision.policy_id, + reason: decision.kind === "allow" ? undefined : decision.reason, + criticality: winningPolicy?.criticality, + }; + + return { decision, record }; + } +} diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-policies.ts b/integrations/openclaw-opencoat-bridge/src/reflex-policies.ts new file mode 100644 index 0000000..dbdcc93 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-policies.ts @@ -0,0 +1,70 @@ +import type { ReflexPolicySpec } from "./reflex-policy-spec.js"; +import type { Action, Decision, ReflexPolicy, State } from "./reflex-monitor.js"; + +function serializeArgs(args: Record): string { + try { + return JSON.stringify(args); + } catch { + return String(args); + } +} + +function argsContains( + action: Action, + needles: string[], + caseInsensitive: boolean, +): boolean { + const hay = caseInsensitive + ? serializeArgs(action.args).toLowerCase() + : serializeArgs(action.args); + for (const needle of needles) { + const n = caseInsensitive ? needle.toLowerCase() : needle; + if (hay.includes(n)) return true; + } + return false; +} + +function compileOne(spec: ReflexPolicySpec): ReflexPolicy { + const deny = (): Decision => ({ + kind: "deny", + policy_id: spec.id, + reason: spec.deny_reason, + }); + + return { + id: spec.id, + criticality: spec.criticality, + applies(action: Action, _state: State): boolean { + if (action.kind !== "tool_call") return false; + if (spec.predicate.kind === "tool_name") { + return spec.predicate.names.includes(action.name); + } + return argsContains( + action, + spec.predicate.needles, + spec.predicate.case_insensitive === true, + ); + }, + decide(action: Action, state: State): Decision { + if (!this.applies(action, state)) return { kind: "allow" }; + return deny(); + }, + }; +} + +export function compileReflexPolicies(specs: ReflexPolicySpec[]): ReflexPolicy[] { + return specs.map(compileOne); +} + +/** Built-in demo policy matching ``demo-tool-block`` when daemon export is unavailable. */ +export const DEMO_TOOL_BLOCK_SPEC: ReflexPolicySpec = { + id: "demo-tool-block", + criticality: "safety_critical", + action_kind: "tool_call", + predicate: { + kind: "args_contains", + needles: ["rm -rf", "rm -rf"], + }, + deny_reason: + "Refusing destructive shell command — `rm -rf` is blocked by demo-tool-block.", +}; diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-policy-spec.ts b/integrations/openclaw-opencoat-bridge/src/reflex-policy-spec.ts new file mode 100644 index 0000000..c25399b --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-policy-spec.ts @@ -0,0 +1,74 @@ +/** Portable deterministic reflex policy spec (v0.3 §10.4 export from OpenCOAT). */ + +export type ReflexCriticality = "safety_critical" | "advisory"; + +export type ReflexPolicySpec = { + id: string; + criticality: ReflexCriticality; + /** Only `tool_call` is implemented in the TCB prototype. */ + action_kind: "tool_call"; + predicate: ReflexPredicateSpec; + deny_reason: string; +}; + +export type ReflexPredicateSpec = + | { + kind: "args_contains"; + needles: string[]; + case_insensitive?: boolean; + } + | { + kind: "tool_name"; + names: string[]; + }; + +export type ReflexPolicyExport = { + version: "0.1"; + policies: ReflexPolicySpec[]; +}; + +export function parseReflexPolicyExport(raw: unknown): ReflexPolicyExport | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + if (obj.version !== "0.1" || !Array.isArray(obj.policies)) return null; + + const policies: ReflexPolicySpec[] = []; + for (const row of obj.policies) { + if (!row || typeof row !== "object") continue; + const p = row as Record; + if (typeof p.id !== "string" || !p.id.trim()) continue; + if (p.action_kind !== "tool_call") continue; + if (p.criticality !== "safety_critical" && p.criticality !== "advisory") continue; + if (typeof p.deny_reason !== "string") continue; + + const pred = p.predicate; + if (!pred || typeof pred !== "object") continue; + const pr = pred as Record; + + let predicate: ReflexPredicateSpec | null = null; + if (pr.kind === "args_contains" && Array.isArray(pr.needles)) { + const needles = pr.needles.filter((n): n is string => typeof n === "string" && n.length > 0); + if (needles.length) { + predicate = { + kind: "args_contains", + needles, + case_insensitive: pr.case_insensitive === true, + }; + } + } else if (pr.kind === "tool_name" && Array.isArray(pr.names)) { + const names = pr.names.filter((n): n is string => typeof n === "string" && n.length > 0); + if (names.length) predicate = { kind: "tool_name", names }; + } + if (!predicate) continue; + + policies.push({ + id: p.id, + criticality: p.criticality, + action_kind: "tool_call", + predicate, + deny_reason: p.deny_reason, + }); + } + + return { version: "0.1", policies }; +} diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-policy-sync.ts b/integrations/openclaw-opencoat-bridge/src/reflex-policy-sync.ts new file mode 100644 index 0000000..d2c0292 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-policy-sync.ts @@ -0,0 +1,85 @@ +import { + parseReflexPolicyExport, + type ReflexPolicyExport, +} from "./reflex-policy-spec.js"; +import { compileReflexPolicies, DEMO_TOOL_BLOCK_SPEC } from "./reflex-policies.js"; +import { ReflexMonitor } from "./reflex-monitor.js"; +import type { BridgeConfig } from "./types.js"; + +export type ReflexRuntime = { + monitor: ReflexMonitor; + exportVersion: string; + policyIds: string[]; +}; + +export async function fetchReflexPolicyExport( + cfg: BridgeConfig, +): Promise { + if (!cfg.enabled) return null; + + const body = { + jsonrpc: "2.0", + method: "reflex.policies.export", + id: `reflex-export-${crypto.randomUUID()}`, + params: { action_kind: "tool_call" }, + }; + + let res: Response; + try { + res = await fetch(cfg.daemonUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + } catch { + return null; + } + + if (!res.ok) return null; + + const json = (await res.json()) as { + result?: unknown; + error?: { message?: string }; + }; + if (json.error) return null; + return parseReflexPolicyExport(json.result); +} + +export function buildReflexRuntime( + cfg: BridgeConfig, + exported: ReflexPolicyExport | null, +): ReflexRuntime { + const specs = [ + ...(cfg.reflexPolicies ?? []), + ...(exported?.policies ?? []), + ...(cfg.reflexIncludeDemoPolicy && !exported?.policies?.length + ? [DEMO_TOOL_BLOCK_SPEC] + : []), + ]; + + const byId = new Map(); + for (const spec of specs) { + byId.set(spec.id, spec); + } + const unique = [...byId.values()]; + const policies = compileReflexPolicies(unique); + + return { + monitor: new ReflexMonitor(policies, { + conservedCore: unique + .filter((s) => s.criticality === "safety_critical") + .map((s) => s.id), + }), + exportVersion: exported?.version ?? "inline", + policyIds: unique.map((s) => s.id), + }; +} + +export async function loadReflexRuntime(cfg: BridgeConfig): Promise { + const exported = + cfg.inProcReflexToolGuard && cfg.reflexSyncFromDaemon + ? await fetchReflexPolicyExport(cfg) + : null; + return buildReflexRuntime(cfg, exported); +} diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-tool-guard.test.ts b/integrations/openclaw-opencoat-bridge/src/reflex-tool-guard.test.ts new file mode 100644 index 0000000..13fb12a --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-tool-guard.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ReflexMonitor } from "./reflex-monitor.js"; +import { compileReflexPolicies, DEMO_TOOL_BLOCK_SPEC } from "./reflex-policies.js"; +import { + buildReflexState, + buildToolCallAction, + reflexToolGuardDecision, +} from "./reflex-tool-guard.js"; + +describe("reflexToolGuardDecision", () => { + const monitor = new ReflexMonitor( + compileReflexPolicies([DEMO_TOOL_BLOCK_SPEC]), + ); + + it("maps deny to OpenClaw block shape", () => { + const params = { command: "rm -rf /tmp/x" }; + const action = buildToolCallAction({ toolName: "shell.exec", params }); + const out = reflexToolGuardDecision( + monitor, + action, + buildReflexState({ runId: "run-1", sessionKey: "sk" }), + params, + ); + assert.equal(out.block, true); + assert.ok(out.blockReason); + }); + + it("allows benign tool calls", () => { + const params = { command: "ls -la" }; + const action = buildToolCallAction({ toolName: "shell.exec", params }); + const out = reflexToolGuardDecision( + monitor, + action, + buildReflexState({}), + params, + ); + assert.equal(out.block, false); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/reflex-tool-guard.ts b/integrations/openclaw-opencoat-bridge/src/reflex-tool-guard.ts new file mode 100644 index 0000000..3a4a0d6 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/reflex-tool-guard.ts @@ -0,0 +1,71 @@ +import type { AgentHookCtx } from "./types.js"; +import type { Action, ReflexMonitor, State } from "./reflex-monitor.js"; +import type { ToolGuardDecision } from "./injector.js"; + +export function buildToolCallAction(event: { + toolName?: string; + params?: Record; +}): Action { + return { + kind: "tool_call", + name: typeof event.toolName === "string" ? event.toolName : "tool", + args: + event.params && typeof event.params === "object" + ? { ...event.params } + : {}, + raw: event, + }; +} + +export function buildReflexState(ctx: AgentHookCtx): State { + return { + session_id: ctx.sessionId ?? ctx.sessionKey ?? "default", + turn_id: ctx.runId ?? ctx.sessionKey ?? "default", + features: { + agent_id: ctx.agentId, + session_key: ctx.sessionKey, + }, + }; +} + +/** Map ReflexMonitor output to OpenClaw ``before_tool_call`` hook return shape. */ +export function reflexToolGuardDecision( + monitor: ReflexMonitor, + action: Action, + state: State, + params: Record, +): ToolGuardDecision & { record?: ReturnType["record"] } { + const { decision, record } = monitor.mediate(action, state); + + if (decision.kind === "deny") { + return { + block: true, + blockReason: decision.reason, + params, + record, + }; + } + + if (decision.kind === "rewrite") { + return { + block: false, + params: decision.action.args, + record, + }; + } + + return { block: false, params, record }; +} + +/** Fail-closed when the monitor itself throws (TCB unavailable). */ +export function failClosedToolGuard( + params: Record, + err: unknown, +): ToolGuardDecision { + const msg = err instanceof Error ? err.message : String(err); + return { + block: true, + blockReason: `OpenCOAT ReflexMonitor fail-closed: ${msg}`, + params, + }; +} diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts index 11504a5..05777d7 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts @@ -8,7 +8,8 @@ import { installRuntimeObservers, recordQueueDepthSnapshot, } from "./runtime-observers.js"; -import type { BridgeConfig, BridgePluginApi } from "./types.js"; +import type { BridgePluginApi } from "./types.js"; +import { resolveConfig } from "./daemon.js"; describe("agentEventJoinpoint", () => { it("maps lifecycle start to reply_run.before_begin", () => { @@ -136,14 +137,9 @@ describe("installRuntimeObservers", () => { registered = { events, opts }; }, }; - const cfg: BridgeConfig = { - daemonUrl: "http://127.0.0.1:7878/rpc", - enabled: false, - logActivations: false, - extractOnUserMessage: false, - runtimeObservers: true, - observerPollMs: 500, - }; + const cfg = resolveConfig(undefined); + cfg.enabled = false; + cfg.runtimeObservers = true; installRuntimeObservers(api, cfg, { observe: async () => null, diff --git a/integrations/openclaw-opencoat-bridge/src/types.ts b/integrations/openclaw-opencoat-bridge/src/types.ts index a387ae6..5673a18 100644 --- a/integrations/openclaw-opencoat-bridge/src/types.ts +++ b/integrations/openclaw-opencoat-bridge/src/types.ts @@ -1,3 +1,5 @@ +import type { ReflexPolicySpec } from "./reflex-policy-spec.js"; + export type BridgeConfig = { daemonUrl: string; enabled: boolean; @@ -8,6 +10,18 @@ export type BridgeConfig = { runtimeObservers: boolean; /** Interval for queue/task poll service (ms). */ observerPollMs: number; + /** Run ``before_tool_call`` through in-proc ``ReflexMonitor`` (v0.3 TCB prototype). */ + inProcReflexToolGuard: boolean; + /** Pull ``reflex.policies.export`` from daemon on plugin load. */ + reflexSyncFromDaemon: boolean; + /** Emit joinpoint.submit after in-proc guard for DCN audit (async). */ + reflexAuditToDaemon: boolean; + /** Inline portable policy specs (merged with daemon export). */ + reflexPolicies: ReflexPolicySpec[]; + /** When daemon export is empty, include built-in ``demo-tool-block`` spec. */ + reflexIncludeDemoPolicy: boolean; + /** Append structured ``r_t`` records via daemon ``credit.r_t.append`` (v0.3 step 3). */ + emitRtJsonl: boolean; }; export type AgentEventPayload = {