Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ jobs:
# tests/test_jail.py (the path-jail security boundary) actually runs
# instead of self-skipping -- jail.mjs has zero npm dependencies, so
# a bare `node` on PATH is all it needs.
#
# Same reasoning for bubblewrap: it is the Linux mechanism behind
# core/sandbox.py, and without it tests/test_sandbox.py's
# exploit-regression class has no sandbox to assert against. That class
# now ERRORS rather than skipping when no mechanism exists, so this step
# is what keeps the HIGH-severity guard (model-authored scripts must not
# reach credentials or the network) actually enforced in CI.
- name: Install bubblewrap (Linux OS sandbox for model-authored scripts)
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
# Ubuntu 24.04 restricts unprivileged user namespaces by default,
# which bwrap requires; without this it fails with
# "Creating new namespace failed: Operation not permitted".
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
# Fail fast and loudly here rather than as a confusing test error.
bwrap --ro-bind / / --unshare-net -- /bin/true
echo "bwrap functional"
- run: python3 -m unittest discover -s tests

ui:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ ui/dist/
demo/out/
evals/cases-harvested/
.ua/.trash-*/
# transient scan scratch — regenerated per run. Deliberately NOT ignoring
# .ua/ wholesale: meta.json, knowledge-graph.json, fingerprints.json and
# .understandignore are tracked on purpose.
.ua/tmp/
31 changes: 21 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,32 @@ a style nit. Each was earned by a real failure.
exceptions documented in CLAUDE.md.
3. **Redact at capture.** Any new text field that a model can influence, or
that comes from repo content, goes through `core.redact.redact()` before it
is written anywhere (prompt, receipt, suggestion, eval case).
4. **The hook fails open.** `hooks/peer_hook.py` must never break a developer's
is written anywhere (prompt, receipt, suggestion, eval case). Text the
MODEL wrote uses `core.redact.sanitize()` instead (strips terminal control
sequences first). Capture reads stay inside the repo — resolve the path and
check containment, never follow a symlink out (`gitwatch._read_confined`).
4. **Never execute foreign code with ambient authority.** Model-authored
scripts and agent-produced code run through `core.sandbox`: `minimal_env`
(never `{**os.environ}`) **and** `wrap` (OS sandbox — denies network and
real-home reads). Env scrubbing alone is NOT sufficient and was measured
insufficient: `pwd.getpwuid()` routes around a redirected `HOME`. If you
add a new execution site, route it through both.
5. **The hook fails open.** `hooks/peer_hook.py` must never break a developer's
session — any error → silent exit 0. `hooks/logic.py` stays **pure** (no
I/O; it takes parsed data and returns decisions).
5. **Daemons never die.** Missing inputs → wait; unparseable state → rebuild,
6. **Daemons never die.** Missing inputs → wait; unparseable state → rebuild,
don't crash; fallible calls in loop bodies → guarded.
6. **NDJSON readers tolerate a partial trailing line** and skip garbage.
7. **NDJSON readers tolerate a partial trailing line** and skip garbage.
Hot paths tail-read (`core.store.read_tail_rows`); dedup sets and metric
consumers read whole files.
7. **Atomic writes** for state/ledger files — use `core.store.write_json_atomic`,
8. **Atomic writes** for state/ledger files — use `core.store.write_json_atomic`,
never a naked `write_text`, on anything a crash mid-write could corrupt.
8. **Verification executes, it doesn't assert.** A finding is delivered only
9. **Verification executes, it doesn't assert.** A finding is delivered only
after a repro runs and confirms it. A broken/crashing repro script is never
a "verified" or "refuted" verdict. This is the product's whole thesis —
don't weaken it.
9. **Precision first.** A false finding costs trust; a missed one is caught by
the miss-detection loop. When in doubt, bias quiet.
10. **Precision first.** A false finding costs trust; a missed one is caught by
the miss-detection loop. When in doubt, bias quiet.

## House rules

