Skip to content

fix(runner): bound the agent socket path under the AF_UNIX sun_path cap - #12

Merged
mattwilkinsonn merged 5 commits into
mainfrom
compass-runner-sea-1440-sunpath-cap
Jul 29, 2026
Merged

fix(runner): bound the agent socket path under the AF_UNIX sun_path cap#12
mattwilkinsonn merged 5 commits into
mainfrom
compass-runner-sea-1440-sunpath-cap

Conversation

@mattwilkinsonn

@mattwilkinsonn mattwilkinsonn commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Ported from sealed #975 + #974, which cannot merge — Compass now lives here.

The bug

The agent socket path is RuntimeDir + /containers/<container>/agent.sock (host.go:291), where <container> is NamePrefix + the server-minted 32-hex agent account id. That tail is 69 bytes, so the default /run/compass lands at 81 of the 107 usable bytes and the operator's --runtime-dir budget is 38 on Linux, 34 on darwin/BSD.

Nothing bounded it on any hop: --runtime-dir flows from main.go through run.go to filepath.Join to lc.Listen unchecked.

Over the cap, bind(2) returns a bare EINVAL naming neither the limit nor the length. Worse, by then MkdirAll has already run and the 0700 directory survives — nothing in the socket's lifecycle removes it, since Close removes only the socket file and never its parent. Checking the length before any directory is created makes that leak moot rather than adding a teardown.

Why the cap is derived, not written down

The sun_path array is not one size across the platforms //go:build unix admits: 108 bytes on linux/solaris/illumos, 104 on darwin and the BSDs, 1023 on aix. No pair of hand-written numbers is correct, and an over-sized constant would admit exactly the path the kernel rejects at bind — the failure this check exists to prevent.

const sunPathMax = len(syscall.RawSockaddrUnix{}.Path) - 1 is not an approximation but exactly the bound Go's own wrapper enforces: syscall_linux.go:559 rejects n == len(Path) for a non-abstract name, syscall_bsd.go:191 rejects n >= len(Path) — both landing on len-1.

The same cap was silently breaking the runnerhub integration test

TestIntegrationProvisionStartRelayToStoreAndBus passed t.TempDir() as the RuntimeDir. t.TempDir() derives its path from the test name, and the 38-byte budget implies a 19-character ceiling that every one of the package's 43 tests exceeds (shortest: 26). Only this test failed, because only this one opens an agent socket — so the latent trap was the next test to wire a SessionHost, not a future long name.

It now uses a fixed short root and asserts the resulting longest path against the cap.

Verification

  • Real Postgres, genuinely executing (not the pgtest.RequireDSN silent-skip): red before the fix — hangs to the 600s deadline; green after — 11.5s PASS. Reclaimed 2031 leaked podman volumes to get past num_locks (2048), which was skipping the suite while printing ok.
  • Two mutations verified red: dropping the pre-check lets the path reach bind, failing the diagnostic assertions and the never-created-directory assertion; tightening the bound to >= refuses an at-cap path the kernel accepts.
  • Full gate green: moon run :ci, 35 tasks, zero failures.

Reach beyond the runtime path

compass-server hit this same limit blocking a runnerhub pgtest on their T3 write-through — the fix unblocks that too.

Refs SEA-1440, SEA-1443


Path traversal, found by review and fixed here

The length guard alone was half a validation. The agent account id is interpolated into the container name (spec.go) and that name becomes a path segment of the socket (host.go). filepath.Join cleans, so a ../ in the id escapes RuntimeDir — verified under Go:

../../../../tmp/pwned  ->  /run/tmp/pwned/agent.sock          (25 bytes)
../../etc              ->  /run/compass/containers/etc/agent.sock

listenAgentSocket would then MkdirAll a 0700 directory there and bind. The length guard cannot catch this, because traversal SHORTENS the path — that first example sails past a 107-byte cap.

The three arguments my own comment made for the id being safe do not hold at this hop, and stating them made the gap look considered-and-closed:

  • minted 32-hex — but the width is a property of the minting site; the Runner never re-checks it.
  • FK-constrained — but the FK is enforced by RecordAgentContainer, which runs after hub.Provision has already created the directory and bound the socket.
  • admin-only — narrows who, not what.

