Files
fusion/packages/engine/src/permanent-agent-gating.ts
gsxdsm 0e90578122 FN-7737: Add file_scope agent permission category with default allow disposition
Introduces a new file_scope sensitive-action category to the agent permission policy model so file-scope-related FN tools are classified and gated distinctly from other categories, defaulting to allowed under the grant-all preset.

- Add file_scope to AGENT_PERMISSION_POLICY_ACTION_CATEGORIES in @fusion/core types
- Classify FILE_SCOPE_FN_TOOLS in both agent-action-gate and permanent-agent-gating with a uniform preset disposition (no review_gate_bypass-style override)
- Update AgentDetailView and AgentPermissionPolicyEditor to surface the new category in the dashboard UI
- Add/extend unit tests across core, engine, and dashboard packages covering the new category's resolution, gating, and UI rendering
- Update docs/agents.md and docs/settings-reference.md to document the new permission category
- Regenerate i18n locale strings (en/es/fr/ko/zh-CN/zh-TW) and resources.d.ts for the new category labels
- Add changeset for @runfusion/fusion (minor) describing the new File Scope permission category

Files changed:
 .../fn-7737-file-scope-permission-category.md      |   7 +
 docs/agents.md                                     |  10 +-
 docs/settings-reference.md                         |   8 +-
 .../agent-permission-policy-resolution.test.ts     |  13 +
 .../src/__tests__/agent-permission-policy.test.ts  |  45 +++
 packages/core/src/types.ts                         |   7 +
 .../dashboard/app/components/AgentDetailView.tsx   |   2 +
 .../app/components/AgentPermissionPolicyEditor.tsx |   8 +
 .../__tests__/AgentPermissionPolicyEditor.test.tsx |  37 +++
 .../engine/src/__tests__/agent-action-gate.test.ts |  46 +++
 .../src/__tests__/gating-classifications.test.ts   |  67 +++++
 .../src/__tests__/permanent-agent-gating.test.ts   |  41 +++
 packages/engine/src/agent-action-gate.ts           |   7 +
 packages/engine/src/gating-classifications.ts      |   6 +
 packages/engine/src/permanent-agent-gating.ts      |   6 +
 packages/i18n/locales/en/app.json                  |   4 +
 packages/i18n/locales/es/app.json                  |   4 +
 packages/i18n/locales/fr/app.json                  |   4 +
 packages/i18n/locales/ko/app.json                  |   4 +
 packages/i18n/locales/zh-CN/app.json               |   4 +
 packages/i18n/locales/zh-TW/app.json               |   4 +
 packages/i18n/src/resources.d.ts                   | 310 +++++++++++++++------
 22 files changed, 550 insertions(+), 94 deletions(-)

Fusion-Task-Id: FN-7737

Fusion-Task-Lineage: 7b161fb3-7dd9-4860-aa4d-0cb35f38ea5b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-09 19:58:07 -07:00

188 lines
6.6 KiB
TypeScript

