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:
Fusion
2026-05-07 13:49:13 -07:00
committed by gsxdsm
parent 9b19199a1d
commit 669fba501a
11 changed files with 648 additions and 4 deletions

View File

@@ -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.

View File

@@ -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

View 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");
});
});

View File

@@ -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();

View File

@@ -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();

View 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,
};
}

View File

@@ -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

View File

@@ -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;
}
/**

View File

@@ -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) => {

View File

@@ -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.

View File

@@ -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({