test(openclaw): make tenant-boundary hook compatibility executable

This commit is contained in:
openclaw
2026-07-14 05:29:29 +08:00
parent 14b03fc893
commit 5f43ac26dc
6 changed files with 529 additions and 14 deletions
+35 -10
View File
@@ -1,31 +1,48 @@
import {
classifyRun,
decideToolCall,
resolveSenderId,
runKey,
} from "./policy.js";
const activeRuns = new Map();
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
function prune(now = Date.now()) {
function prune(activeRuns, now = Date.now()) {
for (const [key, value] of activeRuns.entries()) {
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
}
}
function approvalTimeout(config) {
const value = config.approvalTimeoutMs;
return Number.isInteger(value) && value >= 1000 && value <= 300000
? value
: 60000;
}
export default {
id: "aios-tenant-boundary",
name: "AIOS Tenant Boundary",
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
register(api) {
const activeRuns = new Map();
api.on(
"before_agent_run",
async (event, ctx) => {
prune();
prune(activeRuns);
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
const trust = classifyRun(ctx, config);
const key = runKey(event, ctx);
const existing = key ? activeRuns.get(key) : undefined;
if (existing && existing.senderId !== trust.senderId) {
return {
outcome: "block",
reason: "run_scope_sender_conflict",
message: "This run scope is already bound to a different authenticated sender.",
};
}
if (key) {
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
}
@@ -49,10 +66,14 @@ export default {
api.on(
"before_tool_call",
async (event, ctx) => {
prune();
prune(activeRuns);
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
const key = runKey(event, ctx);
const trust = key ? activeRuns.get(key) : undefined;
let trust = key ? activeRuns.get(key) : undefined;
const senderId = resolveSenderId(ctx);
if (trust && senderId && trust.senderId !== senderId) {
trust = { trusted: false, reason: "run_scope_sender_conflict" };
}
const decision = decideToolCall(event.toolName, trust, config);
if (decision.action === "block") {
@@ -67,7 +88,7 @@ export default {
title: `Approve ${String(event.toolName)}`,
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
severity: "warning",
timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
timeoutMs: approvalTimeout(config),
allowedDecisions: ["allow-once", "deny"],
pluginId: "aios-tenant-boundary",
},
@@ -78,9 +99,13 @@ export default {
{ priority: 100 },
);
api.on("agent_end", async (event, ctx) => {
const key = runKey(event, ctx);
if (key) activeRuns.delete(key);
});
api.on(
"agent_end",
async (event, ctx) => {
const key = runKey(event, ctx);
if (key) activeRuns.delete(key);
},
{ priority: 100 },
);
},
};
+22 -2
View File
@@ -66,6 +66,26 @@ export function decideToolCall(toolName, runTrust, config = {}) {
}
export function runKey(event = {}, ctx = {}) {
const key = event.runId || ctx.runId;
return typeof key === "string" && key.trim() ? key.trim() : undefined;
const normalize = (value) => typeof value === "string" && value.trim()
? value.trim()
: undefined;
const sameField = (eventValue, contextValue) => {
const fromEvent = normalize(eventValue);
const fromContext = normalize(contextValue);
return {
valid: !(fromEvent && fromContext && fromEvent !== fromContext),
value: fromEvent || fromContext,
};
};
const runId = sameField(event.runId, ctx.runId);
const agentId = sameField(event.agentId, ctx.agentId);
const sessionId = sameField(event.sessionId, ctx.sessionId);
const sessionKey = sameField(event.sessionKey, ctx.sessionKey);
if (!runId.valid || !agentId.valid || !sessionId.valid || !sessionKey.valid) {
return undefined;
}
const selectedSession = sessionId.value || sessionKey.value;
return runId.value && agentId.value && selectedSession
? JSON.stringify([runId.value, agentId.value, selectedSession])
: undefined;
}
@@ -2,7 +2,7 @@
"id": "aios-tenant-boundary",
"name": "AIOS Tenant Boundary",
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
"version": "2.1.0-rc1",
"version": "2.1.0-rc.1",
"entry": "dist/index.js",
"configSchema": {
"type": "object",
@@ -12,7 +12,14 @@
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true }
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
"blockUnknownAgentRuns": { "type": "boolean", "default": false },
"approvalTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 300000,
"default": 60000
}
}
}
}
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import plugin from "../dist/index.js";
const packageJson = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
);
const manifest = JSON.parse(
await readFile(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
);
function registrations(config = {}) {
const seen = [];
plugin.register({
pluginConfig: config,
on(name, handler, options) {
seen.push({ name, handler, options });
},
});
return seen;
}
test("package, manifest, and executable test command are release compatible", () => {
assert.equal(packageJson.version, "2.1.0-rc.1");
assert.equal(manifest.version, "2.1.0-rc.1");
assert.equal(packageJson.scripts.test, "node --test test/*.test.js");
});
test("schema declares every runtime-read property and exact B1 bounds", () => {
const properties = manifest.configSchema.properties;
const runtimeKeys = [
"approvalTimeoutMs",
"blockUnknownAgentRuns",
"blockedSenderIds",
"readOnlyTools",
"requireApprovalForHumanSideEffects",
"requireExplicitTrustedSenders",
"trustedSenderIds",
];
assert.deepEqual(Object.keys(properties).sort(), runtimeKeys);
assert.deepEqual(properties.blockUnknownAgentRuns, {
type: "boolean",
default: false,
});
assert.deepEqual(properties.approvalTimeoutMs, {
type: "integer",
minimum: 1000,
maximum: 300000,
default: 60000,
});
});
test("all three hooks register exactly once at priority 100", () => {
const seen = registrations();
assert.deepEqual(
seen.map(({ name }) => name).sort(),
["agent_end", "before_agent_run", "before_tool_call"],
);
for (const registration of seen) {
assert.deepEqual(registration.options, { priority: 100 });
}
});
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { realpath } from "node:fs/promises";
const [mode, inspectPath] = process.argv.slice(2);
assert.ok(["default", "opt-in"].includes(mode), `unsupported mode: ${mode}`);
assert.ok(inspectPath, "inspect JSON path is required");
const data = JSON.parse(await readFile(inspectPath, "utf8"));
const plugin = data.plugin;
assert.equal(plugin.id, "aios-tenant-boundary");
assert.equal(plugin.version, "2.1.0-rc.1");
assert.equal(plugin.status, "loaded");
assert.equal(plugin.activated, true);
assert.equal(await realpath(plugin.rootDir), await realpath(new URL("..", import.meta.url)));
assert.equal(await realpath(plugin.source), await realpath(new URL("../dist/index.js", import.meta.url)));
assert.equal(data.install.source, "path");
assert.equal(await realpath(data.install.installPath), await realpath(plugin.rootDir));
const hooks = data.typedHooks
.map(({ name, priority }) => ({ name, priority }))
.sort((a, b) => a.name.localeCompare(b.name));
const expectedNames = mode === "default"
? ["before_tool_call"]
: ["agent_end", "before_agent_run", "before_tool_call"];
assert.deepEqual(hooks.map(({ name }) => name), expectedNames);
assert.ok(hooks.every(({ priority }) => priority === 100));
const diagnostics = data.diagnostics ?? [];
assert.equal(
diagnostics.filter(({ level, severity }) =>
["error", "fatal"].includes(String(level ?? severity).toLowerCase())).length,
0,
);
if (mode === "default") {
assert.deepEqual(
diagnostics.map(({ level, message }) => ({ level, message })).sort((a, b) => a.message.localeCompare(b.message)),
["agent_end", "before_agent_run"].map((hook) => ({
level: "warn",
message: `typed hook "${hook}" blocked because non-bundled plugins must set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`,
})).sort((a, b) => a.message.localeCompare(b.message)),
);
} else {
assert.deepEqual(diagnostics, []);
}
const properties = plugin.configJsonSchema?.properties ?? {};
for (const key of ["approvalTimeoutMs", "blockUnknownAgentRuns"]) {
assert.ok(Object.hasOwn(properties, key), `missing runtime schema property: ${key}`);
}
@@ -0,0 +1,349 @@
import assert from "node:assert/strict";
import test from "node:test";
import plugin from "../dist/index.js";
import {
classifyRun,
decideToolCall,
resolveSenderId,
runKey,
} from "../dist/policy.js";
function registration(config = {}) {
const handlers = new Map();
plugin.register({
pluginConfig: config,
on(name, handler) {
handlers.set(name, handler);
},
});
return {
beforeAgentRun: handlers.get("before_agent_run"),
beforeToolCall: handlers.get("before_tool_call"),
agentEnd: handlers.get("agent_end"),
};
}
function agentContext(id, senderId) {
return {
runId: `run-${id}`,
agentId: "agent-main",
sessionId: "session-uuid-123",
sessionKey: "agent:main:weixin:direct:user",
...(senderId ? { senderId } : {}),
};
}
function toolContext(id, extra = {}) {
return {
runId: `run-${id}`,
agentId: "agent-main",
sessionId: "session-uuid-123",
sessionKey: "agent:main:weixin:direct:user",
...extra,
};
}
test("sender classification covers missing, blocked, allowlisted, and automation", () => {
assert.equal(resolveSenderId({ channelContext: { sender: { id: " nested " } } }), "nested");
assert.deepEqual(classifyRun({}, {}), {
trusted: false,
origin: "system_or_unknown",
reason: "sender_id_missing",
});
assert.equal(
classifyRun({ senderId: "blocked" }, { blockedSenderIds: ["blocked"] }).reason,
"sender_blocked",
);
assert.equal(
classifyRun(
{ senderId: "alice" },
{ requireExplicitTrustedSenders: true, trustedSenderIds: ["alice"] },
).trusted,
true,
);
assert.deepEqual(classifyRun({ jobId: "job-1" }, {}), {
trusted: false,
origin: "automation",
reason: "job_id_present",
});
});
test("unknown agent runs default to pass but fail closed when explicitly enabled", async () => {
const defaultHandlers = registration();
assert.deepEqual(
await defaultHandlers.beforeAgentRun({}, agentContext("default")),
{ outcome: "pass" },
);
const enforced = registration({ blockUnknownAgentRuns: true });
const denied = await enforced.beforeAgentRun({}, agentContext("denied"));
assert.equal(denied.outcome, "block");
assert.equal(denied.reason, "sender_id_missing");
assert.doesNotMatch(denied.reason + denied.message, /undefined|\r|\n/);
assert.deepEqual(
await enforced.beforeAgentRun({}, { ...agentContext("job"), jobId: "job-1" }),
{ outcome: "pass" },
);
});
test("read-only tools pass without provenance and side effects fail with a stable reason", async () => {
assert.deepEqual(decideToolCall("read", undefined, {}), {
action: "allow",
code: "read_only",
});
assert.deepEqual(decideToolCall("shell", undefined, {}), {
action: "block",
code: "run_provenance_missing",
});
const handlers = registration();
assert.equal(await handlers.beforeToolCall({ toolName: "read" }, {}), undefined);
const blocked = await handlers.beforeToolCall({ toolName: "shell" }, {});
assert.equal(blocked.block, true);
assert.equal(
blocked.blockReason,
"AIOS V2R1 origin policy denied tool call: run_provenance_missing",
);
});
test("trusted human side effects request approval with exact timeout validation", async () => {
for (const [configured, expected] of [
[undefined, 60000],
[1000, 1000],
[300000, 300000],
[999, 60000],
[300001, 60000],
[60000.5, 60000],
["1000", 60000],
[null, 60000],
]) {
const config = {
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
};
if (configured !== undefined) config.approvalTimeoutMs = configured;
const handlers = registration(config);
const id = `approval-${String(configured)}`;
await handlers.beforeAgentRun({}, agentContext(id, "alice"));
const result = await handlers.beforeToolCall({ toolName: "shell" }, toolContext(id));
assert.equal(result.requireApproval.timeoutMs, expected, String(configured));
assert.deepEqual(result.requireApproval.allowedDecisions, ["allow-once", "deny"]);
}
});
test("active run provenance is isolated by registration, run, and sender", async () => {
const config = {
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice", "bob"],
};
const first = registration(config);
const second = registration(config);
await first.beforeAgentRun({}, agentContext("shared", "alice"));
const otherSender = await first.beforeToolCall(
{ toolName: "shell" },
toolContext("shared", { senderId: "bob" }),
);
assert.equal(otherSender.block, true);
assert.match(otherSender.blockReason, /run_scope_sender_conflict$/);
const otherRegistration = await second.beforeToolCall(
{ toolName: "shell" },
toolContext("shared"),
);
assert.equal(otherRegistration.block, true);
assert.match(otherRegistration.blockReason, /run_provenance_missing$/);
});
test("agent_end cleanup removes only the matching shared scope", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice", "bob"],
});
await handlers.beforeAgentRun({}, agentContext("alice-scope", "alice"));
await handlers.beforeAgentRun({}, agentContext("bob-scope", "bob"));
await handlers.agentEnd({}, toolContext("alice-scope"));
const alice = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("alice-scope"),
);
assert.equal(alice.block, true);
const bob = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("bob-scope"),
);
assert.ok(bob.requireApproval);
});
test("run provenance expires after the bounded age", async (t) => {
const originalNow = Date.now;
t.after(() => { Date.now = originalNow; });
let now = 1_000_000;
Date.now = () => now;
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
await handlers.beforeAgentRun({}, agentContext("expiring", "alice"));
now += 60 * 60 * 1000 + 1;
const result = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("expiring"),
);
assert.equal(result.block, true);
assert.match(result.blockReason, /run_provenance_missing$/);
});
test("host-shaped lifecycle keeps distinct sessionId and sessionKey across all hooks", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
const before = agentContext("host-shape", "alice");
const tool = toolContext("host-shape");
const end = toolContext("host-shape");
for (const ctx of [before, tool, end]) {
assert.equal(ctx.sessionId, "session-uuid-123");
assert.equal(ctx.sessionKey, "agent:main:weixin:direct:user");
assert.equal(ctx.runId, "run-host-shape");
assert.equal(ctx.agentId, "agent-main");
}
await handlers.beforeAgentRun({}, before);
const result = await handlers.beforeToolCall(
{ toolName: "shell" },
tool,
);
assert.ok(result.requireApproval);
assert.equal(result.block, undefined);
await handlers.agentEnd({}, end);
const cleaned = await handlers.beforeToolCall({ toolName: "shell" }, tool);
assert.equal(cleaned.block, true);
assert.match(cleaned.blockReason, /run_provenance_missing$/);
});
test("shared scope collision fails closed without overwriting original sender", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice", "bob"],
});
assert.deepEqual(
await handlers.beforeAgentRun({}, agentContext("collision", "alice")),
{ outcome: "pass" },
);
const collision = await handlers.beforeAgentRun(
{},
agentContext("collision", "bob"),
);
assert.equal(collision.outcome, "block");
assert.equal(collision.reason, "run_scope_sender_conflict");
assert.doesNotMatch(collision.message, /alice|bob/);
const original = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("collision"),
);
assert.ok(original.requireApproval);
});
test("shared scope normalization is exact and incomplete scope fails closed", async () => {
const bothAliases = runKey({}, {
runId: " run-1 ",
agentId: " agent-1 ",
sessionId: " session-uuid-123 ",
sessionKey: " agent:main:weixin:direct:user ",
});
assert.equal(
bothAliases,
JSON.stringify(["run-1", "agent-1", "session-uuid-123"]),
);
const fallback = runKey(
{ sessionKey: " agent:main:weixin:direct:user " },
{
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:main:weixin:direct:user",
},
);
assert.equal(
fallback,
JSON.stringify(["run-1", "agent-1", "agent:main:weixin:direct:user"]),
);
assert.equal(runKey(
{ sessionId: "event-session" },
{
runId: "run-1",
agentId: "agent-1",
sessionId: "context-session",
sessionKey: "agent:main:weixin:direct:user",
},
), undefined);
assert.equal(runKey({}, {
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:main:weixin:direct:user",
sessionId: "session-uuid-123",
}), bothAliases);
assert.equal(runKey({}, {
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:main:weixin:direct:user",
}), fallback);
assert.equal(runKey({}, {
agentId: "agent-1", sessionId: "session-uuid-123",
}), undefined);
const handlers = registration();
const missing = await handlers.beforeToolCall(
{ toolName: "shell" },
{ agentId: "agent-1", sessionId: "session-uuid-123" },
);
assert.equal(missing.block, true);
assert.match(missing.blockReason, /run_provenance_missing$/);
});
test("sessionKey fallback requires sessionId absent from both hook contexts", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
const before = {
runId: "run-fallback",
agentId: "agent-main",
sessionKey: "agent:main:weixin:direct:user",
senderId: "alice",
};
const tool = {
runId: "run-fallback",
agentId: "agent-main",
sessionKey: "agent:main:weixin:direct:user",
};
await handlers.beforeAgentRun({}, before);
assert.ok((await handlers.beforeToolCall({ toolName: "shell" }, tool)).requireApproval);
const mismatchedSelection = await handlers.beforeToolCall(
{ toolName: "shell" },
{ ...tool, sessionId: "session-uuid-123" },
);
assert.equal(mismatchedSelection.block, true);
assert.match(mismatchedSelection.blockReason, /run_provenance_missing$/);
});
test("same-named event and context sessionId mismatch fails closed", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
await handlers.beforeAgentRun({}, agentContext("event-mismatch", "alice"));
const result = await handlers.beforeToolCall(
{ toolName: "shell", sessionId: "different-session" },
toolContext("event-mismatch"),
);
assert.equal(result.block, true);
assert.match(result.blockReason, /run_provenance_missing$/);
});