Skip to content

Agent auth-cache hardening, global cache mode, worker CI gate & inject crash-recovery#2

Merged
timqi merged 4 commits into
mainfrom
agent-cache-hardening
Jul 14, 2026
Merged

Agent auth-cache hardening, global cache mode, worker CI gate & inject crash-recovery#2
timqi merged 4 commits into
mainfrom
agent-cache-hardening

Conversation

@timqi

@timqi timqi commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Four commits from a security + code-health review of the Touch ID auth cache and surrounding tooling. Each was adversarially reviewed (codex-expert) before landing.

1. Auth-cache TTL survives sleep; flush on lock/wake/idle; serialize prompts (c02cdeb)

  • Dual-clock expiry: macOS Instant reads CLOCK_UPTIME_RAW, which pauses during sleep — a grant issued before lid-close stayed live on wake. Entries are now valid only while both the monotonic and wall clocks are within the TTL.
  • Idle timeout judged on both clocks too, and idle now flushes both caches unconditionally (decrypt-only agents previously never idle-flushed).
  • New cache watcher (5s tick) flushes grants on screen lock and wake-from-sleep (clock divergence).
  • All human-prompt paths go through one global Semaphore(1) + spawn_blocking so a hostile peer can't stack dialogs or pin tokio workers.
  • decrypt@vt: empty batch rejected before prompting; whole batch granted after approval (strict-TTL).

2. Global cache mode, prompt-queue cache re-check, cache-hit notices (9027185)

  • AuthCacheMode::Global: one shared context for the whole agent, no TTY/session requirement — the only mode that serves orchestrated callers (AI agents / CI) whose TTY-less, fresh-session spawns can never hit per-session/per-app.
  • Prompt-queue double-check: cached paths re-check the cache after acquiring the prompt permit; N concurrent requests for the same key collapse to one Touch ID, the rest resolve as silent hits.
  • Agent cache-hit 免审批 notice: audit-ingest cache_hit rows fan out the same notice as the Worker DEK-cache hit (Pushover / Slack / Slack App / Feishu), throttled per (op_kind, host).

3. sign@vt shares the sign auth cache (f4be887)

vt ssh connect is the transport for multi-host fan-outs (tssh h1 h2 …), where the v1 always-prompt policy meant one Touch ID per host. sign@vt now shares the standard sign cache (same key, opt-in, default none). Design doc §6 updated to record the accepted tradeoff.

4. Worker CI gate, inject crash-recovery, docs cleanup (e8d6b37)

  • cf-worker CI gate: new job type-checks (tsc --noEmit) and unit-tests the worker on every PR — a TS error in the DO / WebAuthn / Access path can no longer reach wrangler deploy. vitest with 18 assertions over the runtime-free pure logic.
  • vt inject --recover: a 0600 sidecar (paths + deadline, no secret) written before plaintext hits disk lets recovery restore ciphertext for any file a crashed/rebooted supervisor left decrypted. Pure plan_recovery decision is unit-tested.
  • Docs: README documents run/hook/fido2/inject --recover + a Linux platform note; CLAUDE.md gains a crash-recovery section and records the no-master-key-rotation limitation; structured-errors.md flipped proposal → shipped.

Test gates

  • cargo test: 223 passed
  • cargo check host + x86_64-unknown-linux-gnu boundary green
  • cf-worker tsc --noEmit + 18 vitest assertions green

🤖 Generated with Claude Code

timqi added 4 commits July 13, 2026 15:19
…erialize prompts

Fixes from a security review of the agent's Touch ID auth caches:

- Dual-clock expiry (CacheExpiry): macOS Instant reads CLOCK_UPTIME_RAW,
  which pauses during sleep, so a grant issued before lid-close stayed
  live on wake. Entries are now valid only while BOTH the monotonic and
  wall clocks are within the TTL (mono caps awake time and bounds NTP
  backwards steps; wall counts sleep).
- Idle timeout judged on both clocks too (a nap-happy laptop could never
  accumulate 30 awake minutes), and idle now flushes both auth caches
  unconditionally — previously the flush was gated on SSH keys being
  loaded, so decrypt-only agents never idle-flushed grants.
- New cache watcher (5s tick): flushes grants on screen lock and on
  wake-from-sleep (wall/mono divergence >= 30s), sudo-timestamp
  semantics. Skips the WindowServer poll while both caches are empty.
- All 7 human-prompt paths (sign, decrypt@vt, auth@vt, run@vt, sign@vt)
  now go through authenticate_serialized: run@vt's Semaphore(1)
  generalized so a hostile VT_AUTH peer cannot stack N dialogs, and the
  blocking auth chain moved to spawn_blocking so concurrent prompts
  cannot pin tokio workers. The sign no-context path now reports
  Unavailable instead of flattening it to Rejected.
- decrypt@vt: empty batch is rejected as BadRequest before any prompt
  ("decrypt 0 secrets" spam); after a fresh approval the whole batch is
  granted (strict-TTL, no sliding refresh) so entries that expired while
  the prompt sat on screen are refreshed.
