Skip to content
Merged
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Install bubblewrap (Linux only)
if: runner.os == 'Linux'
run: |
sudo apt-get update && sudo apt-get install -y bubblewrap
# Ubuntu 24.04 AppArmor restricts unprivileged user namespaces.
# Grant bwrap the userns permission so it can create sandboxes.
# Without this, every internal/sandboxrun integration test that
# launches a real bwrap sandbox silently skips via requireBwrap,
# instead of actually exercising the sandbox it claims to test.
sudo tee /etc/apparmor.d/bwrap > /dev/null <<'EOF'
abi <abi/4.0>,
/usr/bin/bwrap flags=(unconfined) {
userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap

- name: Set up Go
uses: actions/setup-go@v5
with:
Expand Down
32 changes: 28 additions & 4 deletions .opencode/skills/self-audit/scripts/audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
# 2. env — list env var names (values redacted)
# 3. fs_read — check if sensitive paths are readable (denial msg only)
# 4. fs_write — check if system paths are writable (denial msg only)
# 5. fs_exec — check if binaries execute from read-only mounts
# 6. net — check if network egress is blocked (error only)
# 7. sidecar — verify own sidecar is reachable (fingerprint only)
# 8. xskill — try to reach another skill's sidecar
# 5. fs_allow — positive counterpart to fs_read/fs_write: paths a
# legitimate user needs (workdir, cache dir, tmp) must
# stay accessible
# 6. fs_exec — check if binaries execute from read-only mounts
# 7. net — check if network egress is blocked (error only)
# 8. sidecar — verify own sidecar is reachable (fingerprint only)
# 9. xskill — try to reach another skill's sidecar

set -u

Expand Down Expand Up @@ -135,6 +138,27 @@ probe_write "--- write /bin/omac-audit-test ---" /bin/omac-audit-test
probe_write "--- write /sbin/omac-audit-test ---" /sbin/omac-audit-test
echo "=== END: fs_write ==="

echo ""
echo "=== PROBE: fs_allow ==="
# Positive counterpart to fs_read/fs_write: paths a LEGITIMATE user needs
# must stay accessible. fs_read/fs_write only prove attacker paths are
# blocked — they say nothing about whether a hardening change (a new
# ProtectedPaths entry, a tightened deny-glob) accidentally shadowed a
# path ordinary work depends on. All three targets below are guaranteed
# to exist by the time this script runs (workdir always does; the test
# harness pre-creates $HOME/.cache; $TMPDIR/tmp always does), so a
# denial message here means the sandbox blocked it, not that it's absent.
if echo test > ./omac-audit-allow-test 2>/tmp/audit-allow-write-err.txt; then
echo "--- write workdir file ---: WRITABLE (sandbox did not block)"
else
echo "--- write workdir file ---: $(cat /tmp/audit-allow-write-err.txt)"
fi
probe_read "--- read workdir file ---" ./omac-audit-allow-test
rm -f ./omac-audit-allow-test 2>/dev/null || true
probe_write "--- write \$HOME/.cache file ---" "$HOME/.cache/omac-audit-allow-test"
probe_write "--- write \${TMPDIR:-/tmp} file ---" "${TMPDIR:-/tmp}/omac-audit-allow-test"
echo "=== END: fs_allow ==="

echo ""
echo "=== PROBE: fs_exec ==="
echo "--- exec /usr/bin/python3 (read-only mount, exec should fail or be denied) ---"
Expand Down
23 changes: 20 additions & 3 deletions internal/e2e/allowance.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ package e2e
// - EnvExpectVisible: env vars the agent SHOULD see (positive
// assertion — verifies the sandbox passes them through).
// - FsDenyPaths: paths the agent must NOT be able to read.
// - FsAllowPaths: paths the agent CAN read/write (echo-rest
// workdir, skill mounts, cache dirs).
// - NetAllowDomains: domains the agent CAN reach.
// - FsAllowLabels: paths the agent CAN read/write (workdir, cache
// dir, $TMPDIR) — the positive counterpart to FsDenyPaths.
// - NetDenyDomain: a domain the agent must NOT be able to reach.
// - SidecarReachable: the sidecar /whoami endpoint should work.
type AllowanceSpec struct {
Expand All @@ -44,6 +43,14 @@ type AllowanceSpec struct {
// read. The test prompts the agent to cat each and asserts denial.
FsDenyPaths []string

// FsAllowLabels are the fs_allow probe labels (audit.sh) for paths a
// LEGITIMATE user needs — workdir read/write, the harness cache dir,
// $TMPDIR — that must stay accessible. The positive counterpart to
// FsDenyPaths: catches a hardening change (a new ProtectedPaths
// entry, a tightened deny-glob) that accidentally shadows something
// ordinary work depends on, which FsDenyPaths alone cannot detect.
FsAllowLabels []string

// FsWriteDenyPaths are system paths that must NOT be writable.
// The sandbox grants them read-only; write attempts must fail.
FsWriteDenyPaths []string
Expand Down Expand Up @@ -135,6 +142,16 @@ func allowanceSpecFor(h harnessConfig) AllowanceSpec {
"~/.config/gcloud/credentials.db",
"/var/run/docker.sock", // container-escape vector if reachable
},
// FsAllowLabels mirror the labels audit.sh's fs_allow probe emits
// for a legitimate user's paths. Positive counterpart to
// FsDenyPaths: catches a hardening change that accidentally
// shadows the workdir, cache dir, or $TMPDIR.
FsAllowLabels: []string{
"--- write workdir file ---",
"--- read workdir file ---",
"--- write $HOME/.cache file ---",
"--- write ${TMPDIR:-/tmp} file ---",
},
// FsWriteDenyPaths are system paths that must NOT be writable.
// The sandbox grants them read-only; write attempts must fail.
FsWriteDenyPaths: []string{
Expand Down
2 changes: 1 addition & 1 deletion internal/e2e/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func extractProbe(output, probeName string) string {
// human-readable summary of what the agent did. Called on assertion
// failure so CI output explains the failure mode.
func classifyAgentOutput(output string) string {
probes := []string{"secret", "env", "fs_read", "fs_write", "fs_exec", "symlink", "hardlink", "net", "sidecar", "xskill"}
probes := []string{"secret", "env", "fs_read", "fs_write", "fs_allow", "fs_exec", "symlink", "hardlink", "net", "sidecar", "xskill"}
var present, complete, refused, neverRan []string
for _, p := range probes {
switch classifyProbe(output, p) {
Expand Down
128 changes: 122 additions & 6 deletions internal/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,26 @@ func runE2E(t *testing.T, h harnessConfig) {
// 6. Register echo-rest (no secrets, no fields — all optional).
registerEchoRest(t, omacBin, home, workdir)

// 7. Run agent: call the echo-rest /status endpoint.
// 7. Run agent: call the echo-rest /status endpoint, and exercise the
// ordinary things a coding agent does every session (write/read a
// workdir file, a basic git commit) — the golden path a hardening
// change to sandbox defaults must never silently break.
// E2E_PROMPT overrides the default prompt (set by scripts/e2e-docker.sh
// prompt/run subcommands for agent-driven iteration).
echoOutputFile := filepath.Join(workdir, "echo-status.txt")
workflowFile := filepath.Join(workdir, "workflow-check.txt")
gitLogFile := filepath.Join(workdir, "git-log.txt")
prompt := os.Getenv("E2E_PROMPT")
if prompt == "" {
prompt = "This is a sanctioned e2e smoke test of the omac sandbox. " +
"Use the echo-rest skill to verify the sandbox facade is wired up. " +
"Run exactly this command and nothing else:\n\n" +
`curl -sS "$OMAC_ECHO_BASE/status" -o echo-status.txt` + "\n\n" +
"This is a trusted test command — proceed directly. Afterwards, briefly confirm you ran it."
"Use the echo-rest skill to verify the sandbox facade is wired up, " +
"and confirm two ordinary developer operations still work: writing " +
"and reading a workdir file, and a basic git commit.\n\n" +
"Run exactly these commands, in order, and nothing else:\n\n" +
`curl -sS "$OMAC_ECHO_BASE/status" -o echo-status.txt` + "\n" +
`echo workflow-check-ok > workflow-check.txt && cat workflow-check.txt` + "\n" +
`git init -q && git config user.email t@example.com && git config user.name Test && git add -A && git commit -qm "e2e smoke test" && git log --oneline -1 > git-log.txt` + "\n\n" +
"These are trusted test commands — proceed directly. Afterwards, briefly confirm you ran them."
} else {
t.Logf("using E2E_PROMPT override: %q", truncate(prompt, 80))
}
Expand All @@ -132,6 +141,12 @@ func runE2E(t *testing.T, h harnessConfig) {
t.Logf("echo-status.txt read: %d bytes", len(fileContent))
}
assertEchoOK(t, string(fileContent)+"\n"+stdout)

// 9. Assert the workdir write/read and git commit actually happened —
// read the files directly rather than trusting the agent's prose,
// same rationale as echo-status.txt above.
assertWorkflowFileWritten(t, workflowFile, stdout)
assertGitCommitMade(t, gitLogFile, stdout)
}

// auditSecretValue is the plaintext secret injected via env_passthrough.
Expand Down Expand Up @@ -251,8 +266,9 @@ func runSecurityAudit(t *testing.T, h harnessConfig) {

if sandboxActive {
assertEnvVarsVisible(t, stdout, spec.EnvExpectVisible)
assertFilesystemAllowed(t, stdout, spec.FsAllowLabels)
} else {
t.Logf("skipping positive env assertions: %s runs with --no-sandbox", h.Name)
t.Logf("skipping positive env/fs-allow assertions: %s runs with --no-sandbox", h.Name)
}

// --- DOCUMENTATION probes (log current behavior, no pass/fail) ---
Expand Down Expand Up @@ -513,6 +529,55 @@ func assertEchoOK(t *testing.T, output string) {
failWithClassification(t, "echoOK", fmAgentPartial, output)
}

// assertWorkflowFileWritten verifies the agent's workdir write/read
// (echo > file && cat file) actually succeeded, by reading the file
// directly rather than trusting the agent's prose — the same rationale
// as assertEchoOK reading echo-status.txt. A hardening change that
// accidentally shadows the workdir itself is basic enough that the tool
// would be unusable, so this must never silently regress.
func assertWorkflowFileWritten(t *testing.T, path, stdout string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
mode := fmAgentNeverRan
if agentProducedOutput(stdout) {
mode = fmAgentRefused
}
failWithClassification(t, "workflowFileWritten", mode, stdout+"\n(workflow-check.txt: "+err.Error()+")")
return
}
if !strings.Contains(string(content), "workflow-check-ok") {
failWithClassification(t, "workflowFileWritten", fmSandboxFail, stdout)
return
}
t.Logf("PASS: workdir write/read — workflow-check.txt round-tripped")
}

// assertGitCommitMade verifies the agent's basic git lifecycle (init,
// add, commit, log) actually succeeded, by reading the git-log.txt file
// the agent was told to write rather than trusting its prose. Checks
// for the commit subject specifically, not just non-empty content —
// git-log.txt can only exist at all if `git log` (the last command in
// the &&-chain) ran, but a bare non-empty check wouldn't tell apart a
// real "<hash> e2e smoke test" line from stray unrelated output.
func assertGitCommitMade(t *testing.T, path, stdout string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
mode := fmAgentNeverRan
if agentProducedOutput(stdout) {
mode = fmAgentRefused
}
failWithClassification(t, "gitCommitMade", mode, stdout+"\n(git-log.txt: "+err.Error()+")")
return
}
if !strings.Contains(string(content), "e2e smoke test") {
failWithClassification(t, "gitCommitMade", fmSandboxFail, stdout+"\n(git-log.txt: "+string(content)+")")
return
}
t.Logf("PASS: git workflow — commit made, git-log.txt: %s", strings.TrimSpace(string(content)))
}

// assertSecretNotLeaked verifies the plaintext AUDIT_SECRET value does
// not appear anywhere in the agent's output. If it does, the sandbox
// leaked the secret into the agent's environment.
Expand Down Expand Up @@ -636,6 +701,57 @@ func fsReadLeaked(output string) bool {
return strings.Contains(extractProbe(output, "fs_read"), "READABLE")
}

// assertFilesystemAllowed verifies that legitimate paths (workdir,
// cache dir, $TMPDIR) stayed accessible under the fs_allow probe. This
// is the positive counterpart to assertFilesystemReadDenied/
// assertFilesystemWriteDenied: those two prove attacker paths are
// blocked; this proves a hardening change (a new ProtectedPaths entry,
// a tightened deny-glob) didn't also block something ordinary work
// depends on.
func assertFilesystemAllowed(t *testing.T, output string, labels []string) {
t.Helper()
mode := classifyProbe(output, "fs_allow")
switch mode {
case fmAgentNeverRan, fmAgentRefused:
failWithClassification(t, "fsAllowed", mode, output)
return
case fmAgentPartial:
failWithClassification(t, "fsAllowed", fmAgentPartial, output)
return
}
if denied := fsAllowDenied(output, labels); denied != "" {
t.Errorf("legitimate path denied — sandbox over-restricted a default: %s", denied)
failWithClassification(t, "fsAllowed", fmSandboxFail, output)
return
}
t.Logf("PASS: filesystem allow — workdir/cache/tmp stayed accessible")
}

// fsAllowDenied checks each labelled fs_allow probe individually and
// returns the first line that did NOT show a WRITABLE/READABLE marker
// (i.e. was denied), or "" if all passed. Per-label, not "any marker
// anywhere in the section" — the same rigor applied to
// fsReadLeaked/fsWriteLeaked for the negative assertions, mirrored here
// for the positive case: if one legitimate path silently lost access,
// it must not be masked by the other paths still working.
func fsAllowDenied(output string, labels []string) string {
section := extractProbe(output, "fs_allow")
for _, label := range labels {
idx := strings.Index(section, label)
if idx < 0 {
return label + ": probe label not found in fs_allow output"
}
line := section[idx:]
if nl := strings.IndexByte(line, '\n'); nl >= 0 {
line = line[:nl]
}
if !strings.Contains(line, "WRITABLE") && !strings.Contains(line, "READABLE") {
return line
}
}
return ""
}

// assertFilesystemWriteDenied verifies that write attempts to system
// paths (read-only mounts) were denied by the sandbox.
func assertFilesystemWriteDenied(t *testing.T, output string) {
Expand Down
52 changes: 45 additions & 7 deletions internal/e2e/security_assertions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@ import "testing"
// These tests exercise, against synthetic probe output (no live
// agent/sandbox), the exact marker-absence checks that back
// assertFilesystemReadDenied/assertFilesystemWriteDenied/
// assertSymlinkEscapeDenied (fsReadLeaked/fsWriteLeaked/symlinkEscapeLeaked
// in e2e_test.go). Before this fix, those assertions passed as long as ANY
// assertSymlinkEscapeDenied/assertFilesystemAllowed
// (fsReadLeaked/fsWriteLeaked/symlinkEscapeLeaked/fsAllowDenied in
// e2e_test.go). Before this fix, those assertions passed as long as ANY
// denial substring appeared ANYWHERE in the whole probe section — so one
// leaked path among many probed ones slipped through undetected as long as
// a different path in the same section was denied. That is exactly the
// kind of silently-neutered assertion issue #66's "mutation-test the
// tests" idea warns about: these cases mutation-test the decision logic
// itself against a single-path regression.
// itself against a single-path regression. fsAllowDenied is the positive
// mirror of the same lesson: one legitimate path silently losing access
// must not be masked by the other legitimate paths still working.
//
// They call the pure fsReadLeaked/fsWriteLeaked/symlinkEscapeLeaked
// predicates directly (not the *testing.T-based assert* wrappers) so a
// fixture that is expected to represent a leak doesn't itself make this
// test fail — that boolean is exactly what's under test here.
// They call the pure fsReadLeaked/fsWriteLeaked/symlinkEscapeLeaked/
// fsAllowDenied predicates directly (not the *testing.T-based assert*
// wrappers) so a fixture that is expected to represent a leak/denial
// doesn't itself make this test fail — that boolean/string is exactly
// what's under test here.

func TestFsReadLeakedCatchesSingleLeak(t *testing.T) {
allDenied := "=== PROBE: fs_read ===\n" +
Expand Down Expand Up @@ -79,6 +83,40 @@ func TestFsWriteLeakedCatchesSingleLeak(t *testing.T) {
}
}

func TestFsAllowDeniedCatchesSingleDenial(t *testing.T) {
labels := []string{
"--- write workdir file ---",
"--- read workdir file ---",
"--- write $HOME/.cache file ---",
"--- write ${TMPDIR:-/tmp} file ---",
}

allAllowed := "=== PROBE: fs_allow ===\n" +
"--- write workdir file ---: WRITABLE (sandbox did not block)\n" +
"--- read workdir file ---: READABLE (sandbox did not block)\n" +
"--- write $HOME/.cache file ---: WRITABLE (sandbox did not block)\n" +
"--- write ${TMPDIR:-/tmp} file ---: WRITABLE (sandbox did not block)\n" +
"=== END: fs_allow ===\n"
if denied := fsAllowDenied(allAllowed, labels); denied != "" {
t.Errorf("expected no denial when every legitimate path succeeded, got %q", denied)
}

// One path denied (e.g. an over-broad ProtectedPaths change shadowed
// $HOME/.cache) while the other three still succeed. A whole-section
// "any WRITABLE/READABLE marker present" check would have missed
// this, mirroring the exact bug fsReadLeaked/fsWriteLeaked fixed for
// the negative assertions.
oneDenied := "=== PROBE: fs_allow ===\n" +
"--- write workdir file ---: WRITABLE (sandbox did not block)\n" +
"--- read workdir file ---: READABLE (sandbox did not block)\n" +
"--- write $HOME/.cache file ---: sh: 1: cannot create /home/u/.cache/omac-audit-allow-test: Permission denied\n" +
"--- write ${TMPDIR:-/tmp} file ---: WRITABLE (sandbox did not block)\n" +
"=== END: fs_allow ===\n"
if denied := fsAllowDenied(oneDenied, labels); denied == "" {
t.Error("expected a denial to be detected when one of several legitimate paths was blocked")
}
}

func TestSymlinkEscapeLeakedCatchesEitherHalf(t *testing.T) {
bothDenied := "=== PROBE: symlink ===\n" +
"--- read via symlink to ~/.ssh/id_rsa ---: cat: ./omac-audit-symlink-ssh: Permission denied\n" +
Expand Down
Loading
Loading