Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84353d3343 |
+14
-1
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
classifyRun,
|
classifyRun,
|
||||||
decideToolCall,
|
decideToolCall,
|
||||||
|
resolveSenderId,
|
||||||
runKey,
|
runKey,
|
||||||
} from "./policy.js";
|
} from "./policy.js";
|
||||||
|
|
||||||
@@ -34,6 +35,14 @@ export default {
|
|||||||
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() });
|
||||||
}
|
}
|
||||||
@@ -60,7 +69,11 @@ export default {
|
|||||||
prune(activeRuns);
|
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") {
|
||||||
|
|||||||
+20
-5
@@ -66,9 +66,24 @@ export function decideToolCall(toolName, runTrust, config = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runKey(event = {}, ctx = {}) {
|
export function runKey(event = {}, ctx = {}) {
|
||||||
const runId = typeof (event.runId || ctx.runId) === "string"
|
const exact = (...values) => {
|
||||||
? (event.runId || ctx.runId).trim()
|
const normalized = values
|
||||||
: "";
|
.filter((value) => typeof value === "string")
|
||||||
const senderId = resolveSenderId(ctx);
|
.map((value) => value.trim())
|
||||||
return runId && senderId ? JSON.stringify([runId, senderId]) : undefined;
|
.filter(Boolean);
|
||||||
|
return normalized.length > 0 && new Set(normalized).size === 1
|
||||||
|
? normalized[0]
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
const runId = exact(event.runId, ctx.runId);
|
||||||
|
const agentId = exact(event.agentId, ctx.agentId);
|
||||||
|
const sessionId = exact(
|
||||||
|
event.sessionId,
|
||||||
|
event.sessionKey,
|
||||||
|
ctx.sessionId,
|
||||||
|
ctx.sessionKey,
|
||||||
|
);
|
||||||
|
return runId && agentId && sessionId
|
||||||
|
? JSON.stringify([runId, agentId, sessionId])
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
classifyRun,
|
classifyRun,
|
||||||
decideToolCall,
|
decideToolCall,
|
||||||
resolveSenderId,
|
resolveSenderId,
|
||||||
|
runKey,
|
||||||
} from "../dist/policy.js";
|
} from "../dist/policy.js";
|
||||||
|
|
||||||
function registration(config = {}) {
|
function registration(config = {}) {
|
||||||
@@ -23,6 +24,24 @@ function registration(config = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentContext(id, senderId) {
|
||||||
|
return {
|
||||||
|
runId: `run-${id}`,
|
||||||
|
agentId: "agent-main",
|
||||||
|
sessionKey: `session-${id}`,
|
||||||
|
...(senderId ? { senderId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolContext(id, extra = {}) {
|
||||||
|
return {
|
||||||
|
runId: `run-${id}`,
|
||||||
|
agentId: "agent-main",
|
||||||
|
sessionId: `session-${id}`,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test("sender classification covers missing, blocked, allowlisted, and automation", () => {
|
test("sender classification covers missing, blocked, allowlisted, and automation", () => {
|
||||||
assert.equal(resolveSenderId({ channelContext: { sender: { id: " nested " } } }), "nested");
|
assert.equal(resolveSenderId({ channelContext: { sender: { id: " nested " } } }), "nested");
|
||||||
assert.deepEqual(classifyRun({}, {}), {
|
assert.deepEqual(classifyRun({}, {}), {
|
||||||
@@ -51,18 +70,18 @@ test("sender classification covers missing, blocked, allowlisted, and automation
|
|||||||
test("unknown agent runs default to pass but fail closed when explicitly enabled", async () => {
|
test("unknown agent runs default to pass but fail closed when explicitly enabled", async () => {
|
||||||
const defaultHandlers = registration();
|
const defaultHandlers = registration();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await defaultHandlers.beforeAgentRun({}, { runId: "r-default" }),
|
await defaultHandlers.beforeAgentRun({}, agentContext("default")),
|
||||||
{ outcome: "pass" },
|
{ outcome: "pass" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const enforced = registration({ blockUnknownAgentRuns: true });
|
const enforced = registration({ blockUnknownAgentRuns: true });
|
||||||
const denied = await enforced.beforeAgentRun({}, { runId: "r-denied" });
|
const denied = await enforced.beforeAgentRun({}, agentContext("denied"));
|
||||||
assert.equal(denied.outcome, "block");
|
assert.equal(denied.outcome, "block");
|
||||||
assert.equal(denied.reason, "sender_id_missing");
|
assert.equal(denied.reason, "sender_id_missing");
|
||||||
assert.doesNotMatch(denied.reason + denied.message, /undefined|\r|\n/);
|
assert.doesNotMatch(denied.reason + denied.message, /undefined|\r|\n/);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await enforced.beforeAgentRun({}, { runId: "r-job", jobId: "job-1" }),
|
await enforced.beforeAgentRun({}, { ...agentContext("job"), jobId: "job-1" }),
|
||||||
{ outcome: "pass" },
|
{ outcome: "pass" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -104,9 +123,9 @@ test("trusted human side effects request approval with exact timeout validation"
|
|||||||
};
|
};
|
||||||
if (configured !== undefined) config.approvalTimeoutMs = configured;
|
if (configured !== undefined) config.approvalTimeoutMs = configured;
|
||||||
const handlers = registration(config);
|
const handlers = registration(config);
|
||||||
const ctx = { runId: `approval-${String(configured)}`, senderId: "alice" };
|
const id = `approval-${String(configured)}`;
|
||||||
await handlers.beforeAgentRun({}, ctx);
|
await handlers.beforeAgentRun({}, agentContext(id, "alice"));
|
||||||
const result = await handlers.beforeToolCall({ toolName: "shell" }, ctx);
|
const result = await handlers.beforeToolCall({ toolName: "shell" }, toolContext(id));
|
||||||
assert.equal(result.requireApproval.timeoutMs, expected, String(configured));
|
assert.equal(result.requireApproval.timeoutMs, expected, String(configured));
|
||||||
assert.deepEqual(result.requireApproval.allowedDecisions, ["allow-once", "deny"]);
|
assert.deepEqual(result.requireApproval.allowedDecisions, ["allow-once", "deny"]);
|
||||||
}
|
}
|
||||||
@@ -119,41 +138,41 @@ test("active run provenance is isolated by registration, run, and sender", async
|
|||||||
};
|
};
|
||||||
const first = registration(config);
|
const first = registration(config);
|
||||||
const second = registration(config);
|
const second = registration(config);
|
||||||
await first.beforeAgentRun({}, { runId: "shared", senderId: "alice" });
|
await first.beforeAgentRun({}, agentContext("shared", "alice"));
|
||||||
|
|
||||||
const otherSender = await first.beforeToolCall(
|
const otherSender = await first.beforeToolCall(
|
||||||
{ toolName: "shell" },
|
{ toolName: "shell" },
|
||||||
{ runId: "shared", senderId: "bob" },
|
toolContext("shared", { senderId: "bob" }),
|
||||||
);
|
);
|
||||||
assert.equal(otherSender.block, true);
|
assert.equal(otherSender.block, true);
|
||||||
assert.match(otherSender.blockReason, /run_provenance_missing$/);
|
assert.match(otherSender.blockReason, /run_scope_sender_conflict$/);
|
||||||
|
|
||||||
const otherRegistration = await second.beforeToolCall(
|
const otherRegistration = await second.beforeToolCall(
|
||||||
{ toolName: "shell" },
|
{ toolName: "shell" },
|
||||||
{ runId: "shared", senderId: "alice" },
|
toolContext("shared"),
|
||||||
);
|
);
|
||||||
assert.equal(otherRegistration.block, true);
|
assert.equal(otherRegistration.block, true);
|
||||||
assert.match(otherRegistration.blockReason, /run_provenance_missing$/);
|
assert.match(otherRegistration.blockReason, /run_provenance_missing$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent_end cleanup removes only the matching run and sender", async () => {
|
test("agent_end cleanup removes only the matching shared scope", async () => {
|
||||||
const handlers = registration({
|
const handlers = registration({
|
||||||
requireExplicitTrustedSenders: true,
|
requireExplicitTrustedSenders: true,
|
||||||
trustedSenderIds: ["alice", "bob"],
|
trustedSenderIds: ["alice", "bob"],
|
||||||
});
|
});
|
||||||
await handlers.beforeAgentRun({}, { runId: "same", senderId: "alice" });
|
await handlers.beforeAgentRun({}, agentContext("alice-scope", "alice"));
|
||||||
await handlers.beforeAgentRun({}, { runId: "same", senderId: "bob" });
|
await handlers.beforeAgentRun({}, agentContext("bob-scope", "bob"));
|
||||||
await handlers.agentEnd({}, { runId: "same", senderId: "alice" });
|
await handlers.agentEnd({}, toolContext("alice-scope"));
|
||||||
|
|
||||||
const alice = await handlers.beforeToolCall(
|
const alice = await handlers.beforeToolCall(
|
||||||
{ toolName: "shell" },
|
{ toolName: "shell" },
|
||||||
{ runId: "same", senderId: "alice" },
|
toolContext("alice-scope"),
|
||||||
);
|
);
|
||||||
assert.equal(alice.block, true);
|
assert.equal(alice.block, true);
|
||||||
|
|
||||||
const bob = await handlers.beforeToolCall(
|
const bob = await handlers.beforeToolCall(
|
||||||
{ toolName: "shell" },
|
{ toolName: "shell" },
|
||||||
{ runId: "same", senderId: "bob" },
|
toolContext("bob-scope"),
|
||||||
);
|
);
|
||||||
assert.ok(bob.requireApproval);
|
assert.ok(bob.requireApproval);
|
||||||
});
|
});
|
||||||
@@ -167,10 +186,76 @@ test("run provenance expires after the bounded age", async (t) => {
|
|||||||
requireExplicitTrustedSenders: true,
|
requireExplicitTrustedSenders: true,
|
||||||
trustedSenderIds: ["alice"],
|
trustedSenderIds: ["alice"],
|
||||||
});
|
});
|
||||||
const ctx = { runId: "expiring", senderId: "alice" };
|
await handlers.beforeAgentRun({}, agentContext("expiring", "alice"));
|
||||||
await handlers.beforeAgentRun({}, ctx);
|
|
||||||
now += 60 * 60 * 1000 + 1;
|
now += 60 * 60 * 1000 + 1;
|
||||||
const result = await handlers.beforeToolCall({ toolName: "shell" }, ctx);
|
const result = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("expiring"),
|
||||||
|
);
|
||||||
assert.equal(result.block, true);
|
assert.equal(result.block, true);
|
||||||
assert.match(result.blockReason, /run_provenance_missing$/);
|
assert.match(result.blockReason, /run_provenance_missing$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("host-shaped tool context without sender reuses trusted shared scope", async () => {
|
||||||
|
const handlers = registration({
|
||||||
|
requireExplicitTrustedSenders: true,
|
||||||
|
trustedSenderIds: ["alice"],
|
||||||
|
});
|
||||||
|
await handlers.beforeAgentRun({}, agentContext("host-shape", "alice"));
|
||||||
|
const result = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
toolContext("host-shape"),
|
||||||
|
);
|
||||||
|
assert.ok(result.requireApproval);
|
||||||
|
assert.equal(result.block, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 sessionId = runKey({}, {
|
||||||
|
runId: " run-1 ", agentId: " agent-1 ", sessionId: " session-1 ",
|
||||||
|
});
|
||||||
|
const sessionKey = runKey({}, {
|
||||||
|
runId: "run-1", agentId: "agent-1", sessionKey: "session-1",
|
||||||
|
});
|
||||||
|
assert.equal(sessionId, JSON.stringify(["run-1", "agent-1", "session-1"]));
|
||||||
|
assert.equal(sessionKey, sessionId);
|
||||||
|
assert.equal(runKey({}, {
|
||||||
|
runId: "run-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
sessionKey: "different-session",
|
||||||
|
}), undefined);
|
||||||
|
assert.equal(runKey({}, { agentId: "agent-1", sessionId: "session-1" }), undefined);
|
||||||
|
|
||||||
|
const handlers = registration();
|
||||||
|
const missing = await handlers.beforeToolCall(
|
||||||
|
{ toolName: "shell" },
|
||||||
|
{ agentId: "agent-1", sessionId: "session-1" },
|
||||||
|
);
|
||||||
|
assert.equal(missing.block, true);
|
||||||
|
assert.match(missing.blockReason, /run_provenance_missing$/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user