import type {
AgentPermissionPolicyDisposition,
PermanentAgentActionCategory,
PermanentAgentGatingContext,
PermanentAgentSensitiveActionCategory,
} from "@fusion/core";
import {
COMMAND_EXECUTION_FN_TOOLS,
FILE_SCOPE_FN_TOOLS,
FILE_WRITE_BUILTIN_TOOLS,
FILE_WRITE_DELETE_FN_TOOLS,
NETWORK_API_TOOLS,
PERMANENT_AGENT_TASK_MUTATION_TOOLS,
READONLY_BUILTIN_TOOLS,
READONLY_FN_TOOLS,
REVIEW_GATE_BYPASS_FN_TOOLS,
isGitWriteCommand,
} from "./gating-classifications.js";
export interface PermanentAgentToolClassification {
category: PermanentAgentActionCategory;
/** True only when the tool is positively recognized and mapped by this module. */
recognized: boolean;
}
export interface PermanentAgentToolDecision extends PermanentAgentToolClassification {
toolName: string;
disposition: AgentPermissionPolicyDisposition;
}
const FILE_WRITE_TOOLS = FILE_WRITE_BUILTIN_TOOLS;
// FN-3724 / FN-3548: heartbeat-completion and internal coordination tools must remain
// category "none" so restrictive permanent-agent policies cannot deadlock heartbeats.
const TASK_AGENT_MUTATION_TOOLS = PERMANENT_AGENT_TASK_MUTATION_TOOLS;
const FILE_WRITE_DELETE_TOOLS = FILE_WRITE_DELETE_FN_TOOLS;
const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
// FNXC:ToolGovernance 2026-07-09-00:00: FN-7728 — mirror agent-action-gate.ts's review_gate_bypass classification here so the permanent-agent gate resolves fn_task_bypass_review identically (no two-path drift).
const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS;
// FNXC:ToolGovernance 2026-07-09-08:30: FN-7737 — mirror agent-action-gate.ts's file_scope classification here so the permanent-agent gate resolves fn_task_file_scope_add identically (no two-path drift).
const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS;
function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
}
function extractShellCommand(args: Record<string, unknown>): string {
const command = args.command;
return typeof command === "string" ? command.trim() : "";
}
const GATED_SUMMARY_COMMAND_MAX_LENGTH = 200;
function truncateForSummary(value: string, maxLength: number): string {
const singleLine = value.replace(/\s+/g, " ").trim();
if (singleLine.length <= maxLength) {
return singleLine;
}
return `${singleLine.slice(0, maxLength - 1)}\u2026`;
}
function renderCompactArgs(args: Record<string, unknown>): string {
const entries = Object.entries(args).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return "";
}
const rendered = entries
.slice(0, 4)
.map(([key, value]) => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
return `${key}: ${truncateForSummary(String(stringValue ?? ""), 60)}`;
})
.join(", ");
const suffix = entries.length > 4 ? ", \u2026" : "";
return `{${rendered}${suffix}}`;
}
/**
* FNXC:AgentGating 2026-07-05-00:00:
* FN-7609: operators approving a gated agent action need to see the real
* payload (shell command line, or tool arguments), not just a generic
* "Agent gated action for <tool>" placeholder. This pure helper builds a
* payload-bearing, human-readable summary shared by both permanent-agent
* gating context builders (executor.ts and agent-heartbeat.ts) so approval
* cards are actionable instead of blank.
*/
export function buildAgentGatedActionSummary(toolName: string, args: unknown): string {
const normalizedArgs = normalizeArgs(args);
if (toolName === "bash") {
const command = extractShellCommand(normalizedArgs);
if (command) {
return `Run: ${truncateForSummary(command, GATED_SUMMARY_COMMAND_MAX_LENGTH)}`;
}
}
const compactArgs = renderCompactArgs(normalizedArgs);
if (compactArgs) {
return `${toolName} ${compactArgs}`;
}
return `Agent gated action for ${toolName}`;
}
export function classifyPermanentAgentToolCall(
toolName: string,
args?: unknown,
): PermanentAgentToolClassification {
if (FILE_WRITE_TOOLS.has(toolName)) {
return { category: "file_write_delete", recognized: true };
}
if (toolName === "bash") {
const command = extractShellCommand(normalizeArgs(args));
return { category: isGitWriteCommand(command) ? "git_write" : "command_execution", recognized: true };
}
if (READONLY_BUILTIN_TOOLS.has(toolName)) {
return { category: "none", recognized: true };
}
if (REVIEW_GATE_BYPASS_TOOLS.has(toolName)) {
return { category: "review_gate_bypass", recognized: true };
}
if (FILE_SCOPE_TOOLS.has(toolName)) {
return { category: "file_scope", recognized: true };
}
if (TASK_AGENT_MUTATION_TOOLS.has(toolName)) {
return { category: "task_agent_mutation", recognized: true };
}
if (FILE_WRITE_DELETE_TOOLS.has(toolName)) {
return { category: "file_write_delete", recognized: true };
}
if (COMMAND_EXECUTION_TOOLS.has(toolName)) {
return { category: "command_execution", recognized: true };
}
if (NETWORK_API_TOOLS.has(toolName)) {
return { category: "network_api", recognized: true };
}
if (READONLY_FN_TOOLS.has(toolName) || /^fn_(?:list|show|get|read|browse)_/.test(toolName)) {
return { category: "none", recognized: true };
}
return { category: "none", recognized: false };
}
function resolvePolicyDisposition(
toolName: string,
category: PermanentAgentSensitiveActionCategory,
gating: PermanentAgentGatingContext | undefined,
): AgentPermissionPolicyDisposition {
/*
FNXC:ToolPermissions 2026-07-01-00:00:
Permanent-agent heartbeats use exact tool overrides before category rules so a policy can block `fn_task_create` while leaving sibling task-agent mutations allowed. Unknown tools still fail safe to approval and category `none` coordination tools remain non-configurable.
*/
return gating?.permissionPolicy?.toolRules?.[toolName]
?? gating?.permissionPolicy?.rules?.[category]
?? "require-approval";
}
export function resolvePermanentAgentToolDecision(input: {
toolName: string;
args?: unknown;
gating?: PermanentAgentGatingContext;
}): PermanentAgentToolDecision {
const classification = classifyPermanentAgentToolCall(input.toolName, input.args);
if (!input.gating?.permissionPolicy) {
return {
...classification,
toolName: input.toolName,
disposition: "allow",
};
}
if (classification.category === "none") {
return {
...classification,
toolName: input.toolName,
disposition: classification.recognized ? "allow" : "require-approval",
};
}
return {
...classification,
toolName: input.toolName,
disposition: resolvePolicyDisposition(input.toolName, classification.category, input.gating),
};
}