193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { evaluateAgentActionGate } from "../agent-action-gate.js";
|
|
import {
|
|
ACTION_GATE_NETWORK_API_TOOLS,
|
|
ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS,
|
|
COORDINATION_EXEMPT_TOOLS,
|
|
FILE_WRITE_DELETE_FN_TOOLS,
|
|
NETWORK_API_TOOLS,
|
|
READONLY_FN_TOOLS,
|
|
TASK_AGENT_MUTATION_TOOLS,
|
|
classifyGitCommand,
|
|
isGitWriteCommand,
|
|
} from "../gating-classifications.js";
|
|
import { classifyPermanentAgentToolCall, resolvePermanentAgentToolDecision } from "../permanent-agent-gating.js";
|
|
import type { AgentPermissionPolicy } from "@fusion/core";
|
|
|
|
const blockedPolicy: AgentPermissionPolicy = {
|
|
presetId: "locked-down",
|
|
rules: {
|
|
git_write: "block",
|
|
file_write_delete: "block",
|
|
command_execution: "block",
|
|
network_api: "block",
|
|
task_agent_mutation: "block",
|
|
},
|
|
};
|
|
|
|
const gitCases = [
|
|
["git status", false, "git status"],
|
|
["git diff", false, "git diff"],
|
|
["git log --oneline", false, "git log"],
|
|
["git show HEAD", false, "git show"],
|
|
["git add .", true, "git add"],
|
|
["git commit -m x", true, "git commit"],
|
|
["git branch", false, "git branch"],
|
|
["git branch --show-current", false, "git branch --show-current"],
|
|
["git branch feature", true, "git branch"],
|
|
["git branch -d feature", true, "git branch"],
|
|
["git switch main", false, "git switch"],
|
|
["git switch -c feature", true, "git switch -c"],
|
|
["git checkout main", false, "git checkout"],
|
|
["git checkout -b feature", true, "git checkout -b"],
|
|
["git pull", false, "git pull"],
|
|
["git pull --rebase", true, "git pull --rebase"],
|
|
["git restore file.ts", false, "git restore"],
|
|
["git restore --staged file.ts", true, "git restore --staged"],
|
|
["git remote -v", false, "git remote -v"],
|
|
["git remote add origin x", true, "git remote"],
|
|
["git remote set-url origin y", true, "git remote"],
|
|
["git worktree list", false, "git worktree"],
|
|
["git worktree add ../x", true, "git worktree add"],
|
|
["git worktree remove ../x", true, "git worktree remove"],
|
|
["echo hi && git status", false, "git status"],
|
|
["echo hi; git commit -m x", true, "git commit"],
|
|
["echo hi | git diff", false, "git diff"],
|
|
["echo hi\ngit checkout -b t", true, "git checkout -b"],
|
|
] as const;
|
|
|
|
const ACTION_MUTATION_PERMANENT_READONLY_TOOLS = new Set([
|
|
"fn_task_import_github",
|
|
"fn_task_import_github_issue",
|
|
]);
|
|
|
|
describe("gating-classifications parity", () => {
|
|
it("locks coordination exempt membership", () => {
|
|
expect([...COORDINATION_EXEMPT_TOOLS].sort()).toMatchInlineSnapshot(`
|
|
[
|
|
"find",
|
|
"fn_agent_org_chart",
|
|
"fn_agent_show",
|
|
"fn_delegate_task",
|
|
"fn_heartbeat_done",
|
|
"fn_list_agents",
|
|
"fn_memory_append",
|
|
"fn_memory_get",
|
|
"fn_memory_search",
|
|
"fn_post_room_message",
|
|
"fn_read_evaluations",
|
|
"fn_read_messages",
|
|
"fn_reflect_on_performance",
|
|
"fn_send_message",
|
|
"fn_task_create",
|
|
"fn_task_document_read",
|
|
"fn_task_document_write",
|
|
"fn_task_done",
|
|
"fn_task_log",
|
|
"fn_task_update",
|
|
"fn_update_identity",
|
|
"grep",
|
|
"ls",
|
|
"read",
|
|
]
|
|
`);
|
|
});
|
|
|
|
it("ensures coordination exempt tools are recognized and allowed in permanent gating", () => {
|
|
for (const toolName of COORDINATION_EXEMPT_TOOLS) {
|
|
const classification = classifyPermanentAgentToolCall(toolName);
|
|
const decision = resolvePermanentAgentToolDecision({
|
|
toolName,
|
|
gating: { permissionPolicy: blockedPolicy },
|
|
});
|
|
expect(classification.recognized).toBe(true);
|
|
expect(decision.disposition).toBe("allow");
|
|
expect(decision.category).toBe("none");
|
|
}
|
|
});
|
|
|
|
it("classifies fn_web_fetch as network_api in both action and permanent sets", () => {
|
|
expect(ACTION_GATE_NETWORK_API_TOOLS.has("fn_web_fetch")).toBe(true);
|
|
expect(NETWORK_API_TOOLS.has("fn_web_fetch")).toBe(true);
|
|
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_web_fetch")).toBe(false);
|
|
});
|
|
|
|
it("classifies worktrunk_install as network_api in both action and permanent sets", () => {
|
|
expect(ACTION_GATE_NETWORK_API_TOOLS.has("worktrunk_install")).toBe(true);
|
|
expect(NETWORK_API_TOOLS.has("worktrunk_install")).toBe(true);
|
|
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("worktrunk_install")).toBe(false);
|
|
});
|
|
|
|
it("keeps fn_* category equivalence mappings across gates", () => {
|
|
const fnTools = new Set<string>();
|
|
for (const source of [
|
|
READONLY_FN_TOOLS,
|
|
TASK_AGENT_MUTATION_TOOLS,
|
|
ACTION_GATE_NETWORK_API_TOOLS,
|
|
FILE_WRITE_DELETE_FN_TOOLS,
|
|
NETWORK_API_TOOLS,
|
|
]) {
|
|
for (const toolName of source) {
|
|
if (toolName.startsWith("fn_")) fnTools.add(toolName);
|
|
}
|
|
}
|
|
|
|
for (const toolName of fnTools) {
|
|
const action = evaluateAgentActionGate({
|
|
agentId: "a1",
|
|
toolName,
|
|
args: {},
|
|
permissionPolicy: blockedPolicy,
|
|
});
|
|
const permanent = classifyPermanentAgentToolCall(toolName);
|
|
|
|
const actionKind = action.category === "task_agent_mutation"
|
|
? "mutating"
|
|
: action.category === "network_api"
|
|
? "network"
|
|
: action.category === "file_write_delete"
|
|
? "file-write"
|
|
: "readonly";
|
|
|
|
const permanentKind = permanent.category === "task_agent_mutation"
|
|
? "mutating"
|
|
: permanent.category === "network_api"
|
|
? "network"
|
|
: permanent.category === "file_write_delete"
|
|
? "file-write"
|
|
: "readonly";
|
|
|
|
if (FILE_WRITE_DELETE_FN_TOOLS.has(toolName)) {
|
|
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "readonly", permanentKind: "file-write" });
|
|
continue;
|
|
}
|
|
if (NETWORK_API_TOOLS.has(toolName) && !ACTION_GATE_NETWORK_API_TOOLS.has(toolName)) {
|
|
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "readonly", permanentKind: "network" });
|
|
continue;
|
|
}
|
|
if (TASK_AGENT_MUTATION_TOOLS.has(toolName) && !ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)) {
|
|
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "readonly", permanentKind: "mutating" });
|
|
continue;
|
|
}
|
|
if (ACTION_MUTATION_PERMANENT_READONLY_TOOLS.has(toolName)) {
|
|
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "mutating", permanentKind: "readonly" });
|
|
continue;
|
|
}
|
|
|
|
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: permanentKind, permanentKind });
|
|
}
|
|
});
|
|
|
|
it.each(gitCases)("classifyGitCommand handles %s", (command, write, operation) => {
|
|
expect(classifyGitCommand(command)).toEqual({ write, operation });
|
|
});
|
|
|
|
it("classifyGitCommand returns null when no git command is present", () => {
|
|
expect(classifyGitCommand("pnpm test")).toBeNull();
|
|
});
|
|
|
|
it.each(gitCases)("isGitWriteCommand agrees with classifyGitCommand for %s", (command) => {
|
|
expect(isGitWriteCommand(command)).toBe(classifyGitCommand(command)?.write ?? false);
|
|
});
|
|
});
|