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
The policy engine reserves a "prompt" value but never implements it, so today every tool is either always-allowed or always-denied. There is no way to say "let an agent call this destructive tool, but ask me first" — which is the setting most teams would want for a release-signed internal build that an agent drives.
Current state
Documented as a deliberate gap in three places:
docs/ARCHITECTURE.md §1 (non-goals): "Interactive consent prompting for individual tool calls (the policy enum reserves a future prompt value; §12)."
docs/ARCHITECTURE.md §12: "Interactive prompting is not implemented; the policy configuration leaves room for a future prompt value."
The mechanism it hooks into already exists: packages/cordierite/src/daemon/policy.ts evaluates every tools.call from both the CLI and MCP, keyed on the descriptor's annotations, with policy.default, policy.destructive, and per-tool policy.tools["<alias>/<name>"] overrides. Denied calls return policy_denied and are audited. The decision point exists — it just only has two possible answers.
Core model
Add a third policy outcome. A prompt-policy call enters a PENDING_APPROVAL state in the daemon and blocks until some consent channel returns a decision, or a configured timeout denies it.
The daemon is the enforcement point; the channels below are only how the question reaches a human and how the answer comes back. That separation is what keeps this working across harnesses with wildly different capabilities.
Consent channels
1. MCP elicitation — the primary channel where available
elicitation/create asks the client for structured input mid-call, so we can render the tool name, alias, actual arguments, and a reason, and get back accept/decline/cancel.
Support as of writing: Claude Code CLI supports it with no user configuration, in form and URL modes (docs); Claude Desktop and claude.ai do not (client capability matrix); Codex has landed server-driven elicitations after an earlier period of advertising support and auto-declining, so it is worth version-gating.
This is the only MCP-side channel that returns an observable decision — see "Why these are not interchangeable" below.
2. anthropic/requiresUserInteraction — a gate, not a consent source
Setting _meta["anthropic/requiresUserInteraction"]: true on a tool's tools/list entry makes Claude Code show its permission prompt on every call: unskippable by allow rules and by acceptEdits, auto, and bypassPermissions modes, with no "don't ask again" option, and denied outright in dontAsk mode. The documented intent matches this use case exactly — "tools whose permission prompt is itself the point, such as a consent or access-grant step where auto-approval would mean no human ever agreed."
Requires Claude Code v2.1.199 or later; ignored by every other client. It also withholds one-tap approval on Remote Control and Agent SDK surfaces.
3. Local out-of-band approval — the fallback that always works
cordierite pending # blocked calls: id, alias, tool, args, age, expiry
cordierite approve <id> [--once]
cordierite deny <id>
cordierite approve --watch # blocking view to leave open in a second pane
Three details that matter:
Discovery. The daemon auto-spawns detached with stdio redirected to daemon.log (docs/ARCHITECTURE.md §4), so there is usually no TTY to prompt on. A foreground cordierite daemon run can prompt inline, but that can't be relied on — so this channel needs an active nudge (OS notification via osascript / notify-send / Windows toast) when a call enters PENDING_APPROVAL. Without it, "consent" means the call hangs for the timeout and denies while nobody ever knew it was asked.
No MCP approve tool. Approval must not be reachable over the channel the caller controls. A cordierite_approve({ id }) MCP tool would let the agent that triggered the call approve its own call — a two-step tool with extra latency and no consent. A read-onlycordierite_pending() is fine and useful: it lets the agent say "I'm blocked on approval a4f2, expiring in 90s" instead of silently hanging.
4. Device-side confirm — optional, and the only real boundary
The daemon already holds a bidirectional pinned socket to the device, so it can send an approval_request frame and have the app show a native confirm. The person holding the phone approves, and no amount of shell access on the operator machine can tap it.
Opt-in (installCordieriteConsentPrompt()), and it does mean rendering UI, which brushes against the project's "no debug chrome" promise — but a consent dialog that appears only when policy says prompt for a destructive call is a different thing from a hidden debug menu.
Why these are not interchangeable
requiresUserInteraction gates the call before it reaches us and returns us nothing. The harness prompts, the human answers, and the daemon simply sees a call arrive — or not. There is no "approved" signal to observe, record, or verify.
Elicitation returns an actual decision that releases the pending state and can be audited with a real answer and timestamp.
So elicitation is a consent source; the flag is only a gate. Two consequences:
Never arm both for the same call — the user gets the harness prompt and then an elicitation dialog for one call.
The flag is unverifiable server-side. Claude Code before v2.1.199 ignores it silently, and every non-Anthropic client ignores it always. If the daemon skips its own prompt assuming the harness prompted, a stale client downgrades consent to nothing with no visible symptom.
Negotiation
initialize has already completed by the time tools/list runs, so the MCP server knows the client's declared capabilities and its clientInfo. Decide there:
Client declared
_meta flag
Prompt via
Daemon blocks on
elicitation
not set
elicitation/create at call time
the elicitation result
nothing, but trusted clientInfo
set
harness permission prompt
nothing (delegated)
nothing
set (harmless)
—
local approval / device confirm
Row two is a trust delegation and should be explicit, never implicit. Make the channel order configuration:
Channels: elicitation, client (trust the requiresUserInteraction gate), local, device. Default ["elicitation", "local"] — the flag is never trusted as consent unless someone opts in. client can additionally be version-gated on clientInfo (name === "claude-code" && version >= 2.1.199), which covers the stale-client case; it is spoofable by a hostile client, but the threat model here is an unattended agent, not a malicious one.
In row three the flag costs nothing and helps nothing on clients that ignore it, but on Claude Code it surfaces intent in the agent's own window before the call is made, while cordierite approve is still what releases it — two prompts. Worth it as defense in depth for some operators and not others, so make it a knob rather than a hardcoded choice.
No consent channel available → deny, with a message naming the reason. Fail-closed matches how allowPrivateLanOnly and trust already behave, and matches what Claude Code itself does for a flagged tool in dontAsk mode.
Session-scoped memory. A decision should be able to say "allow this tool for the rest of this session", held in memory against the session and never persisted (same rule as resume tokens), so an agent driving a twenty-step flow isn't prompting on every call.
Audit
Record which channel decided, because they carry different evidentiary weight:
elicitation — a human answered, and we observed the answer.
local / device — a human answered out-of-band, and we observed it.
client — the call was gated upstream; the decision was not observed, only assumed.
That last line is why client should not be the default.
Honest limitation, worth documenting
daemon.sock is mode 0600, so anyone who can reach it is the operator user. An agent with shell access on that machine can run cordierite approve itself — and Claude Code and Codex typically do have shell access. Local approval is therefore a speed bump and an audit trail against an unattended agent, not a boundary against a shell-capable one. Elicitation and requiresUserInteraction are similarly bypassable by the operator's own config (Claude Code has an Elicitation hook for auto-responding; with --permission-prompt-tool an allow for a flagged tool is converted to a deny with MCP tool requires user interaction; not supported via --permission-prompt-tool).
Device-side confirm (channel 4) is the only option here that survives a shell-capable agent. Everything else should be documented as protecting against unattended automation, not against a determined or compromised one — for which the boundary remains what it already is: don't register the tool in that build.
Why this matters
docs/SECURITY.md and the React Native README both make the case for shipping Cordierite in release-signed internal builds, and the README's own example of a gated tool is wipe-local-db. Today, protecting something like that means denying it outright — removing it exactly where an agent-driven QA build needs it — or allowing it unconditionally. prompt is what makes "ship it in the internal build" a defensible answer for genuinely destructive tools.
Interacts with #9: a prompt that times out, or is declined while the call is already in flight, should use the same cancellation path.
Summary
The policy engine reserves a
"prompt"value but never implements it, so today every tool is either always-allowed or always-denied. There is no way to say "let an agent call this destructive tool, but ask me first" — which is the setting most teams would want for a release-signed internal build that an agent drives.Current state
Documented as a deliberate gap in three places:
docs/ARCHITECTURE.md§1 (non-goals): "Interactive consent prompting for individual tool calls (the policy enum reserves a futurepromptvalue; §12)."docs/ARCHITECTURE.md§12: "Interactive prompting is not implemented; the policy configuration leaves room for a futurepromptvalue."docs/ARCHITECTURE.md§14 (current limitations): "Interactive consent prompts (the policy configuration reservesprompt)."The mechanism it hooks into already exists:
packages/cordierite/src/daemon/policy.tsevaluates everytools.callfrom both the CLI and MCP, keyed on the descriptor's annotations, withpolicy.default,policy.destructive, and per-toolpolicy.tools["<alias>/<name>"]overrides. Denied calls returnpolicy_deniedand are audited. The decision point exists — it just only has two possible answers.Core model
Add a third policy outcome. A
prompt-policy call enters aPENDING_APPROVALstate in the daemon and blocks until some consent channel returns a decision, or a configured timeout denies it.The daemon is the enforcement point; the channels below are only how the question reaches a human and how the answer comes back. That separation is what keeps this working across harnesses with wildly different capabilities.
Consent channels
1. MCP elicitation — the primary channel where available
elicitation/createasks the client for structured input mid-call, so we can render the tool name, alias, actual arguments, and a reason, and get back accept/decline/cancel.Support as of writing: Claude Code CLI supports it with no user configuration, in form and URL modes (docs); Claude Desktop and claude.ai do not (client capability matrix); Codex has landed server-driven elicitations after an earlier period of advertising support and auto-declining, so it is worth version-gating.
This is the only MCP-side channel that returns an observable decision — see "Why these are not interchangeable" below.
2.
anthropic/requiresUserInteraction— a gate, not a consent sourceSetting
_meta["anthropic/requiresUserInteraction"]: trueon a tool'stools/listentry makes Claude Code show its permission prompt on every call: unskippable by allow rules and byacceptEdits,auto, andbypassPermissionsmodes, with no "don't ask again" option, and denied outright indontAskmode. The documented intent matches this use case exactly — "tools whose permission prompt is itself the point, such as a consent or access-grant step where auto-approval would mean no human ever agreed."Requires Claude Code v2.1.199 or later; ignored by every other client. It also withholds one-tap approval on Remote Control and Agent SDK surfaces.
3. Local out-of-band approval — the fallback that always works
Three details that matter:
daemon.log(docs/ARCHITECTURE.md§4), so there is usually no TTY to prompt on. A foregroundcordierite daemon runcan prompt inline, but that can't be relied on — so this channel needs an active nudge (OS notification viaosascript/notify-send/ Windows toast) when a call entersPENDING_APPROVAL. Without it, "consent" means the call hangs for the timeout and denies while nobody ever knew it was asked.cordierite_approve({ id })MCP tool would let the agent that triggered the call approve its own call — a two-step tool with extra latency and no consent. A read-onlycordierite_pending()is fine and useful: it lets the agent say "I'm blocked on approvala4f2, expiring in 90s" instead of silently hanging.tool_cancelframe and anAbortSignalfor handlers #9. A cancelled call drops out of the pending queue; a session that suspends while a call awaits approval denies rather than lingering.4. Device-side confirm — optional, and the only real boundary
The daemon already holds a bidirectional pinned socket to the device, so it can send an
approval_requestframe and have the app show a native confirm. The person holding the phone approves, and no amount of shell access on the operator machine can tap it.Opt-in (
installCordieriteConsentPrompt()), and it does mean rendering UI, which brushes against the project's "no debug chrome" promise — but a consent dialog that appears only when policy sayspromptfor a destructive call is a different thing from a hidden debug menu.Why these are not interchangeable
requiresUserInteractiongates the call before it reaches us and returns us nothing. The harness prompts, the human answers, and the daemon simply sees a call arrive — or not. There is no "approved" signal to observe, record, or verify.Elicitation returns an actual decision that releases the pending state and can be audited with a real answer and timestamp.
So elicitation is a consent source; the flag is only a gate. Two consequences:
Negotiation
initializehas already completed by the timetools/listruns, so the MCP server knows the client's declared capabilities and itsclientInfo. Decide there:_metaflagelicitationelicitation/createat call timeclientInfoRow two is a trust delegation and should be explicit, never implicit. Make the channel order configuration:
{ "policy": { "destructive": "prompt", "consent": ["elicitation", "local"] } }Channels:
elicitation,client(trust therequiresUserInteractiongate),local,device. Default["elicitation", "local"]— the flag is never trusted as consent unless someone opts in.clientcan additionally be version-gated onclientInfo(name === "claude-code" && version >= 2.1.199), which covers the stale-client case; it is spoofable by a hostile client, but the threat model here is an unattended agent, not a malicious one.In row three the flag costs nothing and helps nothing on clients that ignore it, but on Claude Code it surfaces intent in the agent's own window before the call is made, while
cordierite approveis still what releases it — two prompts. Worth it as defense in depth for some operators and not others, so make it a knob rather than a hardcoded choice.No consent channel available → deny, with a message naming the reason. Fail-closed matches how
allowPrivateLanOnlyandtrustalready behave, and matches what Claude Code itself does for a flagged tool indontAskmode.Session-scoped memory. A decision should be able to say "allow this tool for the rest of this session", held in memory against the session and never persisted (same rule as resume tokens), so an agent driving a twenty-step flow isn't prompting on every call.
Audit
Record which channel decided, because they carry different evidentiary weight:
elicitation— a human answered, and we observed the answer.local/device— a human answered out-of-band, and we observed it.client— the call was gated upstream; the decision was not observed, only assumed.That last line is why
clientshould not be the default.Honest limitation, worth documenting
daemon.sockis mode0600, so anyone who can reach it is the operator user. An agent with shell access on that machine can runcordierite approveitself — and Claude Code and Codex typically do have shell access. Local approval is therefore a speed bump and an audit trail against an unattended agent, not a boundary against a shell-capable one. Elicitation andrequiresUserInteractionare similarly bypassable by the operator's own config (Claude Code has anElicitationhook for auto-responding; with--permission-prompt-toolanallowfor a flagged tool is converted to a deny withMCP tool requires user interaction; not supported via --permission-prompt-tool).Device-side confirm (channel 4) is the only option here that survives a shell-capable agent. Everything else should be documented as protecting against unattended automation, not against a determined or compromised one — for which the boundary remains what it already is: don't register the tool in that build.
Why this matters
docs/SECURITY.mdand the React Native README both make the case for shipping Cordierite in release-signed internal builds, and the README's own example of a gated tool iswipe-local-db. Today, protecting something like that means denying it outright — removing it exactly where an agent-driven QA build needs it — or allowing it unconditionally.promptis what makes "ship it in the internal build" a defensible answer for genuinely destructive tools.Interacts with #9: a prompt that times out, or is declined while the call is already in flight, should use the same cancellation path.