Length and shape are the same validation of the same input at the same hop, so validAccountID now refuses anything that is not a single safe path element, at the entry point in BuildSpec. Red-first: every traversal row fails without the guard.

Other review fixes

  • padTo fails instead of skipping. Under a 100-byte TMPDIR both subtests skipped and the package printed ok — the only coverage of the guard's boundary, silently disabled. The sibling helper shortRuntimeDir already failed closed on the identical condition; they now agree.
  • t.Cleanup(cancel) restored adjacent to context.WithCancel in the integration test: cancel was registered only inside runSessionsLoop, leaving ~60 lines of fixture setup that could t.Fatalf with the context never cancelled. LIFO ordering and the documented drain are unchanged; CancelFunc is idempotent.
  • pgtest budget tied to the real minted id — an assertion after the fixture reddens if store ids widen, rather than letting the modelled budget go stale while the real path overruns.
  • Comments trimmed. Dropped two stdlib line citations that pin the comment to a toolchain version nothing asserts, and corrected the "exactly the bound Go enforces" claim: AIX permits len(Path) and a Linux abstract name carries no terminator, so the constant is deliberately conservative at both edges. Neither reaches the Runner, which only binds filesystem paths.

Review record — three rounds, terminated at the K=3 bound

Run per skill://review: one adversarial Opus reviewer per round, spawn verified against the harness-written transcript each time (holds read, no spawns, no worker-agent prompt).

round high medium low what it found
1 1 3 4 Path traversal via the unsanitized account id; a test that silently self-disabled; a lost t.Cleanup(cancel); stale/overstated citations
2 0 1 3 The round-1 fix commit left two comments asserting the opposite of the code beside them
3 0 1 2 The round-2 fix commit did it again — the new guard's stated rationale was measurably false

Every finding is dispositioned: all fixed, none deferred, no follow-up issue owed.

Worth stating plainly rather than burying: this did not terminate on a zero-finding round. Rounds 2 and 3 each found a defect introduced by the previous round's fix — both comment-truth, never logic. The shipped guards were verified independently correct each round (conjuncts individually load-bearing, no bypass into the socket-path construction, every numeric and platform claim checked against the Go source), so the logic converged after round 1 and the prose took two more rounds to catch up. That pattern is the honest summary of this PR: I was repeatedly more confident in my explanations than the measurements justified.

The round-3 case is the clearest example. I wrote that a control character "fails much later, at bind, as a bare EINVAL." Measured against listenAgentSocket's own ordering:

"abc\x00def"    mkdir=EINVAL   bind=(never reached)
"abc\ndef"      mkdir=ok       bind=ok
"abc\x7fdef"    mkdir=ok       bind=ok
"abc\u202edef"  mkdir=ok       bind=ok
"abc\u0085def"  mkdir=ok       bind=ok

Wrong hop for the NUL, and simply false for the rest — they bind cleanly. The guard was right and its justification was not, which is the worse failure: an unsound reason invites a later reader to delete a guard that is load-bearing for a reason nobody wrote down. Stating the real reason (these characters reach the container name and the logs that quote it) also showed the predicate was wrong — r < 0x20 || r == 0x7f admits exactly the C1 controls and bidi overrides that spoof a rendered name. Widened to unicode.IsControl plus the format class, with rows for both.

Gate: local only — this repo has no CI

sealedsecurity/compass is not registered on Woodpecker, has no workflow config, and has no branch protection on main (gh api .../branches/main/protection → 404). The checks on this PR are Graphite mergeability and Greptile; neither builds anything. The evidence below is testimony, not a gate, and is labelled as such.

moon run :ci --force — forced, so nothing is a cached success string:

Tasks: 35 completed
 Time: 2m 1s 677ms

35 tasks, zero failed, zero task-level cache hits (the 2-minute wall time is the real build; devenv-fork:build alone is ~2m from cold).

Against a live Postgres (not the pgtest.RequireDSN silent-skip): TestIntegrationProvisionStartRelayToStoreAndBus PASS in 14.6s, having hung to the 600s deadline before the fix.

Both re-run after restacking onto 05da6b3 — the earlier green described a base that is no longer the merge tree. Main's four intervening commits are UI-only and touch none of this PR's five Go files, so the rebase was clean, but the evidence above is from the current head.

