Skip to content

feat(code): confine code-evolution test execution, and record the posture - #154

Merged
jramos merged 2 commits into
fix/importer-relevance-rankingfrom
feat/code-evolution-test-containment
Sep 1, 2026
Merged

feat(code): confine code-evolution test execution, and record the posture#154
jramos merged 2 commits into
fix/importer-relevance-rankingfrom
feat/code-evolution-test-containment

Conversation

@jramos

@jramos jramos commented Aug 31, 2026

Copy link
Copy Markdown
Owner

The asymmetry

evolution/validation/claude_runner.py states our doctrine plainly: when no OS sandbox is available it refuses to run rather than execute an unconfined agent. WorktreeEnv.run_test did not hold to it — it executes pytest against an LLM-modified worktree through a bare subprocess.run.

Reach is wider than the asymmetry suggested: eleven run_test call sites, and three entry points drive the LLM loop — the single-tool evolver, the campaign, and the gaming audit, whose proposer is deliberately built to game the gate.

What this does

  • New shared evolution/core/sandbox.py (profile builder, availability, wrap_argv, fail-fast policy check). claude_runner now calls it instead of hand-rolling the same three-way branch; its 13 tests pass unmodified.
  • WorktreeEnv gains a require_sandbox policy, a sandboxed posture resolved once at create time, and a single confine() seam every in-worktree execution goes through.
  • --require-sandbox on all three LLM-loop entry points, each checking the policy once at startup — a demand that cannot be met is a startup error, not a per-item outcome.
  • Posture recorded in repair_trace.json, campaign_report.json, and the human-review PR body.
  • The bug harvester is deliberately excluded from the flag: no LLM-authored code executes there.

A containment failure can no longer be certified as a pass

sandbox-exec exits 65 without running the child when a profile fails to compile. gate.py special-cases only pytest's exit 5, and _parse_pytest_failures returns an empty set on unrecognized output — so a run where zero tests executed read as "no failures", and run_code_oracle_gate could report CORRECT.

A confined run returning a positive non-pytest code now raises ContainmentError. The type is distinct so campaign's except WorktreeError -> Skip("worktree_failed") cannot absorb a systemic failure as a fleet of skipped organisms; a negative control asserts ordinary worktree failures still skip.

Negative exit codes are excluded deliberately. Signal deaths (OOM kill, SIGSEGV) pass through sandbox-exec unchanged and are real test outcomes — calling them containment failures would misdiagnose them and drop those organisms from the denominator on macOS only.

What the posture actually claims

The boundary is non-temp writes denied; reads, process-exec and network unrestricted — it prevents corrupting your checkout or home directory, and it is not isolation. LLM-modified test code retains write access to sibling run roots under the temp tree. The containment proof therefore targets $HOME: inside the temp roots the write would have succeeded and the test would have passed while proving nothing. A companion assertion pins that temp-root writes are allowed, so the wording stays honest if the profile ever changes.

Two profile-construction bugs are fixed: write roots are resolved before interpolation (the kernel matches canonical paths, while mkdtemp returns /var/folders/..., so the named root was granting nothing), and paths containing quote/paren/backslash are rejected rather than interpolated, since SBPL cannot escape them and a crafted root could append allow rules of its own.

Adjacent pre-existing bug, fixed here

The human-review PR body read guards["floor"]["passed"] — a key that does not exist — so every body claimed the regression floor FAILED, including runs that deployed because it was green, and raised AttributeError on oracle decisions where floor is None. It now judges by new_failures. Called out separately because it is not part of the containment change; it was found while wiring the posture into the same function, which had been accepting trace and ignoring it entirely.

Verification

  • 31 tests, including a real denied-write proof on macOS (skipped elsewhere), the exit-65 inversion, the signal-death negative control, SBPL injection rejection, canonicalisation, and a behavioral test that the flag's value reaches create() — the earlier structural check passed even with the value hardcoded inert, so it was replaced.
  • Full non-slow suite: 1794 passed (1763 baseline + 31), ruff clean.
  • Docs: containment field in the repair-trace schema, the flag and confinement behavior in the CLI reference, one clause in the README (the capability change is confinement-by-default, not the flags).

Deliberate non-goals

  • Linux confinement (bubblewrap). Deferred; the recorded posture makes its absence visible rather than assumed.
  • Confining the venv build. Verified unnecessary: the venv is built from the pristine base ref before any candidate is written.

Stacking

Based on the triage-doc branch so the action-item row it flips exists in the same diff. GitHub will retarget this to main when that PR merges.

Rebuilt natively from an idea in NousResearch/hermes-agent-self-evolution#162; no upstream diff applied.

jramos added 2 commits August 31, 2026 17:30
…lently

