350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
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$/);
|
|
});
|