Every guard in this PR is mutation-verified red, not merely covered: dropping the length pre-check, tightening it to >=, dropping the -1, removing the traversal check, narrowing the control-character predicate, and weakening the NamePrefix check each redden a specific named subtest.

Filed, not fixed here

  • SEA-1523pgtest silently skips when podman's lock table fills, reporting a green suite that ran nothing. Found running this PR's integration test: 2031 leaked dangling volumes hit num_locks (2048), and a fully-skipped pgtest still prints ok.

Round 4 — a discarded error, from a bot finding

Both receives on loopDone took the RunSessions error and dropped it. A stream
failure would have ended the loop while assertCleanShutdown still read as a clean
teardown — the error was the only thing distinguishing the ctx cancel ended it from
the stream died, and neither site looked at it.

Both now accept nil or context.Canceled and fail on anything else.

The predicate admits nil because I measured it, not because it is permissive.
Inverting the guard reddens the test and prints what the clean path actually
delivers:

--- FAIL: TestIntegrationProvisionStartRelayToStoreAndBus (11.84s)
    integration_pgtest_test.go:169: MUTATION probe: assertion reached, err=<nil>

That single run is doing two jobs: it proves the assertion is reachable (an
unreached assertion is the same green as a passing one), and it supplies the
value — a check for context.Canceled alone would have reddened every passing run.
Restored; pgtest green against live Postgres in 6.5s, gate 35/0.

@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

SEA-1440

SEA-1443

mattwilkinsonn commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

The PR hardens and tests Runner agent-socket creation.

  • Rejects AF_UNIX socket paths that exceed the platform-derived limit before creating directories.
  • Validates account IDs and container-name prefixes as safe path elements.
  • Uses a bounded runtime directory in the runnerhub integration test and strengthens its setup and teardown checks.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
go/internal/runner/gateway/socket.go Adds a platform-derived AF_UNIX path-length guard before filesystem mutation.
go/internal/runner/gateway/socket_test.go Covers accepted and rejected socket-path boundary conditions and verifies that rejected paths leave no directory.
go/internal/runner/spec.go Validates both components of generated container names against traversal and misleading control or format characters.
go/internal/runner/spec_test.go Adds coverage for unsafe account IDs and path-separator-bearing name prefixes.
go/internal/runnerhub/integration_pgtest_test.go Uses a short runtime directory, verifies the modeled account-ID width, and now checks RunSessions errors during normal and cleanup paths.

Reviews (6): Last reviewed commit: "test(runner): assert the RunSessions loo..." | Re-trigger Greptile

mattwilkinsonn and others added 4 commits July 28, 2026 00:36
The agent socket path is RuntimeDir + /containers/<container>/agent.sock
(host.go:291), where <container> is NamePrefix + the server-minted 32-hex
agent account id. That tail is 69 bytes, so the default /run/compass lands
at 81 of the 107 usable bytes and the operator's --runtime-dir budget is
38 on Linux, 34 on darwin/BSD. Nothing bounded it on any hop: --runtime-dir
flows from main.go through run.go to filepath.Join to lc.Listen unchecked.

Over the cap, bind(2) returns a bare EINVAL naming neither the limit nor
the length. Worse, by then MkdirAll has already run and the 0700 directory
survives: nothing in the socket's lifecycle removes it, since Close removes
only the socket file and never its parent. Checking the length before any
directory is created makes that leak moot rather than adding a teardown.

The cap is derived from the platform's own sockaddr_un rather than
hard-coded. The array is not one size across the platforms //go:build unix
admits — 108 bytes on linux, solaris and illumos, 104 on darwin and the
BSDs, 1023 on aix — so no pair of hand-written numbers is correct, and an
over-sized constant would admit exactly the path the kernel rejects at
bind. The expression matches the bound Go's own wrapper enforces:
syscall_linux.go:559 rejects n == len(Path) for a non-abstract name and
syscall_bsd.go:191 rejects n >= len(Path), both landing on len-1.

