-
Notifications
You must be signed in to change notification settings - Fork 0
Permission Flow
This page documents how unleashed detects and auto-approves Claude Code permission prompts — from raw PTY bytes to carriage return.
sequenceDiagram
participant CC as Claude Code
participant PTY as PTY (pywinpty)
participant Reader as PTY Reader (t2)
participant Sentinel as Worker Thread
participant Term as Terminal
CC->>PTY: Render permission prompt (Ink)
PTY->>Reader: Raw bytes (ANSI + text)
Reader->>Term: Pass through to display
Reader->>Reader: Search PERMISSION_PATTERNS
Note over Reader: Pattern found in overlap + current chunk
Reader->>Reader: extract_permission_context_structured()
Note over Reader: Returns (tool_type, tool_args, context)
alt Shadow Mode
Reader->>Reader: Write to shadow log
end
alt Sentinel Enabled + Tool in Scope
Reader->>Reader: Set in_approval = True
Reader->>Sentinel: Spawn worker thread
Reader->>Reader: Return (resume reading)
Sentinel->>Sentinel: LocalRules.check_bash()
alt Local ALLOW/BLOCK
Note over Sentinel: Resolved in <1ms
else UNCERTAIN
Sentinel->>Sentinel: Haiku API call (1-3s)
end
alt ALLOW
Sentinel->>PTY: pty.write('\r')
else BLOCK
Sentinel->>Term: Red warning to stderr
Note over Sentinel: CR withheld — user decides
else ERROR
Sentinel->>Term: Yellow warning to stderr
Sentinel->>PTY: pty.write('\r') (fail-open)
end
Sentinel->>Sentinel: Set in_approval = False
else No Sentinel or Tool Not in Scope
Reader->>Reader: Set in_approval = True
Reader->>Reader: sleep(0.1)
Reader->>PTY: pty.write('\r')
Reader->>Reader: Set in_approval = False
end
Claude Code renders permission prompts with these text strings:
PERMISSION_PATTERNS = [
b'Tab to amend',
b'Do you want to proceed?',
b'Allow this command to run?',
b'Do you want to allow Claude to fetch this content?',
]These are matched as byte patterns against the ANSI-stripped search chunk. The search includes a 256-byte overlap buffer from the previous read to catch patterns that span two pty.read(8192) chunks.
The patterns are searched in ANSI-stripped text (via strip_ansi()), not raw bytes:
- Ink's cursor-positioning codes intersperse ANSI sequences within text — raw byte matching would fail on fragmented rendering
-
strip_ansi()replaces ANSI codes with spaces and collapses runs, preserving word boundaries - The overlap buffer + stripping together ensure reliable pattern detection
# Prevents missing patterns split across reads
search_chunk = self.overlap_buffer + raw_bytes
clean_chunk = strip_ansi(search_chunk)
# ...
self.overlap_buffer = raw_bytes[-256:]The longest pattern (b'Do you want to allow Claude to fetch this content?') is 50 bytes. A 256-byte overlap guarantees detection even if the pattern spans a read boundary.
Once a permission is detected, extract_permission_context_structured() identifies what tool triggered it:
TOOL_CALL_RE = re.compile(
r'(Read|Write|Edit|Bash|Glob|Grep|WebFetch|WebSearch|Skill|Task|NotebookEdit)\(([^)]{1,500})\)',
re.DOTALL
)This regex runs against the ANSI-stripped context buffer (last ~2KB of output). It returns:
-
tool_type: "Bash", "Write", "Edit", etc. -
tool_args: The content inside parentheses (command text for Bash, file path for Write/Edit) -
raw_context: Fallback string if no regex match
Known issue: The 2KB context buffer may be too small for tool calls preceded by large output (#43).
| Phase | Duration | Notes |
|---|---|---|
| Pattern detection | <1ms | Byte string search in ~8KB chunk |
| Tool extraction | <1ms | Regex on 2KB context buffer |
| Shadow log write | <1ms | Append to file, flush |
| Instant approval | ~200ms | 100ms sleep + CR + 100ms sleep |
| Sentinel local rules | <1ms | Regex matches against safe/block patterns |
| Sentinel API call | 800ms-3s | Haiku API (p50 ~800ms, p99 ~2.5s, timeout 3s) |
| Total (no sentinel) | ~200ms | Imperceptible to user |
| Total (sentinel, local hit) | ~200ms | Same as no sentinel |
| Total (sentinel, API call) | 1-3s | Noticeable but acceptable |
The boolean in_approval flag prevents:
- Double-approval: Two CRs sent for one permission prompt
- Overlapping sentinel checks: Only one worker thread at a time
- Permission detection during approval: Reader skips pattern matching while flag is True
Known issue: No timeout on the flag. If the worker thread hangs, all subsequent approvals are suppressed (#41).
Claude Code sometimes emits multiple permission prompts in quick succession (e.g., when spawning sub-agents). The in_approval flag serializes these — the second prompt waits until the first is resolved.
If Claude's LLM output contains one of the four pattern strings (e.g., in a code comment or documentation), unleashed could detect a false permission prompt. See #39 for analysis and mitigation.
Ink sometimes renders text with escape sequences between characters: \x1b[36mA\x1b[0mllow. The byte-level pattern match still succeeds because the pattern spans enough of the visible text. However, heavily fragmented rendering (one escape sequence per character) could break detection.
The --sentinel-scope flag controls which tool types route through sentinel:
| Scope | Tool Types Evaluated | Use Case |
|---|---|---|
bash |
Bash only | Default — most dangerous tool type |
write |
Bash, Write, Edit | File modification protection |
all |
Bash, Write, Edit, WebFetch, WebSearch, Skill, Task | Full audit mode |
Tools not in scope are auto-approved instantly (no sentinel overhead).
--sentinel-shadow logs what sentinel would see without actually evaluating anything. Every detected permission writes to logs/sentinel-shadow-{session}.log:
--- [14:23:07] ---
Tool: Bash
Args: git push origin main
Context: Bash(git push origin main)
Use shadow mode to validate sentinel's tool type detection before enabling actual evaluation.
Architecture
Safety & Security
Session Mirror
Reference