run_test executes pytest against an LLM-modified worktree through a bare
subprocess, while the agent runner refuses to run unconfined at all. That
asymmetry sat in our own doctrine, on the one code path where autonomously
generated source actually runs.

Extracts the macOS profile builder, availability check and error type into
evolution/core/sandbox.py, shared by both callers. WorktreeEnv gains a
require_sandbox policy and a sandboxed posture resolved once at create time;
run_test confines writes to the run root and records the posture in
repair_trace.json. Reach is wider than the asymmetry alone suggested: eleven
run_test call sites, and three entry points drive the loop — the single-tool
evolver, the campaign, and the gaming audit, whose proposer is deliberately
built to game the gate and so has the strongest claim on confinement. All three
take --require-sandbox. The bug harvester does not: no LLM-authored code
executes there.

A containment failure can no longer present as a test outcome. sandbox-exec
exits 65 without running the child when a profile fails to compile; the gate
special-cases only pytest's exit 5 and its failure parser returns an empty set
on unrecognised output, so a run where zero tests executed would have read as
"no failures" and could be certified correct. A confined run whose exit code is
not one of pytest's own now raises. A negative control asserts 0, 1 and 5 still
return results.

Two things are recorded rather than implied. The write-root argument is a no-op
on macOS — the profile blanket-allows the temp roots and the run dir lives under
one — so the posture describes the real boundary: non-temp writes denied; reads,
process-exec and network unrestricted. It prevents corrupting the checkout or
home dir; it is not isolation. And the containment proof targets a path outside
the temp roots, because inside them it would pass while proving nothing.

wrap_argv now accepts an already-resolved posture. Re-deriving availability per
call made the posture we record and the posture we apply two independent
judgements, free to disagree; the tests caught it.

18 tests, including a real denied-write proof on macOS (skipped elsewhere) and a
structural check that every worktree creation on an LLM-loop path passes the
policy. Full non-slow suite green (1781 passed), ruff clean.
Follow-up on the containment change, in rough order of consequence.

The fuzz driver in the gaming audit ran the candidate through its own
subprocess.run, bypassing the policy entirely — so --require-sandbox promised
confinement on the one harness whose proposer is built to game the gate, and did
not deliver it. Every in-worktree execution now goes through a single
WorktreeEnv.confine() seam.

Signal deaths were misdiagnosed. Python reports them as negative exit codes and
they pass through sandbox-exec unchanged, so an OOM-killed or segfaulting test
run was being called a containment failure — which, because campaign turns
worktree errors into skips, would have dropped those organisms from the
denominator on macOS only. Only positive non-pytest codes escalate now, with a
test pinning the asymmetry.

The write root never matched. tempfile.mkdtemp returns /var/folders/... while
the kernel matches subpaths canonically against /private/var/folders/..., so the
named root granted nothing; it is resolved before interpolation now. Separately,
paths containing a quote, paren or backslash are rejected rather than
interpolated: SBPL has no escape syntax for them, so a crafted root could close
the rule and append allow rules of its own.

Containment failures raise a distinct ContainmentError. campaign's
except WorktreeError -> Skip("worktree_failed") would otherwise absorb a
systemic failure as a fleet of skipped organisms, completing the run with an
empty denominator and a misattributed cause. A negative control asserts ordinary
worktree failures still skip.

Each entry point now checks the policy once at startup instead of discovering it
per candidate. That is the honest behavior for a demand that cannot be met, and
it also closes a tempdir leak per refusal and the gaming audit's exit-0
"successful empty report".

claude_runner calls the shared wrap_argv rather than hand-rolling the same
three-way branch — the drift risk the module's own posture threading exists to
prevent, one level up. Its 13 tests still pass unmodified.

Posture is now recorded for the campaign as well as the single-tool evolver, and
rendered in the human-review PR body, where it was computed and then dropped.

Also fixes an adjacent pre-existing bug in that PR body: the regression-floor
line read guards["floor"]["passed"], a key that does not exist, so every body
claimed the floor FAILED — including runs that deployed because it was green —
and raised AttributeError on oracle decisions, where floor is None.

Docs updated: the containment field in the repair-trace schema, the flag and the
confinement behavior in the CLI reference, and one clause in the README, since
the capability change is confinement-by-default rather than the flags.

31 tests. Full non-slow suite green (1794 passed), ruff clean.
@jramos
jramos force-pushed the feat/code-evolution-test-containment branch from 07506c5 to 8bce7f5 Compare August 31, 2026 23:33
@jramos
jramos changed the base branch from docs/triage-sweep-and-quarterly-cadence to fix/importer-relevance-ranking August 31, 2026 23:33
@jramos
jramos merged commit 294e45b into main Sep 1, 2026
5 checks passed
@jramos
jramos deleted the feat/code-evolution-test-containment branch September 1, 2026 01:20
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