Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions integrations/openclaw-opencoat-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions integrations/openclaw-opencoat-bridge/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion integrations/openclaw-opencoat-bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
HyperdustLabs marked this conversation as resolved.
"prepare": "npm run build"
},
"devDependencies": {
Expand Down
23 changes: 23 additions & 0 deletions integrations/openclaw-opencoat-bridge/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,9 +28,28 @@ export function resolveConfig(raw: Record<string, unknown> | 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";
}
Expand Down
102 changes: 99 additions & 3 deletions integrations/openclaw-opencoat-bridge/src/index.ts
Original file line number Diff line number Diff line change
@@ -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).
*
Expand Down Expand Up @@ -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,
Expand All @@ -48,6 +56,23 @@ import type {
} from "./types.js";

const pendingByRun = new Map<string, ConcernInjection | null>();
const reflexState: { runtime: ReflexRuntime | null } = { runtime: null };

function auditToolGuardJoinpoint(
cfg: BridgeConfig,
api: BridgePluginApi,
binding: (typeof HOOK_BINDINGS)[number],
payload: Record<string, unknown>,
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;
Expand Down Expand Up @@ -218,6 +243,48 @@ async function handleHook(
e.params && typeof e.params === "object"
? { ...(e.params as Record<string, unknown>) }
: {};
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) {
Expand Down Expand Up @@ -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),
Expand All @@ -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" : ""
})`,
);
}
81 changes: 81 additions & 0 deletions integrations/openclaw-opencoat-bridge/src/reflex-monitor.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading