Design R2 (post codex-expert review; R1 findings folded in). Owner: this
document. Implements the migration of the
vt binary into a signed .app bundle with a Swift menu-bar shell, native
UNUserNotificationCenter notifications (replacing osascript), and the
master-key wrap-derivation migration that the path move requires.
- VT-branded system notifications (own icon and name in System Settings →
Notifications) instead of Script Editor via
osascript. - A persistent menu-bar presence: agent status at a glance, live grants with scope and remaining TTL, recent activity, and a panic "revoke everything" action.
- Cache-hit transparency: a grant reuse that skips Touch ID now fires a system
notification (op + scope + remaining TTL), so silent reuse is observable in
real time, complementing
approval-transparency.md. - Survive the binary path change: the master-key wrap currently derives from
current_exe(); moving into the bundle must not lock the store.
- No approval from the UI, ever. Touch ID sheets remain the only approval surface. The menu never gains a "grant"/"approve"/"extend" affordance.
- No
run@vtsurface in the UI beyond the existing allowlist count. - No Worker/phone-approval integration.
- No per-grant revocation and no cache-TTL editing from the menu (v1.5).
- No Sparkle/auto-update (distribution remains
just install-app).
VT.app/Contents/
Info.plist CFBundleIdentifier dev.rustyvault.vt, LSUIElement=true
MacOS/VTApp Swift menu-bar shell (also handles `VTApp notify ...`)
MacOS/vt the Rust binary, unmodified role; CLI symlink target
Resources/AppIcon.icns generated from cf-worker/pwa/icon.svg / icon-512.png
The shell executable is VTApp, not VT: the default APFS volume is
case-insensitive, so MacOS/VT and MacOS/vt would be one file. Menu bar
and notifications show CFBundleDisplayName (VT) regardless.
- Executables under
Contents/MacOS/share the bundle identity, so the Rust agent process itself counts as "VT" for notification and TCC purposes. - Packaging is a
just apprecipe:cargo build --release,swiftcthe shell, generateAppIcon.icns(sipsscale-down set fromicon-512.png; 1024 upscaled once for512@2x— acceptable softness), assemble, thencodesign --force --deep -s "${VT_CODESIGN_ID:--}"(ad-hoc by default, Developer ID when the env var is set). just install-appcopies to/Applications/VT.appand symlinks~/.local/bin/vt → /Applications/VT.app/Contents/MacOS/vt(same target dir the existingjust installuses).- Release (
.github/workflows/release.yml): the macOS job builds VT.app viajust app(reusing the release binary throughVT_APP_BIN, so no second compile) and ships it asVT-app-darwin-arm64-<tag>.tar.gzalongside the barevttarball. It is ad-hoc signed (no Developer ID set up), so: each release re-triggers the one-time Keychain ACL prompt on first launch (ad-hoc signatures differ per build), and Gatekeeper would quarantine it — buttar xzffrom the CLI does not propagate the download quarantine the way Finder/zip does, so a terminal install usually opens cleanly;xattr -dr com.apple.quarantineis the fallback. Moving to Developer ID + notarization later removes both frictions and is a drop-inVT_CODESIGN_ID+ a notarize step. - Menu-bar icon: the hex-nut key shape from the SVG redrawn as an
NSImagetemplate (code-drawn paths in Swift; monochrome, so it adapts to light/dark and lets us render state variants).
Verified breakage: derive_passphrase_secret (src/core/crypto.rs) mixes
current_exe() into the wrap key; moving the binary makes the AES-GCM unwrap
fail. The path term is a soft binding with ~zero security value (the passcode
sits in the same keychain item; path and $USER are attacker-knowable), and
it also breaks Homebrew prefix moves.
- Wrap v2: derivation string becomes
base64(passcode):$USER:vt-wrap-v2— the binary-path term is replaced by a fixed domain label.$USERstays (no added brittleness from the bundle move; dropping it would widen review scope for no gain). - Store marker:
KeychainStoregains#[serde(default = "...")] wrap_v: u32(serde default fn returning 1).STORE_SCHEMA_VERSIONstays 1 so pre-migration binaries can still parse the store (they will fail the unwrap, not the parse — see rollback). - Transparent upgrade: wherever the store is loaded and the unwrap
succeeds with a v1 key (agent startup preflight, CLI ops), rewrap to v2.
Review-mandated shape: a new, minimal mutator run as a closure inside
KeychainStore::modify(the realvt-keychain.lockflock path) that re-reads the store under the lock, re-verifieswrap_v == 1, decrypts and re-encrypts onlyencrypted_passphrase, and setswrap_v = 2. It must never touchpasscode_and_auth_token(else VT_AUTH silently rotates),encrypted_ssh_keys, orencrypted_fido2, and must not reusecreate_and_save_passcode_passphrase(which mints fresh secrets by design and saves without the flock). After the first successful run of the new binary at the old path, the store is path-independent before the move. - Old/new binary coexistence hazard: an old binary still on
$PATHthat runsinit/import/rotate-passcodeperforms a full store rewrite with the old derivation and drops thewrap_vfield it does not know — silently downgrading a v2 store to "bound to the old binary's path". Operator doc must say: remove oldvtbinaries once migrated. The new binary's ownrotate-passcode/importalways write wrap v2. vt secret rebind [--old-bin-path <path>]: for stores that jumped straight to the new location. Gated bylocal_authentication(same asvt secret export). Tries, in order: v2 label,current_exe(), the explicit--old-bin-pathstring (the old binary need not exist — only the string enters the derivation). On first success, rewraps to v2 and saves. Passcode, auth_token (VT_AUTH), encrypted SSH keys, and FIDO2 blobs are preserved byte-for-byte; onlyencrypted_passphraseandwrap_vchange.- Rollback: a v2 store is unreadable by old binaries. Escape hatch:
vt secret rebind --to-v1(rewraps back usingcurrent_exe()), or the existingvt secret export/importpair. - Naming: the top-level
vt rewrap(re-encryptsvt://URLs in files) is unrelated;rebindavoids the collision.
notify_macos(src/server_macos/security.rs) gains a first path: ifcurrent_exe()lives under*.app/Contents/MacOS/, spawn<bundle>/Contents/MacOS/VTApp notify --title <t> --body <b>(argv, no AppleScript interpolation; control-character filtering and length caps stay). The Swift binary handlesnotifybeforeNSApplicationMain: post viaUNUserNotificationCenter, wait for the add-request callback, exit. First use triggers the one-time system authorization prompt.- Fallback is the existing
osascriptpath (binary outside a bundle, helper missing, or spawn failure). Notification failure must never affect the protected operation (let _ =semantics preserved). - Cache-hit notifications (new requirement): in the two places the server
observes
Decision::CacheHit(raw/sign@vtsign,handle_decrypt), firenotify_cache_hit(op, scope_display, remaining_ttl)→ e.g. “sign · github.com · 4m12s left (no Touch ID — cached grant)”.- Call-site constraint (review-mandated): the notification fires only
after
permit.commit()returns — aCacheHitpermit holds the security read gate, andrun_revokerneeds the write side; a blocking notify (worst case: the first-use notification-permission dialog) while the permit is live would stall revocation, violating the live-permit invariant. The call is fire-and-forget (spawn, never await the helper), so it also adds no latency to the sign/decrypt response. - The scope display string comes from the request-side
GrantScope(the server builds it for the lookup anyway), not from the store — so P1 needs no store changes. - New
NotifyKind::CacheHit, reusing the 30 s per-kind throttle (notify_throttle_should_fire) so a burst (multi-signgit push) notifies once; coalesced counts are a later refinement. - Default on; opt-out via
--no-cache-hit-notifyonvt ssh agentand the matching[agent]config key (§4). - Residual visibility note: banners persist in Notification Center after the event (and on the lock screen if the user's preview settings allow), longer than the Touch ID sheet showed the same strings. Users who treat hostnames/workspace paths as sensitive can opt out.
- Both the helper path and the
osascriptfallback route through the same sanitize call (control-char filter + length caps) before the text leaves the agent.
- Call-site constraint (review-mandated): the notification fires only
after
The shell supervises the agent (§6) and must not hardcode the operator's
flags. ~/.config/vt/config.toml gains an optional section read by
vt ssh agent at startup as defaults; explicit CLI flags override:
[agent]
timeout = 1800 # --timeout
ssh_auth_cache_duration = 900 # --ssh-auth-cache-duration
decrypt_auth_cache_duration = 0 # --decrypt-auth-cache-duration
cache_hit_notify = true # --no-cache-hit-notify inverts
run_allow = "/opt/homebrew/bin/zed" # --run-allow (agent policy, not a secret)run_allow lives here (not just the flag) so a supervisor that spawns the
agent without flags — the VT.app shell — keeps run@vt working. It is agent
policy, contains no secret, and follows the same flag > config > default
precedence.
Env-over-file precedence is unchanged (no new env vars; hydrate_env_from_file
already skips structured TOML sections). Duration 0 still maps to
first-class Fresh.
Menu-driven cache policy. The menu-bar shell does NOT rewrite this
(mode-600, secret-bearing) file. Because the shell is the agent's sole
lifecycle owner (§6), a cache choice from the menu is persisted in the
shell's own UserDefaults and applied as an explicit spawn flag on the next
(re)start — precedence stays flag > [agent] config > default, so a user who
prefers the file can set [agent] and never touch the menu. The menu
checkmark reflects the agent's live TTL from ui-status@vt, whatever its
source. Applying a change restarts the managed agent (TTL is read at
startup; there is no hot reload), which drops current grants — acceptable for
a policy change and consistent with every other revoke path.
Implementation note (review finding): the vt ssh agent clap args currently
use eager default_value_t, which cannot distinguish "flag passed" from
"compiled default". They move to Option<T> (and a tri-state for the new
notify boolean) so the merge is flag.or(config).unwrap_or(default).
diag@vt cannot serve the UI: it rides the VT_AUTH cipher path (the shell
holds no VT_AUTH) and is deliberately caller-scoped (live_entries is never
a whole-store count — that stays true). Whole-store visibility needs a
deliberately gated channel:
- Spawn token: the shell generates 32 random bytes, writes them into a
pipe, and spawns
vt ssh agent --ui-token-fd <n>with the read end inherited. The agent reads the token at startup and drops the fd. No env var (visible to same-userps e), no file. A CLI-started agent has no token → the extension always fails → the UI degrades to "agent running, restart from VT.app for details". - Dispatch position: handled with the plaintext pre-cipher extensions —
after
session-bind@openssh.com, before the lock check (a locked agent must still reportlocked: true) and before keychain/auth-cipher work. Constant-time token compare; on mismatch, plainAgentError::Failure(indistinguishable from unknown-extension). - Never resets the idle clock — added to the same
touch_activityexclusion asEXT_DIAG. Not audit-pushed, never cached, no Touch ID. - Request (JSON, like diag):
{ token_b64, action: "status" | "revoke_all" }.status→ response:{ agent_version, locked, sign_ttl_secs, decrypt_ttl_secs, run_allow_len, audit_push, grants: [ { operation, family, display, expires_in_secs, ttl_secs } ] }.revoke_all→engine.invalidate_all()(the existing linearized revoker; epoch advances even when the store is empty). Authority-reducing only, hence token-but-not-Touch-ID is acceptable. If a live permit holds the prompt slot, revocation queues exactly as it does today; the UI shows "waiting for the in-flight approval".
- Grant display labels:
GrantKeydigests are one-way, so the store cannot reconstruct "github.com" or a workspace path.GrantScopegains adisplay: Stringset by each constructor (destination host + key type, workspace root, cwd, parent app name — the same phrasing the Touch ID prompt already shows), stored alongsideCacheExpiryin a newGrantEntry { expiry, display }value. A newGrantStore::snapshot()/AuthorizationEngine::snapshot()enumerates live entries for this handler only. Memory-only; labels never hit disk or audit push. - Information-disclosure stance: the full grant list (hosts, workspace paths)
is exposed only to a holder of the spawn token, i.e. the shell that
parented the agent. Same-user processes without the token get nothing,
preserving today's diag@vt posture. Two consequences made explicit
(review): (a)
ui-status@vtis a deliberate, token-gated exception to the "never a whole-store count" diag@vt invariant — CLAUDE.md gets a companion line naming it as the one legitimate whole-store channel; (b) scope display strings, today shown only transiently in Touch ID prompts, are now retained in agent memory for the grant's TTL and readable by the token holder throughout that window. Memory-only, never disk/audit. - Token compare reuses the
subtle::ConstantTimeEqidiom already used by the agent'sunlock()path (None => falsewhen no token configured).
Single-file AppKit app (app/VTShell.swift), no third-party deps.
- Role — sole agent lifecycle owner. The shell is the intended way the
agent runs (replacing a hand-rolled launchd/supervisor): it registers as a
login item (
SMAppService.mainApp), spawnsvt ssh agent --ui-token-fd 0(token over the stdin pipe), supervises it, and restarts it. Also servesVTApp notifyhelper mode (§3).- Exit intent drives the termination handler:
stop(leave down),restart(respawn after the OS releases the socket — the new agent also self-heals a stale socket file on bind), orcrash(backoff restart, up to 5 attempts). - Never fights an external agent:
startIfNeededdefers if something is already listening on the socket; it only supervises an agent it spawned. An externally-started agent yields a degraded, read-only menu (no token). - Version-skew restart: after
just install-appreplaces the binary, the still-running agent runs old code. The shell compares the runningagent_versionto the bundledvt version; when they differ and the agent is shell-managed, the menu offers a one-click "Restart Agent to update". A restart is the trigger for the transparent wrap-v2 upgrade (§2) to run. - Start-failure surfacing: the agent's stderr is captured; a fast
(< 3 s) or non-zero exit keeps the last line and shows it in the menu
with a Doctor shortcut. This is the visible path for the common
"keychain wrap still bound to the old binary path — run
vt secret rebind" failure (§2), instead of a silent "not running".
- Exit intent drives the termination handler:
- Data flow: speaks the ssh-agent wire protocol over
~/.ssh/vt.sock(u32-BE length framing,SSH_AGENTC_EXTENSION27 →ui-status@vt, replySSH_AGENT_EXTENSION_RESPONSE29). Poll every 3 s for icon state; refresh onmenuWillOpen. Polling is safe by construction (no idle-clock reset). The fixed~/.ssh/vt.sockpath is what lets a login-item-started agent be found by the vt client's built-in fallback with noSSH_AUTH_SOCKexport. - Icon: the hex-nut key from
icon.svg, code-drawn as a monochrome templateNSImage. Its content bounding box (512-space x 152..360, y 90..412) is aspect-fit into a padded ~16 pt cell so it carries the same internal padding as SF Symbol menu-bar glyphs rather than filling the bar height. The badge text (grant count / lock glyph) sits to its right. - Menu (v1):
- status line:
Agent running · <version> · N grants(disabled item;<version>already carries its own leadingv) - version-skew: one-click restart (managed) or warning line (external)
- agent start-failure hint + reason (when the managed agent won't start)
Grants ▸one item per grant:sign · github.com · 4m12s(display-only)Revoke All Grants(⌘L accelerator while menu open) →revoke_allCache Duration ▸ Signing / Decrypt ▸ Off / 5 min / 15 min / 1 h / 2 h / 8 h / Follow config file(managed agent only; applying restarts the agent)Idle timeout — <value> (clears cache when unused)line + submenu (15 min / 30 min / 1 h / 2 h / 8 h / Follow config file) — surfaced so cache expiry is discoverable/debuggable (§10)Start Agent/Stop Agent/Restart Agent(managed agent only)Start at Logintoggle (SMAppService.mainApp)Run Doctor…(opens Terminal withvt doctor)Quit (agent keeps running)/Stop Agent and Quit
- status line:
- "Recent activity" (audit-log tail) is v1.5 — cache-hit notifications cover the transparency need in v1 without the shell parsing audit files.
A store created by a pre-bundle vt is wrap v1, bound to that binary's
resolved path. install-app makes ~/.local/bin/vt a symlink into the
bundle, so current_exe() now resolves to the bundle path — the transparent
upgrade (§2) cannot unwrap, and a shell-started agent would fail to boot.
Rebind once, in this order:
1. Stop the current agent (whatever supervises it today).
2. just install-app
3. vt secret rebind --old-bin-path <resolved path of the old vt binary>
# e.g. /Users/you/.local/bin/vt (the real file, symlinks resolved)
4. Launch VT.app — the shell starts the agent; wrap is now v2 (path-independent).
If step 3 is skipped, the shell surfaces the failure and the vt secret rebind hint in its menu (§6) rather than failing silently. Once on wrap v2,
future bundle moves need no rebind.
Moving the binary re-triggers the legacy keychain ACL prompt once
(rusty.vault.store is a single generic-password item). The first
vt secret rebind run from the bundle doubles as that prompt. With a stable
VT_CODESIGN_ID, it is the last such prompt; with ad-hoc signing the
per-rebuild prompt behavior is unchanged from today.
session-bind@openssh.comstays first in dispatch;ui-status@vtslots after it and never touches the VT_AUTH cipher path.diag@vtsemantics untouched (caller-scoped counts, no idle reset).auth@vt/run@vtfresh-only;run@vtunreachable from the UI.- Cache duration
0→Fresh, neverStrictTtl(0). - Revocation/epoch semantics unchanged;
revoke_allreuses the existing linearized revoker. - No secret or private-key material crosses
ui-status@vt; grant labels are the same strings already shown in Touch ID prompts. - Notification failure never blocks or fails a protected operation.
- P1 (Rust): wrap v2 +
vt secret rebind(+--to-v1),[agent]config defaults, cache-hit notify (request-side scope display, post-commit fire-and-forget), bundle-awarenotify_macos. Tests: derivation vectors old/new, rebind round-trip (v1→v2→v1), the transparent-upgrade mutator preserves passcode/auth_token/ssh/fido2 byte-for-byte, config precedence (flag > file > default), throttle behavior forCacheHit. - P2 (agent): spawn token fd,
ui-status@vthandler,GrantEntrydisplay labels,snapshot(), revoke action. Tests: token gate (no token/wrong token/right token), locked-agent status, idle-clock non-reset, snapshot label/expiry correctness, revoke-all epoch advance. - P3 (app): Swift shell, icns,
just app/install-app, docs. Manual verification: notification identity, menu states, agent supervision, ACL prompt flow. - Gates:
cargo test,just check,just check-worker(untouched but run).
Motivation. The idle timeout couples two concerns onto one timer: wiping
decrypted SSH keys from RAM (memory hygiene, wants short) and invalidating
cached grants (walked-away backstop, wants long for cache convenience).
Raising the default to 2 h (§4) for cache convenience therefore also
lengthened key memory-residency to 2 h — and the screen-lock watcher today
only invalidate_all()s grants, it does not clear the key map. So a
locked screen currently leaves decrypted private keys resident until the
2 h idle sweep. A same-machine memory-scraping attacker (root/debugger
task_for_pid) is not stopped by screen lock. Fix: lock/sleep also wipes
keys (C), and keep idle as a long backstop that is now surfaced and
configurable in the menu so cache expiry is debuggable rather than
"mysterious".
- The cache watcher, on a
watcher_should_cleartrigger (lock transition or detected sleep), in addition toinvalidate_all()(grants), clears the key map and setsidle_cleared = trueso the next use silently reloads (no Touch ID). The watcher gainsArcclones ofkeysandidle_cleared(mirroring the idle sweeper). - Race that must be closed (review Finding 1, blocker): a sign request
arriving while the screen is locked would today hit
ensure_keys_loaded(which seesidle_clearedand reloads) — repopulating RAM with the very keys we just wiped. The interactive guard must be checked in two places (mirroring the existing post-I/Oself.lockedre-check): (a) beforeload_all_keys()to skip the pointless read, and (b) again immediately before*keys = loaded— because the screen can lock during the blocking (possibly ACL-prompting) keychain read, and an entry-only check would then install keys into RAM during the lock. Bothself.lockedand the screen-interactive check gate the install; either failing → no-op reload,idle_clearedleft set so a later interactive call reloads. A sign during lock is independently rejected atauthorize(validatorNotInteractive); the guard's sole job is keeping decrypted keys out of RAM (memory-scraping hygiene), since use is already blocked. Scope: covers the automatic reload path (sign,sign@vt, andrequest_identities, all sharingensure_keys_loaded); does not cover the explicitssh-add -Xunlock()path, which reloads on a caller-supplied passphrase by design and is outside the memory-scraping threat model. - Agent lock (
ssh-add -x) already wipes keys via the lock finalizer; unchanged. Menu "Revoke All" stays grants-only (it is not a lock). - Reload cost after unlock is one silent keychain read on the first post-unlock use; no Touch ID.
UiStatusResgainsidle_timeout_secsso the shell can display and reconcile it.- The menu shows the active idle timeout (e.g. a disabled "Idle timeout: 2h" line) — this is the "make timeout perceivable" requirement: a user seeing the value understands why grants clear after a quiet spell, so cache disappearance is debuggable instead of mysterious. The grant list already shows each grant's remaining TTL; the idle line explains the orthogonal expiry axis.
- An "Idle Timeout ▸ 15m / 30m / 1h / 2h / 8h / Follow config file" submenu
(managed agent only) persists the choice to
UserDefaults(vt.idleTimeoutSecs), passed as--timeouton the next spawn; applying restarts the managed agent (same mechanism/among-caveats as the cache submenu, §4/§6). Precedence staysflag > [agent] config > default. - Floor clamp (review Finding 5):
timeoutis clamped to ≥ 60 s where it becomes aDurationinrun_ssh_agent. Unlike the cache durations, idle0is not a "Fresh"-style special case —0would make the sweepersleep(0)busy-loop (check_interval = min(60, timeout)). The menu presets never offer 0, but the clamp protects the config/CLI/UserDefaults paths. - Idle is not removed — it remains a long backstop for the unlocked-but-unattended window that screen lock does not cover (e.g. display sleep without password-on-wake, or auto-lock disabled). Removing the only automatic non-lock guard would make screen lock a single point of failure; keeping it long costs nothing (silent reload, rarely fires before lock does).
- Lock, sleep/wake, and idle still advance the authorization epoch even with an empty store; key reload is always silent (no Touch ID).
ui-status@vtstays token-gated read + authority-reducing revoke; setting the idle timeout happens via UserDefaults + agent restart (the shell's lifecycle ownership), not a new mutating agent channel — no approval, grant, or extend action is ever added.- No secret or key material crosses any new surface;
idle_timeout_secsis policy, not a secret.