Metadata-Version: 2.4
Name: combra-guard
Version: 0.1.2
Summary: Config-driven policy gate for AI coding agents: in-editor hook, git commit gate, and CI diff wall.
Project-URL: Homepage, https://combra.ai
Project-URL: Repository, https://github.com/Fabrique-Futur/combra-guard
Project-URL: Issues, https://github.com/Fabrique-Futur/combra-guard/issues
Project-URL: Changelog, https://github.com/Fabrique-Futur/combra-guard/blob/main/CHANGELOG.md
Author: Fabrique Futur
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: ai-agents,ci,claude-code,git-hooks,guardrails,hook,policy,pre-commit,pretooluse
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# combra-guard

[![CI](https://github.com/Fabrique-Futur/combra-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/Fabrique-Futur/combra-guard/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![PyPI version](https://img.shields.io/pypi/v/combra-guard.svg)](https://pypi.org/project/combra-guard/)
[![Python versions](https://img.shields.io/pypi/pyversions/combra-guard.svg)](https://pypi.org/project/combra-guard/)

A config-driven policy gate for AI coding agents. You write rules once in `guard.toml`;
combra-guard enforces the same rules at four points, from an instant in-editor deny up to an
authoritative CI wall. The engine hardcodes no repo policy: the four generic rule types
(`forbidden_field`, `forbidden_pattern`, `protected_path`, `protected_read`) are parameterized per
repo in `guard.toml`.

| Gate | Trigger | Covers | Strength |
|---|---|---|---|
| **Action** ([Claude `PreToolUse`](#action-gate)) | writes; `protected_read` on `Read`/`Grep`; literal `Bash` reads/writes to a protected path | Claude only | instant write seatbelt; prevention-only read guard |
| **Turn** ([Claude `Stop`](#turn-gate)) | write result vs turn-start baseline | Claude only | write seatbelt, catches `Bash` writes by result |
| **Commit** ([git `pre-commit`](#commit-gate)) | staged writes on `git commit` | any agent + human | write seatbelt, `--no-verify`-bypassable |
| **Merge/CI** ([`check-diff`](#ci-wall)) | writes in the PR diff | any agent | the authoritative write wall |

The Action, Turn, and Commit gates are seatbelts an agent or a human can route around; the CI wall is
the authoritative write wall. See [`docs/DESIGN.md`](docs/DESIGN.md) for the architecture and
per-phase build notes.

## Install

```bash
pipx install combra-guard                             # or:
pip install combra-guard
uv tool install combra-guard
```

Zero runtime dependencies (stdlib only). Python 3.11+.

## Quickstart

```bash
combra-guard init                # scaffold guard.toml, wire the Claude hook + git commit gate
combra-guard selftest            # run the hermetic golden cases
```

`init` is idempotent. It:

- writes a starter `guard.toml` at the repo root (including an active `protect-guard-config` rule
  that guards `guard.toml`, `guard.remote.toml`, `.claude/settings.json`, and `.githooks/**`, so an
  agent can't disable the guard in-session);
- adds the `Edit|Write|MultiEdit`, `Read|Grep`, and `Bash` `PreToolUse` hooks plus the
  [turn-gate](#turn-gate) `UserPromptSubmit`/`Stop`/`SubagentStop` hooks to `.claude/settings.json`,
  never clobbering hooks you already have;
- compiles `protected_read` paths into Claude's native `permissions.deny`;
- installs the git [commit gate](#commit-gate) and appends `guard.remote.toml` to `.gitignore`.

To wire the Action hook by hand instead:

```json
{ "hooks": { "PreToolUse": [
  { "matcher": "Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "combra-guard hook" }] },
  { "matcher": "Read|Grep", "hooks": [{ "type": "command", "command": "combra-guard hook" }] },
  { "matcher": "Bash", "hooks": [{ "type": "command", "command": "combra-guard hook" }] }
] } }
```

The hook reads `$COMBRA_GUARD_CONFIG`, or the repo-root `guard.toml` (found by walking up from any
subdirectory). Author rules directly, or [pull governed rules](#5-pull-governed-rules-from-combra)
from a Combra workspace.

## Usage

Five runnable examples: a `guard.toml` snippet, the command that trips it, and the line combra-guard
prints.

### 1. In-editor deny on a forbidden pattern

```toml
[[rules]]
id = "no-console-log"
type = "forbidden_pattern"
globs = ["src/*.js"]
pattern = "console\\.log"
cite = "no debug logging in shipped code"
deny_reason = "'{line}' is a debug log; remove it before shipping."
```

When Claude tries to `Edit` `src/app.js` to add `console.log(x)`, the `PreToolUse` hook denies it
before the write lands. Claude sees:

```
combra-guard[no-console-log]: 'console.log(x)' is a debug log; remove it before shipping. — no debug logging in shipped code
```

The `{line}` placeholder is the added line (stripped); `forbidden_pattern` fires only on lines the
edit adds, so existing `console.log`s are grandfathered.

### 2. The CI wall

`check-diff --base <ref>` judges the whole PR diff in one process and exits non-zero on any
violation. No in-diff trick bypasses it; close the workflow-rewrite hole with branch protection +
CODEOWNERS on `.github/workflows/` (see [Honest limits](#honest-limits)). Wire it as a
required status check with a single step (full workflow in the [CI wall](#ci-wall) section):

```yaml
run: combra-guard check-diff --base "origin/$BASE_REF"
```

A PR that adds the `console.log` from example 1 fails the check:

```
combra-guard[no-console-log]: 'console.log("debug")' is a debug log; remove it before shipping. — no debug logging in shipped code (src/app.js)
```

The CI variant appends the file path so a multi-file scan names which file to fix. If you [pull
governed rules](#5-pull-governed-rules-from-combra), add the pull step before this one (see the full
CI section below).

### 3. The commit gate

`init` installs `.githooks/pre-commit`, which runs `combra-guard check-diff --staged` (staged index
vs `HEAD`). A `git commit` that stages the same violation is blocked:

```
$ git add src/app.js && git commit -m "add log"
combra-guard[no-console-log]: 'console.log("debug")' is a debug log; remove it before shipping. — no debug logging in shipped code (src/app.js)
```

It fires for anything that commits — Claude, Codex, Cursor, or a human — with no per-agent wiring.
The non-zero exit and printed reason tell an agent what to fix; `git commit --no-verify` is the
honest bypass.

### 4. Block a secret read

```toml
[[rules]]
id = "no-secret-reads"
type = "protected_read"
paths = ["~/.ssh/**", ".env"]
cite = "secrets never enter model context"
deny_reason = "reading {path} is blocked"
```

A Claude `Read` of `~/.ssh/id_rsa` is denied before the file enters model context:

```
combra-guard[no-secret-reads]: reading /Users/you/.ssh/id_rsa is blocked — secrets never enter model context (/Users/you/.ssh/id_rsa)
```

`{path}` resolves to the canonical absolute path; matching runs on both that and the lexical
(pre-symlink) path, so home paths, repo-relative paths, a symlink pointing at a secret, and a
symlinked directory component under a relative or absolute wildcard all match without the file being
opened. `init` also compiles these paths
into Claude's native `permissions.deny` (e.g. `Read(~/.ssh/**)`). See [read-guard](#read-guard) for
the limits.

### 5. Pull governed rules from Combra

Manage rules centrally and pull them into each repo:

```bash
combra-guard init --remote https://api.combra.ai
export COMBRA_GUARD_TOKEN=<your Combra PAT>
combra-guard pull                # GET {url}/v1/guard/rules → writes guard.remote.toml
```

`init --remote` adds a `[remote]` block naming the URL and the env var holding the token. The token
itself never lands in a file:

```toml
[remote]
url = "https://api.combra.ai"
token_env = "COMBRA_GUARD_TOKEN"
```

`pull` writes the fetched rules to `guard.remote.toml` (git-ignored, marked `# GENERATED — do not
edit`) and refreshes the native read deny-list. Both `guard.toml` and `guard.remote.toml` are loaded
and enforced together; a rule `id` that collides across the two fails closed. `pull` requires `https`
(plain `http` only for `localhost`), never follows redirects, and validates every rule before
writing. A bad response leaves your existing rules untouched.

## The four gates

### Action gate

The `PreToolUse` hook judges one prospective Claude tool call and denies before it runs. It covers
`Edit`/`Write`/`MultiEdit` writes against all rule types, `Read`/`Grep` against
[`protected_read`](#read-guard), and `Bash` against the [write tripwire](#write-tripwire) and the
keychain read tripwire. It fails open on combra-guard's own infra errors (never block the agent
on our breakage) but fails closed on a detector error while evaluating a matched file.

### Turn gate

A Claude-only gate that closes the Action gate's biggest hole: a guarded file rewritten through
`Bash` (`sed -i`, `>` redirect, `python - <<EOF`) never touches the `Edit`/`Write`/`MultiEdit`
matcher. Instead of watching tools, the turn gate diffs the result:

- On `UserPromptSubmit` it snapshots the turn-start state — the trusted rules plus the content of
  every guarded file (including git-ignored control files like `guard.remote.toml`, and any
  `allow_source` a rule references) — into `<git-dir>/combra-guard/turn-<session_id>.json`.
- On `Stop` / `SubagentStop` it re-reads those files and scans `(baseline → working tree)` with the
  snapshot's rules, blocking the Stop once and naming the file to revert.

The snapshot is the trusted side, so a turn that edits `guard.toml` or widens an `allow_source`
cannot re-authorize itself. The baseline lives under the per-worktree git dir, so it is never
committed and a linked worktree resolves correctly.

**Honest limits.** Claude-specific (it uses Claude Code's hook events). It blocks once. After
the block, `stop_hook_active` lets the turn end even if the violation is unfixed; the working tree
still holds it (the commit and merge gates backstop). A turn you interrupt before `Stop` is
unscanned. A resumed session with no baseline is skipped with a warning. The gate never diffs
against `HEAD`, so pre-existing local edits are never blamed on the agent. Human edits made in
parallel with the agent's turn are in the working tree but not the baseline, so they can false-block;
the reason names the exact file so you can see it's yours. `combra-guard pull` refreshes the current
session's baseline for the file it rewrites, so a mid-turn pull does not false-block.

### Commit gate

`init` installs a tracked `.githooks/pre-commit` hook and points `git config core.hooksPath` at it.
The hook runs `combra-guard check-diff --staged`, judging the staged index vs `HEAD` and
exiting non-zero on a violation, so `git commit` is blocked. It fires for anything that commits, with
no per-agent integration.

```sh
# .githooks/pre-commit
#!/bin/sh
# combra-guard commit gate
combra-guard check-diff --staged || exit 1
```

- **Installed via `core.hooksPath`, not `.git/hooks`.** `.git/hooks` isn't shared by a clone;
  `.githooks/` is tracked, so cloning + one `init` arms every checkout.
- **Never-clobber.** If `core.hooksPath` already points elsewhere, or a foreign `pre-commit` already
  lives in `.githooks/`, `init` prints the one line to add (`combra-guard check-diff --staged || exit
  1`) and skips.
- **Partial staging (`git add -p`) is judged correctly** — the index is exactly what will commit.
- **First commit (unborn `HEAD`):** every staged file is treated as newly added; rules load from the
  worktree `guard.toml`. The first commit that *introduces* the control files (`guard.toml`,
  `.githooks/**`, `.claude/settings.json`) must bootstrap with `git commit --no-verify`; it lands
  through a reviewed PR anyway.

**Honest limits.** A seatbelt, not the wall. `git commit --no-verify` bypasses it, and editing
`.githooks/pre-commit` or `core.hooksPath` (which lives in `.git/config`, where no file rule can
reach) disables it locally. The in-repo `protect-guard-config` rule catches a tracked-hook edit at
the merge wall, but a `--no-verify` bypass is honest and undefeatable by design. It is not installed
until someone runs `init` in the clone.

- **Missing binary blocks commits.** The gate fails closed: with `combra-guard` off `PATH`, the
  hook's `command not found` exits non-zero, so every `git commit` is blocked (not a silent no-op)
  until you install it, or `git commit --no-verify` in the meantime.
- **Linked worktrees.** `core.hooksPath` is relative, resolved per worktree. A worktree on a branch
  that predates the gate has no `.githooks/`, so git runs no hook there. Re-run `init` there to arm it.
- **Symlinked `.githooks`.** If `.githooks` is a symlink, the hook write follows it to its target;
  `init` does not replace the link with a directory.

### CI wall

`combra-guard check-diff` is the authoritative, agent-agnostic wall: it judges a whole PR diff in one
process and exits non-zero on any violation. No Bash trick bypasses it, and a required status check
gates the merge.

```
combra-guard check-diff --base <ref>    # PR wall: merge-base(<ref>, HEAD)...HEAD
combra-guard check-diff --staged        # commit gate: staged index vs HEAD
```

- **Three-dot merge-base diff.** `--base` diffs `merge-base(<ref>, HEAD)...HEAD`, so the PR is judged
  as the change that will merge. A violation added and reverted within the branch does not
  fire; base-branch drift after the branch point is not blamed on the PR.
- **Rules load from the trusted (merge-base) side.** A PR that deletes or empties `guard.toml` is
  still judged by the pre-PR rules; it cannot disable the wall from inside the same PR. An
  `allow_source` (e.g. `ENV_PROFILES`) is likewise resolved from the merge-base side: a PR that adds
  a forbidden field and registers its name in the allow-source in the same diff still fires
  (register it in its own PR first). `guard.remote.toml` must stay git-ignored (its trust anchor is
  the Combra pull, which CI re-runs to fetch governed rules fresh); `check-diff` fails closed if a PR
  commits or force-adds one, so a neutered governed ruleset can never be smuggled into the wall.
- **Renames.** A rename from an unguarded path into a guarded one is treated as newly-added content
  (smuggling fires); a rename within guarded scope keeps grandfathered fields; a rename of a
  `protected_path` file out of guarded scope, and a plain deletion of one, both fire.
- **Binaries and symlinks.** A file is read only if its path matches a rule (a PNG elsewhere in the
  PR never bricks the wall). A guarded path that is binary, oversized (>5 MB), or a symlink fails
  closed with a message telling you to narrow the rule's globs.

The full CI workflow, with the optional governed-rules pull:

```yaml
# .github/workflows/guard.yml
name: guard
on: pull_request
jobs:
  wall:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }          # base + head needed for the merge-base diff
      - uses: astral-sh/setup-uv@v5
      - run: uv tool install combra-guard
      # Pull only when a [remote] is configured; a real pull failure reddens CI (no `|| true`) so a
      # down Combra / revoked token can't silently drop the governed rules the wall trusts.
      - name: Pull governed rules from Combra
        env: { COMBRA_GUARD_TOKEN: ${{ secrets.COMBRA_GUARD_TOKEN }} }
        run: |
          if grep -q '^\[remote\]' guard.toml 2>/dev/null; then combra-guard pull; fi
      - name: Enforce guard rules over the PR diff
        env: { BASE_REF: ${{ github.base_ref }} }
        run: combra-guard check-diff --base "origin/$BASE_REF"
```

Add `guard` as a required status check so a denied change can't merge.

**Honest limits.**

- **The workflow file itself is not governed by the diff.** A PR that rewrites `guard.yml` into a
  green no-op is an inherent limit of any PR-triggered check. Protect it with branch protection +
  CODEOWNERS review on `.github/workflows/`. The `protect-guard-config` rule guards `guard.toml`,
  `guard.remote.toml`, `.claude/settings.json`, and `.githooks/**`, but CODEOWNERS review of *rule
  changes* remains sound advice (a rule change is judged by the old rules, then takes effect after
  its own PR merges).
- **`guard.toml` (including its `[remote]`) must stay under a `protected_path` rule.** CI `pull`
  reads `[remote].url` from the checked-out (head-side) `guard.toml`, so a PR that repointed it at an
  attacker host could fetch weakened rules. This is closed *because* the trusted-side (merge-base)
  `protect-guard-config` rule covers `guard.toml`, so `check-diff` denies any edit to it (including
  `[remote]`) before those rules could take effect. `init` scaffolds that rule; a repo that removes
  `guard.toml` from its `protected_path` coverage reopens the redirect.
- **No wall until the first rule PR merges.** A base branch that has never contained a `guard.toml`
  has nothing to load; the rule-introducing PR turns the wall on.
- **No cross-file rule model.** Rules are file-local; a rule that only makes sense across several
  files in one PR is outside the model.
- **Octopus merges.** `git merge-base` returns a single commit; a history with multiple merge-bases
  picks one. Correct by construction for an ordinary single-base PR.

#### `check` — the per-tool-call primitive

`combra-guard check` reads one Claude tool-call JSON on stdin
(`{"tool_name": "Write", "tool_input": {"file_path": ..., "content": ...}}`) and exits non-zero on a
deny or any error. It is the low-level twin of the Action seatbelt; `check-diff` is the whole-PR wall
built on the same engine. Prefer `check-diff` for CI.

## Read-guard

`protected_read` blocks Claude `Read` and `Grep` calls before the target enters model context.
Targets are matched on both the canonical (symlink-resolved) path and the lexical (pre-symlink) path,
so home paths, absolute paths, repo-relative paths, a symlink pointing AT a secret, and a symlinked
directory component UNDER a wildcard, whether the wildcard is relative (`secrets/**` where
`secrets/` links out of the repo) or absolute (`~/.ssh/**` where `~/.ssh/link` links out of `~/.ssh`),
are all covered without opening the file:

```toml
[[rules]]
id = "no-secret-reads"
type = "protected_read"
paths = ["~/.ssh/**", "~/.aws/credentials", ".env", ".env.*"]
cite = "secrets never enter model context"
deny_reason = "reading {path} is blocked"
```

At `init` and after every `pull`, these paths compile into Claude's native `permissions.deny`
entries such as `Read(~/.ssh/**)`. Ownership is recorded under
`permissions._combra_guard_managed`; recompilation removes only those entries and preserves
hand-authored denies and unknown settings, using an atomic replace that never runs in a hook (where a
settings read-modify-write would race a live session).

The `Bash` matcher adds an opportunistic tripwire for literal spellings of protected paths and for
`security find-generic-password` / `security find-internet-password`.

**Honest limits.** Read-guard is accident-grade only: there is no turn, commit, or merge
backstop, because once content is read a later result scan cannot undo the leak. An adversarial or
prompt-injected agent bypasses it with indirection such as `base64 < $(echo ~/.s*h/id_*)`; the real
backstop for reads is the OS sandbox (macOS Seatbelt, a container, or a devcontainer with a mount
allowlist). The Bash tripwire is literal-match, so variables, globs, and constructed paths bypass it.
MCP and WebFetch read channels are uncovered. A missed read is an irrevocable leak.

## Write tripwire

An agent can dodge the `Edit`/`Write` matcher by rewriting a guarded file through the shell. The same
`Bash` `PreToolUse` hook opportunistically pre-denies the *obvious* cases: a literal path in a
plausible write position (a `>`/`>>` redirect target, `sed -i … FILE`, `tee FILE`, a `cp`/`mv`
destination, or `dd of=FILE`) that matches a `protected_path` rule. It fires only when the repo has
at least one `protected_path` rule (opt-in; the engine hardcodes no policy).

```toml
[[rules]]
id = "protect-migrations"
type = "protected_path"
paths = ["migrations/**"]
cite = "migrations are append-only; never rewrite a shipped migration"
deny_reason = "editing {path} is blocked"
```

```bash
echo hacked > migrations/001.sql       # denied
echo hacked>migrations/001.sql         # denied — redirect glued to the preceding token, no space
echo hacked> migrations/001.sql        # denied — trailing glued `>`, target is the next token
echo x>/dev/null>migrations/001.sql    # denied — chained redirect, final target checked
echo hacked >|migrations/001.sql       # denied — `>|` noclobber-override redirect
echo hacked >&migrations/001.sql       # denied — `>&FILE` stdout+stderr redirect to a file
sed -i 's/x/y/' migrations/001.sql     # denied
cat x\>migrations/001.sql              # allowed — `\>` is an escaped literal, not a redirect
echo done >&2                          # allowed — `>&2` duplicates a file descriptor, not a write
python -c "open('migrations/001.sql','w')"  # NOT caught — target buried in a Python string
```

The first two print:

```
combra-guard[protect-migrations]: editing migrations/001.sql is blocked — migrations are append-only; never rewrite a shipped migration (migrations/001.sql)
```

**Honest limits.** A seatbelt, not the wall. It does not parse arbitrary shell: a `python -c` write,
`eval`, a variable-obscured target, or a heredoc all bypass it. The redirect scan is quote- and
backslash-aware. It splits *every* token at each unquoted, unescaped `>` run, so a glued (`x>FILE`),
spaced (`x> FILE`), or chained (`x>/dev/null>FILE`) redirect is caught, as are the `>|FILE`
noclobber-override and `>&FILE` stdout+stderr forms (glued or spaced), while a quoted `'>'`, an
escaped `\>`, or a bare fd-dup (`>&1`, `>&2`, `>&-`) stays a non-write. Still uncaught: a variable- or
glob-obscured target (`> ${f}FILE`), a process-substitution redirect (`> >(cmd)`), and (because the
`sed`/`tee`/`cp`/`mv`/`dd` verb dispatch inspects only the first token) those verbs after a shell
operator (`x && sed -i … FILE`, `foo | tee FILE`). The authoritative catch is the [CI wall](#ci-wall)
(sees the resulting diff) and the [turn gate](#turn-gate) (catches it by result).

## Editor adapters

### Cursor (preview — verified against docs, not yet live-tested)

> **Preview.** Built to Cursor's documented hooks API ([cursor.com/docs/hooks](https://cursor.com/docs/hooks),
> config schema version 1), config emission unit-tested. Not yet run in a live Cursor session.
> The maintainers cannot run Cursor, and its hooks API is beta and has changed across releases.
> Please smoke-test it in a real Cursor session and open an issue with what you see. Do not rely on it
> as your only gate; the commit and CI gates are agent-agnostic.

`combra-guard init --cursor` is opt-in (a plain `init` never touches Cursor config). It compiles the
same `guard.toml` into `.cursor/hooks.json`:

| guard.toml rule | Cursor hook | Effect |
|---|---|---|
| `protected_read` | `beforeReadFile` | denies the read before the file enters model context |
| `protected_path` | `beforeShellExecution` | denies the obvious `Bash` writes to a protected path |

```json
{
  "version": 1,
  "hooks": {
    "beforeReadFile": [{ "command": "combra-guard cursor-read", "failClosed": true }],
    "beforeShellExecution": [{ "command": "combra-guard cursor-shell", "failClosed": true }]
  }
}
```

Both hooks carry `"failClosed": true`. Cursor's read/shell hooks default to fail-open, so a hook
crash on a matched file must block the operation. Emission is atomic; `hooks.json` carries only
Cursor-documented keys, and ownership is tracked in a git-ignored sidecar
`.cursor/.combra-guard-managed.json`. Recompiling removes only our entries and preserves
hand-authored hooks and unknown keys. `combra-guard cursor-read` / `cursor-shell` read Cursor's stdin
JSON and answer in its response schema (`{"permission": "deny", "user_message", "agent_message"}`),
reusing the same read/write evaluators as the Claude hook.

**Honest limits (Cursor).**
- **No pre-deny for file edits.** Cursor exposes only the post-hoc `afterFileEdit` hook,
  so `protected_path` violations written through `Edit` are not blocked in-editor; the commit
  and CI gates are the catch. `beforeShellExecution` covers only the `Bash`-write shape, with the
  same limits as the [write tripwire](#write-tripwire).
- **No content redaction.** Cursor's `beforeReadFile` response is permission-only (`allow`/`deny`).
- **No turn gate.** Cursor's `stop` hook is not wired; the turn-scan backstop is Claude-only.
- Beta-API drift: an unrecognized payload shape degrades to an allow (never a crash); `failClosed`
  makes a hook *error* block instead.
- The community JSON schema at `unpkg.com/cursor-hooks` still lags and rejects `failClosed`. The
  adapter follows the official docs.

### Codex (preview — emission parse-verified against real Codex, not in-session-tested)

> **Preview.** Built to OpenAI Codex's documented execpolicy `prefix_rule` format. The emitted
> `.rules` file was parse-verified against the real `codex execpolicy check` engine (codex-cli
> 0.144.6): it loads, and `security find-generic-password` resolves to `decision: forbidden`. Not
> yet verified in-session: whether a running Codex agent actually refuses the forbidden command
> outside its sandbox. Please smoke-test it in a real Codex session and open an issue. Codex
> execpolicy is experimental and may change. Do not rely on it as your only gate; the commit and CI
> gates are agent-agnostic.

Codex has no `PreToolUse`-equivalent and no edit pre-deny. Its native mechanisms are the
execpolicy (a command-prefix matcher over argv, priority `forbidden` > `prompt` > `allow`, governing
commands Codex runs outside its OS sandbox) and the OS sandbox itself. So combra-guard compiles
rather than clones a hook: `combra-guard init --codex` (opt-in) compiles `guard.toml` into a managed
block in `.codex/rules/combra-guard.rules`:

| guard.toml rule | execpolicy | Effect |
|---|---|---|
| `protected_read` (any present) | `prefix_rule(pattern=["security","find-generic-password"], decision="forbidden")` (+ `find-internet-password`) | blocks reading a stored secret via the macOS keychain outside the sandbox |

```python
# >>> combra-guard managed (do not edit inside this block) >>>
prefix_rule(
    pattern = ["security", "find-generic-password"],
    decision = "forbidden",
    justification = "combra-guard: reading a stored secret via the OS keychain is blocked",
    match = ["security find-generic-password"],
)
# ... find-internet-password ...
# <<< combra-guard managed <<<
```

The managed block is comment-fenced and emission is atomic, so a recompile rewrites only our block
and preserves any hand-authored Starlark around it. The `.rules` file is committed config (the
rulebook Codex reads), so it is not git-ignored.

**Honest limits (Codex).**
- **Only the keychain secret-read tripwire compiles.** execpolicy matches leading argv tokens;
  `security find-*-password` is an exact prefix, so it maps cleanly to a `forbidden` rule. Path
  tripwires do not: in a protected-path write (`sed -i … guard.toml`) or file read (`cat ~/.ssh/id_rsa`)
  the path operand appears later, and there is no bounded set of write verbs.
  Those stay covered by the commit + CI walls.
- **No path/edit pre-deny, no diff rules in-session.** `protected_path`, `forbidden_field`, and
  `forbidden_pattern` do not run inside a Codex session. The commit and CI gates are Codex's real
  gate.
- **Outside-sandbox scope.** execpolicy governs commands Codex runs outside its sandbox. The OS
  sandbox governs commands run inside it.

## MCP advisory tool (preview — needs a real-MCP-client smoke test)

> **Preview & advisory.** `combra-guard mcp` is an advisory surface; it does not enforce. It lets
> an MCP-capable agent *ask* "would this action be denied by a local rule?" before doing it. Nothing
> forces an agent to call it, and ignoring the answer changes nothing. The Action, commit, turn, and
> CI gates catch the write regardless. Never count it as a security layer. Built to the MCP spec
> ([2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18)), not yet tested against a
> live MCP host. Please connect a real client and open an issue with what you see.

`combra-guard mcp` starts a minimal stdlib-only JSON-RPC-over-stdio MCP server (no SDK dependency).
Point any MCP client at it as a stdio server:

```json
{ "mcpServers": { "combra-guard": { "command": "combra-guard", "args": ["mcp"] } } }
```

It exposes two tools, both reading the same local `guard.toml` the gates use (same evaluators, no
second matching implementation to drift):

| Tool | Input | Returns |
|---|---|---|
| `check_rule` | a hook-shaped action `{tool_name, tool_input}` (e.g. `{"tool_name":"Read","tool_input":{"file_path":"secrets/x"}}`, an `Edit`/`Write`, or a `Bash` `{command}`) | `{allowed, reason, rule_id}` — the verdict the hook would give |
| `list_rules` | — | the loaded rules (`id`, `type`, `paths`) for discoverability |

Because it runs the same `evaluate` / `evaluate_read` / `evaluate_bash` the `PreToolUse` hook runs,
`check_rule` sees exactly what the Action gate would, including the opportunistic `Bash` write
tripwire. It does not run the diff-shaped commit/CI wall, so a rule that only fires on the full diff
won't surface here; that is by design (a fast self-check, not the wall). The AI rule-authoring
loop is a separate Combra-backend product, not part of this OSS package (see
[`docs/DESIGN.md`](docs/DESIGN.md) §Phase 7).

## Config reference

### Rule types

- **`forbidden_field`** — deny a newly-declared field of `field_type` (e.g. `bool`) inside a class
  named `*<class_suffix>`, under `globs`. An `allow_source` block whitelists names already managed
  elsewhere. Existing fields are grandfathered. Only new names fire.
- **`forbidden_pattern`** — deny an added line matching a regex `pattern`, under `globs`. The
  `{line}` placeholder is the offending line.
- **`protected_path`** — guard files (`paths`) from write. Enforced at every write gate and by the
  Bash [write tripwire](#write-tripwire). The `{path}` placeholder is the file.
- **`protected_read`** — block Claude `Read`/`Grep` on `paths` (canonical absolute) and compile to
  native `permissions.deny`. See [read-guard](#read-guard).

Every rule takes an `id`, a `cite` (the policy rationale), and a `deny_reason` (the message, with the
placeholders above). A full annotated example ships as [`guard.example.toml`](guard.example.toml).

### Glob semantics

Write-rule glob matching (`globs`, `paths`) is Python `fnmatch`, evaluated against the repo-relative
path (no leading `./`). `protected_read` is the exception: the target and absolute/home patterns are
canonical absolute paths, while relative patterns also match repo-relative. Mis-scoping a rule is a
security hole, so read these carefully:

- **`*` crosses `/`.** `fnmatch`'s `*` matches any run of characters *including* path separators, so
  `api/app/config/*.py` matches `api/app/config/db.py` and `api/app/config/sub/nested.py`. To pin
  a rule to one directory level, spell out the segments; there is no wildcard that stops at `/`.
- **No special `**` operator.** `fnmatch` has no recursive-glob syntax. `**` is two adjacent `*`, which
  already spans directories. `.claude/**` works only because plain `*` already crosses `/`.
- **`?`** matches exactly one character; **`[seq]`** is a character class; **`[!seq]`** negates it.
- **Any-subtree-depth convenience.** A pattern is also tried with a `*/` prefix, so a bare
  `config/*.py` fires whether the file sits at the repo root or under a subdir.
- **Case sensitivity follows the OS, via `os.path.normcase`** — case-sensitive on Linux and macOS,
  case-insensitive only on Windows. Don't rely on case to separate a matched from an unmatched path.

### Worked example: ban bare feature-flag booleans

"No new bare `bool` toggle in a settings class." This `guard.toml` denies a newly-declared `bool`
field in a `class *Settings` under `api/app/config/*.py` unless the field name is already a key in an
`ENV_PROFILES` dict. Point the globs, `class_suffix`, and `allow_source` at your own layout:

```toml
[[rules]]
id = "no-env-flags"
type = "forbidden_field"
globs = ["api/app/config/*.py"]
field_type = "bool"
class_suffix = "Settings"
cite = "No feature flags: env holds secrets; toggles live in ENV_PROFILES, reviewed per-env."
deny_reason = "'{name}: bool' adds a bare feature-flag toggle. Register {name} in ENV_PROFILES, or make it a module constant."

[rules.allow_source]
# Allow set = the union of the INNER dict keys of `symbol = { outer: { field: value } }`.
kind = "nested_dict_value_keys"
path = "api/app/config/deployment.py"
symbol = "ENV_PROFILES"
```

## Honest limits

The precise per-gate limits live in each gate's section. This section consolidates the cross-cutting
ones, plus limits that span rule types:

- **The Action, Turn, and Commit gates are seatbelts, not walls.** A file rewritten through `Bash`
  (`sed`, `>`, `python -c`) can dodge the Action write matcher; `git commit --no-verify` skips the
  commit gate; the turn gate is Claude-only and blocks once. The [write tripwire](#write-tripwire)
  and [turn gate](#turn-gate) narrow the `Bash` hole, but the authoritative catch is the
  [CI wall](#ci-wall).
- **Read-guard is prevention-only and accident-grade.** There is no result backstop for a read; a
  missed read is an irrevocable leak, and indirection defeats the tripwire. The OS sandbox is the real
  backstop.
- **`forbidden_field` catches the config-field flag shape** (a new `bool`-annotated field in a
  `class *Settings`). It does not catch a flag smuggled as `os.getenv("X")` (add a `forbidden_pattern`
  rule), a typed alias like Pydantic `StrictBool`, or a `Bash`-driven file rewrite.
- **Fail policy.** The Action hook fails open on infra errors and closed on a detector error
  on a matched file. CI fails closed on any error; the wall never passes on error.
- **Cursor / Codex / MCP are PREVIEW.** The code ships, but Cursor and Codex are not run in a live
  session and MCP is not tested against a live host; each is documented against the vendor's spec.
  The MCP tool is advisory and enforces nothing.

## Contributing

```bash
uv sync
uv run pytest              # hermetic: throwaway git repos + hook-JSON fixtures, no network
uv run ruff check
uv run combra-guard selftest
uv build
```

Zero runtime dependencies is a hard invariant (`pyproject.toml` `dependencies = []`). The hot path
is stdlib only (`tomllib`, `ast`, `difflib`, `urllib`, `subprocess`). Files stay under ~200 LOC and
one responsibility. When you add a rule type or an enforcement behavior, add a `selftest` golden case
and document what it does not catch. See [`docs/DESIGN.md`](docs/DESIGN.md) for the invariants
(trusted-side rule loading, the guarded control surface) and the phased build history.

Licensed under [Apache 2.0](LICENSE).
