Compare commits

..
Author SHA1 Message Date
openclaw 84353d3343 test(openclaw): make tenant-boundary hook compatibility executable 2026-07-14 05:15:16 +08:00
3 changed files with 138 additions and 25 deletions
+14 -1
View File
@@ -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
View File
@@ -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$/);
});