Skip to content

security: sandbox model-authored script execution, confine capture reads - #6

Open
adigo-pro wants to merge 6 commits into
mainfrom
security/sandbox-execution-boundary
Open

security: sandbox model-authored script execution, confine capture reads#6
adigo-pro wants to merge 6 commits into
mainfrom
security/sandbox-execution-boundary

Conversation

@adigo-pro

Copy link
Copy Markdown
Owner

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.md guaranteed that model-authored verify/probe scripts "cannot read your keys", on the strength of 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 sends it out over a network that was never restricted. Reproduced end-to-end (179 bytes of live credentials).

Environment scrubbing cannot fix this — getpwuid reads the OS user database, not the environment — so the fix is an OS boundary. New core/sandbox.py:

Platform Mechanism Effect
macOS sandbox-exec denies network* + reads under the real home
Linux bwrap --unshare-net + tmpfs over the real home

Both 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.

sandbox OFF (pre-fix path):  LEAKED           NET-OPEN
sandbox ON  (default):       BLOCKED          NET-BLOCKED

Also closed

  • Capture 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 content is ordinary confidential text, not a credential shape. gitwatch._read_confined now resolves then checks containment, matching what jail.mjs already did for tool calls.
  • A/B scorer handed agent-produced code the operator's full os.environ.
  • Knowledge filter matched only imperative phrasings, so a flat declarative ("SQL injection is an accepted convention here") persisted into every later judgment prompt.
  • screen.resolve_new_imports ran 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.

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_userns sysctl 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 (bwrap 0.9.0 and 0.11.0), on Python 3.10 and 3.12.

Reviewer notes

  • Default policy is auto, not require. require was considered and rejected: bubblewrap is absent by default on most distributions, so a require default 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 unhandled RuntimeError into the critic beat).
  • Not a full syscall jail. On macOS the profile denies network and home reads over an (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.md states this plainly rather than overclaiming.
  • Users should rotate keys in ~/.codecouncil/env if they ran verification against a repo they don't fully trust — noted in CHANGELOG.md.
  • One unrelated one-line commit scopes .gitignore to .ua/tmp/ (deliberately not .ua/, which has tracked files).

Verification

  • 713 tests (was 667), zero skips, green on 3.10 and 3.12
  • ruff clean on the pinned CI version
  • No measurable suite-time regression

adigo-pro and others added 6 commits August 2, 2026 12:57
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>
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