Skip to content

feat: skills pinning — supply chain & update drift defense (AST 02 + AST 07)#88

Closed
andreykh89 wants to merge 1 commit intonode9-ai:mainfrom
andreykh89:feat/skills-pinning
Closed

feat: skills pinning — supply chain & update drift defense (AST 02 + AST 07)#88
andreykh89 wants to merge 1 commit intonode9-ai:mainfrom
andreykh89:feat/skills-pinning

Conversation

@andreykh89
Copy link
Copy Markdown
Contributor

Summary

  • Extends the existing MCP tool pinning primitive (PR feat: MCP tool pinning — rug pull defense #81 / v1.5.0) to agent skills~/.claude/skills/, ~/.claude/CLAUDE.md, ~/.claude/rules/, project .claude/CLAUDE.md, .claude/CLAUDE.local.md, .claude/rules/, .cursor/rules/, AGENTS.md, CLAUDE.md. On the first tool call of a session the hook records SHA-256 hashes of every skill file; subsequent sessions re-hash and compare. Any drift quarantines the session and blocks every tool call until a human reviews with node9 skill pin update <rootKey>.
  • One feature, two threats covered in a single primitive: AST 02 Supply Chain Compromise (ClawHub-style registry overwrite) and AST 07 Update Drift (ClawJacked-style auto-update backdoor).
  • Moat: Node9 is now the first/only runtime defense covering both the MCP tool layer AND the skills layer. Competitors (Keycard / Microsoft AGT / Invariant) have no equivalent at the skills layer today.

What's new

  • src/skill-pin.ts — core module mirroring src/mcp-pin.ts: SHA-256 content hashing (files or recursive directories, symlink-safe, 5000 files / 50 MB caps), atomic writes to ~/.node9/skill-pins.json (mode 0o600), per-root exists flag (so "skill appeared" and "skill vanished" both count as drift), batched verifyAndPinRoots(), computePinDiff().
  • src/cli/commands/skill-pin.tsnode9 skill pin list | update <rootKey> [--yes] | reset. update shows per-file diff (added/removed/modified) before re-pinning; reset wipes both pins and session flags so no quarantine state can leak.
  • Hook integration in src/cli/commands/check.ts — first call of a session verifies; result memoised in ~/.node9/skill-sessions/<session_id>.json so the hashing cost is paid once per session, not once per tool call. Session IDs restricted to /^[A-Za-z0-9_-]{1,128}$/ to defeat path traversal. Best-effort GC of flags older than 7 days. Corrupt pin file → fail-closed. Unknown errors → fail-open (debug-logged) so a skill-pin bug can never brick Claude Code.
  • policy.skillRoots: string[] config field — user-extensible list of extra skill paths beyond the defaults (absolute, ~/-prefixed, or cwd-relative; relative paths require an absolute cwd per CLAUDE.md path-validation rules).
  • README + CHANGELOG — new "Skills Pinning" section after MCP Pinning explicitly calling out Node9 as the first runtime defense at the skills layer; v1.10.0 CHANGELOG entry documenting all security properties.

Security properties

  • Fail-closed on corrupt skill-pins.json — only recovery is node9 skill pin reset
  • Symlink-safelstat + explicit isSymbolicLink() check; symlinked roots and child symlinks are treated as missing / skipped, never followed out of the intended tree
  • Size-bounded — any single root capped at 5000 files / 50 MB total to defeat pathological skill directories
  • Path-traversal-safe session IDs — anything outside the allowed charset silently disables the skill check for that payload
  • Atomic writes, mode 0o600 — pin file and every session flag use the same randomBytes().tmp → rename pattern and owner-only permissions already shipping for mcp-pins.json

Test plan

  • src/__tests__/skill-pin.unit.test.ts36 tests covering hash contract, order-invariance, file/dir add/remove/modify sensitivity, symlink safety, mode 0o600, fail-closed semantics, pin diff
  • src/__tests__/skill-pin-cli.integration.test.ts7 tests (spawnSync against dist/cli.js) covering list empty/populated/corrupt, update unknown/with-diff, reset full wipe
  • src/__tests__/check-skill-pin.integration.test.ts8 tests (spawnSync) covering first-call-pins, short-circuit, fresh-session verify, drift blocks, quarantine persists, corrupt fails closed, missing session_id skips, relative cwd scoped to global roots
  • src/__tests__/skill-roots-config.spec.ts4 tests covering default empty, merge, dedup, defensive filter
  • npm run typecheck clean
  • npm run lint clean
  • npm run format:check clean
  • npm run build clean
  • npm test1195 / 1196 pass; the one failure is a pre-existing environmental flake in hud.spec.ts unrelated to this change (fails whenever ~/.claude/CLAUDE.md exists on the test machine; passes cleanly under an isolated HOME)
  • End-to-end manual smoke verified: first call pins + allows (exit 0), skill pin list shows roots, tamper + new session blocks with JSON decision: "block" (exit 2) and recovery message names the exact node9 skill pin update <rootKey> command

🤖 Generated with Claude Code

…AST 07)

Extends the MCP tool pinning primitive (v1.5.0 / PR #81) to agent skill
repositories. On the first tool call of a session, SHA-256 hashes are
recorded for every skill file in known roots (~/.claude/skills/,
~/.claude/CLAUDE.md, ~/.claude/rules/, project .claude/CLAUDE.md,
.claude/CLAUDE.local.md, .claude/rules/, .cursor/rules/, AGENTS.md,
CLAUDE.md). On subsequent sessions the hook re-verifies; any drift
quarantines the session and blocks every tool call until a human
reviews via `node9 skill pin update <rootKey>`.

One feature, two threats covered in a single primitive — AST 02
Supply Chain Compromise and AST 07 Update Drift — at the skills
layer that no competitor currently defends at runtime.

Security properties:
- Fail-closed on corrupt pin file
- Symlink-safe (never follows symlinks out of the tree)
- Size-capped at 5000 files / 50 MB per root
- Path-traversal-safe session IDs (/^[A-Za-z0-9_-]{1,128}$/)
- Atomic writes, mode 0o600 for ~/.node9/skill-pins.json and flags

CLI:
- `node9 skill pin list` — show pinned roots, hashes, file counts
- `node9 skill pin update <rootKey> [--yes]` — diff + re-pin
- `node9 skill pin reset` — clear pins AND wipe session flags

Config:
- `policy.skillRoots: string[]` extends the default root set (absolute,
  `~/`-prefixed, or cwd-relative; relative paths require absolute cwd).

Tests (TDD, all green):
- src/__tests__/skill-pin.unit.test.ts         (36 tests)
- src/__tests__/skill-pin-cli.integration.test.ts (7 tests, spawnSync)
- src/__tests__/check-skill-pin.integration.test.ts (8 tests, spawnSync)
- src/__tests__/skill-roots-config.spec.ts     (4 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@andreykh89
Copy link
Copy Markdown
Contributor Author

Closing and reopening from my personal fork (Xaik89) instead of the work account. Same commit, same diff — see the replacement PR link below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants