FN-7413: configure agent permissions across lifetimes
Enable explicit capability grants and runtime permission policies for both durable and ephemeral agents. - Persist normalized agent capability grants and leave missing permission policies to inherit project defaults at runtime. - Apply action and tool approval gates to permanent agents, ephemeral agents, and fallback task workers. - Add Agent Detail controls, translations, docs, tests, and a changeset for cross-lifetime agent permissions. Files changed: .changeset/fn-7413-agent-permissions.md | 7 ++ docs/agents.md | 12 ++-- docs/settings-reference.md | 11 ++- packages/core/src/__tests__/agent-store.test.ts | 65 +++++++++++++---- packages/core/src/agent-store.ts | 31 ++++++--- packages/core/src/types.ts | 10 +-- .../dashboard/app/components/AgentDetailView.css | 48 +++++++++++++ .../dashboard/app/components/AgentDetailView.tsx | 81 +++++++++++++++++++++- .../settings/sections/AgentPermissionsSection.tsx | 2 +- .../dashboard/src/__tests__/routes-agents.test.ts | 8 +-- .../src/routes/register-agent-core-routes.ts | 32 ++++++--- .../src/__tests__/heartbeat-executor.test.ts | 14 +++- packages/engine/src/agent-heartbeat.ts | 17 ++--- packages/engine/src/executor.ts | 57 +++++++-------- packages/i18n/locales/en/app.json | 12 +++- packages/i18n/locales/es/app.json | 12 +++- packages/i18n/locales/fr/app.json | 12 +++- packages/i18n/locales/ko/app.json | 12 +++- packages/i18n/locales/zh-CN/app.json | 12 +++- packages/i18n/locales/zh-TW/app.json | 12 +++- 20 files changed, 363 insertions(+), 104 deletions(-) Fusion-Task-Id: FN-7413 Fusion-Task-Lineage: bc004572-bde8-4d33-bb27-e941a5bc8884 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7413-agent-permissions.md
Normal file
7
.changeset/fn-7413-agent-permissions.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Allow configuring permissions for ephemeral and permanent agents.
|
||||
category: feature
|
||||
dev: Applies capability grants and runtime permission policies consistently across agent lifetimes.
|
||||
@@ -101,7 +101,7 @@ Every first-class editable agent field has a defined create/edit/import/template
|
||||
| `reportsTo` | ✓ | ✓ | ✓ (from manifest) | Parent agent ID |
|
||||
| `runtimeConfig` | ✓ | ✓ | ✗ | Heartbeat/budget config |
|
||||
| `permissions` | ✓ | ✓ | ✗ | Capability flags |
|
||||
| `permissionPolicy` | ✓ | ✓ | ✗ | Runtime action-gating policy for permanent agents (default/fallback: `unrestricted`) |
|
||||
| `permissionPolicy` | ✓ | ✓ | ✗ | Runtime action-gating policy for permanent and ephemeral agents (project default/fallback: `unrestricted`) |
|
||||
| `instructionsPath` | ✓ | ✓ | ✗ | File-backed instructions path |
|
||||
| `instructionsText` | ✓ | ✓ | ✓ (from manifest `instructionBody`) | Inline instructions |
|
||||
| `soul` | ✓ | ✓ | ✗ | Personality/identity description |
|
||||
@@ -121,7 +121,7 @@ Every first-class editable agent field has a defined create/edit/import/template
|
||||
| `memory` | `memory` | — |
|
||||
| `skills` | `metadata.skills` | — |
|
||||
|
||||
## Permission Policy Presets (Permanent Agents)
|
||||
## Permission Policy Presets (Permanent and Ephemeral Agents)
|
||||
|
||||
`permissionPolicy` is a first-class persisted policy contract for **runtime action gating**, separate from role/capability authorization and separate from dashboard persona presets.
|
||||
|
||||
@@ -148,7 +148,7 @@ V1 runtime action categories:
|
||||
|
||||
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, all agent lifetimes)
|
||||
|
||||
The engine classifies tool calls by behavior (not namespace alone):
|
||||
|
||||
@@ -212,13 +212,15 @@ FN-3973 follow-through: `spawn_agent` evaluation is complete; governance remains
|
||||
Default and legacy fallback behavior:
|
||||
|
||||
- New **non-ephemeral/permanent** agents persist a normalized `permissionPolicy` using preset `unrestricted` when not explicitly provided.
|
||||
- New and existing ephemeral/runtime task-worker agents may store an explicit `permissionPolicy` and canonical `permissions` grants.
|
||||
- Legacy permanent-agent rows missing `permissionPolicy` resolve to the same effective `unrestricted` policy at read time (no eager migration required).
|
||||
- Ephemeral/runtime task-worker agents are intentionally left unchanged and are not backfilled with a default `permissionPolicy`.
|
||||
- Legacy ephemeral/runtime task-worker rows missing `permissionPolicy` are not backfilled on disk; runtime sessions inherit the project `defaultAgentPermissionPolicy` (or `unrestricted` when no project default is configured).
|
||||
- Fallback `executor-FN-*` task workers without a stored agent row use a stable synthetic actor and the same project-default policy, so exact `toolRules` such as `fn_task_create: block` apply consistently.
|
||||
|
||||
Separation of concerns:
|
||||
|
||||
- `permissions` capability flags (plus role defaults) determine what an agent is conceptually authorized to do (for example, `tasks:assign`, `agents:create`).
|
||||
- `permissionPolicy` determines how sensitive runtime actions are gated (`allow`, `block`, `require-approval`) once the capability path is in play.
|
||||
- `permissionPolicy` determines how sensitive runtime actions are gated (`allow`, `block`, `require-approval`) once the capability path is in play. `require-approval` creates an approval request with the permanent or ephemeral actor identity, pauses the associated task safely, and resumes through the existing approval lifecycle.
|
||||
- Dashboard persona presets (`packages/dashboard/app/components/agent-presets/`) are UI templates for identity/behavior and are **not** the source of truth for permission-policy enforcement.
|
||||
|
||||
### CLI agent permission prompts and notifications
|
||||
|
||||
@@ -1519,7 +1519,7 @@ Fusion supports scoped automations and routines:
|
||||
|
||||
### `defaultAgentPermissionPolicy`
|
||||
|
||||
Project-scoped default permission policy for permanent-agent action gates.
|
||||
Project-scoped default permission policy for agent runtime action gates. It applies to permanent agents, stored ephemeral/task-worker agents that do not have an explicit per-agent policy, and fallback `executor-FN-*` task workers that have no stored agent row.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1544,6 +1544,15 @@ Project-scoped default permission policy for permanent-agent action gates.
|
||||
- Missing categories default to `allow` via the built-in `unrestricted` seed; missing or empty `toolRules` preserve legacy category-only behavior.
|
||||
- Runtime precedence is per-agent exact tool rule → per-agent category rule → project default exact tool rule → project default category rule → unrestricted fallback.
|
||||
- Heartbeat-critical coordination/exempt tools remain non-configurable and allowed to prevent deadlocks.
|
||||
- Legacy ephemeral agents without `permissionPolicy` are not rewritten on disk; they inherit this setting when a runtime session is built.
|
||||
|
||||
### `ephemeralAgentsCanCreateTasks`
|
||||
|
||||
Project-scoped backward-compatibility guard for ephemeral/runtime-managed task workers calling `fn_task_create`.
|
||||
|
||||
- Default: `true`, preserving the historical behavior that task workers can create follow-up tasks.
|
||||
- When `false`, ephemeral callers are rejected before task creation even if their unified `permissionPolicy` would otherwise allow `fn_task_create`.
|
||||
- When `true`, the unified runtime policy still applies: `defaultAgentPermissionPolicy.toolRules.fn_task_create = "block"` blocks ephemeral and permanent agents, and `"require-approval"` creates an approval request before the tool can run.
|
||||
|
||||
## Model selection hierarchy
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi }
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js";
|
||||
import { TaskStore } from "../store.js";
|
||||
import { resolveEffectiveAgentPermissionPolicy } from "../agent-permission-policy.js";
|
||||
import { validateSnapshotEnvelope } from "../shared-mesh-state.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -21,7 +22,6 @@ import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||
CheckoutConflictError,
|
||||
getCanonicalAgentAssetDirectoryName,
|
||||
type AgentCapability,
|
||||
@@ -352,16 +352,19 @@ describe("AgentStore", () => {
|
||||
expect(runtimeConfig.runMissedHeartbeatOnStartup).toBe(true);
|
||||
});
|
||||
|
||||
it("stores default unrestricted permission policy for durable agents", async () => {
|
||||
it("leaves durable agents without explicit permission policy to inherit project defaults", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "Policy Default",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
expect(agent.permissionPolicy?.presetId).toBe("unrestricted");
|
||||
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
|
||||
expect(agent.permissionPolicy?.rules[category]).toBe("allow");
|
||||
}
|
||||
expect(agent.permissionPolicy).toBeUndefined();
|
||||
const effective = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, {
|
||||
rules: { task_agent_mutation: "block" },
|
||||
toolRules: { fn_task_create: "block" },
|
||||
});
|
||||
expect(effective.rules.task_agent_mutation).toBe("block");
|
||||
expect(effective.toolRules?.fn_task_create).toBe("block");
|
||||
});
|
||||
|
||||
it("does not backfill permission policy for ephemeral task workers", async () => {
|
||||
@@ -374,6 +377,45 @@ describe("AgentStore", () => {
|
||||
expect(agent.permissionPolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores explicit permission policy for ephemeral task workers", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "executor-FN-101",
|
||||
role: "executor",
|
||||
metadata: { agentKind: "task-worker", taskWorker: true },
|
||||
permissionPolicy: { presetId: "custom", rules: { task_agent_mutation: "block" } },
|
||||
});
|
||||
|
||||
expect(agent.permissionPolicy?.presetId).toBe("custom");
|
||||
expect(agent.permissionPolicy?.rules.task_agent_mutation).toBe("block");
|
||||
expect(agent.permissionPolicy?.rules.git_write).toBe("allow");
|
||||
});
|
||||
|
||||
it("normalizes canonical permission grants for ephemeral and durable agents", async () => {
|
||||
const durable = await store.createAgent({
|
||||
name: "Durable Grants",
|
||||
role: "executor",
|
||||
permissions: { "tasks:assign": true, "agents:create": false, nope: true },
|
||||
});
|
||||
const ephemeral = await store.createAgent({
|
||||
name: "executor-FN-102",
|
||||
role: "executor",
|
||||
metadata: { agentKind: "task-worker", taskWorker: true },
|
||||
permissions: { "tasks:assign": true, "agents:create": false, nope: true },
|
||||
});
|
||||
|
||||
expect(durable.permissions).toEqual({ "tasks:assign": true });
|
||||
expect(ephemeral.permissions).toEqual({ "tasks:assign": true });
|
||||
});
|
||||
|
||||
it("rejects invalid explicit policy payloads for ephemeral task workers", async () => {
|
||||
await expect(store.createAgent({
|
||||
name: "executor-FN-103",
|
||||
role: "executor",
|
||||
metadata: { agentKind: "task-worker", taskWorker: true },
|
||||
permissionPolicy: { presetId: "custom", rules: { task_agent_mutation: "nope" as never } },
|
||||
})).rejects.toThrow(/Invalid permission policy disposition/);
|
||||
});
|
||||
|
||||
it("preserves custom metadata", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "With Meta",
|
||||
@@ -454,16 +496,13 @@ describe("AgentStore", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves legacy durable agents without permissionPolicy to unrestricted", async () => {
|
||||
it("leaves legacy durable agents without permissionPolicy to inherit project defaults at runtime", async () => {
|
||||
const created = await store.createAgent({ name: "Legacy Policy", role: "executor" });
|
||||
const testDb = (store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => unknown } } }).db;
|
||||
testDb.prepare("UPDATE agents SET data = json_remove(data, '$.permissionPolicy') WHERE id = ?").run(created.id);
|
||||
|
||||
const hydrated = await store.getAgent(created.id);
|
||||
expect(hydrated?.permissionPolicy?.presetId).toBe("unrestricted");
|
||||
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
|
||||
expect(hydrated?.permissionPolicy?.rules[category]).toBe("allow");
|
||||
}
|
||||
expect(hydrated?.permissionPolicy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1011,11 +1050,11 @@ describe("AgentStore", () => {
|
||||
name: "Configurable",
|
||||
role: "executor",
|
||||
runtimeConfig: { heartbeatIntervalMs: 30000 },
|
||||
permissions: { canReview: false },
|
||||
permissions: { "tasks:assign": false },
|
||||
});
|
||||
|
||||
await store.updateAgent(created.id, { runtimeConfig: { heartbeatIntervalMs: 10000 } });
|
||||
await store.updateAgent(created.id, { permissions: { canReview: true, canExecute: true } });
|
||||
await store.updateAgent(created.id, { permissions: { "tasks:assign": true, "agents:create": true } });
|
||||
await store.updateAgent(created.id, { permissionPolicy: { presetId: "locked-down", rules: {
|
||||
"git-write": "block",
|
||||
"file-write-delete": "block",
|
||||
|
||||
@@ -54,9 +54,9 @@ import {
|
||||
} from "./types.js";
|
||||
import type { CentralClaimStore, CheckoutClaimContext, RunMutationContext } from "./types.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
import { computeAccessState, normalizePermissions } from "./agent-permissions.js";
|
||||
import { canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, formatRoleMismatchReason } from "./agent-role-policy.js";
|
||||
import { normalizeAgentPermissionPolicy, resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js";
|
||||
import { normalizeAgentPermissionPolicy } from "./agent-permission-policy.js";
|
||||
import { Database } from "./db.js";
|
||||
import { createAgentRunSnapshot, createAgentSnapshot, validateSnapshotEnvelope, type AgentRunSnapshot, type AgentSnapshot } from "./shared-mesh-state.js";
|
||||
|
||||
@@ -195,6 +195,13 @@ export function formatCurrentTaskLine(taskId: string, linkedTask: Pick<Task, "co
|
||||
* Returns `undefined` when there's nothing to persist (only possible for
|
||||
* ephemeral agents with no incoming config).
|
||||
*/
|
||||
function normalizeStoredPermissions(raw: Record<string, boolean> | undefined): Record<string, boolean> | undefined {
|
||||
if (!raw) return undefined;
|
||||
const normalized = normalizePermissions(raw);
|
||||
if (normalized.size === 0) return undefined;
|
||||
return Object.fromEntries([...normalized].map((permission) => [permission, true]));
|
||||
}
|
||||
|
||||
function resolveCreationRuntimeConfig(
|
||||
incoming: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown>,
|
||||
@@ -639,9 +646,13 @@ export class AgentStore extends EventEmitter {
|
||||
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
|
||||
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name));
|
||||
|
||||
const normalizedPermissionPolicy = ephemeral
|
||||
? input.permissionPolicy
|
||||
: resolveEffectiveAgentPermissionPolicy(input.permissionPolicy);
|
||||
/*
|
||||
FNXC:AgentPermissions 2026-07-02-00:00:
|
||||
FN-7413 makes permission configuration lifetime-agnostic: durable identity agents and ephemeral task-worker agents may both store explicit capability grants and runtime permission policies. Missing policies stay absent for every lifetime so legacy rows and newly created agents inherit the project default at runtime instead of being materialized as explicit unrestricted overrides.
|
||||
*/
|
||||
const normalizedPermissionPolicy = input.permissionPolicy
|
||||
? normalizeAgentPermissionPolicy(input.permissionPolicy)
|
||||
: undefined;
|
||||
|
||||
const agent: Agent = {
|
||||
id: agentId,
|
||||
@@ -659,7 +670,7 @@ export class AgentStore extends EventEmitter {
|
||||
...(input.imageUrl && { imageUrl: input.imageUrl }),
|
||||
...(input.reportsTo && { reportsTo: input.reportsTo }),
|
||||
...(runtimeConfig && { runtimeConfig }),
|
||||
...(input.permissions && { permissions: input.permissions }),
|
||||
...(normalizeStoredPermissions(input.permissions) && { permissions: normalizeStoredPermissions(input.permissions) }),
|
||||
...(normalizedPermissionPolicy && { permissionPolicy: normalizedPermissionPolicy }),
|
||||
...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
|
||||
...(input.instructionsText && { instructionsText: input.instructionsText }),
|
||||
@@ -1117,7 +1128,7 @@ export class AgentStore extends EventEmitter {
|
||||
...("reportsTo" in updates && { reportsTo: updates.reportsTo }),
|
||||
...("runtimeConfig" in updates && { runtimeConfig: updates.runtimeConfig }),
|
||||
...("pauseReason" in updates && { pauseReason: updates.pauseReason }),
|
||||
...("permissions" in updates && { permissions: updates.permissions }),
|
||||
...("permissions" in updates && { permissions: normalizeStoredPermissions(updates.permissions) }),
|
||||
...("permissionPolicy" in updates && { permissionPolicy: normalizedUpdatedPermissionPolicy }),
|
||||
...("lastError" in updates && { lastError: updates.lastError }),
|
||||
...("totalInputTokens" in updates && { totalInputTokens: updates.totalInputTokens }),
|
||||
@@ -2860,10 +2871,8 @@ export class AgentStore extends EventEmitter {
|
||||
reportsTo: data.reportsTo,
|
||||
runtimeConfig: data.runtimeConfig,
|
||||
pauseReason: data.pauseReason,
|
||||
permissions: data.permissions,
|
||||
permissionPolicy: isEphemeralAgent(data)
|
||||
? data.permissionPolicy
|
||||
: resolveEffectiveAgentPermissionPolicy(data.permissionPolicy),
|
||||
permissions: normalizeStoredPermissions(data.permissions),
|
||||
permissionPolicy: data.permissionPolicy ? normalizeAgentPermissionPolicy(data.permissionPolicy) : undefined,
|
||||
totalInputTokens: data.totalInputTokens,
|
||||
totalOutputTokens: data.totalOutputTokens,
|
||||
lastError: data.lastError,
|
||||
|
||||
@@ -4033,10 +4033,10 @@ export interface ProjectSettings {
|
||||
/** Backend ids that may bootstrap without approval. Default: ["native"]. */
|
||||
autoApproveBackendIds?: string[];
|
||||
};
|
||||
/** Project default runtime permission-policy overrides for permanent agents.
|
||||
/** Project default runtime permission-policy overrides for all agent lifetimes.
|
||||
* Rules are a partial map of category -> disposition (`allow` | `block` | `require-approval`).
|
||||
* Tool rules are exact tool-name overrides that take precedence over category rules.
|
||||
* Missing categories and tools inherit the built-in `unrestricted` seed (`allow`). */
|
||||
* Missing categories and tools inherit the built-in `unrestricted` seed (`allow`). Agents without an explicit policy, including legacy ephemeral task workers, inherit this project default at runtime. */
|
||||
defaultAgentPermissionPolicy?: {
|
||||
rules?: Partial<AgentPermissionPolicyRules>;
|
||||
toolRules?: AgentPermissionPolicyToolRules;
|
||||
@@ -6388,7 +6388,7 @@ export type AgentPermissionPolicyDisposition = "allow" | "block" | "require-appr
|
||||
/** 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 agent gating context consumed by engine runtime wrappers. The legacy name is retained for API compatibility, but the context applies to permanent identity agents and ephemeral task-worker agents. */
|
||||
export interface PermanentAgentGatingContext {
|
||||
permissionPolicy?: {
|
||||
presetId: string;
|
||||
@@ -6407,7 +6407,7 @@ export interface PermanentAgentGatingContext {
|
||||
findPendingApprovalRequest?: (dedupeKey: string) => Promise<ApprovalRequest | null>;
|
||||
}
|
||||
|
||||
/** Built-in permission policy preset identifiers for permanent agents. */
|
||||
/** Built-in permission policy preset identifiers for agent runtime policies. */
|
||||
export const AGENT_PERMISSION_POLICY_PRESET_IDS = ["unrestricted", "approval-required", "locked-down", "custom"] as const;
|
||||
|
||||
/** A single built-in permission policy preset identifier. */
|
||||
@@ -6420,7 +6420,7 @@ export type AgentPermissionPolicyRules = Record<
|
||||
>;
|
||||
|
||||
/**
|
||||
* First-class persisted permission policy contract for permanent agents.
|
||||
* First-class persisted permission policy contract for permanent and ephemeral 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.
|
||||
|
||||
@@ -2163,3 +2163,51 @@ FNXC:AgentDetailView 2026-06-26-01:00:
|
||||
background: var(--surface);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-capability-grants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-capability-grants h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.agent-capability-grants-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--size-card-min, 16rem)), 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-capability-grant-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.agent-capability-grant-name {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-capability-grants-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-capability-grant-row,
|
||||
.agent-permission-inherit-banner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import remarkGfm from "remark-gfm";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core";
|
||||
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission } from "@fusion/core";
|
||||
import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
@@ -43,6 +43,20 @@ function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AgentPermissions 2026-07-02-00:00:
|
||||
Agent Detail must show explicit capability grants next to role-default grants for both permanent and ephemeral agents. Keep this UI map aligned with core ROLE_DEFAULT_PERMISSIONS while the dashboard source build imports constants through the core types surface.
|
||||
*/
|
||||
const AGENT_ROLE_DEFAULT_PERMISSION_MAP: Record<AgentCapability, AgentPermission[]> = {
|
||||
triage: ["tasks:create", "agents:view", "messages:read"],
|
||||
executor: ["tasks:execute", "agents:view", "messages:read", "messages:send"],
|
||||
reviewer: ["tasks:review", "agents:view", "messages:read", "messages:send"],
|
||||
merger: ["tasks:merge", "agents:view", "messages:read"],
|
||||
scheduler: ["tasks:assign", "tasks:create", "tasks:archive", "agents:view", "automations:manage", "missions:manage", "messages:read"],
|
||||
engineer: ["tasks:execute", "tasks:review", "agents:view", "messages:read", "messages:send"],
|
||||
custom: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp to a relative time string.
|
||||
*/
|
||||
@@ -3759,6 +3773,7 @@ function ConfigTab({
|
||||
const [modelValue, setModelValue] = useState(initialModelValue);
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
|
||||
const [permissionPolicyValue, setPermissionPolicyValue] = useState<AgentPermissionPolicy | undefined>(agent.permissionPolicy);
|
||||
const [permissionsValue, setPermissionsValue] = useState<Record<string, boolean>>(agent.permissions ?? {});
|
||||
const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState<{ rules?: Partial<AgentPermissionPolicyRules>; toolRules?: AgentPermissionPolicy["toolRules"] } | undefined>(undefined);
|
||||
|
||||
const managerSelection = reportsToValue.trim();
|
||||
@@ -3856,6 +3871,10 @@ function ConfigTab({
|
||||
setPermissionPolicyValue(agent.permissionPolicy);
|
||||
}, [agent.permissionPolicy]);
|
||||
|
||||
useEffect(() => {
|
||||
setPermissionsValue(agent.permissions ?? {});
|
||||
}, [agent.permissions]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettingsByScope(projectId)
|
||||
.then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy))
|
||||
@@ -3873,6 +3892,29 @@ function ConfigTab({
|
||||
}
|
||||
};
|
||||
|
||||
const roleDefaultPermissions = useMemo(() => new Set<AgentPermission>(AGENT_ROLE_DEFAULT_PERMISSION_MAP[roleValue] ?? []), [roleValue]);
|
||||
const explicitPermissionSet = useMemo(() => new Set<AgentPermission>(
|
||||
AGENT_PERMISSIONS.filter((permission) => permissionsValue[permission] === true),
|
||||
), [permissionsValue]);
|
||||
|
||||
const handleCapabilityPermissionChange = async (permission: AgentPermission, granted: boolean) => {
|
||||
const next = { ...permissionsValue };
|
||||
if (granted) {
|
||||
next[permission] = true;
|
||||
} else {
|
||||
delete next[permission];
|
||||
}
|
||||
setPermissionsValue(next);
|
||||
try {
|
||||
await updateAgent(agent.id, { permissions: next }, projectId);
|
||||
await onSaved();
|
||||
addToast(t("agents.capabilityPermissionsUpdated", "Capability grants updated"), "success");
|
||||
} catch (err) {
|
||||
setPermissionsValue(agent.permissions ?? {});
|
||||
addToast(t("agents.capabilityPermissionsFailed", "Failed to update capability grants: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Load candidate managers for reports-to dropdown
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -4715,6 +4757,41 @@ function ConfigTab({
|
||||
<p className="config-description">
|
||||
{t("agents.permissionsDescription", "Per-agent settings override project defaults. Each category controls a separate approval gate.")}
|
||||
</p>
|
||||
<div className="agent-capability-grants" data-testid="agent-capability-grants">
|
||||
<h4>{t("agents.capabilityGrantsTitle", "Capability grants")}</h4>
|
||||
<p className="config-hint">
|
||||
{t("agents.capabilityGrantsDescription", "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.")}
|
||||
</p>
|
||||
<div className="agent-capability-grants-grid">
|
||||
{AGENT_PERMISSIONS.map((permission) => {
|
||||
const roleDefault = roleDefaultPermissions.has(permission);
|
||||
const explicitGrant = explicitPermissionSet.has(permission);
|
||||
const inputId = `agent-capability-${agent.id}-${permission.replace(/[^a-z0-9]+/gi, "-")}`;
|
||||
return (
|
||||
<label key={permission} className="agent-capability-grant-row" htmlFor={inputId}>
|
||||
<span>
|
||||
<span className="agent-capability-grant-name">{permission}</span>
|
||||
<span className="config-hint">
|
||||
{roleDefault
|
||||
? t("agents.roleDefaultGrant", "Role default grant")
|
||||
: explicitGrant
|
||||
? t("agents.explicitGrant", "Explicit grant")
|
||||
: t("agents.notGranted", "Not granted")}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
checked={explicitGrant}
|
||||
aria-label={t("agents.toggleCapabilityGrant", "Toggle explicit grant for {{permission}}", { permission })}
|
||||
onChange={(event) => { void handleCapabilityPermissionChange(permission, event.target.checked); }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissionPolicyValue === undefined ? (
|
||||
<div className="agent-permission-inherit-banner">
|
||||
<span>{t("agents.inheritingProjectDefault", "Inheriting project default — no per-agent override set")}</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPer
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.agentPermissions.agentPermissions", "Agent Permissions")}</h4>
|
||||
<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", "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle.")}</small>
|
||||
</div>
|
||||
<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,
|
||||
|
||||
@@ -648,7 +648,7 @@ describe("Agent create/update routes", () => {
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
permissions: { "tasks:assign": true },
|
||||
permissionPolicy: { presetId: "approval-required" },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
@@ -666,7 +666,7 @@ describe("Agent create/update routes", () => {
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
permissions: { "tasks:assign": true },
|
||||
permissionPolicy: {
|
||||
presetId: "approval-required",
|
||||
rules: {
|
||||
@@ -697,7 +697,7 @@ describe("Agent create/update routes", () => {
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
permissions: { "agents:create": true },
|
||||
permissionPolicy: { presetId: "locked-down" },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
@@ -719,7 +719,7 @@ describe("Agent create/update routes", () => {
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
permissions: { "agents:create": true },
|
||||
permissionPolicy: {
|
||||
presetId: "locked-down",
|
||||
rules: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissi
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||
AGENT_PERMISSIONS,
|
||||
aggregateTaskTokenTotalsByAgentLink,
|
||||
getDefaultHeartbeatProcedurePath,
|
||||
isAgentPermissionPolicyPresetId,
|
||||
@@ -29,6 +30,26 @@ const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||
};
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||
const VALID_POLICY_DISPOSITIONS: readonly AgentPermissionPolicyDisposition[] = ["allow", "block", "require-approval"] as const;
|
||||
const VALID_AGENT_PERMISSION_KEYS = new Set<string>(AGENT_PERMISSIONS);
|
||||
|
||||
function parsePermissionsInput(input: unknown): Record<string, boolean> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
throw badRequest("permissions must be an object");
|
||||
}
|
||||
const normalized: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (!VALID_AGENT_PERMISSION_KEYS.has(key)) {
|
||||
throw badRequest(`permissions contains unknown key: ${key}`);
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw badRequest(`permissions.${key} must be a boolean`);
|
||||
}
|
||||
if (value) {
|
||||
normalized[key] = true;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parsePermissionPolicyInput(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
@@ -230,9 +251,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
if (runtimeConfig !== undefined && (typeof runtimeConfig !== "object" || runtimeConfig === null || Array.isArray(runtimeConfig))) {
|
||||
throw badRequest("runtimeConfig must be an object");
|
||||
}
|
||||
if (permissions !== undefined && (typeof permissions !== "object" || permissions === null || Array.isArray(permissions))) {
|
||||
throw badRequest("permissions must be an object");
|
||||
}
|
||||
const normalizedPermissions = permissions !== undefined && permissions !== null ? parsePermissionsInput(permissions) : undefined;
|
||||
let normalizedPermissionPolicy;
|
||||
if (permissionPolicy !== undefined && permissionPolicy !== null) {
|
||||
normalizedPermissionPolicy = parsePermissionPolicyInput(permissionPolicy);
|
||||
@@ -291,7 +310,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
icon: icon ?? undefined,
|
||||
reportsTo: reportsTo ?? undefined,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
permissions: normalizedPermissions,
|
||||
permissionPolicy: normalizedPermissionPolicy,
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
@@ -671,10 +690,7 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
}
|
||||
|
||||
if ("permissions" in body) {
|
||||
if (body.permissions !== null && (typeof body.permissions !== "object" || Array.isArray(body.permissions))) {
|
||||
throw badRequest("permissions must be an object");
|
||||
}
|
||||
updates.permissions = body.permissions ?? undefined;
|
||||
updates.permissions = body.permissions !== null ? parsePermissionsInput(body.permissions) : undefined;
|
||||
}
|
||||
|
||||
if ("permissionPolicy" in body) {
|
||||
|
||||
@@ -788,7 +788,7 @@ describe("executeHeartbeat", () => {
|
||||
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
|
||||
});
|
||||
|
||||
it("omits permanent-agent gating context for ephemeral heartbeat agents", async () => {
|
||||
it("passes permission gating context for ephemeral heartbeat agents", async () => {
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: "FN-001",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
@@ -805,8 +805,16 @@ describe("executeHeartbeat", () => {
|
||||
permanentAgentGating?: unknown;
|
||||
actionGateContext?: unknown;
|
||||
};
|
||||
expect(args.permanentAgentGating).toBeUndefined();
|
||||
expect(args.actionGateContext).toBeUndefined();
|
||||
expect(args.permanentAgentGating).toMatchObject({
|
||||
permissionPolicy: { presetId: "unrestricted" },
|
||||
requester: { actorId: "agent-001", actorName: "executor-ephemeral" },
|
||||
});
|
||||
expect(args.actionGateContext).toMatchObject({
|
||||
agentId: "agent-001",
|
||||
agentName: "executor-ephemeral",
|
||||
isEphemeral: true,
|
||||
permissionPolicy: { presetId: "unrestricted" },
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependency validation", () => {
|
||||
|
||||
@@ -1169,15 +1169,12 @@ export class HeartbeatMonitor {
|
||||
return this.approvalRequestStore;
|
||||
}
|
||||
|
||||
private buildActionGateContext(agent: Agent, taskId?: string, runId?: string, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]> }): AgentActionGateContext | undefined {
|
||||
if (isEphemeralAgent(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
private buildActionGateContext(agent: Agent, taskId?: string, runId?: string, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]>; toolRules?: import("@fusion/core").AgentPermissionPolicyToolRules }): AgentActionGateContext | undefined {
|
||||
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy);
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
isEphemeral: false,
|
||||
isEphemeral: isEphemeralAgent(agent),
|
||||
taskId,
|
||||
runId,
|
||||
permissionPolicy: policy,
|
||||
@@ -1222,11 +1219,7 @@ export class HeartbeatMonitor {
|
||||
};
|
||||
}
|
||||
|
||||
private buildPermanentAgentGatingContext(agent: Agent, taskId?: string, runId?: string, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]> }): import("@fusion/core").PermanentAgentGatingContext | undefined {
|
||||
if (isEphemeralAgent(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private buildPermanentAgentGatingContext(agent: Agent, taskId?: string, runId?: string, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]>; toolRules?: import("@fusion/core").AgentPermissionPolicyToolRules }): import("@fusion/core").PermanentAgentGatingContext | undefined {
|
||||
return {
|
||||
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy),
|
||||
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||
@@ -1239,13 +1232,13 @@ export class HeartbeatMonitor {
|
||||
targetAction: {
|
||||
category,
|
||||
action: toolName,
|
||||
summary: `Permanent-agent gated action for ${toolName}`,
|
||||
summary: `Agent gated action for ${toolName}`,
|
||||
resourceType: "tool",
|
||||
resourceId: toolName,
|
||||
context: {
|
||||
toolName,
|
||||
toolArgs: args,
|
||||
source: "permanent-agent-gating",
|
||||
source: "agent-gating",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2174,23 +2174,27 @@ export class TaskExecutor {
|
||||
return this._approvalRequestStore;
|
||||
}
|
||||
|
||||
private buildActionGateContext(taskId: string | undefined, agent: Agent | null | undefined, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]> }): AgentActionGateContext | undefined {
|
||||
if (!agent || isEphemeralAgent(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
const policy = resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy);
|
||||
private buildActionGateContext(taskId: string | undefined, agent: Agent | null | undefined, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]>; toolRules?: import("@fusion/core").AgentPermissionPolicyToolRules }): AgentActionGateContext | undefined {
|
||||
/*
|
||||
FNXC:AgentPermissions 2026-07-02-00:00:
|
||||
FN-7413 requires task-scoped runtime gates for permanent identity agents, stored ephemeral agents, and fallback executor-FN task workers. Use a stable synthetic actor for fallback workers so category/exact-tool rules and approval dedupe keys apply even when no agent row exists.
|
||||
*/
|
||||
const actorId = agent?.id ?? `executor-${taskId ?? "unknown"}`;
|
||||
const actorName = agent?.name ?? `Task worker ${taskId ?? "unknown"}`;
|
||||
const isEphemeral = !agent || isEphemeralAgent(agent);
|
||||
const policy = resolveEffectiveAgentPermissionPolicy(agent?.permissionPolicy, projectDefaultPolicy);
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
isEphemeral: false,
|
||||
agentId: actorId,
|
||||
agentName: actorName,
|
||||
isEphemeral,
|
||||
taskId,
|
||||
runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined,
|
||||
permissionPolicy: policy,
|
||||
createApprovalRequest: async (decision, args) => this.approvalRequestStore.create({
|
||||
requester: {
|
||||
actorId: agent.id,
|
||||
actorId,
|
||||
actorType: "agent",
|
||||
actorName: agent.name,
|
||||
actorName,
|
||||
},
|
||||
taskId,
|
||||
runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined,
|
||||
@@ -2209,16 +2213,16 @@ export class TaskExecutor {
|
||||
},
|
||||
}),
|
||||
findApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
|
||||
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: actorId, taskId, dedupeKey });
|
||||
return latest ? { id: latest.id, status: latest.status } : null;
|
||||
},
|
||||
findPendingApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
|
||||
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: actorId, taskId, dedupeKey });
|
||||
return latest?.status === "pending" ? { id: latest.id } : null;
|
||||
},
|
||||
pauseForApproval: async ({ approvalRequestId, decision }) => {
|
||||
if (taskId) {
|
||||
await this.store.pauseTask(taskId, true, this.getRunContextFor(taskId), { pausedByAgentId: agent.id });
|
||||
await this.store.pauseTask(taskId, true, this.getRunContextFor(taskId), { pausedByAgentId: actorId });
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`,
|
||||
@@ -2226,57 +2230,56 @@ export class TaskExecutor {
|
||||
this.getRunContextFor(taskId),
|
||||
);
|
||||
}
|
||||
if (this.options.agentStore) {
|
||||
if (agent && this.options.agentStore) {
|
||||
await this.options.agentStore.updateAgentState(agent.id, "paused");
|
||||
await this.options.agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
|
||||
}
|
||||
},
|
||||
markApprovalCompleted: async (approvalRequestId) => {
|
||||
await this.approvalRequestStore.markCompleted(approvalRequestId, {
|
||||
actor: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||
actor: { actorId, actorType: "agent", actorName },
|
||||
note: "Tool executed after approval",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildPermanentAgentGatingContext(taskId: string | undefined, agent: Agent | null | undefined, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]> }): import("@fusion/core").PermanentAgentGatingContext | undefined {
|
||||
if (!agent || isEphemeralAgent(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
private buildPermanentAgentGatingContext(taskId: string | undefined, agent: Agent | null | undefined, projectDefaultPolicy?: { rules?: Partial<import("@fusion/core").AgentPermissionPolicy["rules"]>; toolRules?: import("@fusion/core").AgentPermissionPolicyToolRules }): import("@fusion/core").PermanentAgentGatingContext | undefined {
|
||||
const actorId = agent?.id ?? `executor-${taskId ?? "unknown"}`;
|
||||
const actorName = agent?.name ?? `Task worker ${taskId ?? "unknown"}`;
|
||||
|
||||
return {
|
||||
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent.permissionPolicy, projectDefaultPolicy),
|
||||
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent?.permissionPolicy, projectDefaultPolicy),
|
||||
requester: {
|
||||
actorId: agent.id,
|
||||
actorId,
|
||||
actorType: "agent",
|
||||
actorName: agent.name,
|
||||
actorName,
|
||||
},
|
||||
taskId,
|
||||
runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined,
|
||||
createApprovalRequest: async ({ category, toolName, args }) => this.approvalRequestStore.create({
|
||||
requester: {
|
||||
actorId: agent.id,
|
||||
actorId,
|
||||
actorType: "agent",
|
||||
actorName: agent.name,
|
||||
actorName,
|
||||
},
|
||||
taskId,
|
||||
runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined,
|
||||
targetAction: {
|
||||
category,
|
||||
action: toolName,
|
||||
summary: `Permanent-agent gated action for ${toolName}`,
|
||||
summary: `Agent gated action for ${toolName}`,
|
||||
resourceType: "tool",
|
||||
resourceId: toolName,
|
||||
context: {
|
||||
toolName,
|
||||
toolArgs: args,
|
||||
source: "permanent-agent-gating",
|
||||
source: "agent-gating",
|
||||
},
|
||||
},
|
||||
}),
|
||||
findPendingApprovalRequest: async (dedupeKey) => {
|
||||
const pending = this.approvalRequestStore.list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||
const pending = this.approvalRequestStore.list({ status: "pending", requesterActorId: actorId, taskId, limit: 100 });
|
||||
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1126,7 +1126,15 @@
|
||||
"weekly": "Weekly",
|
||||
"workingOn": "Working on",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out"
|
||||
"zoomOut": "Zoom out",
|
||||
"capabilityGrantsTitle": "Capability grants",
|
||||
"capabilityGrantsDescription": "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.",
|
||||
"roleDefaultGrant": "Role default grant",
|
||||
"explicitGrant": "Explicit grant",
|
||||
"notGranted": "Not granted",
|
||||
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
|
||||
"capabilityPermissionsUpdated": "Capability grants updated",
|
||||
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}"
|
||||
},
|
||||
"app": {
|
||||
"backendError": {
|
||||
@@ -5642,7 +5650,7 @@
|
||||
"agentPermissions": "Agent Permissions",
|
||||
"agentProvisioningApprovals": "Agent Provisioning Approvals",
|
||||
"configureProjectLevelApprovalBehaviorForDurableProvisioning": " Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). ",
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Per-agent settings override project defaults. Each category controls a separate approval gate."
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle."
|
||||
},
|
||||
"appearance": {
|
||||
"hideAISessionNotificationBanners": "Hide AI session notification banners",
|
||||
|
||||
@@ -1116,7 +1116,15 @@
|
||||
"weekly": "Semanal",
|
||||
"workingOn": "Trabajando en:",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar"
|
||||
"zoomOut": "Alejar",
|
||||
"capabilityGrantsTitle": "Capability grants",
|
||||
"capabilityGrantsDescription": "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.",
|
||||
"roleDefaultGrant": "Role default grant",
|
||||
"explicitGrant": "Explicit grant",
|
||||
"notGranted": "Not granted",
|
||||
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
|
||||
"capabilityPermissionsUpdated": "Capability grants updated",
|
||||
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}"
|
||||
},
|
||||
"app": {
|
||||
"backendError": {
|
||||
@@ -5632,7 +5640,7 @@
|
||||
"agentPermissions": "",
|
||||
"agentProvisioningApprovals": "",
|
||||
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle."
|
||||
},
|
||||
"appearance": {
|
||||
"hideAISessionNotificationBanners": "",
|
||||
|
||||
@@ -1116,7 +1116,15 @@
|
||||
"weekly": "Hebdomadaire",
|
||||
"workingOn": "En cours :",
|
||||
"zoomIn": "Zoomer",
|
||||
"zoomOut": "Dézoomer"
|
||||
"zoomOut": "Dézoomer",
|
||||
"capabilityGrantsTitle": "Capability grants",
|
||||
"capabilityGrantsDescription": "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.",
|
||||
"roleDefaultGrant": "Role default grant",
|
||||
"explicitGrant": "Explicit grant",
|
||||
"notGranted": "Not granted",
|
||||
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
|
||||
"capabilityPermissionsUpdated": "Capability grants updated",
|
||||
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}"
|
||||
},
|
||||
"app": {
|
||||
"backendError": {
|
||||
@@ -5632,7 +5640,7 @@
|
||||
"agentPermissions": "",
|
||||
"agentProvisioningApprovals": "",
|
||||
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle."
|
||||
},
|
||||
"appearance": {
|
||||
"hideAISessionNotificationBanners": "",
|
||||
|
||||
@@ -1116,7 +1116,15 @@
|
||||
"weekly": "주간",
|
||||
"workingOn": "작업 중",
|
||||
"zoomIn": "확대",
|
||||
"zoomOut": "축소"
|
||||
"zoomOut": "축소",
|
||||
"capabilityGrantsTitle": "Capability grants",
|
||||
"capabilityGrantsDescription": "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.",
|
||||
"roleDefaultGrant": "Role default grant",
|
||||
"explicitGrant": "Explicit grant",
|
||||
"notGranted": "Not granted",
|
||||
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
|
||||
"capabilityPermissionsUpdated": "Capability grants updated",
|
||||
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}"
|
||||
},
|
||||
"app": {
|
||||
"backendError": {
|
||||
@@ -5632,7 +5640,7 @@
|
||||
"agentPermissions": "",
|
||||
"agentProvisioningApprovals": "",
|
||||
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle."
|
||||
},
|
||||
"appearance": {
|
||||
"hideAISessionNotificationBanners": "",
|
||||
|
||||
@@ -1116,7 +1116,15 @@
|
||||
"weekly": "每周",
|
||||
"workingOn": "正在处理:",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小"
|
||||
"zoomOut": "缩小",
|
||||
"capabilityGrantsTitle": "Capability grants",
|
||||
"capabilityGrantsDescription": "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.",
|
||||
"roleDefaultGrant": "Role default grant",
|
||||
"explicitGrant": "Explicit grant",
|
||||
"notGranted": "Not granted",
|
||||
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
|
||||
"capabilityPermissionsUpdated": "Capability grants updated",
|
||||
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}"
|
||||
},
|
||||
"app": {
|
||||
"backendError": {
|
||||
@@ -5632,7 +5640,7 @@
|
||||
"agentPermissions": "",
|
||||
"agentProvisioningApprovals": "",
|
||||
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle."
|
||||
},
|
||||
"appearance": {
|
||||
"hideAISessionNotificationBanners": "",
|
||||
|
||||
@@ -1116,7 +1116,15 @@
|
||||
"weekly": "每週",
|
||||
"workingOn": "正在處理:",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "縮小"
|
||||
"zoomOut": "縮小",
|
||||
"capabilityGrantsTitle": "Capability grants",
|
||||
"capabilityGrantsDescription": "Explicit grants add to this agent's role defaults and apply to both permanent and ephemeral agents.",
|
||||
"roleDefaultGrant": "Role default grant",
|
||||
"explicitGrant": "Explicit grant",
|
||||
"notGranted": "Not granted",
|
||||
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
|
||||
"capabilityPermissionsUpdated": "Capability grants updated",
|
||||
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}"
|
||||
},
|
||||
"app": {
|
||||
"backendError": {
|
||||
@@ -5632,7 +5640,7 @@
|
||||
"agentPermissions": "",
|
||||
"agentProvisioningApprovals": "",
|
||||
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
|
||||
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle."
|
||||
},
|
||||
"appearance": {
|
||||
"hideAISessionNotificationBanners": "",
|
||||
|
||||
Reference in New Issue
Block a user