diff --git a/CLAUDE.md b/CLAUDE.md index 79723fd..d0e1053 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,13 @@ pin the transport. See [`config.example.toml`](config.example.toml) and requires a fresh approval. Never expose it through the phone-only backend or the `--forward-real-agent` relay. - `vt ssh connect --forward-real-agent` is opt-in. Its relay filter permits only - `encrypt@vt`, `decrypt@vt`, `auth@vt`, and `sign@vt`; it refuses `run@vt` and + `encrypt@vt`, `decrypt@vt`, `auth@vt`, `sign@vt`, and the read-only + `diag@vt`; it refuses `run@vt` and unknown extensions. The relay holds no `VT_AUTH`; authentication remains between the remote client and the upstream agent. +- `diag@vt` is read-only and requires no Touch ID; it must never reset the + agent's idle-activity clock (a polling loop must not keep grants alive), and + its `live_entries` stays scoped to the caller's own cache context. - `vt inject -r` decrypts a file briefly and starts a self-exec'd restore supervisor. Keep the supervisor path before Tokio/clap initialization and keep `--recover` limited to restoring ciphertext backups. diff --git a/README.md b/README.md index 41acfcd..19c058c 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ For a persistent setup, put these values in `~/.config/vt/config.toml` using |---------|-------------| | `version` | Show version information | | `init` | (macOS) Initialize passcode and passphrase in keychain | +| `doctor` | Diagnose config sources (env vs config.toml), transport routing, worker reachability, and — via the read-only `diag@vt` extension — how a reachable vt agent classifies this connection for caching and why | | `create` | Read plaintext from stdin, output encrypted vt protocol | | `read ` | Decrypt a vt protocol string | | `rewrap [--no-dry-run] [--backup] ...` | Re-encrypt legacy `vt://mac/...` URLs in files to the current envelope format (one agent/phone approval per batch) | diff --git a/docs/README.md b/docs/README.md index d9fe56e..b99b530 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ and update the relevant document in the same change. | Use SSH identities | [`README.md` — portable identity](../README.md#portable-ssh-identity-for-git-vt) | `src/ssh_sign.rs`, `src/client.rs` | | Understand extension errors | [`structured-errors.md`](structured-errors.md) | `src/core/wire.rs`, `src/client.rs` | | Enable agent audit push | [`agent-audit.md`](agent-audit.md) | `src/audit.rs`, `src/server_macos/audit.rs` | +| Diagnose config/routing/caching (`vt doctor`) | [`diag-design.md`](diag-design.md) | `src/client.rs` (`doctor`), `src/server_macos/ssh_agent.rs` (`handle_diag`) | | Configure Slack App notifications | [`slack-app.md`](slack-app.md) | `cf-worker/src/slack_app.ts` | | Configure Feishu/Lark notifications | [`feishu.md`](feishu.md) | `cf-worker/src/feishu.ts` | diff --git a/docs/diag-design.md b/docs/diag-design.md new file mode 100644 index 0000000..3ec83f8 --- /dev/null +++ b/docs/diag-design.md @@ -0,0 +1,203 @@ +# `diag@vt` + `vt doctor` — cache/routing observability + +Status: implemented (codex-expert review round folded in; this file is the +feature's decision record — verify current behavior against `src/client.rs` +`doctor` and `src/server_macos/ssh_agent.rs` `handle_diag`) + +## 1. Problem + +vt's behavior is decided by three config layers (env → config.toml → defaults), +routing rules (`VT_BACKEND` pin, agent probe, passkey fallback), and agent-side +cache-context rules (TTY gate, ssh narrowing, vt-relay narrowing, pwd-scoped +keys, modes defaulting to `none`). Each rule is individually justified, but the +composition is opaque to the operator: the observable symptom is just "Touch ID +prompted again" or "the phone buzzed", and diagnosing *why* currently requires +reading `resolve_cache_context` in `src/server_macos/ssh_agent.rs`. + +## 2. Goal + +One command — `vt doctor` — that answers: + +1. Which config values are in effect and where each came from (env vs file). +2. Which transport path a call from *this* process would take, and why. +3. On the agent path: what cache modes/TTLs the agent is running with, and how + the agent classifies *this caller's* connection (cacheable? which context? + which narrowing applied?). + +Non-goals: fixing anything (pure diagnosis); worker-side deep checks (an +HMAC-validating `/api/health` is deferred); auditing/pushing diag events. + +## 3. New agent extension: `diag@vt` + +Read-only. Same envelope discipline as the other five extensions: payload is +AES-GCM encrypted under the VT_AUTH-derived `auth_cipher`, so only VT_AUTH +holders can query, and a non-vt agent answers `SSH_AGENT_FAILURE`. **No Touch +ID prompt** (it discloses no secret and mints no DEK), **never cached, not +audit-pushed** (no human decision to record). + +### Wire (src/core.rs) + +```rust +pub struct DiagReq {} // reserved; no fields in v1 + +pub struct DiagRes { + pub agent_version: String, // env!("CARGO_PKG_VERSION") + pub sign_cache: DiagCacheReport, + pub decrypt_cache: DiagCacheReport, + pub peer: DiagPeerReport, + pub run_allow_len: usize, // 0 = run@vt disabled + pub audit_push: bool, +} + +pub struct DiagCacheReport { + pub mode: String, // none|per-session|per-app|global + pub ttl_secs: u64, + pub live_entries: usize, // unexpired entries right now + pub cacheable: bool, // this connection resolved a context + pub context_basis: String, // see §3.2 +} + +pub struct DiagPeerReport { + pub pid: Option, + pub exe: Option, // proc_pidpath basename only + pub has_tty: bool, + pub is_ssh_client: bool, + pub is_vt_relay: bool, +} +``` + +### 3.1 Handler + +`handle_diag` on `VtSshSession`. The session already resolves both cache +contexts at `new_session`; the handler reports those plus the *basis* for the +resolution (§3.2) and counts live entries via a new +`AuthCache::live_len(context)` with a clock-injectable +`live_len_at(context, now_mono, now_wall)` core — **both clocks**, the same +dual-clock validity predicate as `is_authorized` (review R3). + +Two hard rules from review: + +- **`diag@vt` skips `touch_activity()`** (review R1). `extension()` currently + resets the idle clock before dispatch; a pollable no-Touch-ID extension must + not keep the agent "active" forever and defeat the idle-timeout cache flush + and key clear. +- **`live_entries` is scoped to the caller's own resolved context** (review + R2), never a global count. A relayed remote (or an uncacheable local caller) + must not learn how many grants other sessions/tabs on the Mac hold. Context + `None` → `live_entries = 0`. + +### 3.2 Refactor: `resolve_cache_context` → classification with basis + +To explain *why* a context resolved the way it did without duplicating the +logic (divergence risk), the function is refactored to return + +```rust +struct ContextResolution { context: Option, basis: ContextBasis } + +enum ContextBasis { + ModeNone, // caching disabled for this op + NoPeerPid, // peer PID unavailable → uncacheable + VtRelay, // narrowed to relay (pid,start) — all modes + NoTty, // per-session/per-app TTY gate → uncacheable + SshClient, // narrowed to ssh (pid,start) — all modes + SessionLeader, // per-session: (sid,start) + AppAncestor, // per-app: (.app pid,start) + NoAppAncestor, // per-app under tmux/ssh login → uncacheable + Global, // shared (0,0) slot + ProcLookupFailed,// see below → uncacheable +} +``` + +`ProcLookupFailed` is the explicit catch-all for **all five** fallible proc +lookups that today `?`-return `None` (review R5): `get_start_tvsec` in the +vt-relay branch, in the ssh-client branch, on the session leader, and on the +app ancestor, plus `get_sid` itself. The refactor must NOT use `?` early +returns (they discard which branch failed) — each fallible lookup assigns its +basis explicitly, and the existing precedence order (ModeNone → NoPeerPid → +VtRelay → NoTty gate → SshClient → mode arm) is preserved exactly. + +`resolve_cache_context` becomes a thin `.context` wrapper — **existing callers +and semantics unchanged**; `new_session` stores the full resolution (the two +direct context reads inside the session gain a `.context`, review R6). The +enum serializes to the wire as a stable string; the CLI maps it to a human +sentence (e.g. `NoTty` → "caller has no controlling TTY — per-session/per-app +never cache for orchestrated callers; use --…-cache-mode global"). + +### 3.3 Relay + +`route_extension` (src/ssh_sign.rs) adds `"diag@vt"` to the relayed set — +remote "why doesn't my cache hit" is the primary use case. Note the relayed +report describes the **relay's** connection to the upstream agent (that IS the +context relayed requests ride), so it is the right answer for the remote +caller. Disclosure to the remote = cache config + live counts; a remote holding +VT_AUTH can already exercise decrypts, so this adds no new capability. + +### 3.4 Information-disclosure notes (accepted tradeoffs) + +- `live_entries` (scoped to the caller's own context, §3.1) reveals "grants I + could silently use are live". Accepted: the caller can already *use* them — + cache hits are silent by design; the count only makes that state visible. + No cache keys, no other sessions' contexts, no record identifiers cross the + wire. +- `run_allow_len` is not a new oracle: `handle_run` already fast-fails on an + empty allowlist / unlisted argv[0] before any prompt, so run@vt's enablement + is probeable with zero prompts today; diag only makes the count cheaper. +- `agent_version` becomes remotely obtainable over the relay (previously not + exposed there). Low severity: the remote already has Touch-ID-gated + decrypt/encrypt/auth through the same relay. +- `diag@vt` leaves **no audit trail** even when `--audit-url` is set — the + only vt extension with none. Accepted: there is no human decision to + record, and auditing a pollable read-only op would flood the table; the + cost is that repeated remote fingerprinting of agent_version/cache state is + invisible. + +## 4. `vt doctor` (client, cross-platform) + +Sections, in order; never hard-fails — reports and lints: + +1. **Config** — each `VT_*` knob: effective value (secrets redacted to + `set(…8 chars)`), source `env` / `config.toml` / `unset`. Source tracking: + `hydrate_env_from_file` returns the list of keys it populated, threaded + into the doctor as a plain parameter (no global state); anything set but + not in that list is `env`. Re-lint config file permissions (same check as + loading, but visible on demand). +2. **Routing** — replicate `VTClient::new` validation + the `VT_BACKEND` + table: "this host would try agent first, then fall back to passkey" / + errors the constructor would raise. +3. **Agent** — socket path used (`$SSH_AUTH_SOCK` vs `~/.ssh/vt.sock`), + connectable?, then `diag@vt` via a **dedicated helper** (not the generic + `try_agent_extension` contract — review R4, the two failure shapes are + distinguishable on the wire and must be reported separately): + - `Ok(Some(payload))` → print DiagRes (modes, TTLs, live entries, peer + classification, cacheable + human reason per cache); + - `Ok(None)` (SSH success with empty extension payload — an agent that + ignored the unknown name) → "vt agent too old for diag@vt (or a non-vt + agent that ignores unknown extensions)"; + - `Err` from the extension call (`SSH_AGENT_FAILURE`) → "agent refused: + non-vt agent, wrong VT_AUTH, or agent locked"; + - `VT_AUTH` empty → "agent path disabled (VT_AUTH unset)". +4. **Worker** — `VT_PASSKEY_URL` reachability (GET base URL, status only; + no token validation in v1), token present/absent. + +Exit code 0 always in v1 (diagnostic, not a health gate). + +## 5. Testing + +- Pure: `ContextBasis` mapping unit tests (all branches of the classifier, + extending the existing `resolve_cache_context` tests to assert basis); + DiagReq/DiagRes serde round-trip; basis→human-string mapping total. +- `route_extension` test updated for `diag@vt`. +- macOS handler compiles only under `cfg(target_os = "macos")` — verified by + CI (macos-latest), not locally on Linux. + +## 6. Files touched + +| file | change | +|---|---| +| `src/core.rs` | DiagReq/DiagRes/DiagCacheReport/DiagPeerReport | +| `src/server_macos/ssh_agent.rs` | EXT_DIAG, classification refactor, handle_diag, AuthCache::live_len | +| `src/ssh_sign.rs` | relay `diag@vt` + tests | +| `src/client.rs` | doctor body (config/routing/agent/worker sections) | +| `src/config.rs` | hydrate returns populated keys | +| `src/main.rs` | `vt doctor` subcommand | +| `README.md`, `docs/README.md` | command row / map row | diff --git a/src/client.rs b/src/client.rs index eb42764..d4b321d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1753,6 +1753,279 @@ fn spawn_restore_supervisor( Ok(()) } +// --------------------------------------------------------------------------- +// vt doctor — read-only diagnosis of config, routing, and agent cache behavior +// --------------------------------------------------------------------------- + +/// Outcome of the dedicated `diag@vt` call. Unlike [`VTClient::try_agent_extension`], +/// the wire shapes stay distinct (see `docs/diag-design.md` §4): an agent that +/// merely *ignores* the unknown extension name (an older vt agent) answers SSH +/// success with an empty payload, which is a different signal from an explicit +/// `SSH_AGENT_FAILURE` (non-vt agent, wrong VT_AUTH, or a locked agent). +#[cfg(unix)] +enum DiagOutcome { + /// Socket missing/refused — no agent at all. + NoSocket, + /// SSH success, empty extension payload: the agent ignored `diag@vt`. + TooOld, + /// Explicit failure or an error envelope, with a display string. + Refused(String), + Report(Box), +} + +#[cfg(unix)] +fn call_diag(auth_token: &str) -> Result { + let stream = match VTClient::connect_agent_socket()? { + Some(s) => s, + None => return Ok(DiagOutcome::NoSocket), + }; + let auth_key = decode_auth_cipher_from_b64(auth_token)?; + let auth_cipher = AesGcmCrypto::new(&auth_key)?; + let payload = serde_json::to_vec(&crate::core::DiagReq::default())?; + let ext = Extension { + name: "diag@vt".to_string(), + details: Unparsed::from(auth_cipher.encrypt(&payload)?), + }; + let mut client = ssh_agent_lib::blocking::Client::new(stream); + match client.extension(ext) { + Err(e) => Ok(DiagOutcome::Refused(e.to_string())), + Ok(None) => Ok(DiagOutcome::TooOld), + Ok(Some(resp)) => { + let envelope = match auth_cipher.decrypt(resp.details.as_ref()) { + Ok(b) => b, + Err(_) => { + return Ok(DiagOutcome::Refused( + "response did not decrypt under VT_AUTH".to_string(), + )) + } + }; + match parse_envelope(&envelope) { + Ok(body) => Ok(DiagOutcome::Report(Box::new(serde_json::from_slice( + &body, + )?))), + Err(e) => Ok(DiagOutcome::Refused(e.to_string())), + } + } + } +} + +/// Map a `ContextBasis` wire tag to an operator-facing explanation. Unknown +/// tags (a newer agent) pass through verbatim. +fn basis_human(tag: &str) -> String { + match tag { + "mode-none" => "caching disabled (cache mode 'none', the default)".to_string(), + "no-peer-pid" => "agent could not identify the peer process".to_string(), + "vt-relay" => "narrowed to this relay connection (grants die with it; \ + other connections never share them)" + .to_string(), + "no-tty" => "no controlling TTY — per-session/per-app never cache for \ + orchestrated callers (CI / AI agents); use cache mode 'global'" + .to_string(), + "ssh-client" => "peer is an ssh process: narrowed to this ssh connection. \ + One-shot ssh/git spawns never share grants; use ssh \ + ControlMaster to share within the TTL" + .to_string(), + "session-leader" => "cacheable within this terminal session".to_string(), + "app-ancestor" => "cacheable within this .app bundle".to_string(), + "no-app-ancestor" => "no .app ancestor (tmux / ssh login session) — \ + per-app cannot cache here" + .to_string(), + "global" => "cacheable across ALL local callers (shared global context)".to_string(), + "proc-lookup-failed" => "process info lookup failed".to_string(), + other => format!("unknown basis '{}' (newer agent?)", other), + } +} + +fn doctor_redact(key: &str, value: &str) -> String { + // Both are bearer secrets; showing any prefix helps nobody in a terminal + // scrollback. Presence + length is enough to diagnose. + if matches!(key, "VT_AUTH" | "VT_PASSKEY_TOKEN") { + return format!("set (len {})", value.len()); + } + if value.chars().count() > 60 { + let head: String = value.chars().take(60).collect(); + return format!("{}… (len {})", head, value.len()); + } + value.to_string() +} + +/// `vt doctor`: diagnose config sources, transport routing, and (when an +/// agent is reachable) cache behavior via `diag@vt`. Read-only, never +/// hard-fails on findings — always exits 0; see `docs/diag-design.md`. +pub async fn doctor(auth_token: &str, file_populated_keys: &[String]) -> Result<()> { + println!("vt doctor — vt {}", env!("VT_VERSION")); + + // ── 1. Config ───────────────────────────────────────────────────────── + println!("\nConfig (env beats config.toml):"); + match crate::config::config_path() { + Some(path) if path.exists() => { + println!(" file: {}", path.display()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&path) { + let mode = meta.permissions().mode() & 0o7777; + if mode & 0o077 != 0 { + println!( + " ⚠ file is group/other accessible (mode {:o}); it holds \ + secrets — run: chmod 600 {}", + mode, + path.display() + ); + } + } + } + } + Some(path) => println!(" file: {} (absent — env vars only)", path.display()), + None => println!(" file: none (no home directory and no $VT_CONFIG)"), + } + const DOCTOR_KEYS: &[&str] = &[ + "VT_BACKEND", + "VT_AUTH", + "VT_PASSKEY_URL", + "VT_PASSKEY_TOKEN", + "VT_GIT_SSH_PRIVATE_KEY", + "VT_GIT_SSH_PUB", + "VT_AGENT_CONFIG", + ]; + for key in DOCTOR_KEYS { + match env::var(key) { + Ok(v) if v.is_empty() => println!(" {:24} set but EMPTY (env)", key), + Ok(v) => { + let source = if file_populated_keys.iter().any(|k| k == key) { + "config.toml" + } else { + "env" + }; + println!(" {:24} {} ({})", key, doctor_redact(key, &v), source); + } + Err(_) => println!(" {:24} unset", key), + } + } + + // ── 2. Routing ──────────────────────────────────────────────────────── + println!("\nRouting:"); + let has_auth = !auth_token.is_empty(); + let has_passkey = + env::var("VT_PASSKEY_URL").is_ok() && env::var("VT_PASSKEY_TOKEN").is_ok(); + match crate::config::Backend::from_env() { + Err(e) => println!(" ⚠ {}", e), + Ok(crate::config::Backend::Agent) => { + if has_auth { + println!(" VT_BACKEND=agent: SSH agent only, no passkey fallback"); + } else { + println!(" ⚠ VT_BACKEND=agent but VT_AUTH is unset — every call will fail"); + } + } + Ok(crate::config::Backend::Passkey) => { + if has_passkey { + println!(" VT_BACKEND=passkey: phone ceremony only, agent never probed"); + } else { + println!( + " ⚠ VT_BACKEND=passkey but VT_PASSKEY_URL/VT_PASSKEY_TOKEN incomplete \ + — every call will fail" + ); + } + } + Ok(crate::config::Backend::Auto) => match (has_auth, has_passkey) { + (true, true) => println!( + " auto: try SSH agent first, fall back to phone passkey on \ + recoverable errors" + ), + (true, false) => println!( + " auto: SSH agent only (passkey unconfigured — agent failures \ + have no fallback)" + ), + (false, true) => println!(" auto: phone passkey only (VT_AUTH unset — agent skipped)"), + (false, false) => { + println!(" ⚠ neither path configured — vt commands needing auth will fail") + } + }, + } + + // ── 3. Agent (diag@vt) ──────────────────────────────────────────────── + println!("\nAgent:"); + let socket = env::var("SSH_AUTH_SOCK") + .unwrap_or_else(|_| "~/.ssh/vt.sock (default; $SSH_AUTH_SOCK unset)".to_string()); + println!(" socket: {}", socket); + #[cfg(unix)] + if !has_auth { + println!(" agent path disabled (VT_AUTH unset) — diag@vt skipped"); + } else { + let token = auth_token.to_string(); + let outcome = tokio::task::spawn_blocking(move || call_diag(&token)).await?; + match outcome { + Err(e) => println!(" ⚠ diag@vt transport error: {}", e), + Ok(DiagOutcome::NoSocket) => { + println!(" no agent listening (socket missing or connection refused)") + } + Ok(DiagOutcome::TooOld) => println!( + " agent reachable but ignored diag@vt — a vt agent older than \ + this client (rebuild/restart it), or a non-vt agent" + ), + Ok(DiagOutcome::Refused(e)) => println!( + " agent refused diag@vt ({}) — non-vt agent, wrong VT_AUTH, or \ + agent locked (ssh-add -X)", + e + ), + Ok(DiagOutcome::Report(d)) => { + println!(" agent version: {}", d.agent_version); + println!( + " peer (this process): pid {}, exe {}, tty {}, ssh-client {}, vt-relay {}", + d.peer.pid.map_or("?".to_string(), |p| p.to_string()), + d.peer.exe.as_deref().unwrap_or("?"), + if d.peer.has_tty { "yes" } else { "no" }, + if d.peer.is_ssh_client { "yes" } else { "no" }, + if d.peer.is_vt_relay { "yes" } else { "no" }, + ); + for (label, c) in [("sign", &d.sign_cache), ("decrypt", &d.decrypt_cache)] { + println!( + " {:8} mode {}, ttl {}s, live grants (this context): {}", + format!("{}:", label), + c.mode, + c.ttl_secs, + c.live_entries + ); + println!(" → {}", basis_human(&c.context_basis)); + } + println!( + " run@vt: {}; audit push: {}", + if d.run_allow_len == 0 { + "disabled".to_string() + } else { + format!("{} allowlist entries", d.run_allow_len) + }, + if d.audit_push { "on" } else { "off" } + ); + println!( + " (classification applies to connections opened the way this \ + one was; a different launcher may classify differently)" + ); + } + } + } + + // ── 4. Worker ───────────────────────────────────────────────────────── + println!("\nWorker:"); + match env::var("VT_PASSKEY_URL") { + Err(_) => println!(" not configured (VT_PASSKEY_URL unset)"), + Ok(url) => { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + match client.get(&url).send().await { + Ok(resp) => println!(" {} reachable (HTTP {})", url, resp.status().as_u16()), + Err(e) => println!(" ⚠ {} unreachable: {}", url, e), + } + if env::var("VT_PASSKEY_TOKEN").is_err() { + println!(" ⚠ VT_PASSKEY_URL set but VT_PASSKEY_TOKEN unset"); + } + } + } + + Ok(()) +} + /// Pre-tokio, pre-clap entry point for the detached restore supervisor. /// Dispatched from `main()` so this process never builds a tokio runtime, /// parses clap definitions, or loads tracing — its RSS is just vt's text @@ -1961,6 +2234,71 @@ mod tests { ); } + // ── vt doctor ──────────────────────────────────────────────────────── + + #[test] + fn doctor_redact_hides_secrets_and_truncates_on_char_boundaries() { + // Bearer secrets never echo their value. + let r = doctor_redact("VT_AUTH", "supersecrettoken"); + assert!(!r.contains("supersecret"), "got {r}"); + assert!(r.contains("len 16")); + // Long non-secrets truncate by CHARS, not bytes — a multi-byte char + // spanning the cut must not panic. + let long = "变".repeat(61); + let r = doctor_redact("VT_GIT_SSH_PUB", &long); + assert!(r.ends_with(&format!("… (len {})", long.len()))); + assert_eq!(r.chars().take_while(|c| *c == '变').count(), 60); + // Short values pass through verbatim. + assert_eq!(doctor_redact("VT_BACKEND", "auto"), "auto"); + } + + #[test] + fn doctor_basis_human_is_total_over_known_tags_and_passes_unknown() { + for tag in [ + "mode-none", + "no-peer-pid", + "vt-relay", + "no-tty", + "ssh-client", + "session-leader", + "app-ancestor", + "no-app-ancestor", + "global", + "proc-lookup-failed", + ] { + let human = basis_human(tag); + assert!( + !human.contains("unknown basis"), + "{tag} must map to a real sentence, got: {human}" + ); + } + assert!(basis_human("future-tag").contains("future-tag")); + } + + #[test] + fn diag_wire_round_trip() { + // Client-side view of the agent's DiagRes JSON — pins the field names + // the macOS handler serializes (they cross the wire; renames break + // old clients). + let json = r#"{ + "agent_version": "v1", + "sign_cache": {"mode":"per-session","ttl_secs":120,"live_entries":1,"cacheable":true,"context_basis":"session-leader"}, + "decrypt_cache": {"mode":"none","ttl_secs":30,"live_entries":0,"cacheable":false,"context_basis":"mode-none"}, + "peer": {"pid":42,"exe":"zsh","has_tty":true,"is_ssh_client":false,"is_vt_relay":false}, + "run_allow_len": 0, + "audit_push": false + }"#; + let d: crate::core::DiagRes = serde_json::from_str(json).unwrap(); + assert_eq!(d.sign_cache.context_basis, "session-leader"); + assert!(!d.decrypt_cache.cacheable); + assert_eq!(d.peer.exe.as_deref(), Some("zsh")); + // And the request serializes to an (empty) object. + assert_eq!( + serde_json::to_string(&crate::core::DiagReq::default()).unwrap(), + "{}" + ); + } + #[test] fn fallback_policy_auth_rejected_does_not_fall_back() { let e: anyhow::Error = VtClientError::Agent(ErrKind::AuthRejected, None).into(); diff --git a/src/config.rs b/src/config.rs index 5b835b1..955653a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,20 +68,24 @@ fn warn_if_world_readable(_path: &Path) {} /// are logged at `warn` and otherwise ignored so a malformed file never bricks /// the CLI — the env-var path still works. /// +/// Returns the keys it populated from the file, so `vt doctor` can attribute +/// each effective value to `env` vs `config.toml` (a key present in the +/// environment but absent from this list was set by the caller). +/// /// # Safety /// Must be called before any threads are spawned (i.e. before the tokio runtime /// is built), because it mutates the process environment via /// [`std::env::set_var`]. `main()` calls it at the very top, single-threaded. -pub fn hydrate_env_from_file() { +pub fn hydrate_env_from_file() -> Vec { let Some(path) = config_path() else { - return; + return Vec::new(); }; let contents = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(), Err(e) => { tracing::warn!("could not read {}: {}", path.display(), e); - return; + return Vec::new(); } }; warn_if_world_readable(&path); @@ -90,9 +94,10 @@ pub fn hydrate_env_from_file() { Ok(t) => t, Err(e) => { tracing::warn!("ignoring malformed config {}: {}", path.display(), e); - return; + return Vec::new(); } }; + let mut populated = Vec::new(); for (key, value) in &table { // Structured sections (TOML tables / arrays) are not env-var @@ -119,7 +124,9 @@ pub fn hydrate_env_from_file() { continue; }; std::env::set_var(key, s); + populated.push(key.clone()); } + populated } // --------------------------------------------------------------------------- diff --git a/src/core.rs b/src/core.rs index 68a4f8b..e30e154 100644 --- a/src/core.rs +++ b/src/core.rs @@ -302,6 +302,56 @@ pub struct SignRes { pub signature: Vec, } +/// Request: client → agent for `diag@vt` (read-only diagnostics; no Touch ID, +/// never cached, not audit-pushed). Empty in v1; reserved for future filters. +/// See `docs/diag-design.md`. +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +pub struct DiagReq {} + +/// Response for `diag@vt`: how the agent is configured and how it classifies +/// THIS connection for caching purposes. Discloses no secret, no cache keys, +/// and nothing about other sessions (`live_entries` is scoped to the caller's +/// own resolved context). +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct DiagRes { + pub agent_version: String, + pub sign_cache: DiagCacheReport, + pub decrypt_cache: DiagCacheReport, + pub peer: DiagPeerReport, + /// Number of `run@vt` allowlist entries; 0 = run@vt disabled. + pub run_allow_len: usize, + /// Whether fire-and-forget audit push is enabled on the agent. + pub audit_push: bool, +} + +/// Per-cache diagnostic report (one for sign, one for decrypt). +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct DiagCacheReport { + /// none | per-session | per-app | global + pub mode: String, + pub ttl_secs: u64, + /// Currently-valid grants for THIS connection's context only (0 when the + /// connection is uncacheable) — never a global count. + pub live_entries: usize, + /// Whether this connection resolved a cache context at all. + pub cacheable: bool, + /// Stable wire tag naming WHY the context resolved the way it did + /// (`ContextBasis::as_wire` on the agent); the CLI maps it to a human + /// sentence and passes unknown tags through verbatim. + pub context_basis: String, +} + +/// How the agent sees the connecting peer process. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct DiagPeerReport { + pub pid: Option, + /// Basename of the peer executable (never the full path). + pub exe: Option, + pub has_tty: bool, + pub is_ssh_client: bool, + pub is_vt_relay: bool, +} + // ---- v2 URL parsing --------------------------------------------------------- /// Parsed `vt://...` URL. Strict parser (no `url::Url`, no normalization). diff --git a/src/main.rs b/src/main.rs index e4504d1..a854dd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,10 @@ struct Cli { enum Commands { /// Show version information Version, + /// Diagnose config sources, transport routing, and agent cache behavior. + /// Read-only: asks a reachable vt agent (via diag@vt) how it classifies + /// this connection and why it is or isn't cacheable + Doctor, /// Will read plain text and output encrypted message for you Create, /// Decrypt an existing vt protocol as plaintext @@ -304,7 +308,7 @@ pub enum SshCommands { #[arg( long = "forward-real-agent", default_value_t = false, - help = "Forward the ephemeral agent to the remote (agent forwarding) and relay decrypt@vt / encrypt@vt / auth@vt / sign@vt to the UPSTREAM real vt agent; run@vt and every other extension are refused. Lets `vt read` / `vt inject` / a nested `vt ssh connect` on the remote use this host's Touch ID instead of paging the phone. Must precede the ssh arguments." + help = "Forward the ephemeral agent to the remote (agent forwarding) and relay decrypt@vt / encrypt@vt / auth@vt / sign@vt / diag@vt to the UPSTREAM real vt agent; run@vt and every other extension are refused. Lets `vt read` / `vt inject` / a nested `vt ssh connect` on the remote use this host's Touch ID instead of paging the phone. Must precede the ssh arguments." )] forward_real_agent: bool, #[arg( @@ -417,13 +421,17 @@ pub enum SshCommands { }, } -async fn run(cli: Cli) -> Result<()> { +async fn run(cli: Cli, file_populated_keys: Vec) -> Result<()> { match &cli.command { Commands::Version => { println!("vt {}", env!("VT_VERSION")); println!("commit {} ({})", env!("VT_GIT_SHA"), env!("VT_COMMIT_DATE")); return Ok(()); } + Commands::Doctor => { + let auth = require_auth(&cli.auth)?; + return client::doctor(&auth, &file_populated_keys).await; + } #[cfg(target_os = "macos")] Commands::Init => server_macos::admin::init(), #[cfg(target_os = "macos")] @@ -650,7 +658,7 @@ fn main() { // ~/.config/vt/config.toml (override path via $VT_CONFIG). Env vars always // win. Must run before Cli::parse() (clap reads VT_AUTH from env) and while // still single-threaded (before the tokio runtime is built). - config::hydrate_env_from_file(); + let file_populated_keys = config::hydrate_env_from_file(); let cli = Cli::parse(); @@ -658,7 +666,7 @@ fn main() { .enable_all() .build() .expect("failed to build tokio runtime"); - if let Err(e) = rt.block_on(run(cli)) { + if let Err(e) = rt.block_on(run(cli, file_populated_keys)) { // Walk the error chain to find a `VtClientError`. The agent's // structured `ErrKind` maps to a stable exit code; transport // failures and any other error chain default to exit 1. diff --git a/src/server_macos/ssh_agent.rs b/src/server_macos/ssh_agent.rs index 5019ee8..28db0a7 100644 --- a/src/server_macos/ssh_agent.rs +++ b/src/server_macos/ssh_agent.rs @@ -23,8 +23,9 @@ use crate::core::wire::{ outcome_to_err_strict, wrap_ok_envelope, ErrKind, WIRE_VERSION, }; use crate::core::{ - legacy_decrypt, AuthReq, AuthRes, DecryptInput, DecryptReq, DecryptResItem, EncryptReq, - EncryptResItem, RunReq, RunRes, SignReq, SignRes, SALT_LEN, + legacy_decrypt, AuthReq, AuthRes, DecryptInput, DecryptReq, DecryptResItem, DiagCacheReport, + DiagPeerReport, DiagReq, DiagRes, EncryptReq, EncryptResItem, RunReq, RunRes, SignReq, + SignRes, SALT_LEN, }; use rand::RngCore; use zeroize::{Zeroize, Zeroizing}; @@ -38,6 +39,7 @@ pub const EXT_DECRYPT: &str = "decrypt@vt"; pub const EXT_AUTH: &str = "auth@vt"; pub const EXT_RUN: &str = "run@vt"; pub const EXT_SIGN: &str = "sign@vt"; +pub const EXT_DIAG: &str = "diag@vt"; fn agent_err(e: anyhow::Error) -> AgentError { AgentError::Other(Box::new(std::io::Error::new( @@ -237,6 +239,31 @@ impl AuthCache { let now_wall = SystemTime::now(); self.entries.retain(|_, e| e.is_valid_at(now_mono, now_wall)); } + + pub fn ttl_secs(&self) -> u64 { + self.ttl.as_secs() + } + + /// Diagnostics (`diag@vt`): currently-valid entries for `context` ONLY — + /// same dual-clock predicate as [`is_authorized`]. Deliberately scoped to + /// one context so a caller (possibly a relayed remote) never learns how + /// many grants other sessions/tabs hold. + pub fn live_len(&self, context: CacheContext) -> usize { + self.live_len_at(context, Instant::now(), SystemTime::now()) + } + + /// Clock-injectable core of [`live_len`] (see [`is_authorized_at`]). + fn live_len_at( + &self, + context: CacheContext, + now_mono: Instant, + now_wall: SystemTime, + ) -> usize { + self.entries + .iter() + .filter(|((c, _), e)| *c == context && e.is_valid_at(now_mono, now_wall)) + .count() + } } /// True when `timeout` has elapsed since the last activity on EITHER clock — @@ -620,6 +647,10 @@ impl RunAllowlist { self.bare_names.is_empty() && self.abs_paths.is_empty() } + pub fn len(&self) -> usize { + self.bare_names.len() + self.abs_paths.len() + } + /// Resolve a client-supplied argv[0] to a canonical absolute path that /// passes the allowlist, or return a static failure reason. The returned /// path is what the agent will pass to `Command::new` — never the raw @@ -1046,10 +1077,84 @@ fn resolve_cache_context( mode: AuthCacheMode, peer_is_vt_relay: bool, ) -> Option { + classify_cache_context(peer_pid, mode, peer_is_vt_relay).context +} + +/// A resolved cache context together with WHY it resolved that way — the +/// `basis` feeds `diag@vt` so an operator can see which rule classified their +/// connection without reading this file. [`resolve_cache_context`] is the +/// `.context` projection; behavior must never depend on `basis`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContextResolution { + pub context: Option, + pub basis: ContextBasis, +} + +/// Which branch of [`classify_cache_context`] decided the outcome. Wire tags +/// (`as_wire`) are a stable protocol surface — renaming a variant must not +/// change its tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextBasis { + /// Caching disabled (`--…-cache-mode none`). + ModeNone, + /// Peer PID unavailable → uncacheable. + NoPeerPid, + /// Peer is a `vt ssh connect --forward-real-agent` relay: per-connection. + VtRelay, + /// per-session/per-app TTY gate: peer has no controlling terminal. + NoTty, + /// Peer is an OpenSSH client: narrowed to that ssh process. + SshClient, + /// per-session: anchored on the terminal session leader. + SessionLeader, + /// per-app: anchored on the `.app` bundle ancestor. + AppAncestor, + /// per-app: no `.app` ancestor (tmux, ssh login session) → uncacheable. + NoAppAncestor, + /// global: the shared `(0, 0)` context. + Global, + /// Catch-all for the five fallible proc lookups (`get_start_tvsec` in the + /// vt-relay / ssh-client / session-leader / app-ancestor branches, and + /// `get_sid` itself) → uncacheable. + ProcLookupFailed, +} + +impl ContextBasis { + pub fn as_wire(&self) -> &'static str { + match self { + ContextBasis::ModeNone => "mode-none", + ContextBasis::NoPeerPid => "no-peer-pid", + ContextBasis::VtRelay => "vt-relay", + ContextBasis::NoTty => "no-tty", + ContextBasis::SshClient => "ssh-client", + ContextBasis::SessionLeader => "session-leader", + ContextBasis::AppAncestor => "app-ancestor", + ContextBasis::NoAppAncestor => "no-app-ancestor", + ContextBasis::Global => "global", + ContextBasis::ProcLookupFailed => "proc-lookup-failed", + } + } +} + +/// Core of [`resolve_cache_context`], returning the basis alongside the +/// context. No `?` early-returns: each fallible lookup assigns its basis +/// explicitly so diag can report which branch failed. The precedence order +/// (mode-none → peer pid → vt-relay → TTY gate → ssh narrowing → mode arm) +/// is load-bearing and documented on [`resolve_cache_context`]; preserve it. +fn classify_cache_context( + peer_pid: Option, + mode: AuthCacheMode, + peer_is_vt_relay: bool, +) -> ContextResolution { + fn resolved(context: Option, basis: ContextBasis) -> ContextResolution { + ContextResolution { context, basis } + } if mode == AuthCacheMode::None { - return None; + return resolved(None, ContextBasis::ModeNone); } - let pid = peer_pid?; + let Some(pid) = peer_pid else { + return resolved(None, ContextBasis::NoPeerPid); + }; // vt-relay narrowing (`vt ssh connect --forward-real-agent`): when the peer // is the vt relay forwarding vt extensions from a remote host, give it the // SAME per-connection `(pid, start)` context a forwarded `ssh` gets — in @@ -1062,8 +1167,10 @@ fn resolve_cache_context( // the two can never disagree; a spoofed argv only narrows the caller to // its own context, never widens it. if peer_is_vt_relay { - let start = proc_info::get_start_tvsec(pid)?; - return Some((pid as u64, start)); + return match proc_info::get_start_tvsec(pid) { + Some(start) => resolved(Some((pid as u64, start)), ContextBasis::VtRelay), + None => resolved(None, ContextBasis::ProcLookupFailed), + }; } // Per-session / per-app require the peer to have a controlling terminal. // launchd-managed daemons and other non-interactive peers always prompt @@ -1075,30 +1182,41 @@ fn resolve_cache_context( if matches!(mode, AuthCacheMode::PerSession | AuthCacheMode::PerApp) && proc_info::get_tty_dev(pid).is_none() { - return None; + return resolved(None, ContextBasis::NoTty); } if proc_info::get_proc_path(pid) .as_deref() .is_some_and(is_ssh_client_path) { - let start = proc_info::get_start_tvsec(pid)?; - return Some((pid as u64, start)); + return match proc_info::get_start_tvsec(pid) { + Some(start) => resolved(Some((pid as u64, start)), ContextBasis::SshClient), + None => resolved(None, ContextBasis::ProcLookupFailed), + }; } match mode { - AuthCacheMode::None => None, + // Handled above; kept so the match stays total without a wildcard. + AuthCacheMode::None => resolved(None, ContextBasis::ModeNone), AuthCacheMode::PerSession => { - let sid = proc_info::get_sid(pid)?; - let start = proc_info::get_start_tvsec(sid)?; - Some((sid as u64, start)) + let Some(sid) = proc_info::get_sid(pid) else { + return resolved(None, ContextBasis::ProcLookupFailed); + }; + match proc_info::get_start_tvsec(sid) { + Some(start) => resolved(Some((sid as u64, start)), ContextBasis::SessionLeader), + None => resolved(None, ContextBasis::ProcLookupFailed), + } } AuthCacheMode::PerApp => { - let app_pid = proc_info::find_app_pid(pid)?; - let start = proc_info::get_start_tvsec(app_pid)?; - Some((app_pid as u64, start)) + let Some(app_pid) = proc_info::find_app_pid(pid) else { + return resolved(None, ContextBasis::NoAppAncestor); + }; + match proc_info::get_start_tvsec(app_pid) { + Some(start) => resolved(Some((app_pid as u64, start)), ContextBasis::AppAncestor), + None => resolved(None, ContextBasis::ProcLookupFailed), + } } // (0, 0) can never collide with a real context: sid/app_pid are // always > 0. - AuthCacheMode::Global => Some((0, 0)), + AuthCacheMode::Global => resolved(Some((0, 0)), ContextBasis::Global), } } @@ -1112,6 +1230,22 @@ fn is_ssh_client_path(path: &str) -> bool { path.rsplit('/').next() == Some("ssh") } +/// Assemble one cache's `diag@vt` report. `live_entries` counts only the +/// caller's own context (0 when uncacheable) — never the whole cache. +fn diag_cache_report( + mode: AuthCacheMode, + cache: &AuthCache, + resolution: ContextResolution, +) -> DiagCacheReport { + DiagCacheReport { + mode: mode.to_string(), + ttl_secs: cache.ttl_secs(), + live_entries: resolution.context.map_or(0, |c| cache.live_len(c)), + cacheable: resolution.context.is_some(), + context_basis: resolution.basis.as_wire().to_string(), + } +} + impl Agent for VtSshAgentFactory { fn new_session(&mut self, socket: &tokio::net::UnixStream) -> impl Session { let peer_pid = get_peer_pid(socket); @@ -1125,10 +1259,10 @@ impl Agent for VtSshAgentFactory { .and_then(proc_info::get_proc_argv) .map(|argv| crate::ssh_sign::is_vt_relay_invocation(&argv)) .unwrap_or(false); - let sign_cache_context = - resolve_cache_context(peer_pid, self.sign_cache_mode, peer_is_vt_relay); - let decrypt_cache_context = - resolve_cache_context(peer_pid, self.decrypt_cache_mode, peer_is_vt_relay); + let sign_cache_resolution = + classify_cache_context(peer_pid, self.sign_cache_mode, peer_is_vt_relay); + let decrypt_cache_resolution = + classify_cache_context(peer_pid, self.decrypt_cache_mode, peer_is_vt_relay); VtSshSession { keys: Arc::clone(&self.keys), last_activity: Arc::clone(&self.last_activity), @@ -1142,8 +1276,10 @@ impl Agent for VtSshAgentFactory { audit_push: Arc::clone(&self.audit_push), peer_pid, peer_is_vt_relay, - sign_cache_context, - decrypt_cache_context, + sign_cache_mode: self.sign_cache_mode, + decrypt_cache_mode: self.decrypt_cache_mode, + sign_cache_resolution, + decrypt_cache_resolution, disable_legacy_decrypt: self.disable_legacy_decrypt, } } @@ -1171,10 +1307,16 @@ struct VtSshSession { /// check, resolved once at session creation): its requests originated on /// a remote host and every prompt carries the relay-origin marker. peer_is_vt_relay: bool, - /// Resolved once at session creation. `None` = always prompt for sign. - sign_cache_context: Option, - /// Resolved once at session creation. `None` = always prompt for decrypt. - decrypt_cache_context: Option, + /// Copied from the factory for `diag@vt` reporting only; cache behavior + /// reads the resolutions below. + sign_cache_mode: AuthCacheMode, + decrypt_cache_mode: AuthCacheMode, + /// Resolved once at session creation. `.context == None` = always prompt + /// for sign; `.basis` is diag-only. + sign_cache_resolution: ContextResolution, + /// Resolved once at session creation. `.context == None` = always prompt + /// for decrypt; `.basis` is diag-only. + decrypt_cache_resolution: ContextResolution, disable_legacy_decrypt: bool, } @@ -1384,7 +1526,7 @@ impl VtSshSession { ) -> AuthDecision { // If we couldn't resolve a cache context at session creation (no // peer PID, no TTY, missing proc info), always prompt. - let Some(context) = self.sign_cache_context else { + let Some(context) = self.sign_cache_resolution.context else { return match self.authenticate_serialized(auth_message).await { AuthOutcome::Success(_) => AuthDecision::Approved, AuthOutcome::Rejected => AuthDecision::Rejected, @@ -1462,7 +1604,7 @@ impl VtSshSession { ) -> DecryptDecision { // Cacheable iff there's a resolved context AND the batch is non-empty // pure-v2; everything else falls through to the always-prompt path. - let context = match self.decrypt_cache_context { + let context = match self.decrypt_cache_resolution.context { Some(c) if !has_legacy && !v2_items.is_empty() => c, // No cache eligibility: prompt once. Preserve the structured // ErrKind via `outcome_to_err_strict` so the client-facing error @@ -1858,6 +2000,56 @@ impl VtSshSession { })?)) } + /// `diag@vt`: read-only diagnostics for `vt doctor`. No Touch ID (it + /// discloses no secret and mints no DEK), never cached, not audit-pushed + /// (no human decision to record), and — enforced in `extension()` — it + /// does not reset the idle-activity clock. `live_entries` is scoped to + /// THIS connection's resolved context; see `docs/diag-design.md` §3.4 for + /// the accepted disclosure tradeoffs. + async fn handle_diag( + &self, + decrypted: &[u8], + ) -> Result>, (ErrKind, Option<&'static str>)> { + let _req: DiagReq = serde_json::from_slice(decrypted) + .map_err(|_| (ErrKind::BadRequest, Some(DETAIL_BAD_REQUEST_JSON)))?; + + let peer_path = self.peer_pid.and_then(proc_info::get_proc_path); + let peer = DiagPeerReport { + pid: self.peer_pid, + exe: peer_path + .as_deref() + .map(|p| p.rsplit('/').next().unwrap_or(p).to_string()), + has_tty: self + .peer_pid + .is_some_and(|pid| proc_info::get_tty_dev(pid).is_some()), + is_ssh_client: peer_path.as_deref().is_some_and(is_ssh_client_path), + is_vt_relay: self.peer_is_vt_relay, + }; + let sign_cache = { + let cache = self.sign_auth_cache.read().await; + diag_cache_report(self.sign_cache_mode, &cache, self.sign_cache_resolution) + }; + let decrypt_cache = { + let cache = self.decrypt_auth_cache.read().await; + diag_cache_report( + self.decrypt_cache_mode, + &cache, + self.decrypt_cache_resolution, + ) + }; + let result = DiagRes { + agent_version: env!("VT_VERSION").to_string(), + sign_cache, + decrypt_cache, + peer, + run_allow_len: self.run_allow.len(), + audit_push: self.audit_push.enabled, + }; + Ok(Zeroizing::new(serde_json::to_vec(&result).map_err(|_| { + (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)) + })?)) + } + /// Touch-ID-gated local command launcher. Every call prompts — no auth /// cache, by design. Mirrors the auth@vt policy: forwarded agents share /// a single local process, so caching would let any one remote session's @@ -2364,12 +2556,18 @@ impl Session for VtSshSession { // Only handle vt custom protocol extensions; ignore standard SSH extensions if !matches!( extension.name.as_str(), - EXT_ENCRYPT | EXT_DECRYPT | EXT_AUTH | EXT_RUN | EXT_SIGN + EXT_ENCRYPT | EXT_DECRYPT | EXT_AUTH | EXT_RUN | EXT_SIGN | EXT_DIAG ) { return Ok(None); } - self.touch_activity().await; + // diag@vt must NOT reset the idle clock: it is read-only, requires no + // Touch ID, and is meant to be pollable — if it counted as activity, + // a monitoring loop (or a hostile peer) could keep the agent "active" + // forever and defeat the idle-timeout key clear and cache flush. + if extension.name.as_str() != EXT_DIAG { + self.touch_activity().await; + } // Load the store once, derive auth + passphrase ciphers, drop the // store. Mac_cipher is loaded on demand inside the encrypt/decrypt @@ -2415,6 +2613,7 @@ impl Session for VtSshSession { EXT_AUTH => self.handle_auth(&decrypted).await, EXT_RUN => self.handle_run(&decrypted).await, EXT_SIGN => self.handle_sign_vt(&decrypted).await, + EXT_DIAG => self.handle_diag(&decrypted).await, _ => unreachable!(), }; @@ -3113,6 +3312,56 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert_eq!(resolve_cache_context(Some(me), AuthCacheMode::None, false), None); } + #[test] + fn test_classify_cache_context_basis() { + let me = std::process::id() as i32; + // Precedence: ModeNone wins even with no peer pid. + let r = classify_cache_context(None, AuthCacheMode::None, false); + assert_eq!((r.context, r.basis), (None, ContextBasis::ModeNone)); + let r = classify_cache_context(None, AuthCacheMode::Global, false); + assert_eq!((r.context, r.basis), (None, ContextBasis::NoPeerPid)); + // Our own test binary is not `ssh`, so global resolves via the + // Global arm — and resolve_cache_context must agree (thin wrapper). + let r = classify_cache_context(Some(me), AuthCacheMode::Global, false); + assert_eq!((r.context, r.basis), (Some((0, 0)), ContextBasis::Global)); + assert_eq!( + resolve_cache_context(Some(me), AuthCacheMode::Global, false), + r.context + ); + // vt-relay narrowing beats every mode, keyed on the peer process. + let r = classify_cache_context(Some(me), AuthCacheMode::Global, true); + assert_eq!(r.basis, ContextBasis::VtRelay); + let ctx = r.context.expect("own pid must have a start time"); + assert_eq!(ctx.0, me as u64); + // Wire tags are a stable protocol surface. + assert_eq!(ContextBasis::ModeNone.as_wire(), "mode-none"); + assert_eq!(ContextBasis::VtRelay.as_wire(), "vt-relay"); + assert_eq!(ContextBasis::NoTty.as_wire(), "no-tty"); + assert_eq!(ContextBasis::SshClient.as_wire(), "ssh-client"); + assert_eq!(ContextBasis::Global.as_wire(), "global"); + } + + #[test] + fn test_auth_cache_live_len_scoped_to_context() { + let mut cache = AuthCache::new(300); + let mine = (1u64, 100u64); + let other = (2u64, 100u64); + cache.grant(mine, "fp1"); + cache.grant(mine, "fp2"); + cache.grant(other, "fp3"); + // Scoped: never counts another context's grants. + assert_eq!(cache.live_len(mine), 2); + assert_eq!(cache.live_len(other), 1); + assert_eq!(cache.live_len((3u64, 100u64)), 0); + // Dual-clock expiry: entries drop out when EITHER clock passes TTL, + // same predicate as is_authorized (wall advanced, mono frozen). + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); + assert_eq!(cache.live_len_at(mine, now_mono, now_wall), 2); + let wall_late = now_wall + Duration::from_secs(301); + assert_eq!(cache.live_len_at(mine, now_mono, wall_late), 0); + } + // (Strict-TTL idempotence and expired-entry replacement are covered // deterministically by the clock-injected `_at` tests below — no // sleep-based duplicates.) diff --git a/src/ssh_sign.rs b/src/ssh_sign.rs index ece98cb..40d885b 100644 --- a/src/ssh_sign.rs +++ b/src/ssh_sign.rs @@ -643,14 +643,17 @@ enum ExtensionRoute { /// signature crosses). sign@vt can name ANY upstream agent key and the relay /// cannot filter by key (the payload is opaque), so the upstream prompt names /// the requested key and marks the relay origin, and its cache grants are -/// narrowed to this connection. run@vt is REFUSED — it spawns processes on the +/// narrowed to this connection. diag@vt is relayed so a remote `vt doctor` +/// can ask the upstream agent how it classifies this relay connection — +/// read-only, no Touch ID, and it deliberately does not touch the upstream +/// idle clock. run@vt is REFUSED — it spawns processes on the /// local machine and must never be reachable over a forwarded socket; this is /// the deliberate narrowing vs a raw `ssh -A` of the real agent. Anything /// unknown (incl. session-bind@openssh.com) is refused too. #[cfg(unix)] fn route_extension(name: &str) -> ExtensionRoute { match name { - "decrypt@vt" | "encrypt@vt" | "auth@vt" | "sign@vt" => ExtensionRoute::Relay, + "decrypt@vt" | "encrypt@vt" | "auth@vt" | "sign@vt" | "diag@vt" => ExtensionRoute::Relay, _ => ExtensionRoute::Refuse, } } @@ -1176,10 +1179,11 @@ mod tests { // The vt ops that are Touch-ID-gated (or unauthenticated by design) // upstream may cross the relay; sign@vt relays so a nested remote - // `vt ssh connect` never needs the decrypt-then-sign fallback. + // `vt ssh connect` never needs the decrypt-then-sign fallback; diag@vt + // relays so a remote `vt doctor` can diagnose cache behavior. #[test] - fn relay_filter_allows_decrypt_encrypt_auth_sign() { - for name in ["decrypt@vt", "encrypt@vt", "auth@vt", "sign@vt"] { + fn relay_filter_allows_decrypt_encrypt_auth_sign_diag() { + for name in ["decrypt@vt", "encrypt@vt", "auth@vt", "sign@vt", "diag@vt"] { assert_eq!( super::route_extension(name), super::ExtensionRoute::Relay,