Skip to content

Local approval: block prompt-policy tool calls on an out-of-band operator decision #12

Description

@V3RON

Split out of #10, which covers the whole consent design across four channels. This issue is the one channel with no external dependency — no MCP elicitation, no vendor extension, no research preview — and it is the fallback every other channel needs when its own path is unavailable. It should land first.

Scope

Implement policy: "prompt" end to end, with the decision coming from the operator's own machine over the existing UDS control plane. Elicitation (#10), the requiresUserInteraction gate (#10), and channel permission relay (#11) all layer on top later as nicer front-ends for the same PENDING_APPROVAL state.

1. Accept "prompt" as a policy value

packages/cordierite/src/daemon/config.ts currently has:

export type PolicyDecision = "allow" | "deny";

and requirePolicyDecision rejects anything else with a message that names "prompt" as reserved:

must be "allow" or "deny" ("prompt" is reserved for a future release and is not yet supported).

Extend the union, add "prompt" to POLICY_DECISIONS, and update that error message. policy.ts's evaluate is pure and needs no change — its precedence rules (per-tool override → destructivedefault) already carry whatever value is configured.

Add the consent settings alongside:

{
  "policy": {
    "default": "allow",
    "destructive": "prompt",
    "tools": { "pixel-8/wipe_local_db": "prompt" },
    "consentTimeoutSeconds": 120
  }
}

2. The seam

packages/cordierite/src/daemon/daemon.ts's RPC_METHODS.toolsCall handler is described in its own comment as "the single seam every tools.call passes through". The insertion point is exact: after evaluatePolicy returns, before activeCallsManager.call(...).

const policyDecision = evaluatePolicy(tool, { alias: resolved.alias }, config.policy);

if (policyDecision === "deny") { /* existing branch */ }

if (policyDecision === "prompt") {
  const verdict = await approvals.request({ session: resolved, tool: name, args });
  if (verdict.behavior !== "allow") {
    writeAudit("denied", undefined, verdict);
    throw new RpcApplicationError("policy_denied", , -32000, { reason: verdict.reason });
  }
}

const { callId, result: callResult } = activeCallsManager.call(session, name, args, timeoutMs);

Placing the await here matters for a reason that is easy to get wrong: activeCallsManager.call is what starts the call's own timeout (DEFAULT_CALL_TIMEOUT_MS 10s, max 600s). Awaiting before that line means a 10-second tool doesn't burn its timeout while a human is deciding. Awaiting after it would make every prompt tool time out.

3. The pending-approval registry

A new daemon/approvals.ts, structured like calls.ts (which the module comment describes as keeping "a single seam that a policy layer can wrap" — this is that layer):

  • request(...) returns a promise resolved by a decision, a timeout, or a lifecycle transition.
  • Approval ids are human-typed, so they should be short and unambiguous. Claude Code's permission relay uses five lowercase letters drawn from az excluding l so it never reads as 1 or I on a phone; worth copying outright rather than reusing the call_<random> scheme, which nobody wants to retype.
  • Lifecycle. calls.ts already has suspend/revoke/expiry transitions routed into it; pending approvals need the same treatment — a session that suspends, revokes, or expires while a call awaits approval resolves as denied with the corresponding error type, never lingers.
  • Shutdown. SIGINT/SIGTERM denies everything pending before exit, alongside the existing audit flush.
  • Timeout from consentTimeoutSeconds, defaulting to something well under two minutes.

4. RPC and events

New methods, following the existing selector conventions:

Method Params Result
approvals.list { selector? } PendingApproval[]
approvals.decide { id, behavior: "allow" | "deny", scope?: "call" | "session" } { ok: true }

PendingApproval: { id, sessionId, alias, tool, args, requestedAt, expiresAt, caller }.

Two new event kinds, approval_requested and approval_resolved, on the existing bus. This is what makes the watch command nearly free: cordierite approve --watch becomes an events.subscribe consumer, and commands/events.ts is already the template for a hosted command with a live reporter and NDJSON under --json.

