feat(FN-3560): add permanent agent gating enforcement in pi with tool class

The merge lands three major features: a permanent-agent gating system (FN-3560, 6 steps) that classifies and enforces tool access policies for permanent agents in the PI extension, with full test coverage and updated agent docs; an OpenClaw MCP bridge (FN-3717) adding MCP config, schema server, and

Fusion-Task-Id: FN-3560
This commit is contained in:
Fusion
2026-05-07 18:03:38 -07:00
committed by gsxdsm
parent 836ca6807a
commit 2e3be32f1b
17 changed files with 647 additions and 124 deletions

View File

@@ -53,56 +53,45 @@ Built-in preset catalog:
V1 runtime action categories: V1 runtime action categories:
- `git-write` - `git_write`
- `file-write-delete` - `file_write_delete`
- `shell-command` - `command_execution`
- `network-api` - `network_api`
- `task-agent-management` - `task_agent_mutation`
- `none` (classifier-only read-only result; never stored as a policy rule key)
`permissionPolicy` uses only the five sensitive categories above (everything except `none`) and the FN-3545 disposition contract:
- `allow`
- `block`
- `require-approval`
### Runtime gate v1 mapping (per tool invocation, permanent agents only) ### Runtime gate v1 mapping (per tool invocation, permanent agents only)
The engine classifies each tool call with this precedence order (first match wins): The engine classifies tool calls by behavior (not namespace alone):
1. `git-write` - `file_write_delete`: built-in `write` / `edit`, plus persistent write helpers like `fn_task_document_write`, `fn_memory_append`, `fn_task_attach`
2. `file-write-delete` - `command_execution`: built-in `bash` when not classified as mutating git
3. `task-agent-management` - `git_write`: mutating git shell commands run via `bash`
4. `network-api` - `network_api`: external/network-facing tools (for example `fn_research_run`, `fn_research_cancel`, `fn_research_retry`)
5. `shell-command` - `task_agent_mutation`: task/agent mutation tools (for example `fn_task_create`, `fn_delegate_task`, `fn_update_agent_config`, `fn_update_identity`)
6. exempt/read-only (`allow`) - `none`: positively recognized read-only tools (`read`, `grep`, `find`, `ls`, list/show/get-style `fn_*` tools)
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: `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. - 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`. - 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): Unknown/unclassified tool fallback:
- `fn_task_update`, `fn_task_log`, `fn_task_done`, `fn_task_document_write`, mailbox reads, memory reads, and other routine read-only inspection tools. - In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe).
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
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`). Interim enforcement behavior (pre-persistence path):
Approval-request runtime/storage contract (current implementation): - Permanent-agent gating short-circuits `block` and `require-approval` actions before tool execution and returns structured tool errors.
- `require-approval` is preserved as a distinct disposition for later approval workflow integration.
- The `approval-required` preset normalizes **every** v1 action category (`git-write`, `file-write-delete`, `shell-command`, `network-api`, `task-agent-management`) to `require-approval`. - This v1 gating layer does **not** create approval requests, pause agents, or depend on approval-request persistence APIs.
- Gated actions persist durable request rows (`approval_requests`) with:
- `requester` actor snapshot (`actorId`, `actorType`, `actorName`)
- target action payload (`category`, `action`, `summary`, `resourceType`, `resourceId`, optional `context`)
- optional execution linkage (`taskId`, `runId`)
- Lifecycle updates append immutable audit rows (`approval_request_audit_events`) with actor snapshot + optional note (`created`, `approved`, `denied`, `completed`).
Current boundary vs forthcoming UX/runtime pieces:
- **Implemented now:** durable approval request + append-only audit persistence used by runtime action-gating paths.
- **Not complete yet:** broader pause/resume execution workflow and dashboard mailbox/inbox approval-review surfaces.
- Treat operator review UI/inbox flow as forthcoming follow-up work; do not assume a finished approval inbox surface in current deployments.
Default and legacy fallback behavior: Default and legacy fallback behavior:

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js";
export * from "./mesh-replication-protocol.js"; export * from "./mesh-replication-protocol.js";
export * from "./mesh-task-replication.js"; export * from "./mesh-task-replication.js";

View File

@@ -3676,21 +3676,50 @@ export const AGENT_PERMISSIONS = [
/** A single canonical permission string. */ /** A single canonical permission string. */
export type AgentPermission = (typeof AGENT_PERMISSIONS)[number]; export type AgentPermission = (typeof AGENT_PERMISSIONS)[number];
/**
* Canonical v1 action categories for permanent-agent runtime gating.
*
* `none` is a classifier-only result for positively-recognized read-only actions.
* It is never stored as a policy rule key.
*/
export const PERMANENT_AGENT_ACTION_CATEGORIES = [
"git_write",
"file_write_delete",
"command_execution",
"network_api",
"task_agent_mutation",
"none",
] as const;
/** A single v1 permanent-agent action category. */
export type PermanentAgentActionCategory = (typeof PERMANENT_AGENT_ACTION_CATEGORIES)[number];
/** Sensitive runtime categories covered by policy rules (excludes classifier-only `none`). */
export type PermanentAgentSensitiveActionCategory = Exclude<PermanentAgentActionCategory, "none">;
/** Runtime action categories governed by agent permission policy presets. */ /** Runtime action categories governed by agent permission policy presets. */
export const AGENT_PERMISSION_POLICY_ACTION_CATEGORIES = [ export const AGENT_PERMISSION_POLICY_ACTION_CATEGORIES: readonly PermanentAgentSensitiveActionCategory[] = [
"git-write", "git_write",
"file-write-delete", "file_write_delete",
"shell-command", "command_execution",
"network-api", "network_api",
"task-agent-management", "task_agent_mutation",
] as const; ] as const;
/** A single runtime action category governed by permission policy. */ /** A single runtime action category governed by permission policy. */
export type AgentPermissionPolicyActionCategory = (typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number]; export type AgentPermissionPolicyActionCategory = PermanentAgentSensitiveActionCategory;
/** How a runtime action category is handled by permission policy. */ /** How a runtime action category is handled by permission policy. */
export type AgentPermissionPolicyDisposition = "allow" | "block" | "require-approval"; export type AgentPermissionPolicyDisposition = "allow" | "block" | "require-approval";
/** Minimum portable permanent-agent gating context consumed by engine runtime wrappers. */
export interface PermanentAgentGatingContext {
permissionPolicy?: {
presetId: string;
rules: Partial<Record<PermanentAgentSensitiveActionCategory, AgentPermissionPolicyDisposition>>;
};
}
/** Built-in permission policy preset identifiers for permanent agents. */ /** Built-in permission policy preset identifiers for permanent agents. */
export const AGENT_PERMISSION_POLICY_PRESET_IDS = ["unrestricted", "approval-required", "locked-down"] as const; export const AGENT_PERMISSION_POLICY_PRESET_IDS = ["unrestricted", "approval-required", "locked-down"] as const;

View File

@@ -1883,11 +1883,11 @@ describe("Agent create/update routes", () => {
permissionPolicy: { permissionPolicy: {
presetId: "approval-required", presetId: "approval-required",
rules: { rules: {
"git-write": "require-approval", git_write: "require-approval",
"file-write-delete": "require-approval", file_write_delete: "require-approval",
"shell-command": "require-approval", command_execution: "require-approval",
"network-api": "require-approval", network_api: "require-approval",
"task-agent-management": "require-approval", task_agent_mutation: "require-approval",
}, },
}, },
instructionsPath: "docs/reviewer.md", instructionsPath: "docs/reviewer.md",
@@ -1936,11 +1936,11 @@ describe("Agent create/update routes", () => {
permissionPolicy: { permissionPolicy: {
presetId: "locked-down", presetId: "locked-down",
rules: { rules: {
"git-write": "block", git_write: "block",
"file-write-delete": "block", file_write_delete: "block",
"shell-command": "block", command_execution: "block",
"network-api": "block", network_api: "block",
"task-agent-management": "block", task_agent_mutation: "block",
}, },
}, },
totalInputTokens: 42, totalInputTokens: 42,
@@ -2004,11 +2004,11 @@ describe("Agent create/update routes", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.permissionPolicy.presetId).toBe("locked-down"); expect(res.body.permissionPolicy.presetId).toBe("locked-down");
expect(res.body.permissionPolicy.rules).toEqual({ expect(res.body.permissionPolicy.rules).toEqual({
"git-write": "block", git_write: "block",
"file-write-delete": "block", file_write_delete: "block",
"shell-command": "block", command_execution: "block",
"network-api": "block", network_api: "block",
"task-agent-management": "block", task_agent_mutation: "block",
}); });
}); });

View File

@@ -5,11 +5,11 @@ import type { AgentPermissionPolicy } from "@fusion/core";
const unrestrictedPolicy: AgentPermissionPolicy = { const unrestrictedPolicy: AgentPermissionPolicy = {
presetId: "unrestricted", presetId: "unrestricted",
rules: { rules: {
"git-write": "allow", "git_write": "allow",
"file-write-delete": "allow", "file_write_delete": "allow",
"shell-command": "allow", "command_execution": "allow",
"network-api": "allow", "network_api": "allow",
"task-agent-management": "allow", "task_agent_mutation": "allow",
}, },
}; };
@@ -17,33 +17,33 @@ const approvalPolicy: AgentPermissionPolicy = {
...unrestrictedPolicy, ...unrestrictedPolicy,
presetId: "approval-required", presetId: "approval-required",
rules: { rules: {
"git-write": "require-approval", "git_write": "require-approval",
"file-write-delete": "require-approval", "file_write_delete": "require-approval",
"shell-command": "require-approval", "command_execution": "require-approval",
"network-api": "require-approval", "network_api": "require-approval",
"task-agent-management": "require-approval", "task_agent_mutation": "require-approval",
}, },
}; };
describe("agent-action-gate", () => { describe("agent-action-gate", () => {
it("classifies write/edit as file-write-delete", () => { it("classifies write/edit as file_write_delete", () => {
const write = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy }); 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 }); const edit = evaluateAgentActionGate({ agentId: "a1", toolName: "edit", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy });
expect(write.category).toBe("file-write-delete"); expect(write.category).toBe("file_write_delete");
expect(edit.category).toBe("file-write-delete"); expect(edit.category).toBe("file_write_delete");
}); });
it("classifies mutating git bash commands as git-write", () => { 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 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 }); const branchCreate = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git checkout -b feature" }, permissionPolicy: unrestrictedPolicy });
expect(commit.category).toBe("git-write"); expect(commit.category).toBe("git_write");
expect(branchCreate.operation).toBe("git checkout -b"); expect(branchCreate.operation).toBe("git checkout -b");
}); });
it("classifies non-mutating git status/diff as shell-command (allow by policy)", () => { it("classifies non-mutating git status/diff as command_execution (allow by policy)", () => {
const status = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git status" }, permissionPolicy: unrestrictedPolicy }); 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 }); const diff = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git diff" }, permissionPolicy: unrestrictedPolicy });
expect(status.category).toBe("shell-command"); expect(status.category).toBe("command_execution");
expect(diff.operation).toBe("git diff"); expect(diff.operation).toBe("git diff");
}); });
@@ -51,32 +51,32 @@ describe("agent-action-gate", () => {
const listing = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git branch" }, permissionPolicy: unrestrictedPolicy }); 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 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 }); const create = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git branch feature" }, permissionPolicy: unrestrictedPolicy });
expect(listing.category).toBe("shell-command"); expect(listing.category).toBe("command_execution");
expect(showCurrent.operation).toBe("git branch --show-current"); expect(showCurrent.operation).toBe("git branch --show-current");
expect(create.category).toBe("git-write"); expect(create.category).toBe("git_write");
}); });
it("classifies git remote -v as read-only", () => { it("classifies git remote -v as read-only", () => {
const listing = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git remote -v" }, permissionPolicy: unrestrictedPolicy }); 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 }); const add = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git remote add origin https://x" }, permissionPolicy: unrestrictedPolicy });
expect(listing.category).toBe("shell-command"); expect(listing.category).toBe("command_execution");
expect(add.category).toBe("git-write"); expect(add.category).toBe("git_write");
}); });
it("classifies generic bash commands as shell-command", () => { it("classifies generic bash commands as command_execution", () => {
const result = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "pnpm test" }, permissionPolicy: unrestrictedPolicy }); const result = evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "pnpm test" }, permissionPolicy: unrestrictedPolicy });
expect(result.category).toBe("shell-command"); expect(result.category).toBe("command_execution");
expect(result.resourceType).toBe("command"); expect(result.resourceType).toBe("command");
}); });
it("classifies explicit network and management tools", () => { 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_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_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task-agent-management"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
}); });
it("keeps routine task bookkeeping tools exempt", () => { it("keeps routine task bookkeeping tools exempt", () => {
@@ -94,11 +94,11 @@ describe("agent-action-gate", () => {
agentId: "agent-1", agentId: "agent-1",
taskId: "FN-1", taskId: "FN-1",
toolName: "write", toolName: "write",
category: "file-write-delete", category: "file_write_delete",
resourceType: "file", resourceType: "file",
resourceId: "a.ts", resourceId: "a.ts",
operation: "write", operation: "write",
}); });
expect(key).toBe("agent-1|FN-1|write|file-write-delete|file|a.ts|write"); expect(key).toBe("agent-1|FN-1|write|file_write_delete|file|a.ts|write");
}); });
}); });

View File

@@ -14449,4 +14449,53 @@ describe("allowParallelExecution heartbeat gate", () => {
expect(mockedCreateFnAgent).not.toHaveBeenCalled(); expect(mockedCreateFnAgent).not.toHaveBeenCalled();
} }
}); });
it("builds permanent-agent gating context for durable assigned agents", () => {
const agentStore = makeAgentStore({ ephemeral: false, allowParallelExecution: true, hasActiveRun: false });
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any });
const context = (executor as any).buildPermanentAgentGatingContext({
id: "agent-perm-1",
name: "Perm Agent",
type: "normal",
permissionPolicy: {
presetId: "approval-required",
rules: {
git_write: "require-approval",
file_write_delete: "require-approval",
command_execution: "require-approval",
network_api: "require-approval",
task_agent_mutation: "require-approval",
},
},
});
expect(context?.permissionPolicy?.presetId).toBe("approval-required");
});
it("omits permanent-agent gating context when no agent is assigned", async () => {
const agentStore = makeAgentStore({ ephemeral: false, allowParallelExecution: true, hasActiveRun: false });
const store = createMockStore();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any });
await executor.execute({
...TASK_BASE,
id: "FN-GATE-3",
assignedAgentId: undefined,
});
const hasPermanentGating = mockedCreateFnAgent.mock.calls
.map((call) => call[0] as { permanentAgentGating?: unknown })
.some((args) => args.permanentAgentGating !== undefined);
expect(hasPermanentGating).toBe(false);
});
}); });

View File

@@ -250,7 +250,7 @@ describe("executeHeartbeat", () => {
}); });
}); });
it("passes action gate context for permanent heartbeat agents", async () => { it("passes action gate and permanent gating context for permanent heartbeat agents", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" }); const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession(); const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
@@ -258,9 +258,13 @@ describe("executeHeartbeat", () => {
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
const args = mockedCreateFnAgent.mock.calls[0]?.[0] as { actionGateContext?: { agentId: string; isEphemeral: boolean } }; const args = mockedCreateFnAgent.mock.calls[0]?.[0] as {
actionGateContext?: { agentId: string; isEphemeral: boolean };
permanentAgentGating?: { permissionPolicy?: { presetId: string } };
};
expect(args.actionGateContext?.agentId).toBe("agent-001"); expect(args.actionGateContext?.agentId).toBe("agent-001");
expect(args.actionGateContext?.isEphemeral).toBe(false); expect(args.actionGateContext?.isEphemeral).toBe(false);
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
}); });
describe("dependency validation", () => { describe("dependency validation", () => {

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
classifyPermanentAgentToolCall,
resolvePermanentAgentToolDecision,
} from "../permanent-agent-gating.js";
describe("permanent-agent-gating", () => {
it("classifies builtin coding tools", () => {
expect(classifyPermanentAgentToolCall("write").category).toBe("file_write_delete");
expect(classifyPermanentAgentToolCall("edit").category).toBe("file_write_delete");
expect(classifyPermanentAgentToolCall("read").category).toBe("none");
expect(classifyPermanentAgentToolCall("grep").category).toBe("none");
expect(classifyPermanentAgentToolCall("bash", { command: "echo hi" }).category).toBe("command_execution");
expect(classifyPermanentAgentToolCall("bash", { command: "git commit -m test" }).category).toBe("git_write");
});
it("classifies shared fn tools by behavior", () => {
expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_update_agent_config").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_update_identity").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_task_document_write").category).toBe("file_write_delete");
expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("file_write_delete");
expect(classifyPermanentAgentToolCall("fn_research_run").category).toBe("network_api");
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
});
it("uses unknown-tool fallback to approval-required", () => {
const decision = resolvePermanentAgentToolDecision({
toolName: "plugin_custom_tool",
gating: {
permissionPolicy: {
presetId: "approval-required",
rules: { command_execution: "block" },
},
},
});
expect(decision.category).toBe("none");
expect(decision.recognized).toBe(false);
expect(decision.disposition).toBe("require-approval");
});
it("resolves disposition from policy for sensitive categories", () => {
const blockDecision = resolvePermanentAgentToolDecision({
toolName: "write",
gating: {
permissionPolicy: {
presetId: "locked-down",
rules: {
file_write_delete: "block",
},
},
},
});
expect(blockDecision.disposition).toBe("block");
const approvalDecision = resolvePermanentAgentToolDecision({
toolName: "fn_task_create",
gating: {
permissionPolicy: {
presetId: "approval-required",
rules: {
task_agent_mutation: "require-approval",
},
},
},
});
expect(approvalDecision.disposition).toBe("require-approval");
});
});

View File

@@ -385,6 +385,73 @@ describe("worktree path boundary helpers", () => {
}); });
}); });
describe("wrapToolsWithPermanentAgentGating", () => {
it("blocks policy-blocked actions and skips underlying tool", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
permissionPolicy: {
presetId: "locked-down",
rules: { file_write_delete: "block" },
},
});
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
expect((result as any).isError).toBe(true);
expect((result as any).details).toEqual(expect.objectContaining({
disposition: "block",
category: "file_write_delete",
toolName: "write",
}));
expect(tool.execute).not.toHaveBeenCalled();
});
it("requires approval for unknown tools and skips underlying tool", async () => {
const tool = { name: "plugin_custom", label: "Plugin", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
permissionPolicy: {
presetId: "unrestricted",
rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
},
},
});
const result = await (wrapped[0] as any).execute("t1", { value: 1 });
expect((result as any).isError).toBe(true);
expect((result as any).details).toEqual(expect.objectContaining({
disposition: "require-approval",
category: "none",
toolName: "plugin_custom",
requiresApproval: true,
}));
expect(tool.execute).not.toHaveBeenCalled();
});
it("lets boundary rejections fire before permanent-agent gating", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithPermanentAgentGating, wrapToolsWithBoundary } = await import("../pi.js");
const gated = wrapToolsWithPermanentAgentGating([tool as any], {
permissionPolicy: {
presetId: "locked-down",
rules: { file_write_delete: "block" },
},
});
const wrapped = wrapToolsWithBoundary(gated as any, "/project/.worktrees/fn-001", "/project");
const result = await (wrapped[0] as any).execute("t1", { path: "/project/README.md" });
expect((result as any).isError).toBe(true);
expect((result as any).error).toContain("outside the worktree boundary");
expect((result as any).details).toBeUndefined();
expect(tool.execute).not.toHaveBeenCalled();
});
});
describe("wrapToolsWithActionGate", () => { describe("wrapToolsWithActionGate", () => {
it("blocks disallowed actions and skips underlying tool", async () => { it("blocks disallowed actions and skips underlying tool", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() }; const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
@@ -397,11 +464,11 @@ describe("wrapToolsWithActionGate", () => {
permissionPolicy: { permissionPolicy: {
presetId: "locked-down", presetId: "locked-down",
rules: { rules: {
"git-write": "block", "git_write": "block",
"file-write-delete": "block", "file_write_delete": "block",
"shell-command": "block", "command_execution": "block",
"network-api": "block", "network_api": "block",
"task-agent-management": "block", "task_agent_mutation": "block",
}, },
}, },
createApprovalRequest: vi.fn(), createApprovalRequest: vi.fn(),
@@ -423,11 +490,11 @@ describe("wrapToolsWithActionGate", () => {
permissionPolicy: { permissionPolicy: {
presetId: "locked-down", presetId: "locked-down",
rules: { rules: {
"git-write": "block", "git_write": "block",
"file-write-delete": "block", "file_write_delete": "block",
"shell-command": "block", "command_execution": "block",
"network-api": "block", "network_api": "block",
"task-agent-management": "block", "task_agent_mutation": "block",
}, },
}, },
createApprovalRequest: vi.fn(), createApprovalRequest: vi.fn(),
@@ -451,11 +518,11 @@ describe("wrapToolsWithActionGate", () => {
permissionPolicy: { permissionPolicy: {
presetId: "approval-required", presetId: "approval-required",
rules: { rules: {
"git-write": "require-approval", "git_write": "require-approval",
"file-write-delete": "require-approval", "file_write_delete": "require-approval",
"shell-command": "require-approval", "command_execution": "require-approval",
"network-api": "require-approval", "network_api": "require-approval",
"task-agent-management": "require-approval", "task_agent_mutation": "require-approval",
}, },
}, },
createApprovalRequest, createApprovalRequest,

View File

@@ -128,14 +128,22 @@ describe("Runtime Selection Regression Tests", () => {
const { createResolvedAgentSession } = await import("../agent-session-helpers.js"); const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const permanentAgentGating = {
permissionPolicy: {
presetId: "approval-required",
rules: { command_execution: "require-approval" as const },
},
};
await createResolvedAgentSession({ await createResolvedAgentSession({
sessionPurpose: "executor", sessionPurpose: "executor",
pluginRunner: {} as any, pluginRunner: {} as any,
cwd: "/test/path", cwd: "/test/path",
systemPrompt: "Test prompt", systemPrompt: "Test prompt",
permanentAgentGating,
}); });
expect(mockCreateSession).toHaveBeenCalled(); expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ permanentAgentGating }));
}); });
it("should return runtime metadata along with session", async () => { it("should return runtime metadata along with session", async () => {

View File

@@ -183,25 +183,25 @@ export function evaluateAgentActionGate(params: {
const command = extractShellCommand(args); const command = extractShellCommand(args);
const git = classifyGitCommand(command); const git = classifyGitCommand(command);
if (git?.write) { if (git?.write) {
category = "git-write"; category = "git_write";
operation = git.operation; operation = git.operation;
resourceType = "git"; resourceType = "git";
} else { } else {
category = "shell-command"; category = "command_execution";
operation = git?.operation ?? "shell command"; operation = git?.operation ?? "shell command";
resourceType = git ? "git" : "command"; resourceType = git ? "git" : "command";
} }
} else if (params.toolName === "write" || params.toolName === "edit") { } else if (params.toolName === "write" || params.toolName === "edit") {
category = "file-write-delete"; category = "file_write_delete";
operation = params.toolName; operation = params.toolName;
resourceType = "file"; resourceType = "file";
resourceId = typeof args.path === "string" ? args.path : undefined; resourceId = typeof args.path === "string" ? args.path : undefined;
} else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) { } else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) {
category = "task-agent-management"; category = "task_agent_mutation";
operation = params.toolName; operation = params.toolName;
resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task"; resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task";
} else if (NETWORK_API_TOOLS.has(params.toolName)) { } else if (NETWORK_API_TOOLS.has(params.toolName)) {
category = "network-api"; category = "network_api";
operation = params.toolName; operation = params.toolName;
resourceType = "research"; resourceType = "research";
} else if (EXEMPT_TOOLS.has(params.toolName)) { } else if (EXEMPT_TOOLS.has(params.toolName)) {

View File

@@ -609,7 +609,7 @@ export class HeartbeatMonitor {
taskId, taskId,
runId, runId,
targetAction: { targetAction: {
category: decision.category === "exempt" ? "shell-command" : decision.category, category: decision.category === "exempt" ? "command_execution" : decision.category,
action: decision.operation, action: decision.operation,
summary: decision.summary, summary: decision.summary,
resourceType: decision.resourceType, resourceType: decision.resourceType,
@@ -624,6 +624,16 @@ export class HeartbeatMonitor {
}; };
} }
private buildPermanentAgentGatingContext(agent: Agent): { permissionPolicy: ReturnType<typeof resolveEffectiveAgentPermissionPolicy> } | undefined {
if (isEphemeralAgent(agent)) {
return undefined;
}
return {
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
};
}
/** /**
* Start the heartbeat monitoring loop. * Start the heartbeat monitoring loop.
* Safe to call multiple times - no-op if already running. * Safe to call multiple times - no-op if already running.
@@ -1835,6 +1845,7 @@ export class HeartbeatMonitor {
// Skill selection: use waking agent's skills (heartbeat has no role fallback) // Skill selection: use waking agent's skills (heartbeat has no role fallback)
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
actionGateContext: this.buildActionGateContext(agent, taskId, run.id), actionGateContext: this.buildActionGateContext(agent, taskId, run.id),
permanentAgentGating: this.buildPermanentAgentGatingContext(agent),
}); });
// Track for monitoring // Track for monitoring

View File

@@ -15,6 +15,7 @@
*/ */
import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { PermanentAgentGatingContext } from "@fusion/core";
import type { SkillSelectionContext } from "./skill-resolver.js"; import type { SkillSelectionContext } from "./skill-resolver.js";
import type { FallbackModelUsedPayload } from "./pi.js"; import type { FallbackModelUsedPayload } from "./pi.js";
import type { AgentActionGateContext } from "./agent-action-gate.js"; import type { AgentActionGateContext } from "./agent-action-gate.js";
@@ -86,6 +87,8 @@ export interface AgentRuntimeOptions {
taskId?: string; taskId?: string;
taskTitle?: string; taskTitle?: string;
actionGateContext?: AgentActionGateContext; actionGateContext?: AgentActionGateContext;
/** Permanent-agent action gating context for v1 category classification enforcement. */
permanentAgentGating?: PermanentAgentGatingContext;
} }
/** /**

View File

@@ -737,7 +737,7 @@ export class TaskExecutor {
taskId, taskId,
runId: this.currentRunContext?.runId, runId: this.currentRunContext?.runId,
targetAction: { targetAction: {
category: decision.category === "exempt" ? "shell-command" : decision.category, category: decision.category === "exempt" ? "command_execution" : decision.category,
action: decision.operation, action: decision.operation,
summary: decision.summary, summary: decision.summary,
resourceType: decision.resourceType, resourceType: decision.resourceType,
@@ -757,6 +757,16 @@ export class TaskExecutor {
}; };
} }
private buildPermanentAgentGatingContext(agent: Agent | null | undefined): { permissionPolicy: ReturnType<typeof resolveEffectiveAgentPermissionPolicy> } | undefined {
if (!agent || isEphemeralAgent(agent)) {
return undefined;
}
return {
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
};
}
/** Returns the set of task IDs currently being executed. */ /** Returns the set of task IDs currently being executed. */
getExecutingTaskIds(): Set<string> { getExecutingTaskIds(): Set<string> {
return new Set([...this.executing, ...this.recoveringCompleted, ...this.resumingUnpaused]); return new Set([...this.executing, ...this.recoveringCompleted, ...this.resumingUnpaused]);
@@ -2449,6 +2459,7 @@ export class TaskExecutor {
runtimeHint: stepSessionRuntimeHint, runtimeHint: stepSessionRuntimeHint,
assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined, assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
actionGateContext: this.buildActionGateContext(task.id, stepSessionAgent), actionGateContext: this.buildActionGateContext(task.id, stepSessionAgent),
permanentAgentGating: this.buildPermanentAgentGatingContext(stepSessionAgent),
// Pass skill selection context from the main executor session // Pass skill selection context from the main executor session
skillSelection: skillContext.skillSelectionContext, skillSelection: skillContext.skillSelectionContext,
// Pass agentStore and messageStore for delegation and messaging tools // Pass agentStore and messageStore for delegation and messaging tools
@@ -3006,6 +3017,7 @@ export class TaskExecutor {
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
actionGateContext: this.buildActionGateContext(task.id, assignedAgent), actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
permanentAgentGating: this.buildPermanentAgentGatingContext(assignedAgent),
taskId: task.id, taskId: task.id,
taskTitle: detail.title, taskTitle: detail.title,
onFallbackModelUsed: createFallbackModelObserver({ onFallbackModelUsed: createFallbackModelObserver({
@@ -3323,6 +3335,7 @@ export class TaskExecutor {
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
actionGateContext: this.buildActionGateContext(task.id, assignedAgent), actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
permanentAgentGating: this.buildPermanentAgentGatingContext(assignedAgent),
}); });
if (retrySessionFile) { if (retrySessionFile) {
this.store.updateTask(task.id, { sessionFile: retrySessionFile }).catch((err: unknown) => { this.store.updateTask(task.id, { sessionFile: retrySessionFile }).catch((err: unknown) => {

View File

@@ -0,0 +1,216 @@
import type {
AgentPermissionPolicyDisposition,
PermanentAgentActionCategory,
PermanentAgentGatingContext,
PermanentAgentSensitiveActionCategory,
} from "@fusion/core";
export interface PermanentAgentToolClassification {
category: PermanentAgentActionCategory;
/** True only when the tool is positively recognized and mapped by this module. */
recognized: boolean;
}
export interface PermanentAgentToolDecision extends PermanentAgentToolClassification {
toolName: string;
disposition: AgentPermissionPolicyDisposition;
}
const READONLY_BUILTIN_TOOLS = new Set(["read", "grep", "find", "ls"]);
const FILE_WRITE_TOOLS = new Set(["write", "edit"]);
const TASK_AGENT_MUTATION_TOOLS = new Set([
"fn_task_create",
"fn_task_add_dep",
"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_delegate_task",
"fn_update_agent_config",
"fn_update_identity",
"fn_spawn_agent",
"fn_task_add_dep",
]);
const FILE_WRITE_DELETE_TOOLS = new Set([
"fn_task_document_write",
"fn_memory_append",
"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_document_read",
"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",
]);
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> {
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 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(
toolName: string,
args?: unknown,
): PermanentAgentToolClassification {
if (FILE_WRITE_TOOLS.has(toolName)) {
return { category: "file_write_delete", recognized: true };
}
if (toolName === "bash") {
const command = extractShellCommand(normalizeArgs(args));
return { category: isGitWriteCommand(command) ? "git_write" : "command_execution", recognized: true };
}
if (READONLY_BUILTIN_TOOLS.has(toolName)) {
return { category: "none", recognized: true };
}
if (TASK_AGENT_MUTATION_TOOLS.has(toolName)) {
return { category: "task_agent_mutation", recognized: true };
}
if (FILE_WRITE_DELETE_TOOLS.has(toolName)) {
return { category: "file_write_delete", recognized: true };
}
if (NETWORK_API_TOOLS.has(toolName)) {
return { category: "network_api", recognized: true };
}
if (READONLY_FN_TOOLS.has(toolName) || /^fn_(?:list|show|get|read|browse)_/.test(toolName)) {
return { category: "none", recognized: true };
}
return { category: "none", recognized: false };
}
function resolvePolicyDisposition(
category: PermanentAgentSensitiveActionCategory,
gating: PermanentAgentGatingContext | undefined,
): AgentPermissionPolicyDisposition {
return gating?.permissionPolicy?.rules?.[category] ?? "require-approval";
}
export function resolvePermanentAgentToolDecision(input: {
toolName: string;
args?: unknown;
gating?: PermanentAgentGatingContext;
}): PermanentAgentToolDecision {
const classification = classifyPermanentAgentToolCall(input.toolName, input.args);
if (!input.gating?.permissionPolicy) {
return {
...classification,
toolName: input.toolName,
disposition: "allow",
};
}
if (classification.category === "none") {
return {
...classification,
toolName: input.toolName,
disposition: classification.recognized ? "allow" : "require-approval",
};
}
return {
...classification,
toolName: input.toolName,
disposition: resolvePolicyDisposition(classification.category, input.gating),
};
}

View File

@@ -35,6 +35,7 @@ import {
type ToolDefinition, type ToolDefinition,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core"; import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core";
import type { PermanentAgentGatingContext } from "@fusion/core";
import { import {
resolveSessionSkills, resolveSessionSkills,
createSkillsOverrideFromSelection, createSkillsOverrideFromSelection,
@@ -49,6 +50,7 @@ import {
evaluateAgentActionGate, evaluateAgentActionGate,
type AgentActionGateContext, type AgentActionGateContext,
} from "./agent-action-gate.js"; } from "./agent-action-gate.js";
import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js";
export interface AgentResult { export interface AgentResult {
session: AgentSession; session: AgentSession;
@@ -480,6 +482,8 @@ export interface AgentOptions {
taskId?: string; taskId?: string;
taskTitle?: string; taskTitle?: string;
actionGateContext?: AgentActionGateContext; actionGateContext?: AgentActionGateContext;
/** Permanent-agent action gating context forwarded by runtime/session helpers. */
permanentAgentGating?: PermanentAgentGatingContext;
} }
function resolveConfiguredModel( function resolveConfiguredModel(
@@ -1007,12 +1011,13 @@ function isWorktreeAllowedPath(
* message with `content: undefined`, which pi's downstream handling later * message with `content: undefined`, which pi's downstream handling later
* crashes on with "Cannot read properties of undefined (reading 'filter')". * crashes on with "Cannot read properties of undefined (reading 'filter')".
*/ */
function boundaryRejection(message: string) { function boundaryRejection(message: string, details?: Record<string, unknown>) {
return { return {
content: [{ type: "text", text: message }], content: [{ type: "text", text: message }],
isError: true, isError: true,
ok: false, ok: false,
error: message, error: message,
...(details ? { details } : {}),
}; };
} }
@@ -1072,6 +1077,47 @@ export function wrapToolsWithBoundary(
}); });
} }
export function wrapToolsWithPermanentAgentGating(
tools: ToolDefinition[],
gating: PermanentAgentGatingContext | undefined,
): ToolDefinition[] {
if (!gating) {
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 = resolvePermanentAgentToolDecision({
toolName: tool.name,
args: params,
gating,
});
if (decision.disposition === "allow") {
return originalExecute(...args);
}
const details: Record<string, unknown> = {
disposition: decision.disposition,
category: decision.category,
toolName: decision.toolName,
...(decision.disposition === "require-approval" ? { requiresApproval: true } : {}),
};
const reason = decision.disposition === "block"
? `Action blocked by permanent-agent policy (${decision.category}) for tool ${decision.toolName}`
: `Action requires approval (${decision.category}) before tool ${decision.toolName} can run`;
return boundaryRejection(reason, details);
},
};
});
}
export function wrapToolsWithActionGate( export function wrapToolsWithActionGate(
tools: ToolDefinition[], tools: ToolDefinition[],
gateContext: AgentActionGateContext | undefined, gateContext: AgentActionGateContext | undefined,
@@ -1196,7 +1242,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
if (worktreeProjectRoot) { if (worktreeProjectRoot) {
await assertValidWorktreeSession(worktreePath, worktreeProjectRoot); await assertValidWorktreeSession(worktreePath, worktreeProjectRoot);
} }
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, worktreeProjectRoot); const boundaryContext = { worktreePath, worktreeProjectRoot };
// resolvedProjectRoot was computed above (before registerExtensionProviders) // resolvedProjectRoot was computed above (before registerExtensionProviders)
// and is reused here for resource loader and skill discovery. // and is reused here for resource loader and skill discovery.
@@ -1288,10 +1334,23 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
// suppress the defaults with `noTools: "builtin"` and register our wrapped // suppress the defaults with `noTools: "builtin"` and register our wrapped
// tools through `customTools` instead. The wrapped tools preserve the same // tools through `customTools` instead. The wrapped tools preserve the same
// names (`read`, `bash`, ...) as the built-ins they replace. // names (`read`, `bash`, ...) as the built-ins they replace.
const customToolList: ToolDefinition[] = wrapToolsWithActionGate([ const toolChainStart: ToolDefinition[] = [
...(wrappedTools as ToolDefinition[]), ...(tools as ToolDefinition[]),
...(options.customTools ?? []), ...(options.customTools ?? []),
], options.actionGateContext); ];
const toolsWithPermanentGating = wrapToolsWithPermanentAgentGating(
toolChainStart,
options.permanentAgentGating,
);
const toolsWithActionGate = wrapToolsWithActionGate(
toolsWithPermanentGating,
options.actionGateContext,
);
const customToolList: ToolDefinition[] = wrapToolsWithBoundary(
toolsWithActionGate,
boundaryContext.worktreePath,
boundaryContext.worktreeProjectRoot,
);
// Last-chance abort hook. Fires *here* — after every awaited setup step // Last-chance abort hook. Fires *here* — after every awaited setup step
// in createFnAgent (provider registration, worktree validation, resource // in createFnAgent (provider registration, worktree validation, resource
// loader reload) and immediately before the actual LLM session spawn. // loader reload) and immediately before the actual LLM session spawn.

View File

@@ -17,7 +17,7 @@ const execAsync = promisify(exec);
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { AgentStore, MessageStore, TaskDetail, Settings, TaskStore } from "@fusion/core"; import type { AgentStore, MessageStore, PermanentAgentGatingContext, TaskDetail, Settings, TaskStore } from "@fusion/core";
import { import {
createResolvedAgentSession, createResolvedAgentSession,
@@ -112,6 +112,8 @@ export interface StepSessionExecutorOptions {
messageStore?: MessageStore; messageStore?: MessageStore;
/** Optional action-gate context for permanent assigned agents. */ /** Optional action-gate context for permanent assigned agents. */
actionGateContext?: AgentActionGateContext; actionGateContext?: AgentActionGateContext;
/** Optional permanent-agent action gating context. */
permanentAgentGating?: PermanentAgentGatingContext;
} }
// ── File Scope Extraction ───────────────────────────────────────────── // ── File Scope Extraction ─────────────────────────────────────────────
@@ -987,6 +989,7 @@ Follow instructions precisely and avoid unrelated changes.`,
// Skill selection from step-session executor options // Skill selection from step-session executor options
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}), ...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
actionGateContext: this.options.actionGateContext, actionGateContext: this.options.actionGateContext,
permanentAgentGating: this.options.permanentAgentGating,
taskId: taskDetail.id, taskId: taskDetail.id,
taskTitle: taskDetail.title, taskTitle: taskDetail.title,
onFallbackModelUsed: createFallbackModelObserver({ onFallbackModelUsed: createFallbackModelObserver({