Skip to content

profile: drop @tmp-shared and the {host_tmpdir} variable (closes #399) - #572

Merged
vyskocilm merged 1 commit into
mainfrom
profile/drop-tmp-shared
Sep 10, 2026
Merged

vyskocilm merged 1 commit into
mainfrom
profile/drop-tmp-shared

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Closes #399.

What

@tmp-shared and the {host_tmpdir} variable behind it are removed. internal/cli/tmpdir.go goes with them.

Maintainer ruling: drop it, YAGNI, can be added back later.

Why the whole surface and not the directory's location

#399 asked whether the allocation should move from /tmp to /run/user/<uid>. Nothing needs the variable at all:

  • Only [profile.tmp-shared] named {host_tmpdir}. No other shipped profile, nothing includes it.
  • engineforwardedpath.go cited it only as an example of a divergent host:guest bind; @claude's {home}/.local/bin/claude:/snug/bin/claude is the same example and stays.
  • Environ.HostTmpDir was a resolver input the CLI filled.

The capability did not go anywhere — /tmp is the one path snug's own mount yields, so a profile that NAMES a host directory still takes it over, and --dry-run renders the path plus the "this is the HOST's /tmp" note. What is gone is snug allocating a per-project 0700 directory under os.TempDir(), checking it for the classic /tmp races, and binding it: machinery whose whole job was to pick a path a profile can name itself.

Also gone with it: 213MB/1209-directory /tmp accumulation #399 measured came from that allocator plus SIGKILL orphans.

The retired variable REFUSES

snug: profile "old": {host_tmpdir} in "{host_tmpdir}:/tmp" was removed with the @tmp-shared profile and has no replacement: name the host directory yourself, rw = ["/path/on/host:/tmp"]
exit=77

A profile still carrying {host_tmpdir} is not a typo — it was a real variable for the life of @tmp-shared — and plain "unknown variable" sends its author to check a spelling that is correct. Precedent: retiredAnonKey in internal/profile/file.go.

TestTheRetiredHostTmpdirVariableNamesItsOwnRemoval (internal/policy) is the named regression, and asserts the general arm beside it: a special case swallowing every unknown name would pass an assertion about {host_tmpdir} alone.

What the red-team finding leaves behind

internal/engine/runsweep.go's name filter (issue #425 F1) matches a name SHAPE rather than the snug-<uid>- prefix, because a target-derived name collides with it. The concrete collision was @tmp-shared's directory — payload-writable, so a sandbox writing /tmp/lock got another project's shared /tmp deleted, measured end to end. Filter and its test STAY: the claim is that the NEXT mechanism of that shape is safe without anyone remembering the file. Test renamed TestSweepNeverTouchesATargetDerivedDirectory, comments rewritten to say the profile is gone and the shape rule is not.

The redteam round found one sink the refusal did not reach

symlink = [{ at = "...", target = "..." }] expanded at and passed target through untouched — one key of a two-key table behaving unlike the other, silently. So {host_tmpdir} rendered verbatim into a link inode there while refusing in ro, rw, tmpfs, optional, symlink.at, every environ verb, identity.ssh_key and through an include. So did {home}: target = "{home}/x" put the six literal characters {home} in the sandbox and nothing said so.

Both keys expand now. Target is NOT Cleaned, unlike at — a link target is resolved by the kernel relative to the link and the shipped ones are relative on purpose (usr/bin). TestASymlinkTargetExpandsVariablesLikeEveryOtherField asserts both halves; negative control run against the old line, which fails it on the {home} arm. No golden moves: no shipped profile writes a variable in a symlink target.

The rest of the round is clean

  • Empty host path: unreachable. splitSpec refuses ":/tmp" with "both sides must be absolute paths" before any mount is built, and {host_tmpdir} refuses inside expandVars, so no --bind "" /tmp argv and no nil-deref.
  • Sweeps: snug-<uid>-sha256_<64hex> is still refused by isEngineRunDirName's decimal-components rule, so F1's shape is not newly deletable. Measured with five planted decoys and a live @podman-socket run.
  • Replacement grant: behaves as an ordinary bind. EvalSymlinks canonicalises the source, --dry-run renders what actually gets bound, FIFO source refused (ro is not read-only for a FIFO: a payload writes out through the default @parent-ro grant #287), nonexistent source refused, rejectHostHomeBind still fires.

What a user no longer gets, stated plainly: prepareHostTmpDir's symlink-at-the-allocated-name refusal, its owner == uid check, and its exactly-0700 requirement. Those were owed because SNUG chose the path; a path the human names gets the ordinary bind-grant contract every other ro/rw grant has always had.

Tests

test/integration/hosttmpvisibility_test.go (issue #87's local half) had a @tmp-shared arm. It now authors a profile binding a directory of its own at /tmp, and each arm carries a host-side control: the marker the payload writes to guest /tmp MUST appear in the named directory for the shared arm and MUST NOT for the private-tmpfs arm. Negative control run: with the profile flag removed the arm fails with "this arm never exercised a bind at /tmp and its decoy check proves nothing".

Three fixtures that skipped @tmp-shared while sweeping every builtin (envuncheckedsink, ownedenv) lose the skip — an exception list is where a sweep rots. jsonrefusal_test.go's "grant missing" class reached its refusal through @tmp-shared's unallocated directory and now uses a profile naming a path that does not exist.

Docs

README profile table, INDEX.md (§7.3 rewritten, path-variable list, tree listing, builtin-name count, host-integration-surfaces intro, the sessions-share-this-surface paragraph), THREAT-MODEL.md (#87's local half now argues from what a /tmp grant binds rather than from the allocator), TARGET-LOCK.md, ENVIRONMENT-VARIABLES.md (sanitise example), PARAMETERISED-PROFILES.md, VERIFY.md §9a-quater (new, both halves with real output).

TestINDEXAdvertisesExactlyThePathVariablesResolveBuilds caught the INDEX path-variable list on its own — issue #224's guard doing its job.

Verification

make gate green. SNUG_REQUIRE_SANDBOX=1 SNUG_SANDBOX_TIMEOUT=25m make integration on this host, both suites green: 210 PASS + 3 PASS, 2 SKIP, engine tests: 46 ran, floor 46 — podman version 6.0.2, exit 0. (25m ceiling because the default 8m does not fit this host; predates this branch.)

Redteam round run against this branch before push, per the definition of done. One finding, fixed here; the round's own summary is quoted above.

🤖 Generated with Claude Code

Maintainer ruling: drop it, YAGNI, it can be added back later.

#399 asked whether the allocated directory should move from /tmp to
/run/user/<uid>. Nothing needed the variable at all: only
[profile.tmp-shared] named {host_tmpdir}, nothing includes that profile,
engineforwardedpath.go cited it only as an example of a divergent
host:guest bind (@claude's "{home}/.local/bin/claude:/snug/bin/claude" is
the same example and stays), and policy.Context.HostTmpDir was a resolver
input the CLI filled for that one profile.

THE CAPABILITY DID NOT GO ANYWHERE; THE ALLOCATION DID. /tmp is still the
one path yieldTo yields, so a profile that NAMES a host directory still
takes it over and --dry-run still renders the path and the "this is the
HOST's /tmp" note. What is gone is snug creating a per-project 0700
directory under os.TempDir(), checking it for the classic /tmp races and
binding it — machinery whose whole job was to pick a path a profile can
name itself. internal/cli/tmpdir.go goes with it.

THE RETIRED VARIABLE REFUSES, and the message is the point rather than the
refusal:

    snug: profile "old": {host_tmpdir} in "{host_tmpdir}:/tmp" was removed
    with the @tmp-shared profile and has no replacement: name the host
    directory yourself, rw = ["/path/on/host:/tmp"]

exit 77. A profile still carrying it is not a typo — it was a real
variable for the life of @tmp-shared — and "unknown variable" would send
its author to check a spelling that is correct. Same shape as
retiredAnonKey (internal/profile/file.go).
TestTheRetiredHostTmpdirVariableNamesItsOwnRemoval (internal/policy) is
the named regression and asserts the general arm beside it, so a special
case that swallowed every unknown name would not pass.

WHAT THE RED-TEAM FINDING LEAVES BEHIND. runsweep.go's filter (issue #425
F1) matches a name SHAPE rather than the "snug-<uid>-" prefix because a
target-derived name collides with it; the concrete collision was this
profile's payload-writable directory, where a sandbox writing /tmp/lock
got another project's shared /tmp deleted. Filter and test stay — the
claim is that the NEXT mechanism of that shape is safe without anyone
remembering the file. Test renamed to
TestSweepNeverTouchesATargetDerivedDirectory.

A REDTEAM ROUND FOUND ONE SINK THE REFUSAL DID NOT REACH, AND IT IS FIXED
HERE. `symlink = [{ at = "...", target = "..." }]` expanded `at` and passed
`target` through untouched — one key of a two-key table behaving like the
other, silently — so {host_tmpdir} rendered verbatim into a link inode
there while refusing in ro, rw, tmpfs, optional, symlink.at, every environ
verb, identity.ssh_key and through an include. So did {home}: a profile
writing target = "{home}/x" got the six literal characters "{home}" in the
sandbox and nothing said so. Both keys expand now. The target is NOT
Cleaned, unlike `at` — a link target is resolved by the kernel relative to
the link and the shipped ones are relative on purpose ("usr/bin").
TestASymlinkTargetExpandsVariablesLikeEveryOtherField asserts both halves;
negative control run against the old line, which fails it on the {home}
arm. No golden moves: no shipped profile writes a variable in a symlink
target.

The rest of the round is clean — no findings on the empty-host-path angle
(splitSpec refuses ":/tmp" as "both sides must be absolute paths" before
any mount is built), on the sweeps (a snug-<uid>-sha256_<64hex> directory
is still refused by isEngineRunDirName's decimal-components rule, so the
F1 shape is not newly deletable), or on the replacement grant, which
behaves as an ordinary bind: EvalSymlinks canonicalises the source and
--dry-run renders what gets bound. What a user no longer gets, stated
plainly: prepareHostTmpDir's symlink-at-the-allocated-name refusal, its
owner==uid check and its exactly-0700 requirement. Those were owed because
SNUG chose the path; a path the human names gets the ordinary bind-grant
contract every other ro/rw grant has always had.

Tests. hosttmpvisibility_test.go (issue #87's local half) had a
@tmp-shared arm; it now authors a profile binding a directory of its own
at /tmp, and each arm carries a host-side control — the marker the payload
writes to guest /tmp MUST appear in the named directory for the shared arm
and MUST NOT for the private-tmpfs arm. Negative control run: with the -p
flag removed the arm fails with "this arm never exercised a bind at /tmp
and its decoy check proves nothing". Two builtin sweeps
(envuncheckedsink, ownedenv) lose their @tmp-shared skip, because an
exception list is where a sweep rots. jsonrefusal_test.go's "grant
missing" class reached its refusal through the unallocated directory and
now names a path that does not exist.

VERIFY.md §9a-quater names every sink the refusal reaches and carries the
symlink-target case with its real output.

Docs: README's profile table, INDEX §7.3 rewritten plus the path-variable
list, tree listing, builtin-name count and host-integration intro,
THREAT-MODEL's #87 local half (argues from what a /tmp grant binds, not
from the allocator), TARGET-LOCK, ENVIRONMENT-VARIABLES' sanitise example,
PARAMETERISED-PROFILES, and VERIFY.md §9a-quater with both halves and real
output. TestINDEXAdvertisesExactlyThePathVariablesResolveBuilds caught the
INDEX list by itself — issue #224's guard working.

make gate green. SNUG_REQUIRE_SANDBOX=1 SNUG_SANDBOX_TIMEOUT=25m make
integration: both suites green, 210 PASS + 3 PASS, 2 SKIP, engine tests 46
ran against a floor of 46, podman 6.0.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vyskocilm
vyskocilm merged commit d75cf67 into main Sep 10, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the profile/drop-tmp-shared branch September 10, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant