With the plugin enabled, Claude Code loads hooks/hooks.json automatically. Every hook runs one dispatcher, hooks/guardian-hook.mjs, which imports only Node built-ins and the pre-built detectors in one bundle, mcp/dist/hooks.js, and the part of it the event runs (hooksBash.js, hooksWrite.js, hooksVet.js, hooksSession.js beside it) — no npm install, no native module.
Two rules hold for all of them:
- Fail open. Any error inside a hook exits 0 with no output. A guardrail that breaks must not break your session.
GUARDIAN_HOOKS_DEBUG=1prints what went wrong on stderr. It is not silent, though: when the shell guard, the secret scan, package vetting or the settings guard cannot be loaded (a missing or damagedmcp/dist/hooks.js, or one of its parts), SessionStart says which ones are off for the session — each one fails open alone: a damaged shell guard leaves package vetting running — and, when the plugin'smcp/dist/engine.jsis missing or not the file the build wrote (its size and SHA-256 are checked, the file never loaded), that the MCP tools and the CLI cannot run; when its own checks (hooksSession.js) cannot be loaded, that the briefing has no branch line, no database registration check and no engine check. - Only what just happened. A hook assesses the text just written or the command about to run — it never scans the repository. It does read a little around it: SessionStart runs one
git status --porcelain=v2 --branch --ignore-submodules=dirty— none in a folder with no.gitat it or above it, where git would find no repository — hardened like every git dev-guardian starts, so the project's owncore.fsmonitor, hooks and filter drivers do not run, nor its submodules' (their uncommitted work is not counted; a moved submodule commit is); withoutmcp/distto harden it, git is not run at all and the branch line is left out (SECURITY.md) — and checks.guardian/, and the install hook reads registry configuration (.npmrc,pip.conf,package.jsonworkspaces, … — listed below). The authoritative scans stay in the MCP tools (scan_secrets,vet_packages, …).
Each hook has a 15 s timeout in hooks.json. Every entry is in exec form — "command": "node" with the dispatcher and the event in "args", no shell between Claude Code and node — which needs Claude Code 2.1.139 or later; a shell per call cost 50-130 ms on every Bash call. An older Claude Code does not pass "args": no hook starts at all, and none can say so — not even SessionStart, which never runs. Nothing else in the plugin depends on the version.
The write guards run for Claude Code's own write tools and for a third-party MCP server's: a tool named mcp__<server>__<tool> whose name holds a file verb, in any case — write, edit, create, move, rename, copy, append, patch, replace, save, put, upload, insert (and, before the call, delete, remove, unlink) — or update, modify, apply, set or push as a word of the name (update_file, apply_diff, set_file_contents, fileUpdate, push_files; not get_settings or reset_cache) — such as mcp__filesystem__write_file, Serena's create_text_file, JetBrains' replace_text_in_file or str_replace_editor. The tool's name is what follows mcp__<server>__, __ and all (write__v2). Every string argument is read, to four levels deep and 256 strings: the usual path names (path, file_path, filePath, relative_path, pathInProject, destination, target, to, a file:// URI, paths, files, and any key that names a path, a file, a directory, a folder, a destination or a target — target_file, outDir; a move's or a rename's source as the path it takes away, a copy's as one it only reads) are paths it writes, and — for a tool whose name names a file (file, path, dir, folder, notebook, document: write_file, push_files) — so is any other short, single-line string (the first 32). For another tool, such a string that ends in .json or names registry (the first 32) is still compared with the guarded files, and one that names one — the hook configuration, the registry, Claude Code's settings (save {location, data}, write {args: [path, content]}) — is asked about, never denied: a GitHub create_issue titled .claude/settings.json is the user's to judge, and one titled anything else says nothing. A path that starts with ~ (~/…, ~\…), which the filesystem server and desktop-commander expand, is judged both as written and as the home directory's, and so is — on Windows — an MSYS /c/… as C:/…; the same holds for Write, Edit, MultiEdit and NotebookEdit; its text is content, text, data, file_text or its edits (oldText / newText, old_str / new_str, edits), or else every string that is not a path. A call with an argument past those limits is not taken as read: after every guard passed what was read, a tool whose name names a file is asked about ("did not read all of …'s arguments"), and another one gets a note.
| Event | Matcher | What it does | Default |
|---|---|---|---|
SessionStart |
— | Briefs the agent: plugin version, branch, uncommitted changes, whether the project has a .guardian/ directory and when the database last changed, whether that database is registered as the user's (read from the user's registry, never by opening the database) — and, when the project's hook config asked to loosen a guardrail, that it was ignored. A project with no .guardian/ gets one line saying the user can run /guardian-init — only when the folder looks like code (version control, or a manifest: package.json, pyproject.toml, go.mod, Cargo.toml, pom.xml, a .sln or .csproj, …). |
on |
PostToolUse |
Write, Edit, MultiEdit, NotebookEdit, MCP write tools |
Scans the inserted text for hard-coded secrets (medium confidence and up) and adds a warning with a redacted preview. A long line — a one-line JSON file, a minified bundle — is read to its end, in 16 KB windows overlapping by 2 KB (8 KB for JWTs), so every match lies whole in one of them. | warn |
PreToolUse |
Bash, PowerShell |
Assesses the command: denies catastrophic ones, asks the user about an agent-run baseline update, warns on risky ones, then vets any package it would install. |
deny catastrophic |
PreToolUse |
Write, Edit, MultiEdit, MCP write tools |
Denies any edit of the guard's own configuration, and an edit of Claude Code's .claude/settings*.json that would switch the hooks off (below). |
always — unless every hook is switched off at user level |
PreToolUse |
Write, Edit, MultiEdit, NotebookEdit, MCP write tools |
Denies any write into dev-guardian's registry of trusted databases (below). | always — unless every hook is switched off at user level |
PreToolUse |
Write, Edit, MultiEdit, NotebookEdit, MCP write tools |
Denies writing a high-confidence provider token (AWS, GitHub, Stripe, Hugging Face, …) — not a documented placeholder such as AWS's …EXAMPLE keys or sk-your-…-here. |
off — opt in with "secrets": { "block": true } |
The same detector serves Bash and PowerShell and the CLI's check --bash. It splits a command line into statements and also reads text that is fed to a shell (bash <<EOF … EOF, echo '…' | sh); a heredoc handed to a non-shell command (git commit -F - <<EOF) stays inert.
From the PowerShell tool the command is read twice — as a POSIX shell quotes it, and as PowerShell does (a backslash is literal, a backtick escapes, '' inside '…' and "" inside "…" are one quote, typographic quotes are quotes, a here-string @'…'@ is one literal, <# … #> is a comment, after --% the rest of the line — up to a | — goes to the program unparsed, and the commands in a script block, ForEach-Object { … } included, are assessed like any other) — and the more severe verdict stands. Read the POSIX way alone, an ordinary Windows path ending in \" escapes its closing quote and swallows the rest of the line: Remove-Item "C:\Users\" -Recurse -Force was ok. Program text handed to pwsh -Command / -EncodedCommand is read both ways too, from either tool. Both readings treat a # that starts a word as a comment to the end of the line — not one that continues a word, as after $(…) in bash or = in a PowerShell argument. check --bash "…" --powershell reads a command the same way.
- Denied (unless the block is switched off): recursive deletes —
rm -r,Remove-Item -Recurse,rd /s,del /s, with or without a force flag, since neither tool has a terminal to ask on, and in any spelling the program accepts (rm --rec,rm -rfvd,Remove-Item -Recu,rd /s/q) — of the filesystem root, the home directory (~,$HOME,${HOME}and their/*and\*forms, a default or check that yields its value —"${HOME:?}",${HOME:-x}—, ANSI-C and locale quoting decoded —$'/',$'\x2f',$"/"—, Git Bash's$USERPROFILEand$HOMEDRIVE$HOMEPATH, cmd's%USERPROFILE%and PowerShell's$env:USERPROFILE, another account's~name, and the home directory's own path —C:\Users\<name>,/c/Users/<name>/,/home/<name>/*— compared with the account's home, case-insensitively on Windows; a path below home only warns), Windows and Git Bash / WSL drive roots (also%SystemDrive%,%HOMEDRIVE%, and PowerShell's\and\*), the top-level system directories (/etc,/usr,/Users,/System, …), Windows' own folders in every spelling (C:\Windows,C:/Windows,/c/Windows,/mnt/c/Windows, any case,System32,C:\Users,C:\Program FilesandPROGRA~1,ProgramData), a glob that can name one of them (/[a-z]*,/?*,/c/U*) or a brace that expands to them (/{bin,etc,usr}), any other spelling of one of these paths —.and..segments and doubled separators folded (/usr/..,C:\Windows\.,"C:/Windows//"), a device prefix (\\?\C:\Windows,\\.\…,\\?\UNC\…), an administrative share of any host (\\localhost\c$\Windowsis that host'sC:\Windows), PowerShell'sFileSystem::provider prefix and a Windows path's trailing dots and spaces dropped, Windows' own variables in bash, cmd and PowerShell spellings ("$WINDIR",${PROGRAMFILES},%SystemRoot%\System32,${env:ProgramFiles(x86)}) read as the folders they name, and a climb out of the home directory (~/..,$env:USERPROFILE\..) as the directory above it, the home directory's own 8.3 name (C:\Users\ADMINI~1), a relative target read where the command runs — the hook's working directory moved by thecds before it (rm -rf ..from a child of the home directory,cd / && rm -rf *,cd .. && find . -delete), acdto the home directory (cd,cd ~,cd "$HOME",cd $env:USERPROFILE), to one of the variables below plus a path (cd "$TEMP/../../..",cd $env:TEMP\..\..\..), to Git Bash's bare drive (cd C:) and one run byeval(eval cd /) included, and past a subshell, acasepattern or a PowerShell(1);before it ((true); rm -rf ..) — a target led by$PWD($(pwd)),$TEMP,$TMP,$TMPDIR,$LOCALAPPDATAor$APPDATAin bash's, cmd's or PowerShell's spelling read as what it names ("$TEMP/../../.."is the home directory), Git Bash's bare drive (rm -rf C:is the drive's root there), and what a PowerShell listing hands a recursiveRemove-Itemwith no path of its own (Get-ChildItem C:\ | Remove-Item -Recurse, or anyRemove-Itemwhen the listing itself recurses —gci ~ -Recurse -File | ri -Force; not one a-Filter,-Includeor-Excludenarrows) — and--no-preserve-root; the same throughfind—-delete,-exec/-execdir/-ok rmor| xargs rm, its-H/-L/-P/-O/-Doptions read first — of the root, a drive root or a system directory, and of the home directory with nothing to narrow what it finds (find ~ -delete);rsync --deleteorrobocopy /MIRfrom anywhere into one of them, a recursive[IO.Directory]::Delete(…, $true)of one, andchmod -R/chown -R/chgrp -Rof the root itself; a download run as a script, official installers included —curl/wgetpiped into a shell however the shell is named or piped (|& sh,| (sh),| /bin/bash,| env VAR=x sh -,| "bash",| command bash,| exec bash,| sudo -u root bash,| source /dev/stdin),bash <(curl …),source <(curl …),. <(wget …),bash <<< "$(curl …)",sh -c "$(curl …)",eval $(curl …); the same with an interpreter reading its program from the download (| python3 -, bare| node,| perl,| ruby,| php,| deno run -,| bun run -,| uv run python -,| poetry run python,| pixi run python -,| uvx python -,| uv run -,python3 -c "$(curl …)",node -e "$(curl …)",python3 <(curl …)— its options read with its own table of those that take a value, sopython3 -W ignore -andnode --max-old-space-size 4096 <(curl …)count; not| python3 -c '…',| python3 script.pyor| python3 -m json.tool, where the download is only data) and| xargs -0 sh -c, which makes the download the-cscript, or| xargs -0 sh -c 'eval "$0"'(or"$0"as the command), a script that runs the argument xargs gives it, or| xargs -I{} sh -c '{}'(-i,--replace, or an interpreter's-c/-e), which writes each of its lines into the program —| xargs -I{} sh -c '… "$1"' _ {}hands it over as an argument, is not denied, and is the form the deny names; a file saved bycurl -o/-O/> forwget, or by what the download is piped into (| tee f,| sudo tee /usr/local/bin/tool,| gunzip > f), and run on the same command line (sh f,bash f,./f,source f,python3 f) — also aftermv/cp/installto another name — unless a checksum or signature check of that file (sha256sum -c,shasum -c,gpg --verify,cosign verify-blob, … naming the file, its.sha256/.sha256sumchecksum file, a.asc/.sigsignature handed togpg --verifyorgpgvalone —gpg --verify i.sh.asc otherverifiesother, noti.sh— or a checksum list downloaded in the same command) lies between the download and the run and the run is joined to it by&&; a bare command name (kubectl version) runs what PATH finds, so it counts only undercmd /c, which searches the working directory, or once the download was saved or placed in a PATH directory in the same command (curl -o /usr/local/bin/tool … && tool,install -m 755 tool /usr/local/bin/ && tool) — and, once an archive that was downloaded (piped, or saved and not checked) is extracted into a PATH directory (curl … | tar xz -C /usr/local/bin,unzip t.zip -d …), any later bare name but a builtin or a file tool (chmod,ls,which, …), since what the archive held is unknown — a download run by a later, separate command is not judged at all; PowerShelliwr | iex,(irm …) | iex,iex (irm …),iex ((New-Object Net.WebClient).DownloadString(…))and its single-paren formiex (New-Object Net.WebClient).DownloadString(…),iex "… $(irm …) …", a backtick dropped inside the cmdlet's name (i`ex),Invoke-Expression (Invoke-RestMethod …), a download held in a variable and handed toiex($s = irm …; iex $s— also${s}and$script:s,Set-Variable/New-Variable,-OutVariable,Tee-Object -Variable, a copy through a string,$b = "$a", and a read throughGet-Variable s -ValueOnly), a script block made from a download —&([scriptblock]::Create((iwr …)))(Microsoft's dotnet-install one-liner),[scriptblock]::Create(…).Invoke(),Invoke-Command -ScriptBlock (…),$ExecutionContext.InvokeCommand.InvokeScript(…)/.NewScriptBlock(…)— also insidepowershell -Command "…"or-EncodedCommand, and a file saved byDownloadFile(…),Invoke-WebRequest -OutFile,Start-BitsTransferor a download piped intoOut-File/Set-Contentand then run in the same command (& file,. file,.\file,powershell -File file,Start-Process file,msiexec /i file,Get-Content file | iex) — opening a downloaded document (Start-Process readme.txt,ii notes.pdf) is not running it, and a file merely namedx.iexis noiex;Format-Volume,Clear-Disk;dd,mkfs,wipefsorshredon a block device, andteeorcponto one (cat /dev/zero | tee /dev/sda); fork bombs under any function name (:(){ :|:& };:,f(){ f|f& };f);chmod -R 777 /;mkfifo,mknod,ln,mklink(also throughcmd /c, anywhere in its command line) or PowerShellNew-Item -ItemType SymbolicLink|HardLink|Junction(a parameter or an item type abbreviated as PowerShell allows —ni -it h— too) when the path it creates is one of the hook configuration files, or a link at.guardianor~/.config/dev-guardian; a symbolic link or a junction (ln -s,mklink /D//J,New-Item -ItemType SymbolicLink|Junction,[IO.File]::CreateSymbolicLink) whose target is one of those files or directories, Claude Code's settings,.claude, the managed-settings directory or dev-guardian's registry — a second name a later write goes through without naming them (mklink /J z .claude); a shell write onto one of the hook configuration files, its removal, a directory moved or copied onto a configuration directory, program text on the command line that names one, a shell write of Claude Code's settings that names a key switching the hooks off, andclaude plugin disable|uninstallof dev-guardian — see Shell writes. Commands insidedo … done,then … fi,else,{ … },function f { … },time { … }and after!are assessed like any other, and so are the scripts handed tobash -c(its own options and--read past, sobash -c -- '…'is its script),eval,iex/Invoke-Expression,npx -c,wsl,trap '…' EXIT,flock FILE -c '…'(or the command after the lock file),script -c '…',runuser … -c '…'/runuser … -- CMD, the command behindbusybox,coproc,ionice,unbufferandparallel, a$( … )or backtick inside a double-quoted string (echo "$(…)",: "$(…)"), whateval $(…)/eval `…`runs, and PowerShell's-Command/-EncodedCommand, every command of acmd /cline (cmd /c rd /s /q C:\is denied asrd /s /q C:\is) — also in Git Bash'scmd //cspelling, the one that runs there, its//sand//qread as/sand/q— the command line PowerShell'sStart-Processstarts with its-ArgumentList, and the program cmd'sstartlaunches. Words are brace-expanded first, as bash does ({rm,-rf,/}isrm -rf /, at most 64 words from one). A script whose text another command writes is assessed as that text: a process substitution a shell orsourcereads (bash <(echo '…')), a here-string fed to a shell (bash <<< '…'), whatcatorteepasses on to a shell (cat <<'EOF' | bash,cat <<< '…' | sh,echo '…' | tee log | sh), a string piped to a shell from PowerShell ('…' | bash), a$( … )handed tobash -coreval, and base64 decoded from anecho, aprintfor a here-string (echo cm0gLXJmIC8K | base64 -d | shisrm -rf /); a decoder whose output cannot be read here (base64 -d payload.b64 | sh,xxd -r,gunzip,openssl … -d) fed to a shell or an interpreter warns. What reads such a script: a shell given no script of its own, behind the runners in front of it (| sudo bash,| env bash,| timeout 5 bash,| busybox sh), alsobash -s -- x,bash -andbash /dev/stdin;xargshanding each line to a shell as its whole script (| xargs -I{} bash -c {}), andscript -q -c bash(orscriptwith no-c); PowerShell'siex/Invoke-Expressionfed through a pipe ('…' | iex) or handed a subexpression (iex ('…')), a script block made from a string ([scriptblock]::Create('…'),$ExecutionContext.InvokeCommand.InvokeScript('…')—.SessionState.InvokeCommandtoo — and a runspace's[powershell]::Create().AddScript('…')) and the PowerShell host reading stdin (| powershell -Command -); and cmd.exe with no command of its own (echo 'rd /s /q C:\' | cmd,| cmd /k) — fed from anywhere in a pipeline (… | bash | tee log), from a group ((echo …) | bash,{ echo …; } | bash) or through an output substitution (tee >(bash),> >(bash)). Text such a reader runs that cannot be read here — a file (cat install.sh | bash,Get-Content x.ps1 | iex,iex (gc x.ps1 -Raw)), a variable (iex $s,[scriptblock]::Create($code)) or a loop's output — warns that what it runs was not assessed. - Denied as the user's decision:
dev-guardian db adopt … --yesrun directly — the CLI by name or path,node …/cli/dev-guardian.mjs, dev-spec-driven's launchernode .specs/guardian.cjs,npx/pnpm dlx, behindenv(env -Stoo),sudo,bash -c,cmd /c,pwsh -Command,Start-Process … -ArgumentList,find … -exec, with--yesanywhere on it — since it marks the project's database as trusted, and a hostile repository can ship one that hides findings. The deny says to run it in a terminal after readingdb adoptwithout--yes, which only prints the summary and is allowed. It is a speed bump, not a wall: the other ways to launch it are in the known limits below, and what the command writes is guarded by path (the database registry). - Asked — the user decides:
baseline updaterun by the assistant, in the same spellings (dev-guardian,node …/cli/dev-guardian.mjs,node .specs/guardian.cjs, behind a runner, in a nested script or launched byStart-Process/find -exec). It accepts every current finding into.guardian/baseline.json, a feature's own new one included, and the gate stops reporting them; Claude Code shows the user why and asks. To accept one reviewed finding, suppress it inline with a reason (nosemgrep: <rule> -- <reason>,# nosec), or rebuild the baseline on the base commit. Asked too: a command the guard could not read to its end — over 512 KB, a statement over 16 KB, past the 2.5 s assessment budget or nested more than three levels deep — since padding a command that would be denied past one of those limits would otherwise run it with only a note; the question names the limit. With the block switched off either is only a warning. - Warned: force-push,
git reset --hard,git clean -fd,chmod 777, clearing shell history,sudo, any other recursive delete,find ~ … -deletewith a test that narrows it (-name,-mtime, …),find . -deletewith none,chmod -R/chown -Rof a system directory, a decoded script, or any other script a shell,iexor cmd.exe reads, that cannot be read (above),vssadmin delete shadows,reg delete HKLM\…,terraform destroy,aws s3 rm --recursive/aws s3 rb --force,gh repo delete, and — in a hook call, which knows the project and the working directory — a write of a file named like the hook configuration or Claude Code's settings that lies outside them and the home directory (another project's.guardian/hooks.config.json, a test fixture's fake HOME), which is not the one this session reads.
The deny message never says how to switch the guard off. That takes the user-level config or GUARDIAN_HOOKS_BASH_BLOCK=0, and an assistant's Write / Edit of either config file — or a shell write onto it that the guard can see — is itself denied.
When the command installs packages by name — npm i|install|add, pnpm add, yarn add, bun add|i|install, pip install, uv add, uv pip install, poetry add, composer require, dotnet add package — or downloads one to run it — npx, npm exec, pnpm dlx / pnpx, yarn dlx, bunx / bun x, uvx / uv tool run, uv tool install, pipx install, pipx run (the package is the first word after the launcher's own options, or the value of its -p / --package / --from / --spec, plus uvx --with; the words after it are the program's; npx, npm exec and bunx of a bin the project has installed fetch nothing and are not vetted — judged as npm resolves it: a node_modules/<name>/package.json whose name is the package, in npm's prefix (the nearest directory, from where the command runs up, with a package.json or a node_modules), or for an unscoped program name not given by -p a node_modules/.bin/<name> that is a file, in the prefix or above it up to the repository root. Where the command runs is the working directory moved by the cds that surely ran — at the head of a list or joined to it by &&, outside a pipeline, the background, a compound command (if, a loop, { … }) and a function body, not a bare name under CDPATH, and into a directory that is there through no symbolic link or junction — npx runs in the link's target, where the walk up for an installed bin would answer for another directory than the path as typed (the shell guard's tracker of the line, shell/workdir.ts); --prefix / -C, env -C and sudo -D move it where this does not look, so nothing is taken as installed) — the hook runs the same checks as the vet_packages tool, under one 3 s network budget for the whole command line and one 8 s deadline for everything the hook call does (GUARDIAN_PKG_VET_DEADLINE_MS, at least 3000 — the network budget — and at most 12000, counted from the start of the call) — Claude Code kills a hook at 15 s, and the command then runs with no verdict at all. The process ends 250 ms after its answer is written, even when an unresponsive network still holds a connection open (it used to linger 5 s more, and Claude Code waits for it). A command that names no installer at all does not load the vetting code. The scripts a command hands a shell — bash -c '…', eval, a cmd /c line, pwsh -c / -EncodedCommand, a script fed to a shell (echo '…' | sh, cat <<EOF | bash, base64 decoded into one), as the shell guard reads them — are vetted the same way, each read as its shell reads it; an install found there is never denied as missing (it is not one plain install statement), and a launcher there is not taken as running an installed copy once the outer command changed directory. Local work — reading registry configuration, walking a workspace — happens after the network and only for a name the registry does not have, each file read and each workspace walked once per call; what the deadline leaves unchecked reads "vetting time budget exhausted" and is never reported as vetted. It looks each package up once, at most 50 packages per command — names not on the popular list first, so padding a command with popular names cannot push another past the cap (a note says how many more were not vetted) — and reads a command for installs to 512 KB (past that, no missing-name deny, and a note says so, even when the first 512 KB install nothing):
| Finding | Hook answer |
|---|---|
A version that would install is malicious (OSV MAL- advisory, or npm's 0.0.x-security placeholder) |
deny, in every command shape; the message says to ask the user to install it themselves |
| The name does not exist on the public registry | deny only when the whole command, comments stripped, is ONE plain install statement — a bare tool name (no .venv/bin/pip, sudo, env, VAR=x), no &&, ||, ;, pipes, &, subshells, $(…), backticks, variables, redirections, unquoted commas or '…''…', and only flags from a small per-tool allowlist that cannot change where a package resolves from (not composer --no-update / --no-install or dotnet --no-restore, which defer the lookup) — and nothing below explains the 404. Otherwise a warning: "not found on the public registry — if it is private or local, ignore this". |
| Published less than 72 h ago, npm install scripts, typosquat suspicion, an unpublished exact version | warning |
| Known vulnerabilities | warning, only for an exact version pin (for a range the installed version is unknown) |
A check could not run (offline, timeout, HTTP error, rate limit, GUARDIAN_OFFLINE=1) |
one line saying the package was not verified; the command runs |
| Every package clean, or nothing installed by name | silent, and no network request at all |
A missing-name deny carries its own escape hatch, named for the tool: re-run the install with an explicit --registry <url> (npm, npx, pnpm, bun), --index-url <url> (pip, uv pip, pipx), --index <url> (uv add, uvx, uv tool) or --source (poetry, dotnet), or prefix the command with GUARDIAN_PKG_VET=0 — from the PowerShell tool, $env:GUARDIAN_PKG_VET = '0' first in the same command. Composer and Yarn Berry have no registry flag, so theirs names only the prefix. Either way the command is no longer one plain install statement; the hook's own switch is untouched, and a malicious version is still denied. A malicious-package deny has no escape hatch.
From the PowerShell tool the command is also read the way PowerShell reads it: npm i a,b hands npm two packages, a backtick at the end of a line continues it, a backslash before a closing " is literal, '' inside '…' is one quote, and a no-break space separates words. A package only that reading finds can be denied as malicious, never as missing.
What counts as "something explains the 404" — the hook reads files and environment only, never the network, for this:
- npm, pnpm, yarn, bun:
registry=or@scope:registry=in the project.npmrc, the user npmrc (NPM_CONFIG_USERCONFIGor~/.npmrc), npm's global npmrc (npm_config_globalconfig,npm_config_prefix,%APPDATA%\npm\etc\npmrcor<node prefix>/etc/npmrc) and pnpm's global rc; yarn's.yarnrc.ymlnpmRegistryServer/npmScopes— in every directory from the project up to the filesystem root, as Yarn Berry reads it — and classic.yarnrc; bun'sbunfig.toml(project or~/.bunfig.toml); an npmjs auth token when the name is scoped (a private scoped package answers 404 anonymously); a workspace package (package.jsonworkspaces,pnpm-workspace.yaml) — a workspace is read to 3000 directories, and one larger than that, or cut short by the deadline, leaves a name it did not reach unknown, never absent. - pip, uv, poetry:
pip.conf/pip.ini(user, the environment's prefix —VIRTUAL_ENVorCONDA_PREFIX—PIP_CONFIG_FILE,/etc/pip.conf,/etc/xdg/pip/pip.conf,XDG_CONFIG_DIRS,%ProgramData%\pip\pip.ini, macOS~/Library/Application Support/pipand/Library/Application Support/pip);uv.toml(UV_CONFIG_FILE,~/.config/uv,%APPDATA%\uv,/etc/uv, the project);[[tool.uv.index]],[[tool.poetry.source]],[[tool.pdm.source]]or a[tool.uv]index inpyproject.toml; uv workspace members and[tool.uv.sources]. - Composer: a
repositoriesentry incomposer.jsonor the user's globalconfig.json. - NuGet: any
nuget.configfrom the project up to the filesystem root, the user-level one, every*.configin the extra user directories (%APPDATA%\NuGet\config,~/.nuget/config,~/.config/NuGet/config) and the machine-wide ones (%ProgramFiles(x86)%\NuGet\Config,/etc/opt/NuGet/Config, macOS/Library/Application Support/NuGet/Config), with a source that is not nuget.org — except that a machine-wide config's local-folder source (Visual Studio'sMicrosoft.VisualStudio.Offline.configis one on every VS machine) counts only for a package that folder holds (<id>/or<id>.<version>.nupkg, the id ending where a NuGet version begins —Foo.2FA.1.0.0.nupkgholdsFoo.2FA, notFoo; a file name that splits more than one way counts for each id, sofoo.2.1.0.0.nupkgholdsfoo,foo.2andfoo.2.1). - The hook's environment, case-insensitively:
npm_config_*registry*,YARN_NPM_REGISTRY_SERVER,YARN_REGISTRY,BUN_CONFIG_REGISTRY,PIP_*INDEX*,UV_*INDEX*, and for NuGet only a variable that names a source, feed or configuration file (NUGET_*SOURCE*,NUGET_*FEED*,NUGET_*CONFIG*,NUGET_FALLBACK_PACKAGES) or a source's credentials (NuGetPackageSourceCredentials_<name>) — notNUGET_PACKAGESorNUGET_XMLDOC_MODE. - The command line: any non-public
--registry,-i,--index-url,--source.
These files are read like the hook configuration (below): a file reached through a link to a network or device path is not opened. A configuration that is there but could not be read — that link, a loop, a directory or FIFO where the file belongs, a file over 1 MiB, no permission — is unknown, never absent: it may name a private registry, so a missing name warns — "registry configuration at … could not be read — possibly a private registry", or, for a workspace's package.json / pyproject.toml or a directory that could not be listed, a message naming that — instead of being denied. A malicious version is still denied.
A registry that is the public one (registry.npmjs.org, pypi.org/simple, api.nuget.org) is not custom. Paths, tarballs, URLs, git specs (git@host:repo included), file: / workspace: specs, requirement files and flag values are never looked up; a bare npm install vets nothing.
The network hosts involved are listed in SECURITY.md.
The project is the directory Claude Code names in CLAUDE_PROJECT_DIR; without it, the nearest ancestor of the session's working directory that holds .guardian or .git — never the home directory or one of its ancestors, nor the temp directory itself, where a stray .guardian would otherwise become every unmarked project's configuration; failing both, the working directory itself. The project files below are read from there, and paths are made relative to it, wherever the session has cd-ed to — from <project>/packages/api the project's configuration applies, and a Write of <project>/.guardian/hooks-allowlist.json is denied as it is from <project>. A .guardian/hooks*.json at any depth under the project (or under the working directory) is guarded the same way. Every guarded path — these files and Claude Code's settings — is compared as the filesystem opens it: on Windows without an NTFS stream suffix (hooks.config.json::$DATA writes the file itself; :name and :name:$DATA are streams of it) or trailing dots and spaces (hooks.config.json., .guardian.\…), and everywhere through an 8.3 short name (HOOKSC~1.JSO) or a link, when the path or a directory above it exists. A hard link — the same file under another name, which no path comparison sees — is recognised by device and inode: a Write of notes.json made with ln .guardian/hooks.config.json notes.json is denied as a Write of the configuration, and one of a hard link to Claude Code's settings is judged as that settings file. Creating a hard link to a hook configuration file or to Claude Code's settings (ln without -s, link, cp -l, New-Item -ItemType HardLink, mklink /H, fsutil hardlink create), or a hard-link copy of a directory that holds them (cp -al .guardian backup, and ~/.config/dev-guardian or .claude), is denied too — and so, since 3.4.0, is a symbolic link or a junction to them (the shell guard, above): the Write / Edit path resolution follows one, but a shell write through it names neither. The shell guard drops the same Windows suffixes from the paths a command writes, and judges each path a command writes, removes or replaces also through the links the same command made before it (ln -s ~/.config c && echo … > c/dev-guardian/hooks.json) and — from a hook call — through those already on disk, resolved the way a Write path is (a junction z to .claude makes echo … > z/settings.local.json a write of the settings); a write follows a link at the path itself, a remove or a new link only the links above it. Past 64 links in one command, or 64 paths looked up, the command warns "part of this command was not assessed". A written path on the network (\\host\share\…, //host/share/…, \\?\UNC\…) is never asked of the filesystem — a Write to an unreachable host held the hook 21 s, past its timeout: an admin share of this machine (\\localhost\C$\…, \\127.0.0.1\…, \\[::1]\…, its own name) is read as the local path it names, and any other share's path is denied when it ends in a hook configuration file (.guardian/hooks*.json, .config/dev-guardian/hooks.json) or in the registry's default location; an edit of Claude Code's settings there is judged on its own strings, the file unread.
| Where | Who may write it | What it can do |
|---|---|---|
.guardian/hooks.config.json (project) |
the user — an assistant's Write / Edit / MultiEdit is denied, and so is a shell write the guard can see (below) |
only what makes the guard stricter or is advisory: enabled: true, bash.block: true, bash.warn: true, secrets.block, secrets.warn, sessionStart, ignorePaths. "enabled": false, "bash": { "block": false } and "bash": { "warn": false } here are ignored, and SessionStart says so; there is no project-level switch for package vetting. |
~/.config/dev-guardian/hooks.json (user) |
the user — an assistant's Write / Edit / MultiEdit is denied, and so is a shell write the guard can see (below) |
every key, winning over the project file: "enabled": false turns every hook off, "bash": { "block": false } turns the shell block into a warning |
.guardian/hooks-allowlist.json (project) |
the user — an assistant's Write / Edit / MultiEdit is denied, and so is a shell write the guard can see (below) |
substrings that silence a secret warning (and exempt a match from the opt-in secret block when the project itself enabled it): a JSON array, or { "secrets": [...] }. Advisory, like secrets.warn. An entry under four non-blank characters, which nearly every line would hold, is ignored, and SessionStart names it. |
GUARDIAN_HOOKS=off |
environment | disables every hook |
GUARDIAN_HOOKS_BASH_BLOCK=0 / 1 |
environment | forces the shell block off or on, over both files |
GUARDIAN_PKG_VET=0 |
environment | disables package vetting only |
GUARDIAN_PKG_VET_DEADLINE_MS |
environment | package vetting's deadline for one hook call, in ms from its start (default 8000, at least 3000, at most 12000) |
ignorePaths defaults to /test/fixtures/, eval-vuln-fixture, /.guardian/ and __fixtures__, matched against the path relative to the project. An entry every path (or nearly every one) holds — "", "/", which begins every project-relative path, or ".", which every name with an extension holds — silences every secret warning, and SessionStart says so. The plugin's own directory is always skipped. The user-level ignorePaths, when set, replaces the project's. A project's ignorePaths narrows the secret warning only: it never exempts a path from a secrets.block: true set in the user-level config, which honours the user's own ignorePaths or the defaults. (A block the project itself enabled may be narrowed by the same project's list.) The project's .guardian/hooks-allowlist.json follows the same rule: it silences the warning, and it exempts a match from the block only when the project itself enabled that block — never from one the user enabled.
Each setting is read only when it has the type above — a boolean, an object for secrets and bash, a list of strings for ignorePaths — in either file. One of another type is left out, its default applies, and SessionStart names it with its file (in ~/.config/dev-guardian/hooks.json, "ignorePaths" was ignored (not of the type the hooks read)). Until 3.4.0 a user-level "ignorePaths": "/" broke the check every write makes, so the hook gave no secret warning at all, and "enabled": "no" counted as on.
How the configuration files are read. Each one — the project config, the allowlist and the user-level config — goes through two checks, and a file that fails either is not read, counts as absent (the protective defaults), and is named by SessionStart; a file that is not valid JSON is named too.
- Before anything is opened, every path component below the project directory (for the two project files) or the home directory (for the user-level file) is examined with
lstatandreadlink, which never follow a link and never touch its target. If a link on the way — the file itself,.guardian,.config,dev-guardian, or any local link one of them leads to — points at a UNC or device path (\\host\share,//host/share,\\?\…,\\.\…), the file is refused. Opening such a link can wait on the network for minutes (≈136 s was measured on Windows for an unreachable host), far past the hook's 15 s. - Then the file is opened (a link is followed; on Linux and macOS with
O_NONBLOCK, so a FIFO opens at once instead of waiting for a writer), and what was OPENED is checked withfstat: only a regular file of at most 64 KiB is read, and no more than 64 KiB + 1 bytes are read from it, so a file that grows after the check is refused too. A FIFO, a link to/dev/zero, a Windows link to a named pipe (whichstats as an empty regular file), a larger file or a file this account may not read is refused on its descriptor and closed unread; a directory is refused too (Windows will not open one at all). Because these checks apply to the open descriptor rather than to the path, swapping the path for a FIFO between two checks changes nothing. A leading UTF-8 byte-order mark is stripped.
A FIFO there used to hang the hook until Claude Code killed it at 15 s, after which the tool call ran unguarded. Not covered: the project and home directories themselves and their ancestors (the user's own layout), and a path the operating system redirects to the network without a link — a mapped drive letter, a DFS namespace, an NFS or SMB mount — which is opened like any local path. The install hook reads registry configuration (.npmrc, package.json, pip.conf, nuget.config, …) through both checks too, capped at 1 MiB: the walk runs below the project for a file in it, from the file's own directory for one in a parent of the project, below the home directory for a user file and from the filesystem root for a system file — never over the project or home directory themselves or their parents. A refused file counts as possibly naming a private registry (above).
Shell writes. The shell guard judges what a command does to the three files, command by command — never their content, so reading them (cat, cp from them, sed -n, grep) stays allowed:
- writing one — a redirection (
>,>>,>|,N>,&>),tee,sed -i,perl -pi/ruby -pi,sort -o,truncate,sponge,shred,dd of=,curl -o,wget -O,ed,ex,vi/vim/nvim(vim -es -c … -c wq),cp/mv/install/rsynconto it (or into its directory under its name, a glob — in its name or in a directory above it,~/.config/dev-guardia?/hooks.json— or a brace that can name it included), a rename onto it, PowerShellSet-Content(and Windows PowerShell'ssc),Add-Content,Clear-Content,Out-File,Tee-Object,New-Item -ItemType File,Copy-Item/Move-Item/Rename-Item,[IO.File]::WriteAllTextand the other[IO.File]::writes, a[IO.StreamWriter]or a writing[IO.FileStream]opened on it (::new(…),New-Object …), and cmdcopy/move/ren/>; - removing one —
rm,unlink,del,erase,Remove-Item,[IO.File]::Delete, amvor rename that moves it away — and removing~/.config/dev-guardianitself; - replacing a configuration directory — a directory moved or copied onto
.guardianor~/.config/dev-guardian(mv,cp -r/-a,rsync -a,Copy-Item -Recurse,Move-Item,xcopy,robocopy), which becomes the directory when it did not exist.
A relative path is resolved against the cd, pushd or Set-Location earlier in the same command (cd ~/.config/dev-guardian && echo … > hooks.json) — back through cd -, cd "$OLDPWD" and popd, and through a CDPATH the command sets (CDPATH=~/.config cd dev-guardian) — and every command of a quoted cmd /c "…" line is judged the same way, its cd /d and > included. From a hook call, which knows the project and the working directory, a path spelled in full that lies outside both and the home directory only warns: a project's .guardian/hooks*.json counts under the project or the working directory, the user-level hooks.json only as the home directory's own, Claude Code's settings under any of them (or CLAUDE_CONFIG_DIR); a relative path, a variable or a glob counts as this session's. check --bash knows neither, and denies them all. Scripts a command hands a shell (bash -c, eval, pwsh -Command, -EncodedCommand) are judged as commands. Program text on the command line — node -e, tsx -e, ts-node -e, python -c, perl -e, ruby -e, php -r, deno eval, pwsh -Command, also behind npx, bunx, pnpm dlx / exec, npm exec, yarn dlx / exec, bun x, uv run and cmd's start, and a program fed to python / node through a heredoc or echo … | — cannot be judged that way, so it is denied when one of its string literals names a hook configuration path at all, reading or writing (reading one needs no program): a literal ending in the path, or the parts of one (join(home, '.config', 'dev-guardian', 'hooks.json')); a sentence that merely mentions it is not.
Claude Code's own settings. A .claude/settings.json or .claude/settings.local.json (project or user level) — and, when Claude Code runs with CLAUDE_CONFIG_DIR, the settings.json / settings.local.json directly in that directory, which are then the user's settings; and the machine's managed settings, which win over every other file (managed-settings.json in ClaudeCode under Program Files or ProgramData on Windows, in /Library/Application Support/ClaudeCode on macOS, in /etc/claude-code elsewhere) — with "disableAllHooks" set to anything that may read as true (true, "true", 1, even "false"), an env block setting GUARDIAN_HOOKS=off, GUARDIAN_HOOKS_BASH_BLOCK=0 / false, GUARDIAN_PKG_VET=0, NODE_TLS_REJECT_UNAUTHORIZED=0, or — to any value — NODE_OPTIONS (it runs code in every hook before the hook starts: --import=data:…,process.exit(0) made the dispatcher silent on rm -rf /), GUARDIAN_PKG_VET_DEADLINE_MS or GUARDIAN_OFFLINE, or an enabledPlugins entry naming dev-guardian set to anything but true (false, "false", 0), switches the hooks off. Which value forms Claude Code honours is its parser's business, so an ambiguous one counts; the file is read as JSONC (comments, trailing commas), a key's escapes are decoded ("disable\u0041llHooks"), a key that repeats counts every time it appears, and JSON nested deeper than that reader goes (64 levels) is read by JSON.parse. Each side of an edit is read once, and a reading that takes longer than 4 s is asked about. An assistant's Write / Edit / MultiEdit of those files is denied when its result sets one of those that the file did not already set; every other edit — permissions, other environment variables, other hooks, other plugins, and any edit of a file where the user already set such a key (with that value) — goes through, except that these are asked about: a new env PATH, HOME or USERPROFILE — which decide the node every hook entry starts and the home its user configuration is read from — and any other new NODE_* or GUARDIAN_* variable or proxy (NODE_EXTRA_CA_CERTS, NODE_USE_ENV_PROXY, HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY), whose effect on the hooks is not known here — not NODE_ENV, nor a known switch at a harmless value (GUARDIAN_PKG_VET=1); and an Edit of an existing file that cannot be applied as plain text (its old_string not found). An MCP write tool's change of those files whose result cannot be worked out — Serena's replace_regex, an edit matched as a regular expression, a glob or ignoring case (a flag nested in an options object or spelled as a string included: {options: {useRegex: true}}, isRegex: "true", flags: "i", mode: "regex"), a move or copy of a prepared file onto them, arguments no known key names — is asked about: Claude Code asks the user (denied outright when its own strings name such a setting). So is an MCP tool that deletes those files or moves them away (delete_file, remove_file, move_file with them as the source), which drops every permissions.deny rule they hold; a copy of them is only a read. A shell rm of them is not judged. {"disableAllHooksX": true} written, then X" replaced by ", switched the hooks off in two steps that each passed. A shell write of those files (a redirection, mv tmp .claude/settings.json, jq … | sponge, sed -i, program text naming the file) cannot be read reliably, so it is judged by the command's text: it is denied when the command names disableAllHooks, GUARDIAN_HOOKS, GUARDIAN_HOOKS_BASH_BLOCK, GUARDIAN_PKG_VET, GUARDIAN_PKG_VET_DEADLINE_MS, NODE_OPTIONS, or enabledPlugins together with dev-guardian — also spelled through an escape (disableAllHook\x73, s, octal) or inside a base64 literal the command holds (echo eyJ… | base64 -d > .claude/settings.local.json); jq '.permissions…' … > tmp && mv tmp .claude/settings.json goes through. The shell rule matches the key's name, whatever the value, so it also denies writes that do not loosen anything: setting GUARDIAN_HOOKS_BASH_BLOCK to 1, "disableAllHooks": false, deleting such a key (jq 'del(.disableAllHooks)'), or "dev-guardian@…": true in enabledPlugins — make those edits with Write / Edit, which judge the result. claude plugin disable, uninstall or marketplace remove naming dev-guardian is denied too.
db adopt --yes (node "<plugin root>/cli/dev-guardian.mjs" db adopt --project <dir> --yes — a plugin install puts no dev-guardian on PATH) makes a project's database trusted by writing an entry into dev-guardian's registry, <data dir>/registry/<db_id>.json. The data directory is GUARDIAN_DATA_DIR when set; otherwise %LOCALAPPDATA%\dev-guardian on Windows, $XDG_DATA_HOME/dev-guardian (an absolute one) or ~/.local/share/dev-guardian elsewhere. Which database to trust is the user's decision, so the registry is guarded the way the hook configuration is:
- an assistant's
Write/Edit/MultiEdit/NotebookEdit(or an MCP write tool's call) of the registry or anything in it is denied, the deny naming the runnabledb adoptline for the user — the path compared as the filesystem opens it (NTFS stream suffixes, trailing dots and spaces, 8.3 names, links), and a hard link to an entry recognised by device and inode; - a shell write into it is denied: a redirection,
tee,cp/mv/install/rsyncinto it,Set-Content,Copy-Item,Out-File,[IO.File]::WriteAllText, cmdcopy, a link created in it or at it, a hard link to an entry, the registry or the whole data directory replaced by a directory moved or copied onto it, and program text that names it — in its actual location and in the spellings of the defaults (~/.local/share/dev-guardian,%LOCALAPPDATA%\dev-guardian,$env:LOCALAPPDATA,$XDG_DATA_HOME,$GUARDIAN_DATA_DIR), acdthere included.
Reading the registry and removing an entry — which only un-trusts a database — stay allowed; so does the rest of the data directory. A program that writes the registry by its own means (a script run from disk, a path built at run time) is not seen, as for the hook configuration.
Known limits. None of the following is refused. Each can change what the hooks do, and switching the guardrails off stays the user's decision — these are recorded so nobody reads the guards as covering them:
- a program written to disk and then run —
python fix.py,node tool.mjs,pwsh ./x.ps1, a build script, an editor — can never be judged: what it does is not in the command, and the file it runs can be written by any earlier step; - other programs that write files by their own means:
git checkout/restore/stashof a tracked config file, an archive extracted into a configuration directory (tar -x -C,unzip -d,Expand-Archive),find … -delete/-exec rmof a configuration file (of the root or home, it is denied above),awk -i inplace, and a path handed over at run time — a variable (echo … > "$F"),$(…),xargs, a PowerShell pipeline (Get-ChildItem … | Remove-Item),Join-Pathoutside program text, or program text that builds the path from pieces other than whole string literals ('dev-' + 'guardian'); - a Claude Code settings file prepared elsewhere and then copied into place (
cp /tmp/s.json .claude/settings.json): the command that writes it names no loosening key; - removing the project's whole
.guardiandirectory, or everything in it (rm -rf .guardian,.guardian/*) — it also holds the scan database, and the project configuration it deletes could only have made the guard stricter; likewise moving or removing a parent of~/.config/dev-guardian(mv ~/.config …), which leaves the protective defaults; - the plugin's own installed files (
hooks/,mcp/dist/under~/.claude/plugins/…/dev-guardian): editing the guard's code switches it off, and no hook can protect an installation from the account that owns it; - nesting past the guard's depth: a script handed to
bash -c,sh -c,eval,pwsh -c,npx -c, acmd /cline or a script fed to a shell is fully judged three levels deep (acmd /cline's file effects eight); anything nested deeper is not judged, and the hook asks the user (above) — "part of this command was not assessed (nested more than 3 levels deep)" — socmd /c cmd /c cmd /c cmd /c rd /s /q C:\is asked about, not denied; - a command inside a PowerShell double-quoted string — the
"$(Remove-Item …)"subexpression — is read only as a POSIX shell would read it (soC:\reads asC:and its delete only warns), and one in an expandable@"…"@here-string is not judged; a POSIX$( … )or backtick inside double quotes (echo "$(rm -rf /)") is judged like an unquoted one; - launchers other than those named above (
volta run,pipx run,corepack, a shell alias or function) are not followed to the program they run; db adopt --yeslaunched any other way than the direct forms above —npm run,make, a git alias,alias/Set-Alias, program text (node -ewithchild_process,python -cwithsubprocess),ssh,docker run,xargsbuilding the line from its input,[Diagnostics.Process]::Start, a copy of the CLI under another name, or--yesheld in a variable, an array or a splat — is not recognised. The rule stops the command an assistant would naturally write, and its deny says the decision is the user's; it cannot stop one set on working around it;- download-and-run shapes the guard does not see: a download, its check or its run in a separate command (on purpose — download, inspect, run); a download kept in a variable assigned by an earlier command, passed through
$_or a function, or fetched by a .NET API other thanWebClient/HttpClientand the web cmdlets; within one command, a variable filled bySet-Item variable:s,$ExecutionContext.SessionState.PSVariable.Set(…), a member or key ($h.s = irm …) or an assignment inside a script block (ForEach-Object { $s = $_ }), or read back as a member inside a string (iex "$($r.Content)") or by splatting; a script block made in one command and invoked in another; an interpreter option missing from its table, which is taken for a flag, so a value after it can pass for the program; interpreters other than python, node, perl, ruby, php,deno runandbun run(lua, Rscript, julia, …), and runtimes that fetch a URL themselves (deno run https://…); - a checksum check is trusted as far as its list goes: a checksum list downloaded in the same command lifts the deny for every download in it (its content is not read), and a check proves the file matches that list, not who published it; a PowerShell
Get-FileHashcomparison is not recognised as a check, so that run stays denied; - a bare command name runs the download only when the file was saved or placed in one of a fixed list of PATH directories (
/usr/local/bin,/usr/bin,/bin,/usr/local/sbin,/opt/homebrew/bin,~/bin,~/.local/bin, …) in the same command — another directory on PATH, or a changedPATH, is not seen; - the
Write/Editguard compares a hard link by device and inode with the project's and the working directory's.guardian/hooks*.json, the user-levelhooks.jsonand Claude Code's settings files — not with a.guardian/hooks*.jsondeeper in the tree; and a hard link made by a program the shell guard cannot model, or by a hard-link copy of a directory above the configuration (cp -al . ../snapshot), is caught only when it is written through; - a JWT with a segment over 2000 characters (one over about 6 KB) is not reported by the secret scan — the pattern stops there so that it stays linear, and the 8 KB window overlap is sized to it;
- the secret scan is a fast pre-filter ahead of
scan_secrets(gitleaks), not a replacement: it does not know Discord or Mailgun tokens, reports a URI's password only above an entropy floor (postgres://app:hunter2@…is not), and skips a quoted value with two spaces in it (a sentence, an i18n label); - a secret written from the shell (
cat > .env <<EOF,echo "KEY=…" >> .env) is not scanned: the secret warning andsecrets.blocksee the write tools only; - a third-party MCP tool is guarded only when its name holds one of the verbs above (
touch,truncate,downloadandfetchare not among them); a tool whose name names no file is judged only on the strings under a key that names a path ({location: "…"}passed to such a tool is not); a path spelled with a variable ($HOME/…,%USERPROFILE%\…) is taken as written,~alone being expanded; and an MCP tool that runs a command (a shell, a terminal) is not assessed by the shell guard; - a network share that leads back to this machine under a name other than
localhost, a loopback address or the machine's own name is not resolved: its hook configuration and registry paths are denied by their tail, but a hard link there is not seen; - a command named at run time —
& (gcm Remove-Item) C:\ -Recurse -Force,$c = 'rm'; & $c …, an alias — and Windows' own permission tools (icacls /T,takeown /R) are not judged; nor is a brace expansion past 64 words or in a word that is partly quoted; - a decoder's output is read only for base64 from a literal on the command line; any other (
xxd -r,gunzip,openssl enc -d, a file) fed to a shell only warns; - the
baseline updatequestion needs Claude Code to ask: where nobody can answer (a headless run), it is whatever Claude Code does with an unansweredask. A write of.guardian/baseline.jsonby the write tools is not asked about; - a statement longer than 16 KB: the pattern rules (force-push,
curl | sh,chmod -R 777 /, …) read its first 16 KB only; a command longer than 512 KB is read to that length (the longest real command measured was 58 KB); and an assessment that runs past its 2.5 s budget stops, as does one that fails part-way on an input nobody foresaw. Each is named — "part of this command was not assessed (over 16 KB)", "(over 512 KB)", "(assessment time budget exhausted)", "(the assessment failed)" — never a silentokand never no answer at all (a block found before it still blocks), but what lies past it is not judged. The first three are limits an input can reach on purpose, so the hook asks the user (above):rm -rf /after 512 KB of padding is asked about, not run with a note. A failure part-way is the guard's own fault and only warns, as every hook fails open; so do an[IO.File]call whose arguments run past 512 characters and a command making more than 64 links or path lookups through them. The per-command checks (rm -rf, the configuration files, program text) read the whole statement.
node cli/dev-guardian.mjs check --file path/to/file # secrets, exit 1 on a finding
node cli/dev-guardian.mjs check --bash "rm -rf /" # ok / warn / block, exit 1 unless ok
node cli/dev-guardian.mjs check --bash 'Remove-Item "C:\Users\" -Recurse' --powershell # as the PowerShell tool
node cli/dev-guardian.mjs check --file .env --min high --json
Exit code 3 is a usage error — neither or both of --file and --bash, an unknown argument, a --min other than high or medium — or an unreadable --file (2 before 3.3.0, as for every command). A file is read as UTF-16 when it starts with a UTF-16 byte-order mark or its bytes are NUL-interleaved (what PowerShell 5.1's > and Out-File write), as UTF-8 otherwise.