The same cap was silently breaking the runnerhub integration test, which
passed t.TempDir() as the RuntimeDir. t.TempDir() derives its path from the
test name, and the 38-byte budget implies a 19-character ceiling that every
one of the package's tests exceeds. Only this test failed, because only
this one opens an agent socket — so the latent trap was the next test to
wire a SessionHost, not a future long name. It now uses a fixed short root
and asserts the resulting longest path against the cap.

Verified against a live Postgres: the integration test hangs to the 600s
deadline before the fix and passes in 11.5s after. Two mutations verified
red — dropping the pre-check lets the path reach bind and leaks the
directory; tightening the bound to >= refuses an at-cap path the kernel
accepts.

Refs SEA-1440, SEA-1443

Co-Authored-By: seal <noreply@sealedsecurity.com>
Review of the sun_path guard found the gap beside it: the account id is
interpolated into the container name (spec.go) and that name becomes a path
segment of the agent socket (host.go). filepath.Join cleans, so a "../" in
the id escapes RuntimeDir entirely — verified, "../../../../tmp/pwned"
resolves to /run/tmp/pwned/agent.sock, where listenAgentSocket would
MkdirAll a 0700 directory and bind. The length guard cannot catch it,
because traversal SHORTENS the path: that example is 25 bytes.

The three arguments the guard's own comment made for the id being safe do
not hold at this hop, and the comment claiming otherwise made the gap look
considered-and-closed. The id is minted 32-hex, but the width is a property
of the minting site and is never re-checked in the Runner; the foreign key
tying the id to a real account is enforced by RecordAgentContainer, which
runs after hub.Provision has already created the directory and bound the
socket; and an admin-only RPC narrows who may call it, not what they may
pass. Length and shape are the same validation of the same input at the
same hop, so validate both: validAccountID refuses anything that is not a
single safe path element, at the entry point in BuildSpec.

Also from the review:

Make padTo fail rather than skip. Under a deep TMPDIR both subtests skipped
and the package reported ok — reproduced with a 100-byte TMPDIR. Those two
subtests are the only coverage of the boundary the guard enforces, so a
silent skip reports green for a package that asserted nothing, which is the
same false-green this change exists to remove. The sibling helper
shortRuntimeDir already fails closed on the identical condition; the two now
agree.

Restore t.Cleanup(cancel) adjacent to context.WithCancel in the integration
test. Cancel was registered only inside runSessionsLoop, leaving ~60 lines
of fixture setup that could t.Fatalf with the context never cancelled. The
later registration still runs first under LIFO, so the drain ordering that
loop documents is unchanged, and CancelFunc is idempotent.

Tie the pgtest path budget to the real minted id. shortRuntimeDir models the
account id as accountIDHexLen "f"s because it runs before an account exists;
an assertion after the fixture now reddens if store ids widen, instead of
letting the budget silently go stale while the real path overruns.

Trim the guard's comments to what is load-bearing. Dropped two stdlib line
citations that pin the comment to a toolchain version nothing asserts, and
corrected the claim that the constant is "exactly the bound Go enforces" —
AIX permits len(Path) and a Linux abstract name carries no terminator, so
the constant is deliberately conservative at both edges. Neither reaches the
Runner, which only binds filesystem paths. Fixed a stale line citation to
Close, added in the same diff.

Refs SEA-1440, SEA-1443

Co-Authored-By: seal <noreply@sealedsecurity.com>
…ct the comments the last fix invalidated

Round 2 of review found no high or medium defect in the traversal guard —
the three conjuncts are each load-bearing and there is no bypass into the
socket-path construction — but it caught the previous commit leaving two
comments asserting the opposite of the code beside them, which is the same
defect class that commit was correcting.

