Skip to content

Repository files navigation

Lictor

A Roman lictor bearing the fasces — the rods-and-axe mark of enforcing authority

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.


Why

Agent permission systems are prefix matchers. I got tired of that:

  • can't say "allow git push, deny git push --force"
  • echo ok && git commit walks right past a git commit rule
  • deny is a silent wall — the agent doesn't know why, so it retries variants until something slips through
  • zero control over output: cargo test dumps 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.

What it does

  • 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 backdeny 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: greprg (rewrite), mv of 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 outputminify 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/ rewriterg TODO src/, auto-approved
mv src/a.ts src/b.ts rewritegit 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

Quick start

brew install sladg/tap/lictor

Or 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 saved

lictor 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

Configuration

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 (denyask) 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 mode

Full annotated config: examples/lictor.toml. Every command each catalog covers: src/catalogs/builtin.toml.

Features

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

Recipes

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

Threat model

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.

License

MIT

About

A policy gate for coding-agent tool calls. One Rust binary in Claude Code's `PreToolUse`/`PostToolUse` hooks, configured in TOML.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages