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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 09:45:30 -07:00
parent 914c7c189f
commit 12ee9a92d2
28 changed files with 878 additions and 65 deletions

View File

@@ -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.

View File

@@ -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`)

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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", () => {

View File

@@ -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");
});
});

View File

@@ -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<AgentPermissionPolicyToolRules> | 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<AgentPermissionPolicyRules>;
toolRules?: Partial<AgentPermissionPolicyToolRules>;
}): 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<AgentPermissionPolicyRules> } | undefined,
projectDefault: { rules?: Partial<AgentPermissionPolicyRules>; toolRules?: Partial<AgentPermissionPolicyToolRules> } | undefined,
): AgentPermissionPolicy {
const seed = resolveAgentPermissionPolicyPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID).rules;
const merged: Partial<AgentPermissionPolicyRules> = {};
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<AgentPermissionPolicyRules> },
policy: { presetId: string; rules?: Partial<AgentPermissionPolicyRules>; toolRules?: Partial<AgentPermissionPolicyToolRules> } | undefined,
projectDefault?: { rules?: Partial<AgentPermissionPolicyRules>; toolRules?: Partial<AgentPermissionPolicyToolRules> },
): 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<string, (typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number]>(
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;
}

View File

@@ -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,

View File

@@ -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<AgentPermissionPolicyRules>;
rules?: Partial<AgentPermissionPolicyRules>;
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<string, AgentPermissionPolicyDisposition>;
/** Minimum portable permanent-agent gating context consumed by engine runtime wrappers. */
export interface PermanentAgentGatingContext {
permissionPolicy?: {
presetId: string;
rules: Partial<Record<PermanentAgentSensitiveActionCategory, AgentPermissionPolicyDisposition>>;
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,

View File

@@ -3759,7 +3759,7 @@ function ConfigTab({
const [modelValue, setModelValue] = useState(initialModelValue);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
const [permissionPolicyValue, setPermissionPolicyValue] = useState<AgentPermissionPolicy | undefined>(agent.permissionPolicy);
const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState<Partial<AgentPermissionPolicyRules> | undefined>(undefined);
const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState<{ rules?: Partial<AgentPermissionPolicyRules>; 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({
<AgentPermissionPolicyEditor
mode="agent-override"
value={permissionPolicyValue}
projectDefault={projectDefaultPermissionPolicy}
projectDefault={projectDefaultPermissionPolicy?.rules}
projectDefaultToolRules={projectDefaultPermissionPolicy?.toolRules}
onChange={(next) => { void handlePermissionPolicyChange(next); }}
/>
</div>

View File

@@ -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);

View File

@@ -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<AgentPermissionPolicyRules>;
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<string>();
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<string, { label: string; description: string }> {
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<AgentPermissionPolicyDisposition>("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, "toolRules">): 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
})}
</div>
<section className="agent-policy-tool-rules" aria-labelledby="agent-policy-tool-rules-heading">
<div className="agent-policy-tool-rules-header">
<div>
<h4 id="agent-policy-tool-rules-heading">{t("agentPolicy.exactToolOverrides", "Exact tool overrides")}</h4>
<p>{t("agentPolicy.exactToolOverridesDescription", "Override one governed tool by exact name before its category rule. Heartbeat-critical exempt tools stay non-configurable.")}</p>
</div>
</div>
{toolRuleEntries.length === 0 ? (
<div className="agent-policy-tool-empty" data-testid="agent-policy-tool-empty">
{t("agentPolicy.noExactToolOverrides", "No exact tool overrides configured.")}
</div>
) : (
<div className="agent-policy-tool-list" data-testid="agent-policy-tool-list">
{toolRuleEntries.map(([toolName, disposition]) => {
const inheritedToolDisposition = projectDefaultToolRules?.[toolName];
return (
<div className="agent-policy-tool-row" key={toolName} data-testid="agent-policy-tool-row">
<label>
<span>{t("agentPolicy.toolName", "Tool name")}</span>
<input className="input" value={toolName} disabled readOnly />
</label>
<label>
<span>{t("agentPolicy.toolDisposition", "Disposition")}</span>
<select
className="select"
aria-label={t("agentPolicy.toolDispositionFor", "Disposition for {{toolName}}", { toolName })}
value={disposition}
onChange={(event) => setToolRule(toolName, event.target.value as AgentPermissionPolicyDisposition)}
disabled={disabled}
>
{DISPOSITIONS.map((option) => (
<option key={option} value={option}>{getDispositionLabel(t as TFunction<"app">, option)}</option>
))}
</select>
{mode === "agent-override" && inheritedToolDisposition ? (
<small className="agent-policy-inherit-note">
{t("agentPolicy.projectDefaultExactTool", "project default exact rule")}: {getDispositionLabel(t as TFunction<"app">, inheritedToolDisposition)}
</small>
) : null}
</label>
<button
type="button"
className="btn btn-secondary"
aria-label={t("agentPolicy.removeToolOverride", "Remove exact override for {{toolName}}", { toolName })}
onClick={() => removeToolRule(toolName)}
disabled={disabled}
>
{t("agentPolicy.remove", "Remove")}
</button>
</div>
);
})}
</div>
)}
<div className="agent-policy-tool-add" data-testid="agent-policy-tool-add">
<label>
<span>{t("agentPolicy.toolName", "Tool name")}</span>
<input
className="input"
list="agent-policy-known-tools"
value={pendingToolName}
onChange={(event) => setPendingToolName(event.target.value)}
disabled={disabled}
aria-label={t("agentPolicy.toolOverrideTool", "Tool override tool")}
/>
<datalist id="agent-policy-known-tools">
{knownToolOptions.map((toolName) => (
<option key={toolName} value={toolName} />
))}
</datalist>
</label>
<label>
<span>{t("agentPolicy.toolDisposition", "Disposition")}</span>
<select
className="select"
value={pendingDisposition}
onChange={(event) => setPendingDisposition(event.target.value as AgentPermissionPolicyDisposition)}
disabled={disabled}
aria-label={t("agentPolicy.toolOverrideDisposition", "Tool override disposition")}
>
{DISPOSITIONS.map((option) => (
<option key={option} value={option}>{getDispositionLabel(t as TFunction<"app">, option)}</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-primary"
onClick={addPendingToolRule}
disabled={disabled || !EXACT_TOOL_NAME_PATTERN.test(pendingToolName.trim())}
>
{toolRules[pendingToolName.trim()] ? t("agentPolicy.updateToolOverride", "Update override") : t("agentPolicy.addToolOverride", "Add override")}
</button>
</div>
</section>
<details className="agent-policy-exempt" open={false}>
<summary>{t("agentPolicy.exemptTools", "Tools exempt from approval policy")}</summary>
<p>

View File

@@ -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(
<AgentPermissionPolicyEditor
mode="project-default"
value={undefined}
onChange={() => {}}
/>,
);
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(
<AgentPermissionPolicyEditor
mode="project-default"
value={{ presetId: "custom", rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
} }}
onChange={onChange}
/>,
);
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(
<AgentPermissionPolicyEditor
mode="project-default"
value={blockedPayload}
onChange={onChange}
/>,
);
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(
<AgentPermissionPolicyEditor
mode="project-default"
value={{ presetId: "custom", rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
}, toolRules: { fn_task_create: "block" } }}
onChange={onChange}
/>,
);
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(
<AgentPermissionPolicyEditor
mode="agent-override"
value={{ presetId: "custom", rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
}, toolRules: { fn_task_create: "allow" } }}
projectDefaultToolRules={{ fn_task_create: "block" }}
onChange={() => {}}
/>,
);
expect(screen.getByText("project default exact rule: Block")).toBeInTheDocument();
});
it("renders exempt tools guidance", () => {

View File

@@ -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
<div className="form-group">
<small className="settings-muted">{t("settings.agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory", "Per-agent settings override project defaults. Each category controls a separate approval gate.")}</small>
</div>
<AgentPermissionPolicyEditor mode="project-default" value={form.defaultAgentPermissionPolicy ? { presetId: "custom", rules: toCompleteAgentPermissionRules(form.defaultAgentPermissionPolicy.rules) } : { presetId: "custom", rules: toCompleteAgentPermissionRules() }} onChange={(next) => setForm((f) => ({
<AgentPermissionPolicyEditor mode="project-default" value={form.defaultAgentPermissionPolicy ? { presetId: "custom", rules: toCompleteAgentPermissionRules(form.defaultAgentPermissionPolicy.rules), ...(form.defaultAgentPermissionPolicy.toolRules ? { toolRules: form.defaultAgentPermissionPolicy.toolRules } : {}) } as AgentPermissionPolicy : { presetId: "custom", rules: toCompleteAgentPermissionRules() }} onChange={(next) => setForm((f) => ({
...f,
defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) },
defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules), ...(next?.toolRules ? { toolRules: next.toolRules } : {}) },
}))}/>
<h4 className="settings-section-heading">{t("settings.agentPermissions.agentProvisioningApprovals", "Agent Provisioning Approvals")}</h4>

View File

@@ -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" },
});
});

View File

@@ -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<typeof vi.fn>).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"));

View File

@@ -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<AgentPermissionPolicyToolRules> | undefined,
});
} catch (err) {
throw badRequest(err instanceof Error ? err.message : String(err));
}
}
const customRules = (policy.rules ?? {}) as Record<string, unknown>;
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<AgentPermissionPolicyRules>,
});
try {
return normalizeAgentPermissionPolicy({
presetId: "custom",
rules: customRules as Partial<AgentPermissionPolicyRules>,
toolRules: policy.toolRules as Partial<AgentPermissionPolicyToolRules> | undefined,
});
} catch (err) {
throw badRequest(err instanceof Error ? err.message : String(err));
}
}
function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean {

View File

@@ -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: {},
});
});
});

View File

@@ -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",

View File

@@ -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);

View File

@@ -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,
},
}
: {},
};
}

View File

@@ -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),
};
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {