feat(FN-3751): unify agent action gating classifications across permanent a
Merges two new plugin packages — `fusion-plugin-even-cards` (board/card endpoints with API key auth) and `fusion-plugin-even-realities-glasses` (notifier, quick-capture, settings, transport, Fusion API client, and agent actions) — plus a substantial engine refactor that unifies agent action gating c Fusion-Task-Id: FN-3751
This commit is contained in:
5
.changeset/FN-3751-unify-gating.md
Normal file
5
.changeset/FN-3751-unify-gating.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Unify engine gating exemption lists into a shared source of truth.
|
||||||
@@ -88,6 +88,7 @@ Unknown/unclassified tool fallback:
|
|||||||
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
||||||
- Internal Fusion runtime coordination tools (heartbeat completion, task/agent coordination, messaging, evaluations, identity reflection, memory bookkeeping) are exempt by design and always allowed so permanent-agent heartbeats can complete.
|
- Internal Fusion runtime coordination tools (heartbeat completion, task/agent coordination, messaging, evaluations, identity reflection, memory bookkeeping) are exempt by design and always allowed so permanent-agent heartbeats can complete.
|
||||||
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
|
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
|
||||||
|
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.
|
||||||
|
|
||||||
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):
|
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
|||||||
- `AgentRuntime` (`agent-runtime.ts`) — runtime adapter interface contract
|
- `AgentRuntime` (`agent-runtime.ts`) — runtime adapter interface contract
|
||||||
- `RuntimeResolution` (`runtime-resolution.ts`) — runtime selection and fallback logic
|
- `RuntimeResolution` (`runtime-resolution.ts`) — runtime selection and fallback logic
|
||||||
- `AgentSessionHelpers` (`agent-session-helpers.ts`) — runtime-aware session creation helpers
|
- `AgentSessionHelpers` (`agent-session-helpers.ts`) — runtime-aware session creation helpers
|
||||||
- `AgentActionGate` (`agent-action-gate.ts`) — permanent-agent runtime action classification + policy disposition decisions
|
- `AgentActionGate` (`agent-action-gate.ts`) — permanent-agent runtime action classification + policy disposition decisions (shared classification source: `packages/engine/src/gating-classifications.ts`)
|
||||||
|
|
||||||
Runtime action-gate flow (v1):
|
Runtime action-gate flow (v1):
|
||||||
- Tool execution wrappers in `pi.ts` compose `wrapToolsWithBoundary()` and `wrapToolsWithActionGate()`.
|
- Tool execution wrappers in `pi.ts` compose `wrapToolsWithBoundary()` and `wrapToolsWithActionGate()`.
|
||||||
|
|||||||
170
packages/engine/src/__tests__/gating-classifications.test.ts
Normal file
170
packages/engine/src/__tests__/gating-classifications.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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_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("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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,13 @@ import type {
|
|||||||
AgentPermissionPolicyActionCategory,
|
AgentPermissionPolicyActionCategory,
|
||||||
AgentPermissionPolicyDisposition,
|
AgentPermissionPolicyDisposition,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import {
|
||||||
|
ACTION_GATE_NETWORK_API_TOOLS,
|
||||||
|
ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS,
|
||||||
|
COORDINATION_EXEMPT_TOOLS,
|
||||||
|
READONLY_BUILTIN_TOOLS,
|
||||||
|
classifyGitCommand,
|
||||||
|
} from "./gating-classifications.js";
|
||||||
import { runtimeLog } from "./logger.js";
|
import { runtimeLog } from "./logger.js";
|
||||||
|
|
||||||
export type AgentActionGateResourceType = "file" | "git" | "task" | "agent" | "research" | "command" | "other";
|
export type AgentActionGateResourceType = "file" | "git" | "task" | "agent" | "research" | "command" | "other";
|
||||||
@@ -32,31 +39,7 @@ export interface AgentActionGateContext {
|
|||||||
|
|
||||||
// FN-3724: Internal Fusion runtime/coordinator tools never perform external mutations.
|
// FN-3724: Internal Fusion runtime/coordinator tools never perform external mutations.
|
||||||
// They must bypass user-configurable approval/block policies so permanent-agent heartbeats cannot deadlock.
|
// They must bypass user-configurable approval/block policies so permanent-agent heartbeats cannot deadlock.
|
||||||
const DEFAULT_EXEMPT_TOOLS = [
|
const DEFAULT_EXEMPT_TOOLS = COORDINATION_EXEMPT_TOOLS;
|
||||||
"read",
|
|
||||||
"find",
|
|
||||||
"grep",
|
|
||||||
"ls",
|
|
||||||
"fn_task_update",
|
|
||||||
"fn_task_log",
|
|
||||||
"fn_task_done",
|
|
||||||
"fn_task_document_write",
|
|
||||||
"fn_task_document_read",
|
|
||||||
"fn_memory_search",
|
|
||||||
"fn_memory_get",
|
|
||||||
"fn_read_messages",
|
|
||||||
"fn_heartbeat_done",
|
|
||||||
"fn_task_create",
|
|
||||||
"fn_delegate_task",
|
|
||||||
"fn_list_agents",
|
|
||||||
"fn_agent_show",
|
|
||||||
"fn_agent_org_chart",
|
|
||||||
"fn_send_message",
|
|
||||||
"fn_memory_append",
|
|
||||||
"fn_read_evaluations",
|
|
||||||
"fn_update_identity",
|
|
||||||
"fn_reflect_on_performance",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
let _exemptTools: Set<string> | null = null;
|
let _exemptTools: Set<string> | null = null;
|
||||||
|
|
||||||
@@ -95,43 +78,9 @@ export function getExemptToolNames(): string[] {
|
|||||||
return [...getExemptTools()];
|
return [...getExemptTools()];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
|
const TASK_AGENT_MANAGEMENT_TOOLS = ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS;
|
||||||
"fn_task_create",
|
const NETWORK_API_TOOLS = ACTION_GATE_NETWORK_API_TOOLS;
|
||||||
"fn_task_add_dep",
|
const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS;
|
||||||
"fn_delegate_task",
|
|
||||||
"fn_spawn_agent",
|
|
||||||
"fn_update_agent_config",
|
|
||||||
"fn_update_identity",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const NETWORK_API_TOOLS = new Set(["fn_research_run"]);
|
|
||||||
|
|
||||||
const READONLY_DISCOVERY_TOOLS = new Set(["read", "find", "grep", "ls"]);
|
|
||||||
|
|
||||||
const GIT_WRITE_SUBCOMMANDS = new Set([
|
|
||||||
"add",
|
|
||||||
"commit",
|
|
||||||
"merge",
|
|
||||||
"rebase",
|
|
||||||
"cherry-pick",
|
|
||||||
"am",
|
|
||||||
"apply",
|
|
||||||
"stash",
|
|
||||||
"tag",
|
|
||||||
"push",
|
|
||||||
"reset",
|
|
||||||
"rm",
|
|
||||||
"mv",
|
|
||||||
"clean",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const GIT_READONLY_SUBCOMMANDS = new Set([
|
|
||||||
"status",
|
|
||||||
"diff",
|
|
||||||
"log",
|
|
||||||
"show",
|
|
||||||
"rev-parse",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function normalizeArgs(args: unknown): Record<string, unknown> {
|
function normalizeArgs(args: unknown): Record<string, unknown> {
|
||||||
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
||||||
@@ -142,61 +91,6 @@ function extractShellCommand(args: Record<string, unknown>): string {
|
|||||||
return typeof command === "string" ? command.trim() : "";
|
return typeof command === "string" ? command.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyGitCommand(command: string): { write: boolean; operation: string } | null {
|
|
||||||
const match = command.match(/(?:^|&&|\|\||;|\n)\s*git\s+([^\s]+)/);
|
|
||||||
if (!match) return null;
|
|
||||||
const sub = match[1]?.trim() ?? "";
|
|
||||||
if (!sub) return { write: false, operation: "git" };
|
|
||||||
|
|
||||||
if (GIT_READONLY_SUBCOMMANDS.has(sub)) {
|
|
||||||
if (sub === "rev-parse" && /--show-current\b/.test(command)) {
|
|
||||||
return { write: false, operation: "git rev-parse --show-current" };
|
|
||||||
}
|
|
||||||
return { write: false, operation: `git ${sub}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "branch") {
|
|
||||||
const mutatingFlags = /\s-d\b|\s-D\b|\s-m\b|\s-M\b|\s-c\b|\s-C\b/.test(command);
|
|
||||||
if (mutatingFlags) {
|
|
||||||
return { write: true, operation: "git branch" };
|
|
||||||
}
|
|
||||||
const tail = command.replace(/^[\s\S]*?\bgit\s+branch\b/, "").trim();
|
|
||||||
const hasPositionalArg = tail.length > 0 && !tail.startsWith("-");
|
|
||||||
if (hasPositionalArg) {
|
|
||||||
return { write: true, operation: "git branch" };
|
|
||||||
}
|
|
||||||
return { write: false, operation: /--show-current\b/.test(command) ? "git branch --show-current" : "git branch" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "switch") {
|
|
||||||
return { write: /\s-c\b/.test(command), operation: /\s-c\b/.test(command) ? "git switch -c" : "git switch" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "checkout") {
|
|
||||||
return { write: /\s-b\b/.test(command), operation: /\s-b\b/.test(command) ? "git checkout -b" : "git checkout" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "pull") {
|
|
||||||
return { write: /--rebase\b/.test(command), operation: /--rebase\b/.test(command) ? "git pull --rebase" : "git pull" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "restore") {
|
|
||||||
return { write: /--staged\b/.test(command), operation: /--staged\b/.test(command) ? "git restore --staged" : "git restore" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "remote") {
|
|
||||||
const write = /\s+add\b|\s+remove\b|\s+rename\b|\s+set-url\b/.test(command);
|
|
||||||
return { write, operation: /\s-v\b/.test(command) ? "git remote -v" : "git remote" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "worktree") {
|
|
||||||
if (/\s+add\b/.test(command)) return { write: true, operation: "git worktree add" };
|
|
||||||
if (/\s+remove\b/.test(command)) return { write: true, operation: "git worktree remove" };
|
|
||||||
return { write: false, operation: "git worktree" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { write: GIT_WRITE_SUBCOMMANDS.has(sub), operation: `git ${sub}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeApprovalDedupeKey(input: {
|
export function computeApprovalDedupeKey(input: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|||||||
195
packages/engine/src/gating-classifications.ts
Normal file
195
packages/engine/src/gating-classifications.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
// FN-3548 / FN-3724 / FN-3751: keep agent-action-gate and permanent-agent-gating
|
||||||
|
// classifications sourced from one module to prevent two-path drift (see MEMORY.md drift note).
|
||||||
|
|
||||||
|
export const READONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["read", "find", "grep", "ls"]);
|
||||||
|
export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["write", "edit"]);
|
||||||
|
|
||||||
|
const SHARED_TASK_AGENT_TOOLS = ["fn_task_add_dep", "fn_spawn_agent", "fn_update_agent_config"] as const;
|
||||||
|
|
||||||
|
const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = ["fn_task_create", "fn_delegate_task", "fn_update_identity"] as const;
|
||||||
|
const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
|
||||||
|
"fn_task_pause",
|
||||||
|
"fn_task_unpause",
|
||||||
|
"fn_task_retry",
|
||||||
|
"fn_task_duplicate",
|
||||||
|
"fn_task_refine",
|
||||||
|
"fn_task_archive",
|
||||||
|
"fn_task_unarchive",
|
||||||
|
"fn_task_delete",
|
||||||
|
"fn_task_import_github",
|
||||||
|
"fn_task_import_github_issue",
|
||||||
|
"fn_task_plan",
|
||||||
|
"fn_mission_create",
|
||||||
|
"fn_mission_delete",
|
||||||
|
"fn_milestone_add",
|
||||||
|
"fn_slice_add",
|
||||||
|
"fn_feature_add",
|
||||||
|
"fn_slice_activate",
|
||||||
|
"fn_feature_link_task",
|
||||||
|
"fn_agent_stop",
|
||||||
|
"fn_agent_start",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TASK_AGENT_MUTATION_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
...SHARED_TASK_AGENT_TOOLS,
|
||||||
|
...ACTION_GATE_TASK_AGENT_ONLY_TOOLS,
|
||||||
|
...PERMANENT_TASK_AGENT_ONLY_TOOLS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
...SHARED_TASK_AGENT_TOOLS,
|
||||||
|
...ACTION_GATE_TASK_AGENT_ONLY_TOOLS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const PERMANENT_AGENT_TASK_MUTATION_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
...SHARED_TASK_AGENT_TOOLS,
|
||||||
|
...PERMANENT_TASK_AGENT_ONLY_TOOLS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const FILE_WRITE_DELETE_FN_TOOLS: ReadonlySet<string> = new Set(["fn_task_attach"]);
|
||||||
|
|
||||||
|
export const NETWORK_API_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
"fn_research_run",
|
||||||
|
"fn_research_cancel",
|
||||||
|
"fn_research_retry",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ACTION_GATE_NETWORK_API_TOOLS: ReadonlySet<string> = new Set(["fn_research_run"]);
|
||||||
|
|
||||||
|
export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
"fn_task_list",
|
||||||
|
"fn_task_show",
|
||||||
|
"fn_task_create",
|
||||||
|
"fn_task_document_write",
|
||||||
|
"fn_task_document_read",
|
||||||
|
"fn_delegate_task",
|
||||||
|
"fn_research_list",
|
||||||
|
"fn_research_get",
|
||||||
|
"fn_insight_list",
|
||||||
|
"fn_insight_show",
|
||||||
|
"fn_insight_run_list",
|
||||||
|
"fn_insight_run_show",
|
||||||
|
"fn_mission_list",
|
||||||
|
"fn_mission_show",
|
||||||
|
"fn_list_agents",
|
||||||
|
"fn_agent_show",
|
||||||
|
"fn_agent_org_chart",
|
||||||
|
"fn_skills_search",
|
||||||
|
"fn_memory_search",
|
||||||
|
"fn_memory_get",
|
||||||
|
"fn_task_update",
|
||||||
|
"fn_task_log",
|
||||||
|
"fn_task_done",
|
||||||
|
"fn_heartbeat_done",
|
||||||
|
"fn_memory_append",
|
||||||
|
"fn_send_message",
|
||||||
|
"fn_read_messages",
|
||||||
|
"fn_update_identity",
|
||||||
|
"fn_reflect_on_performance",
|
||||||
|
"fn_read_evaluations",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const COORDINATION_EXEMPT_TOOLS = [
|
||||||
|
"read",
|
||||||
|
"find",
|
||||||
|
"grep",
|
||||||
|
"ls",
|
||||||
|
"fn_task_update",
|
||||||
|
"fn_task_log",
|
||||||
|
"fn_task_done",
|
||||||
|
"fn_task_document_write",
|
||||||
|
"fn_task_document_read",
|
||||||
|
"fn_memory_search",
|
||||||
|
"fn_memory_get",
|
||||||
|
"fn_read_messages",
|
||||||
|
"fn_heartbeat_done",
|
||||||
|
"fn_task_create",
|
||||||
|
"fn_delegate_task",
|
||||||
|
"fn_list_agents",
|
||||||
|
"fn_agent_show",
|
||||||
|
"fn_agent_org_chart",
|
||||||
|
"fn_send_message",
|
||||||
|
"fn_memory_append",
|
||||||
|
"fn_read_evaluations",
|
||||||
|
"fn_update_identity",
|
||||||
|
"fn_reflect_on_performance",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MUTATING_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
||||||
|
"add",
|
||||||
|
"commit",
|
||||||
|
"merge",
|
||||||
|
"rebase",
|
||||||
|
"cherry-pick",
|
||||||
|
"am",
|
||||||
|
"apply",
|
||||||
|
"stash",
|
||||||
|
"tag",
|
||||||
|
"push",
|
||||||
|
"reset",
|
||||||
|
"rm",
|
||||||
|
"mv",
|
||||||
|
"clean",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const READONLY_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set(["status", "diff", "log", "show", "rev-parse"]);
|
||||||
|
|
||||||
|
export function classifyGitCommand(command: string): { write: boolean; operation: string } | null {
|
||||||
|
const match = command.match(/(?:^|&&|\|\||;|\||\n)\s*git\s+([^\s]+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const sub = match[1]?.trim() ?? "";
|
||||||
|
if (!sub) return { write: false, operation: "git" };
|
||||||
|
|
||||||
|
if (READONLY_GIT_SUBCOMMANDS.has(sub)) {
|
||||||
|
if (sub === "rev-parse" && /--show-current\b/.test(command)) {
|
||||||
|
return { write: false, operation: "git rev-parse --show-current" };
|
||||||
|
}
|
||||||
|
return { write: false, operation: `git ${sub}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "branch") {
|
||||||
|
const mutatingFlags = /\s-d\b|\s-D\b|\s-m\b|\s-M\b|\s-c\b|\s-C\b/.test(command);
|
||||||
|
if (mutatingFlags) return { write: true, operation: "git branch" };
|
||||||
|
const tail = command.replace(/^[\s\S]*?\bgit\s+branch\b/, "").trim();
|
||||||
|
const hasPositionalArg = tail.length > 0 && !tail.startsWith("-");
|
||||||
|
if (hasPositionalArg) return { write: true, operation: "git branch" };
|
||||||
|
return { write: false, operation: /--show-current\b/.test(command) ? "git branch --show-current" : "git branch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "switch") {
|
||||||
|
const write = /\s-c\b/.test(command);
|
||||||
|
return { write, operation: write ? "git switch -c" : "git switch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "checkout") {
|
||||||
|
const write = /\s-b\b/.test(command);
|
||||||
|
return { write, operation: write ? "git checkout -b" : "git checkout" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "pull") {
|
||||||
|
const write = /--rebase\b/.test(command);
|
||||||
|
return { write, operation: write ? "git pull --rebase" : "git pull" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "restore") {
|
||||||
|
const write = /--staged\b/.test(command);
|
||||||
|
return { write, operation: write ? "git restore --staged" : "git restore" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "remote") {
|
||||||
|
const write = /\s+add\b|\s+remove\b|\s+rename\b|\s+set-url\b/.test(command);
|
||||||
|
return { write, operation: /\s-v\b/.test(command) ? "git remote -v" : "git remote" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "worktree") {
|
||||||
|
if (/\s+add\b/.test(command)) return { write: true, operation: "git worktree add" };
|
||||||
|
if (/\s+remove\b/.test(command)) return { write: true, operation: "git worktree remove" };
|
||||||
|
return { write: false, operation: "git worktree" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { write: MUTATING_GIT_SUBCOMMANDS.has(sub), operation: `git ${sub}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGitWriteCommand(command: string): boolean {
|
||||||
|
return classifyGitCommand(command)?.write ?? false;
|
||||||
|
}
|
||||||
@@ -4,6 +4,15 @@ import type {
|
|||||||
PermanentAgentGatingContext,
|
PermanentAgentGatingContext,
|
||||||
PermanentAgentSensitiveActionCategory,
|
PermanentAgentSensitiveActionCategory,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import {
|
||||||
|
FILE_WRITE_BUILTIN_TOOLS,
|
||||||
|
FILE_WRITE_DELETE_FN_TOOLS,
|
||||||
|
NETWORK_API_TOOLS,
|
||||||
|
PERMANENT_AGENT_TASK_MUTATION_TOOLS,
|
||||||
|
READONLY_BUILTIN_TOOLS,
|
||||||
|
READONLY_FN_TOOLS,
|
||||||
|
isGitWriteCommand,
|
||||||
|
} from "./gating-classifications.js";
|
||||||
|
|
||||||
export interface PermanentAgentToolClassification {
|
export interface PermanentAgentToolClassification {
|
||||||
category: PermanentAgentActionCategory;
|
category: PermanentAgentActionCategory;
|
||||||
@@ -16,99 +25,12 @@ export interface PermanentAgentToolDecision extends PermanentAgentToolClassifica
|
|||||||
disposition: AgentPermissionPolicyDisposition;
|
disposition: AgentPermissionPolicyDisposition;
|
||||||
}
|
}
|
||||||
|
|
||||||
const READONLY_BUILTIN_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
const FILE_WRITE_TOOLS = FILE_WRITE_BUILTIN_TOOLS;
|
||||||
const FILE_WRITE_TOOLS = new Set(["write", "edit"]);
|
|
||||||
|
|
||||||
// FN-3724 / FN-3548: heartbeat-completion and internal coordination tools must remain
|
// FN-3724 / FN-3548: heartbeat-completion and internal coordination tools must remain
|
||||||
// category "none" so restrictive permanent-agent policies cannot deadlock heartbeats.
|
// category "none" so restrictive permanent-agent policies cannot deadlock heartbeats.
|
||||||
const TASK_AGENT_MUTATION_TOOLS = new Set([
|
const TASK_AGENT_MUTATION_TOOLS = PERMANENT_AGENT_TASK_MUTATION_TOOLS;
|
||||||
"fn_task_add_dep",
|
const FILE_WRITE_DELETE_TOOLS = FILE_WRITE_DELETE_FN_TOOLS;
|
||||||
"fn_task_pause",
|
|
||||||
"fn_task_unpause",
|
|
||||||
"fn_task_retry",
|
|
||||||
"fn_task_duplicate",
|
|
||||||
"fn_task_refine",
|
|
||||||
"fn_task_archive",
|
|
||||||
"fn_task_unarchive",
|
|
||||||
"fn_task_delete",
|
|
||||||
"fn_task_import_github",
|
|
||||||
"fn_task_import_github_issue",
|
|
||||||
"fn_task_plan",
|
|
||||||
"fn_mission_create",
|
|
||||||
"fn_mission_delete",
|
|
||||||
"fn_milestone_add",
|
|
||||||
"fn_slice_add",
|
|
||||||
"fn_feature_add",
|
|
||||||
"fn_slice_activate",
|
|
||||||
"fn_feature_link_task",
|
|
||||||
"fn_agent_stop",
|
|
||||||
"fn_agent_start",
|
|
||||||
"fn_update_agent_config",
|
|
||||||
"fn_spawn_agent",
|
|
||||||
"fn_task_add_dep",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const FILE_WRITE_DELETE_TOOLS = new Set([
|
|
||||||
"fn_task_attach",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const NETWORK_API_TOOLS = new Set([
|
|
||||||
"fn_research_run",
|
|
||||||
"fn_research_cancel",
|
|
||||||
"fn_research_retry",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const READONLY_FN_TOOLS = new Set([
|
|
||||||
"fn_task_list",
|
|
||||||
"fn_task_show",
|
|
||||||
"fn_task_create",
|
|
||||||
"fn_task_document_write",
|
|
||||||
"fn_task_document_read",
|
|
||||||
"fn_delegate_task",
|
|
||||||
"fn_research_list",
|
|
||||||
"fn_research_get",
|
|
||||||
"fn_insight_list",
|
|
||||||
"fn_insight_show",
|
|
||||||
"fn_insight_run_list",
|
|
||||||
"fn_insight_run_show",
|
|
||||||
"fn_mission_list",
|
|
||||||
"fn_mission_show",
|
|
||||||
"fn_list_agents",
|
|
||||||
"fn_agent_show",
|
|
||||||
"fn_agent_org_chart",
|
|
||||||
"fn_skills_search",
|
|
||||||
"fn_memory_search",
|
|
||||||
"fn_memory_get",
|
|
||||||
"fn_task_update",
|
|
||||||
"fn_task_log",
|
|
||||||
"fn_task_done",
|
|
||||||
"fn_heartbeat_done",
|
|
||||||
"fn_memory_append",
|
|
||||||
"fn_send_message",
|
|
||||||
"fn_read_messages",
|
|
||||||
"fn_update_identity",
|
|
||||||
"fn_reflect_on_performance",
|
|
||||||
"fn_read_evaluations",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const MUTATING_GIT_SUBCOMMANDS = new Set([
|
|
||||||
"add",
|
|
||||||
"commit",
|
|
||||||
"merge",
|
|
||||||
"rebase",
|
|
||||||
"cherry-pick",
|
|
||||||
"am",
|
|
||||||
"apply",
|
|
||||||
"stash",
|
|
||||||
"tag",
|
|
||||||
"push",
|
|
||||||
"reset",
|
|
||||||
"rm",
|
|
||||||
"mv",
|
|
||||||
"clean",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const READONLY_GIT_SUBCOMMANDS = new Set(["status", "diff", "log", "show", "rev-parse"]);
|
|
||||||
|
|
||||||
function normalizeArgs(args: unknown): Record<string, unknown> {
|
function normalizeArgs(args: unknown): Record<string, unknown> {
|
||||||
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
||||||
@@ -119,41 +41,6 @@ function extractShellCommand(args: Record<string, unknown>): string {
|
|||||||
return typeof command === "string" ? command.trim() : "";
|
return typeof command === "string" ? command.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGitWriteCommand(command: string): boolean {
|
|
||||||
const match = command.match(/(?:^|&&|\|\||;|\n)\s*git\s+([^\s]+)/);
|
|
||||||
if (!match) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const subcommand = match[1]?.trim() ?? "";
|
|
||||||
if (!subcommand || READONLY_GIT_SUBCOMMANDS.has(subcommand)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (subcommand === "branch") {
|
|
||||||
const tail = command.replace(/^[\s\S]*?\bgit\s+branch\b/, "").trim();
|
|
||||||
const hasPositionalArg = tail.length > 0 && !tail.startsWith("-");
|
|
||||||
return hasPositionalArg || /\s-d\b|\s-D\b|\s-m\b|\s-M\b|\s-c\b|\s-C\b/.test(command);
|
|
||||||
}
|
|
||||||
if (subcommand === "switch") {
|
|
||||||
return /\s-c\b/.test(command);
|
|
||||||
}
|
|
||||||
if (subcommand === "checkout") {
|
|
||||||
return /\s-b\b/.test(command);
|
|
||||||
}
|
|
||||||
if (subcommand === "pull") {
|
|
||||||
return /--rebase\b/.test(command);
|
|
||||||
}
|
|
||||||
if (subcommand === "restore") {
|
|
||||||
return /--staged\b/.test(command);
|
|
||||||
}
|
|
||||||
if (subcommand === "remote") {
|
|
||||||
return /\s+add\b|\s+remove\b|\s+rename\b|\s+set-url\b/.test(command);
|
|
||||||
}
|
|
||||||
if (subcommand === "worktree") {
|
|
||||||
return /\s+add\b|\s+remove\b/.test(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
return MUTATING_GIT_SUBCOMMANDS.has(subcommand);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyPermanentAgentToolCall(
|
export function classifyPermanentAgentToolCall(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user