Skip to content
ictechgyPublic

About

One YAML policy for what your coding agent may do: allow, prompt, or deny every tool call across agent CLIs. Deterministic regex/glob matching, JSONL audit log, zero dependencies, fail-safe.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

riskgate

Like CI declares what runs in GitHub Actions YAML, riskgate declares what your coding agent may do — in one YAML policy, for any agent CLI.

riskgate judges every tool call an agent makes — auto-approve it, force a human prompt, or deny it — from a single declarative policy:

  • deterministic: regex + glob matching only, no LLM in the decision path — verdicts are reproducible and unit-testable
  • portable: one policy drives every supported agent CLI through thin adapters, tool_aliases bridge vendor tool naming, and compile exports static vendor configs
  • auditable: every decision lands in a local JSONL log with the matching rule and reason
  • zero dependencies: pure Python stdlib — a hook this hot path should be cheap to install and impossible to break with dependency drift
  • fail-safe: any error inside riskgate means the agent CLI falls back to its own permission flow (which asks the user). A riskgate bug can add prompts; it can never silently approve.

The policy language is specified in docs/POLICY.md (draft standard, schema 1). Ready-made starting points live in presets/: web, mobile, oss-maintainer.

$ riskgate check riskgate.yaml --command 'git push --force origin main'
verdict: deny
rule:    no-force-push (risk: destructive)
reason:  force push / remote branch deletion

Install

pip install .          # from a checkout; adds the `riskgate` console script

No third-party packages are pulled in. Python ≥ 3.9. You can also run it in place: python3 -m riskgate ….

Supported agents

agent hook event(s) allow prompt deny
Claude Code / ZCode PreToolUse permissionDecision: allow ask deny
Codex CLI (≥ ~0.125) PreToolUse + PermissionRequest allow silence → human prompt deny (blocks at PreToolUse)
Gemini CLI BeforeTool {"decision": "allow"} silence → Gemini's approval flow {"decision": "deny"}

riskgate install wires the hook in for you:

riskgate install --agent claude-code --policy /absolute/riskgate.yaml
riskgate install --agent codex        # also reminds you to trust via /hooks
riskgate uninstall --agent gemini-cli

It edits the agent's settings file idempotently, preserves every other key, and keeps a .riskgate-bak copy when modifying an existing file.

Manual wiring (any agent sharing the Claude hooks schema):

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "*",
        "hooks": [ { "type": "command",
                     "command": "riskgate hook --policy /absolute/riskgate.yaml" } ] }
    ]
  }
}

Without --policy the hook discovers one: $RISKGATE_POLICY, then ./riskgate.yaml, then ~/.config/riskgate/riskgate.yaml. If no policy exists it stays silent and the agent CLI applies its default flow.

Adapter notes, learned from vendor docs (2026-09):

  • Codex has no ask decision on PreToolUse, so prompt stays silent there and the same command is also installed on PermissionRequest, where allow skips the approval prompt, deny blocks, and silence lets the human prompt continue. Destructive commands the sandbox would have run without asking are still caught by the PreToolUse deny. Codex requires trusting the hook once via /hooks (content-hashed).
  • Gemini CLI hooks likewise only allow/deny — prompt abstains and Gemini's own approval flow decides. Note Google transitioned the hosted Gemini CLI service to Antigravity CLI in mid-2026; the open-source gemini-cli repo remains active (verified against v0.58 docs) — Antigravity compatibility is unverified.
  • Tool names are the vendor's: Codex calls its shell tool Bash (same as Claude Code), Gemini calls it run_shell_command. Write tool: patterns against the tool names of the agents you run — or drop tool: and match on cmd_regex alone for portability.

The policy language

One YAML file — see riskgate.yaml for a documented, tested example.

version: 1
defaults: prompt                 # allow | prompt | deny for unmatched calls

tool_aliases:                    # one policy, any agent
  shell: [Bash, run_shell_command]

