Skip to content

Implement the reserved policy: "prompt" value — human-in-the-loop consent for tool calls #10

Description

@V3RON

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 future prompt value; §12)."
  • docs/ARCHITECTURE.md §12: "Interactive prompting is not implemented; the policy configuration leaves room for a future prompt value."
  • docs/ARCHITECTURE.md §14 (current limitations): "Interactive consent prompts (the policy configuration reserves prompt)."

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-only cordierite_pending() is fine and useful: it lets the agent say "I'm blocked on approval a4f2, expiring in 90s" instead of silently hanging.
  • Interaction with Tool calls can't be cancelled: add a tool_cancel frame and an AbortSignal for 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_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:

  1. Never arm both for the same call — the user gets the harness prompt and then an elicitation dialog for one call.
  2. 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:

{ "policy": { "destructive": "prompt", "consent": ["elicitation", "local"] } }

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.

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