Skip to content

redteam: #582's own INDEX.md corrections were wrong, twice - #583

Merged
vyskocilm merged 1 commit into
mainfrom
redteam/582-index-corrections
Sep 17, 2026
Merged

vyskocilm merged 1 commit into
mainfrom
redteam/582-index-corrections

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Red-team round on #582, commissioned before it merged, landing after. The rename held; the prose #582 added did not, and it is now on main.

What held

Resolved policy bit-identical across the rename — every builtin selection and every combination tried, --dry-run --json, mount set, access bits, environ, full bwrap argv. Generated ~/.gitconfig byte-identical inside a real sandbox with credential.helper = !/bin/evil planted in the host config and correctly not carried.

No name collision: git is not in subcommands; a user profile named git and the builtin @git are distinct map keys (-p git selects the user's, -p @git the builtin); a user profile named @git is refused at parse by checkName; no code matches on a profile-name literal other than @claude. The engine store key is targetkey.Hash(target), so the rename orphans no persistent host state.

Retired-name lookup held against nine shapes: -p, defaults =, include chains, profile show/tree/dot, case variants, forging runes, a user-defined git-ro, and a git-ro in a file with a TOML syntax error (refused earlier and louder, so the notice never gets a chance to misdirect).

F1 — §9.5 said one generated file under ~/.config. Two, and the omitted one holds a token

~/.config/gh/hosts.yml, 0600, holding a GitHub OAuth token minted on the host by gh auth token (internal/cli/identity.go, dir := pol.Home + "/.config/gh"), under a (snug anchor) tmpfs at .config/gh.

authored() in dryrun.go exists BECAUSE a --dry-run sentence about that exact path was already false for the same reason — its own comment: "Both were true of ~/.config/gh until identity staged a generated hosts.yml there". #582 reintroduced it one file over. Second time at one path.

The sentence two lines above it in §9.5 names ~/.config/gh/hosts.yml as the canonical example of where applications keep tokens.

F2 — §"deepest mount" and RULE 2 asserted a mechanism the code contradicts

#582 wrote that everything inside @home's tmpfs is "all KindData that snug authors, not binds a profile expresses", and attributed it to validate.go. validate.go says the opposite and names both halves:

// The KindTmpfs row is not a convenience. Everything snug puts into the
// ephemeral $HOME sits inside @home's tmpfs — @claude's read-only binds of
// {home}/.claude/skills and {home}/.claude/plugins, and every generated
// KindData file ...

Worse than a typo. RULE 3 exempts every Authored mount unconditionally (if m.Authored { continue }), so if the tmpfs cases really were all authored, RULE 2's KindTmpfs row is dead code — and a reader trusting the INDEX sentence has an argument for deleting it. Deleting it makes rejectMasking refuse @claude's ro ~/.claude/skills inside tmpfs ~/.claude on the first invocation.

F3 — pre-existing, in a paragraph #582 edited

internal/profile/file.go's comma row and INDEX both claimed engine.New comma-joins profile names into the container store key, "two consumers, and only one of them ever had a rule written for it". It does not. engineKey is targetkey.Hash(pol.Target) (internal/engine/paths.go); issue #276 took the profile selection out; grep -rn "Profiles" internal/engine/ over non-test files returns nothing. SNUG_PROFILES is the only comma join of names in the tree. Grammar rule stands on that alone.

F4 — a VERIFY assertion that cannot fail

grep -o '@git\b'. \b is a word boundary, - is a non-word character, so it matches inside @git-ro:

$ echo "  @git-ro          desc" | grep -o '@git\b'
@git

Prints @git whether or not the rename happened. Replaced with grep -c '^ @git '; measured 0 and 1 on this host. The block now also states that both greps run against the real XDG_CONFIG_HOME while the two below it are hermetic.

Two permanent regression tests, both negative-controlled

Prose that went wrong twice at one path is prose nothing was holding.

TestTheGeneratedConfigInventoryIsExactlyTwoFiles (internal/cli) walks p.Mounts for every Guest under pol.Home + "/.config" and pins the set: {.config/gh/hosts.yml} from a stubbed tokenMinter, joined by .config/git/allowed_signers once identity.git.signing_key is set. Failure message says INDEX §9.5 carries the inventory in prose and must be edited in the same change. Teeth verified by repointing the hosts.yml Guest to /tmp/hosts.yml — fails with the expected message.

TestClaudeSkillsIsABindNestedInsideHomesTmpfsNotAuthoredContent (internal/policy, no privileges) resolves @sys @home @target-rw + a @claude mirror and asserts ~/.claude/skills and ~/.claude/plugins are KindBind with Authored == false, after a positive control that /home/u really is KindTmpfs. Teeth verified by making checkNesting's case KindTmpfs return an error — fails with masking refused under KindTmpfs at /home/u.

NOT fixed here — needs a ruling, carries its reproduction

F5, pre-existing, reproduces byte-identically on 83f0da2. OpenSSH resolves the per-user config from getpwuid()->pw_dir, not $HOME. @sys binds the host's /etc/passwd, so inside the sandbox pw_dir is the host's real home path. snug writes the generated config at pol.Home + "/.ssh/config". Where the two differ, ssh reads a path that does not exist inside and silently falls back to compiled-in defaults.

passwd home: /home/michal
HOME=/tmp/scratch/home
--- with -F explicit:
identitiesonly yes
identityfile /tmp/scratch/home/.ssh/id_snug.pub
userknownhostsfile /tmp/scratch/home/.ssh/known_hosts
--- default (no -F):
6          <- the six compiled-in defaults; the pin is not in effect

Lost: IdentitiesOnly yes, the pinned IdentityFile, UserKnownHostsFile (so known_hosts is the nonexistent default and StrictHostKeyChecking accept-new reverts to ask). --dry-run shows data ~/.ssh/config identity:<profile> and the SSH block as though the pin were live. Invariant 5. The key pin still holds through the agent proxy, so this is a loss of the config-level pin, not of the key pin.

It also explains the one integration FAIL, TestSSHConfigDirectoryCannotBeRenamedAway (test/integration/targetrepoint_test.go), which asserts the pin by reading ssh -G with no -F and so depends on $HOME equalling pw_dir. Identical failure on the origin/main control — not a regression.

Refuse on the mismatch, warn, or document? The test fixture wants -F "$HOME/.ssh/config" or a skip either way.

Not settled on this host, plainly

The @net host-loopback negative with -p @git selected: timed out at 60s on the pasta path, killed by pid rather than widening the budget. Covered by the committed integration suite; the rename cannot plausibly reach it. /etc/snug/profiles.d as a second layer is absent on this host, so every profiles.d case exercised only the XDG layer.

make gate green.

🤖 Generated with Claude Code

… to #582)

Round on #582, run before it merged and landing after it. The rename held — resolved policy bit-identical across it for
every builtin and every combination tried, generated ~/.gitconfig byte-identical
with `credential.helper = !/bin/evil` planted in the host config and correctly
not carried, no name collision, and the retired-name lookup held against nine
attack shapes including include chains and an unreadable profile file. What it
broke was the prose #582 added, which is now on main.

F1. INDEX §9.5 said ONE file is generated under ~/.config. Two are, and the
omitted one is the one that matters: ~/.config/gh/hosts.yml, 0600, holding a
GitHub OAuth token minted on the host by `gh auth token`
(internal/cli/identity.go, `dir := pol.Home + "/.config/gh"`), under a
(snug anchor) tmpfs at .config/gh. `authored()` in dryrun.go exists BECAUSE a
--dry-run sentence about that exact path was already false for the same reason
— its comment says "Both were true of ~/.config/gh until identity staged a
generated hosts.yml there". #582 reintroduced the same false claim one file over. Second time at the same path, which is the argument for mechanising it.

F2. INDEX said the things inside @home's tmpfs are "all KindData that snug
authors, not binds a profile expresses", and attributed that to validate.go.
validate.go says the opposite and names both halves: @claude expresses `ro`
binds at ~/.claude/skills and ~/.claude/plugins inside that tmpfs. Worse than a
typo — RULE 3 already exempts every Authored mount (`if m.Authored { continue }`),
so the sentence licenses deleting RULE 2's KindTmpfs row, and deleting it makes
rejectMasking refuse @claude's skills bind on the first invocation.

F3, pre-existing, in a paragraph #582 edited: file.go's comma row and INDEX
both claim `engine.New` comma-joins profile names into the container store key,
"two consumers". engineKey is targetkey.Hash(pol.Target) and nothing else
(internal/engine/paths.go); issue #276 took the selection out, and
`grep -rn Profiles internal/engine/` over non-test files returns nothing.
SNUG_PROFILES is the only comma join of names in the tree. The grammar rule
stands on that alone; the false second consumer is gone.

F4, mine: VERIFY's `grep -o '@git\b'` cannot fail. `\b` is a word boundary and
`-` is a non-word character, so it matches inside @git-ro and prints @git
whether or not the rename happened. Replaced with `grep -c '^  @git  '`.
Measured on this host: 0 and 1. The block also now says plainly that both greps
run against the real XDG_CONFIG_HOME while the two blocks below it are hermetic.

Two permanent regression tests, both negative-controlled, because prose that
went wrong twice at one path is prose nothing was holding:

  TestTheGeneratedConfigInventoryIsExactlyTwoFiles (internal/cli) walks
  p.Mounts for every Guest under pol.Home + "/.config" and pins the set —
  {.config/gh/hosts.yml} from a stubbed tokenMinter, joined by
  .config/git/allowed_signers once identity.git.signing_key is set. Its failure
  message says INDEX §9.5 carries the inventory in prose and has to be edited in
  the same change. Teeth verified by repointing the hosts.yml Guest to
  /tmp/hosts.yml: fails with the expected message.

  TestClaudeSkillsIsABindNestedInsideHomesTmpfsNotAuthoredContent
  (internal/policy, no privileges) resolves @sys @home @target-rw + a @claude
  mirror and asserts ~/.claude/skills and ~/.claude/plugins are KindBind with
  Authored == false, after a positive control that /home/u really is KindTmpfs.
  Teeth verified by making checkNesting's `case KindTmpfs` return an error:
  fails with "masking refused under KindTmpfs at /home/u".

NOT fixed here, needs a maintainer ruling and carries its reproduction (F5,
pre-existing, reproduces byte-identically on 83f0da2): OpenSSH resolves the
per-user config from getpwuid()->pw_dir, not $HOME. @sys binds the host's
/etc/passwd, so where $HOME differs from the passwd home, ssh never reads the
config snug generated — losing IdentitiesOnly yes, the pinned IdentityFile and
UserKnownHostsFile, so StrictHostKeyChecking accept-new reverts to ask. Silent;
--dry-run shows the row as though the pin were live, which is invariant 5. The
key pin still holds through the agent proxy, so it is a loss of the
config-level pin and not of the key. It also explains the one integration FAIL,
TestSSHConfigDirectoryCannotBeRenamedAway, which asserts the pin by reading
`ssh -G` with no -F and so depends on $HOME equalling pw_dir; identical failure
on the origin/main control, so not a regression from #582.

make gate green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vyskocilm
vyskocilm merged commit f85489d into main Sep 17, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the redteam/582-index-corrections branch September 17, 2026 19:21
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