The respond_agent_connection builtin could flip a pending agent connection to approved without the target owner's YES/NO DM or authenticated API response. Treat it like other security-boundary builtins so the executor shows an approval card before the status change runs. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type ActionApprovalRule,
|
|
applyJudgeDecision,
|
|
connectorKindFromToolName,
|
|
connectorToolRequiresApproval,
|
|
isApprovalAskBlock,
|
|
isSecretAskBlock,
|
|
planActionGate,
|
|
resolveActionApproval,
|
|
resolveActionApprovalDetail,
|
|
toolRequiresApproval,
|
|
toolRequiresExplicitApproval,
|
|
unattendedTriggerToolRequiresApproval,
|
|
} from "./action-approval.js";
|
|
|
|
describe("toolRequiresApproval", () => {
|
|
it("requires approval for consequential builtins and destination writes", () => {
|
|
expect(toolRequiresApproval("destination.write", false)).toBe(true);
|
|
expect(toolRequiresApproval("destination.write", true)).toBe(true);
|
|
expect(toolRequiresApproval("secret_request", false)).toBe(true);
|
|
expect(toolRequiresApproval("forget_secret", false)).toBe(true);
|
|
expect(toolRequiresApproval("forget_memory", false)).toBe(true);
|
|
expect(toolRequiresApproval("list_secrets", false)).toBe(false);
|
|
expect(toolRequiresApproval("delete_bot", false)).toBe(true);
|
|
expect(toolRequiresApproval("archive_bot", false)).toBe(true);
|
|
expect(toolRequiresApproval("cloud_agent_launch", false)).toBe(true);
|
|
expect(toolRequiresApproval("respond_agent_connection", false)).toBe(true);
|
|
expect(toolRequiresApproval("create_space", false)).toBe(true);
|
|
expect(toolRequiresExplicitApproval("create_space")).toBe(true);
|
|
expect(toolRequiresExplicitApproval("archive_bot")).toBe(false);
|
|
});
|
|
|
|
it("does not gate read-only or local work", () => {
|
|
for (const name of [
|
|
"list_files",
|
|
"computer_observe",
|
|
"read_file",
|
|
"write_file",
|
|
"shell",
|
|
"remember",
|
|
"spawn_bot",
|
|
"run_subagent",
|
|
]) {
|
|
expect(toolRequiresApproval(name, false)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("gates connector executes except obvious reads", () => {
|
|
expect(toolRequiresApproval("gmail_send_email", true)).toBe(true);
|
|
expect(toolRequiresApproval("crm_create_note", true)).toBe(true);
|
|
expect(toolRequiresApproval("get_contact", true)).toBe(false);
|
|
expect(toolRequiresApproval("contacts_get", true)).toBe(false);
|
|
expect(toolRequiresApproval("GMAIL_LIST_THREADS", true)).toBe(false);
|
|
expect(toolRequiresApproval("list_messages", true)).toBe(false);
|
|
expect(toolRequiresApproval("search_threads", true)).toBe(false);
|
|
expect(toolRequiresApproval("find_user", true)).toBe(false);
|
|
expect(toolRequiresApproval("read_inbox", true)).toBe(false);
|
|
expect(toolRequiresApproval("contacts_get_or_create", true)).toBe(true);
|
|
expect(toolRequiresApproval("notion_search_and_update", true)).toBe(true);
|
|
expect(toolRequiresApproval("crm_get_then_upsert", true)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("connectorToolRequiresApproval", () => {
|
|
it("matches read-only connector tool names", () => {
|
|
expect(connectorToolRequiresApproval("list_items")).toBe(false);
|
|
expect(connectorToolRequiresApproval("send_message")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("unattendedTriggerToolRequiresApproval", () => {
|
|
it("forces approval for webhook-triggered local side effects", () => {
|
|
for (const name of ["shell", "write_file", "browser_act", "computer_act"]) {
|
|
expect(unattendedTriggerToolRequiresApproval("webhook", name, false)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("allows webhook-triggered reads to stay unattended", () => {
|
|
for (const name of [
|
|
"computer_observe",
|
|
"read_file",
|
|
"web_search",
|
|
"browser_snapshot",
|
|
"request_takeover",
|
|
"run_subagent",
|
|
]) {
|
|
expect(unattendedTriggerToolRequiresApproval("webhook", name, false)).toBe(false);
|
|
}
|
|
expect(unattendedTriggerToolRequiresApproval("webhook", "github_get_issue", true)).toBe(false);
|
|
});
|
|
|
|
it("forces approval for webhook-triggered connector writes regardless of normal rules", () => {
|
|
expect(unattendedTriggerToolRequiresApproval("webhook", "github_create_issue", true)).toBe(
|
|
true,
|
|
);
|
|
expect(unattendedTriggerToolRequiresApproval("user", "shell", false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isApprovalAskBlock", () => {
|
|
it("detects allow/deny approval cards", () => {
|
|
expect(
|
|
isApprovalAskBlock({
|
|
kind: "ask",
|
|
approvalEffectId: "effect-1",
|
|
actions: [
|
|
{ id: "allow", label: "Allow once" },
|
|
{ id: "always", label: "Always allow" },
|
|
{ id: "deny", label: "Deny" },
|
|
],
|
|
}),
|
|
).toBe(true);
|
|
expect(isApprovalAskBlock({ kind: "ask" })).toBe(false);
|
|
expect(
|
|
isApprovalAskBlock({
|
|
kind: "ask",
|
|
approvalEffectId: "effect-1",
|
|
actions: [{ id: "deny", label: "No" }],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isSecretAskBlock", () => {
|
|
it("detects masked secret asks", () => {
|
|
expect(isSecretAskBlock({ kind: "ask", input: "secret" })).toBe(true);
|
|
expect(isSecretAskBlock({ kind: "ask", input: "text" })).toBe(false);
|
|
expect(
|
|
isSecretAskBlock({
|
|
kind: "ask",
|
|
input: "secret",
|
|
approvalEffectId: "effect-1",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("connectorKindFromToolName", () => {
|
|
it("uses the first underscore segment", () => {
|
|
expect(connectorKindFromToolName("gmail_send_email")).toBe("gmail");
|
|
});
|
|
|
|
it("prefers the longest matching connected provider slug", () => {
|
|
expect(
|
|
connectorKindFromToolName("microsoft_outlook_send_email", ["microsoft", "microsoft_outlook"]),
|
|
).toBe("microsoft_outlook");
|
|
});
|
|
});
|
|
|
|
describe("resolveActionApproval", () => {
|
|
const alwaysAllowDestination: ActionApprovalRule[] = [
|
|
{ effect: "always_allow", matchKind: "tool", matchValue: "destination.write" },
|
|
];
|
|
const requireEmail: ActionApprovalRule[] = [
|
|
{ effect: "require_approval", matchKind: "category", matchValue: "email" },
|
|
];
|
|
const requireDestination: ActionApprovalRule[] = [
|
|
{ effect: "require_approval", matchKind: "tool", matchValue: "destination.write" },
|
|
];
|
|
|
|
it("skips approval when always-allow matches", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "destination.write",
|
|
rules: alwaysAllowDestination,
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
|
|
it("requires approval for email even when always-allow would match another tool", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "gmail_send_email",
|
|
rules: [...alwaysAllowDestination, ...requireEmail],
|
|
}),
|
|
).toBe("ask");
|
|
});
|
|
|
|
it("does not apply consequential categories to connector reads", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "gmail_list_threads",
|
|
rules: requireEmail,
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
|
|
it("lets an explicit rule gate a tool that is exempt by default", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "shell",
|
|
rules: [{ effect: "require_approval", matchKind: "tool", matchValue: "shell" }],
|
|
}),
|
|
).toBe("ask");
|
|
});
|
|
|
|
it("lets require_approval beat always-allow at the same specificity", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "destination.write",
|
|
rules: [...alwaysAllowDestination, ...requireDestination],
|
|
}),
|
|
).toBe("ask");
|
|
});
|
|
|
|
it("allows actions by default when no rules match", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "list_files",
|
|
rules: [],
|
|
}),
|
|
).toBe("allow");
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "destination.write",
|
|
rules: [],
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
|
|
it("lets a tool exception override a broader category rule", () => {
|
|
expect(
|
|
resolveActionApproval({
|
|
toolName: "gmail_send_email",
|
|
rules: [
|
|
...requireEmail,
|
|
{ effect: "always_allow", matchKind: "tool", matchValue: "gmail_send_email" },
|
|
],
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
|
|
it("labels always-allow vs default allow", () => {
|
|
expect(
|
|
resolveActionApprovalDetail({
|
|
toolName: "destination.write",
|
|
rules: alwaysAllowDestination,
|
|
}),
|
|
).toMatchObject({ decision: "allow", source: "always_allow" });
|
|
expect(
|
|
resolveActionApprovalDetail({
|
|
toolName: "destination.write",
|
|
rules: [],
|
|
}),
|
|
).toMatchObject({ decision: "allow", source: "default" });
|
|
});
|
|
});
|
|
|
|
describe("planActionGate", () => {
|
|
const consequential = true;
|
|
|
|
it("lets require_approval win without judging", () => {
|
|
expect(
|
|
planActionGate({
|
|
resolved: {
|
|
decision: "ask",
|
|
source: "require_approval",
|
|
matchingRules: [],
|
|
},
|
|
consequential,
|
|
autoReviewEnabled: true,
|
|
checkerConfigured: true,
|
|
}),
|
|
).toBe("ask");
|
|
});
|
|
|
|
it("lets always_allow skip the judge", () => {
|
|
expect(
|
|
planActionGate({
|
|
resolved: {
|
|
decision: "allow",
|
|
source: "always_allow",
|
|
matchingRules: [],
|
|
},
|
|
consequential,
|
|
autoReviewEnabled: true,
|
|
checkerConfigured: true,
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
|
|
it("judges consequential default actions when auto review is ready", () => {
|
|
expect(
|
|
planActionGate({
|
|
resolved: { decision: "allow", source: "default", matchingRules: [] },
|
|
consequential,
|
|
autoReviewEnabled: true,
|
|
checkerConfigured: true,
|
|
}),
|
|
).toBe("judge");
|
|
});
|
|
|
|
it("stays YOLO when auto review is off or checker missing", () => {
|
|
expect(
|
|
planActionGate({
|
|
resolved: { decision: "allow", source: "default", matchingRules: [] },
|
|
consequential,
|
|
autoReviewEnabled: false,
|
|
checkerConfigured: true,
|
|
}),
|
|
).toBe("allow");
|
|
expect(
|
|
planActionGate({
|
|
resolved: { decision: "allow", source: "default", matchingRules: [] },
|
|
consequential,
|
|
autoReviewEnabled: true,
|
|
checkerConfigured: false,
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
|
|
it("does not judge non-consequential tools", () => {
|
|
expect(
|
|
planActionGate({
|
|
resolved: { decision: "allow", source: "default", matchingRules: [] },
|
|
consequential: false,
|
|
autoReviewEnabled: true,
|
|
checkerConfigured: true,
|
|
}),
|
|
).toBe("allow");
|
|
});
|
|
});
|
|
|
|
describe("applyJudgeDecision", () => {
|
|
it("maps pass and ask", () => {
|
|
expect(applyJudgeDecision({ decision: "pass", consequential: true })).toBe("allow");
|
|
expect(applyJudgeDecision({ decision: "ask", consequential: true })).toBe("ask");
|
|
});
|
|
|
|
it("fails closed on consequential checker errors and open on exempt", () => {
|
|
expect(applyJudgeDecision({ decision: "error", consequential: true })).toBe("ask");
|
|
expect(applyJudgeDecision({ decision: "error", consequential: false })).toBe("allow");
|
|
});
|
|
});
|