feat(code): confine code-evolution test execution, and record the posture - #154
Merged
jramos merged 2 commits intoSep 1, 2026
Merged
Conversation
…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
force-pushed
the
feat/code-evolution-test-containment
branch
from
August 31, 2026 23:33
07506c5 to
8bce7f5
Compare
jramos
changed the base branch from
docs/triage-sweep-and-quarterly-cadence
to
fix/importer-relevance-ranking
August 31, 2026 23:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The asymmetry
evolution/validation/claude_runner.pystates our doctrine plainly: when no OS sandbox is available it refuses to run rather than execute an unconfined agent.WorktreeEnv.run_testdid not hold to it — it executespytestagainst an LLM-modified worktree through a baresubprocess.run.Reach is wider than the asymmetry suggested: eleven
run_testcall 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
evolution/core/sandbox.py(profile builder, availability,wrap_argv, fail-fast policy check).claude_runnernow calls it instead of hand-rolling the same three-way branch; its 13 tests pass unmodified.WorktreeEnvgains arequire_sandboxpolicy, asandboxedposture resolved once at create time, and a singleconfine()seam every in-worktree execution goes through.--require-sandboxon 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.repair_trace.json,campaign_report.json, and the human-review PR body.A containment failure can no longer be certified as a pass
sandbox-execexits 65 without running the child when a profile fails to compile.gate.pyspecial-cases only pytest's exit 5, and_parse_pytest_failuresreturns an empty set on unrecognized output — so a run where zero tests executed read as "no failures", andrun_code_oracle_gatecould report CORRECT.A confined run returning a positive non-pytest code now raises
ContainmentError. The type is distinct socampaign'sexcept 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-execunchanged 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
mkdtempreturns/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 raisedAttributeErroron oracle decisions wherefloorisNone. It now judges bynew_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 acceptingtraceand ignoring it entirely.Verification
create()— the earlier structural check passed even with the value hardcoded inert, so it was replaced.Deliberate non-goals
Stacking
Based on the triage-doc branch so the action-item row it flips exists in the same diff. GitHub will retarget this to
mainwhen that PR merges.Rebuilt natively from an idea in
NousResearch/hermes-agent-self-evolution#162; no upstream diff applied.