test(openclaw): make tenant-boundary hook compatibility executable
This commit is contained in:
+35
-10
@@ -1,31 +1,48 @@
|
|||||||
import {
|
import {
|
||||||
classifyRun,
|
classifyRun,
|
||||||
decideToolCall,
|
decideToolCall,
|
||||||
|
resolveSenderId,
|
||||||
runKey,
|
runKey,
|
||||||
} from "./policy.js";
|
} from "./policy.js";
|
||||||
|
|
||||||
const activeRuns = new Map();
|
|
||||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
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()) {
|
for (const [key, value] of activeRuns.entries()) {
|
||||||
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
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 {
|
export default {
|
||||||
id: "aios-tenant-boundary",
|
id: "aios-tenant-boundary",
|
||||||
name: "AIOS Tenant Boundary",
|
name: "AIOS Tenant Boundary",
|
||||||
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
|
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
|
||||||
|
|
||||||
register(api) {
|
register(api) {
|
||||||
|
const activeRuns = new Map();
|
||||||
|
|
||||||
api.on(
|
api.on(
|
||||||
"before_agent_run",
|
"before_agent_run",
|
||||||
async (event, ctx) => {
|
async (event, ctx) => {
|
||||||
prune();
|
prune(activeRuns);
|
||||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||||
const trust = classifyRun(ctx, config);
|
const trust = classifyRun(ctx, config);
|
||||||
const key = runKey(event, ctx);
|
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) {
|
if (key) {
|
||||||
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -49,10 +66,14 @@ export default {
|
|||||||
api.on(
|
api.on(
|
||||||
"before_tool_call",
|
"before_tool_call",
|
||||||
async (event, ctx) => {
|
async (event, ctx) => {
|
||||||
prune();
|
prune(activeRuns);
|
||||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||||
const key = runKey(event, ctx);
|
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);
|
const decision = decideToolCall(event.toolName, trust, config);
|
||||||
|
|
||||||
if (decision.action === "block") {
|
if (decision.action === "block") {
|
||||||
@@ -67,7 +88,7 @@ export default {
|
|||||||
title: `Approve ${String(event.toolName)}`,
|
title: `Approve ${String(event.toolName)}`,
|
||||||
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
|
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
timeoutMs: approvalTimeout(config),
|
||||||
allowedDecisions: ["allow-once", "deny"],
|
allowedDecisions: ["allow-once", "deny"],
|
||||||
pluginId: "aios-tenant-boundary",
|
pluginId: "aios-tenant-boundary",
|
||||||
},
|
},
|
||||||
@@ -78,9 +99,13 @@ export default {
|
|||||||
{ priority: 100 },
|
{ priority: 100 },
|
||||||
);
|
);
|
||||||
|
|
||||||
api.on("agent_end", async (event, ctx) => {
|
api.on(
|
||||||
const key = runKey(event, ctx);
|
"agent_end",
|
||||||
if (key) activeRuns.delete(key);
|
async (event, ctx) => {
|
||||||
});
|
const key = runKey(event, ctx);
|
||||||
|
if (key) activeRuns.delete(key);
|
||||||
|
},
|
||||||
|
{ priority: 100 },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+22
-2
@@ -66,6 +66,26 @@ export function decideToolCall(toolName, runTrust, config = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runKey(event = {}, ctx = {}) {
|
export function runKey(event = {}, ctx = {}) {
|
||||||
const key = event.runId || ctx.runId;
|
const normalize = (value) => typeof value === "string" && value.trim()
|
||||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
? 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",
|
"id": "aios-tenant-boundary",
|
||||||
"name": "AIOS Tenant Boundary",
|
"name": "AIOS Tenant Boundary",
|
||||||
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
|
"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",
|
"entry": "dist/index.js",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -12,7 +12,14 @@
|
|||||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
"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$/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user