feat(FN-4488): complete Step 2 — engine project-default wiring

Fusion-Task-Id: FN-4488
Fusion-Task-Lineage: 3fc1ac43-490f-43f2-a27e-4fdceb64d9c4
This commit is contained in:
Fusion
2026-05-14 12:37:38 -07:00
committed by gsxdsm
parent 914b522087
commit acd24aa794
3 changed files with 66 additions and 16 deletions

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, resolveEffectiveAgentPermissionPolicy, type AgentPermissionPolicyRules } from "@fusion/core";
import { evaluateAgentActionGate } from "../agent-action-gate.js";
describe("agent action gate project-default resolution", () => {
it("applies project default rules when agent policy is undefined", () => {
const requireApprovalRules = AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => {
acc[category] = "require-approval";
return acc;
}, {} as Partial<AgentPermissionPolicyRules>);
const permissionPolicy = resolveEffectiveAgentPermissionPolicy(undefined, {
rules: requireApprovalRules,
});
const decisions = [
evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "git commit -m x" }, permissionPolicy }),
evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "x.ts", content: "x" }, permissionPolicy }),
evaluateAgentActionGate({ agentId: "a1", toolName: "bash", args: { command: "pnpm test" }, permissionPolicy }),
evaluateAgentActionGate({ agentId: "a1", toolName: "fn_web_fetch", args: { url: "https://example.com" }, permissionPolicy }),
evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: { task_id: "FN-1" }, permissionPolicy }),
];
for (const decision of decisions) {
if (decision.category === "exempt") {
continue;
}
expect(decision.disposition).toBe("require-approval");
}
});
it("keeps per-agent custom rule over project default", () => {
const permissionPolicy = resolveEffectiveAgentPermissionPolicy(
{
presetId: "custom",
rules: { command_execution: "allow" },
},
{ rules: { command_execution: "require-approval" } },
);
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "bash",
args: { command: "pnpm test" },
permissionPolicy,
});
expect(decision.disposition).toBe("allow");
});
});

View File

@@ -892,11 +892,11 @@ export class HeartbeatMonitor {
return this.approvalRequestStore;
}
private buildActionGateContext(agent: Agent, taskId?: string, runId?: string): AgentActionGateContext | undefined {
private buildActionGateContext(agent: Agent, taskId?: string, runId?: string, projectDefaultPolicy?: { rules?: import("@fusion/core").AgentPermissionPolicy["rules"] }): AgentActionGateContext | undefined {
if (isEphemeralAgent(agent)) {
return undefined;
}
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy);
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy);
return {
agentId: agent.id,
agentName: agent.name,
@@ -945,13 +945,13 @@ export class HeartbeatMonitor {
};
}
private buildPermanentAgentGatingContext(agent: Agent, taskId?: string, runId?: string): import("@fusion/core").PermanentAgentGatingContext | undefined {
private buildPermanentAgentGatingContext(agent: Agent, taskId?: string, runId?: string, projectDefaultPolicy?: { rules?: import("@fusion/core").AgentPermissionPolicy["rules"] }): import("@fusion/core").PermanentAgentGatingContext | undefined {
if (isEphemeralAgent(agent)) {
return undefined;
}
return {
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy),
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
taskId,
runId,
@@ -2330,8 +2330,8 @@ 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, taskId, run.id),
actionGateContext: this.buildActionGateContext(agent, taskId, run.id, heartbeatModelSettings.defaultAgentPermissionPolicy),
permanentAgentGating: this.buildPermanentAgentGatingContext(agent, taskId, run.id, heartbeatModelSettings.defaultAgentPermissionPolicy),
});
// Track for monitoring

View File

@@ -974,11 +974,11 @@ export class TaskExecutor {
return this._approvalRequestStore;
}
private buildActionGateContext(taskId: string | undefined, agent: Agent | null | undefined): AgentActionGateContext | undefined {
private buildActionGateContext(taskId: string | undefined, agent: Agent | null | undefined, projectDefaultPolicy?: { rules?: import("@fusion/core").AgentPermissionPolicy["rules"] }): AgentActionGateContext | undefined {
if (!agent || isEphemeralAgent(agent)) {
return undefined;
}
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy);
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy);
return {
agentId: agent.id,
agentName: agent.name,
@@ -1040,13 +1040,13 @@ export class TaskExecutor {
};
}
private buildPermanentAgentGatingContext(taskId: string | undefined, agent: Agent | null | undefined): import("@fusion/core").PermanentAgentGatingContext | undefined {
private buildPermanentAgentGatingContext(taskId: string | undefined, agent: Agent | null | undefined, projectDefaultPolicy?: { rules?: import("@fusion/core").AgentPermissionPolicy["rules"] }): import("@fusion/core").PermanentAgentGatingContext | undefined {
if (!agent || isEphemeralAgent(agent)) {
return undefined;
}
return {
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy),
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy),
requester: {
actorId: agent.id,
actorType: "agent",
@@ -2809,8 +2809,8 @@ export class TaskExecutor {
pluginRunner: this.options.pluginRunner,
runtimeHint: stepSessionRuntimeHint,
assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
actionGateContext: this.buildActionGateContext(task.id, stepSessionAgent),
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, stepSessionAgent),
actionGateContext: this.buildActionGateContext(task.id, stepSessionAgent, settings.defaultAgentPermissionPolicy),
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, stepSessionAgent, settings.defaultAgentPermissionPolicy),
// Pass skill selection context from the main executor session
skillSelection: skillContext.skillSelectionContext,
// Pass agentStore and messageStore for delegation and messaging tools
@@ -3416,8 +3416,8 @@ export class TaskExecutor {
taskEnv,
// 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(task.id, assignedAgent),
actionGateContext: this.buildActionGateContext(task.id, assignedAgent, settings.defaultAgentPermissionPolicy),
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, assignedAgent, settings.defaultAgentPermissionPolicy),
taskId: task.id,
taskTitle: detail.title,
onFallbackModelUsed: createFallbackModelObserver({
@@ -3770,8 +3770,8 @@ export class TaskExecutor {
taskEnv,
// 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(task.id, assignedAgent),
actionGateContext: this.buildActionGateContext(task.id, assignedAgent, settings.defaultAgentPermissionPolicy),
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, assignedAgent, settings.defaultAgentPermissionPolicy),
});
if (retrySessionFile) {
this.store.updateTask(task.id, { sessionFile: retrySessionFile }).catch((err: unknown) => {