FN-7191: allow direct agent questions

Permanent agents can ask structured user questions without approval gating.

- Classify fn_ask_question as a read-only coordination tool for permanent-agent decisions.
- Exempt fn_ask_question from the action gate under locked-down policies.
- Document the user-question exemption and add release notes plus gate coverage.

Files changed:
 .changeset/FN-7191-ask-question-no-gate.md         |  7 +++
 docs/agents.md                                     |  2 +-
 .../engine/src/__tests__/agent-action-gate.test.ts | 14 ++++++
 .../src/__tests__/gating-classifications.test.ts   |  6 +++
 .../src/__tests__/permanent-agent-gating.test.ts   | 50 ++++++++++++++++++++++
 packages/engine/src/gating-classifications.ts      | 10 +++++
 6 files changed, 88 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7191

Fusion-Task-Lineage: 349084a8-9098-49f7-ab82-c5a775c0a24c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 07:53:44 -07:00
parent 4405dec310
commit 7a2137c250
6 changed files with 88 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Permanent agents can ask the user a question directly without an approval gate.
category: fix
dev: Classify fn_ask_question in COORDINATION_EXEMPT_TOOLS and READONLY_FN_TOOLS (gating-classifications.ts) so both the permanent-agent gate and action gate auto-allow it, mirroring fn_send_message.

View File

@@ -164,7 +164,7 @@ 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.
- Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, messaging, evaluations, identity reflection, memory bookkeeping, and read-only discovery) are exempt by design and always allowed so permanent-agent heartbeats can complete. `fn_task_create` is governed as `task_agent_mutation` in both action-gate and permanent-agent evaluation because it creates task rows; delegation/import tools remain governed in action-gate evaluation while the permanent-agent classifier still treats them as positively recognized `none` coordination primitives. Task field/status mutation via `fn_task_update` is also governed as `task_agent_mutation`. - Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, messaging, structured user questions via `fn_ask_question`, evaluations, identity reflection, memory bookkeeping, and read-only discovery) are exempt by design and always allowed so permanent-agent heartbeats can complete. `fn_task_create` is governed as `task_agent_mutation` in both action-gate and permanent-agent evaluation because it creates task rows; delegation/import tools remain governed in action-gate evaluation while the permanent-agent classifier still treats them as positively recognized `none` coordination primitives. Task field/status mutation via `fn_task_update` is also governed as `task_agent_mutation`.
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process. - Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths. - Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.

View File

@@ -20,6 +20,7 @@ const FN_3548_COORDINATION_TOOLS = [
"fn_list_agents", "fn_list_agents",
"fn_agent_show", "fn_agent_show",
"fn_agent_org_chart", "fn_agent_org_chart",
"fn_ask_question",
"fn_send_message", "fn_send_message",
"fn_read_messages", "fn_read_messages",
"fn_memory_search", "fn_memory_search",
@@ -119,6 +120,7 @@ describe("agent-action-gate", () => {
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_ask_question", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
}); });
@@ -241,6 +243,18 @@ describe("agent-action-gate", () => {
expect(decision.category).toBe("exempt"); expect(decision.category).toBe("exempt");
}); });
it("allows fn_ask_question as an action-gate coordination exemption under locked-down policy", () => {
expect(evaluateAgentActionGate({
agentId: "a1",
toolName: "fn_ask_question",
args: {},
permissionPolicy: lockedDownPolicy,
})).toMatchObject({
category: "exempt",
disposition: "allow",
});
});
it("keeps FN-3548 coordination tool list in exempt registry", () => { it("keeps FN-3548 coordination tool list in exempt registry", () => {
expect(getExemptToolNames()).toEqual(expect.arrayContaining([...FN_3548_COORDINATION_TOOLS])); expect(getExemptToolNames()).toEqual(expect.arrayContaining([...FN_3548_COORDINATION_TOOLS]));
}); });

View File

