<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: nomos</title>
    <description>The latest articles on DEV Community by nomos (@nomos4aisec).</description>
    <link>https://dev.to/nomos4aisec</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4144723%2F7a68dad1-e245-4fde-92ed-21a91c2714ac.png</url>
      <title>DEV Community: nomos</title>
      <link>https://dev.to/nomos4aisec</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nomos4aisec"/>
    <language>en</language>
    <item>
      <title>Deny rules are not a boundary: building a policy hook for Claude Code and Codex</title>
      <dc:creator>nomos</dc:creator>
      <pubDate>Sat, 26 Sep 2026 18:40:24 +0000</pubDate>
      <link>https://dev.to/nomos4aisec/deny-rules-are-not-a-boundary-building-a-policy-hook-for-claude-code-and-codex-285a</link>
      <guid>https://dev.to/nomos4aisec/deny-rules-are-not-a-boundary-building-a-policy-hook-for-claude-code-and-codex-285a</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The usual defenses have known gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission prompts&lt;/strong&gt; get approved on reflex. Anthropic reports that Claude Code users approve 93% of permission prompts and calls the result approval fatigue (&lt;a href="https://www.anthropic.com/engineering/claude-code-auto-mode" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;String-matched deny rules&lt;/strong&gt; are easy to step around. Claude Code's permissions documentation says a Bash deny rule "isn't a security boundary around the program" (&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;source&lt;/a&gt;), because &lt;code&gt;/bin/rm&lt;/code&gt;, &lt;code&gt;bash -c&lt;/code&gt;, and &lt;code&gt;git -C . push&lt;/code&gt; do not look like the string you wrote.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;a href="https://github.com/safe-agentic-world/nomos" rel="noopener noreferrer"&gt;Nomos&lt;/a&gt;, an open-source PreToolUse hook written in Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse, don't match
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny-force-push&lt;/span&gt;
  &lt;span class="na"&gt;action_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;process.exec&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file://workspace/&lt;/span&gt;
  &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DENY&lt;/span&gt;
  &lt;span class="na"&gt;exec_match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argv_patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;push"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--force*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every decision goes into a hash-linked audit log with the argv that ran and the rule that decided it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody measures: noise
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The default &lt;code&gt;safe-dev&lt;/code&gt; profile on that corpus today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Commands&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Allow&lt;/td&gt;
&lt;td&gt;777&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ask&lt;/td&gt;
&lt;td&gt;740&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of the asks are CI lines full of &lt;code&gt;$VARIABLES&lt;/code&gt;, &lt;code&gt;$(substitutions)&lt;/code&gt;, and pipes into &lt;code&gt;bash&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;You can measure your own number before installing anything. Replay reads your Claude Code transcripts and runs nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nomos hook claude-code &lt;span class="nt"&gt;--replay-transcripts&lt;/span&gt; &lt;span class="nt"&gt;--profile&lt;/span&gt; safe-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After installing, &lt;code&gt;--suggest&lt;/code&gt; proposes allow rules from the calls you were asked about and then approved. It prints them. It never writes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it against a real agent
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;.env&lt;/code&gt; 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:&lt;/p&gt;

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

&lt;p&gt;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 &lt;a href="https://github.com/safe-agentic-world/nomos/blob/main/docs/validation-claude-code-hook.md" rel="noopener noreferrer"&gt;the validation record&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Porting it to Codex taught me the most
&lt;/h2&gt;

&lt;p&gt;Codex ships Claude-style hooks, so a second adapter looked easy. Reading the Codex source changed the design twice.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Codex's PreToolUse hook cannot ask.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An approval prompt is not always a prompt.&lt;/strong&gt; 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".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;*** Add File:&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;python -c&lt;/code&gt;. Pair it with a sandbox if you need containment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;safe-agentic-world/nomos/nomos
&lt;span class="nb"&gt;cd &lt;/span&gt;your-project
nomos hook claude-code &lt;span class="nt"&gt;--replay-transcripts&lt;/span&gt; &lt;span class="nt"&gt;--profile&lt;/span&gt; safe-dev
nomos hook claude-code &lt;span class="nt"&gt;--install&lt;/span&gt; &lt;span class="nt"&gt;--profile&lt;/span&gt; safe-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows installs with Scoop, and Linux uses the release binaries. It is Apache-2.0: &lt;a href="https://github.com/safe-agentic-world/nomos" rel="noopener noreferrer"&gt;https://github.com/safe-agentic-world/nomos&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The feedback I want most is a bypass: a command that should have been refused. There is an issue template for exactly that.&lt;/p&gt;

&lt;p&gt;I drafted this article with help from Claude and checked every claim and number against the repository.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>go</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
