diff --git a/designs/account-trust-diagrams.html b/designs/account-trust-diagrams.html new file mode 100644 index 0000000..abd32c0 --- /dev/null +++ b/designs/account-trust-diagrams.html @@ -0,0 +1,215 @@ + + + + + +Atem — one pairing, many devices (§9 proposal) + + + + + + +
+

One pairing, many devices

+

§9 proposal for Atem + Astation — same account, no repeated pairing, no forwarding through the Mac, no trust in the account login. + Full text: account-trust.md

+ +
+

The idea

+

The account keeps a small signed device list. Atem pins its start when it pairs once, then accepts exactly the devices that list names — whoever relays the traffic.

+
+
Paironce per Atem, with any one trusted Astation (today's safety-code ceremony).
+
Add a deviceonce per device: an already-trusted device approves it with Touch ID / password.
+
Connectany listed device reaches every Atem of the account, over relay or direct :8080.
+
RemoveSettings → Devices → Remove. Atem stops accepting it within 24 h at most (usually at once).
+
Not trustedthe account login, the relay, presence, or a device's own claims.
+
Not neededthe Mac staying online, or pairing each device with each Atem.
+
+
+ +
+

Who holds what

+

Each device keeps its own key. The relay only stores the list; signatures make it trustworthy.

+
+
+flowchart LR
+  subgraph ACC["Account: alice"]
+    direction TB
+    MAC["Mac Astation
device key in Secure Enclave
roles: manage · control · view"] + PHONE["iPhone Astation
own device key
roles: control · view"] + IPAD["iPad Astation
own device key
roles: view"] + end + LOG[("Membership log
1 add Mac (genesis)
2 add iPhone — signed by Mac
3 add iPad — signed by Mac")] + RELAY["Relay
routes traffic + stores the log
(untrusted)"] + subgraph HOSTS["Your machines"] + direction TB + A1["Atem on Genie
pins: account + genesis hash
+ newest seq seen"] + A2["Atem on laptop
same pin"] + end + MAC -- "appends signed entries" --> LOG + LOG --- RELAY + PHONE <-- "session channel" --> RELAY + RELAY <-- "session channel" --> A1 + IPAD <-. "direct :8080 (LAN)" .-> A2 + RELAY <--> A2 + A1 -. "verifies chain back to its pin" .-> LOG +
+
+ +
+

Adding the iPhone — once

+

The approval is a signature by a trusted device after the user compares a code and authenticates. Logging in alone produces nothing Atem accepts.

+
+
+sequenceDiagram
+  autonumber
+  actor U as You
+  participant P as iPhone Astation
+  participant R as Relay
+  participant M as Mac Astation (manager)
+  participant A as Atem (any host)
+  U->>P: sign in to the same account
+  P->>P: create device key (Secure Enclave)
+  P->>R: "please add me" + key + short code ABCD-1234
+  R->>M: pending request
+  M->>U: "Allow iPhone? code ABCD-1234"
+  U->>M: codes match → Touch ID / password
+  M->>M: sign entry seq 2: add iPhone, roles control+view
+  M->>R: append entry (relay stores, can't forge)
+  Note over A: no action on any Atem — it learns entry 2
the next time any device connects or it polls the head +
+
+ +
+

The iPhone connects to Atem

+

Atem checks the chain from its pin, a fresh head, and that the device really holds its key — then grants exactly its roles.

+
+
+sequenceDiagram
+  autonumber
+  participant P as iPhone Astation
+  participant R as Relay
+  participant M as Any online manager
+  participant A as Atem
+  P->>A: hello (via relay or direct :8080)
+  A->>P: challenge nonce + Atem's newest seq (2)
+  P->>M: head attestation for nonce, please
+  M->>P: signed {seq 3, head hash, nonce, time}
+  P->>A: entries 3 + attestation + signature over (nonce, atem id, trust gen, ephemeral keys)
+  A->>A: entries chain from pinned hash? attestation by a listed manager, not older than seq 2?
+  A->>A: iPhone listed, not removed, roles control+view? signature valid?
+  A->>P: welcome — roles control+view, 24 h window starts now
+  Note over P,A: Step 2 of the proposal: both derive session keys from the ephemeral exchange
so the relay can't read terminal or chat traffic +
+
+ +
+

Removing a device, and staying safe offline

+

Removal is just another signed entry. Atem never moves backwards and never trusts a list older than 24 h.

+
+
+flowchart TD
+  RM["Mac: Settings → Devices → Remove Old iPad
(Touch ID)"] --> E4["entry seq 4: remove Old iPad
signed by Mac"] + E4 --> REL[("Relay stores seq 4")] + REL --> C1{"Next connection to Atem
brings entries up to the head"} + C1 -- "Atem sees seq 4" --> GONE["Old iPad refused
its live attachment is fenced"] + C1 -- "relay withholds seq 4" --> WIN{"Attestation the iPad shows
older than 24 h?"} + WIN -- "yes" --> PAUSE["refused / paused
(no fresh proof)"] + WIN -- "no" --> LATE["accepted at most until the
24 h window ends — the approved bound"] + HW["Atem keeps the highest seq it has seen
— a list older than that is always refused"] -.-> C1 + LOST["All manager devices lost?"] --> REC["local atem unpair + atem pair
→ new genesis, new trust generation
(data and keys kept)"] +
+
+ + +
+ + + diff --git a/designs/account-trust.md b/designs/account-trust.md new file mode 100644 index 0000000..dcd5f2a --- /dev/null +++ b/designs/account-trust.md @@ -0,0 +1,324 @@ +# §9 proposal: one pairing, many devices on the same account + +Status: Atem-side proposal for joint review, 2026-10-16. Answers +`session-multiplexing.md` §9 / atem-session §9 ("trust anchor, membership, +freshness") for the user's decision: **Atem pairs once; the user's other +devices on the same account (Mac, iPhone, iPad) reach it without pairing +again — not by trusting the account login, and not by forwarding through the +Mac.** Diagrams: [account-trust-diagrams.html](account-trust-diagrams.html) (open it in a browser, e.g. via `atem serv files designs/account-trust-diagrams.html`). + +## 1. Idea in one paragraph + +The account has a small **signed device list** (the *membership log*). Every +trusted device keeps its own private key; the log records which device keys +belong to the account and what each may do. When Atem pairs with the first +Astation it **pins the start of that log** (its genesis hash) and from then on +accepts any device the log lists — and nothing else. A new device joins once, +approved with Touch ID or the password on a device that is already trusted; +that adds one signed entry. Removing a device appends a signed removal. Atem +never trusts the relay, the account login or a device's own claims: it trusts +signatures that chain back to what it pinned. + +## 2. Pieces + +| Piece | Where it lives | What it is | +|---|---|---| +| Device key | each native Astation (Secure Enclave / platform keystore) | P-256 signing key + X25519 key; never leaves the device | +| Membership log | relay stores it (untrusted storage), every trusted device and Atem keep copies | append-only list of signed entries | +| Log entry | in the log | `{account_id, seq, prev_hash, action: add\|remove\|set_roles, device_id, device_sign_pub, device_enc_pub, roles, signer_device_id, signed_at}` + signature by a device whose roles include `manage` at `seq-1` | +| Genesis | entry `seq = 1` | `add` of the first Astation by itself, roles `manage, view, control` | +| Head attestation | produced on request by any online `manage` device | `{account_id, seq, head_hash, issued_at, atem_nonce?}` signed by a manager — "this is the latest log as of now" | +| Atem pin | Atem's `cred_state.json`, next to the existing verification pins | `{account_id, genesis_hash, high_water_seq, high_water_hash, last_fresh_at}` | + +Roles: `view` (see sessions/output), `control` (take the lease, type, submit, +answer the agent's permission prompts while holding the lease), `manage` (add/remove devices). +Viewing ≠ control ≠ enrolment, as the product design requires. + +## 3. Flows + +### 3.1 Pairing (once per Atem) + +Today's `atem pair` + safety-code verification, with one addition: Astation +sends the current membership log; Atem verifies the chain from genesis, checks +that the pairing Astation's verified key is a listed `manage` device, and pins +`genesis_hash` and the head as its high-water mark. The safety code also covers +the genesis hash (so a relay can't swap in another account's log). + +### 3.2 Adding a device (once per device, not per Atem) + +1. New iPhone signs in to the same account → creates its device key → shows + "waiting for approval" with a short code (and its key fingerprint). +2. A trusted manager device (the Mac) shows "Allow iPhone of Alice? code + ABCD-1234". The user compares the code and approves with Touch ID/password. +3. The Mac appends `add{iPhone keys, roles}` signed with its own device key; the + relay stores it. Account login alone can't produce this signature. +4. Every Atem that pinned this account will accept the iPhone as soon as it + sees the new entry — no Atem-side action. + +### 3.3 A device connects to Atem + +1. Device ↔ Atem over relay or direct :8080 (same messages). +2. Atem sends `challenge{nonce}`. +3. Device answers with: its device id, the log entries Atem doesn't have yet + (from Atem's high-water seq to head), a **head attestation** that includes + Atem's nonce (fresh) or one issued < 24 h ago, and a signature by its device + key over the transcript (nonce, `atem_device_id`, `host_trust_gen`, both + ephemeral X25519 keys). +4. Atem verifies: the new entries chain from its pinned high-water hash; the + attestation is signed by a manager listed at that seq and isn't older than + the head Atem already knows; the device is listed (not removed) with the + roles it asks for; the transcript signature is the device's. +5. Both sides derive session keys from the ephemeral exchange (step 2 of this + proposal: end-to-end encryption of the session channel). `welcome` grants + exactly the device's roles; leases and receipts work as today. + +### 3.4 Removing a device + +Settings → Devices → Remove "Old iPad" on any manager device → `remove` entry +signed and appended. Atem applies it the next time any device connects (they +must bring entries up to the head) or when Atem polls the relay for the head. +Atem keeps the highest seq it has seen, so it never goes back to an older list. + +### 3.5 Freshness and offline + +- **Fresh proof:** a head attestation that echoes Atem's nonce, signed by an + online manager, shows the list is current *now*. +- **Offline window (approved: 24 h):** with no online manager, Atem accepts an + attestation issued < 24 h ago (Atem's own clock; also bounded by the time it + last saw a fresh one). After 24 h without fresh proof, remote viewing/control + pause; agents and the local terminal keep running. +- **A withheld removal** (relay or attacker hides the newest entries) can delay + revocation by at most 24 h — the approved bound — and never past any newer + head Atem has already seen. +- **Clock changes:** Atem measures the 24 h with both the monotonic and the wall + clock and uses whichever expires first; a clock set backwards can't extend it. + +### 3.6 Lost devices / recovery + +If every manager device is lost, the account's log can't grow. Recovery is the +approved one: on each Atem host, local `atem unpair` + `atem pair` with a new +Astation, which starts a **new genesis** (new trust generation). Account data +and encryption keys are kept as today. + +## 4. Why this shape + +| Alternative | Problem | +|---|---| +| Trust the account login | Anyone with the password/session (or a compromised relay) controls the agents | +| Forward through the paired Mac | Phone stops working when the Mac sleeps; Mac becomes a single point of failure | +| Pair every device with every Atem | Exactly what the user doesn't want; N×M ceremonies | +| One shared account private key copied to all devices | Can't stay in Secure Enclave; a leak can't be revoked per device | +| **Signed device list pinned at pairing (proposed)** | Pair once; add a device once; per-device revocation; relay is only storage | + +## 5. What changes on each side + +**Astation (native):** device keys (exists for verification), membership log +create/append/verify, the add-device approval UI (code + Touch ID), Settings → +Devices (remove), head attestations on request, connecting to Atem with the +proof bundle. **Relay:** store and serve the log per account (append-only, +reject non-chaining appends as a convenience — not trusted). **Atem:** verify +the chain and attestations, pin at pairing, high-water + freshness state, +challenge/response in `hello`, roles → permissions, accept many attachments at +once (the runtime already multiplexes viewers; today it only admits the paired +Astation). + +## 6. Steps + +1. **Membership + freshness** (this proposal): log format, signatures, pairing + pin, add/remove, connect proof, 24 h rule — plus shared fixtures (valid + chain, forked chain, removed device, stale attestation, wrong account). +1b. **Approvals from chat/mobile:** prompt-bound `approval_request` / + `approval` submissions for the lease holder (see question 3). +2. **End-to-end channel:** ephemeral X25519 + device keys → session keys for + the atem-session channel over relay and direct; relay can no longer read + terminal or chat traffic. +3. **Browser QR grants:** a native device signs `{browser key, target, scope, + expiry ≤ 1 h}`; the browser proves its key on connect; same checks. + +## 6a. Approvals in chat and on mobile (step 1b) + +The lease holder may answer the agent's permission prompts from the chat view or +a phone. Both agents can take an external allow/deny without simulating keys: + +| | Claude Code ([hooks](https://code.claude.com/docs/en/hooks)) | Codex ([hooks](https://developers.openai.com/codex/hooks)) | +|---|---|---| +| Hook | `PermissionRequest` — runs only when a permission dialog would appear | `PermissionRequest` — runs only when an approval would be asked | +| Input | `session_id`, `tool_name`, `tool_input`, `permission_suggestions` | `turn_id`, `tool_name`, `tool_input` | +| Answer | `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` or `deny` + `message` | same shape | +| No answer | normal terminal dialog appears | normal prompt appears | +| Setup | per launch via `--settings` (as chat does) | `~/.codex/hooks.json`, trusted once in Codex (`/hooks`) | + +Verified on Claude Code 2.1.301 (interactive, hook from `--settings`; +fixtures: `designs/fixtures/permission-hook/`): + +- The hook fires with `session_id`, `transcript_path`, `cwd`, `prompt_id`, + `permission_mode`, `tool_name`, `tool_input`, `permission_suggestions`. +- **The terminal dialog is shown at once while the hook runs.** The local user + can answer it immediately; the hook is not killed and its late output is + ignored. The hook decides only if it answers before the user does. +- `allow` → no dialog, the command runs ("Allowed by PermissionRequest hook"); + `deny` + message → not run, the model sees the message and doesn't retry; + no output → normal dialog. +- A `timeout` field is honoured (SIGTERM at the limit; the dialog stays). +- Esc on the dialog interrupts the turn and SIGTERMs the waiting hook. +- The Notification `permission_prompt` hook fires ~6 s later, only if the dialog + is still unanswered. + +So the terminal dialog and the remote approval race, and **whichever answers +first wins** — Claude arbitrates; the local user never loses the ability to +answer. Flow: + +1. The hook writes the request (tool, summary, `prompt_id`) as one event file in + the session's private directory, like the other chat hooks. +2. **No remote lease holder** → the runtime answers "no decision" at once; the + hook exits; the local dialog (already visible) is the only way to answer. +3. **A remote lease holder** → the runtime announces `approval_request{approval_id, + session_id, process_gen, tool, summary, created_at}`; chat shows an approval + card with Allow / Deny (a phone can raise a lock-screen notification); the hook + keeps waiting (hook `timeout` 600 s). +4. The controller answers with a §6 submission `kind:"approval"`, payload + `{approval_id, decision: allow|deny}`, under its current lease epoch; the + runtime checks the prompt is still pending for that exact session/process and + writes the decision file; the hook returns it. +5. **Answered in the terminal first**, interrupted (Esc), timed out, or the lease + lost → the request is closed (the runtime sees the tool run or fail via the + PostToolUse/transcript events, or the hook's exit); a later remote answer gets + `error{code:"stale_target"}` and is never applied. The card disappears. +6. Receipts record the outcome as observed: `completed` when the tool ran/was + refused as decided by the remote answer, `outcome_unknown` if the terminal + answer and the remote answer crossed and Atem can't tell which won. + +Codex (verified on codex-cli 0.162.1, interactive, `-a on-request`; fixtures: +`designs/fixtures/permission-hook/codex/`) behaves differently in two ways: + +- **Codex holds its approval prompt until the hook exits** (the TUI shows + "Running hook"); the local user can't answer while the hook waits. +- Esc interrupts the turn but doesn't stop the hook; a timeout shows "Hook + failed: hook timed out" and then the normal prompt. +- Same answers otherwise: no output → prompt; allow → runs; deny + message → + "Blocked by hook", the model sees the message. Input: `session_id`, + `turn_id`, `transcript_path`, `cwd`, `model`, `permission_mode`, `tool_name`, + `tool_input` (no `prompt_id`). +- Hooks are on by default; a non-managed hook must be **trusted once** in Codex + (startup review or `/hooks`), stored as a hash of the hook entry — any change + to the entry needs a new review. + +What that means for Atem with Codex: + +1. **Local input releases the hook.** All local keystrokes already pass through + the runtime, which revokes the remote lease before writing them; for Codex it + also answers "no decision" to a waiting hook first, so Codex shows its prompt + and the user answers it in the terminal (the keystroke that released it is + delivered after the prompt appears). +2. **Hook timeout 300 s for Codex** (long enough to answer from a locked phone); + any local key releases it earlier; on timeout Codex shows its own prompt. +3. On Esc the runtime closes the request itself (the hook may keep running; its + late output is ignored). +4. **Set up by `atem agent launch codex`, once, without breaking anything** + (user decision, 2026-10-16 — no separate command): + - On the first Codex launch Atem asks once ("Allow approvals from Astation + for Codex? This adds one hook to ~/.codex/hooks.json") and remembers the + answer; declining just means Codex approvals stay terminal-only. + - It **merges**: the user's existing hooks are kept, one entry marked as + Atem's is added (idempotent; a backup `hooks.json.atem-bak` is written + before the first change; never overwritten wholesale). + - The hook command is identical for every session (it finds the session + through an environment variable set by the worker), so Codex's one-time + trust stays valid. + - **Outside Atem it does nothing:** in a Codex run not started by Atem the + variable is absent and the hook exits at once with no output, so Codex shows + its normal prompt — plain `codex` behaves as before (one short shell start + per approval prompt). + - Inside Atem with no remote controller it also exits at once; it never + auto-allows, and any failure means "no decision". + - Codex shows its "hooks need review" screen once after the entry is added + (also in plain `codex`); the user trusts it there. + - To verify while building: how Codex combines our hook with other + `PermissionRequest` hooks the user already has (ours must never override + their deny). + - Claude Code needs none of this: its hook comes per launch through + `--settings`, nothing global changes. + + +Changes to earlier rules: chat's "no approvals from chat" becomes "approvals by +the lease holder, bound to the exact prompt"; "hooks never decide" gains one +exception — the `PermissionRequest` hook may return a decision it received from +the runtime; the local terminal dialog always stays available. + +### Push-based approvals (user decision, 2026-10-16) + +The core experience: leave the computer, lock the phone, still get and answer +approval requests. Notification delivery and lease ownership are independent: + +1. Every approval opens a card for all devices with `control` — no lease needed + to receive it. +2. Atem asks the relay (over its authenticated link) to push it: + `push_approval{approval_id, atem_device_id, session_id, tool, summary, + expires_at}`; the relay delivers via **APNs (iOS) / FCM (Android)** to every + device on the account with `control`, online or not. Astation owns the push + credentials, device token registration and the notification UI. +3. The notification shows "Claude on Genie wants to run `npm test`" (tool + + short redacted summary only); marked `mutable-content` so iOS can decrypt it on + device once end-to-end encryption lands. +4. Tapping wakes the app, which reconnects, authenticates as a member device and + receives the pending approval; Allow once / Always allow (Claude) / Deny. +5. The answer carries `acquire: true`: Atem takes the lease for that device + (takeover allowed) and applies the decision **atomically** — the lease is only + taken when the user acts. +6. Answered elsewhere, expired or closed → `push_resolved{approval_id}` replaces + the notification everywhere (collapse-id / FCM tag = approval_id); a late tap + says "already answered". +7. Hook waits: Claude 600 s (dialog stays visible; first answer wins), Codex + 300 s (prompt hidden while waiting; any local key releases it). + +Phones can answer once they are members (step 1); until then only the paired +Astation can. + +## 6b. Comparison with Codex Remote (ChatGPT mobile) + +OpenAI's Remote lets a phone continue Codex chats, answer questions and approve +commands on a Mac/Windows host through OpenAI's relay +([announcement](https://openai.com/index/work-with-codex-from-anywhere/), +[architecture overview](https://codex.danielvaughan.com/2026/05/15/codex-mobile-chatgpt-app-relay-architecture-remote-agent-control/), +[pairing RPCs](https://codex.danielvaughan.com/2026/06/06/codex-cli-remote-control-mobile-pairing-app-server-v2-remodex/), +[app-server](https://developers.openai.com/codex/app-server/)). + +| | Codex Remote | Atem + Astation | +|---|---|---| +| Agent session | `codex app-server`; an already running terminal session isn't shared live | the original terminal session stays live; chat comes from the agent's hooks and transcript | +| Phone view | structured thread items (messages, tool steps, diffs, approvals) | chat-v1 messages + terminal view | +| Approvals | `item/…/requestApproval` → accept / acceptForSession / decline / cancel | `PermissionRequest` hook → `approval_request` → lease-bound `approval` | +| Trust | QR pairing per phone per host + account MFA; list/revoke controllers per host | pair Atem once; add each device to the account once; Settings → Devices | +| Control | multi-device / local-terminal arbitration not documented | many viewers, one lease holder, local input wins | +| Relay | OpenAI relay; E2EE documented only by a third-party bridge | our relay; E2EE in step 2 (ephemeral X25519 + signed transcript) | + +Borrowed: approval cards with push; recording the approval's source (our +receipts); a revocable controller list (Settings → Devices); the handshake shape +for step 2. Not borrowed: per-host pairing of every phone, and replacing the +terminal session with an app-server conversation. + +## 7. Decisions (user, 2026-10-16) + +1. **Who can add devices:** only devices marked "can manage devices" (role + `manage`). The approval screen asks for it; off by default for phones. The + first Astation (genesis, e.g. the Mac) starts as the **only** manager; + further devices become managers only when the user marks them (confirmed by + the user, 2026-10-16). +2. **Freshness heartbeat:** besides on-demand attestations for Atem's nonce, + every online manager re-signs the current head every 6 h, so a device can + connect with a fresh-enough list even when no manager is reachable at that + moment (still bounded by the 24 h window). +3. **Decided (user, 2026-10-16): approvals from chat and mobile are allowed for + whoever holds control.** No separate `approve` role: a device with `control` + that currently holds the session's lease may answer the agent's permission + prompts from the chat view or a phone, not only in the terminal. To keep a late + or replayed tap from approving the wrong action, approvals are **bound to the + exact prompt**: Atem announces `approval_request{approval_id, session, + process_gen, tool, summary}` when the agent asks; the controller answers with a + §6 submission `kind:"approval"`, payload `{approval_id, decision: + allow|deny}`, under its current lease epoch; Atem applies it only if that + prompt is still the one pending, then records the receipt. Delivery to the + agent (to be verified per agent): preferably the agent's own structured + permission hook (Claude Code's permission hook answering allow/deny), else the + exact keypress for that prompt. Built as its own step after membership (§6 + step 1b). diff --git a/designs/fixtures/permission-hook/README.md b/designs/fixtures/permission-hook/README.md new file mode 100644 index 0000000..003786e --- /dev/null +++ b/designs/fixtures/permission-hook/README.md @@ -0,0 +1,29 @@ +# Claude Code PermissionRequest hook probe (interactive, `--settings`) + +Claude Code 2.1.300 at start (auto-updated to 2.1.301 mid-probe; all runs below on 2.1.301), Linux, tmux 3.4, haiku, +`--permission-mode default` ("manual mode"), hooks only from `--settings `. +Fixtures: permission-request-stdin.json, notification-stdin.json, perm.sh/notif.sh (probe hooks), +settings-with-timeout.json, pane-{none,allow,deny}.txt (capture-pane tails). + +1. Fires: yes. stdin = session_id, transcript_path, cwd, prompt_id, permission_mode, effort{level}, + hook_event_name, tool_name, tool_input{command,description}, permission_suggestions[{type:addDirectories,...}]. +2. The dialog is NOT hidden. It renders immediately and concurrently with the running hook (hook = `sleep 25`, dialog + visible at t=5..25 s). Keys work: Enter (Yes) ran the command at once; the hook was NOT killed, it kept running to + its natural end (40 s) and its late output was ignored. So the hook only wins if it answers before the user does. +3. none (exit 0, no output) -> normal dialog. allow -> no dialog, runs, tool result "Allowed by PermissionRequest hook". + deny+message -> tool result "Error: Denied by phone probe: do not run this" + "Denied by PermissionRequest hook"; + the message reaches the model (it quoted it and did not retry). +4. `"timeout": 5` accepted. At 5 s the hook got SIGTERM (script exited); dialog was already up and stayed; no visible + error text in the pane. A late `allow` (sleep 20) was never delivered. Default 600 s not tested (not waited). +5. Notification(permission_prompt) fires ("Claude needs your permission", notification_type permission_prompt) but only + ~6 s after PermissionRequest started, and only if the dialog is still unanswered (not fired for allow/deny that + answered immediately). It does not wait for the hook to end. +6. Esc on the visible dialog = "Interrupted - What should Claude do instead?" and the waiting hook got SIGTERM + (killed; ps empty). Contrast with answering via Yes: hook left running. + +Design implication: a hook that waits for a phone decision leaves the terminal dialog live (the local user can answer +first; the hook must tolerate being ignored/TERMed, and the runtime must treat a local answer as superseding the remote +approval). Never rely on the hook to hide the dialog. + +Side effects: Claude added a trust entry for the temp project in ~/.claude.json (projects["/tmp/phprobe.*/proj"]); +the temp project dir under ~/.claude/projects was removed. diff --git a/designs/fixtures/permission-hook/codex/README.md b/designs/fixtures/permission-hook/codex/README.md new file mode 100644 index 0000000..9e07dd5 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/README.md @@ -0,0 +1,32 @@ +# Codex PermissionRequest hook probe (interactive TUI) + +codex-cli 0.162.1, Linux, tmux 3.4, `codex --profile oss -a on-request -s read-only`, throwaway CODEX_HOME, +hook = hooks.json (`matcher "*"`, type command, perm.sh from the Claude probe). Fixtures here: permission-request-stdin.json, +perm.sh, hooks-with-timeout.json, trust-state-excerpt.toml, pane-*.txt (capture-pane tails, paths anonymised). + +1. Fires: yes. stdin = session_id, turn_id, transcript_path, cwd, hook_event_name, model, permission_mode ("default"), + tool_name ("Bash"), tool_input{command, description}. No tool_use_id, prompt_id, effort, permission_suggestions. +2. OPPOSITE of Claude: the approval prompt is HELD until the hook exits. While `sleep 25` ran the status line showed + "Working ... Running hook" with no dialog (pane-sleep25-prompt.txt shows the dialog only after the hook ended at ~25 s); + the user cannot answer meanwhile. So the hook (a phone decision) is authoritative for up to its timeout. +3. no output -> normal dialog. allow JSON -> runs with no dialog (even in a read-only sandbox: probe.txt was created). + deny+message -> "Blocked by hook: "; message goes to the model, command not run, the turn continues + (followed by "Automatic approval review rejected ... with the reason ..."), no dialog. +4. `"timeout": 5` honoured: after 5 s "Hook failed: hook timed out after 5s", then the normal dialog appears. A late + allow was never delivered; the hook process was gone afterwards (no SIGTERM trap output, so probably SIGKILL). + Default is 600 s (docs; not waited). +5. Esc while the hook waits: turn interrupted ("Conversation interrupted"), but the hook is NOT killed; it ran to its + natural end (40 s) and its late output was ignored. (Claude: Esc killed the hook.) +6. Trust: hooks are on by default (`features.hooks` stable true, no flag). Non-managed hooks need trust: a startup screen + "Hooks need review" (Review / Trust all and continue / Continue without trusting) or `/hooks` then `t`. Trust is stored + as `[hooks.state.":permission_request:0:0"] trusted_hash = sha256:...` in the writable config layer + of that CODEX_HOME (with `--profile oss` it went to oss.config.toml); any edit of the hook entry (e.g. adding + timeout) changes the hash and prompts for review again. So persistent per CODEX_HOME/config layer, per hook content. + `--dangerously-bypass-hook-trust` skips it for one run. Other setup notes: `-a untrusted` does not exist in this version + (values: on-request, never); `--profile NAME` means $CODEX_HOME/NAME.config.toml, which was needed besides config.toml; + the folder trust prompt and an update prompt appear on first start; typing before the TUI is ready loses input. + +Design implication: with Codex a remote approval can be decided inside the hook, but the local terminal has no +prompt until it returns/times out; Esc does not stop the hook. Use a short timeout (fallback is the local dialog). + +Cleanup: temp CODEX_HOME (with a copied auth.json), project and tmux session removed; ~/.codex untouched. diff --git a/designs/fixtures/permission-hook/codex/hooks-with-timeout.json b/designs/fixtures/permission-hook/codex/hooks-with-timeout.json new file mode 100644 index 0000000..f46f7d8 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/hooks-with-timeout.json @@ -0,0 +1 @@ +{"hooks":{"PermissionRequest":[{"matcher":"*","hooks":[{"type":"command","command":"/home/user/t/hook/perm.sh","timeout":5}]}]}} diff --git a/designs/fixtures/permission-hook/codex/pane-allow.txt b/designs/fixtures/permission-hook/codex/pane-allow.txt new file mode 100644 index 0000000..2d05502 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-allow.txt @@ -0,0 +1,14 @@ +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +✗ You canceled the request to run touch probe.txt +• Failed (exit 1) touch probe.txt + └ (no output) +■ Conversation interrupted - use /feedback if something went wrong +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Ran touch probe.txt + └ (no output) + Worked for 7s • 7:16 PM +› Ask Codex to do anything + GPT-6.1-Sol xhigh · /home/user/proj + ? for shortcuts ⚠ 2 warnings · f2 to view diff --git a/designs/fixtures/permission-hook/codex/pane-deny-after.txt b/designs/fixtures/permission-hook/codex/pane-deny-after.txt new file mode 100644 index 0000000..52219d4 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-deny-after.txt @@ -0,0 +1,10 @@ +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Blocked by hook + └ Denied by phone probe: do not run this +• Automatic approval review rejected touch probe.txt with the reason: “Denied by phone probe: do not run this.” The command did not run. + └ Tip: Use /feedback to send logs to the maintainers when something looks off. + Worked for 25s • 7:17 PM +› Ask Codex to do anything + GPT-6.1-Sol xhigh · /home/user/proj + ? for shortcuts ⚠ 2 warnings · f2 to view diff --git a/designs/fixtures/permission-hook/codex/pane-deny.txt b/designs/fixtures/permission-hook/codex/pane-deny.txt new file mode 100644 index 0000000..f497415 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-deny.txt @@ -0,0 +1,14 @@ +■ Conversation interrupted - use /feedback if something went wrong +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Ran touch probe.txt + └ (no output) + Worked for 7s • 7:16 PM +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Blocked by hook + └ Denied by phone probe: do not run this +◦ Working (19s • esc to interrupt) +› Ask Codex to do anything + GPT-6.1-Sol xhigh · /home/user/proj + ? for shortcuts ⚠ 2 warnings · f2 to view diff --git a/designs/fixtures/permission-hook/codex/pane-esc-after.txt b/designs/fixtures/permission-hook/codex/pane-esc-after.txt new file mode 100644 index 0000000..28fcfb3 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-esc-after.txt @@ -0,0 +1,8 @@ +• Automatic approval review rejected touch probe.txt with the reason: “Denied by phone probe: do not run this.” The command did not run. + Worked for 25s • 7:17 PM +› Run the shell command: touch probe2.txt . Do nothing else. +• I’ll run touch probe2.txt. +■ Conversation interrupted - use /feedback if something went wrong +› Ask Codex to do anything + GPT-6.1-Sol xhigh · /home/user/proj + ? for shortcuts ⚠ 2 warnings · f2 to view diff --git a/designs/fixtures/permission-hook/codex/pane-esc-before.txt b/designs/fixtures/permission-hook/codex/pane-esc-before.txt new file mode 100644 index 0000000..e432ee3 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-esc-before.txt @@ -0,0 +1,14 @@ + └ (no output) + Worked for 7s • 7:16 PM +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Blocked by hook + └ Denied by phone probe: do not run this +• Automatic approval review rejected touch probe.txt with the reason: “Denied by phone probe: do not run this.” The command did not run. + Worked for 25s • 7:17 PM +› Run the shell command: touch probe2.txt . Do nothing else. +• I’ll run touch probe2.txt. +◦ Working (7s • esc to interrupt) · Running hook +› Ask Codex to do anything + GPT-6.1-Sol xhigh · /home/user/proj + ? for shortcuts ⚠ 2 warnings · f2 to view diff --git a/designs/fixtures/permission-hook/codex/pane-none.txt b/designs/fixtures/permission-hook/codex/pane-none.txt new file mode 100644 index 0000000..6742825 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-none.txt @@ -0,0 +1,14 @@ +• Failed (exit 1) touch probe.txt + └ (no output) +■ Conversation interrupted - use /feedback if something went wrong +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Running touch probe.txt + Would you like to run the following command? + Environment: local + Reason: Do you want to allow touch probe.txt to create the file in this read-only workspace? + $ touch probe.txt +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p) + 3. No, and tell Codex what to do differently (esc) + Press enter to confirm or esc to cancel diff --git a/designs/fixtures/permission-hook/codex/pane-sleep25-prompt.txt b/designs/fixtures/permission-hook/codex/pane-sleep25-prompt.txt new file mode 100644 index 0000000..2e13647 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-sleep25-prompt.txt @@ -0,0 +1,12 @@ + /home/user/proj +› Run the shell command: touch probe.txt . Do nothing else. +• I’ll run touch probe.txt. +• Running touch probe.txt + Would you like to run the following command? + Environment: local + Reason: Do you want to allow creating probe.txt in the read-only workspace? + $ touch probe.txt +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p) + 3. No, and tell Codex what to do differently (esc) + Press enter to confirm or esc to cancel diff --git a/designs/fixtures/permission-hook/codex/pane-timeout-t15.txt b/designs/fixtures/permission-hook/codex/pane-timeout-t15.txt new file mode 100644 index 0000000..43f6e85 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/pane-timeout-t15.txt @@ -0,0 +1,12 @@ +• I’ll run touch probe.txt. +• Hook failed + └ hook timed out after 5s +• Running touch probe.txt + Would you like to run the following command? + Environment: local + Reason: Do you want to allow creating probe.txt in this read-only workspace? + $ touch probe.txt +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p) + 3. No, and tell Codex what to do differently (esc) + Press enter to confirm or esc to cancel diff --git a/designs/fixtures/permission-hook/codex/perm.sh b/designs/fixtures/permission-hook/codex/perm.sh new file mode 100755 index 0000000..c4d81f1 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/perm.sh @@ -0,0 +1,16 @@ +#!/bin/sh +D=$(dirname "$0") +T=$(dirname "$D") +n=$(date +%s%N) +cat > "$T/perm-$n.json" +echo "$(date +%s.%N) perm start pid=$$ file=perm-$n.json" >> "$T/log" +trap 'echo "$(date +%s.%N) perm got TERM" >> "$T/log"; exit 143' TERM +m=$(cat "$T/mode") +case "$m" in + none) ;; + sleep*) # "sleep N [none|allow]" + set -- $m; sleep $2 & wait $!; [ "$3" = allow ] && printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}';; + allow) printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}';; + deny) printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied by phone probe: do not run this"}}}';; +esac +echo "$(date +%s.%N) perm end mode=$m" >> "$T/log" diff --git a/designs/fixtures/permission-hook/codex/permission-request-stdin.json b/designs/fixtures/permission-hook/codex/permission-request-stdin.json new file mode 100644 index 0000000..247b07a --- /dev/null +++ b/designs/fixtures/permission-hook/codex/permission-request-stdin.json @@ -0,0 +1,14 @@ +{ + "session_id": "01a14623-4f76-7f53-b17b-9f9e7d3239fc", + "turn_id": "01a14623-c9c0-7ac2-ad86-e247cfcc4603", + "transcript_path": "/home/user/home/sessions/2026/10/16/rollout-2026-10-16T19-14-41-01a14623-4f76-7f53-b17b-9f9e7d3239fc.jsonl", + "cwd": "/home/user/proj", + "hook_event_name": "PermissionRequest", + "model": "gpt-6.1-sol", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": { + "command": "touch probe.txt", + "description": "Do you want to allow creating probe.txt in the read-only workspace?" + } +} diff --git a/designs/fixtures/permission-hook/codex/trust-state-excerpt.toml b/designs/fixtures/permission-hook/codex/trust-state-excerpt.toml new file mode 100644 index 0000000..7cc9988 --- /dev/null +++ b/designs/fixtures/permission-hook/codex/trust-state-excerpt.toml @@ -0,0 +1,5 @@ +[hooks.state] + +[hooks.state."/home/user/home/hooks.json:permission_request:0:0"] +trusted_hash = "sha256:e37a7fde990ccce512ead8d8de55d003231437b29f01a45326faaa8fc484f84a" + diff --git a/designs/fixtures/permission-hook/notif.sh b/designs/fixtures/permission-hook/notif.sh new file mode 100755 index 0000000..21b5a20 --- /dev/null +++ b/designs/fixtures/permission-hook/notif.sh @@ -0,0 +1,4 @@ +#!/bin/sh +T=$(dirname "$(dirname "$0")") +n=$(date +%s%N); cat > "$T/notif-$n.json" +echo "$(date +%s.%N) notif file=notif-$n.json" >> "$T/log" diff --git a/designs/fixtures/permission-hook/notification-stdin.json b/designs/fixtures/permission-hook/notification-stdin.json new file mode 100644 index 0000000..e3b112c --- /dev/null +++ b/designs/fixtures/permission-hook/notification-stdin.json @@ -0,0 +1 @@ +{"session_id":"00000000-0000-0000-0000-000000000000","transcript_path":"/home/user/.claude/projects/-tmp-PROBE-proj/00000000-0000-0000-0000-000000000000.jsonl","cwd":"/tmp/PROBE/proj","prompt_id":"00000000-0000-0000-0000-000000000000","hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"} diff --git a/designs/fixtures/permission-hook/pane-allow.txt b/designs/fixtures/permission-hook/pane-allow.txt new file mode 100644 index 0000000..f73fa97 --- /dev/null +++ b/designs/fixtures/permission-hook/pane-allow.txt @@ -0,0 +1,8 @@ + ⎿  (No output) + ⎿  Allowed by PermissionRequest hook +● The command ran and completed with no output, so /tmp/phprobe-out4.txt should contain probe4. I didn't read it back to confirm. +✻ Worked for 3s · done 5:58 PM +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⏸ manual mode on · ? for shortcuts · ← for agents diff --git a/designs/fixtures/permission-hook/pane-deny.txt b/designs/fixtures/permission-hook/pane-deny.txt new file mode 100644 index 0000000..3fbc656 --- /dev/null +++ b/designs/fixtures/permission-hook/pane-deny.txt @@ -0,0 +1,10 @@ +● Bash(echo probe5 > /tmp/phprobe-out5.txt) + ⎿  Error: Denied by phone probe: do not run this + ⎿  Denied by PermissionRequest hook +● The command was denied with the message "Denied by phone probe: do not run this", so /tmp/phprobe-out5.txt was not written. I won't retry it. The + denial came from a hook or permission check, not from you, so if you want it to run, that check needs to be changed first. +✻ Cooked for 3s · done 5:59 PM +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⏸ manual mode on · ? for shortcuts · ← for agents diff --git a/designs/fixtures/permission-hook/pane-none.txt b/designs/fixtures/permission-hook/pane-none.txt new file mode 100644 index 0000000..6471d2d --- /dev/null +++ b/designs/fixtures/permission-hook/pane-none.txt @@ -0,0 +1,48 @@ + ▐▛███▛█ Claude Code v2.1.301 +▝▜██████▀ Haiku 5.5 · API Usage Billing + ▝▝ ▝▝ /tmp/PROBE/proj + + +❯ hello + +● Hello! What can I help you with today? I can work in /tmp/PROBE/proj, which isn't a git repository, so I'll need a bit more direction on what + you'd like done. + +✻ Worked for 2s · done 5:56 PM + +❯ Use the Bash tool to run: echo probe1 > /tmp/phprobe-out1.txt + +● Bash(echo probe1 > /tmp/phprobe-out1.txt) + ⎿  Waiting… + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Bash command + Tip: auto mode handles these prompts for you — choose "switch to auto mode" below + Write probe1 to /tmp/phprobe-out1.txt +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + echo probe1 > /tmp/phprobe-out1.txt +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + Do you want to proceed? + ❯ 1. Yes + 2. Yes, and always allow access to /tmp from this project + 3. Yes, and switch to auto mode · auto mode handles these prompts for you + 4. No + + Esc to cancel · Tab to amend + + + + + + + + + + + + + + + + + diff --git a/designs/fixtures/permission-hook/perm.sh b/designs/fixtures/permission-hook/perm.sh new file mode 100755 index 0000000..c4d81f1 --- /dev/null +++ b/designs/fixtures/permission-hook/perm.sh @@ -0,0 +1,16 @@ +#!/bin/sh +D=$(dirname "$0") +T=$(dirname "$D") +n=$(date +%s%N) +cat > "$T/perm-$n.json" +echo "$(date +%s.%N) perm start pid=$$ file=perm-$n.json" >> "$T/log" +trap 'echo "$(date +%s.%N) perm got TERM" >> "$T/log"; exit 143' TERM +m=$(cat "$T/mode") +case "$m" in + none) ;; + sleep*) # "sleep N [none|allow]" + set -- $m; sleep $2 & wait $!; [ "$3" = allow ] && printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}';; + allow) printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}';; + deny) printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied by phone probe: do not run this"}}}';; +esac +echo "$(date +%s.%N) perm end mode=$m" >> "$T/log" diff --git a/designs/fixtures/permission-hook/permission-request-stdin.json b/designs/fixtures/permission-hook/permission-request-stdin.json new file mode 100644 index 0000000..848eebd --- /dev/null +++ b/designs/fixtures/permission-hook/permission-request-stdin.json @@ -0,0 +1 @@ +{"session_id":"00000000-0000-0000-0000-000000000000","transcript_path":"/home/user/.claude/projects/-tmp-PROBE-proj/00000000-0000-0000-0000-000000000000.jsonl","cwd":"/tmp/PROBE/proj","prompt_id":"00000000-0000-0000-0000-000000000000","permission_mode":"default","effort":{"level":"medium"},"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"echo probe1 > /tmp/phprobe-out1.txt","description":"Write probe1 to /tmp/phprobe-out1.txt"},"permission_suggestions":[{"type":"addDirectories","directories":["/tmp"],"destination":"session"}]} diff --git a/designs/fixtures/permission-hook/settings-with-timeout.json b/designs/fixtures/permission-hook/settings-with-timeout.json new file mode 100644 index 0000000..f145506 --- /dev/null +++ b/designs/fixtures/permission-hook/settings-with-timeout.json @@ -0,0 +1,2 @@ +{"hooks":{"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"/tmp/PROBE/hook/perm.sh","timeout":5}]}], +"Notification":[{"matcher":"","hooks":[{"type":"command","command":"/tmp/PROBE/hook/notif.sh"}]}]}}