Skip to content

Latest commit

 

History

History
254 lines (210 loc) · 13.1 KB

coding-agents.md

File metadata and controls

254 lines (210 loc) · 13.1 KB

Guard a coding agent

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.

Install

pip install "git+https://github.com/SourceShift/InterLock.git"
interlock install-hook --for claude-code      # or: codex, cursor, all
interlock doctor                              # check it took

install-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.

What happens on each call

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"]
Loading

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.

The built-in policy

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.

Change the policy

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.json

lists 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.

Per-project rules

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 / MISSING

An 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).

See what it did

interlock report ~/.interlock/receipts.jsonl --key-file ~/.interlock/receipts.key
interlock report ~/.interlock/receipts.jsonl --since 24h --monitor

Each 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.

Try it before it blocks

interlock install-hook --for claude-code --monitor

Nothing 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.

When something goes wrong

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).

Limits

  • 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 cd whose target isn't literal. cd -, a target holding a variable, glob, brace, $(...) or backtick, and a cd with more than one operand leave the directory where it was, so cd "$DIR" && cat .interlock/receipts.key is judged from the session's working directory. pushd, CDPATH and env -C aren't followed either;
    • a recursive read of a parent directory (tar czf x.tgz ~, or Grep over ~);
    • 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 ~/.interlock as a separate word.

  • 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.