Status: implemented (codex-expert review round folded in; verify current behavior against the anchors named per section). Follow-up to the activity-scopes V2 review: a field-level comparison of the Touch ID prompt, the Worker approval surfaces, and the agent audit push found gaps in what each shows/records. This document is the decision record for closing them.
- Raw
SIGN_REQUESThides the verified destination by default. The agent verifies the destination host key viasession-bind@openssh.com, but the prompt (Session::sign,src/server_macos/ssh_agent.rs) only surfaces it inside thereuse:line — which exists only when--ssh-auth-cache-duration > 0and the scope is destination-reusable. With the default (0), the user approvessign: <key> (ssh)with no destination shown, even though the agent knows it. Forwarding-capable and tainted connections show no marker at all, yet these are exactly the connections where a signature may serve a request originating beyond hop one. - Kernel truth vs client claims are indistinguishable in vt-extension
prompts. decrypt@vt / sign@vt / auth@vt / run@vt render the
client-reported
via:(meta.ppid_cmd) but never the kernel-verified peer executable (self.peer_exe), which only the raw-sign prompt shows. - The agent audit push is under-specified.
AgentAuditEntry(src/server_macos/audit.rs) records outcome +ChallengeMetaonly:- raw sign rows carry
ClientMeta::default()— the key, the peer process, and the verified destination survive only as unstructured prompt text stuffed intocommand; - no row records whether the approval minted a reusable grant, its
scope family/label, or the TTL;
cache_hitrows do not say which scope was hit; - the kernel-verified peer executable is not reported anywhere
(
meta.ppiddoes carry the kernel peer pid); - relay provenance (
peer_is_vt_relay) is not reported.
- raw sign rows carry
- Worker approval surfaces have small field gaps.
- The approve page renders ten meta fields with equal visual weight,
though only
ipis worker-verified (CF-Connecting-IP); the rest are client-claimed and unbound (src/cf.rs:109-114, accepted). - The DEK-cache consent copy claims "同一来源 IP 且同一工作目录" as if
both halves were verified;
pwdis client-reported advisory (types.tsCacheEntry v3 notes). - An approval request never shows the decrypt batch size, although the
cache-hit FYI does (
N 条) and the Touch ID prompt does (decrypt N secrets). A 50-record batch is exactly the anomaly an approver should see. - Notifications (
metaLines,cf-worker/src/notify.ts) omitppid_cmd, though users often decide from the notification alone.
- The approve page renders ten meta fields with equal visual weight,
though only
Rendering order invariant (already implicit, now stated): agent-derived truth lines precede every client-reported line, so a hostile caller can pad only its own region off-screen.
- A1 — raw sign destination. After the
sign:header:BindState::Bound { forwarding: false }and the scope is not destination-reusable (Fresh): adddest: <destination_label>— the same precomputed label the reuse line would have used. (When the reuse line exists it already names the destination; no duplicate line.)BindState::Bound { forwarding: true }: adddest: <destination_label> (forwarding — may serve a relayed request).BindState::Tainted: addwarning: session-bind verification failed.Unbound: unchanged (an unbound ssh peer has nothing verified to show; modern OpenSSH always binds).
- A2 — caller line. New helper
append_caller_line: appendscaller: <peer_exe basename>whenself.peer_exeis known, placed with the truth lines (right after the relay line). Applied to decrypt@vt, sign@vt, auth@vt, run@vt. Raw sign keeps its inline({proc})form. Althoughpeer_exeis kernel-derived, it is a filesystem basename an attacker with local code execution controls, so the line goes throughsanitize_for_displaylike every other prompt field (the raw-sign header gets the same treatment while we are there). - A3 — run@vt relay marker. Add the missing
append_relay_origincall for consistency. Dead path today (the relay filter refuses run@vt) — cheap insurance, keeps the four extension prompts uniform.
Descoped: showing the resolved workspace on Fresh prompts (would blur the
"reuse line appears iff a grant can be created" invariant); tty in the
prompt; localizing prompt language; enriching the static CLI-local
prompts (export master secret …) — separate, lower-value change.
New top-level fields on AgentAuditEntry / DoAuditIngestOp — not
on ChallengeMeta, which stays the client-claimed display struct; these
are agent-authoritative:
| field | type | content |
|---|---|---|
peer_exe |
String | kernel-verified peer executable basename ("" unknown) |
key_fp |
String | sign ops: SHA256:… of the signing key ("" otherwise) |
dest |
String | verified non-forwarding session-bind destination label ("" otherwise) |
scope_family |
String | connection / destination / workspace / cwd-fallback / parent-app; "" = fresh |
scope_label |
String | the exact label the reuse line displayed ("" = fresh) |
grant_ttl_s |
u64 | effective TTL for the reusable scope; 0 = fresh |
relayed |
bool | peer_is_vt_relay |
scope_family/scope_label/grant_ttl_sare set on bothapproved(grant minted) andcache_hitrows (digest match implies the hit's recomputed label equals the grant's), answering "which prior approval class did this silent hit ride" without engine surgery.GrantScopegainspub fn family(&self) -> Option<ScopeFamily>andScopeFamily::as_wire(&self) -> &'static strinsrc/core/authorization.rs. UnlikeContextBasis,ScopeFamilyneeds nofrom_wire: this is one-way telemetry, never parsed back.emit_audit's argument list is refactored into a smallAuditContext<'_>struct (it is at 8 arguments already).- Raw sign keeps
meta = ClientMeta::default()(honest: nothing client-reported exists on that path) — the new structured fields now carry key/peer/destination.
Deliberately deferred — grant→hit token linkage. Storing the minting
approval's token_id inside each grant and echoing it on cache_hit
(mirroring the Worker cache's origin_token_id) requires threading an
audit token through AuthorizationRequest, the grant store, and
Decision. scope_label + timestamps give the correlation for
forensics; revisit only if that proves insufficient in practice. Known
ambiguity, accepted: distinct approvals can share an identical
scope_label over time (commit_at's TTL-tightening rule re-mints the
same scope), so label+timestamp correlation blurs when approvals cluster
closely — a forensic imprecision, not a security gap.
Worker side (cf-worker):
DoAuditIngestOp(types.ts) gains the seven fields (all optional).- Additive
ALTER TABLE audit ADD COLUMNmigrations:peer_exe TEXT,key_fp TEXT,dest TEXT,scope_family TEXT,scope_label TEXT,grant_ttl_s INTEGER,relayed INTEGER— same pattern as the existingcache_ttl_s/ppidmigrations indo_account.ts(re-snapshotPRAGMA table_infoper the stale-snapshot note there). - NULL vs "" convention: the agent always sends every field, using
""/0/falsefor "not applicable" (fresh, unknown peer, non-sign op). Ingest must preserve absence as SQLNULL— a null-preserving cap helper for the strings (NOT the existingcapMeta, which coerces absent to'') and a null-preserving numeric/bool parse forgrant_ttl_s/relayed(NOTclampInt, which coerces to0). ThusNULL= "old agent, field never sent" and''/0= "new agent, explicitly fresh/unknown" stay distinguishable in SQL. auditAgentINSERT includes them; defensive caps + control-char strip at ingest (strings capped at 160).AUDIT_SELECT_COLS+ the admin audit page detail view render them (labels: 调用进程peer_exe/ 密钥key_fp/ 目的主机dest/ 复用范围scope_label/ 范围类型scope_family/ 授权时长grant_ttl_s/ 经中继relayed; the page already skips empty/NULL values, so pre-migration rows render unchanged).- Compatibility: old agent → new worker ⇒ columns NULL; new agent → old worker ⇒ unknown JSON fields ignored. The ingest HMAC covers the body as-is; no protocol version needed.
- C1 — trust labeling on the approve page (
pwa/approve.js): theiprow is labeledIP(已验证); a footnote under the field list states除 IP 外均为客户端自报信息,仅供参考. New row记录数: N(fromsalts_b64u.length, worker-derived) when N > 0. - C2 — cache consent copy: 「同一来源 IP(已验证)且同一工作目录 (客户端自报)」 so the stated boundary matches the implemented one (ctx = verified IP + advisory pwd).
- C3 — batch size in approval notifications:
metaLinesgains asaltsparameter; when > 0 the head line becomesuser@host · N 条(mirrors the cache-hit head). Ripple is wider than one call site:buildApprovalMessage(notify.ts) plusbuildCard/buildMessageand the publicsendApprovalCard/editCardin feishu.ts and slack_app.ts, and their do_account.ts callers, all threadch.salts_b64u.length. Terminal edit cards (approved/rejected/expired) keep the count too — same shared block, no drift. - C4 — notifications show the parent process:
metaLinesaddsvia: <ppid_cmd>betweencmdandssh. Cache-hit lines stay compact (unchanged).
docs/authorization-scopes-v2.md§6: add the truth-line ordering and the newdest:/caller:lines.CLAUDE.mdinvariants: extend the prompt invariant with "agent-derived truth lines precede client-reported lines".- This file joins
docs/README.md.
A first round of live use found the opposite failure mode: fields that are always the same carry no signal, and long unshortened strings drown the signal they do carry. Changes, all display-only (grants, digests, and audit fields are unaffected unless noted):
caller:is exception-display.caller: vt(the CLI itself, the overwhelmingly common case) is suppressed; the line appears only for other basenames (ssh -Atraffic,ssh-keygen, renamed binaries). Suppressing on the name hides nothing a rename could not already hide — the basename is attacker-chosen either way. Auditpeer_exestays unconditional.- Workspace reuse labels are unmarked.
reuse: ~/code/vt · 8h— bare, home-contracted path = whole repository;directory/appkeep their prefixes (§6 of authorization-scopes-v2.md).contract_homeis display only. This changes auditscope_labeltoo (it mirrors the displayed line by definition). op: injectline dropped at the source (src/client.rs): thecmd:/file:lines themselves mean inject, and every surface already names the operation (prompt header / approval-page 类型).vt readkeeps its explicitop: read(sole distinguishing line on that path).- Raw-sign prompt unified with sign@vt: header
ssh-sign, thenkey:/caller:/dest:/reuse:truth lines — replaces the oldsign: key (proc)one-liner, so the two sign paths read as one UI. cmd:shortened at the source: basename(argv[0]) + args, cap 160 (was full path, cap 1024). Applies to the Touch ID prompt, the approval page, and notifications alike; the executable path was client-claimed display data, never a verified field.ppid_cmdshortened at collection (src/cf.rs parent_cmd): basename(argv[0]) + args. Affects thevia:prompt line, the approval page 父进程 row, notifications, and audit rows uniformly.
- Rust (
ssh_agent.rsunit tests): dest line on Fresh bound; no duplicate dest when reuse line names it; forwarding/tainted variants; caller line presence/absence; run@vt relay line;AuditContextpopulation for raw sign (key_fp/dest/peer_exe) and cache_hit scope fields.audit.rs: serde of new fields; ip-absent test unchanged. - Worker (Vitest):
metaLineswith salts/via;auditAgentwrites new columns; ingest tolerates missing fields (old agent); approve-page data path for 记录数. - Gates:
cargo test,just check,just check-worker.
- Touch ID dialog height grows by 1–2 lines on sign/decrypt; caps keep each line bounded and truth lines are at the top.
- Notification format change (
· N 条,via:) may surprise downstream parsers of Pushover/Slack text — none are known; the audit row is the stable machine surface. - Audit schema growth is additive-only; no migration risk beyond the established ALTER pattern.
scope_labelfor workspace/cwd grants contains a local filesystem path (workspace /Users/…). Accepted: the same class of data already leaves the host viaChallengeMeta.pwdon every ceremony, and the audit push is opt-in (--audit-url).