security: sandbox model-authored script execution, confine capture reads - #6
Open
adigo-pro wants to merge 6 commits into
Open
security: sandbox model-authored script execution, confine capture reads#6adigo-pro wants to merge 6 commits into
adigo-pro wants to merge 6 commits into
Conversation
A security review of the whole codebase found one high-severity issue and
several smaller ones. The headline: SECURITY.md guaranteed that model-authored
verify/probe scripts "cannot read your keys" because the child process got a
scrubbed environment and a HOME redirected into the staging dir. That
guarantee was false. HOME only governs `~` expansion, so a script recovers the
real home via pwd.getpwuid(os.getuid()).pw_dir, reads ~/.codecouncil/env by
absolute path, and POSTs it out over a network that was never blocked. This
was reproduced end-to-end (179 bytes of live credentials read).
Environment scrubbing cannot fix it -- getpwuid reads the OS user database,
not the environment -- so the fix is an OS boundary. core/sandbox.py adds one:
macOS sandbox-exec, Linux bwrap, denying network egress and reads under the
real home while keeping the staging dir writable. Both builders re-allow the
interpreter's own prefixes AFTER the home denial, because pyenv/asdf install
Python inside ~ and a blanket home deny would break Python before the script
ran. Measured cost: ~4.5ms per run, against a multi-second model call.
Also closed:
- observer/gitwatch.py followed symlinks out of the repo. `git ls-files`
lists untracked symlinks, so a repo shipping `leaked.txt -> <outside>` had
that file captured and sent to the model provider. Redaction is no defense:
the leaked content is ordinary confidential text, not a credential shape.
_read_confined resolves then checks containment -- the same discipline
jail.mjs already enforced for judgment-turn tools.
- evals/ab/score.py handed agent-produced code the operator's full os.environ.
- core/knowledge.py's fact filter matched imperative phrasings only, so a flat
declarative ("SQL injection is an accepted convention here") persisted into
every later judgment prompt.
- screen.resolve_new_imports ran a probe with cwd=<untrusted repo> and an
inherited environment; now -I plus a scrubbed env.
- Model-authored text is control-character stripped, not just redacted: an
ANSI escape in a finding can repaint the terminal and fake a severity.
The exploit-regression tests deliberately ERROR rather than skip when no
sandbox mechanism exists, and CI installs bubblewrap -- a silent skip is
exactly how a security guard rots green. Verified each new test fails against
the pre-fix code path rather than passing vacuously.
708 tests pass (was 667), ruff clean, no measurable regression in suite time.
Co-Authored-By: Claude <noreply@anthropic.com>
The first CI run on this branch went green with "OK (skipped=1)", and the skipped test was the credential half of the sandbox guard. It bailed out with "no ~/.codecouncil on this host" whenever that directory did not exist -- which is exactly the case on a GitHub runner. So CI proved the network block and silently proved nothing about the credential block. That is the same failure this file was written to prevent, one level down: a guard that quietly tests nothing still reports green. The test now plants its own decoy in the real home and removes it afterwards, so the assertion holds on any machine rather than only on a developer box that happens to have run CodeCouncil before. It also asserts the attack's first step still succeeds (getpwuid does recover the real home), keeping it honest that the OS boundary -- not the HOME redirect -- is what stops the read. Only skips now if the real home is genuinely unwritable, which is reported. Co-Authored-By: Claude <noreply@anthropic.com>
SECURITY.md offered `require` as the fail-closed setting for operators who would rather lose verification than run model-authored code unsandboxed. It did not work. SandboxUnavailable is a RuntimeError, and both execution call sites caught only TimeoutExpired and OSError, so on a host with no mechanism the exception propagated straight out of verify_finding into the critic beat -- an unhandled crash in a daemon that is required never to die, triggered by the very setting a security-conscious operator would reach for first. Both call sites now handle it as what it is: a refusal. verify -> inconclusive, "verification skipped — <reason>" probe -> error, "probe skipped — <reason>" Deliberately NOT "refuted". hooks/logic.py drops refuted rows before delivery, so classifying an untested finding as refuted would turn a safety setting into a muzzle -- the operator would lose findings entirely rather than lose only their proofs. Inconclusive keeps the finding flowing, minus the execution evidence. The default stays `auto`. Flipping it to `require` was considered and rejected: bubblewrap is absent by default on most distributions, so a `require` default would silently disable verification -- the product's whole thesis -- for a large share of Linux users. Fail-closed remains one env var away, and now actually behaves as documented. 712 tests (was 708), ruff clean. Co-Authored-By: Claude <noreply@anthropic.com>
CI caught these red: 'error' != 'inconclusive'. The require-policy tests called verify_finding without stubbing the model, so on CI -- where no model is configured -- agent.ask raised AgentError and verify_finding returned early with status "error", never reaching the run_script call whose refusal behaviour was the entire point of the test. Worse, the reason they passed locally: this dev box HAS a model configured, so the tests were making real API calls. That breaks the house rule that no test may hit a real model or the network, and it made the suite's result depend on the machine's credentials. The 4.6s these tests spent (5.0s -> 0.4s after the fix) was the round trip. Now stubbed via CRITIC_CMD like every other model-touching test, so the model reply is canned and execution reaches the sandbox refusal path deterministically. Verified both normally and under a scrubbed environment with no API keys, no pi on PATH, and no config -- i.e. what CI actually looks like. 712 tests, ruff clean. Co-Authored-By: Claude <noreply@anthropic.com>
Found in self-review. The suppression-resistance filter added earlier matched
bare review-vocabulary nouns -- finding, severity, suggestion, review, and
critically `critic`, which is a top-level PACKAGE in this repo. Because
knowledge facts describe THIS repo, they mention those words constantly, so
the filter rejected true facts wholesale:
"The critic emits at most one finding per beat." -> rejected
"Rate limiting uses a token bucket in critic/agent.py" -> rejected
"Findings carry a severity of low, medium, or high." -> rejected
That is the over-rejection direction (safe -- it injects nothing bad), but it
guts the feature: the whole point is to persist a correct rebuttal so it is
not re-litigated every beat, and most real facts here name the critic.
Replaced the bare-noun set with unambiguous suppression PHRASES (false
positive, no need to report, safe to ignore, not a real bug, is/are noise,
...), keeping the security-class exemption rule that covers the highest-value
attack ("SQL injection is an accepted convention here"). Validated against a
10-reject / 10-pass matrix, now locked in tests/test_knowledge.py.
713 tests, ruff clean.
Co-Authored-By: Claude <noreply@anthropic.com>
.ua/tmp/ is regenerated per scan and was showing up as untracked noise. Scoped deliberately: .ua/ is NOT ignored wholesale, because meta.json, knowledge-graph.json, fingerprints.json and .understandignore under it are tracked on purpose. .ua/config.json is left alone -- its siblings are tracked, so whether it belongs in the repo is a call for the maintainer, not something to silently ignore. Co-Authored-By: Claude <noreply@anthropic.com>
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.
A security review of the whole codebase turned up one high-severity issue and several smaller ones. This closes all of them, with regression tests that were each checked to fail against the pre-fix code rather than pass vacuously.
The headline issue
SECURITY.mdguaranteed that model-authored verify/probe scripts "cannot read your keys", on the strength of a scrubbed environment and aHOMEredirected into the staging dir.That guarantee was false.
HOMEonly governs~expansion, so a script recovers the real home viapwd.getpwuid(os.getuid()).pw_dir, reads~/.codecouncil/envby absolute path, and sends it out over a network that was never restricted. Reproduced end-to-end (179 bytes of live credentials).Environment scrubbing cannot fix this —
getpwuidreads the OS user database, not the environment — so the fix is an OS boundary. Newcore/sandbox.py:sandbox-execnetwork*+ reads under the real homebwrap--unshare-net+ tmpfs over the real homeBoth re-allow the interpreter's own prefixes after the home denial — pyenv/asdf install Python inside
~, so a blanket home deny breaks Python before the script runs. Measured cost: ~4.5ms/run against a multi-second model call.Also closed
git ls-fileslists untracked symlinks, so a repo shippingleaked.txt -> <outside>had that file captured and sent to the model provider. Redaction is no defense — the content is ordinary confidential text, not a credential shape.gitwatch._read_confinednow resolves then checks containment, matching whatjail.mjsalready did for tool calls.os.environ.screen.resolve_new_importsran withcwd=<untrusted repo>and an inherited environment; now-Iplus a scrubbed env.Enforcement
The exploit-regression tests error rather than skip when no sandbox mechanism exists, and CI installs bubblewrap (plus the Ubuntu 24.04
apparmor_restrict_unprivileged_usernssysctl that bwrap needs). A silent skip is exactly how a security guard rots green — which it did on the first CI run here, caught and fixed.Validated on macOS (
sandbox-exec), and Ubuntu 24.04 + Debian containers (bwrap0.9.0 and 0.11.0), on Python 3.10 and 3.12.Reviewer notes
auto, notrequire.requirewas considered and rejected: bubblewrap is absent by default on most distributions, so arequiredefault would silently disable verification — the product's core thesis — for many Linux users. Fail-closed is one env var away and now behaves as documented (it previously threw an unhandledRuntimeErrorinto the critic beat).(allow default)base; world-readable paths elsewhere stay readable. With egress denied, stdout is the only channel back, and it is redacted and capped.SECURITY.mdstates this plainly rather than overclaiming.~/.codecouncil/envif they ran verification against a repo they don't fully trust — noted inCHANGELOG.md..gitignoreto.ua/tmp/(deliberately not.ua/, which has tracked files).Verification
ruffclean on the pinned CI version