fix(runner): bound the agent socket path under the AF_UNIX sun_path cap - #12
Merged
Merged
Conversation
Contributor
Author
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Greptile SummaryThe PR hardens and tests Runner agent-socket creation.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| 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
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
force-pushed
the
compass-runner-sea-1440-sunpath-cap
branch
from
July 28, 2026 04:39
f8d6bf1 to
f9d1b22
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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>isNamePrefix+ the server-minted 32-hex agent account id. That tail is 69 bytes, so the default/run/compasslands at 81 of the 107 usable bytes and the operator's--runtime-dirbudget is 38 on Linux, 34 on darwin/BSD.Nothing bounded it on any hop:
--runtime-dirflows frommain.gothroughrun.gotofilepath.Jointolc.Listenunchecked.Over the cap,
bind(2)returns a bareEINVALnaming neither the limit nor the length. Worse, by thenMkdirAllhas already run and the0700directory survives — nothing in the socket's lifecycle removes it, sinceCloseremoves 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_patharray is not one size across the platforms//go:build unixadmits: 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) - 1is not an approximation but exactly the bound Go's own wrapper enforces:syscall_linux.go:559rejectsn == len(Path)for a non-abstract name,syscall_bsd.go:191rejectsn >= len(Path)— both landing onlen-1.The same cap was silently breaking the runnerhub integration test
TestIntegrationProvisionStartRelayToStoreAndBuspassedt.TempDir()as theRuntimeDir.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 aSessionHost, not a future long name.It now uses a fixed short root and asserts the resulting longest path against the cap.
Verification
pgtest.RequireDSNsilent-skip): red before the fix — hangs to the 600s deadline; green after — 11.5s PASS. Reclaimed 2031 leaked podman volumes to get pastnum_locks (2048), which was skipping the suite while printingok.bind, failing the diagnostic assertions and the never-created-directory assertion; tightening the bound to>=refuses an at-cap path the kernel accepts.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.Joincleans, so a../in the id escapesRuntimeDir— verified under Go:listenAgentSocketwould thenMkdirAlla0700directory 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:
RecordAgentContainer, which runs afterhub.Provisionhas already created the directory and bound the socket.Length and shape are the same validation of the same input at the same hop, so
validAccountIDnow refuses anything that is not a single safe path element, at the entry point inBuildSpec. Red-first: every traversal row fails without the guard.Other review fixes
padTofails instead of skipping. Under a 100-byteTMPDIRboth subtests skipped and the package printedok— the only coverage of the guard's boundary, silently disabled. The sibling helpershortRuntimeDiralready failed closed on the identical condition; they now agree.t.Cleanup(cancel)restored adjacent tocontext.WithCancelin the integration test: cancel was registered only insiderunSessionsLoop, leaving ~60 lines of fixture setup that couldt.Fatalfwith the context never cancelled. LIFO ordering and the documented drain are unchanged;CancelFuncis idempotent.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 (holdsread, nospawns, no worker-agent prompt).t.Cleanup(cancel); stale/overstated citationsEvery 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: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 == 0x7fadmits exactly the C1 controls and bidi overrides that spoof a rendered name. Widened tounicode.IsControlplus the format class, with rows for both.Gate: local only — this repo has no CI
sealedsecurity/compassis not registered on Woodpecker, has no workflow config, and has no branch protection onmain(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:35 tasks, zero failed, zero task-level cache hits (the 2-minute wall time is the real build;
devenv-fork:buildalone is ~2m from cold).Against a live Postgres (not the
pgtest.RequireDSNsilent-skip):TestIntegrationProvisionStartRelayToStoreAndBusPASS 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 theNamePrefixcheck each redden a specific named subtest.Filed, not fixed here
pgtestsilently 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 hitnum_locks (2048), and a fully-skipped pgtest still printsok.Round 4 — a discarded error, from a bot finding
Both receives on
loopDonetook theRunSessionserror and dropped it. A streamfailure would have ended the loop while
assertCleanShutdownstill read as a cleanteardown — 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
nilorcontext.Canceledand fail on anything else.The predicate admits
nilbecause I measured it, not because it is permissive.Inverting the guard reddens the test and prints what the clean path actually
delivers:
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.Canceledalone would have reddened every passing run.Restored; pgtest green against live Postgres in 6.5s, gate 35/0.