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_aliasesbridge vendor tool naming, andcompileexports 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
pip install . # from a checkout; adds the `riskgate` console scriptNo third-party packages are pulled in. Python ≥ 3.9. You can also run
it in place: python3 -m riskgate ….
| 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-cliIt 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, sopromptstays silent there and the same command is also installed onPermissionRequest, whereallowskips the approval prompt,denyblocks, 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 —
promptabstains 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 itrun_shell_command. Writetool:patterns against the tool names of the agents you run — or droptool:and match oncmd_regexalone for portability.
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| 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: safeThe 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.
- Tool calls carrying a
commandstring are split into shell segments (quote-aware:|,&&,||,;, newlines;2>&1-style redirections stay inside their segment). - Every segment is judged independently; the most severe verdict
across segments wins (
deny>prompt>allow). One safe segment cannot smuggle through an unsafe neighbor. - 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).
- Anything unmatched falls back to
defaults. - 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 atprompt, because the substituted text is invisible to matching.
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).
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.
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 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 TOMLCompilation 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.
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.
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.jsonEmits 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.
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.
- 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+testmake 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: allowdraws a lint warning; a missing/broken policy degrades to the agent CLI's own permission flow.
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.
All milestones from the original plan are shipped:
v0.2 — codex + gemini-cli adapters,✅installcommand, audit format freeze,within:path-boundary matcherv0.3 — policy spec (docs/POLICY.md),✅compile --agent(Claude permissions / Codex.rules/ Gemini policy TOML), presets, tool aliasesv0.4 —✅compile --sandbox seatbeltwith golden-file regression and live kernel-enforcement testsv0.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.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.