Skip to content

test(e2e): harden suite against config changes that silently break legitimate workflows#87

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

test(e2e): harden suite against config changes that silently break legitimate workflows#87
nhuelstng merged 7 commits into
mainfrom
worktree-issue-66-e2e-security-2

Conversation

@nhuelstng

Copy link
Copy Markdown
Contributor

What

Follow-up to #66/#84. Adds test coverage for a failure mode the suite had none for: a sandbox hardening change (new ProtectedPaths entry, tightened default, a regression in a documented escape hatch) silently breaking a legitimate workflow, as opposed to failing to block an attacker.

  • Developer-workflow regression battery (internal/sandboxrun/workflow_integration_{linux,darwin}_test.go): git init/add/commit/log/diff, go build, common interpreters, and a network open_port grant — all run through a real bwrap/Seatbelt sandbox under the exact, unmodified sandboxprofile.DefaultProfile().
  • Real integration tests for override_deny/allow_unix_dir: these documented escape hatches (Docker socket / custom daemon dir access) were previously tested only structurally (Grants-struct/generated-arg checks). Now proven end-to-end through a real sandboxed process.
  • Config backward-compat fixtures (internal/sandboxrun/testdata/profiles/): checked-in profile JSON mirroring real user shapes, run through the actual Parse → Validate → ResolveGrants pipeline.
  • Positive fs_allow probe in the live security audit (audit.sh/allowance.go/e2e_test.go): proves the workdir, cache dir, and $TMPDIR stay reachable — the positive counterpart to the existing fs_read/fs_write denial checks, checked per-path for the same reason those were hardened earlier this cycle.
  • TestE2EEchoRest extended with an ordinary git commit + workdir file round-trip, file-asserted like the existing echo-status.txt pattern.
  • CI fix: ci.yml's Linux test job never installed bubblewrap, so all 15 internal/sandboxrun integration tests (9 pre-existing + 6 new) were silently skipping via requireBwrap — passing green while testing nothing. Ported the working bwrap+AppArmor install from e2e.yml.
  • Assertion fix: assertGitCommitMade now checks the commit subject, not just non-empty file content.

Why

Explicitly requested: "how can we ensure we make no breaking (config) changes that makes the users stopping the tool?" The suite proved attacker paths are blocked but had no signal for the opposite regression. Deliberately did not add a golden/snapshot test pinning the exact baseline lists — that only proves a default changed (which git diff already shows in review), not whether it breaks anything real. These tests instead exercise real consequences.

The CI fix was found by auditing this work rather than trusting green CI: the bwrap-based tests looked correct in isolation but a check of ci.yml showed bubblewrap was never installed there, confirmed empirically by removing bwrap from PATH locally and observing the same near-instant "all-skip" signature as the historical ubuntu-latest CI log.

How

See commit messages for per-workstream detail. internal/sandboxrun changes are plain Go tests (no e2e build tag, no live agent). internal/e2e changes ride along on the existing TestE2ESecurityAudit/TestE2EEchoRest live runs — no new live-agent cost.

Verification

  • go build ./..., go vet ./..., go vet -tags e2e ./..., gofmt -l . — clean.
  • go test ./... — green (includes the new internal/sandboxrun tests, verified locally with a real bwrap; will now also run for real in CI per the ci.yml fix).
  • Cross-compiled GOOS=darwin — clean.
  • Live E2E workflow (workflow_dispatch, skip_claude_code=true) run twice against this branch: https://github.com/TNG/oh-my-agentic-coder/actions/runs/29352091511 — all 7 harness×OS combinations green (one copilot/macos-latest run hit an unrelated agent-refusal flake on the first attempt — every probe in that job failed identically, indicating the audit script never ran at all, not a regression; the rerun passed cleanly).

… default profile

Hardening a sandbox default (a new ProtectedPaths entry, a tightened
grant) can silently break a legitimate workflow with nothing to catch
it. A snapshot test of the baseline lists wouldn't help — it would only
prove the list changed, which git diff already shows in review; it says
nothing about the consequences.

This instead exercises real developer workflows through a real bwrap/
Seatbelt sandbox under the exact, unmodified sandboxprofile.DefaultProfile()
(the template that ships as ~/.config/omac/sandbox-profiles/default.json):
git init/add/commit/log/diff, reading ~/.gitconfig, a `go build` that
needs GOCACHE/GOPATH under the default's ~/.cache and ~/go grants to
actually be writable, common scripting interpreters if present, and
the default's filtered-network posture combined with a legitimate
open_port grant.

A change to baseline.go or DefaultProfile() that breaks any of these
now fails fast, locally, with no live agent required.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
filesystem.override_deny and filesystem.allow_unix_dir are the
documented escape hatches for legitimate access (README's protected-path
override and dynamic-socket-dir grant, e.g. Docker/Agent View). Both
were previously tested only structurally — grants_test.go checks the
Grants struct, bwrap_test.go/sbpl_test.go check generated arg/SBPL
strings — with no test proving the actual kernel/policy mechanism grants
the access a user following the README recipe expects.