risk_matrix:                     # risk level -> verdict (yours to define)
  destructive:   deny
  irreversible:  prompt
  side_effect:   prompt
  safe:          allow

rules:
  - id: no-force-push
    match: { tool: 'Bash', cmd_regex: '\bgit\s+push\b.*--force\b' }
    risk: destructive             # a matrix level…
    reason: force push            # …or a direct verdict: deny
  - match: { tool: 'Read', path_glob: '**/.env*' }
    risk: deny

audit: ./riskgate-log.jsonl      # JSONL, local only, relative to the policy

tests:                           # policy-as-code, run in CI
  - name: force push is denied
    tool: Bash
    input: { command: 'git push --force origin main' }
    expect: deny
    rule: no-force-push          # optional: assert which rule fired

Matchers

field applies to semantics
tool any rule fnmatch pattern on the tool name (Bash, Read, Web*); omitted = any tool
cmd_regex shell commands re.search against each segment of the command field, and against the full command string
path_glob path arguments fnmatch against the raw value and the cwd-resolved absolute path of file_path / path / notebook_path
host_in / host_not_in URL arguments hostname (from url / uri) allow/deny-list, case-insensitive
within / within_not shell commands (require cmd_regex) path-operand boundary check — see below

within / within_not port the prototype's workspace-boundary guard:

rules:
  - id: ask-rm-outside
    match: { tool: 'Bash', cmd_regex: '\brm\b', within_not: 'cwd' }
    risk: irreversible
  - id: allow-rm-inside
    match: { tool: 'Bash', cmd_regex: '^rm\b', within: 'cwd' }
    risk: safe

The boundary is fixed at the cwd the judgment starts from; operand resolution follows cd segments (cd /tmp && rm x does not inherit the boundary's trust); wildcards resolve to their parent directory (rm -rf sub/* is judged by sub/); the boundary root itself is not "inside" (rm -rf . never auto-approves); and anything unresolvable — shell variables like $HOME, unparsable quoting, an untrackable cd — fails closed to within_not. within = every operand strictly inside; within_not = at least one operand not provably inside.

Within one match, fields are ANDed. A rule matches if any segment matches; combine rules for anything fancier. cmd_regex cannot be combined with path_glob/host_* in a single rule — split it in two; lint enforces this.

How a verdict is computed

  1. Tool calls carrying a command string are split into shell segments (quote-aware: |, &&, ||, ;, newlines; 2>&1-style redirections stay inside their segment).
  2. Every segment is judged independently; the most severe verdict across segments wins (deny > prompt > allow). One safe segment cannot smuggle through an unsafe neighbor.
  3. Among rules matching the same unit, the most severe verdict wins and the earliest such rule is cited (order in the file is your priority).
  4. Anything unmatched falls back to defaults.
  5. Engine-level safety caps (not policy — learned from the prototype this core was extracted from): a segment containing command substitution ($(…), backticks, <(…)) or unbalanced quotes can never be auto-allowed — its verdict is capped at prompt, because the substituted text is invisible to matching.

Escaping tip

Write regexes in single quotes (cmd_regex: '\bgit\s+push\b') — single-quoted YAML keeps backslashes literal. Double quotes apply YAML escapes ("\b" is a backspace).

YAML subset

The stdlib parser accepts the subset policies need: block mappings/sequences, single-line flow collections, quoted/plain scalars, comments, ints/bools/null. Anything else fails lint with a line number instead of being silently misparsed.

CLI

riskgate lint policy.yaml     # validate schema, regex, references
riskgate test policy.yaml     # run the policy's declared cases (CI-able)
riskgate check policy.yaml --command 'git push'
riskgate check policy.yaml --tool Read --input '{"file_path": ".env"}' -v
riskgate hook [--agent A] [--policy p]   # hook protocol on stdin
riskgate install --agent codex --policy policy.yaml
riskgate uninstall --agent claude-code
riskgate compile policy.yaml --agent gemini-cli [-o FILE] [--strict]

Compile: policy → vendor config

compile exports the policy as a static vendor permission config — for machines where you want pre-approvals without a running hook:

riskgate compile riskgate.yaml --agent claude-code   # permissions JSON fragment
riskgate compile riskgate.yaml --agent codex         # .rules Starlark
riskgate compile riskgate.yaml --agent gemini-cli    # policy TOML

Compilation is conservative by proof: a rule is emitted only when the vendor entry decides a provable subset of the rule (anchored literal prefixes — ^npm run.*, ^(ls|cat).*, ^git status$). Anything else — \b/lookahead/\s tails, within boundaries, path rules — is skipped with a warning (use --strict to fail CI on skips). Static configs don't reproduce the engine caps (segment splitting, substitution cap), so keep the hook installed; compile is the portable pre-approval layer, not a replacement. Codex even gains a prompt decision this way (decision = "prompt"), and Gemini TOML gets ask_user.

Enforcement: compile --sandbox

The same policy, compiled one level deeper — approval and enforcement from one declaration. Hooks ask humans about prompt; the kernel enforces deny:

riskgate compile riskgate.yaml --sandbox seatbelt --root /path/to/project
# -> riskgate.sb; run anything under it (macOS):
sandbox-exec -f riskgate.sb <command…>

riskgate compile riskgate.yaml --sandbox bwrap --root /path/to/project
# -> POSIX shell wrapper (Linux); run anything under it:
./riskgate.bwrap.sh <command…>

riskgate compile riskgate.yaml --sandbox seccomp
# -> OCI-style seccomp JSON: docker/podman --security-opt seccomp=…
layer write boundary (within) denied paths (path_glob deny) network (deny host rules)
seatbelt (macOS) (deny file-write*) + allow root (deny file-read*/file-write* (regex …)) (deny network*)
bwrap (Linux) --ro-bind / / + --bind root ✗ not expressible (mount granularity) --unshare-net
seccomp (Linux) ✗ ✗ ERRNO on outbound socket syscalls

cmd_regex denies (force-push, pipe-to-shell) are not expressible in any sandbox without blocking whole binaries — they stay hook-enforced and every compiler says so. When a layer can't express a deny precisely, it denies more, never less.

Verified live: the seatbelt suite runs real sandbox-exec on macOS and asserts kernel-level denial of .env reads, denied-path writes, and writes outside the root. The Linux outputs can't run on this project's dev machine — they are pinned by golden-file regression (policy ↔ profile) and structural checks instead. Note Apple considers sandbox-exec an implementation detail; treat generated profiles as one enforcement layer, not the only one.

Observability: export the audit log

The audit log is the join point with the observability layer — no dashboard of our own (the viewer is an existing asset):

riskgate export riskgate-log.jsonl -o decisions.perfetto.json

Emits the agent2perfetto IR (Chrome Trace Event JSON): every decision an instant on a decisions thread, cumulative allow/prompt/deny counters, evidence in the args. Drag it into ui.perfetto.dev (parsed in-browser, never uploaded) — riskgate decision traces and agent session traces compose on one timeline. The exported trace passes agent2perfetto's own validator, which the test suite runs against when that checkout is present.

check -v prints the per-segment judgment trace — useful when a verdict surprises you.

Audit log

One JSON line per decision — schema 1, frozen; local only (riskgate never transmits it). Future emitters (the agent2perfetto export planned for v0.5) consume this same shape.

{"schema": 1, "ts": "2026-09-06T01:16:04+0900", "event": "decision",
 "adapter": "claude-code", "tool": "Bash",
 "input": {"command": "git push --force"}, "verdict": "deny",
 "rule": "no-force-push", "risk": "destructive",
 "reason": "force push / remote branch deletion", "cwd": "/proj", "session_id": "…"}

Note: records carry the full tool input, commands included — a command like curl -H "Authorization: …" logs its header verbatim. Treat the audit log as sensitive (same care as shell history), and rotate or shred it when it leaves the machine. There is no built-in size cap or rotation yet.

Design principles

  • Enforcement, not detection. Prompt-injection detection, DLP, and output screening are out of scope — that market is saturated. riskgate is the policy layer that decides who may do what; detectors, when present, are upstream producers of risk signals.
  • Deterministic judgments. No model calls in the decision path. Latency stays flat, verdicts are testable, and there is no prompt-crafted bypass of a regex.
  • Policy is code. lint + test make a policy reviewable and CI-enforceable, like a GitHub Actions workflow.
  • Local only. Audit logs are files. There is no telemetry, no network I/O at all.
  • Safe by default. Unmatched → prompt; defaults: allow draws a lint warning; a missing/broken policy degrades to the agent CLI's own permission flow.

Project layout

riskgate/
├── yamlio.py            # stdlib YAML-subset parser
├── policy.py            # schema, loading, validation
├── matchers.py          # tool / cmd_regex / path_glob / host / within
├── engine.py            # segmentation + verdict aggregation (pure)
├── audit.py             # JSONL audit log, schema 1 (best effort)
├── install.py           # install / uninstall into agent settings
├── compile.py           # policy -> vendor config (sound prefixes only)
├── seatbelt.py          # policy -> macOS sandbox-exec profile (deny layer)
├── linuxsb.py           # policy -> bwrap wrapper / seccomp JSON
├── emit.py              # audit log -> agent2perfetto IR
├── cli.py               # lint / test / check / hook / install / compile / export
├── adapters/
│   ├── base.py          # shared stdin→judge→audit pipeline
│   ├── claude_code.py   # Claude Code / ZCode PreToolUse
│   ├── codex.py         # Codex PreToolUse + PermissionRequest
│   └── gemini_cli.py    # Gemini CLI BeforeTool
├── docs/POLICY.md       # the policy language spec (draft standard)
└── presets/             # web / mobile / oss-maintainer starting points
tests/
├── …                    # unit + CLI + adapter + install + compile suites
├── fixtures/parity-policy.yaml
├── fixtures/sandbox/         # policy <-> profile golden pairs (all 3 sandboxes)
└── test_prototype_parity.py  # differential tests vs the prototype hook

Run the suite: python3 -m unittest discover -s tests.

The core was extracted from a battle-tested single-file hook (~/.zcode/hooks/risk_gate.py). test_prototype_parity.py runs both implementations over the same commands — including the safety-critical direction: riskgate never auto-approves what the prototype does not. One deliberate divergence: the prototype auto-allows rm -rf $HOME/x (resolving the literal unexpanded string inside the boundary); riskgate fails closed — the shell would expand $HOME outside.

Roadmap

All milestones from the original plan are shipped:

  • v0.2 — codex + gemini-cli adapters, install command, audit format freeze, within: path-boundary matcher ✅
  • v0.3 — policy spec (docs/POLICY.md), compile --agent (Claude permissions / Codex .rules / Gemini policy TOML), presets, tool aliases ✅
  • v0.4 — compile --sandbox seatbelt with golden-file regression and live kernel-enforcement tests ✅
  • v0.5 — compile --sandbox bwrap/seccomp (Linux), agent2perfetto audit emitter ✅

Post-1.0 candidates: more agents (each is one adapter module), policy schema 2 driven by adoption feedback (e.g. named within scopes, per-rule compile hints), CI GitHub Action for riskgate lint/test.

Contributing & changelog

CONTRIBUTING.md documents the project invariants (zero dependencies, fail-safe direction, deterministic judgments, conservative compilation) and good first contributions — new agent adapters, presets, and compile targets all follow documented patterns. Releases are listed in CHANGELOG.md.

License

MIT

About

One YAML policy for what your coding agent may do: allow, prompt, or deny every tool call across agent CLIs. Deterministic regex/glob matching, JSONL audit log, zero dependencies, fail-safe.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages