Conversation
shawnm-anthropic
previously approved these changes
Aug 18, 2026
denyRead on ~/.ssh does not cover keys living elsewhere, and ssh configs routinely point IdentityFile/CertificateFile at them. At initialize (and updateConfig), parse ~/.ssh/config — Include chains bounded by depth/file-count/cycle checks; only statically-knowable tokens expanded — and append read denies for IdentityFile, CertificateFile, ControlPath, and IdentityAgent targets, plus ssh's default key filenames. Append-only: never narrows configured protection, and a malformed config never fails initialization. The paths ride the credential deny-read channel (getCredentialDenyReadPaths) — the documented choke point — so every backend enforces them: the POSIX seatbelt/bwrap assembly, the Windows ACL stamp set, and the per-exec path. Targets are denied whether or not they exist yet (a ControlPath mux socket appears only when the first master connection opens; every backend tolerates absent deny entries). Candidates containing glob metacharacters are skipped with a warning rather than mis-compiled as globs. To make a key readable, allowRead its exact path (directory allowRead does not override a file-specific deny).
…t tests Appending ssh's default key names when no .ssh directory exists at all would placeholder-materialize a .ssh skeleton into every Windows profile that never used ssh; gate the defaults on the directory (names within an existing .ssh stay denied before the keys exist, so mid-session keygen remains covered). credential-deny's exact-set assertions picked up the runner's real ~/.ssh via the new protection: point the scan at an empty temp home through a _test seam — bun's os.homedir() reads passwd, not $HOME, so an env-var swap cannot make the scan hermetic.
The choke-point routing through getCredentialDenyReadPaths had a platform hole: getCredentialRestrictions early-returns for configs with no credentials block, so POSIX assembly silently dropped every ssh deny in the common credentials-less config while Windows (which calls the function directly) kept them. The ssh set is now its own NAMED source: a third arm in unionDenyReadPaths (POSIX), an explicit union in computeWindowsFsAccessSet (session stamp), and its own sshFiles field in the updateConfig staleness inputs. getCredentialDenyReadPaths is pure again, which also restores the per-exec fast path and the filesystem.disabled invariant (per-exec denies are credential-files-only; the ssh set is session-stamped). updateConfig re-scans the ssh config BEFORE the staleness compare (recomputing after made ssh drift invisible to the warning, then desynchronized silently); reset() clears the set. Platform fixes: glob-metachar checks use the platform-aware predicate (brackets are legal literal bytes on Windows — a bracket path is deniable there, and Include expansion now routes through expandGlobPattern); tilde expansion reuses the shared expandTilde (gaining the Windows `~\` form) with an injectable home; id_xmss joins the default identity list; on Windows, absent targets are skipped (srt-win placeholder-materializes absent deny targets, and planting zero-byte files at default key names inside a real ~/.ssh makes OpenSSH try to load them and fail) while POSIX keeps deny-before-exists. Hygiene: single test seam (_test.homedirOverride; the parameter is gone), filesParsed derived from visitedFiles.size, mkdtempSync in tests, contract docs rewritten to match behavior.
ig-ant
force-pushed
the
ig/ssh-config-read-exclusions
branch
from
September 23, 2026 20:54
f9eb1a2 to
df2e569
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
denyReadon~/.sshdoesn't cover keys the ssh config points at elsewhere. At initialize/updateConfig the runtime now parses~/.ssh/config(Include chains, bounded: depth ≤ 8, ≤ 64 files, realpath cycle detection; only statically-knowable tokens expanded —~,%d,%u,%%; connection-scoped tokens skip the entry) and appends read denies forIdentityFile,CertificateFile,ControlPath, andIdentityAgenttargets, plus ssh's default key filenames. Append-only and fail-open on malformed configs (protection can only widen; init never fails).Placement: the collected paths ride the credential deny-read channel (
getCredentialDenyReadPaths— the file's documented choke point for "paths this config wants read-denied"), so all three enforcement surfaces get them: POSIX seatbelt/bwrap assembly, the Windows ACL stamp set, and the per-exec path. Targets are denied whether or not they exist yet — aControlPathmux socket only appears when the first master connection opens, and every backend tolerates absent deny entries (seatbelt subpath denies, Linux log-and-skip, Windows placeholder materialization). Candidates containing glob metacharacters (legal filename bytes) are skipped with a warning rather than mis-compiled as globs. Agent-socket rationale: anIdentityAgentsocket signs with every loaded key — strictly more credential-equivalent than any single key file. Provider libraries (PKCS11Provideretc.) are deliberately excluded (public code, not secrets).Test plan
23 unit tests (
test/sandbox/ssh-config-deny.test.ts): parsing, Include recursion/globs/cycles, token expansion and skips, deny-before-exists, IdentityAgent, glob-metachar skip,IdentityFile none. Neighboring suites (wrap-with-sandbox, allow-read) green: 79 pass / 0 fail; tsc + eslint clean. E2E on macOS: built CLI with a staged fake HOME — an ssh-config-referenced key outside~/.sshand~/.ssh/id_ed25519both read "Operation not permitted" inside the sandbox while a sibling file reads normally.