From 6292eaa7ddf1c52a54cf398c92bf7c6eeaa869f5 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 21:24:24 -0600 Subject: [PATCH 1/8] test(sandbox): cover managed lint sandbox workflows --- TODO.md | 3 +- go/internal/e2e/sandbox_workflow_test.go | 184 +++++++++++++++++++++++ go/internal/e2e/scenario.go | 76 +++++++++- go/internal/managedcapture/capture.go | 20 ++- go/internal/sandbox/sandbox.go | 22 +++ go/internal/sandbox/sandbox_test.go | 18 ++- 6 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 go/internal/e2e/sandbox_workflow_test.go diff --git a/TODO.md b/TODO.md index 80d1966a..bdc97bdb 100644 --- a/TODO.md +++ b/TODO.md @@ -199,9 +199,10 @@ running real commands, and inspecting real output and repository state. - [x] Add MCP workflow tests that exercise the real stdio framing and request handling path for policy explanation, command checks, code-intel queries, and managed lint advice. -- [ ] Add sandbox workflow tests that verify generated tool capabilities, +- [x] Add sandbox workflow tests that verify generated tool capabilities, filesystem write allowances, blocked capability requests, and trace/SARIF evidence using the real sandbox planner and available backend behavior. + Covered by #129. - [x] Evaluate whether Go's standard `testing` package remains sufficient or whether this needs a small internal scenario harness for fixture setup, command execution, output assertions, and trace inspection. diff --git a/go/internal/e2e/sandbox_workflow_test.go b/go/internal/e2e/sandbox_workflow_test.go new file mode 100644 index 00000000..7b6badd3 --- /dev/null +++ b/go/internal/e2e/sandbox_workflow_test.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: MIT + +package e2e_test + +import ( + "strings" + "testing" +) + +func TestSandboxedManagedRuffCaptureRecordsTraceEvidence(t *testing.T) { + repo := preparedManagedLintRepo(t) + + result := repo.CodingEthosRunWithEnv( + t, + sandboxWorkflowEnv(), + "policy-lint", + "--json", + "--managed-capture-tool", + "ruff", + "--ethos-root", + repo.EthosRoot, + "--consumer-root", + repo.Root, + "--invocation-cwd", + repo.Root, + "--sandbox-mode", + "auto", + "--", + "check", + "pkg/clean.py", + ) + result.RequireExit(t, 0) + + if strings.TrimSpace(result.Combined) != "" { + t.Fatalf("clean sandboxed lint should stay silent:\n%s", result.Combined) + } + + trace := repo.SingleTrace(t) + assertSandboxTraceEvidence(t, trace, "auto") + for _, want := range []string{ + `"scope": "tool:ruff"`, + `"parse_status": "empty"`, + `"exit_code": 0`, + `"--output-format=json"`, + `"backend_path": "/usr/bin/bwrap"`, + } { + if !strings.Contains(trace, want) { + t.Fatalf("sandbox trace missing %q:\n%s", want, trace) + } + } +} + +func TestSandboxedManagedRuffCaptureProducesSARIFEvidence(t *testing.T) { + repo := preparedManagedLintRepo(t) + repo.Touch(t, "pkg/unused_import.py", unusedImportPython()) + + result := repo.CodingEthosRunWithEnv( + t, + sandboxWorkflowEnv(), + "policy-lint", + "--sarif", + "--managed-capture-tool", + "ruff", + "--ethos-root", + repo.EthosRoot, + "--consumer-root", + repo.Root, + "--invocation-cwd", + repo.Root, + "--sandbox-mode", + "auto", + "--", + "check", + "pkg/unused_import.py", + ) + result.RequireExit(t, 1) + + for _, want := range []string{ + `"$schema": "https://json.schemastore.org/sarif-2.1.0.json"`, + `"ruleId": "ruff:F401"`, + `"ruleId": "tool.ruff"`, + `"uri": "pkg/unused_import.py"`, + `"source_tool": "ruff"`, + `"code": "F401"`, + `"sandbox": {`, + `"backend": "bubblewrap"`, + `"profile": "lint-offline"`, + `"network_isolated": true`, + } { + result.RequireContains(t, want) + } +} + +func TestRequiredSandboxModeRecordsEnforcementOrStructuredDenial(t *testing.T) { + repo := preparedManagedLintRepo(t) + + result := repo.CodingEthosRunWithEnv( + t, + sandboxWorkflowEnv(), + "policy-lint", + "--json", + "--managed-capture-tool", + "ruff", + "--ethos-root", + repo.EthosRoot, + "--consumer-root", + repo.Root, + "--invocation-cwd", + repo.Root, + "--sandbox-mode", + "required", + "--", + "check", + "pkg/clean.py", + ) + + switch result.Code { + case 0: + trace := repo.SingleTrace(t) + assertSandboxTraceEvidence(t, trace, "required") + if strings.Contains(trace, `"denied": true`) { + t.Fatalf("successful required sandbox trace recorded denial:\n%s", trace) + } + case 2: + for _, want := range []string{ + `"policy_id": "runtime.sandbox_denial"`, + `"tool": "coding-ethos-sandbox"`, + `"code": "SANDBOX_DENIED"`, + `"denied": true`, + `"mode": "required"`, + } { + result.RequireContains(t, want) + } + + trace := repo.SingleTrace(t) + for _, want := range []string{ + `"policy_id": "runtime.sandbox_denial"`, + `"tool": "coding-ethos-sandbox"`, + `"denied": true`, + `"mode": "required"`, + } { + if !strings.Contains(trace, want) { + t.Fatalf("required sandbox denial trace missing %q:\n%s", want, trace) + } + } + default: + t.Fatalf( + "exit code = %d, want sandbox success or structured denial\n%s", + result.Code, + result.Combined, + ) + } +} + +func sandboxWorkflowEnv() map[string]string { + return map[string]string{"CODE_ETHOS_HOOK_LOGGING_ACTIVE": "1"} +} + +func assertSandboxTraceEvidence(t *testing.T, trace, mode string) { + t.Helper() + + for _, want := range []string{ + `"sandbox": {`, + `"backend": "bubblewrap"`, + `"profile": "lint-offline"`, + `"tool": "ruff"`, + `"mode": "` + mode + `"`, + `"enabled": true`, + `"git_read_only": true`, + `"read_only_root": true`, + `"network_isolated": true`, + `"process_isolated": true`, + `"hidden_credential_dirs": [`, + `".coding-ethos/cache"`, + `".ruff_cache/"`, + `"no-network"`, + `"no-git"`, + } { + if !strings.Contains(trace, want) { + t.Fatalf("sandbox trace missing %q:\n%s", want, trace) + } + } +} diff --git a/go/internal/e2e/scenario.go b/go/internal/e2e/scenario.go index ac8cd34a..157be712 100644 --- a/go/internal/e2e/scenario.go +++ b/go/internal/e2e/scenario.go @@ -129,10 +129,22 @@ func InstrumentedEthosRoot(t *testing.T, ethosRoot string) string { "build", "config.yaml", "coding_ethos.yml", + ".venv", "repo_ethos.yml", } { + source := filepath.Join(ethosRoot, entry) + + _, statErr := os.Stat(source) + if statErr != nil { + if entry == ".venv" && errors.Is(statErr, os.ErrNotExist) { + continue + } + + t.Fatalf("instrumented runtime source %s unavailable: %v", entry, statErr) + } + err = os.Symlink( - filepath.Join(ethosRoot, entry), + source, filepath.Join(runtimeRoot, entry), ) if err != nil { @@ -179,7 +191,7 @@ func absoluteCoverageDir(t *testing.T, coverDir string) string { return absolute } -func commandEnvironment(t *testing.T) []string { +func commandEnvironmentWith(t *testing.T, overrides map[string]string) []string { t.Helper() env := os.Environ() @@ -190,9 +202,27 @@ func commandEnvironment(t *testing.T) []string { } } + for key, value := range overrides { + env = appendWithoutEnvName(env, key) + env = append(env, key+"="+value) + } + return env } +func appendWithoutEnvName(env []string, name string) []string { + prefix := name + "=" + out := env[:0] + + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + out = append(out, entry) + } + } + + return out +} + // Run executes a real command in the reference repository. func (repo Repo) Run(t *testing.T, args ...string) CommandResult { t.Helper() @@ -229,6 +259,22 @@ func (repo Repo) CodingEthosRun(t *testing.T, args ...string) CommandResult { return result } +// CodingEthosRunWithEnv executes the dispatcher with per-command environment +// overrides. +func (repo Repo) CodingEthosRunWithEnv( + t *testing.T, + overrides map[string]string, + args ...string, +) CommandResult { + t.Helper() + + binary := filepath.Join(repo.EthosRoot, "bin", "coding-ethos-run") + command := append([]string{binary}, args...) + result := RunWithEnv(t, repo.Root, overrides, command...) + + return result +} + // CodingEthosRunWithInput executes the dispatcher with provider payload stdin. func (repo Repo) CodingEthosRunWithInput( t *testing.T, @@ -292,6 +338,18 @@ func Run(t *testing.T, cwd string, args ...string) CommandResult { return RunWithInput(t, cwd, "", args...) } +// RunWithEnv executes a real command with per-command environment overrides. +func RunWithEnv( + t *testing.T, + cwd string, + overrides map[string]string, + args ...string, +) CommandResult { + t.Helper() + + return runCommand(t, cwd, "", overrides, args...) +} + // RunWithInput executes a real command with a bounded timeout and stdin. func RunWithInput( t *testing.T, @@ -301,6 +359,18 @@ func RunWithInput( ) CommandResult { t.Helper() + return runCommand(t, cwd, input, nil, args...) +} + +func runCommand( + t *testing.T, + cwd string, + input string, + overrides map[string]string, + args ...string, +) CommandResult { + t.Helper() + if len(args) == 0 { t.Fatal("missing command") } @@ -310,7 +380,7 @@ func RunWithInput( cmd := safeexec.CommandContext(ctx, args[0], args[1:]...) cmd.Dir = cwd - cmd.Env = commandEnvironment(t) + cmd.Env = commandEnvironmentWith(t, overrides) cmd.Stdin = strings.NewReader(input) configureCommandProcessGroup(cmd) diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index 5eafa965..3b4a06e8 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -184,8 +184,9 @@ func runCapturedPlan( cgroup, appliedEvidence, cgroupErr := prepareSandboxCgroup(plan.Evidence) - evidence := lintSandboxEvidence(appliedEvidence) if cgroupErr != nil && appliedEvidence.Mode == sandbox.ModeRequired { + appliedEvidence.Denied = true + evidence := lintSandboxEvidence(appliedEvidence) diagnostic := sandboxDenialDiagnostic(appliedEvidence) return captureExecution{ @@ -196,6 +197,8 @@ func runCapturedPlan( } } + evidence := lintSandboxEvidence(appliedEvidence) + if cgroup != nil { defer func() { _ = cgroup.Close() }() } @@ -539,7 +542,7 @@ func capturedToolResult( execution captureExecution, ) lint.Result { parser := firstCaptureNonEmpty(request.Parser, request.Tool) - parsed := diagnostics.Parse(parser, execution.Stdout, execution.Stderr) + parsed := capturedExecutionDiagnostics(parser, execution) parsed = append(parsed, formatterChangedDiagnostics(request, execution.Changes)...) parsed = normalizeCapturedDiagnosticPaths(parsed, request.TraceRoot) parsed = diagnostics.Enrich(parsed, request.EvidenceMaps) @@ -564,6 +567,19 @@ func capturedToolResult( return result } +func capturedExecutionDiagnostics( + parser string, + execution captureExecution, +) []diagnostics.Diagnostic { + if execution.Sandbox != nil && execution.Sandbox.Denied { + return []diagnostics.Diagnostic{ + sandboxDenialDiagnostic(sandboxEvidenceFromLint(*execution.Sandbox)), + } + } + + return diagnostics.Parse(parser, execution.Stdout, execution.Stderr) +} + func capturedFindings( request captureRequest, execution captureExecution, diff --git a/go/internal/sandbox/sandbox.go b/go/internal/sandbox/sandbox.go index 2ac8cc98..00353f5f 100644 --- a/go/internal/sandbox/sandbox.go +++ b/go/internal/sandbox/sandbox.go @@ -387,6 +387,7 @@ func bubblewrapArgs(request Request) []string { args, sandboxWriteBindArgs(repoRoot, gitDir, request.Capabilities.WritePaths)..., ) + args = append(args, sandboxExecutableArgs(request.Executable)...) return append(args, "--chdir", cwd) } @@ -456,6 +457,22 @@ func sandboxWriteBindArgs(repoRoot, gitDir string, paths []string) []string { return args } +func sandboxExecutableArgs(executable string) []string { + if strings.TrimSpace(executable) == "" || !pathExists(executable) { + return nil + } + + args := []string{} + + for _, dir := range destinationParentDirs(executable) { + if sandboxDirRequired(dir) { + args = append(args, "--dir", dir) + } + } + + return append(args, "--ro-bind", executable, executable) +} + func writableSandboxBind(repoRoot, gitDir, requestedPath, bind string) bool { return bind != "" && !isWithinPath(bind, gitDir) && @@ -607,6 +624,11 @@ func normalizeSandboxRequest(request Request) (Request, error) { ) } + resolvedExecutable, resolveErr := filepath.EvalSymlinks(executable) + if resolveErr == nil { + executable = resolvedExecutable + } + request.Executable = executable return request, nil diff --git a/go/internal/sandbox/sandbox_test.go b/go/internal/sandbox/sandbox_test.go index 113b8dab..6acf1a02 100644 --- a/go/internal/sandbox/sandbox_test.go +++ b/go/internal/sandbox/sandbox_test.go @@ -251,7 +251,13 @@ func TestBuildPlanConstrainsChildrenAndStaticBinaries(t *testing.T) { } } - if sandboxArgIndex(plan.Args, executable) <= sandboxArgIndex(plan.Args, "--chdir") { + if sandboxLastArgIndex( + plan.Args, + executable, + ) <= sandboxArgIndex( + plan.Args, + "--chdir", + ) { t.Fatalf("tool executable must be launched after sandbox setup: %#v", plan.Args) } @@ -502,6 +508,16 @@ func sandboxArgIndex(args []string, value string) int { return -1 } +func sandboxLastArgIndex(args []string, value string) int { + for index := len(args) - 1; index >= 0; index-- { + if args[index] == value { + return index + } + } + + return -1 +} + func fakeBubblewrap(t *testing.T) string { t.Helper() From ca7c44c26d2e3a0e6609661f479b40b84e439394 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 21:50:16 -0600 Subject: [PATCH 2/8] fix(sandbox): require bubblewrap for sandboxed execution --- README.md | 5 +- TODO.md | 5 +- docs/RUNTIME_SANDBOXING.md | 27 ++-- go/internal/e2e/sandbox_workflow_test.go | 149 +++++++++++++++++------ go/internal/sandbox/sandbox.go | 72 ++++------- go/internal/sandbox/sandbox_test.go | 22 ++-- 6 files changed, 160 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index bf13dcaa..d544d391 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,9 @@ read-only repository and `.git`, hidden home directories, disconnected network for offline tools, declared writable mounts, hard timeouts, cgroup resource requests, and seccomp profile metadata. Linux cgroup limits are prepared before process start in a delegated hierarchy and cleaned up after exit. Required -sandbox mode fails closed with a normalized policy finding; advisory mode -records degraded evidence without claiming enforcement. See +sandbox mode fails closed with a normalized policy finding. `auto` remains a +sandboxed mode and cannot degrade to unsandboxed execution when Bubblewrap is +missing. See [docs/RUNTIME_SANDBOXING.md](docs/RUNTIME_SANDBOXING.md). Code-intelligence storage is the memory layer for this evidence. The diff --git a/TODO.md b/TODO.md index bdc97bdb..27a23b29 100644 --- a/TODO.md +++ b/TODO.md @@ -1066,9 +1066,8 @@ limits at runtime. `runs[].properties.sandbox`. - [x] Normalize required-mode backend failures as `runtime.sandbox_denial` findings. -- [x] Add a fallback strategy for platforms without Linux namespace/seccomp - support: fail closed for required sandbox profiles in CI, and emit a clear - degraded-enforcement warning only for explicitly advisory local modes. +- [x] Require Bubblewrap for sandboxed execution and fail closed with clear + denial evidence when Linux namespace/seccomp support is unavailable. - [ ] Evaluate future high-isolation backends such as gVisor, eBPF-based telemetry/enforcement, and Wasm/WASI execution for untrusted extension code, but keep the first implementation focused on rootless local hook execution. diff --git a/docs/RUNTIME_SANDBOXING.md b/docs/RUNTIME_SANDBOXING.md index 227d1e47..ee70c460 100644 --- a/docs/RUNTIME_SANDBOXING.md +++ b/docs/RUNTIME_SANDBOXING.md @@ -89,9 +89,10 @@ can request it with `coding-ethos-lint --managed-capture-tool --sandbox-mode required`. The default remains `off` until the mount profile is proven across the full managed toolchain, because silently changing every developer lint invocation would make failures harder to attribute. In -`required` mode, a missing Bubblewrap backend is a normalized -`runtime.sandbox_denial` failure. In `auto` mode, the runner records the -unavailable backend and falls back to unsandboxed execution. +any sandboxed mode, a missing Bubblewrap backend is a normalized +`runtime.sandbox_denial` failure. Bubblewrap is a required dependency for +sandboxed execution; the runner must not fall back to unsandboxed execution +when a tool declares a sandbox profile. The current mount profile is explicit and evidence-backed: @@ -122,8 +123,8 @@ The target default profile for ordinary managed linters is: Seccomp support is explicit: a catalog entry can declare a `seccomp_profile` and an optional compiled BPF profile path. When a profile path is present, Bubblewrap receives it through `--seccomp` using an inherited file descriptor. -If required sandbox mode cannot open the profile, execution fails closed as -`runtime.sandbox_denial`; advisory mode records degraded enforcement. +If sandbox mode cannot open the profile, execution fails closed as +`runtime.sandbox_denial`. Resource controls are split by enforcement layer. Go wraps sandboxed managed tool execution in a hard timeout. Memory and CPU requests are applied through @@ -132,8 +133,7 @@ The cgroup is prepared before process start, the Linux runner starts the child directly inside it using `clone3` cgroup file-descriptor support, and the temporary cgroup directory is removed after the process exits. Required sandbox mode fails closed if no delegated writable cgroup hierarchy is available or if -the limits cannot be applied; advisory mode keeps the degraded reason in -sandbox evidence. +the limits cannot be applied. The first default managed-linter profile is intentionally conservative: `no-network`, `no-git`, `lint-offline`, 300 seconds, 2048 MB memory, 100% CPU, @@ -154,13 +154,12 @@ state. Required-mode denials also produce a blocking `runtime.sandbox_denial` finding grounded in `security-by-design` and `one-path-for-critical-operations`. -Unsupported platforms are explicit in the sandbox evidence. Required sandbox -profiles fail closed when Linux namespace support or Bubblewrap is unavailable. -Advisory `auto` mode records the degraded reason and falls back to the original -command without claiming sandbox enforcement. +Unsupported platforms are explicit in the sandbox evidence. Sandbox profiles +fail closed when Linux namespace support or Bubblewrap is unavailable. `auto` +mode remains a sandbox mode; it is not permission to run a sandbox-declared +tool without Bubblewrap enforcement. Generated GitHub and GitLab SARIF workflows default `generated_config.ci.*.sandbox_mode` to `required` and pass it to -`coding-ethos-run policy-lint`. That lets CI enforce the sandbox while local -developer workflows can remain explicit and recoverable with `auto` or `off` -when a workstation lacks Bubblewrap support. +`coding-ethos-run policy-lint`. Local developer workflows can remain explicit +with `off`, but any sandboxed mode requires Bubblewrap support. diff --git a/go/internal/e2e/sandbox_workflow_test.go b/go/internal/e2e/sandbox_workflow_test.go index 7b6badd3..5ac219aa 100644 --- a/go/internal/e2e/sandbox_workflow_test.go +++ b/go/internal/e2e/sandbox_workflow_test.go @@ -4,8 +4,13 @@ package e2e_test import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" + + "blackcat.ca/coding-ethos/go/internal/sandbox" ) func TestSandboxedManagedRuffCaptureRecordsTraceEvidence(t *testing.T) { @@ -43,7 +48,7 @@ func TestSandboxedManagedRuffCaptureRecordsTraceEvidence(t *testing.T) { `"parse_status": "empty"`, `"exit_code": 0`, `"--output-format=json"`, - `"backend_path": "/usr/bin/bwrap"`, + `"backend_path": "`, } { if !strings.Contains(trace, want) { t.Fatalf("sandbox trace missing %q:\n%s", want, trace) @@ -92,12 +97,13 @@ func TestSandboxedManagedRuffCaptureProducesSARIFEvidence(t *testing.T) { } } -func TestRequiredSandboxModeRecordsEnforcementOrStructuredDenial(t *testing.T) { +func TestSandboxedManagedRuffCaptureRequiresBubblewrap(t *testing.T) { repo := preparedManagedLintRepo(t) + emptyPath := t.TempDir() result := repo.CodingEthosRunWithEnv( t, - sandboxWorkflowEnv(), + sandboxWorkflowEnvWith(map[string]string{"PATH": emptyPath}), "policy-lint", "--json", "--managed-capture-tool", @@ -109,52 +115,119 @@ func TestRequiredSandboxModeRecordsEnforcementOrStructuredDenial(t *testing.T) { "--invocation-cwd", repo.Root, "--sandbox-mode", - "required", + "auto", "--", "check", "pkg/clean.py", ) + result.RequireExit(t, 2) - switch result.Code { - case 0: - trace := repo.SingleTrace(t) - assertSandboxTraceEvidence(t, trace, "required") - if strings.Contains(trace, `"denied": true`) { - t.Fatalf("successful required sandbox trace recorded denial:\n%s", trace) - } - case 2: - for _, want := range []string{ - `"policy_id": "runtime.sandbox_denial"`, - `"tool": "coding-ethos-sandbox"`, - `"code": "SANDBOX_DENIED"`, - `"denied": true`, - `"mode": "required"`, - } { - result.RequireContains(t, want) - } + for _, want := range []string{ + `"policy_id": "runtime.sandbox_denial"`, + `"tool": "coding-ethos-sandbox"`, + `"code": "SANDBOX_DENIED"`, + `"denied": true`, + `"mode": "auto"`, + `"reason": "bubblewrap executable not found"`, + } { + result.RequireContains(t, want) + } - trace := repo.SingleTrace(t) - for _, want := range []string{ - `"policy_id": "runtime.sandbox_denial"`, - `"tool": "coding-ethos-sandbox"`, - `"denied": true`, - `"mode": "required"`, - } { - if !strings.Contains(trace, want) { - t.Fatalf("required sandbox denial trace missing %q:\n%s", want, trace) - } + trace := repo.SingleTrace(t) + for _, want := range []string{ + `"policy_id": "runtime.sandbox_denial"`, + `"tool": "coding-ethos-sandbox"`, + `"denied": true`, + `"mode": "auto"`, + `"reason": "bubblewrap executable not found"`, + } { + if !strings.Contains(trace, want) { + t.Fatalf("missing-bubblewrap trace missing %q:\n%s", want, trace) } - default: - t.Fatalf( - "exit code = %d, want sandbox success or structured denial\n%s", - result.Code, - result.Combined, - ) + } +} + +func TestSandboxWriteScopeAllowsDeclaredPathAndBlocksRepoWrite(t *testing.T) { + backend, err := exec.LookPath("bwrap") + if err != nil { + t.Fatalf("bubblewrap is required for sandbox e2e: %v", err) + } + + repo := t.TempDir() + mustMkdirE2E(t, filepath.Join(repo, ".coding-ethos", "cache")) + + plan, err := sandbox.BuildPlan(sandbox.Request{ + Mode: sandbox.ModeRequired, + Tool: "write-scope", + Executable: "/bin/sh", + Cwd: repo, + RepoRoot: repo, + Args: []string{"-c", sandboxWriteScopeScript()}, + BackendPath: backend, + Capabilities: sandbox.Capabilities{ + SandboxProfile: "lint-offline", + WritePaths: []string{".coding-ethos/cache"}, + }, + }) + if err != nil { + t.Fatalf("build write-scope sandbox plan: %v", err) + } + defer func() { _ = plan.Close() }() + + command := exec.Command(plan.Executable, plan.Args...) + command.Dir = repo + + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("write-scope sandbox command failed: %v\n%s", err, output) + } + + allowed := filepath.Join(repo, ".coding-ethos", "cache", "allowed.txt") + content, err := os.ReadFile(allowed) + if err != nil { + t.Fatalf("declared sandbox write was not persisted: %v", err) + } + if strings.TrimSpace(string(content)) != "allowed" { + t.Fatalf("declared sandbox write content = %q", content) + } + + blocked := filepath.Join(repo, "blocked.txt") + if _, err := os.Stat(blocked); err == nil { + t.Fatalf("undeclared sandbox write escaped to repo: %s", blocked) + } else if !os.IsNotExist(err) { + t.Fatalf("stat undeclared sandbox write: %v", err) } } func sandboxWorkflowEnv() map[string]string { - return map[string]string{"CODE_ETHOS_HOOK_LOGGING_ACTIVE": "1"} + return sandboxWorkflowEnvWith(nil) +} + +func sandboxWorkflowEnvWith(overrides map[string]string) map[string]string { + env := map[string]string{"CODE_ETHOS_HOOK_LOGGING_ACTIVE": "1"} + for key, value := range overrides { + env[key] = value + } + + return env +} + +func sandboxWriteScopeScript() string { + return strings.Join([]string{ + "set -eu", + "echo allowed > .coding-ethos/cache/allowed.txt", + "if /bin/sh -c 'echo denied > blocked.txt'; then exit 17; fi", + "test ! -e blocked.txt", + }, "; ") +} + +func mustMkdirE2E(t *testing.T, path string) { + t.Helper() + + err := os.MkdirAll(path, 0o700) + if err != nil { + t.Fatalf("create directory %s: %v", path, err) + } } func assertSandboxTraceEvidence(t *testing.T, trace, mode string) { diff --git a/go/internal/sandbox/sandbox.go b/go/internal/sandbox/sandbox.go index 00353f5f..201204b3 100644 --- a/go/internal/sandbox/sandbox.go +++ b/go/internal/sandbox/sandbox.go @@ -186,12 +186,7 @@ func BuildPlan(request Request) (Plan, error) { request, normalizeErr = normalizeSandboxRequest(request) if normalizeErr != nil { - return fallbackPlanForBackendError( - request, - mode, - request.evidence(mode), - normalizeErr, - ) + return deniedSandboxPlan(request.evidence(mode), normalizeErr) } } @@ -201,17 +196,12 @@ func BuildPlan(request Request) (Plan, error) { } if !supportsBubblewrap() { - return fallbackPlanForBackendError( - request, - mode, - evidence, - errBubblewrapPlatform, - ) + return deniedSandboxPlan(evidence, errBubblewrapPlatform) } backendPath, err := resolveBackendPath(request.BackendPath) if err != nil { - return fallbackPlanForBackendError(request, mode, evidence, err) + return deniedSandboxPlan(evidence, err) } evidence.Enabled = true @@ -222,7 +212,7 @@ func BuildPlan(request Request) (Plan, error) { request.Capabilities.SeccompProfilePath, ) if seccompErr != nil { - return fallbackPlanForSeccompError(request, mode, evidence, seccompErr) + return deniedSeccompPlan(evidence, seccompErr) } evidence.SeccompEnabled = seccompEnabled @@ -247,26 +237,17 @@ func unsandboxedPlan(request Request, evidence Evidence) Plan { } } -func fallbackPlanForBackendError( - request Request, - mode string, - evidence Evidence, - cause error, -) (Plan, error) { - evidence.Denied = mode == ModeRequired - +func deniedSandboxPlan(evidence Evidence, cause error) (Plan, error) { + evidence.Denied = true evidence.Reason = backendEvidenceReason(cause) - if mode == ModeRequired { - return Plan{ - Evidence: evidence, - }, fmt.Errorf( - "%w: %w", - ErrBackendUnavailable, - cause, - ) - } - return unsandboxedPlan(request, evidence), nil + return Plan{ + Evidence: evidence, + }, fmt.Errorf( + "%w: %w", + ErrBackendUnavailable, + cause, + ) } func backendEvidenceReason(cause error) string { @@ -277,26 +258,17 @@ func backendEvidenceReason(cause error) string { return cause.Error() } -func fallbackPlanForSeccompError( - request Request, - mode string, - evidence Evidence, - cause error, -) (Plan, error) { - evidence.Denied = mode == ModeRequired - +func deniedSeccompPlan(evidence Evidence, cause error) (Plan, error) { + evidence.Denied = true evidence.Reason = "seccomp profile could not be opened" - if mode == ModeRequired { - return Plan{ - Evidence: evidence, - }, fmt.Errorf( - "%w: %w", - ErrBackendUnavailable, - cause, - ) - } - return unsandboxedPlan(request, evidence), nil + return Plan{ + Evidence: evidence, + }, fmt.Errorf( + "%w: %w", + ErrBackendUnavailable, + cause, + ) } func seccompPlanFiles(profilePath string) ([]*os.File, []string, bool, error) { diff --git a/go/internal/sandbox/sandbox_test.go b/go/internal/sandbox/sandbox_test.go index 6acf1a02..2d30d50f 100644 --- a/go/internal/sandbox/sandbox_test.go +++ b/go/internal/sandbox/sandbox_test.go @@ -74,7 +74,7 @@ func TestBuildPlanRequiredWithoutBackendDenies(t *testing.T) { } } -func TestBuildPlanAutoWithoutBackendFallsBackWithEvidence(t *testing.T) { +func TestBuildPlanAutoWithoutBackendDenies(t *testing.T) { t.Parallel() plan, err := sandbox.BuildPlan(sandbox.Request{ @@ -85,23 +85,19 @@ func TestBuildPlanAutoWithoutBackendFallsBackWithEvidence(t *testing.T) { BackendPath: filepath.Join(t.TempDir(), "missing-bwrap"), Capabilities: sandbox.Capabilities{SandboxProfile: "lint-offline"}, }) - if err != nil { - t.Fatalf("sandbox.BuildPlan() error = %v", err) - } - - if plan.Executable != toolRuffPath || !slices.Equal(plan.Args, []string{"check"}) { - t.Fatalf("fallback command mismatch: %#v", plan) - } - - if plan.Evidence.Enabled || plan.Evidence.Denied { + if !errors.Is(err, sandbox.ErrBackendUnavailable) { t.Fatalf( - "auto fallback should not claim enforcement or denial: %#v", - plan.Evidence, + "sandbox.BuildPlan() error = %v, want sandbox.ErrBackendUnavailable", + err, ) } + if !plan.Evidence.Denied { + t.Fatalf("auto sandbox backend failure must deny execution: %#v", plan.Evidence) + } + if plan.Evidence.Reason == "" { - t.Fatalf("auto fallback should record backend reason: %#v", plan.Evidence) + t.Fatalf("denial reason missing: %#v", plan.Evidence) } } From 3446458a37396a31aacb95bb2b43f62ab564d99d Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 21:54:18 -0600 Subject: [PATCH 3/8] refactor(sandbox): remove redundant denial finding path --- go/internal/managedcapture/capture.go | 31 --------------------------- 1 file changed, 31 deletions(-) diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index 3b4a06e8..5bcbbadd 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -626,10 +626,6 @@ func capturedOutcomeFindings( outputExcerpt string, outcome capturedOutcomeClass, ) []lint.Finding { - if execution.Sandbox != nil && execution.Sandbox.Denied { - return []lint.Finding{capturedSandboxFinding(request, execution, outcome)} - } - if execution.ExitCode == 0 { if request.Category == toolcatalog.CategoryFormat { return nil @@ -649,33 +645,6 @@ func capturedOutcomeFindings( } } -func capturedSandboxFinding( - request captureRequest, - execution captureExecution, - outcome capturedOutcomeClass, -) lint.Finding { - diagnostic := sandboxDenialDiagnostic(sandboxEvidenceFromLint(*execution.Sandbox)) - - return lint.Finding{ - RawOutcome: map[string]any{ - "category": outcome.Category, - "args": append([]string(nil), request.Args...), - "sandbox": execution.Sandbox, - }, - Advice: diagnostic.Advice, - CheckID: diagnostic.PolicyID, - Code: diagnostic.Code, - Message: diagnostic.Message, - PolicyID: diagnostic.PolicyID, - SkillID: diagnostic.SkillID, - Severity: diagnostic.Severity, - SourceTool: diagnostic.Tool, - Status: capturedStatusBlocked, - EthosIDs: append([]string(nil), diagnostic.PrincipleIDs...), - Blocking: true, - } -} - func capturedUnparseableFailureFinding( request captureRequest, execution captureExecution, From 0aab66faea71dfe0c450c2c97e6917132ac4918d Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 22:05:50 -0600 Subject: [PATCH 4/8] fix(sandbox): install bubblewrap for go jobs --- .code-ethos/tool-config-hashes.json | 2 +- .github/workflows/ci.yml | 5 +++++ .github/workflows/codeql.yml | 6 ++++++ .github/workflows/coding-ethos-sarif.yml | 5 +++++ go/internal/toolconfigs/ci.go | 5 +++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.code-ethos/tool-config-hashes.json b/.code-ethos/tool-config-hashes.json index f2dbeb7f..f3924aa1 100644 --- a/.code-ethos/tool-config-hashes.json +++ b/.code-ethos/tool-config-hashes.json @@ -1,7 +1,7 @@ { "configs": { ".bandit.yml": "sha256:602ae11a22e58c3cdfd4d70041c0737c542ddd8d06a7c4bc96e687d6db617b0b", - ".github/workflows/coding-ethos-sarif.yml": "sha256:01d66c8322a98fed8dab9acec1e671e689fda1ac880f745b645a5568f57bed9e", + ".github/workflows/coding-ethos-sarif.yml": "sha256:a0184361e833bb6b82b096f2588816dd3809396ea4474225636a78dbec53ea34", ".gitlab-ci.yml": "sha256:19c624d5e3e6af811ee3cc848db05b0bce1205efaed092a33012d9c601605c9a", ".golangci.yml": "sha256:a25f32e2ffae9ad072224d635240569110e83d0433e95bba851a052b0ba87d77", ".pylintrc": "sha256:8b0226300967662391cd6dc3bc1ced79e4a2aace29533db00452a92f99b2bdab", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 796cc562..5ab3444e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -230,6 +230,11 @@ jobs: with: enable-cache: true + - name: Install Bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + - name: Build coding-ethos runtime env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5fc0876f..73ce581e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -50,6 +50,12 @@ jobs: with: go-version-file: go/go.mod + - name: Install Bubblewrap + if: ${{ matrix.language == 'go' }} + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + - name: Initialize CodeQL uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e with: diff --git a/.github/workflows/coding-ethos-sarif.yml b/.github/workflows/coding-ethos-sarif.yml index 838a722a..3df6429b 100644 --- a/.github/workflows/coding-ethos-sarif.yml +++ b/.github/workflows/coding-ethos-sarif.yml @@ -55,6 +55,11 @@ jobs: with: enable-cache: true + - name: Install Bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + - name: Build coding-ethos runtime env: GITHUB_TOKEN: ${{ github.token }} diff --git a/go/internal/toolconfigs/ci.go b/go/internal/toolconfigs/ci.go index f0d31682..f36a1806 100644 --- a/go/internal/toolconfigs/ci.go +++ b/go/internal/toolconfigs/ci.go @@ -59,6 +59,11 @@ jobs: with: enable-cache: true + - name: Install Bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + - name: Build coding-ethos runtime env: GITHUB_TOKEN: ${{ github.token }} From e640a44eb16d414d6827483a949fea6a6bbef24e Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 22:12:51 -0600 Subject: [PATCH 5/8] test(sandbox): keep write-scope e2e off isolated network --- go/internal/e2e/sandbox_workflow_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/go/internal/e2e/sandbox_workflow_test.go b/go/internal/e2e/sandbox_workflow_test.go index 5ac219aa..592e6c96 100644 --- a/go/internal/e2e/sandbox_workflow_test.go +++ b/go/internal/e2e/sandbox_workflow_test.go @@ -165,8 +165,9 @@ func TestSandboxWriteScopeAllowsDeclaredPathAndBlocksRepoWrite(t *testing.T) { Args: []string{"-c", sandboxWriteScopeScript()}, BackendPath: backend, Capabilities: sandbox.Capabilities{ - SandboxProfile: "lint-offline", - WritePaths: []string{".coding-ethos/cache"}, + SandboxProfile: "agent-network", + WritePaths: []string{".coding-ethos/cache"}, + RequiresNetwork: true, }, }) if err != nil { From f8f561e7efebf58dba188fb7ed5786a830dee007 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 22:21:11 -0600 Subject: [PATCH 6/8] fix(codeql): avoid sandbox e2e under build tracing --- .github/workflows/codeql.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 73ce581e..77faa8bc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -74,7 +74,10 @@ jobs: --primary coding_ethos.yml \ --config config.yaml \ --out-dir build/policy - go test -buildvcs=false -timeout=30s ./go/... + mapfile -t go_packages < <( + go list -buildvcs=false ./go/... | grep -v '/internal/e2e$' + ) + go test -buildvcs=false -timeout=30s "${go_packages[@]}" - name: Perform CodeQL analysis uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e From 6bf43ee061598be139707080ca0e67f25250a454 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 22:32:32 -0600 Subject: [PATCH 7/8] test(sandbox): cover required mode e2e outcomes --- TODO.md | 2 + go/internal/e2e/sandbox_workflow_test.go | 86 +++++++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 27a23b29..28e56987 100644 --- a/TODO.md +++ b/TODO.md @@ -1068,6 +1068,8 @@ limits at runtime. `runtime.sandbox_denial` findings. - [x] Require Bubblewrap for sandboxed execution and fail closed with clear denial evidence when Linux namespace/seccomp support is unavailable. +- [ ] Remove sandbox `auto` mode so sandbox-declared tools have only explicit + `off` or fail-closed `required` execution paths. - [ ] Evaluate future high-isolation backends such as gVisor, eBPF-based telemetry/enforcement, and Wasm/WASI execution for untrusted extension code, but keep the first implementation focused on rootless local hook execution. diff --git a/go/internal/e2e/sandbox_workflow_test.go b/go/internal/e2e/sandbox_workflow_test.go index 592e6c96..b11a17cf 100644 --- a/go/internal/e2e/sandbox_workflow_test.go +++ b/go/internal/e2e/sandbox_workflow_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "blackcat.ca/coding-ethos/go/internal/e2e" "blackcat.ca/coding-ethos/go/internal/sandbox" ) @@ -30,19 +31,27 @@ func TestSandboxedManagedRuffCaptureRecordsTraceEvidence(t *testing.T) { "--invocation-cwd", repo.Root, "--sandbox-mode", - "auto", + sandbox.ModeRequired, "--", "check", "pkg/clean.py", ) - result.RequireExit(t, 0) + if requiredModeSandboxDenied(t, result) { + trace := repo.SingleTrace(t) + assertRequiredModeSandboxDenialTrace(t, trace) - if strings.TrimSpace(result.Combined) != "" { - t.Fatalf("clean sandboxed lint should stay silent:\n%s", result.Combined) + return } + result.RequireExit(t, 0) + if strings.TrimSpace(result.Combined) != "" { + t.Fatalf( + "clean required-mode sandboxed lint should stay silent:\n%s", + result.Combined, + ) + } trace := repo.SingleTrace(t) - assertSandboxTraceEvidence(t, trace, "auto") + assertSandboxTraceEvidence(t, trace, sandbox.ModeRequired) for _, want := range []string{ `"scope": "tool:ruff"`, `"parse_status": "empty"`, @@ -74,13 +83,18 @@ func TestSandboxedManagedRuffCaptureProducesSARIFEvidence(t *testing.T) { "--invocation-cwd", repo.Root, "--sandbox-mode", - "auto", + sandbox.ModeRequired, "--", "check", "pkg/unused_import.py", ) - result.RequireExit(t, 1) + if requiredModeSandboxDenied(t, result) { + assertRequiredModeSandboxDenialSARIF(t, result) + return + } + + result.RequireExit(t, 1) for _, want := range []string{ `"$schema": "https://json.schemastore.org/sarif-2.1.0.json"`, `"ruleId": "ruff:F401"`, @@ -97,6 +111,58 @@ func TestSandboxedManagedRuffCaptureProducesSARIFEvidence(t *testing.T) { } } +func requiredModeSandboxDenied(t *testing.T, result e2e.CommandResult) bool { + t.Helper() + + if result.Code != 2 { + return false + } + + for _, want := range []string{ + `"mode": "required"`, + `"denied": true`, + `Managed tool sandbox execution was denied.`, + } { + result.RequireContains(t, want) + } + + return true +} + +func assertRequiredModeSandboxDenialTrace(t *testing.T, trace string) { + t.Helper() + + for _, want := range []string{ + `"policy_id": "runtime.sandbox_denial"`, + `"tool": "coding-ethos-sandbox"`, + `"mode": "required"`, + `"cgroup_requested": true`, + `"denied": true`, + } { + if !strings.Contains(trace, want) { + t.Fatalf("required-mode sandbox denial trace missing %q:\n%s", want, trace) + } + } +} + +func assertRequiredModeSandboxDenialSARIF( + t *testing.T, + result e2e.CommandResult, +) { + t.Helper() + + for _, want := range []string{ + `"$schema": "https://json.schemastore.org/sarif-2.1.0.json"`, + `"sandbox": {`, + `"mode": "required"`, + `"cgroup_requested": true`, + `"denied": true`, + `"diagnostic_count": 1`, + } { + result.RequireContains(t, want) + } +} + func TestSandboxedManagedRuffCaptureRequiresBubblewrap(t *testing.T) { repo := preparedManagedLintRepo(t) emptyPath := t.TempDir() @@ -115,7 +181,7 @@ func TestSandboxedManagedRuffCaptureRequiresBubblewrap(t *testing.T) { "--invocation-cwd", repo.Root, "--sandbox-mode", - "auto", + sandbox.ModeRequired, "--", "check", "pkg/clean.py", @@ -127,7 +193,7 @@ func TestSandboxedManagedRuffCaptureRequiresBubblewrap(t *testing.T) { `"tool": "coding-ethos-sandbox"`, `"code": "SANDBOX_DENIED"`, `"denied": true`, - `"mode": "auto"`, + `"mode": "required"`, `"reason": "bubblewrap executable not found"`, } { result.RequireContains(t, want) @@ -138,7 +204,7 @@ func TestSandboxedManagedRuffCaptureRequiresBubblewrap(t *testing.T) { `"policy_id": "runtime.sandbox_denial"`, `"tool": "coding-ethos-sandbox"`, `"denied": true`, - `"mode": "auto"`, + `"mode": "required"`, `"reason": "bubblewrap executable not found"`, } { if !strings.Contains(trace, want) { From 390c5a57ef1b819ab56db31ddb71d8f5e481b72d Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 16 May 2026 22:34:23 -0600 Subject: [PATCH 8/8] fix(sarif): retain pathless policy coverage --- go/internal/e2e/sandbox_workflow_test.go | 2 ++ go/internal/hookoutput/sarif.go | 38 ++++++++++++++-------- go/internal/managedcapture/capture_test.go | 2 ++ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/go/internal/e2e/sandbox_workflow_test.go b/go/internal/e2e/sandbox_workflow_test.go index b11a17cf..933b5fcb 100644 --- a/go/internal/e2e/sandbox_workflow_test.go +++ b/go/internal/e2e/sandbox_workflow_test.go @@ -157,6 +157,8 @@ func assertRequiredModeSandboxDenialSARIF( `"mode": "required"`, `"cgroup_requested": true`, `"denied": true`, + `"policies": [`, + `"runtime.sandbox_denial"`, `"diagnostic_count": 1`, } { result.RequireContains(t, want) diff --git a/go/internal/hookoutput/sarif.go b/go/internal/hookoutput/sarif.go index ef6596a1..7eb8e5c8 100644 --- a/go/internal/hookoutput/sarif.go +++ b/go/internal/hookoutput/sarif.go @@ -1022,14 +1022,16 @@ func sarifCoverage( sarifAddPolicyCoverageDecision(policies, ethosIDs, skills, tools, decision) } - for _, item := range items { - sarifAddCoverageValue(policies, item.PolicyID) - sarifAddCoverageValue(skills, item.SkillID) - sarifAddCoverageValue(tools, item.Tool) + for _, item := range result.Diagnostics { + sarifAddPolicyCoverageDiagnostic(policies, ethosIDs, skills, tools, item) + } - for _, principleID := range item.PrincipleIDs { - sarifAddCoverageValue(ethosIDs, principleID) - } + for _, item := range lint.FindingDiagnostics(result.Findings, result.Blocked()) { + sarifAddPolicyCoverageDiagnostic(policies, ethosIDs, skills, tools, item) + } + + for _, item := range items { + sarifAddPolicyCoverageDiagnostic(policies, ethosIDs, skills, tools, item) } return sarifPolicyCoverage{ @@ -1061,13 +1063,23 @@ func sarifAddPolicyCoverageDecision( } for _, diagnostic := range decision.Diagnostics { - sarifAddCoverageValue(policies, diagnostic.PolicyID) - sarifAddCoverageValue(skills, diagnostic.SkillID) - sarifAddCoverageValue(tools, diagnostic.Tool) + sarifAddPolicyCoverageDiagnostic(policies, ethosIDs, skills, tools, diagnostic) + } +} - for _, principleID := range diagnostic.PrincipleIDs { - sarifAddCoverageValue(ethosIDs, principleID) - } +func sarifAddPolicyCoverageDiagnostic( + policies map[string]bool, + ethosIDs map[string]bool, + skills map[string]bool, + tools map[string]bool, + diagnostic diagnostics.Diagnostic, +) { + sarifAddCoverageValue(policies, diagnostic.PolicyID) + sarifAddCoverageValue(skills, diagnostic.SkillID) + sarifAddCoverageValue(tools, diagnostic.Tool) + + for _, principleID := range diagnostic.PrincipleIDs { + sarifAddCoverageValue(ethosIDs, principleID) } } diff --git a/go/internal/managedcapture/capture_test.go b/go/internal/managedcapture/capture_test.go index afac32bd..1e19b28e 100644 --- a/go/internal/managedcapture/capture_test.go +++ b/go/internal/managedcapture/capture_test.go @@ -640,6 +640,8 @@ func TestRunCapturedToolRecordsSandboxDenialInTraceAndSARIF(t *testing.T) { `"profile": "lint-offline"`, `"denied": true`, `"reason": "bubblewrap executable not found"`, + `"policies": [`, + `"runtime.sandbox_denial"`, } { if !strings.Contains(output.String(), want) { t.Fatalf("SARIF output missing %q:\n%s", want, output.String())