DEV Community

nomos
nomos

Posted on AI-assisted

Deny rules are not a boundary: building a policy hook for Claude Code and Codex

Coding agents such as Claude Code and Codex run with your shell and your credentials. Between October 2025 and September 2026, the Claude Code, Codex, Cline, and Gemini CLI issue trackers collected seventeen reports of an agent deleting files outside the project it was working in. Most were not dramatic. A cleanup step expanded a path or a variable into something larger than intended, and a legitimate task deleted the wrong tree.

The usual defenses have known gaps:

  • Permission prompts get approved on reflex. Anthropic reports that Claude Code users approve 93% of permission prompts and calls the result approval fatigue (source).
  • String-matched deny rules are easy to step around. Claude Code's permissions documentation says a Bash deny rule "isn't a security boundary around the program" (source), because /bin/rm, bash -c, and git -C . push do not look like the string you wrote.

I wanted something in between: a decision on every tool call, made by rules I keep in Git, that still holds when the agent runs with permissions bypassed. That became Nomos, an open-source PreToolUse hook written in Go.

Parse, don't match

A hook receives the tool call as JSON before it runs. For a shell command, Nomos parses it the way a shell would, without running it:

  • It splits &&, ||, ;, and pipes into simple commands and decides each one. Deny wins across the whole chain.
  • It unwraps bash -c, sh -lc, pwsh -Command, and cmd /c, and reduces /bin/rm to rm.
  • It strips git's harmless global options, so git -C . push is decided as git push.
  • It resolves every path argument against the workspace, both as written and through symlinks, from every directory the command could be running in. After cd nope ; cat ../secret.txt, a shell keeps going if the cd fails, so the read is checked from both directories.
  • It refuses what it cannot see through: command substitution, heredocs, eval, sudo, and variable expansion anywhere except in print-only commands like echo. Refused syntax asks for confirmation, or denies if you choose. It is never allowed on a guess.

Then a deny-wins policy decides. Deny beats ask, ask beats allow, and a call with no matching rule asks. Rules match on the normalized action:

- id: deny-force-push
  action_type: process.exec
  resource: file://workspace/
  decision: DENY
  exec_match:
    argv_patterns:
      - ["git", "push", "**", "--force*", "**"]
Enter fullscreen mode Exit fullscreen mode

Every decision goes into a hash-linked audit log with the argv that ran and the rule that decided it.

The part nobody measures: noise

A guard that prompts on every second command recreates the approval fatigue it was meant to fix. So before tuning anything, I built a corpus: 1,526 build and test commands from the CI workflows, Makefiles, and package scripts of ten permissively licensed projects, pinned by commit. A golden test replays the corpus through each profile and fails CI whenever a decision moves, so every shift gets reviewed.

The default safe-dev profile on that corpus today:

Decision Commands
Allow 777
Ask 740
Deny 2

Most of the asks are CI lines full of $VARIABLES, $(substitutions), and pipes into bash, which the parser refuses on purpose. Interactive sessions look different: in a recorded session where Claude Code fixed failing tests, the hook allowed all seven calls without a prompt.

You can measure your own number before installing anything. Replay reads your Claude Code transcripts and runs nothing:

nomos hook claude-code --replay-transcripts --profile safe-dev
Enter fullscreen mode Exit fullscreen mode

After installing, --suggest proposes allow rules from the calls you were asked about and then approved. It prints them. It never writes them.

Testing it against a real agent

Unit tests prove the parser. They do not prove the hook works inside the agent. So I ran headless Claude Code sessions in throwaway projects with canary files: a fake home directory, a .env with fake secrets, and directories an agent might decide to clean up. Five of the six scenarios ran in bypass mode, where nothing can answer a prompt:

  • Removing stale directories asked, was refused, and the directories survived.
  • Reading config/.env was denied for the Read tool, for cat -A, and for grep.
  • git commit --amend asked three times and was refused. The force push never ran.
  • Commands built on $BUILD_DIR in a cleanup script asked and were refused, and the script never ran.

Every canary survived every run. One scenario, a home-directory wipe, was never exercised because the model declined on its own, and the record says so instead of counting it as a pass. The prompts, decisions, and audit lines are in the validation record.

Porting it to Codex taught me the most

Codex ships Claude-style hooks, so a second adapter looked easy. Reading the Codex source changed the design twice.

  1. Codex's PreToolUse hook cannot ask. In its usual on-request mode, a command the hook does not block usually runs inside the sandbox without a prompt. So a Nomos "ask" becomes a deny with the reason, not silence.
  2. An approval prompt is not always a prompt. Codex's PermissionRequest hook also carries escalations, such as a retry outside the sandbox after a denial, and some of them look exactly like a plain confirmation. Answering "allow" there could remove the sandbox instead of a prompt, so Nomos only ever answers "deny".

An adversarial review against the Codex source found fifteen problems in my first version. My favorite: Codex's patch parser trims whitespace before it matches *** Add File: headers, and mine did not, so an indented header could write a file my parser never saw. A second review re-ran every case and found two remaining gaps, which are also fixed. I have not yet run the adapter against a live Codex binary, and the guide says so.

What it is not

Nomos is not an OS sandbox, a prompt-injection detector, or a backup. It decides only the calls that reach it. A hook that times out does not block, and allowing an interpreter allows whatever that interpreter runs, which is why the default profiles ask or deny before inline code such as python -c. Pair it with a sandbox if you need containment.

Try it

brew install safe-agentic-world/nomos/nomos
cd your-project
nomos hook claude-code --replay-transcripts --profile safe-dev
nomos hook claude-code --install --profile safe-dev
Enter fullscreen mode Exit fullscreen mode

Windows installs with Scoop, and Linux uses the release binaries. It is Apache-2.0: https://github.com/safe-agentic-world/nomos

The feedback I want most is a bypass: a command that should have been refused. There is an issue template for exactly that.

I drafted this article with help from Claude and checked every claim and number against the repository.

Top comments (1)

Collapse
 
devsupport profile image
Dev Support •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

‍​​ ​