Checks package installs before they actually run. When an AI agent decides to npm install something for you (or pip, cargo, gem, bun, etc.), Watchdog intercepts the command, asks OSV.dev about known CVEs, and — when the package ships install-time exec surface (postinstall, setup.py, build.rs, gem ext, Homebrew scriptlets) — feeds only those hooks to your configured LLM. Source-code review is left to OSV + Snyk/Socket-class scanners, which already do that job better. You get allow, ask, or deny back before anything lands on disk.
Static Go binary. Linux, macOS, Windows.
Works with Claude Code, Claude Desktop, Cursor, Continue, Zed, OpenCode, Aider, Cline, and plain shells that an agent happens to be driving.
npm audit, Snyk, and Dependabot all inspect manifest edits in your repository. They don't see the install command an agent types at a prompt. They also don't see a plugin that drops a hostile skill into ~/.claude/ or wherever your host stores extensions.
Watchdog sits in front of that. Two surfaces, two tools:
- Dep CVEs → OSV.dev. Cached, parallel, no LLM involved. An LLM can't beat OSV + Snyk/Socket at known-vuln detection, so Watchdog doesn't try.
- Natural-language attack surface → LLM. Install hooks (
preinstall/postinstallscripts,setup.py,build.rs, gemextconf.rb/Rakefile, Homebrew formula scriptlets) and Claude Code agent artifacts (skills, commands, hooks, plugins). These are text, not compiled code; no CVE database covers them. The model looks for typosquats, exfil patterns, credential-stealing skills, prompt-injection bait, install-time persistence.
A published package with no install hooks short-circuits to allow after OSV — Watchdog does not re-scan source files that Snyk/Socket already cover. Worst verdict across the packages in a command wins. If OSV is unreachable, the LLM CLI isn't installed, or the analyzer times out, the default is ask. There's no path where Watchdog silently allows something it couldn't check.
Beyond the verdict, an Ed25519-signed integrity manifest detects tampering with Watchdog's own state — binaries, shim wrappers, decision tokens. The hook wrappers fail-closed on tamper instead of silently passing. See Tamper resistance.
If you already have something covering manifest edits in PRs, this isn't a replacement for that. It covers the surface those tools weren't built for.
Three commands:
# 1. Install the binaries.
curl -fsSL https://raw.githubusercontent.com/Maxlemore97/Watchdog/main/install.sh | sh
# 2. If the installer warned about PATH, fix it. Then install the
# package-manager shims. On a TTY, this also generates the local
# Ed25519 signing keypair and prompts to wire up any detected
# MCP-aware hosts (Claude Desktop, Cursor, Continue, Cline, Zed).
# Use --no-register to skip the prompt; --register (or -y) to
# accept without prompting.
export PATH="$HOME/.local/bin:$PATH"
watchdog-shim install
# 3. Put the shim dir at the FRONT of your PATH (the previous step
# prints the exact line), open a new shell, then check.
export PATH="$HOME/.watchdog/bin:$PATH"
watchdog-shim doctorHealthy doctor output:
watchdog-shim doctor:
ok shim dir is first on PATH
ok watchdog-shim-exec found on PATH
ok at least one LLM provider CLI on PATH
ok cache dir writable (/home/you/.cache/watchdog)
ok integrity manifest matches (/home/you/.watchdog/manifest.json)
ok cursor: watchdog-mcp registered (/home/you/.cursor/mcp.json)
doctor is the single source of truth across every layer: PATH, the shim binaries, the integrity manifest + signature (see Tamper resistance), and any MCP-aware host you have installed. Run it after install and any time something looks off.
If doctor warns that no LLM provider CLI is on PATH, that's fine. Watchdog will still run OSV checks; the LLM review just gets skipped. Install claude, gemini, openai, or ollama if you want it back (see LLM providers).
doctor only checks that the CLI is on PATH, not that it actually answers. If you suspect a broken setup (expired auth, exhausted quota, model name typo), add --llm-smoke to send a one-token challenge:
watchdog-shim doctor --llm-smoke # 5s timeout, costs ~10 tokens
watchdog-shim doctor --llm-smoke --llm-smoke-timeout=15sPick whichever applies.
curl -fsSL https://raw.githubusercontent.com/Maxlemore97/Watchdog/main/install.sh | shPulls the latest release for your OS+arch, verifies SHA-256 against the published checksums.txt, and drops eight binaries into ~/.local/bin. Override the destination with WATCHDOG_INSTALL_DIR. Pin a version with WATCHDOG_VERSION=v0.9.9 sh install.sh.
iwr -useb https://raw.githubusercontent.com/Maxlemore97/Watchdog/main/install.ps1 | iexDrops binaries into %USERPROFILE%\.watchdog\bin. The script prints a one-liner that puts that on your user PATH. Copy-paste it and restart your shell.
If you have Go 1.25+:
go install github.com/Maxlemore97/watchdog/cmd/...@latestInstalls all eight binaries under $(go env GOPATH)/bin. Make sure that's on your PATH.
For air-gapped or locked-down machines. Grab the archive for your platform from Releases, check checksums.txt, extract, and copy the binaries somewhere on your PATH.
The shims are PATH-prepend wrappers. Without them, Watchdog only fires inside Claude Code or an MCP-aware host.
| Shim | Install verb(s) | OSV ecosystem | Notes |
|---|---|---|---|
npm, pnpm, yarn, bun |
install / i / add |
npm |
yarn only catches add |
pip, pip3, pipx |
install |
PyPI |
wheel-only PyPI → metadata-only review |
uv |
add; uv pip install two-word |
PyPI |
|
poetry |
add |
PyPI |
|
cargo |
add, install |
crates.io |
|
gem |
install |
RubyGems |
|
composer |
require |
Packagist |
|
brew |
install |
— (LLM-only) | formula/cask JSON from formulae.brew.sh |
go |
install <mod>@<ver> |
Go |
proxy.golang.org zip |
dotnet |
add [<proj>] package three-word |
NuGet |
api.nuget.org .nupkg |
Scope the set with WATCHDOG_SHIMMED_TOOLS_ADD and WATCHDOG_SHIMMED_TOOLS_SKIP (comma-separated). Names are validated against the parser's known set (parsers.EcosystemByCmd) at install time; an unknown name fails the install rather than writing a dead wrapper:
$ WATCHDOG_SHIMMED_TOOLS_ADD=notarealpm watchdog-shim install
install failed: unknown tool name(s) in WATCHDOG_SHIMMED_TOOLS_ADD/_SKIP: notarealpm (valid: see parsers.EcosystemByCmd)
$ echo $?
1watchdog-shim installWrites one wrapper per tool in the effective set (fifteen by default) into ~/.watchdog/bin/ (or %USERPROFILE%\.watchdog\bin on Windows), records an integrity manifest at ~/.watchdog/manifest.json, and prints the PATH line you need to add. The shim dir has to be first on your PATH; otherwise the shell finds the real binary directly and the check never runs.
The manifest records sha256 hashes of every Watchdog binary and shim wrapper at install time. Hot paths (the PreToolUse hook, the shim, MCP watchdog_health) verify against it on every invocation — see Tamper resistance.
Easiest — let watchdog-shim install manage the rc edit:
watchdog-shim install --add-to-path--add-to-path detects your shell (zsh / bash / fish, override with WATCHDOG_RC=/path/to/rc), writes an idempotent managed block to the rc file, and prints the source command needed to pick it up. watchdog-shim uninstall removes the same block. Already-installed setups can fix a stale PATH later with watchdog-shim doctor --fix.
Manual equivalent — Linux / macOS, bash or zsh:
echo 'export PATH="$HOME/.watchdog/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
exec $SHELL -lWindows PowerShell (persistent, user-scoped):
[Environment]::SetEnvironmentVariable(
'Path', "$env:USERPROFILE\.watchdog\bin;" + [Environment]::GetEnvironmentVariable('Path','User'), 'User')Verify:
watchdog-shim doctor
watchdog-shim status # per-tool install stateTo remove later: watchdog-shim uninstall. It only deletes scripts that carry the Watchdog shim marker (so your own binaries are untouched) and removes the manifest so the hook fallback correctly distinguishes a clean uninstall from a tamper.
Re-running install.sh still works, but watchdog-shim update is the in-process equivalent and is easier to remember once Watchdog is on PATH:
watchdog-shim update --check # current vs latest, exit 0
watchdog-shim update # download latest, verify sha256, atomic replace
watchdog-shim update --version v0.9.5 # pin to a specific tag
watchdog-shim update --force # reinstall same version or downgradeThe update path matches install.sh: same eight binaries, same checksums.txt. Linux and macOS pull the tar.gz; Windows pulls the zip (goreleaser ships both formats from the same release). The Go implementation adds two things on top: target binaries are staged into .new files and only renamed once the whole archive is verified (so a partial download cannot leave a broken install), and the integrity manifest is rebuilt in the same process so the hot-path hash checks keep matching the binaries they front. Downgrades are refused without --force.
On Windows the running watchdog-shim.exe holds a deny-write lock on its own image, so the in-process update first renames the existing .exe to .exe.old (rename-aside is permitted on a running binary) before dropping the new bytes at the original path. The .old files are best-effort-cleaned on the next update run.
If a cached verdict turns out to be wrong, you can inspect and prune the cache:
watchdog-shim cache stats # counts + size, broken down by kind
watchdog-shim cache clear --type=llm --dry-run # preview what would go
watchdog-shim cache clear --type=llm --older-than=24h # drop stale LLM verdicts, keep OSV
watchdog-shim cache clear --type=all # nuke everything except the ledger--type is one of llm, osv, all. The persistent vetted-plugins ledger (vetted_plugins.json) is always preserved; delete it by hand if you really want it gone.
Optional, recommended. Watchdog shells out to whichever LLM CLI you have configured (cloud or local). Auto-detect order: claude → gemini → openai → ollama. Pin one with WATCHDOG_LLM_PROVIDER.
| Provider | CLI binary | Default model | Install hint |
|---|---|---|---|
| Claude | claude |
claude-haiku-4-5-20251001 |
Claude Code |
| Gemini | gemini |
gemini-2.5-flash |
npm install -g @google/gemini-cli |
| OpenAI | openai |
gpt-4.1-mini |
pip install openai |
| Ollama | ollama |
llama3.1 |
ollama.com |
| Generic | WATCHDOG_LLM_CMD |
user-specified | Any CLI that reads a prompt on stdin and writes JSON on stdout |
Verdict cache keys are content-addressed: sha256(provider, model, sha256(system_prompt), ecosystem, name, version, bundle_digest). bundle_digest is a sha256 over the curated file set the LLM would see, computed by every fetcher. Unchanged bytes hit cache indefinitely; any change (republished name@version, fetcher-curation update, system-prompt edit, model upgrade) misses and re-runs. The 30-day WATCHDOG_LLM_CACHE_TTL default is a paranoia floor, not a freshness driver — OSV runs on its own 1h cycle and is where new-CVE detection happens. ask verdicts are not persisted: they often reflect model non-determinism, and the next call re-rolls.
A verdict is only accepted if the model's entire trimmed output is one JSON object, or wraps that JSON in a ```json fence. Prose-embedded JSON is ignored; a hostile artifact echoed back can't smuggle a forged verdict object. If parsing fails, the fallback is ask.
No CLI installed? OSV checks still run, just without the LLM stage.
flowchart LR
subgraph Sources["Where installs originate"]
direction TB
cc["Claude Code<br/>Bash + /plugin install"]
mcp["MCP-aware host<br/>Cursor / Continue / Zed"]
sh["Aider / Cline /<br/>agent-driven shell"]
pr["GitHub PR<br/>hostile skill / hook"]
end
subgraph Adapters["Watchdog adapters"]
direction TB
a1["watchdog-pretool<br/>-session / -prompt / -scan"]
a2["watchdog-mcp"]
a3["watchdog-shim<br/>+ shim-exec"]
a4["watchdog-action"]
end
engine[("internal/preflight<br/>OSV + LLM aggregator")]
cache[("~/.cache/watchdog<br/>shared verdict cache")]
cc --> a1
mcp --> a2
sh --> a3
pr --> a4
a1 --> engine
a2 --> engine
a3 --> engine
a4 --> engine
engine <--> cache
| Adapter | Host | When to use |
|---|---|---|
watchdog-shim |
Anything that shells out to a package manager | The general catch-all. OpenCode, Aider, Cline, Cursor terminal, plain shell. |
watchdog-mcp |
Any MCP-aware host (Cursor, Continue, Zed, custom agents) | Native integration without writing host glue. Shares the cache. |
watchdog-pretool-session / -prompt / -scan |
Claude Code | Tightest integration. The PreToolUse hook blocks the install inside the agent. |
watchdog-action |
GitHub PRs | Repos that publish Claude Code plugins or skills. |
All four share ~/.cache/watchdog/, so a plugin vetted by one adapter is recognized by the others.
watchdog-scan project [DIR] walks a project tree without running an install, discovers pinned dependency lockfiles plus agent-extension roots, and pipes everything through the same OSV + analyzer engine. Useful for "I just cloned this repo — is it safe?" before you touch it.
watchdog-scan project . # scan cwd, JSON to stdout
watchdog-scan project ~/code/foo --format=text
watchdog-scan project . --plugins-only # skip the dependency walk
watchdog-scan project . --packages-only # skip the plugin/skill walkLockfiles parsed: package-lock.json, pnpm-lock.yaml, Pipfile.lock, poetry.lock, uv.lock, Cargo.lock, Gemfile.lock, composer.lock, go.mod, packages.lock.json. Bare manifests without a lockfile are skipped (pinned versions only) and surfaced as a note. Plugin roots detected: any directory holding .claude-plugin/, skills/, commands/, hooks/, or a top-level plugin.json. Standalone CLAUDE.md / agents.md files are listed in the report. Exit code follows the worst verdict (0 for allow, 1 for ask / deny).
watchdog-scan local is the no-argument shortcut for "audit whatever plugins this host already has installed". Resolves roots from WATCHDOG_PLUGIN_DIRS (: / ; separated), then CLAUDE_PLUGINS_DIR, then ~/.claude/plugins/; runs the analyzer on every plugin child and aggregates the worst verdict. Same content-addressed cache as the install path, so a repeat scan with no on-disk changes is sub-second.
watchdog-scan local # auto-discover roots, JSON output
watchdog-scan local --format=text # human-readable summary
watchdog-scan local --root /some/other/dir # repeatable: append extra root(s)In Claude Code, the same scan is exposed as the /watchdog-scan-local slash command (auto-discovered from commands/watchdog-scan-local.md).
Every adapter funnels into the same pipeline. OSV runs first because it's quick and cached; an OSV deny short-circuits the rest. A clean OSV result still goes through a deterministic prefilter (PEM keys, AWS / GitHub / OpenAI / Slack token shapes, curl … | sh, env-piped-to-network). Only clean prefilter output reaches the LLM stage, and only when the fetcher surfaced install-hook content (or for the agent-artifact paths, plugin / skill / command / hook text). Empty bundles short-circuit to allow — Snyk/Socket cover the source code.
The parser dispatches on the package-manager binary and its install verb. Most verbs are single tokens (npm install, pip install, cargo add); two multi-word forms are special-cased: uv pip install and dotnet add [<project>] package. Ecosystems with no OSV mapping (brew, plus the special plugin ecosystem used by Claude Code plugin installs) skip the OSV step and run straight into the LLM stage on registry metadata.
flowchart TD
start(["install command<br/>(npm install foo bar, /plugin install …)"])
parse["parsers.CollectPackages<br/>tokenize, split on && || ;, resolve unpinned versions"]
cap{"pkg count ><br/>WATCHDOG_MAX_PACKAGES?"}
askCap["verdict: ask<br/>(reason: too many packages)"]
osv["OSV.dev query (parallel, cached)<br/>filter by WATCHDOG_MIN_SEVERITY"]
osvHit{"CVE at/above floor?"}
denyOSV["verdict: deny<br/>(GHSA-… [severity])"]
prefilter["deterministic prefilter<br/>regexes for keys, tokens, curl|sh, env|net"]
pfRes{"hit?"}
denyPF["verdict: deny<br/>(code-file hit)"]
askPF["verdict: ask<br/>(README-only hit)"]
hooks{"install hooks<br/>in bundle?"}
allowNoHook["verdict: allow<br/>(OSV-clean, no install hooks;<br/>source review delegated to Snyk/OSV)"]
llm["LLM review of install hooks<br/>+ agent artifacts<br/>UNTRUSTED-wrapped bundle"]
agg["policy.WorstVerdict<br/>allow < ask < deny"]
out(["verdict: allow / ask / deny<br/>+ findings"])
start --> parse --> cap
cap -- yes --> askCap
cap -- no --> osv --> osvHit
osvHit -- yes --> denyOSV --> agg
osvHit -- no --> prefilter --> pfRes
pfRes -- code --> denyPF --> agg
pfRes -- doc-only --> askPF --> agg
pfRes -- clean --> hooks
hooks -- no --> allowNoHook --> agg
hooks -- yes --> llm --> agg
agg --> out
Fail-closed at every step. OSV unreachable returns ask (or deny from the shim, which has no UI to ask through). An analyzer panic gets recovered into ask. A blown budget returns ask. Nothing is silently allowed.
The shim and the Claude Code hooks intercept by being on PATH and registered in plugin config. An adversarial agent that can edit files can try to bypass them — delete the binary, rewrite PATH, edit ~/.claude/settings.json. Watchdog's job is to make every such attempt either visible (audit log) or denied at the next request.
Integrity manifest. watchdog-shim install snapshots the hashes of every Watchdog binary and shim wrapper into ~/.watchdog/manifest.json. Hot paths verify against it on every call:
watchdog-pretooldenies install-shaped Bash when the manifest mismatch — and emitsadditionalContextto the agent for non-install commands so the degraded state is visible.watchdog-shim-execdenies installs on integrity failure (belt-and-suspenders if the hook was bypassed).watchdog-shim doctorreports the full state.- The MCP
watchdog_healthtool exposes the same status to MCP-aware agents.
The hook wrappers (hooks/*.sh) are manifest-aware. If the watchdog binary is missing AND the manifest exists, that's tamper — install-shaped Bash gets denied. If both are absent, that's a clean uninstall, and the hooks fall through silently.
Tamper-pattern detection. watchdog-pretool inspects every Bash tool call for signatures of disable/evade attempts: unset PATH, PATH=… overrides, absolute-path installs (/opt/homebrew/bin/npm install …), writes to ~/.claude/settings.json, pkill watchdog-*, rm -rf ~/.watchdog/. Any hit returns deny and writes an integrity.deny event to the audit log.
Audit log. Every protective and meta-protective event lands at ~/.watchdog/audit.jsonl (override via WATCHDOG_AUDIT_LOG): manifest writes, integrity denials, tamper-pattern matches, MCP tool start/ok/error/panic/timeout, decision write/consume/expire, host register/unregister. Post-incident jq is the forensics tool:
jq -c 'select(.event | startswith("integrity") or startswith("tamper"))' \
~/.watchdog/audit.jsonlEd25519 signing. Manifests and decision tokens are signed with a per-install local keypair generated at watchdog-shim install time and stored at ~/.watchdog/.signing.{key,pub} (private 0600, public 0644). On verification:
SIGNATURE_INVALID— manifest content changed without re-signing → hard fail, deny installs.SIGNATURE_KEY_MISSING— manifest claims a signature but~/.watchdog/.signing.pubis gone → hard fail.SIGNATURE_MISSING— legacy v1 manifest (pre-signing) → soft warning; next install upgrades it.
Decision tokens follow the same pattern. Unsigned or invalid tokens are rejected (ErrUnsignedToken); the shim falls back to a fresh preflight. A filesystem-write attacker who also reads ~/.watchdog/.signing.key can still forge signatures — local-key signing is detection, not prevention.
Build-time baseline (scaffolded). Each binary embeds integrity.BaselinePubKey via -ldflags '-X .../integrity.BaselinePubKey=…'. At release time, CI signs a baseline.json listing the expected sha256 of every binary. VerifyDeep checks each binary's hash against the signed baseline. Drift fails with BASELINE_BINARY_DRIFT; missing baseline on a stamped build fails with BASELINE_MISSING. Unstamped builds (go install without ldflags) skip baseline verification silently. The verification path ships today; wiring the release CI to actually sign baseline.json is a follow-up that needs a GitHub secret for the release private key.
What this gives you. A filesystem-write adversary with both keys still wins. The combined model raises the bar by one well-defined step: tamper that doesn't re-sign gets caught, and tamper that swaps in a different binary gets caught by the baseline. The audit log records every step.
This repo ships a Claude Code plugin (.claude-plugin/plugin.json plus hook scripts in hooks/) that registers three hooks: PreToolUse on Bash, UserPromptSubmit, and SessionStart.
Inside a Claude Code session:
/plugin marketplace add Maxlemore97/Watchdog
/plugin install watchdog@watchdog-marketplace
The first line registers this repo as a plugin marketplace; the second installs the watchdog plugin from it. The hook scripts (hooks/pretool.sh etc.) shell out to the watchdog-pretool / watchdog-session / watchdog-prompt binaries on your PATH, so the binaries need to be installed first (see Install). If they're missing, the hook scripts exit silently rather than override other plugins' decisions.
Confirm it's wired up by running /plugin and looking for watchdog in the list. New Bash installs (npm install …) should now trigger the PreToolUse check; a deny shows up in the Claude UI as a blocked tool call.
watchdog-mcp defaults to stdio (one child per MCP host session). It can also serve HTTP+SSE over a unix socket or loopback TCP via --listen — see Daemon mode. Seven tools:
| Tool | What it does |
|---|---|
watchdog_preflight_install |
Parse + OSV + (optional) LLM on a full install command |
watchdog_scan_package |
LLM source review of one published package |
watchdog_audit_plugin |
Audit a plugin git URL or name@version |
watchdog_audit_plugin_local |
Audit an already-installed plugin directory |
watchdog_list_vetted_plugins |
Read the persistent vetted-plugins ledger |
watchdog_osv_query |
Raw OSV.dev query, mostly for diagnostics |
watchdog_health |
Self-check: version, integrity status, uptime, pending decisions |
Every handler runs under a guard that adds panic recovery, a bounded timeout (WATCHDOG_MCP_HANDLER_TIMEOUT, default 60s), and audit-log entries. A panic in any tool returns a structured error rather than killing the server.
Agents using the MCP integration should call watchdog_health once at session start and refuse package work if status != "ok". The status surfaces missing-manifest, hash-mismatch, and PATH-misconfiguration in one structured payload.
The easiest way to wire watchdog-mcp into a host is the built-in registration:
watchdog-shim register --host=claude-desktop # or --host=cursor
watchdog-shim register --all # every detected hostThis atomically edits the host's mcpServers config, adds the watchdog entry with an absolute path to the binary, and preserves any other entries you already have. watchdog-shim unregister reverses it (also preserving other entries). Detected hosts are listed by watchdog-shim doctor.
If you'd rather hand-edit the config, the shape is:
{
"mcpServers": {
"watchdog": { "command": "/absolute/path/to/watchdog-mcp" }
}
}Auto-registration covers Claude Desktop, Cursor, Continue, Cline, and Zed. Each uses its native config:
- Continue:
~/.continue/config.yaml(or.yml/.json— detected automatically). YAML round-trip viagopkg.in/yaml.v3does not preserve comments. - Cline: VS Code's extension storage path (
~/Library/Application Support/Code/.../saoudrizwan.claude-dev/.../cline_mcp_settings.jsonon macOS;~/.config/Code/...on Linux). - Zed:
~/.config/zed/settings.jsonwith thecontext_serverskey (notmcpServers) and the nested{source, command:{path, args}}shape.
watchdog-shim install runs the registration prompt automatically when stdin is a TTY. Use --register / -y to skip the prompt and accept; --no-register to skip the prompt and decline. Non-TTY contexts (CI) get a one-line hint instead of hanging.
watchdog-mcp defaults to stdio — one child process per MCP host session. For machines that run several MCP-aware hosts, you can run a single long-lived server that speaks HTTP+SSE over a unix socket instead, supervised by launchd (macOS) or systemd --user (Linux):
# Render the service file, register with the supervisor, start the daemon
watchdog-shim daemon install --listen=auto
# Inspect
watchdog-shim daemon status
# Remove
watchdog-shim daemon uninstall--listen=auto resolves to unix://$WATCHDOG_DIR/mcp.sock with mode 0600. You can pass tcp://127.0.0.1:PORT instead (non-loopback hosts are refused); TCP auth is a follow-up.
Daemon mode is mainly useful for hosts that natively speak HTTP/SSE MCP. Hosts that only spawn stdio children (Claude Desktop, Cursor today) keep using the existing stdio registration. A stdio↔HTTP proxy that bridges the two is planned.
Windows daemon mode is not yet supported.
When an MCP-aware agent calls watchdog_preflight_install, the server writes a short-TTL decision token to ~/.watchdog/decisions/ keyed by the exact canonicalized command. If the agent subsequently runs the same install in shell, the shim reads the token and short-circuits to the cached verdict instead of re-running OSV+LLM. The cache key is the literal command, so an allow for lodash cannot be reused for lodash-evil. If the MCP returned deny, the shim also denies — even if the agent ignored the MCP response. TTL is 60s, override via WATCHDOG_DECISION_TTL.
For repos that publish Claude Code plugins or skills, the Action runs analyze_local_plugin on every modified plugin root on PR. Annotations land as file-level comments. The job exits non-zero when any plugin is denied, which is configurable via fail-on.
name: Watchdog
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
watchdog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: Maxlemore97/Watchdog@v0.9.9
with:
fail-on: deny # deny | ask | neverInputs: fail-on (default deny), model (override default LLM), base-ref (auto-detected), version (pin a Watchdog release).
Everything's an env var. Defaults are sensible; nothing's required.
| Env var | Default | What it does |
|---|---|---|
WATCHDOG_MODE |
both |
osv / claude / both |
WATCHDOG_MIN_SEVERITY |
low |
OSV severity floor (none/low/medium/high/critical) |
WATCHDOG_FAILCLOSED_VERDICT |
ask (hooks) / deny (shim) |
Verdict to emit when a check can't run (OSV unreachable, LLM CLI missing, analyzer panic/timeout) |
WATCHDOG_MAX_PACKAGES |
50 |
Above this, return ask without scanning |
WATCHDOG_LLM_PROVIDER |
auto |
claude / gemini / openai / ollama / generic |
WATCHDOG_LLM_MODEL |
per-provider | Override the model name |
WATCHDOG_LLM_TIMEOUT |
60 |
Per-invocation timeout in seconds |
WATCHDOG_LLM_CMD |
— | When provider=generic, the CLI to spawn |
WATCHDOG_CACHE_DIR |
~/.cache/watchdog |
Where verdicts and the ledger live |
WATCHDOG_CACHE_TTL |
3600 |
OSV cache TTL (seconds) |
WATCHDOG_LLM_CACHE_TTL |
2592000 (30d) |
LLM-verdict cache TTL (paranoia floor — cache is content-addressed via bundle digest, so unchanged bytes hit cache regardless; lower this to force more frequent re-rolls) |
WATCHDOG_HOOK_BUDGET_SECS |
30 |
Wall-clock cap per hook invocation |
WATCHDOG_SESSION_MAX_SCANS |
10 |
Max plugins re-analyzed per SessionStart |
WATCHDOG_ACTION_FAIL_ON |
deny |
deny / ask / never for the GitHub Action exit code |
WATCHDOG_OSV_ENDPOINT |
OSV.dev | Override (http/https only; file:// is rejected) |
WATCHDOG_LOG |
— | If set, JSON-line event log path (see Events) |
WATCHDOG_DISABLE |
— | Set to 1 in nested LLM child env to break hook recursion |
WATCHDOG_DIR |
~/.watchdog |
Where the shim dir, manifest, decisions, and audit log live |
WATCHDOG_AUDIT_LOG |
$WATCHDOG_DIR/audit.jsonl |
Override the tamper / integrity audit log path |
WATCHDOG_DECISION_TTL |
60 |
MCP→shim decision-token lifetime in seconds |
WATCHDOG_MCP_HANDLER_TIMEOUT |
60 |
Per-tool ceiling for MCP handlers (seconds) |
WATCHDOG_DAEMON_LISTEN |
— | Default --listen value for the watchdog-mcp daemon (e.g., auto, tcp://127.0.0.1:7274) |
WATCHDOG_SHIMMED_TOOLS_ADD |
— | Comma-separated tool names to add to the default shim set. Unknown names → watchdog-shim install exits 1 |
WATCHDOG_SHIMMED_TOOLS_SKIP |
— | Comma-separated tool names to drop from the default shim set. Same validation as ADD |
If WATCHDOG_LOG points at a writable path, Watchdog appends one JSON object per line for each interesting moment. The format is stable enough to grep but isn't a versioned API; field set may grow.
Most useful events:
analyzer_completed— one perAnalyzePackage/AnalyzeLocalPlugincall. Fields:ecosystem,name,version,route(cache/prefilter/llm/unfetchable/unparseable/provider_err),verdict,elapsed_ms. When the LLM ran: alsoprovider,model,prompt_bytes,response_bytes. When the provider's envelope surfaces totals (currentlyclaudeandopenai): alsotokens_in,tokens_out.preflight_packages— one perPackagesaggregation:mode,verdict,packages,reason.prefilter_deny/prefilter_ask— when the deterministic regex stage matched.osv_query_failed— OSV call failed (network, timeout, etc.).cache_write_failed— a verdict cache write didn't land.
The tamper / integrity audit log at ~/.watchdog/audit.jsonl (separate from WATCHDOG_LOG) carries:
manifest.written/manifest.removed— install / uninstall.integrity.deny— request denied because the integrity check failed or a tamper pattern was hit. Includestool,command, and eitherpatternsorfailures.integrity.failed— integrity check produced a non-fatal failure surfaced to the agent viaadditionalContext(degraded state without an outright deny).tamper.suspected— emitted by the hook wrappers (hooks/pretool.sh,hooks/prompt.sh) when the binary is missing but the manifest exists.mcp.tool.start/.ok/.error/.panic/.timeout— one pair per MCP handler invocation.decision.written(MCP) /decision.consumed(shim) /.expired/.miss/.gc— decision-token cache lifecycle.decision.unsigned/decision.corrupt/decision.write_failed— token was rejected for missing signature (v1→v2 rollout), token bytes failed to parse, or the on-disk write didn't land.host.registered/host.unregistered—watchdog-shim register/unregister.install.registered_via_prompt/install.register_skipped— auto-prompt path duringwatchdog-shim install.daemon.start/daemon.stop— watchdog-mcp daemon process lifecycle.daemon.installed/daemon.uninstalled—watchdog-shim daemon install/uninstall.
Quick budget estimate, given WATCHDOG_LOG=/tmp/watchdog.jsonl:
jq -s 'map(select(.event=="analyzer_completed" and .tokens_in))
| {total_in: (map(.tokens_in) | add), total_out: (map(.tokens_out) | add)}' \
/tmp/watchdog.jsonlwatchdog-shim doctor says the shim dir isn't first on PATH. You appended instead of prepended. Re-export with the shim dir on the left of $PATH and restart the shell.
watchdog-shim-exec: real binary "npm" not found on PATH. The shim dir is on PATH but no other entry has npm in it. Install Node/npm the normal way; the shim picks it up next call.
doctor says no integrity manifest at …. You installed via go install or another path that didn't run watchdog-shim install. Run watchdog-shim install to create the manifest. Pre-manifest installs still work, just without tamper detection.
doctor says SHIM_HASH_MISMATCH or BINARY_HASH_MISMATCH. Either you upgraded one binary without re-running watchdog-shim install, or something modified files in ~/.watchdog/. The fix is the same: re-run watchdog-shim install to re-snapshot the current state.
doctor says cursor detected but not registered. Run watchdog-shim register --host=cursor. Or --all to register against every detected host.
Hook denied with tamper pattern detected (UNSET_PATH,…). Your tool call contains an evade signature (see Tamper resistance). Reformulate without unsetting PATH or invoking package managers by absolute path. If the detection is wrong for your case, file an issue.
Claude Code hook fires but the verdict says watchdog: plugin install detected but analyzer unavailable. No LLM CLI on PATH. Install one (see LLM providers) or set WATCHDOG_MODE=osv to skip the LLM stage.
go install reports package github.com/.../cmd/...: cannot find package. Run go version. You need 1.25+. Older Go can't resolve the wildcard form, so either install each binary individually (go install github.com/Maxlemore97/watchdog/cmd/watchdog-pretool@latest) or use the install script.
Hook says scan budget exceeded. Raise WATCHDOG_HOOK_BUDGET_SECS (default 30) or WATCHDOG_MAX_PACKAGES (default 50). A fresh monorepo npm install from a lockfile can easily produce more than 50 packages.
Bypass once: WATCHDOG_DISABLE=1 npm install something. Short-circuits to a straight pass-through.
"I don't know what Watchdog just did". The engine emits structured JSON events but logging is opt-in — no file is written unless WATCHDOG_LOG points somewhere writable. Enable it before reproducing:
export WATCHDOG_LOG=$HOME/.cache/watchdog/log.jsonl
# now run the command you want to investigate
tail -f $WATCHDOG_LOG | jq .Every analyzer call, preflight verdict, prefilter hit, OSV failure, and cache write lands as one JSON line — field list is in the Events section above. watchdog-shim doctor reports whether logging is currently enabled.
Full version with disclosure address: SECURITY.md. Short version:
In scope: prompt injection from fetched artifacts, malicious install commands, supply-chain payloads in published packages, hostile plugin repos, recursive LLM invocation, OSV / registry network failure, DoS via install-command fan-out. Tamper attempts on Watchdog's own state — detected via Ed25519 signatures on the manifest and decision tokens, and logged to the audit log on each request. See Tamper resistance.
Out of scope: filesystem-write attackers who can read the local signing key at ~/.watchdog/.signing.key and re-sign forged state — combined signing (local + release-time baseline) is the v1 model, and a goreleaser hook that produces the signed baseline is the next step. Verdict cache poisoning, compromised LLM provider CLIs, SSRF via plugin git URLs.
Report vulnerabilities via GitHub Security Advisories on this repo.
The core (parser, OSV, fetchers, analyzer, ledger, preflight) is stdlib only. The MCP server brings in github.com/mark3labs/mcp-go; the rest of Watchdog stays vendorable.
Source layout:
cmd/ thin CLI entry points (each <200 LOC)
watchdog-pretool/ Claude Code PreToolUse hook
watchdog-session/ Claude Code SessionStart hook
watchdog-prompt/ Claude Code UserPromptSubmit hook
watchdog-scan/ manual /watchdog-scan slash command
watchdog-mcp/ MCP stdio server (uses mark3labs/mcp-go)
watchdog-shim/ install/uninstall/status/doctor/register/unregister CLI
watchdog-shim-exec/ per-call shim dispatcher
watchdog-action/ GitHub Action entry
internal/
types/ Package, ArtifactBundle structs
paths/ cache_dir / watchdog_dir / manifest / audit-log resolution
log/ opt-in JSON-line event log
audit/ always-on tamper / integrity audit log
cli/ shared TTY-detection helper
policy/ verdict ranking + worst-wins
osv/ OSV.dev query, severity, version resolution
parsers/ install command lexer + plugin prompt parser + tamper-pattern detector
fetchers/ per-ecosystem artifact fetch + tar safety (npm, PyPI, crates.io, RubyGems, Packagist, Homebrew, Go, NuGet, plugin git)
analyzer/ LLM prompt + prefilter + verdict extraction
providers/ multi-LLM CLI registry
ledger/ persistent plugin vetting ledger
preflight/ shared OSV+LLM aggregator
shim/ wrapper templates, FindRealBinary
integrity/ install-time manifest + Verify / VerifyDeep + Ed25519 signing + baseline
decisions/ short-TTL MCP↔shim handoff cache (signed)
hosts/ register watchdog-mcp with detected MCP hosts (5 adapters)
mcp/ pure-Go handlers + Guard (panic / timeout / audit)
daemon/ launchd plist / systemd unit templates for daemon mode
ghaction/ workflow command emitter, path classifiers
urlenc/ shared URL-path escaper
config/ env validation + Disabled() helper
hooks/ Claude Code hook shell scripts (POSIX, manifest-aware fail-closed)
Go 1.25+ (mcp-go's runtime floor).
git clone https://github.com/Maxlemore97/Watchdog
cd Watchdog
go build ./...
go test -race ./...asdf users: .tool-versions pins Go 1.26.3.
Releases stamp the version via ldflags so each binary reports it through --version:
go build -ldflags '-X github.com/Maxlemore97/watchdog/internal/version.Version=v0.9.9' ./...Unstamped builds report dev. Release builds may additionally stamp internal/integrity.BaselinePubKey with a base64 Ed25519 verifier public key — when set, every binary checks ~/.watchdog/baseline.json against its release signature and reports drift via BASELINE_BINARY_DRIFT. Unstamped builds skip baseline verification silently.
Race-clean, no network. The analyzer has an adversarial-archive corpus and a set of prompt-injection cases.
go test -race ./...MIT © Maxlemore97