@@ -122,6 +122,7 @@ describe("gating-classifications parity", () => {
"fn_artifact_list", "fn_artifact_list",
"fn_artifact_register", "fn_artifact_register",
"fn_artifact_view", "fn_artifact_view",
"fn_ask_question",
"fn_goal_list", "fn_goal_list",
"fn_goal_show", "fn_goal_show",
"fn_heartbeat_done", "fn_heartbeat_done",
@@ -151,6 +152,11 @@ describe("gating-classifications parity", () => {
`); `);
}); });
it("classifies fn_ask_question in both gate source sets", () => {
expect(READONLY_FN_TOOLS.has("fn_ask_question")).toBe(true);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_ask_question")).toBe(true);
});
it("ensures coordination exempt tools are recognized and allowed in permanent gating", () => { it("ensures coordination exempt tools are recognized and allowed in permanent gating", () => {
for (const toolName of COORDINATION_EXEMPT_TOOLS) { for (const toolName of COORDINATION_EXEMPT_TOOLS) {
const classification = classifyPermanentAgentToolCall(toolName); const classification = classifyPermanentAgentToolCall(toolName);

View File

@@ -1,9 +1,43 @@
import type { AgentPermissionPolicy } from "@fusion/core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
classifyPermanentAgentToolCall, classifyPermanentAgentToolCall,
resolvePermanentAgentToolDecision, resolvePermanentAgentToolDecision,
} from "../permanent-agent-gating.js"; } from "../permanent-agent-gating.js";
const unrestrictedPolicy: AgentPermissionPolicy = {
presetId: "unrestricted",
rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
},
};
const approvalRequiredPolicy: AgentPermissionPolicy = {
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",
},
};
const blockedPolicy: AgentPermissionPolicy = {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
},
};
const FN_7111_GOVERNED_TOOLS = [ const FN_7111_GOVERNED_TOOLS = [
["fn_workflow_select", "task_agent_mutation"], ["fn_workflow_select", "task_agent_mutation"],
["fn_workflow_create", "task_agent_mutation"], ["fn_workflow_create", "task_agent_mutation"],
@@ -30,6 +64,7 @@ const FN_3548_COORDINATION_TOOLS = [
"fn_list_agents", "fn_list_agents",
"fn_agent_show", "fn_agent_show",
"fn_agent_org_chart", "fn_agent_org_chart",
"fn_ask_question",
"fn_send_message", "fn_send_message",
"fn_read_messages", "fn_read_messages",
"fn_post_room_message", "fn_post_room_message",
@@ -70,6 +105,7 @@ describe("permanent-agent-gating", () => {
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true }); expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true });
expect(classifyPermanentAgentToolCall("fn_ask_question")).toEqual({ category: "none", recognized: true });
expect(classifyPermanentAgentToolCall("fn_send_message")).toEqual({ category: "none", recognized: true }); expect(classifyPermanentAgentToolCall("fn_send_message")).toEqual({ category: "none", recognized: true });
expect(classifyPermanentAgentToolCall("fn_read_messages")).toEqual({ category: "none", recognized: true }); expect(classifyPermanentAgentToolCall("fn_read_messages")).toEqual({ category: "none", recognized: true });
}); });
@@ -147,6 +183,20 @@ describe("permanent-agent-gating", () => {
expect(decision.disposition).toBe("allow"); expect(decision.disposition).toBe("allow");
}); });
it.each([unrestrictedPolicy, approvalRequiredPolicy, blockedPolicy])(
"allows fn_ask_question under %s permanent-agent policy",
(permissionPolicy) => {
expect(resolvePermanentAgentToolDecision({
toolName: "fn_ask_question",
gating: { permissionPolicy },
})).toMatchObject({
category: "none",
disposition: "allow",
recognized: true,
});
},
);
it.each(FN_3548_COORDINATION_TOOLS)("classifies FN-3548 coordination tool %s as recognized none", (toolName) => { it.each(FN_3548_COORDINATION_TOOLS)("classifies FN-3548 coordination tool %s as recognized none", (toolName) => {
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true }); expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
}); });

View File

@@ -154,6 +154,11 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
"fn_task_done", "fn_task_done",
"fn_heartbeat_done", "fn_heartbeat_done",
"fn_memory_append", "fn_memory_append",
/**
* FNXC:ToolGovernance 2026-06-28-00:00:
* FN-7191 requires permanent agents to call fn_ask_question directly without an approval gate. The tool only posts a structured question to the user's inbox, so it has the same trust level as fn_send_message and must be positively recognized in both gate paths.
*/
"fn_ask_question",
"fn_send_message", "fn_send_message",
"fn_read_messages", "fn_read_messages",
"fn_post_room_message", "fn_post_room_message",
@@ -196,6 +201,11 @@ export const COORDINATION_EXEMPT_TOOLS = [
"fn_agent_show", "fn_agent_show",
"fn_agent_org_chart", "fn_agent_org_chart",
"fn_workflow_list", "fn_workflow_list",
/**
* FNXC:ToolGovernance 2026-06-28-00:00:
* FN-7191 requires fn_ask_question to bypass permanent-agent approval gates like other user-messaging coordination tools; membership here makes the action gate classify it as exempt/allow even under locked-down policies.
*/
"fn_ask_question",
"fn_send_message", "fn_send_message",
"fn_post_room_message", "fn_post_room_message",
"fn_memory_append", "fn_memory_append",