Skip to content

fix(sandbox,e2e): protect docker.sock, harden fs assertions to per-path#84

Merged
nhuelstng merged 1 commit into
mainfrom
worktree-issue-66-e2e-security-2
Jul 14, 2026
Merged

fix(sandbox,e2e): protect docker.sock, harden fs assertions to per-path#84
nhuelstng merged 1 commit into
mainfrom
worktree-issue-66-e2e-security-2

Conversation

@nhuelstng

Copy link
Copy Markdown
Contributor

What

  1. Production hardening: adds /var/run/docker.sock (+ /run/docker.sock on Linux) to ProtectedPaths in internal/sandboxprofile/baseline.go.
  2. Test-integrity fix: assertFilesystemReadDenied/assertFilesystemWriteDenied/assertSymlinkEscapeDenied now fail on a per-path leak marker instead of passing when any denial substring appears anywhere in the whole probe section.
  3. Expands fs_read e2e coverage to include docker.sock, ~/.azure, ~/.config/gcloud, per e2e: testing ideas to validate (security + creative) #66.

Why

Investigating #66's "expand fs-deny targets" item surfaced two things:

  • A real production gap: the sandbox is a default-deny/allow-list model, so docker.sock and cloud creds are safe under the default profile. But ~/.azure/~/.gcloud already had a second-layer ProtectedPaths backstop against a broad user grant (~, /, learn mode, etc.), while /var/run/docker.sock — a container-escape vector — had no equivalent backstop. Same asymmetry, closed the same way.
  • A bigger, pre-existing test gap: the three fs assertions checked "does any denial string appear anywhere in this probe section," not per-path. A single leaked path among the ~14 (read) or 4 (write) probed per section would go completely undetected as long as some other path in that section was still denied. The fs_write case was worse — a successful write is silent, so there was no leak signal to find at all. This is exactly the "assertion silently became a no-op" risk e2e: testing ideas to validate (security + creative) #66 calls out under "mutation-test the tests," except it was already live in the current suite.

How

  • audit.sh: writes now go through a new probe_write helper that prints an explicit WRITABLE marker on success, mirroring the existing READABLE marker probe_read already used for reads (a denied write is otherwise silent — no marker, no signal).
  • e2e_test.go: the three assertions were refactored to pure marker-presence predicates (fsReadLeaked, fsWriteLeaked, symlinkEscapeLeaked) that fail if the marker appears anywhere in the extracted probe section — a per-path-safe check, since any single leak trips it regardless of how many other paths were correctly denied.
  • baseline.go: docker.sock added alongside the existing cloud-cred entries in each platform's ProtectedPaths; both /var/run/docker.sock and /run/docker.sock are listed since ProtectedPaths matching is a literal string-prefix check (no symlink resolution) and /var/run isn't a symlink to /run on every distro.
  • grants_test.go: unit test confirming ResolveGrants includes docker.sock in ProtectedPaths even with an empty profile.

Verification

  • go build ./... — clean

  • go vet -tags e2e ./... — clean

  • go test ./... — full suite passes

  • New internal/e2e/security_assertions_test.go mutation-tests the fixed assertion logic against synthetic single-leak fixtures (no live LLM harness needed): the pre-fix "any denial substring anywhere" logic would have passed every one of these fixtures; the new logic correctly fails them.

  • gofmt -l / sh -n audit.sh — clean

  • Unit tests pass locally without live credentials

  • CI e2e run (live harnesses) — will confirm once CI runs on this PR

Relates to #66.

🤖 Generated with Claude Code

Two related security-testing gaps from issue #66:

1. Production defense-in-depth gap: /var/run/docker.sock (and
   /run/docker.sock) had no ProtectedPaths entry, unlike ~/.azure and
   ~/.gcloud. Under the default profile this was safe (allow-list
   sandbox model, nothing grants /var/run by default), but a user
   granting a broad path (~, /var, /, or learn mode) would expose the
   Docker socket with no backstop — a container-escape vector. Added
   to protectedCommon()-adjacent platform baselines.

2. Test-integrity gap: assertFilesystemReadDenied/WriteDenied/
   SymlinkEscapeDenied checked "does ANY denial substring appear
   ANYWHERE in the whole probe section", not per-path. A single leaked
   path among the ~14 (read) or 4 (write) probed per section would go
   undetected as long as a different path in the same section was
   denied — silently neutering most of the assertion. fs_write was
   worse: a successful write is silent, so there was no leak signal to
   find at all.

   Fixed by giving probe_write (audit.sh) the same explicit
   success-marker pattern probe_read already used (WRITABLE, mirroring
   READABLE), then rewriting the three assertions to fail on marker
   presence rather than pass on denial-substring presence. This is a
   per-path-safe check: one leak anywhere in the section now fails the
   assertion. Added unit tests (security_assertions_test.go) that
   mutation-test this against synthetic single-leak fixtures — the
   pre-fix logic would have passed all of them.

Also expanded fs_read coverage (audit.sh, allowance.go) to include
docker.sock, ~/.azure, ~/.config/gcloud per issue #66's ask.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
@nhuelstng nhuelstng merged commit 9c06aee into main Jul 14, 2026
11 checks passed
nhuelstng added a commit that referenced this pull request Jul 15, 2026
…xposure (#100)

The whole internal/e2e/ package sits behind //go:build e2e, and that tag is
compiled only by e2e.yml (weekly cron + manual dispatch). ci.yml and
release.yml run `go test ./...` without it, so the model-free security
regression tests never ran on the merge path — including the ones explicitly
built to run fast without a live model. A reintroduced dir_token leak (#74) or
a silently-neutered assertion (#84) would pass CI and only surface the
following Saturday.

Add an `e2e_fast` build tag shared with the model-free files (`e2e || e2e_fast`)
and a PR-time CI job that runs just that slice on ubuntu + macOS in ~3s: no
harness install, no API token, no OS sandbox. The full live suite still runs
weekly via e2e.yml (those files build under both tags).

Also close the --no-sandbox gap (#66): runSecurityAudit no longer silently
skips every negative assertion for a --no-sandbox harness. It now asserts the
audit ran to completion and documents the per-property exposure surface, so a
no-sandbox run that produced no audit fails loudly instead of passing green
with nothing checked. The classification logic is a pure, unit-tested function
(noSandboxExposureReport) that runs in the fast PR slice.

Refactor: extract the pure assertion predicates (fs*Leaked, fsAllowDenied,
secretLeaked, envVarsLeaked, netProbeDenied) into security_assertions.go and
buildOmac into fixtures.go so both build modes share them; the *testing.T
assert wrappers stay in e2e_test.go.

Verification:
- go test -tags=e2e_fast -race ./internal/e2e/  → 11 tests pass (~3s)
- go vet -tags=e2e ./internal/e2e/              → full live suite still compiles
- go build ./... && go vet ./...                → untagged build unaffected

Refs #66

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
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