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
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ M9 不实现:

`TrustedModelCallBoundary` 与 `TrustedToolExecutionBoundary` 是 control runtime 的两个受信调用边界,不是新的模型 runtime、事实库、ledger 或状态机;它们只复用现有 Package/bootstrap、ToolResult spool、CoordinationStore 和 session/turn facts。

### 4.1 当前实现成本纪律

M9-A0 及后续实现必须同时遵守三条硬约束:

1. **最薄可信 seam 优先。** 优先复用 pinned runtime 最接近最终调用点的原生能力;model path 先验证 native `before_provider_request`/`onPayload` 能否在所有原生变换后接入唯一、必选且不可后置绕过的 Tiangong handler,tool path 先验证 awaited final tool callback。薄不降低 fail-closed 要求:handler 缺失、身份不符、重复、timeout、throw 或结果畸形时必须阻止相应 provider request/ToolResult release,且之后不能再有未受控 payload 变换。任何更大的 agent-loop、provider 或依赖补丁都必须先证明这些原生 seam 不能满足相同合同。
2. **不新增持久化事实域。** 两个 boundary 不拥有新的 ledger、表、权威状态机或第二份事实索引;它们只读取既有 authority/session facts,并调用既有 Package/bootstrap、ToolResult spool/receipt、recovery 和 bounded diagnostics 路径。`TrustedToolExecutionBoundary` 在 release 前写既有 ToolResult spool 不违反本约束;以 boundary 名义另建数据库表、通用 Evidence 存储或平行恢复账本必须拒绝。
3. **设置 patch budget 与升级闸门。** 若修补开始跨越多个独立模型生命周期、修改依赖内部 agent loop、引入持久化,或随每个 provider 重复扩张,立即停止实现并比较最小 pinned patch、向上游贡献标准 seam 与受审 OpenClaw 升级。补丁膨胀只是重新决策触发器,不自动证明升级更便宜;升级必须作为单独决策,更新精确 runtime identity、重新执行 M9-A0,且不得由实现人员静默改变 `2026.4.14`。采用补丁时有效 runtime identity 必须同时包含 upstream version/commit 与 patch-set digest。

必须始终区分:

- Human 或 Leader 的协调;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanupTempPaths,
createContextEngineAttemptRunner,
createContextEngineBootstrapAndAssemble,
getHoisted,
resetEmbeddedAttemptHarness,
} from "./attempt.spawn-workspace.test-support.js";

describe("M9-A0 patched before_model_call wiring", () => {
const tempPaths: string[] = [];

afterEach(async () => {
resetEmbeddedAttemptHarness();
await cleanupTempPaths(tempPaths);
});

it("blocks the actual session/provider call when the model gate rejects", async () => {
const promptCalls: string[] = [];
const events: string[] = [];
const hookRunner = {
hasHooks: (name: string) => {
events.push(`has:${name}`);
return name === "before_model_call";
},
runBeforeModelCall: vi.fn(async () => {
events.push("before_model_call");
return { block: true, blockReason: "BOOTSTRAP_INVALID" };
}),
};
getHoisted().getGlobalHookRunnerMock.mockReturnValue(hookRunner);

const attemptPromise = createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey: "agent:test:main",
tempPaths,
sessionPrompt: async (_session, prompt) => {
events.push("session_prompt");
promptCalls.push(prompt);
},
attemptOverrides: { disableTools: true, timeoutMs: 250 },
});
const result = await Promise.race([
attemptPromise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`MODEL_GATE_DIAGNOSTIC_TIMEOUT events=${events.join(",")}`)), 15000),
),
], 30000);

