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:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
72
packages/engine/src/__tests__/permanent-agent-gating.test.ts
Normal file
72
packages/engine/src/__tests__/permanent-agent-gating.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
216
packages/engine/src/permanent-agent-gating.ts
Normal file
216
packages/engine/src/permanent-agent-gating.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user