From 5f43ac26dcec7a824a8a3ea23fc54de26ab3eaaf Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 14 Jul 2026 05:29:29 +0800 Subject: [PATCH] test(openclaw): make tenant-boundary hook compatibility executable --- .../aios-tenant-boundary/dist/index.js | 45 ++- .../aios-tenant-boundary/dist/policy.js | 24 +- .../aios-tenant-boundary/openclaw.plugin.json | 11 +- .../aios-tenant-boundary/test/compat.test.js | 64 ++++ .../aios-tenant-boundary/test/loader.test.mjs | 50 +++ .../aios-tenant-boundary/test/policy.test.js | 349 ++++++++++++++++++ 6 files changed, 529 insertions(+), 14 deletions(-) create mode 100644 openclaw/plugins/aios-tenant-boundary/test/compat.test.js create mode 100644 openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs create mode 100644 openclaw/plugins/aios-tenant-boundary/test/policy.test.js diff --git a/openclaw/plugins/aios-tenant-boundary/dist/index.js b/openclaw/plugins/aios-tenant-boundary/dist/index.js index 34c9a3f..ef4ef55 100644 --- a/openclaw/plugins/aios-tenant-boundary/dist/index.js +++ b/openclaw/plugins/aios-tenant-boundary/dist/index.js @@ -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 }, + ); }, }; diff --git a/openclaw/plugins/aios-tenant-boundary/dist/policy.js b/openclaw/plugins/aios-tenant-boundary/dist/policy.js index 0434d4b..6ba12eb 100644 --- a/openclaw/plugins/aios-tenant-boundary/dist/policy.js +++ b/openclaw/plugins/aios-tenant-boundary/dist/policy.js @@ -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; } diff --git a/openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json b/openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json index 6130c91..62ec233 100644 --- a/openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json +++ b/openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json @@ -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 + } } } } diff --git a/openclaw/plugins/aios-tenant-boundary/test/compat.test.js b/openclaw/plugins/aios-tenant-boundary/test/compat.test.js new file mode 100644 index 0000000..43350df --- /dev/null +++ b/openclaw/plugins/aios-tenant-boundary/test/compat.test.js @@ -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 }); + } +}); diff --git a/openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs b/openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs new file mode 100644 index 0000000..1353ed2 --- /dev/null +++ b/openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs @@ -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}`); +} diff --git a/openclaw/plugins/aios-tenant-boundary/test/policy.test.js b/openclaw/plugins/aios-tenant-boundary/test/policy.test.js new file mode 100644 index 0000000..e4791d6 --- /dev/null +++ b/openclaw/plugins/aios-tenant-boundary/test/policy.test.js @@ -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$/); +});