Files
fusion/plugins/fusion-plugin-acp-runtime/src/control-handler.ts
gsxdsm 189f237a07 fix(security): make the approval and permission gates actually enforce (#2457)
## Why

An AI agent autonomously deleted a live task (FN-8609) via
`fn_task_delete`. Investigating that led to two audits, which found the
gate machinery is well-built and its *enforcement* well-tested — but the
*decision boundary* was unguarded and the tools that matter never
reached the gate at all.

The headline defect: all ~76 tools in `packages/cli/src/extension.ts`
are delivered through `hostExtensionPaths` → `DefaultResourceLoader`,
which never passes through `wrapToolsWithActionGate`. `fn_task_delete`
*was* classified as gated in `gating-classifications.ts` — that
classification was dead code. Roughly 15 destructive tools were ungated
even for an operator who had locked their policy all the way down.

Second: `POST /api/approvals/:id/decision` took the approver identity
from the request body, validated for shape only, with no check that the
decider was human or differed from the requester. Combined with a bash
tool whose command string was never inspected (only `params.cwd`), an
agent could read the daemon token from `~/.fusion/settings.json`, find
its own pending request, and approve it.

## Scope

This does **not** tighten defaults. The `unrestricted` preset is
byte-for-byte unchanged — `git diff` on `agent-permission-policy.ts` is
empty — and regression tests assert that an out-of-the-box install
behaves exactly as before. The bug was never "the default is too
permissive"; it was "strict policy doesn't enforce." This makes turning
security up actually work.

The one deliberate exception: the containment that stops an agent
escalating its *own* privileges (reading the daemon token / credentials,
calling the approvals API to self-approve) applies at every preset
including `unrestricted`. That is a privilege-escalation boundary rather
than a permission preference — if it only engaged under strict policy it
would not have prevented the incident that prompted this.

## What changed

8 bisectable commits:

- **Approval lifecycle** — self-approval blocked via server-derived
deciders; same-verdict replay 409s; decide re-reads and re-validates
inside the transaction; expiry TTLs; `markCompleted` ownership check;
session identity registry in core.
- **Engine gates enforce for real** — unclassified tools resolve to a
policy-governed category instead of hardcoded `allow`; missing-policy
fail-open closed; bash containment floor + exact-command approval
binding.
- **Dashboard decision routes** — stop trusting client-supplied actors
(decision, bypass-review, worktrunk → 403 on forged actors).
- **`fn serve` authenticated by default** — auto-mints a token following
the existing `fn dashboard` precedent; `--no-auth` opts out.
- **Sibling entry points closed** — user-sourced hard-cancel moves, ACP
execute-once approvals, plugin task-store gating.
- **pi-extension principal resolution** — the extension resolves the
acting principal and can withhold or policy-gate the previously ungated
destructive tools.
- **Root-cause bonus fix** — `findLatestByDedupeKey` was broken in
PostgreSQL backend mode (already-parsed jsonb fed through a string-only
parser), so approved-grant redemption **never matched in production**,
minting duplicate requests. This explains the live DB state of 17
approved / 0 completed. *(Also cherry-picked to `main` as `a9b30013bb`,
since it is an active production defect on its own.)*
- **Review follow-ups** (`627f1b1fa8`) — operator-configured
provisioning privilege and a configurable grant TTL; see below.

## Review follow-ups

**Provisioning privilege is operator-configured, not role-derived.**
`isCallerPrivileged` had gone from `caller.reportsTo == null` (every
top-level agent privileged — permanent escalation by creating a
manager-less agent) to `caller.role === "ceo"`, which swapped an
implicit rule for a magic string: any agent config can claim that role,
while an operator who genuinely wants a privileged agent had no
supported way to say so. Privilege now derives solely from
`agentProvisioning.trustedAgentIds` / `trustedRoles` and fails closed
when settings are unresolvable.

It is also no longer forwarded to `resolveAgentProvisioningPolicy` as
`isPrivileged`, because that flag short-circuits ahead of
`alwaysApproveDelete` — a trusted caller was bypassing delete approval
entirely. The policy applies the same trusted rules itself, in the right
order. The function now governs only the org-chart escape hatch (acting
outside your own direct reports).

**Grant TTL defaults to 1 hour and is configurable.** Approval →
redemption is not instantaneous: an operator approving from their phone,
an engine restart, a queued lane, or a task waiting on a worktree all
routinely exceeded 15 minutes, after which the grant expired and the
agent silently re-requested. One hour remains far short of the
"redeemable forever" hazard the TTL exists to bound. Override via
`FUSION_APPROVAL_GRANT_TTL_MS` or `configureApprovalRequestTtls()`;
invalid overrides are ignored rather than widening the window to
infinity or collapsing it to zero.

## Behavior changes requiring operator review before rollout

1. `fn serve` requires a bearer token by default (`--no-auth` opts out);
unauthenticated clients get 401.
2. Agents can no longer run withheld destructive tools
(`fn_task_delete`, `fn_task_bypass_review`,
mission/milestone/slice/feature/workflow deletes, `experiment_finalize`,
`skills_install`). Operators keep them via CLI/dashboard. **This is the
incident fix.**
3. Agents get provisioning privilege only when the operator lists them
in `agentProvisioning.trustedAgentIds` / `trustedRoles`; the
provisioning gate is now live in production. Previously-implicit
privilege (top-level position, or a `ceo` role) no longer grants
anything on its own.
4. Decision replay 409s (was 200); pending approvals expire after 24h,
approved grants after 1h (configurable); bash approvals bind per exact
command.
5. Forged/body actors on decision, bypass-review, worktrunk routes →
403; `archive-all-done` requires `{confirm:true}` (external scripts
affected).
6. `fn_secret_get` approvals grant exactly one reveal (previously
granted nothing and looped forever); ACP approvals are execute-once
(previously infinite reuse).
7. Bash containment denies token/credential/approvals-API commands in
all agent sessions at every preset.

## Verification

Independently re-run against the branch, not just self-reported:

- 5 typechecks (core, engine, cli, dashboard `tsconfig.json` +
`tsconfig.app.json`) — clean
- `pnpm lint` — clean
- `pnpm test:gate` — 379 passed
- `pnpm build --force` — green (a plain `pnpm build` skips packages as
unchanged and does **not** compile the branch)
- `pnpm check:changesets` — clean
- ~650 file-scoped tests including new negative-path suites for the
decision boundary, which previously had **zero** test coverage

`packages/engine/src/__tests__/plugin-runner.test.ts` fails 56/80 —
**verified pre-existing**, reproducing identically at base commit
`93a403af67` on `main`. Not in the merge gate.

### A mutation check that failed to fail

Worth recording, because it nearly shipped an untested security fix. The
first mutation check on the provisioning change reintroduced the `ceo`
hardcode and **all 17 tests still passed** — the tests asserted through
the policy path, which can no longer observe `isCallerPrivileged` at
all, precisely because `isPrivileged` is no longer forwarded there.
Org-chart cases that do exercise the function were added; the hardcode
now fails exactly 1 of 19, and restoring is green. A green mutation run
is only meaningful if the test can actually see the code under test.

## Known limitations (stated, not papered over)

- The bash containment floor is string-matching: a cost-raiser, not a
sandbox. Quoting, encoding, `$HOME`, symlinks, or an interpreter
one-liner can evade it. The durable protection is the decision route
refusing agent-originated deciders — the filter is the belt, not the
braces.
- Approval expiry is lazy (evaluated at decide/complete/redeem), not
swept, so an expired pending row stays visible in lists until touched.
- The extension's require-approval path returns a pending message but
cannot suspend a pi session mid-turn; engine-side pause hooks cover
engine lanes only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Security**
* Hardened approval and permission gating with server-side decider
attribution, self-approval blocking, ownership checks, replay/race
protection, and status/TTL enforcement.
* Added fail-closed behavior for sensitive/unclassified tools and
sandbox provisioning approvals.
* Blocked credential/approval access via bash containment; plugin
destructive task operations now require explicit permission.
* **New Features**
* `fn serve` now defaults to bearer-token auth, with `--no-auth` as the
explicit opt-out.
* **Bug Fixes**
* Improved task move-source attribution (`moveSource: "user"`) and
tightened dashboard archive/bypass confirmation and operator attribution
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:37 -07:00

320 lines
12 KiB
TypeScript

// U5 — the SECURITY FLOOR for `session/request_permission`.
//
// The ACP agent is an UNTRUSTED subprocess. When it asks permission to run a
// tool call, this resolver classifies the call PER-CATEGORY against Fusion's
// live action gate and answers `allow_once` / `reject_once` / `cancelled`.
//
// Why per-category and not per-preset (S1 / KTD3a): Fusion's shipped default
// policy preset is `unrestricted` (every category → allow). Mapping a preset id
// straight to an outcome would auto-approve EVERY tool call of an untrusted
// agent the instant a user selects the ACP runtime. So we classify the call's
// `kind` into a Fusion category and read `gate.permissionPolicy.rules[category]`.
//
// Default-deny is the floor everywhere a decision can't be made safely:
// - no gate / no permissionPolicy → deny
// - an unmappable / missing / `other` kind → deny (most-restrictive)
// - `require-approval` with no HITL machinery → deny
// - the `allow_once` option isn't offered → reject (never `*_always`, S2)
import type {
PermissionOption,
RequestPermissionResponse,
ToolCallUpdate,
ToolKind,
} from "@agentclientprotocol/sdk";
import type {
ApprovalStatus,
FusionCategory,
GateDisposition,
PermissionGate,
} from "./types.js";
/** Sentinel returned by `classifyToolKind` for an unmappable kind → force deny. */
export const DENY = "deny" as const;
/**
* Map an ACP `toolCall.kind` to a Fusion action-gate category (KTD3a).
*
* Read-only / benign kinds map to the implicit `exempt` category (always allow).
* `other`, `undefined`, and any unknown kind map to the `DENY` sentinel — the
* most-restrictive outcome — and MUST NOT fall through to allow.
*/
export function classifyToolKind(kind: ToolKind | null | undefined): FusionCategory | "exempt" | typeof DENY {
switch (kind) {
case "execute":
return "command_execution";
case "edit":
case "delete":
case "move":
return "file_write_delete";
case "fetch":
return "network_api";
case "read":
case "search":
case "think":
case "switch_mode":
return "exempt";
// "other", undefined, null, or anything unknown → most-restrictive deny.
default:
return DENY;
}
}
/**
* Select the ACP option to answer with, honoring the allow_once-ONLY rule (S2).
*
* - `allow` → an option whose `kind === "allow_once"`. Never `allow_always`
* (delegating a blanket grant to untrusted code loses Fusion's per-call
* interception). If no `allow_once` option is offered → fall back to deny.
* - `deny` → an option whose `kind === "reject_once"`. If none is offered the
* caller answers `{ outcome: "cancelled" }`. Never `reject_always`.
*/
export function selectOption(
decision: "allow" | "deny",
options: PermissionOption[],
): { decision: "allow" | "deny"; optionId?: string } {
const list = Array.isArray(options) ? options : [];
if (decision === "allow") {
const allowOnce = list.find((o) => o?.kind === "allow_once");
if (allowOnce?.optionId) return { decision: "allow", optionId: allowOnce.optionId };
// No allow_once offered: do NOT up-grade to allow_always. Fall back to deny.
const rejectOnce = list.find((o) => o?.kind === "reject_once");
return { decision: "deny", optionId: rejectOnce?.optionId };
}
const rejectOnce = list.find((o) => o?.kind === "reject_once");
return { decision: "deny", optionId: rejectOnce?.optionId };
}
/** Build the ACP response for a resolved {decision, optionId}. */
function buildResponse(sel: {
decision: "allow" | "deny";
optionId?: string;
}): RequestPermissionResponse {
if (sel.optionId) {
return { outcome: { outcome: "selected", optionId: sel.optionId } };
}
// No usable option (e.g. deny with no reject_once offered) → cancelled.
return { outcome: { outcome: "cancelled" } };
}
/**
* Read the raw per-category disposition from the live policy (exempt → allow),
* before the Risk S1 acknowledgement escalation. Callers that gate untrusted
* actions should use `effectiveDisposition` (which applies the escalation); this
* is the unescalated primitive it builds on.
*/
export function dispositionFor(
category: FusionCategory | "exempt",
gate: PermissionGate,
): GateDisposition {
if (category === "exempt") return "allow";
const rules = gate.permissionPolicy?.rules;
const disposition = rules?.[category];
// A category with no explicit rule is treated as require-approval (not allow):
// never silently allow an unmapped category for an untrusted agent.
return disposition ?? "require-approval";
}
/** A stable dedupe key for an identical tool call (decision reuse). */
function dedupeKeyFor(toolCall: ToolCallUpdate, category: string): string {
return [toolCall.toolCallId ?? "", category, toolCall.title ?? ""].join("|");
}
/**
* Run the human-in-the-loop approval flow for a `require-approval` category.
*
* Requires `createApprovalRequest` (the one non-optional HITL closure). When it
* is absent there is no human channel → DEFAULT-DENY (never throw, never allow).
*
* Flow: reuse a prior decision via `findApprovalByDedupeKey` when present;
* otherwise register the request, block on `pauseForApproval`, re-read the final
* status, finalize via `markApprovalCompleted`. `approved` → allow; everything
* else (denied / pending / completed / lookup-failure) → deny.
*/
async function runApproval(
toolCall: ToolCallUpdate,
category: FusionCategory,
gate: PermissionGate,
): Promise<"allow" | "deny"> {
return runApprovalForCategory(gate, {
category,
toolName: toolCall.title ?? category,
dedupeKey: dedupeKeyFor(toolCall, category),
args:
toolCall.rawInput && typeof toolCall.rawInput === "object"
? (toolCall.rawInput as Record<string, unknown>)
: {},
});
}
/**
* Run the HITL approval flow for an arbitrary `require-approval` action,
* identified by a category + dedupe key (not necessarily an ACP `toolCall`).
*
* Exported so the fs `writeTextFile` path (U7) routes its `file_write_delete`
* gating through the IDENTICAL approval machinery as U5 — register, block on
* `pauseForApproval`, re-read the final status, finalize — with the same
* default-deny floor when no human channel exists. Never throws, never allows
* on failure.
*/
export async function runApprovalForCategory(
gate: PermissionGate,
req: {
category: FusionCategory;
toolName: string;
dedupeKey: string;
args?: Record<string, unknown>;
},
): Promise<"allow" | "deny"> {
const { category, dedupeKey } = req;
if (typeof gate.createApprovalRequest !== "function") {
// No human channel available → default-deny.
return "deny";
}
const decisionPayload = {
disposition: "require-approval" as const,
category,
toolName: req.toolName,
approvalDedupeKey: dedupeKey,
};
const mapStatus = (status: ApprovalStatus | undefined): "allow" | "deny" =>
status === "approved" ? "allow" : "deny";
try {
/*
FNXC:AcpApprovalConsumption 2026-07-26-12:50:
Approvals are execute-once-then-complete (mirrors the pi action gate's
resolveGateOutcome + markApprovalCompleted contract). The previous reuse
branch returned allow on an `approved` row WITHOUT consuming it, so one
human approval authorized unlimited repeats of the same tool call. Now an
approved row is marked completed (the engine-wired closure records the
requesting agent's actor snapshot and the "Tool executed after approval"
note) BEFORE the allow is returned; a `completed` row is not approved, so
the next identical call goes back through the HITL round-trip. When
`markApprovalCompleted` is absent the approval CANNOT be consumed, so it is
not reused either — the call falls through to a fresh approval round-trip
(or the default-deny floor) instead of granting an unconsumable allow.
Denied rows remain reusable: repeating a denial is the conservative outcome.
*/
if (typeof gate.findApprovalByDedupeKey === "function") {
const prior = await gate.findApprovalByDedupeKey(dedupeKey);
if (prior && prior.status === "denied") {
return "deny";
}
if (prior && prior.status === "approved" && typeof gate.markApprovalCompleted === "function") {
await gate.markApprovalCompleted(prior.id);
return "allow";
}
}
// Default-deny BEFORE creating a request when the HITL round-trip cannot
// complete: without `pauseForApproval` we cannot block for a decision, and
// without `findApprovalByDedupeKey` we cannot READ the decision after the
// pause — a human approval would be silently discarded (mapStatus(undefined)
// → deny). Denying upfront never orphans a pending record and never wastes
// a human's approval on an outcome that would be denied anyway.
if (
typeof gate.pauseForApproval !== "function" ||
typeof gate.findApprovalByDedupeKey !== "function"
) {
return "deny";
}
const created = (await gate.createApprovalRequest(
decisionPayload,
req.args ?? {},
)) as { id?: string } | undefined;
const approvalRequestId = typeof created?.id === "string" ? created.id : dedupeKey;
await gate.pauseForApproval({ approvalRequestId, decision: decisionPayload });
// Re-read the final status after the pause resolves.
let finalStatus: ApprovalStatus | undefined;
if (typeof gate.findApprovalByDedupeKey === "function") {
const resolved = await gate.findApprovalByDedupeKey(dedupeKey);
finalStatus = resolved?.status;
}
if (typeof gate.markApprovalCompleted === "function") {
await gate.markApprovalCompleted(approvalRequestId);
}
return mapStatus(finalStatus);
} catch {
// Any HITL failure (timeout/dismiss/store error) → default-deny, no throw.
return "deny";
}
}
/**
* The full per-call security floor: classify → read the per-category
* disposition → run HITL for `require-approval` → select an `allow_once`-only
* option → build the ACP response.
*
* Default-deny on: missing gate, missing `permissionPolicy`, unmappable kind,
* `require-approval` without a resolvable approver, or a missing `allow_once`
* option.
*/
export interface ResolvePermissionOptions {
/**
* Risk S1 acknowledgement. When false (the safe default), a blanket `allow`
* disposition on a *sensitive* category is escalated to `require-approval`
* rather than auto-approved — so the shipped `unrestricted` default policy
* does not silently green-light an untrusted agent's command/file/network
* calls. The user opts out of the escalation by acknowledging the risk.
*/
allowUnrestricted?: boolean;
}
/**
* Per-category disposition with the Risk S1 acknowledgement escalation applied:
* a *sensitive* category the policy would `allow` is upgraded to
* `require-approval` unless `allowUnrestricted` is set. `exempt` (read-only)
* never escalates. Exported so the fs write path applies the identical rule.
*/
export function effectiveDisposition(
category: FusionCategory | "exempt",
gate: PermissionGate,
opts?: ResolvePermissionOptions,
): GateDisposition {
const disposition = dispositionFor(category, gate);
if (disposition === "allow" && category !== "exempt" && opts?.allowUnrestricted !== true) {
return "require-approval";
}
return disposition;
}
export async function resolvePermission(
toolCall: ToolCallUpdate,
options: PermissionOption[],
gate: PermissionGate | undefined,
opts?: ResolvePermissionOptions,
): Promise<RequestPermissionResponse> {
// No gate / no policy → default-deny.
if (!gate || !gate.permissionPolicy) {
return buildResponse(selectOption("deny", options));
}
const category = classifyToolKind(toolCall?.kind);
// Unmappable / missing / `other` kind → most-restrictive deny.
if (category === DENY) {
return buildResponse(selectOption("deny", options));
}
// Per-category disposition + S1 acknowledgement escalation.
const disposition = effectiveDisposition(category, gate, opts);
if (disposition === "allow") {
return buildResponse(selectOption("allow", options));
}
if (disposition === "block") {
return buildResponse(selectOption("deny", options));
}
// require-approval → HITL (or default-deny when no human channel exists).
const decision = await runApproval(toolCall, category as FusionCategory, gate);
return buildResponse(selectOption(decision, options));
}