## 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>
386 lines
17 KiB
TypeScript
386 lines
17 KiB
TypeScript
// U5 security-floor tests for the PURE permission resolver.
|
|
//
|
|
// Each `it` is a security assertion. Do NOT weaken these to go green — if one
|
|
// fails, the implementation is wrong, not the test.
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import type {
|
|
PermissionOption,
|
|
RequestPermissionResponse,
|
|
ToolCallUpdate,
|
|
ToolKind,
|
|
} from "@agentclientprotocol/sdk";
|
|
import {
|
|
classifyToolKind,
|
|
selectOption,
|
|
resolvePermission,
|
|
DENY,
|
|
} from "../control-handler.js";
|
|
import type { GateDisposition, PermissionGate } from "../types.js";
|
|
|
|
// A full option set the agent might offer (includes the dangerous *_always).
|
|
const ALL_OPTIONS: PermissionOption[] = [
|
|
{ optionId: "allow_once_id", name: "Allow once", kind: "allow_once" },
|
|
{ optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
|
|
{ optionId: "reject_once_id", name: "Reject once", kind: "reject_once" },
|
|
{ optionId: "reject_always_id", name: "Reject always", kind: "reject_always" },
|
|
];
|
|
|
|
function toolCall(kind: ToolKind | null | undefined, extra: Partial<ToolCallUpdate> = {}): ToolCallUpdate {
|
|
return { toolCallId: "tc-1", kind, ...extra } as ToolCallUpdate;
|
|
}
|
|
|
|
function gateWithRules(rules: Record<string, GateDisposition>, extra: Partial<PermissionGate> = {}): PermissionGate {
|
|
return { permissionPolicy: { rules }, ...extra };
|
|
}
|
|
|
|
/** The shipped `unrestricted` default: every category → allow. */
|
|
const UNRESTRICTED: Record<string, GateDisposition> = {
|
|
git_write: "allow",
|
|
file_write_delete: "allow",
|
|
command_execution: "allow",
|
|
network_api: "allow",
|
|
task_agent_mutation: "allow",
|
|
};
|
|
|
|
function selectedId(res: RequestPermissionResponse): string | undefined {
|
|
return res.outcome.outcome === "selected" ? res.outcome.optionId : undefined;
|
|
}
|
|
|
|
describe("classifyToolKind", () => {
|
|
it("maps execute → command_execution", () => {
|
|
expect(classifyToolKind("execute")).toBe("command_execution");
|
|
});
|
|
it("maps edit/delete/move → file_write_delete", () => {
|
|
expect(classifyToolKind("edit")).toBe("file_write_delete");
|
|
expect(classifyToolKind("delete")).toBe("file_write_delete");
|
|
expect(classifyToolKind("move")).toBe("file_write_delete");
|
|
});
|
|
it("maps fetch → network_api", () => {
|
|
expect(classifyToolKind("fetch")).toBe("network_api");
|
|
});
|
|
it("maps read/search/think/switch_mode → exempt", () => {
|
|
expect(classifyToolKind("read")).toBe("exempt");
|
|
expect(classifyToolKind("search")).toBe("exempt");
|
|
expect(classifyToolKind("think")).toBe("exempt");
|
|
expect(classifyToolKind("switch_mode")).toBe("exempt");
|
|
});
|
|
it("maps other/undefined/null/unknown → DENY sentinel", () => {
|
|
expect(classifyToolKind("other")).toBe(DENY);
|
|
expect(classifyToolKind(undefined)).toBe(DENY);
|
|
expect(classifyToolKind(null)).toBe(DENY);
|
|
expect(classifyToolKind("totally_made_up" as ToolKind)).toBe(DENY);
|
|
});
|
|
});
|
|
|
|
describe("selectOption — allow_once ONLY (S2)", () => {
|
|
it("allow selects allow_once, never allow_always", () => {
|
|
const sel = selectOption("allow", ALL_OPTIONS);
|
|
expect(sel).toEqual({ decision: "allow", optionId: "allow_once_id" });
|
|
});
|
|
it("allow with NO allow_once falls back to reject (never allow_always)", () => {
|
|
const noAllowOnce = ALL_OPTIONS.filter((o) => o.kind !== "allow_once");
|
|
const sel = selectOption("allow", noAllowOnce);
|
|
expect(sel.decision).toBe("deny");
|
|
expect(sel.optionId).not.toBe("allow_always_id");
|
|
expect(sel.optionId).toBe("reject_once_id");
|
|
});
|
|
it("deny selects reject_once, never reject_always", () => {
|
|
const sel = selectOption("deny", ALL_OPTIONS);
|
|
expect(sel).toEqual({ decision: "deny", optionId: "reject_once_id" });
|
|
});
|
|
it("deny with no reject_once leaves optionId undefined (→ cancelled)", () => {
|
|
const onlyAllow: PermissionOption[] = [
|
|
{ optionId: "allow_once_id", name: "Allow once", kind: "allow_once" },
|
|
{ optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
|
|
];
|
|
const sel = selectOption("deny", onlyAllow);
|
|
expect(sel.decision).toBe("deny");
|
|
expect(sel.optionId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("resolvePermission — the security floor", () => {
|
|
// [Risk S1] per-category honored, NOT preset-allowed.
|
|
it("blocks an execute call when command_execution is custom-blocked even under an otherwise-unrestricted policy", async () => {
|
|
const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "block" });
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(res.outcome.outcome).toBe("selected");
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
// [Risk S2] allow → allow_once, allow_always NEVER selected.
|
|
// (acknowledged: with allowUnrestricted the S1 escalation is off, so the allow
|
|
// disposition reaches option selection — the point of this test.)
|
|
it("selects allow_once for an allow category and never allow_always even when offered", async () => {
|
|
const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "allow" });
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate, {
|
|
allowUnrestricted: true,
|
|
});
|
|
expect(selectedId(res)).toBe("allow_once_id");
|
|
expect(selectedId(res)).not.toBe("allow_always_id");
|
|
});
|
|
|
|
// [Risk S1] WITHOUT acknowledgement, a blanket allow on a sensitive category
|
|
// is escalated to approval — and default-denies when no approver exists.
|
|
it("escalates a sensitive allow to deny under the unrestricted default (no acknowledgement, no approver)", async () => {
|
|
const gate = gateWithRules(UNRESTRICTED); // command_execution: "allow"
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
it("auto-allows a sensitive call only when the unrestricted risk is acknowledged", async () => {
|
|
const gate = gateWithRules(UNRESTRICTED);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate, {
|
|
allowUnrestricted: true,
|
|
});
|
|
expect(selectedId(res)).toBe("allow_once_id");
|
|
});
|
|
it("never escalates an exempt (read-only) kind regardless of acknowledgement", async () => {
|
|
const gate = gateWithRules(UNRESTRICTED);
|
|
const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("allow_once_id");
|
|
});
|
|
|
|
it("exempt kinds (read) always allow via allow_once", async () => {
|
|
// Even with a block-everything policy, a read-only kind is exempt → allow.
|
|
const gate = gateWithRules({
|
|
git_write: "block",
|
|
file_write_delete: "block",
|
|
command_execution: "block",
|
|
network_api: "block",
|
|
task_agent_mutation: "block",
|
|
});
|
|
const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("allow_once_id");
|
|
});
|
|
|
|
// [KTD3a] missing / other / unknown kind → denied even under unrestricted.
|
|
it("denies a missing kind even under the unrestricted default", async () => {
|
|
const gate = gateWithRules(UNRESTRICTED);
|
|
const res = await resolvePermission(toolCall(undefined), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
it("denies an `other` kind even under the unrestricted default", async () => {
|
|
const gate = gateWithRules(UNRESTRICTED);
|
|
const res = await resolvePermission(toolCall("other"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
// No gate / no policy → default-deny.
|
|
it("default-denies when no gate is supplied", async () => {
|
|
const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, undefined);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
it("default-denies when permissionPolicy is absent", async () => {
|
|
const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, {} as PermissionGate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
// Options missing the expected *_once kind → safe fallback, never *_always, no throw.
|
|
it("falls back to cancelled (never allow_always) when an allow category offers no allow_once", async () => {
|
|
const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "allow" });
|
|
const noAllowOnce: PermissionOption[] = [
|
|
{ optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
|
|
{ optionId: "reject_always_id", name: "Reject always", kind: "reject_always" },
|
|
];
|
|
const res = await resolvePermission(toolCall("execute"), noAllowOnce, gate, {
|
|
allowUnrestricted: true,
|
|
});
|
|
// No reject_once either → cancelled, and definitely not allow_always.
|
|
expect(res.outcome.outcome).toBe("cancelled");
|
|
expect(selectedId(res)).toBeUndefined();
|
|
});
|
|
|
|
describe("require-approval HITL", () => {
|
|
it("creates an approval request, blocks until decision, granted → allow_once", async () => {
|
|
let resolvePause: (() => void) | undefined;
|
|
const order: string[] = [];
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest: vi.fn(async () => {
|
|
order.push("create");
|
|
return { id: "appr-1" };
|
|
}),
|
|
findApprovalByDedupeKey: vi
|
|
.fn()
|
|
// first lookup (reuse check): nothing prior
|
|
.mockResolvedValueOnce(null)
|
|
// second lookup (after pause): approved
|
|
.mockResolvedValueOnce({ id: "appr-1", status: "approved" }),
|
|
pauseForApproval: vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
order.push("pause");
|
|
resolvePause = () => {
|
|
order.push("resume");
|
|
resolve();
|
|
};
|
|
}),
|
|
),
|
|
markApprovalCompleted: vi.fn(async () => {
|
|
order.push("complete");
|
|
}),
|
|
},
|
|
);
|
|
|
|
const promise = resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
|
|
// It must be blocked on pauseForApproval — give the microtask queue a tick.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(order).toEqual(["create", "pause"]);
|
|
|
|
resolvePause!();
|
|
const res = await promise;
|
|
expect(selectedId(res)).toBe("allow_once_id");
|
|
expect(gate.createApprovalRequest).toHaveBeenCalledTimes(1);
|
|
expect(gate.markApprovalCompleted).toHaveBeenCalledWith("appr-1");
|
|
expect(order).toEqual(["create", "pause", "resume", "complete"]);
|
|
});
|
|
|
|
it("rejected decision → reject_once", async () => {
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest: vi.fn(async () => ({ id: "appr-2" })),
|
|
findApprovalByDedupeKey: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: "appr-2", status: "denied" }),
|
|
pauseForApproval: vi.fn(async () => undefined),
|
|
markApprovalCompleted: vi.fn(async () => undefined),
|
|
},
|
|
);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
it("timeout/error during pause → reject_once (no throw)", async () => {
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest: vi.fn(async () => ({ id: "appr-3" })),
|
|
findApprovalByDedupeKey: vi.fn().mockResolvedValueOnce(null),
|
|
pauseForApproval: vi.fn(async () => {
|
|
throw new Error("timed out");
|
|
}),
|
|
},
|
|
);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
/*
|
|
FNXC:AcpApprovalConsumption 2026-07-26-12:50:
|
|
Approvals are execute-once-then-complete. Reusing an approved row must
|
|
CONSUME it via markApprovalCompleted; a completed row no longer authorizes,
|
|
so an identical second request goes back through the HITL round-trip.
|
|
*/
|
|
it("reuses a prior approved decision once, consuming it via markApprovalCompleted (no new request)", async () => {
|
|
const createApprovalRequest = vi.fn(async () => ({ id: "appr-x" }));
|
|
const markApprovalCompleted = vi.fn(async () => {});
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest,
|
|
markApprovalCompleted,
|
|
findApprovalByDedupeKey: vi.fn(async () => ({ id: "prior", status: "approved" as const })),
|
|
},
|
|
);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("allow_once_id");
|
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
|
// The single-use grant is finalized before the allow is returned.
|
|
expect(markApprovalCompleted).toHaveBeenCalledOnce();
|
|
expect(markApprovalCompleted).toHaveBeenCalledWith("prior");
|
|
});
|
|
|
|
it("does NOT auto-allow a second identical request after the approval is consumed", async () => {
|
|
// In-memory approval store: one approved row that flips to completed on
|
|
// markApprovalCompleted, mirroring the engine's approval lifecycle.
|
|
const row = { id: "prior", status: "approved" as "approved" | "completed" };
|
|
const createApprovalRequest = vi.fn(async () => ({ id: "appr-2" }));
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest,
|
|
markApprovalCompleted: vi.fn(async () => {
|
|
row.status = "completed";
|
|
}),
|
|
findApprovalByDedupeKey: vi.fn(async () => ({ id: row.id, status: row.status })),
|
|
// Pause never resolves a decision → the second call must NOT allow.
|
|
pauseForApproval: vi.fn(async () => {}),
|
|
},
|
|
);
|
|
|
|
const first = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(first)).toBe("allow_once_id");
|
|
expect(row.status).toBe("completed");
|
|
|
|
const second = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
// Completed is not approved: the second identical call re-enters the HITL
|
|
// flow (a new request is registered) and, with no human grant, denies.
|
|
expect(selectedId(second)).toBe("reject_once_id");
|
|
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not reuse an approved row when it cannot be consumed (no markApprovalCompleted)", async () => {
|
|
const createApprovalRequest = vi.fn(async () => ({ id: "appr-x" }));
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest,
|
|
findApprovalByDedupeKey: vi.fn(async () => ({ id: "prior", status: "approved" as const })),
|
|
// No markApprovalCompleted and no pauseForApproval → the fresh
|
|
// round-trip cannot complete → default-deny, never an unconsumable allow.
|
|
},
|
|
);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
it("still reuses a prior denied decision without consuming anything", async () => {
|
|
const markApprovalCompleted = vi.fn(async () => {});
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{
|
|
createApprovalRequest: vi.fn(async () => ({ id: "appr-x" })),
|
|
markApprovalCompleted,
|
|
findApprovalByDedupeKey: vi.fn(async () => ({ id: "prior", status: "denied" as const })),
|
|
},
|
|
);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
expect(markApprovalCompleted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("require-approval with NO closures → default-deny, no throw", async () => {
|
|
const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "require-approval" });
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
|
|
it("require-approval with createApprovalRequest but no pauseForApproval → default-deny (no orphaned request)", async () => {
|
|
const createApprovalRequest = vi.fn(async () => ({ id: "a" }));
|
|
const gate: PermissionGate = gateWithRules(
|
|
{ ...UNRESTRICTED, command_execution: "require-approval" },
|
|
{ createApprovalRequest },
|
|
);
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
// Without a way to pause for a decision, no request is committed to the
|
|
// store — otherwise it would sit perpetually `pending`.
|
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("treats a category with no explicit rule as require-approval (not allow)", async () => {
|
|
// command_execution missing from rules entirely → require-approval → with no
|
|
// closures that default-denies (never silent allow).
|
|
const gate = gateWithRules({ git_write: "allow" });
|
|
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
|
|
expect(selectedId(res)).toBe("reject_once_id");
|
|
});
|
|
});
|