ADR-0013: Structured permission prompts via --permission-prompt-tool stdio¶
Date: 2026-05-08
Status¶
Accepted
Context¶
ADR-0012 introduced a reactive permission system that scanned Claude's natural-language text output for English phrases like "need permission" and "is restricted" to detect when Claude was blocked. This approach has two fundamental problems:
- Language dependency: Claude mirrors the user's language. A non-English user receives non-English refusals, which never match the English phrase list.
- Fragility: Claude's phrasing is not under our control and changes across versions. The phrase list requires ongoing maintenance.
Investigation of the Claude Code CLI event schema (v2.1.132) revealed a structured alternative: the --permission-prompt-tool stdio flag.
How it works¶
When --permission-prompt-tool stdio is passed, Claude does not emit English prose when blocked. Instead it:
- Emits a
control_requestNDJSON event on stdout and pauses, keeping the session alive - Waits for a
control_responsewritten to its stdin before continuing
The control_request schema includes:
request.subtype: "can_use_tool"— always this value for permission checksrequest.tool_name— the tool Claude wants to use (e.g."Bash","Read")request.input— the tool argumentsrequest.decision_reason_type— language-neutral enum:workingDir,rule,mode,safetyCheck,classifier,hook,sandboxOverride,asyncAgent,subcommandResults,otherrequest.blocked_path— the specific path that triggered aworkingDirdenialrequest.display_name,request.title,request.description— human-readable context (in Claude's language, not used for logic)request.classifier_approvable— false when a safety check requires explicit human approval
The response milk sends back:
{"type":"control_response","response":{"subtype":"success","request_id":"<same uuid>","response":{"behavior":"allow","updatedInput":{}}}}
or "behavior":"deny" to refuse.
Additionally, the final result event includes a permission_denials array listing all tools that were silently blocked (when --dangerously-skip-permissions is not set and --permission-prompt-tool is not used). This is used for post-hoc diagnostics only.
Known risk¶
Bug #34046 (filed ~v2.1.73-74) reported control_request events not reliably emitting. The installed version is v2.1.132; testing against the actual binary is required before shipping.
Decision¶
Replace the phrase-based reactive permission system (ADR-0012) with --permission-prompt-tool stdio:
- Pass
--permission-prompt-tool stdioon all Claude invocations (alongside--print --output-format stream-json --verbose) - Connect Claude's stdin to a pipe controlled by milk (previously
cmd.Stdin = nil) - In
Stream(), detectcontrol_requestevents and invoke aPermissionHandlercallback synchronously before continuing to read the stream - The handler receives
ControlRequest(tool name, input, reason type, blocked path) and returns"allow"or"deny" - milk's default handler asks the user interactively: show the tool name and input, prompt y/n; for
workingDirblocks show the blocked path explicitly - Remove
PermissionDenied,DeniedTool,DirRestrictedfromParseResult - Remove
permissionPhrases,dirRestrictionPhrases,detectPermissionDenied,detectDirRestrictedfromstream.go - Remove
retryWithTool,retryWithDir,handleClaudeRetryfrommain.go - Remove
WithExtraTools,WithExtraDirsfromclaude.go(no longer needed — the session stays live) AllowedTools/add_dirsconfig fields and--allowedTools/--add-dirflags are kept as proactive pre-approval, unchanged
Consequences¶
- Permission detection is fully language-neutral and version-stable
- The session stays alive during the approval prompt — no
--resumeround-trip, no context loss blocked_pathis surfaced directly to the user without asking them to type a path- Tool input is shown to the user so they can make an informed decision
cmd.Stdinmust now be a pipe (not nil), and theStream()goroutine must write responses to it while simultaneously reading stdout — requires careful goroutine coordination- If bug #34046 is still present in the installed version,
control_requestmay not emit reliably; the fallback is to re-enable--dangerously-skip-permissionsvia config