Skip to content

Environment variables: the environ verbs, and the sanitise kind rule - #10

Merged
vyskocilm merged 47 commits into
mainfrom
env-implement
Aug 12, 2026
Merged

Environment variables: the environ verbs, and the sanitise kind rule#10
vyskocilm merged 47 commits into
mainfrom
env-implement

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Supersedes #8, which carried the design document alone. Every commit on that
branch is contained here, and the one defect it recorded as open is fixed below.

What this is

environ replaces the two ad-hoc keys (env, path) with five verbs under one
section, and gives the environment the same treatment the mount model already
has: one resolved policy, structural bands, conflicts that name every claimant,
and a --dry-run block that is the review artifact.

[profile.sys.environ.merge]
PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]

[profile.home.environ.set]
XDG_CONFIG_HOME = "{home}/.config"

[profile.claude.environ.inherit]
ANTHROPIC_BASE_URL = true

The design is .claude/design/ENVIRONMENT-VARIABLES.md; the twelve-step plan it
was built from is .claude/design/ENV-IMPLEMENTATION-PLAN.md.

Three bugs the design measured on main are closed here, each named in the
document with the commit that closed it: a variable set to the empty string was
silently dropped (which inverts the meaning of every flag variable), one
unparseable file in profiles.d took down the entire registry including the
builtins, and PATH entries were not deduplicated.

The security-relevant part

Three findings, all confirmed by execution, all fixed with permanent named tests.

Transport hooks passed where their own equivalents were refused. GIT_SSH
passed while GIT_SSH_COMMAND — the same capability, newer spelling — was
refused two entries above it in the same table. The red team hijacked a real
git fetch in a sandbox whose ssh identity a different profile had pinned,
from a profile granting no filesystem path at all. Nine names added; the rule is
not "the newest spelling", it is the value is code.

sanitise carried a shadow slot onto the PATH snug itself writes. A host
PATH element under /tmp survived the filter, because the filter asked "is
this granted" and /tmp is granted — as an empty writable tmpfs. The payload
creates the directory and drops a file named git, and the next git a human or
another agent runs inside is that file. An element whose deepest covering mount
is a tmpfs is now dropped.

The wider fix — drop anything writable — was considered and rejected, and the
reasoning bounds what this claims: the payload can rewrite PATH at will, so no
filter closes shadowing. What the filter owes is that the environment snug
itself
hands over does not ship the shadow slot pre-installed. A writable
bind still survives, target included.

/proc's magic symlinks reopened it. Found after the above had shipped and
claimed the class was closed. KindProc and KindDev were kept as "kernel- and
bwrap-populated, not empty" — true of the directory, false of what the magic
links resolve to. The walk is lexical and stops at /proc; the kernel walks
/proc/self/cwd to the target, where the shadow binary also persists to the
host. Both kinds now drop. The generalisable form is in TODO.md: a lexical
predicate answers about the path it was handed, the kernel answers about the path
it resolves, and wherever those differ is the attack.

A fourth, low: a newline in a host PATH element forged a row in the --dry-run
ENVIRONMENT block. Escapes nothing, but that screen is the trust artifact.

Reviewing this

internal/policy/testdata/sanitise.bwrap.txt is the new golden and it exists so
the security diff is one readable line — the --setenv PATH operand, with the
shadow slots absent. No existing golden moved, which is itself a claim the tests
check rather than assume.

Every regression test here was verified to fail against the pre-fix code
before being accepted. VERIFY.md gains the by-hand /proc/self/cwd
reproduction.

make gate green. make integration green with SNUG_REQUIRE_SANDBOX=1 SNUG_TEST_NET=1.

Known gaps, recorded not carried

