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:
@@ -14455,7 +14455,7 @@ describe("allowParallelExecution heartbeat gate", () => {
|
||||
const store = createMockStore();
|
||||
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",
|
||||
name: "Perm Agent",
|
||||
type: "normal",
|
||||
@@ -14472,6 +14472,9 @@ describe("allowParallelExecution heartbeat gate", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -267,6 +267,27 @@ describe("executeHeartbeat", () => {
|
||||
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", () => {
|
||||
it("throws when taskStore is not configured", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
|
||||
@@ -26,6 +26,21 @@ describe("permanent-agent-gating", () => {
|
||||
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", () => {
|
||||
const decision = resolvePermanentAgentToolDecision({
|
||||
toolName: "plugin_custom_tool",
|
||||
|
||||
@@ -408,8 +408,12 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
||||
|
||||
it("requires approval for unknown tools and skips underlying tool", async () => {
|
||||
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 wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Perm" },
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: {
|
||||
presetId: "unrestricted",
|
||||
rules: {
|
||||
@@ -420,6 +424,8 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
||||
task_agent_mutation: "allow",
|
||||
},
|
||||
},
|
||||
createApprovalRequest,
|
||||
findPendingApprovalRequest,
|
||||
});
|
||||
|
||||
const result = await (wrapped[0] as any).execute("t1", { value: 1 });
|
||||
@@ -429,7 +435,86 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
||||
category: "none",
|
||||
toolName: "plugin_custom",
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
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)
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
actionGateContext: this.buildActionGateContext(agent, taskId, run.id),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(agent),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(agent, taskId, run.id),
|
||||
});
|
||||
|
||||
// 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)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
|
||||
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
|
||||
skillSelection: skillContext.skillSelectionContext,
|
||||
// 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
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(assignedAgent),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, assignedAgent),
|
||||
taskId: task.id,
|
||||
taskTitle: detail.title,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
@@ -3335,7 +3367,7 @@ export class TaskExecutor {
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(assignedAgent),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, assignedAgent),
|
||||
});
|
||||
if (retrySessionFile) {
|
||||
this.store.updateTask(task.id, { sessionFile: retrySessionFile }).catch((err: unknown) => {
|
||||
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
type ToolDefinition,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
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 {
|
||||
resolveSessionSkills,
|
||||
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(
|
||||
tools: ToolDefinition[],
|
||||
worktreePath: string | null,
|
||||
@@ -1108,6 +1135,29 @@ export function wrapToolsWithPermanentAgentGating(
|
||||
...(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"
|
||||
? `Action blocked by permanent-agent policy (${decision.category}) for tool ${decision.toolName}`
|
||||
: `Action requires approval (${decision.category}) before tool ${decision.toolName} can run`;
|
||||
|
||||
Reference in New Issue
Block a user