Correct them. padTo's doc line still promised it "skips when the root alone
already exceeds the budget" twenty lines above the t.Fatalf that replaced
the skip. The pgtest helper built a justification on a contrast that no
longer exists ("unlike gateway/socket_test.go:381 which t.Skipf's the same
condition") — that Skipf is gone and :381 never held it, so the next reader
was sent to reconcile something already reconciled. Both now state the
standing reason on its own terms. Two further line citations pointed at a
prose line and a bare brace; they cite the symbol instead, which is what
this diff already argued for when it dropped the stdlib line citations. The
"never restated" claim is narrowed to what is true: the number is never
restated, the derivation is duplicated because the constant is unexported.

Validate NamePrefix at startup. The container name is NamePrefix +
accountID, and the previous commit constrained only the request-derived
operand — so the operator-derived one could still carry a separator and
escape RuntimeDir through the same filepath.Join clean. Not reachable today
(NamePrefix is a literal in main), but NewConfigSpecBuilder exists to make a
misconfigured Runner fail at startup rather than at first provision, and a
path-affecting default is exactly that class. Both operands are now checked.

Refuse a control character in the account id. A NUL is valid UTF-8 and
carries no separator, so it clears every existing check and fails at bind as
a bare EINVAL naming nothing — the same undiagnosable error the length guard
was added to eliminate. There is no traversal or injection consequence
(podman receives the name as a distinct argv element, not through a shell),
so this is diagnostic quality, refused where the message can name the input.

Both new guards verified red by mutation.

Refs SEA-1440, SEA-1443

Co-Authored-By: seal <noreply@sealedsecurity.com>
…d, and widen it to match

Review measured the claim in the previous commit and it was false. The
comment said a control character "fails much later, at bind, as a bare
EINVAL". Probed on Linux against listenAgentSocket's own ordering — MkdirAll
then bind — with the real path shape:

    "abc\x00def"   mkdir=EINVAL          bind=(never reached)
    "abc\ndef"     mkdir=ok              bind=ok
    "abc\x7fdef"   mkdir=ok              bind=ok
    "abc\u202edef" mkdir=ok              bind=ok
    "abc\u0085def" mkdir=ok              bind=ok

So the stated rationale was wrong-hop for the NUL (MkdirAll, not bind) and
simply false for the other rows, which bind cleanly. The guard was right and
its justification was not, which is the worse failure: an unsound reason
invites a later reader to delete a guard that is load-bearing for a reason
nobody wrote down.

The real reason is what those measurements show. Nothing downstream refuses
these characters, so the id reaches the container name and every log line
that quotes it. That name is what an operator reads out of `podman ps` and
the Runner's logs to identify an agent, so a character that forges a line
break or reorders the rendered name makes the identification unreliable.

Widening the predicate follows from stating the reason correctly. `r < 0x20
|| r == 0x7f` admits the C1 range and the bidi overrides — precisely the
characters that spoof a rendered name — while refusing DEL, which does not.
unicode.IsControl covers C0, DEL and C1; the format class is checked
alongside it because a bidi override is not a control character but spoofs
just as effectively. Two rows added for the cases the narrow predicate
admitted; narrowing it back reddens exactly those three.

Also from review: scope the pgtest helper's "never restated" claim to the
code, since the platform sizes quoted two lines above are numbers, and drop
the empty-string case from the fs.ValidPath enumeration, which describes a
callsite that cannot be reached (the empty id returns above with its own
message).

Refs SEA-1440, SEA-1443

Co-Authored-By: seal <noreply@sealedsecurity.com>
@seal-agent
seal-agent force-pushed the compass-runner-sea-1440-sunpath-cap branch from f8d6bf1 to f9d1b22 Compare July 28, 2026 04:39
…ng it

Both receives on the loopDone channel took the value and dropped it, so a
RunSessions stream failure ended the loop while assertCleanShutdown still
read as a clean teardown and the cleanup still read as a timely exit. The
error was the only evidence distinguishing "the ctx cancel ended it" from
"the stream died," and neither site looked at it.

Both now accept a nil or context.Canceled end and fail on anything else.
The predicate admits nil deliberately: probing the assertion by inverting
it shows the clean path delivers a nil, not a context.Canceled, so a
check for Canceled alone would redden every passing run.

Verified the assertion executes rather than assuming it: inverting the
guard reddens the test at integration_pgtest_test.go:169 with err=<nil>,
which is both the reachability proof and the measurement behind admitting
nil. Restored, pgtest green against live Postgres in 6.5s.

Refs SEA-1440, SEA-1443

Co-Authored-By: seal <noreply@sealedsecurity.com>
@mattwilkinsonn
mattwilkinsonn merged commit d459c26 into main Jul 29, 2026
2 checks passed
@mattwilkinsonn
mattwilkinsonn deleted the compass-runner-sea-1440-sunpath-cap branch July 29, 2026 16:50
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