From 12ee9a92d2ec6b9e9ca208f5d39c80661c5c24fc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 09:45:30 -0700 Subject: [PATCH] FN-7361: add exact tool permission overrides Add exact tool-name permission overrides for permanent agent tool usage. - Add persisted toolRules policy support with normalization, project-default inheritance, and escalation comparison. - Enforce exact tool dispositions before category rules in engine gating and permanent-agent decisions. - Extend the permission editor, agent/settings routes, translations, docs, and tests for fine-grained tool controls. Files changed: .changeset/fn-7361-tool-permissions.md | 7 + docs/agents.md | 18 ++- docs/settings-reference.md | 12 +- .../agent-permission-policy-resolution.test.ts | 58 +++++++- .../src/__tests__/agent-permission-policy.test.ts | 17 +++ ...settings-schema-agent-permission-policy.test.ts | 6 +- packages/core/src/agent-permission-policy.ts | 87 ++++++++++-- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 19 ++- .../dashboard/app/components/AgentDetailView.tsx | 17 +-- .../app/components/AgentPermissionPolicyEditor.css | 59 +++++++- .../app/components/AgentPermissionPolicyEditor.tsx | 156 ++++++++++++++++++++- .../__tests__/AgentPermissionPolicyEditor.test.tsx | 92 +++++++++++- .../settings/sections/AgentPermissionsSection.tsx | 6 +- .../dashboard/src/__tests__/routes-agents.test.ts | 79 +++++++++++ .../src/__tests__/routes-settings.test.ts | 21 +++ .../src/routes/register-agent-core-routes.ts | 37 +++-- .../agent-action-gate-project-default.test.ts | 34 +++++ .../engine/src/__tests__/agent-action-gate.test.ts | 43 ++++++ .../src/__tests__/gating-classifications.test.ts | 55 ++++++++ packages/engine/src/agent-action-gate.ts | 17 ++- packages/engine/src/permanent-agent-gating.ts | 11 +- packages/i18n/locales/en/app.json | 15 +- packages/i18n/locales/es/app.json | 15 +- packages/i18n/locales/fr/app.json | 15 +- packages/i18n/locales/ko/app.json | 15 +- packages/i18n/locales/zh-CN/app.json | 15 +- packages/i18n/locales/zh-TW/app.json | 15 +- 28 files changed, 878 insertions(+), 65 deletions(-) Fusion-Task-Id: FN-7361 Fusion-Task-Lineage: 006e4311-91a5-4792-9a54-feaba892129a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7361-tool-permissions.md | 7 + docs/agents.md | 18 +- docs/settings-reference.md | 12 +- ...agent-permission-policy-resolution.test.ts | 58 ++++++- .../__tests__/agent-permission-policy.test.ts | 17 ++ ...ngs-schema-agent-permission-policy.test.ts | 6 +- packages/core/src/agent-permission-policy.ts | 87 +++++++++- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 19 ++- .../app/components/AgentDetailView.tsx | 17 +- .../AgentPermissionPolicyEditor.css | 59 ++++++- .../AgentPermissionPolicyEditor.tsx | 156 +++++++++++++++++- .../AgentPermissionPolicyEditor.test.tsx | 92 ++++++++++- .../sections/AgentPermissionsSection.tsx | 6 +- .../src/__tests__/routes-agents.test.ts | 79 +++++++++ .../src/__tests__/routes-settings.test.ts | 21 +++ .../src/routes/register-agent-core-routes.ts | 37 +++-- .../agent-action-gate-project-default.test.ts | 34 ++++ .../src/__tests__/agent-action-gate.test.ts | 43 +++++ .../__tests__/gating-classifications.test.ts | 55 ++++++ packages/engine/src/agent-action-gate.ts | 17 +- packages/engine/src/permanent-agent-gating.ts | 11 +- packages/i18n/locales/en/app.json | 15 +- packages/i18n/locales/es/app.json | 15 +- packages/i18n/locales/fr/app.json | 15 +- packages/i18n/locales/ko/app.json | 15 +- packages/i18n/locales/zh-CN/app.json | 15 +- packages/i18n/locales/zh-TW/app.json | 15 +- 28 files changed, 878 insertions(+), 65 deletions(-) create mode 100644 .changeset/fn-7361-tool-permissions.md diff --git a/.changeset/fn-7361-tool-permissions.md b/.changeset/fn-7361-tool-permissions.md new file mode 100644 index 0000000000..c6f296ff52 --- /dev/null +++ b/.changeset/fn-7361-tool-permissions.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add exact tool overrides for permanent-agent permission policies. +category: feature +dev: Adds per-tool permission policy overrides on top of category rules. diff --git a/docs/agents.md b/docs/agents.md index 2cc2d20c6e..d73d34f328 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -140,12 +140,14 @@ V1 runtime action categories: - `task_agent_mutation` - `none` (classifier-only read-only result; never stored as a policy rule key) -`permissionPolicy` uses only the five sensitive categories above (everything except `none`) and the FN-3545 disposition contract: +`permissionPolicy` uses the five sensitive categories above (everything except `none`) plus optional exact `toolRules`, with the FN-3545 disposition contract: - `allow` - `block` - `require-approval` +Exact tool overrides are stored as `toolRules: { [toolName]: disposition }` on either a per-agent `permissionPolicy` or the project `defaultAgentPermissionPolicy`. They apply before category rules, so a policy can block one governed tool such as `fn_task_create` while leaving the broader `task_agent_mutation` category set to `allow` for `fn_task_update` or workflow tools. + ### Runtime gate v1 mapping (per tool invocation, permanent agents only) The engine classifies tool calls by behavior (not namespace alone): @@ -155,7 +157,7 @@ The engine classifies tool calls by behavior (not namespace alone): - `git_write`: mutating git shell commands run via `bash` - `network_api`: external/network-facing tools (for example `fn_research_run`, `fn_research_cancel`, `fn_web_fetch`, `worktrunk_install`; `fn_research_retry` is permanent-agent network-classified and remains action-gate read-only/exception behavior) - `task_agent_mutation`: task/agent/workflow mutation tools (for example `fn_update_agent_config`, `fn_task_pause`, `fn_spawn_agent`, `fn_task_create`, `fn_task_update`, `fn_task_promote`, `fn_task_refine`, and workflow mutators such as `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_workflow_select`; action-gate-only task coordination tools like `fn_delegate_task`, `fn_task_import_github`, and `fn_task_import_github_issue` use this category in action-gate evaluation) -- Dashboard permission editors now show per-category example tools sourced from `AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES` in `@fusion/core`, plus a read-only exempt-tools panel for coordination/messaging bypass tools. +- Dashboard permission editors now show per-category example tools sourced from `AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES` in `@fusion/core`, exact-tool override controls, plus a read-only exempt-tools panel for coordination/messaging bypass tools. - `none`: positively recognized read-only tools (`read`, `grep`, `find`, `ls`, list/show/get-style `fn_*` tools, plus permanent-agent coordination helpers like `fn_delegate_task`, `fn_task_import_github`, and `fn_task_import_github_issue`). Artifact tools mirror `fn_task_document_write` in the shipped allow-lists: `fn_artifact_register`, `fn_artifact_list`, and `fn_artifact_view` are present in `READONLY_FN_TOOLS` and `COORDINATION_EXEMPT_TOOLS`, so registration is treated as coordination/registry publication instead of a broad mutation approval. `bash` git-write heuristic in v1: @@ -167,7 +169,7 @@ Unknown/unclassified tool fallback: - 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. -- 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`. +- 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. Exact `toolRules` do not make these heartbeat-critical tools configurable. `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. - Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths. @@ -1541,11 +1543,13 @@ Each category can be set to one disposition: - `block` Precedence: -1. Per-agent permission policy override (Agent Detail → Settings → Permissions) -2. Project default permission policy (`defaultAgentPermissionPolicy` in Project Settings → Agent Permissions) -3. Built-in fallback preset (`unrestricted` / allow-all) +1. Per-agent exact `toolRules` override (Agent Detail → Settings → Permissions) +2. Per-agent category rule +3. Project default exact `toolRules` override (`defaultAgentPermissionPolicy` in Project Settings → Agent Permissions) +4. Project default category rule +5. Built-in fallback preset (`unrestricted` / allow-all) -Per-agent rows can inherit project defaults category-by-category. +For example, `toolRules: { "fn_task_create": "block" }` with `rules.task_agent_mutation: "allow"` blocks new task creation while allowing other governed task-agent mutation tools. Per-agent category rows can inherit project defaults category-by-category; per-agent exact-tool rows override project exact-tool rows when present. ## Pi extension scope (`packages/cli/src/extension.ts`) diff --git a/docs/settings-reference.md b/docs/settings-reference.md index d499e1fb98..49e404b3ef 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1520,17 +1520,23 @@ Project-scoped default permission policy for permanent-agent action gates. "rules": { "git_write": "require-approval", "command_execution": "require-approval", - "network_api": "block" + "network_api": "block", + "task_agent_mutation": "allow" + }, + "toolRules": { + "fn_task_create": "block" } } } ``` - `rules` is a partial map of category → disposition. +- `toolRules` is an optional exact tool-name map (`fn_task_create`, `fn_web_fetch`, `bash`, etc.) → disposition. Exact tool rules apply before category rules, so the example blocks task creation while leaving other `task_agent_mutation` tools allowed. - Categories: `git_write`, `file_write_delete`, `command_execution`, `network_api`, `task_agent_mutation`. - Dispositions: `allow`, `require-approval`, `block`. -- Missing categories default to `allow` via the built-in `unrestricted` seed. -- Per-agent overrides take precedence over this project default. +- Missing categories default to `allow` via the built-in `unrestricted` seed; missing or empty `toolRules` preserve legacy category-only behavior. +- Runtime precedence is per-agent exact tool rule → per-agent category rule → project default exact tool rule → project default category rule → unrestricted fallback. +- Heartbeat-critical coordination/exempt tools remain non-configurable and allowed to prevent deadlocks. ## Model selection hierarchy diff --git a/packages/core/src/__tests__/agent-permission-policy-resolution.test.ts b/packages/core/src/__tests__/agent-permission-policy-resolution.test.ts index be5ebf888a..142f6752e7 100644 --- a/packages/core/src/__tests__/agent-permission-policy-resolution.test.ts +++ b/packages/core/src/__tests__/agent-permission-policy-resolution.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isPolicyBroaderThanDefault, normalizeAgentPermissionPolicy, resolveEffectiveAgentPermissionPolicy, resolveAgentPermissionPolicyPreset, @@ -29,11 +30,13 @@ describe("agent permission policy resolution", () => { it("uses project default when agent policy is undefined", () => { const policy = resolveEffectiveAgentPermissionPolicy(undefined, { rules: { network_api: "block" }, + toolRules: { fn_task_create: "block" }, }); expect(policy.presetId).toBe("custom"); expect(policy.rules.network_api).toBe("block"); expect(policy.rules.git_write).toBe("allow"); + expect(policy.toolRules).toEqual({ fn_task_create: "block" }); }); it("keeps per-agent custom rule over project default", () => { @@ -42,10 +45,22 @@ describe("agent permission policy resolution", () => { presetId: "custom", rules: { command_execution: "allow" }, }, - { rules: { command_execution: "require-approval" } }, + { rules: { command_execution: "require-approval" }, toolRules: { fn_task_create: "block" } }, ); expect(policy.rules.command_execution).toBe("allow"); + expect(policy.toolRules).toBeUndefined(); + }); + + it("keeps per-agent exact tool override over category defaults", () => { + const policy = resolveEffectiveAgentPermissionPolicy({ + presetId: "custom", + rules: { task_agent_mutation: "allow" }, + toolRules: { fn_task_create: "block" }, + }); + + expect(policy.rules.task_agent_mutation).toBe("allow"); + expect(policy.toolRules?.fn_task_create).toBe("block"); }); it("rejects invalid disposition values", () => { @@ -56,4 +71,45 @@ describe("agent permission policy resolution", () => { }), ).toThrow(/Invalid permission policy disposition/); }); + + it("rejects invalid exact tool dispositions and blank names", () => { + expect(() => + normalizeAgentPermissionPolicy({ + presetId: "custom", + toolRules: { fn_task_create: "nope" as never }, + }), + ).toThrow(/toolRules\.fn_task_create has invalid disposition/); + + expect(() => + normalizeAgentPermissionPolicy({ + presetId: "custom", + toolRules: { " ": "block" }, + }), + ).toThrow(/blank tool name/); + }); + + it("detects broader-than-default exact tool escalation", () => { + const defaultPolicy = resolveEffectiveAgentPermissionPolicy(undefined, { + rules: { task_agent_mutation: "allow" }, + toolRules: { fn_task_create: "block" }, + }); + const agentPolicy = resolveEffectiveAgentPermissionPolicy({ + presetId: "custom", + rules: { task_agent_mutation: "allow" }, + }); + + expect(isPolicyBroaderThanDefault(agentPolicy, defaultPolicy)).toBe(true); + }); + + it("does not flag exact tool overrides that are no broader than the project default", () => { + const defaultPolicy = resolveEffectiveAgentPermissionPolicy(undefined, { + toolRules: { fn_task_create: "require-approval" }, + }); + const agentPolicy = resolveEffectiveAgentPermissionPolicy({ + presetId: "custom", + rules: { task_agent_mutation: "block" }, + }); + + expect(isPolicyBroaderThanDefault(agentPolicy, defaultPolicy)).toBe(false); + }); }); diff --git a/packages/core/src/__tests__/agent-permission-policy.test.ts b/packages/core/src/__tests__/agent-permission-policy.test.ts index 366dc849f3..db8735a60f 100644 --- a/packages/core/src/__tests__/agent-permission-policy.test.ts +++ b/packages/core/src/__tests__/agent-permission-policy.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID, getBuiltInAgentPermissionPolicyPresets, isAgentPermissionPolicyPresetId, + normalizeAgentPermissionPolicy, normalizeAgentPermissionPolicyFromPreset, resolveEffectiveAgentPermissionPolicy, } from "../agent-permission-policy.js"; @@ -52,6 +53,22 @@ describe("agent-permission-policy", () => { for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) { expect(effective.rules[category]).toBe("allow"); } + expect(effective.toolRules).toBeUndefined(); + }); + + it("normalizes exact tool overrides without changing category preset semantics", () => { + const policy = normalizeAgentPermissionPolicy({ + presetId: "unrestricted", + toolRules: { fn_task_create: "block" }, + }); + + expect(policy.rules.task_agent_mutation).toBe("allow"); + expect(policy.toolRules).toEqual({ fn_task_create: "block" }); + }); + + it("omits empty exact tool override maps", () => { + const policy = normalizeAgentPermissionPolicy({ presetId: "custom", toolRules: {} }); + expect(policy.toolRules).toBeUndefined(); }); it("resolves malformed policy payload to unrestricted default", () => { diff --git a/packages/core/src/__tests__/settings-schema-agent-permission-policy.test.ts b/packages/core/src/__tests__/settings-schema-agent-permission-policy.test.ts index 1a1a127eee..a4ba417e2a 100644 --- a/packages/core/src/__tests__/settings-schema-agent-permission-policy.test.ts +++ b/packages/core/src/__tests__/settings-schema-agent-permission-policy.test.ts @@ -10,13 +10,17 @@ describe("defaultAgentPermissionPolicy settings schema contract", () => { expect(DEFAULT_PROJECT_SETTINGS.defaultAgentPermissionPolicy).toBeUndefined(); }); - it("supports partial category rules", () => { + it("supports partial category rules and exact tool overrides", () => { const setting = { rules: { command_execution: "require-approval", }, + toolRules: { + fn_task_create: "block", + }, }; expect(setting.rules.command_execution).toBe("require-approval"); + expect(setting.toolRules.fn_task_create).toBe("block"); }); }); diff --git a/packages/core/src/agent-permission-policy.ts b/packages/core/src/agent-permission-policy.ts index 3f0d013e0a..6e52f50896 100644 --- a/packages/core/src/agent-permission-policy.ts +++ b/packages/core/src/agent-permission-policy.ts @@ -3,6 +3,7 @@ import type { AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, AgentPermissionPolicyRules, + AgentPermissionPolicyToolRules, } from "./types.js"; import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, @@ -58,10 +59,37 @@ function buildRules(disposition: AgentPermissionPolicyDisposition): AgentPermiss }, {} as AgentPermissionPolicyRules); } -function isValidDisposition(value: unknown): value is AgentPermissionPolicyDisposition { +export function isValidAgentPermissionPolicyDisposition(value: unknown): value is AgentPermissionPolicyDisposition { return typeof value === "string" && (VALID_DISPOSITIONS as readonly string[]).includes(value); } +function normalizeToolRules( + toolRules: Partial | undefined, + errorPrefix: string, +): AgentPermissionPolicyToolRules | undefined { + if (toolRules === undefined) return undefined; + if (!toolRules || typeof toolRules !== "object" || Array.isArray(toolRules)) { + throw new Error(`${errorPrefix} toolRules must be an object`); + } + + const normalized: AgentPermissionPolicyToolRules = {}; + for (const [rawToolName, disposition] of Object.entries(toolRules)) { + const toolName = rawToolName.trim(); + if (!toolName) { + throw new Error(`${errorPrefix} toolRules contains a blank tool name`); + } + if (Object.prototype.hasOwnProperty.call(normalized, toolName)) { + throw new Error(`${errorPrefix} toolRules contains duplicate tool name ${toolName}`); + } + if (!isValidAgentPermissionPolicyDisposition(disposition)) { + throw new Error(`${errorPrefix} toolRules.${toolName} has invalid disposition: ${String(disposition)}`); + } + normalized[toolName] = disposition; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + export function isAgentPermissionPolicyPresetId(value: unknown): value is AgentPermissionPolicyPresetId { return typeof value === "string" && (AGENT_PERMISSION_POLICY_PRESET_IDS as readonly string[]).includes(value); } @@ -89,12 +117,15 @@ export function normalizeAgentPermissionPolicyFromPreset( export function normalizeAgentPermissionPolicy(input: { presetId: AgentPermissionPolicyPresetId; rules?: Partial; + toolRules?: Partial; }): AgentPermissionPolicy { const preset = resolveAgentPermissionPolicyPreset(input.presetId); + const toolRules = normalizeToolRules(input.toolRules, "permission policy"); if (input.presetId !== "custom") { return { presetId: input.presetId, rules: { ...preset.rules }, + ...(toolRules ? { toolRules } : {}), }; } @@ -104,7 +135,7 @@ export function normalizeAgentPermissionPolicy(input: { if (nextValue === undefined) { continue; } - if (!isValidDisposition(nextValue)) { + if (!isValidAgentPermissionPolicyDisposition(nextValue)) { throw new Error(`Invalid permission policy disposition for ${category}: ${String(nextValue)}`); } rules[category] = nextValue; @@ -113,47 +144,55 @@ export function normalizeAgentPermissionPolicy(input: { return { presetId: "custom", rules, + ...(toolRules ? { toolRules } : {}), }; } function normalizeProjectDefaultPolicy( - projectDefault: { rules?: Partial } | undefined, + projectDefault: { rules?: Partial; toolRules?: Partial } | undefined, ): AgentPermissionPolicy { const seed = resolveAgentPermissionPolicyPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID).rules; const merged: Partial = {}; + const toolRules = normalizeToolRules(projectDefault?.toolRules, "project default permission policy"); for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) { const nextValue = projectDefault?.rules?.[category]; if (nextValue === undefined) { continue; } - if (!isValidDisposition(nextValue)) { + if (!isValidAgentPermissionPolicyDisposition(nextValue)) { throw new Error(`Invalid project default permission policy disposition for ${category}: ${String(nextValue)}`); } merged[category] = nextValue; } - if (Object.keys(merged).length === 0) { + if (Object.keys(merged).length === 0 && !toolRules) { return normalizeAgentPermissionPolicyFromPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID); } return normalizeAgentPermissionPolicy({ presetId: "custom", rules: { ...seed, ...merged }, + ...(toolRules ? { toolRules } : {}), }); } export function resolveEffectiveAgentPermissionPolicy( - policy: AgentPermissionPolicy | undefined, - projectDefault?: { rules?: Partial }, + policy: { presetId: string; rules?: Partial; toolRules?: Partial } | undefined, + projectDefault?: { rules?: Partial; toolRules?: Partial }, ): AgentPermissionPolicy { if (!policy || !isAgentPermissionPolicyPresetId(policy.presetId)) { return normalizeProjectDefaultPolicy(projectDefault); } + /* + FNXC:ToolPermissions 2026-07-01-00:00: + Runtime permission resolution is intentionally ordered as per-agent exact tool override, then per-agent category rule, then project-default exact tool override, then project-default category rule, then the unrestricted built-in default. A configured per-agent policy is normalized as a full override; agents with no policy inherit the normalized project default. + */ return normalizeAgentPermissionPolicy({ presetId: policy.presetId, rules: policy.rules, + toolRules: policy.toolRules, }); } @@ -194,15 +233,35 @@ function dispositionRank( return DISPOSITION_BREADTH_RANK[disposition]; } +const EXACT_TOOL_CATEGORY_HINTS = new Map( + AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.flatMap((category) => + AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES[category] + .filter((toolName) => /^[A-Za-z0-9_-]+$/.test(toolName)) + .map((toolName) => [toolName, category] as const), + ), +); + +function toolDispositionRank(policy: AgentPermissionPolicy, toolName: string): number { + const exactDisposition = policy.toolRules?.[toolName]; + if (exactDisposition !== undefined) return DISPOSITION_BREADTH_RANK[exactDisposition]; + + const category = EXACT_TOOL_CATEGORY_HINTS.get(toolName); + if (category) return dispositionRank(policy.rules, category); + + return BROADEST_RANK; +} + /** * True when `agentPolicy`'s effective policy is broader (more privileged) than - * the project `defaultPolicy` on at least one action category (R13). + * the project `defaultPolicy` on at least one action category or exact tool (R13). * * Both arguments should already be resolved via * {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The * defensive per-category handling here guards against a partial/custom rules * map slipping through with a missing category key — an absent key must never - * silently suppress a genuine escalation. + * silently suppress a genuine escalation. Exact tool keys compare against their + * known category fallback when possible, so an agent category `allow` still + * escalates over a project default `toolRules.fn_task_create = "block"`. */ export function isPolicyBroaderThanDefault( agentPolicy: AgentPermissionPolicy, @@ -213,5 +272,15 @@ export function isPolicyBroaderThanDefault( const defaultRank = dispositionRank(defaultPolicy.rules, category); if (agentRank < defaultRank) return true; } + + const toolNames = new Set([ + ...Object.keys(agentPolicy.toolRules ?? {}), + ...Object.keys(defaultPolicy.toolRules ?? {}), + ]); + for (const toolName of toolNames) { + const agentRank = toolDispositionRank(agentPolicy, toolName); + const defaultRank = toolDispositionRank(defaultPolicy, toolName); + if (agentRank < defaultRank) return true; + } return false; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 33750927c5..25f60b3318 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6997a7d093..a461212ceb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4027,9 +4027,11 @@ export interface ProjectSettings { }; /** Project default runtime permission-policy overrides for permanent agents. * Rules are a partial map of category -> disposition (`allow` | `block` | `require-approval`). - * Missing categories inherit the built-in `unrestricted` seed (`allow`). */ + * Tool rules are exact tool-name overrides that take precedence over category rules. + * Missing categories and tools inherit the built-in `unrestricted` seed (`allow`). */ defaultAgentPermissionPolicy?: { - rules: Partial; + rules?: Partial; + toolRules?: AgentPermissionPolicyToolRules; }; /** When true, enforces that task specifications (PROMPT.md) are refreshed if they * become stale. Stale specs are detected based on specStalenessMaxAgeMs. @@ -6369,11 +6371,15 @@ export type ApprovalRequestActionCategory = /** How a runtime action category is handled by permission policy. */ export type AgentPermissionPolicyDisposition = "allow" | "block" | "require-approval"; +/** Exact tool-name permission overrides layered above category rules. */ +export type AgentPermissionPolicyToolRules = Record; + /** Minimum portable permanent-agent gating context consumed by engine runtime wrappers. */ export interface PermanentAgentGatingContext { permissionPolicy?: { presetId: string; rules: Partial>; + toolRules?: AgentPermissionPolicyToolRules; }; requester?: ApprovalRequestActorSnapshot; taskId?: string; @@ -6399,10 +6405,16 @@ export type AgentPermissionPolicyRules = Record< AgentPermissionPolicyDisposition >; -/** First-class persisted permission policy contract for permanent agents. */ +/** + * First-class persisted permission policy contract for permanent agents. + * + * FNXC:ToolPermissions 2026-07-01-00:00: + * Operators must be able to block a single governed tool such as `fn_task_create` without blocking every task-agent mutation. `toolRules` stores exact tool-name overrides and the engine resolves them before category rules while leaving heartbeat-critical exempt tools non-configurable. + */ export interface AgentPermissionPolicy { presetId: AgentPermissionPolicyPresetId; rules: AgentPermissionPolicyRules; + toolRules?: AgentPermissionPolicyToolRules; } /** Approval request lifecycle statuses. */ @@ -7049,6 +7061,7 @@ export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot { ? { presetId: agent.permissionPolicy.presetId, rules: { ...agent.permissionPolicy.rules }, + ...(agent.permissionPolicy.toolRules ? { toolRules: { ...agent.permissionPolicy.toolRules } } : {}), } : undefined, instructionsPath: agent.instructionsPath, diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 2e2c958425..2703ed6725 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -3759,7 +3759,7 @@ function ConfigTab({ const [modelValue, setModelValue] = useState(initialModelValue); const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint); const [permissionPolicyValue, setPermissionPolicyValue] = useState(agent.permissionPolicy); - const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState | undefined>(undefined); + const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState<{ rules?: Partial; toolRules?: AgentPermissionPolicy["toolRules"] } | undefined>(undefined); const managerSelection = reportsToValue.trim(); const availableManagers = useMemo( @@ -3858,7 +3858,7 @@ function ConfigTab({ useEffect(() => { fetchSettingsByScope(projectId) - .then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy?.rules)) + .then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy)) .catch(() => setProjectDefaultPermissionPolicy(undefined)); }, [projectId]); @@ -4724,11 +4724,11 @@ function ConfigTab({ onClick={() => void handlePermissionPolicyChange({ presetId: "custom", rules: { - git_write: projectDefaultPermissionPolicy?.git_write ?? "allow", - file_write_delete: projectDefaultPermissionPolicy?.file_write_delete ?? "allow", - command_execution: projectDefaultPermissionPolicy?.command_execution ?? "allow", - network_api: projectDefaultPermissionPolicy?.network_api ?? "allow", - task_agent_mutation: projectDefaultPermissionPolicy?.task_agent_mutation ?? "allow", + git_write: projectDefaultPermissionPolicy?.rules?.git_write ?? "allow", + file_write_delete: projectDefaultPermissionPolicy?.rules?.file_write_delete ?? "allow", + command_execution: projectDefaultPermissionPolicy?.rules?.command_execution ?? "allow", + network_api: projectDefaultPermissionPolicy?.rules?.network_api ?? "allow", + task_agent_mutation: projectDefaultPermissionPolicy?.rules?.task_agent_mutation ?? "allow", }, })} > @@ -4740,7 +4740,8 @@ function ConfigTab({ { void handlePermissionPolicyChange(next); }} /> diff --git a/packages/dashboard/app/components/AgentPermissionPolicyEditor.css b/packages/dashboard/app/components/AgentPermissionPolicyEditor.css index 9577464b57..523f4e54fc 100644 --- a/packages/dashboard/app/components/AgentPermissionPolicyEditor.css +++ b/packages/dashboard/app/components/AgentPermissionPolicyEditor.css @@ -15,7 +15,7 @@ .agent-policy-row { display: grid; - grid-template-columns: 1fr 220px; + grid-template-columns: minmax(0, 1fr) minmax(10rem, 14rem); gap: var(--space-md); padding: var(--space-sm); border: 1px solid var(--border); @@ -53,6 +53,54 @@ font-size: 0.75rem; } +.agent-policy-tool-rules { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.agent-policy-tool-rules h4, +.agent-policy-tool-rules p { + margin: 0; +} + +.agent-policy-tool-rules p, +.agent-policy-tool-empty { + color: var(--text-muted); + font-size: 0.875rem; +} + +.agent-policy-tool-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.agent-policy-tool-row, +.agent-policy-tool-add { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(9rem, 12rem) auto; + gap: var(--space-sm); + align-items: end; +} + +.agent-policy-tool-row label, +.agent-policy-tool-add label { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.agent-policy-tool-row .input, +.agent-policy-tool-add .input { + width: 100%; +} + .agent-policy-exempt { border: 1px solid var(--border); border-radius: var(--radius-md); @@ -72,10 +120,17 @@ } @media (max-width: 768px) { - .agent-policy-row { + .agent-policy-row, + .agent-policy-tool-row, + .agent-policy-tool-add { grid-template-columns: 1fr; } + .agent-policy-tool-row .btn, + .agent-policy-tool-add .btn { + width: 100%; + } + .agent-policy-examples, .agent-policy-exempt-list { padding-left: var(--space-lg); diff --git a/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx b/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx index afa67b3512..664585f192 100644 --- a/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx +++ b/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx @@ -1,4 +1,5 @@ import "./AgentPermissionPolicyEditor.css"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { @@ -8,6 +9,7 @@ import { type AgentPermissionPolicy, type AgentPermissionPolicyDisposition, type AgentPermissionPolicyRules, + type AgentPermissionPolicyToolRules, } from "@fusion/core"; type Mode = "project-default" | "agent-override"; @@ -15,12 +17,27 @@ type Mode = "project-default" | "agent-override"; interface Props { value: AgentPermissionPolicy | undefined; projectDefault?: Partial; + projectDefaultToolRules?: AgentPermissionPolicyToolRules; mode: Mode; onChange(next: AgentPermissionPolicy | undefined): void; disabled?: boolean; } const DISPOSITIONS: AgentPermissionPolicyDisposition[] = ["allow", "require-approval", "block"]; +const EXACT_TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +function getKnownToolRuleOptions(): string[] { + const exempt = new Set(AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES); + const names = new Set(); + for (const examples of Object.values(AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES)) { + for (const example of examples) { + if (EXACT_TOOL_NAME_PATTERN.test(example) && !exempt.has(example)) { + names.add(example); + } + } + } + return [...names].sort(); +} function getCategoryLabels(t: TFunction<"app">): Record { return { @@ -90,12 +107,22 @@ function derivePresetFromRules(rules: AgentPermissionPolicyRules): AgentPermissi return "custom"; } -export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onChange, disabled = false }: Props) { +export function AgentPermissionPolicyEditor({ value, projectDefault, projectDefaultToolRules, mode, onChange, disabled = false }: Props) { const { t } = useTranslation("app"); const categoryLabels = getCategoryLabels(t as TFunction<"app">); + const knownToolOptions = useMemo(() => getKnownToolRuleOptions(), []); + const [pendingToolName, setPendingToolName] = useState("fn_task_create"); + const [pendingDisposition, setPendingDisposition] = useState("block"); const derivedPreset = value ? derivePresetFromRules(value.rules) : "unrestricted"; const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId === "custom" ? derivedPreset : (value?.presetId ?? "unrestricted")); const rules = value?.rules ?? buildAllowRules(); + const toolRules = value?.toolRules ?? {}; + const toolRuleEntries = Object.entries(toolRules).sort(([a], [b]) => a.localeCompare(b)); + + const withToolRules = (policy: Omit): AgentPermissionPolicy => ({ + ...policy, + ...(Object.keys(toolRules).length > 0 ? { toolRules: { ...toolRules } } : {}), + }); const setPreset = (preset: string) => { if (mode === "agent-override" && preset === "inherit") { @@ -103,10 +130,10 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha return; } if (preset === "custom") { - onChange({ presetId: "custom", rules: { ...rules } }); + onChange(withToolRules({ presetId: "custom", rules: { ...rules } })); return; } - onChange({ presetId: preset as AgentPermissionPolicy["presetId"], rules: getPresetRules(preset as "unrestricted" | "approval-required" | "locked-down") }); + onChange(withToolRules({ presetId: preset as AgentPermissionPolicy["presetId"], rules: getPresetRules(preset as "unrestricted" | "approval-required" | "locked-down") })); }; const setRule = (category: keyof AgentPermissionPolicyRules, next: string) => { @@ -114,11 +141,32 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha if (!value) return; const nextRules = { ...value.rules }; nextRules[category] = projectDefault?.[category] ?? "allow"; - onChange({ presetId: "custom", rules: nextRules }); + onChange(withToolRules({ presetId: "custom", rules: nextRules })); return; } const nextRules = { ...rules, [category]: next as AgentPermissionPolicyDisposition }; - onChange({ presetId: "custom", rules: nextRules }); + onChange(withToolRules({ presetId: "custom", rules: nextRules })); + }; + + const setToolRule = (rawToolName: string, disposition: AgentPermissionPolicyDisposition) => { + const toolName = rawToolName.trim(); + if (!toolName || !EXACT_TOOL_NAME_PATTERN.test(toolName)) return; + const nextToolRules = { ...toolRules, [toolName]: disposition }; + onChange({ presetId: "custom", rules: { ...rules }, toolRules: nextToolRules }); + }; + + const removeToolRule = (toolName: string) => { + const nextToolRules = { ...toolRules }; + delete nextToolRules[toolName]; + onChange({ + presetId: "custom", + rules: { ...rules }, + ...(Object.keys(nextToolRules).length > 0 ? { toolRules: nextToolRules } : {}), + }); + }; + + const addPendingToolRule = () => { + setToolRule(pendingToolName, pendingDisposition); }; return ( @@ -182,6 +230,104 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha })} +
+
+
+

{t("agentPolicy.exactToolOverrides", "Exact tool overrides")}

+

{t("agentPolicy.exactToolOverridesDescription", "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.")}

+
+
+ + {toolRuleEntries.length === 0 ? ( +
+ {t("agentPolicy.noExactToolOverrides", "No exact tool overrides configured.")} +
+ ) : ( +
+ {toolRuleEntries.map(([toolName, disposition]) => { + const inheritedToolDisposition = projectDefaultToolRules?.[toolName]; + return ( +
+ + + +
+ ); + })} +
+ )} + +
+ + + +
+
+
{t("agentPolicy.exemptTools", "Tools exempt from approval policy")}

diff --git a/packages/dashboard/app/components/__tests__/AgentPermissionPolicyEditor.test.tsx b/packages/dashboard/app/components/__tests__/AgentPermissionPolicyEditor.test.tsx index 2cf7d2b213..64df3054d1 100644 --- a/packages/dashboard/app/components/__tests__/AgentPermissionPolicyEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentPermissionPolicyEditor.test.tsx @@ -89,7 +89,97 @@ describe("AgentPermissionPolicyEditor", () => { ); expect(screen.getByText("fn_research_run (web/research)")).toBeInTheDocument(); - expect(screen.getByText("fn_task_create")).toBeInTheDocument(); + expect(screen.getAllByText("fn_task_create").length).toBeGreaterThan(0); + }); + + it("renders an empty state for exact tool overrides", () => { + render( + {}} + />, + ); + + expect(screen.getByText("Exact tool overrides")).toBeInTheDocument(); + expect(screen.getByTestId("agent-policy-tool-empty")).toHaveTextContent("No exact tool overrides configured."); + expect(screen.getByTestId("agent-policy-tool-add")).toBeInTheDocument(); + }); + + it("adds and replaces a fn_task_create block override", () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.change(screen.getByLabelText("Tool override tool"), { target: { value: "fn_task_create" } }); + fireEvent.change(screen.getByLabelText("Tool override disposition"), { target: { value: "block" } }); + fireEvent.click(screen.getByRole("button", { name: "Add override" })); + const blockedPayload = onChange.mock.calls.at(-1)?.[0] as AgentPermissionPolicy; + expect(blockedPayload.toolRules).toEqual({ fn_task_create: "block" }); + + rerender( + , + ); + expect(screen.getAllByTestId("agent-policy-tool-row")).toHaveLength(1); + fireEvent.change(screen.getByLabelText("Tool override disposition"), { target: { value: "require-approval" } }); + fireEvent.click(screen.getByRole("button", { name: "Update override" })); + const updatedPayload = onChange.mock.calls.at(-1)?.[0] as AgentPermissionPolicy; + expect(updatedPayload.toolRules).toEqual({ fn_task_create: "require-approval" }); + }); + + it("removing the final exact tool override leaves no row shell", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Remove exact override for fn_task_create" })); + const payload = onChange.mock.calls.at(-1)?.[0] as AgentPermissionPolicy; + expect(payload.toolRules).toBeUndefined(); + }); + + it("shows inherited project default exact tool disposition in agent override mode", () => { + render( + {}} + />, + ); + + expect(screen.getByText("project default exact rule: Block")).toBeInTheDocument(); }); it("renders exempt tools guidance", () => { diff --git a/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx b/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx index d9c00881de..9da49832e9 100644 --- a/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES } from "@fusion/core"; -import type { AgentPermissionPolicyRules } from "@fusion/core"; +import type { AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core"; import { AgentPermissionPolicyEditor } from "../../AgentPermissionPolicyEditor"; import { AgentProvisioningPolicyEditor } from "../../AgentProvisioningPolicyEditor"; import type { SectionBaseProps } from "./context"; @@ -22,9 +22,9 @@ export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPer

{t("settings.agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory", "Per-agent settings override project defaults. Each category controls a separate approval gate.")}
- setForm((f) => ({ + setForm((f) => ({ ...f, - defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) }, + defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules), ...(next?.toolRules ? { toolRules: next.toolRules } : {}) }, }))}/>

