Skip to main content

Config-driven policy gate for AI coding agents: in-editor hook, git commit gate, and CI diff wall.

Project description

combra-guard

CI License: Apache 2.0 PyPI version Python versions

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) 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) write result vs turn-start baseline Claude only write seatbelt, catches Bash writes by result
Commit (git pre-commit) staged writes on git commit any agent + human write seatbelt, --no-verify-bypassable
Merge/CI (check-diff) 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 for the architecture and per-phase build notes.

Install

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

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

Quickstart

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 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 and appends guard.remote.toml to .gitignore.

To wire the Action hook by hand instead:

{ "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 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

[[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.logs 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). Wire it as a required status check with a single step (full workflow in the CI wall section):

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, 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

[[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 for the limits.

5. Pull governed rules from Combra

Manage rules centrally and pull them into each repo:

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:

[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, and Bash against the 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.

# .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:

# .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:

[[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).

[[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"
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 (sees the resulting diff) and the 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, 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
{
  "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.
  • 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
# >>> 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), 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:

{ "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 §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. 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.

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.

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:

[[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 and turn gate narrow the Bash hole, but the authoritative catch is the 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

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 for the invariants (trusted-side rule loading, the guarded control surface) and the phased build history.

Licensed under Apache 2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

combra_guard-0.1.1.tar.gz (143.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

combra_guard-0.1.1-py3-none-any.whl (84.8 kB view details)

Uploaded Python 3

File details

Details for the file combra_guard-0.1.1.tar.gz.

File metadata

  • Download URL: combra_guard-0.1.1.tar.gz
  • Upload date:
  • Size: 143.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for combra_guard-0.1.1.tar.gz
Algorithm Hash digest
SHA256 3c4eede3864985f86f6f8c5a3fc57ae838cad2bead5f2a26c115d74e91994cca
MD5 e4ac9bc1173c2086b7dcfe7c629c2b92
BLAKE2b-256 66b81bd10e1e5a1eb0c36bccf3ff5ec4c99ae153277a2fada3565191e3822dc0

See more details on using hashes here.

File details

Details for the file combra_guard-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: combra_guard-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 84.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for combra_guard-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 da216e754ede584e2a9bd738bb8cbc8a6dca98647be9b70557f6bc4d44cbcff1
MD5 3942264e960d8c1ec20e41108821d75a
BLAKE2b-256 ad43c666d2fedfc64ef725ec5fd679e4b6747a7943f6d2b027ca7df25a7014d4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page