feat(FN-3561): wire permanent-agent approval context in runtime paths
Wired permanent-agent approval context into runtime paths for FN-3561, updating the agents documentation and adding test coverage for the heartbeat executor to validate the runtime behavior. Fusion-Task-Id: FN-3561
This commit is contained in:
@@ -87,11 +87,11 @@ Unknown/unclassified tool fallback:
|
|||||||
- In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe).
|
- 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.
|
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
||||||
|
|
||||||
Interim enforcement behavior (pre-persistence path):
|
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):
|
||||||
|
|
||||||
- Permanent-agent gating short-circuits `block` and `require-approval` actions before tool execution and returns structured tool errors.
|
- Permanent-agent gating short-circuits `block` and `require-approval` actions before tool execution and returns structured non-success tool results.
|
||||||
- `require-approval` is preserved as a distinct disposition for later approval workflow integration.
|
- For `require-approval`, the engine now creates durable approval requests (via `ApprovalRequestStore`) with requester identity, task/run context, and tool/action metadata; the original mutation is not executed.
|
||||||
- This v1 gating layer does **not** create approval requests, pause agents, or depend on approval-request persistence APIs.
|
- Pause/resume execution, suspended-run continuation, and approve/deny continuation behavior remain deferred to FN-3548.
|
||||||
|
|
||||||
Default and legacy fallback behavior:
|
Default and legacy fallback behavior:
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
APPROVAL_REQUEST_AUDIT_EVENT_TYPES,
|
APPROVAL_REQUEST_AUDIT_EVENT_TYPES,
|
||||||
APPROVAL_REQUEST_STATUSES,
|
APPROVAL_REQUEST_STATUSES,
|
||||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||||
|
normalizeApprovalRequestActionCategory,
|
||||||
isValidApprovalRequestTransition,
|
isValidApprovalRequestTransition,
|
||||||
type ApprovalRequest,
|
type ApprovalRequest,
|
||||||
type ApprovalRequestActorSnapshot,
|
type ApprovalRequestActorSnapshot,
|
||||||
@@ -35,6 +36,15 @@ describe("approval request domain contract", () => {
|
|||||||
expect(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.length).toBeGreaterThan(0);
|
expect(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy action-category aliases", () => {
|
||||||
|
expect(normalizeApprovalRequestActionCategory("file_write")).toBe("file_write_delete");
|
||||||
|
expect(normalizeApprovalRequestActionCategory("file_delete")).toBe("file_write_delete");
|
||||||
|
expect(normalizeApprovalRequestActionCategory("command_execute")).toBe("command_execution");
|
||||||
|
expect(normalizeApprovalRequestActionCategory("network_access")).toBe("network_api");
|
||||||
|
expect(normalizeApprovalRequestActionCategory("task_mutation")).toBe("task_agent_mutation");
|
||||||
|
expect(normalizeApprovalRequestActionCategory("agent_mutation")).toBe("task_agent_mutation");
|
||||||
|
});
|
||||||
|
|
||||||
it("enforces the lifecycle transition matrix", () => {
|
it("enforces the lifecycle transition matrix", () => {
|
||||||
expect(isValidApprovalRequestTransition("pending", "approved")).toBe(true);
|
expect(isValidApprovalRequestTransition("pending", "approved")).toBe(true);
|
||||||
expect(isValidApprovalRequestTransition("pending", "denied")).toBe(true);
|
expect(isValidApprovalRequestTransition("pending", "denied")).toBe(true);
|
||||||
@@ -116,6 +126,22 @@ describe("ApprovalRequestStore", () => {
|
|||||||
expect(fetched?.runId).toBe("run-abc");
|
expect(fetched?.runId).toBe("run-abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy category aliases on create/read", () => {
|
||||||
|
const created = store.create({
|
||||||
|
requester: REQUESTER,
|
||||||
|
targetAction: {
|
||||||
|
category: "file_write",
|
||||||
|
action: "write",
|
||||||
|
summary: "Write file",
|
||||||
|
resourceType: "file",
|
||||||
|
resourceId: "foo.ts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = store.get(created.id);
|
||||||
|
expect(fetched?.targetAction.category).toBe("file_write_delete");
|
||||||
|
});
|
||||||
|
|
||||||
it("supports pending -> approved and approved -> completed with audit trail", () => {
|
it("supports pending -> approved and approved -> completed with audit trail", () => {
|
||||||
const created = createSampleRequest();
|
const created = createSampleRequest();
|
||||||
const approved = store.decide(created.id, "approved", { actor: APPROVER, note: "Looks good" });
|
const approved = store.decide(created.id, "approved", { actor: APPROVER, note: "Looks good" });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Database } from "./db.js";
|
|||||||
import { fromJson, toJsonNullable } from "./db.js";
|
import { fromJson, toJsonNullable } from "./db.js";
|
||||||
import {
|
import {
|
||||||
isValidApprovalRequestTransition,
|
isValidApprovalRequestTransition,
|
||||||
|
normalizeApprovalRequestActionCategory,
|
||||||
type ApprovalRequest,
|
type ApprovalRequest,
|
||||||
type ApprovalRequestActorSnapshot,
|
type ApprovalRequestActorSnapshot,
|
||||||
type ApprovalRequestAuditEvent,
|
type ApprovalRequestAuditEvent,
|
||||||
@@ -59,7 +60,9 @@ export class ApprovalRequestStore {
|
|||||||
actorName: row.requesterActorName,
|
actorName: row.requesterActorName,
|
||||||
},
|
},
|
||||||
targetAction: {
|
targetAction: {
|
||||||
category: row.targetActionCategory as ApprovalRequest["targetAction"]["category"],
|
category: normalizeApprovalRequestActionCategory(
|
||||||
|
row.targetActionCategory as Parameters<typeof normalizeApprovalRequestActionCategory>[0],
|
||||||
|
),
|
||||||
action: row.targetActionOperation,
|
action: row.targetActionOperation,
|
||||||
summary: row.targetActionSummary,
|
summary: row.targetActionSummary,
|
||||||
resourceType: row.targetResourceType,
|
resourceType: row.targetResourceType,
|
||||||
@@ -130,7 +133,10 @@ export class ApprovalRequestStore {
|
|||||||
id: `apr-${randomUUID().slice(0, 8)}`,
|
id: `apr-${randomUUID().slice(0, 8)}`,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
requester: input.requester,
|
requester: input.requester,
|
||||||
targetAction: input.targetAction,
|
targetAction: {
|
||||||
|
...input.targetAction,
|
||||||
|
category: normalizeApprovalRequestActionCategory(input.targetAction.category),
|
||||||
|
},
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
requestedAt: now,
|
requestedAt: now,
|
||||||
|
|||||||
@@ -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, 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 { 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, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, 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, 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 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, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, 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";
|
||||||
|
|||||||
@@ -3731,6 +3731,16 @@ export interface PermanentAgentGatingContext {
|
|||||||
presetId: string;
|
presetId: string;
|
||||||
rules: Partial<Record<PermanentAgentSensitiveActionCategory, AgentPermissionPolicyDisposition>>;
|
rules: Partial<Record<PermanentAgentSensitiveActionCategory, AgentPermissionPolicyDisposition>>;
|
||||||
};
|
};
|
||||||
|
requester?: ApprovalRequestActorSnapshot;
|
||||||
|
taskId?: string;
|
||||||
|
runId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
createApprovalRequest?: (input: {
|
||||||
|
category: AgentPermissionPolicyActionCategory;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}) => Promise<ApprovalRequest | null>;
|
||||||
|
findPendingApprovalRequest?: (dedupeKey: string) => Promise<ApprovalRequest | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Built-in permission policy preset identifiers for permanent agents. */
|
/** Built-in permission policy preset identifiers for permanent agents. */
|
||||||
@@ -3775,6 +3785,44 @@ export interface ApprovalRequestActorSnapshot {
|
|||||||
actorName: string;
|
actorName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Legacy action-category aliases accepted for backward compatibility. */
|
||||||
|
export const LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES = [
|
||||||
|
"file_write",
|
||||||
|
"file_delete",
|
||||||
|
"command_execute",
|
||||||
|
"network_access",
|
||||||
|
"task_mutation",
|
||||||
|
"agent_mutation",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LegacyAgentPermissionPolicyActionCategory =
|
||||||
|
(typeof LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES)[number];
|
||||||
|
|
||||||
|
/** Canonical + compatibility action-category input accepted at boundaries. */
|
||||||
|
export type ApprovalRequestActionCategoryInput =
|
||||||
|
| AgentPermissionPolicyActionCategory
|
||||||
|
| LegacyAgentPermissionPolicyActionCategory;
|
||||||
|
|
||||||
|
/** Normalize legacy action-category aliases to canonical v1 categories. */
|
||||||
|
export function normalizeApprovalRequestActionCategory(
|
||||||
|
category: ApprovalRequestActionCategoryInput,
|
||||||
|
): AgentPermissionPolicyActionCategory {
|
||||||
|
switch (category) {
|
||||||
|
case "file_write":
|
||||||
|
case "file_delete":
|
||||||
|
return "file_write_delete";
|
||||||
|
case "command_execute":
|
||||||
|
return "command_execution";
|
||||||
|
case "network_access":
|
||||||
|
return "network_api";
|
||||||
|
case "task_mutation":
|
||||||
|
case "agent_mutation":
|
||||||
|
return "task_agent_mutation";
|
||||||
|
default:
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Action payload gated by an approval request. */
|
/** Action payload gated by an approval request. */
|
||||||
export interface ApprovalRequestTargetAction {
|
export interface ApprovalRequestTargetAction {
|
||||||
category: AgentPermissionPolicyActionCategory;
|
category: AgentPermissionPolicyActionCategory;
|
||||||
@@ -3813,7 +3861,9 @@ export interface ApprovalRequest {
|
|||||||
/** Create input for a new pending approval request. */
|
/** Create input for a new pending approval request. */
|
||||||
export interface ApprovalRequestCreateInput {
|
export interface ApprovalRequestCreateInput {
|
||||||
requester: ApprovalRequestActorSnapshot;
|
requester: ApprovalRequestActorSnapshot;
|
||||||
targetAction: ApprovalRequestTargetAction;
|
targetAction: Omit<ApprovalRequestTargetAction, "category"> & {
|
||||||
|
category: ApprovalRequestActionCategoryInput;
|
||||||
|
};
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14455,7 +14455,7 @@ describe("allowParallelExecution heartbeat gate", () => {
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any });
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any });
|
||||||
|
|
||||||
const context = (executor as any).buildPermanentAgentGatingContext({
|
const context = (executor as any).buildPermanentAgentGatingContext("FN-GATE-2", {
|
||||||
id: "agent-perm-1",
|
id: "agent-perm-1",
|
||||||
name: "Perm Agent",
|
name: "Perm Agent",
|
||||||
type: "normal",
|
type: "normal",
|
||||||
@@ -14472,6 +14472,9 @@ describe("allowParallelExecution heartbeat gate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(context?.permissionPolicy?.presetId).toBe("approval-required");
|
expect(context?.permissionPolicy?.presetId).toBe("approval-required");
|
||||||
|
expect(context?.taskId).toBe("FN-GATE-2");
|
||||||
|
expect(typeof context?.createApprovalRequest).toBe("function");
|
||||||
|
expect(typeof context?.findPendingApprovalRequest).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits permanent-agent gating context when no agent is assigned", async () => {
|
it("omits permanent-agent gating context when no agent is assigned", async () => {
|
||||||
|
|||||||
@@ -267,6 +267,27 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
|
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits permanent-agent gating context for ephemeral heartbeat agents", async () => {
|
||||||
|
const store = createStoreWithAgentForExec({
|
||||||
|
taskId: "FN-001",
|
||||||
|
metadata: { agentKind: "task-worker" },
|
||||||
|
name: "executor-ephemeral",
|
||||||
|
reportsTo: undefined,
|
||||||
|
});
|
||||||
|
const mockSession = createMockAgentSession();
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||||
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||||
|
|
||||||
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||||
|
|
||||||
|
const args = mockedCreateFnAgent.mock.calls[0]?.[0] as {
|
||||||
|
permanentAgentGating?: unknown;
|
||||||
|
actionGateContext?: unknown;
|
||||||
|
};
|
||||||
|
expect(args.permanentAgentGating).toBeUndefined();
|
||||||
|
expect(args.actionGateContext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
describe("dependency validation", () => {
|
describe("dependency validation", () => {
|
||||||
it("throws when taskStore is not configured", async () => {
|
it("throws when taskStore is not configured", async () => {
|
||||||
const store = createStoreWithAgentForExec();
|
const store = createStoreWithAgentForExec();
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ describe("permanent-agent-gating", () => {
|
|||||||
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses only canonical action-category names", () => {
|
||||||
|
const categories = [
|
||||||
|
classifyPermanentAgentToolCall("bash", { command: "git commit -m x" }).category,
|
||||||
|
classifyPermanentAgentToolCall("write").category,
|
||||||
|
classifyPermanentAgentToolCall("bash", { command: "echo hi" }).category,
|
||||||
|
classifyPermanentAgentToolCall("fn_research_run").category,
|
||||||
|
classifyPermanentAgentToolCall("fn_task_create").category,
|
||||||
|
classifyPermanentAgentToolCall("read").category,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(new Set(categories)).toEqual(
|
||||||
|
new Set(["git_write", "file_write_delete", "command_execution", "network_api", "task_agent_mutation", "none"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses unknown-tool fallback to approval-required", () => {
|
it("uses unknown-tool fallback to approval-required", () => {
|
||||||
const decision = resolvePermanentAgentToolDecision({
|
const decision = resolvePermanentAgentToolDecision({
|
||||||
toolName: "plugin_custom_tool",
|
toolName: "plugin_custom_tool",
|
||||||
|
|||||||
@@ -408,8 +408,12 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
|||||||
|
|
||||||
it("requires approval for unknown tools and skips underlying tool", async () => {
|
it("requires approval for unknown tools and skips underlying tool", async () => {
|
||||||
const tool = { name: "plugin_custom", label: "Plugin", description: "", parameters: {}, execute: vi.fn() };
|
const tool = { name: "plugin_custom", label: "Plugin", description: "", parameters: {}, execute: vi.fn() };
|
||||||
|
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-1" });
|
||||||
|
const findPendingApprovalRequest = vi.fn().mockResolvedValue(null);
|
||||||
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
||||||
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||||
|
requester: { actorId: "agent-1", actorType: "agent", actorName: "Perm" },
|
||||||
|
taskId: "FN-1",
|
||||||
permissionPolicy: {
|
permissionPolicy: {
|
||||||
presetId: "unrestricted",
|
presetId: "unrestricted",
|
||||||
rules: {
|
rules: {
|
||||||
@@ -420,6 +424,8 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
|||||||
task_agent_mutation: "allow",
|
task_agent_mutation: "allow",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
createApprovalRequest,
|
||||||
|
findPendingApprovalRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await (wrapped[0] as any).execute("t1", { value: 1 });
|
const result = await (wrapped[0] as any).execute("t1", { value: 1 });
|
||||||
@@ -429,7 +435,86 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
|||||||
category: "none",
|
category: "none",
|
||||||
toolName: "plugin_custom",
|
toolName: "plugin_custom",
|
||||||
requiresApproval: true,
|
requiresApproval: true,
|
||||||
|
approvalRequestId: "apr-1",
|
||||||
}));
|
}));
|
||||||
|
expect(findPendingApprovalRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createApprovalRequest).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
category: "command_execution",
|
||||||
|
toolName: "plugin_custom",
|
||||||
|
}));
|
||||||
|
expect(tool.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires approval for mutating fn_* tools and never executes mutation", async () => {
|
||||||
|
const tool = { name: "fn_task_create", label: "Task Create", description: "", parameters: {}, execute: vi.fn() };
|
||||||
|
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-fn-1" });
|
||||||
|
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
||||||
|
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||||
|
requester: { actorId: "agent-1", actorType: "agent", actorName: "Perm" },
|
||||||
|
taskId: "FN-1",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createApprovalRequest,
|
||||||
|
findPendingApprovalRequest: vi.fn().mockResolvedValue(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await (wrapped[0] as any).execute("t1", { description: "create" });
|
||||||
|
expect((result as any).isError).toBe(true);
|
||||||
|
expect((result as any).details).toEqual(expect.objectContaining({
|
||||||
|
disposition: "require-approval",
|
||||||
|
category: "task_agent_mutation",
|
||||||
|
toolName: "fn_task_create",
|
||||||
|
approvalRequestId: "apr-fn-1",
|
||||||
|
}));
|
||||||
|
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tool.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps read-only tools allowed without approval-request creation", async () => {
|
||||||
|
const tool = { name: "read", label: "Read", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
|
||||||
|
const createApprovalRequest = vi.fn();
|
||||||
|
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
||||||
|
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createApprovalRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||||
|
expect(tool.execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create approval requests for policy-block outcomes", async () => {
|
||||||
|
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
|
||||||
|
const createApprovalRequest = vi.fn();
|
||||||
|
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
||||||
|
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||||
|
permissionPolicy: {
|
||||||
|
presetId: "locked-down",
|
||||||
|
rules: { file_write_delete: "block" },
|
||||||
|
},
|
||||||
|
createApprovalRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||||
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
expect(tool.execute).not.toHaveBeenCalled();
|
expect(tool.execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -624,13 +624,37 @@ export class HeartbeatMonitor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPermanentAgentGatingContext(agent: Agent): { permissionPolicy: ReturnType<typeof resolveEffectiveAgentPermissionPolicy> } | undefined {
|
private buildPermanentAgentGatingContext(agent: Agent, taskId?: string, runId?: string): import("@fusion/core").PermanentAgentGatingContext | undefined {
|
||||||
if (isEphemeralAgent(agent)) {
|
if (isEphemeralAgent(agent)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
|
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
|
||||||
|
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||||
|
taskId,
|
||||||
|
runId,
|
||||||
|
createApprovalRequest: async ({ category, toolName, args }) => this.getApprovalRequestStore().create({
|
||||||
|
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||||
|
taskId,
|
||||||
|
runId,
|
||||||
|
targetAction: {
|
||||||
|
category,
|
||||||
|
action: toolName,
|
||||||
|
summary: `Permanent-agent gated action for ${toolName}`,
|
||||||
|
resourceType: "tool",
|
||||||
|
resourceId: toolName,
|
||||||
|
context: {
|
||||||
|
toolName,
|
||||||
|
toolArgs: args,
|
||||||
|
source: "permanent-agent-gating",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findPendingApprovalRequest: async (dedupeKey) => {
|
||||||
|
const pending = this.getApprovalRequestStore().list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||||
|
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1845,7 +1869,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),
|
permanentAgentGating: this.buildPermanentAgentGatingContext(agent, taskId, run.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track for monitoring
|
// Track for monitoring
|
||||||
|
|||||||
@@ -757,13 +757,45 @@ export class TaskExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPermanentAgentGatingContext(agent: Agent | null | undefined): { permissionPolicy: ReturnType<typeof resolveEffectiveAgentPermissionPolicy> } | undefined {
|
private buildPermanentAgentGatingContext(taskId: string | undefined, agent: Agent | null | undefined): import("@fusion/core").PermanentAgentGatingContext | undefined {
|
||||||
if (!agent || isEphemeralAgent(agent)) {
|
if (!agent || isEphemeralAgent(agent)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
|
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
|
||||||
|
requester: {
|
||||||
|
actorId: agent.id,
|
||||||
|
actorType: "agent",
|
||||||
|
actorName: agent.name,
|
||||||
|
},
|
||||||
|
taskId,
|
||||||
|
runId: this.currentRunContext?.runId,
|
||||||
|
createApprovalRequest: async ({ category, toolName, args }) => this.approvalRequestStore.create({
|
||||||
|
requester: {
|
||||||
|
actorId: agent.id,
|
||||||
|
actorType: "agent",
|
||||||
|
actorName: agent.name,
|
||||||
|
},
|
||||||
|
taskId,
|
||||||
|
runId: this.currentRunContext?.runId,
|
||||||
|
targetAction: {
|
||||||
|
category,
|
||||||
|
action: toolName,
|
||||||
|
summary: `Permanent-agent gated action for ${toolName}`,
|
||||||
|
resourceType: "tool",
|
||||||
|
resourceId: toolName,
|
||||||
|
context: {
|
||||||
|
toolName,
|
||||||
|
toolArgs: args,
|
||||||
|
source: "permanent-agent-gating",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findPendingApprovalRequest: async (dedupeKey) => {
|
||||||
|
const pending = this.approvalRequestStore.list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||||
|
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2459,7 +2491,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),
|
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, 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
|
||||||
@@ -3017,7 +3049,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),
|
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, assignedAgent),
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
taskTitle: detail.title,
|
taskTitle: detail.title,
|
||||||
onFallbackModelUsed: createFallbackModelObserver({
|
onFallbackModelUsed: createFallbackModelObserver({
|
||||||
@@ -3335,7 +3367,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),
|
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, 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) => {
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ 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 type {
|
||||||
|
AgentPermissionPolicyActionCategory,
|
||||||
|
PermanentAgentActionCategory,
|
||||||
|
PermanentAgentGatingContext,
|
||||||
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
resolveSessionSkills,
|
resolveSessionSkills,
|
||||||
createSkillsOverrideFromSelection,
|
createSkillsOverrideFromSelection,
|
||||||
@@ -1021,6 +1025,29 @@ function boundaryRejection(message: string, details?: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalRequestCategory(
|
||||||
|
category: PermanentAgentActionCategory,
|
||||||
|
): AgentPermissionPolicyActionCategory {
|
||||||
|
if (category === "none") {
|
||||||
|
return "command_execution";
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPermanentAgentApprovalDedupeKey(input: {
|
||||||
|
requesterActorId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
toolName: string;
|
||||||
|
category: PermanentAgentActionCategory;
|
||||||
|
}): string {
|
||||||
|
return [
|
||||||
|
input.requesterActorId ?? "",
|
||||||
|
input.taskId ?? "",
|
||||||
|
input.toolName,
|
||||||
|
input.category,
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
export function wrapToolsWithBoundary(
|
export function wrapToolsWithBoundary(
|
||||||
tools: ToolDefinition[],
|
tools: ToolDefinition[],
|
||||||
worktreePath: string | null,
|
worktreePath: string | null,
|
||||||
@@ -1108,6 +1135,29 @@ export function wrapToolsWithPermanentAgentGating(
|
|||||||
...(decision.disposition === "require-approval" ? { requiresApproval: true } : {}),
|
...(decision.disposition === "require-approval" ? { requiresApproval: true } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (decision.disposition === "require-approval") {
|
||||||
|
const dedupeKey = buildPermanentAgentApprovalDedupeKey({
|
||||||
|
requesterActorId: gating.requester?.actorId,
|
||||||
|
taskId: gating.taskId,
|
||||||
|
toolName: decision.toolName,
|
||||||
|
category: decision.category,
|
||||||
|
});
|
||||||
|
details.approvalDedupeKey = dedupeKey;
|
||||||
|
|
||||||
|
let approvalRequest = await gating.findPendingApprovalRequest?.(dedupeKey);
|
||||||
|
if (!approvalRequest && gating.createApprovalRequest) {
|
||||||
|
approvalRequest = await gating.createApprovalRequest({
|
||||||
|
category: normalizeApprovalRequestCategory(decision.category),
|
||||||
|
toolName: decision.toolName,
|
||||||
|
args: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalRequest?.id) {
|
||||||
|
details.approvalRequestId = approvalRequest.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reason = decision.disposition === "block"
|
const reason = decision.disposition === "block"
|
||||||
? `Action blocked by permanent-agent policy (${decision.category}) for tool ${decision.toolName}`
|
? `Action blocked by permanent-agent policy (${decision.category}) for tool ${decision.toolName}`
|
||||||
: `Action requires approval (${decision.category}) before tool ${decision.toolName} can run`;
|
: `Action requires approval (${decision.category}) before tool ${decision.toolName} can run`;
|
||||||
|
|||||||
Reference in New Issue
Block a user