You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
exporttypePolicyDecision="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 → destructive → default) already carry whatever value is configured.
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: afterevaluatePolicy returns, beforeactiveCallsManager.call(...).
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 a–z 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:
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):
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/.
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), therequiresUserInteractiongate (#10), and channel permission relay (#11) all layer on top later as nicer front-ends for the samePENDING_APPROVALstate.1. Accept
"prompt"as a policy valuepackages/cordierite/src/daemon/config.tscurrently has:and
requirePolicyDecisionrejects anything else with a message that names"prompt"as reserved:Extend the union, add
"prompt"toPOLICY_DECISIONS, and update that error message.policy.ts'sevaluateis pure and needs no change — its precedence rules (per-tool override →destructive→default) 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'sRPC_METHODS.toolsCallhandler is described in its own comment as "the single seam everytools.callpasses through". The insertion point is exact: afterevaluatePolicyreturns, beforeactiveCallsManager.call(...).Placing the await here matters for a reason that is easy to get wrong:
activeCallsManager.callis what starts the call's own timeout (DEFAULT_CALL_TIMEOUT_MS10s, 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 everyprompttool time out.3. The pending-approval registry
A new
daemon/approvals.ts, structured likecalls.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.a–zexcludinglso it never reads as1orIon a phone; worth copying outright rather than reusing thecall_<random>scheme, which nobody wants to retype.calls.tsalready 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.consentTimeoutSeconds, defaulting to something well under two minutes.4. RPC and events
New methods, following the existing selector conventions:
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_requestedandapproval_resolved, on the existing bus. This is what makes the watch command nearly free:cordierite approve --watchbecomes anevents.subscribeconsumer, andcommands/events.tsis 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 rawargsin 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 the0600UDS to a local process and are dropped when the approval resolves. The audit log keeps storing the digest alone.5. CLI
--jsonon 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 foregroundcordierite daemon runcan prompt inline and should, but that can't be the plan.Without an active nudge, "consent" means the call hangs for
consentTimeoutSecondsand denies while nobody ever knew it was asked. So an OS notification onapproval_requestedis part of this issue, not a follow-up:osascript -e 'display notification …'notify-sendBest-effort with the same failure posture as the audit logger — never block the call path, never throw, log and count failures. Add a
notifications: falseconfig escape for operators who run--watchand don't want the noise.7. Audit
AuditOutcomeis 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 produceslocal; #10's other channels fill in the rest):One thing to fix while here:
durationMsis measured fromauditStartedAt, 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 —waitedForConsentMsanddurationMsmeasured 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 thedatapayload should distinguish the cause the way the existing deny branch already attaches ahintpointing atconfigPath:consent_denied— a human said noconsent_timeout— nobody answered in timeno_consent_channel— nothing was available to askConstraints worth writing into the docs
policy: "prompt"value — human-in-the-loop consent for tool calls #10 for the detail.promptis wrong for CI. An unattended pipeline has no one to ask, so it will sit for the timeout and then deny. CI should configureallowordenyexplicitly; the docs should say so wherepromptis introduced.daemon.sockis mode0600, 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 reservedpolicy: "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.tsalready provides the seamcalls.tsuses), 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 insrc/__tests__/e2e/.