Put interlock in front of every tool call Claude Code, Codex or Cursor makes: shell commands, file reads and writes, patches, web fetches and MCP tools. The same policy language and detectors you use in your own agents decide each call, through the agent's own hook system.
pip install "git+https://github.com/SourceShift/InterLock.git"
interlock install-hook --for claude-code # or: codex, cursor, all
interlock doctor # check it tookinstall-hook adds interlock hook --agent <name> to the agent's hook settings
(~/.claude/settings.json, ~/.codex/hooks.json, ~/.cursor/hooks.json). It
keeps every other entry, backs the file up to <file>.interlock-bak first, and
replaces any earlier interlock entry, so running it twice is safe. It also
creates ~/.interlock/ with:
| File | What it is |
|---|---|
policy.json |
Your policy. By default {"extends": "builtin:coding-agent"} |
receipts.jsonl |
The receipt log, one line per decision, chained and MAC-signed |
receipts.key |
The log's MAC key (mode 0600) |
trusted.json |
Project overlays you approved (see below) |
reviews.db |
The review queue, created by the first hook call |
Restart running agent sessions so they load the hooks. Use --dry-run to see the
change without writing anything, and --uninstall to remove it.
For Codex, install-hook also sets [features] hooks = true in
~/.codex/config.toml (only when that edit is unambiguous; otherwise it tells you
what to change), and Codex then asks you to trust the new hooks: open Codex, run
/hooks, press t.
flowchart LR
agent(["Claude Code / Codex / Cursor"]) -->|"hook payload (stdin)"| hook["interlock hook"]
hook --> policy{"your policy<br/>+ trusted overlay"}
policy -->|allow| run["tool runs"]
policy -->|block| no["refused, with the reason"]
policy -->|ask| prompt["the agent's own<br/>approval prompt"]
policy -->|rewrite| changed["tool runs with<br/>rewritten input"]
run --> result{"result rules"}
result -->|"injection / secrets"| scrubbed["output withheld<br/>or redacted"]
Each agent's protocol can express a different part of this, and the hook never claims more than the agent can do:
| Verdict | Claude Code | Codex | Cursor |
|---|---|---|---|
| Allow | silent: Claude's own permission rules still apply | silent | allow |
| Block | deny + reason |
exit 2 + reason | deny + reason |
| Escalate (ask a human) | the native permission prompt | refused: Codex hooks can't open a prompt | the native prompt; refused for file reads |
| Rewrite the input | updatedInput |
refused: can't be applied | refused |
| Block a result | output withheld (updatedToolOutput) |
flagged only | no result hook |
| Rewrite a result | redacted output (updatedToolOutput) |
flagged only | no result hook |
"Refused" means the call is denied with a message that says why: a rewrite that can't be applied, or a question that can't be asked, must not quietly turn into "run it anyway".
For an escalation the agent can't ask about (Codex, Cursor file reads), the
hook files a review in the review queue and refuses with its id:
"review r-1a2b3c4d is pending; a human must run interlock reviews approve r-1a2b3c4d, then retry this identical call". After you approve, the agent's
identical retry runs once. --reviews always sends Claude Code's escalations
through the queue as well, instead of its native prompt; --reviews off
disables the queue. The receipt records both verdicts: policy_verdict is what your
policy said and verdict is what was enforced.
An allow never auto-approves. The hook only adds restrictions on top of the agent's own permission system.
Claude Code's rewrite and output-replacement paths follow the Claude Code hooks
reference (checked 2026-10-13). They have not been exercised against a live
Claude Code here, so confirm them on your version: ask the agent to cat a file
containing API_TOKEN=abcdef123456 and check the output it reports is redacted.
builtin:coding-agent (run interlock policy show builtin:coding-agent for the
full text):
| Rule | Verdict | Catches |
|---|---|---|
self-protect-files |
block, locked | any read or write under ~/.interlock (the signing key, receipt log, review queue and policy) by Read, Write, Edit, MultiEdit, NotebookEdit, apply_patch, or Grep/Glob pointed there |
self-protect-commands |
block, locked | interlock trust/install-hook/reviews approve, the console's write API, and any shell command that names a path under ~/.interlock. It matches on the target path, not the program: cat, a database client, an interpreter one-liner, a redirect, tee, cp and install all count |
unreadable-commands |
ask, locked | a shell command the guard could only partly read: an unclosed quote, or nesting deeper than it follows. What it couldn't read may be what the other rules stop, so it is asked about; Codex, which can't ask, refuses it. A partly read command whose readable part is blocked is still blocked |
destructive-commands |
block, locked | recursive delete or chmod of /, ~ or a system directory, raw disk writes, formats, however they're spelled |
fetch-and-execute |
block | curl ... | sh, bash -c "$(curl ...)", bash <(curl ...) |
secret-files |
block | reading or writing ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.netrc, ~/.npmrc and similar, with a file tool or a Grep/Glob search |
secret-file-commands |
block | a shell command whose words name one of those paths: cat ~/.ssh/id_rsa, grep -r TOKEN ~/.aws, curl --netrc-file $HOME/.netrc, and redirects into one |
git-credentials |
block | git config --get ...password, git config --list, git credential fill |
force-push |
ask | force-push to a default branch, or with no branch named |
push-delete, discard-work |
ask | deleting a remote branch; reset --hard, clean -f, checkout ., branch -D |
publish |
ask | npm/cargo/twine/gem/poetry/uv publish, docker push, gh release create |
shell-init-write, scheduled-jobs |
ask | writing ~/.zshrc and friends; crontab, launchctl load, systemctl enable |
privilege |
ask | sudo, doas, su |
env-files |
ask | reading .env (but not .env.example) |
agent-hook-settings |
ask | editing the agent's own hook settings, through a file tool or a patch body |
agent-hook-settings-commands |
ask | a shell command that writes one of those files, such as a redirect into ~/.claude/settings.json |
result-injection |
withhold | prompt injection in tool output |
result-secrets |
redact | credentials in tool output |
Shell rules match what a command does, not its text. rm -fr ~,
/bin/rm -Rf /, bash -c 'rm -rf /', sudo rm -rf / and
python3 -c 'os.system("rm -rf /")' are the same act to them (see
parsed commands).
self-protect-commands checks every word that could name a file: each
argument, each --opt=VALUE or -oVALUE, each output redirect, and the
string literals inside a script interpreter's code, a database client's query,
or an awk/sed program. It expands ~, $HOME, braces and globs, resolves
a relative path against the session's working directory, removes ..
segments, and ignores case before it compares a path with ~/.interlock.
So cat ~/.interlock/receipts.key,
sqlite3 :memory: "ATTACH '/Users/me/.interlock/reviews.db' AS r" and
cat ~/.inter*/receipts.key are refused, while cat ~/.interlock-notes.md
and a project's own .interlock/policy.json are not.
A literal cd earlier in the same command moves the directory a later
relative path resolves against: cd ~ && cat .interlock/receipts.key is
refused, cd /tmp && cat .interlock/receipts.key is not. A cd inside a
subshell, pipeline or sh -c counts for the rest of the command, and a
$(...) is checked against every directory the command later moves to, so
the guess errs toward refusing.
Edit ~/.interlock/policy.json. Keep the pack and switch rules off, or add your
own:
{
"version": 1,
"extends": "builtin:coding-agent",
"disable": ["privilege", "env-files"],
"rules": [
{"id": "no-prod-kubectl", "verdict": "block", "on": "Bash",
"reason": "no kubectl against production",
"when": {"arg": "command", "matches": "kubectl\\b.*--context[= ]prod"}},
{"id": "ask-before-terraform", "verdict": "escalate", "on": "Bash",
"reason": "terraform changes infrastructure",
"when": {"arg": "command", "matches": "^terraform (apply|destroy)"}}
]
}Locked rules stay on: a disable naming one is ignored and reported.
interlock policy check ~/.interlock/policy.jsonlists the rules, the locked ones, and any rule skipped as invalid, with the
reason ("Bash has no argument cmd (did you mean command?)"). The language is
in declarative policies.
A repository can carry .interlock/policy.json. The hook finds it from the
session's working directory, but ignores it until you trust it:
cd ~/src/payments
interlock trust add # shows what it adds, asks, records its hash
interlock trust list # ok / CHANGED / MISSINGAn overlay can only add rules and widen lists the base marks widenable. It
can't disable, replace or relax anything. Editing the file revokes trust until
you approve it again, so an agent that rewrites the overlay gets nothing. The
agent can't run interlock trust itself (the locked self-protect-commands
rule).
interlock report ~/.interlock/receipts.jsonl --key-file ~/.interlock/receipts.key
interlock report ~/.interlock/receipts.jsonl --since 24h --monitorEach tool call's receipts share a call_id (the agent's tool_use_id when it
sends one), so you can follow a call from its policy decision through the
agent's approval prompt to its result.
interlock install-hook --for claude-code --monitorNothing is blocked. Every decision is recorded, and Claude shows what would have
happened. After a day, interlock report ... --monitor lists what would have
been stopped. Re-run install-hook without --monitor to enforce.
interlock doctor warns while a hook is in monitor mode.
Any error in the hook, whether unreadable input, a policy that won't load, or a
crash, denies the call and tells the agent to have you run
interlock doctor. --on-error allow lets calls through instead, with a loud
warning on each one; use it only if you'd rather keep working unguarded than
stop.
The installed command runs the Python and the interlock package that installed
it, isolated from the agent's working directory, so a secrets.py or an
interlock/ in the project cannot stand in for interlock or the standard
library. If the hook cannot start at all (interlock was moved or is
half-installed), it still denies the call in the agent's own format and names
the failure. Re-run install-hook after moving interlock.
interlock doctor checks that each agent's hook is installed and that the
Python it runs still exists (a deleted virtualenv would break every call).
-
Hooks are cooperative. They see the tools the agent routes through its hook system. They are not an OS boundary: run the agent in a sandbox (or a container) if it must be contained.
-
Command parsing is best-effort. ANSI-C quoting (
$'\x72\x6d') and a base64 payload piped into a shell are decoded, and a script fetched to a literal path and run later is followed; a payload encoded another way does get past it. The locked rules and the sandbox are the backstop, not the parser. A command it can only partly read (an unclosed quote, nesting deeper than it follows) is never passed as harmless: it is asked about, and refused where the agent can't ask (Codex). -
Self-protection sees the paths a command names, not the ones it builds. It does not catch:
- a path assembled at run time, such as a shell variable set earlier or
Path.home() / ".interlock"; - a file read through an input redirect (
cat < ~/.interlock/receipts.key); - a relative path after a
cdwhose target isn't literal.cd -, a target holding a variable, glob, brace,$(...)or backtick, and acdwith more than one operand leave the directory where it was, socd "$DIR" && cat .interlock/receipts.keyis judged from the session's working directory.pushd,CDPATHandenv -Caren't followed either; - a recursive read of a parent directory (
tar czf x.tgz ~, orGrepover~); - a symlink made earlier.
It errs the other way for here-doc bodies, which the parser reads as commands: a commit message built with
$(cat <<EOF ...)is refused if one of its lines has a path under~/.interlockas a separate word. - a path assembled at run time, such as a shell variable set earlier or
-
Codex can't ask or rewrite at
PreToolUse, and can't withhold tool output. Cursor has no result hook. -
Each call starts a Python process. For lower latency, run a sidecar and install with
--sidecar PATH.