Add TestIntegrationOverrideDenyGrantsAccess (Linux + macOS) and
TestIntegrationAllowUnixDirGrantsSocketAccess (macOS, real AF_UNIX
listener) that run a real bwrap/Seatbelt process and assert the granted
access actually works, so a regression in either recipe is caught here
instead of only when a user's Docker/daemon-socket grant silently stops
working.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
…t breaks

No test in the repo re-parsed a realistic, on-disk sandbox profile file
through the actual Parse -> Validate -> ResolveGrants pipeline. A schema,
parser, or resolver change that would silently break an existing user's
profile file had no chance to surface until a live e2e run — or a user
bug report.

Add internal/sandboxrun/testdata/profiles/ with fixtures mirroring real
shapes users write today (minimal, workdir+network-allow, the
override_deny+allow_unix_dir Docker-socket recipe, a deny-glob), and
TestProfileFixturesParseAndResolve, which runs each through the same
pipeline TestDenyFullCLIPipeline already exercises for `omac sandbox
run`, with spot checks on the resulting grants.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
The live security audit (TestE2ESecurityAudit) proves attacker paths are
blocked, but had no positive counterpart proving a hardening change (a
new ProtectedPaths entry, a tightened deny-glob) doesn't also shadow a
path legitimate work depends on. Separately, the AllowanceSpec doc
comment claimed a positive FsAllowPaths field existed; it didn't.

- audit.sh: add an fs_allow probe (workdir write/read, $HOME/.cache,
  $TMPDIR) — all guaranteed to exist by the time the script runs, so a
  denial there means the sandbox blocked it, not that it's absent.
- allowance.go: add the real FsAllowLabels field, replacing the
  aspirational doc comment.
- e2e_test.go: add assertFilesystemAllowed/fsAllowDenied, checked
  per-label (not "any marker anywhere in the section") for the same
  reason fsReadLeaked/fsWriteLeaked were hardened earlier — one
  legitimate path silently losing access must not be masked by the
  others still working. Wired into TestE2ESecurityAudit's positive
  assertions.
- Also extend TestE2EEchoRest's prompt to write/read a workdir file and
  run a basic git commit, with file-based assertions (git-log.txt,
  workflow-check.txt) mirroring the existing echo-status.txt pattern —
  catching a regression in the most basic things a coding agent does
  every session, riding along on the existing live run.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
…ually run

internal/sandboxrun's bwrap-based integration tests (TestIntegration*,
15 of them) all call requireBwrap(t), which skips silently when bwrap
isn't installed or functional. ci.yml's ubuntu-latest test job never
installed it, so every one of those tests — proving the sandbox itself
denies/grants access, not just the Grants struct — has been silently
skipping in CI. Confirmed empirically: with bwrap removed from PATH,
the whole package finishes in 0.008s (all-skip); the real historical
ubuntu-latest CI log shows an equally suspicious 1.261s for the same
package, versus 21s on macOS where Seatbelt needs no separate setup.

Port the bubblewrap install + AppArmor userns allow-rule already proven
in e2e.yml (Ubuntu 24.04 restricts unprivileged user namespaces by
default) into ci.yml's test job, so these tests genuinely execute on
every push/PR instead of only when a developer happens to have a
working bwrap locally.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
The previous check only asserted git-log.txt was non-empty, which
doesn't actually verify the claim ("a commit was made") — just that
some content landed in the file. Check for the expected commit subject
instead, so a stray unrelated write can't produce a false pass.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
@nhuelstng nhuelstng force-pushed the worktree-issue-66-e2e-security-2 branch from fa0753b to 1b0ea1b Compare July 14, 2026 17:39
…iagnostics

Caught by real CI: on macos-latest, TestIntegrationWorkflowGitBattery
failed because runSandboxed's CombinedOutput() mixed in a harmless
"shell-init"/"chdir: ... getcwd: cannot access parent directories:
Operation not permitted" diagnostic that /bin/sh prints when it can't
resolve the full parent-directory chain of a narrowly-scoped Seatbelt
grant, ahead of the real `git log | wc -l` result. The exact-match
`strings.TrimSpace(out) != "2"` check treated that noise as part of the
expected value and failed even though the actual git workflow (2 real
commits) succeeded.

Compare the last whitespace-separated token instead of the whole
trimmed output, so the assertion is robust to any such sandbox/shell
diagnostic noise. Applied to both platform variants for consistency.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
@nhuelstng nhuelstng merged commit bad16b6 into main Jul 14, 2026
4 checks passed
@nhuelstng nhuelstng deleted the worktree-issue-66-e2e-security-2 branch July 14, 2026 18:03
nhuelstng added a commit that referenced this pull request Jul 15, 2026
#93)

requireBwrap, requireUnixSocket, requireLoopback, and requireLoopbackDial
all t.Skip() when a runtime capability (bwrap, AF_UNIX, loopback TCP) is
unavailable, with no CI-side check that skipped tests didn't just zero
out coverage. This already happened for requireBwrap: 15 integration
tests silently skipped in ci.yml because bubblewrap wasn't installed
there, caught only by chance and fixed in #87.

Add skipOrFailCI, mirroring the GITHUB_ACTIONS gating already used in
internal/e2e/classify.go: skip locally (a dev sandbox without bwrap or
network access shouldn't be blocked), but t.Fatal in CI, where a missing
capability is an infrastructure regression, not something to tolerate.

Fixes #91

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