expect(promptCalls).toEqual([]);
expect(hookRunner.runBeforeModelCall).toHaveBeenCalledTimes(1);
expect(result.promptError).toBeInstanceOf(Error);
expect(String(result.promptError)).toContain("BOOTSTRAP_INVALID");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
--- a/src/plugins/hook-types.ts
+++ b/src/plugins/hook-types.ts
@@ -56,0 +57 @@
+ | "before_model_call"
@@ -70,0 +72 @@
+ | "before_tool_result_release"
@@ -87,0 +90 @@
+ "before_model_call",
@@ -101,0 +105 @@
+ "before_tool_result_release",
@@ -281,0 +286,14 @@
+export type PluginHookBeforeModelCallEvent = {
+ prompt: string;
+ systemPrompt?: string;
+ messages: unknown[];
+ provider: string;
+ model: string;
+ runId?: string;
+};
+
+export type PluginHookBeforeModelCallResult = {
+ block?: boolean;
+ blockReason?: string;
+};
+
@@ -323,0 +342,13 @@
+export type PluginHookBeforeToolResultReleaseEvent = {
+ toolName: string;
+ params: unknown;
+ toolCallId?: string;
+ runId?: string;
+ result: unknown;
+};
+
+export type PluginHookBeforeToolResultReleaseResult = {
+ block?: boolean;
+ blockReason?: string;
+};
+
@@ -575,0 +607,4 @@
+ before_model_call: (
+ event: PluginHookBeforeModelCallEvent,
+ ctx: PluginHookAgentContext,
+ ) => Promise<PluginHookBeforeModelCallResult | void> | PluginHookBeforeModelCallResult | void;
@@ -633,0 +669,4 @@
+ before_tool_result_release: (
+ event: PluginHookBeforeToolResultReleaseEvent,
+ ctx: PluginHookToolContext,
+ ) => Promise<PluginHookBeforeToolResultReleaseResult | void> | PluginHookBeforeToolResultReleaseResult | void;
--- a/src/plugins/hooks.ts
+++ b/src/plugins/hooks.ts
@@ -26,0 +27 @@
+ PluginHookBeforeModelCallEvent,
@@ -27,0 +29 @@
+ PluginHookBeforeModelCallResult,
@@ -37,0 +40 @@
+ PluginHookBeforeToolResultReleaseEvent,
@@ -38,0 +42 @@
+ PluginHookBeforeToolResultReleaseResult,
@@ -83,0 +88 @@
+ PluginHookBeforeModelCallEvent,
@@ -84,0 +90 @@
+ PluginHookBeforeModelCallResult,
@@ -102,0 +109 @@
+ PluginHookBeforeToolResultReleaseEvent,
@@ -103,0 +111 @@
+ PluginHookBeforeToolResultReleaseResult,
@@ -526,0 +535,19 @@
+ * Run before_model_call hook.
+ * Allows a fail-closed plugin to block the final provider/session request.
+ */
+ async function runBeforeModelCall(
+ event: PluginHookBeforeModelCallEvent,
+ ctx: PluginHookAgentContext,
+ ): Promise<PluginHookBeforeModelCallResult | undefined> {
+ return runModifyingHook<"before_model_call", PluginHookBeforeModelCallResult>(
+ "before_model_call",
+ event,
+ ctx,
+ {
+ shouldStop: (result) => result.block === true,
+ terminalLabel: "block=true",
+ },
+ );
+ }
+
+ /**
@@ -813,0 +841,19 @@
+ * Run before_tool_result_release hook.
+ * Allows a fail-closed plugin to close a bounded result before it is released.
+ */
+ async function runBeforeToolResultRelease(
+ event: PluginHookBeforeToolResultReleaseEvent,
+ ctx: PluginHookToolContext,
+ ): Promise<PluginHookBeforeToolResultReleaseResult | undefined> {
+ return runModifyingHook<"before_tool_result_release", PluginHookBeforeToolResultReleaseResult>(
+ "before_tool_result_release",
+ event,
+ ctx,
+ {
+ shouldStop: (result) => result.block === true,
+ terminalLabel: "block=true",
+ },
+ );
+ }
+
+ /**
@@ -1111,0 +1158 @@
+ runBeforeModelCall,
@@ -1131,0 +1179 @@
+ runBeforeToolResultRelease,
--- a/src/plugins/hook-runner-global.ts
+++ b/src/plugins/hook-runner-global.ts
@@ -44,0 +45,2 @@
+ before_model_call: "fail-closed",
+ before_tool_result_release: "fail-closed",
--- a/src/agents/pi-tools.before-tool-call.ts
+++ b/src/agents/pi-tools.before-tool-call.ts
@@ -414,0 +415,23 @@
+ const hookRunner = getGlobalHookRunner();
+ const release = hookRunner?.hasHooks("before_tool_result_release")
+ ? await hookRunner.runBeforeToolResultRelease(
+ {
+ toolName: normalizedToolName,
+ params: outcome.params,
+ toolCallId,
+ runId: ctx?.runId,
+ result,
+ },
+ {
+ agentId: ctx?.agentId,
+ sessionKey: ctx?.sessionKey,
+ sessionId: ctx?.sessionId,
+ runId: ctx?.runId,
+ toolName: normalizedToolName,
+ toolCallId,
+ },
+ )
+ : undefined;
+ if (release?.block) {
+ throw new Error(release.blockReason || "Tool result release blocked by plugin hook");
+ }
--- a/src/agents/pi-embedded-runner/run/attempt.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.ts
@@ -2006,0 +2007,17 @@
+ const modelCallHookResult = hookRunner?.hasHooks("before_model_call")
+ ? await hookRunner.runBeforeModelCall(
+ {
+ prompt: effectivePrompt,
+ systemPrompt: systemPromptText,
+ messages: activeSession.messages,
+ provider: params.provider,
+ model: params.modelId,
+ runId: params.runId,
+ },
+ hookCtx,
+ )
+ : undefined;
+ if (modelCallHookResult?.block) {
+ throw new Error(modelCallHookResult.blockReason || "Model call blocked by plugin hook");
+ }
+
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
spike_phase=source-seam-prototype
baseline=1c1534f
image=tg-worker:dev
image_id=sha256:ac1cb183e5b2c82982f6473fef86ccf128763a31192d711526d843a79edb69ff
image_created=2026-08-21T13:00:21.057325912+08:00
network=none
openclaw_version=OpenClaw 2026.4.14 (2f35b6f)
openclaw_package={ "name": "openclaw", "version": "2026.4.14", "license": "MIT" }
patch_file=evidence/openclaw-2026.4.14-trusted-boundary.patch
patch_sha256=ff58803f3497c4aa67f40c32cf72a63508dfb992aa87d037eda8b85146d3723f
patch_bytes=5150
patch_dry_run=pass

focused_test_1=src/plugins/hooks.before-tool-call.test.ts src/plugins/hooks.security.test.ts src/plugins/hooks.sync-only.test.ts
focused_test_1_result=3 files passed, 25 tests passed
focused_test_2=src/plugins/m9-a0-source-seam.test.ts
focused_test_2_result=1 file passed, 3 tests passed
focused_test_3=src/agents/pi-embedded-runner/run/attempt.test.ts src/agents/pi-tools.before-tool-call.integration.e2e.test.ts
focused_test_3_result=2 files passed, 116 tests passed
focused_test_4=src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts
focused_test_4_result=2 files passed, 16 tests passed

prototype_observations=before_model_call block keeps fake provider request count at zero; thrown model gate fails closed; before_tool_result_release runs after execute and before ordinary release; capture throw rejects release; handler-owned deadline returns block within bounded time
full_project_tsc=blocked_by_container_oom_not_used_as_pass_evidence
product_runtime_changed=false
matrix_started=false
external_resources_created=false

actual_attempt_model_gate_wiring=blocked_by_existing_harness_timeout
actual_attempt_diagnostic=custom runEmbeddedAttempt harness timed out before any hook/provider event was observed
clean_image_baseline=the same attempt.spawn-workspace.timeout.test timed out on unpatched tg-worker:dev
classification=test-driver-or-image-readiness, not a passing provider-boundary result
layer2_fake_provider_status=not_started
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { createHookRunner } from "/opt/openclaw/src/plugins/hooks.ts";
import { initializeGlobalHookRunner, resetGlobalHookRunner } from "/opt/openclaw/src/plugins/hook-runner-global.ts";
import { wrapToolWithBeforeToolCallHook } from "/opt/openclaw/src/agents/pi-tools.before-tool-call.ts";

function registry(typedHooks: any[]) {
return { hooks: typedHooks, typedHooks } as any;
}

afterEach(() => resetGlobalHookRunner());

describe("M9-A0 source seam prototype", () => {
it("blocks the final model-call seam and fails closed on handler error", async () => {
const runner = createHookRunner(
registry([
{
pluginId: "test-model-gate",
hookName: "before_model_call",
priority: 100,
source: "test",
handler: () => ({ block: true, blockReason: "BOOTSTRAP_INVALID" }),
},
]),
{ failurePolicyByHook: { before_model_call: "fail-closed" } },
);
let providerRequests = 0;
const gateResult = await runner.runBeforeModelCall(
{ prompt: "p", systemPrompt: "s", messages: [], provider: "fake", model: "fake" },
{ runId: "run-1" },
);
if (!gateResult?.block) providerRequests += 1;
expect(gateResult).toEqual({ block: true, blockReason: "BOOTSTRAP_INVALID" });
expect(providerRequests).toBe(0);

const failing = createHookRunner(
registry([
{
pluginId: "test-model-gate",
hookName: "before_model_call",
priority: 100,
source: "test",
handler: () => { throw new Error("BOOTSTRAP_CHECK_FAILED"); },
},
]),
{ failurePolicyByHook: { before_model_call: "fail-closed" } },
);
await expect(
failing.runBeforeModelCall(
{ prompt: "p", systemPrompt: "s", messages: [], provider: "fake", model: "fake" },
{ runId: "run-2" },
),
).rejects.toThrow("before_model_call handler from test-model-gate failed");
});

it("uses a handler-owned deadline for a never-resolving admission", async () => {
const started = performance.now();
const result = await Promise.race([
new Promise(() => undefined),
new Promise((resolve) => setTimeout(() => resolve({ block: true, blockReason: "ADMISSION_TIMEOUT" }), 20)),
]);
expect(result).toEqual({ block: true, blockReason: "ADMISSION_TIMEOUT" });
expect(performance.now() - started).toBeLessThan(250);
});

it("closes a tool result before ordinary release and blocks on capture failure", async () => {
const events: string[] = [];
initializeGlobalHookRunner(
registry([
{
pluginId: "test-capture",
hookName: "before_tool_result_release",
priority: 100,
source: "test",
handler: (event: any) => {
events.push(`capture:${event.result}`);
return undefined;
},
},
]),
);
const tool = wrapToolWithBeforeToolCallHook({
name: "synthetic",
execute: async () => {
events.push("execute");
return "tool-value";
},
} as any);
await expect(tool.execute("call-1", { value: 1 })).resolves.toBe("tool-value");
expect(events).toEqual(["execute", "capture:tool-value"]);

resetGlobalHookRunner();
initializeGlobalHookRunner(
registry([
{
pluginId: "test-capture",
hookName: "before_tool_result_release",
priority: 100,
source: "test",
handler: () => { throw new Error("SPOOL_WRITE_FAILED"); },
},
]),
);
let executed = false;
const failingTool = wrapToolWithBeforeToolCallHook({
name: "synthetic",
execute: async () => {
executed = true;
return "must-not-release";
},
} as any);
await expect(failingTool.execute("call-2", { value: 1 })).rejects.toThrow(
"before_tool_result_release handler from test-capture failed",
);
expect(executed).toBe(true);
});
});
Loading
Loading