Skip to content

identity.ssh: sandbox ssh never laxer than host ssh for unpinned hosts (fixes #697) - #702

Merged
vyskocilm merged 2 commits into
mainfrom
fix/697-ssh-unpinned
Oct 26, 2026
Merged

vyskocilm merged 2 commits into
mainfrom
fix/697-ssh-unpinned

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Fixes #697. Book changes not in this PR; facts listed on #700.

Change

  • System ssh_config replacement written whenever sandbox's own ssh (first ssh on sandbox PATH, via Policy.SandboxBinary) is inside, not only where bind supplies host file. Debian/Fedora shape: /etc/ssh/ssh_config written on root tmpfs (system-ssh-etc.bwrap.txt golden). Bind arm unchanged: bound existing host file always replaced (main's rule).
  • Probe asks sandbox's ssh. Host PATH ssh != sandbox ssh: re-probe (ssh -G -vvv, pin probe too) with sandbox binary, re-resolve once, refuse if binary changes again.
  • Refuse when sandbox ssh is writable by this run (Policy.SandboxWritesHost): re-probe runs it on host.
  • First-pass probe refusals held until sandbox ssh known (heldSSHErrs).
  • Host global RevokedHostKeys read (hostread.Required, relative path refuses), staged /snug/revoked_host_keys, named in generated file. Host path never written into file.
  • Profile mount under /snug refused regardless of host KRL (Policy.Replace no longer displaces it; refusals.txt golden: /snug/engine wording).
  • Tmpfs arm writes only top-level config (-vvv Including file lines mark includes).
  • Per-Host/Match blocks for unpinned hosts NOT carried (maintainer choice). Disclosed in generated file + --dry-run, incl. negated pattern (Host * !x, Match !host x) matching probe name and so applying to x inside.
  • --dry-run SSH block: REPLACED vs WRITTEN per path (Policy.SystemSSHReplaced), sandbox ssh row, KRL host path, "no system-wide ssh_config written" row.

Tests

  • Unit: binary predicate, KRL staging (nil / empty / bytes), F5 re-probe once + change refusal, writable-ssh refusal (direct rw, host symlink into rw, snug symlink grant into rw), held pin refusal yields to sandbox ssh, relative KRL refusal on re-probe, top-level-only on tmpfs, REPLACED/WRITTEN over profile tmpfs, /snug/revoked_host_keys mounts refused with/without KRL, dry-run rows. Each negative-controlled.
  • Integration test/integration/sshunpinned_test.go: unpinned KRL applied inside, Host block not carried (residual held), negated block applies to excluded host, sandbox ssh probed not host PATH's, pin asked of sandbox ssh, unpinned revoked key refused.
  • Harness: suite ssh wrapper now sandbox's ssh in every selection (suite-ssh profile); adds suite known_hosts only when SNUG_SUITE_KNOWN_HOSTS set (probe inherits host env, sandbox env empty). Audit: github.com removed from suite known_hosts, every identity.ssh test failed, so none reads real known_hosts. Right-target test TestTheSuitesSSHIsTheSandboxsAndPlainInside.

Runs (openSUSE, OpenSSH_10.6p1)

  • make gate rc=0.
  • SNUG_REQUIRE_SANDBOX=1 make integration rc=0; 61 SKIP, engine tests (host podman prints no version), same set as origin/main.
  • make verify 5 passed, 4 skipped, 0 failed.
  • Debian/Fedora /etc/ssh shape not measured on real host: golden + unit only; ubuntu CI exercises it.

Red team

3 rounds before PR.

  • R1: F1 negated-pattern false disclosure (medium), F2 ssh bound at other path, F3 wrapper on host PATH regression vs main (15 integration tests died Bad owner or permissions on /usr/etc/ssh/ssh_config.d/50-suse.conf), F4 pinned clause wording. Fixed.
  • R2: F5 probed wrong binary (inside requiredrsasize 1024, revokedhostkeys none; host 3072 + KRL; dry-run claimed WRITTEN) (medium), F6 node per Include'd ssh_config, F7 REPLACED inferred from provenance, F8 /snug refusal host-dependent. Fixed.
  • R3: no confirmed findings. Canary never written: snug never executed a file the run can write. Dry-run KRL row lacked source path; fixed.

Residual

  • Host/Match blocks for unpinned hosts, by choice; disclosed.
  • Profile-supplied ssh binary can make snug stage any host file it names as RevokedHostKeys. Trusted author only (could ro bind same file); disclosed on --dry-run.

🤖 Generated with Claude Code

fixes #697)

The system-wide ssh_config replacement is written whenever the sandbox's
own ssh (the first ssh on the sandbox PATH) is inside, not only where a
bind supplies the host's file. On a host whose system file is under
/etc/ssh, which @sys does not grant, the sandbox's ssh ran on OpenSSH's
compiled-in defaults.

- The probe asks the sandbox's ssh. When the host PATH ssh is a
  different file, snug re-probes with the sandbox's binary (global and
  pin) and resolves once more. It refuses when that binary is writable
  by this run, because the probe runs it on the host.
- First-pass probe refusals are held until the sandbox's ssh is known.
- The host's global RevokedHostKeys is staged at /snug/revoked_host_keys
  and named in the generated file. A profile mount there is refused
  whatever the host's config says.
- On a tmpfs, only the top-level config is written (-vvv "Including
  file" lines identify the included ones).
- Per-Host and Match blocks for unpinned hosts are not carried. This is
  the maintainer's choice, and --dry-run and the generated file say so,
  including that a negated pattern (Host * !x) applies to x inside.
- --dry-run says REPLACED or WRITTEN per path, names the sandbox's ssh
  and the KRL's host file, and states when nothing was written.

The integration suite's ssh wrapper is now the sandbox's ssh in every
selection, and adds the suite known_hosts only for snug's host-side
probe. No pinned test reads the account's real known_hosts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OpenSSH on the ubuntu-latest runner omits the revokedhostkeys line when
none is set; OpenSSH 10.6 prints "none". TestTheSandboxsSSHIsProbedNotTheHostPATHs
refused its own fixture on CI with revokedhostkeys "".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vyskocilm
vyskocilm merged commit e787a86 into main Oct 26, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the fix/697-ssh-unpinned branch October 26, 2026 12:48
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.

Sandbox ssh: unpinned hosts lose per-Host blocks, global RevokedHostKeys, and (on /etc/ssh hosts) the system config

1 participant