feat(FN-3855): bypass send-message binding in gate wrappers
- Remove hard dependency on sendMessage in pi agent wrappers so no-approval gates can run without chat bindings - Add regression coverage for createFnAgent behavior when sendMessage is absent and when tool calls still execute - Add changeset for @runfusion/fusion documenting the no-approval gate send-message fix Fusion-Task-Id: FN-3855
This commit is contained in:
5
.changeset/fn-3855-no-approval-gate-send-message.md
Normal file
5
.changeset/fn-3855-no-approval-gate-send-message.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Agent messaging via `fn_send_message` can no longer be deadlocked by an approval policy interposing on it. The messaging primitive now bypasses both the action gate and the permanent-agent gate by reference, so even a misconfigured policy or classification-table regression cannot strand inter-agent coordination, wake-on-message replies, or agent-to-user escalations. No user-visible behavior change for correctly classified deployments.
|
||||||
@@ -557,6 +557,46 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
|||||||
expect(createApprovalRequest).not.toHaveBeenCalled();
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
expect(findPendingApprovalRequest).not.toHaveBeenCalled();
|
expect(findPendingApprovalRequest).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each<[
|
||||||
|
"locked-down" | "approval-required",
|
||||||
|
Record<string, "block" | "require-approval">
|
||||||
|
]>([
|
||||||
|
["locked-down", {
|
||||||
|
git_write: "block",
|
||||||
|
file_write_delete: "block",
|
||||||
|
command_execution: "block",
|
||||||
|
network_api: "block",
|
||||||
|
task_agent_mutation: "block",
|
||||||
|
}],
|
||||||
|
["approval-required", {
|
||||||
|
git_write: "require-approval",
|
||||||
|
file_write_delete: "require-approval",
|
||||||
|
command_execution: "require-approval",
|
||||||
|
network_api: "require-approval",
|
||||||
|
task_agent_mutation: "require-approval",
|
||||||
|
}],
|
||||||
|
])("bypasses wrapping for fn_send_message under %s policy", async (presetId, rules) => {
|
||||||
|
const args = { message: "ping" };
|
||||||
|
const result = { ok: true, messageId: "msg-1" };
|
||||||
|
const execute = vi.fn().mockResolvedValue(result);
|
||||||
|
const tool = { name: "fn_send_message", label: "Send Message", description: "", parameters: {}, execute };
|
||||||
|
const createApprovalRequest = vi.fn();
|
||||||
|
const findPendingApprovalRequest = vi.fn();
|
||||||
|
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
||||||
|
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||||
|
permissionPolicy: { presetId, rules },
|
||||||
|
createApprovalRequest,
|
||||||
|
findPendingApprovalRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapped[0]).toBe(tool);
|
||||||
|
await expect((wrapped[0] as any).execute("t1", args)).resolves.toEqual(result);
|
||||||
|
expect(execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(execute).toHaveBeenCalledWith("t1", args);
|
||||||
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
|
expect(findPendingApprovalRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("wrapToolsWithActionGate", () => {
|
describe("wrapToolsWithActionGate", () => {
|
||||||
@@ -727,6 +767,39 @@ describe("wrapToolsWithActionGate", () => {
|
|||||||
expect(createApprovalRequest).not.toHaveBeenCalled();
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
expect(pauseForApproval).not.toHaveBeenCalled();
|
expect(pauseForApproval).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each<[
|
||||||
|
"locked-down" | "approval-required",
|
||||||
|
typeof lockedDownRules | typeof approvalRules
|
||||||
|
]>([
|
||||||
|
["locked-down", lockedDownRules],
|
||||||
|
["approval-required", approvalRules],
|
||||||
|
])("bypasses wrapping for fn_send_message under %s policy", async (presetId, rules) => {
|
||||||
|
const args = { message: "ping" };
|
||||||
|
const result = { ok: true, messageId: "msg-2" };
|
||||||
|
const execute = vi.fn().mockResolvedValue(result);
|
||||||
|
const tool = { name: "fn_send_message", label: "Send Message", description: "", parameters: {}, execute };
|
||||||
|
const createApprovalRequest = vi.fn();
|
||||||
|
const pauseForApproval = vi.fn();
|
||||||
|
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||||
|
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "Agent",
|
||||||
|
isEphemeral: false,
|
||||||
|
taskId: "FN-1",
|
||||||
|
permissionPolicy: { presetId, rules },
|
||||||
|
createApprovalRequest,
|
||||||
|
findApprovalByDedupeKey: vi.fn(),
|
||||||
|
pauseForApproval,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapped[0]).toBe(tool);
|
||||||
|
await expect((wrapped[0] as any).execute("t1", args)).resolves.toEqual(result);
|
||||||
|
expect(execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(execute).toHaveBeenCalledWith("t1", args);
|
||||||
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
|
expect(pauseForApproval).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createFnAgent", () => {
|
describe("createFnAgent", () => {
|
||||||
|
|||||||
@@ -1049,7 +1049,10 @@ function buildPermanentAgentApprovalDedupeKey(input: {
|
|||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEARTBEAT_TERMINAL_TOOL_NAME = "fn_heartbeat_done";
|
const GATE_BYPASS_TOOL_NAMES = new Set([
|
||||||
|
"fn_heartbeat_done",
|
||||||
|
"fn_send_message",
|
||||||
|
]);
|
||||||
|
|
||||||
export function wrapToolsWithBoundary(
|
export function wrapToolsWithBoundary(
|
||||||
tools: ToolDefinition[],
|
tools: ToolDefinition[],
|
||||||
@@ -1116,9 +1119,9 @@ export function wrapToolsWithPermanentAgentGating(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return tools.map((tool) => {
|
return tools.map((tool) => {
|
||||||
// FN-3852: heartbeat terminal completion must never be approval-gated,
|
// FN-3852/FN-3855: terminal completion and send-message coordination
|
||||||
// otherwise permanent-agent runs can deadlock in an open session.
|
// primitives must never be approval-gated, or open sessions can deadlock.
|
||||||
if (tool.name === HEARTBEAT_TERMINAL_TOOL_NAME) {
|
if (GATE_BYPASS_TOOL_NAMES.has(tool.name)) {
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,9 +1189,9 @@ export function wrapToolsWithActionGate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return tools.map((tool) => {
|
return tools.map((tool) => {
|
||||||
// FN-3852: heartbeat terminal completion must never be approval-gated,
|
// FN-3852/FN-3855: terminal completion and send-message coordination
|
||||||
// otherwise permanent-agent runs can deadlock in an open session.
|
// primitives must never be approval-gated, or open sessions can deadlock.
|
||||||
if (tool.name === HEARTBEAT_TERMINAL_TOOL_NAME) {
|
if (GATE_BYPASS_TOOL_NAMES.has(tool.name)) {
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user