Skip to content

account: home comes from the account, read only from a pinned getent or snug's own passwd (closes #641) - #646

Merged
vyskocilm merged 15 commits into
mainfrom
feat/641-home-pwdir
Oct 11, 2026
Merged

vyskocilm merged 15 commits into
mainfrom
feat/641-home-pwdir

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Closes #641. Stacked on #642 (#637), which provides trustedParent. The base is fix/637-bind-by-fd; retarget to main after #642 merges.

Why

snug took its home from $HOME while every other account fact came from getent, so the two could disagree. HOME=/etc ran with exit 0 and let the payload write /etc/ld.so.preload, which the next dynamically linked program honoured (measured by sandbox-policy).

Rule (maintainer: refuse mismatch, unset HOME takes pw_dir, pinned getent, test fixture build, nested reads snug's own passwd)

  • Account source.
    • Host: getent only at /usr/bin/getent or /bin/getent. It must be root-owned with no group/other write all the way down (policy.TrustedExecutable over authorableLinks + trustedParent) and is run with an empty environment. $PATH is never searched.
    • Inside a snug sandbox (single-line uid map) snug parses the generated /etc/passwd itself, on the descriptor it reads. It must be a regular file owned by the user, on a read-only tmpfs mount on the same device as the root tmpfs, and contain exactly one 7-field line with uid/gid matching.
    • A plain user namespace refuses ("not a snug sandbox"). A nested run whose outer selection has no nss refuses and names nss = true.
  • Home is pw_dir.
    • A $HOME that resolves elsewhere refuses: $HOME "X" is not this account's home directory "Y" (getent passwd); snug takes the home from the account. Unset HOME, or set HOME=Y.
    • Unset HOME takes pw_dir.
    • A home that is a system tree (/ /bin /boot /dev /etc /lib /lib64 /proc /run /sbin /snug /sys /tmp /usr /var) refuses.
    • The lookup runs before profiles load. HostAccountErr is gone.
  • claudeFiles uses pol.Home, and the dry-run basename fallbacks are gone.
  • --dry-run notes gain snug: account: <name>, home <home> (from <source>).

Tests

  • bin/snug-fixture (-tags snugfixture, SNUG_FIXTURE_ACCOUNT_HOME) replaces only the home for integration fixtures. TestShippedBinaryHasNoAccountFixture checks the untagged binary lacks it (the tagged build has it as a control), and only the fixture/gate Makefile recipes name the tag.
  • Raw-userns harnesses (engine preflight P2, two DNS tests, outer pidns) build a snug-shaped authored passwd. Each still asserts its original subject plus (from snug's generated /etc/passwd).
  • policy: AccountHome cases, the system-tree table (15 entries hard-coded), the TrustedExecutable table (18 cases, including 65534).
  • getent: ChooseSource, AuthoredMountRule (measured snug mountinfo, plus a later bind over /etc), strict parser, owner/symlink/FIFO, never $PATH, empty environment.
  • cli: lookup failure refuses before profiles load; HOME mismatch; claude mounts under pol.Home.
  • integration: HOME mismatch 77; unset HOME follows the account; system-tree home 77; nested dry-run uses the outer home; nested unset HOME; nested changed HOME 77; nested without nss 77; plain userns 77; shadowed authored passwd 77 (--no-seccomp, payload binds over /etc; positive control: getent shows the forged home); nested real run under the default seccomp pinned to fork/exec /proc/self/exe: operation not permitted.
  • TestQuietRunsAreQuiet: the NOTES block may contain exactly the account line.
  • Mutations: ChooseSource always authored, TrustedExecutable accepting 65534, and the mount check made inert each fail named tests.
  • Golden diff: none.

Verification

Residuals

  • XDG_CONFIG_HOME still picks the trusted profile directory, and XDG_DATA_HOME the engine store (environment authority).
  • config, profile show and doctor still read $HOME unchecked.
  • NixOS and plain user namespaces refuse.
  • A real nested run needs the outer run to use --no-seccomp, and under it a payload with its own tmpfs root can choose an inner home. That grants nothing beyond the outer view.

🤖 Generated with Claude Code

vyskocilm and others added 12 commits October 11, 2026 11:25
…ing symlinks (closes #637)

bwrap bound grants by PATH, so a concurrent sandbox with rw on a parent
could rename a symlink onto a resolved grant path between resolve and
bind: measured 21 of 200 runs reading an ungranted file.

Every non-/proc KindBind now compiles to --ro-bind-fd/--bind-fd from an
fd snug opens at launch with openat2(O_PATH|O_CLOEXEC,
RESOLVE_NO_SYMLINKS); a component turned into a symlink refuses the run,
and a later swap cannot move the fd. /proc binds stay path form (a fresh
procfs makes the fd's superblock differ; nothing renames inside procfs).
No openat2 or no --ro-bind-fd in bwrap refuses, no fallback. Stage
netns fds move to 250-252 for the budget; the runtime dir is
canonicalised; --dry-run lists the bind sources and JSON gains
bind_sources.

Identity keys: the walked destination is stored and read with
hostread.RequiredNoLinks. authorableLinks now trusts a root-owned link
only in a root-owned directory not writable by group or others, since
rename(2) keeps the owner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(refs #637)

#636's rootOwnedLinks made the test's own symlink root-owned; #637's rule
also requires the link's directory to be root-owned and not group- or
other-writable, so the fake reports directories that way too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nto #632 (refs #637)

The browser FIFO is a KindBind like any other, so it now compiles to
--bind-fd; TestDryRunArgvCarriesTheBrowserFIFOBind finds it through
bwrap.bind_sources. bwrap.claude-login.txt and json.browser-bridge.diff
regenerated: only bind lines and fd numbers moved. A FIFO bound by an
O_PATH fd carries data host-ward, checked with bwrap directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r binds (refs #637)

loginArgvOps rewrites --*-bind-fd N guest to the host bwrap.bind_sources
names for N, cross-checked against the document's mounts, so the
two-selection diff ignores shifting fd numbers but still catches a real
extra change; loginDryDests counts fd binds as required mounts. Each
check fails on a wrong bind_sources host or access (mutation-checked).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…or snug's own passwd (closes #641)

snug took its home from $HOME while every other account fact came from
getent, so two homes could disagree, and HOME=/etc gave the payload a
writable /etc (ld.so.preload honoured by the loader).

- Account source: getent only at /usr/bin/getent or /bin/getent, root-
  owned all the way down (policy.TrustedExecutable over authorableLinks
  and trustedParent), run with an empty environment; never $PATH.
  Inside a snug sandbox (single-line uid map) snug parses its own
  generated /etc/passwd, accepted only as a read-only tmpfs file on the
  root's device, owned by the user, checked on the descriptor it reads.
  A plain user namespace, or a nested run without nss, refuses.
- Home: getent's pw_dir. A $HOME resolving elsewhere refuses and names
  the fix; unset HOME takes pw_dir. A home that is a system tree refuses.
  The lookup runs before profiles load; HostAccountErr is gone.
- claudeFiles uses pol.Home; the dry-run basename fallbacks are gone.
- Tests: bin/snug-fixture (-tags snugfixture) replaces only the home for
  integration fixtures; a guard proves the shipped binary lacks it.
  Raw-userns harnesses build a snug-shaped authored passwd.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#641)

Inside a user namespace / reads as the overflow uid with mode 0555, and
'not owned by root or can be written by group or other (mode 0555)' read
as if the mode were the problem. The refusal now names the one condition
that failed; TestTrustedExecutable asserts each wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t yet (refs #637)

canonicalRuntimeBase resolves $XDG_RUNTIME_DIR with EvalSymlinks, which
fails where the directory does not exist; CI pins /run/user/1000 and has
none, so every dry run naming a socket (agent, FIFO, door, podman)
refused. plannedSocket is a label and now falls back to the unresolved
base on ENOENT; the real run's openRuntimeDir still refuses there.
TestPlannedSocketWithAMissingRuntimeBase fails without the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…kips the --ro-bind-fd probe (refs #637)

#637 has each process run `bwrap --help` once to check --ro-bind-fd.
Under @net that probe runs in the stage, and
TestASignalWhileThePayloadIsParkedNeverReleasesIt/net could catch it,
SIGSTOP the stage, and wait for a sandbox the stopped stage never forks
(engine CI: 'PRECONDITION: the parked sandbox pid 1 never appeared').
isSandboxBwrap skips a bwrap whose argv carries --help; the nested-pidns
kill test, which counts bwraps, uses it too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…efs #637)

Engine CI still failed the parked-signal /net subtest after e7e139a: the
--ro-bind-fd probe, once exited and not yet reaped, is a zombie named
bwrap whose cmdline reads empty, so the --help check never saw it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Base automatically changed from fix/637-bind-by-fd to main October 11, 2026 10:54
vyskocilm and others added 3 commits October 11, 2026 13:01
…missing (refs #641)

CI failed TestShadowedAuthoredPasswdIsRefused: the forged file sits on
ext4 there, so the mount rule refused with 'it is on a ext4 mount, not
tmpfs' where the test pinned 'not read-only' (tmpfs here). Both are the
rule refusing; the test now accepts any of its reasons. The refusal
itself said 'this run's profiles generate none; select ... nss = true',
the fix for a MISSING file: a file that is present but not snug's now
reads 'the file there now is not it (<reason>); snug will not read an
account from it'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vyskocilm
vyskocilm merged commit 053b971 into main Oct 11, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the feat/641-home-pwdir branch October 11, 2026 19:19
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.

home: derive snug's home from getent pw_dir instead of $HOME?

1 participant