Args are shown to the approver, and still never logged. The handler already computes argsSha256(args) for audit while holding the raw args in memory. The pending record can carry the real arguments — an approver who can't see what they're approving isn't approving anything — because they travel only over the 0600 UDS to a local process and are dropped when the approval resolves. The audit log keeps storing the digest alone.

5. CLI

cordierite pending [selector]                     # id, alias, tool, args, age, expiry
cordierite approve <id> [--session]               # --session: remember for the rest of this session
cordierite deny <id>
cordierite approve --watch [selector]             # live queue; answer inline

--json on all of them, NDJSON for --watch, consistent with the rest of the CLI.

6. Discovery — the part that decides whether this is usable

The daemon auto-spawns detached with stdio redirected to daemon.log (docs/ARCHITECTURE.md §4), so in the normal case there is no TTY to prompt on. A foreground cordierite daemon run can prompt inline and should, but that can't be the plan.

Without an active nudge, "consent" means the call hangs for consentTimeoutSeconds and denies while nobody ever knew it was asked. So an OS notification on approval_requested is part of this issue, not a follow-up:

  • macOS: osascript -e 'display notification …'
  • Linux: notify-send
  • Windows: PowerShell toast

Best-effort with the same failure posture as the audit logger — never block the call path, never throw, log and count failures. Add a notifications: false config escape for operators who run --watch and don't want the noise.

7. Audit

AuditOutcome is currently "ok" | "error" | "denied". A human decision is not the same event as a policy denial, so extend it, and record which channel decided (this issue only produces local; #10's other channels fill in the rest):

export type AuditOutcome = "ok" | "error" | "denied" | "consent_denied" | "consent_timeout";
export type ConsentChannel = "local" | "elicitation" | "client" | "device";

One thing to fix while here: durationMs is measured from auditStartedAt, captured at handler entry. A call that waits 90 seconds for a human would report a 90-second duration and quietly poison any latency reading of the log. Split the two — waitedForConsentMs and durationMs measured from the post-approval start — rather than letting consent time masquerade as execution time.

8. Error surface

Every refusal is still policy_denied (no new error type, so nothing downstream has to learn one), but the data payload should distinguish the cause the way the existing deny branch already attaches a hint pointing at configPath:

  • consent_denied — a human said no
  • consent_timeout — nobody answered in time
  • no_consent_channel — nothing was available to ask

Constraints worth writing into the docs

  • Cap the consent timeout below the MCP idle window. A blocked call is a long-running MCP tool call; a call that sends no response and no progress notification for the idle window aborts client-side (30 minutes for stdio servers, 5 for HTTP/SSE/WebSocket). Emitting periodic progress while pending also keeps it alive, and Claude Code backgrounds calls past two minutes so the agent isn't stalled meanwhile. See Implement the reserved policy: "prompt" value — human-in-the-loop consent for tool calls #10 for the detail.
  • prompt is wrong for CI. An unattended pipeline has no one to ask, so it will sit for the timeout and then deny. CI should configure allow or deny explicitly; the docs should say so where prompt is introduced.
  • This is not a boundary against a shell-capable agent. daemon.sock is mode 0600, so anyone who can reach it is the operator user — including an agent with shell access on that machine, which Claude Code and Codex typically have. Local approval is a speed bump and an audit trail against unattended automation. The boundary against a determined caller remains not registering the tool in that build. Implement the reserved policy: "prompt" value — human-in-the-loop consent for tool calls #10 covers device-side confirm, the only channel that survives this.

Testing

Almost all of it is daemon-side and device-free: the approvals registry is a pure state machine over an injected clock and timers (daemon/timers.ts already provides the seam calls.ts uses), the RPC methods are exercisable over a UDS with no app attached, and the CLI commands follow the existing DI/renderer patterns. Only an end-to-end "approve then the app really runs it" case needs the e2e harness in src/__tests__/e2e/.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions