Policy gate for coding-agent tool calls. One Rust binary in Claude Code's PreToolUse/PostToolUse hooks, configured in TOML.
A lictor was the Roman officer who walked ahead of a magistrate and enforced his orders on the spot. Same job here — walks in front of every tool call, stops the ones that shouldn't pass.
Designed by human, coded by Claude.
Agent permission systems are prefix matchers. I got tired of that:
- can't say "allow
git push, denygit push --force" echo ok && git commitwalks right past agit commitrule- deny is a silent wall — the agent doesn't know why, so it retries variants until something slips through
- zero control over output:
cargo testdumps 3 000 lines into the context, model forgets what it was doing, re-runs the suite because the output scrolled away
End state: you either click "approve" all day or turn the checks off. Lictor is the third option — a real policy engine in the hook layer.
- gates — commands parsed with tree-sitter-bash, every command in a chain judged individually: pipes, subshells,
$(...),bash -c "...",eval, loop bodies. Can't prove it statically → fail closed to a prompt. - talks back — deny reasons and warn hints are your words, handed to the agent verbatim. Blocked agent with a reason corrects in one turn; blocked agent without one brute-forces variants.
- rewrites — fix instead of block:
grep→rg(rewrite),mvof a tracked file →git mv(modules). Result is re-gated, so a rewrite can't smuggle past a ban. - gates file edits too — by path + content: what's added, what's deleted, what must be present, what's changed in place (edit rules).
- gates URLs — domain allowlists and extension denylists for
curl/wget/WebFetch, or reroute a fetch through a markdown proxy (web rules). Subagent prompts and outputs get regex rules too (agent rules). - mode-aware — one shared rule, different action per permission mode:
modes = { plan = "allow", auto = "deny" }; remap final decisions per mode, or flip to a deny-by-default allowlist (modes). - shrinks output — minify noisy CLIs, spill oversized output to a local cache; the model gets the tail + a retrieval command instead of 3 000 lines.
| agent runs | lictor decides |
|---|---|
echo ok && git commit -m wip |
deny — "Commits are manual…" (ban found inside the chain) |
bash -c "gi''t commit" |
deny — payload parsed, quote-splice resolved |
grep -r TODO src/ |
rewrite → rg TODO src/, auto-approved |
mv src/a.ts src/b.ts |
rewrite → git mv src/a.ts src/b.ts (file is git-tracked) |
curl -sSL https://docs.rs/regex | jq . |
allow — every URL on an allowed domain, statically verified |
wget https://github.com/x/archive.zip |
deny — "no downloading archives…" (extension beats domain allow) |
cat ~/.zshrc |
ask — path outside the project jail |
git $ACTION |
ask — dynamic arg defeats the ban check, fail closed |
cargo test (3 400 lines) |
output spilled to kv; model sees the tail + a kv get note |
brew install sladg/tap/lictorOr with Rust 1.85+: cargo install --git https://github.com/sladg/lictor.
lictor init --write # starter lictor.toml + the hooks snippet for settings.json
lictor check # validate config (a broken config fails closed: everything asks until this passes)
lictor check -- <command...> # dry-run one command through the exact hook pipeline
lictor check --mode auto -- … # dry-run as a specific permission mode
lictor gain # audit-log summary: decisions + tokens/bytes savedlictor init prints the hooks block to paste into .claude/settings.json (or ~/.claude/settings.json): PreToolUse for Bash, the file-edit tools, WebFetch (web rules), and Task (agent rules); PostToolUse for output minify and agent-output rules. Optional companions: kv for spill (brew install AmrSaber/tap/kv) and rtk for wrap. Without kv, spill falls back to plain truncation — nothing is lost. wrap rules rewrite unconditionally, so only write them for tools you actually have installed.
Dry-run anything before trusting it:
$ lictor check -- 'echo ok && git commit -m wip'
lictor: deny — Commits are manual — propose a commit message and wait for the user.
$ lictor check -- 'seq 1 900'
lictor: allow
lictor: output shrunk 3492 → 267 bytes
Everything lives in lictor.toml. Configs chain — user file (~/.config/lictor/config.toml, or $XDG_CONFIG_HOME), then .claude/lictor.toml / lictor.toml in every directory from the filesystem root down to cwd — so a monorepo root config applies in every package. Rule lists concatenate and deeper files win per key. A deeper [[bash]] rule spelling the identical match replaces the earlier file's rules for that pattern — so a project can relax a user-level ban (deny → ask) as well as tighten it; patterns the project doesn't redefine keep the user verdict, and within one file same-pattern rules stack as before.
[settings]
catalogs = ["recommended"] # ~150 commands gated in one line
spill_lines = 800 # oversized output -> kv cache + retrieval note
jail = "ask" # paths outside the repo -> prompt
[[bash]]
match = "git commit*" # word-wise glob, checked against every command in a chain
action = "deny" # allow | deny | ask | rewrite | warn | log | skip
reason = "Commits are manual — propose a commit message and wait for the user."
[[edit]]
paths = ["**/*.ts", "**/*.tsx"]
pattern = "as (any|never|unknown)" # regex over written content
action = "deny"
hint = "No type assertions — fix the type design instead."
[[web]]
domains = ["docs.rs", "github.com", "*.github.com"]
action = "ask" # one shared rule…
modes = { plan = "allow", auto = "deny" } # …different action per permission modeFull annotated config: examples/lictor.toml. Every command each catalog covers: src/catalogs/builtin.toml.
One short doc per feature — what it does, config, and exactly what happens when the agent runs something:
| Area | Docs |
|---|---|
| Actions | allow · deny · ask · warn · rewrite · log · skip |
| Rule types | catalogs · edit rules · path rules · web rules · agent rules · retries |
| Guards | jail · strikes · detectors · fail-closed · modes |
| Output & context | minify · spill |
| Helpers | modules · activate |
Worked policies for common scenarios — docs/use-cases/:
| Recipe | You get |
|---|---|
| Read-only auto-approve | ~150 read/query commands run without prompts; anything mutating still asks |
| Git write-ban | agent reads history freely; commit/stash/reset/checkout/push stay manual, chain-proof |
| TypeScript discipline | no eslint-disable/@ts-ignore/as any, project scripts over npx tsc, quiet lint output |
| Protect docs & tests | edits that delete doc-comments or test cases warn or bounce |
| Markdown frontmatter | every .md the agent writes carries created_at:/updated_at:, bumped on each edit |
| Search discipline | grep/find/ack denied with a hint that teaches rg |
| Temp & scratch hygiene | /tmp banned in every form (args, redirects, env values); big output cached in kv |
Defense-in-depth against a sloppy or manipulated agent, not a sandbox — no process isolation. Lictor decides what the permission system sees; the permission prompt stays the last line. Details: fail-closed.
