Repository navigation
feat(desktop): 0.24.0, the Touch ID approver key and one-tap stop review - #1519
Conversation
`item-approval approve|decline --approver-signer stdio --approver-public-key HEX` lets the process that holds a registered approver key sign the answer without handing the key to Flow. Flow looks the public key up under `approvers`, writes the exact bytes to sign as one JSON line on stdout and reads one line back: a signature, checked against the registered key before use, or a refusal. Anything else is a refusal, and nothing is recorded. Desktop uses this to sign with a key kept behind Touch ID. Also adds `authority.remove_principal`, which removes a registered key only while it is still the one registered, for key rotation and removal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a Mac with a Secure Enclave, Run it no longer needs terminal setup. The approval screen shows "Set up your approver key" until the key exists: one click and one Touch ID (or login password) prompt. - The Ed25519 approver key is sealed to a Secure Enclave key whose access control asks for user presence on every use, and kept in the login Keychain. It is never written to disk unsealed, never logged and never given to Flow. - Setup registers the public key under `approvers` with Flow's own `register_principal`, the function `jit register` calls. - Each answer runs Flow with `--approver-signer stdio`. Desktop checks that the bytes Flow asks it to sign are the answer the person chose, prompts, signs and drops the key. - No key, a cancelled prompt or a Keychain error means no answer. - Settings > Approver key replaces the key (new one registered before the old one is retired) or removes it. - Windows, Linux and Macs without a Secure Enclave keep the terminal setup. The Keychain's own access-control flags need a keychain-access-groups entitlement the Python engine doesn't have (errSecMissingEntitlement), so the key is sealed to the Secure Enclave instead, through Security.framework over ctypes. Real-hardware tests are opt-in (OPENADAPT_REAL_KEYCHAIN_TESTS=1) and never show a prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a stop-review queue over Needs you and waiting save approvals:
- stop_queue.py builds one card per stop: the question ("Same person?",
"Did it save?", "Approve this save?"), the item's values next to the
record or form with differences marked, and choices that say what they do.
- A card id is a digest of everything it shows. The dispatcher rebuilds the
cards before acting, so a changed, expired or missing card, or a choice
the card doesn't offer, changes nothing.
- Answers to stopped items are signed with the approver key (Touch ID on a
Mac, one prompt per batch), then applied through the item's existing
action and checks. Waiting saves go through the signed item approval.
- Similar stops batch under one choice; each card is still checked.
- Review stops screen with keys: 1-9 answer, J/K move, X batch, Shift+1-9
answer the batch. Each answer records how long its card was shown.
- A nothing-written stop now also allows "I did it by hand".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds authenticated portal routes for the stop queue (list, per-card screenshot, single and batch answers) and a phone page at /stops. Phone answers are signed with the decision portal's approver key through a dispatcher method no IPC command registers; the receipt records key "portal" and the device session. Waiting saves stay on the portal's save approval and re-arming stays on the computer. Phone answers share the window's dispatch lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Same-person cards put the item next to each possible match in one table, each value marked Same or Different. No choice is styled as the default. - Review stops takes a backend, so a practice page can run the real screen on recorded stops (benchmark/stop_queue/practice.html, dev only). - benchmark/stop_queue: the first 20 stops of the 2026-10-24 production soak as cards, with the answer the ground truth calls right; a keystroke-level estimate of seconds per stop before and after (26.7 s to 8.2 s mean, a model, not a person); a practice page that scores accuracy only and records time for the measurement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/stop-review-queue
…a run waits - The phone's stop page renders each possible match field by field with Same/Different marks, like the window. - A phone answer waits for the window's lock only when it runs an item again; closing a stop (skip, done by hand, saved) goes through while a run waits for a save. - OPERATOR_SCREENS: Review stops (cards, signing, keys, batches); CAPABILITIES row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The re-arm card shows Flow's re-arm request: why the switch went off, when, and the 10 items saved and checked in a row since, named. Its id covers the request, so a re-arm signs only what the person saw. - Answering it is one Touch ID over Flow's own bytes (rearm_to_sign); Flow verifies against the approvers registry, never a key Desktop supplies. Desktop's switch view honours a re-arm by a registered approver. - Feature-detected: the card appears only with a Flow that has signed re-arm (#1517). Checked against #1517's real switch log. - A locked Mac no longer reads as having no Secure Enclave: the check isn't remembered, and the refusal says to unlock the Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
errSecInteractionNotAllowed (-25308) while the screen is locked made the availability check remember False, so Desktop showed the terminal setup until restart. The check is no longer remembered then, and the refusal says to unlock the Mac instead of blaming Touch ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…feat/desktop-keychain-approver
… Touch ID Macs From the security review of #1500: - setup() re-registered a stored but unregistered key without a prompt, so anything that can write the Keychain item could plant its own public key. It now requires the key to open behind a presence prompt (a fresh signature over a setup challenge) first, and refuses an item that admits it has no presence check. - On a Mac that can keep the key behind Touch ID but has none set up, Run it fell back to a terminal-made file key, which asks for no presence. It now refuses and points to setup in OpenAdapt; the file key counts only where the app can't keep the key. Settings says so. - Desktop 0.24.0 with its changelog (after #1486's 0.23.0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…into feat/stop-review-queue
From the security review of #1519: - Answering stops re-checks every card after the signature returns and before anything changes, so a card that changed or expired while the Touch ID prompt was up changes nothing. - Queue answers on a Mac that can keep the key behind Touch ID never fall back to a terminal-made key (as Run it, #1500). - Removed the unreachable re-arm branch and a method pasted into the portal's StopQueueBackend Protocol. - 0.24.0 changelog entries for the stop queue and re-arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop's Touch ID setup now goes through Flow's signed roster: - No roster yet: the new key signs the roster's root behind the one Touch ID prompt (the signature is the presence proof), and this Mac pins that root in the login Keychain; Flow children get it as OPENADAPT_APPROVER_ROOT. - Rotation: the new key proves it opens, the old key signs it onto the roster, and a non-root old key is retired with a signature. - A roster this Mac didn't start: an approver on it adds the key; Desktop never adopts a root it finds. - Removal takes a non-root key off the roster with its own signature; the root stays and its key is only deleted here. - The stdio signer and the re-arm's approver keys read the pinned roster. - The real Secure Enclave tests skip, saying why, while the Mac is locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abrichr
left a comment
There was a problem hiding this comment.
Coordinator security review (AI-assisted), head a12ab81. Changes requested.
- The env var overrides the Keychain pin.
OPENADAPT_APPROVER_ROOToverrides it inFlowRoster.root()andflow_bridge._subprocess_env. The Keychain pin must win, and a differing env value should be refused. - Replay and rollback, inherited from #1486. Store the roster head in the Keychain.
- Consider protecting the pin with the Secure Enclave key. A keyring item is readable by the same binary without a prompt.
…feat/stop-review-queue
… rollback From the security review of a12ab81, on top of #1486's 56d0e4a (config-only pins on governed runs, a chained roster, recorded heads): - FlowRoster.root(): the pin this Mac set up behind Touch ID (login Keychain) wins. OPENADAPT_APPROVER_ROOT is an administrator's setting that counts only when the Keychain holds no pin; a different value is refused (approver_root_conflict), and Desktop then starts no Flow process. - A Mac that pins a root never starts another one. - The roster head (seq, hash) is kept in the Keychain after each change Desktop makes; lookups and changes require the roster to extend it, so an older roster put back approves nobody. - Governed runs take their pin only from their deployment config, so Desktop writes this Mac's pin into each run's config snapshot; a config that names another root is refused before Flow runs. - 0.24.0 changelog entry. Not done: sealing the pin behind the Secure Enclave. The pin and its signature would live in the same user-writable Keychain as everything a same-user process can rewrite, so a presence-signed pin has no anchor the attacker can't replace; the run-level config pin and recorded heads are the controls that hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abrichr
left a comment
There was a problem hiding this comment.
Coordinator security re-review (AI-assisted), head 95b3e93. Fix (5) holds: the Keychain pin wins. Changes requested. This PR inherits H1-H3 from #1486, plus two of its own:
- Pass the Keychain roster head into Flow runs. Today only Desktop's own lookups check it, so a Flow run accepted an approver that Desktop refused.
- Stage the deployment config snapshot outside
run_dir(dispatch.py:1530); the agent's user can write the run dir. Have Flow parse--configonce, so it can't be swapped mid-run.
…un dirs From the second security review: - Each run's deployment config snapshot carries this Mac's roster head (approvers.head) next to its pin, so the Flow run refuses a roster that Desktop would refuse. - The snapshot is staged in an owner-only folder under Desktop's data directory, not in the run directory the run (and anything it drives) can write. - Run it answers pass this Mac's pin explicitly (--approver-root): the reader brings its own pin, and the run's record must match it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…feat/stop-review-queue
AI re-review (coordinator)Re-reviewed at bb671b1. Verdict: not ready. This PR is report-only here because it ships Desktop 0.24.0 after 0.23.0, so I did not enable auto-merge. This PR contains #1486, so it inherits both items from the #1486 re-review:
Both probes reproduce on this head. This PR also adds one blocking hole of its own. Blocking: the agent can swap the staged config before Flow parses it
The file exists for the whole Flow call, and Flow parses it once, after interpreter start-up. A same-user process that watches the folder can replace the file in that window with a config that names its own root and Probe: Fix (either works):
Also refuse a symlinked Minor
What holds
|
…ed dirs From the third security review, with #1486's 7fd4699 merged: - Every run and qualification case passes --config-sha256 with the digest of the bytes Desktop wrote (staged byte-exact, no newline translation). Flow reads the file once, refuses it if it changed, and parses only those bytes, so a same-user process can't swap the staged config between Desktop writing it and Flow reading it. - Run it answers pass the Keychain's roster head (--approver-head) with the pin. - ensure_private_dir refuses a symlinked folder. - OPENADAPT_APPROVER_ROOT never counts for Desktop. With no Keychain pin (never set, or deleted by a same-user process), only an administrator's approvers.root_public_key in Desktop's own deployment.json does; one that differs from the Keychain's is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI re-review (coordinator), round 2Re-reviewed at eced3b1. This PR is report-only here (it ships Desktop 0.24.0 after 0.23.0), so I did not enable auto-merge. Verdict: not ready. Two blocking holes remain:
Blocking (new): the env fallback now reads an agent-writable file
The environment was at least set by whoever launched Desktop. Probe, with no Keychain item and an agent-written Desktop then hashes this snapshot and passes Fix: drop the fallback. With no Keychain root, Fixed and confirmed
Still to confirmWhether a same-user process can delete the Keychain root or head items. The python |
…feat/stop-review-queue
From the fourth security review: with no Keychain root, FlowRoster read <data_dir>/deployment.json, which the user (and so a same-user agent) can write, so the agent's root reached the staged config and its digest. - FlowRoster.root() returns the Keychain pin or None; the deployment.json fallback is gone. With no pin, Desktop's runs record none and every person's answer is refused. - APPROVAL_MODES.md notes that a same-user process can delete the Keychain items, which now fails closed. An administrator's pin for a Mac that didn't start the roster would need a location the user can't write (a root-owned file or an MDM profile); not built. - Inherits #1486's a6f2e00 (config pinned by path, O_NOFOLLOW). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI re-review (coordinator), round 3Re-reviewed at 70184d3. This PR is report-only here (it ships Desktop 0.24.0 after 0.23.0), so I did not enable auto-merge. Verdict: not ready, one blocking hole. Fixed and confirmed
Blocking: with no Keychain pin, an agent-written
|
With no Keychain pin, the config snapshot Desktop hands Flow drops any approvers root or head from the base config (a file the agent's user can write), so Flow refuses person answers. The Flow child environment never carries OPENADAPT_APPROVER_ROOT. Setup hints no longer point at the env var. Flow pins --config by its absolute path, so a later chdir can't miss the pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI re-review (coordinator), round 4Re-reviewed at beb1d2f. Verdict: no blocking hole left in the approver trust root. This PR is report-only here (it ships Desktop 0.24.0, which goes out after 0.23.0), so I did not enable auto-merge. Probe results on this head
Non-blocking: a
|
A section with key_dir but no root is unreadable to Flow, which refused every run (deployment_config_unreadable), including runs no person answers. Drop the whole section: Flow runs as normal and refuses only a person's answer (approver_root_unpinned). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # llms-full.txt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop 0.24.0. This PR carries #1500 (the approver key set up in the app, behind Touch ID), which was closed and folded in here, and builds on #1486 (Desktop 0.23.0, signed approvals), which merges first. The founder approved the 0.24.0 release.
#1500, folded in: the approver key behind Touch ID (a Secure Enclave key wraps the Ed25519 key, and every signature asks for presence). It also includes
--approver-signer stdioin Flow, so Flow never holds the key, and the security-review fixes: a stored key is re-registered only after it opens behind Touch ID, and a Touch ID Mac never falls back to a terminal-made key.Problem
Most operator time in the production soak (#1513) goes to stops. For each stop, a person opens the item, switches to the app, looks the patient up, compares, comes back and picks an action. The soak's cost model charges 90 s per stop plus a 30 s interrupt.
Change
Review stops shows every stop as one card. A card has three parts:
The queue presents and collects; it never decides.
stop_decisions.jsonl. Real operator times can then replace the cost model's assumption./stopson the existing paired decision portal shows the same cards.rearm_to_sign, problems_only switch policy: input problems leave it on, a person re-arms with one signed tap #1517), for exactly the request shown. A newer item makes the card stale, so nothing changes.approvers, never a key Desktop supplies.Measurement: 20 recorded soak stops
packages/desktop/benchmark/stop_queue/turns the first 20 stops of the 2026-10-24 production soak into the cards Desktop builds. The ground truth for each comes from the soak. The possible-match rows are reconstructed, because the soak didn't keep the app's list. Ten of the cards are "hard": the possible match is a different person, or the save went in wrong.A keystroke-level model (Card, Moran, Newell 1983; Kieras 2001) estimates the seconds to decide one stop:
This is a model, not a person. No one has timed either arm yet. "Did it save?" cards barely improve because they don't carry the record check's read-back values yet.
A practice page runs the real Review stops screen on the 20 cards, so a person can time it:
The page scores accuracy only (right answers, and hard stops caught). Time goes into the downloaded file and is never scored. Pressing 1 blindly scores 8 of 20 and catches 0 of 10 hard stops.
Not done
desktop release intentneeds a version bump, which publishes Desktop, so I left it to the release decision.Tests
test_stop_queue.py):test_stop_queue_portal.py):test_stop_queue_drill.py): every right answer is offered, and the card ids are digests of what the cards show.Stops.test.tsx,ItemApproval.test.tsx): keys, batch, timing sent, setup prompt and the comparison table.tscclean.🤖 Generated with Claude Code