Expand All @@ -89,12 +98,14 @@ a style nit. Each was earned by a real failure.
- `critic/` — judges new observations. `main.py` is the beat; `prompt.py`
builds prompts; `screen.py`/`deps.py` do zero-cost mechanical screening;
`verify.py`/`probe.py` execute model-written repro/probe scripts in a
throwaway staging dir; `agent.py` is the only model boundary.
throwaway staging dir, sandboxed via `core.sandbox`; `agent.py` is the only
model boundary.
- `hooks/` — deliver findings into the coding agent's context. `logic.py` pure,
`peer_hook.py` fail-open.
- `reflector/` — grades outcomes and rewrites `heuristics.md` (eval-gated,
auto-rolled-back).
- `core/` — the only shared code (`store`, `redact`, `config`, `knowledge`).
- `core/` — the only shared code (`store`, `redact`, `config`, `knowledge`,
`sandbox`).
- `evals/` — frozen cases + the A/B benchmark harness.
- Tests mirror this: one `tests/test_<thing>.py` per concern; synthetic
transcript fixture in `tests/fixtures/session.jsonl`.
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,40 @@ Notable changes. Format: [Keep a Changelog](https://keepachangelog.com); version

## [Unreleased]

### Security
- **Model-authored verify/probe scripts now run under an OS sandbox**
(`core/sandbox.py`; macOS `sandbox-exec`, Linux `bwrap`) that denies network
egress and reads under your real home directory. Previously these scripts
were protected only by a scrubbed environment and a redirected `HOME` —
which **did not stop credential theft**: `pwd.getpwuid()` recovers the real
home regardless of `$HOME`, so a script could read `~/.codecouncil/env` by
absolute path and POST it out. `SECURITY.md` claimed keys were unreachable;
that claim was wrong and has been corrected.
**If you ran CodeCouncil's verification against a repository you don't fully
trust, rotate the API keys in `~/.codecouncil/env`.**
Policy via `COUNCIL_SANDBOX` (`auto` default / `require` / `off`) or the
`sandbox` config key; hosts with no mechanism warn instead of pretending.
- Observer capture no longer follows a symlink out of the repo
(`gitwatch._read_confined`). `git ls-files` lists untracked symlinks, so a
repo shipping `leaked.txt -> ~/.aws/credentials` could previously have that
outside file captured and sent to the model provider — redaction does not
catch it, since such files are ordinary confidential text, not key shapes.
- A/B scoring subprocesses (`evals/ab/score.py`) get a scrubbed environment
instead of the operator's full `os.environ`; they import agent-produced code,
which runs its top-level statements.
- Distilled knowledge facts (`core/knowledge.py`) now reject review-process
vocabulary and security-exemption phrasing, closing a path where a crafted
rebuttal could persist "SQL injection is an accepted convention here" into
every future judgment prompt.
- `screen.resolve_new_imports` runs its probe with `-I` and a scrubbed
environment, so the untrusted repo's directory is off `sys.path` and no API
key reaches a process rooted in it.
- Model-authored text is control-character stripped as well as redacted
(`core.redact.sanitize`) — an ANSI escape in a finding could otherwise
repaint the terminal and misrepresent a severity.
- CI installs bubblewrap, and the exploit-regression tests now ERROR rather
than silently skipping when no sandbox mechanism exists.

### Added
- Per-key model auto-defaults: with no model configured, the first configured
API key picks its provider's default model (`core.config.KEY_DEFAULT_MODELS`,
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ Python is stdlib-only by design (3.10+): do not add pip dependencies to observer

## Architecture

Four loops communicating **only through NDJSON/JSON files** in the watched repo's `.codecouncil/` directory (gitignored). Each loop is an independent daemon; there are no imports across loop boundaries except small shared utilities (`core.store` for NDJSON + startup waits, `core.redact`, `observer.events`, `observer.transcript`, `critic.agent`). `codecouncil/` is only a launcher: it preflights the pi/model setup (clear warnings instead of silent per-beat failures), installs hooks, and runs the three loops as subprocesses with prefixed output — filtered for signal (`codecouncil/signal_filter.py`, pure functions: findings/grades/rewrites are highlighted, idle-beat chatter dropped unless `/verbose`) and driven by an interactive console (`codecouncil/console.py`: `/keys`, `/model`, `/prober`, `/status` work in place, restarting the critic as needed; parse logic is pure and testable, side effects live in `Console`, and non-TTY stdin never starts a console). CodeCouncil watches its own repo — `.codecouncil/` here contains live data, and the hooks are installed on this repo, so the Critic may review your work as you code.
Four loops communicating **only through NDJSON/JSON files** in the watched repo's `.codecouncil/` directory (gitignored). Each loop is an independent daemon; there are no imports across loop boundaries except small shared utilities (`core.store` for NDJSON + startup waits, `core.redact`, `core.sandbox`, `observer.events`, `observer.transcript`, `critic.agent`). `codecouncil/` is only a launcher: it preflights the pi/model setup (clear warnings instead of silent per-beat failures), installs hooks, and runs the three loops as subprocesses with prefixed output — filtered for signal (`codecouncil/signal_filter.py`, pure functions: findings/grades/rewrites are highlighted, idle-beat chatter dropped unless `/verbose`) and driven by an interactive console (`codecouncil/console.py`: `/keys`, `/model`, `/prober`, `/status` work in place, restarting the critic as needed; parse logic is pure and testable, side effects live in `Console`, and non-TTY stdin never starts a console). CodeCouncil watches its own repo — `.codecouncil/` here contains live data, and the hooks are installed on this repo, so the Critic may review your work as you code.

**Redaction invariant:** every text-bearing field is passed through `core.redact.redact()` **at observer capture time** — diffs, untracked/touched file contents, commit subjects + diffs (`observer/gitwatch.py`), reasoning text and tool-call commands (`observer/transcript.py`). Nothing downstream (prompts, receipts, harvested eval cases, dashboard) ever holds a raw credential; the `«REDACTED:kind»` marker itself is taught to the critic as a confirmed secret-in-code finding. If you add a new text field to any event, redact it at capture.
**Execution invariant:** code CodeCouncil did not write never runs with ambient authority. Model-authored verify/probe scripts (`critic/probe.py`'s `run_script`) and the A/B scorer's hidden tests (`evals/ab/score.py`) both go through `core/sandbox.py`: a from-scratch env allowlist (`minimal_env` — never `{**os.environ}`, so no API key reaches the child) **plus** an OS sandbox (`wrap` — macOS `sandbox-exec`, Linux `bwrap`) that denies network egress and reads under the real home. The OS layer is not optional decoration: `HOME` redirection alone was measured insufficient because `pwd.getpwuid(os.getuid()).pw_dir` recovers the real home and reads `~/.codecouncil/env` by absolute path (`tests/test_sandbox.py` runs that exact attack and requires it to fail). Policy via `COUNCIL_SANDBOX` env / `sandbox` config: `auto` (default; warns once if the host has no mechanism), `require` (refuse to execute unsandboxed), `off`. Profiles must re-allow reads of the interpreter's own prefixes after the home denial — pyenv/asdf install Python *inside* `~`, so a blanket home deny breaks Python itself.

**Redaction invariant:** every text-bearing field is passed through `core.redact.redact()` **at observer capture time** — diffs, untracked/touched file contents, commit subjects + diffs (`observer/gitwatch.py`), reasoning text and tool-call commands (`observer/transcript.py`). Nothing downstream (prompts, receipts, harvested eval cases, dashboard) ever holds a raw credential; the `«REDACTED:kind»` marker itself is taught to the critic as a confirmed secret-in-code finding. If you add a new text field to any event, redact it at capture. Two adjacent rules: capture reads are **repo-confined** (`observer/gitwatch.py`'s `_read_confined` — `git ls-files` lists untracked symlinks and a naive read follows one out of the repo, so paths are resolved and checked against the root, the same containment `jail.mjs` enforces for tool calls), and MODEL-authored text uses `core.redact.sanitize()` (strip terminal control sequences, then redact) rather than bare `redact()` — findings reach a terminal and the coding agent's context, where a raw ANSI escape can repaint the line above and misrepresent a severity.

1. **Observer** (`observer/`, event-driven: beats fire when a transcript grows, `--interval` is only the fallback floor) — pairs *intent* with *reality*. Tails Claude Code session transcripts (`~/.claude/projects/<munged-path>/*.jsonl`, persisted byte offsets in `state.json`) into `reasoning`/`tool_call` events, and snapshots git state into `diff` events (fingerprinted, emitted only on change; includes capped contents of new untracked files) and `commit` events (`old..new` HEAD ranges). Appends to `observations.ndjsonl`.

Expand Down
91 changes: 69 additions & 22 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ Redaction is pattern-based and deliberately precision-first; it is a strong
floor, not a guarantee against every exotic secret format. Review
`core/redact.py` for the exact patterns.

Two related boundaries:

- **Capture never follows a symlink out of the repo.** `git ls-files` lists
untracked symlinks and a naive read would follow one to, say,
`~/.aws/credentials` or a sibling checkout — capturing a file that is not
part of your project and shipping it in the next prompt. `observer/gitwatch.py`
resolves each path and refuses anything landing outside the repo root, the
same containment the judgment-turn tools already enforce.
- **Model-authored text is control-character stripped**, not just redacted
(`core/redact.py`'s `sanitize`). Findings are printed to your terminal and
injected into your coding agent's context, so an ANSI escape sequence in a
model-written `issue` string could otherwise repaint the line above it and
misrepresent a finding's severity.

## What never leaves

- Your API keys. They live in `~/.codecouncil/env` — **outside every
Expand All @@ -51,10 +65,13 @@ floor, not a guarantee against every exotic secret format. Review
the repo root (symlink-escape and traversal rejected, `.git`/`.codecouncil`
excluded). pi's builtin file tools are deliberately NOT used for this,
because they resolve `~` and absolute paths.
- **Repro commands** delivered to your coding agent are allowlist-gated
(`python3`/`pytest`/… prefixes, shell metacharacters rejected) and framed
"review before running" — they are suggestions as text, never executed by
CodeCouncil itself.
- **Repros** delivered to your coding agent are the verification script
itself, redacted, control-character-stripped, capped, and framed "review
before running". CodeCouncil hands it over as *text* and never executes it
in your repo — but it is model-authored code, so treat it as a suggestion
to read, not a command to run blind. (Earlier versions of this document
described a `python3`/`pytest` prefix allowlist; that gate applied to the
single-shell-command repro format which no longer exists.)
- The Claude Code hook (`hooks/peer_hook.py`) is **fail-open**: any internal
error exits silently rather than breaking your session.

Expand All @@ -66,24 +83,54 @@ script, which CodeCouncil then **executes on your machine** — in a
throwaway staging directory, never in your repo — to prove a finding real
before it's ever delivered.

That execution is not credential-blind by accident: the child process's
environment is a minimal allowlist built from scratch (`PATH`, `HOME`,
`LANG`/`LC_ALL`, plus `PYTHONPATH` pointed at the staging copy), never a
copy of the parent's real environment. Your API keys — whether real
environment variables or values loaded from `~/.codecouncil/env` — are not
in that allowlist, so model-authored code cannot read them. `HOME` is also
redirected to point inside the staging directory, so `~/.codecouncil/env`
and `~/.ssh` resolve to a nonexistent path for that script rather than your
real home.

**This is a credential-exposure mitigation, not a full OS sandbox.** A
malicious or prompt-injected script running in staging can still read any
absolute filesystem path it's given, and can still make outbound network
calls — neither of those is blocked. Run CodeCouncil only on repositories
(and against coding-agent output) you would already be willing to execute
code from. A full syscall-level sandbox (e.g. seccomp/landlock, a
container, or a no-network jail) is on the roadmap but not implemented
today.
That execution gets two independent layers (`core/sandbox.py`):

1. **A scrubbed environment.** The child's environment is a minimal
allowlist built from scratch (`PATH`, `HOME`, `LANG`/`LC_ALL`, plus
`PYTHONPATH` pointed at the staging copy), never a copy of the parent's.
No API key is in it, and `HOME` points inside the staging directory.
2. **An OS sandbox.** On macOS via `sandbox-exec`, on Linux via `bwrap`:
**all network egress is denied**, and **reads under your real home
directory are denied** (so `~/.codecouncil/env`, `~/.ssh`, and your shell
history are unreachable). The staging directory stays writable, and the
Python interpreter's own prefixes stay readable — necessary because
pyenv/asdf install the interpreter *inside* your home.

Layer 2 is not redundant, and this is worth being precise about because an
earlier version of this document got it wrong. It claimed layer 1 alone
meant "model-authored code cannot read your keys." **That was false.**
`HOME` only governs `~` expansion; `pwd.getpwuid(os.getuid()).pw_dir`
returns your real home regardless, and reading `<real home>/.codecouncil/env`
by absolute path and POSTing it out was demonstrated working. Environment
scrubbing cannot fix that — `getpwuid` reads the OS user database, not the
environment — which is why the OS boundary was added.

**Scope of the guarantee.** The two headline risks (credential theft and
network exfiltration) are closed where a sandbox mechanism exists. It is
still not a full syscall jail: on macOS the profile denies network and home
reads over an `(allow default)` base, so a script can read world-readable
paths elsewhere on disk — with egress denied, its only channel back is
stdout, which CodeCouncil redacts and caps.

**If no sandbox mechanism exists** (a Linux host without `bwrap`), scripts
run with layer 1 only and CodeCouncil prints a warning rather than implying
protection it isn't providing. Set `COUNCIL_SANDBOX=require` (or
`"sandbox": "require"` in `~/.codecouncil/config.json`) to refuse to execute
instead; `off` disables sandboxing for debugging.

`require` degrades, it does not break: verification and probes are skipped
with an explicit "verification skipped" note, and the finding is still
delivered — just without an execution proof attached. It is never recorded as
*refuted*, because a refusal to test something is not evidence against it.
The default is `auto` rather than `require` deliberately: bubblewrap is not
installed by default on most distributions, and defaulting to `require` would
silently disable the product's core "prove it before speaking" behaviour for
those users. Operators who prefer fail-closed should set it explicitly.

Even so: run CodeCouncil on repositories you would be willing to execute
code from. Verification and probe scripts **import the file under review**,
and importing a Python module runs its top-level statements — so "review
this repo" does mean "run some of this repo's code", sandboxed.

## Reporting a vulnerability

Expand Down
Loading
Loading