feat(FN-3547): add runtime action classification and gating system for agen
Merged seven commits that introduce a runtime action gate for task execution (FN-3547) — adding action classification, git-based heuristics for branch/remote gating, and per-step session enforcement — with tests covering the gate logic, heartbeat integration, and PI agent creation. Also landed FN-37 Fusion-Task-Id: FN-3547
This commit is contained in:
@@ -59,6 +59,36 @@ V1 runtime action categories:
|
||||
- `network-api`
|
||||
- `task-agent-management`
|
||||
|
||||
### Runtime gate v1 mapping (per tool invocation, permanent agents only)
|
||||
|
||||
The engine classifies each tool call with this precedence order (first match wins):
|
||||
|
||||
1. `git-write`
|
||||
2. `file-write-delete`
|
||||
3. `task-agent-management`
|
||||
4. `network-api`
|
||||
5. `shell-command`
|
||||
6. exempt/read-only (`allow`)
|
||||
|
||||
Current v1 mapping:
|
||||
|
||||
- `file-write-delete`: `write`, `edit`
|
||||
- `task-agent-management`: `fn_task_create`, `fn_task_add_dep`, `fn_delegate_task`, `fn_update_agent_config`, `fn_update_identity`, `fn_spawn_agent`
|
||||
- `network-api`: `fn_research_run` (explicit tool-owned network/API surface only)
|
||||
- `shell-command`: non-git `bash`, and read-only git shell commands
|
||||
- `git-write`: mutating `bash` git commands
|
||||
|
||||
`bash` git-write heuristic in v1:
|
||||
|
||||
- Mutating git operations include: `git add`, `commit`, `merge`, `rebase`, `cherry-pick`, `am`, `apply`, `stash`, `tag`, `push`, `reset`, `rm`, `mv`, `clean`, `worktree add/remove`, `checkout -b`, `switch -c`, `pull --rebase`, `restore --staged`, and branch/remote mutation forms.
|
||||
- Read-only git operations include: `git status`, `diff`, `log`, `show`, `rev-parse`, `branch --show-current`, `branch` listing, and `remote -v`.
|
||||
|
||||
Intentionally exempt in v1 (remain normal execution plumbing):
|
||||
|
||||
- `fn_task_update`, `fn_task_log`, `fn_task_done`, `fn_task_document_write`, mailbox reads, memory reads, and other routine read-only inspection tools.
|
||||
|
||||
For `require-approval` dispositions, execution is intercepted before side effects; the engine creates/reuses a pending approval request keyed by a deterministic dedupe key (`agentId + taskId + toolName + category + resourceType + resourceId + operation`).
|
||||
|
||||
Default and legacy fallback behavior:
|
||||
|
||||
- New **non-ephemeral/permanent** agents persist a normalized `permissionPolicy` using preset `unrestricted` when not explicitly provided.
|
||||
|
||||
@@ -480,6 +480,13 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
||||
- `AgentRuntime` (`agent-runtime.ts`) — runtime adapter interface contract
|
||||
- `RuntimeResolution` (`runtime-resolution.ts`) — runtime selection and fallback logic
|
||||
- `AgentSessionHelpers` (`agent-session-helpers.ts`) — runtime-aware session creation helpers
|
||||
- `AgentActionGate` (`agent-action-gate.ts`) — permanent-agent runtime action classification + policy disposition decisions
|
||||
|
||||
Runtime action-gate flow (v1):
|
||||
- Tool execution wrappers in `pi.ts` compose `wrapToolsWithBoundary()` and `wrapToolsWithActionGate()`.
|
||||
- Non-ephemeral agents receive `AgentActionGateContext` from executor/heartbeat session creation.
|
||||
- `block` and `require-approval` dispositions intercept before tool side effects.
|
||||
- `require-approval` persists durable requests via `ApprovalRequestStore`, reusing pending requests by dedupe key in `targetAction.context.approvalDedupeKey`.
|
||||
|
||||
### Concurrency, recovery, and resiliency
|
||||
- `AgentSemaphore` (`concurrency.ts`) — slot acquisition
|
||||
|
||||
104
packages/engine/src/__tests__/agent-action-gate.test.ts
Normal file
104
packages/engine/src/__tests__/agent-action-gate.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateAgentActionGate, computeApprovalDedupeKey } from "../agent-action-gate.js";
|
||||
import type { AgentPermissionPolicy } from "@fusion/core";
|
||||
|
||||
const unrestrictedPolicy: AgentPermissionPolicy = {
|
||||
presetId: "unrestricted",
|
||||
rules: {
|
||||
"git-write": "allow",
|
||||
"file-write-delete": "allow",
|
||||
"shell-command": "allow",
|
||||
"network-api": "allow",
|
||||
"task-agent-management": "allow",
|
||||
},
|
||||
};
|
||||
|
||||
const approvalPolicy: AgentPermissionPolicy = {
|
||||
...unrestrictedPolicy,
|
||||
presetId: "approval-required",
|
||||
rules: {
|
||||
"git-write": "require-approval",
|
||||
"file-write-delete": "require-approval",
|
||||
"shell-command": "require-approval",
|
||||
"network-api": "require-approval",
|
||||
"task-agent-management": "require-approval",
|
||||
},
|
||||
};
|
||||
|
||||
describe("agent-action-gate", () => {
|
||||
it("classifies write/edit as file-write-delete", () => {
|
||||
const write = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy });
|
||||
const edit = evaluateAgentActionGate({ agentId: "a1", toolName: "edit", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy });
|
||||
expect(write.category).toBe("file-write-delete");
|
||||
expect(edit.category).toBe("file-write-delete");
|
||||
});
|
||||
|
||||
it("classifies mutating git bash commands as git-write", () => {
|
||||
const commit = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git commit -m x" }, permissionPolicy: unrestrictedPolicy });
|
||||
const branchCreate = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git checkout -b feature" }, permissionPolicy: unrestrictedPolicy });
|
||||
expect(commit.category).toBe("git-write");
|
||||
expect(branchCreate.operation).toBe("git checkout -b");
|
||||
});
|
||||
|
||||
it("classifies non-mutating git status/diff as shell-command (allow by policy)", () => {
|
||||
const status = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git status" }, permissionPolicy: unrestrictedPolicy });
|
||||
const diff = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git diff" }, permissionPolicy: unrestrictedPolicy });
|
||||
expect(status.category).toBe("shell-command");
|
||||
expect(diff.operation).toBe("git diff");
|
||||
});
|
||||
|
||||
it("classifies git branch listing/read vs branch creation", () => {
|
||||
const listing = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git branch" }, permissionPolicy: unrestrictedPolicy });
|
||||
const showCurrent = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git branch --show-current" }, permissionPolicy: unrestrictedPolicy });
|
||||
const create = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git branch feature" }, permissionPolicy: unrestrictedPolicy });
|
||||
expect(listing.category).toBe("shell-command");
|
||||
expect(showCurrent.operation).toBe("git branch --show-current");
|
||||
expect(create.category).toBe("git-write");
|
||||
});
|
||||
|
||||
it("classifies git remote -v as read-only", () => {
|
||||
const listing = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git remote -v" }, permissionPolicy: unrestrictedPolicy });
|
||||
const add = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git remote add origin https://x" }, permissionPolicy: unrestrictedPolicy });
|
||||
expect(listing.category).toBe("shell-command");
|
||||
expect(add.category).toBe("git-write");
|
||||
});
|
||||
|
||||
it("classifies generic bash commands as shell-command", () => {
|
||||
const result = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "pnpm test" }, permissionPolicy: unrestrictedPolicy });
|
||||
expect(result.category).toBe("shell-command");
|
||||
expect(result.resourceType).toBe("command");
|
||||
});
|
||||
|
||||
it("classifies explicit network and management tools", () => {
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_research_run", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("network-api");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management");
|
||||
});
|
||||
|
||||
it("keeps routine task bookkeeping tools exempt", () => {
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).category).toBe("exempt");
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).disposition).toBe("allow");
|
||||
});
|
||||
|
||||
it("resolves disposition from policy", () => {
|
||||
const result = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: approvalPolicy });
|
||||
expect(result.disposition).toBe("require-approval");
|
||||
});
|
||||
|
||||
it("computes deterministic dedupe key", () => {
|
||||
const key = computeApprovalDedupeKey({
|
||||
agentId: "agent-1",
|
||||
taskId: "FN-1",
|
||||
toolName: "write",
|
||||
category: "file-write-delete",
|
||||
resourceType: "file",
|
||||
resourceId: "a.ts",
|
||||
operation: "write",
|
||||
});
|
||||
expect(key).toBe("agent-1|FN-1|write|file-write-delete|file|a.ts|write");
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,19 @@ describe("executeHeartbeat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes action gate context for permanent heartbeat agents", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
const args = mockedCreateFnAgent.mock.calls[0]?.[0] as { actionGateContext?: { agentId: string; isEphemeral: boolean } };
|
||||
expect(args.actionGateContext?.agentId).toBe("agent-001");
|
||||
expect(args.actionGateContext?.isEphemeral).toBe(false);
|
||||
});
|
||||
|
||||
describe("dependency validation", () => {
|
||||
it("throws when taskStore is not configured", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
|
||||
@@ -385,6 +385,90 @@ describe("worktree path boundary helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapToolsWithActionGate", () => {
|
||||
it("blocks disallowed actions and skips underlying tool", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: {
|
||||
presetId: "locked-down",
|
||||
rules: {
|
||||
"git-write": "block",
|
||||
"file-write-delete": "block",
|
||||
"shell-command": "block",
|
||||
"network-api": "block",
|
||||
"task-agent-management": "block",
|
||||
},
|
||||
},
|
||||
createApprovalRequest: vi.fn(),
|
||||
findPendingApprovalByDedupeKey: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
expect((result as any).isError).toBe(true);
|
||||
expect(tool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips gating wrapper for ephemeral contexts", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: true,
|
||||
permissionPolicy: {
|
||||
presetId: "locked-down",
|
||||
rules: {
|
||||
"git-write": "block",
|
||||
"file-write-delete": "block",
|
||||
"shell-command": "block",
|
||||
"network-api": "block",
|
||||
"task-agent-management": "block",
|
||||
},
|
||||
},
|
||||
createApprovalRequest: vi.fn(),
|
||||
findPendingApprovalByDedupeKey: vi.fn(),
|
||||
});
|
||||
|
||||
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
expect(tool.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates approval request once for require-approval", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
|
||||
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-1" });
|
||||
const findPendingApprovalByDedupeKey = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "apr-1" });
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: {
|
||||
presetId: "approval-required",
|
||||
rules: {
|
||||
"git-write": "require-approval",
|
||||
"file-write-delete": "require-approval",
|
||||
"shell-command": "require-approval",
|
||||
"network-api": "require-approval",
|
||||
"task-agent-management": "require-approval",
|
||||
},
|
||||
},
|
||||
createApprovalRequest,
|
||||
findPendingApprovalByDedupeKey,
|
||||
});
|
||||
|
||||
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
await (wrapped[0] as any).execute("t2", { path: "a.ts" });
|
||||
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
||||
expect(tool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFnAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
247
packages/engine/src/agent-action-gate.ts
Normal file
247
packages/engine/src/agent-action-gate.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
AgentPermissionPolicy,
|
||||
AgentPermissionPolicyActionCategory,
|
||||
AgentPermissionPolicyDisposition,
|
||||
} from "@fusion/core";
|
||||
|
||||
export type AgentActionGateResourceType = "file" | "git" | "task" | "agent" | "research" | "command" | "other";
|
||||
|
||||
export interface AgentActionGateDecision {
|
||||
disposition: "allow" | "block" | "require-approval";
|
||||
category: AgentPermissionPolicyActionCategory | "exempt";
|
||||
toolName: string;
|
||||
operation: string;
|
||||
summary: string;
|
||||
resourceType: AgentActionGateResourceType;
|
||||
resourceId?: string;
|
||||
approvalDedupeKey: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentActionGateContext {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
isEphemeral: boolean;
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
permissionPolicy: AgentPermissionPolicy;
|
||||
createApprovalRequest: (decision: AgentActionGateDecision, args: Record<string, unknown>) => Promise<unknown>;
|
||||
findPendingApprovalByDedupeKey: (dedupeKey: string) => Promise<unknown | null>;
|
||||
}
|
||||
|
||||
const EXEMPT_TOOLS = new Set([
|
||||
"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",
|
||||
]);
|
||||
|
||||
const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
|
||||
"fn_task_create",
|
||||
"fn_task_add_dep",
|
||||
"fn_delegate_task",
|
||||
"fn_spawn_agent",
|
||||
"fn_update_agent_config",
|
||||
"fn_update_identity",
|
||||
]);
|
||||
|
||||
const NETWORK_API_TOOLS = new Set(["fn_research_run"]);
|
||||
|
||||
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> {
|
||||
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function extractShellCommand(args: Record<string, unknown>): string {
|
||||
const command = args.command;
|
||||
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: {
|
||||
agentId: string;
|
||||
taskId?: string;
|
||||
toolName: string;
|
||||
category: string;
|
||||
resourceType: AgentActionGateResourceType;
|
||||
resourceId?: string;
|
||||
operation: string;
|
||||
}): string {
|
||||
return [
|
||||
input.agentId,
|
||||
input.taskId ?? "",
|
||||
input.toolName,
|
||||
input.category,
|
||||
input.resourceType,
|
||||
input.resourceId ?? "",
|
||||
input.operation,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function evaluateAgentActionGate(params: {
|
||||
agentId: string;
|
||||
taskId?: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
permissionPolicy: AgentPermissionPolicy;
|
||||
}): AgentActionGateDecision {
|
||||
const args = normalizeArgs(params.args);
|
||||
|
||||
let category: AgentPermissionPolicyActionCategory | "exempt" = "exempt";
|
||||
let operation = params.toolName;
|
||||
let resourceType: AgentActionGateResourceType = "other";
|
||||
let resourceId: string | undefined;
|
||||
|
||||
if (params.toolName === "bash") {
|
||||
const command = extractShellCommand(args);
|
||||
const git = classifyGitCommand(command);
|
||||
if (git?.write) {
|
||||
category = "git-write";
|
||||
operation = git.operation;
|
||||
resourceType = "git";
|
||||
} else {
|
||||
category = "shell-command";
|
||||
operation = git?.operation ?? "shell command";
|
||||
resourceType = git ? "git" : "command";
|
||||
}
|
||||
} else if (params.toolName === "write" || params.toolName === "edit") {
|
||||
category = "file-write-delete";
|
||||
operation = params.toolName;
|
||||
resourceType = "file";
|
||||
resourceId = typeof args.path === "string" ? args.path : undefined;
|
||||
} else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) {
|
||||
category = "task-agent-management";
|
||||
operation = params.toolName;
|
||||
resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task";
|
||||
} else if (NETWORK_API_TOOLS.has(params.toolName)) {
|
||||
category = "network-api";
|
||||
operation = params.toolName;
|
||||
resourceType = "research";
|
||||
} else if (EXEMPT_TOOLS.has(params.toolName)) {
|
||||
category = "exempt";
|
||||
operation = params.toolName;
|
||||
}
|
||||
|
||||
const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt"
|
||||
? "allow"
|
||||
: params.permissionPolicy.rules[category];
|
||||
|
||||
const dedupeKey = computeApprovalDedupeKey({
|
||||
agentId: params.agentId,
|
||||
taskId: params.taskId,
|
||||
toolName: params.toolName,
|
||||
category,
|
||||
resourceType,
|
||||
resourceId,
|
||||
operation,
|
||||
});
|
||||
|
||||
return {
|
||||
disposition,
|
||||
category,
|
||||
toolName: params.toolName,
|
||||
operation,
|
||||
summary: `${params.toolName}: ${operation}`,
|
||||
resourceType,
|
||||
...(resourceId ? { resourceId } : {}),
|
||||
approvalDedupeKey: dedupeKey,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGateRejection(decision: AgentActionGateDecision, reason: string) {
|
||||
return {
|
||||
content: [{ type: "text", text: reason }],
|
||||
isError: true,
|
||||
ok: false,
|
||||
error: reason,
|
||||
decision,
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore } from "@fusion/core";
|
||||
import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core";
|
||||
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -34,6 +34,7 @@ import { heartbeatLog, formatError } from "./logger.js";
|
||||
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
|
||||
import { promptWithFallback } from "./pi.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint, extractRuntimeModel } from "./agent-session-helpers.js";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
import { buildSessionSkillContextSync } from "./session-skill-context.js";
|
||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
|
||||
@@ -546,6 +547,7 @@ export class HeartbeatMonitor {
|
||||
private reflectionStore?: ReflectionStore;
|
||||
private reflectionService?: AgentReflectionService;
|
||||
private selfImproveService?: SelfImproveServiceLike;
|
||||
private approvalRequestStore?: ApprovalRequestStore;
|
||||
|
||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
||||
@@ -575,6 +577,48 @@ export class HeartbeatMonitor {
|
||||
this.selfImproveService = options.selfImproveService;
|
||||
}
|
||||
|
||||
private getApprovalRequestStore(): ApprovalRequestStore {
|
||||
if (!this.approvalRequestStore) {
|
||||
if (!this.taskStore) {
|
||||
throw new Error("HeartbeatMonitor missing taskStore for approval request persistence");
|
||||
}
|
||||
this.approvalRequestStore = new ApprovalRequestStore(this.taskStore.getDatabase());
|
||||
}
|
||||
return this.approvalRequestStore;
|
||||
}
|
||||
|
||||
private buildActionGateContext(agent: Agent, taskId?: string, runId?: string): AgentActionGateContext | undefined {
|
||||
if (isEphemeralAgent(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy);
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
isEphemeral: false,
|
||||
taskId,
|
||||
runId,
|
||||
permissionPolicy: policy,
|
||||
createApprovalRequest: async (decision, args) => this.getApprovalRequestStore().create({
|
||||
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||
taskId,
|
||||
runId,
|
||||
targetAction: {
|
||||
category: decision.category === "exempt" ? "shell-command" : decision.category,
|
||||
action: decision.operation,
|
||||
summary: decision.summary,
|
||||
resourceType: decision.resourceType,
|
||||
resourceId: decision.resourceId ?? "",
|
||||
context: { ...decision.metadata, approvalDedupeKey: decision.approvalDedupeKey, toolName: decision.toolName, toolArgs: args },
|
||||
},
|
||||
}),
|
||||
findPendingApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const pending = this.getApprovalRequestStore().list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the heartbeat monitoring loop.
|
||||
* Safe to call multiple times - no-op if already running.
|
||||
@@ -1777,6 +1821,7 @@ export class HeartbeatMonitor {
|
||||
},
|
||||
// Skill selection: use waking agent's skills (heartbeat has no role fallback)
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
actionGateContext: this.buildActionGateContext(agent, taskId, run.id),
|
||||
});
|
||||
|
||||
// Track for monitoring
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import type { FallbackModelUsedPayload } from "./pi.js";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
|
||||
/**
|
||||
* Options for creating an agent session.
|
||||
@@ -84,6 +85,7 @@ export interface AgentRuntimeOptions {
|
||||
/** Optional task context for fallback notifications. */
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
actionGateContext?: AgentActionGateContext;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,15 @@ const execAsync = promisify(exec);
|
||||
import { isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig } from "@fusion/core";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent } from "@fusion/core";
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
buildExecutionMemoryInstructions,
|
||||
getTaskMergeBlocker,
|
||||
isEphemeralAgent,
|
||||
isResearchExperimentalEnabled,
|
||||
resolveAgentPrompt,
|
||||
resolveEffectiveAgentPermissionPolicy,
|
||||
resolveProjectDefaultModel,
|
||||
type RunCommandResult,
|
||||
} from "@fusion/core";
|
||||
@@ -74,6 +76,7 @@ import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
|
||||
import { createRunVerificationTool } from "./run-verification-tool.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
|
||||
// Re-export for backward compatibility (tests import from executor.ts)
|
||||
export { summarizeToolArgs } from "./agent-logger.js";
|
||||
@@ -690,6 +693,7 @@ export class TaskExecutor {
|
||||
/** Token cap detector for proactive context compaction. */
|
||||
private tokenCapDetector = new TokenCapDetector();
|
||||
private _modelRegistry?: ModelRegistry;
|
||||
private _approvalRequestStore?: ApprovalRequestStore;
|
||||
/** Current run context for mutation correlation. Set at execute() start, cleared in finally. */
|
||||
private currentRunContext: RunMutationContext | undefined;
|
||||
|
||||
@@ -702,6 +706,54 @@ export class TaskExecutor {
|
||||
return this._modelRegistry;
|
||||
}
|
||||
|
||||
private get approvalRequestStore(): ApprovalRequestStore {
|
||||
if (!this._approvalRequestStore) {
|
||||
this._approvalRequestStore = new ApprovalRequestStore(this.store.getDatabase());
|
||||
}
|
||||
return this._approvalRequestStore;
|
||||
}
|
||||
|
||||
private buildActionGateContext(taskId: string | undefined, agent: Agent | null | undefined): AgentActionGateContext | undefined {
|
||||
if (!agent || isEphemeralAgent(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy);
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
isEphemeral: false,
|
||||
taskId,
|
||||
runId: this.currentRunContext?.runId,
|
||||
permissionPolicy: policy,
|
||||
createApprovalRequest: async (decision, args) => this.approvalRequestStore.create({
|
||||
requester: {
|
||||
actorId: agent.id,
|
||||
actorType: "agent",
|
||||
actorName: agent.name,
|
||||
},
|
||||
taskId,
|
||||
runId: this.currentRunContext?.runId,
|
||||
targetAction: {
|
||||
category: decision.category === "exempt" ? "shell-command" : decision.category,
|
||||
action: decision.operation,
|
||||
summary: decision.summary,
|
||||
resourceType: decision.resourceType,
|
||||
resourceId: decision.resourceId ?? "",
|
||||
context: {
|
||||
...decision.metadata,
|
||||
approvalDedupeKey: decision.approvalDedupeKey,
|
||||
toolName: decision.toolName,
|
||||
toolArgs: args,
|
||||
},
|
||||
},
|
||||
}),
|
||||
findPendingApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const pending = this.approvalRequestStore.list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the set of task IDs currently being executed. */
|
||||
getExecutingTaskIds(): Set<string> {
|
||||
return new Set([...this.executing, ...this.recoveringCompleted, ...this.resumingUnpaused]);
|
||||
@@ -2393,6 +2445,7 @@ export class TaskExecutor {
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
runtimeHint: stepSessionRuntimeHint,
|
||||
assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
|
||||
actionGateContext: this.buildActionGateContext(task.id, stepSessionAgent),
|
||||
// Pass skill selection context from the main executor session
|
||||
skillSelection: skillContext.skillSelectionContext,
|
||||
// Pass agentStore and messageStore for delegation and messaging tools
|
||||
@@ -2949,6 +3002,7 @@ export class TaskExecutor {
|
||||
sessionManager,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
|
||||
taskId: task.id,
|
||||
taskTitle: detail.title,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
@@ -3265,6 +3319,7 @@ export class TaskExecutor {
|
||||
sessionManager: SessionManager.create(worktreePath),
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
|
||||
});
|
||||
if (retrySessionFile) {
|
||||
this.store.updateTask(task.id, { sessionFile: retrySessionFile }).catch((err: unknown) => {
|
||||
|
||||
@@ -44,6 +44,11 @@ import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
|
||||
import { piLog, extensionsLog } from "./logger.js";
|
||||
import { readCustomProviders } from "./custom-providers.js";
|
||||
import {
|
||||
buildGateRejection,
|
||||
evaluateAgentActionGate,
|
||||
type AgentActionGateContext,
|
||||
} from "./agent-action-gate.js";
|
||||
|
||||
export interface AgentResult {
|
||||
session: AgentSession;
|
||||
@@ -474,6 +479,7 @@ export interface AgentOptions {
|
||||
/** Optional task context for fallback notifications. */
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
actionGateContext?: AgentActionGateContext;
|
||||
}
|
||||
|
||||
function resolveConfiguredModel(
|
||||
@@ -1066,6 +1072,53 @@ export function wrapToolsWithBoundary(
|
||||
});
|
||||
}
|
||||
|
||||
export function wrapToolsWithActionGate(
|
||||
tools: ToolDefinition[],
|
||||
gateContext: AgentActionGateContext | undefined,
|
||||
): ToolDefinition[] {
|
||||
if (!gateContext || gateContext.isEphemeral) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
return tools.map((tool) => {
|
||||
const originalExecute = tool.execute as any;
|
||||
return {
|
||||
...tool,
|
||||
execute: async (...args: any[]) => {
|
||||
const params = (args[1] ?? {}) as Record<string, unknown>;
|
||||
const decision = evaluateAgentActionGate({
|
||||
agentId: gateContext.agentId,
|
||||
taskId: gateContext.taskId,
|
||||
toolName: tool.name,
|
||||
args: params,
|
||||
permissionPolicy: gateContext.permissionPolicy,
|
||||
});
|
||||
|
||||
if (decision.disposition === "allow") {
|
||||
return originalExecute(...args);
|
||||
}
|
||||
|
||||
if (decision.disposition === "block") {
|
||||
return buildGateRejection(
|
||||
decision,
|
||||
`Action blocked by permission policy (${decision.category}) for ${gateContext.agentName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await gateContext.findPendingApprovalByDedupeKey(decision.approvalDedupeKey);
|
||||
if (!existing) {
|
||||
await gateContext.createApprovalRequest(decision, params);
|
||||
}
|
||||
|
||||
return buildGateRejection(
|
||||
decision,
|
||||
`Action requires approval (${decision.category}). Approval request queued.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pi agent session configured for fn.
|
||||
* Reuses the user's existing pi auth and model configuration.
|
||||
@@ -1235,10 +1288,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
// suppress the defaults with `noTools: "builtin"` and register our wrapped
|
||||
// tools through `customTools` instead. The wrapped tools preserve the same
|
||||
// names (`read`, `bash`, ...) as the built-ins they replace.
|
||||
const customToolList: ToolDefinition[] = [
|
||||
const customToolList: ToolDefinition[] = wrapToolsWithActionGate([
|
||||
...(wrappedTools as ToolDefinition[]),
|
||||
...(options.customTools ?? []),
|
||||
];
|
||||
], options.actionGateContext);
|
||||
// Last-chance abort hook. Fires *here* — after every awaited setup step
|
||||
// in createFnAgent (provider registration, worktree validation, resource
|
||||
// loader reload) and immediately before the actual LLM session spawn.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
promptWithAutoRetry,
|
||||
resolveExecutorSessionModel,
|
||||
} from "./agent-session-helpers.js";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import { generateWorktreeName } from "./worktree-names.js";
|
||||
import { AgentSemaphore } from "./concurrency.js";
|
||||
@@ -109,6 +110,8 @@ export interface StepSessionExecutorOptions {
|
||||
agentStore?: AgentStore;
|
||||
/** Optional message store for messaging tools. */
|
||||
messageStore?: MessageStore;
|
||||
/** Optional action-gate context for permanent assigned agents. */
|
||||
actionGateContext?: AgentActionGateContext;
|
||||
}
|
||||
|
||||
// ── File Scope Extraction ─────────────────────────────────────────────
|
||||
@@ -983,6 +986,7 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
},
|
||||
// Skill selection from step-session executor options
|
||||
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
|
||||
actionGateContext: this.options.actionGateContext,
|
||||
taskId: taskDetail.id,
|
||||
taskTitle: taskDetail.title,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
|
||||
Reference in New Issue
Block a user