{t("settings.agentPermissions.agentProvisioningApprovals", "Agent Provisioning Approvals")}

diff --git a/packages/dashboard/src/__tests__/routes-agents.test.ts b/packages/dashboard/src/__tests__/routes-agents.test.ts index b0afc892bb..9c4d1e7a23 100644 --- a/packages/dashboard/src/__tests__/routes-agents.test.ts +++ b/packages/dashboard/src/__tests__/routes-agents.test.ts @@ -798,6 +798,83 @@ describe("Agent create/update routes", () => { }); }); + it("POST /api/agents accepts exact permissionPolicy toolRules", async () => { + const res = await REQUEST( + buildAgentApp(), + "POST", + "/api/agents", + JSON.stringify({ + name: "Tool Rule Agent", + role: "executor", + permissionPolicy: { + presetId: "approval-required", + toolRules: { fn_task_create: "block" }, + }, + }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(201); + expect(res.body.permissionPolicy).toMatchObject({ + presetId: "approval-required", + rules: { task_agent_mutation: "require-approval" }, + toolRules: { fn_task_create: "block" }, + }); + }); + + it("POST /api/agents rejects malformed permissionPolicy toolRules", async () => { + const badShape = await REQUEST( + buildAgentApp(), + "POST", + "/api/agents", + JSON.stringify({ + name: "Bad Tool Rules Agent", + role: "executor", + permissionPolicy: { + presetId: "custom", + toolRules: ["fn_task_create"], + }, + }), + { "Content-Type": "application/json" }, + ); + expect(badShape.status).toBe(400); + expect(badShape.body.error).toContain("toolRules must be an object"); + + const badDisposition = await REQUEST( + buildAgentApp(), + "POST", + "/api/agents", + JSON.stringify({ + name: "Bad Tool Disposition Agent", + role: "executor", + permissionPolicy: { + presetId: "custom", + toolRules: { fn_task_create: "sometimes" }, + }, + }), + { "Content-Type": "application/json" }, + ); + expect(badDisposition.status).toBe(400); + expect(badDisposition.body.error).toContain("toolRules.fn_task_create has invalid disposition"); + + const blankKey = await REQUEST( + buildAgentApp(), + "POST", + "/api/agents", + JSON.stringify({ + name: "Blank Tool Agent", + role: "executor", + permissionPolicy: { + presetId: "custom", + toolRules: { " ": "block" }, + }, + }), + { "Content-Type": "application/json" }, + ); + expect(blankKey.status).toBe(400); + expect(blankKey.body.error).toContain("blank tool name"); + }); + it("POST /api/agents rejects unknown custom permissionPolicy category", async () => { const res = await REQUEST( buildAgentApp(), @@ -847,6 +924,7 @@ describe("Agent create/update routes", () => { permissionPolicy: { presetId: "custom", rules: { command_execution: "require-approval" }, + toolRules: { fn_task_create: "block" }, }, }), { "Content-Type": "application/json" }, @@ -858,6 +936,7 @@ describe("Agent create/update routes", () => { expect(res.body.permissionPolicy).toMatchObject({ presetId: "custom", rules: { command_execution: "require-approval" }, + toolRules: { fn_task_create: "block" }, }); }); diff --git a/packages/dashboard/src/__tests__/routes-settings.test.ts b/packages/dashboard/src/__tests__/routes-settings.test.ts index a93127163c..8ed3bd4735 100644 --- a/packages/dashboard/src/__tests__/routes-settings.test.ts +++ b/packages/dashboard/src/__tests__/routes-settings.test.ts @@ -448,6 +448,27 @@ describe("PUT /settings", () => { expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 }); }); + it("passes defaultAgentPermissionPolicy toolRules through settings updates", async () => { + const payload = { + defaultAgentPermissionPolicy: { + rules: { task_agent_mutation: "allow" }, + toolRules: { fn_task_create: "block" }, + }, + }; + (store.updateSettings as ReturnType).mockResolvedValue({ ...DEFAULT_SETTINGS, ...payload }); + + const res = await REQUEST( + buildApp(), + "PUT", + "/api/settings", + JSON.stringify(payload), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(store.updateSettings).toHaveBeenCalledWith(payload); + }); + it("updates settings with auto-backup enabled without logging routine sync failure", async () => { const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-backup-routine-")); const db = new Database(join(tempDir, ".fusion")); diff --git a/packages/dashboard/src/routes/register-agent-core-routes.ts b/packages/dashboard/src/routes/register-agent-core-routes.ts index 52696dee18..1889b359a2 100644 --- a/packages/dashboard/src/routes/register-agent-core-routes.ts +++ b/packages/dashboard/src/routes/register-agent-core-routes.ts @@ -1,7 +1,7 @@ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Request, Response } from "express"; -import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissionPolicyRules, AgentPermissionPolicyDisposition } from "@fusion/core"; +import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissionPolicyRules, AgentPermissionPolicyDisposition, AgentPermissionPolicyToolRules } from "@fusion/core"; import { ApprovalRequestStore, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, @@ -10,7 +10,6 @@ import { isAgentPermissionPolicyPresetId, isEphemeralAgent, normalizeAgentPermissionPolicy, - normalizeAgentPermissionPolicyFromPreset, } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import type { ApiRoutesContext } from "./types.js"; @@ -35,19 +34,30 @@ function parsePermissionPolicyInput(input: unknown) { if (typeof input !== "object" || input === null || Array.isArray(input)) { throw badRequest("permissionPolicy must be an object"); } - const policy = input as { presetId?: unknown; rules?: unknown }; + const policy = input as { presetId?: unknown; rules?: unknown; toolRules?: unknown }; if (typeof policy.presetId !== "string" || !isAgentPermissionPolicyPresetId(policy.presetId)) { throw badRequest("permissionPolicy.presetId must be one of: unrestricted, approval-required, locked-down, custom"); } - if (policy.presetId !== "custom") { - return normalizeAgentPermissionPolicyFromPreset(policy.presetId); - } - + /* + FNXC:ToolPermissions 2026-07-01-00:00: + Agent create/update payloads accept exact `toolRules` alongside preset/category rules so API clients can block one governed tool such as `fn_task_create` without switching an entire category. Route parsing delegates disposition/blank-key normalization to the core helper to keep dashboard and runtime semantics aligned. + */ if (policy.rules !== undefined && (typeof policy.rules !== "object" || policy.rules === null || Array.isArray(policy.rules))) { throw badRequest("permissionPolicy.rules must be an object"); } + if (policy.presetId !== "custom") { + try { + return normalizeAgentPermissionPolicy({ + presetId: policy.presetId, + toolRules: policy.toolRules as Partial | undefined, + }); + } catch (err) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + } + const customRules = (policy.rules ?? {}) as Record; for (const category of Object.keys(customRules)) { if (!(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES as readonly string[]).includes(category)) { @@ -59,10 +69,15 @@ function parsePermissionPolicyInput(input: unknown) { } } - return normalizeAgentPermissionPolicy({ - presetId: "custom", - rules: customRules as Partial, - }); + try { + return normalizeAgentPermissionPolicy({ + presetId: "custom", + rules: customRules as Partial, + toolRules: policy.toolRules as Partial | undefined, + }); + } catch (err) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } } function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean { diff --git a/packages/engine/src/__tests__/agent-action-gate-project-default.test.ts b/packages/engine/src/__tests__/agent-action-gate-project-default.test.ts index 87a20be208..b18fc44b78 100644 --- a/packages/engine/src/__tests__/agent-action-gate-project-default.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate-project-default.test.ts @@ -79,4 +79,38 @@ describe("agent action gate project-default resolution", () => { expect(decision.disposition).toBe("allow"); }); + + it("applies project default exact tool override before its category rule", () => { + const permissionPolicy = resolveEffectiveAgentPermissionPolicy(undefined, { + rules: { task_agent_mutation: "allow" }, + toolRules: { fn_task_create: "block" }, + }); + + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_create", + args: {}, + permissionPolicy, + })).toMatchObject({ + category: "task_agent_mutation", + disposition: "block", + metadata: { + permissionPolicyMatch: { + type: "toolRule", + toolName: "fn_task_create", + disposition: "block", + }, + }, + }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_update", + args: {}, + permissionPolicy, + })).toMatchObject({ + category: "task_agent_mutation", + disposition: "allow", + metadata: {}, + }); + }); }); diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index dd7f6e30dc..40f90ae60f 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -135,6 +135,49 @@ describe("agent-action-gate", () => { expect(blockedDecision.disposition).toBe("block"); }); + it("uses exact tool overrides before task-agent category rules", () => { + const policy: AgentPermissionPolicy = { + ...unrestrictedPolicy, + presetId: "custom", + rules: { + ...unrestrictedPolicy.rules, + task_agent_mutation: "allow", + }, + toolRules: { + fn_task_create: "block", + fn_task_refine: "require-approval", + }, + }; + + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", args: {}, permissionPolicy: policy })).toMatchObject({ + category: "task_agent_mutation", + disposition: "block", + metadata: { + permissionPolicyMatch: { + type: "toolRule", + toolName: "fn_task_create", + disposition: "block", + }, + }, + }); + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: policy })).toMatchObject({ + category: "task_agent_mutation", + disposition: "allow", + metadata: {}, + }); + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_refine", args: {}, permissionPolicy: policy })).toMatchObject({ + category: "task_agent_mutation", + disposition: "require-approval", + metadata: { + permissionPolicyMatch: { + type: "toolRule", + toolName: "fn_task_refine", + disposition: "require-approval", + }, + }, + }); + }); + it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("allows workflow discovery tool %s as a known coordination exemption", (toolName) => { expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy })).toMatchObject({ category: "exempt", diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 3366a1d23d..16f2703b51 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -216,6 +216,61 @@ describe("gating-classifications parity", () => { } }); + it("applies exact tool overrides consistently for governed task creation", () => { + const permissionPolicy: AgentPermissionPolicy = { + ...unrestrictedPolicy, + presetId: "custom", + rules: { + ...unrestrictedPolicy.rules, + task_agent_mutation: "allow", + }, + toolRules: { fn_task_create: "block" }, + }; + + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_task_create", + args: {}, + gating: { permissionPolicy }, + })).toMatchObject({ category: "task_agent_mutation", disposition: "block", recognized: true }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_create", + args: {}, + permissionPolicy, + })).toMatchObject({ category: "task_agent_mutation", disposition: "block" }); + + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_task_update", + args: {}, + gating: { permissionPolicy }, + })).toMatchObject({ category: "task_agent_mutation", disposition: "allow", recognized: true }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_update", + args: {}, + permissionPolicy, + })).toMatchObject({ category: "task_agent_mutation", disposition: "allow" }); + }); + + it("keeps coordination-exempt tools allowed even when an exact rule is present", () => { + const permissionPolicy: AgentPermissionPolicy = { + ...blockedPolicy, + toolRules: { fn_task_done: "block", fn_heartbeat_done: "block" }, + }; + + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_done", + args: {}, + permissionPolicy, + })).toMatchObject({ category: "exempt", disposition: "allow" }); + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_heartbeat_done", + args: {}, + gating: { permissionPolicy }, + })).toMatchObject({ category: "none", disposition: "allow", recognized: true }); + }); + it.each(permanentReadonlySiblingTaskCreationTools)("keeps sibling task creation tool %s permanent-readonly", (toolName) => { expect(READONLY_FN_TOOLS.has(toolName)).toBe(true); expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true); diff --git a/packages/engine/src/agent-action-gate.ts b/packages/engine/src/agent-action-gate.ts index 7c65269faf..6000545926 100644 --- a/packages/engine/src/agent-action-gate.ts +++ b/packages/engine/src/agent-action-gate.ts @@ -171,9 +171,14 @@ export function evaluateAgentActionGate(params: { resourceType = "research"; } + /* + FNXC:ToolPermissions 2026-07-01-00:00: + Exact tool-name overrides must be resolved before category policy so operators can block a single governed tool such as `fn_task_create` without blocking every `task_agent_mutation` tool. Exempt coordination tools remain hard-bypassed to avoid heartbeat deadlocks. + */ + const exactDisposition = category === "exempt" ? undefined : params.permissionPolicy.toolRules?.[params.toolName]; const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt" ? "allow" - : params.permissionPolicy.rules[category]; + : exactDisposition ?? params.permissionPolicy.rules[category]; const dedupeKey = computeApprovalDedupeKey({ agentId: params.agentId, @@ -194,7 +199,15 @@ export function evaluateAgentActionGate(params: { resourceType, ...(resourceId ? { resourceId } : {}), approvalDedupeKey: dedupeKey, - metadata: {}, + metadata: exactDisposition + ? { + permissionPolicyMatch: { + type: "toolRule", + toolName: params.toolName, + disposition: exactDisposition, + }, + } + : {}, }; } diff --git a/packages/engine/src/permanent-agent-gating.ts b/packages/engine/src/permanent-agent-gating.ts index 68a585bb1e..bf55e85200 100644 --- a/packages/engine/src/permanent-agent-gating.ts +++ b/packages/engine/src/permanent-agent-gating.ts @@ -78,10 +78,17 @@ export function classifyPermanentAgentToolCall( } function resolvePolicyDisposition( + toolName: string, category: PermanentAgentSensitiveActionCategory, gating: PermanentAgentGatingContext | undefined, ): AgentPermissionPolicyDisposition { - return gating?.permissionPolicy?.rules?.[category] ?? "require-approval"; + /* + 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: { @@ -110,6 +117,6 @@ export function resolvePermanentAgentToolDecision(input: { return { ...classification, toolName: input.toolName, - disposition: resolvePolicyDisposition(classification.category, input.gating), + disposition: resolvePolicyDisposition(input.toolName, classification.category, input.gating), }; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 9687cf5cf3..4a34214520 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -190,7 +190,20 @@ "lockedDown": "Locked Down", "preset": "Preset", "requireApproval": "Require approval", - "unrestricted": "Unrestricted" + "unrestricted": "Unrestricted", + "exactToolOverrides": "Exact tool overrides", + "exactToolOverridesDescription": "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.", + "noExactToolOverrides": "No exact tool overrides configured.", + "toolName": "Tool name", + "toolDisposition": "Disposition", + "toolDispositionFor": "Disposition for {{toolName}}", + "projectDefaultExactTool": "project default exact rule", + "removeToolOverride": "Remove exact override for {{toolName}}", + "remove": "Remove", + "toolOverrideTool": "Tool override tool", + "toolOverrideDisposition": "Tool override disposition", + "updateToolOverride": "Update override", + "addToolOverride": "Add override" }, "agentPrompts": { "actions": { diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index f5b0de1381..4b35f1d492 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -180,7 +180,20 @@ "lockedDown": "Bloqueado", "preset": "Preajuste", "requireApproval": "Requerir aprobación", - "unrestricted": "Sin restricciones" + "unrestricted": "Sin restricciones", + "exactToolOverrides": "Exact tool overrides", + "exactToolOverridesDescription": "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.", + "noExactToolOverrides": "No exact tool overrides configured.", + "toolName": "Tool name", + "toolDisposition": "Disposition", + "toolDispositionFor": "Disposition for {{toolName}}", + "projectDefaultExactTool": "project default exact rule", + "removeToolOverride": "Remove exact override for {{toolName}}", + "remove": "Remove", + "toolOverrideTool": "Tool override tool", + "toolOverrideDisposition": "Tool override disposition", + "updateToolOverride": "Update override", + "addToolOverride": "Add override" }, "agentPrompts": { "actions": { diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 517e3a70d3..9faf71d659 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -180,7 +180,20 @@ "lockedDown": "Verrouillé", "preset": "Présélection", "requireApproval": "Exiger l'approbation", - "unrestricted": "Sans restriction" + "unrestricted": "Sans restriction", + "exactToolOverrides": "Exact tool overrides", + "exactToolOverridesDescription": "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.", + "noExactToolOverrides": "No exact tool overrides configured.", + "toolName": "Tool name", + "toolDisposition": "Disposition", + "toolDispositionFor": "Disposition for {{toolName}}", + "projectDefaultExactTool": "project default exact rule", + "removeToolOverride": "Remove exact override for {{toolName}}", + "remove": "Remove", + "toolOverrideTool": "Tool override tool", + "toolOverrideDisposition": "Tool override disposition", + "updateToolOverride": "Update override", + "addToolOverride": "Add override" }, "agentPrompts": { "actions": { diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 6964e96755..7fc821a436 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -180,7 +180,20 @@ "lockedDown": "잠금", "preset": "프리셋", "requireApproval": "승인 필요", - "unrestricted": "무제한" + "unrestricted": "무제한", + "exactToolOverrides": "Exact tool overrides", + "exactToolOverridesDescription": "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.", + "noExactToolOverrides": "No exact tool overrides configured.", + "toolName": "Tool name", + "toolDisposition": "Disposition", + "toolDispositionFor": "Disposition for {{toolName}}", + "projectDefaultExactTool": "project default exact rule", + "removeToolOverride": "Remove exact override for {{toolName}}", + "remove": "Remove", + "toolOverrideTool": "Tool override tool", + "toolOverrideDisposition": "Tool override disposition", + "updateToolOverride": "Update override", + "addToolOverride": "Add override" }, "agentPrompts": { "actions": { diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 9497e69b4c..03793584ee 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -180,7 +180,20 @@ "lockedDown": "锁定", "preset": "预设", "requireApproval": "需要批准", - "unrestricted": "无限制" + "unrestricted": "无限制", + "exactToolOverrides": "Exact tool overrides", + "exactToolOverridesDescription": "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.", + "noExactToolOverrides": "No exact tool overrides configured.", + "toolName": "Tool name", + "toolDisposition": "Disposition", + "toolDispositionFor": "Disposition for {{toolName}}", + "projectDefaultExactTool": "project default exact rule", + "removeToolOverride": "Remove exact override for {{toolName}}", + "remove": "Remove", + "toolOverrideTool": "Tool override tool", + "toolOverrideDisposition": "Tool override disposition", + "updateToolOverride": "Update override", + "addToolOverride": "Add override" }, "agentPrompts": { "actions": { diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 3a2b1a1ada..9af70316a5 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -180,7 +180,20 @@ "lockedDown": "鎖定", "preset": "預設", "requireApproval": "需要批准", - "unrestricted": "無限制" + "unrestricted": "無限制", + "exactToolOverrides": "Exact tool overrides", + "exactToolOverridesDescription": "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.", + "noExactToolOverrides": "No exact tool overrides configured.", + "toolName": "Tool name", + "toolDisposition": "Disposition", + "toolDispositionFor": "Disposition for {{toolName}}", + "projectDefaultExactTool": "project default exact rule", + "removeToolOverride": "Remove exact override for {{toolName}}", + "remove": "Remove", + "toolOverrideTool": "Tool override tool", + "toolOverrideDisposition": "Tool override disposition", + "updateToolOverride": "Update override", + "addToolOverride": "Add override" }, "agentPrompts": { "actions": {