- Docs: CLAUDE.md agent auth-cache section + CLI help warning that
  forwarded-agent (ssh -A) requests share the local session's cache
  context within the TTL.

Clock-injected regression tests replace the sleep-based ones
(dual-clock expiry, idle_exceeded, watcher_should_clear).
…t notices; fix macOS shim recursion

Four changes, each closing a gap found while dogfooding the auth cache:

- AuthCacheMode::Global: one shared context for the whole agent, no TTY
  or session requirement. Orchestrated callers (AI agents / CI / make)
  spawn TTY-less commands with a fresh session per call, so
  per-session/per-app resolve to None and can never cache; global is
  the opt-in escape hatch for exactly that workload. Coarsest blast
  radius (any socket reacher within the TTL, incl. forwarded sessions)
  — documented in CLAUDE.md and the CLI help.

- Prompt-queue cache double-check: cached paths now re-check the cache
  after acquiring the global prompt permit, and grants land while the
  permit is still held. N concurrent requests for the same key/records
  collapse to one Touch ID; the waiters resolve as silent cache hits
  instead of stacking N sequential dialogs.

- Agent cache-hit 免审批 notice (cf-worker): audit-ingest rows with
  outcome=cache_hit now fan out the same one-line notice as the Worker
  DEK-cache hit (Pushover / Slack / Slack App / Feishu), with the note
  「缓存命中,免 Touch ID」, throttled DO-side to one per (op_kind,
  host) per 60s. The dispatch is a shared pushCacheHitNotices used by
  both the DEK-cache and agent paths; buildCacheHitLines gains an
  optional note and drops the count segment when salts=0.

- fix(hook): vt shims looped to the recursion limit on macOS.
  current_exe() there returns the invoked symlink path (unlike Linux's
  /proc/self/exe), so basename(vt_binary()) equalled the shimmed tool
  name, the "bare vt runs as-is" guard matched every shim, and the
  bare-name exec resolved straight back to the shim. vt_binary() now
  canonicalizes (canonical_self_exe); and resolve_real fails fast with
  a clear error when every PATH candidate is a vt shim instead of
  letting execvp loop.
vt ssh connect is also the transport for multi-host fan-outs
(tssh h1 h2 ...), where v1's always-prompt policy produced one Touch ID
per host. sign@vt now routes through check_or_prompt_auth, sharing the
standard sign cache — same (context, fingerprint) key, same strict-TTL /
dual-clock / lock-wake-idle-flush / prompt-queue-re-check semantics,
same opt-in (--ssh-auth-cache-mode, default none = old behaviour).

The v1 design's "cache-context escalation" objection (a standard-sign
grant silently authorizing sign@vt for the same key, and vice versa) is
accepted deliberately: both operations sign an arbitrary challenge with
the same key, so a cached grant on either path already concedes the
capability for the TTL; a per-(host, op) namespace would just re-add
one prompt per host on fan-outs. docs/sign-vt-design.md §2/§5/§6/§9
updated to record the reversal.

AuthDecision::Unavailable now carries UnavailableReason so sign@vt
keeps its structured ErrKind mapping (SessionLocked/NoGuiSession)
instead of flattening to Generic.
Three high-leverage improvements from the project review, plus doc gaps.

cf-worker CI gate (was: zero automated checks on half the system):
- New CI job type-checks (tsc --noEmit) and unit-tests the worker on every
  PR — a TS error in the DO / WebAuthn / Access path can no longer reach
  `wrangler deploy`.
- vitest with 18 tests over the runtime-free pure logic (b64u, challengeHash
  domain separation, ctEq, replay window, credentials parse/lookup).
- justfile: `test`, `check-worker`, `ci` recipes.

vt inject crash-recovery (was: reboot/SIGKILL left plaintext exposed
indefinitely with no record):
- A 0600 sidecar (~/.local/state/vt/inject/<rand>.json, paths + deadline, no
  secret) is written before any plaintext hits disk and deleted on every
  normal restore path (parent immediate-restore + supervisor post-timeout).
- `vt inject --recover` (no auth — only moves the ciphertext backup back)
  sweeps the dir and restores entries whose backup survives and whose window
  has elapsed (+5s grace so a live supervisor is never raced). Pure
  `plan_recovery` decision is unit-tested; end-to-end verified.

Docs/product-gap cleanup:
- README: document run / hook / fido2 / inject --recover; add a Linux
  platform note to Quick Start (bootstrap is macOS-only).
- CLAUDE.md: crash-recovery section replaces the old "known gap"; record the
  no-master-key-rotation limitation as accepted.
- docs/structured-errors.md: flip status proposal -> shipped.
@timqi
timqi merged commit 63abc4e into main Jul 14, 2026
4 checks passed
@timqi
timqi deleted the agent-cache-hardening branch July 14, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant