Skip to content

docs(fork): host tools design: agents on a person's own computer (limited account, fast own seeing/acting layer, runner-side activity) - #26

Merged
scuttletoby merged 12 commits into
mainfrom
fork/host-tools-design
Nov 14, 2026
Merged

scuttletoby merged 12 commits into
mainfrom
fork/host-tools-design

Conversation

@scuttletoby

@scuttletoby scuttletoby commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Design only, no code. Adds docs/fork/host-tools.md. It lets agents on one of a person's machines work on another of their computers. You give the agent a goal; it picks the fastest route: files, commands, app automation, app bridges, accessibility elements, and input last. Commands run as a separate limited agent account, so the OS enforces the rules.

Round 9 = scope cut per stuck audit

The review rounds kept adding mechanisms. Round 9 only removes or narrows; it adds no new mechanism. The doc goes from 2191 to 2170 lines.

What was cut

  • Remote confirm (phone / second computer) deferred to its own design doc later: §4.2 rule 7, the t3-confirm page and service identity, phone registration, guard confirm keys and guard identity keys (§10), the §1 Confirm page row, and their §15 rows and §16 tests. They are one line each in the new "Deferred" list in §17.
  • Hardened confirm = the OS's own admin prompt in v1 (UAC on the secure desktop, the macOS authorization dialog, polkit on Wayland).
  • X11: no on-screen acting and no high-risk OK in v1; Wayland works.
  • Lock while acting dropped (deny-write entries, ownership changes, leases, the start-time lock restore). The 2-minute acting window and the 30-minute rule stay as plain time windows; the watch and baseline diff stay.

What was added (rules only, no mechanism)

  • Rule 1 (§3.4): on places another account can write, the guard uses its own rights only to take away, on a handle whose file identity matches its ledger, never by path; anything that gives runs as the least-privileged account that can write the place (you), never admin, SYSTEM or root (see the final text fix below).
  • Rule 2 (§4.2): nothing running as you changes who confirms; v1 is fixed to the OS admin prompt, only the admin-run install can change it.
  • §14 tier label: each control is a boundary (OS-enforced) or a tripwire (best effort against code already running as you).
  • One-line minors: per-user COM registrations in the DCOM walk, a lost or old ledger, re-registering a password-storing task as S4U, the SMB residual covers the tailnet. The other two minors are listed in §17.

Final text fix (round cap)

From the last correctness and security diff lenses at d1b9af0; wording and narrowing only, no new mechanism.

  • Major A (sec M1b, corr A), §3.4 audit rule 1: a give (owner, permissions, restored content, Keep) runs as the least-privileged account that can write the place (you), never as admin, SYSTEM or root, even where they own it; refused if that account cannot. Only exception: the guard undoing its own earlier take-away on the same object, through the checked handle, identity matched including the generation (NTFS sequence number, or birth time) with link count 1. "Make it mine" copies .git as you, never a recursive chown as root or SYSTEM. The §3.4 link rules now cover any folder an account other than the guard can write (also corr m4).
  • Major B (sec m2, corr B), §4.2 and §14: the Linux polkit agent runs as you and can be replaced by code running as you, so it is not a protected surface. It counts as the hardened confirm only where the build proves it cannot be replaced; otherwise those items are refused, as on X11. §14 boundary label, elevation residual and §17 deferred list say so.
  • Minors fixed (one line each): where the hardened confirm is refused (X11, replaceable polkit), host tools come back after the kill switch or a failed verify, and an outside store edit is confirmed, only by re-running the admin install (§4.2, §4.3 rule 8); §3.3 says the hardened confirm wins over the automatic turn-on after the kill switch or a verify that failed on more than the guard's own re-applied entries (corr m1); restore and Keep "as you", and with no token for you the object stays neutralised until your sign-in (§3.4, §4.3 rule 6, §4.4, §15; corr m2); "hardened confirm" wording, "§4.2 rule 1", the §17 build line rewritten (corr m3); §15 marks row: the acting window is read from the batch journal and grant records, a stop inside it counts as unclean (sec m4); §14 links row names audit rule 1, §3.1 reach row applies the grant as you, §4.3 rule 6 restore "as the place's writer" (sec m5).

Minors left at merge

  • §17 build list: the exact calls for a give as you (guard copy handed to a process running as you; S4U or setfsuid) and for the guard undoing its own take-away.
  • Option from sec m2 not taken (would be new mechanism): the guard also shows the item's text and code in its own panel, with polkit counting only as proof of the password.
  • Lint (format check) on docs/fork/host-tools.md was already red at d1b9af0 and is still red; no formatter was run (doc-only round, no builds).

Open for the owner (§17)

  1. Hardened confirm = the OS admin prompt in v1, a phone or second computer later (default yes): Owner question: host tools, high-risk OKs via the computer's own admin prompt in v1 (phone later)? #28
  2. Block SMB and printers to the internet for the whole computer (default yes): Owner question: host tools, block SMB and printers to the internet computer-wide? #29

General vs project

All of it is general. Machine names, addresses, accounts, folders, apps, bridges, recipes and named commands are local policy and settings. The examples use placeholders only.

Upstream fit

See §2 and §10 of the doc. Read at upstream main 8cd8a3c on 2026-11-13, with file:line for every claim. Related upstream items: pingdotgg#33183, pingdotgg#9884, pingdotgg#31755, pingdotgg#26757, pingdotgg#25024, pingdotgg#34293, pingdotgg#36102, pingdotgg#32639, pingdotgg#35519, pingdotgg#32158, pingdotgg#35826. Computer-use work considered but not built on: pingdotgg#16527-pingdotgg#16532 and pingdotgg#28050.

🤖 Generated with Claude Code

scuttletoby and others added 2 commits November 13, 2026 18:16
…ugh a limited OS account

Design only, no code. Builds on upstream linked environments, MCP tool
forwarding, the background service and server settings; the fork adds a
host toolkit, a session helper in the desktop app, per-OS guard tasks and
one availability setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r-side activity, tray and wind-down, link renewal

Owner feedback 2026-11-13:
- no activity gate on host tools; activity-aware scale-down moved to runners (§11)
- tray icon with pause, wind down ("safe to shut down") and kill switch (§12)
- link expiry checked upstream (30-day MCP-client session, no refresh grant); fork option: refresh while in use, or a longer-lifetime setting (§10)
- seeing and acting: own agent-native engine, most direct route first and pixels last; batches with on-computer waits, diffed text snapshots, recipes, latency budget and benchmark, why not classic computer use (§5-§8)
- safety: per-app see/act policy, built-in refusals, indicator excluded from captures, instant take-over, held-input journal, idempotent batches (§6.5, §7.2)
- invisible by default as a tested requirement (§6.6); speculative parallel batches for side-effect-free alternatives (§7.4)
- failure table rows for wind-down, runner scale-down, take-over, mid-drag crash, half-done UI action, speculative batches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby scuttletoby changed the title docs(fork): host tools design: agents on a person's own computer through a limited OS account docs(fork): host tools design: agents on a person's own computer (limited account, fast own seeing/acting layer, runner-side activity) Nov 13, 2026
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security-design · reviewer: Opus 5.5 (t3lens-26-sec) · read at: 2f6c78c
covered: all of docs/fork/host-tools.md at the head above (§1-§17, the threat model in §14 and the failure table in §15). I read the PR description only after writing this list. There were no earlier comments.
checks: for each mechanism I tried these hostile or confused inputs: prompt injection from files, screen text, web pages and bridge data; a compromised agent or a hostile build script running as the agent account; a malicious repo or CI job; a stolen link or refresh token; races between the person and the engine; bypassing the policy through allowed apps (shell, editor, file manager, browser, settings); secrets in the clipboard, trees, screenshots, logs and journals; privilege (SYSTEM guard tasks, UAC, other sessions); escape from speculative sandboxes; cleanup after a crash. I also asked of every step that writes: "What happens when this runs again, stops half-way, or meets existing/old/empty state (rerun, half-applied, legacy data, empty history)?" and compared the answers with §15.
summary: 3 blockers, 10 majors, 5 minors. The main pattern: the doc treats the agent account as the boundary, but several paths turn what the agent account writes into code run as the person or as SYSTEM, and some state rows fail open.

finding: BLOCKER B1. Files the agent account writes become code run as the person (§3 allowed folders, §4.1, §6.3 app/window launch, §6.4 bridges, §9.1 named commands, approved scripts and host_request_admin). The agent account can write to every read-write folder. Several parts then run what it wrote as the person, so the account boundary is bypassed. Attacks: (1) host_write /x.bat (or .lnk, .url, .ps1, .js, .hta, .msi, .docm), then a host_do app open step. The engine runs as the person, so the Shell "open" verb runs the file as the person, with no act rule or indicator needed beyond the app's policy. (2) Write /.git/config with core.fsmonitor=, or a .git/hooks/* file, or .vscode/tasks.json. The next git status from the person's IDE or shell, or from an act-step in the IDE, runs it as the person. (3) Replace a listed bridge's mod or plug-in in a game folder that is an allowed folder (the hash is optional). (4) A named command such as "deploy" = npm run deploy in an allowed repo: edit package.json and it runs with the person's SSH key. (5) host_request_admin "powershell -File /fix.ps1": the person approves, the agent swaps the file between the pop-up and the run, and the swapped file runs elevated. (6) window launch with a working directory in a rw folder lets the agent plant a DLL. Required: (a) The engine never opens, launches or Shell-executes a file in an agent-writable folder unless the target app is named in the policy and the file type is on a per-app data-type allowlist. It never does so for executable or script types (PATHEXT, .lnk, .url, .scf, .hta, .desktop, .command, +x files, .app bundles). Office and other COM servers are created with macros forced off. (b) Every launch, named command, approved script and admin command uses a working directory the agent account cannot write. (c) Named commands, approved scripts and admin commands may not reference agent-writable paths; or the helper copies each referenced file to a protected place, shows its hash or content in the pop-up and runs the copy. (d) Bridge hashes are required when the bridge file is in an agent-writable folder. (e) The install check and host_status flag rw folders that hold code the person runs (.git, .vscode, .idea, package manifests, mods and plug-in folders, shell profiles, the Startup folder). Those paths are read-only to the agent by default unless the person accepts them. (f) §14 gets a row for this.

finding: BLOCKER B2. SYSTEM and the person's processes follow links the agent planted (§3 "inherited" grants, §4.1 daily re-apply, §4.2 deny entries, §4.3 VSS and rsync restore, §7.4 sandbox copy and class-3 restore, host_undo). Attack: in a rw folder, the agent account creates a junction or symlink to C:\Windows\System32, /etc or the person's profile, or a hard link to a file of the person's. The daily guard task (SYSTEM) re-applies the inherited Modify grant through the junction, which gives the agent account write access to system files and so admin. Or host_undo or the rsync restore (root) writes the snapshot content through a path the agent swapped for a symlink. Or the helper (as the person) applies ACLs or copies through the link. Required: every operation by SYSTEM, root or the person on a path inside an agent-writable folder opens without following reparse points or symlinks (FILE_FLAG_OPEN_REPARSE_POINT; O_NOFOLLOW; openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS). It refuses files with a link count above 1. It checks that the final resolved path is under the allowed root before it writes or sets an ACL. Recursive ACL application stops at reparse points. Add a per-OS test: a planted junction, symlink and hard link do not widen any grant or redirect any restore.

finding: BLOCKER B3. The agent can widen its own policy through act apps (§6.5 refusals, §4.5 PAUSED file, §1 "a file only you and admins can change", §14 "Clicking its own permissions wider"). The refusal list covers the policy screens only. The policy JSON and the PAUSED file can be changed by the person's account, and so by any act app running as the person. Attack: with act on for an editor, file manager or terminal (or full input in any app that has a file-open dialog), open the policy file and set acting to full for every app, delete PAUSED, or add an approved script. The helper reloads the policy. Terminals, the Run dialog, Explorer's address bar, Settings, Task Scheduler, regedit and browser devtools also give the agent the person's full rights in one act rule. Required: (a) The policy takes effect only through the helper. An edit made outside the helper (hash mismatch on load) is ignored until the person confirms it in the tray, and the tray screen is itself refused to the engine. (b) The engine refuses acting on any window whose document or path is in the policy folder, and refuses file-dialog navigation into it. (c) A built-in "full user rights" class of apps (shells and terminals, the Run dialog, the file manager, system settings, the task scheduler, the registry editor, script hosts, browser devtools) can never get act from the unknown-app default. Each one needs an explicit per-app acknowledgement in the tray.

finding: MAJOR M1. The T3 server shares an account with the code it runs (§3 "How commands run", §6.1, §4 intro). host_run, delegated agents and speculative runs all run as the same account that owns the computer's T3 data (auth DB, issued sessions, link ceilings), its provider logins and possibly its install files. A hostile build script can raise a read-only link's ceiling, mint MCP sessions, read any outbound link credential the computer's T3 holds, steal model API logins, patch the T3 install so it keeps running at boot, and call the engine channel directly with fake caller details. Required: commands run under a second, unprivileged identity (a second account, or AppContainer on Windows, a sandbox profile on macOS, a separate uid or user namespace on Linux) that cannot read or write the T3 server's data, secrets or install. The install is owned by an admin and read-only to both. The engine pipe or socket admits only the server's identity, not the command identity. The computer's T3 holds no outbound link to the agent machine. If that is not taken, §14 must state that one hostile command owns the computer-side T3 and its link state.

finding: MAJOR M2. The OS does not enforce speculative sandboxes for files and IPC (§7.4). "run steps marked read-only" is the agent's own claim. Nothing says how a run process is limited to its copy, and a network namespace or a no-network profile does not block filesystem Unix sockets: on Linux and macOS a speculative run can connect to the engine's socket and call act verbs, which escapes "never speculative". Copying a folder into a sandbox (clonefile, reflink or block clone) does not carry explicit deny ACEs, so a denied .env becomes readable in the copy, and the output digest or diff leaks it. Process groups do not hold setsid daemons on macOS. Required: the OS enforces the sandbox (a mount namespace with read-only binds of real folders and a writable copy only; AppContainer with ACEs only on the copy; a macOS sandbox profile with file-write limited to the copy and no IPC to the engine). Speculative processes cannot reach the engine, the T3 server or any local socket. The agent account makes the copy (so denied files cannot be read and are skipped), or secret-pattern files are left out. On macOS, cleanup uses a per-run identity or tracks every descendant. §16 tests that a speculative run cannot reach the engine channel or a denied file in its copy.

finding: MAJOR M3. The network block misses loopback, the tailnet and metadata addresses (§3 Network, §10). "Your local network blocked except the linked machine" says nothing of 127.0.0.1 and ::1, so the agent account reaches every local service of the person and of other accounts: a desktop T3, dev servers, browser debug ports, a container API, sync GUIs, a fleet dispatcher. A per-account firewall rule for "the LAN" usually leaves out 100.64.0.0/10 and fd7a:115c:a1e0::/48 (other tailnet nodes, including hypervisor hosts), link-local, 169.254.169.254 and the tailnet DNS. Required: the default-deny list for the account includes loopback (except the T3 server's own port), RFC1918, CGNAT/tailnet v4 and v6, link-local, ULA and multicast, with explicit allow entries. The first-install verify probes each class. The computer's T3 gives loopback callers no trust or bootstrap (other local accounts are remote clients).

finding: MAJOR M4. The clipboard and the scope of redaction (§6.2 "clipboard if asked", §6.3 clipboard, §4.2 redaction, §4.4 log, §7.2 journal). Clipboard read is a plain read tool, and the clipboard is where password managers put passwords. Clipboard write is a silent act with no app rule (paste-jacking: set "curl … | sh" just before the person pastes into a terminal). Redaction is promised only for "what host tools return". It does not say whether it covers tree values, window titles, diffs, bridge read results, journalled results returned on retry, host_run argv in the T3 log, or values typed (logged unless the field is marked sensitive). Required: clipboard read and write are each a policy switch, off by default. A read is refused when the content carries the concealed or excluded markers (Windows ExcludeClipboardContentFromMonitorProcessing / CanIncludeInClipboardHistory=0, macOS org.nspasteboard.ConcealedType, KDE x-kde-passwordManagerHint). A write is logged, shows the indicator and counts as acting. Token-shape redaction applies to every output path and every stored record (observe text, titles, diffs, bridge data, journal, both logs, argv).

finding: MAJOR M5. Synthetic input has a time-of-check to time-of-use gap (§6.3 "before each input step the engine checks …", §8.1 "1,000 characters ≤ 150 ms"). The check happens once per step, then a burst of SendInput or CGEvent follows. A notification, an app that steals focus or a window the person opens takes the foreground mid-burst, and the typed text lands there: in a terminal it runs as the person, in a hidden app it is typed blind. Required: during an input burst the engine locks the foreground (Windows LockSetForegroundWindow(LSFW_LOCK)), sends in chunks of a few events with a foreground and target check between chunks, aborts on a foreground-change event (EVENT_SYSTEM_FOREGROUND, or the NSWorkspace activation notification), releases held input, and returns stale. Text goes through element set_value whenever the target allows it.

finding: MAJOR M6. Any thread on the agent machine can call host tools (§9 "a non-plan caller", §4.6). The agent machine runs many agents, including ones that read untrusted input (public PR text, issues, web pages, CI logs). Attack: an injected instruction in a PR body read by a review thread leads it to call tool_search, then host_do on the person's desktop. The availability setting is per computer, not per caller. Required: host tools are granted per project or thread on the agent machine (default: none, the person grants them), and a thread whose turn includes untrusted outside content gets read tools at most unless granted. The computer's logs record the calling thread or project id that the agent machine forwards, and the tray shows it while acting.

finding: MAJOR M7. Refresh tokens have no theft controls (§10 option 1). Under the doc's rules, a stolen refresh token lives forever as long as the thief keeps using it. "Used in the last 7 days" is judged by the client. Reuse of a rotated token outside the 60 s grace is not handled. There is no absolute lifetime. Required: the computer (the server side) tracks last use. A rotated token presented after the grace revokes the whole token family and raises a tray alert (RFC 9700 refresh-token reuse detection). The family has a maximum lifetime (a setting, for example 90 days) after which the link must be paired again. Refresh is bound to the link's client identity. A revoke issued while a refresh is in flight wins. Each renewal is logged and shown in the tray.

finding: MAJOR M8. Some §15 rows fail open, and some rows are missing (the rerun / half-way / old-state question). (a) "snapshot store full: writes and runs go on": a hostile agent first fills the disk or VSS area so old shadows are evicted, then deletes files with no undo. VSS also keeps 64 copies, while the stated cadence makes about 96 a day. Required: when a snapshot cannot be taken, or keeping it would evict one younger than the retention, writes and runs are refused. The agent account gets a disk quota. (b) Secret deny entries rely on a live watcher. Files created while the helper is down, before the first pass, or by an overflowing watcher are readable until the daily re-apply. Required: on helper start (and after a watcher overflow) a full deny pass runs before file tools are enabled, and the server refuses file reads while the pass is not current. (c) Install half-applied (firewall rule missing, deny pass not done, account in a wrong group after a later change) is not a row. Required: host tools stay off until the install verify passes, and host_status re-runs it. (d) The helper dies while the engine lives: §6.5 says the helper draws the indicator, and §15 "engine or helper dies" says the indicator goes with the engine. Required: the engine stops acting within a set time when the helper's heartbeat stops, so it never acts without an indicator. (e) Missing rows: turning host tools off or uninstalling (folder grants, deny ACEs, firewall rules, tasks, refresh tokens, the account), and a folder removed from the list while an agent process holds an open handle (the kill or restart that removal needs).

finding: MAJOR M9. host_undo can wipe the person's own work (§4.3, §9). The watcher records every writer, and undo "since 14:05" restores every changed file, so an agent's undo (injected or confused) reverts the person's edits made in that window. Required: an agent-called host_undo restores only files whose changes since the time came from the agent account (writer recorded per change), and compares each file against the hash it had at listing before writing. Restoring files the person changed is offered only in the tray.

finding: MAJOR M10. The agents' browser runs as the person and drives it over raw CDP (§6.3 Browser, §6.4). The engine runs as the person, so this browser can open file:///<the person's profile>, download anywhere, and run page script. Any web page it visits is an injection source with the person's file rights behind it. Required: the agents' browser runs as the agent account (or under the M1 command identity) when headless. When visible, file:// and other local schemes are blocked by browser policy, downloads are pinned to a rw folder, and there are no extensions and no sync sign-in. CDP is exposed only as typed verbs, never passed through. State which of these applies to bridge-driven browsers.

finding: minor m1. Pause does not stop everything (§4.5): "running host_run jobs are stopped" through a job object or process group, but a daemonised child (setsid, or a scheduled run in its own session) survives pause and resume. Required: pause ends every agent-account process except the T3 server, or the doc says only the kill switch is complete.
finding: minor m2. The T3-side log is written by the agent account (§4.4). Append-only stops cuts, not forged lines, and the hash chain can be recomputed by the same writer. State that the helper or guard-task log is the authority, and have the guard task countersign the T3-log head on a schedule.
finding: minor m3. Sessions that are not active (§6.5): refuse acting when the person's session is disconnected, switched away (another user on the console) or remote. Take-over and the indicator assume someone is at this session.
finding: minor m4. Pop-ups and bridges (§9.1, §6.4): rate-limit repeat requests after a Deny so the person is not worn down by repeats. Allow bridge verbs one by one, not all at once. Mark bridge read data (game chat, page text) as untrusted in results, like file content.
finding: minor m5. Unknown-app default (§6.5 and open point 2): see sends the person's mail, chat and bank screens to the provider and makes them the largest injection surface. Recommend hidden with a tray list of first-seen apps to opt in, or at least see without screenshots.

…put ladder

Owner scope A (whole computer except sensitive things, one batched ask), separate host and agent
accounts with a command broker, link-safe file operations, network deny classes, guard-owned
policy store, writer-aware undo, a four-rung input ladder, private-copy speculative runs sized
from free hardware, long link lifetime with one-click extension and device binding, runner
budgets from measured free hardware, wind down without a timer, power-loss recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security-design r2 · reviewer: Opus 5.5 (t3lens-26-sec-r2) · read at: 57f3917
covered: the diff 2f6c78c..57f3917 and all of docs/fork/host-tools.md at 57f3917 (§1-§17, §14.1 and the §15 failure table). Scope A is taken as decided. Where the doc keeps A, I judged only whether the replacement control in §14.1 closes the hole and whether the stated residual risk is true.
checks: I re-ran each r1 attack against the new text. I attacked the new parts: the two accounts and the broker, the input ladder (rungs 1-4), link lifetime and device binding, runner measurement, power-loss recovery, speculative private layers, and 24 h snapshots that refuse writes when full. I asked word for word: "What happens when this runs again, stops half-way, or meets existing/old/empty state (rerun, half-applied, legacy data, empty history)?" and compared the answers with §15.
summary: r1 status: 9 closed (B2, M2, M4, M5, M6, M7, M8, M9, M10, with accepted residuals where A applies), 4 partly closed (B1, B3, M1, M3). New: 1 blocker, 9 majors, 8 minors.

r1 status:

  • B1 partly (§4.3 rules 3-4, §9.1). Engine open/launch verbs, working folders, frozen copies and bridge hashes are closed. Two paths remain: running a file through the UI (N1), and getting around the auto-run denies (N3).
  • B2 closed (§3.4). One leftover: the Linux rsync path (minor n1).
  • B3 partly (§4.3 rule 5, §4.2). The store file route is closed. Not closed: the guard trusts the helper's word that consent happened, and a full-rights grant can leave things running after it ends (N4). The UI path of N1 also reaches full rights with no grant.
  • M1 partly (§3, §3.2). Commands are closed. Only delegate_task is switched off. Other forwarded upstream tools still start work as the host account (N2).
  • M2 closed for Linux and Windows (§7.4). New gaps in the apply step and the macOS slot users (N6).
  • M3 partly (§3.5). The computer's own non-loopback addresses and on-link global IPv6 are not in the deny classes (N5).
  • M4 closed with an honest residual (§6.7, §14.1). The "for you" clipboard exception and the journal copy are minor n2.
  • M5 closed (§6.4 rung table).
  • M6 closed by replacement (§4.3 rule 9). The residual in §14.1 is honest.
  • M7 closed by replacement (§10, device binding). An honest residual for bound links, except for legacy links (N7).
  • M8 closed (§3.3, §4.3 rules 2 and 6, §6.1 heartbeat, §15 uninstall row). The snapshot guarantee is overstated (N9).
  • M9 closed (§4.3 rule 6, writer recorded, hash check).
  • M10 closed (§6.5). Policy scope is minor n3.
  • m1, m2, m3 closed. m4 and m5 accepted under A, and the residuals are honest.

finding: BLOCKER N1. Any app's file dialog, launcher or link runs agent-written files as you. This gets around the file-type rule and the full-user-rights list (§4.3 rule 4, §4.2 full-user-rights row, §6.3, §14 first row, §14.1 B1a). The file-type rule covers only the engine's own app open and window launch. The full-rights list names apps, not abilities. Under A, acting in every other app is open by default. Attack: (1) A web page or file the agent reads injects a goal, and the agent writes <reach map>/Desktop/x.bat with host_write. (2) It sends host_do to any non-full-rights app with a file dialog (Notepad, a mail client, an image editor): el invoke File > Open, select x.bat in the dialog's shell view, open its context menu (an input step, Shift+F10 at rung 2), then el invoke "Open". (3) The dialog's host process calls ShellExecute, so x.bat runs as you. The engine saw no launch, no grant was asked, and no OS warning shows: agent-written files carry no Mark-of-the-Web, so SmartScreen and Office Protected View stay quiet. The same works through an IDE's built-in terminal or task runner, the Start/Search box or Spotlight (type the path, press Enter), and a file://, search-ms: or ms-*: link clicked in a chat or mail app. Once code runs as you, it can click the ask dialog, so B3 is open again without any grant. The §14 residual "it can do what you could there (send a message, press a button)" understates this: the agent can run any code as you. Required: (a) Treat a shell view hosted in any app (IFileDialog, NSOpenPanel, the GTK/portal file chooser) as the file manager. Without a full-rights grant, acting there is limited to typing a path into the name field and pressing Open, Save or Cancel. Refuse context menus, "Open with" and invoking a file item there. (b) Define the full-user-rights class by ability as well as by name: OS launchers and search, IDEs and editors with a terminal or task runner, apps with a script or macro console. As a backstop, when a process the engine is acting in, or acted in during the last few seconds, starts a shell, a script host or a file whose last writer is the agent account, stop the batch and end that child. Block it where the OS has a blocking hook (macOS Endpoint Security AUTH_EXEC; Linux fanotify FAN_OPEN_EXEC_PERM; Windows an App Control/WDAC path rule or a minifilter). Otherwise end it at start and report it. (c) The guard tags each executable or script file the agent account creates in the reach map with the OS's untrusted mark (Zone.Identifier with ZoneId=3; com.apple.quarantine), and puts the tag back after each agent write, so the OS warns when you open it. (d) §14 says plainly what is left. (e) §16 adds a test: an agent-written .bat cannot run through a file dialog's context menu, the Start/Search box or a clicked file:// link.

finding: MAJOR N2. Forwarded upstream tools other than delegate_task still start work as the host account (§3.2, §2 table "delegate_task … off in v1", §14 M1 row). §3.2 switches off delegated agents because a provider would run as the host account and could reach T3's data. But any upstream tool that takes environmentId is forwarded to the computer once the link has write access. That includes thread launch and creating threads (which start a provider there), project create and clone, worktree tools, and any terminal or git operation the server runs for them. Attack: an injected or confused agent calls the thread-launch tool with the computer's environmentId and a prompt. The provider starts as the host account and reads the auth database and link state. This is the M1 hole again. Required: on a host-tools computer, the host account's T3 refuses every forwarded tool outside an allowlist (the host_* tools plus read-only environment reads). That allowlist is enforced on the computer, not the agent machine. T3's own terminal and provider features are switched off for that install. The install verify checks both. §16 adds a test: a forwarded thread launch to a host-tools computer is refused.

finding: MAJOR N3. The auto-run denies (§4.3 rule 3) can be bypassed by renaming or newly creating the folder, and IDE auto-sync makes "nothing the agent wrote runs as you" untrue (In short, §4.3 rule 3, §14.1 B1e). (1) The deny entries sit on file objects, not on paths. The inherited Modify grant gives DELETE, and so rename, on the .vscode, .idea and .git folders themselves. The agent renames .vscode to .vscode-old and writes a new .vscode/tasks.json with "runOptions": {"runOn": "folderOpen"}. (2) In a repo or folder with no .vscode, .idea, .envrc or new .git yet, the agent creates the file with its content in one write. The watcher denies writes about a second later, but the content is already there. (.git gets some help from git's own safe.directory owner check, unless the person has set safe.directory = *.) (3) The §14.1 B1e residual says package manifests run "only when you choose to run them". IDE build sync breaks that. IntelliJ/Android Studio reload Gradle on external build-script changes by default, rust-analyzer runs build.rs and proc macros on open, and Visual Studio runs design-time MSBuild. An agent edit to build.gradle(.kts), build.rs or a .csproj runs as you with no click. Required: (a) Deny DELETE and rename on each auto-run folder itself, and on the folder that holds an auto-run file, for the agent account. (b) The guard quarantines an auto-run path that the agent account created (writer recorded) until you accept it through the ask. Example: rename it aside, or deny read for your account. A live watcher alone is not enough. (c) The install verify flags safe.directory = * in your git config. (d) Either list the build files that IDEs auto-sync as auto-run paths, or require auto-sync off and check it in the install verify. Otherwise, remove "Nothing the agent writes is ever run as you" from In short and §4.3 rule 4, and write the IDE-sync case into the §14.1 B1e residual.

finding: MAJOR N4. The guard trusts the helper's word that consent happened, and a full-rights grant leaves running code behind (§4.2 "Confirmed where the agent cannot click", §4.3 rule 5, §14.1 B3c). The store is written by the guard "when the helper asks". The OS consent prompt is shown by the helper, which runs as you. During a full-rights grant (or through N1), code running as you can inject into the helper or the engine, or call the guard's channel directly, and ask for a widening with no prompt. The guard cannot tell. The grant is scoped "this task", but code that ran as you can add a Run key, a Startup entry, a scheduled task, a LaunchAgent, a systemd --user unit, a shell profile line or a browser extension. Those keep running after the task ends. The residual "during a grant the agent has your user rights" leaves out "and afterwards, through anything it installed". Required: (a) The guard checks the consent itself. A widening is written only when the request comes from an elevated or authorized process the guard can verify: on Windows, a high-integrity token started through UAC; on macOS, an AuthorizationRef the guard checks; on Linux, a polkit check the guard makes. The helper's claim is never enough. (b) When a full-rights grant starts, the guard records a baseline of your auto-start places. When the grant ends, it diffs them, shows every addition in the tray with Remove, and logs the diff. (c) §14.1 B3c states what is left after the grant.

finding: MAJOR N5. The computer's own non-loopback addresses and global IPv6 on the LAN are not in the deny classes (§3.5). The loopback rule says the agent account reaches only its own listeners. But your services usually listen on all addresses. The computer's own global IPv6 address, and other LAN devices with global IPv6 addresses (SLAAC on the same /64), fall in none of the listed classes, so they count as "the public internet". Attack: a hostile build script run as the agent account connects to [own global IPv6]:<a dev server, a browser debug port, a container API or a sync UI's port>. It does the same to a NAS or router on the LAN by its global address. Required: the deny list includes every address of every local interface (both families, tracked when addresses change) and every on-link IPv6 prefix. The install verify probes your own global address and an on-link neighbour, and refuses to enable host tools when either answers.

finding: MAJOR N6. Speculative apply and macOS slot users get around the denies (§7.4). (1) §7.4 does not say who applies a winner's layer to the real folders. It names the §3.4 link-safe operations, which are for privileged writers. If the guard, the helper or the engine applies the layer, the agent account's OS deny entries do not hold. A layer that contains .git/hooks/post-checkout, a new .vscode/tasks.json or a file named .env is written over the real one, so the auto-run and secret denies are bypassed. (2) On macOS each run is a per-slot hidden user, not the agent account. Deny entries name the agent account, so the slot user can read a world-readable repo .env through the read-only view of the real folders. Required: (a) The apply runs as the agent account, so the OS denies hold. If it cannot, the applier refuses every path that matches the secret patterns or the auto-run paths. (b) Deny entries and reach-map grants target a group that holds the agent account and every slot user. The macOS sandbox profile also denies reading secret-pattern paths. (c) §16's speculative test adds "a winner cannot write an auto-run or secret path", and the macOS slot-user .env case.

finding: MAJOR N7. New input-ladder back ends open new surfaces (§6.4 rungs 3-4, §3.1). (1) Rung 3 on Windows offers "a loopback RDP session of the agent account". That conflicts with §3.1, which denies log on through RDP and keeps the account out of Remote Desktop Users, and with "no password anyone types". To make it work, the RDP listener would be opened to the LAN and tailnet with a login for the agent account. (2) Rung 4's Linux fallback, "uinput through a root-owned guard device", gives input at kernel level. If your account can reach that device, every process running as you can inject input past Wayland's input isolation. That input reaches the lock screen, polkit agents and VT switching, and the engine's target checks cannot hold it back. Required: drop loopback RDP, or require the listener bound to loopback only, NLA, a guard-held random credential and its own install-verify check. Drop the uinput fallback, or make the device reachable only by a guard-owned process that applies the §6.7 refusals itself.

finding: MAJOR N8. On power loss, restore and fallback can turn narrowing back into widening (§12.3, §15 "protected store edit" and "power loss" rows). (1) "Falls back to the last good version" when the signature fails can bring back an older store with grants you revoked since, an auto-run unlock you withdrew, or no pause. (2) If you press Revoke, Deny or Pause and the tray confirms before that write is durable, a power cut brings the grant back on the next start. Required: a narrowing write (revoke, deny, pause, kill switch, narrowing the reach map) is fsynced before the tray confirms it. On fallback, the guard replays every narrowing record in its own log since that version. If it cannot, it starts with no grants beyond the built-in defaults and with pause on, and the tray says so. Add both to §15 and to the power-cut test.

finding: MAJOR N9. The undo guarantee is overstated under heavy agent writes (§4.3 rule 6, §14 "Damage to your folders"). The quota bounds bytes stored, not blocks changed. VSS and copy-on-write snapshots grow with every overwritten block. Attack: the agent deletes or damages files at T. It then rewrites one large file inside its quota over and over. The snapshot store hits its tight cap, and after the 1-hour floor the snapshot from before T is evicted. So "any file can go back … within 24 hours" holds only for about the floor time, and §14 does not say so. Required: state the real guarantee: 24 h normally, at least the floor (1 h) under heavy writes. The tray warns when a snapshot is evicted before 24 h. Optionally, refuse agent writes when eviction would drop the newest snapshot that comes before an agent delete or overwrite that is not yet undone.

finding: MAJOR N10. Background runners on a personal PC have no stated isolation (§11, §2 "Next to the fork's fleet runners"). §11 brings CI jobs and other projects' runners onto the person's PC. Only "separate accounts" is said. A CI job runs repository code, maybe from a PR. Nothing gives runner accounts the §3.5 network deny classes (loopback, LAN, tailnet), a disk quota, no access to your session, or the §3.3 reach check. A hostile PR job could reach your dev servers, other tailnet nodes and every world-readable folder. Required: runner slot accounts get the same deny classes, quota, session isolation and reach check as the agent account, checked by the install verify. Or §14 states that runners on a personal PC are a separate trust zone with its own rules, and names those rules.

finding: minor n1. Linux rsync --link-dest as root (§4.3 rule 6) is not one of the §3.4 link-safe operations. Use the shared walker, or check fs.protected_hardlinks=1 and fs.protected_symlinks=1 in the install verify.
finding: minor n2. Clipboard (§6.7, §15): "unless the step says the result is for you" lets the agent leave text to be pasted into a terminal. Show kept content in the tray or a notification. The previous clipboard is fsynced to the journal. When it carries a concealed marker, do not journal it: clear it at batch end instead, or keep it encrypted for the user (DPAPI or keychain) and delete it after the restore.
finding: minor n3. The visible agents' browser (§6.5): user-level browser policy (Chrome's per-user policy) also applies to your own browser of the same brand. Use a separate browser build or channel, with policy scoped to it.
finding: minor n4. Protected store (§4.3 rule 5): the agent account needs no read access to named commands, approved scripts or recipes. Give read to the host account and you only.
finding: minor n5. Rung 3 on a hidden desktop in your session runs apps as you (§6.4, §6.7). Say whether it is refused while your session is locked, as rungs 1, 2 and 4 are.
finding: minor n6. Frozen copies (§4.3 rule 4, §9.1): the ask for a new commit shows a hash. Also show the diff since the last approved commit, so the approval means something.
finding: minor n7. Pre-images (§4.3 rule 6): state the undo store's size cap, and what host_delete of a large tree does when the cap is reached (refuse, as for snapshots).
finding: minor n8. Windows speculative runs (§7.4): AppContainer read ACEs on the real folders must be applied with the §3.4 walk, and must be in the §15 uninstall removal list.

… internet

Folds in the owner decisions of 2026-11-13 on the second security review
(blocker N1, majors N2-N10, minors n1-n8): acting in the UI acts as you,
with OS-enforced protected areas, OK per action and an activity log; a
forwarded-tool allowlist; auto-run quarantine and rename denies; frozen
copies for approved runs; no direct internet for the agent group; durable
narrowing; take-over pause with no idle timer; 365-day bound links;
runner isolation. Failure table, tests and open points updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: correctness · reviewer: Opus 5.5 (t3lens-26-cor) · read at: 9fde35b
covered: docs/fork/host-tools.md, all 1438 lines, §1-§17 and "General vs project". I checked the upstream paths and line numbers in §2 and §10 against upstream 8cd8a3c. I wrote this list before reading the PR description or any earlier comment.
checks: (1) §4 rules against §14, §15, §16 and §17, numbers, § references and leftovers from earlier revisions; (2) whether each OS mechanism can do what the doc says, and whether the named upstream paths exist; (3) for every store, ledger, journal, lock, undo store and link: what happens on a rerun, a stop half-way, and old or empty state; (4) every new rule has a §15 row and a §16 test.
checks result: every upstream path exists. The line numbers in §10 match upstream 8cd8a3c (PeerLinks.ts:308/379/536/593/645-648, PeerLinkRequests.ts:106-112, EnvironmentAuth.ts:79/1347, McpOAuth.ts:97/510-511/546/564). No leftovers from earlier revisions remain: no file-type rule, no idle timer, no links that never expire, no visible agents' browser and no direct internet. §6.5, §14.1 and §17 state all of these the current way. Every § reference I followed points to the right section.

finding: major · §10 rule 1-2, L1064-1072 · Extending a link does not reach the linking side. The linking side stores expiresAt = now + expires_in once, at sign-in (PeerLinks.ts:536). It refuses its own calls once that stored time passes (PeerLinkRequests.ts:106-112). After the tray's "Extend links" (and for a link that already expired, which usableLink filters out), the agent machine still holds the old time. It stops calling at the original expiry even though the computer extended the session, and the 7-day heads-up (L1076) is computed from that stale time too. So "the linking side's own expiry check keeps working unchanged" is wrong. Fix: the linking side must learn the new expiry. Either every response carries the session's current expiry and the linking side updates its stored value, or there is a fork change to usableLink. A link extended from the computer's tray reaches the agent machine on its next call, so usableLink must not drop an expired link before it has asked the computer once. Add a §15 row ("extended on the computer, linking side still holds the old expiry") and a §16 test.

finding: major · §4.4 "Who enforces it", L528-529, and §3.3 reach check L217-219 · The doc says protected areas are OS-enforced because writing to system areas needs elevation. That is false for some of the listed areas. On Windows, C:\ProgramData lets Users create files and folders by default, and the root of the system drive lets Authenticated Users create folders. That holds for the agent group too, so the agent group can write into a protected area with no ask. On macOS, an admin user writes /Applications with no prompt, and a Homebrew prefix (/opt/homebrew, /usr/local) is owned by you. Fix: the guard puts deny-create ACEs for the agent group on these places. The reach check also walks for writable places outside the reach map, not only readable ones. The "acting as you" row names the areas where your own account writes system areas without elevation (macOS admin, Homebrew prefixes) and moves them to the guard's watch layer. Add a §16 test: the agent account cannot create a file in ProgramData or the drive root.

finding: major · §6.4 rung 3, L672, and §6.5 L714-717 · The design cannot drive rung 3 in the agent account's own window station (Windows) or display (Linux Xvfb as the agent account). The engine runs only as you, in your session (§6.1). UIA, PostMessage and AT-SPI cannot reach windows in another session or window station, or on another user's accessibility bus. The CDP pipe --remote-debugging-pipe belongs to the browser's parent, which is the broker (agent account), not the engine. §6.1 also forbids any engine part as the agent account. Fix: either add a small agent-account "engine worker" behind the broker that runs the rung 1-2 verbs and CDP inside that station, or drop "agent account's own window station" from rung 3 and keep only the hidden desktop in your session. Then §6.5's headless browser needs another owner. Say which. Adjust §6.7, §6.8 and §16 to match.

finding: major · §4.3 rule 6, L435-436 · On Linux without btrfs/ZFS/LVM-thin, the fallback is "a copy of only the files the agent changed". That cannot be made before the change for host_run commands. fanotify has no general pre-write permission event, so the guard only learns of a change after it happened. Only T3's own file tools can keep pre-images. Fix: on such filesystems either run host_run writes through an overlay and commit (as in §7.4), or state plainly that command changes there have no undo and fail closed or warn in host_status. Update §14 "Damage to your folders" and §15 "snapshot".

finding: major · §4.3 rule 2, L374-376, and §9 L1007 · The fail-closed deny pass gates only the file tools ("before file tools are enabled", "the server refuses file reads"). host_run, host_do run steps and speculative runs read through the OS. While the pass is stale (after a watcher overflow or a helper restart), they can read a secret file that has no deny entry yet. Fix: gate every agent-account execution (host_run, run steps, speculative runs, the agents' browser) on a current pass. Update the §15 "secret deny pass" row and add a §16 test (watcher overflow, then host_run cat of a new .env is refused).

finding: major · §3.5 L247-248 · "Name lookups go only to the tailnet's resolver, so DNS cannot carry data out" is wrong. MagicDNS (100.100.100.100) forwards names outside the tailnet to the global resolvers, so <data>.attacker.example lookups still leave. Per-goal "named hosts" also cannot be expressed in WFP or nftables, which match addresses, not names (CDN addresses change). Fix: a resolver run by the guard for the agent group that answers only the linked machines' names and the hosts a goal opened, refuses everything else, and pins the answers into the firewall address sets while the goal lasts. Add a §16 test that a lookup of an unknown name gets no answer.

finding: major · §3.5 L247, §14 row M3 · "Only your own linked machines" opens every port on them, including the agent machine itself: its T3, SSH, databases and dev services listening on the tailnet. A hostile build script on the computer then gets a lateral route to the server. The floor protects only the computer's own services. Fix: allow per linked machine only the services named in config (for example the git server and package cache ports), default none. Name the residual in §14.

finding: major · §4.2 L325-326, §12.3 L1217, §15 (missing row) · A per-goal network opening and the one-address exception are firewall state that outlives its grant. §12.3 ends "this task" grants on start, and pause ends them too, but nothing says the allow rules are removed. After a crash or power cut the agent account keeps the opened network (fail open). Fix: open the rules with a ledger line written first. Remove them on start, on pause and on the kill switch, before the agent account's network comes up. Or use WFP dynamic-session filters that vanish with the guard's session. Add a §15 row and a §16 power-cut test.

finding: major · §4.4 L530 layer (3) and L532-537, §4.3 rule 8 L474, §15 (missing rows) · The agent-caused process set and the per-grant persistence baseline are runtime state with no §15 row. A guard restart or power cut in the middle of a grant loses (a) the marks, so the kill switch can no longer end "every agent-caused process running as you", and (b) the start baseline, so the end-of-grant diff never runs. Fix: journal the marks (pid plus start time, so reused pids are not hit) and the baselines in the guard's ledger. On start, re-attach to the marked processes that are still alive. For an unfinished grant, diff against its baseline (or, if the baseline is missing, show every persistence entry newer than the grant start). Add §15 rows and §16 tests.

finding: major · §4.3 rule 6 L439 together with rule 3 L395 and §4.4 L530 · Quarantined auto-run paths and held protected-area changes go "into the undo store". That store keeps items for 24 h and has a size cap that refuses what does not fit. Nothing says (a) whether a held or quarantined item that is still waiting for Keep or Remove expires after 24 h, which silently loses your choice or the file, or (b) what happens when a quarantine move or revert does not fit the cap. Refusing is not possible for a reaction, and leaving the file in place fails open. Fix: keep held and quarantined items outside the 24 h and cap rules, with their own reserved space. On overflow, block the agent's writes and runs (fail closed) and alert in the tray. Add §15 rows ("held item older than 24 h", "undo store full during quarantine").

finding: minor · §4.3 rule 3 L390-391 (Linux) · Bind mounts made in a mount namespace that the agent's command owns as root of its own user namespace can be unmounted by that command. Fix: say that the namespace is set up so the command runs as a non-root mapped uid with no capabilities (the mounts are then locked), or that a privileged helper sets it up. Same note for the network namespace and pasta in §3.5 L266.

finding: minor · §4.4 L530 layer (1) · The lock on files and folders that sit in folders you own does not hold. Your account (and code running as you) can replace ~/.bashrc by a rename, or rename the Startup folder, because the parent folder grants delete-child. Windows HKCU\...\Explorer\User Shell Folders\Startup (and on Linux XDG_CONFIG_HOME through environment.d or the shell profile) can point autostart to an unwatched folder. Fix: add the redirect keys and files to the persistence list, and say that layer (2), not (1), catches the rename-replace.

finding: minor · §3.5 L269 · Windows Defender Firewall block rules always beat allow rules, so "block all for the agent group except the linked machines, plus a one-address exception" cannot be written as ordinary firewall rules. Fix: say it is done with raw WFP filters in the fork's own sublayer with weights.

finding: minor · §10 L1081-1085 · A link bound to a tailnet node identity that is later used over the LAN route (routes list tailnet and LAN, L1040) fails the node check. It is then refused and marked suspect, a false alarm. Fix: always bind with the key pair (DPoP) and use the tailnet identity only as an extra check when the call arrives over the tailnet. Also note that asking local tailscaled whois from the host account (no interactive session) is "to verify" on Windows.

finding: minor · §7.4 L887 · overlayfs gives undefined behaviour when the lower (real) folders change while the overlay is mounted, and you keep editing them. CAS at apply time catches the final conflict, but the run may already have read torn data. Fix: name it in §14, or snapshot-mount the lower layer where the filesystem allows.

finding: minor · §7.4 L908-910 · Windows: if all alternatives share one AppContainer SID (the read ACEs on the real folders are persistent, per the uninstall list), each alternative can write the other alternatives' private layers. Fix: one fixed package or capability SID for the read ACEs on the real folders, plus a per-alternative AppContainer SID that only its own layer grants.

finding: minor · §7.2 L857 / §15 L1314 · The journal keeps entries 24 h. A retry with the same key after that runs the batch again. A same-key retry with a different step list returns the old result without any error. Fix: keep key tombstones (key plus step-list hash) longer than any retry window, refuse a reused key after that, and refuse a key whose step list does not match. Add both to the §15 row.

finding: minor · §7.4 L929 with §12.3 L1215 · pick: "all" layers wait up to 10 minutes for apply, but a restart deletes every journalled layer. A later apply has no defined result. Fix: apply on a missing or expired layer returns layer_gone (a §15 row).

finding: minor · §4.2 L312 vs L332-333 · Not clear whether a 2-minute timeout counts as a Deny. If it does, one missed dialog blocks the item for the whole task and for 10 minutes. Fix: say that a timeout is not a Deny for the wear-down rules.

finding: minor · §6.7 L763-767 vs §4.2 L339-340 and §17 open points 2-3 · Pressing Continue after a take-over is a widening, but it comes from the helper. Code running as you could forge it unless it is classed as an item that needs OS consent while an agent-caused process runs. §6.7 also labels as owner decisions the points that §17 still lists as open (2: does pause stop commands; 3: does the agent's ask resume). Fix: class Continue under the §4.2 consent rule, and mark §6.7's scope as provisional until the owner answers. Also state the lifetime of a take-over pause tied to a task that has ended or is gone after a restart (§15 L1317 says only "kept").

finding: minor · §4.3 rule 8 L475-476 · "the kill switch relies only on the OS" contradicts the same rule: the guard ends the processes, and agent-caused marking is the guard's own tracking. Fix: "relies only on the guard and the OS, not on T3 or the helper".

finding: minor · §12.2 L1193-1194 vs §12.3 L1216 · Wind-down ends "at the next sign-in" in one place and is "cleared" at the guard's start in the other (§15 L1325 says sign-in). Fix: pick one.

finding: minor · §4.3 rule 6 L432, §12.2 L1189, §7.4 L941 · The 30-minute reuse rule (and the 48-per-day count under the VSS limit of 64) conflicts with the wind-down snapshot of "folders changed since the last one" and with "snapshot first" before a winner is applied. Fix: say whether these force a fresh snapshot (then count them in the limit, which also counts System Restore copies) or reuse the last one. On macOS the OS deletes local snapshots under disk pressure, so neither the size cap nor the 1-hour floor can be guaranteed there; say so.

finding: minor · In short L41 · "undo the agent's file changes of the last 24 hours" over-promises against §4.3 rule 6 (a 1-hour floor under heavy writes, and no undo of command changes on some Linux filesystems). Fix: "of up to the last 24 hours (§4.3 rule 6)".

finding: minor · §9 L1017 · "Nine tools (plus the file group)": the table has eight besides the two file groups (status, run, undo, observe, do, recipe, ask, named). Fix: "Eight".

finding: minor · §2 L108-109 · "no change to upstream files except one registration line per toolkit and setting, plus the small link changes of §10" leaves out the forwarded-tool allowlist on the computer (§3.2), turning off T3's terminal and provider features, t3 service install --account, and the linking-side expiry update (first finding). Fix: list them.

finding: minor · §3.3 L203-204 · On Linux the install verify checks only NOPASSWD. Membership of docker, lxd or libvirt, and polkit auth_admin_keep rules, also give root with no prompt. Fix: warn about these too (and in §14 "Elevation without a prompt").

finding: minor · §17 L1388-1402 · Costs leave out Apple's Endpoint Security entitlement (needs Apple's approval and Full Disk Access) and macOS sandbox-exec being deprecated, both of which macOS §4.3/§4.4/§7.4 depend on. "One UAC prompt" is Windows-only. Fix: add both to the costs.

finding: minor · §16 · New rules with no test: clearing the sudo timestamps (§4.4), the 10 s agent-caused window and descendant marking (§4.4), unlocking .git/config for one repo through the ask (§4.3 rule 3), the reach-check walk (§3.3), the non-blocking install warnings (§3.3), revoke winning over an extension in flight (§10), and the DNS refusal (above). Fix: add a line each.

verdict: changes needed. 0 blockers, 10 majors, 19 minors. All of them are doc fixes; none of them changes an owner decision.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security-design r3 · reviewer: Opus 5.5 (t3lens-26-sec-r3) · read at: 9fde35b
covered: the diff 57f3917..9fde35b and all of docs/fork/host-tools.md at 9fde35b (In short, §1-§17, the new §4.4, §14.1, the §15 failure table, §16). I took the 8 owner decisions of 2026-11-13 as decided: acting in the UI is acting as you, protected areas with a per-action OK and an activity log, the take-over pause with no timer, 365-day links with one-click extend, the default folders, OS consent kept, new projects off, .git/config read-only, and no direct internet for the agent group. Where a decision applies, I judged only whether the doc's controls close the hole and whether the stated residual is true.
checks: I re-ran each r2 attack (N1-N10, n1-n8) against the new text. I attacked the new parts: protected areas and who enforces them; the persistence lock, watch and diff; the agent-caused mark; the forwarded-tool allowlist; the auto-run rename denies and quarantine; new repositories; frozen copies; the guard-checked consent on each OS; no direct internet and the network floor; durable narrowing and replay; the take-over pause and Continue; 365-day bound links and extension; runner isolation. For each named OS mechanism I checked whether it can do what the doc says. For the store, ledgers, journals, locks, the undo store and links I asked, word for word: "What happens when this runs again, stops half-way, or meets existing/old/empty state (rerun, half-applied, legacy data, empty history)?" Then I compared the answers with §15.
summary: r2 status: 16 closed (N2, N4-N10, n1-n8). N1 is closed as the owner decided, but its new control is overstated (P1, P4). N3 is partly closed (P6, P7). New findings: 1 blocker, 6 majors, 11 minors. Verdict: not ready yet. The owner decisions are written in faithfully and honestly. The blocker and the majors are about controls that the named OS mechanisms do not deliver as the doc says.

r2 status:

  • N1 closed as decided (In short lines 28-32, §4.4 lines 494-545, §14.1 line 1272, §16 line 1352). It is said openly, with protected areas, the activity log and a test. Not held: "OS-enforced" protected areas where the OS leaves spots writable (P1), and reverts that come after the effect (P4). The residual leaves out processes that escaped the mark (p1).
  • N2 closed (§3.2 lines 182-188, §3.3 line 197, §15 line 1307, §16 line 1345). The exact list is minor p3.
  • N3 partly closed. (a) is closed (§4.3 rule 3 lines 386-391). (b) is closed except for new clones (P6) and a full undo store (P7). (c) is closed (§3.3 lines 210-212, §4.3 lines 398-402). (d) is closed as a residual (lines 403-407, §14.1 line 1280).
  • N4 closed (§4.2 lines 342-357, §4.4 layer 3 line 530, §14.1 B3c line 1273). The macOS and X11 details are p4. Widening actions that are not classed are P5.
  • N5 closed (§3.5 lines 258-263, 273-274, §15 line 1308). Other bypasses of the floor are P3.
  • N6 closed (§3 lines 146-148, §7.4 lines 907 and 937-943, §16 lines 1360-1361).
  • N7 closed (§6.4 lines 672-673, §14 line 1253).
  • N8 closed (§4.3 rule 8 lines 476-478, §12.3 lines 1203-1209, §15 lines 1309 and 1324, §16 line 1379).
  • N9 closed (§4.3 rule 6 lines 450-456, §14 line 1258).
  • N10 closed (§11 lines 1148-1161, §3.3 line 205). Who may trigger a job is p11.
  • n1 closed (lines 436, 202). n2 closed (lines 771-774). n3 closed (lines 718-719). n4 closed (lines 424-425). n5 closed (lines 751-753). n6 closed (lines 414, 1036). n7 closed (lines 438-441). n8 closed (lines 909-910, 1331).

finding: BLOCKER P1. Protected areas are not OS-enforced where the OS leaves them writable to standard users (§4.4 list line 513, "Who enforces it" lines 528-529, In short lines 30-31). The doc gives two reasons. Commands are stopped "by the OS: standard accounts ...". Acting as you is stopped because "writing there needs elevation". Both are false for common spots. C:\ProgramData grants BUILTIN\Users create-file and create-folder rights, and CREATOR OWNER gets full control of what is made. Many installers leave Program Files subfolders writable by Users: game launchers and updaters do this often, and some have a SYSTEM service that runs from there. Some HKLM keys grant Users write. On macOS the admin group can write /Applications with no prompt, and the user owns Homebrew's prefix. On Linux, /opt and /usr/local subtrees are often owned by the user. The agent account is in BUILTIN\Users, so this needs no UI at all. The §3.3 reach check walks the drives, but it lists only what can be reached, lets you "accept" it, and does not cover your own account. Attack: (1) An injected goal runs host_run as the agent account and finds a Users-writable launcher folder under Program Files (x86) or a writable ProgramData app folder. (2) It writes a DLL that the app or its SYSTEM service loads, or it replaces the updater exe. (3) The next start runs it as you, or the service runs it as SYSTEM. No ask, no OS consent, no hold: layer 2 of §4.4 watches only persistence points, not system areas. The same works through the UI as you, and on macOS by replacing a bundle in /Applications. Required: (a) The reach check walks every protected area as the agent account and as your standard (filtered) token, and lists every writable file, folder and registry key. (b) For the agent group, the install adds Deny write, delete and create-child entries (link-safe, §3.4) on every writable spot it finds, with no "accept". The entries are re-checked daily, and the verify fails if one is missing. (c) For your account, those spots join the protected-area watch (§4.4 layer 2), and the lock while acting covers them where they take ACLs, so a change is held. (d) The §4.4 table says "the OS, except spots it leaves writable to users, which (a)-(c) cover". (e) §16 adds tests: an agent-account write to a Users-writable ProgramData folder and to a writable Program Files folder is denied, and a write as you there in an acting batch is held.

finding: MAJOR P2. Frozen copies trust a git object store the agent can write (§4.3 rule 4 lines 412-415, §9.1 lines 1027-1036). The helper "makes a git worktree at a commit", and "the same hash later needs no new ask". But .git/objects (with objects/info/alternates and packs) and .git/worktrees stay writable to the agent: rule 3 denies only config, hooks and info. Git does not re-hash objects on normal reads, so a commit id does not prove what is checked out. Attack: (1) You approve a named deploy command at commit C, and the diff looks fine. (2) The agent overwrites the loose object for a blob in C, or adds an alternates entry or a pack, so the same name now holds different content. (3) The next run makes a fresh worktree at C, needs no ask, and runs agent code as you with your deploy key. For an admin command, the same swap between the ask and the checkout runs agent code elevated, which is beyond what decision A accepted. Required: (a) The frozen copy is built only from verified objects. The helper clones into the protected folder with object checking (for example git -c transfer.fsckObjects=true clone --no-local, with no alternates), or it hashes every object it reads. (b) Git runs there with the repo's config, hooks, attribute filters and alternates ignored. (c) "Same hash, no new ask" reuses the kept, verified copy, or verifies again on each run. (d) §16 adds a test: a swapped loose object under an approved commit is refused.

finding: MAJOR P3. The network floor can be bypassed through system services and VMs (§3.5 lines 247-248 and 265-270, §14 line 1245). (1) DNS: "Name lookups go only to the tailnet's resolver, so DNS cannot carry data out" is not true. Windows resolves names through the DNS Client service, not under the agent's SID, so WFP rules by SID do not see it. macOS resolves through mDNSResponder. On Linux the namespace blocks 127.0.0.53, but resolved's varlink socket and nscd's socket are filesystem sockets, so the namespace can still reach them. Tailscale's 100.100.100.100 also forwards names outside the tailnet to public resolvers. A lookup of .attacker.example carries data out. (2) By default pasta maps the namespace's gateway address to the host's loopback, so "its own loopback" holds only when that mapping is off. (3) WSL2: a standard user can wsl --import a distro. Its traffic leaves through the Hyper-V NAT, not under the agent's SID, so no WFP rule applies, and on WSL-first machines the feature is on. (4) BITS jobs are carried out by a service. Verify that SID rules see them. Required: (a) The agent group gets a resolver run by the guard that answers only names of linked machines and names a per-goal grant opened. The system resolver's sockets are denied to the agent group (on Linux in the mount namespace). Where that cannot be done (the Windows and macOS DNS services), §14 states the residual and the "DNS cannot carry data out" claim is removed. (b) pasta runs with host-loopback mapping off and forwards DNS only to the guard's resolver, and the nftables skuid floor also applies on the host side. (c) The agent and runner groups may not run WSL, Hyper-V or other VM tools (deny execute on wsl.exe, or an App Control rule), checked by the verify. (d) The verify probes egress through DNS (a name outside the tailnet), WSL and BITS.

finding: MAJOR P4. Held persistence is reverted after it has already run, and what it started is not ended (§4.4 line 530 layer 2, lines 532-537, §15 line 1304). A standard user can register a scheduled task for itself without admin rights. The Task Scheduler service starts the task's process, so that process is not marked agent-caused, and the registration event names the user, not the process. Attack: (1) In an acting batch, a file run as you calls schtasks with a registration trigger, or registers a task and then runs schtasks /run. (2) The task process starts at once. (3) Within a second the guard reverts the task and offers Remove, and the tray looks clean. (4) The process keeps running unmarked and survives the kill switch. It waits past the 2-minute lock window, then adds persistence somewhere that is shown but not held. The same works with launchctl bootstrap of a new LaunchAgent and systemctl --user start of a new unit. Separately, the file part of the lock (layer 1) can be bypassed. You own your profile folder and home, so the locked file can be deleted and recreated through the parent folder's delete-child right, not only by renaming the parent. Required: (a) When the guard holds or reverts a task, unit, LaunchAgent, service or WMI entry, it ends every process that entry started (Task Scheduler process-creation events carry the PID; the unit's cgroup; the launchd job), and it marks such processes agent-caused from their start. (b) Processes started as you by the Task Scheduler, WMI, COM/DCOM activation, systemd --user or launchd during an acting batch or its lock window are marked agent-caused. (c) §4.4 says that layer 1 holds only against edits in place, and that the watch catches a delete-and-recreate. (d) The §14.1 N1 residual changes as in p1.

finding: MAJOR P5. Code running as you, or an agent, can press tray and T3 actions that widen (§4.2 lines 335-357, §6.7 lines 763-767, §12.1 lines 1174-1180, §4.3 rule 3 lines 392-402). The guard checks OS consent only for full rights, elevation and protected areas, and for other widening only "while an agent-caused process runs as you", which is best effort (§4.4 line 535-537). Some actions are not classed at all: Resume after Pause, Continue after a take-over, Keep on a quarantined auto-run path, "make it mine", accepting a reach-check find, and turning host tools back on after a failed verify. The tray runs as you at the same integrity, so any process running as you can invoke its menu through UI Automation or send it input. "Continue in T3" can be sent by anything that can drive T3's UI, such as a browser tab on this computer that the engine does not recognise as a host-tools screen. Attack: (1) A process that escaped the mark (P4) waits. (2) You take over, and acting stays paused. (3) The process invokes the tray's Continue, or Keep on a quarantined tasks.json. (4) The agent goes on acting on your screen, or the IDE task runs as you. Required: (a) The doc classes every tray and T3 action as narrowing or widening. (b) Widening actions are written only through the guard. They need OS consent that the guard checks whenever an acting batch, a full-rights grant or a lock window ran in the last N minutes (not only while a marked process is alive), and always for Keep of a quarantined auto-run path and for "make it mine". (c) The computer accepts "Continue in T3" only from a person's T3 UI session, never from an MCP or agent session. (d) The engine refuses any window that shows a T3 page with host-tools controls, including Continue banners.

finding: MAJOR P6. The new-repository rule exempts more than .git (§4.3 rule 3 lines 398-402). The exemption is worded for the whole repository: "New repositories (git init, git clone by the agent) are agent work and are not quarantined". But git's safe.directory check protects only .git. A clone of a hostile repository brings .vscode/tasks.json with runOn folderOpen, .idea run configurations, .envrc and plug-in files, all written by the agent account. Attack: (1) An injected goal asks the agent to clone a repo into your code folder and look at it. (2) You open the folder in VS Code (a parent code folder is often trusted) or in IntelliJ. (3) The task runs as you with no click. Required: (a) Only .git is exempt (it is covered by safe.directory). (b) Every other auto-run path inside a new clone is quarantined like any other new auto-run path, and "make it mine" shows them in the same ask. (c) §16 adds a test: a clone with a folderOpen task.

finding: MAJOR P7 (state question). The undo store also holds quarantined and held items, but its cap and its 24-hour expiry fail open there (§4.3 rule 6 lines 437-441, rule 3 lines 392-397, §4.4 line 530, §15 lines 1304 and 1306). Pre-images are refused when the store is full. A quarantine or a held revert, though, follows something that has already happened. The doc does not say what happens when the undo store is full, or when a held item is older than 24 hours. Attack: (1) The agent fills the undo store close to its cap with pre-images of large writes, which is allowed within its quota. (2) It creates a new .vscode/tasks.json or a Run key. (3) The guard cannot move it into the store, so it either leaves the file in place (fail open) or deletes it with no record. Or: a held change waits over a weekend, the 24-hour rule drops it, and Keep or Remove then silently has nothing to act on. Required: (a) Quarantine and held reverts never fail open. They use a reserved area outside the pre-image cap, or the guard removes the path and logs its content hash and first bytes, and agent writes are refused until there is room again. (b) The 24-hour rule never expires a held or quarantined item that is still unanswered. (c) Add both cases to §15: "undo store full during a quarantine or hold" and "held item older than 24 h".

finding: minor p1. Honest residuals (§14.1 N1 line 1272, §14 lines 1239 and 1255). "Until the kill switch" is not true for a process that escaped the agent-caused mark (§4.4 lines 535-537). Such a process survives the kill switch, and survives a restart if it set up persistence. The Elevation row should also say that at the default UAC level with an admin account, auto-elevation defeats the guard-checked consent, the protected store and every protected area, not only system writes.
finding: minor p2. A lost or stolen linked device (§10 lines 1068-1075, §14 line 1249). A device-bound link can extend itself from its own machine, so expiry never ends a lost device's access; only revoke does. Say so in the residual, and show "extended by" for each link in the tray.
finding: minor p3. The allowlist (§3.2 lines 185-188). "t3_environment_read and the like" should be an exact list. Any tool upstream adds later is denied by default, and the list is enforced on every MCP session of that install (peer links and the §2 outside-client fallback), not only on the forwarding path.
finding: minor p4. The consent mechanism (§4.2 lines 349-352). A root LaunchDaemon cannot show SecurityAgent itself. Use the pattern where the helper obtains the right and the guard verifies it: AuthorizationExternalForm, a right with shared=false and timeout 0, and authenticate-session-owner. On Linux with X11, code running as you can read the password you type into the polkit agent. State that residual, or require Wayland for consent items.
finding: minor p5. macOS prerequisites (§4.3 rule 3 line 389, §4.4 layer 2, §17). Endpoint Security needs Apple's ES client entitlement, a system extension and Full Disk Access. List them in §17, with the fallback when they are missing.
finding: minor p6. Overlapping lock windows (§15 line 1305). If two batches' windows cover one object, ending the first must not unlock it while the second still runs. Keep a count or a lease per object in the ledger.
finding: minor p7. A half-applied winner after a crash (§15 line 1321, §7.4 lines 937-943). If the re-apply meets a file you changed in the meantime, the layer stays half applied. Say that it rolls the applied files back from the pre-burst snapshot or the pre-images, or that it reports a half-applied conflict with undo.
finding: minor p8. Lowering peerLinkSessionDays (§10 lines 1064-1067). Say whether existing links are cut to the new lifetime. They should be.
finding: minor p9. Secrets in readable system areas (§4.1 line 286, §4.3 rule 2 lines 372-374). The deny pass covers only the reach map, but host_run can read app tokens that every user can read in ProgramData, and /etc configs. Extend the pattern deny pass to the readable parts the reach check finds, or name this in the §14 residual.
finding: minor p10. AppContainer read entries (§7.4 lines 908-910). Use a fork-owned capability SID or the per-run AppContainer SID, never ALL APPLICATION PACKAGES, which would open your folders to every packaged app.
finding: minor p11. Runner triggers (§11 lines 1160-1161). The dispatcher on the PC checks each job's event and actor itself before it runs the job. Routing on the server is not enough.

…s 1-7

- User-writable system spots get deny entries for the agent group and the
  guard's watch for the owner (owner answer 7, lens P1).
- Engine worker and the agent's own loopback RDP session for rung 3; never
  depends on the owner's sign-in (spike 2026-11-13).
- Network: only named services on linked machines, guard resolver, openings
  ledger; take-over pause covers the whole computer, Continue needs consent.
- Undo store sized to not fill (owner answer 6); "Run" defined (answer 1);
  UAC Always notify by default (answer 4); driver measure-first (answer 5).
- Held entries' processes ended, service-started processes marked, marks and
  baselines journalled, lock leases, key tombstones, layer_gone, link expiry
  sync on the linking side, DPoP always; §15 rows and §16 tests for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: correctness · reviewer: Opus 5.5 (t3lens-26-cor)
summary: not ready yet. 0 blockers, 8 majors, 12 minors. Reviewed docs/fork/host-tools.md at 5b9a9fd; upstream refs checked at 8cd8a3c (all cited lines still match). Most findings from the last round are closed (protected areas, engine worker, DNS, named services, network openings, marks, reserved area and most minors). What is left: one-click link extension cannot work against upstream's signed token, the Windows rung-3 session route, the daily password rotation vs the stored task password, and a few rules that over-trigger (held system spots, forced snapshots) or fail after a restart.

finding: [major] A. Link extension, "lower the lifetime" and the usableLink status call cannot work as written (L1237-1255, L1525). Upstream session tokens are signed and carry exp; SessionStore.verify (SessionStore.ts ~L752-848 at 8cd8a3c) refuses on claims.exp before it reads the row, and never checks the row's expiresAt for signed tokens. So a link extended in the tray is still refused when the old exp passes, the status call with an expired token is refused too, and lowering the lifetime does not cut existing links. Fix: for peer-link sessions let verify use the row's expiry (a fork change to SessionStore.ts, add it to the §2 list), or have the extension issue a new token to the linking side. Add §16 tests: extend then call after the old exp; lower the lifetime then call.
finding: [major] B. Rung 3 on Windows client editions (L171, L823, open point 2 at L1661-1664). Windows Pro/Home allow one interactive session: a second user's RDP session disconnects your console session. RDP to loopback is blocked on client SKUs unless termsrv is patched, which breaks the licence. A disconnected RDP session cannot capture the screen or take SendInput, and the doc does not say who holds the RDP client side. Fix: name the real options for open point 2 (Server SKU, a VM, or a hidden desktop in a separate window station) and say which one the owner is choosing between.
finding: [major] C. Daily password rotation breaks the broker task (L145-149, L173). The broker runs from a "whether signed in or not" Task Scheduler task, which stores the account password. After the guard rotates it, the task fails at its next start. A crash between setting the new password and updating the task loses the password. No §15 row. Fix: S4U logon for the task (no stored password), or update the task's credential in the same ledgered step, with a §15 row and a §16 test (rotate, then reboot).
finding: [major] D. Held changes on your own system spots revert your own work (L664-665, L563-568). "Any change during the window is held" on user-writable system spots (ProgramData, /Applications, Homebrew) also catches your own app and launcher updates and SYSTEM-run writes. When the 1 GB reserved area overflows, the guard removes the path (L566-568), which can delete data that is yours. The recursive deny lock on large trees is slow and itself a write storm. Fix: for spots that are not persistence points, hold only changes by agent-caused writers (marked processes, the acting batch); on overflow never delete content that may be yours, refuse new agent work instead; lock only the top object.
finding: [major] E. Forced fresh snapshots use up the cap (L532-536, L571). Every winner apply after any agent write and every wind-down takes a fresh snapshot; with the 48/day cap and the VSS 64 limit, a busy day hits the cap and then every agent write is refused. The winner apply already journals pre-images (L1108-1111), so it does not need a forced snapshot. Fix: drop the forced snapshot for winner apply (rely on the journal), and say what happens at the cap (log and continue on journal undo, not refuse all).
finding: [major] F. Named-host pins do not match what programs connect to on Windows and macOS (L290-301). The guard resolves the name itself and pins its answers, but programs resolve through the DNS Client service or mDNSResponder, and CDN hosts give different answers to different queries. The program then connects to an address that is not pinned and the goal flakes. Fix: a guard-run CONNECT/SNI proxy for named hosts, or feed the pins from the system resolver's answers (ETW DNS client events / mDNSResponder), with a §16 test against a CDN host.
finding: [major] G. After an unattended restart host tools stay off until someone signs in (L465, L1401-1403). The full deny pass and the install verify run in the helper, which lives in your session, so on a reboot with nobody signed in they never run. This contradicts "every part recovers" (L45) and the runners section that expects unattended work. Fix: the guard runs the deny pass and the verify at start; the helper only shows the result.
finding: [major] H. Carry-overs from the last round are only partly closed. (1) §15 "snapshot" row (L1507) does not say Linux without a snapshot-capable volume runs with journal-only undo, as §4.3 and §14 now say. (2) §15 "secret deny pass" row (L1493) still says only file tools stay off, but L186-187 and L466-468 now block every agent-account process; and §16 has no test for watcher overflow followed by host_run cat .env. Fix: update both rows, add the test.
finding: [minor] Engine worker channel (L738-739): an ACL "only the engine and the broker" cannot keep out agent commands that run as the same account; they can connect to the worker or inject into it and skip its policy checks and logging. Say what stops that (separate account or a token check per call), and add a §16 test.
finding: [minor] §14 N7 row (L1443) "no RDP session for the agent account" is out of date: rung 3 now opens one.
finding: [minor] Network floor filters: WFP filters must be persistent (or boot-time) and Linux nft rules must load before the broker starts; say so, or a crash or reboot leaves a gap.
finding: [minor] The guard's own resolver is behind the loopback floor (pasta on Linux, the WFP loopback filter on Windows); it needs a named exception.
finding: [minor] Start-up removes every ledger opening; "this project" and "always" exceptions must then be re-applied from the store. Say that step and its order in L1400-1407.
finding: [minor] Descendants of marked processes that start while the guard is down are not marked. Say how marking catches up (or that agent processes are killed when the guard stops).
finding: [minor] Tombstones (L1018-1021): the log must carry the key and the step-list hash so tombstones can be rebuilt after store loss; and "refuse every unknown key" would block every new batch. Reword to "refuse a key whose tombstone exists without an entry".
finding: [minor] VSS deletes old shadow copies itself when the diff area fills from any writer, so the undo window is not guaranteed on Windows either (L576-578). State it like the macOS case.
finding: [minor] AppContainer alternatives: per-alternative profiles are not cleaned after a crash (add to start-up); AppContainer runs cannot read the agent's own toolchains unless granted; "no capabilities" conflicts with the capability SID named at L1069-1074.
finding: [minor] Wind-down clearing: L1382-1383 and L1522 say it is cleared at the next sign-in, L1407 clears it at start. Pick one.
finding: [minor] Linking: no §15 row for the linking side losing its device key; device binding could reuse upstream DPoP (proofKeyThumbprint / jkt in EnvironmentAuth.ts, dpop.ts) instead of a new scheme. Docker groups are missing from the §14 row L1445; §6.7 should mark Continue consent provisional while open point 1 is open; reach map "data drives" (L453) vs "root of each fixed drive" as a protected area (L639): say whether the root deny is for that folder only.
finding: [minor] §16 is missing tests for: key_expired, lowering the lifetime, the LAN route with no false alarm, reserved-area overflow, wind-down clearing in T3 and runners, macOS snapshot vanishing.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security-design r4 · reviewer: Opus 5.5 (t3lens-26-sec-r4)
summary: not ready yet · read at 5b9a9fd (docs/fork/host-tools.md, all 1679 lines, plus the diff from 9fde35b) · r3 status: P1, P2, P4, P5, P7 and p1-p11 closed, P6 closed except its test, P3 partly closed. New: 1 blocker, 11 majors, 7 minors. Most new problems are in the r5 additions: the agent's own Remote Desktop session, the "Run" gate on Windows, OS consent that is not tied to the item, and Windows services that carry network traffic for the agent. The owner decisions (§17 "Decided", owner answers 1-7) are taken as fixed. I judged only whether the named OS mechanism delivers what the doc says.

r3 status:

  • P1 closed (§3.3 L215-217 and L243-257, §4.4 L662 and L664, §15 L1499, §16 L1576-1577). Gaps left: minor n2 (spots that appear between daily walks, agent-owned objects).
  • P2 closed (§4.3 rule 4 L510-516). The verified clone with fixed config and empty hooksPath does what P2 asked.
  • P3 partly closed. (a) done on Linux (L297-298); on Windows and macOS it is a stated residual, but the residual is understated (major M9). (b) done (L316-317, L322-323). (c) is done with a mechanism that does not hold (major M10). (d) done for the listed probes (L221-222, L338-341), but SMB and WebDAV are missing (M9).
  • P4 closed (L665 ends the entry's processes, L669-670 service starts marked, L665 layer-1 wording, L683 residual). But the mark is easier to escape than the doc says (M7).
  • P5 closed (§4.2 L412-425, §6.7 L915-923). But the OS consent is not tied to the item it approves (M8).
  • P6 closed in the text (L490-496). The §16 test P6(c) asked for, a clone with a folderOpen task, is missing (minor n5).
  • P7 closed (L563-568, §15 L1500). A held change to an existing file has no pre-image to revert to (M6).
  • p1 closed (L683, L687-689, L1464). p2 closed (L1250, L1277-1278). p3 closed (L197-201). p4 closed (L434-440). p5 closed (L1618-1620). p6 closed (L665, L1497). p7 closed (L1108-1111, L1518). p8 closed (L1240-1241). p9 closed (L461-464). p10 closed (L1069-1072). p11 closed (L1343-1346, L1604).

finding: BLOCKER · §3 L145-148, §3.1 L171, §6.4 rung 3 L823 · B1. Remote Desktop redirection hands the agent's session the client account's files, clipboard and keys. The doc says the guard "opens" the agent's RDP session on the computer itself. It does not say which account runs the RDP client, and it says nothing about RDP device redirection. RDP carries virtual channels from the client into the session: clipboard, drives (\tsclient\C), printers, smart cards, WebAuthn (security keys and Windows Hello), Plug and Play and USB devices, audio and camera. Redirected drives and the clipboard are served by the client process with the client's token. mstsc turns on clipboard, printer, smart card and WebAuthn redirection by default; drive redirection is one setting away. Attack: (1) The guard (SYSTEM) or a helper running as you starts the client. (2) The agent's code inside its own session reads \tsclient\C... with SYSTEM's or your rights, or reads your clipboard through rdpclip, or asks your security key for a WebAuthn assertion. (3) The reach map, the secret deny pass, the sensitive set and the clipboard markers are all bypassed, because the read is done by the client account, not the agent group. Fix: (a) turn off every redirection on the server side, by the machine policies "Do not allow clipboard / drive / COM / LPT / printer / smart card / Plug and Play / audio and video capture / WebAuthn redirection" (fDisableClip, fDisableCdm, fDisableCcm, fDisableLPT, fDisableCpm, fEnableSmartCard=0, fDisablePNPRedir, fDisableAudioCapture, fDisableCameraRedir, fDisableWebAuthn; names to verify per build), and check each in the install verify; (b) run the RDP client as a dedicated low-rights account (or as the agent account), never as SYSTEM or as you; (c) say in §17 that on Windows client editions these policies are machine-wide, so they also apply when the owner uses Remote Desktop into this PC (an owner choice); (d) §16 test: from inside the agent's session, \tsclient, the clipboard and a smart card reader are not reachable.

finding: major · §3.1 L171, §6.4 L823, §17 L1627-1630 and L1661-1664 · M1. Windows client editions allow one interactive session at a time (to verify in the planned spike, but this is the documented licence and behaviour of Windows 10/11 Pro). When another user signs in by RDP while you are signed in, Windows tells the new user that you will be disconnected, and shows you a prompt that disconnects your session after about 30 seconds if you do not press Cancel. So the agent's session would lock you out of your own desktop, or be refused. While your session is disconnected, every on-screen rung is refused (§6.7 L903-905). Tools that patch termsrv.dll to allow more sessions break the licence and are not an option. Fix: the spike must test with you signed in and active, and also with you away. If it is confirmed, rung 3 on Windows Pro needs another base, for example a desktop in a non-interactive window station of the agent account in session 0 (no GPU; to verify for UI Automation and app compatibility), and §1, §3.1, §6.4 and owner question 2 change to match. Multi-session RDP stays for Server editions only.

finding: major · §3.1 L170 and L176, §3.3 L208-209 and L250-252 · M2. An interactive session changes what the agent account is. Before r5 the agent account never signed in interactively, so the doc denies only its Startup folders and Run keys and lets the guard delete its scheduled tasks after the fact. With its own RDP session: (1) its token gains INTERACTIVE, REMOTE INTERACTIVE LOGON and LOCAL, so ACLs that grant these SIDs open to it, and the reach check (run with a batch or service token) does not see them; (2) its own per-user logon autostarts now run at each session start, outside the broker, its job object and its gates (deny pass current, pause): HKCU Winlogon Shell, Environment\UserInitMprLogonScript, Policies\Explorer\Run, per-user COM registrations under HKCU\Software\Classes that its Explorer loads, Active Setup, tasks "at logon" and "run only when logged on"; (3) the agent account owns its own profile and HKCU, and an owner always has WRITE_DAC, so it can remove the Deny entries on its own Startup folder and Run keys (the same is true for its LaunchAgents folder on macOS). Fix: (a) run the reach check also with the token of the open RDP session (WTSQueryUserToken by the guard); (b) apply the whole §4.4 Windows persistence list to the agent accounts' own profiles, with the guard as owner of those keys and folders (or an OWNER RIGHTS ACE), so the agent cannot rewrite the DACL; (c) deny the agent group write on the Task Scheduler root folder (task folders have their own security descriptor; to verify) instead of deleting tasks after they ran; (d) open the session only while the broker gate is open, and log it off (WTSLogoffSession) on pause, on the kill switch and when the deny pass is stale.

finding: major (state) · §3 L145-148, §3.1 L170-171, §15 L1489-1528 · M3. The agent's own session adds stored state that no §15 row or uninstall step covers. Asked word for word: what happens when this runs again, stops half-way, or meets existing, old or empty state? (1) Order: if RDP is turned on before the drop rule exists and the install stops there, the RDP listener is open on the LAN and the tailnet for your own admin account. The rule must exist first, for TCP and UDP 3389, and the verify must probe both. (2) The RDP sign-in right is granted while the session is on and taken back otherwise; a crash in between leaves it granted. (3) Daily password rotation: the broker's "run whether signed in or not" task stores the password. Changing the password without updating the task (or a crash between the two) stops the broker at the next boot. Either use an S4U task (no stored password) or write the new password to a ledger first and update the task and the account in one recoverable step. (4) A guard crash leaves an orphaned agent session; disabling an account (kill switch) does not end its open sessions. (5) The uninstall row L1528 does not list: turning RDP off again, the RDP firewall rules, the user right, the redirection policies (B1), the WSL/VM/BITS denies, the App Control rules and the UAC level setup changed. Fix: one §15 row "agent session" (enable order, right, password, orphan, logoff on kill) and the missing uninstall items, each with a power-cut test.

finding: major · §1 L62, §6.1 L736-740, §6.5 L865-869 · M4. The engine worker is fully under agent control, and the doc trusts it. (1) "A channel only the engine and the broker can open": an ACL can name only accounts, and the broker, the worker and every host_run command are the same agent account. So any agent command can open that channel, take the worker's place, or attach to the worker as a debugger. (2) "Applies the same policy checks before it does": checks inside an agent-account process protect nothing. (3) What the worker sends back (trees, screenshots, browser pages) is parsed by the engine, which runs as you with all of the engine's power. (4) On Windows the broker cannot start a process in another session: that needs WTSQueryUserToken or a session id on the token, which need SeTcbPrivilege (SYSTEM). Fix: (a) the engine (or the guard) makes the channel as an unnamed pipe pair or an inherited handle, passed to the worker at start, with no named endpoint to squat; for named pipes the engine connects with SECURITY_SQOS_PRESENT and SECURITY_IDENTIFICATION, so the agent side cannot impersonate you; (b) every policy check is in the engine; the worker's replies are untrusted data, with size limits and a strict parser; (c) the guard starts the worker in the agent's session (it has SeTcb), and the doc says so.

finding: major · §4.4 "Run" L648-656, §14 L1450, §16 L1582-1583 · M5. The "Run" gate does not stop what it names, and the OS can do better without a driver. (1) Windows "ends it at its process-start event": ETW real-time events arrive in buffers (the flush timer is 1 second by default), so the process has run its code before the guard sees it. (2) A DLL the agent placed and that a running SYSTEM service or one of your apps loads causes no process start at all, so it is never ended. Scripts run through an interpreter (powershell -File, python x.py, bash x.sh) are a start of the interpreter, not of the placed file. (3) macOS AUTH_EXEC and Linux FAN_OPEN_EXEC_PERM also miss library loads and interpreter-run scripts. (4) fanotify allows pending permission events when the listener's fd closes, so blocking stops while the guard is down. Fix: (a) on every agent-placed file in a protected area (approved or kept), the guard puts a Deny FILE_EXECUTE entry for Everyone (SYSTEM included, link-safe, guard-owned) until the run is approved. Windows checks FILE_EXECUTE when it maps an image, so exe and DLL starts and loads are refused before any code runs, with no driver; (b) for scripts and on macOS and Linux, deny the open itself (Endpoint Security AUTH_OPEN, fanotify FAN_OPEN_PERM on those files) except for the guard; (c) host tools and acting are off while the guard is not answering permission events; (d) say what Keep means for a kept autostart entry (does every later logon run need an OK?); (e) update §14 L1450 and the §16 tests (a DLL load into a test service, a script through an interpreter).

finding: major (state) · §4.3 rule 6 L563-568, §4.4 L665, §15 L1496 · M6. A held change to an existing file or key has nothing to revert to. "Reverted at once into the reserved area" works for a new entry: remove it. For a change to an existing watched object (your shell profile, an existing Run value, a file in a user-writable Program Files folder), the old content is needed. On Windows and on Linux without blocking, the guard learns of the write after it happened, so it has no pre-image. The newest snapshot can be up to 30 minutes old or missing, and taking it back loses your own edits since then. The fallback at L566-567 ("the guard still removes the path") would delete your file. Asked: what happens when this meets existing state? Fix: (a) the guard keeps its own copy of every watched persistence object and every small watched system spot (registry values, profile files, plists, unit files), taken when the watch starts and refreshed after each change you make; the revert writes from that copy; (b) for large watched folders, the revert comes from the newest snapshot, and the tray names its time and the files it would roll back, with nothing applied without you; (c) never delete an existing file as a fallback; (d) add a §15 row "held change to an existing object" and a test.

finding: major · §4.4 L667-673 and L683, §14 L1429, §14.1 L1464 · M7. Escaping the agent-caused mark is easy at any time, not only after the lock window. (1) Parent spoofing: a marked process running as you opens Explorer and starts a child with PROC_THREAD_ATTRIBUTE_PARENT_PROCESS. ETW's ParentProcessID then names Explorer. Only the event header names the real creator. (2) COM into the running Explorer (ShellWindows / IShellDispatch2.ShellExecute): Explorer starts the child, and Explorer was not started in the window. (3) WMI Win32_Process.Create after the window. Each gives an unmarked process at once. The kill switch then does not end it, and the 30-minute consent rule stops counting once the last marked process ends. Fix: (a) count descendants by the real creator (the ETW event header's process id; on macOS the Endpoint Security parent audit token, not the "original ppid"); (b) mark starts by Task Scheduler, WMI, COM/DCOM activation and the Explorer shell-execute path while any marked process is alive, not only during the window; use the WMI-Activity event's ClientProcessId where it exists; (c) state in §4.4 and §14 that Explorer COM is a known way around the mark.

finding: major · §4.2 L404-445, §6.7 L915-923 · M8. The OS consent is not tied to the item it approves. On Windows, the UAC prompt names only the binary ("T3 confirm, verified publisher ..."). A process running as you (marked or escaped) can start the same signed confirm binary itself, through ShellExecute "runas", with the item of its choice. It can do this when you expect a prompt, for example right after you press Continue, or while an ask dialog is open. You approve the prompt, and the guard writes "Keep the quarantined tasks.json" or "make it mine". On macOS the caller of AuthorizationCopyRights chooses the prompt text (kAuthorizationEnvironmentPrompt), and one right serves every item. On Linux the polkit message is the same for every item. Fix: (a) the guard holds exactly one pending consent item, created from the person's click; a second request while one is pending cancels both and alerts; (b) Windows: the elevated confirm binary asks the guard for the pending item and shows its exact text in its own elevated window (UIPI keeps medium-integrity input out), and the person confirms there; the item never comes from the command line; (c) macOS: the guard defines a right per pending item with its own description and verifies that exact right; (d) Linux: pass the item text in the polkit details and check what the agent shows (to verify); (e) a §16 test: a confirm binary started by another process with a forged item is refused.

finding: major · §3.5 L294-301 and L332-341, §3.3 L221-222, §14 L1435 · M9. On Windows, SMB, WebDAV and other system services still carry the agent's network traffic, and the DNS residual is larger than stated. (1) A UNC path (\host\share) is opened by the SMB redirector in the System process. \host@SSL\x is opened by the WebClient service (LocalService), which any user can trigger-start. Neither connection runs as the agent account's SID, so the per-SID WFP floor does not see it (to verify, but well known: SMB traffic is attributed to System, PID 4). So the agent reaches a LAN NAS (inside the floor) and the internet over HTTPS (WebDAV PUT is a full upload channel). The print spooler (adding an IPP printer by URL) and the WinHTTP auto-proxy service (a PAC URL chosen by the caller) are similar (to verify). (2) DNS through the DNS Client service or mDNSResponder is not "a little data" (L301): DNS tunnelling is a working two-way channel, so on Windows and macOS "no direct internet" does not hold for a determined agent. Mechanisms to verify before accepting the residual: a WFP RPC-layer filter by caller token on the DNS Client's RPC interface (FWPM_LAYER_RPC_UM with a user-token condition); on macOS, a sandbox profile for agent commands that denies mach-lookup to the mDNSResponder services. (3) Linux: the system D-Bus socket is still visible in the agent's mount namespace; hide it too, or check that no service there fetches on request (PackageKit refresh, fwupd). Fix: deny the agent group UNC and WebDAV (to verify how: an AppContainer for agent commands, WebClient off, or an RPC filter); add probes for a UNC path to an on-link neighbour, a WebDAV URL and a PAC fetch to the verify (L221-222, L338-341); rewrite the §14 L1435 residual as "a two-way channel through the system resolver" unless (2) closes it.

finding: major · §3.3 L218-220, §3.5 L332-335 · M10. The WSL, VM and BITS denies use mechanisms that do not hold. (1) Deny execute on wsl.exe: the agent can copy wsl.exe (read is enough) and run the copy, or call WslLaunch in wslapi.dll, or call the WSL COM service directly. The same goes for hvc.exe and Windows Sandbox. (2) "An App Control rule": App Control for Business policies are machine-wide, not per group. AppLocker rules are per group, but they control files, not COM calls into a running service (BITS, WSL), and AppLocker enforcement on Pro is to verify. Fix: deny the agent and runner groups in the DCOM launch and access permissions of the service AppIDs (the WSL service, BITS, Windows Sandbox; to verify per build). Keep the file denies only as a second layer. Make the verify probe call the APIs (WslLaunch, the BITS COM interface), not only wsl.exe.

finding: major · §10 L1261-1267, §10 L1213-1215 · M11. DPoP does not protect the request body, and upstream allows plain HTTP LAN routes for paired tokens (docs/internals/remote.md L49-53). A DPoP proof signs the method, the URL, the token hash, a time and an id. It does not sign the body. Attack: (1) An attacker on the same LAN (ARP spoofing on a café or hotel network when the computer is a laptop, or a hostile device at home) sits on a plain HTTP route. (2) It drops a live host-tools request and sends its own body with the same token and the same fresh, unused proof. (3) The computer runs host_run or host_do with the attacker's steps. Restarts make it worse: if the jti cache is in memory, a captured proof can be replayed after a restart within the iat window. Fix: (a) host tools only over the tailnet or over HTTPS with a pinned certificate, never over a plain HTTP route; (b) or sign the body as well (RFC 9421 HTTP Message Signatures with Content-Digest) with the device key; (c) use DPoP server nonces, or a jti cache that survives a restart; (d) add a §15 row for the replay cache and a §16 test with a swapped body.

finding: minor · §14 L1443 · n1. The N7 row still says "no RDP session for the agent account", which r5 changed. Restate N7 for the agent's session: input there reaches only that session's own secure desktop, and its password is held by the guard.
finding: minor · §3.3 L257, §15 L1499 · n2. User-writable system spots that appear between two daily walks (an installer or an updater with an explicit DACL) are writable by the agent group until the next walk. Also, an object in a protected area that the agent account owns keeps WRITE_DAC and can drop its Deny entry. Fix: the guard's watch holds every agent-group write in a protected area (ETW file and registry events by SID), whether or not the spot is known, and takes ownership of agent-owned objects in protected areas.
finding: minor · §4.4 L664 and L681-691 · n3. A user-writable spot that a SYSTEM service or an elevated task loads from is a local privilege escalation for code that escaped the mark (shown, not held). That code becomes SYSTEM and can turn off the guard, the store and the kill switch. Say so next to the UAC residual, and offer "fix this folder's permissions" in the tray for spots the reach check sees used by a service image or a task.
finding: minor · §3.5 L290-293 and L327-331 · n4. (1) A goal opening for a named host pins addresses that are often shared CDN addresses, which opens every site on that CDN (and domain fronting). Name this in §14. (2) Say that the agent group's base block is a persistent, boot-time WFP filter and only the openings are dynamic, so a guard crash or a boot before the guard starts leaves the network closed.
finding: minor · §16 L1555-1556 · n5. P6(c) is not in §16: add "a cloned repository with a .vscode/tasks.json runOn folderOpen is quarantined and listed by make it mine".
finding: minor · §10 L1242-1255 · n6. The usableLink status call must be accepted with an expired token. Say that only that one call accepts an expired token, only with a valid device proof, that it returns only the expiry, and that it never extends. Say also that a linked machine cannot revive another device's link that has already expired (only the tray can).
finding: minor · §17 L1656-1659 · n7. "PIN, fingerprint or password" is the consent prompt for an administrator account. For a standard-user owner, UAC asks for an administrator's name and password for each Continue. Say so in owner question 1.

…ctness fixes

No Remote Desktop session for the agent (password set once, batch logon only,
the guard starts the broker and the engine worker); the T3 confirm replaces
the OS prompt for Continue and a renewed ask (real input only, bound to the
item, phone or other computer counts); later screen options in one section;
named services only on linked machines with a worked example. Closes the
security r4 blocker and majors (guard proxy for named hosts, DCOM denies,
service-path probes, deny execute on agent-placed files, guard copies for held
changes, DPoP body signing, persisted replay cache) and the correctness majors
(row expiry for link extension, no forced snapshots, unattended restart).
§15 recovery rows and §16 tests for every changed rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security r5 · reviewer: Opus 5.5 (t3lens-26-sec-r5)

summary: not ready yet. 0 blockers, 7 majors, 13 minors. Round 4 is mostly fixed (the RDP blocker is gone, and most majors are closed). The new majors are ways around the guard: the guard's own proxy skips the network floor, agent code can start outside the broker's job, stale or lost events fail open, and the confirm panel and passkey confirm can be driven or replayed. Doc at ddf5bdd.

round-4 findings:

  • B1 closed: no RDP session for the agent (L154, L182, L1673-1674).
  • M1 closed (L183, L1850-1852).
  • M2 partly closed: no interactive logon closes parts (1) and (2). Open: L188 still deletes agent scheduled tasks only after they exist, and the kept "log on as a batch job" right lets them run. See T1.
  • M3 closed: password row L1690, uninstall step L1733.
  • M4 closed (L884-891).
  • M5 mostly closed (L773-792). Open: the script read-deny goes by file extension (L781-782), so a renamed copy is readable; on Linux/macOS the deny is gone while the guard is down. See minor m3.
  • M6 closed (L676-686), but the "full reserved area" rule fails open. See T4.
  • M7 closed (L803-813).
  • M8 closed (L472-476, L502-506, L531-536). To verify: what the Linux polkit details dialog really shows.
  • M9 partly closed: probes fail closed (L236-240, L376-384); DNS is named as a residual (L326-327, L1630). L316-317 still claims more than that residual allows. See m12.
  • M10 closed (L230-235).
  • M11 partly closed (L1448-1459): the body digest must be a claim inside the signed proof, not only a header. See m2.
  • n1 closed (L1638). n2 partly closed: gaps between the daily walks and agent-owned objects remain (L278, L1701). n3 closed (L832-835). n4 closed (L365-368). n5 closed (L1808-1809). n6 closed (L1435-1438). n7 closed (L506).

finding: [major] P1. The guard's proxy skips the per-agent network floor, §3.5 (L313-318, L342-350). The CONNECT/SNI proxy opens its outbound connection as the guard, not as the agent SID, so the floor (no loopback, LAN, link-local, metadata, tailnet) does not apply to it. An allowed host name that resolves to 127.0.0.1, 10.x/192.168.x, 169.254.169.254 or a 100.x tailnet address (DNS rebinding, or a name the agent controls) gives the agent a TLS path to local and LAN services. Fix: the proxy checks every resolved address against the same floor before it connects (and after each re-resolve), or opens its sockets under an account the floor covers. Add a test with a name that resolves to loopback and to a tailnet address.

finding: [major] T1. Agent code can start outside the broker's job (L182, L188, L223, L572-574, L814-815). Routes: a scheduled task the agent registers for itself (S4U or a registration trigger; it runs because the agent keeps the batch logon right), WMI Win32_Process.Create, and out-of-process COM servers started for the agent. Such processes are not in the broker's job, so the job limits, the network floor tied to the job and kill-on-close do not apply. The guard removes them only after the fact, and nothing removes them while the guard is down. Fix: deny task registration, WMI process creation and DCOM launch/activation for the agent group up front (Task Scheduler and WMI namespace security, DCOM machine launch limits), and add a probe for each route that must fail.

finding: [major] T2. A stale deny pass stops only new starts (L571-574). Processes already running keep reading. An agent can flood the file watcher on purpose until it overflows, then a long-running agent process reads secrets created during the stale window. Fix: while the pass is stale, suspend (or end) every process of the agent group until the full pass is done, and test it with a forced watcher overflow.

finding: [major] T3. An unknown writer counts as "yours" (L682, L688-689, L1697). For holds and for refreshing the guard's copies this fails open. Lost ETW events (the agent can cause them by flooding) or a guard-down gap let agent persistence (Run keys, startup files, profile scripts) be accepted as your change and copied into the guard's good copy. Fix: an unknown writer counts as the agent while any agent-marked process is alive, or after any lost-events report, or after a guard-down gap. Never refresh a guard copy from an unknown writer; ask you instead.

finding: [major] T4. A full reserved area keeps the agent's change (L676-678). "Locks it as it is" keeps an agent edit of an existing Run value. The agent can fill the 1 GB reserved area on purpose with changes that get quarantined, and then its next change stays. Fix: when the area is full, restore from the guard copy and keep only the hash and size of the dropped content in the log. Give quarantine a per-writer quota so one writer cannot fill it.

finding: [major] T5. The confirm panel must act only on its own raw input (§4.2, L467-490). Windows: a process with a UIAccess token (an agent can get one by duplicating a UIAccess process token, Forshaw's technique) can send UIA Invoke or BM_CLICK past UIPI, and that makes no low-level input hook event. macOS: the panel runs as you as a LaunchAgent, so AXPress from any app you gave Accessibility (for example Terminal running agent code) presses the button. Fix: the panel decides only from its own raw keyboard/mouse input, exposes no accessibility actions on the confirm control, and ignores window messages that press it. List each injection API (SendInput, UIA, WM_ messages, AX, XTest) with a test that it fails.

finding: [major] T6. The passkey and other-computer confirms (L487-490, L530, L549-550): (a) The challenge is only the item hash. The same text (for example "Continue") gives the same hash, so a captured assertion can be replayed. Fix: add a one-time nonce and an expiry to the challenge and mark it used. (b) The doc does not say which origin and RP ID serve the passkey page and the item text. If the agent machine serves either, it can show false text. Fix: name the origin; it must be served by the guard, never the agent machine. (c) Passkey registration needs a trusted channel (the panel), not a page the agent can reach. (d) "That guard's device key §10" is the T3 host account's DPoP link key, not a key only the guard holds. Fix: a separate guard confirm key, enrolled through the panel; the agent machine never holds one.

finding: [minor] m1. Linux X11 panel text can be forged (L471, L480-481). On X11 any of your clients can draw over or change the panel window, so the 3-letter code proves you are present, not which text you read. Fix: on X11 confirm on the phone only (or on a separate VT), or state this as a residual in the risk table.

finding: [minor] m2. The body digest (L1448-1450) must be a claim inside the signed DPoP JWT (for example htd), not only the Content-Digest header, and the check must be new code: upstream's verifyDpopProof (apps/server/src/auth/dpop.ts) checks no extra claims. Also, upstream's replay markers are files in the secret store pruned after 1 day, not "T3's database"; say which one you mean.

finding: [minor] m3. The script read-deny goes by file extension (L781-782). A renamed copy or a script without an extension is readable. Fix: deny by folder or by owner, not by name; and say what protects these files on Linux/macOS while the guard is down.

finding: [minor] m4. The panel runs as SYSTEM in your session and draws agent-supplied text. Fix: run the panel with the fewest rights it needs; escape bidi and control characters; show host names in punycode; cap the length.

finding: [minor] m5. A second request cancels both (L475-476). The agent can use this to block every confirm (denial of service) and to train you to click quickly. Fix: rate-limit asks per agent, and show "too many requests" instead of cancelling forever.

finding: [minor] m6. The pop-up can catch a click meant for something else. Fix: Deny is the default; the panel does not steal focus and opens away from the pointer; buttons are active only after a short delay; renewed asks are rate-limited even after timeouts.

finding: [minor] m7. Cursor-jacking: the agent cannot call SetSystemCursor in your session, but code running as you can. The panel should use its own cursor and ignore the system one.

finding: [minor] m8. The broker pipe is created by the agent account and can be squatted (L185). Fix: the guard creates it, like the worker channel, or T3 checks the server PID and account of the pipe before it trusts it.

finding: [minor] m9. The agent password is in "the guard's store" (L157, L631), but that store is readable by you and by the host account. Fix: keep it in a store only the guard can read, and set "password never expires" so the account does not lock T3 out.

finding: [minor] m10. The agent's batch processes need their own window station and desktop, not the service one (L1109); otherwise they can reach other services' windows.

finding: [minor] m11. DCOM AppID security changes (L230-235, L1733): record the original descriptor in the ledger so uninstall restores it, and re-check after Windows updates, which can reset it.

finding: [minor] m12. "Over the tailnet" (L1451) must be decided by the interface the connection came in on, not by the source address. And L316-317 claims more than the DNS/fronting residual at L1630 allows; say it is limited the same way.

finding: [minor] m13. The proxy and the resolver run as SYSTEM and parse agent input. Run them under a low-rights service account. Also L68 and L882 disagree: is the worker in the broker's job or in a guard job? Pick one.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: correctness r2 · reviewer: Opus 5.5 (t3lens-26-cor-r2)

summary: not ready yet. 0 blockers, 6 majors, 11 minors. Reviewed docs/fork/host-tools.md at ddf5bdd; upstream refs checked at 8cd8a3c. Most round-1 findings are closed well. What is left is a few new holes in the new parts (proxy, secret pass, confirm panel, passkey replay, host task) and three stale lines that now contradict the owner's consent table at L513-517.

Upstream refs: all cited lines match at 8cd8a3c (PeerLinks.ts:308/379/536/593/645-648, EnvironmentAuth.ts:79/1347, McpOAuth.ts:97/510-511/546/564, PeerLinkRequests.ts:106-112). SessionStore.verify checks claims.exp (L811 there) before the row and a plk claim exists (L441), so the peer-link session change is feasible.

earlier findings:

  • A: closed (L1417-1421, L121-122, L1727, L1743-1744).
  • B: closed by owner decision (L154, L974, L1879).
  • C: closed (L157, L1690); see new H2 for the host task.
  • D: partly. L801 limits holds to agent-caused writers and L675-678 never deletes your content, but L800 still locks system spots (ProgramData, /Applications, Homebrew prefix) for your own account while acting, so your own app updates fail in that window, and it does not say "top object only". Fix: say the lock is a deny on the top object for the agent-launched process tree only, or that your own updater is let through.
  • E: closed (L640-644, L1709). Small left-over: L1281 still says "snapshot first" (see minors).
  • F: closed (L313-318), but see new P.
  • G: closed (L251-253, L1597, L1691, L1815).
  • H: closed (L1693, L1709).
  • minor worker channel: closed (L884-891).
  • minor N7: closed (L1638).
  • minor WFP persistent: closed (L365-368).
  • minor resolver exception: closed (L352-357).
  • minor re-apply order: closed (L373-375).
  • minor descendants: partly (L817-822); see minors.
  • minor tombstones: closed (L1187-1193).
  • minor VSS: closed (L650-652).
  • minor AppContainer: closed (L1241-1249).
  • minor wind-down: closed (L1574-1577, L1602, L1724).
  • minor linking: closed (L1444-1447, L1730, L257, L563-564).
  • minor §16 tests: closed (L1742-1744, L1799-1800, L1807, L1819-1820).

finding: [major] P. The proxy bypasses the floor. Where: L313-318 with L342. Why: the guard's CONNECT proxy connects as the guard, so the agent-SID / agent-group network floor does not apply to its outbound side. A named host the goal opened whose DNS answer is 127.0.0.1, an RFC 1918 or link-local address (169.254.169.254), one of this computer's own addresses or a LAN host gets connected anyway. That breaks L342 "the floor no answer removes". State question: a DNS answer that changes between the ask and the connect (rebinding) gets the same hole. Fix: the proxy resolves the name itself and applies the floor to every resolved address on every connect (refuse, log), and pins the address for the connection. §16 test: an opened name that resolves to 127.0.0.1, to 169.254.169.254 and to a LAN address is refused.

finding: [major] S. Running agent processes during a stale secret pass. Where: L571-574, L1693. Why: while the deny pass for a new secret has not finished, the broker refuses new T3 requests, but agent processes that are already running (and their children, which they spawn without the broker) can still open the new secret. State question: a new secret file appears while a long agent command runs, or the guard restarts in the middle of a pass. Fix: the guard freezes (job object / cgroup freeze / SIGSTOP of the agent process group) or ends running agent-group processes until the pass is done, and a restart treats the pass as stale until it reruns. §16 test with a process already running when the secret appears.

finding: [major] K. The confirm panel can be clicked by software. Where: L467-485. Why: "real input only" is stated, but the doc does not say the answer comes only from a real input event the panel's own low-level hook saw over the button. On Windows a uiAccess process passes UIPI and can use UIA Invoke or send BM_CLICK; on macOS any of your processes with the Accessibility right can send AXPress; on Linux an AT-SPI DoAction does the same. Any of these activates the button without an injected-input flag. Fix: say the panel ignores control activation and counts only a physical event (Windows: LLMHF_INJECTED clear from its own hook; macOS: CGEvent source state HID, not synthetic; Linux: the compositor-level input path), and hides itself from accessibility (no Invoke / AXPress / DoAction pattern). §16 test: UIA Invoke, BM_CLICK, AXPress and AT-SPI DoAction on the panel do nothing.

finding: [major] R. Passkey and guard-signature confirms can be replayed. Where: L472-476, L487-490. Why: the pending item's hash is made from its text only. "Continue on your screen" has the same text every time, so a captured passkey assertion or guard signature for one Continue is valid for the next one. The replay cache (L1452) covers peer-link proofs, not these. Fix: each pending item carries a fresh random nonce from the guard; the WebAuthn challenge and the guard signature cover nonce + text hash; the guard accepts each nonce once. Also name the WebAuthn RP id and origin, and say that the item text the phone shows is rendered from the guard's signed item, never by the agent machine's T3 UI.

finding: [major] H2. The host account's task password. Where: L184, L158, L1880. Why: on Windows a Task Scheduler task "run whether the user is signed in or not" stores the account's password, unless it uses S4U ("do not store password", which also has no network credentials). The doc does not say which. If it stores a password, L1880 "no task password to keep in step" is false, and a store-loss reset of the host account's password (L158, same pattern as the agent account) makes host T3 fail at the next boot with no error the person sees. State question: what happens after a restore of an old system image or a password reset? Fix: say S4U for the host task (T3 needs no network credentials for its own work), or the guard starts host T3 with the same batch logon it uses for the broker. Add a §15 row for the host task.

finding: [major] W. Three stale lines contradict the consent table (L513-517, owner answer 3 2026-11-14 and owner decision 2026-11-13). (1) L1551: tray Continue "needs OS consent", but L515 says Continue needs the T3 confirm. (2) L1627 (§14): "the T3 confirm ... for Continue and full rights", but L516 says full rights need the OS consent. (3) L1766-1767 (§16): "widening during a full-rights grant needs OS consent", but L517 says the T3 confirm. An implementer or test writer will build the wrong check. Fix: change the three lines to match L515-517.

finding: [minor] 1. Broker pipe or socket owner. Where: L185. Why: if the broker creates its pipe or socket as the agent account, an agent command can create a second instance of the same pipe name first or change its DACL, and T3 talks to it. Fix: T3 checks GetNamedPipeServerProcessId (Windows) / SO_PEERCRED or LOCAL_PEERPID (Linux, macOS) equals the broker pid the guard started, or the guard creates the pipe or socket and hands it over.

finding: [minor] 2. Window station for batch-logon processes. Where: §3.1, §6.1. Why: a process started by CreateProcessAsUser with a batch-logon token and no granted window station and desktop fails user32 init (0xC0000142) for anything GUI-linked. Fix: say the guard creates (or grants) a window station and desktop for the agent SID, the same one rung 3's hidden desktop uses (L731).

finding: [minor] 3. Agent password expiry. Where: L1764-1766. Why: "not rotated" plus a default password policy (42 days on many machines, domain policies) means the batch logon fails later. Fix: the guard sets "password never expires" on the agent account and checks it at start.

finding: [minor] 4. Linux pasta and the proxy port. Where: L313-318 with the Linux network row. Why: in a pasta namespace loopback is the namespace's own; the guard's proxy and resolver on host loopback are only reachable when pasta forwards them (-T <port> for TCP, -U for the resolver's UDP). Fix: name the forwards.

finding: [minor] 5. git over SSH. Where: L313-318. Why: ssh does not read HTTPS_PROXY/ALL_PROXY, so a goal that opens github.com for git over SSH fails ("it fails, nothing opens" is safe but surprising). Fix: say only HTTPS git works, or the broker sets GIT_SSH_COMMAND with a ProxyCommand to the guard's proxy.

finding: [minor] 6. Replay cache location. Where: L1452, L1729. Why: says "T3's database"; upstream replay markers are files in secretsDir (replayMarkers.ts), not database rows. Fix: say which one the fork uses; if files, it survives a restart as written, if DB, say why it differs from upstream.

finding: [minor] 7. Two pending items over-trigger. Where: L475-476. Why: "a second request while one is pending cancels both and raises an alert" fires on a normal case: the person takes over, the agent's ask item "continue on your screen" is pending, and the person presses Continue in the tray. Fix: one item per kind; the same kind replaces, different kinds queue; alert only on a repeat from a source that is not a person (state question: guard restart drops both, L1010, fine).

finding: [minor] 8. Snapshot at the cap. Where: L642-644 vs L690-691. Why: "at the cap the oldest goes" and "cannot be taken: refuse" read differently when VSS hits its 64-shadow limit or the shadow storage is full and Windows deletes old shadows by itself. Fix: say which wins: a failed or silently dropped snapshot refuses the batch.

finding: [minor] 9. Hidden desktop and single-instance apps. Where: L678, L731 (rung 3 on a hidden desktop in your session). Why: when an app that is already open on your desktop is started on the hidden desktop, many single-instance apps (Office, browsers, Explorer) hand the request to the running instance, so the window opens in front of you and acting happens on your screen. Fix: rung 3 checks that the window it gets is on the hidden desktop and refuses (falls to rung 4) otherwise; a §16 test with an app already open.

finding: [minor] 10. The verify with nobody signed in. Where: L274 (watch of HKCU). Why: the guard reads and watches your HKCU and uses your token while you are signed out; that needs your hive loaded and an S4U token. Fix: say S4U plus LoadUserProfile, or that the check is deferred to your next sign-in.

finding: [minor] 11. Descendant catch-up on Windows and L1281 wording. Where: L817-822, L1281. Why: (a) the gap walk after a missed process-create event can only use ParentProcessId, which a process can spoof (PROC_THREAD_ATTRIBUTE_PARENT_PROCESS) and which is reused after the parent exits; use the job object membership (all agent processes run in the guard's job, L185) as the source of truth instead. (b) L1281 still says "snapshot first" while L640-644 now orders the steps differently. Fix: say job membership; align L1281 with L640-644.

scuttletoby and others added 2 commits November 14, 2026 00:56
…wner defaults

- Proxy checks every resolved address against the network floor, pins it, runs as a low-rights account.
- T3 confirm: one-time nonce per item, only the panel's own raw input (no accessibility actions or
  window messages), one item per kind with rate limits, phone passkey on the guard's own page,
  a separate guard confirm key, X11 confirms on the phone.
- No agent process outside the guard's job: task, WMI and DCOM starts denied up front, probed.
- Unknown writers and full reserved area fail closed; stale deny pass freezes running processes.
- Host T3 task uses S4U; guard creates the broker pipe and the agent window station.
- Three stale consent lines fixed; owner questions 1-3 of 2026-11-14 written as pending defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ided

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: correctness r3 · reviewer: Opus 5.5 (t3lens-26-cor-r3) · read at: 347be19
summary: not ready yet. 0 blockers, 4 majors, 12 minors. Every r2 major is closed in the text (P, S, K, R, H2, W), and most r2 minors too. The new r7 rules mostly have a §15 row and a §16 test. What is left: the DCOM deny is placed where it cannot be loosened per AppID; the new t3-net account cannot reach a DNS server and is missing from the account lists; a guard-down gap holds Windows' own servicing changes as the agent's; and uninstall writes back whole old descriptors. Upstream refs checked at 8cd8a3c. The three "pending owner" questions are judged by their written defaults. Owner decisions in §17 are not raised again.

earlier findings:

  • P (proxy bypasses the floor): closed (L330-334, L1819, L1919-1922). See new major 2 for the t3-net account's own DNS.
  • S (running processes during a stale pass): closed (L648-652, L1805, test L1805). See minor 3 (daily pass, crash while frozen).
  • K (panel clickable by software): closed (L521-533, L1904-1906).
  • R (replayable passkey / guard signature): closed in the rules (L508-512, L541-545, L1844, L1907-1909). Wording left-overs that still say "hash" only: L1902, L2024 (minor 9).
  • H2 (host task password): closed (L189 S4U, L1802 row, L1923-1924 test).
  • W (three stale consent lines): closed (L1176-1178, L1735, L1880-1881). New left-overs of the same kind are in minors 8 and 9.
  • minor 1 (pipe owner): closed (L189, GetNamedPipeServerProcessId).
  • minor 2 (window station): closed (L189).
  • minor 3 (password expiry): closed (L161, L244).
  • minor 4 (pasta forwards): closed (L378-381). See minor 12 (how pasta is started).
  • minor 5 (git over SSH): closed (L1738, "git over SSH to named hosts does not work").
  • minor 6 (replay cache location): closed (L1842, marker files). See minor 10 (function name).
  • minor 7 (two pending items): closed (L515-520). See minor 4 (queued items expire).
  • minor 8 (snapshot at the cap): closed (L721-722, L1822).
  • minor 9 (single-instance apps on the hidden desktop): partly (L1077). The check runs after the window already exists; see minor 7.
  • minor 10 (verify with nobody signed in): partly (L285-287). S4U + LoadUserProfile is named, but whether host tools stay off while the walk waits is not; see minor 11.
  • minor 11 (descendant catch-up, "snapshot first"): closed (L919-920 job membership; "snapshot first" is gone).
  • D (locks on system spots): closed (L899, no lock on your writable system spots, only agent-caused changes held).
  • E (snapshot order left-over): closed.

finding: [major] 1. The DCOM deny sits in the machine-wide limits, so no AppID can be allowed. Where: L193 (also L239-241, test L1913-1915). Why: the machine-wide launch and activation limits are an upper bound. A Deny ACE for the agent group there wins over any per-AppID grant, so "the few servers the engine worker needs are allowed by AppID" cannot work. "Local activation" also covers connecting to an already running server. So the agent group would lose all out-of-process COM, including plain WMI queries. Many build and test tools use these (PowerShell CIM cmdlets, MSBuild and Visual Studio tooling, installers, language runtimes that read system info through WMI). The §16 test at L1913-1915 only checks that activation fails, so it would pass while builds break. Fix (sentence for the doc): "The install denies launch and activation for the agent group in the DCOM permissions of each AppID it must block (Task Scheduler, WMI process creation, WSL, BITS, Hyper-V, Windows Sandbox and the shell-execute brokers), not in the machine-wide limits, because a machine-wide Deny cannot be loosened per AppID; §16 also checks that the build and test tools the agent account needs still run."

finding: [major] 2. The t3-net account cannot reach a DNS server, and it is not listed as an account. Where: L333-334, L376 (also L155, L187, L225, L1797, L1972). Why: the proxy and the resolver run as t3-net, and "that account's outbound sockets sit under the same floor", which denies loopback and LAN. On most computers the upstream DNS server is the LAN router, or a loopback stub (systemd-resolved at 127.0.0.53, a local DNS cache, the tailnet's own resolver address). Under the floor the guard's resolver can then resolve nothing, so every opened goal fails. Also, t3-net is a third local account, but §3 says "Two local accounts per computer" (L155). The account table (L187), the verify (L225 "both accounts") and the install row (L1797 "create both accounts") do not cover it, and §17 (L1972) counts "two accounts". So rerun, half-install and "an account of that name is an admin" have no rule for it. Fix (sentence): "The t3-net account's sockets are under the floor except for one named exception: the DNS servers the system is configured with, on port 53 only, re-read on every address change; t3-net is a third local account (Windows: a virtual service account or a standard user with only 'log on as a service'), created, checked and removed like the other two, with the same §15 install row and verify check."

finding: [major] 3. After every reboot, Windows' own servicing changes are held as the agent's. Where: L779-783 (also L900, §15 L1809, test L1916-1918). Why: "for every change found after a guard-down gap" the unknown writer counts as the agent's, and the change is held. A held change is reverted from the guard's copy and shown with Keep or Remove (L763-766, L1809). A reboot is a guard-down gap. During shutdown and boot, Windows Update and servicing (TrustedInstaller) change scheduled tasks, services and HKLM Run values, and an app updater may run at boot. All of these would be reverted, and their processes ended, after each Patch Tuesday. The owner then gets a pile of Keep or Remove items with no way to tell which ones are real. State question: a clean shutdown with no agent process alive, then a reboot with updates. Fix (sentence): "A gap after a clean guard stop, with no agent-marked process alive and no open lock window in the journal, counts as yours; after an unclean gap, an unknown-writer change is reverted only for objects an agent-group or agent-caused process could have written, and a change to an object that needs elevation is logged and shown for review without being reverted."

finding: [major] 4. Uninstall writes back whole old descriptors, and a re-apply can overwrite the recorded original. Where: L239-241, L407, §15 L1803, L1847. Why: the install records "each AppID's and launch limit's original security descriptor" and uninstall "restores exactly that". Any change Windows or another installer made to that descriptor in the months between is then undone, which can break system COM after a feature update. L1803 says a Windows update that resets one is "re-applied", but it does not say whether the ledger keeps the first original or records the reset one. If it records the reset one, a later uninstall puts back the wrong state. The WebClient start type (L407) has the same rerun problem: a second install run would record "Disabled" as the old start type, and it has no §15 row of its own. Also, L239 names only the AppIDs and launch limits, while L1847 also puts back the Task Scheduler and WMI descriptors, so the ledger rule should name all of them. Fix (sentence): "The ledger records each descriptor and the WebClient start type once, at the first change, and never overwrites that record; uninstall removes only the entries the guard added (those for the agent, runner and t3-net SIDs) and leaves the rest of the current descriptor as it is; the WebClient start type is put back only if it still has the value the install set. §15 gets a WebClient row, and §16 a test: install, run install again, change a descriptor by hand, uninstall; the hand change stays and only the guard's entries are gone."

finding: [minor] 1. A copy taken again "from current state" bakes in an agent change. Where: L776-777, §15 L1808. Why: a missing copy (store lost, first start after an upgrade) is retaken from whatever is there. If an agent-caused change is already there, it becomes the trusted copy and every later revert restores it. Fix (sentence): "A retaken copy is marked unverified; its object is shown once in the tray for review, and a change to it is never reverted to an unverified copy without your OK."

finding: [minor] 2. The unknown-writer share has no rule for when it is full. Where: L761-766. Why: an unknown-writer change is restored from the copy "only after its content was kept in the unknown-writer share". The doc does not say what happens when that quarter is full (for example after a gap with many changes, see major 3). Fix (sentence): "When the unknown-writer share is full, an unknown-writer change is left in place and locked, logged with its hash, and new agent work is refused until there is room."

finding: [minor] 3. The daily re-apply freezes all agent work, and a guard crash can leave processes frozen. Where: L644-652, §15 L1805. Why: "after the daily re-apply, a full deny pass" makes the pass stale, so every agent process is frozen for the whole pass. That drops network connections and burns command timeouts every day, for no new secret. Also: a Windows job ends with the guard, but a Linux cgroup freezer and macOS SIGSTOP stay in place after a guard crash. The doc does not say the new guard thaws or ends them. Fix (sentence): "The daily re-apply runs while the last pass stays current and freezes nothing; only guard start and a watcher overflow make the pass stale; at start the guard ends every agent-group process it finds frozen from before."

finding: [minor] 4. A queued confirm item can expire before it is shown. Where: L508-509 with L517. Why: the 2-minute expiry starts when the item is made, but items of different kinds "queue and are shown one at a time". An item that waits behind another for 2 minutes is dead when it appears. Fix (sentence): "The 2-minute expiry starts when the panel shows the item, not when it is made."

finding: [minor] 5. The typed 3-letter code reaches the focused app. Where: L535-537 with L556-559. Why: the panel "does not take focus by itself", and the hardened confirm asks you to type a code. The hook only reads the physical keys, so the same keys also go to the app that has focus, which may be a terminal or an editor. Fix (sentence): "While the panel is open, its hook swallows the physical keys it reads for the code (Windows and macOS; on Linux the guard grabs the keyboard device for that time), so they reach no other app."

finding: [minor] 6. "The agent machine holds no confirm key" conflicts when that machine is also a host-tools computer. Where: L548-550. Why: a machine can run agents and also be a host-tools computer with its own guard confirm key. The rule does not say which wins. Fix (sentence): "A computer that sends tool calls to this computer can never be enrolled as one of its confirm computers, and enrolment refuses it."

finding: [minor] 7. The single-instance check comes after the window already appeared. Where: L1077. Why: the engine checks GetThreadDesktop of the window it gets, but by then the running copy has already opened the window on your desktop, which breaks the no-pop-in rule of §6.8. Fix (sentence): "Before starting an app on the hidden desktop, the engine checks whether that app already runs in your session; if it does, it uses rung 4 with your OK instead and never starts it there."

finding: [minor] 8. §9.1 still says the OS prompt follows every protected-area Allow. Where: L1480. Why: "on Allow the OS's own elevation prompt follows" is true only for an elevated run. Under the default of pending question 1, a protected-area action that is not elevated takes the hardened T3 confirm and has no OS prompt. Fix (sentence): "On Allow, the guard checks the hardened T3 confirm (§4.2), and an elevated run also shows the OS's own prompt."

finding: [minor] 9. Wording left-overs from earlier rounds. Where: L887, L1853, L1902, L2024. Why: L887 says "the broker's job", but §6.1 now says the agent processes run in the guard's job (L919-920). L1853 tests "the rare OS-consent items", but under the default those are hardened T3 confirm items. L1902 says "for the shown hash only", and L2024 "bound to the one item by its hash", but the binding is the nonce plus the text hash (L508-512). Fix: "the guard's agent job" (L887); "the hardened T3 confirm items" (L1853); "for that item's nonce only" (L1902); "bound to the one item by its one-time nonce" (L2024).

finding: [minor] 10. The upstream function name for the request check. Where: L1555. Why: verifyDpopProof is in the shared package; the server-side request check is verifyRequestDpopProof in apps/server/src/auth/dpop.ts, which is where the fork's body-digest claim check would sit. Fix (sentence): "This check is new fork code in the server's verifyRequestDpopProof, next to the shared verifyDpopProof, which checks no extra claims."

finding: [minor] 11. With nobody signed in, it is unclear whether host tools wait for your-token walk. Where: L285-287 with L262. Why: L262 says host tools come back with nobody signed in once the pass and the verify pass. L285-287 says that if the S4U logon or LoadUserProfile fails, the walk as your token and the HKCU watch wait for your next sign-in. It does not say whether host tools are on in that time, without the watch of your persistence points. Fix (sentence): "While the walk as your token and the HKCU watch wait for your sign-in, commands and file tools may run, but acting as you and every full-rights grant stay off."

finding: [minor] 12. How pasta is started is not said (to verify). Where: L378-381. Why: when pasta starts as root it drops to an unprivileged user by default, and joining a network namespace the guard (root) made then needs capabilities it no longer has. Without a stated start mode, the per-command namespace may get no route, or pasta may keep running as root. Fix (sentence): "The guard starts pasta with --runas set to a dedicated low-rights user and only the capabilities it needs to join the command's namespace (CAP_SYS_ADMIN and CAP_NET_ADMIN, kept as ambient capabilities), and §16 checks that a command's namespace reaches the proxy and the resolver and nothing else."

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security r6 · reviewer: Opus 5.5 (t3lens-26-sec-r6) · read at: 347be19
summary: not ready yet. 0 blockers, 6 majors, 7 minors. Most round-5 holes are closed (side starts denied up front, unknown writer fails closed, freeze on a stale pass, nonce-bound confirms, low-rights proxy). The new majors: code running as you can still steer a real click or a typed code into the confirm panel (cursor clip/warp, global key hook, screen capture), the phone page and its TLS key share an account with the agent-facing proxy, passkey registration and guard-key enrollment can be won by code running as you, the "linked machines" exception in the floor opens every port of a linked machine through the proxy, SMB from the System process reaches the internet (not only the LAN), and a full unknown-writer share leaves a persistence change active.

round-5 findings:

  • P1 partly: the proxy checks every resolved address and runs as t3-net under the floor (L328-335, test L1919-1922). Open: the floor itself excludes linked machines (L366-367), so the proxy reaches any port on them. See S4.
  • T1 mostly closed: task, WMI and DCOM routes denied up front with probes (L193, L242-244, L1803, L1913-1915). Open: the machine-wide DCOM deny cannot have per-AppID exceptions as written, and Secondary Logon is not on the list. See n3.
  • T2 partly: running agent processes are frozen while the pass is stale (L648-653, L1805). Open: Windows per-process suspend and macOS SIGSTOP race with new processes, and SIGCONT undoes SIGSTOP. See n1.
  • T3 closed (L770-771, L777-783, L1809, test L1916-1918).
  • T4 partly: per-task quota and restore-from-copy (L758-767). Open: when the unknown-writer share is full the change stays active (L765-766, L1814). See S6.
  • T5 closed for injection: own raw input only, no accessibility actions, window messages ignored, test per route (L521-533, L1904-1906). New way around it: see S1.
  • T6 partly: (a) closed, nonce + expiry + used mark (L507-512, L617-619); (b) closed on paper, origin and RP id are this computer's tailnet name (L541-543), but the serving account is the proxy's account, see S2; (c) partly, registration only from the panel (L545-547), but the QR can be read off the screen, see S3; (d) closed, separate guard confirm key (L548-552), but enrollment trusts the other side's screen, see S3.
  • m1 closed (L504-506). m2 closed (L1553-1561). m3 closed (L875-885). m4 closed (L498-501, L513-514; UIPI with the restricted token still to verify). m5 closed (L515-520; per task only, see n5). m6 closed (L537-539; see S1). m7 partly: the hit test ignores the cursor image (L526-527), but a relative mouse has no raw position: the click lands where the system cursor is, which code running as you can move or clip, see S1. m8 closed (L190). m9 closed (L158-162). m10 closed (L190). m11 closed (L239-241). m12 closed (L336-338, L1561-1562). m13 closed (L333-335, L983-985; see S2 and n4).

state check ("What happens when this runs again, stops half-way, or meets existing/old/empty state?"): covered in §15 for the S4U task (L1802), the up-front denies (L1803), the stale pass (L1805), guard copies (L1809), the proxy (L1819), confirm items and passkeys (L1844-1845). Missing: a full unknown-writer share (S6), macOS processes left stopped after a guard crash (n1), the t3-net account (n4), WebClient set back or still running (n7), and an old install whose proxy ran as SYSTEM (n4).

finding: [major] S1. Code running as you can steer a real click or the typed code into the confirm panel (§4.2 rules 4 and 6, L521-539; hardened form L557-561; L1036 excludes only the indicator from capture). Attack (Windows): (1) the agent gets code running as you (open by owner decision, §4.4); (2) the agent asks for a widening item through host_ask, for example "the public internet, always" or a full-rights grant; (3) that code screen-captures the panel (a medium process can read any window's pixels) and learns the 3-letter code; (4) it shows its own window "type ABC to finish the update"; the panel does not take focus, and its low-level keyboard hook sees keys typed into any window, so the code counts; (5) after the 1 s arming it calls ClipCursor on the Allow button (or SetCursorPos just before a click); the next physical click anywhere lands on Allow, is not injected and is inside the button, so the guard accepts. macOS: CGWarpMouseCursorPosition does the same. Linux: the guard reads the code from evdev wherever it is typed, and a 3-letter code can also appear by chance in normal typing during the 2 minutes. Rule 3 also lets tray requests count as "a person", but code running as you can drive the tray, and a same-kind replacement swaps the text under a user who is about to click. Fix (requirements): (a) the panel is excluded from screen capture (Windows WDA_EXCLUDEFROMCAPTURE; macOS sharingType none, to verify on current macOS; Wayland only on Linux); (b) a click counts only if no cursor clip is active (GetClipCursor equals the whole virtual screen) and the pointer reached the button by hardware move events and rested there about 300 ms; a cursor position that differs from the one tracked from hardware moves refuses the click; (c) the typed code counts only while the panel is the foreground window after a hardware click on it; any other key resets it; on Linux the code is a fresh sequence (start key, code, Enter, nothing in between) and longer for the hardened form; (d) a replaced item re-arms the 1 s delay and shows "this request changed"; requests from the tray count as "from your session", not as proof of a person; (e) §16 tests: ClipCursor, SetCursorPos, CGWarpMouseCursorPosition, code typed into another window, capture of the panel.

finding: [major] S2. The phone confirm page and its TLS key live in the same account as the agent-facing proxy (L333-335, L541-543). The proxy and resolver parse agent input (CONNECT lines, TLS ClientHello, DNS), and the doc itself plans for "a bug" there. Attack: (1) an agent command exploits a proxy parser bug and runs code as t3-net; (2) it reads the TLS key for the tailnet name or changes the page it serves; (3) the agent asks for a hardened item; (4) the page shows "Continue" but passes the real challenge (nonce + hash of the hardened item); the phone's passkey sheet shows only the site name, so you approve; (5) the guard checks the signature over nonce and hash and accepts. Separately, WebAuthn binds only the host name, not the port: any process that gets a certificate for the tailnet name (on Linux, code running as you when you are the Tailscale operator can run tailscale cert) can serve a look-alike page on another port. Fix: serve the confirm page from the guard itself or from an account of its own that parses no agent input; keep the TLS key readable only by that account; the guard checks clientDataJSON origin exactly (scheme, host and port) and type; the verify checks that no other local account can obtain a certificate for the tailnet name, or §14 names that residual.

finding: [major] S3. Passkey registration and guard-key enrollment can be won by code running as you (L545-552, L1845). Passkey: (1) you start a registration and the panel shows the QR code; (2) code running as you captures it (see S1a) and registers its own software passkey named "iPhone" on the guard's page first (your account reaches this computer's tailnet name; the floor binds only the agent group); (3) you confirm "the new passkey's name" with real input; (4) that code now holds a lasting confirm, which also turns host tools back on after a kill switch. Guard key: B learns "this is computer A" from the tailnet node only; code running as you on A can answer B's enrollment request (on Windows any user can bind a free port while A's guard is down), show a look-alike A panel with the same code, and you confirm on the real B panel; B then trusts the attacker's key as A. Fix: the registration code is single-use and valid for one credential; the panel lists every registration attempt for it and aborts if more than one; the phone and the panel show the same fingerprint of the new credential; registering a passkey and enrolling a guard key are hardened-confirm actions on the computer that will trust them; B enrolls a key only if A's guard proves it with a guard identity key created by the admin-run install and exchanged during §10 pairing, never from what A's screen shows.

finding: [major] S4. The floor excludes linked machines, so the proxy and "the public internet" open every port on them (L366-367 against L319-323 and the worked example L358-363; L329-335; test L1919-1922). Attack: (1) a goal opens a host whose DNS the attacker controls (prompt injection asks for "cdn.example-tools.dev", scope this task), or you open the public internet; (2) its name resolves to the agent machine's tailnet address; (3) the proxy's floor check passes because the floor carves out linked machines, and t3-net's firewall floor is "the same floor"; (4) the agent gets a TCP tunnel to that machine's database, dev server or T3 port, which the worked example says stay closed. With the public internet opened, direct connections hit the same carve-out. The §3.3 probe for "a port on a linked machine that config does not name" (L420) runs with nothing opened, so it does not catch this. Fix: the floor contains every tailnet address with no carve-out; named services are separate allow rules by address and port for the agent group's direct connections only; the proxy refuses every tailnet address; add tests with an opened name that resolves to a linked machine and with the public internet opened, probing an unnamed port on a linked machine.

finding: [major] S5. SMB from the System process reaches the internet, not only the LAN, and the planned fix covers only the floor classes (L401-416, probes L246-250, §14 L1738). Attack: (1) an agent command opens \203.0.113.5\s\out or \name.attacker.example\s (the name goes through the system resolver); (2) the SMB redirector connects from the System process over TCP 445, or over SMB-over-QUIC on UDP 443 and alternate SMB ports in current Windows 11 and Server 2025 (to verify per build); (3) the agent writes any file it can read to that share, with no goal opening anything. The per-SID floor does not see it, the probe tests only an on-link neighbour, the candidate fix says "SMB to the floor's classes", and §14 says "on your LAN". The owner decision "no direct internet" therefore does not hold and the residual is understated. The same applies to an IPP printer URL on an internet host. Fix: the per-account block must refuse agent-caller SMB and spooler connections to every address except named services, not only the floor classes; probes add a UNC path to a public test address over TCP 445, over QUIC and on an alternate port, and an internet IPP URL; until a block holds, §3.5 and §14 say "your LAN and the internet".

finding: [major] S6. A full unknown-writer share leaves a persistence change active (L758-767, L1814). Attack: (1) the agent floods file activity until ETW reports lost events (the doc says it can); (2) in that window, code running as you writes large changes to watched objects (100 MB profile scripts, Startup files); each counts as the agent's but has no task, so the per-task quota does not bound it, and its content is kept in the reserved area; (3) once the unknown quarter is full, a small Run-value change by an unknown writer "is restored from the copy only after its content was kept in the unknown-writer share", which no longer fits, so it is not restored; L1814 "locked rather than deleted" keeps the value in place and it runs at your next sign-in. A new Startup entry by an unknown writer is not covered by "a new path the agent created is removed" either. Fix: a change counted as the agent's under the unknown-writer rule is charged to the agent's total share (all tasks together), never to the unknown quarter; a held change that does not fit is neutralised in place without losing content (move the file into a guard-owned folder on the same volume, a rename that needs no space; move a registry value under a guard-owned key; disable a task, service or launch item), so nothing held stays active; a held change above a size cap is always neutralised in place; add a §15 row and a test for "unknown share full".

finding: [minor] n1. The freeze races (L648-651, L1805). Windows: suspending each process in the job races with new children made during the walk; use a job-wide freeze, or cap the job's active process count and then repeat until every member is suspended; state that the job has no breakaway (JOB_OBJECT_LIMIT_BREAKAWAY_OK and SILENT_BREAKAWAY_OK off). macOS: a process forked during the SIGSTOP walk can send SIGCONT to its stopped siblings (same uid), so repeat until a full check finds every agent process stopped, re-check before the pass reads, or end the processes instead. End every agent-group process outside the job before the pass starts. Add a §15 row for macOS processes left stopped after a guard crash. "Only freezes its own processes" (L651-652) is overstated: it freezes every agent task and the worker on that computer; say so.

finding: [minor] n2. A new secret file can be opened in the second before its deny, and the handle stays (L643, §14 L1752). An agent process watching a reach-map folder opens a new .env at once; the later deny entry does not revoke the open handle, so later rewrites of that file are readable too. Fix: on macOS and Linux use AUTH_OPEN / FAN_OPEN_PERM for secret-pattern names (the guard already uses them for protected areas, L881-883), so there is no race; on Windows, after the deny, end agent-group processes that hold a handle to the file (Restart Manager, as rule 1 does); §14 says what is left.

finding: [minor] n3. The machine-wide DCOM deny cannot have per-AppID exceptions (L193, test L1913-1914). The machine-wide launch and access limits are a ceiling: an AppID's own permission cannot grant past a Deny entry in the limit. So "the few servers the engine worker needs are allowed by AppID" does not work as written. State zero exceptions (measure that the worker needs none), or run what needs COM outside the agent group; never fall back to per-AppID denies (a deny list). Also add Secondary Logon (CreateProcessWithLogonW, runas /netonly, which accepts any network password) to the up-front denies and probes, unless it is verified that the new process stays in the caller's job.

finding: [minor] n4. The t3-net account is not in the install verify or §15 (L333-335, L375-377; §3.3 L223-254; only the uninstall row L1847 names it). The verify should check that it is not an admin, holds no sign-in right, that its firewall floor exists, and that the proxy and resolver listeners are owned by it. Its floor contains loopback, LAN and tailnet, which is where upstream DNS servers often are (127.0.0.53, the router, 100.100.100.100); state the narrow exception it gets (port 53 to the configured resolvers only) or that it resolves through the system resolver. State that plain-HTTP proxy requests (absolute-form GET, no CONNECT or SNI) are refused. Old state: an install whose proxy ran as SYSTEM removes that service before t3-net's starts.

finding: [minor] n5. The ask rate limit is per task (L517-519). An agent that starts many tasks or threads gets 3 asks per 10 minutes each and can wear you down. Add a per-computer limit across tasks.

finding: [minor] n6. S4U host task details (L189, L1802). An S4U logon has no password-derived DPAPI key; state that the host T3 keeps no secret under DPAPI or Credential Manager (or the verify tests that). Set the task's execution time limit to none (the default of 72 hours stops T3) and restart on failure. Old state: a task found storing a password is re-registered as S4U by the install, not only failed.

finding: [minor] n7. WebClient off (L406-408, pending default). Setting the start type to Disabled does not stop a running WebClient; stop it too. Write the ledger line before the change. The daily verify re-checks the start type (an update or another program can set it back) and the WebDAV probe runs then too. Add a §15 row: half-applied install, found re-enabled, found running, already Disabled before install (uninstall keeps it Disabled).

- DCOM: per-AppID denies computed from the descriptors (machine-wide limits
  untouched, measured allow list), Secondary Logon denied too
- Service identities t3-net and t3-confirm (virtual service accounts /
  DynamicUser); t3-net may reach only the system DNS servers on port 53
- Floor holds every tailnet address; named services are separate allow
  rules for direct connections only; proxy accepts CONNECT only
- SMB and printers to the internet blocked computer-wide (pending owner)
- Hardened confirm only on a phone or another computer (pending owner);
  panel capture-excluded, no clip/warp, Linux keyboard grab; phone page
  only from registered phones, exact origin check, single-use
  registration, guard identity key from pairing
- Held changes neutralised in place (no quota, no unknown share); clean
  guard stops count as yours; retaken copies unverified
- Ledger records originals once; uninstall removes only the guard's
  entries; WebClient stopped and checked daily
- Freeze: daily re-apply freezes nothing, job cap with no breakaway,
  macOS ends, leftovers ended at start; secret opens race-free

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: correctness r4 · reviewer: Opus 5.5 (t3lens-26-cor-r4) · read at: a4d62fb
summary: not ready, by one rule. 0 blockers, 1 major, 5 minors. All 4 r3 majors and all 12 r3 minors are closed in the text, each with a §15 row or a §16 test where it writes state. The one major is in the new remote-confirm setup: removing your phones needs no check and resets the "first registration" path, and on X11 that path has no safe route. Owner decisions in §17 (including the two pending defaults) are not raised again; line numbers are at a4d62fb.

earlier:

  • major 1 (DCOM deny machine-wide): closed (§3.1 per-AppID computed walk with a measured allow list, §3.3 L252-256, §16 L2037-2039 checks build tools still run).
  • major 2 (t3-net has no DNS, not an account): closed (§3.5 L366-369 port-53 exception re-read on address change; §3 L159 service identities; §3.3 L241-244; §15 install row L1905; §16 L2005-2006).
  • major 3 (servicing changes held after a reboot): closed (§4.4 clean-stop gap is yours, unclean gap reverted only where the agent could write; §15 L1918; §16 L2046).
  • major 4 (uninstall writes back whole descriptors): closed (§3.3 L257-262 ledger once, only added entries removed; §15 L1911-1912 WebClient row; §16 L2041-2042).
  • minor 1 (retaken copy bakes in a change): closed (§4.4 L870 unverified, §15 L1918-1919, §16 L2047).
  • minor 2 (unknown-writer share full): closed (share removed; held changes neutralised in place, L854; §15 L1919).
  • minor 3 (daily re-apply freezes, frozen after crash): closed (§15 L1915).
  • minor 4 (queued item expires): closed (§4.2 rule 2 L561-563, §15 L1954).
  • minor 5 (typed code reaches the focused app): closed (§4.2 rule 4, EVIOCGRAB on Linux, click only on Windows and macOS).
  • minor 6 (agent machine as confirm computer): closed (§4.2 L627-629). See minor 3 below for a link added later.
  • minor 7 (single-instance check too late): closed (§6.4 rung 3, L1178).
  • minor 8 (§9.1 OS prompt on every Allow): closed (§9.1 L1581-1583).
  • minor 9 (wording left-overs): closed (L988, L1085 "guard's agent job"; L1992; L2020; L2153).
  • minor 10 (verifyRequestDpopProof): closed (§10 L1662-1664).
  • minor 11 (nobody signed in, your-token walk): closed (§3.3 L314-316).
  • minor 12 (how pasta starts): closed (§3.5 L425-430, --runas, only CAP_NET_ADMIN; §16 L2007).

state check: asked "What happens when this runs again, stops half-way, or meets existing/old/empty state?" for every rule that writes state, including the new r7/r8 ones (WebClient, SMB filters, ledger-once, t3-net and t3-confirm identities, passkeys and guard confirm keys, guard identity key, clean-stop gap, held-in-place changes, take-over pause, tombstones). §15 covers all of them except two gaps: the "first registration" state can be re-entered by a narrowing (major 1), and the confirm page's certificate and tailnet name have no row (minor 4).

finding: [major] 1. Removing every remote confirmer needs no check and resets the "first registration" path; on X11 that path has no safe route. Where: §4.2 rule 7 L614-618 ("the very first one, while nothing is registered yet, is confirmed with the OS's own admin prompt"), L638-643 (fallback "polkit (Wayland only; on X11 a phone or second computer is required)"), L555-557 (on X11 every T3 confirm goes to the phone), L706 ("Removing a passkey or a computer from the tray (narrowing) stops it counting at once"), narrowing row of the class table L659 (guard checks nothing), §15 L1955, §14 L1843. Attack, X11: (1) agent-caused code running as you (the open residual of §4.4) asks the helper to remove each registered phone and confirm computer; that is narrowing, so the guard checks nothing and nothing is shown beyond the tray list. (2) "Nothing is registered" is now true again, so the next phone registration is a "first one" and takes the OS admin prompt. (3) The code starts a registration naming a tailnet node it controls; nothing in the doc refuses this computer's own node or the agent machine (the confirm port filter then admits that node, L608-610), and a software WebAuthn authenticator there satisfies "user verification required". (4) On X11 the OS prompt is polkit, where §4.2 L690-691 and §14 L1856 already say code running as you can read the password you type and can inject input into the agent; the panel's fingerprint step on X11 proves only presence (L555-557), and any X client can redraw its text. Result: code running as you owns a "phone" and answers every hardened confirm itself (full-rights grants, protected-area Allow and Keep, host tools back on after the kill switch, outside store edits). On Windows, macOS and Wayland the same steps (1)-(2) silently move every hardened confirm from the phone back to this computer's own OS prompt, which still holds (UAC secure desktop, SecurityAgent) but is not what the owner set up and is not shown. Separately, X11 has no stated way to register the first phone at all: the hardened confirm needs a phone, the first phone needs the OS prompt, and the OS prompt is excluded on X11. Fix (requirements the doc can state): (a) the guard records in its store that remote confirmers were set up; removing the last one stays allowed (lost-phone recovery) but raises a tray alert "all your phones and confirm computers were removed", and the next registration then needs the OS admin prompt on Windows, macOS and Wayland only; (b) on X11 the first phone, and any phone after the last one was removed, is registered only from the admin-run install (or a re-run of it), never from a polkit prompt in the X session; (c) a registration refuses this computer's own tailnet node, the node of any computer that sends tool calls here, and any node already enrolled as something else, and the item text shows the node's name and OS as Tailscale reports them; (d) §15 row "passkeys and guard confirm keys" gets "the last one removed: tray alert, next registration as in (a)/(b)", and §16 a test: remove every phone as code running as you, then try to register a node on this computer and on the agent machine; both refused, and on X11 no polkit route registers one. §14 L1843 should also name the OS-prompt fallback, since it now says "taken only on a phone or another computer".

finding: [minor] 1. The SMB residual says "your LAN" but the computer-wide filter allows the tailnet class too. Where: §3.5 L452-461, §14 L1846, §17 L2176-2177. Attack: where no per-account block holds and you accepted the residual, an agent command opens \100.x.y.z\share on a tailnet node. That reaches every linked machine's SMB service (outside "only the services named in config", §3.5 sec S4) and any node shared into your tailnet from another account, which is not one of your machines. Fix: "Where no per-account block holds, the accepted residual is SMB paths and printers on your LAN and on your tailnet, including your linked machines' SMB services and nodes shared into your tailnet"; and say it the same way in §14 and in the owner's view.

finding: [minor] 2. The guard-to-guard channel for remote confirms is not specified. Where: §4.2 rule 7 L619-620 ("its guard fetches the item from this guard over the tailnet"), §3.5 listeners, §3.3 verify, §15 L1955. Attack: the guard (SYSTEM) now has a tailnet listener that the doc does not limit; the agent machine, which may be compromised and is on the tailnet, can reach it, try enrolments, flood item fetches, or probe a SYSTEM parser. Fix: "The guard's confirm channel listens on the tailnet interface only, accepts connections only from the nodes of enrolled confirm computers (and, during an enrolment, the one node you named), authenticates both sides with the guard identity keys from pairing before it parses anything else, and serves only the pending hardened item; the verify checks its listener and filter, and §16 checks that the agent machine cannot reach it."

finding: [minor] 3. A link added after enrolment does not remove a confirm computer. Where: §4.2 L627-629, §15 L1955. Attack: computer B is enrolled as A's confirm computer; later B is linked to send tool calls to A (B becomes an agent machine for A). The rule says enrolment refuses such a computer, not that a later link ends its enrolment, so B keeps confirming hardened items for the machine its agents now drive. Fix: "Adding a link through which a computer sends tool calls here removes that computer's confirm enrolment in the same step, and the guard re-checks the link list at each confirm."

finding: [minor] 4. The confirm page's certificate and tailnet name have no state rule. Where: §3 L183-184, §4.2 L606-613, §15 (no row). Why: WebAuthn needs HTTPS on the tailnet name, and a passkey is bound to that name. The doc does not say who obtains the certificate (e.g. the guard through the local Tailscale for the tailnet name), who renews it before it expires, or what happens when renewal fails or the computer's tailnet name changes; in both cases every registered phone stops working, and since phones are still "set up", the OS-prompt fallback does not apply, so the rare actions (including host tools back on after the kill switch) have no route. Fix: "The guard obtains and renews the confirm page's certificate for the computer's tailnet name and installs its key readable only by t3-confirm; an expired certificate or a changed tailnet name marks the phone path unavailable in the tray, and the hardened confirm then uses a confirm computer or the OS admin prompt until you register again"; plus a §15 row (cert renewal fails, name changed, cert left over after uninstall) and a §16 test.

finding: [minor] 5. "Only on a phone or another computer" in §14 and §16 drops the stated fallback. Where: §14 L1843, §16 L1991-1993 and L2030-2031 ("never offered on this computer's own screen"). Why: §4.2 L636-642 falls back to the OS admin prompt on this computer when no phone or second computer exists. The §16 test as worded would fail on a fresh install with no phone. Fix: "the hardened confirm is never the confirm panel on this computer's screen; with no phone or confirm computer it is the OS admin prompt (not on X11)", in §14 and both §16 lines.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: security r7 · reviewer: Opus 5.5 (t3lens-26-sec-r7) · read at: a4d62fb
summary: not ready yet, one major left. 0 blockers, 1 major, 6 minors. All six r6 majors are closed, and so are almost all r6 minors and the older partly-fixed items. Most fixes take a whole class away rather than patching each case: hardened confirms only off this screen, neutralise in place, a floor with no carve-out, separate service identities. The one major is a missing rule. The guard runs as SYSTEM or root and changes owners and ACLs on your profile and persistence points by path. Code running as you can swap those paths for links, and that turns your account into SYSTEM or root with no prompt.

earlier:

  • S1 closed: the panel is excluded from capture. A click counts only with no clip, after hardware moves and a rest on the button, and a warp refuses it. On Windows and macOS a click alone answers (no typed code); on Linux the keyboards are held with EVIOCGRAB and the answer is the code plus Enter. Tray requests count as "from your session", a replaced item re-arms. The hardened actions moved off this screen. (§4.2 L572, L584-602, L632-639; §15 L1954)
  • S2 closed: the page runs as t3-confirm, which parses no agent input. Only that identity can read its TLS key. clientDataJSON is checked exactly (type, scheme, host, port). The guard uses the page only while t3-confirm owns the listener. (§3 L182-187, §4.2 L605-613)
  • S3 closed: the registration code is single-use for one credential, more than one attempt aborts, and the fingerprints must match. Registration and enrolment are hardened actions. Confirm keys count only when signed by the guard identity key from pairing. (§4.2 L614-625, §10 L1598-1602, §15 L1955). How that identity key is trusted: minor m1.
  • S4 closed: every tailnet address is in the floor. Named services are separate direct-only allow rules. The proxy refuses every tailnet address. Tests cover an opened name that resolves to a linked machine, and the public internet. (§3.5 L349-354, L360-363; §15 L1929; §16 L2003)
  • S5 closed by the pending-owner default: SMB and printers to the internet are blocked computer-wide for the System process and the spooler, with probes over 445, an alternate port, QUIC and IPP. Until that holds, §14 says host tools stay off. (§3.5 L449-465, §14 L1846, §15 L1913)
  • S6 closed: held changes are neutralised in place by a same-volume rename, a moved value or a disabled entry. There is no unknown-writer share any more, and nothing held needs room. (§4.3 rule 6 L846-860, §15 L1918-1919, L1924)
  • n1 closed: the job's active-process cap with no breakaway, the cgroup freezer, macOS processes ended, any process outside the job ended. (§4.3 L736-741, §15 L1915)
  • n2 closed: AUTH_OPEN and FAN_OPEN_PERM on secret-pattern names on macOS and Linux. On Windows the handle holder is ended, and §14 states the residual. (L727-728, §14 L1860)
  • n3 closed: the machine-wide limits are untouched, the per-AppID walk is computed with a measured allow list and re-walked on change, and Secondary Logon has an RPC filter. (§3.1 L207). One gap left, per-user registrations: minor m5.
  • n4 closed: t3-net is in the verify. It gets port 53 to the system DNS servers only. Absolute-form requests are refused. An old SYSTEM proxy is removed first. (§3.3 L241-243, §3.5 L358-370, §15 L1905, L1929)
  • n5 closed: rate limits per task and per computer. (§14 L1858, §15 L1954)
  • n6 partly: no DPAPI or Credential Manager secret, no time limit, restart on failure. (§3.1 L203) Still open: a task found storing a password only fails the verify (§15 L1910). The install should re-register it as S4U.
  • n7 closed: ledger first, then Disabled, then the service is stopped, with a daily verify and the WebDAV probe, and a §15 row. (§15 L1912)
  • P1 closed (see S4). T2 closed (see n1). T4 closed (see S6).
  • T6 closed: (b) via S2, (c) and (d) via S3.
  • m7 closed: hardware move tracking plus a warp check. (§4.2 L600-602)

state check ("What happens when this runs again, stops half-way, or meets existing/old/empty state?"): §15 now covers these: ledger-once for the up-front denies, WebClient and SMB; neutralise and restore finished from the ledger; unverified retaken copies; the clean-gap and unclean-gap rules; deny-execute journalled; lock leases; the freeze; service identities and an old SYSTEM proxy; passkeys and confirm keys; the DCOM re-walk; power loss. Still missing:
(a) a lock, unlock or start-time restore that finds the path changed since its ledger line, swapped for a link (major M1);
(b) the ledger itself lost or restored from an old image (m6);
(c) a peer's identity key replaced at a later pairing (m1);
(d) the confirm page's certificate expired or not renewed: who renews it, as which identity, and what hardened confirms do meanwhile.

finding: [major] M1. Guard-privileged operations on your own profile and persistence points are outside the link-safe rules.
Where:

  • §3.4 L324-327 limits those rules to "a path inside an agent-writable folder".
  • The §4.4 lock layer (table "Who enforces it", row "your own persistence points", layer 1) takes ownership as SYSTEM or root and "afterwards gives ownership back".
  • The same happens in the start-time restore of leftover locks (§12.3 L1810, §15 L1921), in Keep of a deny-executed file, which "gives the file back its owner and mode" (§4.4 L990-991), and in the restore from the guard's copy after a neutralise (§4.3 L851-852).
  • The doc itself says (§4.4, layer 1) that "a locked file can still be deleted and recreated through its parent folder, or a parent renamed". So the object at the path can change during the window.
    Attack (Windows):
    (1) Agent-caused code runs as you. The owner chose to keep this open (§4.4), and §14 already lists the case of a process that escaped the mark.
    (2) An acting batch starts. The guard takes ownership of your Startup folder as SYSTEM and records the old owner as you.
    (3) During the window that code renames the Startup folder through its parent (Programs, which you own). It puts a junction named Startup in its place, pointing at a folder SYSTEM owns, for example C:\Windows\System32\Tasks or a service's folder. ETW cannot block this, and layer 2 holds it only after the fact.
    (4) The window ends. The guard gives ownership back by path and removes its entries. A path-based call follows the junction, so you now own the SYSTEM folder. As owner you rewrite its DACL and plant a task or DLL. That gives SYSTEM code with no UAC prompt, which defeats §14 L1856 ("Elevation without a prompt") and every protected area.
    Linux and macOS: replace ~/.profile or the autostart folder with a symlink to /etc/sudoers.d or /etc/passwd. A path-based chown restores your ownership onto the target, which gives root.
    Variants:
  • Power off during the window, so the start-time ledger restore does the same.
  • For an agent-placed file that is root-owned with mode 000, the folder stays writable. Before you press Keep, swap the file for a symlink, and Keep then chowns and chmods the target.
  • If the restore from the guard's copy is done by path, it can be redirected into a system folder.
    Fix (requirements the doc can state):
    (a) §3.4 covers every guard, broker or helper operation on a path that any account other than the guard (you, the agent group, a runner) can write. That includes the lock and unlock, the start-time restore of locks, neutralise, restore from copy or snapshot, Keep, Remove, setting and removing deny-execute, and quarantine. Each opens without following links, works on the handle and checks the final path.
    (b) The lock ledger records each object's identity: the file id and volume, or the inode and device, or for a registry key its handle-checked path with no symbolic link. Ownership and ACLs go back only onto that same object. If the object is a different one, the line is dropped and logged, never applied by path.
    (c) No guard operation ever calls a path-based owner or ACL setter (SetNamedSecurityInfo, chown, chmod, lchown on a looked-up path).
    (d) A refusal for a link or link-count reason never leaves a held entry active: the directory entry is still renamed away, or the operation fails closed with host tools off.
    (e) Add a §15 row and §16 tests per OS: a junction or symlink swapped in during a lock window, before Keep, and before a start-time restore. None of them changes owner, mode or ACL outside your profile.

finding: [minor] m1. The guard identity key is trusted on the word of the host T3 (§10 L1598-1602, §4.2 L622-625). The host T3s exchange identity keys and pass them to their guards. The host T3 is a non-admin process that faces the network, which is the M1 threat in §14 L1844.
Attack:
(1) The host T3 on B is taken over, or a pairing is redone ("a reinstall makes a new identity key, pairing again is needed").
(2) B's host T3 gives B's guard an attacker key as A's identity.
(3) Enrolment on B is a hardened action, but the item shows nothing the owner can compare.
(4) The attacker signs confirm keys "as A" and approves B's hardened items.
Fix: the enrolment item on B shows the fingerprint of A's identity key, A's own panel or tray shows the same fingerprint, and you confirm that they match. Storing or replacing a peer's identity key is a hardened action on the computer that stores it.

finding: [minor] m2. The rule "never from a computer that sends tool calls to this one" is checked at enrolment (§4.2 L627-628). L1955 can be read as enrolment-only too.
Attack: enrol computer A as B's confirm computer, then link A so it sends tool calls to B. A's agents now share a machine with B's confirm source.
Fix: check the rule at every confirm, and drop the enrolment when such a link appears.

finding: [minor] m3. The guard-to-guard item fetch has the guard itself (SYSTEM or root) serve and parse tailnet input (§4.2 L619-620). That runs against the S2 reasoning that moved the phone page to t3-confirm.
Fix: serve and fetch through t3-confirm or another low-rights identity, accept only enrolled computers' tailnet nodes (checked by interface plus node identity, as §10 L1669 does for links), and have the guard read only the signed result.

finding: [minor] m4. Proxy openings and "the public internet" apply to the whole agent group, not to the task that asked (§3.5 L355-376). The default scope is "this task", but the loopback proxy cannot tell which task connects, and direct-connection filters are per SID.
Attack:
(1) Task 1 in project A opens a package registry or git host for this task.
(2) Meanwhile task 2, in another granted project and steered by injected text, pushes or publishes data to that host through the same proxy.
Fix: give each task its own proxy credential (a per-task token the broker puts into the proxy URL, checked on CONNECT) and per-task direct filters (slot SID or job). Otherwise §3.5 and §14 say that an opening reaches every agent task on the computer while it lasts.

finding: [minor] m5. Per-user COM registrations are not named in the DCOM walk (§3.1 L207). The agent account can write its own HKCU\Software\Classes CLSID and AppID keys, and a LocalServer32 it registers there is started by the COM service, outside the guard's job.
Fix: state that the walk includes the agent, slot and runner accounts' per-user class registrations, or deny those groups write access to their own Classes\CLSID and Classes\AppID keys. Add a probe (register a per-user LocalServer32, activate it, and expect a refusal). Also say how soon "an agent-group process outside a job is ended" acts outside the start and freeze paths.

finding: [minor] m6. Old state of the ledger itself (§3.3 L257-264; §15 L1911-1913, L1957). The ledger is the only record of what the install changed. If it is lost, or restored from an old image, a reinstall records the guard's own values as the originals (the DCOM deny ACEs, WebClient Disabled, the QUIC setting), so uninstall can no longer tell them apart.
Fix: every guard change carries a recognisable mark (the agent group SID in the ACE, the WFP provider GUID, a fixed description), so the ledger can be rebuilt from system state. Add a §15 row "ledger lost": rebuild from the marks, and on uninstall show anything uncertain instead of guessing.

Defer the remote confirm (phone and second computer: rule 7, the
t3-confirm page and identity, registration, guard confirm and identity
keys) to its own design, listed in section 17. The hardened confirm is
the OS's own admin prompt in v1; X11 gets no on-screen acting and no
high-risk OK. Drop the lock while acting (leases, start-time lock
restore); the 2-min acting window and the 30-min rule stay as plain
time windows. Add rule 1 (the guard only takes away on places another
account can write, by file identity) and rule 2 (nothing running as you
changes who confirms). Label controls boundary or tripwire in section
14. Fold the one-line minors (per-user COM in the DCOM walk, lost or
old ledger, S4U re-register, SMB residual covers the tailnet); list the
rest. Pending questions now point to issues 28 and 29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: t3lens26-cut-corr (correctness + state, last round) · reviewer: Opus 5.5 · read at d1b9af0 (diff a4d62fb..d1b9af0, docs/fork/host-tools.md)

summary: close, but not merge-ready by two short text fixes. 2 majors (both one sentence), 4 minors for the merge list. cor r4 major 1 (last-phone reset) is closed. sec r7 M1 is closed for your profile but still open for the user-writable system spots. The cut left no dangling references.

(1) Old majors

  • cor r4 major 1: closed. No phones or confirm computers exist any more, so there is nothing to remove and no "first registration" to fall back to. Rule 2 (L624-626) fixes the confirm to the OS admin prompt, and only the admin install can change that. X11 has no polkit route (L562, L617). The §15 passkey row is gone.
  • sec r7 M1: closed for your profile, open for system spots (major A). The lock layer and its start-time restore are gone, and take-aways go through a handle matched to the ledger (L339-341).

(2) Dangling references: none. §4.2 rule 7, t3-confirm, the confirm page, passkeys, guard identity and confirm keys, and phone confirms now appear only in the status line, §17 "Deferred" (L2142-2150) and the "deferred" residual notes. Lock, lease and layer 1-3 wording is gone from §4.4, §12.3, §15 and §16 (the §4.4 rows now have two layers). "Rule 7" in §4.3 and §11 is the activity-log rule and is correct. The §14 boundary/tripwire label (L1806) fits the rows, except for the polkit point in major B.

(3) New holes

[major] A. Rule 1 lets a give run as SYSTEM or root at user-writable system spots.

  • Where: §3.4 L341-343 ("done with the rights of the account that owns the place"); §4.4 L975-976 (system spots are held and restored from the guard's copy, offered with Keep); L965 (Keep "gives the file back its owner and mode" on agent-placed files in protected areas); §17 L2155.
  • Why: for a ProgramData folder with Users:Modify, or a root-owned /opt subtree you can write, the place's owner is SYSTEM or root. So the restore, or the Keep chown/chmod, runs with full rights. Rule 1 needs the ledger-matched handle only for take-aways. Code running as you can write that folder. Between the guard's check and the give, it swaps the entry for a junction or symlink, and the write or chown lands on a system file. That is SYSTEM or root with no prompt: the Keep and restore variants of sec r7 M1.
  • Fix (§3.4, one sentence): "A give runs with the rights of the account whose write it restores or keeps (you), never the guard's, even where SYSTEM or root owns the place. The only exception is giving back exactly what the guard itself took from the same object, and only on the ledger-matched handle with link count 1." The exception also settles the author's open point on Keep of a root-owned mode-000 file.

[major] B. On Linux the hardened confirm's "protected surface" runs as you.

  • Where: L617-618 ("Code running as you cannot draw on or click that surface"), L664-670, §14 L1806 (the OS admin prompt is listed as a boundary).
  • Why: UAC's secure desktop and macOS SecurityAgent hold. The polkit authentication agent, though, is a process in your session running as you. Code running as you can end a stand-alone agent (KDE and most desktops other than GNOME Shell), register its own over D-Bus (RegisterAuthenticationAgent) and show a look-alike prompt. You then type your password into code running as you, and it can answer every hardened item itself. That changes who confirms, which rule 2 forbids. Before the cut, Linux users with a phone did not depend on this. Now every Linux user does.
  • Fix (§4.2, one sentence): "On Linux the hardened confirm counts only where the build shows the session's polkit agent cannot be replaced by code running as you (for example, an agent built into the compositor); elsewhere those items are refused as on X11. Named in §14." An alternative is to accept it openly as a §14 residual, but that is the owner's call.

(4) Author's open points

  • Keep and "make it mine" on a root-owned or agent-owned object: parking this in §17 as build-time is fine. Fix A covers Keep. For "make it mine", a way with no privileged give: a process running as you copies .git into a new folder that you own, then the guard removes the agent's copy.
  • §4.4 "restored from its own copy": see minor m2.

Minors (merge list)

  • m1. No way back on X11, or on a Linux desktop refused by fix B. "Host tools back on after the kill switch or a failed verify" needs a hardened OK (L612-613, L888), and that is refused there, so host tools stay off for good. Fix: "where the hardened confirm is refused, these come back only by re-running the admin install" (this fits rule 2). Also, L288 says host tools come back on their own after a passed verify, but L613 asks for a hardened OK. Say which one wins.
  • m2. §4.3 rule 6 (L840-841, Keep "makes it the new copy"), the §4.4 restore at L976 and Keep at L965 should say "as you (§3.4 audit rule 1)". State: with nobody signed in (start-time revert after an unclean gap), the give needs a token for you: Windows S4U, Linux setfsuid. If no token can be made, the object stays neutralised, the restore waits for your sign-in, and the tray says so. The take-away is already done, so this fails safe.
  • m3. Wording: L470 "(hardened T3 confirm)" should be "hardened confirm". L617 "refused on X11, rule 1" is ambiguous now that "audit rule 1" exists: write "§4.2 rule 1". L2155 "Rule 1 (§3.4) and gives that change an owner:" does not read as a sentence.
  • m4. §3.4 L325-328 still limits rules 1-5 to "a path inside an agent-writable folder", but audit rule 1 now covers "anything another account can write". Widen the scope sentence to match, so that rule 4's final-path check also covers your profile and the system spots.

State question (runs again, stops half-way, meets old or empty state):

  • No build ever shipped the lock layer or phone pairing, so no install has lock ledger lines, passkeys or identity keys, and no migration row is needed.
  • Removing the lock-window §15 row and the start step is right, since nothing is locked any more.
  • The acting window is a plain time window that is journalled ("no open acting window in the journal", L857), so a crash inside it counts as an unclean gap, as before.
  • Rule 1's take-away writes the ledger line from the handle it then uses, so running it again or stopping half-way is safe.
  • The open gap is the empty state of m2 (no token for you at start).

verdict: merge after fixes A and B (one sentence each); the minors can go to the build list.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: t3lens26-cut-sec (security, round 9 scope cut, diff-only) · reviewer: Opus 5.5 · read at d1b9af0 (diff a4d62fb..d1b9af0, docs/fork/host-tools.md)

summary: almost ready. 0 blockers, 1 major, 5 minors. The cut is clean. Correctness r4 major 1 (resetting the last phone) is closed. Security r7 M1 is closed for the attack it named (the lock layer and its start-time restore are gone, and rule 1 covers your profile and persistence points). One part of M1 is still open: the wording of rule 1 lets the guard "give" with admin rights on the user-writable system spots. The fix is one sentence. I found no dangling references to the phone, t3-confirm, the Confirm page, the locks or leases, or the identity keys, and the "rule 7" in §4.3/§11 is the activity-log rule as it should be.

state check (runs again, stops half-way, meets old or empty state): no build exists ("design, no code"), so no install ever had the lock layer or a phone. Nothing needs migrating, and rule 2 already makes the later remote-confirm design go through the admin-run install. The lock-window §15 row was removed. The "clean gap" rule in §4.3 rule 6 L853-854 now depends on "no open acting window in the journal", which comes from the batch journal and the grant records (§15 L1898, L1908). That works, but it is implied rather than stated (minor m4). X11 after a kill switch has no way back except the install (minor m3).

check 1, majors:

  • cor r4 major 1: closed. There is no phone and no registration, so there is no "first registration" state to reset. Rule 2 (§4.2 L622-624) fixes the confirmer to the OS admin prompt, and only the admin-run install can change it. On X11, high-risk OKs are refused (L562-563), so no polkit route remains there.
  • sec r7 M1: closed for the named attack, one part still open (major M1b below). The lock and unlock, the lease restore at start and the §15 lock row are gone. Rule 1 (§3.4 L338-343) covers neutralise, Keep and restored content on your own places.

finding: [major] M1b. Rule 1 gives "with the rights of the account that owns the place", but on the user-writable system spots the owner is an admin. Where: §3.4 L342; §4.3 rule 6 L836-837 (the guard keeps copies of "every small watched system spot" and restores them); §4.4 table L975 (system spots use "the same two layers", including the restore from the copy); the §3.4 link rules 1-5 cover only "a path inside an agent-writable folder" (L324), and system spots are denied to the agent group, so they are not agent-writable. Attack (Windows): (1) code running as you (the accepted residual) writes a DLL into a ProgramData folder that an installer made, owned by Administrators with Users:Modify. You are a known writer, so the guard refreshes its copy with that content. (2) A marked process changes the file, so the guard holds it: it renames the file away (a take-away, fine) and then restores the content from its copy. (3) Before the restore, the unmarked code swaps a subfolder on that path for a junction to a system folder. (4) The restore runs "with the rights of the account that owns the place", which is Administrators or SYSTEM, so the DLL lands in the system folder. That is SYSTEM code with no prompt. Rule 1's own reason ("a give the place's owner could make anyway") only holds when the owner is no stronger than the person who can write the place. §14 L1810 counts this as a major, because the tripwire itself hands out new rights. Fix (one line, L342): "is done with the rights of the least-privileged account that can write the place (your account for your profile, your persistence points and the user-writable system spots), never more; if that account cannot do it, the give is refused." Also widen L324 to "a folder any account other than the guard can write".

finding: [minor] m1. Keep, "make it mine" and Keep-moves-it-back (the §17 open point, L2155-2157) need a stated build rule. As written, rule 1 makes three things impossible: Keep of a root-owned mode-000 file (§4.4 L965), Keep moving a neutralised item back out of the guard-owned folder (L826; you cannot read that folder), and "make it mine" (L744; neither you nor the agent account can chown to you). Refusing is safe, so this is not a hole. Recommended rule: (a) the guard may reverse its own earlier take-away, on a ledger-matched handle whose identity also includes the generation (the NTFS sequence number, or the birth time on macOS and Linux, so a reused inode does not match), with link count 1, into a parent opened by handle and also ledger-matched. That gives back only what was there before. (b) "make it mine" copies .git as you with a walk that does not follow links into a new folder you own, then swaps it in; it never runs a recursive chown of an agent-owned tree as root or SYSTEM.

finding: [minor] m2. On Linux, polkit is not a "protected surface". Where: §4.2 L617-618 ("Code running as you cannot draw on or click that surface"); §14 L1806-1808 labels "the OS admin prompt" a boundary. The polkit authentication agent is a process in your session that runs as you. Code running as you can end it and register its own (Wayland too), then show other item text for the real request or read the password. Before the cut this was only the fallback; now it is the only hardened path on Linux. It still needs the admin password, so it remains a boundary against the agent account, but the text you read is not trustworthy against code running as you. Fix: say this in §4.2 and in the §14 "Elevation without a prompt" residual (today it names X11 only). Option for the build: the guard also shows the item's text and code in its own panel, and polkit counts only as proof of the password.

finding: [minor] m3. On X11 there is no way back after the kill switch or a failed verify. Turning host tools back on and confirming an outside store edit are hardened actions, and §4.2 L562-563 refuses those on X11. This fails closed and is safe, but no recovery route is written down. Fix: "on X11 these are done by re-running the admin install."

finding: [minor] m4. The acting window's state is implied, not stated. The §15 lock row that held the window's state is gone. Fix: add to §15 "marks and baselines" (L1898): "the acting window is read from the batch journal and grant records (end + 2 minutes); a stop inside it counts as unclean."

finding: [minor] m5. Wording left over. L470 still says "hardened T3 confirm" (now "hardened confirm"). §14 B2 row (L1817) lists only the link-safe operations; add "the guard only takes away on places others can write (§3.4 rule 1)". §3.1 reach row (L202) says Modify is "applied by the guard"; under rule 1 that is a give, so the row should say "as you" or name the exception. §4.3 rule 6 L826 "restored from the guard's own copy" could add "(as the place's writer, §3.4)" (author's open point; rule 1 already covers it, since it names "restored content").

check 3, new holes from the cut: none besides M1b and m2. Without the lock, Windows has about 1 s between a write to a persistence point and its revert during an acting window (ETW cannot block). The watch ends every process the entry started, and §14 labels this a tripwire, so it stays a tripwire gap and is not a major. The §14 boundary and tripwire labels are right except the OS admin prompt on Linux (m2).

verdict: merge after the one-line M1b fix. Put m1-m5 on the merge list.

scuttletoby and others added 2 commits November 14, 2026 03:05
… protected surface

Final text fix (round cap) of the r9 scope cut, from the last correctness and
security diff lenses:

- Audit rule 1 (§3.4): a give (owner, permissions, restored content, Keep) runs
  as the least-privileged account that can write the place (you), never as an
  administrator, SYSTEM or root; refused if that account cannot. Only exception:
  the guard undoing its own take-away on the same object, identity incl.
  generation and link count 1 matched through the checked handle. "Make it mine"
  copies as you. The link rules now cover every folder another account can write.
- §4.2/§14: the Linux polkit agent runs as you and can be replaced, so it counts
  as the hardened confirm only where the build proves it cannot; otherwise those
  items are refused, as on X11. Where the hardened confirm is refused, the way
  back is re-running the admin install.
- One-line minors: restore and Keep "as you", waiting for sign-in without a
  token; acting window state in §15; wording at §3.1 reach row, §4.2, §14 links
  row, §17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby
scuttletoby marked this pull request as ready for review November 14, 2026 03:09
@scuttletoby
scuttletoby merged commit 8a719ea into main Nov 14, 2026
23 checks passed
@scuttletoby
scuttletoby deleted the fork/host-tools-design branch November 14, 2026 03:09
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