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:
7
.changeset/fn-7361-tool-permissions.md
Normal file
7
.changeset/fn-7361-tool-permissions.md
Normal 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.
|
||||||
@@ -140,12 +140,14 @@ V1 runtime action categories:
|
|||||||
- `task_agent_mutation`
|
- `task_agent_mutation`
|
||||||
- `none` (classifier-only read-only result; never stored as a policy rule key)
|
- `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`
|
- `allow`
|
||||||
- `block`
|
- `block`
|
||||||
- `require-approval`
|
- `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)
|
### Runtime gate v1 mapping (per tool invocation, permanent agents only)
|
||||||
|
|
||||||
The engine classifies tool calls by behavior (not namespace alone):
|
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`
|
- `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)
|
- `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)
|
- `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.
|
- `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:
|
`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).
|
- In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe).
|
||||||
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
||||||
- Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, messaging, 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.
|
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
|
||||||
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.
|
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.
|
||||||
|
|
||||||
@@ -1541,11 +1543,13 @@ Each category can be set to one disposition:
|
|||||||
- `block`
|
- `block`
|
||||||
|
|
||||||
Precedence:
|
Precedence:
|
||||||
1. Per-agent permission policy override (Agent Detail → Settings → Permissions)
|
1. Per-agent exact `toolRules` override (Agent Detail → Settings → Permissions)
|
||||||
2. Project default permission policy (`defaultAgentPermissionPolicy` in Project Settings → Agent Permissions)
|
2. Per-agent category rule
|
||||||
3. Built-in fallback preset (`unrestricted` / allow-all)
|
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`)
|
## Pi extension scope (`packages/cli/src/extension.ts`)
|
||||||
|
|
||||||
|
|||||||
@@ -1520,17 +1520,23 @@ Project-scoped default permission policy for permanent-agent action gates.
|
|||||||
"rules": {
|
"rules": {
|
||||||
"git_write": "require-approval",
|
"git_write": "require-approval",
|
||||||
"command_execution": "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.
|
- `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`.
|
- Categories: `git_write`, `file_write_delete`, `command_execution`, `network_api`, `task_agent_mutation`.
|
||||||
- Dispositions: `allow`, `require-approval`, `block`.
|
- Dispositions: `allow`, `require-approval`, `block`.
|
||||||
- Missing categories default to `allow` via the built-in `unrestricted` seed.
|
- Missing categories default to `allow` via the built-in `unrestricted` seed; missing or empty `toolRules` preserve legacy category-only behavior.
|
||||||
- Per-agent overrides take precedence over this project default.
|
- 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
|
## Model selection hierarchy
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
isPolicyBroaderThanDefault,
|
||||||
normalizeAgentPermissionPolicy,
|
normalizeAgentPermissionPolicy,
|
||||||
resolveEffectiveAgentPermissionPolicy,
|
resolveEffectiveAgentPermissionPolicy,
|
||||||
resolveAgentPermissionPolicyPreset,
|
resolveAgentPermissionPolicyPreset,
|
||||||
@@ -29,11 +30,13 @@ describe("agent permission policy resolution", () => {
|
|||||||
it("uses project default when agent policy is undefined", () => {
|
it("uses project default when agent policy is undefined", () => {
|
||||||
const policy = resolveEffectiveAgentPermissionPolicy(undefined, {
|
const policy = resolveEffectiveAgentPermissionPolicy(undefined, {
|
||||||
rules: { network_api: "block" },
|
rules: { network_api: "block" },
|
||||||
|
toolRules: { fn_task_create: "block" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(policy.presetId).toBe("custom");
|
expect(policy.presetId).toBe("custom");
|
||||||
expect(policy.rules.network_api).toBe("block");
|
expect(policy.rules.network_api).toBe("block");
|
||||||
expect(policy.rules.git_write).toBe("allow");
|
expect(policy.rules.git_write).toBe("allow");
|
||||||
|
expect(policy.toolRules).toEqual({ fn_task_create: "block" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps per-agent custom rule over project default", () => {
|
it("keeps per-agent custom rule over project default", () => {
|
||||||
@@ -42,10 +45,22 @@ describe("agent permission policy resolution", () => {
|
|||||||
presetId: "custom",
|
presetId: "custom",
|
||||||
rules: { command_execution: "allow" },
|
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.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", () => {
|
it("rejects invalid disposition values", () => {
|
||||||
@@ -56,4 +71,45 @@ describe("agent permission policy resolution", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/Invalid permission policy disposition/);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID,
|
DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID,
|
||||||
getBuiltInAgentPermissionPolicyPresets,
|
getBuiltInAgentPermissionPolicyPresets,
|
||||||
isAgentPermissionPolicyPresetId,
|
isAgentPermissionPolicyPresetId,
|
||||||
|
normalizeAgentPermissionPolicy,
|
||||||
normalizeAgentPermissionPolicyFromPreset,
|
normalizeAgentPermissionPolicyFromPreset,
|
||||||
resolveEffectiveAgentPermissionPolicy,
|
resolveEffectiveAgentPermissionPolicy,
|
||||||
} from "../agent-permission-policy.js";
|
} from "../agent-permission-policy.js";
|
||||||
@@ -52,6 +53,22 @@ describe("agent-permission-policy", () => {
|
|||||||
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
|
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
|
||||||
expect(effective.rules[category]).toBe("allow");
|
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", () => {
|
it("resolves malformed policy payload to unrestricted default", () => {
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ describe("defaultAgentPermissionPolicy settings schema contract", () => {
|
|||||||
expect(DEFAULT_PROJECT_SETTINGS.defaultAgentPermissionPolicy).toBeUndefined();
|
expect(DEFAULT_PROJECT_SETTINGS.defaultAgentPermissionPolicy).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports partial category rules", () => {
|
it("supports partial category rules and exact tool overrides", () => {
|
||||||
const setting = {
|
const setting = {
|
||||||
rules: {
|
rules: {
|
||||||
command_execution: "require-approval",
|
command_execution: "require-approval",
|
||||||
},
|
},
|
||||||
|
toolRules: {
|
||||||
|
fn_task_create: "block",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(setting.rules.command_execution).toBe("require-approval");
|
expect(setting.rules.command_execution).toBe("require-approval");
|
||||||
|
expect(setting.toolRules.fn_task_create).toBe("block");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AgentPermissionPolicyDisposition,
|
AgentPermissionPolicyDisposition,
|
||||||
AgentPermissionPolicyPresetId,
|
AgentPermissionPolicyPresetId,
|
||||||
AgentPermissionPolicyRules,
|
AgentPermissionPolicyRules,
|
||||||
|
AgentPermissionPolicyToolRules,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import {
|
import {
|
||||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||||
@@ -58,10 +59,37 @@ function buildRules(disposition: AgentPermissionPolicyDisposition): AgentPermiss
|
|||||||
}, {} as AgentPermissionPolicyRules);
|
}, {} 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);
|
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 {
|
export function isAgentPermissionPolicyPresetId(value: unknown): value is AgentPermissionPolicyPresetId {
|
||||||
return typeof value === "string" && (AGENT_PERMISSION_POLICY_PRESET_IDS as readonly string[]).includes(value);
|
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: {
|
export function normalizeAgentPermissionPolicy(input: {
|
||||||
presetId: AgentPermissionPolicyPresetId;
|
presetId: AgentPermissionPolicyPresetId;
|
||||||
rules?: Partial<AgentPermissionPolicyRules>;
|
rules?: Partial<AgentPermissionPolicyRules>;
|
||||||
|
toolRules?: Partial<AgentPermissionPolicyToolRules>;
|
||||||
}): AgentPermissionPolicy {
|
}): AgentPermissionPolicy {
|
||||||
const preset = resolveAgentPermissionPolicyPreset(input.presetId);
|
const preset = resolveAgentPermissionPolicyPreset(input.presetId);
|
||||||
|
const toolRules = normalizeToolRules(input.toolRules, "permission policy");
|
||||||
if (input.presetId !== "custom") {
|
if (input.presetId !== "custom") {
|
||||||
return {
|
return {
|
||||||
presetId: input.presetId,
|
presetId: input.presetId,
|
||||||
rules: { ...preset.rules },
|
rules: { ...preset.rules },
|
||||||
|
...(toolRules ? { toolRules } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +135,7 @@ export function normalizeAgentPermissionPolicy(input: {
|
|||||||
if (nextValue === undefined) {
|
if (nextValue === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isValidDisposition(nextValue)) {
|
if (!isValidAgentPermissionPolicyDisposition(nextValue)) {
|
||||||
throw new Error(`Invalid permission policy disposition for ${category}: ${String(nextValue)}`);
|
throw new Error(`Invalid permission policy disposition for ${category}: ${String(nextValue)}`);
|
||||||
}
|
}
|
||||||
rules[category] = nextValue;
|
rules[category] = nextValue;
|
||||||
@@ -113,47 +144,55 @@ export function normalizeAgentPermissionPolicy(input: {
|
|||||||
return {
|
return {
|
||||||
presetId: "custom",
|
presetId: "custom",
|
||||||
rules,
|
rules,
|
||||||
|
...(toolRules ? { toolRules } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProjectDefaultPolicy(
|
function normalizeProjectDefaultPolicy(
|
||||||
projectDefault: { rules?: Partial<AgentPermissionPolicyRules> } | undefined,
|
projectDefault: { rules?: Partial<AgentPermissionPolicyRules>; toolRules?: Partial<AgentPermissionPolicyToolRules> } | undefined,
|
||||||
): AgentPermissionPolicy {
|
): AgentPermissionPolicy {
|
||||||
const seed = resolveAgentPermissionPolicyPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID).rules;
|
const seed = resolveAgentPermissionPolicyPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID).rules;
|
||||||
const merged: Partial<AgentPermissionPolicyRules> = {};
|
const merged: Partial<AgentPermissionPolicyRules> = {};
|
||||||
|
const toolRules = normalizeToolRules(projectDefault?.toolRules, "project default permission policy");
|
||||||
|
|
||||||
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
|
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
|
||||||
const nextValue = projectDefault?.rules?.[category];
|
const nextValue = projectDefault?.rules?.[category];
|
||||||
if (nextValue === undefined) {
|
if (nextValue === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isValidDisposition(nextValue)) {
|
if (!isValidAgentPermissionPolicyDisposition(nextValue)) {
|
||||||
throw new Error(`Invalid project default permission policy disposition for ${category}: ${String(nextValue)}`);
|
throw new Error(`Invalid project default permission policy disposition for ${category}: ${String(nextValue)}`);
|
||||||
}
|
}
|
||||||
merged[category] = 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 normalizeAgentPermissionPolicyFromPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeAgentPermissionPolicy({
|
return normalizeAgentPermissionPolicy({
|
||||||
presetId: "custom",
|
presetId: "custom",
|
||||||
rules: { ...seed, ...merged },
|
rules: { ...seed, ...merged },
|
||||||
|
...(toolRules ? { toolRules } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEffectiveAgentPermissionPolicy(
|
export function resolveEffectiveAgentPermissionPolicy(
|
||||||
policy: AgentPermissionPolicy | undefined,
|
policy: { presetId: string; rules?: Partial<AgentPermissionPolicyRules>; toolRules?: Partial<AgentPermissionPolicyToolRules> } | undefined,
|
||||||
projectDefault?: { rules?: Partial<AgentPermissionPolicyRules> },
|
projectDefault?: { rules?: Partial<AgentPermissionPolicyRules>; toolRules?: Partial<AgentPermissionPolicyToolRules> },
|
||||||
): AgentPermissionPolicy {
|
): AgentPermissionPolicy {
|
||||||
if (!policy || !isAgentPermissionPolicyPresetId(policy.presetId)) {
|
if (!policy || !isAgentPermissionPolicyPresetId(policy.presetId)) {
|
||||||
return normalizeProjectDefaultPolicy(projectDefault);
|
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({
|
return normalizeAgentPermissionPolicy({
|
||||||
presetId: policy.presetId,
|
presetId: policy.presetId,
|
||||||
rules: policy.rules,
|
rules: policy.rules,
|
||||||
|
toolRules: policy.toolRules,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,15 +233,35 @@ function dispositionRank(
|
|||||||
return DISPOSITION_BREADTH_RANK[disposition];
|
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
|
* 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
|
* Both arguments should already be resolved via
|
||||||
* {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The
|
* {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The
|
||||||
* defensive per-category handling here guards against a partial/custom rules
|
* defensive per-category handling here guards against a partial/custom rules
|
||||||
* map slipping through with a missing category key — an absent key must never
|
* 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(
|
export function isPolicyBroaderThanDefault(
|
||||||
agentPolicy: AgentPermissionPolicy,
|
agentPolicy: AgentPermissionPolicy,
|
||||||
@@ -213,5 +272,15 @@ export function isPolicyBroaderThanDefault(
|
|||||||
const defaultRank = dispositionRank(defaultPolicy.rules, category);
|
const defaultRank = dispositionRank(defaultPolicy.rules, category);
|
||||||
if (agentRank < defaultRank) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
|
||||||
export {
|
export {
|
||||||
resolveEntryPointBranchAssignment,
|
resolveEntryPointBranchAssignment,
|
||||||
|
|||||||
@@ -4027,9 +4027,11 @@ export interface ProjectSettings {
|
|||||||
};
|
};
|
||||||
/** Project default runtime permission-policy overrides for permanent agents.
|
/** Project default runtime permission-policy overrides for permanent agents.
|
||||||
* Rules are a partial map of category -> disposition (`allow` | `block` | `require-approval`).
|
* 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?: {
|
defaultAgentPermissionPolicy?: {
|
||||||
rules: Partial<AgentPermissionPolicyRules>;
|
rules?: Partial<AgentPermissionPolicyRules>;
|
||||||
|
toolRules?: AgentPermissionPolicyToolRules;
|
||||||
};
|
};
|
||||||
/** When true, enforces that task specifications (PROMPT.md) are refreshed if they
|
/** When true, enforces that task specifications (PROMPT.md) are refreshed if they
|
||||||
* become stale. Stale specs are detected based on specStalenessMaxAgeMs.
|
* 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. */
|
/** How a runtime action category is handled by permission policy. */
|
||||||
export type AgentPermissionPolicyDisposition = "allow" | "block" | "require-approval";
|
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. */
|
/** Minimum portable permanent-agent gating context consumed by engine runtime wrappers. */
|
||||||
export interface PermanentAgentGatingContext {
|
export interface PermanentAgentGatingContext {
|
||||||
permissionPolicy?: {
|
permissionPolicy?: {
|
||||||
presetId: string;
|
presetId: string;
|
||||||
rules: Partial<Record<PermanentAgentSensitiveActionCategory, AgentPermissionPolicyDisposition>>;
|
rules: Partial<Record<PermanentAgentSensitiveActionCategory, AgentPermissionPolicyDisposition>>;
|
||||||
|
toolRules?: AgentPermissionPolicyToolRules;
|
||||||
};
|
};
|
||||||
requester?: ApprovalRequestActorSnapshot;
|
requester?: ApprovalRequestActorSnapshot;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
@@ -6399,10 +6405,16 @@ export type AgentPermissionPolicyRules = Record<
|
|||||||
AgentPermissionPolicyDisposition
|
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 {
|
export interface AgentPermissionPolicy {
|
||||||
presetId: AgentPermissionPolicyPresetId;
|
presetId: AgentPermissionPolicyPresetId;
|
||||||
rules: AgentPermissionPolicyRules;
|
rules: AgentPermissionPolicyRules;
|
||||||
|
toolRules?: AgentPermissionPolicyToolRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Approval request lifecycle statuses. */
|
/** Approval request lifecycle statuses. */
|
||||||
@@ -7049,6 +7061,7 @@ export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
|
|||||||
? {
|
? {
|
||||||
presetId: agent.permissionPolicy.presetId,
|
presetId: agent.permissionPolicy.presetId,
|
||||||
rules: { ...agent.permissionPolicy.rules },
|
rules: { ...agent.permissionPolicy.rules },
|
||||||
|
...(agent.permissionPolicy.toolRules ? { toolRules: { ...agent.permissionPolicy.toolRules } } : {}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
instructionsPath: agent.instructionsPath,
|
instructionsPath: agent.instructionsPath,
|
||||||
|
|||||||
@@ -3759,7 +3759,7 @@ function ConfigTab({
|
|||||||
const [modelValue, setModelValue] = useState(initialModelValue);
|
const [modelValue, setModelValue] = useState(initialModelValue);
|
||||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
|
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
|
||||||
const [permissionPolicyValue, setPermissionPolicyValue] = useState<AgentPermissionPolicy | undefined>(agent.permissionPolicy);
|
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 managerSelection = reportsToValue.trim();
|
||||||
const availableManagers = useMemo(
|
const availableManagers = useMemo(
|
||||||
@@ -3858,7 +3858,7 @@ function ConfigTab({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettingsByScope(projectId)
|
fetchSettingsByScope(projectId)
|
||||||
.then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy?.rules))
|
.then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy))
|
||||||
.catch(() => setProjectDefaultPermissionPolicy(undefined));
|
.catch(() => setProjectDefaultPermissionPolicy(undefined));
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
@@ -4724,11 +4724,11 @@ function ConfigTab({
|
|||||||
onClick={() => void handlePermissionPolicyChange({
|
onClick={() => void handlePermissionPolicyChange({
|
||||||
presetId: "custom",
|
presetId: "custom",
|
||||||
rules: {
|
rules: {
|
||||||
git_write: projectDefaultPermissionPolicy?.git_write ?? "allow",
|
git_write: projectDefaultPermissionPolicy?.rules?.git_write ?? "allow",
|
||||||
file_write_delete: projectDefaultPermissionPolicy?.file_write_delete ?? "allow",
|
file_write_delete: projectDefaultPermissionPolicy?.rules?.file_write_delete ?? "allow",
|
||||||
command_execution: projectDefaultPermissionPolicy?.command_execution ?? "allow",
|
command_execution: projectDefaultPermissionPolicy?.rules?.command_execution ?? "allow",
|
||||||
network_api: projectDefaultPermissionPolicy?.network_api ?? "allow",
|
network_api: projectDefaultPermissionPolicy?.rules?.network_api ?? "allow",
|
||||||
task_agent_mutation: projectDefaultPermissionPolicy?.task_agent_mutation ?? "allow",
|
task_agent_mutation: projectDefaultPermissionPolicy?.rules?.task_agent_mutation ?? "allow",
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -4740,7 +4740,8 @@ function ConfigTab({
|
|||||||
<AgentPermissionPolicyEditor
|
<AgentPermissionPolicyEditor
|
||||||
mode="agent-override"
|
mode="agent-override"
|
||||||
value={permissionPolicyValue}
|
value={permissionPolicyValue}
|
||||||
projectDefault={projectDefaultPermissionPolicy}
|
projectDefault={projectDefaultPermissionPolicy?.rules}
|
||||||
|
projectDefaultToolRules={projectDefaultPermissionPolicy?.toolRules}
|
||||||
onChange={(next) => { void handlePermissionPolicyChange(next); }}
|
onChange={(next) => { void handlePermissionPolicyChange(next); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
.agent-policy-row {
|
.agent-policy-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 220px;
|
grid-template-columns: minmax(0, 1fr) minmax(10rem, 14rem);
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -53,6 +53,54 @@
|
|||||||
font-size: 0.75rem;
|
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 {
|
.agent-policy-exempt {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -72,10 +120,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.agent-policy-row {
|
.agent-policy-row,
|
||||||
|
.agent-policy-tool-row,
|
||||||
|
.agent-policy-tool-add {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-policy-tool-row .btn,
|
||||||
|
.agent-policy-tool-add .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-policy-examples,
|
.agent-policy-examples,
|
||||||
.agent-policy-exempt-list {
|
.agent-policy-exempt-list {
|
||||||
padding-left: var(--space-lg);
|
padding-left: var(--space-lg);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "./AgentPermissionPolicyEditor.css";
|
import "./AgentPermissionPolicyEditor.css";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
type AgentPermissionPolicy,
|
type AgentPermissionPolicy,
|
||||||
type AgentPermissionPolicyDisposition,
|
type AgentPermissionPolicyDisposition,
|
||||||
type AgentPermissionPolicyRules,
|
type AgentPermissionPolicyRules,
|
||||||
|
type AgentPermissionPolicyToolRules,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
|
||||||
type Mode = "project-default" | "agent-override";
|
type Mode = "project-default" | "agent-override";
|
||||||
@@ -15,12 +17,27 @@ type Mode = "project-default" | "agent-override";
|
|||||||
interface Props {
|
interface Props {
|
||||||
value: AgentPermissionPolicy | undefined;
|
value: AgentPermissionPolicy | undefined;
|
||||||
projectDefault?: Partial<AgentPermissionPolicyRules>;
|
projectDefault?: Partial<AgentPermissionPolicyRules>;
|
||||||
|
projectDefaultToolRules?: AgentPermissionPolicyToolRules;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
onChange(next: AgentPermissionPolicy | undefined): void;
|
onChange(next: AgentPermissionPolicy | undefined): void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPOSITIONS: AgentPermissionPolicyDisposition[] = ["allow", "require-approval", "block"];
|
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 }> {
|
function getCategoryLabels(t: TFunction<"app">): Record<string, { label: string; description: string }> {
|
||||||
return {
|
return {
|
||||||
@@ -90,12 +107,22 @@ function derivePresetFromRules(rules: AgentPermissionPolicyRules): AgentPermissi
|
|||||||
return "custom";
|
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 { t } = useTranslation("app");
|
||||||
const categoryLabels = getCategoryLabels(t as TFunction<"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 derivedPreset = value ? derivePresetFromRules(value.rules) : "unrestricted";
|
||||||
const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId === "custom" ? derivedPreset : (value?.presetId ?? "unrestricted"));
|
const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId === "custom" ? derivedPreset : (value?.presetId ?? "unrestricted"));
|
||||||
const rules = value?.rules ?? buildAllowRules();
|
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) => {
|
const setPreset = (preset: string) => {
|
||||||
if (mode === "agent-override" && preset === "inherit") {
|
if (mode === "agent-override" && preset === "inherit") {
|
||||||
@@ -103,10 +130,10 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (preset === "custom") {
|
if (preset === "custom") {
|
||||||
onChange({ presetId: "custom", rules: { ...rules } });
|
onChange(withToolRules({ presetId: "custom", rules: { ...rules } }));
|
||||||
return;
|
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) => {
|
const setRule = (category: keyof AgentPermissionPolicyRules, next: string) => {
|
||||||
@@ -114,11 +141,32 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
|||||||
if (!value) return;
|
if (!value) return;
|
||||||
const nextRules = { ...value.rules };
|
const nextRules = { ...value.rules };
|
||||||
nextRules[category] = projectDefault?.[category] ?? "allow";
|
nextRules[category] = projectDefault?.[category] ?? "allow";
|
||||||
onChange({ presetId: "custom", rules: nextRules });
|
onChange(withToolRules({ presetId: "custom", rules: nextRules }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextRules = { ...rules, [category]: next as AgentPermissionPolicyDisposition };
|
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 (
|
return (
|
||||||
@@ -182,6 +230,104 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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}>
|
<details className="agent-policy-exempt" open={false}>
|
||||||
<summary>{t("agentPolicy.exemptTools", "Tools exempt from approval policy")}</summary>
|
<summary>{t("agentPolicy.exemptTools", "Tools exempt from approval policy")}</summary>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -89,7 +89,97 @@ describe("AgentPermissionPolicyEditor", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("fn_research_run (web/research)")).toBeInTheDocument();
|
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", () => {
|
it("renders exempt tools guidance", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES } from "@fusion/core";
|
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 { AgentPermissionPolicyEditor } from "../../AgentPermissionPolicyEditor";
|
||||||
import { AgentProvisioningPolicyEditor } from "../../AgentProvisioningPolicyEditor";
|
import { AgentProvisioningPolicyEditor } from "../../AgentProvisioningPolicyEditor";
|
||||||
import type { SectionBaseProps } from "./context";
|
import type { SectionBaseProps } from "./context";
|
||||||
@@ -22,9 +22,9 @@ export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPer
|
|||||||
<div className="form-group">
|
<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>
|
<small className="settings-muted">{t("settings.agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory", "Per-agent settings override project defaults. Each category controls a separate approval gate.")}</small>
|
||||||
</div>
|
</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,
|
...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>
|
<h4 className="settings-section-heading">{t("settings.agentPermissions.agentProvisioningApprovals", "Agent Provisioning Approvals")}</h4>
|
||||||
|
|||||||
@@ -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 () => {
|
it("POST /api/agents rejects unknown custom permissionPolicy category", async () => {
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildAgentApp(),
|
buildAgentApp(),
|
||||||
@@ -847,6 +924,7 @@ describe("Agent create/update routes", () => {
|
|||||||
permissionPolicy: {
|
permissionPolicy: {
|
||||||
presetId: "custom",
|
presetId: "custom",
|
||||||
rules: { command_execution: "require-approval" },
|
rules: { command_execution: "require-approval" },
|
||||||
|
toolRules: { fn_task_create: "block" },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ "Content-Type": "application/json" },
|
{ "Content-Type": "application/json" },
|
||||||
@@ -858,6 +936,7 @@ describe("Agent create/update routes", () => {
|
|||||||
expect(res.body.permissionPolicy).toMatchObject({
|
expect(res.body.permissionPolicy).toMatchObject({
|
||||||
presetId: "custom",
|
presetId: "custom",
|
||||||
rules: { command_execution: "require-approval" },
|
rules: { command_execution: "require-approval" },
|
||||||
|
toolRules: { fn_task_create: "block" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -448,6 +448,27 @@ describe("PUT /settings", () => {
|
|||||||
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 });
|
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 () => {
|
it("updates settings with auto-backup enabled without logging routine sync failure", async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-backup-routine-"));
|
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-backup-routine-"));
|
||||||
const db = new Database(join(tempDir, ".fusion"));
|
const db = new Database(join(tempDir, ".fusion"));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Request, Response } from "express";
|
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 {
|
import {
|
||||||
ApprovalRequestStore,
|
ApprovalRequestStore,
|
||||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
isAgentPermissionPolicyPresetId,
|
isAgentPermissionPolicyPresetId,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
normalizeAgentPermissionPolicy,
|
normalizeAgentPermissionPolicy,
|
||||||
normalizeAgentPermissionPolicyFromPreset,
|
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
@@ -35,19 +34,30 @@ function parsePermissionPolicyInput(input: unknown) {
|
|||||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||||
throw badRequest("permissionPolicy must be an object");
|
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)) {
|
if (typeof policy.presetId !== "string" || !isAgentPermissionPolicyPresetId(policy.presetId)) {
|
||||||
throw badRequest("permissionPolicy.presetId must be one of: unrestricted, approval-required, locked-down, custom");
|
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))) {
|
if (policy.rules !== undefined && (typeof policy.rules !== "object" || policy.rules === null || Array.isArray(policy.rules))) {
|
||||||
throw badRequest("permissionPolicy.rules must be an object");
|
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>;
|
const customRules = (policy.rules ?? {}) as Record<string, unknown>;
|
||||||
for (const category of Object.keys(customRules)) {
|
for (const category of Object.keys(customRules)) {
|
||||||
if (!(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES as readonly string[]).includes(category)) {
|
if (!(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES as readonly string[]).includes(category)) {
|
||||||
@@ -59,10 +69,15 @@ function parsePermissionPolicyInput(input: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeAgentPermissionPolicy({
|
try {
|
||||||
presetId: "custom",
|
return normalizeAgentPermissionPolicy({
|
||||||
rules: customRules as Partial<AgentPermissionPolicyRules>,
|
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 {
|
function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean {
|
||||||
|
|||||||
@@ -79,4 +79,38 @@ describe("agent action gate project-default resolution", () => {
|
|||||||
|
|
||||||
expect(decision.disposition).toBe("allow");
|
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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,6 +135,49 @@ describe("agent-action-gate", () => {
|
|||||||
expect(blockedDecision.disposition).toBe("block");
|
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) => {
|
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({
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy })).toMatchObject({
|
||||||
category: "exempt",
|
category: "exempt",
|
||||||
|
|||||||
@@ -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) => {
|
it.each(permanentReadonlySiblingTaskCreationTools)("keeps sibling task creation tool %s permanent-readonly", (toolName) => {
|
||||||
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
|
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
|
||||||
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true);
|
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true);
|
||||||
|
|||||||
@@ -171,9 +171,14 @@ export function evaluateAgentActionGate(params: {
|
|||||||
resourceType = "research";
|
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"
|
const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt"
|
||||||
? "allow"
|
? "allow"
|
||||||
: params.permissionPolicy.rules[category];
|
: exactDisposition ?? params.permissionPolicy.rules[category];
|
||||||
|
|
||||||
const dedupeKey = computeApprovalDedupeKey({
|
const dedupeKey = computeApprovalDedupeKey({
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
@@ -194,7 +199,15 @@ export function evaluateAgentActionGate(params: {
|
|||||||
resourceType,
|
resourceType,
|
||||||
...(resourceId ? { resourceId } : {}),
|
...(resourceId ? { resourceId } : {}),
|
||||||
approvalDedupeKey: dedupeKey,
|
approvalDedupeKey: dedupeKey,
|
||||||
metadata: {},
|
metadata: exactDisposition
|
||||||
|
? {
|
||||||
|
permissionPolicyMatch: {
|
||||||
|
type: "toolRule",
|
||||||
|
toolName: params.toolName,
|
||||||
|
disposition: exactDisposition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,10 +78,17 @@ export function classifyPermanentAgentToolCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePolicyDisposition(
|
function resolvePolicyDisposition(
|
||||||
|
toolName: string,
|
||||||
category: PermanentAgentSensitiveActionCategory,
|
category: PermanentAgentSensitiveActionCategory,
|
||||||
gating: PermanentAgentGatingContext | undefined,
|
gating: PermanentAgentGatingContext | undefined,
|
||||||
): AgentPermissionPolicyDisposition {
|
): 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: {
|
export function resolvePermanentAgentToolDecision(input: {
|
||||||
@@ -110,6 +117,6 @@ export function resolvePermanentAgentToolDecision(input: {
|
|||||||
return {
|
return {
|
||||||
...classification,
|
...classification,
|
||||||
toolName: input.toolName,
|
toolName: input.toolName,
|
||||||
disposition: resolvePolicyDisposition(classification.category, input.gating),
|
disposition: resolvePolicyDisposition(input.toolName, classification.category, input.gating),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,20 @@
|
|||||||
"lockedDown": "Locked Down",
|
"lockedDown": "Locked Down",
|
||||||
"preset": "Preset",
|
"preset": "Preset",
|
||||||
"requireApproval": "Require approval",
|
"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": {
|
"agentPrompts": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -180,7 +180,20 @@
|
|||||||
"lockedDown": "Bloqueado",
|
"lockedDown": "Bloqueado",
|
||||||
"preset": "Preajuste",
|
"preset": "Preajuste",
|
||||||
"requireApproval": "Requerir aprobación",
|
"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": {
|
"agentPrompts": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -180,7 +180,20 @@
|
|||||||
"lockedDown": "Verrouillé",
|
"lockedDown": "Verrouillé",
|
||||||
"preset": "Présélection",
|
"preset": "Présélection",
|
||||||
"requireApproval": "Exiger l'approbation",
|
"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": {
|
"agentPrompts": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -180,7 +180,20 @@
|
|||||||
"lockedDown": "잠금",
|
"lockedDown": "잠금",
|
||||||
"preset": "프리셋",
|
"preset": "프리셋",
|
||||||
"requireApproval": "승인 필요",
|
"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": {
|
"agentPrompts": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -180,7 +180,20 @@
|
|||||||
"lockedDown": "锁定",
|
"lockedDown": "锁定",
|
||||||
"preset": "预设",
|
"preset": "预设",
|
||||||
"requireApproval": "需要批准",
|
"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": {
|
"agentPrompts": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -180,7 +180,20 @@
|
|||||||
"lockedDown": "鎖定",
|
"lockedDown": "鎖定",
|
||||||
"preset": "預設",
|
"preset": "預設",
|
||||||
"requireApproval": "需要批准",
|
"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": {
|
"agentPrompts": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
Reference in New Issue
Block a user