TODO.md gains the residual shadow slots this deliberately does not close
(@claude's merged {home}/.local/bin, @tmp-shared making /tmp a real
bind, a profile's own KindSymlink), the note that sanitise's monotonicity now
rests on rejectMasking, and the flat environ = { set = { ... } } form that
parses with this go-toml and would not with a stricter one.

🤖 Generated with Claude Code

vyskocilm and others added 30 commits August 10, 2026 10:29
… a trap

A reviewer asked whether `@stubs` being a switch with a hardcoded directory
means "insert a value into a variable" is unavailable to user profiles. The
short answer is "mostly, and inconsistently". Working out why produced this.

Measured against main, not against a branch -- an earlier draft measured an
unmerged branch and reported a defect that does not exist here. What survived
that check is §2, and it is worth more than the bug report was: a branch that
authored the five XDG variables did so unconditionally in Resolve, while the
directories they name come from `@home`. Select without `@home` and the
variables survive naming nothing. main authors no XDG variable, so this is not
a defect here -- it is a trap for whoever implements them, and the obvious
implementation walks straight into it.

§3 is the reviewer's second note and it reorganises the problem: variables are
typed even though they are all char*. Scalars join by equality-or-conflict and
have no order to get wrong, which confines the entire ordering question to the
list type. Semi-structured values (PS1-PS4, PROMPT_COMMAND, LS_COLORS) have no
join at all, which is what `forbiddenEnv` has been expressing as a blocklist --
restating it as a type judgement says what else belongs there without waiting
to be bitten. The class x type table has illegal cells, and the holes are the
specification.

§3(d) is a live hazard for the sanitise capability, measured:

  env -i PATH="/usr/bin:"      sh -c 'victim'  ->  PWD-BINARY-RAN
  env -i PATH="/usr/bin::/bin" sh -c 'victim'  ->  PWD-BINARY-RAN
  env -i PATH="/usr/bin:/bin"  sh -c 'victim'  ->  command not found

An empty element in PATH is the current working directory, which in snug is the
target -- the one place a hostile payload has full write access. Implement
"drop the ungranted elements" as a string replace and a feature sold as
tightening the environment creates a code-execution vector that did not exist.
The rule: rebuild the list from survivors, never edit the string; unset rather
than set empty. §9 makes it a named regression test with a positive control.

Also surveyed: makeWrapper, systemd.exec, flatpak, Lmod, the NixOS module
system, Nickel, CUE. Two conclusions independent of which candidate wins.
Every neighbour except CUE ships subtraction, so borrowing a vocabulary by
analogy imports the thing invariant 1 exists to prevent. And an expression
language would move the "no negation key" guarantee from syntax, where it is
checked by listing keys, to semantics, where it would have to be re-proved.

Recorded but not fixed, live on main: `forbiddenEnv` sits inside the "is it set
on the host" guard, so a profile carrying env = ["LD_PRELOAD"] is accepted on a
host where the variable is unset and refused where it is set (§1(e)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tests it

A reviewer asked for it to be explicit that `snug . -- podman` runs the command
using the sandbox's PATH rather than the host's. It does, and the property is a
precondition for the rest of the document: if the payload name were resolved
host-side, every ordering question here would be moot, `shadows` could not work,
and the podman stub would be decoration. bwrap --clearenv's, sets PATH with
--setenv, and execvp's inside the namespaces with its own modified environ, so
the lookup happens inside.

Measured four ways, all quoted in §1(f):

  PATH=/…/hostonly:$PATH snug . -- hostmarker
    bwrap: execvp hostmarker: No such file or directory
  snug -p tbin . -- ls
    SANDBOX-LS-RAN        # fake ls in a profile's path dir beat /usr/bin/ls

The second is the decisive one: it is `shadows` (§7C) already working, unnamed
and undeclared. Also recorded is a case that looks like a leak and is not -- a
host binary reached by absolute path ran, because @parent-ro grants the target's
parent read-only and the binary lived under it; the same directory returns
"Read-only file system" to touch, and a binary outside every grant does not run.

Two gaps fall out, both now in §8 as fixable now.

Nothing tests any of it. A refactor to host-side exec.LookPath would leave every
existing test green while resolving the payload's name in the wrong namespace --
and it would make the negative case SUCCEED, which reads like a feature. §9 asks
for the three-part test with its own positive control.

And the error is bwrap's, not snug's: "bwrap: execvp podman: No such file or
directory" is what a user gets for the most ordinary mistake in the tool, asking
for a command no profile granted. It names neither the sandbox's own PATH nor
the profile that would grant the binary. "Errors name the fix" is a working
agreement rule and this is the highest-traffic error there is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An independent reviewer re-ran every load-bearing measurement and read the code
behind every claim about snug itself. The measurements about the outside world
all reproduced. The failures were concentrated exactly where the author had
reasoned instead of executed -- claims about snug's own code and tests -- which
is the failure mode CLAUDE.md documents five times over.

Verified each before accepting it; all six hold.

§2 said "this is not a defect on main". Wrong, and it understated the problem.
main authors no XDG variable, which is true and is not the point: HOME is
assigned unconditionally at resolve.go:396 while the directory comes from
@home's tmpfs, and SHELL and PATH name things @sys grants. So the pattern is
already instantiated three times and already dangles:

  snug --dry-run --no-defaults -p @parent-ro . -- true
    HOME=/home/michal                     no @home: does not exist
    SHELL=/usr/bin/bash                   no @sys: does not exist

That turns §9's checklist item from a forward-looking nicety into a test that
fails on main today.

§3(b) claimed forbiddenEnv "is a type judgement in disguise". It is not. The
list on main holds no semi-structured name at all, while LD_LIBRARY_PATH is in
it and is a list; PROMPT_COMMAND, LS_COLORS and GIT_CONFIG_PARAMETERS all pass
through unrefused, measured. The two rules are orthogonal and both are needed --
forbiddenEnv is about what the value DOES, at any type; "semi-structured values
have no join" is a separate and currently unenforced type fact. Merging them
would have argued for adding PS1 and dropping LD_LIBRARY_PATH, both wrong.

§9 reported a missing commutativity test for the environment.
TestResolveIsCommutative already covers it -- canon() renders p.Env, and the
fixture carries a `path` entry deliberately so PATH assembly is covered. The
correction cuts both ways: §1(a)'s alphabetical sort is enforced, not accidental.

§3(e) called the sanitised-scalar cell meaningless. It is the degenerate
one-element case and the most useful application there is: inherit a host scalar
iff it names something the policy grants, else unset. That is the points-inside
check, and it is what repairs the three live instances above -- so excluding it
put the one class with a measured problem outside the scope of the one new
capability proposed.

§7F shipped two ways for a profile to subtract. `passthrough` and `sanitise`
over the same name lets profile B remove elements profile A's grant put in the
sandbox, which is invariant 1 with no deny key in sight; it needs to be a
resolve-time conflict or one key, not the ergonomics question §10 called it.
And `authors` needs a reserved namespace as its FIRST guard: authors = { PATH }
subtracts basePATH in one line, and SNUG_PROFILES / SNUG_TARGET would let a
profile lie to the artifacts a human reads to decide whether to trust the
sandbox. That is closed today only by accident of ordering (the prof.Env loop
runs before snug's own assignments), and an authors key would have to make the
accident deliberate.

Also recorded: env = ["PATH"] is accepted and silently discarded today -- no
error, no warning, nothing in --dry-run. Under "no silent downgrade, ever" that
should be a named refusal, and it is fixable in the same patch as §1(e).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he file

A second agent measured the variables a development sandbox actually meets and
classified them by type, separator, and what an empty element means. The result
is §3a, and it changes two conclusions the document had already drawn.

§3(d) said "unset rather than empty". Half right, and the other half inverts:

  env -i PATH="" ./execvp_probe   ->  PWD-BINARY-RAN   # empty = the cwd
  env -i         ./execvp_probe   ->  rc=7             # unset = confstr(_CS_PATH)
  env -i bash --noprofile --norc -c 'echo "${PATH-UNSET}"'
    /usr/local/bin:/usr/bin:/bin:.                     # bash's compiled-in default

For execvp unset is the safe floor; for bash it is worse, because bash
substitutes DEFAULT_PATH_VALUE, which on this build ends in ".". PATH has no
safe absent state at all.

And the discriminator for `sanitise` is not the type, it is the empty-element
semantics -- safe where an empty element is ignored, hazardous where it is the
cwd, and illegal where it is an OPERATOR. MANPATH is the last: a leading empty
element means "prepend the system manpath", so removing an element can add
directories. "Rebuild, never edit the string" is necessary and not sufficient
there.

The finding that outranks the rest is not about the environment language at all.
CLAUDE.md's "generate, don't bind" rule pins a tool's config FILE and leaves its
environment, which is a higher-precedence source. Measured:

  env -i GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
    GIT_CONFIG_PARAMETERS="'user.name'='StillInjected'" git config --get user.name
      StillInjected
  ... GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=user.name GIT_CONFIG_VALUE_0=AlsoInjected
      AlsoInjected

Re-verified by hand before recording. A hostile process inside the sandbox can
set GIT_CONFIG_KEY_0=core.sshCommand and have the next git fetch -- including one
an unsuspecting user or agent runs -- execute its command, with
GIT_CONFIG_GLOBAL pointing at a clean generated file. Not a break in the sandbox
boundary; the payload already runs code. It is a break in identity pinning,
which is the guarantee GIT_CONFIG_GLOBAL exists to make, and it wants its own
investigation.

forbiddenEnv measures both too wide and too narrow: PYTHONSTARTUP is in it and
does not fire for a non-interactive interpreter, while PYTHONPATH is not in it
and fires on every python3 via sitecustomize.py (measured). glibc's own
secure-execution strip list in ld.so(8) is the closest thing to an authoritative
denylist and should seed ours. Four vectors are name PREFIXES that a
map[string]bool cannot express at all.

Also corrected: §1(b) overstated the stub's gating -- it needs a detected
HostShims entry as well as p.Podman, so on a host where podman is a real binary
/run/snug/bin never appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ge rule

Replaces the CUE-syntax section, which was written in a notation nobody
implementing this has to read. Same semantics, expressed as the TOML snug
already parses.

The owner's sketch left one thing rough and said so: "lists like PATH can be
merged, while scalars like SHELL do not -- this is not very well expressed
here." It does not need expressing. TOML already has both types, so the value's
own type carries it:

  PATH  = ["/usr/bin", "/usr/sbin"]   array  -> list     -> merges (union, sorted)
  SHELL = "/usr/bin/bash"             string -> scalar   -> two different values is an error

No lookup table, no `type =` key, nothing to keep in sync. You say which one you
mean by how you write it.

That also closes the empty-element hazard by construction, which is the finding
this document spent the most measurement on. A profile never writes a separator,
so it cannot write "/usr/bin:" and cannot produce "/usr/bin::/bin" by dropping
an element from a string; snug joins with the right separator per variable and
refuses an empty element. §3(d)'s PATH-means-cwd hazard and §3a.2's
MANPATH-empty-element-is-an-operator problem both stop being things the
implementation has to remember. A string where a list is expected is a load
error rather than a coercion, or the separator returns through the front door.

The prepend rule is the owner's: once across the whole selected set, a second is
a failure. Cost stated plainly rather than buried -- @stubs-in-path is in
defaults, so it holds the slot on every ordinary run and a user profile wanting
it must displace it with --no-defaults or a defaults list.

inherit-env moves passthrough from profiles to config.toml, so selecting a
profile can no longer put a host credential inside; the human writes that line
for their own machine. Flagged honestly: CLAUDE.md says config holds preferences
and never grants, and this is that rule being amended rather than applied.

Also corrected from the sketch: XDG_CONFIG_DIR is XDG_CONFIG_HOME.
XDG_CONFIG_DIRS is a different variable -- plural, a list, defaulting to
/etc/xdg -- which is exactly the confusion the type table exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner asked for consistent naming, with the verb telling how the variable
is handled. Taken literally that gives five verbs, each with exactly one
meaning: environ-set (scalars), environ-merge (lists, union), environ-prepend
(lists, at most one across the selected set), and in config.toml
environ-inherit and environ-sanitise.

This replaces the previous draft's idea of letting the TOML value type imply the
operation. The verb is better for a reason that is not aesthetic: it is
greppable and a type is not. `grep environ-prepend ~/.config/snug/profiles.d/`
finds every ordered claim on the host in one command, and a reader does not need
to know whether PATH is a list to understand the line.

The type does not go away, it becomes a named error. snug ships the type table
from §3a, so environ-merge on SHELL and environ-set on PATH are both refused
with the verb to use instead. An unknown name defaults to scalar, which is the
conservative reading -- a scalar merges with nothing, so it can only conflict,
never silently combine.

The rule that kills the empty-element hazard survives intact and is now stated
as a rule rather than a consequence: snug never splits a string on a separator,
ever. A string is one element. So a profile cannot write "/usr/bin:", cannot
produce "/usr/bin::/bin" by dropping an element, and cannot smuggle a ";" into
LD_LIBRARY_PATH.

Semi-structured variables get no verb at all. LS_COLORS, TERMCAP,
DBUS_SESSION_BUS_ADDRESS, GIT_CONFIG_PARAMETERS and IFS are ignored, and that is
a position rather than a gap -- there is nothing to refuse because the operation
does not exist, the same shape as "no X11 profile ships".

PS1 is the exception and §11.6 works it through. The thing that is easy to get
backwards: PS1 is not a security control. Anything inside can set it, so
refusing to let the USER configure it buys nothing against a hostile payload and
only hurts the honest case. But inheriting the host's PS1 is a real code channel
-- bash runs command substitution on prompt strings, promptvars on by default,
measured -- so environ-inherit must refuse the whole PS0-PS4 and PROMPT_COMMAND
family. The answer is neither a verb nor a flat no: a `prompt` preference with a
constrained template ({lock}, {profiles}, {target}, {cwd}) that snug renders,
which is "generate, don't bind" applied to a display string. With one caveat
worth writing down now rather than discovering later: a template is still a
display string, so \r or a cursor-movement escape can erase whatever precedes
it, and the marker should be placed last or control characters rejected.

Also recorded as open rather than smoothed over: the environ family are verbs
while the mount keys are nouns. The defence -- mount keys are all one operation
differing by access, the environment has five -- reads as justification after
the fact, and should either be argued properly or the mount keys should move
too. Not both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recommended, and not on taste -- the flat spelling does not parse for the case
that needs it most. Multi-line inline tables are invalid in TOML 1.0, so
`environ-set = { ...several variables... }` across lines is a syntax error.
Measured in a reference parser and in snug's own:

  environ-set = {                 python3 tomllib: Invalid initial character for a key part
    XDG_CONFIG_HOME = "...",      go-toml/v2:      toml: invalid character at start of key
    XDG_CACHE_HOME  = "...",
  }

  [profile.home.environ.set]      both parsers: OK
  XDG_CONFIG_HOME = "..."
  XDG_CACHE_HOME  = "..."

@home sets four XDG variables and @sys sets several, so the flat form forces
every profile onto one long line or onto invalid TOML. Every example in the
document had the illegal shape; all nine TOML blocks in §11 now parse, checked
by a script rather than by eye.

Nesting also closes the naming inconsistency the previous commit recorded as
open, rather than arguing around it. `environ` is a noun at the top level
alongside ro, rw, tmpfs and symlink; the verbs live one level down, describing
operations within one thing instead of competing with grants for the root
namespace. The flat spelling had no answer to that and the nested one does not
need to invent one.

Two smaller consequences. An unknown verb is refused for free -- environ becomes
a struct with known fields, so DisallowUnknownFields catches environ.deny
exactly as it catches an unknown root key, which is the "a negation key cannot
be smuggled in" guarantee applying one level down with no new code. And adding
`append` later costs a nested field rather than a sixth root key.

Greppability survives, which was the whole reason to prefer verbs over letting
the value type imply the operation: `grep -rn 'environ.prepend'` still finds
every ordered claim on the host, because the section header spells the full
path. That would not hold for a nested INLINE form, which is a further argument
for writing these as table headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1302 lines to 470. The document had grown as an investigation and read like one:
the format it was arguing towards arrived in §11, after ten sections of prior
art, algebra and rejected candidates. Reordered so the format is the document
and the reasoning supports it.

  §1  the format          five verbs, nested, with worked profiles
  §2  how it behaves      verb/type agreement, the errors, --dry-run output
  §3  the variable types  what drives which verb applies
  §4  why                 the measurements that forced each rule
  §5  sidenote            considered and rejected, kept short
  §6  open

New in §2.4: what `--dry-run` prints. The format is only half the answer to
"how will this look" -- a user meets the rendered environment far more often
than the TOML, and it has to say two things a flat list cannot: which verb
produced a value, and for sanitise, what was dropped. A filter that silently
removes two of three elements is the exact failure shape the rest of the
document is trying to avoid.

The survey of makeWrapper, systemd, flatpak, Lmod, NixOS, Nickel, CUE and
Kubernetes collapses from a full section to two paragraphs, keeping only the two
conclusions that actually shaped the format: everyone offering `prepend` buys
commutativity back with a number, and everyone except CUE ships subtraction, so
borrowing a vocabulary by analogy imports the thing invariant 1 exists to
prevent. Candidates A-F become six bullets. The expression-language argument is
the one kept at length, because it is the reason the answer is "borrow CUE's
semantics, do not take the dependency".

Every measurement survives -- PATH empty element, MANPATH's operator, bash's
DEFAULT_PATH_VALUE, the payload-name resolution, the three dangling variables on
main, the git config env bypass. Checked mechanically after compression rather
than by eye: code blocks byte-identical, headings byte-identical, and all nine
TOML blocks still parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e model

An independent spec review attacked the format as something to implement from,
and found the core sound but the document self-contradictory in three places and
regressive in two. Verified each against the code before accepting it.

The root cause is one omission: the model had no place for snug's OWN
environment authorship. Everything else followed from that.

@stubs-in-path was shown prepending /run/snug/bin. That is illegal under the
document's own rule -- the directory is KindData created by Policy.Replace, so no
profile can grant it -- and it inverted a decision the code states explicitly.
resolve.go:395-409 places the stub AFTER every profile path entry, "because a
profile entry is an explicit human grant and the stub is snug's own generated
fallback". The format had it beating everything. The profile is a switch with no
environ key at all; snug authors the directory, as it already does.

The fix is the distinction the codebase already draws for mounts and CLAUDE.md
already states -- a profile mounting over another profile's grant is masking and
is refused, snug replacing a path with its own content is replacement and is
allowed. Mount has an Authored field and Policy.Replace is its single writer. The
environment needs the same and did not have it.

§4.2's repair was worse than the defect. "Author only what the profile grants"
would move PATH into @sys, so `--no-defaults -p @cwd-rw . -- bash` yields a
sandbox with no PATH -- and §4.3, two sections later, measures bash substituting
a compiled-in default ending in "." which is the target. Same for HOME, which is
where the identity generator writes .gitconfig, .ssh/config and known_hosts. The
repair is now to MARK unbacked authored values in --dry-run, which is invariant
5's shape, and to leave the granted-path rule on profiles, which have no floor to
protect.

The one-prepend guarantee was overclaimed. merge is "union then sorted", so
/opt/bin sorts ahead of /usr/bin and beats @sys without anyone using prepend or
consuming the slot -- merge makes the same claim and ASCII decides it. §2.3 now
states the narrower true guarantee: the front is exclusive and checkable from
declarations; base entries are structurally last; merge-vs-merge is sorted and
sorting is not a decision. The bands are named.

Moving inherit/sanitise to config.toml was a security regression, not the
improvement it was sold as. @claude carries ANTHROPIC_API_KEY today and it enters
only when @claude is selected; a host-wide config line puts it in every sandbox
on the machine, inverting CLAUDE.md's bound on adapters. The trust argument does
not survive either -- profiles.d and config.toml are the same tree at the same
trust level, so moving it changes only how narrowly it can be scoped. Both verbs
stay in profiles.

Also recorded: `path` must be retired alongside `env`, since environ.merge on
PATH does the same thing and @claude uses both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit landed the model fix but two of its consequences did not
make it into the file.

4.2 said the repair for the three dangling variables on main was to author only
what the profile grants. That is worse than the defect: 4.3, two sections later,
measures bash substituting a compiled-in default ending in "." when PATH is
unset, and "." is the target. HOME is worse still -- it is where the identity
generator writes .gitconfig, .ssh/config and known_hosts, so a profile able to
move it defeats identity pinning silently. The repair is to MARK unbacked
authored values in --dry-run, which is invariant 5 shape, and to leave the
granted-path rule on profiles.

Section 6 still asked whether environ.inherit is a preference or a grant, and
leaned toward amending CLAUDE.md. Settled the other way in 1.2: it is a grant,
it stays in a profile, and config keeps only defaults and prompt.

Also recorded there: path must be retired alongside env, since environ.merge on
PATH does the same job and @claude uses both today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second independent audit re-ran every measurement and read the primary sources
behind the prior-art claims. The compression pass came out clean -- code blocks
byte-identical, no negation or hedge altered. Everything below came from the
restructure or from claims that were wrong when written.

The one that matters most is mine. §1.1's heaviest argument for nesting was
"multi-line inline tables do not parse, measured in a reference parser and in
snug's own". Half false: python3 tomllib refuses them, but the scratch module I
used to check go-toml pinned v2.2.3 while go.mod says v2.4.3 -- and v2.4.3
accepts them. Re-verified against the real version. The lesson is worth more than
the argument was: check the version the project builds with, not the one a test
module resolved to. What survives is smaller and still worth saying -- the flat
form is spec-invalid but silently accepted here, so it works on this host and
breaks on any TOML 1.0 parser. That is a portability trap and an argument for
snug rejecting the form deliberately rather than inheriting whatever the
dependency allows this month.

§1.2's worked @sys set SHELL and the four base PATH entries, which §1.1 had just
declared snug's and forbidden to profiles -- the document contradicting itself
one section apart. @sys now sets no environment at all, and a new @rust shows the
shape a profile actually wants: grant the directory, merge it. The canonical
scalar-conflict error moved off SHELL for the same reason.

§2.5's --dry-run block was never updated, though the commit that reworked the
model claimed it had been. It showed @stubs-in-path prepending, (config)
provenance for verbs that had moved back to profiles, and snug's stub in the
front band when §2.3's own diagram puts it after every profile entry. Rewritten:
(snug) provenance for snug's own authorship, bands top to bottom in resolution
order so the rendering IS the diagram, and dropped sanitise elements named rather
than counted.

Four sidenote claims were wrong against primary sources. flatpak's
empty-means-unset is pre-1.10 behaviour, not current. makeWrapper is not the only
system putting the separator in the signature -- Environment Modules has -d and
Lmod takes a delimiter. "Everyone offering prepend buys commutativity back with a
number" is false for makeWrapper and Modules; the true rule is that a number
appears where units are INDEPENDENTLY AUTHORED and no sequence exists, which is
exactly snug's case and a better argument. And "everyone except CUE and
Kubernetes ships subtraction" conflates removal with override: mkForce and
Nickel's force win a priority comparison and never delete. The three-way split
makes the conclusion sharper, since borrowing by analogy imports removal from one
group and a priority field from the other, and invariant 1 forbids both.

Also restored from the restructure: inherit is refused for every list variable
(it was a table column that a rewrite dropped, and it is a rule); forbiddenEnv
survives the new format unchanged and orthogonal to the type table, which had
been re-learned once already; and sanitise with zero survivors unsets rather than
setting empty, because an empty PATH is the current directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of the review gaps that needed no further thinking.

3.2 is new: the type table had no scalars in it at all, while 2.1 teaches the
rule with a scalar example. So the one case a reader learns from was not in the
table it points at, and SHELL, TERM and LANG could not be looked up anywhere.
The marks are also defined now -- an earlier draft used checkmark/warning/cross
throughout without ever saying what they meant, and warning in particular read
as "probably fine" when it means "allowed, with this constraint".

Adding the table restored the TZ finding the restructure had dropped, which
belongs here anyway: TZ is a two-branch grammar, and when TZDIR is unreachable
glibc silently re-reads the value as an inline POSIX rule rather than failing.
TZDIR=/nonexistent TZ=Asia/Tokyo gives +0000 Asia -- every timestamp in the
sandbox wrong, on no channel at all. It is the best non-XDG example of the
document own rule, and it is why the cell is a warning and not a tick.

2.3 is new: nothing said a variable name has to look like one. TOML keys are
arbitrary strings, so "A=B" = "c" parses, as does an empty key or one carrying a
newline, and all of them reach --setenv. Profile names have checkName for exactly
this reason. The = case deserves its own sentence: NAME=VALUE is the wire format
of the environment, so a key containing = is a second assignment smuggled inside
the first, and the only thing preventing that today is that no key accepts a
variable name yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three agents took the grant-check semantics, the verb interactions, and the
authorship split. Verified their load-bearing claims before accepting. Two of
them disagreed on one point and the disagreement is resolved in the document
rather than papered over.

The reframing that dissolves most of the grant question: it is a COUPLING rule,
not an existence check. internal/policy may not touch the filesystem, a tmpfs
grant creates an empty directory, and a bind's contents are host state -- so the
rule cannot prove a path exists. What it can do is require that the profile
naming a path is the profile that put a node on the chain to it, so a reviewer
reading one profile sees both acts. That has to go in the code comment, because
otherwise someone will cite the check as a boundary. It is not one.

The decisions: coverage rather than exact match, downward, no depth limit;
symlinks resolved first and never a grant themselves; the include closure counts
and the selected set does not; checked against profile TEXT so an absent optional
grant cannot make legality host-dependent; the guest side of a host:guest spec;
and a hard refusal for profiles while snug's own values are only marked --
because HOME, PATH and SHELL have no safe absent state and refusing would make
the sandbox worse. Where refusing makes the sandbox worse, mark; where it costs
only an author's line, refuse.

sanitise drops rather than rewrites, because the host-to-guest map is not a
function. ro is enough, and the honest scope is narrower than the name: it is a
truthfulness filter, not a capability filter. Unset and empty collapse for lists
but must NOT be written as a shared helper, because the flag scalars are
"set to any value, including empty" and the collapse is exactly wrong for them.

The disagreement was the sanitise band's order. Sorted, said one, because host
order is an order snug did not author. Host order, said the other, because
sanitise's contract is "copy the host value" and GOPATH element 0 is semantic.
Host order wins: sorting is a second silent transformation, and the objection it
answers -- that the value depends on a host string -- is true either way.

Two profiles prepending the IDENTICAL value now agree rather than conflict. They
do not disagree about who is first and the resolved policy is byte-identical, so
refusing it refuses a non-conflict; and it collapses prepend into the rule set,
identity, address, gateway and mtu already share. This softens the stated "once
per set" rule and is flagged as such.

Also corrected: the same-value exemption's stated justification was false --
expand folds includes into a set keyed by name, so a diamond never reaches an
agreement check at all. And the conflict error must name every claimant, which
today's scalar conflicts do not.

@stubs-in-path is deleted rather than argued into defaults. Its abuse sentence
cannot be written, which makes it @null under another name; the two existing
gates already select the exact intersection, measured four ways; and putting it
in defaults would create a silent-downgrade path, since defaults = [...] replaces
the built-in list wholesale. What it was reaching for is telling the human, and
--dry-run's COMMANDS block already does that.

forbiddenEnv splits by verb. set carries a reviewable file value; inherit is a
hole punched in --clearenv. So BASH_ENV and its family are allowed for set under
the grant rule and refused for inherit -- which composes the two rules instead of
maintaining them separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They surfaced during the design reviews and were sitting only in commit
messages, where nobody looking for open defects would find them. All three
measured on main, all three fixable today whether or not the environ format ever
lands. §6 says so explicitly, so they are not read as blocked on it.

(a) A variable set to the empty string is silently dropped. resolve.go:283 reads
`if v := env.Getenv(e); v != ""`, so set-but-empty is indistinguishable from
unset:

  NO_COLOR=  snug --dry-run -p @claude .   ->  no NO_COLOR at all
  NO_COLOR=1 snug --dry-run -p @claude .   ->  NO_COLOR=1

NO_COLOR's spec is "set to any value, including empty", so NO_COLOR= means
disable colour and snug silently re-enables it. §3.2's flag row already has the
semantics right; the code is wrong. The fix is Environ.LookupEnv plus a presence
check, which is the same line that carries §4.4's host-conditional refusal -- so
both go together.

(b) One unparseable file in profiles.d disables the entire registry, builtins
included. Load() returns on the first bad file rather than collecting, so a
single unknown key makes @sys unreachable and takes down `snug profile list` --
the one command that would tell the user what still works. This is also what
would make any future change to the variable type table frightening, since
reclassifying one name becomes a total registry failure on every host that used
the old verb. The fix shape is the interesting part: diagnostics report the
broken file and continue with what loaded, anything that runs a sandbox stays
fatal, and `unknown profile` must consult the skipped-file record or it becomes a
silent downgrade.

(c) PATH entries are not deduplicated and an ungranted directory is accepted in
silence -- path = ["/nonexistent/bin", "/bin"] yields /bin twice and no message.
Harmless today; worth fixing regardless, because a duplicated entry makes the
rendered value depend on how many profiles happened to name a directory, which is
a fold artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file was named for how it started, not for what it is. It stopped being a
brainstorm several reviews ago -- it now carries decided semantics for five
verbs, a variable type table, the resolution order, the errors, and three live
bugs recorded against main. A reader looking for the environment design would
not have found it under BRAINSTORM.

Status line follows: accepted, implementation in progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§1.1 said flatly "no profile may write a name snug writes" and listed PATH.
§1.2 worked @rust with environ.merge PATH, §2.4 gives PATH a merge band, and
§2.8 renders one. Both cannot hold, and the implementer hit the contradiction on
the first profile that used it.

The reading that makes every one of those statements true: ownership refuses the
verbs that REPLACE a value. For a scalar that is every verb a profile has. For a
list, snug authorship is a band, so merge/prepend/sanitise contribute ahead of
snug base and displace nothing, while set and inherit are already refused by the
type rules. What stays unconditional is the base PATH, not the variable.

Verified while writing this: PATH is the only list among the twenty owned names,
so the exemption is exactly one variable wide.

Also corrected: the list was given as nineteen keys and is twenty.

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

The spec is accepted; this is how it gets built. Twelve independently
committable steps, each leaving `make gate` green, with step 3 named as the
first that changes observable behaviour and therefore the first golden diff.

Four places the design contradicts itself are decided rather than handed to
the implementer:

  1. a TOML string on a list variable is one element for `merge` as well as
     `prepend`; the refusal is about the SEPARATOR, not the shape, because
     PATH = ["/usr/bin:/usr/sbin"] is the identical smuggle and the
     shape-based rule in §2.7 case 3 does not catch it;
  2. `set` and `inherit` on one scalar are a symmetric conflict — "set beats
     inherit" is a priority field wearing a verb's clothes;
  3. TERM/TZ/LANG are snug-owned per §1.1, so §3.2's `set` cells for them are
     dead and the "TZ requires /usr/share/zoneinfo" guard becomes a --dry-run
     mark rather than a refusal;
  4. PIP_*/npm_config_* are forbidden for `inherit` only, GIT_CONFIG_*/LD_*/
     BASH_FUNC_* for both — §4.5's finding is about the host's environment
     outranking a pinned file, which is an argument about inherit.

Three things measured while writing it, not recalled. go-toml v2.4.3 refuses
[profile.x.environ.deny] under strict mode with a caret at the table, so
§1.1(b) holds; it accepts "A=B" and "" as keys under environ.set, so §2.3's
grammar is load-bearing rather than theoretical; and it accepts `inherit =
false`, so the true-only rule has to be code. bwrap refuses `--setenv 'A=B' c`
but names neither variable nor profile, which is why the check belongs at
parse time. `--setenv NAME ''` delivers a present-empty value, so §4.6(a)
needs no new flag.

One finding that changes the shape of the work: the *.bwrap.txt goldens are
generated from testRegistry(), a fake, so nothing in base.toml has a golden at
all. Changing @home and @claude — the user-visible payload of this design —
would ship with no golden diff, which the working agreement says to treat as
untested. So step 1 is a real-registry ENVIRONMENT golden in cmd/snug, built
before any behaviour changes, and every later step is reviewed against it.

Also retracted: §1.1's suggestion that snug reject the multi-line inline
`environ = {...}` form. It is not implementable post-decode — the decoded
value is identical to the header form and go-toml hands us no syntax
provenance. Recorded as a portability note; no comment may claim it is a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The .bwrap.txt goldens are generated from testRegistry(), a fake profile set,
so nothing golden covers internal/profile/profiles/base.toml. A change to what
@home or @claude puts in the environment would therefore ship with no diff for a
human to read — which CLAUDE.md says to treat as probably untested.

cmd/snug/testdata/env.*.txt is the ENVIRONMENT block of --dry-run, resolved
against profile.Builtins() with a fake host, for three selections: the shipped
defaults, defaults + @claude, and containers on a host whose podman is a
distrobox shim. It captures TODAY's behaviour so the later steps of the environ
work produce a reviewable diff rather than arriving fully formed.

No production behaviour changes: profile.builtins is exported as Builtins (it
is the only registry that is a pure function of the binary, which is what makes
it goldenable), and dryRun's eight-line environment block moves into
describeEnvironment so the test can render exactly it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Policy.Env becomes map[string]EnvVar. A map of strings could carry the values
and nothing else — not which verb produced one, not which profile, not where in
a list's ordering an entry sits, not what a filter dropped — and every one of
those is something --dry-run has to print, because that screen is how a human
decides whether to trust the sandbox at all.

No new noun: policy.Environ already exists as the injected host lookup, and a
policy.Env sitting two letters away from it is the one-word-two-things confusion
the (builtin) -> (snug) rename was done to remove. So the writers are Policy
methods mirroring Policy.Replace. AuthorEnv/AuthorEnvList are the only writers
of a VerbSnug entry, the same way Replace is the only writer of Mount.Authored,
and that is what makes the ownership set derivable rather than retyped.

policy.SnugOwnedEnv ships as data because the refusal that will consult it runs
at parse time, and parse time cannot run a resolve. Two tests keep it honest and
neither alone is enough: a static pass over every AuthorEnv call in the tree
asserting set equality (it hard-fails on a computed name, because an unchecked
name is the hole this closes), and an executed one over a resolved policy. An
earlier draft of the list was hand-counted and missed the six writers that run
after Resolve, which are the dangerous half.

canon() now renders the environment entry by entry rather than as a joined
string, and testRegistry gains two profiles that actually use it — widening
canon asserts nothing if no fixture exercises it, which is the same trap its
own comment already records for the network scalars.

No golden diff, and that is expected: this is a pure refactor and adds no
capability. A diff here would have meant the refactor was not faithful.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he host

Two bugs sharing one line, so they are fixed together.

Environ gains LookupEnv, and the profile `env` fold reads presence rather than
`v != ""`. A variable the host set to the empty string is SET: NO_COLOR's
specification is "set to any value, including empty", so `NO_COLOR=` means
disable colour and snug was silently re-enabling it. bwrap delivers it faithfully
— `--setenv NO_COLOR ''` produces a present, empty variable inside — so no new
flag and no --unsetenv is needed.

envOr keeps the old reading deliberately, with a comment saying why: its one
caller wants a fallback for USER, where empty is no more useful than unset. The
two readings must not be unified — "right for one type, wrong for the other" is
how this class of bug ships.

The forbiddenEnv refusal moves OUT of the presence guard. It used to fire only
where the host happened to have the variable set, so a profile carrying
env = ["LD_PRELOAD"] passed review on one machine and failed on another.
Whether a grant is legal is a property of the profile.

Golden diff, exactly as planned: one new line `NO_COLOR=` in the @claude
environment golden (the fixture host now has it present and empty), and one new
unconditional case in refusals.txt. Nothing else moves.

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

snug owns the variable types, because the alternative is inferring an operation
from a value's shape — and the shape is identical for a search path, a URL, a
template bash performs command substitution on, and a set of delimiter
characters. internal/policy/envtypes.go is §3.2, §3.3 and §3.4 as data, with the
empty-element kind alongside the separator: a type that carries one without the
other is a filter written once and wrong for a third of its inputs.

checkEnvName is checkName for variables, and for the same reason. TOML keys are
arbitrary strings, so nothing in the syntax stops environ.set carrying "A=B", an
empty key, or a name with a newline, and those go straight to --setenv NAME
VALUE. '=' gets its own message: NAME=VALUE is the wire format of the
environment, so a name containing one is a second assignment smuggled inside the
first. bwrap does catch it, but its message names neither the variable nor the
profile nor the file.

forbiddenEnv splits by verb (CALL 4). A `set` carries a value from a reviewed
file in a trusted layer; an `inherit` carries whatever the process that launched
snug had. inherit is a hole punched in --clearenv and set is not, so BASH_ENV,
NODE_OPTIONS, PIP_* and npm_config_* are legal as set and refused as inherit,
while LD_*, GIT_CONFIG_* and BASH_FUNC_* are refused at both. The prefixes are
the half a map[string]bool could never express, and four of §4.4's findings are
exactly that shape.

ONE PLACE THE DESIGN CONTRADICTS ITSELF, resolved and flagged for review.
§1.1 says no profile may write a name snug writes, and PATH is in that list —
but §1.2's worked @rust profile, §2.4's whole band diagram, §2.8's rendering and
step 12's migration message all require `environ.merge PATH = [...]` to be
legal. There is one reading under which every one of those holds: ownership
refuses the verbs that REPLACE a value, and for a list snug's authorship is a
BAND rather than the value. So set and inherit on PATH are refused by the type
rules (naming merge/sanitise as the fix, which is §2.1's own wording), while
merge and prepend contribute a band ahead of snug's base and displace nothing.
Every scalar snug owns stays refused at every verb. §1.2 is precise about this
scope where §1.1 is loose: what stays unconditional is "the base PATH", not the
variable. checkEnvOwnership carries the argument and
TestPATHIsSharedButNotReplaceable pins both halves.

Applied immediately to the legacy `env = [...]` key, which is semantically
inherit. Its forbidden-name refusal keeps its older wording, because that text
is pinned in the refusals golden and the key is on its way out.

Golden diff: refusals.txt grows by one case per rule and no existing line
changes. The env.*.txt goldens do not move, which is the check that the forbid
table is not too wide — @claude inherits ANTHROPIC_BASE_URL, EDITOR, VISUAL,
PAGER and NO_COLOR, and none of them is forbidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`environ` is one nested key rather than five root keys, and the load-bearing
reason is that it makes `environ.deny` fatal for free: `environ` is a struct
with known fields, so DisallowUnknownFields catches an unknown VERB exactly as
it catches an unknown root key. "A negation key cannot be smuggled in" now
applies one level down with no new code, and there is a test for each spelling
someone might reach for.

merge and prepend decode into `any` because that is what go-toml v2.4.3 actually
accepts both spellings into — measured, not assumed. Both are legal: a bare
string is exactly ONE element, because snug never splits a value on a separator
(CALL 1). The converter says which profile, which file, which verb and which
variable for anything else; go-toml's own error names none of them.

inherit and sanitise take `NAME = true`, and `NAME = false` is refused by name.
A stored false would be a negation key that parsed — there is no way to
un-inherit, because nothing was inherited to begin with.

`env = [...]` and `path = [...]` are REWRITTEN into inherit and merge-on-PATH
rather than carried alongside, so policy.Profile loses both fields and the
resolver reads only EnvGrants. The retirement warning is silent for a builtin,
on purpose: it names the file to edit, and for a builtin that is a file inside
the binary — a warning nobody can act on trains people to ignore warnings, and
snug's own use of the keys is caught by its test suite the day they turn fatal.

ValidateEnvGrants runs in parse, beside checkName, so `snug profile show`
reports a verdict with no target and the answer never depends on the host
reading the file. It runs in Resolve too, because a Profile built in code never
went through a parser and a gate on one of two paths is not a gate.

Two consequences worth reading rather than skimming:

- A verb snug can parse but cannot yet apply is REFUSED, not accepted and
  ignored. set, prepend and sanitise resolve in the commit that follows this
  one; until then a profile using one gets an error naming the verb. Accepting
  a grant and silently doing nothing with it is the failure mode this project
  treats as its worst.
- `snug profile show` now renders all five verbs. It had to change anyway, and
  the old line rendered `env` and never rendered `path` at all — so a profile
  putting a directory on the sandbox's PATH looked, on that screen, like a
  profile that granted nothing to the environment.

Golden diff: one line. The forbidden-name refusal now speaks with
checkEnvName's single voice instead of the legacy key's older wording, which is
the point of the rewrite. No env.*.txt or .bwrap.txt file moves — the rewrite is
value-identical, and the review artifact for this step is the parse test table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e filter

The engine, in internal/policy/envresolve.go.

CONFLICTS ARE ACCUMULATED, THEN CHECKED. Every profile's claims go into a set
during the fold and nothing is decided until the fold completes, because §2.7
requires a refusal to name EVERY claimant and a decision taken mid-fold can only
name the two it happened to be holding. Today's scalar conflicts do exactly
that: with three profiles where two agree, the message names the
alphabetically-last agreeing profile and never mentions the first. Checking
afterwards also leaves no fold order to keep order-independent.

CALL 2 lands here: `set` and `inherit` on one scalar are ONE slot. "set beats
inherit" would be a priority field wearing a verb's clothes, so they join the
rule `set` already follows — equal claims agree, unequal claims are a symmetric
error naming both verbs. An inherit of a name the host does not have contributes
nothing and therefore never conflicts.

BANDS are structural: prepend, then merge sorted, then sanitise in host order,
then snug's generated band, then the base. Nothing a profile writes chooses its
band. Sorting the merge band is not a tie-break dressed up as a decision —
`merge` is where an author declined to say who is first, so ordering it by
anything the fold saw would invent an answer nobody gave.

SANITISE, with each decision separately asserted: drop and never rewrite (the
host->guest map is not a function); `ro` is enough, no stat — it is a
truthfulness filter, not a capability filter, and a surviving element may name
an empty bind; survivors keep HOST order, because sorting would be a second
silent transformation and §3.3 documents a variable where position is semantic;
unset and empty both mean absent FOR LISTS, written inline rather than in a
shared helper because the collapse is exactly inverted for flag scalars; and
nothing surviving means the variable is UNSET, never set-empty. That last one is
the single place in this feature where getting it wrong ADDS a hole rather than
failing to close one — an empty PATH element is the current directory, which
inside snug is the target.

DEDUP to the earliest band runs last, after snug's own bands, because the
duplicate that actually occurs is a profile's entry against the base. This is
§4.6(c): `path = ["/nonexistent/bin", "/bin"]` used to yield /bin twice.

Monotonicity is stated precisely in the file's doc comment, both halves. The
entry set of a list only grows; the ORDER does not, because a prepend demotes
another profile's merged entry. TestEnvIsMonotoneAsASet and
TestPrependReordersWithoutRemoving pin them separately, the second existing so
nobody reads the first as proving more than it does — the same relationship
TestDeeperGrantOverridesShallowerAccess has to TestResolveIsMonotone.

testRegistry gains fixtures using all five verbs, and they are in
TestResolveIsCommutative's list: a claim accumulator is the newest thing in the
resolver that could make resolve([a,b]) != resolve([b,a]), and canon() only
covers it if a fixture exercises it.

Golden diff: refusals.txt gains the four conflict cases. env.*.txt and the
.bwrap.txt files do not move — no builtin uses a verb yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolveVia answered two different questions with one body and got the second one
by accident. It returned the FIRST match Go's map iteration produced, which is
nondeterministic the moment one link prefixes another — /lib and /lib64 on a
usr-merged host is the shipped case, not a hypothetical — so a verdict could
differ between two runs of the same policy. A security tool that is wrong
reproducibly is better than one that is wrong occasionally.

It also skipped g == link, which is right for a MOUNTPOINT (a grant at the link
path is the link itself, and bwrap's refusal is about creating a mountpoint at a
symlink DESTINATION) and wrong for a PATH element that is literally /bin. The
coupling rule in the next step needs the second reading: on a usr-merged host
/bin is a symlink, and judging a value unrewritten would refuse a profile that
granted /usr and named the path the sandbox actually sees.

So: resolveViaDeepest, which skips the link itself and replaces the existing
call site, and resolveLinkForEnv, which matches it and rewrites. Not one
function with a bool — the two comments have to be able to say which question
each answers. No behaviour change for any shipped profile; the determinism test
runs 200 iterations because a single call would have passed about half the time,
which is the worst kind of green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§2.5, and the framing matters more than the code: this is a COUPLING rule, not
an existence check. The profile that names a path must be the profile that put a
node on the chain to it, so a reviewer reading one profile sees both acts on one
screen. It cannot prove the path exists — internal/policy may not touch the
filesystem, a tmpfs grant creates an empty directory, and a bind's contents are
host state — and the value is inert either way, since the payload can set any
variable it likes once it is running. It stops a profile LYING; it does not stop
anything REACHING, and envcoupling.go says so in its first paragraph because
otherwise someone will cite it as a boundary.

Scope is values a profile WRITES (set/merge/prepend) at names the type table
marks path-valued. inherit and sanitise are exempt — the value is the host's, and
sanitise already has a filter for exactly that question.

Two decisions worth reading. Coverage is downward with no depth limit, which
makes @home a rubber stamp for all of $HOME and makes the check vacuous under
/usr for anything including @sys — accepted, because that profile DID bring them,
on an include line --dry-run renders, and a false positive costs a variable
naming an empty directory rather than a hole. And the include closure counts
while the SELECTED SET DOES NOT: if it counted, Resolve([a]) could refuse what
Resolve([a,b]) admits, so one profile would decide another profile's legality.
TestCouplingVerdictDoesNotDependOnTheSelectedSet exists because that is a
two-word edit nobody would notice in review.

Every fixture that named a path it did not grant is now a fixture that grants it
— eight of them, which is the rule doing its job on the first day. The one
message this borrows rather than writes is the relative-path refusal:
checkAbsoluteElement already owned it, so a relative `set` on a path variable now
reaches it too, which it never did before.

Golden diff: refusals.txt gains four cases and nothing else moves. The .bwrap.txt
and env.*.txt goldens are unchanged, which is the expected shape — every shipped
profile already satisfies the rule, @claude through its include of @home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The environment block was a flat NAME=value list, which cannot say three things
a human needs in order to trust it: which verb produced a value, which profile
supplied it, and — for sanitise — what was removed on the way. §2.8 is those
three, and the PATH bands now read top to bottom in resolution order, so the
rendering IS the §2.4 band diagram. If the two ever disagree the renderer is
lying, and a flat list could not disagree only because it said nothing.

Dropped host elements are NAMED, not counted. A filter that silently removes two
of three elements is the exact shape of failure this whole model exists to avoid,
and "1 of 3 kept" does not let anyone check it. A variable whose every element
was dropped is UNSET, and the block says that too rather than leaving a hole
where a name used to be.

The other half is §4.2's repair, measured on main and never rendered: snug
authors HOME, SHELL and the four base PATH entries in every sandbox, and with
only @parent-ro selected not one of those paths is granted. It must keep
authoring them — §4.3 shows PATH and HOME have no safe absent state, and an
earlier draft that concluded the opposite would have converted a confusion bug
into a reachable hole — so the repair is to MARK them. The mark is computed
against the resolved mounts, unlike the coupling rule of the previous step, which
is deliberately text-only: refusing must not depend on the host, marking must.

One predicate, exported (policy.GrantsGuestPath), shared with the sanitise
filter, because two implementations of "is this granted" eventually disagree and
the one on screen is the one a human trusts. It alone would call /run/snug/bin
"not granted" — true in the policy's vocabulary, misleading on the line whose
whole point is that the podman stub is found there — so the mark names how many
grants sit inside the path. Same predicate, no second rule.

Golden diff: env.*.txt rewritten wholesale, which is the review artifact for this
change, plus a new env.parent-ro-marks.txt holding the §4.2 case. That selection
is REFUSED, and --dry-run renders a refused policy by contract, which is the only
way to see the marks at all. VERIFY.md gains both by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s the two retired keys

Four XDG variables, set by the profile that creates the directories they name.
The coupling is the point: they sit three lines under the `tmpfs` list, so a
reviewer reading @home sees both acts at once, and §2.5's rule is what makes that
structural rather than a convention somebody remembered.

@home gains a fifth tmpfs, {home}/.local/share, and it is a real grant change
with its own abuse sentence. Without it XDG_DATA_HOME would name a directory the
sandbox does not have — which is the coupling rule doing its job on the very
first profile to use it, rather than a rule bent to fit.

They are `set` and never `inherit`. The host's XDG_CONFIG_HOME names a real
directory full of real credentials; admitting it past --clearenv points every
tool in the sandbox at a path the sandbox does not have, and on a host that moved
it somewhere @home does not cover, tools fall back to writing wherever it says.

@claude's `env = [...]` becomes environ.inherit and `path = [...]` becomes
environ.merge on PATH. The rewrite is value-identical — the goldens move by
exactly the four XDG lines and nothing else, which is what says so — and every
sentence of the ANTHROPIC_API_KEY reasoning survives the move, because that
comment is the reason the list is five names rather than a prefix.

The fake @home in testRegistry now matches the shipped one entry for entry. It
had two of the five tmpfs directories and none of the XDG block, so the .bwrap.txt
goldens — the artifact a human reads to approve a boundary change — were
describing a sandbox no user gets. Hence three new --tmpfs lines and four new
--setenv lines there, in a step whose production change is a profile.

Verified by execution, not inferred: `snug . -- printenv | grep XDG` shows all
four inside, and ~/.local/share is a fresh empty tmpfs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…says it renders all five

Most of this step landed with the parser: deleting Profile.Env and Profile.Path
broke `show("env", p.Env)` at compile time, so showEnviron was written then. What
was missing is the part that makes it readable and the part that keeps it honest.

The labels now carry the `environ.` prefix the TOML uses. Bare "set" and "merge"
sit directly under "ro" and "tmpfs" on this screen, where they read as two more
kinds of filesystem grant — and "environ.prepend" is also the string somebody
will grep for when they want to find every ordered claim on a host. The label
column widens from 10 to 16 to fit "environ.sanitise".

The test is the point of the commit. The line this all replaced rendered one of
the two keys that existed and never rendered `path` at all, so a profile putting
a directory on the sandbox's PATH looked, on this screen, like a profile that
granted nothing to the environment — and nothing failed, because nothing checked.
A display that omits a grant is worse than no display, because it is read as
complete. The negative control is in there too: a verb nobody used must not print
an empty heading, or the reader learns to skim the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on main: Load() returned at the first bad file, so a single stale file
in profiles.d made `snug --dry-run -p @sys .` fail — and made `snug profile
list`, the one command that would have said what still works, fail with the same
error. A file the user may not have edited disabled the builtins.

This is a prerequisite rather than a cleanup, and the sequencing is the argument.
The environ work adds a whole new class of parse error to files users already
have, and retiring `env = [...]` turns a key that is in shipped documentation
into a hard error. With this unfixed, that sequence means one stale user file
takes down @sys and `snug profile list` at the same moment.

The split is by CONSEQUENCE, not severity. Running a sandbox stays fatal on any
bad file: the file that did not parse may be the one granting what was asked
for, so continuing would assemble a sandbox out of whatever happened to load,
which is invariant 5's exact shape. A diagnostic — profile list/show/tree/dot,
config, doctor — reports the file loudly, continues with what did load, and
still exits non-zero, because the output is worth printing and the exit code must
not claim everything is fine.

A REDEFINITION stays hard everywhere. Two files claiming one name is a question
with no answer, and continuing would mean picking one silently; it has its own
test so the two failures do not blur.

And the caveat without which the whole thing is a silent downgrade: `unknown
profile` consults the skipped-file record. A name defined in the file that failed
to parse would otherwise come back as "unknown profile", which is a lie — snug
does not know whether it exists, and the difference between "you typed it wrong"
and "the file defining it is broken" is the whole of what the user needs.

The registry test carries a positive control that is the point of it: @sys must
still be there. Without that assertion it passes on a registry that loaded
nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d them

Both keys are now a hard error. They stay as FIELDS on rawProfile rather than
being deleted, and that is the whole design of this commit: deleting them would
let DisallowUnknownFields produce the generic "unknown key", which is right for a
key that should never have existed — publish_auto was retired exactly that way —
and wrong for a key whose MEANING MOVED. `env = [...]` is still a thing a profile
wants to say; the author needs the new spelling, not the news that a word does
not exist.

So each error prints the replacement block with the profile's OWN variables in
it, ready to paste, and says what changed beyond the spelling: `inherit` is now
checked per name (a value that is code is refused, a list variable is refused
outright in favour of environ.sanitise), and a `merge` on PATH now has to GRANT
the directories it names. Both of those would otherwise surface later as a
refusal from a rule the author has not met yet.

The prefix changed deliberately. A silently CHANGED meaning is worse than a
removed key: anyone whose muscle memory reaches for `env` gets an error naming
`environ.inherit` rather than a subtly different grant that parses.

Each test carries a positive control — the replacement spelling parses — because
without it the refusal reads as a ban on the CAPABILITY rather than on the
retired spelling, which is the control TestRetiredPublishAutoIsAHardError already
carries.

Sequenced immediately after §4.6(b) and not before it: turning a key that is in
shipped documentation into a hard error, while one such file still took down @sys
and `snug profile list` together, would have been an unacceptable migration.
Verified by hand: a stale file now names itself, prints the fix, and `snug
profile list` still lists every builtin.

TODO.md gains the two things this work leaves open — the multi-line inline
`environ = {…}` form, which parses here and cannot be detected post-decode (and
which no comment may claim snug refuses), and §4.5's pre-existing finding that
the environment outranks the pinned config file, recorded now so nobody
rediscovers it as a regression of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vyskocilm and others added 7 commits August 10, 2026 11:41
…load's own name

Both are on the design's "Tests this needs" list and neither existed.

TestSanitiseNeverLeavesAnEmptyPATHElement pins §4.3/§2.2 — the one place in
this design where getting it wrong ADDS a hole rather than failing to close
one. An empty element in PATH is the current directory, and inside snug the
current directory is the target: the one writable thing a hostile payload
controls. The host PATH in the fixture has four elements of which policy grants
exactly one, so a `sanitise` written as a string replacement has three gaps to
leave behind. The positive control is the last line of the payload: with an
empty element deliberately present the planted binary IS found, so "the sandbox
did not run ./snugvictim" cannot pass on a sandbox that never started or on a
binary that was never executable.

  Proved it fails: adding one line to sanitiseHostList that records an empty
  entry for each dropped element — the naive string-replacement bug — turns
  PATH into ":<granted>:/usr/bin:..." and the test reports the leading empty
  element, the empty element by index, AND the behavioural half: VICTIM-RAN in
  the bare section, meaning a file in the project root ran off the sandbox's
  own PATH.

TestThePayloadNameResolvesAgainstTheSandboxPATH pins §4.1's precondition, which
was measured true and asserted nowhere: `snug . -- podman` resolves against the
sandbox's PATH, not the host's. Three parts — a binary only on the host's PATH
must not run, one in a directory a profile grants and merges must (its positive
control, identical invocation shape), and a name present in both resolves to
the profile's.

  Part three is the one that earns its place. Refactor the lookup to a
  host-side exec.LookPath and parts one and two stay green — one still fails,
  which reads like the feature working. Proved it fails: with exec.LookPath
  applied to p.Command[0] in internal/sandbox/exec.go, part three goes red with
  "bwrap: execvp <hostonly>/snugbothmarker: No such file or directory", the
  host's copy having won the lookup and then not existed inside.

Every planted binary emits its own marker, and writeScript executes it on the
host before the test relies on it, so a fixture that could not have run
anywhere is a named precondition failure rather than a green tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
inheritEnv and mergeEnvList landed in step 2 with callers and lost the last one
in step 6, when the `prof.Env` fold was replaced wholesale by the resolver —
which writes through addEnvEntry directly. Verified: the only occurrences left
in the tree were their own definitions and doc comments, and no interface
anywhere declares either name, so there was no dynamic caller either.

Unexported methods, so nothing forced the issue: the compiler does not mind and
`go vet` does not look. The reason to remove them now rather than never is that
they are WRITERS of environment entries with the verb baked in, sitting one
call away from the resolver that is supposed to decide the verb. addEnvEntry
says so where it stands, so the next reader knows this was a decision and not
an omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A redteam run reached git's transport through GIT_SSH while GIT_SSH_COMMAND --
its exact equivalent -- was refused two lines above it in the same table. Then it
hijacked a real `git fetch` in a sandbox whose ssh identity a DIFFERENT profile
had pinned:

  snug -p work -p helper <tgt> -- git fetch origin
    HIJACKED-GIT-TRANSPORT host=git@github.com … SSH_AUTH_SOCK=/run/snug/ssh-agent.sock

`helper` granted no filesystem path at all. That is one profile defeating a
guarantee another profile established, which is the composability case the table
exists to prevent.

Verified before fixing: GIT_SSH, GIT_ASKPASS and GIT_SEQUENCE_EDITOR all reached
the sandbox, and GIT_ASKPASS even rendered with "← not granted" beside it while
being passed anyway. GIT_SSH_COMMAND failed the same profile at parse time.

The rule was never "the newest spelling"; it is "the value is code". Added as
forbidBoth: GIT_SSH, GIT_PROXY_COMMAND, GIT_ASKPASS, SSH_ASKPASS,
GIT_SEQUENCE_EDITOR, and the same class in other runtimes -- JAVA_TOOL_OPTIONS,
_JAVA_OPTIONS, JDK_JAVA_OPTIONS, RUBYOPT. Deliberately NOT a blanket "forbid all
code": BASH_ENV, NODE_OPTIONS, PYTHONSTARTUP and PERL5OPT stay forbidInheritOnly,
because as `set` they carry a reviewable value from the trusted profile layer and
the coupling rule constrains where they point. These nine are peers of a name
already in forbidBoth.

The comment says why rather than what, and names the measurement, because
forgetting one entry here is indistinguishable from allowing it.

Regression test extends the forbidBoth table in envtypes_test.go. Confirmed the
refusal fires and that a legal `environ.set EDITOR` still resolves, so the test
cannot pass on a resolver that refuses everything.

Scope, honestly: against a payload already executing this adds nothing -- it can
set these itself. The weight is that it fires when a HUMAN or a different agent
later runs git inside, and that it lowers the bar for the planned §2.7 defence,
which will classify ro=["/"] as privileged but not environ.set GIT_SSH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…CLAUDE.md

Three things, all in the same direction: the documents narrated as live a state
the implementation had already changed.

§4 now says outright that its measurements are the evidence that argued for the
design, not a description of this tree, and each closed subsection carries a
`Closed by` line naming the commit. §4.4 (host-conditional refusal), §4.6(a)
(set-empty dropped), §4.6(b) (one bad file takes down the registry) and §4.6(c)
(PATH not deduplicated) are all closed here; nothing else in §4 is.

§2.1 described the forbid-both set as "names snug owns, LD_* and BASH_FUNC_*",
which under-describes what the code enforces by some distance: two more prefixes,
the ten glibc strips, the git/ssh transport hooks, the interpreter option
channels and the prompt hooks. It also never mentioned PIP_* and npm_config_*
being inherit-only. The summary now matches the table, and says which one runs.

CLAUDE.md told the next implementer to stage a substituted binary in "the
writable tmpfs $HOME" and put that directory first on PATH. That is a shadow
slot: a writable directory ahead of /usr/bin means the payload writes a file
called git and the next git anyone runs inside is that file. The live case has
always staged at /run/snug/bin, read-only after --remount-ro, so the code was
right and the rule was wrong. Stated with the reason, because the same reason is
about to decide what sanitise does with a host PATH element under a tmpfs.
`GrantsGuestPath` returned true for any covering mount regardless of its kind,
and `sanitiseHostList` kept every element it approved. `/tmp` is a tmpfs in every
policy and `@home` makes `{home}` and four subdirectories tmpfs too, so a host
PATH carrying `/tmp/x/bin` survived into the PATH snug writes — at a directory
that is empty and writable inside, in a band ahead of `/usr/bin`. The payload
creates the directory, drops a file called `git` in it, and the next `git` a
human or another agent runs inside the sandbox is that file. Reproduced end to
end, marker SHADOWED-GIT-RAN. Reachable only through a user-written
`environ.sanitise = ["PATH"]`, because no shipped profile sanitises anything.

The filter's contract is "copy the host value, keep only what policy grants", so
the question at each element is whether the sandbox has the HOST'S CONTENT at
that path. A tmpfs answers no: it grants an empty directory. So this is not a new
rule, it is the existing predicate giving a correct answer to the question it
already asks — and `ro` is still enough, no stat, no mode bits, internal/policy
still pure.

The wider candidate — drop anything whose covering mount is writable — was
rejected, and the reason bounds what this claims:

  the payload can rewrite PATH inside the sandbox at will, so no filter can
  close the shadowing attack. What the filter owes is that the environment SNUG
  ITSELF hands over does not ship the shadow slot pre-installed. Dropping every
  writable element chases an invariant the sandbox cannot hold; this makes
  snug's own output truthful.

It would also drop the target's own `bin/`, which is a truthful element: a real
bind of real host content. A writable bind therefore still survives.

The walk is factored into `coveringMount` and `GrantsGuestPath` delegates to it,
its own semantics unchanged for every input it has ever been asked. `grantMark`
is deliberately untouched: --dry-run's mark asks whether a node exists, the
filter asks whether the host's content is there, and a tmpfs answers yes to the
first and no to the second. Unifying them would print "not granted" beside
@claude's `{home}/.local/bin`, which is false, on the screen that is the whole
mechanism by which a human trusts snug. One walk, two predicates, one exported.

The deepest covering mount decides, which stopped being incidental the moment the
kind mattered: a bind nested inside a tmpfs is kept and the tmpfs directory above
it is dropped. `keepHostElement` switches exhaustively on Kind and fails closed
on one it does not name.

`EnvDrop` gains a `Reason`, carried from the decision rather than recomputed at
render time, and --dry-run groups the dropped-element lines by it. "Nothing
grants that path" and "only an empty writable tmpfs is mounted there" are
materially different facts, and the second is the one that otherwise reads as a
bug ("/tmp/x/bin vanished but /tmp plainly exists inside").

No golden moved, exactly as the design predicted, because no shipped profile
sanitises anything — which per the working agreement means this change is
currently untested. The named tests and the golden that exercises the new band
are the next commit, and TODO.md's claim that
TestSanitiseMonotonicityRestsOnRejectMasking asserts the rejectMasking coupling
is not true until then.
… the band

The change in effeceb moved no golden, because no shipped profile sanitises
anything — which by this project's own working agreement means it was untested.
This is the part that makes it reviewable.

internal/policy/testdata/sanitise.bwrap.txt is new, and it exists so the security
diff is one readable line:

  --setenv PATH /opt/tools/bin:/home/u/.local/bin/tool:/home/u/proj/sub/bin:/usr/bin:/bin:/usr/sbin:/sbin

/tmp/x/bin, /tmp, /home/u/.local/bin and /srv/nothing were all in the probe host
PATH and none of them is there. The fifth case in TestGoldenBwrapArgs needs a
per-case host environment to supply that probe value, so the table gains an `env`
field; the shared fixture deliberately still has no PATH, or every future case
would inherit one.

The eight tests, and what each is for:

  DropsAShadowSlotOnlyATmpfsCovers    the named regression. Asserts the element
                                      is absent from the argv, not merely from
                                      Entries — those are two claims and only the
                                      second is the one that matters
  KeepsAnElementARealBindCovers       the positive control, without which the
                                      first passes on a filter that drops
                                      everything. Its target-bind assertion is
                                      what pins this fix rather than the wider
                                      "drop anything writable" that was rejected
  UsesTheDeepestCoveringMount         both directions in one test: the tmpfs
                                      directory drops, the bind nested inside it
                                      is kept. An implementation that kept an
                                      element because some mount exists at or
                                      below it passes half of this and fails half
  DropReasonDistinguishes...          the two reasons are not conflated, which is
                                      what makes --dry-run's new line honest
  MonotonicityRestsOnRejectMasking    TODO.md claimed this test asserted the
                                      coupling before it existed. It does now: a
                                      tmpfs beneath a bind is refused, and that
                                      refusal is the only reason adding a profile
                                      cannot turn a kept element into a dropped
                                      one
  EmitsTheHostElementVerbatim...      guards drop-never-rewrite against a
                                      well-meaning "clean the path" fix
  GrantMarkStillUsesTheWiderPredicate guards against unifying the two predicates.
                                      The mark asks whether a node exists, the
                                      filter asks whether the host's content is
                                      there
  DropLinesNameTheirReason            the rendering, on a hand-built policy with
                                      no host dependency

The positive control was verified by deliberately breaking keepHostElement to
return false unconditionally: KeepsAnElementARealBindCovers failed on its
target-bind and nested-bind assertions, and so did the two older sanitise tests
the brief required to stay green — so those are real controls too, not decoration.

One correction to the design's illustrative text: the verbatim test cannot look
for a cleaned /usr/bin in all of Entries, because snug's own base band
contributes a real /usr/bin. It filters Entries to the sanitise band, which is
what actually pins the clean-to-decide, emit-verbatim split.

TODO.md also gets two stale references corrected. ANTHROPIC_API_KEY is no longer
injected into the environment at all, so the secrets audit's row 3 says closed
rather than describing a live leak; and forbiddenEnv has moved to
internal/policy/envtypes.go and is split by verb. The credential-shaped-name
refusal that entry proposes is still open, and its counter-example is gone —
which argues for writing it now, while it costs nothing that ships.
…ry-run line

Found against a8652ba, after the tmpfs fix had shipped and claimed the class was
closed. It was not.

FINDING 1 — /proc and /dev re-installed the shadow slot.

keepHostElement kept KindProc and KindDev, justified as "kernel- and
bwrap-populated, not empty". That is true of the DIRECTORY and false of what
/proc's magic symlinks RESOLVE TO. The filter is lexical and deliberately does
not follow symlinks, so coveringMount stops at /proc while the kernel walks
/proc/self/root/tmp/x/bin to the writable tmpfs and /proc/self/cwd to the
target — where the shadow binary also persists to the host after the sandbox
exits. Both reproduced end to end, markers SHADOWED-GIT-RAN-VIA-PROC-ROOT and
SHADOWED-GIT-VIA-PROC-CWD.

The argument for the fix had already been written down as an argument against
it: TODO.md declined to widen the rule to KindDev because "an element under /dev
on a host PATH does not occur". If it never legitimately occurs, keeping it costs
nothing to drop and serves only an attacker's spelling. Both kinds now drop,
under their own DropPseudoOnly reason — the fact is not the tmpfs one wearing
another name, because the directory really is populated; what makes the element
untruthful is that its magic links leave it.

KindSymlink still survives, and the line is principled rather than convenient: a
KindSymlink is authored by a GRANT and points where that grant says, so following
it would be a second resolution rule with its own failure modes. /proc's magic
links are authored by the KERNEL, point at whatever the reading process has open,
and are not a grant at all.

The lesson generalises past this filter, and is the same shape as the dirfd
bullet in CLAUDE.md: a lexical predicate answers about the path it was handed,
the kernel answers about the path it resolves, and wherever the two can differ is
where the attack is. Probed and NOT exploitable: `..`, trailing slashes, repeated
slashes — coveringMount cleans before it walks.

FINDING 2 — a newline in a host PATH element forged a line in --dry-run.

The drop line rendered the host-supplied value verbatim, so an element carrying a
newline split the line and the injected second line read as a legitimate
ENVIRONMENT row, complete with a plausible provenance column. It escapes nothing,
but --dry-run is the mechanism by which a human can trust snug at all, and a
value that can author a row in it is a hole in the trust artifact.
internal/policy already applied exactly this guard to variable NAMES in its error
messages (quoteVisible); the values had no equivalent. visibleValue now covers
kept entries as well as dropped ones, because a host element under a bind
survives the filter and can carry a newline just as easily. A value with no
control characters renders unchanged, so no golden moved.

Both findings have permanent named tests, and both were verified to FAIL against
the pre-fix code before being confirmed against the fix: the pure table asserts
the reason rather than only the verdict, and its end-to-end companion asserts the
element is absent from the --setenv PATH operand bwrap actually receives, not
merely from p.Env. The control-character test carries a newline in a KEPT entry
too, since visibleValue has two call sites and a fix at only one would look
identical on the drop line alone.

VERIFY.md gets 6f, the by-hand /proc/self/cwd reproduction, run and its real
output pasted.
vyskocilm and others added 10 commits August 10, 2026 23:34
…s away

.claude/design/ENV-IMPLEMENTATION-PLAN.md is deleted. It was scaffolding — a
twelve-step sequence for work that is now done, and the kind of document that
rots fastest, because every step it describes is better read as the commit that
performed it. What was worth keeping is in ENVIRONMENT-VARIABLES.md, which
describes the design rather than the route to it. Nothing referenced the plan.

The README gains an "Environment variables" section. It had none, and the
environment is now as much of the boundary as the mounts are: the five verbs,
the band order that decides what beats what, the conflict rule, and the four
rules that catch a profile author out — grant what you name, names whose value
is code are refused, names snug writes cannot be replaced, and sanitise filters
harder than "is it granted".

Every claim in it was executed rather than recalled, and the first draft was
wrong twice as a result. The inherit example used TERM, which snug owns and
which is refused outright; it now uses COLORTERM. And a bullet listed PATH among
the names a profile cannot write, which contradicts the merge example three
lines above it — ownership refuses REPLACEMENT, and PATH is a list, so
contributing to it is exactly what merge and prepend are for. The section also
carries the grant line the example needs to parse, since the coupling rule
refuses a PATH entry naming a path the profile does not grant.

ENVIRONMENT-VARIABLES.md joins the design table at the bottom of the README.
`sanitise` is easy to read as hygiene — "clean the unwanted references out of
PATH" — and in this codebase that reading is backwards. The floor is an empty
environment, so nothing of the host's is inside to be cleaned; `sanitise` is the
list counterpart of `inherit`, and it ADDS. A builtin using it would give every
sandbox host-derived entries on behalf of a human who never asked.

So the bound is that the host's environment enters only where a human on this
host wrote a profile saying so. What snug ships is the knowledge rather than the
selection: forbiddenEnv refuses the names whose value is code at every verb, and
envTypes' `sanitisable` column says which lists may be filtered at all.

"No builtin sanitises" was true and accidental. Now it is a decision with a
failure message that says what to do instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the screen someone reads to decide WHETHER to select a profile, which
puts it upstream of every --dry-run, and it had no golden. The gap was not
theoretical: relocating @claude's binary changed

    ro    {home}/.local/bin/claude  ->  {home}/.local/bin/claude:/run/snug/bin/claude

and produced no test diff at all. TestProfileShowRendersEveryEnvironVerb calls
showEnviron directly with a hand-built EnvGrants, so it pins five label strings
and never exercises the header, the grant keys, `optional` or the layout.

Three profiles for three distinct shapes: @sys (ro/rw/tmpfs and symlink, no
environ), @claude (the host:guest pair, includes, inherit, optional) and
@podman-socket (the include closure, and the interim @net include whose eventual
removal now has a second place to be conscious).

It drives the real profileCmd rather than an extracted renderer, so the exit
code and the profile.Load() path are covered too — a golden built from a
refactored-out helper would pass while the command printed something else.

Also: an unknown name must be a named error on stderr with a non-zero exit.
"Printed nothing, exited 0" reads as "this profile grants nothing", which is the
worst answer because it looks like information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y is writable

@claude bound its binary read-only at {home}/.local/bin/claude and merged that
DIRECTORY onto PATH. The bind was sound; the directory is @home's writable
tmpfs, so snug was handing over a shadow slot pre-installed — the payload writes
a file called `git` there and the next `git` a human or another agent runs
inside the sandbox is that file. It passed review, passed `make gate`, and sat
in TODO.md as an accepted residual under a defence ("a profile's own
declaration") that only applies to profiles a human wrote.

`sanitise` structurally cannot reach it: that filter only ever judges the HOST's
value for an imported variable, never a `merge` entry written in a file.

The rule, now stated once and enforced: snug never mounts an executable anywhere
the payload can write. PodmanStubDir becomes StagedBinDir, because it is no
longer podman's — every executable snug puts in front of the payload goes in
that one directory, whether snug generated it or a profile bound it. It sits on
the root tmpfs, so --remount-ro / covers it (measured: touch and `echo >` both
EROFS). No profile names a PATH directory at all any more; snug adds the staging
directory itself, iff something is staged there, so a profile cannot pick a
writable one by accident because it does not pick one.

And the screen now says it. --dry-run was rendering two entries with the
identical property in opposite ways, four lines apart: a profile's merge of a
writable directory kept and unmarked, directly above a sanitise drop line
explaining that a writable directory is a shadow slot. Both were correct and the
asymmetry was invisible, which is exactly where {home}/.local/bin sat for a
milestone. Marks are PATH-only — those entries are searched for COMMANDS, while
a writable CARGO_HOME is what that variable is FOR, and marking it would teach
the reader to skip the mark where it matters.

It stays a mark, not a refusal. A human's own profile may do this deliberately;
what snug may never do is ship one, and that is enforced separately and
absolutely by a sweep over the builtins.

Tests: TestNoBuiltinPutsAWritableDirectoryOnPATH with a positive control that
reconstructs the old @claude shape and asserts the predicate fires on it;
TestSnugStagesNoCommandInAWritableDirectory end to end (mkdir -p before touch,
because a PATH element that does not exist yet on a writable tmpfs is still a
slot and probing with touch alone fails with ENOENT, which reads as a refusal);
TestWritableMarkIsPathOnlyAndDistinctFromNotGranted. Both new tests were
confirmed to fail when the old merge line is restored.

describeCommands now enumerates the staging directory instead of hard-coding the
podman stub — it had gone stale the moment @claude's binary moved there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--setenv NAME VALUE` is three elements of a flag list snug NUL-joins into the
args memfd, and bwrap's --args splits it on NUL. VALUE is last in the triple, so
a NUL inside it re-syncs bwrap's parser onto the remainder. Measured:

    EDITOR = "vim\u0000--ro-bind\u0000~/.ssh\u0000~/.ssh"

mounted the host's ssh keys into the sandbox, while --dry-run listed ~/.ssh
under NOT GRANTED and the FILESYSTEM block showed no such line — there was no
Mount, so Validate, rejectMasking and the provenance model never saw it. The
same shape with --tmpfs masked @sys's `ro /usr`: a profile expressing
subtraction, which invariant 1 calls structurally impossible.

A raw NUL never got this far; go-toml refuses control characters in a basic
string. The \u0000 escape is accepted and produces the same byte, which anyone
re-testing needs.

checkEnvName has refused NUL in a NAME since the beginning, with reasoning that
applies word for word to the value — a rule written once and applied to one of
its two halves. checkEnvValue now refuses every C0 control and DEL in a value a
profile wrote, for set/merge/prepend; inherit and sanitise carry the host's
value and are deliberately out of scope (§2.3, and the environment execve hands
over cannot contain a NUL anyway).

nulJoin is the second guard, at the code that owns the separator and at the last
point the whole argv exists as Go values: it refuses any element containing a
NUL whatever wrote it, and names the index.

The other control characters are refused for a different reason and the two are
worth keeping apart: NUL authors a MOUNT, newline and ESC author a LIE in the
artifact a human reads to decide whether to trust the sandbox.

Found by an independent adversarial review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Two unrelated repairs to the same file ride along, because they touch the same
tables and splitting them would mean splitting a file rather than a change.

forbiddenEnv gains GIT_PAGER, GIT_TEMPLATE_DIR, GIT_DIR, GIT_ALLOW_PROTOCOL and
GIT_PROTOCOL_FROM_USER, each measured on git 2.55 before being added. Three
different shapes, which is why the rule is "the value is code" and not "the
value is a command": GIT_PAGER is a command; GIT_TEMPLATE_DIR and GIT_DIR are
directories whose HOOKS are code, installed into every repo a later clone
creates or run on the next commit; the last two carry no code at all and merely
switch off git's refusal of the ext:: transport, which runs an arbitrary
command.

What that does NOT do is close the class, and the comment now says so. git falls
back GIT_EDITOR -> core.editor -> VISUAL -> EDITOR and GIT_PAGER -> core.pager
-> PAGER, and EDITOR/VISUAL/PAGER are legal by §3.2's decision — @claude
inherits all three. The GIT_* entries close the INVISIBLE half of the class.
Carried in TODO.md, pinned by a test so withdrawing those three is a deliberate
§3.2 decision rather than a table edit. §3.2's claim that they were "refused
inside @git-ro-style identity" was a phantom gate — nothing reads
Policy.Identity — and is deleted rather than implemented.

IsEnvList is exported for the renderer, which needs to know whether a space in a
value separates two elements or belongs to one.
…cannot know

A profile could mount a tmpfs — or a rw bind — AT the staging directory and stage
one file inside it. HasStagedBin then saw the staged file, snug put the
now-writable directory FIRST on PATH in its own `(snug)` provenance, and the
payload wrote `git` into it. Measured: WROTE-OK, and the shadowed git ran. The
rw-bind spelling was worse, persisting the shadowed command to the host
directory.

The profile named PATH nowhere, so this is not the accepted-residual class
either: no human read a declaration. It is the case CLAUDE.md's staging rule
says cannot happen — "a profile cannot pick a writable directory by accident,
because it does not pick one at all" — defeated one indirection below the layer
the rule was written about. podmanstub.go predicted the exact failure and
nothing checked it.

StagedBinDir joins /proc and /dev in snugsOwn. It is keyed on the exact guest
path, so @claude's `{home}/.local/bin/claude:/run/snug/bin/claude` and the
Authored podman stub are untouched — staging one executable INSIDE the directory
is what the directory exists for.

Two sentences in describeCommands were false on the same screen:

  - the closing paragraph printed "NOT writable from inside" unconditionally,
    four lines above `PATH /run/snug/bin (snug) staged bin ← writable from
    inside`. It is now gated on IsShadowSlot and says the opposite when the
    predicate fires. Validate refuses that arrangement, so the branch should be
    unreachable — which is why it is worth keeping: a refusal that is later
    relaxed must not silently restore a false sentence.
  - the per-command line never read m.Access, so a rw staged bind was announced
    as "read-only" while the payload rewrote it and the rewrite reached the host
    file.

Two assertions that named this property could not fail. Their fixtures staged
nothing, so IsShadowSlot answered false through its "nothing is there" branch
and grantMark returned "not granted" — both measuring ABSENT and reading as
UNWRITABLE, and both would have kept passing under the defect above. Each now
has a control that puts a tmpfs there and requires the predicate to say yes.

Found by an independent adversarial review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It had exactly three call sites, all in describeEnvironment. Unguarded: the
bwrap argv block (formatArgs, which had no test at all), the FILESYSTEM loop,
and `snug profile show`. Each of the three was reached, measured:

  (a) FILESYSTEM. filepath.Clean leaves a newline alone, so a guest path could
      carry one and render as TWO correctly-columned grant rows — the second
      spelled to look like `ro /etc/shadow @sys` for a mount that did not exist.
      The sandbox really had one directory whose name contained a newline.

  (b) The argv block, from a HOST value with no profile file involved: @claude
      ships `inherit EDITOR`, and EDITOR=$'vim\n  --ro-bind ~/.ssh ~/.ssh'
      printed that as a --ro-bind line in the argv of a policy with no such
      mount. The ENVIRONMENT block on the same screen escaped the same string
      correctly. This is the identical source d2888b5 closed one commit ago, at
      the block four lines below the one it fixed.

  (c) `snug profile show`, in a real 110-column terminal: a value ending in
      ESC[1A CR overwrote the row above it, and `rw /home/michal` — the whole of
      $HOME, writable — was simply not on the screen. `cat -v` showed it there
      all along. That screen is upstream of every --dry-run: it is what someone
      reads to decide WHETHER to select a profile.

The renderers now escape at every sink, and Validate refuses a control character
in a GUEST path outright, next to the clean-path check it sat beside. Guest
only: a host path is not snug's to refuse, since a file on this machine may
legally be named with a newline, and the renderer covers that half.

The new test asserts the SET of sinks rather than a site — it drives the whole
dry-run and the whole `profile show` and asks whether anything anywhere emitted
a raw ESC or CR. The existing per-site test calls describeEnvironment directly
and structurally cannot observe another sink; its own comment warns that a fix
at one site would look identical, which is exactly what happened.

Found by an independent adversarial review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing itself

Both need an unusual path to fire, both are a tightening rather than an
escalation, and both were silent — which is what puts them in the same commit.

checkPrependAgreement keyed on `strings.Join(values, " ")`, directly under a doc
comment stating that equality is over the whole ordered sequence. A space-join is
injective only if no element contains a space, and an absolute path may:

    -p ptools          two elements, /opt/a and /opt/b
    -p qtools          ONE element, "/opt/a b"
    -p ptools -p qtools    keys equal -> "they agree" -> one entry deleted,
                           exit 0, nothing on the screen

A profile removing what another profile put on PATH is the property envresolve.go
opens by promising. seqKey renders with %q, which quotes each element separately,
and reads well enough to go straight into the refusal message — so the two
prepend refusals in the golden now show `["/opt/a" "/opt/b"]` instead of a run of
paths that could be read either way.

The screen had the same ambiguity from the other side: consecutive entries from
one verb and one profile are collapsed onto a line and joined with a space, so
`/opt/a /opt/b` could be two elements or one. elementValue quotes an element
containing a space — for LIST variables only, via the newly exported
policy.IsEnvList, because PS1 is mostly spaces and quoting a scalar would be
noise. No golden moved.

expandVars restarted its scan over the whole result after every substitution, so
substituted text was itself expanded — and the substituted text is a path the
HUMAN chose, not profile text. Measured:

    mkdir -p '/tmp/x/{home}' /tmp/x/home/michal
    snug '/tmp/x/{home}'
      TARGET  /tmp/x/{home}        read-only, via @parent-ro
      rw      /tmp/x/home/michal   @cwd-rw     <- a different directory, writable

The payload wrote there and it persisted to the host, while the directory the
user named was read-only: "the target is the only writable thing that persists"
was false for that input. A directory named `{target}` was worse — expansion fed
its own output back in and spun at 224% CPU until killed, quadratic in TIME with
RSS flat at 18 MB, so it presents as a hang rather than an OOM. One pass, with
the literal text and the substituted value both committed to a builder and never
looked at again, kills both. The self-reference case is tested with a deadline
rather than inline, so a regression fails in five seconds instead of hanging the
package.

Found by an independent adversarial review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit suite covers both defects at the layer they were fixed. These run them
in a real sandbox, because at every layer above the kernel both were invisible:
the first produced a real mount with no Mount in the policy, and the second a
real writable directory that --dry-run described as unwritable. Only a running
sandbox can say which was true. Each has a control that must still work — the
same profile without the NUL, and one file staged INSIDE the directory — so
"snug refused" cannot be satisfied by a snug that refuses everything.

Both were proved to fail: with the fixes backed out, the first sandbox started
and listed the host's ssh keys, and the second wrote an executable into the
directory snug puts first on PATH.

Docs, all of them corrections rather than additions:

  CLAUDE.md gains the NUL-in-a-value fact — the flag list is a NUL-separated
  string, so a profile's bytes could author a mount — and the general shape it
  belongs to: a rule written once and applied to one of its two halves
  (checkEnvName refused NUL in a NAME from the beginning). The staging bullet is
  corrected where it said a profile cannot pick a writable directory by
  accident; it could, one indirection down.

  sandbox-policy gains that correction as part of invariant 6, and a new
  invariant 7: text a profile wrote is not text snug wrote, at ANY sink, and the
  two questions to ask of each are different — can it author a MOUNT, or can it
  author a LIE.

  redteam gains both attacks as checklist items, including the instruction to
  read `profile show` in a real terminal as well as through `cat -v`, because
  the two disagree and the terminal is the one a human uses.

  VERIFY.md gains §6h and §6i, with the escape spelling anyone re-testing needs.

  TODO.md carries the residual this work did NOT close: forbiddenEnv does not
  close git's exec class, because EDITOR/VISUAL/PAGER are legal by §3.2 and git
  falls back to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TEST.md was a local scratch list of things worth poking at while deciding
whether this branch was right. Everything in it that is a check now lives here,
re-run verbatim against HEAD rather than copied — the prepend refusal in
particular has changed since it was written, and now renders each element with
%q, which is the whole point of that check.

  6d  gains the run where all three sanitise drop reasons appear at once. Each
      one alone reads as "an entry went away"; together they say which of "not
      in the sandbox", "in it and empty and writable" and "in it and its magic
      symlinks leave it" happened, and the three must never collapse.
  6j  all five verbs in one profile, then the payload picking the prepended
      binary over the merged one of the same name. The screen agreeing with
      itself proves nothing about what execve resolves.
  6k  a host variable set to empty arrives set. NO_COLOR is specified as "any
      value, including empty", so dropping it silently re-enabled colour — and
      both the bug and the fix are invisible on every other screen.
  6l  the parse-time refusals, one config dir each, because a file that fails to
      load makes every later sandbox in the sequence refuse and mask the next
      case. Carries the bound as well: this list does not close git's exec class,
      since PAGER/EDITOR/VISUAL stay legal by §3.2 and git falls back to them.
  8b  a disagreement names every claimant, and the two -p orders must print the
      same bytes. §8 already checked that for the argv; this is the same property
      for the environment, where it is enforced by sorting rather than by the
      model having no order.
  8c  a file that does not parse degrades diagnostics and refuses sandboxes.
      Including the wording check: -p on a profile from that file must say the
      file failed to parse, never "unknown profile", which would send someone to
      fix their command line while the grant sat unloaded.

Two sentences were simply false and are corrected while nearby: §7 still said
"M0 is offline by design ... networking arrives in M2", and the closing section
listed seccomp, networking and containers as not covered, four sections after
covering all three. What is genuinely not covered is GUI/audio/D-Bus, and that
is by decision rather than by schedule.

The scratch file's last section was not checks but the places its author thought
the reasoning was thinnest. Three of those are still live and are kept as such,
named as review notes rather than steps. The fourth — profile show has no golden
— is no longer true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vyskocilm
vyskocilm merged commit 110a6be into main Aug 12, 2026
2 checks passed
@vyskocilm
vyskocilm deleted the env-implement branch August 12, 2026 21:04
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