From 0aa9fa8b16c0aaefccbd8bdd24f3291f835c26be Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 27 May 2026 11:12:50 -0400 Subject: [PATCH 1/3] chore(wheelhouse): cascade template@85d360d Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-bin-1440. 359 file(s) touched: - .claude/commands/audit-gha-settings.md - .claude/commands/green-ci.md - .claude/hooks/_shared/README.md - .claude/hooks/_shared/acorn/acorn-bindgen.cjs - .claude/hooks/_shared/fleet-repos.mts - .claude/hooks/_shared/shell-command.mts - .claude/hooks/_shared/test/fleet-repos.test.mts - .claude/hooks/_shared/test/shell-command.test.mts - .claude/hooks/_shared/token-patterns.mts - .claude/hooks/_shared/transcript.mts - .claude/hooks/actionlint-on-workflow-edit/index.mts - .claude/hooks/actionlint-on-workflow-edit/test/index.test.mts - .claude/hooks/ask-suppression-reminder/test/index.test.mts - .claude/hooks/auth-rotation-reminder/index.mts - .claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts - .claude/hooks/check-new-deps/audit.mts - .claude/hooks/check-new-deps/index.mts - .claude/hooks/claude-md-section-size-guard/README.md - .claude/hooks/claude-md-section-size-guard/test/index.test.mts - .claude/hooks/claude-md-size-guard/README.md ... and 339 more --- .claude/commands/audit-gha-settings.md | 3 +- .claude/commands/green-ci.md | 2 +- .claude/hooks/_shared/README.md | 4 +- .claude/hooks/_shared/acorn/acorn-bindgen.cjs | 1442 ++++++++++------- .claude/hooks/_shared/fleet-repos.mts | 75 + .claude/hooks/_shared/shell-command.mts | 254 +++ .../hooks/_shared/test/fleet-repos.test.mts | 97 ++ .../hooks/_shared/test/shell-command.test.mts | 140 ++ .claude/hooks/_shared/token-patterns.mts | 5 +- .claude/hooks/_shared/transcript.mts | 36 +- .../actionlint-on-workflow-edit/index.mts | 136 +- .../test/index.test.mts | 10 +- .../test/index.test.mts | 7 +- .../hooks/auth-rotation-reminder/index.mts | 6 +- .../test/auth-rotation-reminder.test.mts | 9 +- .claude/hooks/check-new-deps/audit.mts | 4 +- .claude/hooks/check-new-deps/index.mts | 2 +- .../claude-md-section-size-guard/README.md | 4 +- .../test/index.test.mts | 7 +- .claude/hooks/claude-md-size-guard/README.md | 6 +- .claude/hooks/claude-md-size-guard/index.mts | 120 +- .../claude-md-size-guard/test/index.test.mts | 81 +- .claude/hooks/codex-no-write-guard/index.mts | 20 +- .../codex-no-write-guard/test/index.test.mts | 36 +- .claude/hooks/comment-tone-reminder/index.mts | 4 +- .../comment-tone-reminder/test/index.test.mts | 4 +- .claude/hooks/commit-author-guard/index.mts | 8 +- .../commit-author-guard/test/index.test.mts | 4 +- .../commit-pr-reminder/test/index.test.mts | 4 +- .../test/index.test.mts | 4 +- .../concurrent-cargo-build-guard/index.mts | 34 +- .../test/index.test.mts | 7 +- .../test/index.test.mts | 7 +- .claude/hooks/cross-repo-guard/index.mts | 20 +- .../test/cross-repo-guard.test.mts | 7 +- .claude/hooks/default-branch-guard/index.mts | 11 +- .../default-branch-guard/test/index.test.mts | 4 +- .../dirty-worktree-on-stop-reminder/README.md | 48 + .../dirty-worktree-on-stop-reminder/index.mts | 154 ++ .../package.json | 15 + .../test/index.test.mts | 94 ++ .../tsconfig.json | 16 + .../test/index.test.mts | 5 +- .claude/hooks/drift-check-reminder/README.md | 4 +- .claude/hooks/drift-check-reminder/index.mts | 6 +- .../drift-check-reminder/test/index.test.mts | 4 +- .../README.md | 50 + .../index.mts | 247 +++ .../package.json | 15 + .../test/index.test.mts | 164 ++ .../tsconfig.json | 16 + .../error-message-quality-reminder/index.mts | 12 +- .../test/index.test.mts | 4 +- .claude/hooks/excuse-detector/index.mts | 14 +- .../hooks/excuse-detector/test/index.test.mts | 7 +- .../file-size-reminder/test/index.test.mts | 4 +- .../README.md | 44 + .../index.mts | 309 ++++ .../package.json | 15 + .../test/index.test.mts | 111 ++ .../tsconfig.json | 16 + .../hooks/gh-token-hygiene-guard/README.md | 237 +++ .../hooks/gh-token-hygiene-guard/index.mts | 831 ++++++++++ .../hooks/gh-token-hygiene-guard/package.json | 15 + .../test/index.test.mts | 384 +++++ .../gh-token-hygiene-guard/tsconfig.json | 16 + .../test/index.test.mts | 4 +- .../identifying-users-reminder/index.mts | 6 +- .../test/index.test.mts | 4 +- .../immutable-release-pattern-guard/README.md | 57 + .../immutable-release-pattern-guard/index.mts | 190 +++ .../package.json | 15 + .../test/index.test.mts | 152 ++ .../tsconfig.json | 16 + .../test/index.test.mts | 7 +- .../judgment-reminder/test/index.test.mts | 4 +- .../lock-step-ref-guard/test/index.test.mts | 5 +- .claude/hooks/logger-guard/index.mts | 23 +- .../logger-guard/test/logger-guard.test.mts | 7 +- .../hooks/markdown-filename-guard/index.mts | 16 +- .../test/index.test.mts | 44 +- .../test/index.test.mts | 5 +- .../minify-mcp-output/test/index.test.mts | 4 +- .../test/index.test.mts | 7 +- .../test/index.test.mts | 4 +- .../no-blind-keychain-read-guard/README.md | 65 + .../no-blind-keychain-read-guard/index.mts | 229 +++ .../no-blind-keychain-read-guard/package.json | 15 + .../test/index.test.mts | 142 ++ .../tsconfig.json | 16 + .claude/hooks/no-empty-commit-guard/README.md | 40 + .claude/hooks/no-empty-commit-guard/index.mts | 136 ++ .../hooks/no-empty-commit-guard/package.json | 15 + .../no-empty-commit-guard/test/index.test.mts | 134 ++ .../hooks/no-empty-commit-guard/tsconfig.json | 16 + .../index.mts | 42 +- .../test/index.test.mts | 7 +- .../test/index.test.mts | 8 +- .claude/hooks/no-fleet-fork-guard/index.mts | 16 + .../no-fleet-fork-guard/test/index.test.mts | 24 +- .../test/index.test.mts | 7 +- .../hooks/no-non-fleet-push-guard/README.md | 81 + .../hooks/no-non-fleet-push-guard/index.mts | 173 ++ .../no-non-fleet-push-guard/package.json | 15 + .../test/index.test.mts | 167 ++ .../no-non-fleet-push-guard/tsconfig.json | 16 + .claude/hooks/no-orphaned-staging/index.mts | 2 +- .../no-orphaned-staging/test/index.test.mts | 6 +- .../README.md | 55 + .../index.mts | 179 ++ .../package.json | 15 + .../test/index.test.mts | 147 ++ .../tsconfig.json | 16 + .claude/hooks/no-revert-guard/index.mts | 114 +- .../hooks/no-revert-guard/test/index.test.mts | 138 +- .../test/index.test.mts | 7 +- .../hooks/no-token-in-dotenv-guard/index.mts | 2 +- .../test/index.test.mts | 7 +- .../no-underscore-identifier-guard/index.mts | 21 +- .../test/index.test.mts | 7 +- .../node-modules-staging-guard/index.mts | 6 +- .../test/index.test.mts | 7 +- .../hooks/overeager-staging-guard/index.mts | 73 +- .../test/index.test.mts | 18 +- .claude/hooks/path-guard/README.md | 9 +- .claude/hooks/path-guard/index.mts | 4 +- .claude/hooks/path-guard/segments.mts | 2 - .../hooks/path-guard/test/path-guard.test.mts | 5 +- .../hooks/paths-mts-inherit-guard/index.mts | 4 +- .../test/index.test.mts | 5 +- .../hooks/perfectionist-reminder/index.mts | 12 +- .../test/index.test.mts | 4 +- .../plan-location-guard/test/index.test.mts | 7 +- .claude/hooks/plan-review-reminder/README.md | 2 +- .claude/hooks/plan-review-reminder/index.mts | 8 +- .../plan-review-reminder/test/index.test.mts | 4 +- .claude/hooks/pointer-comment-guard/index.mts | 2 +- .../pointer-comment-guard/test/index.test.mts | 5 +- .../pr-vs-push-default-reminder/index.mts | 6 +- .../test/index.test.mts | 10 +- .../prefer-rebase-over-revert-guard/index.mts | 36 +- .../test/index.test.mts | 19 +- .claude/hooks/private-name-guard/index.mts | 8 +- .../test/private-name-guard.test.mts | 7 +- .../hooks/public-surface-reminder/index.mts | 8 +- .../test/public-surface-reminder.test.mts | 7 +- .../test/index.test.mts | 7 +- .../hooks/readme-fleet-shape-guard/README.md | 36 + .../hooks/readme-fleet-shape-guard/index.mts | 334 ++++ .../readme-fleet-shape-guard/package.json | 18 + .../test/index.test.mts | 139 ++ .../readme-fleet-shape-guard/tsconfig.json | 16 + .../hooks/release-workflow-guard/index.mts | 174 +- .../test/release-workflow-guard.test.mts | 29 +- .../scan-label-in-commit-guard/README.md | 53 + .../scan-label-in-commit-guard/index.mts | 257 +++ .../scan-label-in-commit-guard/package.json | 15 + .../test/index.test.mts | 177 ++ .../scan-label-in-commit-guard/tsconfig.json | 16 + .claude/hooks/setup-basics-tools/README.md | 23 + .claude/hooks/setup-basics-tools/install.mts | 53 + .claude/hooks/setup-basics-tools/package.json | 16 + .../hooks/setup-basics-tools/tsconfig.json | 16 + .claude/hooks/setup-claude-scanners/README.md | 39 + .../hooks/setup-claude-scanners/install.mts | 45 + .../hooks/setup-claude-scanners/package.json | 16 + .../hooks/setup-claude-scanners/tsconfig.json | 16 + .claude/hooks/setup-firewall/README.md | 40 + .claude/hooks/setup-firewall/install.mts | 79 + .claude/hooks/setup-firewall/package.json | 16 + .claude/hooks/setup-firewall/tsconfig.json | 16 + .claude/hooks/setup-misc-tools/README.md | 21 + .claude/hooks/setup-misc-tools/install.mts | 48 + .claude/hooks/setup-misc-tools/package.json | 16 + .claude/hooks/setup-misc-tools/tsconfig.json | 16 + .claude/hooks/setup-security-tools/index.mts | 2 +- .../hooks/setup-security-tools/install.mts | 215 +-- .../setup-security-tools/lib/api-token.mts | 99 +- .../setup-security-tools/lib/installers.mts | 128 +- .../lib/operator-prompts.mts | 220 +++ .../lib/token-storage.mts | 13 +- .../test/setup-security-tools.test.mts | 7 +- .claude/hooks/setup-signing/README.md | 60 + .claude/hooks/setup-signing/install.mts | 287 ++++ .claude/hooks/setup-signing/package.json | 15 + .claude/hooks/setup-signing/tsconfig.json | 16 + .../index.mts | 7 + .../test/index.test.mts | 7 +- .../socket-token-minifier-start/index.mts | 4 +- .../hooks/squash-history-reminder/README.md | 36 + .../hooks/squash-history-reminder/index.mts | 220 +++ .../squash-history-reminder/package.json | 18 + .../test/index.test.mts | 51 + .../squash-history-reminder/tsconfig.json | 16 + .claude/hooks/stale-process-sweeper/index.mts | 2 +- .../test/stale-process-sweeper.test.mts | 7 +- .claude/hooks/sweep-ds-store/README.md | 45 + .claude/hooks/sweep-ds-store/index.mts | 152 ++ .claude/hooks/sweep-ds-store/package.json | 15 + .../hooks/sweep-ds-store/test/index.test.mts | 115 ++ .claude/hooks/sweep-ds-store/tsconfig.json | 16 + .claude/hooks/token-guard/README.md | 24 +- .claude/hooks/token-guard/index.mts | 13 +- .../token-guard/test/token-guard.test.mts | 39 +- .../test/index.test.mts | 4 +- .../index.mts | 4 +- .../test/index.test.mts | 14 +- .../hooks/version-bump-order-guard/index.mts | 26 +- .../test/index.test.mts | 4 +- .../test/index.test.mts | 7 +- .../test/index.test.mts | 4 +- .../test/index.test.mts | 7 +- .claude/settings.json | 69 + .../_shared/scripts/git-default-branch.mts | 3 +- .../_shared/scripts/logger-guardrails.mts | 4 +- .../skills/_shared/scripts/resolve-tools.mts | 2 +- .claude/skills/_shared/variant-analysis.md | 2 +- .claude/skills/auditing-gha-settings/SKILL.md | 33 +- .claude/skills/auditing-gha-settings/run.mts | 4 +- .claude/skills/cascading-fleet/SKILL.md | 26 +- .../cascading-fleet/lib/cascade-template.mts | 332 ++++ .../cascading-fleet/lib/fleet-repos.json | 58 + .../cascading-fleet/lib/fleet-repos.txt | 5 +- .claude/skills/cleaning-redundant-ci/SKILL.md | 120 ++ .claude/skills/driving-cursor-bugbot/SKILL.md | 8 +- .claude/skills/greening-ci/SKILL.md | 32 +- .claude/skills/greening-ci/run.mts | 4 +- .claude/skills/guarding-paths/SKILL.md | 22 +- .../locking-down-programmatic-claude/SKILL.md | 57 +- .claude/skills/prose/SKILL.md | 116 ++ .claude/skills/prose/references/examples.md | 69 + .claude/skills/prose/references/phrases.md | 154 ++ .claude/skills/prose/references/structures.md | 201 +++ .claude/skills/refreshing-history/SKILL.md | 12 +- .claude/skills/refreshing-history/run.mts | 8 +- .claude/skills/reviewing-code/SKILL.md | 8 +- .claude/skills/reviewing-code/run.mts | 9 +- .claude/skills/running-test262/SKILL.md | 110 +- .claude/skills/scanning-quality/SKILL.md | 31 +- .claude/skills/scanning-security/SKILL.md | 12 +- .claude/skills/updating-coverage/SKILL.md | 116 ++ .claude/skills/updating-lockstep/SKILL.md | 28 +- .claude/skills/updating-security/SKILL.md | 37 +- .claude/skills/updating-security/reference.md | 356 +++- .claude/skills/updating/SKILL.md | 48 +- .claude/skills/updating/reference.md | 40 +- .claude/skills/worktree-management/SKILL.md | 32 +- .config/.markdownlint-cli2.jsonc | 51 + .config/.prettierignore | 14 + .../_shared/wheelhouse-self-skip.mjs | 40 + .../socket-no-private-wheelhouse-leak.mjs | 61 + .../socket-no-relative-sibling-script.mjs | 67 + .../socket-readme-required-sections.mjs | 93 ++ .config/oxlint-plugin/index.mts | 14 +- .config/oxlint-plugin/lib/fleet-paths.mts | 15 + .config/oxlint-plugin/lib/rule-tester.mts | 206 ++- .../oxlint-plugin/rules/_inject-import.mts | 9 +- .../oxlint-plugin/rules/max-file-lines.mts | 2 +- .../rules/no-bare-crypto-named-usage.mts | 120 ++ .../rules/no-console-prefer-logger.mts | 22 +- .../oxlint-plugin/rules/no-inline-logger.mts | 11 +- .../rules/no-logger-newline-literal.mts | 2 +- .../rules/no-src-import-in-test-expect.mts | 202 +++ .../oxlint-plugin/rules/no-status-emoji.mts | 8 +- .../rules/no-sync-rm-in-test-lifecycle.mts | 113 +- .../rules/prefer-async-spawn.mts | 30 +- .../rules/prefer-cached-for-loop.mts | 10 +- .../rules/prefer-non-capturing-group.mts | 279 ++++ .../rules/prefer-safe-delete.mts | 20 +- .../rules/prefer-spawn-over-execsync.mts | 16 +- .../rules/prefer-stable-self-import.mts | 140 ++ .../rules/socket-api-token-env.mts | 36 +- .../rules/sort-boolean-chains.mts | 33 +- .../use-fleet-canonical-api-token-getter.mts | 25 +- .../test/no-console-prefer-logger.test.mts | 2 +- .../test/no-inline-logger.test.mts | 4 +- .../no-src-import-in-test-expect.test.mts | 66 + .../test/no-underscore-identifier.test.mts | 10 +- .../test/prefer-async-spawn.test.mts | 6 +- .../test/prefer-cached-for-loop.test.mts | 8 + .../test/prefer-non-capturing-group.test.mts | 82 + .../test/prefer-spawn-over-execsync.test.mts | 27 +- .../test/prefer-stable-self-import.test.mts | 90 + .../test/socket-api-token-env.test.mts | 7 + .../test/sort-boolean-chains.test.mts | 8 + .config/socket-registry-pins.json | 6 +- .git-hooks/_helpers.mts | 294 +++- .git-hooks/_resolve-node.sh | 42 + .git-hooks/commit-msg | 6 + .git-hooks/commit-msg.mts | 27 +- .git-hooks/pre-commit | 38 +- .git-hooks/pre-commit.mts | 179 +- .git-hooks/pre-push | 6 + .git-hooks/pre-push.mts | 238 ++- .git-hooks/test/_helpers.test.mts | 229 ++- .git-hooks/test/commit-msg.test.mts | 4 +- .git-hooks/test/pre-commit.test.mts | 122 +- .git-hooks/test/pre-push.test.mts | 102 +- docs/claude.md/fleet/agent-delegation.md | 40 +- docs/claude.md/fleet/agents-and-skills.md | 20 +- docs/claude.md/fleet/bypass-phrases.md | 39 +- docs/claude.md/fleet/code-style.md | 34 +- docs/claude.md/fleet/commit-signing.md | 88 + docs/claude.md/fleet/conformance-runners.md | 198 +++ docs/claude.md/fleet/database.md | 118 ++ docs/claude.md/fleet/drift-watch.md | 40 +- docs/claude.md/fleet/error-messages.md | 53 +- docs/claude.md/fleet/file-size.md | 8 +- docs/claude.md/fleet/gh-token-hygiene.md | 138 ++ docs/claude.md/fleet/immutable-releases.md | 81 + docs/claude.md/fleet/lint-rules.md | 49 +- .../fleet/parallel-claude-sessions.md | 56 +- docs/claude.md/fleet/parser-comments.md | 26 +- docs/claude.md/fleet/path-hygiene.md | 38 + docs/claude.md/fleet/plan-storage.md | 56 +- docs/claude.md/fleet/security-stack.md | 124 ++ docs/claude.md/fleet/sorting.md | 20 +- docs/claude.md/fleet/token-hygiene.md | 38 +- docs/claude.md/fleet/tooling.md | 89 +- docs/claude.md/fleet/untracked-by-default.md | 10 +- docs/claude.md/fleet/version-bumps.md | 22 +- docs/claude.md/fleet/worktree-hygiene.md | 19 + .../wheelhouse/no-local-fork-canonical.md | 18 +- .../lib/release-checksums/consumer.mts | 30 +- .../lib/release-checksums/core.mts | 80 +- .../build-infra/release-assets.schema.json | 2 +- scripts/ai-lint-fix/cli.mts | 87 +- scripts/ai-lint-fix/rule-guidance.mts | 12 +- scripts/audit-transcript.mts | 356 ++++ scripts/check-lock-step-refs.mts | 2 +- scripts/check-paths/exempt.mts | 2 +- scripts/check-prompt-less-setup.mts | 10 +- scripts/fix.mts | 4 +- scripts/git-partial-submodule.mts | 648 ++++++++ scripts/install-claude-plugins.mts | 4 +- scripts/install-git-hooks.mts | 2 +- scripts/install-sfw.mts | 10 +- scripts/install-token-minifier.mts | 22 +- scripts/janus.mts | 4 +- scripts/lint-github-settings.mts | 149 +- scripts/lockstep/checks.mts | 8 +- scripts/lockstep/cli.mts | 2 +- scripts/lockstep/emit-schema.mts | 4 +- scripts/lockstep/git.mts | 2 +- scripts/lockstep/manifest.mts | 2 +- scripts/lockstep/report.mts | 2 +- scripts/lockstep/scan.mts | 2 +- scripts/power-state.mts | 2 +- scripts/publish.mts | 391 ++++- scripts/security.mts | 6 +- scripts/socket-wheelhouse-emit-schema.mts | 4 +- scripts/test/check-lock-step-header.test.mts | 2 +- scripts/test/check-lock-step-refs.test.mts | 2 +- scripts/test/install-git-hooks.test.mts | 4 +- scripts/update.mts | 2 +- scripts/validate-bundle-deps.mts | 15 +- scripts/validate-config-paths.mts | 2 +- scripts/validate-esbuild-minify.mts | 2 +- scripts/validate-file-size.mts | 18 +- 359 files changed, 18048 insertions(+), 2755 deletions(-) create mode 100644 .claude/hooks/_shared/fleet-repos.mts create mode 100644 .claude/hooks/_shared/shell-command.mts create mode 100644 .claude/hooks/_shared/test/fleet-repos.test.mts create mode 100644 .claude/hooks/_shared/test/shell-command.test.mts create mode 100644 .claude/hooks/dirty-worktree-on-stop-reminder/README.md create mode 100644 .claude/hooks/dirty-worktree-on-stop-reminder/index.mts create mode 100644 .claude/hooks/dirty-worktree-on-stop-reminder/package.json create mode 100644 .claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts create mode 100644 .claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json create mode 100644 .claude/hooks/enterprise-push-property-reminder/README.md create mode 100644 .claude/hooks/enterprise-push-property-reminder/index.mts create mode 100644 .claude/hooks/enterprise-push-property-reminder/package.json create mode 100644 .claude/hooks/enterprise-push-property-reminder/test/index.test.mts create mode 100644 .claude/hooks/enterprise-push-property-reminder/tsconfig.json create mode 100644 .claude/hooks/follow-direct-imperative-reminder/README.md create mode 100644 .claude/hooks/follow-direct-imperative-reminder/index.mts create mode 100644 .claude/hooks/follow-direct-imperative-reminder/package.json create mode 100644 .claude/hooks/follow-direct-imperative-reminder/test/index.test.mts create mode 100644 .claude/hooks/follow-direct-imperative-reminder/tsconfig.json create mode 100644 .claude/hooks/gh-token-hygiene-guard/README.md create mode 100644 .claude/hooks/gh-token-hygiene-guard/index.mts create mode 100644 .claude/hooks/gh-token-hygiene-guard/package.json create mode 100644 .claude/hooks/gh-token-hygiene-guard/test/index.test.mts create mode 100644 .claude/hooks/gh-token-hygiene-guard/tsconfig.json create mode 100644 .claude/hooks/immutable-release-pattern-guard/README.md create mode 100644 .claude/hooks/immutable-release-pattern-guard/index.mts create mode 100644 .claude/hooks/immutable-release-pattern-guard/package.json create mode 100644 .claude/hooks/immutable-release-pattern-guard/test/index.test.mts create mode 100644 .claude/hooks/immutable-release-pattern-guard/tsconfig.json create mode 100644 .claude/hooks/no-blind-keychain-read-guard/README.md create mode 100644 .claude/hooks/no-blind-keychain-read-guard/index.mts create mode 100644 .claude/hooks/no-blind-keychain-read-guard/package.json create mode 100644 .claude/hooks/no-blind-keychain-read-guard/test/index.test.mts create mode 100644 .claude/hooks/no-blind-keychain-read-guard/tsconfig.json create mode 100644 .claude/hooks/no-empty-commit-guard/README.md create mode 100644 .claude/hooks/no-empty-commit-guard/index.mts create mode 100644 .claude/hooks/no-empty-commit-guard/package.json create mode 100644 .claude/hooks/no-empty-commit-guard/test/index.test.mts create mode 100644 .claude/hooks/no-empty-commit-guard/tsconfig.json create mode 100644 .claude/hooks/no-non-fleet-push-guard/README.md create mode 100644 .claude/hooks/no-non-fleet-push-guard/index.mts create mode 100644 .claude/hooks/no-non-fleet-push-guard/package.json create mode 100644 .claude/hooks/no-non-fleet-push-guard/test/index.test.mts create mode 100644 .claude/hooks/no-non-fleet-push-guard/tsconfig.json create mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/README.md create mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/index.mts create mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/package.json create mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts create mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json create mode 100644 .claude/hooks/readme-fleet-shape-guard/README.md create mode 100644 .claude/hooks/readme-fleet-shape-guard/index.mts create mode 100644 .claude/hooks/readme-fleet-shape-guard/package.json create mode 100644 .claude/hooks/readme-fleet-shape-guard/test/index.test.mts create mode 100644 .claude/hooks/readme-fleet-shape-guard/tsconfig.json create mode 100644 .claude/hooks/scan-label-in-commit-guard/README.md create mode 100644 .claude/hooks/scan-label-in-commit-guard/index.mts create mode 100644 .claude/hooks/scan-label-in-commit-guard/package.json create mode 100644 .claude/hooks/scan-label-in-commit-guard/test/index.test.mts create mode 100644 .claude/hooks/scan-label-in-commit-guard/tsconfig.json create mode 100644 .claude/hooks/setup-basics-tools/README.md create mode 100644 .claude/hooks/setup-basics-tools/install.mts create mode 100644 .claude/hooks/setup-basics-tools/package.json create mode 100644 .claude/hooks/setup-basics-tools/tsconfig.json create mode 100644 .claude/hooks/setup-claude-scanners/README.md create mode 100644 .claude/hooks/setup-claude-scanners/install.mts create mode 100644 .claude/hooks/setup-claude-scanners/package.json create mode 100644 .claude/hooks/setup-claude-scanners/tsconfig.json create mode 100644 .claude/hooks/setup-firewall/README.md create mode 100644 .claude/hooks/setup-firewall/install.mts create mode 100644 .claude/hooks/setup-firewall/package.json create mode 100644 .claude/hooks/setup-firewall/tsconfig.json create mode 100644 .claude/hooks/setup-misc-tools/README.md create mode 100644 .claude/hooks/setup-misc-tools/install.mts create mode 100644 .claude/hooks/setup-misc-tools/package.json create mode 100644 .claude/hooks/setup-misc-tools/tsconfig.json create mode 100644 .claude/hooks/setup-security-tools/lib/operator-prompts.mts create mode 100644 .claude/hooks/setup-signing/README.md create mode 100644 .claude/hooks/setup-signing/install.mts create mode 100644 .claude/hooks/setup-signing/package.json create mode 100644 .claude/hooks/setup-signing/tsconfig.json create mode 100644 .claude/hooks/squash-history-reminder/README.md create mode 100644 .claude/hooks/squash-history-reminder/index.mts create mode 100644 .claude/hooks/squash-history-reminder/package.json create mode 100644 .claude/hooks/squash-history-reminder/test/index.test.mts create mode 100644 .claude/hooks/squash-history-reminder/tsconfig.json create mode 100644 .claude/hooks/sweep-ds-store/README.md create mode 100644 .claude/hooks/sweep-ds-store/index.mts create mode 100644 .claude/hooks/sweep-ds-store/package.json create mode 100644 .claude/hooks/sweep-ds-store/test/index.test.mts create mode 100644 .claude/hooks/sweep-ds-store/tsconfig.json create mode 100644 .claude/skills/cascading-fleet/lib/cascade-template.mts create mode 100644 .claude/skills/cascading-fleet/lib/fleet-repos.json create mode 100644 .claude/skills/cleaning-redundant-ci/SKILL.md create mode 100644 .claude/skills/prose/SKILL.md create mode 100644 .claude/skills/prose/references/examples.md create mode 100644 .claude/skills/prose/references/phrases.md create mode 100644 .claude/skills/prose/references/structures.md create mode 100644 .claude/skills/updating-coverage/SKILL.md create mode 100644 .config/.markdownlint-cli2.jsonc create mode 100644 .config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs create mode 100644 .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs create mode 100644 .config/markdownlint-rules/socket-no-relative-sibling-script.mjs create mode 100644 .config/markdownlint-rules/socket-readme-required-sections.mjs create mode 100644 .config/oxlint-plugin/rules/no-src-import-in-test-expect.mts create mode 100644 .config/oxlint-plugin/rules/prefer-non-capturing-group.mts create mode 100644 .config/oxlint-plugin/rules/prefer-stable-self-import.mts create mode 100644 .config/oxlint-plugin/test/no-src-import-in-test-expect.test.mts create mode 100644 .config/oxlint-plugin/test/prefer-non-capturing-group.test.mts create mode 100644 .config/oxlint-plugin/test/prefer-stable-self-import.test.mts create mode 100644 .git-hooks/_resolve-node.sh create mode 100644 docs/claude.md/fleet/commit-signing.md create mode 100644 docs/claude.md/fleet/conformance-runners.md create mode 100644 docs/claude.md/fleet/database.md create mode 100644 docs/claude.md/fleet/gh-token-hygiene.md create mode 100644 docs/claude.md/fleet/immutable-releases.md create mode 100644 docs/claude.md/fleet/path-hygiene.md create mode 100644 docs/claude.md/fleet/security-stack.md create mode 100644 docs/claude.md/fleet/worktree-hygiene.md create mode 100644 scripts/audit-transcript.mts create mode 100644 scripts/git-partial-submodule.mts diff --git a/.claude/commands/audit-gha-settings.md b/.claude/commands/audit-gha-settings.md index 7ce9368..6ce21eb 100644 --- a/.claude/commands/audit-gha-settings.md +++ b/.claude/commands/audit-gha-settings.md @@ -15,9 +15,10 @@ If no arguments given, audit the canonical fleet repo list: - `SocketDev/socket-sdk-js` - `SocketDev/socket-sdxgen` - `SocketDev/socket-stuie` +- `SocketDev/socket-vscode` +- `SocketDev/socket-webext` - `SocketDev/socket-wheelhouse` - `SocketDev/ultrathink` -- `SocketDev/vscode-socket-security` ## Process diff --git a/.claude/commands/green-ci.md b/.claude/commands/green-ci.md index 79dd94b..4e7f4d8 100644 --- a/.claude/commands/green-ci.md +++ b/.claude/commands/green-ci.md @@ -42,7 +42,7 @@ If the user types `/green-ci socket-btm ci.yml` we run `fast`. If they type `/gr ## Rules -- **Never push to a protected branch without confirming.** If the target repo blocks direct push to main, open a PR instead (use the fleet's push-or-PR pattern; see `scripts/cascade-tooling/cascade-fleet.mts` in this repo for the canonical implementation). +- **Never push to a protected branch without confirming.** If the target repo blocks direct push to main, open a PR instead (use the fleet's push-or-PR pattern; see `scripts/fleet/cascade-fleet.mts` in this repo for the canonical implementation). - **Each fix is one commit.** Don't bundle the CI fix with unrelated changes — the commit message should let a future reader understand exactly which failing step it addresses. - **Don't bump cache versions just to mask a real bug.** If the failure is a cache miss + downstream code that can't handle a fresh cache, fix the downstream code. Only bump the cache version when the cached artifact itself is staler than the source. - **Escalate, don't paper over, GH org policy failures.** "Action not allowed by enterprise admin" requires the org-level allowlist update; the repo can't fix it. Tell the user. diff --git a/.claude/hooks/_shared/README.md b/.claude/hooks/_shared/README.md index 9f8d2da..9d5a177 100644 --- a/.claude/hooks/_shared/README.md +++ b/.claude/hooks/_shared/README.md @@ -4,7 +4,7 @@ Helper modules shared across multiple hooks under `.claude/hooks/`. **Not a depl ## What lives here -- **`bash-quote-mask.mts`** — Parses a Bash command string and reports the byte ranges that sit inside single-quoted, double-quoted, or heredoc bodies. Used by `no-experimental-strip-types-guard`, `token-guard`, and similar Bash-scanning hooks to skip false positives in literal strings (e.g. `echo "tip: --experimental-strip-types is..."` should not trigger). +- **`shell-command.mts`** — Tokenizes a Bash command string with `shell-quote` into discrete `Command`s (`binary`, `args`, leading env `assignments`, plus `viaVariable` / `viaEval` indirection flags). Exposes `parseCommands`, `findInvocation`, `commandsFor`, `invocationHasFlag`, and `hasOpaqueInvocation`. Used by every structure-sensitive Bash guard (`codex-no-write-guard`, `release-workflow-guard`, `no-empty-commit-guard`, the git-detection guards, …) so a forbidden invocation is matched on the actual parsed command — `$(…)` / `$VAR` / `eval` indirection is seen rather than evaded, and a quoted mention inside an `echo` or `-m` body can't false-trigger. - **`hook-env.mts`** — `isHookDisabled(slug)` and `hookLog(slug, ...lines)`. Standardizes the `SOCKET__DISABLED` env-var convention every hook supports plus the `[] ` stderr prefix shape. Use these in new hooks so every hook gets a uniform kill switch + output format for free. @@ -26,7 +26,7 @@ Helper modules shared across multiple hooks under `.claude/hooks/`. **Not a depl - Writing a **PreToolUse hook** that inspects a tool call's input? → `import { ToolCallPayload, readCommand, readFilePath } from '../_shared/payload.mts'`. Saves you the `typeof === 'string'` guard. -- Reading the Bash command + want to skip false positives inside quoted strings? → `import { containsOutsideQuotes } from '../_shared/bash-quote-mask.mts'`. +- Detecting whether a Bash command really invokes some binary/subcommand (and want `$(…)` / `$VAR` / quoted-mention false positives handled)? → `import { commandsFor, findInvocation } from '../_shared/shell-command.mts'`. - Want a kill switch for your hook? → `import { isHookDisabled, hookLog } from '../_shared/hook-env.mts'`. The hook is enabled by default and `SOCKET__DISABLED=1` opts out — same shape across the fleet. diff --git a/.claude/hooks/_shared/acorn/acorn-bindgen.cjs b/.claude/hooks/_shared/acorn/acorn-bindgen.cjs index 23c4b87..44a5ca1 100644 --- a/.claude/hooks/_shared/acorn/acorn-bindgen.cjs +++ b/.claude/hooks/_shared/acorn/acorn-bindgen.cjs @@ -1,769 +1,993 @@ +let imports = {} +imports['__wbindgen_placeholder__'] = module.exports -let imports = {}; -imports['__wbindgen_placeholder__'] = module.exports; +let heap = new Array(128).fill(undefined) -let heap = new Array(128).fill(undefined); +heap.push(undefined, null, true, false) -heap.push(undefined, null, true, false); - -function getObject(idx) { return heap[idx]; } +function getObject(idx) { + return heap[idx] +} -let heap_next = heap.length; +let heap_next = heap.length function addHeapObject(obj) { - if (heap_next === heap.length) heap.push(heap.length + 1); - const idx = heap_next; - heap_next = heap[idx]; + if (heap_next === heap.length) heap.push(heap.length + 1) + const idx = heap_next + heap_next = heap[idx] - heap[idx] = obj; - return idx; + heap[idx] = obj + return idx } function handleError(f, args) { - try { - return f.apply(this, args); - } catch (e) { - wasm.__wbindgen_export_0(addHeapObject(e)); - } + try { + return f.apply(this, args) + } catch (e) { + wasm.__wbindgen_export_0(addHeapObject(e)) + } } -let cachedUint8ArrayMemory0 = null; +let cachedUint8ArrayMemory0 = null function getUint8ArrayMemory0() { - if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { - cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); - } - return cachedUint8ArrayMemory0; + if ( + cachedUint8ArrayMemory0 === null || + cachedUint8ArrayMemory0.byteLength === 0 + ) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer) + } + return cachedUint8ArrayMemory0 } -let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +let cachedTextDecoder = new TextDecoder('utf-8', { + ignoreBOM: true, + fatal: true, +}) -cachedTextDecoder.decode(); +cachedTextDecoder.decode() function decodeText(ptr, len) { - return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); + return cachedTextDecoder.decode( + getUint8ArrayMemory0().subarray(ptr, ptr + len), + ) } function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0; - return decodeText(ptr, len); + ptr = ptr >>> 0 + return decodeText(ptr, len) } function isLikeNone(x) { - return x === undefined || x === null; + return x === undefined || x === null } function debugString(val) { - // primitive types - const type = typeof val; - if (type == 'number' || type == 'boolean' || val == null) { - return `${val}`; - } - if (type == 'string') { - return `"${val}"`; - } - if (type == 'symbol') { - const description = val.description; - if (description == null) { - return 'Symbol'; - } else { - return `Symbol(${description})`; - } - } - if (type == 'function') { - const name = val.name; - if (typeof name == 'string' && name.length > 0) { - return `Function(${name})`; - } else { - return 'Function'; - } - } - // objects - if (Array.isArray(val)) { - const length = val.length; - let debug = '['; - if (length > 0) { - debug += debugString(val[0]); - } - for(let i = 1; i < length; i++) { - debug += ', ' + debugString(val[i]); - } - debug += ']'; - return debug; - } - // Test for built-in - const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); - let className; - if (builtInMatches && builtInMatches.length > 1) { - className = builtInMatches[1]; + // primitive types + const type = typeof val + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}` + } + if (type == 'string') { + return `"${val}"` + } + if (type == 'symbol') { + const description = val.description + if (description == null) { + return 'Symbol' } else { - // Failed to match the standard '[object ClassName]' - return toString.call(val); - } - if (className == 'Object') { - // we're a user defined class or Object - // JSON.stringify avoids problems with cycles, and is generally much - // easier than looping through ownProperties of `val`. - try { - return 'Object(' + JSON.stringify(val) + ')'; - } catch (_) { - return 'Object'; - } + return `Symbol(${description})` } - // errors - if (val instanceof Error) { - return `${val.name}: ${val.message}\n${val.stack}`; - } - // TODO we could test for more things here, like `Set`s and `Map`s. - return className; + } + if (type == 'function') { + const name = val.name + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})` + } else { + return 'Function' + } + } + // objects + if (Array.isArray(val)) { + const length = val.length + let debug = '[' + if (length > 0) { + debug += debugString(val[0]) + } + for (let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]) + } + debug += ']' + return debug + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)) + let className + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1] + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val) + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')' + } catch (_) { + return 'Object' + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}` + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className } -let WASM_VECTOR_LEN = 0; +let WASM_VECTOR_LEN = 0 -const cachedTextEncoder = new TextEncoder(); +const cachedTextEncoder = new TextEncoder() if (!('encodeInto' in cachedTextEncoder)) { - cachedTextEncoder.encodeInto = function (arg, view) { - const buf = cachedTextEncoder.encode(arg); - view.set(buf); - return { - read: arg.length, - written: buf.length - }; - } + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg) + view.set(buf) + return { + read: arg.length, + written: buf.length, + } + } } function passStringToWasm0(arg, malloc, realloc) { - - if (realloc === undefined) { - const buf = cachedTextEncoder.encode(arg); - const ptr = malloc(buf.length, 1) >>> 0; - getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); - WASM_VECTOR_LEN = buf.length; - return ptr; - } - - let len = arg.length; - let ptr = malloc(len, 1) >>> 0; - - const mem = getUint8ArrayMemory0(); - - let offset = 0; - - for (; offset < len; offset++) { - const code = arg.charCodeAt(offset); - if (code > 0x7F) break; - mem[ptr + offset] = code; - } - - if (offset !== len) { - if (offset !== 0) { - arg = arg.slice(offset); - } - ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; - const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); - const ret = cachedTextEncoder.encodeInto(arg, view); - - offset += ret.written; - ptr = realloc(ptr, len, offset, 1) >>> 0; - } - - WASM_VECTOR_LEN = offset; - return ptr; + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg) + const ptr = malloc(buf.length, 1) >>> 0 + getUint8ArrayMemory0() + .subarray(ptr, ptr + buf.length) + .set(buf) + WASM_VECTOR_LEN = buf.length + return ptr + } + + let len = arg.length + let ptr = malloc(len, 1) >>> 0 + + const mem = getUint8ArrayMemory0() + + let offset = 0 + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset) + if (code > 0x7f) break + mem[ptr + offset] = code + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset) + } + ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0 + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len) + const ret = cachedTextEncoder.encodeInto(arg, view) + + offset += ret.written + ptr = realloc(ptr, len, offset, 1) >>> 0 + } + + WASM_VECTOR_LEN = offset + return ptr } -let cachedDataViewMemory0 = null; +let cachedDataViewMemory0 = null function getDataViewMemory0() { - if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { - cachedDataViewMemory0 = new DataView(wasm.memory.buffer); - } - return cachedDataViewMemory0; + if ( + cachedDataViewMemory0 === null || + cachedDataViewMemory0.buffer.detached === true || + (cachedDataViewMemory0.buffer.detached === undefined && + cachedDataViewMemory0.buffer !== wasm.memory.buffer) + ) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer) + } + return cachedDataViewMemory0 } function dropObject(idx) { - if (idx < 132) return; - heap[idx] = heap_next; - heap_next = idx; + if (idx < 132) return + heap[idx] = heap_next + heap_next = idx } function takeObject(idx) { - const ret = getObject(idx); - dropObject(idx); - return ret; + const ret = getObject(idx) + dropObject(idx) + return ret } /** - * Parse `source`, compile `selector`, run the matcher, return a - * JSON-encoded result string. Meant to be called from JavaScript as: + * Parse `source`, compile `selector`, run the matcher, return a JSON-encoded + * result string. Meant to be called from JavaScript as: * * const result = JSON.parse(aqs_match(source, selector)) + * * @param {string} source * @param {string} selector + * * @returns {string} */ -exports.aqs_match = function(source, selector) { - let deferred3_0; - let deferred3_1; - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(source, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(selector, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len1 = WASM_VECTOR_LEN; - wasm.aqs_match(retptr, ptr0, len0, ptr1, len1); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - deferred3_0 = r0; - deferred3_1 = r1; - return getStringFromWasm0(r0, r1); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - wasm.__wbindgen_export_3(deferred3_0, deferred3_1, 1); - } -}; +exports.aqs_match = function (source, selector) { + let deferred3_0 + let deferred3_1 + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + source, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + const ptr1 = passStringToWasm0( + selector, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len1 = WASM_VECTOR_LEN + wasm.aqs_match(retptr, ptr0, len0, ptr1, len1) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + deferred3_0 = r0 + deferred3_1 = r1 + return getStringFromWasm0(r0, r1) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + wasm.__wbindgen_export_3(deferred3_0, deferred3_1, 1) + } +} /** * Standalone parse function (matches Acorn API) + * * @param {string} code * @param {any} options + * * @returns {any} */ -exports.parse = function(code, options) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.parse(retptr, ptr0, len0, addHeapObject(options)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.parse = function (code, options) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.parse(retptr, ptr0, len0, addHeapObject(options)) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** * Check if code has syntax errors (returns true if valid) + * * @param {string} code + * * @returns {boolean} */ -exports.is_valid = function(code) { - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.is_valid(ptr0, len0); - return ret !== 0; -}; +exports.is_valid = function (code) { + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + const ret = wasm.is_valid(ptr0, len0) + return ret !== 0 +} /** - * Get version information + * Get version information. + * * @returns {string} */ -exports.version = function() { - let deferred1_0; - let deferred1_1; - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - wasm.version(retptr); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - deferred1_0 = r0; - deferred1_1 = r1; - return getStringFromWasm0(r0, r1); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - wasm.__wbindgen_export_3(deferred1_0, deferred1_1, 1); - } -}; +exports.version = function () { + let deferred1_0 + let deferred1_1 + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + wasm.version(retptr) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + deferred1_0 = r0 + deferred1_1 = r1 + return getStringFromWasm0(r0, r1) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + wasm.__wbindgen_export_3(deferred1_0, deferred1_1, 1) + } +} /** - * Find innermost node containing position + * Find innermost node containing position. + * * @param {string} code * @param {number} pos * @param {string | null | undefined} node_type * @param {any} options_js + * * @returns {any} */ -exports.findNodeAround = function(code, pos, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - var ptr1 = isLikeNone(node_type) ? 0 : passStringToWasm0(node_type, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - var len1 = WASM_VECTOR_LEN; - wasm.findNodeAround(retptr, ptr0, len0, pos, ptr1, len1, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.findNodeAround = function (code, pos, node_type, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + var ptr1 = isLikeNone(node_type) + ? 0 + : passStringToWasm0( + node_type, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + var len1 = WASM_VECTOR_LEN + wasm.findNodeAround( + retptr, + ptr0, + len0, + pos, + ptr1, + len1, + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Find first node starting at or after position + * Find first node starting at or after position. + * * @param {string} code * @param {number} pos * @param {string | null | undefined} node_type * @param {any} options_js + * * @returns {any} */ -exports.findNodeAfter = function(code, pos, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - var ptr1 = isLikeNone(node_type) ? 0 : passStringToWasm0(node_type, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - var len1 = WASM_VECTOR_LEN; - wasm.findNodeAfter(retptr, ptr0, len0, pos, ptr1, len1, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.findNodeAfter = function (code, pos, node_type, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + var ptr1 = isLikeNone(node_type) + ? 0 + : passStringToWasm0( + node_type, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + var len1 = WASM_VECTOR_LEN + wasm.findNodeAfter( + retptr, + ptr0, + len0, + pos, + ptr1, + len1, + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Find outermost node ending before position + * Find outermost node ending before position. + * * @param {string} code * @param {number} pos * @param {string | null | undefined} node_type * @param {any} options_js + * * @returns {any} */ -exports.findNodeBefore = function(code, pos, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - var ptr1 = isLikeNone(node_type) ? 0 : passStringToWasm0(node_type, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - var len1 = WASM_VECTOR_LEN; - wasm.findNodeBefore(retptr, ptr0, len0, pos, ptr1, len1, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.findNodeBefore = function (code, pos, node_type, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + var ptr1 = isLikeNone(node_type) + ? 0 + : passStringToWasm0( + node_type, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + var len1 = WASM_VECTOR_LEN + wasm.findNodeBefore( + retptr, + ptr0, + len0, + pos, + ptr1, + len1, + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Simple walk - parse code and call visitor for each node type + * Simple walk - parse code and call visitor for each node type. + * * @param {string} code * @param {any} visitors_obj * @param {any} options_js */ -exports.simple = function(code, visitors_obj, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.simple(retptr, ptr0, len0, addHeapObject(visitors_obj), addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - if (r1) { - throw takeObject(r0); - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.simple = function (code, visitors_obj, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.simple( + retptr, + ptr0, + len0, + addHeapObject(visitors_obj), + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + if (r1) { + throw takeObject(r0) + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Walk with ancestors + * Walk with ancestors. + * * @param {string} code * @param {any} visitors_obj * @param {any} options_js */ -exports.walk = function(code, visitors_obj, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.walk(retptr, ptr0, len0, addHeapObject(visitors_obj), addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - if (r1) { - throw takeObject(r0); - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.walk = function (code, visitors_obj, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.walk( + retptr, + ptr0, + len0, + addHeapObject(visitors_obj), + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + if (r1) { + throw takeObject(r0) + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Full walk with enter/exit + * Full walk with enter/exit. + * * @param {string} code * @param {any} visitors_obj * @param {any} options_js */ -exports.full = function(code, visitors_obj, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.full(retptr, ptr0, len0, addHeapObject(visitors_obj), addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - if (r1) { - throw takeObject(r0); - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.full = function (code, visitors_obj, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.full( + retptr, + ptr0, + len0, + addHeapObject(visitors_obj), + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + if (r1) { + throw takeObject(r0) + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** * Recursive walk — visitor controls child traversal via c(child, state) + * * @param {string} code * @param {any} state * @param {any} funcs * @param {any} options_js */ -exports.recursive = function(code, state, funcs, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.recursive(retptr, ptr0, len0, addHeapObject(state), addHeapObject(funcs), addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - if (r1) { - throw takeObject(r0); - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.recursive = function (code, state, funcs, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.recursive( + retptr, + ptr0, + len0, + addHeapObject(state), + addHeapObject(funcs), + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + if (r1) { + throw takeObject(r0) + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Find all nodes matching a type string + * Find all nodes matching a type string. + * * @param {string} code * @param {string} node_type * @param {any} options_js + * * @returns {any} */ -exports.findAll = function(code, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(node_type, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len1 = WASM_VECTOR_LEN; - wasm.findAll(retptr, ptr0, len0, ptr1, len1, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.findAll = function (code, node_type, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + const ptr1 = passStringToWasm0( + node_type, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len1 = WASM_VECTOR_LEN + wasm.findAll(retptr, ptr0, len0, ptr1, len1, addHeapObject(options_js)) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Count nodes by type + * Count nodes by type. + * * @param {string} code * @param {any} options_js + * * @returns {any} */ -exports.countNodes = function(code, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.countNodes(retptr, ptr0, len0, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.countNodes = function (code, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.countNodes(retptr, ptr0, len0, addHeapObject(options_js)) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Walk all nodes, calling callback with (node, ancestors) for every node + * Walk all nodes, calling callback with (node, ancestors) for every node. + * * @param {string} code * @param {any} callback * @param {any} options_js */ -exports.fullAncestor = function(code, callback, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.fullAncestor(retptr, ptr0, len0, addHeapObject(callback), addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - if (r1) { - throw takeObject(r0); - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -}; +exports.fullAncestor = function (code, callback, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.fullAncestor( + retptr, + ptr0, + len0, + addHeapObject(callback), + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + if (r1) { + throw takeObject(r0) + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} /** - * Find innermost node at exact start/end position + * Find innermost node at exact start/end position. + * * @param {string} code * @param {number | null | undefined} start * @param {number | null | undefined} end * @param {string | null | undefined} node_type * @param {any} options_js + * * @returns {any} */ -exports.findNodeAt = function(code, start, end, node_type, options_js) { +exports.findNodeAt = function (code, start, end, node_type, options_js) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + var ptr1 = isLikeNone(node_type) + ? 0 + : passStringToWasm0( + node_type, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + var len1 = WASM_VECTOR_LEN + wasm.findNodeAt( + retptr, + ptr0, + len0, + isLikeNone(start) ? 0x100000001 : start >>> 0, + isLikeNone(end) ? 0x100000001 : end >>> 0, + ptr1, + len1, + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) + } finally { + wasm.__wbindgen_add_to_stack_pointer(16) + } +} + +const WasmParserFinalization = + typeof FinalizationRegistry === 'undefined' + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_wasmparser_free(ptr >>> 0, 1)) + +class WasmParser { + __destroy_into_raw() { + const ptr = this.__wbg_ptr + this.__wbg_ptr = 0 + WasmParserFinalization.unregister(this) + return ptr + } + + free() { + const ptr = this.__destroy_into_raw() + wasm.__wbg_wasmparser_free(ptr, 0) + } + constructor() { + const ret = wasm.wasmparser_new() + this.__wbg_ptr = ret >>> 0 + WasmParserFinalization.register(this, this.__wbg_ptr, this) + return this + } + /** + * Parse JavaScript code and return AST as JsValue (WASM) or JSON string + * (native). + * + * The WASM path goes: options_js (JS object) → options_from_jsvalue + * (Reflect-based reads, no serde_json) → parser → JSON string → JSON::parse + * (one cheap JS-side parse) → JsValue handed back to JS as the AST root. + * + * @param {string} code + * @param {any} options_js + * + * @returns {any} + */ + parse(code, options_js) { try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - var ptr1 = isLikeNone(node_type) ? 0 : passStringToWasm0(node_type, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - var len1 = WASM_VECTOR_LEN; - wasm.findNodeAt(retptr, ptr0, len0, isLikeNone(start) ? 0x100000001 : (start) >>> 0, isLikeNone(end) ? 0x100000001 : (end) >>> 0, ptr1, len1, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) + const ptr0 = passStringToWasm0( + code, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len0 = WASM_VECTOR_LEN + wasm.wasmparser_parse( + retptr, + this.__wbg_ptr, + ptr0, + len0, + addHeapObject(options_js), + ) + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) + if (r2) { + throw takeObject(r1) + } + return takeObject(r0) } finally { - wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_add_to_stack_pointer(16) } -}; + } +} +if (Symbol.dispose) + WasmParser.prototype[Symbol.dispose] = WasmParser.prototype.free + +exports.WasmParser = WasmParser + +exports.__wbg_call_641db1bb5db5a579 = function () { + return handleError(function (arg0, arg1, arg2, arg3) { + const ret = getObject(arg0).call( + getObject(arg1), + getObject(arg2), + getObject(arg3), + ) + return addHeapObject(ret) + }, arguments) +} -const WasmParserFinalization = (typeof FinalizationRegistry === 'undefined') - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry(ptr => wasm.__wbg_wasmparser_free(ptr >>> 0, 1)); +exports.__wbg_call_a5400b25a865cfd8 = function () { + return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)) + return addHeapObject(ret) + }, arguments) +} -class WasmParser { +exports.__wbg_get_0da715ceaecea5c8 = function (arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0] + return addHeapObject(ret) +} - __destroy_into_raw() { - const ptr = this.__wbg_ptr; - this.__wbg_ptr = 0; - WasmParserFinalization.unregister(this); - return ptr; - } +exports.__wbg_get_458e874b43b18b25 = function () { + return handleError(function (arg0, arg1) { + const ret = Reflect.get(getObject(arg0), getObject(arg1)) + return addHeapObject(ret) + }, arguments) +} - free() { - const ptr = this.__destroy_into_raw(); - wasm.__wbg_wasmparser_free(ptr, 0); - } - constructor() { - const ret = wasm.wasmparser_new(); - this.__wbg_ptr = ret >>> 0; - WasmParserFinalization.register(this, this.__wbg_ptr, this); - return this; - } - /** - * Parse JavaScript code and return AST as JsValue (WASM) or JSON string (native). - * - * The WASM path goes: - * options_js (JS object) - * → options_from_jsvalue (Reflect-based reads, no serde_json) - * → parser → JSON string - * → JSON::parse (one cheap JS-side parse) - * → JsValue handed back to JS as the AST root - * @param {string} code - * @param {any} options_js - * @returns {any} - */ - parse(code, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(code, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len0 = WASM_VECTOR_LEN; - wasm.wasmparser_parse(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(options_js)); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } - } +exports.__wbg_isArray_030cce220591fb41 = function (arg0) { + const ret = Array.isArray(getObject(arg0)) + return ret +} + +exports.__wbg_keys_ef52390b2ae0e714 = function (arg0) { + const ret = Object.keys(getObject(arg0)) + return addHeapObject(ret) +} + +exports.__wbg_length_186546c51cd61acd = function (arg0) { + const ret = getObject(arg0).length + return ret +} + +exports.__wbg_new_19c25a3f2fa63a02 = function () { + const ret = new Object() + return addHeapObject(ret) +} + +exports.__wbg_new_1f3a344cf3123716 = function () { + const ret = new Array() + return addHeapObject(ret) +} + +exports.__wbg_new_da9dc54c5db29dfa = function (arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)) + return addHeapObject(ret) +} + +exports.__wbg_parse_442f5ba02e5eaf8b = function () { + return handleError(function (arg0, arg1) { + const ret = JSON.parse(getStringFromWasm0(arg0, arg1)) + return addHeapObject(ret) + }, arguments) +} + +exports.__wbg_pop_5aaf63e29ea83074 = function (arg0) { + const ret = getObject(arg0).pop() + return addHeapObject(ret) +} + +exports.__wbg_push_330b2eb93e4e1212 = function (arg0, arg1) { + const ret = getObject(arg0).push(getObject(arg1)) + return ret +} + +exports.__wbg_set_453345bcda80b89a = function () { + return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)) + return ret + }, arguments) +} + +exports.__wbg_setname_832b43d4602cb930 = function (arg0, arg1, arg2) { + getObject(arg0).name = getStringFromWasm0(arg1, arg2) +} + +exports.__wbg_wbindgenbooleanget_3fe6f642c7d97746 = function (arg0) { + const v = getObject(arg0) + const ret = typeof v === 'boolean' ? v : undefined + return isLikeNone(ret) ? 0xffffff : ret ? 1 : 0 +} + +exports.__wbg_wbindgendebugstring_99ef257a3ddda34d = function (arg0, arg1) { + const ret = debugString(getObject(arg1)) + const ptr1 = passStringToWasm0( + ret, + wasm.__wbindgen_export_1, + wasm.__wbindgen_export_2, + ) + const len1 = WASM_VECTOR_LEN + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true) + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true) +} + +exports.__wbg_wbindgenisfunction_8cee7dce3725ae74 = function (arg0) { + const ret = typeof getObject(arg0) === 'function' + return ret +} + +exports.__wbg_wbindgenisnull_f3037694abe4d97a = function (arg0) { + const ret = getObject(arg0) === null + return ret +} + +exports.__wbg_wbindgenisobject_307a53c6bd97fbf8 = function (arg0) { + const val = getObject(arg0) + const ret = typeof val === 'object' && val !== null + return ret +} + +exports.__wbg_wbindgenisundefined_c4b71d073b92f3c5 = function (arg0) { + const ret = getObject(arg0) === undefined + return ret +} + +exports.__wbg_wbindgennumberget_f74b4c7525ac05cb = function (arg0, arg1) { + const obj = getObject(arg1) + const ret = typeof obj === 'number' ? obj : undefined + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true) + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true) +} + +exports.__wbg_wbindgenstringget_0f16a6ddddef376f = function (arg0, arg1) { + const obj = getObject(arg1) + const ret = typeof obj === 'string' ? obj : undefined + var ptr1 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2) + var len1 = WASM_VECTOR_LEN + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true) + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true) +} + +exports.__wbg_wbindgenthrow_451ec1a8469d7eb6 = function (arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)) +} + +exports.__wbindgen_cast_2241b6af4c4b2941 = function (arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1) + return addHeapObject(ret) +} + +exports.__wbindgen_cast_d6cd19b81560fd6e = function (arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0 + return addHeapObject(ret) +} + +exports.__wbindgen_object_clone_ref = function (arg0) { + const ret = getObject(arg0) + return addHeapObject(ret) +} + +exports.__wbindgen_object_drop_ref = function (arg0) { + takeObject(arg0) } -if (Symbol.dispose) WasmParser.prototype[Symbol.dispose] = WasmParser.prototype.free; - -exports.WasmParser = WasmParser; - -exports.__wbg_call_641db1bb5db5a579 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - const ret = getObject(arg0).call(getObject(arg1), getObject(arg2), getObject(arg3)); - return addHeapObject(ret); -}, arguments) }; - -exports.__wbg_call_a5400b25a865cfd8 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); - return addHeapObject(ret); -}, arguments) }; - -exports.__wbg_get_0da715ceaecea5c8 = function(arg0, arg1) { - const ret = getObject(arg0)[arg1 >>> 0]; - return addHeapObject(ret); -}; - -exports.__wbg_get_458e874b43b18b25 = function() { return handleError(function (arg0, arg1) { - const ret = Reflect.get(getObject(arg0), getObject(arg1)); - return addHeapObject(ret); -}, arguments) }; - -exports.__wbg_isArray_030cce220591fb41 = function(arg0) { - const ret = Array.isArray(getObject(arg0)); - return ret; -}; - -exports.__wbg_keys_ef52390b2ae0e714 = function(arg0) { - const ret = Object.keys(getObject(arg0)); - return addHeapObject(ret); -}; - -exports.__wbg_length_186546c51cd61acd = function(arg0) { - const ret = getObject(arg0).length; - return ret; -}; - -exports.__wbg_new_19c25a3f2fa63a02 = function() { - const ret = new Object(); - return addHeapObject(ret); -}; - -exports.__wbg_new_1f3a344cf3123716 = function() { - const ret = new Array(); - return addHeapObject(ret); -}; - -exports.__wbg_new_da9dc54c5db29dfa = function(arg0, arg1) { - const ret = new Error(getStringFromWasm0(arg0, arg1)); - return addHeapObject(ret); -}; - -exports.__wbg_parse_442f5ba02e5eaf8b = function() { return handleError(function (arg0, arg1) { - const ret = JSON.parse(getStringFromWasm0(arg0, arg1)); - return addHeapObject(ret); -}, arguments) }; - -exports.__wbg_pop_5aaf63e29ea83074 = function(arg0) { - const ret = getObject(arg0).pop(); - return addHeapObject(ret); -}; - -exports.__wbg_push_330b2eb93e4e1212 = function(arg0, arg1) { - const ret = getObject(arg0).push(getObject(arg1)); - return ret; -}; - -exports.__wbg_set_453345bcda80b89a = function() { return handleError(function (arg0, arg1, arg2) { - const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); - return ret; -}, arguments) }; - -exports.__wbg_setname_832b43d4602cb930 = function(arg0, arg1, arg2) { - getObject(arg0).name = getStringFromWasm0(arg1, arg2); -}; - -exports.__wbg_wbindgenbooleanget_3fe6f642c7d97746 = function(arg0) { - const v = getObject(arg0); - const ret = typeof(v) === 'boolean' ? v : undefined; - return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; -}; - -exports.__wbg_wbindgendebugstring_99ef257a3ddda34d = function(arg0, arg1) { - const ret = debugString(getObject(arg1)); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); -}; - -exports.__wbg_wbindgenisfunction_8cee7dce3725ae74 = function(arg0) { - const ret = typeof(getObject(arg0)) === 'function'; - return ret; -}; - -exports.__wbg_wbindgenisnull_f3037694abe4d97a = function(arg0) { - const ret = getObject(arg0) === null; - return ret; -}; - -exports.__wbg_wbindgenisobject_307a53c6bd97fbf8 = function(arg0) { - const val = getObject(arg0); - const ret = typeof(val) === 'object' && val !== null; - return ret; -}; - -exports.__wbg_wbindgenisundefined_c4b71d073b92f3c5 = function(arg0) { - const ret = getObject(arg0) === undefined; - return ret; -}; - -exports.__wbg_wbindgennumberget_f74b4c7525ac05cb = function(arg0, arg1) { - const obj = getObject(arg1); - const ret = typeof(obj) === 'number' ? obj : undefined; - getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); -}; - -exports.__wbg_wbindgenstringget_0f16a6ddddef376f = function(arg0, arg1) { - const obj = getObject(arg1); - const ret = typeof(obj) === 'string' ? obj : undefined; - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2); - var len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); -}; - -exports.__wbg_wbindgenthrow_451ec1a8469d7eb6 = function(arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)); -}; - -exports.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { - // Cast intrinsic for `Ref(String) -> Externref`. - const ret = getStringFromWasm0(arg0, arg1); - return addHeapObject(ret); -}; - -exports.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) { - // Cast intrinsic for `F64 -> Externref`. - const ret = arg0; - return addHeapObject(ret); -}; - -exports.__wbindgen_object_clone_ref = function(arg0) { - const ret = getObject(arg0); - return addHeapObject(ret); -}; - -exports.__wbindgen_object_drop_ref = function(arg0) { - takeObject(arg0); -}; - -const wasmPath = `${__dirname}/./acorn.wasm`; -const wasmBytes = require('fs').readFileSync(wasmPath); -const wasmModule = new WebAssembly.Module(wasmBytes); -const wasm = exports.__wasm = new WebAssembly.Instance(wasmModule, imports).exports; +const wasmPath = `${__dirname}/./acorn.wasm` +const wasmBytes = require('fs').readFileSync(wasmPath) +const wasmModule = new WebAssembly.Module(wasmBytes) +const wasm = (exports.__wasm = new WebAssembly.Instance( + wasmModule, + imports, +).exports) diff --git a/.claude/hooks/_shared/fleet-repos.mts b/.claude/hooks/_shared/fleet-repos.mts new file mode 100644 index 0000000..b16ca05 --- /dev/null +++ b/.claude/hooks/_shared/fleet-repos.mts @@ -0,0 +1,75 @@ +/** + * @file Single source of truth for fleet-repo membership, shared by the + * hooks that need to know "is this one of ours?": + * + * - `cross-repo-guard` — blocks `..//…` sibling-path imports. + * - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the + * fleet (a non-fleet repo never has the fleet hook chain installed, so + * the guard has to live agent-side and know the roster itself). + * + * This is the BROAD membership set, intentionally wider than the cascade + * roster in `cascading-fleet/lib/fleet-repos.json` (which lists only + * template-cascade targets and omits e.g. `ultrathink`). Membership here + * answers "may fleet tooling act on this repo at all", not "does the + * wheelhouse cascade into it". Keep the two distinct: a repo can be a + * fleet member (pushable, importable) without being a cascade target. + */ + +// All under the SocketDev org. Names match the GitHub repo slug +// (`github.com:SocketDev/`). Sorted; add new fleet repos here and +// both consuming guards pick them up. +export const FLEET_REPO_NAMES = [ + 'claude-code', + 'skills', + 'socket-addon', + 'socket-btm', + 'socket-cli', + 'socket-lib', + 'socket-packageurl-js', + 'socket-registry', + 'socket-sdk-js', + 'socket-sdxgen', + 'socket-stuie', + 'socket-vscode', + 'socket-webext', + 'socket-wheelhouse', + 'ultrathink', +] as const + +const FLEET_REPO_SET: ReadonlySet = new Set(FLEET_REPO_NAMES) + +/** + * True when `slug` (a bare repo name like `socket-cli`) is a fleet member. + * Case-insensitive — GitHub slugs are case-insensitive and remotes can be + * typed in any case. + */ +export function isFleetRepo(slug: string): boolean { + return FLEET_REPO_SET.has(slug.toLowerCase()) +} + +/** + * Extract the bare repo slug from a git remote URL, or `undefined` when the + * URL isn't a recognizable GitHub remote. Handles the three forms git emits: + * + * git@github.com:SocketDev/socket-cli.git (SSH scp-like) + * ssh://git@github.com/SocketDev/socket-cli.git (SSH URL) + * https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git) + * + * Returns the slug only (`socket-cli`), lowercased. The owner is dropped on + * purpose: membership is keyed on the repo name, and a fork under a + * different owner is still not a fleet push target. + */ +export function slugFromRemoteUrl(url: string): string | undefined { + const trimmed = url.trim() + if (!trimmed) { + return undefined + } + // Capture `/` from any of the three remote shapes, then + // strip a trailing `.git`. The `[^/:]+` owner segment is bounded by the + // `:` (scp form) or `/` (URL forms) that precedes it. + const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(trimmed) + if (!match) { + return undefined + } + return match[2]!.toLowerCase() +} diff --git a/.claude/hooks/_shared/shell-command.mts b/.claude/hooks/_shared/shell-command.mts new file mode 100644 index 0000000..73ce834 --- /dev/null +++ b/.claude/hooks/_shared/shell-command.mts @@ -0,0 +1,254 @@ +/** + * @file Shell-command parsing for Bash-allowlist hooks. Wraps `shell-quote` + * (a maintained, zero-dep JS tokenizer) so structure-sensitive guards can + * reason about "what binary actually runs, at each command position" + * instead of regex-matching the raw string. + * + * Why this exists: regex command detection is evaded by ordinary shell + * indirection — `g=git; $g push`, `eval "git push"`, `git $(printf push)`, + * `\git push`. CLAUDE.md ("Background Bash") mandates AST-based parsing for + * structure-sensitive Bash rules; this is the fleet's JS parser layer, + * built on `shell-quote` (the fleet-canonical shell parser). + * + * What it gives you: + * - `parseCommands(command)` — split a command line into Command segments, + * one per shell command (separated by `;`, `&&`, `||`, `|`, `&`, and the + * boundaries of `$(…)` substitutions). Each segment carries its binary, + * args, leading `VAR=val` assignments, and indirection flags. + * - `findInvocation(command, { binary, subcommand })` — true when any + * segment invokes `binary` (optionally with `subcommand` as its first + * non-flag argument). Sees through chains, substitution, and quoting. + * - Each Command exposes `viaVariable` (binary resolved from `$VAR` → + * shell-quote yields an empty binary token) and `viaEval` (the binary is + * `eval`), so a guard can choose to BLOCK or fail-loud on indirection it + * can't statically resolve rather than silently allow it. + * + * Limitation: shell-quote tokenizes, it doesn't fully evaluate. It cannot + * expand a variable's value (`g=git; $g push` yields an empty binary, not + * `git`) — but it FLAGS that the binary was variable-sourced, which is the + * actionable signal. Aliases defined elsewhere and wrapper scripts remain + * out of scope for any static parser. + */ + +// shell-quote ships no types and we don't want a second dep (@types/ +// shell-quote) + its own soak entry just for a 2-shape union. The +// runtime contract is stable and narrow: parse() returns an array whose +// entries are bare strings, `{ op }` operator objects, or `{ comment }` +// objects. Type it locally. +// oxlint-disable-next-line no-explicit-any -- shell-quote has no types; parse is the documented entry point. +import { parse as shellQuoteParse } from 'shell-quote' + +type ParseEntry = string | { op: string } | { comment: string } + +const parse = shellQuoteParse as unknown as (cmd: string) => ParseEntry[] + +// shell-quote emits operator objects ({ op }), comment objects ({ comment }), +// and bare strings. These ops separate one command from the next. +const COMMAND_SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n']) + +export interface Command { + /** The resolved binary (first non-assignment token), or '' when it could + * not be statically resolved (e.g. `$VAR` indirection). */ + readonly binary: string + /** Arguments after the binary, bare strings only (ops/comments dropped). */ + readonly args: readonly string[] + /** Leading `NAME=value` assignments that prefixed the command. */ + readonly assignments: readonly string[] + /** True when the binary token came from a variable (`$g push` → ''). */ + readonly viaVariable: boolean + /** True when the binary is `eval` (the command it runs is opaque). */ + readonly viaEval: boolean +} + +function isOp(e: ParseEntry): e is { op: string } { + return typeof e === 'object' && e !== null && 'op' in e +} + +function isComment(e: ParseEntry): boolean { + return typeof e === 'object' && e !== null && 'comment' in e +} + +const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/ + +/** + * Parse a shell command line into its constituent Command segments. + * + * Token handling: + * - Operators in COMMAND_SEPARATORS start a new segment. + * - `$(…)` substitution shows up as `"$" ( … )`; the `(`/`)` ops bound an + * inner command, which becomes its own segment (so a substituted binary + * like `git $(printf push)` surfaces `printf` as a command too). + * - Comments are dropped. + * - A leading run of `NAME=value` tokens are assignments; the first + * non-assignment token is the binary. + * - An empty-string binary token means the binary was `$VAR`-sourced. + */ +export function parseCommands(command: string): Command[] { + let entries: ParseEntry[] + try { + entries = parse(command) + } catch { + return [] + } + + const commands: Command[] = [] + let tokens: string[] = [] + let sawVarPlaceholder = false + + const flush = () => { + if (tokens.length === 0) { + // A segment that was nothing but a `$VAR` placeholder still counts — + // the binary was variable-sourced. + if (sawVarPlaceholder) { + commands.push({ + binary: '', + args: [], + assignments: [], + viaVariable: true, + viaEval: false, + }) + } + sawVarPlaceholder = false + return + } + const assignments: string[] = [] + let i = 0 + while (i < tokens.length && ASSIGNMENT_RE.test(tokens[i]!)) { + assignments.push(tokens[i]!) + i += 1 + } + const binary = i < tokens.length ? tokens[i]! : '' + const args = tokens.slice(i + 1) + commands.push({ + binary, + args, + assignments, + // Empty binary after assignments means a `$VAR` placeholder collapsed + // to '' sat in the binary slot. + viaVariable: binary === '' && sawVarPlaceholder, + viaEval: binary === 'eval', + }) + tokens = [] + sawVarPlaceholder = false + } + + for (const e of entries) { + if (isComment(e)) { + continue + } + if (isOp(e)) { + if (COMMAND_SEPARATORS.has(e.op) || e.op === '(' || e.op === ')') { + flush() + } + // Redirect ops (`>`, `>>`, `<`, etc.) and the `$` substitution sigil + // are not separators; the redirect TARGET that follows is dropped by + // not being a command token we care about. Simplest correct behavior: + // treat a redirect op as ending the meaningful args (skip the rest of + // this segment's tokens until a separator). We keep it lenient — args + // after a redirect aren't binaries. + continue + } + // Bare string token. + if (e === '') { + // shell-quote collapses `$VAR` / `${VAR}` to ''. Mark indirection; + // hold a placeholder so an all-variable command still flushes. + sawVarPlaceholder = true + tokens.push('') + continue + } + tokens.push(e) + } + flush() + return commands +} + +export interface InvocationQuery { + /** Binary name to match, e.g. 'git' or 'gh'. Case-sensitive. */ + readonly binary: string + /** Optional first non-flag argument, e.g. 'push' or 'workflow'. */ + readonly subcommand?: string | undefined +} + +/** + * True when `command` invokes `query.binary` (optionally with `subcommand` + * as its first non-flag argument) in any of its command segments. + * + * "First non-flag argument" skips leading `-x` / `--long` / `-x value` + * option tokens so `git -C /x push` matches `{ binary: 'git', subcommand: + * 'push' }`. Flags that take a separate-word value (`-C `) are handled + * by skipping a non-flag token that immediately follows a known value-taking + * flag is NOT attempted — instead we scan for `subcommand` among the + * non-flag args, which is robust for the subcommand-detection use case. + */ +export function findInvocation( + command: string, + query: InvocationQuery, +): boolean { + const commands = parseCommands(command) + for (const cmd of commands) { + if (cmd.binary !== query.binary) { + continue + } + if (query.subcommand === undefined) { + return true + } + // Scan ALL non-flag args for the subcommand verb. The first non-flag + // token is NOT reliable: a global option's separate-word VALUE (e.g. + // `/x` after `-C`, or `k=v` after `-c`) is itself non-flag and would + // shadow the real subcommand. Scanning every non-flag arg is safe + // because those VALUES are paths / kv strings, not subcommand verbs + // like `push` / `workflow`, so a match on the verb is unambiguous. + if (cmd.args.some(a => !a.startsWith('-') && a === query.subcommand)) { + return true + } + } + return false +} + +/** + * Every command segment that invokes `binary`. Use when a guard needs the + * matched command's args (to check for a flag like `--write` or a + * subcommand) rather than a yes/no. Returns [] when `binary` isn't invoked. + * + * This is the right entry point for "binary X with flag/arg Y" rules: a + * guard reads `binary === 'codex'` segments and inspects their `args`, + * instead of regex-matching `--write` anywhere in the raw command (which + * trips on the flag appearing in a path, a sibling command, or a quoted + * string). + */ +export function commandsFor(command: string, binary: string): Command[] { + return parseCommands(command).filter(c => c.binary === binary) +} + +/** + * True when any `binary` segment carries one of `flags` as an argument. + * Matches both the exact flag token (`--write`, `-w`) and the `--flag=value` + * form (so `--write=true` counts for `--write`). Bundled short flags + * (`-wf`) are NOT decomposed — list each short flag you care about. + */ +export function invocationHasFlag( + command: string, + binary: string, + flags: readonly string[], +): boolean { + const flagSet = new Set(flags) + return commandsFor(command, binary).some(c => + c.args.some(a => { + if (flagSet.has(a)) { + return true + } + const eq = a.indexOf('=') + return eq > 0 && flagSet.has(a.slice(0, eq)) + }), + ) +} + +/** + * True when the command uses indirection a static parser can't resolve to a + * concrete binary: a `$VAR`-sourced binary or an `eval`. A guard that wants + * to be strict (fail-closed on evasion attempts) can treat this as + * suspicious; a guard that wants to stay permissive can ignore it. + */ +export function hasOpaqueInvocation(command: string): boolean { + return parseCommands(command).some(c => c.viaVariable || c.viaEval) +} diff --git a/.claude/hooks/_shared/test/fleet-repos.test.mts b/.claude/hooks/_shared/test/fleet-repos.test.mts new file mode 100644 index 0000000..3291e26 --- /dev/null +++ b/.claude/hooks/_shared/test/fleet-repos.test.mts @@ -0,0 +1,97 @@ +// node --test specs for the shared fleet-repos membership helpers. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + FLEET_REPO_NAMES, + isFleetRepo, + slugFromRemoteUrl, +} from '../fleet-repos.mts' + +test('FLEET_REPO_NAMES includes the broad membership set', () => { + // ultrathink is a fleet member but NOT in the cascade roster + // (fleet-repos.json) — the broad set must carry it. + assert.ok(FLEET_REPO_NAMES.includes('ultrathink')) + assert.ok(FLEET_REPO_NAMES.includes('socket-cli')) + assert.ok(FLEET_REPO_NAMES.includes('socket-wheelhouse')) +}) + +test('FLEET_REPO_NAMES is sorted + has no duplicates', () => { + const sorted = [...FLEET_REPO_NAMES].sort() + assert.deepStrictEqual([...FLEET_REPO_NAMES], sorted) + assert.strictEqual(new Set(FLEET_REPO_NAMES).size, FLEET_REPO_NAMES.length) +}) + +test('isFleetRepo: member names pass', () => { + assert.ok(isFleetRepo('socket-cli')) + assert.ok(isFleetRepo('ultrathink')) +}) + +test('isFleetRepo: case-insensitive', () => { + assert.ok(isFleetRepo('Socket-CLI')) + assert.ok(isFleetRepo('ULTRATHINK')) +}) + +test('isFleetRepo: non-members fail', () => { + assert.ok(!isFleetRepo('depot')) + assert.ok(!isFleetRepo('some-personal-repo')) + assert.ok(!isFleetRepo('')) +}) + +test('slugFromRemoteUrl: SSH scp-like form', () => { + assert.strictEqual( + slugFromRemoteUrl('git@github.com:SocketDev/socket-cli.git'), + 'socket-cli', + ) +}) + +test('slugFromRemoteUrl: SSH URL form', () => { + assert.strictEqual( + slugFromRemoteUrl('ssh://git@github.com/SocketDev/socket-lib.git'), + 'socket-lib', + ) +}) + +test('slugFromRemoteUrl: HTTPS form with .git', () => { + assert.strictEqual( + slugFromRemoteUrl('https://github.com/SocketDev/ultrathink.git'), + 'ultrathink', + ) +}) + +test('slugFromRemoteUrl: HTTPS form without .git', () => { + assert.strictEqual( + slugFromRemoteUrl('https://github.com/SocketDev/depot'), + 'depot', + ) +}) + +test('slugFromRemoteUrl: trailing slash tolerated', () => { + assert.strictEqual( + slugFromRemoteUrl('https://github.com/SocketDev/depot/'), + 'depot', + ) +}) + +test('slugFromRemoteUrl: lowercases the slug', () => { + assert.strictEqual( + slugFromRemoteUrl('git@github.com:SocketDev/Socket-CLI.git'), + 'socket-cli', + ) +}) + +test('slugFromRemoteUrl: a fork under a different owner still yields the slug', () => { + // Owner is dropped on purpose — a fork is still not a fleet push target, + // and isFleetRepo keys on the bare name. + assert.strictEqual( + slugFromRemoteUrl('git@github.com:someuser/socket-cli.git'), + 'socket-cli', + ) +}) + +test('slugFromRemoteUrl: unrecognized input → undefined', () => { + assert.strictEqual(slugFromRemoteUrl(''), undefined) + assert.strictEqual(slugFromRemoteUrl(' '), undefined) + assert.strictEqual(slugFromRemoteUrl('not-a-url'), undefined) +}) diff --git a/.claude/hooks/_shared/test/shell-command.test.mts b/.claude/hooks/_shared/test/shell-command.test.mts new file mode 100644 index 0000000..2d8b6ff --- /dev/null +++ b/.claude/hooks/_shared/test/shell-command.test.mts @@ -0,0 +1,140 @@ +// node --test specs for the shared shell-command parser util. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + commandsFor, + findInvocation, + hasOpaqueInvocation, + invocationHasFlag, + parseCommands, +} from '../shell-command.mts' + +test('parseCommands: simple command → binary + args', () => { + const [cmd] = parseCommands('git push origin main') + assert.strictEqual(cmd!.binary, 'git') + assert.deepStrictEqual(cmd!.args, ['push', 'origin', 'main']) + assert.strictEqual(cmd!.viaVariable, false) + assert.strictEqual(cmd!.viaEval, false) +}) + +test('parseCommands: leading assignments are separated from the binary', () => { + const [cmd] = parseCommands('A=1 B=2 git push') + assert.deepStrictEqual(cmd!.assignments, ['A=1', 'B=2']) + assert.strictEqual(cmd!.binary, 'git') + assert.deepStrictEqual(cmd!.args, ['push']) +}) + +test('parseCommands: && / ; / | split into separate segments', () => { + const cmds = parseCommands('cd /x && git push ; echo done | cat') + const bins = cmds.map(c => c.binary) + assert.ok(bins.includes('cd')) + assert.ok(bins.includes('git')) + assert.ok(bins.includes('echo')) + assert.ok(bins.includes('cat')) +}) + +test('parseCommands: $(…) substitution surfaces the inner command', () => { + const cmds = parseCommands('git $(printf push)') + const bins = cmds.map(c => c.binary) + assert.ok(bins.includes('git')) + assert.ok(bins.includes('printf')) +}) + +test('parseCommands: comments dropped', () => { + const cmds = parseCommands('git push # remember to do this') + assert.strictEqual(cmds.length, 1) + assert.strictEqual(cmds[0]!.binary, 'git') +}) + +test('findInvocation: matches plain git push', () => { + assert.ok(findInvocation('git push origin main', { binary: 'git', subcommand: 'push' })) +}) + +test('findInvocation: matches git -C push (subcommand after option value)', () => { + assert.ok(findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' })) +}) + +test('findInvocation: matches git -c k=v push', () => { + assert.ok(findInvocation('git -c foo=bar push', { binary: 'git', subcommand: 'push' })) +}) + +test('findInvocation: matches push reached via && chain', () => { + assert.ok( + findInvocation('cd /x/depot && git push', { binary: 'git', subcommand: 'push' }), + ) +}) + +test('findInvocation: matches push in a pipe chain', () => { + assert.ok( + findInvocation('ls | grep x && git push', { binary: 'git', subcommand: 'push' }), + ) +}) + +test('findInvocation: a different subcommand does not match', () => { + assert.ok(!findInvocation('git status', { binary: 'git', subcommand: 'push' })) +}) + +test('findInvocation: quoted "git push" in a commit message is NOT a push', () => { + assert.ok( + !findInvocation('git commit -m "remember to git push later"', { + binary: 'git', + subcommand: 'push', + }), + ) +}) + +test('findInvocation: binary-only query (no subcommand)', () => { + assert.ok(findInvocation('gh auth status', { binary: 'gh' })) + assert.ok(!findInvocation('git status', { binary: 'gh' })) +}) + +test('hasOpaqueInvocation: eval flagged', () => { + assert.ok(hasOpaqueInvocation('eval "git push"')) +}) + +test('hasOpaqueInvocation: $VAR-sourced binary flagged', () => { + assert.ok(hasOpaqueInvocation('g=git; $g push')) +}) + +test('hasOpaqueInvocation: plain command is not opaque', () => { + assert.ok(!hasOpaqueInvocation('git push origin main')) +}) + +test('parseCommands: empty / unparseable input → empty list, no throw', () => { + assert.deepStrictEqual(parseCommands(''), []) +}) + +test('commandsFor: returns matching segments with args', () => { + const cmds = commandsFor('codex --write "do the thing"', 'codex') + assert.strictEqual(cmds.length, 1) + assert.ok(cmds[0]!.args.includes('--write')) +}) + +test('commandsFor: binary-in-a-path is NOT the binary', () => { + // `codex-no-write-guard` as a path token must not count as invoking codex. + assert.deepStrictEqual(commandsFor('ls codex-no-write-guard/', 'codex'), []) + assert.deepStrictEqual( + commandsFor('grep -n "codex --write" file.mts', 'codex'), + [], + ) +}) + +test('invocationHasFlag: exact flag', () => { + assert.ok(invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w'])) + assert.ok(invocationHasFlag('codex -w prompt', 'codex', ['--write', '-w'])) +}) + +test('invocationHasFlag: --flag=value form', () => { + assert.ok(invocationHasFlag('codex --write=true x', 'codex', ['--write'])) +}) + +test('invocationHasFlag: flag only inside a quoted string does NOT count', () => { + // the flag is part of an arg STRING to a different binary + assert.ok(!invocationHasFlag('echo "codex --write"', 'codex', ['--write'])) +}) + +test('invocationHasFlag: flag on a different binary does NOT count', () => { + assert.ok(!invocationHasFlag('rm --write-protect x', 'codex', ['--write'])) +}) diff --git a/.claude/hooks/_shared/token-patterns.mts b/.claude/hooks/_shared/token-patterns.mts index 367fc67..bd91c3f 100644 --- a/.claude/hooks/_shared/token-patterns.mts +++ b/.claude/hooks/_shared/token-patterns.mts @@ -195,8 +195,9 @@ export function isTokenKey(key: string): boolean { * inspection). * * Kept short to minimize false positives. A "PASSWORD" mention in a - * commit-message body would otherwise trip every commit; token-guard pairs this - * list with `containsOutsideQuotes()` to skip in-string fragments. + * commit-message body would otherwise trip every commit, so token-guard + * narrows matches to assignment / flag-value positions rather than any + * occurrence in arbitrary text. */ export const SENSITIVE_NAME_FRAGMENTS: readonly string[] = [ 'TOKEN', diff --git a/.claude/hooks/_shared/transcript.mts b/.claude/hooks/_shared/transcript.mts index 5e5c01e..ac7fcf4 100644 --- a/.claude/hooks/_shared/transcript.mts +++ b/.claude/hooks/_shared/transcript.mts @@ -37,6 +37,28 @@ import { existsSync, readFileSync } from 'node:fs' * so a single phrase doesn't open the door for a follow-up action of the same * shape later. */ +/** + * Normalize a bypass phrase / haystack so hyphens and runs of whitespace + * collapse to a single space. `Allow workflow-scope bypass`, `Allow workflow + * scope bypass`, and `Allow workflow—scope bypass` all collapse to the same + * canonical form for matching. The transcript-reading helpers run user text + * through this so minor punctuation variations don't break the bypass match. + */ +function normalizeBypassText(text: string): string { + // NFKC: canonical-decompose + compose + compatibility-fold so + // visually-similar variants collapse — smart quotes, full-width, + // ligatures all map to ASCII-canonical. + // \p{Cf} strip: format / zero-width / bidi-override chars are removed + // so an attacker can't inject a benign-rendering turn that contains + // the bypass phrase only after invisible chars are stripped — nor + // can a user accidentally type a phrase that fails to match because + // an editor inserted a zero-width-space. + return text + .normalize('NFKC') + .replace(/\p{Cf}/gu, '') + .replace(/[-—–\s]+/g, ' ') +} + export function bypassPhrasePresent( transcriptPath: string | undefined, phrases: string | readonly string[], @@ -51,8 +73,10 @@ export function bypassPhrasePresent( if (!text) { return false } + const haystack = normalizeBypassText(text) for (let i = 0; i < length; i += 1) { - if (text.includes(list[i]!)) { + const needle = normalizeBypassText(list[i]!) + if (haystack.includes(needle)) { return true } } @@ -113,10 +137,15 @@ export function countBypassPhrases( if (length === 0) { return 0 } - const text = readUserText(transcriptPath, lookbackUserTurns) - if (!text) { + const rawText = readUserText(transcriptPath, lookbackUserTurns) + if (!rawText) { return 0 } + // Normalize hyphens / em-dashes / runs of whitespace to single + // spaces so `Allow workflow-scope bypass` and `Allow workflow scope + // bypass` match the same phrase. Indices below run in the + // normalized string's coordinate space. + const text = normalizeBypassText(rawText) // Track which `[start, end)` spans were already counted by a prior // phrase so a shorter phrase that's a substring of a longer one // doesn't double-count (e.g. `Allow workflow-dispatch bypass: build` @@ -125,6 +154,7 @@ export function countBypassPhrases( // claims the span first. const sorted = [...list] .filter(p => p) + .map(p => normalizeBypassText(p)) .toSorted((a, b) => b.length - a.length) const claimed: Array<[number, number]> = [] let total = 0 diff --git a/.claude/hooks/actionlint-on-workflow-edit/index.mts b/.claude/hooks/actionlint-on-workflow-edit/index.mts index 7046a30..b5de8ff 100644 --- a/.claude/hooks/actionlint-on-workflow-edit/index.mts +++ b/.claude/hooks/actionlint-on-workflow-edit/index.mts @@ -2,18 +2,26 @@ // Claude Code PostToolUse hook — actionlint-on-workflow-edit. // // After an Edit/Write touches `.github/workflows/*.y*ml`, invoke local -// `actionlint` (if installed) against the file. Surface any errors as -// stderr so Claude sees the problem before the next turn. +// `actionlint` AND `zizmor` (if installed) against the file. Surface +// findings as stderr so Claude sees them before the next turn. // -// PostToolUse (not PreToolUse) so the edit lands first and actionlint -// reads the on-disk state. No block — reporting only. The block surface -// is covered by sibling hooks (`workflow-uses-comment-guard`, +// Two scanners, independent: +// - actionlint catches YAML / shell / SHA-pin issues that GitHub's +// parser would silently reject as "0 jobs" +// - zizmor catches security-sensitive patterns: pull_request_target +// misuse, untrusted-input-in-script, secret leaks, privilege +// escalation — supply-chain risks actionlint doesn't model +// +// PostToolUse (not PreToolUse) so the edit lands first and the scanners +// read on-disk state. No block — reporting only. The block surface is +// covered by sibling hooks (`workflow-uses-comment-guard`, // `workflow-yaml-multiline-body-guard`, `pull-request-target-guard`). // -// No-op when actionlint isn't on PATH — most fleet machines have it via -// brew, CI runners have it preinstalled, but downstreams may not. +// No-op for either scanner when it isn't on PATH — most fleet machines +// have both via brew or setup-security-tools, CI runners have them +// preinstalled, but downstreams may not. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' import { readStdin } from '../_shared/transcript.mts' @@ -25,6 +33,13 @@ export function actionlintAvailable(): boolean { return r.status === 0 && String(r.stdout ?? '').trim().length > 0 } +export function zizmorAvailable(): boolean { + const r = spawnSync('command', ['-v', 'zizmor'], { + timeout: 2_000, + }) + return r.status === 0 && String(r.stdout ?? '').trim().length > 0 +} + interface ToolInput { readonly tool_name?: string | undefined readonly tool_input?: { readonly file_path?: string | undefined } | undefined @@ -58,43 +73,82 @@ async function main(): Promise { process.exit(0) } - if (!actionlintAvailable()) { - process.exit(0) - } - - const r = spawnSync('actionlint', [filePath], { - timeout: 10_000, - }) - if (r.status === 0) { - process.exit(0) + // actionlint — YAML / shell / SHA-pin issues. + if (actionlintAvailable()) { + const r = spawnSync('actionlint', [filePath], { timeout: 10_000 }) + if (r.status !== 0) { + process.stderr.write( + [ + '[actionlint-on-workflow-edit] actionlint reported errors', + '', + ` File: ${filePath}`, + '', + ' Output:', + ...String(r.stdout ?? '') + .trim() + .split('\n') + .map((l: string) => ` ${l}`), + ...(r.stderr + ? String(r.stderr) + .trim() + .split('\n') + .map((l: string) => ` ${l}`) + : []), + '', + ' Fix the workflow before relying on it firing in CI. actionlint', + " catches the same YAML / shell / SHA-pin issues GitHub Actions'", + ' parser would (silently) reject as "0 jobs."', + '', + ].join('\n'), + ) + } } - // actionlint failed — surface its output to stderr so Claude reads it. - process.stderr.write( - [ - '[actionlint-on-workflow-edit] actionlint reported errors', - '', - ` File: ${filePath}`, - '', - ' Output:', - ...String(r.stdout ?? '') - .trim() - .split('\n') - .map((l: string) => ` ${l}`), - ...(r.stderr - ? String(r.stderr) + // zizmor — security-focused workflow auditor. Catches privilege + // escalation, secret injection, untrusted-input-in-script patterns, + // and pull_request_target misuse — the supply-chain threats that + // actionlint doesn't model. Independent scan; both can flag the + // same file. + if (zizmorAvailable()) { + const r = spawnSync( + 'zizmor', + ['--no-progress', '--format', 'plain', filePath], + { + timeout: 15_000, + }, + ) + // zizmor exits non-zero when findings exist. Surface the output + // regardless so even informational findings are visible. + if (r.status !== 0) { + process.stderr.write( + [ + '[actionlint-on-workflow-edit] zizmor reported findings', + '', + ` File: ${filePath}`, + '', + ' Output:', + ...String(r.stdout ?? '') .trim() .split('\n') - .map((l: string) => ` ${l}`) - : []), - '', - ' Fix the workflow before relying on it firing in CI. actionlint', - " catches the same YAML / shell / SHA-pin issues GitHub Actions'", - ' parser would (silently) reject as "0 jobs."', - '', - ].join('\n'), - ) - // PostToolUse — emit warning to stderr but don't block the edit + .map((l: string) => ` ${l}`), + ...(r.stderr + ? String(r.stderr) + .trim() + .split('\n') + .map((l: string) => ` ${l}`) + : []), + '', + ' zizmor scans for security-sensitive workflow patterns:', + ' pull_request_target misuse, untrusted-input-in-script,', + ' secret leaks, privilege escalation. Address findings', + ' before merging.', + '', + ].join('\n'), + ) + } + } + + // PostToolUse — emit warnings to stderr but don't block the edit // (the edit already happened). Exit 0 so Claude sees the stderr. process.exit(0) } diff --git a/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts b/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts index 167087d..9ff0de2 100644 --- a/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts +++ b/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts @@ -1,6 +1,9 @@ // node --test specs for the actionlint-on-workflow-edit hook. -import { spawn, spawnSync } from '@socketsecurity/lib-stable/spawn' +import { + spawn, + spawnSync, +} from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' @@ -13,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/ask-suppression-reminder/test/index.test.mts b/.claude/hooks/ask-suppression-reminder/test/index.test.mts index 9e49bf1..ab8c939 100644 --- a/.claude/hooks/ask-suppression-reminder/test/index.test.mts +++ b/.claude/hooks/ask-suppression-reminder/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -28,6 +28,11 @@ function writeTranscript(userTurns: string[]): string { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/auth-rotation-reminder/index.mts b/.claude/hooks/auth-rotation-reminder/index.mts index 5da5864..1391422 100644 --- a/.claude/hooks/auth-rotation-reminder/index.mts +++ b/.claude/hooks/auth-rotation-reminder/index.mts @@ -40,7 +40,7 @@ // SOCKET_AUTH_ROTATION_DISABLED default: unset // If set to a truthy value, skip the hook entirely. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { existsSync, mkdirSync, @@ -54,8 +54,8 @@ import path from 'node:path' import process from 'node:process' import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeDelete } from '@socketsecurity/lib-stable/fs' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { readLastAssistantText, diff --git a/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts b/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts index 2f25192..0adf2fa 100644 --- a/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts +++ b/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts @@ -1,7 +1,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { test } from 'node:test' import assert from 'node:assert/strict' -import { safeDelete } from '@socketsecurity/lib-stable/fs' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const HOOK = path.resolve(__dirname, '..', 'index.mts') @@ -36,6 +36,11 @@ function runHook( ...opts.env, }, }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/check-new-deps/audit.mts b/.claude/hooks/check-new-deps/audit.mts index fa78ab7..53d017a 100644 --- a/.claude/hooks/check-new-deps/audit.mts +++ b/.claude/hooks/check-new-deps/audit.mts @@ -26,8 +26,8 @@ import path from 'node:path' import { stringify } from '@socketregistry/packageurl-js-stable' import type { PackageURL } from '@socketregistry/packageurl-js-stable' -import { createTtlCache } from '@socketsecurity/lib-stable/cache-with-ttl' -import type { TtlCache } from '@socketsecurity/lib-stable/cache-with-ttl' +import { createTtlCache } from '@socketsecurity/lib-stable/cache/ttl/store' +import type { TtlCache } from '@socketsecurity/lib-stable/cache/ttl/types' import { errorMessage } from '@socketsecurity/lib-stable/errors' import type { diff --git a/.claude/hooks/check-new-deps/index.mts b/.claude/hooks/check-new-deps/index.mts index a2f5e4a..57b0420 100644 --- a/.claude/hooks/check-new-deps/index.mts +++ b/.claude/hooks/check-new-deps/index.mts @@ -31,7 +31,7 @@ import { import type { PackageURL } from '@socketregistry/packageurl-js-stable' import { SOCKET_PUBLIC_API_TOKEN } from '@socketsecurity/lib-stable/constants/socket' import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' import { SocketSdk } from '@socketsecurity/sdk-stable' import type { MalwareCheckPackage } from '@socketsecurity/sdk-stable' diff --git a/.claude/hooks/claude-md-section-size-guard/README.md b/.claude/hooks/claude-md-section-size-guard/README.md index 444027e..54c4b97 100644 --- a/.claude/hooks/claude-md-section-size-guard/README.md +++ b/.claude/hooks/claude-md-section-size-guard/README.md @@ -4,7 +4,7 @@ PreToolUse hook that caps the body length of individual `### ` sections inside t ## What it does -Complements `claude-md-size-guard` (40KB byte cap on the whole block) by enforcing a per-section line cap inside the block. Each `### Section heading` inside the `` markers gets at most **8 body lines** (configurable via `CLAUDE_MD_FLEET_SECTION_MAX_LINES`). +Complements `claude-md-size-guard` (48KB byte cap on the whole block) by enforcing a per-section line cap inside the block. Each `### Section heading` inside the `` markers gets at most **8 body lines** (configurable via `CLAUDE_MD_FLEET_SECTION_MAX_LINES`). Sections that exceed 8 lines should have a long-form companion at `docs/claude.md/fleet/.md` and the inline body should shrink to 1-2 sentences plus a link. The cap was 20 initially (during the bootstrap when several fleet sections were 12-19 lines); it tightened to 8 once those sections were outsourced. @@ -24,7 +24,7 @@ When a section exceeds the cap, the hook prints: ## Why a per-section cap, not just the byte cap -The failure mode this hook addresses: an operator can grow a single rule from 2 lines to 60 lines of detailed prose without ever tripping the 40KB byte cap — until enough other sections accrete that an unrelated 1-line addition breaks the build. The per-section cap catches this directly, at the moment the long content is written, when the operator has the long-form text in hand and can immediately drop it into a `docs/claude.md/fleet/.md` companion. +The failure mode this hook addresses: an operator can grow a single rule from 2 lines to 60 lines of detailed prose without ever tripping the 48KB byte cap — until enough other sections accrete that an unrelated 1-line addition breaks the build. The per-section cap catches this directly, at the moment the long content is written, when the operator has the long-form text in hand and can immediately drop it into a `docs/claude.md/fleet/.md` companion. ## Override diff --git a/.claude/hooks/claude-md-section-size-guard/test/index.test.mts b/.claude/hooks/claude-md-section-size-guard/test/index.test.mts index 5910406..705d5d7 100644 --- a/.claude/hooks/claude-md-section-size-guard/test/index.test.mts +++ b/.claude/hooks/claude-md-section-size-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -22,6 +22,11 @@ async function runHook( stdio: 'pipe', env: { ...process.env, ...env }, }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/claude-md-size-guard/README.md b/.claude/hooks/claude-md-size-guard/README.md index ead7e36..910c722 100644 --- a/.claude/hooks/claude-md-size-guard/README.md +++ b/.claude/hooks/claude-md-size-guard/README.md @@ -1,10 +1,10 @@ # claude-md-size-guard -PreToolUse Edit/Write hook that blocks CLAUDE.md edits which would push the **fleet-canonical block** (between `` / `` markers) above 40 KB. +PreToolUse Edit/Write hook that blocks CLAUDE.md edits which would push the **fleet-canonical block** (between `` / `` markers) above 48 KB. ## Why -The fleet block is byte-identical across every `socket-*` repo. Every byte added there costs N copies of in-context tokens fleet-wide. Per-repo content outside the markers is paid once. Capping the fleet block at 40 KB: +The fleet block is byte-identical across every `socket-*` repo. Every byte added there costs N copies of in-context tokens fleet-wide. Per-repo content outside the markers is paid once. Capping the fleet block at 48 KB: - Forces new fleet rules to be **terse + reference-deferred** (link to `docs/references/.md`). - Leaves headroom for per-repo content. Per-repo CLAUDE.md additions are NOT capped here. @@ -16,7 +16,7 @@ The hook fires on Edit/Write tool calls. For Write, it inspects `content`. For E ## Cap -- **Default:** 40 KB (40 960 bytes). +- **Default:** 48 KB (49 152 bytes). Sized to leave per-repo CLAUDE.md additions ample room outside the fleet block. - **Override:** set `CLAUDE_MD_FLEET_BLOCK_BYTES=` in env (rarely needed; bumping the cap should be a deliberate fleet-wide decision). ## Failing open diff --git a/.claude/hooks/claude-md-size-guard/index.mts b/.claude/hooks/claude-md-size-guard/index.mts index 06338ef..ad263c9 100644 --- a/.claude/hooks/claude-md-size-guard/index.mts +++ b/.claude/hooks/claude-md-size-guard/index.mts @@ -1,43 +1,35 @@ #!/usr/bin/env node // Claude Code PreToolUse hook — claude-md-size-guard. // -// Blocks Edit/Write tool calls that would push the CLAUDE.md -// fleet-canonical block above the 40KB size cap. The fleet block lives -// between `` and `` -// markers; everything outside is per-repo content owned by the host -// repo (different cap, evaluated separately). +// Blocks Edit/Write tool calls that would push CLAUDE.md above the +// 40KB whole-file size cap. The cap measures the ENTIRE post-edit +// file, not just the fleet-canonical block — fleet content + per-repo +// content both count. // -// Why a fleet-block cap, not a whole-file cap: each fleet rule lands -// in EVERY socket-* repo as load-bearing in-context bytes. A rule -// added to the fleet block costs N copies of its size in working-set -// tokens. Per-repo content only costs once. The cap forces fleet -// additions to be terse + reference-deferred (defer details to -// `docs/references/.md`) so the canonical block stays load-bearing -// and the per-repo section keeps headroom. +// Why a whole-file cap: every byte in CLAUDE.md is load-bearing +// in-context tokens for every Claude session opened in the repo, AND +// fleet content is duplicated across ~12 socket-* repos. The 40KB +// ceiling forces ruthless reference-deferral: each rule states the +// invariant + a one-line "Why" + a link to docs/claude.md/fleet/.md +// for the full pattern catalog. Detail goes in the linked doc. // // What the hook does: // 1. Fires only on Edit/Write tool calls targeting a CLAUDE.md. -// 2. Extracts the post-edit fleet block (between markers) from the -// proposed `new_string` / `content`. -// 3. If the proposed fleet block exceeds the cap, exits 2 with a -// stderr message naming the size, the cap, and the canonical -// remediation (move detail into `docs/references/.md`). +// 2. Computes the post-edit text (Write: content; Edit: splice). +// 3. If the whole file exceeds the cap, exits 2 with a stderr message +// naming the size, the cap, and the canonical remediation. // // Cap policy: -// - Default: 40 KB (40_960 bytes). Override per-repo by setting -// `CLAUDE_MD_FLEET_BLOCK_BYTES` in the env (rarely needed). -// - Whole-file cap: NOT enforced here. Per-repo content can grow -// freely; this hook only protects the fleet block. +// - Default: 40 KB (40_960 bytes). Override per-repo via env +// `CLAUDE_MD_BYTES`. Legacy `CLAUDE_MD_FLEET_BLOCK_BYTES` is read +// as a fallback so existing per-repo overrides don't break. // // Hook contract: // - Reads Claude Code's PreToolUse JSON from stdin. // - Operates on `tool_input.new_string` (Edit) or `tool_input.content` -// (Write). Edit doesn't always carry the whole file, so when the -// edit is a partial replacement we ALSO read the on-disk file and -// compute the post-edit size by applying the diff in-memory. If we -// can't reliably compute (e.g. ambiguous Edit), we err on the side -// of letting it through (fail-open, log a warning). -// - Fails open on hook bugs (exit 0 + stderr log). +// (Write). When an Edit is a partial replacement we read the on- +// disk file and apply the diff in-memory. If we can't reliably +// compute (ambiguous Edit), we fail open. import { existsSync, readFileSync } from 'node:fs' import process from 'node:process' @@ -45,8 +37,6 @@ import process from 'node:process' import { readStdin } from '../_shared/transcript.mts' const DEFAULT_CAP_BYTES = 40 * 1024 -const FLEET_BEGIN_MARKER = '`', - ) - lines.push( - ' and ``) is byte-identical across all', - ) - lines.push(' ~12 fleet repos. Every byte added there costs N copies of in-') - lines.push(' context tokens. Per-repo content (outside the markers) has') - lines.push(' no cap — keep new fleet rules terse and link to a reference') - lines.push(' doc for the details:') + lines.push(' CLAUDE.md is load-bearing in-context for every session, and') + lines.push(' the fleet block is duplicated across ~12 socket-* repos. The') + lines.push(' 40KB ceiling forces ruthless reference-deferral:') lines.push('') - lines.push(' 1. Add a one-paragraph rule in the fleet block.') - lines.push(' 2. Move expanded explanation to') - lines.push(' `docs/references/.md` (cascaded fleet-wide).') - lines.push( - ' 3. Link from the rule: `[Full details](docs/references/...)`.', - ) + lines.push(' 1. State the invariant + one-line "Why" inline.') + lines.push(' 2. Move detail to `docs/claude.md/fleet/.md`.') + lines.push(' 3. Link from the rule: `[Full details](docs/claude.md/...)`.') lines.push('') - lines.push(' See `docs/references/bypass-phrases.md` for an example of the') - lines.push(' one-paragraph + reference shape.') + lines.push(' See `docs/claude.md/fleet/bypass-phrases.md` for an example') + lines.push(' of the one-paragraph + reference shape.') process.stderr.write(lines.join('\n') + '\n') } -/** - * Extract the fleet-canonical block from a CLAUDE.md text. Returns undefined if - * the markers aren't present (per-repo CLAUDE.md may not have them, in which - * case the cap doesn't apply). - */ -export function extractFleetBlock(text: string): string | undefined { - const beginIdx = text.indexOf(FLEET_BEGIN_MARKER) - if (beginIdx === -1) { - return undefined - } - const endIdx = text.indexOf(FLEET_END_MARKER, beginIdx) - if (endIdx === -1) { - return undefined - } - // Include both markers in the measured block. - const blockEnd = text.indexOf('-->', endIdx) - if (blockEnd === -1) { - return undefined - } - return text.slice(beginIdx, blockEnd + 3) -} - export function getCap(): number { - const env = process.env['CLAUDE_MD_FLEET_BLOCK_BYTES'] + const env = + process.env['CLAUDE_MD_BYTES'] ?? process.env['CLAUDE_MD_FLEET_BLOCK_BYTES'] if (!env) { return DEFAULT_CAP_BYTES } @@ -209,14 +165,8 @@ async function main(): Promise { // Fail open — couldn't compute post-edit text reliably. return } - const fleetBlock = extractFleetBlock(postEdit) - if (fleetBlock === undefined) { - // No fleet markers in the file (per-repo CLAUDE.md without sync). - // Cap doesn't apply. - return - } const cap = getCap() - const size = Buffer.byteLength(fleetBlock, 'utf8') + const size = Buffer.byteLength(postEdit, 'utf8') if (size <= cap) { return } diff --git a/.claude/hooks/claude-md-size-guard/test/index.test.mts b/.claude/hooks/claude-md-size-guard/test/index.test.mts index 6ea4d1e..9da49a8 100644 --- a/.claude/hooks/claude-md-size-guard/test/index.test.mts +++ b/.claude/hooks/claude-md-size-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -24,6 +24,11 @@ async function runHook( stdio: 'pipe', env: { ...process.env, ...envOverride }, }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -36,18 +41,6 @@ async function runHook( }) } -function fleetBlock(bodyBytes: number): string { - // Build a fleet block whose byte size is approximately bodyBytes. - // The wrapper markers + minimal text overhead is ~80 bytes; the - // body is filler. - const filler = 'x'.repeat(Math.max(0, bodyBytes - 80)) - return [ - '', - filler, - '', - ].join('\n') -} - test('non-CLAUDE.md targets are ignored', async () => { const result = await runHook({ tool_input: { content: 'x'.repeat(100_000), file_path: 'README.md' }, @@ -56,46 +49,48 @@ test('non-CLAUDE.md targets are ignored', async () => { assert.strictEqual(result.code, 0) }) -test('Write of small fleet block is allowed', async () => { +test('Write of small file is allowed', async () => { const result = await runHook({ - tool_input: { content: fleetBlock(1_000), file_path: 'CLAUDE.md' }, + tool_input: { content: 'x'.repeat(1_000), file_path: 'CLAUDE.md' }, tool_name: 'Write', }) assert.strictEqual(result.code, 0) }) -test('Write of fleet block at exactly 40KB is allowed', async () => { +test('Write of file at exactly 40KB is allowed', async () => { const result = await runHook({ - tool_input: { content: fleetBlock(40 * 1024), file_path: 'CLAUDE.md' }, + tool_input: { content: 'x'.repeat(40 * 1024), file_path: 'CLAUDE.md' }, tool_name: 'Write', }) assert.strictEqual(result.code, 0) }) -test('Write of fleet block over 40KB is blocked', async () => { +test('Write of file over 40KB is blocked', async () => { const result = await runHook({ - tool_input: { content: fleetBlock(45 * 1024), file_path: 'CLAUDE.md' }, + tool_input: { content: 'x'.repeat(45 * 1024), file_path: 'CLAUDE.md' }, tool_name: 'Write', }) assert.strictEqual(result.code, 2) assert.match(result.stderr, /claude-md-size-guard/) - assert.match(result.stderr, /fleet block too large/) - assert.match(result.stderr, /docs\/references\//) + assert.match(result.stderr, /too large/) + assert.match(result.stderr, /docs\/claude\.md\/fleet\//) }) -test('Write of CLAUDE.md without fleet markers is allowed (per-repo only)', async () => { - const result = await runHook({ - tool_input: { content: 'x'.repeat(100_000), file_path: 'CLAUDE.md' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) +test('cap override via env var', async () => { + const result = await runHook( + { + tool_input: { content: 'x'.repeat(2_000), file_path: 'CLAUDE.md' }, + tool_name: 'Write', + }, + { CLAUDE_MD_BYTES: '1024' }, + ) + assert.strictEqual(result.code, 2) }) -test('cap override via env var', async () => { - // Override to 1KB so even a small block trips the cap. +test('legacy CLAUDE_MD_FLEET_BLOCK_BYTES env still works as fallback', async () => { const result = await runHook( { - tool_input: { content: fleetBlock(2_000), file_path: 'CLAUDE.md' }, + tool_input: { content: 'x'.repeat(2_000), file_path: 'CLAUDE.md' }, tool_name: 'Write', }, { CLAUDE_MD_FLEET_BLOCK_BYTES: '1024' }, @@ -103,39 +98,31 @@ test('cap override via env var', async () => { assert.strictEqual(result.code, 2) }) -test('Edit splice that grows fleet block over cap is blocked', async () => { - // Write a small base file to disk, then propose an Edit that adds - // 50KB of body inside the fleet block. +test('Edit splice that grows file over cap is blocked', async () => { const dir = mkdtempSync(path.join(os.tmpdir(), 'claude-md-size-guard-')) const file = path.join(dir, 'CLAUDE.md') - const baseBlock = fleetBlock(1_000) - writeFileSync(file, baseBlock) - // The Edit proposes to add 50KB more body before the END marker. - const oldStr = '' - const newStr = 'y'.repeat(50 * 1024) + oldStr + writeFileSync(file, 'base\n') const result = await runHook({ tool_input: { file_path: file, - new_string: newStr, - old_string: oldStr, + new_string: 'y'.repeat(45 * 1024), + old_string: 'base\n', }, tool_name: 'Edit', }) assert.strictEqual(result.code, 2) - assert.match(result.stderr, /fleet block too large/) + assert.match(result.stderr, /too large/) }) -test('Edit splice that keeps fleet block under cap is allowed', async () => { +test('Edit splice that keeps file under cap is allowed', async () => { const dir = mkdtempSync(path.join(os.tmpdir(), 'claude-md-size-guard-')) const file = path.join(dir, 'CLAUDE.md') - writeFileSync(file, fleetBlock(1_000)) - const oldStr = '' - const newStr = 'z'.repeat(2_000) + oldStr + writeFileSync(file, 'base\n') const result = await runHook({ tool_input: { file_path: file, - new_string: newStr, - old_string: oldStr, + new_string: 'z'.repeat(2_000), + old_string: 'base\n', }, tool_name: 'Edit', }) diff --git a/.claude/hooks/codex-no-write-guard/index.mts b/.claude/hooks/codex-no-write-guard/index.mts index 11e82a8..5a1530e 100644 --- a/.claude/hooks/codex-no-write-guard/index.mts +++ b/.claude/hooks/codex-no-write-guard/index.mts @@ -23,6 +23,7 @@ import process from 'node:process' +import { commandsFor, invocationHasFlag } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' interface ToolInput { @@ -70,7 +71,11 @@ export function hasWriteIntent(text: string): string | undefined { } export function isCodexBashCommand(command: string): boolean { - return /(?:^|[\s;&|(`])codex\b/.test(command) + // Parser-based: the binary at a command position is exactly `codex`. + // Rejects `codex-no-write-guard` (a path/identifier, not the binary) and + // a quoted "codex …" inside an arg to another command — both of which + // the old `codex\b` regex wrongly matched. + return commandsFor(command, 'codex').length > 0 } async function main(): Promise { @@ -99,11 +104,18 @@ async function main(): Promise { if (payload.tool_name === 'Bash') { const command = input.command ?? '' - if (isCodexBashCommand(command)) { - if (/(?:^|\s)(?:--write|-w)\b/.test(command)) { + const codexCommands = commandsFor(command, 'codex') + if (codexCommands.length > 0) { + if (invocationHasFlag(command, 'codex', ['--write', '-w'])) { blocked = { kind: 'bash', reason: '--write / -w flag' } } else { - const verb = hasWriteIntent(command) + // Check write-intent verbs only in the codex command's OWN args + // (the prompt), not the whole shell line — so a sibling command + // or a path containing a verb word doesn't trip the guard. + const codexArgText = codexCommands + .flatMap(c => c.args) + .join(' ') + const verb = hasWriteIntent(codexArgText) if (verb) { blocked = { kind: 'bash', reason: `write-intent verb "${verb}"` } } diff --git a/.claude/hooks/codex-no-write-guard/test/index.test.mts b/.claude/hooks/codex-no-write-guard/test/index.test.mts index e48fc17..f83b5d0 100644 --- a/.claude/hooks/codex-no-write-guard/test/index.test.mts +++ b/.claude/hooks/codex-no-write-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -18,6 +18,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -38,6 +43,35 @@ test('non-codex Bash passes', async () => { assert.strictEqual(r.code, 0) }) +test('command mentioning the guard name (codex-no-write-guard) is NOT a codex invocation', async () => { + // Regression: the old `codex\b` regex matched `codex-no-write-guard` and + // the word "write" in it → false block. The parser sees the binary is + // `for`/`ls`/`grep`, not `codex`. + const r = await runHook({ + tool_name: 'Bash', + tool_input: { + command: 'grep -n "write" template/.claude/hooks/codex-no-write-guard/index.mts', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('quoted "codex --write" inside an echo is NOT a codex invocation', async () => { + const r = await runHook({ + tool_name: 'Bash', + tool_input: { command: 'echo "run codex --write to apply"' }, + }) + assert.strictEqual(r.code, 0) +}) + +test('real codex --write in a chain is still blocked', async () => { + const r = await runHook({ + tool_name: 'Bash', + tool_input: { command: 'cd /x && codex --write "do it"' }, + }) + assert.strictEqual(r.code, 2) +}) + test('codex with --write blocked', async () => { const r = await runHook({ tool_name: 'Bash', diff --git a/.claude/hooks/comment-tone-reminder/index.mts b/.claude/hooks/comment-tone-reminder/index.mts index c97d4e0..3af6fba 100644 --- a/.claude/hooks/comment-tone-reminder/index.mts +++ b/.claude/hooks/comment-tone-reminder/index.mts @@ -19,7 +19,7 @@ await runStopReminder({ patterns: [ { label: 'first, we (will|are)', - regex: /\bfirst,? we (are|need|should|will)\b/i, + regex: /\bfirst,? we (?:are|need|should|will)\b/i, why: 'Teacher-tone narration. Drop the step-by-step framing in comments.', }, { @@ -39,7 +39,7 @@ await runStopReminder({ }, { label: 'remember that', - regex: /\bremember (that|to)\b/i, + regex: /\bremember (?:that|to)\b/i, why: "Teacher-tone. The reader doesn't need to be reminded — state the rule.", }, { diff --git a/.claude/hooks/comment-tone-reminder/test/index.test.mts b/.claude/hooks/comment-tone-reminder/test/index.test.mts index 9159238..85d59a3 100644 --- a/.claude/hooks/comment-tone-reminder/test/index.test.mts +++ b/.claude/hooks/comment-tone-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -32,7 +32,6 @@ function runHook(transcriptPath: string): { exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { @@ -107,7 +106,6 @@ test('disabled env var short-circuits', () => { const { path: p, cleanup } = makeTranscript('Note that we should skip this.') try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, SOCKET_COMMENT_TONE_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/commit-author-guard/index.mts b/.claude/hooks/commit-author-guard/index.mts index 7f5a182..d702508 100644 --- a/.claude/hooks/commit-author-guard/index.mts +++ b/.claude/hooks/commit-author-guard/index.mts @@ -36,7 +36,7 @@ // Bypass: type "Allow commit-author bypass" in a recent user message, // or set SOCKET_COMMIT_AUTHOR_GUARD_DISABLED=1. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -148,11 +148,7 @@ export function readAllowedAuthors(): AllowedAuthors { // Source (b): global git config let email: string | undefined let name: string | undefined - const emailResult = spawnSync('git', [ - 'config', - '--global', - 'user.email', - ]) + const emailResult = spawnSync('git', ['config', '--global', 'user.email']) if (emailResult.status === 0) { email = String(emailResult.stdout).trim() || undefined } diff --git a/.claude/hooks/commit-author-guard/test/index.test.mts b/.claude/hooks/commit-author-guard/test/index.test.mts index 7de655a..8dfcbd3 100644 --- a/.claude/hooks/commit-author-guard/test/index.test.mts +++ b/.claude/hooks/commit-author-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -60,7 +60,6 @@ function runHook( extraEnv: Record = {}, ): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), env: { ...process.env, HOME: home, ...extraEnv }, }) @@ -333,7 +332,6 @@ test('fails open when no canonical email is configured anywhere', () => { // fails open; if it's set to the user's real email, this test's // imposter email gets blocked. Either way, the hook should not crash. const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'git commit -m "fix"' }, diff --git a/.claude/hooks/commit-pr-reminder/test/index.test.mts b/.claude/hooks/commit-pr-reminder/test/index.test.mts index 3882f93..d3cf75d 100644 --- a/.claude/hooks/commit-pr-reminder/test/index.test.mts +++ b/.claude/hooks/commit-pr-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -23,7 +23,6 @@ function makeTranscript(assistantText: string): string { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -72,7 +71,6 @@ test('does NOT fire on the word "generated" without "claude" nearby', () => { test('disabled env var short-circuits', () => { const t = makeTranscript('Generated with Claude Code') const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: t }), env: { ...process.env, SOCKET_COMMIT_PR_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/compound-lessons-reminder/test/index.test.mts b/.claude/hooks/compound-lessons-reminder/test/index.test.mts index 3559d79..aa5a7c5 100644 --- a/.claude/hooks/compound-lessons-reminder/test/index.test.mts +++ b/.claude/hooks/compound-lessons-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -46,7 +46,6 @@ function makeTranscript( function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -182,7 +181,6 @@ test('disabled env var short-circuits', () => { const { path: p, cleanup } = makeTranscript('Hitting this again.') try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, SOCKET_COMPOUND_LESSONS_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/concurrent-cargo-build-guard/index.mts b/.claude/hooks/concurrent-cargo-build-guard/index.mts index 83809de..dda34ec 100644 --- a/.claude/hooks/concurrent-cargo-build-guard/index.mts +++ b/.claude/hooks/concurrent-cargo-build-guard/index.mts @@ -20,9 +20,10 @@ // Fires only on cargo / build-prod commands, so a no-op in repos that // don't use cargo. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' +import { commandsFor } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' interface ToolInput { @@ -40,7 +41,8 @@ const BYPASS_PHRASE = 'Allow concurrent-cargo-build bypass' // the actual long-running cargo / linker process. interface BuildPattern { readonly label: string - readonly cmdRe: RegExp + // Parser-based matcher: true when `command` invokes this release build. + readonly matches: (command: string) => boolean // pgrep -f pattern (string, not RegExp — pgrep uses POSIX ERE). readonly pgrepPattern: string } @@ -48,28 +50,42 @@ interface BuildPattern { const BUILD_PATTERNS: BuildPattern[] = [ { label: 'cargo build --release', - cmdRe: /\bcargo\s+(?:b|build)\b[^&;|]*?(?:--release|\s-r\b)/, + // `cargo` (or `cargo b`/`build`) with a release flag, as a real + // command — not the words appearing in a quoted string or a sibling. + matches: command => + commandsFor(command, 'cargo').some( + c => + (c.args.includes('build') || c.args.includes('b')) && + (c.args.includes('--release') || c.args.includes('-r')), + ), pgrepPattern: 'cargo (build|b).*(--release|-r)', }, { label: 'pnpm build:prod', - cmdRe: /\bpnpm\s+(?:run\s+)?build:prod\b/, + // `pnpm build:prod` or `pnpm run build:prod` — the script token shows + // up as an arg either way. + matches: command => + commandsFor(command, 'pnpm').some(c => c.args.includes('build:prod')), pgrepPattern: 'pnpm.*build:prod', }, { label: 'node scripts/build.mts --prod', - cmdRe: /\bnode\s+(?:[^&;|]*\/)?scripts\/build\.mts\b[^&;|]*?--prod/, + // `node …/scripts/build.mts --prod` — the script path is an arg ending + // in scripts/build.mts and --prod is a flag on the same node command. + matches: command => + commandsFor(command, 'node').some( + c => + c.args.some(a => /(?:^|\/)scripts\/build\.mts$/.test(a)) && + c.args.includes('--prod'), + ), pgrepPattern: 'node.*scripts/build\\.mts.*--prod', }, ] export function commandMatchesBuild(command: string): BuildPattern | undefined { - // Exempt cargo check + bare cargo build (no --release) explicitly. - // The matching regex already requires --release / -r, so this is just - // documentation — the false-positive surface is bounded. for (let i = 0, { length } = BUILD_PATTERNS; i < length; i += 1) { const p = BUILD_PATTERNS[i]! - if (p.cmdRe.test(command)) { + if (p.matches(command)) { return p } } diff --git a/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts b/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts index 64ebe17..a297b82 100644 --- a/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts +++ b/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { fileURLToPath } from 'node:url' import path from 'node:path' import test from 'node:test' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/consumer-grep-reminder/test/index.test.mts b/.claude/hooks/consumer-grep-reminder/test/index.test.mts index 86f007d..cc4cff9 100644 --- a/.claude/hooks/consumer-grep-reminder/test/index.test.mts +++ b/.claude/hooks/consumer-grep-reminder/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -27,6 +27,11 @@ function mkRepo(opts: { consumerDirs?: string[] | undefined } = {}): string { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/cross-repo-guard/index.mts b/.claude/hooks/cross-repo-guard/index.mts index 4478506..a74d929 100644 --- a/.claude/hooks/cross-repo-guard/index.mts +++ b/.claude/hooks/cross-repo-guard/index.mts @@ -40,29 +40,13 @@ import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { FLEET_REPO_NAMES } from '../_shared/fleet-repos.mts' import { readStdin } from '../_shared/transcript.mts' const logger = getDefaultLogger() -const FLEET_REPO_NAMES = [ - 'claude-code', - 'skills', - 'socket-addon', - 'socket-btm', - 'socket-cli', - 'socket-lib', - 'socket-packageurl-js', - 'socket-registry', - 'socket-wheelhouse', - 'socket-sdk-js', - 'socket-sdxgen', - 'socket-stuie', - 'ultrathink', - 'vscode-socket-security', -] as const - const FLEET_RE_FRAGMENT = FLEET_REPO_NAMES.join('|') // `..//…` and deeper variants like `../..//…`. Boundary diff --git a/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts b/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts index c7340d8..3a3d745 100644 --- a/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts +++ b/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts @@ -1,7 +1,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' import { test } from 'node:test' @@ -24,6 +24,11 @@ function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { const child = spawn(process.execPath, [HOOK], { stdio: ['pipe', 'ignore', 'pipe'], }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/default-branch-guard/index.mts b/.claude/hooks/default-branch-guard/index.mts index d56a7f2..0b3c802 100644 --- a/.claude/hooks/default-branch-guard/index.mts +++ b/.claude/hooks/default-branch-guard/index.mts @@ -53,15 +53,16 @@ const SCRIPT_CONTEXT_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ { label: 'BASE=main / BASE=master literal assignment', - regex: /\bBASE\s*=\s*(["']?)(main|master)\1\b/, + regex: /\bBASE\s*=\s*(["']?)(?:main|master)\1\b/, }, { label: '--base main / --base=main literal value', - regex: /--base[\s=](["']?)(main|master)\1\b/, + regex: /--base[\s=](["']?)(?:main|master)\1\b/, }, { label: 'DEFAULT_BRANCH=main literal assignment', - regex: /\b(DEFAULT_BRANCH|MAIN_BRANCH)\s*=\s*(["']?)(main|master)\2\b/, + regex: + /\b(?:DEFAULT_BRANCH|MAIN_BRANCH)\s*=\s*(["']?)(?:main|master)\1\b/, }, ] @@ -70,9 +71,9 @@ const SCRIPT_CONTEXT_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = // to `main..HEAD` / `main...HEAD` inside the writeable body counts as // scripting context. const SCRIPT_WRITE_RE = - /(cat\s*>\s*|tee\s+|>\s*)\S+\.(bash|fish|js|mjs|mts|sh|ts|zsh)\b/ + /(?:cat\s*>\s*|tee\s+|>\s*)\S+\.(?:bash|fish|js|mjs|mts|sh|ts|zsh)\b/ -const TRIPLE_DOT_BRANCH_RE = /\b(main|master)\.{2,3}HEAD\b/ +const TRIPLE_DOT_BRANCH_RE = /\b(?:main|master)\.{2,3}HEAD\b/ async function main(): Promise { if (process.env['SOCKET_DEFAULT_BRANCH_GUARD_DISABLED']) { diff --git a/.claude/hooks/default-branch-guard/test/index.test.mts b/.claude/hooks/default-branch-guard/test/index.test.mts index c988cd0..3ee2a32 100644 --- a/.claude/hooks/default-branch-guard/test/index.test.mts +++ b/.claude/hooks/default-branch-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -25,7 +25,6 @@ function runHook( extraEnv: Record = {}, ): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, @@ -89,7 +88,6 @@ test('ALLOWS the canonical lookup pattern', () => { test('IGNORES non-Bash tools', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Write', tool_input: { command: 'BASE=main' }, diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/README.md b/.claude/hooks/dirty-worktree-on-stop-reminder/README.md new file mode 100644 index 0000000..5afeee6 --- /dev/null +++ b/.claude/hooks/dirty-worktree-on-stop-reminder/README.md @@ -0,0 +1,48 @@ +# dirty-worktree-on-stop-reminder + +Stop hook that emits a stderr reminder at turn-end if `git status +--porcelain` shows any modified, untracked, or staged-uncommitted +files in the harness project dir. + +## Why + +CLAUDE.md "Don't leave the worktree dirty" already states the rule: +finish a code change → commit it. The complementary +`no-orphaned-staging` hook catches only staged-but-uncommitted index +entries; this hook closes the broader gap — **unstaged modifications +and untracked files** that the agent left behind because they came +from a `pnpm run format` sweep, a script side-effect, or +"I'll get to it later." + +Past failure: an agent committed surgical work (T1, T2) but left 28 +formatter-touched files dirty because they came from an earlier +`pnpm run format` sweep. The agent announced "intentional pause" +in the turn summary instead of resolving the state. The next session +inherited a 28-file diff with no clear ownership. + +## What it does + +Runs `git status --porcelain` in `$CLAUDE_PROJECT_DIR`. Filters out +untracked-by-default trees (`vendor/`, `third_party/`, `upstream/`, +`additions/source-patched/`, `deps/`, `external/`, `pkg-node/`, +`*-bundled/`, `*-vendored/`) so vendor drops don't trip the reminder. +Reports the remaining dirty paths plus a 3-option remediation menu: +commit / revert / explicitly announce. + +Never blocks. Informational stderr only — the Stop event has no tool +call to refuse. + +## Disable + +```bash +SOCKET_DIRTY_WORKTREE_REMINDER_DISABLED=1 +``` + +## Related + +- `no-orphaned-staging` — Stop hook for staged-but-uncommitted hunks +- `node-modules-staging-guard` — PreToolUse block for `git add -f` of + `node_modules/` (bypass: `Allow node-modules-staging bypass`) +- `overeager-staging-guard` — PreToolUse block for `git add -A` / + `git add .` (bypass: `Allow add-all bypass`) +- Fleet doc: [`docs/claude.md/fleet/worktree-hygiene.md`](../../docs/claude.md/fleet/worktree-hygiene.md) diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts b/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts new file mode 100644 index 0000000..f25636f --- /dev/null +++ b/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Claude Code Stop hook — dirty-worktree-on-stop-reminder. +// +// Fires at turn-end. Checks `git status --porcelain` in the harness +// project dir. If anything is modified, untracked, or staged but +// uncommitted, emits a stderr reminder listing the dirty paths. +// +// The fleet rule (CLAUDE.md "Don't leave the worktree dirty"): +// +// Finish a code change → commit it. Never end a turn with +// uncommitted edits, untracked files, or staged-but-uncommitted +// hunks. If you can't commit yet (mid-refactor, failing tests, +// waiting on user), announce it in the turn summary — silent +// dirty worktrees are the failure mode. +// +// Why a reminder, not a block: Stop hooks fire AFTER the turn ended; +// there's no tool call to refuse. The reminder makes dirty state +// visible at the very turn that created it, so the agent can resolve +// it (commit / revert / explicitly announce) before the next turn. +// +// Complements `no-orphaned-staging` which only catches index entries. +// This hook catches the broader dirty-worktree case: unstaged +// modifications and untracked files. +// +// Untracked-by-default directories (vendor/, third_party/, upstream/, +// additions/source-patched/) are filtered out — they're under +// .gitignore rules and not the failure mode this hook targets. +// +// Exit codes: +// 0 — always. Informational; never blocks. +// +// Disabled via `SOCKET_DIRTY_WORKTREE_REMINDER_DISABLED=1`. + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import process from 'node:process' + +export async function drainStdin(): Promise { + await new Promise(resolve => { + let chunks = '' + process.stdin.on('data', d => { + chunks += d.toString('utf8') + }) + process.stdin.on('end', () => resolve()) + process.stdin.on('error', () => resolve()) + setTimeout(() => resolve(), 200) + void chunks + }) +} + +export function getProjectDir(): string | undefined { + return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() +} + +interface DirtyEntry { + readonly status: string + readonly path: string +} + +// Untracked-by-default path prefixes — match the CLAUDE.md +// "Untracked-by-default for vendored / build-copied trees" list. +const UNTRACKED_BY_DEFAULT_PREFIXES = [ + 'additions/source-patched/', + 'vendor/', + 'third_party/', + 'external/', + 'upstream/', + 'deps/', + 'pkg-node/', +] + +export function isUntrackedByDefault(p: string): boolean { + for (const prefix of UNTRACKED_BY_DEFAULT_PREFIXES) { + if (p.startsWith(prefix)) { + return true + } + } + if (/(^|\/)[^/]+-(?:bundled|vendored)(\/|$)/.test(p)) { + return true + } + return false +} + +export function parsePorcelain(out: string): DirtyEntry[] { + const entries: DirtyEntry[] = [] + for (const line of out.split('\n')) { + if (!line) { + continue + } + const status = line.slice(0, 2) + const rest = line.slice(3) + const arrow = rest.indexOf(' -> ') + const filePath = arrow === -1 ? rest : rest.slice(arrow + 4) + if (isUntrackedByDefault(filePath)) { + continue + } + entries.push({ status, path: filePath }) + } + return entries +} + +export function listDirtyEntries(repoDir: string): DirtyEntry[] { + const r = spawnSync('git', ['status', '--porcelain'], { + cwd: repoDir, + timeout: 5_000, + }) + if (r.status !== 0) { + return [] + } + return parsePorcelain(String(r.stdout)) +} + +async function main(): Promise { + if (process.env['SOCKET_DIRTY_WORKTREE_REMINDER_DISABLED']) { + return + } + await drainStdin() + + const repoDir = getProjectDir() + if (!repoDir) { + return + } + + const dirty = listDirtyEntries(repoDir) + if (dirty.length === 0) { + return + } + + process.stderr.write( + `[dirty-worktree-on-stop-reminder] Turn ended with ${dirty.length} dirty path(s):\n`, + ) + for (const e of dirty.slice(0, 10)) { + process.stderr.write(` ${e.status} ${e.path}\n`) + } + if (dirty.length > 10) { + process.stderr.write(` ... and ${dirty.length - 10} more\n`) + } + process.stderr.write( + "\nFleet rule: end-of-turn worktree must match the user's mental\n" + + "model of where the work is. 'Done' means committed. Options:\n" + + ' • Commit the dirty paths (surgical: explicit file args).\n' + + ' • Revert paths you did not author this session.\n' + + ' • If pause is intentional (mid-refactor, waiting on user),\n' + + ' announce it explicitly in the turn summary.\n' + + '\nSilent dirty worktrees break the next session. See:\n' + + ' CLAUDE.md → "Don\'t leave the worktree dirty"\n' + + ' docs/claude.md/fleet/worktree-hygiene.md\n', + ) +} + +main().catch(e => { + process.stderr.write( + `[dirty-worktree-on-stop-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) +}) diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/package.json b/.claude/hooks/dirty-worktree-on-stop-reminder/package.json new file mode 100644 index 0000000..6b836ac --- /dev/null +++ b/.claude/hooks/dirty-worktree-on-stop-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-dirty-worktree-on-stop-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts b/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts new file mode 100644 index 0000000..8595474 --- /dev/null +++ b/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts @@ -0,0 +1,94 @@ +// node --test specs for the dirty-worktree-on-stop-reminder hook. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { isUntrackedByDefault, parsePorcelain } from '../index.mts' + +test('isUntrackedByDefault: vendor/ prefix', () => { + assert.strictEqual(isUntrackedByDefault('vendor/foo.cc'), true) +}) + +test('isUntrackedByDefault: third_party/ prefix', () => { + assert.strictEqual(isUntrackedByDefault('third_party/lib/x.h'), true) +}) + +test('isUntrackedByDefault: upstream/ prefix', () => { + assert.strictEqual(isUntrackedByDefault('upstream/node/src/foo.cc'), true) +}) + +test('isUntrackedByDefault: additions/source-patched/ prefix', () => { + assert.strictEqual( + isUntrackedByDefault('additions/source-patched/bin-infra/main.js'), + true, + ) +}) + +test('isUntrackedByDefault: deps/ prefix', () => { + assert.strictEqual(isUntrackedByDefault('deps/curl/src.c'), true) +}) + +test('isUntrackedByDefault: pkg-node/ prefix', () => { + assert.strictEqual(isUntrackedByDefault('pkg-node/foo.js'), true) +}) + +test('isUntrackedByDefault: *-bundled component', () => { + assert.strictEqual(isUntrackedByDefault('something-bundled/x.js'), true) + assert.strictEqual(isUntrackedByDefault('packages/foo-bundled/a.ts'), true) +}) + +test('isUntrackedByDefault: *-vendored component', () => { + assert.strictEqual(isUntrackedByDefault('node-vendored/file.cc'), true) +}) + +test('isUntrackedByDefault: ordinary tracked path', () => { + assert.strictEqual(isUntrackedByDefault('src/index.ts'), false) + assert.strictEqual(isUntrackedByDefault('packages/foo/lib/x.ts'), false) + assert.strictEqual( + isUntrackedByDefault('.github/workflows/release.yml'), + false, + ) +}) + +test('parsePorcelain: modified + untracked + staged', () => { + const out = [ + ' M src/index.ts', + '?? new-file.md', + 'M staged.ts', + 'A added.ts', + '', + ].join('\n') + const entries = parsePorcelain(out) + assert.strictEqual(entries.length, 4) + assert.deepStrictEqual(entries.map(e => e.path).sort(), [ + 'added.ts', + 'new-file.md', + 'src/index.ts', + 'staged.ts', + ]) +}) + +test('parsePorcelain: rename uses destination', () => { + const out = 'R old/path.ts -> new/path.ts\n' + const entries = parsePorcelain(out) + assert.strictEqual(entries.length, 1) + assert.strictEqual(entries[0]!.path, 'new/path.ts') +}) + +test('parsePorcelain: filters vendor/upstream', () => { + const out = [ + ' M src/real.ts', + ' M vendor/skip.cc', + ' M upstream/node/skip.cc', + '?? third_party/skip.h', + '', + ].join('\n') + const entries = parsePorcelain(out) + assert.strictEqual(entries.length, 1) + assert.strictEqual(entries[0]!.path, 'src/real.ts') +}) + +test('parsePorcelain: empty input', () => { + assert.deepStrictEqual(parsePorcelain(''), []) + assert.deepStrictEqual(parsePorcelain('\n\n'), []) +}) diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json b/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts b/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts index e79528a..1bf1051 100644 --- a/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts +++ b/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -31,7 +31,6 @@ function runHook( extraEnv: Record = {}, ): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), env: { ...process.env, ...extraEnv }, }) @@ -347,7 +346,6 @@ test('disabled env var short-circuits', () => { test('does not crash on missing transcript_path', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({}), }) assert.equal(result.status, 0) @@ -355,7 +353,6 @@ test('does not crash on missing transcript_path', () => { test('does not crash on malformed payload', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not-json', }) assert.equal(result.status, 0) diff --git a/.claude/hooks/drift-check-reminder/README.md b/.claude/hooks/drift-check-reminder/README.md index 8a98e5f..31e6c17 100644 --- a/.claude/hooks/drift-check-reminder/README.md +++ b/.claude/hooks/drift-check-reminder/README.md @@ -4,7 +4,7 @@ Stop hook that nudges when an assistant turn edits a fleet-canonical surface (CL ## Why -Fleet repos drift fast when one repo bumps a shared resource and the others aren't updated. CLAUDE.md's "Drift watch" rule requires: edit in repo A, reconcile in repos B/C/D in the same PR or open a `chore(sync): cascade …` follow-up. +Fleet repos drift fast when one repo bumps a shared resource and the others aren't updated. CLAUDE.md's "Drift watch" rule requires: edit in repo A, reconcile in repos B/C/D in the same PR or open a `chore(wheelhouse): cascade …` follow-up. ## What it catches @@ -12,7 +12,7 @@ Assistant turn that: 1. Mentions a drift surface — `external-tools.json`, `template/CLAUDE.md`, `template/.claude/hooks/`, `.github/actions/`, `lockstep.json`, `setup-and-install`, `cache-versions.json`, `.gitmodules`. 2. AND uses an edit verb (`updated`, `edited`, `bumped`, `added`, `removed`, `landed`, etc.). -3. AND does NOT mention `cascade` / `sync` / `drift` / `fleet` / `other repos` / `downstream` / `chore(sync)` / `re-cascade`. +3. AND does NOT mention `cascade` / `sync` / `drift` / `fleet` / `other repos` / `downstream` / `chore(wheelhouse)` / `re-cascade`. ## Bypass diff --git a/.claude/hooks/drift-check-reminder/index.mts b/.claude/hooks/drift-check-reminder/index.mts index 086e213..9c51895 100644 --- a/.claude/hooks/drift-check-reminder/index.mts +++ b/.claude/hooks/drift-check-reminder/index.mts @@ -5,7 +5,7 @@ // repo without mentioning a drift check / cascade to the other fleet // repos. The fleet's "Drift watch" rule says: when you bump a shared // resource (tool SHA, action SHA, CLAUDE.md fleet block, hook code), -// either reconcile in the same PR or open a `chore(sync): cascade …` +// either reconcile in the same PR or open a `chore(wheelhouse): cascade …` // follow-up. // // What this hook catches: @@ -43,7 +43,7 @@ const DRIFT_SURFACE_RE = // Cascade-acknowledgement phrases. Any of these in the same turn // satisfies the check. const CASCADE_ACK_RE = - /\b(cascade|sync(-scaffolding)?|drift|fleet|other repos?|downstream|chore\(sync\)|re-cascade|recascade)\b/i + /\b(cascade|sync-scaffolding|drift|fleet|other repos?|downstream|chore\(wheelhouse\)|re-cascade|recascade|wheelhouse)\b/i // We want this to fire only when an EDIT actually happened, not just // a passing mention. The simplest proxy: look for verbs that imply @@ -92,7 +92,7 @@ async function main(): Promise { '', ' Per CLAUDE.md "Drift watch": when you edit one of these in repo A,', ' either reconcile the other fleet repos in the same PR or open a', - ' `chore(sync): cascade from ` follow-up.', + ' `chore(wheelhouse): cascade from ` follow-up.', '', ' Drift surfaces include: external-tools.json, template/CLAUDE.md,', ' template/.claude/hooks/, .github/actions/, lockstep.json,', diff --git a/.claude/hooks/drift-check-reminder/test/index.test.mts b/.claude/hooks/drift-check-reminder/test/index.test.mts index ceab96a..d350bef 100644 --- a/.claude/hooks/drift-check-reminder/test/index.test.mts +++ b/.claude/hooks/drift-check-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -23,7 +23,6 @@ function makeTranscript(assistantText: string): string { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -91,7 +90,6 @@ test('does NOT fire on non-drift edits', () => { test('disabled env var short-circuits', () => { const t = makeTranscript('Bumped external-tools.json.') const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: t }), env: { ...process.env, SOCKET_DRIFT_CHECK_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/enterprise-push-property-reminder/README.md b/.claude/hooks/enterprise-push-property-reminder/README.md new file mode 100644 index 0000000..c841f95 --- /dev/null +++ b/.claude/hooks/enterprise-push-property-reminder/README.md @@ -0,0 +1,50 @@ +# enterprise-push-property-reminder + +A **Claude Code PostToolUse hook** that fires after a `git push` rejected by the Socket enterprise ruleset, and surfaces the canonical bypass: the repo's `temporarily-doesnt-touch-customers` custom property. + +## Why this exists + +Some SocketDev repos sit under an enterprise-level GitHub ruleset on `refs/heads/main` that rejects direct pushes with: + +``` +remote: - Required workflow '' is not satisfied +remote: - Changes must be made through a pull request. +``` + +These rules sit ABOVE per-repo admin. The fleet escape hatch — the wheelhouse-canonical mechanism — is the per-repo custom property `temporarily-doesnt-touch-customers === "true"`. When set, `canSkipReviewGate()` in `socket-wheelhouse/scripts/_shared/repo-properties.mts` allows direct push for routine cascade work. + +The hook makes this discoverable. Without it, the rejection error leaves the operator (or the next assistant turn) guessing which of "open a PR / `gh pr merge --admin` / disable the ruleset / something else" is right. The property is the actual answer for routine work. + +## What it does + +1. PostToolUse on every `Bash` call. +2. Filters to commands matching `\bgit\s+push\b`. +3. Inspects `tool_response` for the enterprise-ruleset rejection pattern (both `Repository rule violations found` AND `Changes must be made through a pull request` must be present — single-match would false-fire on generic push errors). +4. On match: writes a stderr reminder to Claude with: + - The property name + required literal-string value (`"true"`) + - The current property value (queried via `gh api repos/{owner}/{repo}/properties/values`) + - A link to the repo's properties page in the GitHub UI + - A pointer to `docs/claude.md/fleet/push-policy.md` for full rationale + +The hook **does not** modify the property or retry the push. The operator decides whether the bypass is appropriate for the current change set. + +## Exit semantics + +- Exit 0 with stderr message on match (informational, doesn't block). +- Exit 0 silent on any non-match path (wrong tool, wrong command, no ruleset error). +- Exit 0 silent on any internal error (fail-open — a bad hook deploy can't suppress legitimate push errors). + +## When NOT to expect a reminder + +- The push succeeded. +- The push failed for a non-ruleset reason (auth, conflict, signature mismatch). +- The push wasn't actually `git push` (e.g. `gh push` or `git-lfs push`). +- The repo isn't under the Socket enterprise ruleset. + +The pattern requires both error lines for a tight match — generic "permission denied" or "branch protection" failures don't trip it. + +## See also + +- `docs/claude.md/fleet/push-policy.md` — full rationale + operator flow. +- `scripts/_shared/repo-properties.mts` — `canSkipReviewGate()` implementation used by the cascade. +- `.claude/hooks/pr-vs-push-default-reminder/` — sibling hook for the reverse case (Claude opening a PR when direct push would have worked). diff --git a/.claude/hooks/enterprise-push-property-reminder/index.mts b/.claude/hooks/enterprise-push-property-reminder/index.mts new file mode 100644 index 0000000..fe3a40c --- /dev/null +++ b/.claude/hooks/enterprise-push-property-reminder/index.mts @@ -0,0 +1,247 @@ +#!/usr/bin/env node +// Claude Code PostToolUse hook — enterprise-push-property-reminder. +// +// After a Bash `git push` fails with the enterprise-ruleset error +// pattern, surface the canonical bypass: the repo's +// `temporarily-doesnt-touch-customers` custom property. +// +// Fleet context: some SocketDev repos sit under a Socket-enterprise +// ruleset on refs/heads/main that requires PRs + a specific Audit +// workflow. The escape hatch (per cascade convention in +// `socket-wheelhouse/scripts/_shared/repo-properties.mts`) is the +// per-repo custom property `temporarily-doesnt-touch-customers === +// 'true'`. When set, `canSkipReviewGate()` returns true and direct +// push is allowed. +// +// This hook detects: +// 1. Bash tool calls +// 2. Containing `git push` (or `git push --no-verify`, etc.) +// 3. Whose output contains the enterprise ruleset rejection pattern +// +// On match, it writes a stderr reminder to Claude with: +// - The property name + required value (`"true"` literal string) +// - The current value of that property (via `gh api`) +// - A link to docs/claude.md/fleet/push-policy.md +// +// The hook does NOT modify the property or retry the push — the +// operator decides whether the bypass is appropriate. +// +// PostToolUse, not PreToolUse: we react to the rejection, we don't +// try to predict it. Server-side rulesets are the ground truth. +// +// Fail-open on hook bugs: exit 0 + silent log so a bad deploy +// can't suppress legitimate push errors. + +import process from 'node:process' + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +import { findInvocation } from '../_shared/shell-command.mts' + +interface Payload { + readonly hook_event_name?: string | undefined + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: string | undefined } | undefined + readonly tool_response?: unknown | undefined +} + +// Patterns that identify the enterprise-ruleset rejection. Both must +// be present in the push output to fire — we don't want false +// positives from generic push failures (auth, conflict, etc.). +const RULESET_ERROR_PATTERNS: readonly RegExp[] = [ + /Repository rule violations found/, + /Changes must be made through a pull request/, +] + +// Detects `git push` invocations via the shell parser (sees through +// chains / `$(…)`; ignores a quoted "git push" in a message). The hook +// scopes to push commands only — pulls/fetches/commits don't trip the +// enterprise ruleset. +function isGitPush(command: string): boolean { + return findInvocation(command, { binary: 'git', subcommand: 'push' }) +} + +// Read the tool_response into a string for pattern matching. Bash's +// tool_response shape is typically `{ stdout: string, stderr: string, +// interrupted: boolean, isImage: boolean }` but harness variants may +// pass it as a bare string. Walk both shapes. +export function extractOutput(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (value !== null && typeof value === 'object') { + const obj = value as Record + const parts: string[] = [] + for (const key of ['stdout', 'stderr', 'output', 'content']) { + const v = obj[key] + if (typeof v === 'string') { + parts.push(v) + } + } + return parts.join('\n') + } + return '' +} + +export function isEnterpriseRulesetFailure(output: string): boolean { + for (let i = 0, { length } = RULESET_ERROR_PATTERNS; i < length; i += 1) { + if (!RULESET_ERROR_PATTERNS[i]!.test(output)) { + return false + } + } + return true +} + +// Read `owner/repo` from the current `git remote get-url origin` +// output. Returns undefined if the URL isn't a recognized +// SSH/HTTPS GitHub shape — the hook just won't surface the +// per-repo property state in that case. +export function getCurrentRepoSlug(): string | undefined { + const r = spawnSync('git', ['remote', 'get-url', 'origin'], { + encoding: 'utf8', + timeout: 2_000, + }) + if (r.status !== 0 || typeof r.stdout !== 'string') { + return undefined + } + const url = r.stdout.trim() + // SSH form: git@github.com:owner/repo.git + // HTTPS form: https://github.com/owner/repo(.git)? + const sshMatch = /git@github\.com:([^/]+)\/([^/.]+)/.exec(url) + if (sshMatch) { + return `${sshMatch[1]}/${sshMatch[2]}` + } + const httpsMatch = /github\.com\/([^/]+)\/([^/.]+)/.exec(url) + if (httpsMatch) { + return `${httpsMatch[1]}/${httpsMatch[2]}` + } + return undefined +} + +// Query the current state of the `temporarily-doesnt-touch-customers` +// property via `gh api`. Returns the value string or undefined on +// any failure (no auth, API blocked by firewall, property not set, +// etc.). The reminder treats undefined as "unknown, instruct the +// operator to set it explicitly". +export function getPropertyValue( + slug: string, + propertyName: string, +): string | undefined { + const r = spawnSync( + 'gh', + [ + 'api', + `repos/${slug}/properties/values`, + '--jq', + `.[] | select(.property_name == "${propertyName}") | .value`, + ], + { + encoding: 'utf8', + timeout: 5_000, + }, + ) + if (r.status !== 0) { + return undefined + } + const value = String(r.stdout ?? '').trim() + return value.length > 0 ? value : undefined +} + +export function formatReminder( + slug: string | undefined, + currentValue: string | undefined, +): string { + const lines: string[] = [] + lines.push('') + lines.push('🚨 enterprise-push-property-reminder') + lines.push('') + lines.push('The `git push` was rejected by the Socket enterprise ruleset on') + lines.push('refs/heads/main:') + lines.push('') + lines.push(' - Required workflow ... is not satisfied') + lines.push(' - Changes must be made through a pull request') + lines.push('') + lines.push('Canonical bypass for routine cascade work: set the repo') + lines.push( + '`temporarily-doesnt-touch-customers` custom property to the LITERAL', + ) + lines.push('string `"true"` (not `true`, not `True`).') + if (slug) { + lines.push('') + lines.push(`Repo: ${slug}`) + if (currentValue === undefined) { + lines.push(' current value: ') + } else { + lines.push(` current value: "${currentValue}"`) + } + lines.push(` GitHub UI: https://github.com/${slug}/settings/properties`) + } + lines.push('') + lines.push('After flipping the property:') + lines.push(' git push origin main') + lines.push('') + lines.push( + 'After the in-flight remediation window closes, flip it back to "false"', + ) + lines.push('(re-engages the ruleset).') + lines.push('') + lines.push( + 'Full rationale: docs/claude.md/fleet/push-policy.md (Enterprise-ruleset', + ) + lines.push('escape hatch section).') + lines.push('') + return lines.join('\n') +} + +async function readStdin(): Promise { + let raw = '' + for await (const chunk of process.stdin) { + raw += chunk + } + return raw +} + +async function main(): Promise { + let raw: string + try { + raw = await readStdin() + } catch { + process.exit(0) + } + if (!raw) { + process.exit(0) + } + let payload: Payload + try { + payload = JSON.parse(raw) as Payload + } catch { + process.exit(0) + } + if (payload.hook_event_name !== 'PostToolUse') { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command + if (typeof command !== 'string' || !isGitPush(command)) { + process.exit(0) + } + const output = extractOutput(payload.tool_response) + if (!isEnterpriseRulesetFailure(output)) { + process.exit(0) + } + const slug = getCurrentRepoSlug() + const currentValue = slug + ? getPropertyValue(slug, 'temporarily-doesnt-touch-customers') + : undefined + process.stderr.write(formatReminder(slug, currentValue)) + // Exit 0 — informational only. The push already failed; we're + // just adding context for the next assistant turn. + process.exit(0) +} + +main().catch(() => { + // Fail-open. + process.exit(0) +}) diff --git a/.claude/hooks/enterprise-push-property-reminder/package.json b/.claude/hooks/enterprise-push-property-reminder/package.json new file mode 100644 index 0000000..61ae449 --- /dev/null +++ b/.claude/hooks/enterprise-push-property-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-enterprise-push-property-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts b/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts new file mode 100644 index 0000000..25c402b --- /dev/null +++ b/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts @@ -0,0 +1,164 @@ +// node --test specs for the enterprise-push-property-reminder hook. + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +interface Result { + code: number + stderr: string +} + +async function runHook(payload: Record): Promise { + return new Promise(resolve => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + child.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + child.stdin.end(JSON.stringify(payload)) + }) +} + +const ENTERPRISE_ERROR_OUTPUT = [ + 'remote: error: GH013: Repository rule violations found for refs/heads/main.', + 'remote: Review all repository rules at https://github.com/.../rules?ref=refs%2Fheads%2Fmain', + 'remote: ', + "remote: - Required workflow 'Audit GHA Workflows, Audit GHA Workflows' is not satisfied", + 'remote: ', + 'remote: - Changes must be made through a pull request.', + 'To github.com:SocketDev/socket-btm.git', + ' ! [remote rejected] main -> main (push declined due to repository rule violations)', + 'error: failed to push some refs to ...', +].join('\n') + +test('non-Bash tool passes silently', async () => { + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/tmp/foo.ts' }, + tool_response: 'whatever', + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('Bash non-git-push command passes silently', async () => { + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls -la' }, + tool_response: ENTERPRISE_ERROR_OUTPUT, + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('git push WITHOUT enterprise error passes silently', async () => { + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git push origin main' }, + tool_response: 'Everything up-to-date', + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('git push WITH enterprise error fires reminder', async () => { + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git push origin main' }, + tool_response: ENTERPRISE_ERROR_OUTPUT, + }) + assert.equal(r.code, 0) + assert.match(r.stderr, /enterprise-push-property-reminder/) + assert.match(r.stderr, /temporarily-doesnt-touch-customers/) + assert.match(r.stderr, /"true"/) +}) + +test('git push WITH --no-verify + enterprise error still fires', async () => { + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git push --no-verify origin main' }, + tool_response: ENTERPRISE_ERROR_OUTPUT, + }) + assert.equal(r.code, 0) + assert.match(r.stderr, /enterprise-push-property-reminder/) +}) + +test('tool_response shaped as object with stderr field is read', async () => { + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git push origin main' }, + tool_response: { + stdout: '', + stderr: ENTERPRISE_ERROR_OUTPUT, + interrupted: false, + }, + }) + assert.equal(r.code, 0) + assert.match(r.stderr, /enterprise-push-property-reminder/) +}) + +test('partial error pattern (one line only) does NOT fire', async () => { + // Only "Repository rule violations" — missing "must be made through a PR" + const r = await runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git push origin main' }, + tool_response: 'remote: error: Repository rule violations found', + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('non-PostToolUse event passes silently', async () => { + const r = await runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'git push origin main' }, + tool_response: ENTERPRISE_ERROR_OUTPUT, + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('malformed JSON input passes silently (fail-open)', async () => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + child.stdin.end('not valid json') + const code: number = await new Promise(resolve => { + child.on('exit', c => resolve(c ?? 0)) + }) + assert.equal(code, 0) + assert.equal(stderr, '') +}) + +test('empty stdin passes silently', async () => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + child.stdin.end('') + const code: number = await new Promise(resolve => { + child.on('exit', c => resolve(c ?? 0)) + }) + assert.equal(code, 0) + assert.equal(stderr, '') +}) diff --git a/.claude/hooks/enterprise-push-property-reminder/tsconfig.json b/.claude/hooks/enterprise-push-property-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/enterprise-push-property-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/error-message-quality-reminder/index.mts b/.claude/hooks/error-message-quality-reminder/index.mts index b15f35a..110b134 100644 --- a/.claude/hooks/error-message-quality-reminder/index.mts +++ b/.claude/hooks/error-message-quality-reminder/index.mts @@ -60,18 +60,18 @@ const VAGUE_MESSAGE_PATTERNS: ReadonlyArray<{ { label: 'bare "invalid"', regex: - /^(invalid|invalid value|invalid input|invalid argument|invalid format)\.?$/i, + /^(?:invalid|invalid value|invalid input|invalid argument|invalid format)\.?$/i, hint: '"Invalid" describes the fallout, not the rule. Say what shape was expected: "must be lowercase", "must match /^[a-z]+$/", "must be one of X / Y / Z".', }, { label: 'bare "failed"', regex: - /^(failed|failure|operation failed|request failed|action failed)\.?$/i, + /^(?:failed|failure|operation failed|request failed|action failed)\.?$/i, hint: '"Failed" describes the symptom. Name what was attempted and what blocked it: "could not write : ENOENT", "fetch returned 503".', }, { label: 'bare "error occurred"', - regex: /^(an? )?error(\s+occurred)?\.?$/i, + regex: /^(?:an? )?error(?:\s+occurred)?\.?$/i, hint: 'The message says nothing the reader can act on. State the rule, the location, the bad value.', }, { @@ -81,18 +81,18 @@ const VAGUE_MESSAGE_PATTERNS: ReadonlyArray<{ }, { label: 'bare "unable to X" / "could not X" (verb-only)', - regex: /^(unable to|could not|cannot|can'?t)\s+\w+\.?$/i, + regex: /^(?:unable to|could not|cannot|can'?t)\s+\w+\.?$/i, hint: 'No object / no reason. "Unable to read" → "could not read : ".', }, { label: 'bare "not found"', - regex: /^(not found|not\s+exist|does not exist|missing)\.?$/i, + regex: /^(?:not found|not\s+exist|does not exist|missing)\.?$/i, hint: 'Missing what? Where? Say "config file not found: " with the specific path.', }, { label: 'bare "bad" / "wrong" / "incorrect"', regex: - /^(bad|wrong|incorrect|invalid format)(\s+(argument|data|format|input|value))?\.?$/i, + /^(?:bad|wrong|incorrect|invalid format)(?:\s+(?:argument|data|format|input|value))?\.?$/i, hint: 'Same as "invalid" — describe the rule the value violated, not how you feel about it.', }, ] diff --git a/.claude/hooks/error-message-quality-reminder/test/index.test.mts b/.claude/hooks/error-message-quality-reminder/test/index.test.mts index 40b1f99..5b1e966 100644 --- a/.claude/hooks/error-message-quality-reminder/test/index.test.mts +++ b/.claude/hooks/error-message-quality-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -28,7 +28,6 @@ function makeTranscript(assistantText: string): { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -165,7 +164,6 @@ test('disabled env var short-circuits', () => { ) try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, diff --git a/.claude/hooks/excuse-detector/index.mts b/.claude/hooks/excuse-detector/index.mts index 049dfdf..23e1338 100644 --- a/.claude/hooks/excuse-detector/index.mts +++ b/.claude/hooks/excuse-detector/index.mts @@ -90,14 +90,14 @@ await runStopReminder({ label: 'leave it for later', // Already deferral-shaped by construction ("leave" is the verb); // no extra DEFER pairing needed. - regex: /\bleave (it|that|this) for later\b/i, + regex: /\bleave (?:it|that|this) for later\b/i, why: 'CLAUDE.md "Completion": never leave TODO/FIXME/XXX/shims/stubs/placeholders — finish 100%.', }, { label: 'not my issue', // Already deferral-shaped; "not my X" is the surface form of // the deferral itself. - regex: /\bnot my (bug|issue|problem)\b/i, + regex: /\bnot my (?:bug|issue|problem)\b/i, why: 'CLAUDE.md "Unrelated issues are critical": same as "unrelated".', }, { @@ -108,31 +108,31 @@ await runStopReminder({ { label: 'should I implement … or accept', regex: - /\bshould (i|we) (build|do|fix|implement) [^.?!\n]+(or|,)\s+(accept|defer|document|leave|skip|treat)\b/i, + /\bshould (?:i|we) (?:build|do|fix|implement) [^.?!\n]+(?:or|,)\s+(?:accept|defer|document|leave|skip|treat)\b/i, why: 'CLAUDE.md "Fix > defer": this is a choice-architecture masquerading as a question. Fix it.', }, { label: 'accept … as (a) (known )?gap', regex: - /\baccept (this|it|that|[^.?!\n]{1,40}) as (a |an )?(known |documented |expected )?(drift|gap|limitation)\b/i, + /\baccept (?:this|it|that|[^.?!\n]{1,40}) as (?:a |an )?(?:known |documented |expected )?(?:drift|gap|limitation)\b/i, why: 'CLAUDE.md "Fix > defer": gap-acceptance is the rationalization branch. The fix is the answer unless the user explicitly asked for the trade-off.', }, { label: 'two paths/options: fix … or', regex: - /\b(three|two) (choices|options|paths)[^.?!\n]{0,40}(fix|implement)[^.?!\n]{0,80}(or|,)\s+(accept|defer|document|leave|skip|treat)\b/i, + /\b(?:three|two) (?:choices|options|paths)[^.?!\n]{0,40}(?:fix|implement)[^.?!\n]{0,80}(?:or|,)\s+(?:accept|defer|document|leave|skip|treat)\b/i, why: 'CLAUDE.md "Fix > defer": collapsing the menu — pick the fix path, start the first sub-step.', }, { label: 'document(ed)? (it )?as a known (gap|drift|limitation)', regex: - /\bdocument(ed)?\b[^.?!\n]{0,40}\bas a known (drift|gap|limitation)\b/i, + /\bdocument(?:ed)?\b[^.?!\n]{0,40}\bas a known (?:drift|gap|limitation)\b/i, why: 'CLAUDE.md "Fix > defer": "document as known gap" is the deferral euphemism. Fix it instead.', }, { label: 'want me to fix … or', regex: - /\bwant me to (address|build|do|fix|implement) [^.?!\n]+(or|,)\s+(skip|defer|document|treat|accept|leave|move on)\b/i, + /\bwant me to (?:address|build|do|fix|implement) [^.?!\n]+(?:or|,)\s+(?:skip|defer|document|treat|accept|leave|move on)\b/i, why: 'CLAUDE.md "Fix > defer": same pattern — re-litigating the fix decision. The user already said yes by virtue of asking.', }, ], diff --git a/.claude/hooks/excuse-detector/test/index.test.mts b/.claude/hooks/excuse-detector/test/index.test.mts index 7df0c7c..1145ba5 100644 --- a/.claude/hooks/excuse-detector/test/index.test.mts +++ b/.claude/hooks/excuse-detector/test/index.test.mts @@ -7,7 +7,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -66,6 +66,11 @@ async function runHook( const transcript = setupTranscript(rawContent) try { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) const payload: Record = { transcript_path: transcript.transcriptPath, } diff --git a/.claude/hooks/file-size-reminder/test/index.test.mts b/.claude/hooks/file-size-reminder/test/index.test.mts index ecbb2c1..2e93659 100644 --- a/.claude/hooks/file-size-reminder/test/index.test.mts +++ b/.claude/hooks/file-size-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -49,7 +49,6 @@ function writeLines(filePath: string, n: number): void { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -186,7 +185,6 @@ test('disabled env var short-circuits', () => { { name: 'Write', input: { file_path: target, content: '...' } }, ]) const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcript }), env: { ...process.env, SOCKET_FILE_SIZE_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/follow-direct-imperative-reminder/README.md b/.claude/hooks/follow-direct-imperative-reminder/README.md new file mode 100644 index 0000000..e2da1e3 --- /dev/null +++ b/.claude/hooks/follow-direct-imperative-reminder/README.md @@ -0,0 +1,44 @@ +# follow-direct-imperative-reminder + +Stop hook that flags assistant turns which respond to a bare imperative user command with hedging or re-litigation before the tool call. + +## Why + +CLAUDE.md "Judgment & self-evaluation" rule: + +> Direct imperatives → execute, don't litigate. When the user issues a bare command ("use nvm 26.2.0", "cancel the build", "do it", "kill it"), the response is the tool call, not a paragraph weighing trade-offs. + +Past incident (the trigger for this hook): user typed "use nvm use 26.2.0". Assistant responded with a paragraph explaining why it wouldn't help the in-flight build, instead of switching Node. Same turn the user typed "cancel the build right now". Assistant kept narrating build phases instead of killing the process. User asked for a hook to stop the behavior. + +The failure mode is analysis-before-action when the command was unambiguous. The user already weighed the trade-off. Re-litigating wastes a turn and signals the directive was optional. It wasn't. + +## Detection + +Two-signal rule, both must hit: + +1. **Previous user turn is a bare imperative.** Single short sentence (≤ 8 words), starts with an action verb (`cancel`, `kill`, `use`, `run`, `commit`, `push`, `do`, `continue`, etc.) or common imperative phrase (`let's`, `just`, `please`). No question mark (questions invite analysis). +2. **Assistant turn contains hedge / re-litigation markers**: + - `doesn't help` / `won't help` + - `before I do that` / `let me explain` / `let me first` + - `to be clear` / `worth noting` / `that said` / `actually` + - `the in-flight X` (re-litigating in-flight state) + - `caveat:` / `note:` / `important:` + +Both signals fire: stderr reminder lands in the next turn's context. + +## What it does NOT catch + +- Questions from the user ("should I use Node 26?"). Analysis is invited. +- Long contextual user messages. Those carry their own framing. +- Assistant turns that hedge after the tool call. Post-action qualification is fine. + +## Disable + +```bash +SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1 +``` + +## Related + +- `dont-stop-mid-queue-reminder`: Stop hook for premature "what's next?" after authorized continuous-work directives. +- `ask-suppression-reminder`: Stop hook for AskUserQuestion when recent transcript already authorized the obvious default. diff --git a/.claude/hooks/follow-direct-imperative-reminder/index.mts b/.claude/hooks/follow-direct-imperative-reminder/index.mts new file mode 100644 index 0000000..4eee34b --- /dev/null +++ b/.claude/hooks/follow-direct-imperative-reminder/index.mts @@ -0,0 +1,309 @@ +#!/usr/bin/env node +// Claude Code Stop hook — follow-direct-imperative-reminder. +// +// Fires at turn-end. If the immediately-preceding user turn was a bare +// imperative command (short, action-verb-led) AND the just-emitted +// assistant text contains hedge / re-litigation patterns BEFORE any +// tool call, emit a stderr reminder pointing at the failure mode. +// +// The fleet rule (CLAUDE.md "Judgment & self-evaluation"): +// +// Direct imperatives → execute, don't litigate. When the user +// issues a bare command ("use nvm 26.2.0", "cancel the build", +// "do it", "kill it"), the response is the tool call, not a +// paragraph weighing trade-offs. +// +// Past incident: user typed "use nvm use 26.2.0"; assistant responded +// with a paragraph explaining why it wouldn't help the in-flight +// build instead of running the command. Same turn the user typed +// "cancel the build right now" — assistant continued narrating +// build phases instead of killing the process. The user explicitly +// asked for a hook to stop this. +// +// Detection: +// - Last user turn is a single short imperative (≤ 8 words, +// starts with an action verb or a known imperative form). +// - Last assistant turn (just emitted) contains hedge openers +// OR a leading analysis paragraph that precedes any tool call. +// +// Why a reminder, not a block: Stop hooks fire AFTER the turn ended. +// The reminder lands in the next turn's context so the agent sees +// the pattern it just exhibited. +// +// Exit codes: +// 0 — always. Informational; never blocks. +// +// Disabled via `SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1`. + +import { readFileSync } from 'node:fs' +import process from 'node:process' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +interface TranscriptEntry { + readonly type?: string | undefined + readonly role?: string | undefined + readonly message?: + | { + readonly content?: unknown | undefined + readonly role?: string | undefined + } + | undefined + readonly content?: unknown | undefined +} + +export async function drainStdinJson(): Promise { + return await new Promise(resolve => { + let raw = '' + process.stdin.on('data', d => { + raw += d.toString('utf8') + }) + process.stdin.on('end', () => { + try { + resolve(raw ? (JSON.parse(raw) as StopPayload) : {}) + } catch { + resolve({}) + } + }) + process.stdin.on('error', () => resolve({})) + setTimeout(() => resolve({}), 200) + }) +} + +// Read the last N entries from a JSONL transcript file. The harness +// uses one JSON object per line. +export function readTranscriptTail( + path: string, + count: number, +): TranscriptEntry[] { + let text: string + try { + text = readFileSync(path, 'utf8') + } catch { + return [] + } + const lines = text.split('\n').filter(Boolean) + const tail = lines.slice(-count) + const out: TranscriptEntry[] = [] + for (const line of tail) { + try { + out.push(JSON.parse(line) as TranscriptEntry) + } catch { + // ignore malformed + } + } + return out +} + +// Flatten content (string | content-block-array) into one string. +export function flattenContent(content: unknown): string { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + const parts: string[] = [] + for (const block of content) { + if (block && typeof block === 'object') { + const b = block as { type?: string; text?: string } + if (b.type === 'text' && typeof b.text === 'string') { + parts.push(b.text) + } + } + } + return parts.join('\n') + } + return '' +} + +// Role detection across the two shapes the transcript uses. +export function entryRole(e: TranscriptEntry): string | undefined { + return e.role ?? e.message?.role ?? e.type +} + +export function entryText(e: TranscriptEntry): string { + return flattenContent(e.message?.content ?? e.content ?? '') +} + +// Imperative-command opening verbs/forms. Kept conservative — +// over-matching would trigger the reminder on normal conversation. +const IMPERATIVE_OPENERS = [ + // Single-verb commands. + 'cancel', + 'kill', + 'stop', + 'abort', + 'do', + 'use', + 'run', + 'commit', + 'push', + 'fix', + 'try', + 'continue', + 'restart', + 'rerun', + 'redo', + 'execute', + 'go', + 'land', + 'merge', + 'rebase', + 'reset', + 'add', + 'remove', + 'delete', + 'install', + 'switch', + 'check', + 'show', + 'list', + 'open', + 'close', + 'undo', + 'revert', + 'apply', + 'build', + 'test', + 'deploy', + 'finish', + 'follow', + 'now', + // Common imperative phrases. + "let's", + 'just', + 'please', +] + +// Returns true when the text looks like a bare imperative directive +// (short, action-verb-led, no question mark, no long context). +export function looksLikeImperative(text: string): boolean { + const trimmed = text.trim().toLowerCase() + if (!trimmed) { + return false + } + // Strip leading punctuation. + const body = trimmed.replace(/^[!,.\s]+/, '') + // Skip questions entirely — questions invite analysis. + if (body.includes('?')) { + return false + } + // Bounded length: long contextual messages are not bare imperatives. + const wordCount = body.split(/\s+/).filter(Boolean).length + if (wordCount > 8) { + return false + } + // Pull the first word. + const firstWord = body.split(/\s+/)[0] ?? '' + return IMPERATIVE_OPENERS.includes(firstWord) +} + +// Hedge / re-litigation markers in the assistant's text. The goal is +// to catch paragraphs that explain WHY the command might not help +// before the tool call lands. +const HEDGE_MARKERS = [ + /\bdoesn't help\b/i, + /\bwon't help\b/i, + /\bbefore (?:i|we) (?:do that|run|kick|switch|cancel)\b/i, + /\blet me (?:explain|first|note)\b/i, + /\b(?:to be clear|just so we'?re clear)\b/i, + /\bworth (?:checking|confirming|noting)\b/i, + /\bone thing to (?:note|flag)\b/i, + /\bthat said\b/i, + /\bactually,?\s+/i, + /\b(?:however|but),?\s+(?:that|the|this)\b/i, + // "the in-flight X is past Y" — re-litigation of in-flight state. + /\bthe in-?flight\b/i, + // Heavy throat-clearing. + /\b(?:caveat|note|important):/i, +] + +export function hasHedge(text: string): boolean { + for (const re of HEDGE_MARKERS) { + if (re.test(text)) { + return true + } + } + return false +} + +async function main(): Promise { + if (process.env['SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED']) { + return + } + const payload = await drainStdinJson() + const transcriptPath = payload.transcript_path + if (!transcriptPath) { + return + } + // Pull the last ~6 entries — usually covers the last user + last + // assistant turn plus any tool result entries between them. + const tail = readTranscriptTail(transcriptPath, 8) + if (tail.length === 0) { + return + } + + // Find the last assistant entry (what we just emitted) and the + // last user entry BEFORE it. + let lastAssistantIdx = -1 + for (let i = tail.length - 1; i >= 0; i -= 1) { + if (entryRole(tail[i]!) === 'assistant') { + lastAssistantIdx = i + break + } + } + if (lastAssistantIdx === -1) { + return + } + let lastUserIdx = -1 + for (let i = lastAssistantIdx - 1; i >= 0; i -= 1) { + if (entryRole(tail[i]!) === 'user') { + lastUserIdx = i + break + } + } + if (lastUserIdx === -1) { + return + } + + const userText = entryText(tail[lastUserIdx]!) + const assistantText = entryText(tail[lastAssistantIdx]!) + if (!userText || !assistantText) { + return + } + if (!looksLikeImperative(userText)) { + return + } + if (!hasHedge(assistantText)) { + return + } + + const userPreview = userText.trim().slice(0, 60) + process.stderr.write( + [ + '[follow-direct-imperative-reminder] You hedged before executing a direct imperative.', + '', + ` User said: "${userPreview}"`, + '', + ' The response to a bare command should be the tool call,', + ' not a paragraph weighing trade-offs. Hedge openers ("That', + ' won\'t help…", "Let me explain…", "Before I do that…") +', + ' analysis-before-action when the command was unambiguous', + ' are the failure mode the rule targets.', + '', + ' Fix: state the intent in one short sentence at most, then', + ' run the command. If you genuinely think the directive is', + " wrong, run it AFTER raising the concern — don't refuse to act.", + '', + " CLAUDE.md → 'Judgment & self-evaluation' → Direct imperatives.", + '', + ].join('\n'), + ) +} + +main().catch(e => { + process.stderr.write( + `[follow-direct-imperative-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) +}) diff --git a/.claude/hooks/follow-direct-imperative-reminder/package.json b/.claude/hooks/follow-direct-imperative-reminder/package.json new file mode 100644 index 0000000..fe86e4b --- /dev/null +++ b/.claude/hooks/follow-direct-imperative-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-follow-direct-imperative-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts b/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts new file mode 100644 index 0000000..a9798fc --- /dev/null +++ b/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts @@ -0,0 +1,111 @@ +// node --test specs for follow-direct-imperative-reminder. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { flattenContent, hasHedge, looksLikeImperative } from '../index.mts' + +test('looksLikeImperative: "use nvm 26.2.0"', () => { + assert.strictEqual(looksLikeImperative('use nvm 26.2.0'), true) +}) + +test('looksLikeImperative: "cancel the build right now"', () => { + assert.strictEqual(looksLikeImperative('cancel the build right now'), true) +}) + +test('looksLikeImperative: "kill it"', () => { + assert.strictEqual(looksLikeImperative('kill it'), true) +}) + +test('looksLikeImperative: "do what I said"', () => { + assert.strictEqual(looksLikeImperative('do what I said'), true) +}) + +test('looksLikeImperative: "continue"', () => { + assert.strictEqual(looksLikeImperative('continue'), true) +}) + +test('looksLikeImperative: rejects questions', () => { + assert.strictEqual(looksLikeImperative('should I use 26?'), false) +}) + +test('looksLikeImperative: rejects long context', () => { + assert.strictEqual( + looksLikeImperative( + 'use nvm to switch to Node 26.2.0 so the build runs with the right engines', + ), + false, + ) +}) + +test('looksLikeImperative: rejects non-verb opener', () => { + assert.strictEqual(looksLikeImperative('hey there friend'), false) + assert.strictEqual(looksLikeImperative('thanks for that'), false) +}) + +test('looksLikeImperative: empty', () => { + assert.strictEqual(looksLikeImperative(''), false) + assert.strictEqual(looksLikeImperative(' '), false) +}) + +test('hasHedge: "doesn\'t help"', () => { + assert.strictEqual( + hasHedge( + "Switching the shell's Node to 26.2.0 doesn't help the build that's already running", + ), + true, + ) +}) + +test('hasHedge: "Before I do that"', () => { + assert.strictEqual( + hasHedge('Before I do that, the in-flight build is at 37%.'), + true, + ) +}) + +test('hasHedge: "Let me explain"', () => { + assert.strictEqual(hasHedge('Let me explain why this fails.'), true) +}) + +test('hasHedge: "actually,"', () => { + assert.strictEqual(hasHedge('actually, the dependency graph shows…'), true) +}) + +test('hasHedge: clean status update', () => { + assert.strictEqual(hasHedge('Switched. Now on Node 26.2.0.'), false) +}) + +test('hasHedge: tool result narration', () => { + assert.strictEqual(hasHedge('Build cancelled. No processes remain.'), false) +}) + +test('flattenContent: string', () => { + assert.strictEqual(flattenContent('hi'), 'hi') +}) + +test('flattenContent: text blocks', () => { + assert.strictEqual( + flattenContent([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ]), + 'one\ntwo', + ) +}) + +test('flattenContent: ignores non-text blocks', () => { + assert.strictEqual( + flattenContent([ + { type: 'tool_use', name: 'Bash' }, + { type: 'text', text: 'survives' }, + ]), + 'survives', + ) +}) + +test('flattenContent: empty/garbage', () => { + assert.strictEqual(flattenContent(undefined), '') + assert.strictEqual(flattenContent(42), '') + assert.strictEqual(flattenContent(null), '') +}) diff --git a/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json b/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/gh-token-hygiene-guard/README.md b/.claude/hooks/gh-token-hygiene-guard/README.md new file mode 100644 index 0000000..2602d17 --- /dev/null +++ b/.claude/hooks/gh-token-hygiene-guard/README.md @@ -0,0 +1,237 @@ +# gh-token-hygiene-guard + +PreToolUse hook on Bash commands invoking `gh`. Enforces four +invariants motivated by the May 2026 Nx Console supply-chain +compromise (a malicious npm package read `~/.config/gh/hosts.yml` and +used the token against the GitHub API within 74 seconds of install). + +1. **Keychain storage.** Token must live in the OS keychain + (`gh auth status` reports `(keyring)`). On-disk + `~/.config/gh/hosts.yml` is rejected; no bypass. Detection is + **per-host**: the hook isolates the `github.com` block from + `gh auth status` before checking, so a keyring-backed + `github.enterprise.com` login can't mask a file-backed + `github.com` token. +2. **8-hour token age cap.** The hook stamps a local timestamp on + `gh auth login` / `gh auth refresh` and blocks every non-auth `gh` + command after 8 hours. Self-recovery: `gh auth refresh -h +github.com` is always allowed (re-stamps the file). This cap lives + in THIS hook, not `auth-rotation-reminder` (which handles non-gh + CLIs like npm / pnpm / gcloud / docker / vault). +3. **`workflow` scope is on-demand, single-use, physical-presence-gated.** + Recommended default scopes: `read:org, repo` (the hook does not + enforce a scope allowlist; gh forces `gist` as a minimum, so the + practical floor is `read:org, repo, gist`). To add the scope: + - Type `Allow workflow-scope bypass` in chat. **The phrase alone is + not enough** — an attacker who forges the chat-typed slot still + can't proceed without your physical presence. + - The hook runs **OS physical-presence authentication** (Touch ID / + YubiKey / fingerprint — see "Physical-presence auth" below). + - On success, `gh auth refresh -h github.com -s workflow` is let + through and the hook records a **session-bound** grant at + `~/.claude/gh-workflow-grant` (body = `\n`). + - The next `gh workflow run` verifies the grant's `session_id` + matches the dispatching session, then consumes it (deletes the + file). A grant planted by another process or a stale session is + rejected. + - A second dispatch requires a fresh bypass + auth cycle. +4. **Workflow scope revoke is always allowed** without bypass or auth + (`gh auth refresh -r workflow`), so users can clean up after a + dispatch. + +The dispatch gate also covers the API shape +(`gh api .../actions/workflows/.../dispatches`), not just +`gh workflow run` / `gh workflow dispatch`. + +## Operational state + +Two files under `~/.claude/`: + +- `gh-token-issued-at` — local timestamp of the last `gh auth login` / + `gh auth refresh`. Drives the 8h age check. First run stamps "now" + and treats the token as fresh (so the hook ships without forcing + every dev to re-auth on upgrade). +- `gh-workflow-grant` — **session-bound** marker for an unconsumed + workflow-dispatch authorization. Body is `\n`. + Presence alone is insufficient — the dispatch step cross-checks the + recorded `session_id` against the current Claude session. Deleted as + soon as a dispatch is let through. + +## Threat model & design choices + +- **Session-bound grants (not presence-only).** A presence-only marker + could be pre-created by a malicious postinstall (`touch +~/.claude/gh-workflow-grant`) before Claude even launches. Binding + the grant to the `session_id` the harness provides means a planted + grant from another process / session is rejected — the attacker + can't guess a session id the hook will later receive. +- **Physical presence on top of the chat phrase.** The single most + dangerous capability (dispatching workflows with access to all repo + secrets incl. npm publish tokens) is gated by a per-use biometric / + hardware-key check, not just a chat phrase that an injected agent + could emit. +- **Absolute `/usr/bin/` paths for sudo / dscl / osascript.** Defeats + PATH-hijack — a postinstall that drops `~/.local/bin/sudo` can't + intercept the auth call. (`gh` itself stays PATH-resolved; there's + no single canonical path across Homebrew / Intel / Linux.) +- **Known gaps** (documented in + [`docs/claude.md/fleet/security-stack.md`](../../../docs/claude.md/fleet/security-stack.md)): + the transcript JSONL the bypass-phrase check reads is + unauthenticated (needs harness HMAC), and `containsGhInvocation` is + regex-based, not AST-based (shell-variable / eval evasion possible). + +## Escape hatches + +None. The hook is failsafe-deny on its core invariants and +fail-closed on the auth path (no working physical-presence method → +block, never silently pass). There is **no test-only env-var +override** — `SOCKET_GH_HYGIENE_TEST_AUTH` was removed 2026-05-26 +because an attacker who planted it in a shell rc / `.envrc` / VS Code +terminal env would have bypassed Touch ID. The OS-auth path is +intentionally unreachable in unit tests and is exercised by manual +smoke-testing instead. + +## Physical-presence auth (cross-platform) + +The workflow-scope bypass (invariant 3) requires biometric / hardware +confirmation after the chat phrase. What works per platform: + +| Platform | Path | Notes | +| ---------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **macOS + Touch ID** | `pam_tid.so` on sudo | Best. Setup below. | +| **macOS + osascript, no MDM** | password dialog → `dscl -authonly` | Fallback when Touch ID isn't configured. | +| **macOS + MDM (iru/Jamf/Mosyle/Kandji)** | Touch ID only | osascript is blocked by org policy; the hook detects the MDM install on disk and skips osascript (no "Process Blocked" toast). | +| **Linux + YubiKey** | `pam_u2f.so` on sudo | FIDO2 device. | +| **Linux + fingerprint reader** | `pam_fprintd.so` on sudo | ThinkPad / Framework / some Dells. | +| **Linux, no biometric/key** | — | `unsupported` → block. Error gives setup recipes. | +| **Windows** | — | No reachable equivalent (Windows Hello needs a UWP context). Dispatch from a macOS/Linux host or the GitHub web UI. | + +**MDM detection is filesystem-only.** The hook checks for known +blocker install paths (`/Library/Application Support/iru`, +`/usr/local/jamf/bin/jamf`, `/Library/Mosyle`, `/Library/Kandji`, …) +with `existsSync` — it never invokes osascript to probe, because the +probe itself triggers the block toast. + +### Linux setup (one-time) + +YubiKey (or any FIDO2 device): + +```sh +sudo apt install libpam-u2f # Debian/Ubuntu +sudo dnf install pam-u2f # Fedora/RHEL +pamu2fcfg | sudo tee -a /etc/u2f_mappings +# Add to /etc/pam.d/sudo, above `@include common-auth`: +# auth sufficient pam_u2f.so authfile=/etc/u2f_mappings +``` + +Laptop fingerprint reader: + +```sh +sudo apt install libpam-fprintd fprintd # Debian/Ubuntu +sudo dnf install fprintd-pam # Fedora/RHEL +fprintd-enroll +# Add to /etc/pam.d/sudo, above `@include common-auth`: +# auth sufficient pam_fprintd.so +``` + +Verify either with `sudo -k && sudo -n true` — a silent exit 0 means +the hook will recognize it as a physical-presence success. + +## macOS Touch ID setup (one time, recommended on Sonoma+) + +The hook prints these instructions on first use if Touch ID isn't +configured. Run once to enable Touch ID as a sudo auth method (sudo +falls back to the password prompt if Touch ID is unavailable — +declined, no fingerprint enrolled, lid closed): + +```sh +sudo tee /etc/pam.d/sudo_local <<'EOF' +auth sufficient pam_tid.so +EOF +``` + +> **Copy-paste verbatim.** The closing `EOF` must start at column 0 +> (no leading whitespace) or the heredoc will not terminate and +> your shell will hang waiting for input. Same constraint applies +> to the body lines — they're sent to `tee` as-is. If you indented +> this block when transcribing it, strip the indent. + +After this, every bypass-authorized refresh pops a Touch ID dialog +(no password typing required). + +### What the command does, line by line + +- **`sudo tee /etc/pam.d/sudo_local`** — writes to `/etc/pam.d/sudo_local`, which requires root; `sudo tee` is the canonical "write a file as root from a normal shell" pattern. `tee` reads stdin and writes the file; `sudo` elevates `tee`. Plain `> /etc/pam.d/sudo_local` redirection wouldn't work because the redirect happens in your unprivileged shell BEFORE sudo runs. This first sudo invocation prompts for your password the conventional way (since Touch ID isn't set up yet); every sudo after this point gets the Touch ID option. + +- **`/etc/pam.d/sudo_local`** — the official macOS PAM extension point introduced in macOS Sonoma (14). Apple created it so users can layer auth methods on sudo without modifying `/etc/pam.d/sudo`, which is replaced on every macOS update. `/etc/pam.d/sudo`'s first line is `auth include sudo_local`, which pulls in whatever you put here. The file doesn't exist by default; creating it is what activates the extension. + +- **`<<'EOF' ... EOF`** — a [heredoc](https://en.wikipedia.org/wiki/Here_document). Everything between the markers becomes stdin for `tee`. The single quotes around the opening `'EOF'` disable shell variable / backtick expansion inside the body — `$foo` and `` ` `` stay literal. Conservative default for config files. + +- **`auth sufficient pam_tid.so`** — the PAM directive. Three fields: + - **`auth`** — the module-type. PAM stacks split into `auth`, `account`, `password`, and `session`; only `auth` modules participate in the "prove who you are" phase that sudo cares about. + - **`sufficient`** — the control flag. PAM evaluates auth modules top-to-bottom; `sufficient` means "if this succeeds, the whole stack succeeds; if it fails, ignore and try the next module". So Touch ID is given first chance, and if you decline the dialog or no fingerprint is enrolled, sudo silently falls through to the password prompt. + - **`pam_tid.so`** — Apple's Touch ID PAM module shipped at `/usr/lib/pam/pam_tid.so.2`. Pops the system Touch ID dialog and reports success / failure to PAM. Requires Touch ID hardware (M-series MacBook, Touch ID Magic Keyboard, or unlocked Apple Watch). + +### Why `sufficient` and not `required`? + +The four PAM control flags: + +- **`required`** — must succeed; failure recorded but stack keeps evaluating +- **`requisite`** — must succeed; failure short-circuits immediately +- **`sufficient`** — succeeds the whole stack on success; failure ignored, falls through +- **`optional`** — result ignored + +We use `sufficient` because Touch ID should be an **alternative** to typing the password, not a precondition. Lid closed, no fingerprint enrolled, declined dialog, broken sensor → sudo silently moves to the password path. No friction, no lockout. + +### Why not edit `/etc/pam.d/sudo` directly? + +You can; it's a text file. But macOS updates replace it on every system upgrade — your edit silently disappears after the next macOS minor release. `sudo_local` is preserved across upgrades; that's its whole purpose. + +### Verifying it works + +```sh +sudo -k # invalidate any cached auth +sudo -v # next sudo should pop the Touch ID dialog +``` + +If Touch ID dialog appears → good. If you see a password prompt → Touch ID isn't enrolled, or you're on hardware without Touch ID, or the file path / content is wrong. Re-run the setup and double-check. + +### Undoing it + +```sh +sudo rm /etc/pam.d/sudo_local +``` + +Back to default. On a non-MDM Mac the osascript password dialog still +works (slower). On an MDM-managed Mac, removing Touch ID leaves **no** +working path — re-enable it or dispatch from elsewhere. + +## Tests + +Run `node --test test/index.test.mts` (the `pnpm test` wrapper goes +through a workspace install that currently has unrelated drift). + +14 cases cover: + +- non-`gh` Bash command → pass +- on-disk storage → block +- keyring storage + non-dispatch `gh` command → pass +- workflow dispatch + no scope → block +- workflow dispatch + scope + unconsumed grant → pass +- workflow dispatch consumes the grant (single-use) → grant deleted +- workflow dispatch + scope + missing grant → block +- workflow dispatch + **attacker-planted grant (wrong session)** → block +- `gh auth refresh -s workflow` + no bypass → block +- `gh auth refresh -s workflow` + bypass → reaches the auth path + (outcome is environment-dependent; the test asserts it does NOT hit + the bypass-missing branch) +- `gh auth refresh -r workflow` (revoke) → pass without bypass +- `gh api .../dispatches` (api shape) → block +- token >8h old → block +- token >8h old + `gh auth refresh` → pass (self-recovery) + +The OS physical-presence path (Touch ID / pam_u2f / pam_fprintd / +osascript) and the MDM-blocker filesystem detection are **not** unit +tested — they're OS-specific and were removed from the test surface +when the `SOCKET_GH_HYGIENE_TEST_AUTH` override was deleted. Verify +manually on the target machine. diff --git a/.claude/hooks/gh-token-hygiene-guard/index.mts b/.claude/hooks/gh-token-hygiene-guard/index.mts new file mode 100644 index 0000000..4ff2230 --- /dev/null +++ b/.claude/hooks/gh-token-hygiene-guard/index.mts @@ -0,0 +1,831 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — gh-token-hygiene-guard. +// +// Four invariants on `gh` invocations, motivated by the May 2026 Nx +// Console supply-chain compromise (malicious npm package exfiltrated +// ~/.config/gh/hosts.yml and used the token against the GitHub API in +// <74 seconds): +// +// 1. KEYRING STORAGE. `gh auth status` must report `(keyring)`. The +// on-disk default at `~/.config/gh/hosts.yml` is exactly what the +// Nx malware exfiltrated. No bypass — move the token off disk. +// Fix: `gh auth logout && gh auth login` (keychain is the default +// since gh 2.40; `--secure-storage` does not exist — the only flag +// is `--insecure-storage` for opting out, which this hook rejects). +// Detection is PER-HOST: extractHostBlock() isolates the +// github.com block before checking, so a keyring-backed +// github.enterprise.com login can't mask a file-backed github.com. +// +// 2. 8-HOUR TOKEN AGE CAP. The hook stamps ~/.claude/gh-token-issued-at +// on `gh auth login` / `gh auth refresh` and blocks every non-auth +// `gh` command once the token is >8h old. Self-recovery: +// `gh auth refresh -h github.com` is always allowed (re-stamps). +// +// 3. WORKFLOW SCOPE ON-DEMAND, SINGLE-USE, PHYSICAL-PRESENCE-GATED. +// The `workflow` scope grants dispatch power over every workflow +// including publish / release. Recommended default scope set: +// `read:org, repo` (the hook does not enforce a scope allowlist; +// gh itself forces `gist` as a minimum, so the practical floor is +// `read:org, repo, gist`). To add the scope: +// a. User types `Allow workflow-scope bypass` in chat. +// b. Hook runs OS physical-presence auth (see +// requireUserAuthentication below) — the chat phrase ALONE is +// insufficient. An attacker who forges the chat-typed slot +// still can't proceed without your fingerprint / hardware key. +// c. On success, the hook records a SESSION-BOUND grant +// (~/.claude/gh-workflow-grant = `\n`). +// d. The next `gh workflow run` verifies the grant's session_id +// matches the dispatching session, then consumes it (deletes +// the file). A grant planted by another process / session is +// rejected. Any further dispatch needs a fresh phrase + auth. +// e. User manually re-revokes scope via +// `gh auth refresh -r workflow` when done (revoke needs no +// bypass). +// +// 4. KEYCHAIN-CLI READ DETECTION. Routing through the existing +// `no-blind-keychain-read-guard` handles `security +// find-generic-password` etc. — not duplicated here. +// +// Physical-presence auth (invariant 3, step b) is cross-platform: +// - macOS: Touch ID via pam_tid.so on sudo. osascript password +// dialog as fallback — UNLESS an MDM blocker (iru / Jamf / Mosyle / +// Kandji) is detected on disk, in which case osascript is skipped +// (invoking it would surface a "Process Blocked" toast). +// - Linux: pam_u2f (YubiKey / FIDO2) or pam_fprintd (laptop +// fingerprint) on sudo. resolveSudoBin() handles NixOS path. +// - Windows: no reachable path → 'unsupported' (fails closed). +// +// Exit codes: +// - 0: pass (not a gh command, or all checks satisfied) +// - 2: block (one of the invariants violated; stderr explains) +// +// Fail-open on hook bugs: main().catch() exits 0 so a bad deploy can't +// brick every gh command. Fail-CLOSED on auth (unsupported/denied → 2) +// because a missing physical-presence check must not silently pass. +// +// No test-only env override (removed 2026-05-26 as a supply-chain +// hardening measure — an attacker who planted SOCKET_GH_HYGIENE_TEST_AUTH +// in a shell rc / .envrc would have bypassed Touch ID). The OS-auth +// path is exercised by manual smoke-testing. +// +// Reads a PreToolUse JSON payload from stdin: +// { "tool_name": "Bash", "tool_input": { "command": "..." }, +// "transcript_path": "...", "session_id": "..." } + +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +import { findInvocation, parseCommands } from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +// Absolute paths for OS-auth binaries. PATH-hijack defense — a +// malicious npm postinstall that drops ~/.local/bin/sudo, ~/.local/bin/dscl, +// or ~/.local/bin/osascript cannot intercept these calls because spawnSync +// is given the absolute path. +// +// dscl + osascript are macOS-only and live at /usr/bin/. sudo varies: +// - macOS: /usr/bin/sudo +// - Linux: /usr/bin/sudo (most distros) or /run/wrappers/bin/sudo (NixOS) +// - Windows: no equivalent — Windows has no physical-presence path that +// can be invoked from a Node child process. Hook fails closed +// on win32. +// resolveSudoBin() checks the candidates and returns the first that +// exists, or undefined if none. Calls fail-closed via ENOENT if the +// returned path becomes unavailable between resolve and spawn (TOCTOU +// is non-exploitable here because the candidates are all system paths +// outside user writability). +const DSCL_BIN = '/usr/bin/dscl' +const OSASCRIPT_BIN = '/usr/bin/osascript' +const SUDO_CANDIDATES = [ + '/usr/bin/sudo', + '/usr/local/bin/sudo', + '/run/wrappers/bin/sudo', +] as const +function resolveSudoBin(): string | undefined { + for (let i = 0; i < SUDO_CANDIDATES.length; i += 1) { + if (existsSync(SUDO_CANDIDATES[i]!)) { + return SUDO_CANDIDATES[i] + } + } + return undefined +} + +const BYPASS_PHRASE = 'Allow workflow-scope bypass' +// One bypass phrase authorizes ONE workflow dispatch. The grant file's +// presence = unconsumed. The hook deletes the file immediately after +// letting the dispatch through, so a second dispatch (chain attack or +// genuine re-use) requires a fresh phrase. Token-age (8h) is the +// time-based check; the dispatch gate is single-use. +const WORKFLOW_GRANT_FILE = path.join(homedir(), '.claude', 'gh-workflow-grant') +const TOKEN_ISSUED_AT_FILE = path.join( + homedir(), + '.claude', + 'gh-token-issued-at', +) +const TOKEN_TTL_MS = 8 * 60 * 60 * 1000 // 8 hours + +interface PreToolUsePayload { + tool_name?: string | undefined + tool_input?: { command?: string | undefined } | undefined + transcript_path?: string | undefined + session_id?: string | undefined +} + +interface GhAuthStatus { + storage: 'keyring' | 'file' | 'unknown' + scopes: readonly string[] +} + +async function main(): Promise { + const raw = await readStdin() + let payload: PreToolUsePayload + try { + payload = raw ? JSON.parse(raw) : {} + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command ?? '' + if (!command) { + process.exit(0) + } + // Cheap pre-filter: only inspect commands that mention `gh`. + if (!containsGhInvocation(command)) { + process.exit(0) + } + // The auth-status read is the slow path (~50ms). Skip it when the + // gh command is a known read-only shape that doesn't touch tokens. + // For now, run on every gh command — paranoid by default. + let status: GhAuthStatus + try { + status = readGhAuthStatus() + } catch (e) { + // gh not installed, or no active auth — let the command run and + // gh itself will report. Don't double-block. + process.exit(0) + } + // Invariant 1: keyring storage. + if (status.storage === 'file') { + fail( + 'gh-token-hygiene-guard: gh token is stored on disk', + [ + 'Your gh CLI token lives at ~/.config/gh/hosts.yml. Any local', + 'process can read it (this is exactly the path the Nx Console', + 'supply-chain malware exfiltrated in May 2026).', + '', + 'Fix:', + ' gh auth logout', + ' gh auth login # keychain is the default', + ' gh auth status # confirms "(keyring)"', + '', + 'No bypass — moving the token off disk is non-negotiable.', + ].join('\n'), + ) + } + // Invariant 4 (checked early so the user can self-recover by + // running `gh auth refresh -h github.com` even when expired). + if (!isAuthMaintenanceCommand(command) && !isTokenFresh()) { + fail( + 'gh-token-hygiene-guard: gh token is >8h old', + [ + 'The fleet enforces an 8-hour cap on gh token age. Refresh:', + ' gh auth refresh -h github.com', + '', + '(Once refreshed, the hook stamps a local timestamp and', + 'gh commands flow normally again.)', + ].join('\n'), + ) + } + // Stamp the token-issued-at file on ANY auth-refresh / login flow. + // The actual refresh runs after this hook; stamping pre-emptively is + // fine because a failed refresh leaves the old token in place (and + // the next successful refresh re-stamps). Parser-confirmed `gh auth + // login|refresh` so a quoted mention doesn't spuriously re-stamp. + if ( + parseCommands(command).some( + c => + c.binary === 'gh' && + c.args.includes('auth') && + (c.args.includes('login') || c.args.includes('refresh')), + ) + ) { + recordTokenIssuedAt() + } + // Invariant 2: workflow scope on-demand. + const isWorkflowDispatch = + isWorkflowDispatchCommand(command) || isWorkflowApiDispatch(command) + const isWorkflowRefresh = isWorkflowScopeRefresh(command) + const hasWorkflowScope = status.scopes.includes('workflow') + if (isWorkflowRefresh) { + // Revoke is always allowed (no bypass needed). + if (isWorkflowScopeRevoke(command)) { + process.exit(0) + } + // Refresh-add: chat-bypass phrase + Touch ID sudo prompt both + // required. The phrase alone isn't sufficient — an attacker who + // exfiltrates the bypass-typed slot still can't proceed without + // your physical presence. + if (!bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + fail( + 'gh-token-hygiene-guard: adding workflow scope requires bypass', + [ + `Type \`${BYPASS_PHRASE}\` in chat before running:`, + ` ${command}`, + '', + 'After the phrase, Touch ID will prompt for physical confirmation.', + ].join('\n'), + ) + } + const authResult = requireUserAuthentication() + if (authResult === 'denied') { + fail( + 'gh-token-hygiene-guard: physical-presence check failed', + [ + 'Authentication was cancelled or password did not match.', + 'Re-run your command and approve the Touch ID / password prompt.', + ].join('\n'), + ) + } + if (authResult === 'unsupported') { + const platformGuidance = platformAuthGuidance() + fail( + 'gh-token-hygiene-guard: no physical-presence auth available', + [ + 'The workflow-scope bypass requires biometric / hardware-key', + 'confirmation. Nothing was reachable in this environment.', + '', + ...platformGuidance, + ].join('\n'), + ) + } + recordWorkflowGrant(payload.session_id) + process.exit(0) + } + if (isWorkflowDispatch) { + // Block if scope is absent — nothing to dispatch with. + if (!hasWorkflowScope) { + fail( + 'gh-token-hygiene-guard: workflow dispatch requires workflow scope', + [ + 'Token does not have the `workflow` scope. To dispatch:', + ` 1. Type \`${BYPASS_PHRASE}\` in chat.`, + ' 2. Run: gh auth refresh -h github.com -s workflow', + ' 3. Re-run your dispatch command.', + ' 4. Scope auto-revokes after one dispatch.', + ].join('\n'), + ) + } + // One bypass phrase = one dispatch. Grant file must exist AND + // bind to the current session_id. Pre-creation attack (attacker + // touches the file from a different process) is rejected because + // the recorded session_id won't match the dispatch session. + if (!verifyWorkflowGrant(payload.session_id)) { + fail( + 'gh-token-hygiene-guard: workflow dispatch grant is missing, expired, or session-mismatched', + [ + 'Token has `workflow` scope, but no valid dispatch grant for', + 'this Claude session was found.', + '', + 'Each bypass phrase authorizes ONE dispatch in the SAME', + 'session it was typed. A grant from a different session, or', + 'a grant file planted by another process, will not match.', + '', + 'To dispatch:', + ' 1. Run: gh auth refresh -h github.com -r workflow', + ` 2. Type \`${BYPASS_PHRASE}\` in chat (this session).`, + ' 3. Run: gh auth refresh -h github.com -s workflow', + ' 4. Re-run your dispatch command in the SAME session.', + ].join('\n'), + ) + } + consumeWorkflowGrant() + } + process.exit(0) +} + +// True when any command segment actually invokes the `gh` binary. Uses +// the shell parser, not regex: a regex on `gh` over-matched (a path or a +// quoted string containing "gh" tripped it — see the false positives this +// hook used to throw on `grep gh`) AND under-matched (missed indirection). +// The parser reads the real binary at each segment, so `echo "gh ..."` +// (quoted, not a command) is correctly ignored and `cmd1 && gh ...` +// (chained) is caught. +function containsGhInvocation(command: string): boolean { + return findInvocation(command, { binary: 'gh' }) +} + +// A `gh` segment whose args contain `workflow` then `run`/`dispatch`. +// Parser-confirmed `gh` binary + structured arg check (the args list, +// not a raw-string regex, so a quoted "workflow run" can't trip it). +function isWorkflowDispatchCommand(command: string): boolean { + return parseCommands(command).some( + c => + c.binary === 'gh' && + c.args.includes('workflow') && + (c.args.includes('run') || c.args.includes('dispatch')), + ) +} + +// `gh api …/actions/workflows//dispatches`. Parser-confirms the `gh` +// binary, then checks the args for the dispatches API path. +function isWorkflowApiDispatch(command: string): boolean { + return parseCommands(command).some( + c => + c.binary === 'gh' && + c.args.includes('api') && + c.args.some(a => /\/actions\/workflows\/[^/\s]+\/dispatches\b/.test(a)), + ) +} + +// `gh auth refresh` with a scope flag (`-s`/`--scopes` add, `-r`/ +// `--remove-scopes` remove) referencing `workflow`. Parser-confirms the +// `gh auth refresh` shape; the scope value can be `workflow` or a +// comma-list containing it (`-s repo,workflow`), so test each arg. +function isWorkflowScopeRefresh(command: string): boolean { + return parseCommands(command).some(c => { + if ( + c.binary !== 'gh' || + !c.args.includes('auth') || + !c.args.includes('refresh') + ) { + return false + } + // Find a scope flag, then look at the value token(s) for `workflow`. + for (let i = 0; i < c.args.length; i += 1) { + const a = c.args[i]! + const isScopeFlag = /^(?:-s|-r|--scopes|--remove-scopes)$/.test(a) + // Inline form: `--scopes=workflow` or `-sworkflow`. + if (/^(?:-s|-r|--scopes|--remove-scopes)\b.*workflow\b/.test(a)) { + return true + } + if (isScopeFlag) { + const value = c.args[i + 1] + if (value && /\bworkflow\b/.test(value)) { + return true + } + } + } + return false + }) +} + +function isWorkflowScopeRevoke(command: string): boolean { + return ( + /\bgh\s+auth\s+refresh\b/.test(command) && + /(?:^|\s)(?:-r|--remove-scopes)\b[^|;&]*\bworkflow\b/.test(command) + ) +} + +function isAuthMaintenanceCommand(command: string): boolean { + // Self-recovery commands that must run even when the age-block + // is active. Otherwise the user is locked out. + return /\bgh\s+auth\s+(?:login|logout|refresh|status)\b/.test(command) +} + +function isTokenFresh(): boolean { + if (!existsSync(TOKEN_ISSUED_AT_FILE)) { + // First run: stamp now and treat as fresh. This makes the hook + // ship-able without forcing every developer to re-auth on first + // upgrade — the 8h clock starts from the moment the hook first + // observes them. + recordTokenIssuedAt() + return true + } + try { + const recorded = Number(readFileSync(TOKEN_ISSUED_AT_FILE, 'utf8')) + if (!Number.isFinite(recorded)) { + return false + } + return Date.now() - recorded < TOKEN_TTL_MS + } catch { + return false + } +} + +function recordTokenIssuedAt(): void { + try { + mkdirSync(path.dirname(TOKEN_ISSUED_AT_FILE), { recursive: true }) + writeFileSync(TOKEN_ISSUED_AT_FILE, String(Date.now()), 'utf8') + } catch { + // best-effort + } +} + +function readGhAuthStatus(): GhAuthStatus { + const r = spawnSync('gh', ['auth', 'status'], { + stdio: 'pipe', + stdioString: true, + timeout: 5000, + }) + const text = String(r.stdout ?? '') + String(r.stderr ?? '') + if (!text) { + throw new Error('gh auth status: no output') + } + // Per-host parse. `gh auth status` lists every host the user is logged + // in to, each as its own block. We care about github.com specifically. + // Substring-matching the entire blob for `(keyring)` was a vuln: if the + // user is logged in to both github.com (file-backed) AND + // github.enterprise.com (keyring-backed), the regex sees `(keyring)` + // anywhere and concludes the github.com token is safe. + const githubComBlock = extractHostBlock(text, 'github.com') + let storage: GhAuthStatus['storage'] = 'unknown' + if (githubComBlock) { + if (/\(keyring\)|stored in:\s*keychain/i.test(githubComBlock)) { + storage = 'keyring' + } else if (/Logged in to github\.com/i.test(githubComBlock)) { + storage = 'file' + } + } + // Scopes are still parsed from the github.com block. + const scopesText = githubComBlock ?? text + const scopesMatch = scopesText.match(/Token scopes:\s*(.+)/i) + const scopes = scopesMatch + ? scopesMatch[1]!.split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')) + : [] + return { storage, scopes } +} + +// Extract a single host's block from `gh auth status` output. +// Block boundaries: from the line containing the host header +// (typically `github.com` or `github.enterprise.com` as the FIRST +// non-blank chars on its own line, optionally followed by `:`) to +// the next host header OR EOF. +function extractHostBlock(text: string, host: string): string | undefined { + const lines = text.split('\n') + // Match the host header — a line starting with the host name (with + // optional `:` suffix) at zero or low indent. + const headerRe = /^\S+/ + let start = -1 + let end = lines.length + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + if (!headerRe.test(line)) continue + const trimmed = line.trim().replace(/:$/, '') + if (start === -1) { + if (trimmed === host) { + start = i + } + } else { + // Already inside our block — next header line ends it. + end = i + break + } + } + if (start === -1) return undefined + return lines.slice(start, end).join('\n') +} + +// Grant body is `\n`. The session_id binds the +// grant to the Claude session that authorized it — an attacker who +// pre-creates the file (postinstall, .envrc) cannot guess a session_id +// the hook would later receive on dispatch. Presence-only was vulnerable +// to pre-creation; session-binding closes that gap. +function recordWorkflowGrant(sessionId: string | undefined): void { + if (!sessionId) { + // No session_id from harness — refuse to record. The dispatch + // step would have no way to verify; failing closed here is safer + // than recording an unverifiable grant. + return + } + try { + mkdirSync(path.dirname(WORKFLOW_GRANT_FILE), { recursive: true }) + writeFileSync(WORKFLOW_GRANT_FILE, `${sessionId}\n${Date.now()}`, 'utf8') + } catch { + // best-effort; if we can't write, the next dispatch will still + // require a fresh bypass phrase, so no security regression. + } +} + +// Returns true iff the grant file exists AND its session_id matches +// the current session. An attacker-planted grant from a different +// (or no) session is rejected. +function verifyWorkflowGrant(sessionId: string | undefined): boolean { + if (!sessionId) return false + if (!existsSync(WORKFLOW_GRANT_FILE)) return false + try { + const body = readFileSync(WORKFLOW_GRANT_FILE, 'utf8') + const recordedSessionId = body.split('\n')[0]?.trim() ?? '' + return recordedSessionId === sessionId + } catch { + return false + } +} + +function consumeWorkflowGrant(): void { + try { + rmSync(WORKFLOW_GRANT_FILE, { force: true }) + } catch { + // best-effort + } +} + +// Detect MDM-managed Macs (iru / Jamf / Mosyle / Kandji) where +// osascript is likely intercepted by org policy. **Filesystem-only +// detection** — we MUST NOT probe osascript itself, because the probe +// invocation triggers the same "Process Blocked" toast we're trying +// to avoid. Past variant: a `osascript -e 'return "probe"'` healthcheck +// surfaced the iru block toast on every hook invocation. +// +// Detection signals (presence of any known MDM-blocker install path): +// * iru: /Library/Application Support/iru +// * Jamf: /usr/local/jamf/bin/jamf or /Library/Application Support/JAMF +// * Mosyle: /usr/local/bin/mosyle or /Library/Mosyle +// * Kandji: /Library/Kandji +// +// False-positive cost: hook returns 'unsupported' for a working +// osascript, user gets pointed at Touch ID — recoverable. +// False-negative cost: hook tries osascript, user sees ONE toast per +// bypass (acceptable, much better than ONE PER HOOK INVOCATION). +// +// Result is cached for the lifetime of this hook invocation. +let mdmBlockerDetectedCache: boolean | undefined +function isOsascriptBlocked(): boolean { + if (mdmBlockerDetectedCache !== undefined) { + return mdmBlockerDetectedCache + } + // osascript missing entirely (non-darwin or stripped install). + if (!existsSync(OSASCRIPT_BIN)) { + mdmBlockerDetectedCache = true + return true + } + const mdmPaths = [ + '/Library/Application Support/iru', + '/usr/local/jamf/bin/jamf', + '/Library/Application Support/JAMF', + '/usr/local/bin/mosyle', + '/Library/Mosyle', + '/Library/Kandji', + ] + for (let i = 0; i < mdmPaths.length; i += 1) { + if (existsSync(mdmPaths[i]!)) { + mdmBlockerDetectedCache = true + return true + } + } + mdmBlockerDetectedCache = false + return false +} + +// Platform-specific setup guidance for the 'no auth method' error. +// Tailored to which paths actually work on each OS: +// - macOS: Touch ID via pam_tid.so (best). osascript fallback if no +// MDM blocker is present. +// - Linux: pam_u2f (YubiKey / FIDO2) or pam_fprintd (laptop +// fingerprint reader) — both layered onto sudo via PAM. +// - Windows: no clean path. Run releases from a macOS / Linux host. +function platformAuthGuidance(): readonly string[] { + if (process.platform === 'win32') { + return [ + 'Windows has no equivalent to Touch ID / pam_u2f reachable from', + 'a Node child process. Options:', + ' * Run gh workflow dispatches from a macOS or Linux machine.', + ' * Use the GitHub web UI (Actions → Run workflow) instead.', + ] + } + if (process.platform === 'darwin') { + const osBlocked = isOsascriptBlocked() + const mdmNote = osBlocked + ? [ + 'An MDM (iru / Jamf / Mosyle / Kandji) is intercepting', + 'osascript on this machine, so the password-dialog fallback', + 'is unusable. Touch ID is the only working path.', + '', + ] + : [] + return [ + ...mdmNote, + 'Enable Touch ID for sudo (copy-paste verbatim — `EOF` MUST be', + 'at column 0, no leading whitespace, or the heredoc will hang):', + '', + "sudo tee /etc/pam.d/sudo_local <<'EOF'", + 'auth sufficient pam_tid.so', + 'EOF', + '', + 'Then re-run your gh command — Touch ID will prompt.', + 'Mac without Touch ID hardware + MDM-blocked osascript = no path;', + 'use the GitHub web UI to dispatch instead.', + ] + } + // Linux / BSD / other POSIX. + return [ + 'Layer a biometric / hardware-key onto sudo via PAM. Two common', + 'options — pick the one matching your hardware:', + '', + ' YubiKey (or any FIDO2 device):', + ' sudo apt install libpam-u2f # Debian/Ubuntu', + ' sudo dnf install pam-u2f # Fedora/RHEL', + ' pamu2fcfg | sudo tee -a /etc/u2f_mappings', + ' # Then add to /etc/pam.d/sudo (above @include common-auth):', + ' # auth sufficient pam_u2f.so authfile=/etc/u2f_mappings', + '', + ' Laptop fingerprint reader (ThinkPad / Framework / some Dells):', + ' sudo apt install libpam-fprintd fprintd # Debian/Ubuntu', + ' sudo dnf install fprintd-pam # Fedora/RHEL', + ' fprintd-enroll', + ' # Then add to /etc/pam.d/sudo (above @include common-auth):', + ' # auth sufficient pam_fprintd.so', + '', + 'Test with `sudo -k && sudo -n true` — if it returns 0 silently,', + 'the hook will recognize it as a physical-presence success.', + ] +} + +type AuthResult = 'authenticated' | 'denied' | 'unsupported' + +/** + * Verify physical presence via the OS. Tries Touch ID (if sudo is configured + * with pam_tid.so) first; falls back to an osascript password dialog validated + * against the user's account. + * + * Returns: 'authenticated' — user proved presence 'denied' — user cancelled or + * password did not match 'unsupported' — neither path available (non-macOS, no + * osascript) + */ +function requireUserAuthentication(): AuthResult { + // Windows: no equivalent path. Windows Hello requires a UWP context + // (UserConsentVerifier) not reachable from a regular Node child. + // runas + UAC is a click, not physical presence. + if (process.platform === 'win32') { + return 'unsupported' + } + // Path 1: physical-presence via PAM-backed sudo. + // macOS: pam_tid.so (Touch ID). + // Linux: pam_u2f.so (YubiKey / FIDO2) OR pam_fprintd.so (fingerprint + // reader on supported laptops). + // If PAM is configured to make these "sufficient" auth methods, then + // `sudo -n true` (non-interactive) succeeds silently after physical + // confirmation. If PAM falls through to password, `-n` blocks and + // we fall through here. + const sudoBin = resolveSudoBin() + if (sudoBin) { + // Invalidate any cached sudo timestamp so the user can't accidentally + // skip the prompt. -k is silent and always exits 0. + spawnSync(sudoBin, ['-k'], { stdio: 'ignore', timeout: 2000 }) + // -n suppresses the TTY password prompt. If pam_tid.so / pam_u2f / + // pam_fprintd is configured "sufficient" in the auth stack, sudo + // presents the system biometric dialog (no TTY needed) and -n + // still allows it to succeed. + const touchIdResult = spawnSync(sudoBin, ['-n', 'true'], { + stdio: 'ignore', + timeout: 30_000, + }) + if (touchIdResult.status === 0) { + return 'authenticated' + } + } + // Path 2: macOS-only — osascript password prompt + dscl validation. + // Linux/BSD: no GUI-portable fallback that works across distros + // without assuming a specific desktop (zenity/kdialog/gum all have + // packaging caveats). Falls back to 'unsupported' on non-darwin. + // macOS-with-MDM-blocker: skipped via isOsascriptBlocked() to avoid + // surfacing the "Process Blocked" toast. + if (process.platform !== 'darwin') { + return 'unsupported' + } + if (isOsascriptBlocked()) { + return 'unsupported' + } + // `display dialog` runs in osascript's own UI process — it does NOT + // require Automation / System Events permissions (which Claude Code + // typically doesn't have). Bare `display dialog` works without any + // privacy prompt the first time. + const dialogScript = + 'display dialog ' + + '"Authenticate to authorize workflow scope bypass.\\n\\n' + + 'This step is required even after the chat bypass phrase." ' + + 'default answer "" with hidden answer with title "gh-token-hygiene-guard" ' + + 'buttons {"Cancel", "Authenticate"} default button "Authenticate" with icon caution\n' + + 'return text returned of result' + const dialog = spawnSync(OSASCRIPT_BIN, ['-e', dialogScript], { + stdio: ['ignore', 'pipe', 'pipe'], + stdioString: true, + timeout: 120_000, + }) + if (dialog.status !== 0) { + // Reached only when isOsascriptBlocked() returned false (no MDM + // signal on disk) but the dialog still errored. Most common cause: + // user clicked Cancel. Treat as 'denied' (cancellation message). + return 'denied' + } + const password = String(dialog.stdout ?? '').replace(/\n$/, '') + if (!password) { + return 'denied' + } + // Validate against the user's account via dscl. -authonly returns + // exit 0 on match, non-zero otherwise. The password never touches + // disk; it flows through stdin only. + const user = process.env['USER'] ?? '' + if (!user) { + return 'unsupported' + } + const dscl = spawnSync(DSCL_BIN, ['.', '-authonly', user], { + stdio: ['pipe', 'ignore', 'ignore'], + input: password, + stdioString: true, + timeout: 10_000, + }) + if (dscl.status === 0) { + // Password fallback worked. If Touch ID isn't configured for sudo, + // surface a one-time educational nudge so the user can set it up + // and skip the password dialog on future bypasses. + maybePrintTouchIdSetupNudge() + return 'authenticated' + } + return 'denied' +} + +const TOUCH_ID_NUDGED_FILE = path.join( + homedir(), + '.claude', + 'gh-touch-id-setup-nudged', +) + +function maybePrintTouchIdSetupNudge(): void { + // Already configured → no nudge needed. + if (isTouchIdSudoConfigured()) { + return + } + // Already shown the nudge → don't repeat. + if (existsSync(TOUCH_ID_NUDGED_FILE)) { + return + } + try { + mkdirSync(path.dirname(TOUCH_ID_NUDGED_FILE), { recursive: true }) + writeFileSync(TOUCH_ID_NUDGED_FILE, String(Date.now()), 'utf8') + } catch { + // best-effort; if we can't write the sentinel, the nudge prints + // again next time — minor annoyance, no security impact. + } + process.stderr.write( + [ + '', + 'TIP — skip the password dialog next time: enable Touch ID for sudo.', + '', + 'Run this once (copy-paste verbatim; `EOF` must be at column 0,', + 'no leading whitespace, or the heredoc will hang):', + '', + "sudo tee /etc/pam.d/sudo_local <<'EOF'", + 'auth sufficient pam_tid.so', + 'EOF', + '', + 'What this does:', + " /etc/pam.d/sudo_local is macOS Sonoma+'s sudo PAM extension", + " point (Apple's officially-supported way to layer auth methods).", + ' The line adds pam_tid.so as a `sufficient` auth method — meaning', + ' sudo tries Touch ID first and falls back to your password if', + ' Touch ID is unavailable (lid closed, no fingerprint enrolled,', + ' declined). The file is preserved across macOS updates, unlike', + ' /etc/pam.d/sudo which is replaced on every system upgrade.', + '', + "After the one-time setup, this hook's bypass-auth step pops a", + 'Touch ID dialog instead of asking for your password.', + '', + 'This tip is shown once. Full doc:', + ' docs/claude.md/fleet/gh-token-hygiene.md', + '', + ].join('\n'), + ) +} + +function isTouchIdSudoConfigured(): boolean { + // pam_tid.so can be in either /etc/pam.d/sudo_local (Sonoma+ preferred + // location) or directly in /etc/pam.d/sudo (older systems / manual + // edits). Either is "configured". + for (const f of ['/etc/pam.d/sudo_local', '/etc/pam.d/sudo']) { + try { + if (existsSync(f)) { + const content = readFileSync(f, 'utf8') + // Detect lines like `auth ... pam_tid.so` (whitespace-flexible). + if (/^\s*auth\b.*\bpam_tid\.so\b/m.test(content)) { + return true + } + } + } catch { + // Unreadable → assume not configured. + } + } + return false +} + +function fail(headline: string, body: string): never { + process.stderr.write(`\n${headline}\n\n${body}\n\n`) + process.exit(2) +} + +main().catch(() => { + // Fail open on internal errors — don't break Claude Code's tool + // pipeline if our hook itself crashes. + process.exit(0) +}) diff --git a/.claude/hooks/gh-token-hygiene-guard/package.json b/.claude/hooks/gh-token-hygiene-guard/package.json new file mode 100644 index 0000000..f006ca4 --- /dev/null +++ b/.claude/hooks/gh-token-hygiene-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-gh-token-hygiene-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts b/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts new file mode 100644 index 0000000..2ae2baf --- /dev/null +++ b/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts @@ -0,0 +1,384 @@ +// node --test specs for the gh-token-hygiene-guard hook. +// +// The hook shells out to `gh auth status`. To make tests deterministic +// we stage a fake `gh` binary on PATH that prints scripted output, and +// point the timestamp-file env override at a tmpdir so grant state +// doesn't bleed between tests. + +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +interface RunOptions { + // What the fake `gh auth status` should print. + ghStatusOutput?: string + // Pretend a transcript with this body exists. Path passed as + // transcript_path to the hook. + transcriptText?: string + // The Bash command to feed via tool_input.command. + command: string + // Pre-create the workflow-grant file body. Use a string to set the + // body content (e.g. a session_id for a valid grant, or 'wrong-session' + // for a mismatch test). Set to `true` to record with the same + // session_id the hook sees ('test-session-id'). Omit for no grant. + hasGrant?: boolean | string + // session_id passed to the hook (defaults to 'test-session-id'). + sessionId?: string +} + +const TEST_SESSION_ID = 'test-session-id' + +async function runHook( + opts: RunOptions, +): Promise { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'gh-hyg-')) + // Fake gh binary: prints scripted output to stdout, exits 0. + const fakeGh = path.join(tmp, 'gh') + const body = (opts.ghStatusOutput ?? '').replace(/'/g, "'\\''") + writeFileSync(fakeGh, `#!/bin/sh\nprintf '%s\\n' '${body}'\n`) + chmodSync(fakeGh, 0o755) + // Fake HOME so the grant file lands in tmpdir. + const fakeHome = path.join(tmp, 'home') + mkdirSync(path.join(fakeHome, '.claude'), { recursive: true }) + const grantFile = path.join(fakeHome, '.claude', 'gh-workflow-grant') + if (opts.hasGrant === true) { + // Valid grant: bind to the test session id. + writeFileSync(grantFile, `${TEST_SESSION_ID}\n${Date.now()}`) + } else if (typeof opts.hasGrant === 'string') { + // Caller-specified body (e.g. 'wrong-session' to simulate mismatch). + writeFileSync(grantFile, `${opts.hasGrant}\n${Date.now()}`) + } + let transcriptPath: string | undefined + if (opts.transcriptText !== undefined) { + transcriptPath = path.join(tmp, 'transcript.jsonl') + // Minimal transcript line shape: { role: 'user', content: '...' } + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: opts.transcriptText }, + }) + '\n', + ) + } + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${tmp}${path.delimiter}${process.env['PATH'] ?? ''}`, + HOME: fakeHome, + } + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe', env }) + void child.catch(() => undefined) + child.stdin!.end( + JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: opts.command }, + transcript_path: transcriptPath, + session_id: opts.sessionId ?? TEST_SESSION_ID, + }), + ) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + // Inspect grant file BEFORE cleanup + let grantStillExists = false + try { + grantStillExists = existsSync(grantFile) + } catch {} + try { + rmSync(tmp, { recursive: true, force: true }) + } catch {} + resolve({ code: code ?? 0, stderr, grantStillExists }) + }) + }) +} + +const KEYRING_OUTPUT_NO_WORKFLOW = [ + 'github.com', + ' ✓ Logged in to github.com account jdalton (keyring)', + " - Token scopes: 'read:org', 'repo'", +].join('\n') + +const KEYRING_OUTPUT_WITH_WORKFLOW = [ + 'github.com', + ' ✓ Logged in to github.com account jdalton (keyring)', + " - Token scopes: 'read:org', 'repo', 'workflow'", +].join('\n') + +const FILE_STORAGE_OUTPUT = [ + 'github.com', + ' ✓ Logged in to github.com account jdalton', + " - Token scopes: 'read:org', 'repo'", +].join('\n') + +test('non-gh Bash passes', async () => { + const r = await runHook({ + command: 'ls -la', + ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, + }) + assert.strictEqual(r.code, 0) +}) + +test('grep that mentions gh as a search string is NOT a gh invocation', async () => { + // Regression: the old regex matched `gh ` anywhere, so a grep for + // "gh workflow" tripped the guard. The parser reads the real binary + // (grep), so this passes regardless of gh storage state. + const r = await runHook({ + command: 'grep -n "gh workflow run" some-file.mts', + ghStatusOutput: FILE_STORAGE_OUTPUT, + }) + assert.strictEqual(r.code, 0) +}) + +test('echo of a quoted gh command is NOT a gh invocation', async () => { + const r = await runHook({ + command: 'echo "run gh auth login to fix"', + ghStatusOutput: FILE_STORAGE_OUTPUT, + }) + assert.strictEqual(r.code, 0) +}) + +test('chained real gh invocation is still caught', async () => { + // The parser must still SEE a real gh command in a chain. + const r = await runHook({ + command: 'echo start && gh pr list', + ghStatusOutput: FILE_STORAGE_OUTPUT, + }) + assert.strictEqual(r.code, 2) +}) + +test('on-disk gh storage is blocked', async () => { + const r = await runHook({ + command: 'gh pr list', + ghStatusOutput: FILE_STORAGE_OUTPUT, + }) + assert.strictEqual(r.code, 2) + assert.match(r.stderr, /stored on disk/) +}) + +test('keyring storage + non-dispatch gh command passes', async () => { + const r = await runHook({ + command: 'gh pr list', + ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, + }) + assert.strictEqual(r.code, 0) +}) + +test('workflow dispatch without workflow scope is blocked', async () => { + const r = await runHook({ + command: 'gh workflow run publish.yml', + ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, + }) + assert.strictEqual(r.code, 2) + assert.match(r.stderr, /workflow scope/i) +}) + +test('workflow dispatch with scope + unconsumed grant passes', async () => { + const r = await runHook({ + command: 'gh workflow run publish.yml', + ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, + hasGrant: true, + }) + assert.strictEqual(r.code, 0) +}) + +test('workflow dispatch consumes the grant (single-use)', async () => { + const r = await runHook({ + command: 'gh workflow run publish.yml', + ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, + hasGrant: true, + }) + assert.strictEqual(r.code, 0) + assert.strictEqual( + r.grantStillExists, + false, + 'grant file should be deleted after a single dispatch', + ) +}) + +test('workflow dispatch with scope + missing grant is blocked', async () => { + const r = await runHook({ + command: 'gh workflow run publish.yml', + ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, + }) + assert.strictEqual(r.code, 2) + assert.match(r.stderr, /missing, expired, or session-mismatched/) +}) + +test('workflow dispatch with attacker-planted grant (wrong session) blocked', async () => { + // Simulates the pre-creation attack: a malicious postinstall writes + // ~/.claude/gh-workflow-grant with some arbitrary content (or a + // session_id from a previous, legitimate session). The hook MUST + // reject because the recorded session_id doesn't match the current + // session_id. + const r = await runHook({ + command: 'gh workflow run publish.yml', + ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, + hasGrant: 'attacker-planted-session-xxx', + }) + assert.strictEqual(r.code, 2) + assert.match(r.stderr, /session-mismatched/) +}) + +test('refresh -s workflow without bypass is blocked', async () => { + const r = await runHook({ + command: 'gh auth refresh -h github.com -s workflow', + ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, + }) + assert.strictEqual(r.code, 2) + assert.match(r.stderr, /requires bypass/) +}) + +// Bypass-phrase normalization (hyphen vs space, em-dashes, etc.) is +// unit-tested directly in _shared/transcript.test.mts. End-to-end +// here only verifies block/allow behavior at the hook boundary; +// the OS-auth path (sudo + dscl + osascript on absolute /usr/bin/ +// paths) is intentionally unreachable in unit tests — testing it +// would require either an env-var bypass (rejected on security +// grounds) or a /usr/bin/ overlay (rejected as fragile / dangerous). +// The auth path is exercised by manual smoke-testing on the +// developer's machine when the hook ships. + +test('refresh -s workflow with bypass phrase passes the bypass-detect gate', async () => { + // With the bypass phrase present, the hook proceeds past the + // bypass-detect gate and runs OS-auth. The OS-auth outcome is + // environment-dependent — on a Touch-ID-configured developer + // machine `sudo -n true` succeeds silently and the hook records + // the grant; in CI / on a fresh box, `sudo -n` errors and the + // hook falls through to the osascript dialog (which is denied + // without a TTY). Both are acceptable outcomes — what this test + // verifies is that the bypass-MISSING error is NOT what we get. + const r = await runHook({ + command: 'gh auth refresh -h github.com -s workflow', + ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, + transcriptText: 'Allow workflow-scope bypass', + }) + // Must NOT be the bypass-missing branch (which would say "requires bypass"). + assert.doesNotMatch(r.stderr, /requires bypass/) + // Exit code is 0 (auth succeeded, grant recorded) OR 2 (auth denied). + assert.ok( + r.code === 0 || r.code === 2, + `unexpected exit code ${r.code} (stderr: ${r.stderr})`, + ) +}) + +test('refresh -r workflow (revoke) passes without bypass', async () => { + const r = await runHook({ + command: 'gh auth refresh -h github.com -r workflow', + ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, + }) + assert.strictEqual(r.code, 0) +}) + +test('gh api workflow dispatch shape is also blocked', async () => { + const r = await runHook({ + command: + 'gh api -X POST repos/foo/bar/actions/workflows/publish.yml/dispatches -f ref=main', + ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, + }) + assert.strictEqual(r.code, 2) +}) + +test('expired token age (>8h) blocks non-auth commands', async () => { + // Pre-stamp the issued-at file with an old timestamp by running + // through the hook with HOME pointing at our tmpdir. + const tmp = mkdtempSync(path.join(os.tmpdir(), 'gh-age-')) + const fakeHome = path.join(tmp, 'home') + mkdirSync(path.join(fakeHome, '.claude'), { recursive: true }) + writeFileSync( + path.join(fakeHome, '.claude', 'gh-token-issued-at'), + String(Date.now() - 9 * 60 * 60 * 1000), // 9h ago + ) + const fakeGh = path.join(tmp, 'gh') + writeFileSync( + fakeGh, + `#!/bin/sh\nprintf '%s\\n' '${KEYRING_OUTPUT_NO_WORKFLOW.replace(/'/g, "'\\''")}'\n`, + ) + chmodSync(fakeGh, 0o755) + const child = spawn(process.execPath, [HOOK], { + stdio: 'pipe', + env: { + ...process.env, + PATH: `${tmp}${path.delimiter}${process.env['PATH'] ?? ''}`, + HOME: fakeHome, + }, + }) + void child.catch(() => undefined) + child.stdin!.end( + JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'gh pr list' }, + }), + ) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + const code = await new Promise(resolve => { + child.process.on('exit', c => { + try { + rmSync(tmp, { recursive: true, force: true }) + } catch {} + resolve(c ?? 0) + }) + }) + assert.strictEqual(code, 2) + assert.match(stderr, />8h old/) +}) + +test('expired token age allows gh auth refresh (self-recovery)', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'gh-age-r-')) + const fakeHome = path.join(tmp, 'home') + mkdirSync(path.join(fakeHome, '.claude'), { recursive: true }) + writeFileSync( + path.join(fakeHome, '.claude', 'gh-token-issued-at'), + String(Date.now() - 9 * 60 * 60 * 1000), + ) + const fakeGh = path.join(tmp, 'gh') + writeFileSync( + fakeGh, + `#!/bin/sh\nprintf '%s\\n' '${KEYRING_OUTPUT_NO_WORKFLOW.replace(/'/g, "'\\''")}'\n`, + ) + chmodSync(fakeGh, 0o755) + const child = spawn(process.execPath, [HOOK], { + stdio: 'pipe', + env: { + ...process.env, + PATH: `${tmp}${path.delimiter}${process.env['PATH'] ?? ''}`, + HOME: fakeHome, + }, + }) + void child.catch(() => undefined) + child.stdin!.end( + JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'gh auth refresh -h github.com' }, + }), + ) + const code = await new Promise(resolve => { + child.process.on('exit', c => { + try { + rmSync(tmp, { recursive: true, force: true }) + } catch {} + resolve(c ?? 0) + }) + }) + assert.strictEqual(code, 0) +}) diff --git a/.claude/hooks/gh-token-hygiene-guard/tsconfig.json b/.claude/hooks/gh-token-hygiene-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/gh-token-hygiene-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/gitmodules-comment-guard/test/index.test.mts b/.claude/hooks/gitmodules-comment-guard/test/index.test.mts index a86723c..e07e5f7 100644 --- a/.claude/hooks/gitmodules-comment-guard/test/index.test.mts +++ b/.claude/hooks/gitmodules-comment-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,7 +9,6 @@ const HOOK_PATH = path.join(__dirname, '..', 'index.mts') function runHook(payload: object): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -130,7 +129,6 @@ test('handles multiple submodules, blocks only the orphan', () => { test('fails open on malformed JSON', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not-json', }) assert.equal(result.status, 0) diff --git a/.claude/hooks/identifying-users-reminder/index.mts b/.claude/hooks/identifying-users-reminder/index.mts index ed7c88a..ef603e1 100644 --- a/.claude/hooks/identifying-users-reminder/index.mts +++ b/.claude/hooks/identifying-users-reminder/index.mts @@ -40,7 +40,7 @@ const PATTERNS: readonly RuleViolation[] = [ // specific person's intent. The verb-list is intentionally narrow // — generic API docs say "the user can call X" which is fine. regex: - /\b[Tt]he\s+user\s+(asked|chose|decided|likes|needs|picked|prefers|requested|said|wants|wrote)\b/i, + /\b[Tt]he\s+user\s+(?:asked|chose|decided|likes|needs|picked|prefers|requested|said|wants|wrote)\b/i, why: 'Refers to a specific person\'s intent. Use their name from `git config user.name`, or "you" if speaking directly.', }, { @@ -50,13 +50,13 @@ const PATTERNS: readonly RuleViolation[] = [ }, { label: 'someone (singular human reference)', - regex: /^Someone\s+(asked|needs|prefers|requested|said|wants|wrote)\b/im, + regex: /^Someone\s+(?:asked|needs|prefers|requested|said|wants|wrote)\b/im, why: '"Someone" hedges around naming. If you have access to git config, use the name.', }, { label: 'the developer / the engineer (third-party framing)', regex: - /\b[Tt]he\s+(developer|engineer)\s+(asked|needs|prefers|said|wants|wrote)\b/i, + /\b[Tt]he\s+(?:developer|engineer)\s+(?:asked|needs|prefers|said|wants|wrote)\b/i, why: 'Same — name them if known, "you" if direct.', }, ] diff --git a/.claude/hooks/identifying-users-reminder/test/index.test.mts b/.claude/hooks/identifying-users-reminder/test/index.test.mts index 36d8974..736975e 100644 --- a/.claude/hooks/identifying-users-reminder/test/index.test.mts +++ b/.claude/hooks/identifying-users-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -30,7 +30,6 @@ function makeTranscript(assistantText: string): { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -154,7 +153,6 @@ test('disabled env var short-circuits', () => { const { path: p, cleanup } = makeTranscript('The user wants this.') try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, SOCKET_IDENTIFYING_USERS_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/immutable-release-pattern-guard/README.md b/.claude/hooks/immutable-release-pattern-guard/README.md new file mode 100644 index 0000000..95ca98c --- /dev/null +++ b/.claude/hooks/immutable-release-pattern-guard/README.md @@ -0,0 +1,57 @@ +# immutable-release-pattern-guard + +PreToolUse Edit/Write hook that blocks introducing a single-call +`gh release create ` into a workflow YAML file. + +## Why + +GitHub immutable releases ([GA 2025-10-28](https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/)) +auto-generate a Sigstore-bundle release attestation at publish-time over +the locked asset set. The single-call `gh release create` form combines +create + upload + publish into one action, which can race the +attestation hash before all assets land — the resulting release may +publish without a verifiable attestation. + +The fleet rule is the 3-step pattern: + +```bash +gh release create "$TAG" --draft --title "$TITLE" --notes "$NOTES" +gh release upload "$TAG" +gh release edit "$TAG" --draft=false +``` + +The `--draft` flag on `gh release create` is the marker. The publish +step is `gh release edit ... --draft=false` (a different verb). + +## What it blocks + +| Pattern | Block? | +| -------------------------------------------------------------- | ------ | +| `gh release create "$TAG" --draft --title ... --notes ...` | no | +| `gh release create "$TAG" --draft=true ...` | no | +| `gh release create "$TAG" --title ... --notes ... file.tar.gz` | yes | +| `gh release create "$TAG" file.tar.gz` (drive-by) | yes | +| `gh release edit "$TAG" --draft=false` | no | +| Same pattern outside `.github/workflows/*.y*ml` | no | + +## Bypass + +Type the canonical phrase in a new message: + + Allow immutable-release-pattern bypass + +Use sparingly — releases without verifiable attestations defeat the +supply-chain audit trail downstream consumers rely on. + +## Detection + +Regex over the after-edit text: find each `gh release create` opener, +walk to the next unescaped newline (respecting backslash line +continuations), check whether the captured call includes the `--draft` +flag. Any non-draft call is a violation. + +## Related + +- Fleet doc: [`docs/claude.md/fleet/immutable-releases.md`](../../docs/claude.md/fleet/immutable-releases.md) +- Fleet doc: [`docs/claude.md/fleet/version-bumps.md`](../../docs/claude.md/fleet/version-bumps.md) +- Memory: `feedback_immutable_releases_three_step.md` diff --git a/.claude/hooks/immutable-release-pattern-guard/index.mts b/.claude/hooks/immutable-release-pattern-guard/index.mts new file mode 100644 index 0000000..7dc215f --- /dev/null +++ b/.claude/hooks/immutable-release-pattern-guard/index.mts @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — immutable-release-pattern-guard. +// +// Blocks Edit/Write to `.github/workflows/*.y*ml` files that introduce a +// single-call `gh release create [...flags] ` pattern. +// +// GitHub immutable releases (GA 2025-10-28) attach a Sigstore-bundle +// release attestation at publish-time over the locked asset set. The +// single-call form combines create + upload + publish into one action, +// which can race the attestation hash before all assets land. The fleet +// rule is the 3-step pattern: +// +// gh release create "$TAG" --draft --title ... --notes ... +// gh release upload "$TAG" +// gh release edit "$TAG" --draft=false +// +// Detection: scan after-edit text for `gh release create` calls that do +// NOT include `--draft`. Skip when the call is followed by a `gh release +// upload` + `gh release edit ... --draft=false` pair (3-step pattern +// spread across multiple shell lines but the same workflow file). +// +// Bypass: `Allow immutable-release-pattern bypass` typed verbatim in a +// recent user turn. + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly old_string?: string | undefined + readonly content?: string | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow immutable-release-pattern bypass' + +// Match a `gh release create` invocation up to the next newline that isn't +// continued by a backslash. The capture is the full call (incl. continued +// lines). Subsequent analysis decides whether it's the 3-step or single-call +// form. +export function findReleaseCreateCalls(text: string): string[] { + const calls: string[] = [] + // Find each `gh release create` opener. + const opener = /gh\s+release\s+create\b/g + let m: RegExpExecArray | null + while ((m = opener.exec(text)) !== null) { + const start = m.index + // Walk forward, collecting until an unescaped newline. + let i = start + let prevWasBackslash = false + while (i < text.length) { + const c = text[i] + if (c === '\n' && !prevWasBackslash) { + break + } + prevWasBackslash = c === '\\' + i += 1 + } + calls.push(text.slice(start, i)) + } + return calls +} + +// A single `gh release create` call is "safe" if it includes the `--draft` +// flag — that marks it as the first step of the 3-step pattern. +export function callIsDraft(call: string): boolean { + // Match `--draft` as a standalone flag (not e.g. `--draft=false`, which + // is the publish step using `gh release edit`, not `create`). + return /(^|\s)--draft(\s|$|=true)/.test(call) +} + +export function isWorkflowYaml(filePath: string): boolean { + return /[\\/]\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/.test(filePath) +} + +export function readFileSafe(p: string): string { + try { + return readFileSync(p, 'utf8') + } catch { + return '' + } +} + +// Return the first offending (non-draft) `gh release create` call, or +// undefined if all calls in the text are draft-form. +export function findUnsafeCall(text: string): string | undefined { + for (const call of findReleaseCreateCalls(text)) { + if (!callIsDraft(call)) { + return call + } + } + return undefined +} + +async function main(): Promise { + let raw: string + try { + raw = await readStdin() + } catch { + process.exit(0) + } + if (!raw) { + process.exit(0) + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + process.exit(0) + } + const input = payload.tool_input + const filePath = input?.file_path + if (!filePath || !isWorkflowYaml(filePath)) { + process.exit(0) + } + + let afterText: string + if (payload.tool_name === 'Write') { + afterText = input?.content ?? input?.new_string ?? '' + } else { + const currentText = readFileSafe(filePath) + const oldStr = input?.old_string ?? '' + const newStr = input?.new_string ?? '' + if (!oldStr || !currentText.includes(oldStr)) { + process.exit(0) + } + afterText = currentText.replace(oldStr, newStr) + } + + const unsafe = findUnsafeCall(afterText) + if (!unsafe) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + const preview = unsafe.replace(/\s+/g, ' ').slice(0, 90) + process.stderr.write( + [ + '[immutable-release-pattern-guard] Blocked: single-call `gh release create` in workflow YAML', + '', + ` File: ${path.basename(filePath)}`, + ` Call: ${preview}...`, + '', + ' GitHub immutable releases (GA 2025-10-28) auto-generate a Sigstore', + ' release attestation at publish-time over the locked asset set. The', + ' single-call `gh release create ` form combines create', + ' + upload + publish into one action and can race the attestation', + ' hash before all assets land.', + '', + ' Fix — use the 3-step pattern:', + '', + ' gh release create "$TAG" \\', + ' --draft \\', + ' --title "$TITLE" \\', + ' --notes "$NOTES"', + ' gh release upload "$TAG" release/*.tar.gz release/checksums.txt', + ' gh release edit "$TAG" --draft=false', + '', + ' Detail: docs/claude.md/fleet/immutable-releases.md', + '', + ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[immutable-release-pattern-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/immutable-release-pattern-guard/package.json b/.claude/hooks/immutable-release-pattern-guard/package.json new file mode 100644 index 0000000..14e0196 --- /dev/null +++ b/.claude/hooks/immutable-release-pattern-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-immutable-release-pattern-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts b/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts new file mode 100644 index 0000000..45178b9 --- /dev/null +++ b/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts @@ -0,0 +1,152 @@ +// node --test specs for the immutable-release-pattern-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +function tmpWorkflow(content: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'imm-rel-test-')) + const wfDir = path.join(dir, '.github', 'workflows') + mkdirSync(wfDir, { recursive: true }) + const p = path.join(wfDir, 'release.yml') + writeFileSync(p, content) + return p +} + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-workflow file passes', async () => { + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/tmp/foo.md', + content: 'gh release create v1.0.0 file.tar.gz\n', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('workflow without gh release create passes', async () => { + const filePath = tmpWorkflow('') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: 'jobs:\n x:\n steps:\n - run: echo hi\n', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('3-step pattern passes', async () => { + const filePath = tmpWorkflow('') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: + 'jobs:\n release:\n steps:\n - run: |\n gh release create "$TAG" --draft --title "$TITLE" --notes "$NOTES"\n gh release upload "$TAG" release/*.tar.gz\n gh release edit "$TAG" --draft=false\n', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('3-step with --draft=true also passes', async () => { + const filePath = tmpWorkflow('') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: + 'jobs:\n release:\n steps:\n - run: |\n gh release create "$TAG" --draft=true --title "$TITLE"\n gh release upload "$TAG" file.tar.gz\n gh release edit "$TAG" --draft=false\n', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('multi-line draft form with backslash continuations passes', async () => { + const filePath = tmpWorkflow('') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: + 'jobs:\n release:\n steps:\n - run: |\n gh release create "$TAG" \\\n --draft \\\n --title "$TITLE" \\\n --notes "$NOTES"\n gh release upload "$TAG" file.tar.gz\n gh release edit "$TAG" --draft=false\n', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('single-call form (no --draft) is blocked', async () => { + const filePath = tmpWorkflow('') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: + 'jobs:\n release:\n steps:\n - run: gh release create "$TAG" --title "$TITLE" --notes "$NOTES" file.tar.gz\n', + }, + }) + assert.strictEqual(r.code, 2) +}) + +test('drive-by single-call form (just files) is blocked', async () => { + const filePath = tmpWorkflow('') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: + 'jobs:\n release:\n steps:\n - run: gh release create v1.0.0 file.tar.gz checksums.txt\n', + }, + }) + assert.strictEqual(r.code, 2) +}) + +test('bypass phrase passes', async () => { + const filePath = tmpWorkflow('') + const txDir = mkdtempSync(path.join(os.tmpdir(), 'imm-rel-tx-')) + const transcriptPath = path.join(txDir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: 'Allow immutable-release-pattern bypass' }, + }) + '\n', + ) + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: + 'jobs:\n release:\n steps:\n - run: gh release create "$TAG" file.tar.gz\n', + }, + transcript_path: transcriptPath, + }) + assert.strictEqual(r.code, 0) +}) diff --git a/.claude/hooks/immutable-release-pattern-guard/tsconfig.json b/.claude/hooks/immutable-release-pattern-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/immutable-release-pattern-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/inline-script-defer-guard/test/index.test.mts b/.claude/hooks/inline-script-defer-guard/test/index.test.mts index 9c0db71..fb3bba8 100644 --- a/.claude/hooks/inline-script-defer-guard/test/index.test.mts +++ b/.claude/hooks/inline-script-defer-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -18,6 +18,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/judgment-reminder/test/index.test.mts b/.claude/hooks/judgment-reminder/test/index.test.mts index 098edc9..cf19b44 100644 --- a/.claude/hooks/judgment-reminder/test/index.test.mts +++ b/.claude/hooks/judgment-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -28,7 +28,6 @@ function makeTranscript(assistantText: string): { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -130,7 +129,6 @@ test('disabled env var short-circuits', () => { const { path: p, cleanup } = makeTranscript("I'm not sure which approach.") try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, SOCKET_JUDGMENT_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/lock-step-ref-guard/test/index.test.mts b/.claude/hooks/lock-step-ref-guard/test/index.test.mts index 9b3d534..c0793b4 100644 --- a/.claude/hooks/lock-step-ref-guard/test/index.test.mts +++ b/.claude/hooks/lock-step-ref-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -62,7 +62,6 @@ function runHook( payload['cwd'] = options.cwd } const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), env: { ...process.env, ...(options.env ?? {}) }, }) @@ -282,7 +281,6 @@ test('BYPASS via SOCKET_LOCK_STEP_REF_GUARD_DISABLED=1', () => { test('exits 0 on invalid JSON payload', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not-json', }) assert.equal(result.status, 0) @@ -290,7 +288,6 @@ test('exits 0 on invalid JSON payload', () => { test('exits 0 on missing tool_input', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Write' }), }) assert.equal(result.status, 0) diff --git a/.claude/hooks/logger-guard/index.mts b/.claude/hooks/logger-guard/index.mts index 0ca71b3..c53c10c 100644 --- a/.claude/hooks/logger-guard/index.mts +++ b/.claude/hooks/logger-guard/index.mts @@ -11,7 +11,7 @@ // Why this rule: // // The fleet's source code uses `getDefaultLogger()` from -// `@socketsecurity/lib-stable/logger` for every output. Direct stream +// `@socketsecurity/lib-stable/logger/default` for every output. Direct stream // writes bypass color/theme handling, indentation tracking, stream // redirection in tests, and spinner-counter increments — producing // inconsistent output that breaks layout-sensitive workflows. @@ -44,13 +44,16 @@ import { readStdin } from '../_shared/transcript.mts' const EXEMPT_PATH_PATTERNS: RegExp[] = [ /\.claude\/hooks\//, /\.git-hooks\//, - /(^|\/)scripts\//, - /\.(spec|test)\.(m?[jt]s|tsx?|cts|mts)$/, - /(^|\/)tests?\//, - /(^|\/)fixtures\//, - /(^|\/)external\//, - /(^|\/)vendor\//, - /(^|\/)upstream\//, + /(?:^|\/)scripts\//, + /\.(?:spec|test)\.(?:m?[jt]s|tsx?|cts|mts)$/, + /(?:^|\/)tests?\//, + /(?:^|\/)fixtures\//, + /(?:^|\/)external\//, + /(?:^|\/)vendor\//, + /(?:^|\/)upstream\//, + // The logger is its own owner — these files implement the Logger + // class + its browser shim and must call console.* directly. + /(?:^|\/)src\/logger\//, ] // The forbidden calls and the canonical logger replacement for each. @@ -86,7 +89,7 @@ export function emitBlock(filePath: string, hits: Hit[]): void { out.push('') out.push('[logger-guard] Blocked: direct stream write found') out.push( - ' Use `getDefaultLogger()` from `@socketsecurity/lib-stable/logger` instead.', + ' Use `getDefaultLogger()` from `@socketsecurity/lib-stable/logger/default` instead.', ) out.push(` File: ${filePath}`) for (const h of hits.slice(0, 3)) { @@ -116,7 +119,7 @@ export function isInScope(filePath: string): boolean { if (!filePath) { return false } - if (!/\.(m?ts|tsx|cts)$/.test(filePath)) { + if (!/\.(?:m?ts|tsx|cts)$/.test(filePath)) { return false } for (let i = 0, { length } = EXEMPT_PATH_PATTERNS; i < length; i += 1) { diff --git a/.claude/hooks/logger-guard/test/logger-guard.test.mts b/.claude/hooks/logger-guard/test/logger-guard.test.mts index 285f50d..3573d08 100644 --- a/.claude/hooks/logger-guard/test/logger-guard.test.mts +++ b/.claude/hooks/logger-guard/test/logger-guard.test.mts @@ -1,7 +1,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' import { test } from 'node:test' @@ -24,6 +24,11 @@ function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { const child = spawn(process.execPath, [HOOK], { stdio: ['pipe', 'ignore', 'pipe'], }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/markdown-filename-guard/index.mts b/.claude/hooks/markdown-filename-guard/index.mts index 8478905..d2ddbf7 100644 --- a/.claude/hooks/markdown-filename-guard/index.mts +++ b/.claude/hooks/markdown-filename-guard/index.mts @@ -196,12 +196,16 @@ export function emitBlock(filePath: string, verdict: Verdict): void { export function isAtAllowedRegularLocation(relPath: string): boolean { const dir = path.posix.dirname(relPath) - return ( - dir === 'docs' || - dir.startsWith('docs/') || - dir === '.claude' || - dir.startsWith('.claude/') - ) + if (dir === '.claude' || dir.startsWith('.claude/')) { + return true + } + // Accept any path segment named `docs` so per-package doc trees like + // `packages//docs/.md` and + // `packages//lang//docs/.md` resolve to the same "in + // a docs/ directory" rule as repo-root docs/. Segment-equality (not + // substring) so `foo-docs/`, `docs-old/`, `.docs/` don't match. + const segments = dir.split('/') + return segments.includes('docs') } export function isAtAllowedScreamingLocation(relPath: string): boolean { diff --git a/.claude/hooks/markdown-filename-guard/test/index.test.mts b/.claude/hooks/markdown-filename-guard/test/index.test.mts index 906454b..d9a0edd 100644 --- a/.claude/hooks/markdown-filename-guard/test/index.test.mts +++ b/.claude/hooks/markdown-filename-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -185,6 +190,43 @@ test('lowercase-with-hyphens in docs/ is allowed', async () => { assert.strictEqual(result.code, 0) }) +test('lowercase-with-hyphens in packages//docs/ is allowed', async () => { + for (const p of [ + '/Users/x/projects/foo/packages/acorn/docs/perf/journey.md', + '/Users/x/projects/foo/packages/acorn/docs/architecture.md', + '/Users/x/projects/foo/packages/acorn/lang/rust/docs/performance.md', + '/Users/x/projects/foo/packages/acorn/lang/typescript/docs/building.md', + ]) { + const result = await runHook({ + tool_input: { content: 'doc', file_path: p }, + tool_name: 'Write', + }) + assert.strictEqual( + result.code, + 0, + `${p} should be allowed (got code ${result.code}: ${result.stderr})`, + ) + } +}) + +test('docs-lookalike segments (foo-docs, docs-old, .docs) are blocked', async () => { + for (const p of [ + '/Users/x/projects/foo/packages/acorn/foo-docs/notes.md', + '/Users/x/projects/foo/docs-old/notes.md', + '/Users/x/projects/foo/.docs/notes.md', + ]) { + const result = await runHook({ + tool_input: { content: 'doc', file_path: p }, + tool_name: 'Write', + }) + assert.strictEqual( + result.code, + 2, + `${p} should be blocked (got code ${result.code})`, + ) + } +}) + test('lowercase-with-hyphens at root is blocked', async () => { const result = await runHook({ tool_input: { diff --git a/.claude/hooks/marketplace-comment-guard/test/index.test.mts b/.claude/hooks/marketplace-comment-guard/test/index.test.mts index 74c1b68..3a03fcc 100644 --- a/.claude/hooks/marketplace-comment-guard/test/index.test.mts +++ b/.claude/hooks/marketplace-comment-guard/test/index.test.mts @@ -3,19 +3,18 @@ import assert from 'node:assert/strict' // prefer-async-spawn: sync-required — test flow is sync. // prefer-spawn-over-execsync: required — uses encoding/input options // not exposed on the lib spawnSync wrapper. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs' +import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const HOOK_PATH = path.join(__dirname, '..', 'index.mts') function runHook(payload: object): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } diff --git a/.claude/hooks/minify-mcp-output/test/index.test.mts b/.claude/hooks/minify-mcp-output/test/index.test.mts index 22b61d8..ce83b7e 100644 --- a/.claude/hooks/minify-mcp-output/test/index.test.mts +++ b/.claude/hooks/minify-mcp-output/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -14,7 +14,6 @@ function runHook(payload: object): { exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { stdout: String(result.stdout), exitCode: result.status ?? -1 } @@ -158,7 +157,6 @@ test('hook: emits updatedMCPToolOutput for MCP tool with string-shaped response' test('hook: fails open on malformed stdin', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '{not json', }) assert.equal(result.status, 0) diff --git a/.claude/hooks/minimum-release-age-guard/test/index.test.mts b/.claude/hooks/minimum-release-age-guard/test/index.test.mts index 2f9a768..e18e0e3 100644 --- a/.claude/hooks/minimum-release-age-guard/test/index.test.mts +++ b/.claude/hooks/minimum-release-age-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -25,6 +25,11 @@ function tmpYaml(content: string): string { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts b/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts index 13c85e2..0222641 100644 --- a/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts +++ b/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -45,7 +45,6 @@ function makeTranscript(dir: string, bypassPhrase?: string): string { function runHook(payload: object): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -220,7 +219,6 @@ test('disabled env var short-circuits', () => { const repo = makeFakeRepo('# no reference') try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: repo.hookIndexPath('my-new-hook') }, diff --git a/.claude/hooks/no-blind-keychain-read-guard/README.md b/.claude/hooks/no-blind-keychain-read-guard/README.md new file mode 100644 index 0000000..22a1796 --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/README.md @@ -0,0 +1,65 @@ +# no-blind-keychain-read-guard + +`PreToolUse(Bash)` blocker that refuses direct keychain READ calls +from Bash. The keychain APIs surface a UI auth prompt per call; +reading three times costs three prompts. The fleet's canonical +in-process resolver (`api-token.mts.findApiToken()`) caches the +value module-scoped after the first hit, so subsequent code paths +should never need to re-read the keychain. + +## Detected reads + +| Platform | Pattern | +| -------------- | ---------------------------------------------- | +| macOS | `security find-{generic,internet}-password` | +| Linux | `secret-tool lookup` / `secret-tool search` | +| Windows | `Get-StoredCredential` | +| Windows | `Get-Credential … \| ConvertFrom-SecureString` | +| cross-platform | `keyring get` | + +## Allowed (not flagged) + +Writes and deletes — these only happen during operator-driven +setup / rotation, never on hot paths: + +- `security add-generic-password` / `security delete-generic-password` +- `secret-tool store` / `secret-tool clear` +- `New-StoredCredential` / `Remove-StoredCredential` +- `keyring set` / `keyring del` + +## Bypass + +Type the canonical phrase verbatim in your next user turn: + +``` +Allow blind-keychain-read bypass +``` + +Use when you genuinely need a fresh keychain read — operator-invoked +diagnostics, verifying an entry exists, etc. + +## Why + +`security find-generic-password` on macOS prompts the user every call +unless the calling process is on the entry's ACL. Claude Code's Bash +tool spawns a fresh process per call, so each `security` invocation +re-prompts. The same shape exists on Linux (`secret-tool` against +gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against +the CredentialManager UI). + +The right answer is to read the cached value from process state: + +```ts +import { findApiToken } from '../setup-security-tools/lib/api-token.mts' +const { token } = findApiToken() // module-cached after first call +``` + +Or from a child process spawned by hooks: + +```bash +echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge +``` + +The bridge writes the token to `~/.zshenv` (or platform equivalent) +so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN` +without a keychain read. diff --git a/.claude/hooks/no-blind-keychain-read-guard/index.mts b/.claude/hooks/no-blind-keychain-read-guard/index.mts new file mode 100644 index 0000000..bdb1094 --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/index.mts @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-blind-keychain-read-guard. +// +// Blocks Bash invocations that READ a credential from the OS +// keychain. Reading via the platform CLI surfaces a per-call UI auth +// prompt on the user's screen ("this app wants to access your +// keychain"), and the prompt fires once per call — a hook chain that +// reads the keychain three times costs three prompts. Tokens are +// already cached in process memory after the first resolution; the +// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits +// the cache, then env, then keychain, in that order. Bash callers +// that go straight to `security find-generic-password` skip all of +// that and re-prompt the user every time. +// +// Detects (case-sensitive, structural — not just substring): +// +// macOS: +// security find-generic-password +// security find-internet-password +// +// Linux: +// secret-tool lookup +// secret-tool search +// +// Windows (PowerShell): +// Get-StoredCredential (CredentialManager module) +// Get-Credential (when piping to ConvertFrom-SecureString) +// +// Cross-platform (Python keyring CLI): +// keyring get +// +// Allowed (writes / deletes — necessary for operator-driven setup / +// rotation, never on hot paths): +// +// security add-generic-password security delete-generic-password +// secret-tool store secret-tool clear +// New-StoredCredential Remove-StoredCredential +// keyring set keyring del +// +// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. +// Use when you genuinely need to verify a keychain entry exists +// (e.g. operator-invoked diagnostics). +// +// Exit codes: +// 0 — pass. +// 2 — block. +// +// Fails open on malformed payloads (exit 0 + stderr log) — the fleet's +// hook contract. + +import process from 'node:process' + +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly command?: string | undefined + } + | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined +} + +interface Hit { + readonly tool: string + readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' + readonly snippet: string +} + +const BYPASS_PHRASE = 'Allow blind-keychain-read bypass' + +// Token-bearing read patterns. Each entry: the literal verb that +// surfaces a UI prompt + a label for the error message. Writes / +// deletes are intentionally absent from this list. +const READ_PATTERNS: ReadonlyArray<{ + readonly re: RegExp + readonly tool: string + readonly platform: Hit['platform'] +}> = [ + // macOS — `security(1)`. The `-w` flag prints the password to + // stdout, but even the metadata-only form triggers the ACL prompt. + { + re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, + tool: 'security find-*-password', + platform: 'macos', + }, + // Linux — `secret-tool`. `lookup` returns the password; `search` + // lists matches (also surfaces the libsecret prompt). + { + re: /\bsecret-tool\s+(?:lookup|search)\b/, + tool: 'secret-tool lookup/search', + platform: 'linux', + }, + // Windows PowerShell — CredentialManager module. The + // `Get-StoredCredential` cmdlet returns a PSCredential; reading + // `.Password | ConvertFrom-SecureString` is the read pattern. + { + re: /\bGet-StoredCredential\b/, + tool: 'Get-StoredCredential', + platform: 'windows', + }, + // PowerShell `Get-Credential -Credential` piped to + // `ConvertFrom-SecureString -AsPlainText` is the readback shape. + // The bare `Get-Credential` (no pipe) is a fresh-prompt-the-user + // flow and not the issue here — match only the readback pipe. + { + re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, + tool: 'Get-Credential | ConvertFrom-SecureString', + platform: 'windows', + }, + // Python `keyring` CLI — `keyring get `. + { + re: /\bkeyring\s+get\b/, + tool: 'keyring get', + platform: 'cross-platform', + }, +] + +/** + * Scan a Bash command string for keychain READ patterns. Returns one hit per + * matching subcommand so the error message can name them all (a `&&`-chained + * command might have multiple). + */ +export function findKeychainReads(command: string): Hit[] { + const hits: Hit[] = [] + for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { + const entry = READ_PATTERNS[i]! + const m = entry.re.exec(command) + if (!m) { + continue + } + // Pull a short snippet around the match (up to 80 chars) so the + // operator can see the context. Centered on the match start. + const start = Math.max(0, m.index - 10) + const end = Math.min(command.length, m.index + m[0].length + 50) + const snippet = command.slice(start, end) + hits.push({ + tool: entry.tool, + platform: entry.platform, + snippet: snippet.length < command.length ? `…${snippet}…` : snippet, + }) + } + return hits +} + +function handlePayload(payloadRaw: string): number { + let payload: ToolInput + try { + payload = JSON.parse(payloadRaw) as ToolInput + } catch { + return 0 + } + if (payload.tool_name !== 'Bash') { + return 0 + } + const command = payload.tool_input?.command ?? '' + if (!command) { + return 0 + } + const hits = findKeychainReads(command) + if (hits.length === 0) { + return 0 + } + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + return 0 + } + const lines: string[] = [] + lines.push( + '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', + ) + lines.push('') + for (let i = 0, { length } = hits; i < length; i += 1) { + const h = hits[i]! + lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) + lines.push(` Saw: ${h.snippet}`) + } + lines.push('') + lines.push(' Reading the keychain via the platform CLI surfaces a UI auth') + lines.push(" prompt on the user's screen — and the prompt fires once per") + lines.push(' call. A hook chain that reads three times costs three prompts.') + lines.push('') + lines.push(' The token is almost certainly already available without a') + lines.push(' keychain read:') + lines.push('') + lines.push(' - In-process: call findApiToken() from setup-security-tools/') + lines.push(' lib/api-token.mts. It returns the module-cached value from') + lines.push(' the first call onward, then env, then keychain.') + lines.push('') + lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') + lines.push( + ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', + ) + lines.push(' exports both for every new shell session.') + lines.push('') + lines.push(' Writes / deletes (security add-generic-password / secret-tool') + lines.push(' store / New-StoredCredential / etc.) are allowed — they only') + lines.push(' happen during operator-driven setup / rotation.') + lines.push('') + lines.push(' Bypass (e.g. operator-invoked diagnostics that need a fresh') + lines.push(' keychain read):') + lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) + process.stderr.write(lines.join('\n') + '\n') + return 2 +} + +export { handlePayload } + +// CLI entrypoint — only fires when this file is the main module. +// During tests the importer pulls `findKeychainReads` without triggering +// the stdin reader (which would never see an `end` event in test env +// and hang the process). +if (process.argv[1] && process.argv[1].endsWith('index.mts')) { + let payloadRaw = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + payloadRaw += chunk + }) + process.stdin.on('end', () => { + try { + process.exit(handlePayload(payloadRaw)) + } catch (e) { + process.stderr.write( + `[no-blind-keychain-read-guard] hook error (allowing): ${e}\n`, + ) + process.exit(0) + } + }) +} diff --git a/.claude/hooks/no-blind-keychain-read-guard/package.json b/.claude/hooks/no-blind-keychain-read-guard/package.json new file mode 100644 index 0000000..819429b --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-blind-keychain-read-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts new file mode 100644 index 0000000..8567a3b --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts @@ -0,0 +1,142 @@ +/** + * @file Unit tests for findKeychainReads — the structural matcher that + * classifies a Bash command string into keychain READ hits (vs writes, + * deletes, and unrelated commands). + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { findKeychainReads } from '../index.mts' + +test('macOS find-generic-password is flagged', () => { + const hits = findKeychainReads( + 'security find-generic-password -s socket-cli -a SOCKET_API_KEY -w', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'macos') +}) + +test('macOS find-internet-password is flagged', () => { + const hits = findKeychainReads( + 'security find-internet-password -s example.com -a user', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'macos') +}) + +test('macOS add-generic-password is NOT flagged (write)', () => { + const hits = findKeychainReads( + 'security add-generic-password -U -s socket-cli -a SOCKET_API_KEY -w xxx', + ) + assert.equal(hits.length, 0) +}) + +test('macOS delete-generic-password is NOT flagged (delete)', () => { + const hits = findKeychainReads( + 'security delete-generic-password -s socket-cli -a SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Linux secret-tool lookup is flagged', () => { + const hits = findKeychainReads( + 'secret-tool lookup service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'linux') +}) + +test('Linux secret-tool search is flagged', () => { + const hits = findKeychainReads('secret-tool search service socket-cli') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'linux') +}) + +test('Linux secret-tool store is NOT flagged (write)', () => { + const hits = findKeychainReads( + 'secret-tool store --label="Socket API token" service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Linux secret-tool clear is NOT flagged (delete)', () => { + const hits = findKeychainReads( + 'secret-tool clear service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Windows Get-StoredCredential is flagged', () => { + const hits = findKeychainReads( + 'powershell -Command "(Get-StoredCredential -Target \'socket-cli:SOCKET_API_KEY\').Password"', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'windows') +}) + +test('Windows Get-Credential | ConvertFrom-SecureString is flagged', () => { + const hits = findKeychainReads( + 'Get-Credential -Credential admin | ConvertFrom-SecureString -AsPlainText', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'windows') +}) + +test('Windows Get-Credential WITHOUT pipe is NOT flagged (fresh prompt)', () => { + // Bare Get-Credential is an interactive fresh-prompt flow, not a + // readback of a stored credential. Don't block. + const hits = findKeychainReads('$cred = Get-Credential -Credential admin') + assert.equal(hits.length, 0) +}) + +test('Windows New-StoredCredential is NOT flagged (write)', () => { + const hits = findKeychainReads( + "New-StoredCredential -Target 'socket-cli:SOCKET_API_KEY' -UserName x -SecurePassword $s", + ) + assert.equal(hits.length, 0) +}) + +test('keyring get is flagged', () => { + const hits = findKeychainReads('keyring get socket-cli SOCKET_API_KEY') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'cross-platform') +}) + +test('keyring set is NOT flagged (write)', () => { + const hits = findKeychainReads('keyring set socket-cli SOCKET_API_KEY') + assert.equal(hits.length, 0) +}) + +test('chained reads count separately', () => { + // && chain with two reads + const hits = findKeychainReads( + 'security find-generic-password -s a -a b -w && secret-tool lookup service a user b', + ) + assert.equal(hits.length, 2) +}) + +test('unrelated commands are not flagged', () => { + for (const cmd of [ + 'ls -la', + 'git log --oneline -5', + 'echo $SOCKET_API_KEY', + 'pnpm install', + 'grep security file.txt', + 'security delete-keychain ~/Library/Keychains/foo.keychain', + ]) { + const hits = findKeychainReads(cmd) + assert.equal(hits.length, 0, `should not flag: ${cmd}`) + } +}) + +test('command substitution wrapping is still flagged', () => { + // The structural matcher is intentionally a regex, not an AST. This + // catches the common subshell shape — verifying the inner verb is + // detected even inside `$(...)`. AST-based parsing is overkill for + // a non-security-critical reminder hook. + const hits = findKeychainReads( + 'TOKEN="$(security find-generic-password -s socket-cli -a SOCKET_API_KEY -w)" && echo done', + ) + assert.equal(hits.length, 1) +}) diff --git a/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/no-empty-commit-guard/README.md b/.claude/hooks/no-empty-commit-guard/README.md new file mode 100644 index 0000000..c43b041 --- /dev/null +++ b/.claude/hooks/no-empty-commit-guard/README.md @@ -0,0 +1,40 @@ +# no-empty-commit-guard + +PreToolUse hook that blocks two empty-commit shapes the fleet bans +(see CLAUDE.md "Commits & PRs → No empty commits"): + +1. `git commit --allow-empty` (with or without `-m`, also covers + `--allow-empty-message`). +2. `git cherry-pick --allow-empty` / `--keep-redundant-commits` — + replaying a no-content commit forward. + +## Why blocking + +Empty commits pollute `git log`, break CHANGELOG generators (which +expect each commit to carry a diff), and hide intent: a future +reader can't tell whether the author meant to amend the previous +commit, anchor a tag, or something else. + +The canonical way to anchor a release tag forward is +`git tag -f vX.Y.Z ` against an actual content +commit, not a fake "anchor" commit with no diff. Force-moving the +tag is a cleaner mechanism than synthesising history. + +## Bypass + +Type `Allow empty-commit bypass` verbatim in a recent user turn, +then retry. The phrase authorises the next blocked `git commit` +or `git cherry-pick` invocation within the conversation window. + +## Skipped silently + +- `tool_name !== 'Bash'`. +- Commands that don't contain `git commit` or `git cherry-pick`. +- `--allow-empty` appearing inside a quoted string (e.g. inside a + `-m` commit-message body that mentions the flag). + +## Failure mode + +Fails open: any internal error logs to stderr and exits 0. The hook +is a quality gate, not a hard dependency — it never wedges the +operator's flow. diff --git a/.claude/hooks/no-empty-commit-guard/index.mts b/.claude/hooks/no-empty-commit-guard/index.mts new file mode 100644 index 0000000..93ad6e1 --- /dev/null +++ b/.claude/hooks/no-empty-commit-guard/index.mts @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-empty-commit-guard. +// +// Blocks two empty-commit shapes the fleet bans (see CLAUDE.md +// "Commits & PRs → No empty commits"): +// +// 1. `git commit --allow-empty` (with or without `-m`). +// 2. `git cherry-pick --allow-empty` / `--keep-redundant-commits` +// against a ref whose patch is empty relative to HEAD. +// +// Why blocking, not reminder: empty commits pollute `git log`, break +// CHANGELOG generators (which expect each commit to carry a diff), +// and hide intent ("did the author mean to anchor a tag? amend a +// previous commit? something else?"). The canonical way to anchor +// a release tag forward is `git tag -f vX.Y.Z` against the actual +// content commit, not a fake "anchor" commit with no diff. +// +// Skipped silently: +// - tool_name !== 'Bash'. +// - Command doesn't contain `git commit` or `git cherry-pick`. +// - Bypass phrase present in recent transcript turns. +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Bash", +// "tool_input": { "command": "..." }, +// "transcript_path": "/path/to/jsonl", // optional +// ... } +// +// Exit codes: +// 0 — allow. +// 2 — block. Stderr carries the operator-facing message. +// +// Fails open on any internal error (exit 0 + stderr log) so the +// hook never wedges the operator's flow. + +import process from 'node:process' + +import { commandsFor } from '../_shared/shell-command.mts' +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: { readonly command?: string | undefined } | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow empty-commit bypass' + +/** + * Detect `git commit --allow-empty` (and `--allow-empty-message`, which is the + * same antipattern — both produce a no-op commit). Parser-based: the flag must + * belong to a real `git commit` invocation, so a literal `--allow-empty` in a + * commit-message body or a sibling command doesn't false-positive. + */ +export function isAllowEmptyCommit(command: string): boolean { + return commandsFor(command, 'git').some( + c => + c.args.includes('commit') && + c.args.some(a => a === '--allow-empty' || a === '--allow-empty-message'), + ) +} + +/** + * Detect `git cherry-pick --allow-empty` or `--keep-redundant-commits` — both + * replay a no-content commit forward into the current branch, which is exactly + * the empty-commit pattern the rule bans. + */ +export function isCherryPickAllowEmpty(command: string): boolean { + return commandsFor(command, 'git').some( + c => + c.args.includes('cherry-pick') && + c.args.some( + a => a === '--allow-empty' || a === '--keep-redundant-commits', + ), + ) +} + +let payloadRaw = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', chunk => { + payloadRaw += chunk +}) +process.stdin.on('end', () => { + try { + let payload: ToolInput + try { + payload = JSON.parse(payloadRaw) as ToolInput + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command ?? '' + if (!command) { + process.exit(0) + } + + const allowEmptyCommit = isAllowEmptyCommit(command) + const allowEmptyCherryPick = isCherryPickAllowEmpty(command) + if (!allowEmptyCommit && !allowEmptyCherryPick) { + process.exit(0) + } + + // Operator bypass — `Allow empty-commit bypass` in a recent turn. + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + process.exit(0) + } + + const flag = allowEmptyCommit + ? '--allow-empty (or --allow-empty-message)' + : '--allow-empty / --keep-redundant-commits' + process.stderr.write( + [ + `[no-empty-commit-guard] Blocked: git ${allowEmptyCommit ? 'commit' : 'cherry-pick'} ${flag}`, + '', + ' Empty commits pollute `git log`, break CHANGELOG generators', + ' (which expect each commit to carry a diff), and hide intent.', + '', + ' If you are anchoring a release tag forward, use:', + ' git tag -f vX.Y.Z ', + ' git push origin --force-with-lease vX.Y.Z', + '', + ' If you genuinely need to record a no-content waypoint, type', + ` "${BYPASS_PHRASE}" in chat, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) + } catch (e) { + process.stderr.write( + `[no-empty-commit-guard] hook error (allowing): ${e}\n`, + ) + process.exit(0) + } +}) diff --git a/.claude/hooks/no-empty-commit-guard/package.json b/.claude/hooks/no-empty-commit-guard/package.json new file mode 100644 index 0000000..b78fb04 --- /dev/null +++ b/.claude/hooks/no-empty-commit-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-empty-commit-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/no-empty-commit-guard/test/index.test.mts b/.claude/hooks/no-empty-commit-guard/test/index.test.mts new file mode 100644 index 0000000..8e06188 --- /dev/null +++ b/.claude/hooks/no-empty-commit-guard/test/index.test.mts @@ -0,0 +1,134 @@ +// node --test specs for the no-empty-commit-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-Bash tool calls pass through silently', async () => { + const result = await runHook({ + tool_input: { file_path: 'foo.ts', new_string: 'x' }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 0) + assert.strictEqual(result.stderr, '') +}) + +test('plain git commit passes through silently', async () => { + const result = await runHook({ + tool_input: { command: 'git commit -m "real change"' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) + assert.strictEqual(result.stderr, '') +}) + +test('git commit --allow-empty is blocked', async () => { + const result = await runHook({ + tool_input: { + command: 'git commit --allow-empty -m "anchor v1.0.0 tag"', + }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) +}) + +test('git commit --allow-empty-message is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git commit --allow-empty-message -m ""' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) +}) + +test('git cherry-pick --allow-empty is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git cherry-pick --allow-empty abc1234' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) +}) + +test('git cherry-pick --keep-redundant-commits is blocked', async () => { + const result = await runHook({ + tool_input: { + command: 'git cherry-pick --keep-redundant-commits abc1234', + }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) +}) + +test('plain git cherry-pick passes through silently', async () => { + const result = await runHook({ + tool_input: { command: 'git cherry-pick abc1234' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) + assert.strictEqual(result.stderr, '') +}) + +test('commit message bodies mentioning --allow-empty are skipped (quote-aware)', async () => { + const result = await runHook({ + tool_input: { + command: `git commit -m "docs: forbid git commit --allow-empty in fleet"`, + }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) + assert.strictEqual(result.stderr, '') +}) + +test('--allow-empty in a SEPARATE chained command is not attributed to git commit', async () => { + // Parser scopes the flag to the invocation that owns it: here the + // commit is plain and `--allow-empty` is just an echo arg. The old + // substring approach would have wrongly blocked this. + const result = await runHook({ + tool_input: { + command: 'git commit -m "real change" && echo "next: --allow-empty"', + }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) + assert.strictEqual(result.stderr, '') +}) + +test('git commit --allow-empty chained after another command is still blocked', async () => { + const result = await runHook({ + tool_input: { command: 'cd /x && git commit --allow-empty -m anchor' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) diff --git a/.claude/hooks/no-empty-commit-guard/tsconfig.json b/.claude/hooks/no-empty-commit-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/no-empty-commit-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/no-experimental-strip-types-guard/index.mts b/.claude/hooks/no-experimental-strip-types-guard/index.mts index 08d6a82..f15f407 100644 --- a/.claude/hooks/no-experimental-strip-types-guard/index.mts +++ b/.claude/hooks/no-experimental-strip-types-guard/index.mts @@ -23,13 +23,34 @@ import process from 'node:process' -import { containsOutsideQuotes } from '../_shared/bash-quote-mask.mts' +import { parseCommands } from '../_shared/shell-command.mts' interface ToolInput { readonly tool_input?: { readonly command?: string | undefined } | undefined readonly tool_name?: string | undefined } +const FLAG = '--experimental-strip-types' + +// True when any parsed command passes `--experimental-strip-types` as a +// real argument, or carries it inside a `NODE_OPTIONS=…` env assignment +// (Node parses that value as args at startup, so it's live even when the +// assignment value is quoted). The parser scopes the flag to an actual +// invocation, so a quoted mention inside an `echo`/`-m` body is ignored. +function passesStripTypesFlag(command: string): boolean { + for (const c of parseCommands(command)) { + if (c.args.some(a => a === FLAG || a.startsWith(`${FLAG}=`))) { + return true + } + for (const a of c.assignments) { + if (a.startsWith('NODE_OPTIONS=') && a.includes(FLAG)) { + return true + } + } + } + return false +} + let payloadRaw = '' process.stdin.setEncoding('utf8') process.stdin.on('data', chunk => { @@ -55,21 +76,10 @@ process.stdin.on('end', () => { } const command = payload.tool_input?.command ?? '' - // Check for the flag at a position the shell would actually execute - // (outside quoted strings and outside heredoc bodies). This skips - // false positives from `echo "tip: ..."` reminders and - // `git commit -m "$(cat <): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts b/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts index 0899da2..4091f77 100644 --- a/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts +++ b/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts @@ -6,7 +6,7 @@ */ import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' import { describe, test } from 'node:test' @@ -21,7 +21,6 @@ interface RunResult { function runHook(payload: object): RunResult { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { @@ -149,7 +148,8 @@ EOF 'and again spencermountain/compromise#1203"', ) assert.equal(r.code, 2) - const matches = String(r.stderr).match(/spencermountain\/compromise#1203/g) || [] + const matches = + String(r.stderr).match(/spencermountain\/compromise#1203/g) || [] // Ref appears in 'Refs found:' bullet — one bullet, not two. // (May also appear in narrative text once.) assert.ok(matches.length <= 2, `expected dedup; saw ${matches.length}`) @@ -157,7 +157,6 @@ EOF test('fails open on invalid JSON', () => { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not json', }) assert.equal(r.status, 0) @@ -165,7 +164,6 @@ EOF test('fails open on empty stdin', () => { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '', }) assert.equal(r.status, 0) diff --git a/.claude/hooks/no-fleet-fork-guard/index.mts b/.claude/hooks/no-fleet-fork-guard/index.mts index 83af9b3..fcfbe67 100644 --- a/.claude/hooks/no-fleet-fork-guard/index.mts +++ b/.claude/hooks/no-fleet-fork-guard/index.mts @@ -73,6 +73,13 @@ const CANONICAL_PREFIXES = [ 'docs/claude.md/', ] +// Carve-out: paths under a CANONICAL_PREFIXES dir that are explicitly +// per-repo (not cascaded). `docs/claude.md/repo/` is the per-repo +// analog of `docs/claude.md/fleet/` — host repos drop architecture / +// commands / build-pipeline detail here to keep CLAUDE.md under the +// whole-file size cap. +const PER_REPO_PREFIXES = ['docs/claude.md/repo/'] + // Fleet-canonical individual files (not under one of the prefix // dirs). Matches relative-to-repo-root. const CANONICAL_FILES: string[] = [ @@ -132,6 +139,15 @@ export function findFleetRepoRoot(filePath: string): string | undefined { export function isCanonicalRelativePath(rel: string): boolean { const normalized = rel.replace(/\\/g, '/') + // Per-repo carve-outs take precedence over the canonical prefixes + // (they're more specific). Edits under these paths are intentionally + // per-repo and don't go through the fleet cascade. + for (let i = 0, { length } = PER_REPO_PREFIXES; i < length; i += 1) { + const prefix = PER_REPO_PREFIXES[i]! + if (normalized.startsWith(prefix)) { + return false + } + } for (let i = 0, { length } = CANONICAL_PREFIXES; i < length; i += 1) { const prefix = CANONICAL_PREFIXES[i]! if (normalized.startsWith(prefix)) { diff --git a/.claude/hooks/no-fleet-fork-guard/test/index.test.mts b/.claude/hooks/no-fleet-fork-guard/test/index.test.mts index a1826bb..265361b 100644 --- a/.claude/hooks/no-fleet-fork-guard/test/index.test.mts +++ b/.claude/hooks/no-fleet-fork-guard/test/index.test.mts @@ -11,7 +11,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -36,6 +36,11 @@ async function runHook( payload['transcript_path'] = transcriptPath } const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -184,6 +189,23 @@ test('Edit on docs/claude.md/* in a fleet repo is BLOCKED', async () => { } }) +test('Edit on docs/claude.md/repo/* in a fleet repo is ALLOWED (per-repo carve-out)', async () => { + // The repo/ subdirectory is the per-repo analog of fleet/. Host repos + // drop architecture/commands/build detail here to fit the whole-file + // size cap without cascading the content fleet-wide. + const repo = makeFakeFleetRepo() + try { + const file = makeCanonicalFile(repo, 'docs/claude.md/repo/architecture.md') + const result = await runHook({ + tool_input: { file_path: file, new_string: 'x' }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 0) + } finally { + rmSync(repo, { force: true, recursive: true }) + } +}) + test('Write tool also blocked, not just Edit', async () => { const repo = makeFakeFleetRepo() try { diff --git a/.claude/hooks/no-meta-comments-guard/test/index.test.mts b/.claude/hooks/no-meta-comments-guard/test/index.test.mts index 0ec5c61..82aeb4e 100644 --- a/.claude/hooks/no-meta-comments-guard/test/index.test.mts +++ b/.claude/hooks/no-meta-comments-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/no-non-fleet-push-guard/README.md b/.claude/hooks/no-non-fleet-push-guard/README.md new file mode 100644 index 0000000..332dccb --- /dev/null +++ b/.claude/hooks/no-non-fleet-push-guard/README.md @@ -0,0 +1,81 @@ +# no-non-fleet-push-guard + +PreToolUse(Bash) hook that blocks `git push` to a repository outside the +fleet. + +## Why + +The fleet's git-side pre-push hook only exists in repos that installed +the fleet hook chain. A non-fleet repo (a personal checkout, a sibling +project like `depot`) has no such hook, so a stray `cd /…/depot && git +push` sails straight through. The block has to live agent-side, before +the command runs, and resolve the target repo against the fleet roster. + +Past incident: an agent `cd`-ed into `depot` (not a fleet repo) and +pushed a fleet-convention change to its `main`. The push succeeded +because depot has no fleet pre-push hook. This guard is the response. + +## What it blocks + +| Command shape | Resolves target via | Block? | +| ------------------------------------------ | ------------------- | ------ | +| `git push` (in a fleet repo cwd) | process cwd | no | +| `git push` (in a non-fleet repo cwd) | process cwd | yes | +| `cd /path/to/depot && git push` | leading `cd` | yes | +| `git -C /path/to/depot push` | `-C` flag | yes | +| `echo "git push"` / commit msg saying push | (not a push) | no | +| `git push` where `origin` is unresolvable | (fail open) | no | + +Fleet membership is the broad set in +[`_shared/fleet-repos.mts`](../_shared/fleet-repos.mts) (`FLEET_REPO_NAMES`), +which includes `ultrathink` and other members the narrower cascade +roster (`cascading-fleet/lib/fleet-repos.json`) omits. Gating on the +broad set is deliberate: a fleet member is pushable even if it isn't a +cascade target. + +## Target-directory resolution + +In priority order: + +1. `git -C push …` — the explicit `-C` dir. +2. A leading `cd ` in the command chain (`cd X && git push`), + resolved against the process cwd for relative paths. +3. The hook's process cwd. + +Then `git -C remote get-url origin` → slug via `slugFromRemoteUrl` +→ `isFleetRepo(slug)`. + +## Fail-open + +Any resolution ambiguity (no `git push` found, dir unreadable, no +`origin`, unparseable remote URL) → allow. Under-blocking is recoverable +(the operator reverts a stray push); a false block wedges a valid +workflow. The guard only fires when it can positively identify a +non-fleet origin slug. + +## Bypass + +Type the canonical phrase in a new message: + + Allow non-fleet-push bypass + +Use for a genuine push to a personal / non-fleet repo you own. + +## Detection: shell parser, not regex + +`git push` detection goes through the shared shell parser +([`_shared/shell-command.mts`](../_shared/shell-command.mts), which wraps +`shell-quote`), not a regex. The parser splits the command line into +segments and reads the binary + subcommand at each position, so it sees +through: + +- `&&` / `||` / `;` / `|` chains (`cd /x && git push`) +- `$(…)` command substitution (`git push $(echo origin)`) +- quoted bodies (`git commit -m "git push later"` is NOT a push) +- global options before the subcommand (`git -C /x push`) + +Remaining limits of any static parser (shared with +`gh-token-hygiene-guard`): a binary fully sourced from a variable +(`g=git; $g push`) can't be statically resolved to `git` — the parser +FLAGS it as opaque (`hasOpaqueInvocation`) but this guard doesn't act on +that today; and an alias or wrapper script that pushes is out of scope. diff --git a/.claude/hooks/no-non-fleet-push-guard/index.mts b/.claude/hooks/no-non-fleet-push-guard/index.mts new file mode 100644 index 0000000..1bb753a --- /dev/null +++ b/.claude/hooks/no-non-fleet-push-guard/index.mts @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-non-fleet-push-guard. +// +// Blocks `git push` to a repository that is NOT a fleet member. The +// fleet's git-side pre-push hook can't catch this: a non-fleet repo +// never has the fleet hook chain installed (that's exactly how a stray +// push to e.g. `depot` slips through). So the guard lives agent-side, +// inspecting the Bash command before it runs, and resolves the target +// repo's origin remote against the canonical fleet roster. +// +// Detection model: +// - Fires only on Bash commands containing `git push` at an +// executable position (not inside quotes / heredoc bodies — a +// commit message that says "git push" is not a push). +// - Resolves the TARGET directory, in priority order: +// 1. `git -C push …` (explicit -C) +// 2. a leading `cd && …` (the `cd /…/depot && git push` +// shape that bypasses the session cwd) +// 3. the hook's process cwd +// - Reads `git -C remote get-url origin`, extracts the repo +// slug, and blocks when the slug is not in FLEET_REPO_NAMES. +// +// Bypass: `Allow non-fleet-push bypass` typed verbatim in a recent user +// turn — for the rare legitimate push to a personal / non-fleet repo. +// +// Fails OPEN on any resolution ambiguity (can't find the command, the +// dir, or the remote): better to under-block than to wedge a valid +// push when the shape is unfamiliar. The cost of a missed block is one +// `Allow … bypass`-free push the operator can revert; the cost of a +// false block is a bricked workflow. + +import path from 'node:path' +import process from 'node:process' + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +import { isFleetRepo, slugFromRemoteUrl } from '../_shared/fleet-repos.mts' +import { findInvocation } from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: string | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow non-fleet-push bypass' + +// `git -C …` — capture the dir (quoted or bare). Still a regex +// because we only need the -C VALUE, not command structure; the push +// DETECTION (which needs structure) goes through the shell parser. +const GIT_DASH_C_RE = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/ + +// A leading `cd ` before the push, e.g. `cd /x/depot && git push`. +// Only the FIRST cd in the chain matters for where git runs. +const LEADING_CD_RE = /(?:^|[;&|]|&&)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/ + +export function extractGitCwd(command: string): string { + // Priority 1: explicit `git -C `. + const dashC = GIT_DASH_C_RE.exec(command) + if (dashC) { + return dashC[2] ?? dashC[3] ?? dashC[4] ?? process.cwd() + } + // Priority 2: a leading `cd ` in the chain. + const cd = LEADING_CD_RE.exec(command) + if (cd) { + const dir = cd[2] ?? cd[3] ?? cd[4] + if (dir) { + // Resolve against process cwd so a relative `cd ../foo` works. + return path.resolve(process.cwd(), dir) + } + } + // Priority 3: the hook's own cwd. + return process.cwd() +} + +export function originSlug(dir: string): string | undefined { + let out: string + try { + const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { + encoding: 'utf8', + }) + if (r.status !== 0) { + return undefined + } + out = String(r.stdout ?? '').trim() + } catch { + return undefined + } + return slugFromRemoteUrl(out) +} + +async function main(): Promise { + let raw: string + try { + raw = await readStdin() + } catch { + process.exit(0) + } + if (!raw) { + process.exit(0) + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.exit(0) + } + + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command + if (!command) { + process.exit(0) + } + + // Detect `git push` via the shell parser (not regex): it splits the + // command line into segments, sees through `&&`/`|`/`;` chains and + // `$(…)` substitution, and ignores `push` inside a quoted commit + // message — so `git commit -m "git push later"` is correctly NOT a + // push, while `cd /x && git push` and `git -C /x push` are. + if (!findInvocation(command, { binary: 'git', subcommand: 'push' })) { + process.exit(0) + } + + const dir = extractGitCwd(command) + const slug = originSlug(dir) + + // Fail open: no resolvable origin slug → can't classify, allow. + if (!slug) { + process.exit(0) + } + if (isFleetRepo(slug)) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + process.stderr.write( + [ + '[no-non-fleet-push-guard] Blocked: push to a non-fleet repository', + '', + ` Target dir: ${dir}`, + ` origin repo: ${slug}`, + '', + ` \`${slug}\` is not in the fleet roster, and fleet tooling must`, + ' not push to repos outside the fleet. A non-fleet repo has no', + ' fleet hook chain, so this agent-side guard is the only check', + ' standing between you and a stray push to someone else’s repo.', + '', + ' If this push is wrong: you probably `cd`-ed into the wrong repo', + ' or have the wrong `origin`. Verify with:', + ` git -C ${dir} remote get-url origin`, + '', + ` If the push is genuinely intended (a personal / non-fleet repo`, + ` you own), type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[no-non-fleet-push-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/no-non-fleet-push-guard/package.json b/.claude/hooks/no-non-fleet-push-guard/package.json new file mode 100644 index 0000000..4f2d28d --- /dev/null +++ b/.claude/hooks/no-non-fleet-push-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-non-fleet-push-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts b/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts new file mode 100644 index 0000000..b77b347 --- /dev/null +++ b/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts @@ -0,0 +1,167 @@ +// node --test specs for the no-non-fleet-push-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns the hook +// subprocess and pipes stdin/stdout/stderr. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +// Make a throwaway git repo with the given origin URL, return its path. +function gitRepoWithOrigin(originUrl: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'nfp-guard-')) + const run = (...args: string[]) => + execFileSync('git', ['-C', dir, ...args], { stdio: 'ignore' }) + run('init', '-q') + run('remote', 'add', 'origin', originUrl) + return dir +} + +// A dir that is NOT a git repo (no origin) — for the fail-open case. +function nonGitDir(): string { + return mkdtempSync(path.join(os.tmpdir(), 'nfp-nongit-')) +} + +async function runHook( + payload: Record, + cwd?: string, +): Promise { + const child = spawn(process.execPath, [HOOK], { cwd, stdio: 'pipe' }) + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +const bash = (command: string) => ({ tool_name: 'Bash', tool_input: { command } }) + +test('non-Bash tool passes', async () => { + const r = await runHook({ tool_name: 'Edit', tool_input: { command: 'x' } }) + assert.strictEqual(r.code, 0) +}) + +test('Bash without git push passes', async () => { + const r = await runHook(bash('ls -la && echo hi')) + assert.strictEqual(r.code, 0) +}) + +test('fleet repo via cwd — git push allowed', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/socket-cli.git') + const r = await runHook(bash('git push origin main'), dir) + assert.strictEqual(r.code, 0) +}) + +test('non-fleet repo via cwd — git push BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const r = await runHook(bash('git push origin main'), dir) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('non-fleet repo via leading cd — BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + // cwd is a fleet repo; the cd redirects git into the non-fleet one. + const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') + const r = await runHook(bash(`cd ${dir} && git push origin main`), fleetCwd) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('non-fleet repo via git -C — BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') + const r = await runHook(bash(`git -C ${dir} push origin main`), fleetCwd) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('ultrathink (fleet member, not in cascade roster) — allowed', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/ultrathink.git') + const r = await runHook(bash('git push'), dir) + assert.strictEqual(r.code, 0) +}) + +test('HTTPS remote, non-fleet — BLOCKED', async () => { + const dir = gitRepoWithOrigin('https://github.com/SocketDev/depot.git') + const r = await runHook(bash('git push origin main'), dir) + assert.strictEqual(r.code, 2) +}) + +test('fork under another owner of a fleet name — allowed (slug matches)', async () => { + // slug is keyed on repo name; a socket-cli fork still resolves to a + // fleet slug. (Owner-level gating is out of scope; the name is the key.) + const dir = gitRepoWithOrigin('git@github.com:someuser/socket-cli.git') + const r = await runHook(bash('git push'), dir) + assert.strictEqual(r.code, 0) +}) + +test('git push mentioned only in a quoted commit message — not a push', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const r = await runHook( + bash(`git commit -m "remember to git push later"`), + dir, + ) + assert.strictEqual(r.code, 0) +}) + +test('non-git dir (no origin) — fail open, allowed', async () => { + const dir = nonGitDir() + const r = await runHook(bash('git push'), dir) + assert.strictEqual(r.code, 0) +}) + +test('substitution: git $(printf push) to a non-fleet repo — BLOCKED', async () => { + // The shell parser surfaces `git push` even when the subcommand is + // produced by a $(…) substitution — a form the old regex missed. + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const r = await runHook(bash('git push $(echo origin) main'), dir) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('pipe/chain push to non-fleet repo — BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') + const r = await runHook( + bash(`echo start && cd ${dir} && git push origin main`), + fleetCwd, + ) + assert.strictEqual(r.code, 2) +}) + +test('bypass phrase in transcript — non-fleet push allowed', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const txDir = mkdtempSync(path.join(os.tmpdir(), 'nfp-tx-')) + const transcriptPath = path.join(txDir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: 'Allow non-fleet-push bypass' }, + }) + '\n', + ) + const r = await runHook( + { + ...bash('git push origin main'), + transcript_path: transcriptPath, + }, + dir, + ) + assert.strictEqual(r.code, 0) +}) diff --git a/.claude/hooks/no-non-fleet-push-guard/tsconfig.json b/.claude/hooks/no-non-fleet-push-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/no-non-fleet-push-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/no-orphaned-staging/index.mts b/.claude/hooks/no-orphaned-staging/index.mts index 0b9e373..7fab1d6 100644 --- a/.claude/hooks/no-orphaned-staging/index.mts +++ b/.claude/hooks/no-orphaned-staging/index.mts @@ -34,7 +34,7 @@ // // Disabled via `SOCKET_NO_ORPHANED_STAGING_DISABLED=1`. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' export async function drainStdin(): Promise { diff --git a/.claude/hooks/no-orphaned-staging/test/index.test.mts b/.claude/hooks/no-orphaned-staging/test/index.test.mts index c60a34c..8b55414 100644 --- a/.claude/hooks/no-orphaned-staging/test/index.test.mts +++ b/.claude/hooks/no-orphaned-staging/test/index.test.mts @@ -5,7 +5,7 @@ */ import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -22,7 +22,6 @@ interface RunResult { function runHook(env: Record): RunResult { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '{}', env: { ...process.env, ...env }, }) @@ -33,7 +32,7 @@ function runHook(env: Record): RunResult { } function git(repoDir: string, args: string[]): void { - const r = spawnSync('git', args, { cwd: repoDir,}) + const r = spawnSync('git', args, { cwd: repoDir }) if (r.status !== 0) { throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`) } @@ -120,7 +119,6 @@ describe('no-orphaned-staging', () => { // Empty stdin would normally drain; verifying the hook doesn't // crash on missing-env-vars or other edge cases. const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '', env: { ...process.env, CLAUDE_PROJECT_DIR: '/nonexistent/path' }, }) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md b/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md new file mode 100644 index 0000000..acffb60 --- /dev/null +++ b/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md @@ -0,0 +1,55 @@ +# no-package-json-pnpm-overrides-guard + +PreToolUse Edit/Write hook that blocks adding (or expanding) a +`pnpm.overrides` block in any `package.json`. + +## Why + +pnpm reads dependency overrides from two places: `pnpm.overrides` in +`package.json`, or the top-level `overrides:` map in `pnpm-workspace.yaml`. +The fleet standardizes on the workspace file as the single override surface. + +A `pnpm.overrides` block in package.json splits the source of truth: a +reviewer auditing pins now has to check two files, and the workspace file's +`trustPolicy: no-downgrade` only governs the overrides declared there. An +override hiding in a package.json can silently downgrade a transitive dep +past the trust policy. + +## What it blocks + +| Pattern | Block? | +| ------------------------------------------------------------------ | ------ | +| Edit/Write that adds a key under `pnpm.overrides` in package.json | yes | +| Edit/Write that removes a key from `pnpm.overrides` | no | +| Edit/Write touching package.json but not `pnpm.overrides` | no | +| Edit/Write to `pnpm-workspace.yaml` `overrides:` (the right place) | no | +| Edit/Write to any other file | no | + +## Bypass + +Type the canonical phrase in a new message: + + Allow package-json-overrides bypass + +Rare legitimate case: a published package that ships its own +`pnpm.overrides` you're vendoring verbatim and must not rewrite. + +## Detection + +The hook parses both the current package.json and the after-edit contents +as JSON, reads `pnpm.overrides`, and computes the set difference of override +keys. Keys added → block. Keys removed or unchanged → pass. + +Fails open on JSON parse errors: better to under-block than to brick edits +when the file is in a transient bad state. + +## Fix + +Move the override to the top-level `overrides:` map in `pnpm-workspace.yaml`, +then `pnpm install`: + +```yaml +# pnpm-workspace.yaml +overrides: + some-dep: '>=1.2.3' +``` diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts b/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts new file mode 100644 index 0000000..fa7435d --- /dev/null +++ b/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts @@ -0,0 +1,179 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-package-json-pnpm-overrides-guard. +// +// Blocks Edit/Write operations that add (or expand) a `pnpm.overrides` +// block in any `package.json`. The fleet keeps dependency overrides in +// `pnpm-workspace.yaml` `overrides:` as the single source of truth. A +// `pnpm.overrides` block in package.json splits that surface and sits +// outside the workspace file's `trustPolicy: no-downgrade` governance. +// +// Detection model: +// - Fires only on Edit / Write to files named `package.json`. +// - Parses before + after JSON. Reports the override keys that are +// present in the after-state but absent (or fewer) in the before. +// - New / expanded `pnpm.overrides` → block. +// +// Bypass: `Allow package-json-overrides bypass` typed verbatim in a +// recent user turn. +// +// Fails open on parse errors (better to under-block than to brick edits +// when the file isn't parseable JSON). + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly old_string?: string | undefined + readonly content?: string | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow package-json-overrides bypass' + +// Extract the set of override keys declared under `pnpm.overrides` in a +// package.json text. Returns an empty set when the block is absent, the +// text isn't valid JSON, or `pnpm.overrides` isn't an object. pnpm reads +// overrides from `pnpm.overrides` (package.json) or top-level `overrides` +// (pnpm-workspace.yaml); this guard targets the package.json form only. +export function extractOverrideKeys(jsonText: string): Set { + const out = new Set() + let parsed: unknown + try { + parsed = JSON.parse(jsonText) + } catch { + return out + } + if (!parsed || typeof parsed !== 'object') { + return out + } + const pnpm = (parsed as { pnpm?: unknown }).pnpm + if (!pnpm || typeof pnpm !== 'object') { + return out + } + const overrides = (pnpm as { overrides?: unknown }).overrides + if (!overrides || typeof overrides !== 'object') { + return out + } + for (const key of Object.keys(overrides as Record)) { + out.add(key) + } + return out +} + +export function readFileSafe(p: string): string { + try { + return readFileSync(p, 'utf8') + } catch { + return '' + } +} + +async function main(): Promise { + let raw: string + try { + raw = await readStdin() + } catch { + process.exit(0) + } + if (!raw) { + process.exit(0) + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.exit(0) + } + + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + process.exit(0) + } + const input = payload.tool_input + const filePath = input?.file_path + if (!filePath || path.basename(filePath) !== 'package.json') { + process.exit(0) + } + + const currentText = readFileSafe(filePath) + let afterText: string + if (payload.tool_name === 'Write') { + afterText = input?.content ?? input?.new_string ?? '' + } else { + const oldStr = input?.old_string ?? '' + const newStr = input?.new_string ?? '' + if (!oldStr) { + process.exit(0) + } + if (!currentText.includes(oldStr)) { + process.exit(0) + } + afterText = currentText.replace(oldStr, newStr) + } + + let beforeKeys: Set + let afterKeys: Set + try { + beforeKeys = extractOverrideKeys(currentText) + afterKeys = extractOverrideKeys(afterText) + } catch (e) { + process.stderr.write( + `[no-package-json-pnpm-overrides-guard] parse error (allowing): ${e}\n`, + ) + process.exit(0) + } + + const added: string[] = [] + for (const key of afterKeys) { + if (!beforeKeys.has(key)) { + added.push(key) + } + } + if (added.length === 0) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + added.sort() + process.stderr.write( + [ + '[no-package-json-pnpm-overrides-guard] Blocked: package.json pnpm.overrides additions', + '', + ` File: ${filePath}`, + ` New entries: ${added.map(k => `\`${k}\``).join(', ')}`, + '', + ' The fleet keeps dependency overrides in `pnpm-workspace.yaml`', + ' `overrides:`, the single override surface. A `pnpm.overrides`', + ' block in package.json splits the source of truth and sits', + ' outside the workspace file’s `trustPolicy: no-downgrade`.', + '', + ' Fix: move the override to the top-level `overrides:` map in', + ' `pnpm-workspace.yaml`, then `pnpm install`.', + '', + ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[no-package-json-pnpm-overrides-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json b/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json new file mode 100644 index 0000000..eeb28c3 --- /dev/null +++ b/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-package-json-pnpm-overrides-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts b/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts new file mode 100644 index 0000000..616ff54 --- /dev/null +++ b/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts @@ -0,0 +1,147 @@ +// node --test specs for the no-package-json-pnpm-overrides-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +function tmpPackageJson(content: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-test-')) + const p = path.join(dir, 'package.json') + writeFileSync(p, content) + return p +} + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-Edit/Write tool passes', async () => { + const r = await runHook({ + tool_name: 'Bash', + tool_input: { command: 'echo hi' }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit to a non-package.json file passes', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-other-')) + const filePath = path.join(dir, 'pnpm-workspace.yaml') + writeFileSync(filePath, 'overrides:\n foo: 1.0.0\n') + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: 'foo: 1.0.0', + new_string: 'foo: 2.0.0', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit that does not touch pnpm.overrides passes', async () => { + const filePath = tmpPackageJson( + '{\n "name": "x",\n "version": "1.0.0"\n}\n', + ) + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: '"1.0.0"', + new_string: '"1.0.1"', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit removes a pnpm.overrides key — passes', async () => { + const filePath = tmpPackageJson( + '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1", "b": "2" } }\n}\n', + ) + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: '{ "a": "1", "b": "2" }', + new_string: '{ "a": "1" }', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit adds a new pnpm.overrides key — blocked', async () => { + const filePath = tmpPackageJson( + '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1" } }\n}\n', + ) + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: '{ "a": "1" }', + new_string: '{ "a": "1", "b": "2" }', + }, + }) + assert.strictEqual(r.code, 2) + assert.ok(String(r.stderr).includes('`b`')) +}) + +test('Write adds a fresh pnpm.overrides — blocked', async () => { + const filePath = tmpPackageJson('{ "name": "x" }') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: '{ "name": "x", "pnpm": { "overrides": { "sketchy": "9" } } }', + }, + }) + assert.strictEqual(r.code, 2) + assert.ok(String(r.stderr).includes('sketchy')) +}) + +test('Edit with bypass phrase in transcript — passes', async () => { + const filePath = tmpPackageJson('{ "name": "x" }') + const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: 'Allow package-json-overrides bypass' }, + }) + '\n', + ) + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: '{ "name": "x", "pnpm": { "overrides": { "b": "2" } } }', + }, + transcript_path: transcriptPath, + }) + assert.strictEqual(r.code, 0) +}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json b/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/no-revert-guard/index.mts b/.claude/hooks/no-revert-guard/index.mts index 783eae2..b91e75f 100644 --- a/.claude/hooks/no-revert-guard/index.mts +++ b/.claude/hooks/no-revert-guard/index.mts @@ -38,6 +38,7 @@ import process from 'node:process' +import { commandsFor } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' type ToolInput = { @@ -51,19 +52,26 @@ type GuardCheck = { readonly bypassPhrase: string // Human-readable label for the rule (logged on rejection). readonly label: string - // Pattern that detects the destructive command. - readonly pattern: RegExp + // Detector. Exactly one of `pattern` / `matches` is set: + // - `pattern`: a regex matched anywhere in the command. Correct for + // flag / env-var rules (`--no-verify`, `DISABLE_PRECOMMIT_LINT=1`) + // that apply regardless of which binary they sit on. + // - `matches`: a parser-based detector for command-STRUCTURE rules + // (which git subcommand runs). Returns the offending substring for + // the log, or undefined when no match. Sees through chains / `$(…)` + // / quotes, where a regex would over- or under-match. + readonly pattern?: RegExp + readonly matches?: (command: string) => string | undefined } const CHECKS: readonly GuardCheck[] = [ { bypassPhrase: 'Allow revert bypass', label: 'git revert (checkout/restore/reset/stash/clean)', - // Match destructive git commands. Anchored on `git ` or `git\t` - // (with optional leading whitespace) so we don't match inside - // arbitrary strings. - pattern: - /(?:^|[\s;&|(`])git\s+(?:checkout\s+(?:--?[a-z]+\s+)*(?:--\s|\S+\s+--\s)|restore(?!\s+--staged\b)|reset\s+--hard|stash\s+(?:clear|drop|pop)|clean\s+-[a-z]*f|rm\s+-r?f?\s)/, + // Parser-based: inspect each real `git` command's args for a + // destructive subcommand shape. Sees through chains / quotes so a + // quoted "git reset --hard" in a commit message isn't a match. + matches: command => matchDestructiveGit(command), }, { bypassPhrase: 'Allow no-verify bypass', @@ -113,8 +121,19 @@ const CHECKS: readonly GuardCheck[] = [ // user knows no other Claude session is active. bypassPhrase: 'Allow stash bypass', label: 'git stash (primary-checkout parallel-Claude hazard)', - pattern: - /(?:^|[\s;&|(`])git\s+stash(?:\s+(?:push|save|--keep-index|--patch|-[a-z]+)|\s*$|\s+[^a-z])/, + // Any `git stash` (bare, or push/save/--keep-index/etc.) — but NOT + // `git stash pop/drop/clear`, which the destructive-git check above + // already owns (it's a different destruction surface). + matches: command => + commandsFor(command, 'git').some(c => { + if (c.args[0] !== 'stash') { + return false + } + const sub = c.args[1] + return sub !== 'pop' && sub !== 'drop' && sub !== 'clear' + }) + ? 'git stash' + : undefined, }, { // Bash file-write surfaces agents reach for when an Edit/Write @@ -154,10 +173,59 @@ const CHECKS: readonly GuardCheck[] = [ { bypassPhrase: 'Allow force-push bypass', label: 'git push --force / -f', - pattern: /(?:^|[\s;&|(`])git\s+push\b[^;&|()`]*\s(?:--force\b|-f\b)/, + matches: command => + commandsFor(command, 'git').some( + c => + c.args.includes('push') && + (c.args.includes('--force') || + c.args.includes('-f') || + c.args.some(a => a.startsWith('--force-with-lease'))), + ) + ? 'git push --force' + : undefined, }, ] +// Destructive `git` subcommands the revert rule blocks. Operates on a +// parsed git command's args (a1 = first arg = subcommand, rest = flags). +// Mirrors the old regex's surface: +// checkout … -- (discards working-tree changes) +// restore (but NOT `restore --staged`, which only unstages) +// reset --hard +// stash clear|drop|pop +// clean -f / -xf / -df … +// rm -f / -rf +export function matchDestructiveGit(command: string): string | undefined { + for (const c of commandsFor(command, 'git')) { + const [sub, ...rest] = c.args + if (!sub) { + continue + } + if (sub === 'checkout' && rest.includes('--')) { + return 'git checkout -- ' + } + if (sub === 'restore' && !rest.includes('--staged')) { + return 'git restore' + } + if (sub === 'reset' && rest.includes('--hard')) { + return 'git reset --hard' + } + if ( + sub === 'stash' && + (rest[0] === 'clear' || rest[0] === 'drop' || rest[0] === 'pop') + ) { + return `git stash ${rest[0]}` + } + if (sub === 'clean' && rest.some(a => /^-[a-z]*f/.test(a))) { + return 'git clean -f' + } + if (sub === 'rm' && rest.some(a => /^-r?f?$/.test(a) && a.includes('f'))) { + return 'git rm -f' + } + } + return undefined +} + export function emitBlock( command: string, match: GuardCheck, @@ -210,8 +278,8 @@ async function main(): Promise { // global env-var poisoning — and only allows the two operations the // cascade actually needs: // - // 1. `git commit --no-verify -m "chore(sync): cascade fleet template@"` - // — the commit message MUST start with `chore(sync): cascade fleet template@`. + // 1. `git commit --no-verify -m "chore(wheelhouse): cascade template@"` + // — the commit message MUST start with `chore(wheelhouse): cascade template@`. // 2. `git push --no-verify origin ` — any branch / direct push. // // Anything else with `FLEET_SYNC=1` still falls through to the normal @@ -220,21 +288,31 @@ async function main(): Promise { if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { const isCascadeCommit = /\bgit\s+commit\b/.test(command) && - /chore\(sync\):\s*cascade\s+fleet\s+template@/.test(command) + /chore\(wheelhouse\):\s*cascade\s+template@/.test(command) const isCascadePush = /\bgit\s+push\b/.test(command) if (isCascadeCommit || isCascadePush) { return } } - // Find the first matching destructive pattern. + // Find the first matching destructive pattern. A check is either a + // regex (`pattern`, matched anywhere — flags / env vars) or a parser + // detector (`matches`, command-structure — git subcommands). let triggered: { check: GuardCheck; matchedSubstring: string } | undefined for (let i = 0, { length } = CHECKS; i < length; i += 1) { const check = CHECKS[i]! - const m = command.match(check.pattern) - if (m) { - triggered = { check, matchedSubstring: m[0].trim() } - break + if (check.matches) { + const hit = check.matches(command) + if (hit) { + triggered = { check, matchedSubstring: hit } + break + } + } else if (check.pattern) { + const m = command.match(check.pattern) + if (m) { + triggered = { check, matchedSubstring: m[0].trim() } + break + } } } if (!triggered) { diff --git a/.claude/hooks/no-revert-guard/test/index.test.mts b/.claude/hooks/no-revert-guard/test/index.test.mts index 950f9a7..1862351 100644 --- a/.claude/hooks/no-revert-guard/test/index.test.mts +++ b/.claude/hooks/no-revert-guard/test/index.test.mts @@ -6,7 +6,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -31,6 +31,11 @@ async function runHook( payload['transcript_path'] = transcriptPath } const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -370,7 +375,7 @@ test('FLEET_SYNC=1 allows the cascade commit without bypass phrase', async () => const result = await runHook({ tool_input: { command: - 'FLEET_SYNC=1 git commit --no-verify -m "chore(sync): cascade fleet template@abc1234"', + 'FLEET_SYNC=1 git commit --no-verify -m "chore(wheelhouse): cascade template@abc1234"', }, tool_name: 'Bash', }) @@ -422,7 +427,7 @@ test('no FLEET_SYNC sentinel: cascade commit still requires the bypass phrase', const result = await runHook({ tool_input: { command: - 'git commit --no-verify -m "chore(sync): cascade fleet template@abc1234"', + 'git commit --no-verify -m "chore(wheelhouse): cascade template@abc1234"', }, tool_name: 'Bash', }) @@ -434,10 +439,135 @@ test('FLEET_SYNC=0 (explicit off) does NOT activate the allowlist', async () => const result = await runHook({ tool_input: { command: - 'FLEET_SYNC=0 git commit --no-verify -m "chore(sync): cascade fleet template@abc1234"', + 'FLEET_SYNC=0 git commit --no-verify -m "chore(wheelhouse): cascade template@abc1234"', }, tool_name: 'Bash', }) assert.strictEqual(result.code, 2) assert.ok(String(result.stderr).includes('Allow no-verify bypass')) }) + +// ── Parser-enabled coverage (added with the shell-quote migration) ── + +test('destructive git in an && chain is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'echo backup && git reset --hard origin/main' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('destructive git after a cd is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'cd /repo; git clean -fdx' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('quoted "git reset --hard" in a commit message is NOT a revert', async () => { + const result = await runHook({ + tool_input: { + command: 'git commit -m "document why git reset --hard is dangerous"', + }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('quoted "git push --force" in an echo is NOT a force-push', async () => { + const result = await runHook({ + tool_input: { command: 'echo "never git push --force to main"' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('git clean -f is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git clean -f' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('git clean -xdf (bundled flags) is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git clean -xdf' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('git rm -rf is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git rm -rf old-dir' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('git checkout -- is blocked (ref form)', async () => { + const result = await runHook({ + tool_input: { command: 'git checkout HEAD~1 -- src/foo.ts' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('git push --force-with-lease is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git push --force-with-lease origin main' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('git push -f is blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git push -f origin main' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) +}) + +test('plain git push (no force) is NOT blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git push origin main' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('git checkout (switch, no --) is NOT a revert', async () => { + const result = await runHook({ + tool_input: { command: 'git checkout main' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('git reset (soft, default) is NOT blocked', async () => { + const result = await runHook({ + tool_input: { command: 'git reset HEAD~1' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('git stash pop attributed to the revert rule (not stash rule)', async () => { + const result = await runHook({ + tool_input: { command: 'git stash pop' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /Allow revert bypass/) +}) + +test('a word ending in "git" is not a git command (e.g. legit)', async () => { + const result = await runHook({ + tool_input: { command: 'echo legit && ls' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts b/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts index 6d96666..cbc133e 100644 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts +++ b/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { describe, test } from 'node:test' import { fileURLToPath } from 'node:url' @@ -20,6 +20,11 @@ interface RunResult { function runHook(payload: object): Promise { return new Promise((resolve, reject) => { const child = spawn('node', [HOOK], { stdio: ['pipe', 'pipe', 'pipe'] }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/no-token-in-dotenv-guard/index.mts b/.claude/hooks/no-token-in-dotenv-guard/index.mts index ef7c835..fb1d77f 100644 --- a/.claude/hooks/no-token-in-dotenv-guard/index.mts +++ b/.claude/hooks/no-token-in-dotenv-guard/index.mts @@ -60,7 +60,7 @@ interface ToolInput { } // Dotfile shapes that carry env-style KEY=VALUE content. -const DOTENV_BASENAME_RE = /^\.env(\..+)?$|^\.envrc$/ +const DOTENV_BASENAME_RE = /^\.env(?:\..+)?$|^\.envrc$/ // Token-bearing key names live in `_shared/token-patterns.mts` so // every hook that scans for secret leaks (this one + token-guard) diff --git a/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts b/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts index a7b198e..d8a819b 100644 --- a/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts +++ b/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/no-underscore-identifier-guard/index.mts b/.claude/hooks/no-underscore-identifier-guard/index.mts index cefd0b3..92ff414 100644 --- a/.claude/hooks/no-underscore-identifier-guard/index.mts +++ b/.claude/hooks/no-underscore-identifier-guard/index.mts @@ -45,6 +45,8 @@ import process from 'node:process' +import { bypassPhrasePresent } from '../_shared/transcript.mts' + interface ToolInput { readonly tool_input?: | { @@ -55,6 +57,7 @@ interface ToolInput { } | undefined readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined } // Match declarations that introduce a leading-underscore identifier. @@ -104,16 +107,12 @@ export function findBannedIdentifiers(text: string): Finding[] { return findings } -export function hasRecentBypass(): boolean { - // Bypass detection is delegated to the harness's transcript reader — - // we can't see the user turn from here without parsing the env. - // The harness sets CLAUDE_RECENT_USER_TURNS when a bypass phrase - // hook is registered upstream; absent that, we look for it in env. - const turns = process.env['CLAUDE_RECENT_USER_TURNS'] - if (!turns) { - return false - } - return turns.includes(BYPASS_PHRASE) +export function hasRecentBypass(transcriptPath: string | undefined): boolean { + // Delegates to the shared transcript reader. Reads the JSONL the harness + // points at; `normalizeBypassText` handles hyphen/em-dash/whitespace + // normalization. Previous version checked process.env['CLAUDE_RECENT_USER_TURNS'], + // which no harness sets — bypass channel was effectively dead. + return bypassPhrasePresent(transcriptPath, BYPASS_PHRASE) } export function isGeneratedPath(filePath: string): boolean { @@ -204,7 +203,7 @@ async function main(): Promise { process.exit(0) } - if (hasRecentBypass()) { + if (hasRecentBypass(payload.transcript_path)) { process.stderr.write( `no-underscore-identifier-guard: ${findings.length} underscore identifier(s) — bypassed via "${BYPASS_PHRASE}"\n`, ) diff --git a/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts b/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts index 37acc50..a396e96 100644 --- a/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts +++ b/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/node-modules-staging-guard/index.mts b/.claude/hooks/node-modules-staging-guard/index.mts index d442f97..5b87967 100644 --- a/.claude/hooks/node-modules-staging-guard/index.mts +++ b/.claude/hooks/node-modules-staging-guard/index.mts @@ -75,15 +75,15 @@ export function isForbiddenPath(arg: string): boolean { // Any `/node_modules/` segment OR a top-level `node_modules` / // `node_modules/...`. if ( - /(^|\/)node_modules(\/|$)/.test(stripped) || - /[\\]node_modules([\\]|$)/.test(stripped) + /(?:^|\/)node_modules(?:\/|$)/.test(stripped) || + /[\\]node_modules(?:[\\]|$)/.test(stripped) ) { return true } // `package-lock.json` under `.claude/hooks//` or // `.claude/skills//`. if ( - /(^|\/)\.claude\/(hooks|skills)\/[^/]+\/(package-lock\.json|pnpm-lock\.yaml)$/.test( + /(?:^|\/)\.claude\/(?:hooks|skills)\/[^/]+\/(?:package-lock\.json|pnpm-lock\.yaml)$/.test( stripped, ) ) { diff --git a/.claude/hooks/node-modules-staging-guard/test/index.test.mts b/.claude/hooks/node-modules-staging-guard/test/index.test.mts index 818b9da..ac078eb 100644 --- a/.claude/hooks/node-modules-staging-guard/test/index.test.mts +++ b/.claude/hooks/node-modules-staging-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -18,6 +18,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/overeager-staging-guard/index.mts b/.claude/hooks/overeager-staging-guard/index.mts index 5420390..03f4ef3 100644 --- a/.claude/hooks/overeager-staging-guard/index.mts +++ b/.claude/hooks/overeager-staging-guard/index.mts @@ -36,11 +36,12 @@ // "tool_input": { "command": "..." }, // "transcript_path": "/.../session.jsonl" } -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { readFileSync } from 'node:fs' import path from 'node:path' import process from 'node:process' +import { commandsFor, findInvocation } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' interface ToolInput { @@ -85,42 +86,31 @@ export function addTouchedFromBash( } } -// Detects `git add` invocations that sweep the working tree. We split -// the command into tokens and check for the flags rather than regexing -// the raw string — that way `git add ./path` (a legitimate surgical -// add of a file that starts with `.`) is not confused with `git add .` -// (the broad sweep). +// Detects `git add` invocations that sweep the working tree. Parses +// the command with the shared shell tokenizer so chains, quoting, and +// leading env-var assignments (`GIT_AUTHOR_NAME=x git add …`) are all +// handled — and a quoted "git add ." inside a message can't false-fire. +// `git add ./path` (a legitimate surgical add of a dotfile) is NOT +// confused with `git add .` (the broad sweep) because the parser +// preserves the exact arg. export function detectBroadGitAdd(command: string): string | undefined { - // Tokenize on whitespace; not bulletproof against quoted arguments - // but the broad-add forms never need quoting. Strip trailing - // semicolons / && / || segments by splitting on those operators. - const segments = command.split(/(?:&&|\|\||;|\n)/) - for (let i = 0, { length } = segments; i < length; i += 1) { - const segment = segments[i]! - const tokens = segment.trim().split(/\s+/) - if (tokens.length < 2) { - continue - } - // Find "git add" — tolerate leading env-var sets like - // `GIT_AUTHOR_NAME=x git add ...` - let j = 0 - while (j < tokens.length && tokens[j]!.includes('=')) { - j += 1 - } - if (tokens[j] !== 'git') { + for (const c of commandsFor(command, 'git')) { + // The `add` subcommand can sit after global flags (`git -C x add .`), + // so scan all args rather than assuming position. + if (!c.args.includes('add')) { continue } - if (tokens[j + 1] !== 'add') { - continue - } - // Inspect remaining args; flag if -A / --all / -u / --update / . - // appear as a bare positional. - const rest = tokens.slice(j + 2) - for (let k = 0, { length } = rest; k < length; k += 1) { - const arg = rest[k]! - if (arg === '--all' || arg === '-A') {return `git add ${arg}`} - if (arg === '--update' || arg === '-u') {return `git add ${arg}`} - if (arg === '.') {return 'git add .'} + for (let k = 0, { length } = c.args; k < length; k += 1) { + const arg = c.args[k]! + if (arg === '--all' || arg === '-A') { + return `git add ${arg}` + } + if (arg === '--update' || arg === '-u') { + return `git add ${arg}` + } + if (arg === '.') { + return 'git add .' + } } } return undefined @@ -131,20 +121,7 @@ export function getRepoDir(): string { } export function isGitCommit(command: string): boolean { - // Tokenize as above; look for "git commit" anywhere in any segment. - const segments = command.split(/(?:&&|\|\||;|\n)/) - for (let i = 0, { length } = segments; i < length; i += 1) { - const segment = segments[i]! - const tokens = segment.trim().split(/\s+/) - let j = 0 - while (j < tokens.length && tokens[j]!.includes('=')) { - j += 1 - } - if (tokens[j] === 'git' && tokens[j + 1] === 'commit') { - return true - } - } - return false + return findInvocation(command, { binary: 'git', subcommand: 'commit' }) } export function listStagedFiles(repoDir: string): string[] { diff --git a/.claude/hooks/overeager-staging-guard/test/index.test.mts b/.claude/hooks/overeager-staging-guard/test/index.test.mts index c0b8475..1fd9a97 100644 --- a/.claude/hooks/overeager-staging-guard/test/index.test.mts +++ b/.claude/hooks/overeager-staging-guard/test/index.test.mts @@ -7,7 +7,7 @@ */ import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -36,7 +36,6 @@ function runHook( transcript_path: options.transcriptPath, } const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), env: { ...process.env, @@ -124,6 +123,19 @@ test('blocks broad add when env vars are set on the command', () => { assert.equal(r.code, 2) }) +test('blocks `git -C path add .` (subcommand after a global flag)', () => { + const r = runHook(`git -C ${tmpRepo} add .`, { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git add \./) +}) + +test('quoted "git add ." inside a message is NOT a broad add', () => { + // Regression: the parser distinguishes a real invocation from the + // same words sitting inside a quoted commit-message argument. + const r = runHook('git commit -m "stop using git add ."', { cwd: tmpRepo }) + assert.equal(r.code, 0) +}) + test('allows `git add path/to/file.ts`', () => { const r = runHook('git add src/foo.ts', { cwd: tmpRepo }) assert.equal(r.code, 0) @@ -255,7 +267,6 @@ test('git commit silent when index files match transcript git-add history', () = test('non-Bash tool_name is ignored', () => { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Edit', tool_input: { file_path: '/tmp/foo' }, @@ -266,7 +277,6 @@ test('non-Bash tool_name is ignored', () => { test('malformed payload is ignored (fail-open)', () => { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not-json', }) assert.equal(r.status, 0) diff --git a/.claude/hooks/path-guard/README.md b/.claude/hooks/path-guard/README.md index 72ef970..bec03c1 100644 --- a/.claude/hooks/path-guard/README.md +++ b/.claude/hooks/path-guard/README.md @@ -73,11 +73,10 @@ The hook recognizes Rule B traversals only when the next segment after `..` is a known fleet package name: `binflate`, `binject`, `binpress`, `bin-infra`, `build-infra`, -`codet5-models-builder`, `curl-builder`, `iocraft-builder`, -`ink-builder`, `libpq-builder`, `lief-builder`, `minilm-builder`, -`models`, `napi-go`, `node-smol-builder`, `onnxruntime-builder`, -`opentui-builder`, `stubs-builder`, `ultraviolet-builder`, -`yoga-layout-builder` +`codet5-models-builder`, `curl-builder`, `libpq-builder`, +`lief-builder`, `minilm-builder`, `models`, `napi-go`, +`node-smol-builder`, `onnxruntime-builder`, `opentui-builder`, +`stubs-builder`, `ultraviolet-builder`, `yoga-layout-builder` When a new package joins the workspace, add it to `KNOWN_SIBLING_PACKAGES` in `index.mts`. diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/path-guard/index.mts index 5e24aea..33f730d 100644 --- a/.claude/hooks/path-guard/index.mts +++ b/.claude/hooks/path-guard/index.mts @@ -62,10 +62,10 @@ import { } from './segments.mts' const EXEMPT_FILE_PATTERNS: RegExp[] = [ - /(^|\/)paths\.(cts|mts)$/, + /(?:^|\/)paths\.(?:cts|mts)$/, /scripts\/check-paths\.mts$/, /scripts\/check-paths\//, - /\.claude\/hooks\/path-guard\/index\.(cts|mts)$/, + /\.claude\/hooks\/path-guard\/index\.(?:cts|mts)$/, /\.claude\/hooks\/path-guard\/test\//, /scripts\/check-consistency\.mts$/, ] diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/path-guard/segments.mts index d680eb8..c4eb78e 100644 --- a/.claude/hooks/path-guard/segments.mts +++ b/.claude/hooks/path-guard/segments.mts @@ -54,8 +54,6 @@ export const KNOWN_SIBLING_PACKAGES = new Set([ 'codet5-models-builder', 'core', 'curl-builder', - 'ink-builder', - 'iocraft-builder', 'libpq-builder', 'lief-builder', 'minilm-builder', diff --git a/.claude/hooks/path-guard/test/path-guard.test.mts b/.claude/hooks/path-guard/test/path-guard.test.mts index 0da895b..12e35b9 100644 --- a/.claude/hooks/path-guard/test/path-guard.test.mts +++ b/.claude/hooks/path-guard/test/path-guard.test.mts @@ -5,7 +5,7 @@ // Run: pnpm --filter hook-path-guard test // (or directly: node --test test/*.test.mts) -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' @@ -30,7 +30,6 @@ const runHook = ( : { file_path: filePath, content: source }, }) const result = spawnSync(process.execPath, [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: payload, }) return { @@ -298,7 +297,6 @@ describe('path-guard — tool-name filter', () => { describe('path-guard — bug-tolerance (fails open)', () => { it('passes through invalid JSON payload', () => { const result = spawnSync(process.execPath, [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not json at all', }) assert.equal(result.status, 0) @@ -306,7 +304,6 @@ describe('path-guard — bug-tolerance (fails open)', () => { it('passes through empty stdin', () => { const result = spawnSync(process.execPath, [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '', }) assert.equal(result.status, 0) diff --git a/.claude/hooks/paths-mts-inherit-guard/index.mts b/.claude/hooks/paths-mts-inherit-guard/index.mts index 39a816c..f77746c 100644 --- a/.claude/hooks/paths-mts-inherit-guard/index.mts +++ b/.claude/hooks/paths-mts-inherit-guard/index.mts @@ -74,9 +74,9 @@ type ToolInput = { const BYPASS_PHRASE = 'Allow paths-mts-inherit bypass' const BYPASS_LOOKBACK_USER_TURNS = 8 -const PATHS_MTS_RE = /(^|\/)paths\.(?:cts|mts)$/ +const PATHS_MTS_RE = /(?:^|\/)paths\.(?:cts|mts)$/ const EXPORT_STAR_RE = - /^\s*export\s+\*\s+from\s+['"]([^'"]+\/paths\.m?ts)['"];?\s*$/m + /^\s*export\s+\*\s+from\s+['"](?:[^'"]+\/paths\.m?ts)['"];?\s*$/m /** * Walk up from `filePath` looking for an ancestor `scripts/paths.mts` or diff --git a/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts b/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts index a336d94..3d5bc3f 100644 --- a/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts +++ b/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts @@ -5,7 +5,7 @@ */ import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -22,7 +22,6 @@ interface RunResult { function runHook(payload: object): RunResult { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { @@ -170,7 +169,6 @@ describe('paths-mts-inherit-guard', () => { test('fails open on invalid JSON', () => { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not json', }) assert.equal(r.status, 0) @@ -178,7 +176,6 @@ describe('paths-mts-inherit-guard', () => { test('fails open on empty stdin', () => { const r = spawnSync('node', [HOOK], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '', }) assert.equal(r.status, 0) diff --git a/.claude/hooks/perfectionist-reminder/index.mts b/.claude/hooks/perfectionist-reminder/index.mts index 2c14ba5..135a461 100644 --- a/.claude/hooks/perfectionist-reminder/index.mts +++ b/.claude/hooks/perfectionist-reminder/index.mts @@ -36,29 +36,29 @@ await runStopReminder({ { label: 'option A (depth/correctness) … option B (speed/shipped)', regex: - /\boption\s+a\b[^.?!\n]{0,80}\b(correctness|depth|proper|thorough)\b[\s\S]{0,200}\boption\s+b\b[^.?!\n]{0,80}\b(breadth|fast|ship|speed)\b/i, + /\boption\s+a\b[^.?!\n]{0,80}\b(?:correctness|depth|proper|thorough)\b[\s\S]{0,200}\boption\s+b\b[^.?!\n]{0,80}\b(?:breadth|fast|ship|speed)\b/i, why: 'Speed-vs-depth choice menu. Per CLAUDE.md "Default to perfectionist when you have latitude" — pick depth and execute.', }, { label: 'maximally useful vs maximally shipped', regex: - /\bmaximally\s+(correct|thorough|useful)\b[\s\S]{0,80}\bmaximally\s+(fast|quick|shipped)\b/i, + /\bmaximally\s+(?:correct|thorough|useful)\b[\s\S]{0,80}\bmaximally\s+(?:fast|quick|shipped)\b/i, why: 'Same pattern — re-litigating perfectionist-vs-velocity. User already chose perfectionist.', }, { label: 'ship-it precision / ship-it-now', - regex: /\bship[- ]it[- ]?(fast|now|precision|version)\b/i, + regex: /\bship[- ]it[- ]?(?:fast|now|precision|version)\b/i, why: 'Velocity-framed; CLAUDE.md says perfectionist default. Use unless user explicitly time-boxed.', }, { label: 'depth over breadth / breadth over depth', - regex: /\b(depth\s+over\s+breadth|breadth\s+over\s+depth)\?/i, + regex: /\b(?:depth\s+over\s+breadth|breadth\s+over\s+depth)\?/i, why: 'The CLAUDE.md default is depth (perfectionist). Pick it.', }, { label: 'speed vs depth / fast vs right / now vs correct', regex: - /\b(fast|now|quick|speed)\s+vs\.?\s+(correct|depth|proper|right|thorough)\b/i, + /\b(?:fast|now|quick|speed)\s+vs\.?\s+(?:correct|depth|proper|right|thorough)\b/i, why: 'Same speed-vs-quality framing; perfectionist is the default unless user opted out.', }, { @@ -69,7 +69,7 @@ await runStopReminder({ { label: 'plow through vs do it right', regex: - /\bplow\s+(ahead|through)\b[\s\S]{0,80}\b(carefully|correctly|properly|right)\b/i, + /\bplow\s+(?:ahead|through)\b[\s\S]{0,80}\b(?:carefully|correctly|properly|right)\b/i, why: 'Same pattern (velocity vs care). Default perfectionist.', }, ], diff --git a/.claude/hooks/perfectionist-reminder/test/index.test.mts b/.claude/hooks/perfectionist-reminder/test/index.test.mts index e091d35..9b320fd 100644 --- a/.claude/hooks/perfectionist-reminder/test/index.test.mts +++ b/.claude/hooks/perfectionist-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -28,7 +28,6 @@ function makeTranscript(assistantText: string): { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -127,7 +126,6 @@ test('disabled env var short-circuits', () => { ) try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, SOCKET_PERFECTIONIST_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/plan-location-guard/test/index.test.mts b/.claude/hooks/plan-location-guard/test/index.test.mts index 0af717f..9c7c1e9 100644 --- a/.claude/hooks/plan-location-guard/test/index.test.mts +++ b/.claude/hooks/plan-location-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/plan-review-reminder/README.md b/.claude/hooks/plan-review-reminder/README.md index e400527..d93fb09 100644 --- a/.claude/hooks/plan-review-reminder/README.md +++ b/.claude/hooks/plan-review-reminder/README.md @@ -5,7 +5,7 @@ Stop hook that nudges when an assistant turn proposes a plan in prose without th ## What it catches - **Plan phrase without numbered list** — "Here's the plan:" / "My plan is" / "Steps:" / "Approach:" / "I will:" / "Step 1" followed by paragraph prose and no `1.` / `1)` line within 800 characters. -- **Fleet-shared edits without second-opinion invite** — when the plan mentions `CLAUDE.md` / `.claude/hooks/` / `_shared/` / `template/CLAUDE.md` / `sync-scaffolding` / `cascade-tooling` but does not invite a "second opinion" / "review the plan" / "sanity check" / "pair review" pass. +- **Fleet-shared edits without second-opinion invite** — when the plan mentions `CLAUDE.md` / `.claude/hooks/` / `_shared/` / `template/CLAUDE.md` / `sync-scaffolding` / `scripts/fleet` but does not invite a "second opinion" / "review the plan" / "sanity check" / "pair review" pass. ## Bypass diff --git a/.claude/hooks/plan-review-reminder/index.mts b/.claude/hooks/plan-review-reminder/index.mts index a7a8418..4e340d5 100644 --- a/.claude/hooks/plan-review-reminder/index.mts +++ b/.claude/hooks/plan-review-reminder/index.mts @@ -35,18 +35,18 @@ interface StopPayload { // Plan-announcement phrases. Each fires only if the announcement is // NOT followed (within a window of text) by a numbered list. const PLAN_PHRASE_RE = - /\b(here'?s the plan|my plan is|i will:|approach:|steps:|step 1)\b/i + /\b(?:here'?s the plan|my plan is|i will:|approach:|steps:|step 1)\b/i // Numbered-list shape: "1." or "1)" at line start. const NUMBERED_LIST_RE = /^\s*1\s*[.)]\s+\S/m // Fleet-shared resources whose edits should invite a second-opinion pass. const FLEET_SHARED_RE = - /\b(CLAUDE\.md|\.claude\/hooks\/|_shared\/|template\/CLAUDE\.md|sync-scaffolding|cascade-tooling)\b/ + /\b(?:CLAUDE\.md|\.claude\/hooks\/|_shared\/|template\/CLAUDE\.md|sync-scaffolding|scripts\/fleet)\b/ // Second-opinion-invitation phrases. const SECOND_OPINION_RE = - /\b(second[- ]opinion|review (the|this) plan|sanity[- ]check|pair[- ]review|invite a review)\b/i + /\b(?:second[- ]opinion|review (?:the|this) plan|sanity[- ]check|pair[- ]review|invite a review)\b/i async function main(): Promise { const payloadRaw = await readStdin() @@ -90,7 +90,7 @@ async function main(): Promise { // (which keeps the I'll context) and the stripped text. if ( PLAN_PHRASE_RE.test(text) || - /\b(I'?ll|I will|I'm going to)\b/i.test(rawText) + /\b(?:I'?ll|I will|I'm going to)\b/i.test(rawText) ) { hits.push( 'plan touches fleet-shared resources (CLAUDE.md / .claude/hooks/ / ' + diff --git a/.claude/hooks/plan-review-reminder/test/index.test.mts b/.claude/hooks/plan-review-reminder/test/index.test.mts index cbff9ca..f130b91 100644 --- a/.claude/hooks/plan-review-reminder/test/index.test.mts +++ b/.claude/hooks/plan-review-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -23,7 +23,6 @@ function makeTranscript(assistantText: string): string { function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -78,7 +77,6 @@ test('does NOT fire on plain non-plan prose', () => { test('disabled env var short-circuits', () => { const t = makeTranscript("Here's the plan: do stuff.") const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: t }), env: { ...process.env, SOCKET_PLAN_REVIEW_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/pointer-comment-guard/index.mts b/.claude/hooks/pointer-comment-guard/index.mts index dc104bc..cc9cefe 100644 --- a/.claude/hooks/pointer-comment-guard/index.mts +++ b/.claude/hooks/pointer-comment-guard/index.mts @@ -225,7 +225,7 @@ async function main(): Promise { process.exit(0) } // Skip tests — they often have illustrative pointer-only comments. - if (/(^|\/)test\//.test(filePath) || /\.test\.[jt]sx?$/.test(filePath)) { + if (/(?:^|\/)test\//.test(filePath) || /\.test\.[jt]sx?$/.test(filePath)) { process.exit(0) } if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { diff --git a/.claude/hooks/pointer-comment-guard/test/index.test.mts b/.claude/hooks/pointer-comment-guard/test/index.test.mts index d3743d0..e878109 100644 --- a/.claude/hooks/pointer-comment-guard/test/index.test.mts +++ b/.claude/hooks/pointer-comment-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -36,7 +36,6 @@ function runHook( payload['transcript_path'] = options.transcriptPath } const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), env: { ...process.env, ...(options.env ?? {}) }, }) @@ -196,7 +195,6 @@ test('handles block comments — pointer + claim in /* … */ passes', () => { test('does not crash on malformed payload', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not-json', }) assert.equal(result.status, 0) @@ -204,7 +202,6 @@ test('does not crash on malformed payload', () => { test('does not crash when content is missing', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '/repo/src/foo.ts' }, diff --git a/.claude/hooks/pr-vs-push-default-reminder/index.mts b/.claude/hooks/pr-vs-push-default-reminder/index.mts index 4abeee7..87cd24b 100644 --- a/.claude/hooks/pr-vs-push-default-reminder/index.mts +++ b/.claude/hooks/pr-vs-push-default-reminder/index.mts @@ -15,7 +15,7 @@ // Skipped when the branch isn't main/master (feature branches always // PR via the wheelhouse push-fallback policy). -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { readFileSync } from 'node:fs' import process from 'node:process' @@ -58,7 +58,9 @@ export function hasPrDirective(turns: string[]): boolean { const text = turns[i]! for (let i = 0, { length } = PR_DIRECTIVE_PATTERNS; i < length; i += 1) { const re = PR_DIRECTIVE_PATTERNS[i]! - if (re.test(text)) {return true} + if (re.test(text)) { + return true + } } } return false diff --git a/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts b/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts index faad65a..91a75f7 100644 --- a/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts +++ b/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts @@ -1,6 +1,9 @@ // node --test specs for the pr-vs-push-default-reminder hook. -import { spawn, spawnSync } from '@socketsecurity/lib-stable/spawn' +import { + spawn, + spawnSync, +} from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -36,6 +39,11 @@ function mkTranscript(userTurns: string[]): string { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/index.mts b/.claude/hooks/prefer-rebase-over-revert-guard/index.mts index 97090e6..072344d 100644 --- a/.claude/hooks/prefer-rebase-over-revert-guard/index.mts +++ b/.claude/hooks/prefer-rebase-over-revert-guard/index.mts @@ -30,10 +30,10 @@ // // Fails open on any internal error (exit 0 + stderr log). -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' -import { containsOutsideQuotes } from '../_shared/bash-quote-mask.mts' +import { commandsFor } from '../_shared/shell-command.mts' interface ToolInput { readonly tool_input?: { readonly command?: string | undefined } | undefined @@ -49,23 +49,26 @@ interface ToolInput { * git revert .. git revert --no-commit HEAD. */ export function extractRef(command: string): string | undefined { - const m = command.match( - /\bgit\s+revert\s+([^\s;&|`]+(?:\s+[^\s;&|`-][^\s;&|`]*)?)/, - ) - if (!m) { - return undefined - } - // The capture may include subsequent non-flag tokens for ranges - // like `..`. Take the first whitespace-delimited token - // that isn't a flag. - for (const tok of m[1]!.split(/\s+/)) { - if (!tok.startsWith('-') && tok.length > 0) { - return tok + for (const c of commandsFor(command, 'git')) { + const revertIdx = c.args.indexOf('revert') + if (revertIdx === -1) { + continue + } + // First non-flag token after `revert` is the target ref. + for (let i = revertIdx + 1, { length } = c.args; i < length; i += 1) { + const tok = c.args[i]! + if (!tok.startsWith('-') && tok.length > 0) { + return tok + } } } return undefined } +function isGitRevert(command: string): boolean { + return commandsFor(command, 'git').some(c => c.args.includes('revert')) +} + /** * Probe `git` for whether `ref` is reachable on `origin/`. If * the local branch has no upstream we can't tell, so return undefined (= "don't @@ -142,8 +145,9 @@ process.stdin.on('end', () => { process.exit(0) } - // Only fire on real `git revert` invocations (outside quotes). - if (!containsOutsideQuotes(command, /\bgit\s+revert\b/)) { + // Only fire on real `git revert` invocations (parser sees through + // chains / `$(…)`; a quoted "git revert" in a message is ignored). + if (!isGitRevert(command)) { process.exit(0) } diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts b/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts index 65266a1..7d26db0 100644 --- a/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts +++ b/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts @@ -11,7 +11,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' @@ -24,6 +24,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -65,6 +70,18 @@ test('commit message bodies mentioning git revert are skipped (quote-aware)', as assert.strictEqual(result.stderr, '') }) +test('git revert chained after another command is still detected', async () => { + // Parser sees through the `&&` chain — the old regex matched on the + // raw substring; the parser confirms a real `git revert` invocation. + const result = await runHook({ + tool_input: { command: 'cd /tmp && git revert this-ref-does-not-exist' }, + tool_name: 'Bash', + }) + // Bogus ref → defensive exit 0; the point is the hook didn't bail at + // the detection gate (it reached the ref-resolution probe). + assert.strictEqual(result.code, 0) +}) + test('git revert with --no-commit is skipped (advanced workflow)', async () => { const result = await runHook({ tool_input: { command: 'git revert --no-commit HEAD' }, diff --git a/.claude/hooks/private-name-guard/index.mts b/.claude/hooks/private-name-guard/index.mts index 03b11b8..2d8795f 100644 --- a/.claude/hooks/private-name-guard/index.mts +++ b/.claude/hooks/private-name-guard/index.mts @@ -38,10 +38,10 @@ type ToolInput = { const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ /\bgit\s+commit\b/, /\bgit\s+push\b/, - /\bgh\s+pr\s+(comment|create|edit|review)\b/, - /\bgh\s+issue\s+(comment|create|edit)\b/, - /\bgh\s+api\b[^|]*-X\s*(PATCH|POST|PUT)\b/i, - /\bgh\s+release\s+(create|edit)\b/, + /\bgh\s+pr\s+(?:comment|create|edit|review)\b/, + /\bgh\s+issue\s+(?:comment|create|edit)\b/, + /\bgh\s+api\b[^|]*-X\s*(?:PATCH|POST|PUT)\b/i, + /\bgh\s+release\s+(?:create|edit)\b/, ] export function isPublicSurface(command: string): boolean { diff --git a/.claude/hooks/private-name-guard/test/private-name-guard.test.mts b/.claude/hooks/private-name-guard/test/private-name-guard.test.mts index f092bac..e4c1854 100644 --- a/.claude/hooks/private-name-guard/test/private-name-guard.test.mts +++ b/.claude/hooks/private-name-guard/test/private-name-guard.test.mts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { test } from 'node:test' import { fileURLToPath } from 'node:url' @@ -24,6 +24,11 @@ function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { const child = spawn(process.execPath, [HOOK], { stdio: ['pipe', 'ignore', 'pipe'], }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/public-surface-reminder/index.mts b/.claude/hooks/public-surface-reminder/index.mts index 208bfbe..0856652 100644 --- a/.claude/hooks/public-surface-reminder/index.mts +++ b/.claude/hooks/public-surface-reminder/index.mts @@ -37,10 +37,10 @@ type ToolInput = { const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ /\bgit\s+commit\b/, /\bgit\s+push\b/, - /\bgh\s+pr\s+(comment|create|edit|review)\b/, - /\bgh\s+issue\s+(comment|create|edit)\b/, - /\bgh\s+api\b[^|]*-X\s*(PATCH|POST|PUT)\b/i, - /\bgh\s+release\s+(create|edit)\b/, + /\bgh\s+pr\s+(?:comment|create|edit|review)\b/, + /\bgh\s+issue\s+(?:comment|create|edit)\b/, + /\bgh\s+api\b[^|]*-X\s*(?:PATCH|POST|PUT)\b/i, + /\bgh\s+release\s+(?:create|edit)\b/, ] export function isPublicSurface(command: string): boolean { diff --git a/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts b/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts index e947183..8240de4 100644 --- a/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts +++ b/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { test } from 'node:test' import { fileURLToPath } from 'node:url' @@ -24,6 +24,11 @@ function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { const child = spawn(process.execPath, [HOOK], { stdio: ['pipe', 'ignore', 'pipe'], }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/pull-request-target-guard/test/index.test.mts b/.claude/hooks/pull-request-target-guard/test/index.test.mts index 424788b..9a908ac 100644 --- a/.claude/hooks/pull-request-target-guard/test/index.test.mts +++ b/.claude/hooks/pull-request-target-guard/test/index.test.mts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +16,11 @@ type Result = { code: number; stderr: string } async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/readme-fleet-shape-guard/README.md b/.claude/hooks/readme-fleet-shape-guard/README.md new file mode 100644 index 0000000..20f19c6 --- /dev/null +++ b/.claude/hooks/readme-fleet-shape-guard/README.md @@ -0,0 +1,36 @@ +# readme-fleet-shape-guard + +PreToolUse Edit/Write hook that blocks edits to the **root `README.md`** when the resulting content violates the canonical fleet skeleton. + +## Why + +Root READMEs across fleet repos drift in three predictable ways: (a) the canonical 5-section structure gets reordered or partially missing, (b) `socket-wheelhouse` (a private repo) leaks into prose or links, (c) commands invoke sibling-repo relative paths (`node ../socket-foo/scripts/...`) that outside readers can't follow. All three are public-facing failure modes. + +The fleet has matching surfaces at three layers: + +- **Lint-time** — `template/.config/markdownlint-rules/socket-{readme-required-sections, no-private-wheelhouse-leak, no-relative-sibling-script}.mjs`. +- **Sync-time** — `scripts/sync-scaffolding/checks/readme-skeleton-drift.mts` (report-only; no autofix because README content is contextual). +- **Edit-time** — this hook. Fires at the earliest surface, before the drift can be committed or pushed. + +## How + +On `Edit` / `MultiEdit` / `Write` whose `file_path` resolves to the repo-root `README.md`, the hook: + +1. Reconstructs the post-edit text (Write → `content`; Edit → splice `old_string` → `new_string` against the on-disk file). +2. Runs three checks: section list (5 required, in order); `socket-wheelhouse` mention (outside fenced code blocks); sibling-repo relative path patterns. +3. If any check fires AND the user hasn't typed the bypass phrase, exits 2 with a stderr explaining which rule was hit, the canonical fix, and the bypass instructions. + +Nested READMEs (`packages/*/README.md`, `docs/*/README.md`, etc.) are silently ignored — they're scoped docs with their own shape. + +## Bypass + +User types **`Allow readme-fleet-shape bypass`** verbatim in a recent message (within the last 8 user turns). Case-sensitive; paraphrases don't count. + +## Failing open + +The hook fails open on its own bugs (exit 0 + stderr log) so a buggy hook can't brick the session. The trade-off: a bug means the check silently doesn't apply for that edit. The sync-time check and the lint-time check still catch the drift later. + +## Related + +- `.claude/hooks/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern. +- `.claude/hooks/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification. diff --git a/.claude/hooks/readme-fleet-shape-guard/index.mts b/.claude/hooks/readme-fleet-shape-guard/index.mts new file mode 100644 index 0000000..dccc7c2 --- /dev/null +++ b/.claude/hooks/readme-fleet-shape-guard/index.mts @@ -0,0 +1,334 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — readme-fleet-shape-guard. +// +// Blocks Edit/Write of the root README.md when the resulting content +// violates the canonical fleet skeleton: +// +// (a) Missing or out-of-order canonical section. The 5 level-2 +// sections must appear in this order: +// Why this repo exists / Install / Usage / Development / License +// +// (b) Mentions `socket-wheelhouse` outside fenced code blocks. +// socket-wheelhouse is a private repo; the link 404s for outside +// readers. +// +// (c) Invokes a command against a sibling-repo relative path. +// `node ../socket-foo/scripts/...` and similar shapes assume the +// reader has the sibling repo checked out at exactly the right +// relative level — almost never true for an outside user. +// +// Only fires on the REPO-ROOT README.md (basename === 'README.md' AND +// directory is repo root). Nested READMEs (packages/, docs/, .claude/, +// etc.) are scoped docs with their own shape; this hook is silent for +// them. +// +// Bypass phrase: `Allow readme-fleet-shape bypass`. Reading recent user +// turns follows the same pattern as no-revert-guard, plan-location-guard. +// +// Companion to: +// - scripts/sync-scaffolding/checks/readme-skeleton-drift.mts +// (sync-time check, no autofix) +// - template/.config/markdownlint-rules/socket-{readme-required-sections, +// no-private-wheelhouse-leak, no-relative-sibling-script}.mjs +// (lint-time check) +// +// This hook is the edit-time enforcement — it fires when the README is +// being written, catching the failure mode at its earliest surface. +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Edit" | "MultiEdit" | "Write", +// "tool_input": { "file_path": "...", +// "content"?: "...", +// "new_string"?: "...", +// "old_string"?: "..." }, +// "transcript_path": "/.../session.jsonl" } +// +// Exits: +// 0 — allowed. +// 2 — blocked (with stderr message that explains rule + fix + bypass). +// 0 (with stderr log) — fail-open on hook bugs. + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +type ToolInput = { + tool_input?: + | { + content?: string | undefined + file_path?: string | undefined + new_string?: string | undefined + old_string?: string | undefined + } + | undefined + tool_name?: string | undefined + transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow readme-fleet-shape bypass' +const BYPASS_LOOKBACK_USER_TURNS = 8 + +const REQUIRED_SECTIONS = [ + 'Why this repo exists', + 'Install', + 'Usage', + 'Development', + 'License', +] as const + +const WHEELHOUSE_LEAK_RE = /socket-wheelhouse/i +const SIBLING_PATH_RES: readonly RegExp[] = [ + /\b(?:bun|deno|node|npm|pnpm|yarn)\s+\.\.\/[\w@-]+\//, + // socket-hook: allow regex-alternation-order + /(?:^|\s)\.\.\/socket-[\w-]+\//i, + // socket-hook: allow regex-alternation-order + /(?:^|\s)\.\.\/sdxgen\//, + // socket-hook: allow regex-alternation-order + /(?:^|\s)\.\.\/stuie\//, +] + +/** + * Repo-root README detection. The hook only fires on the root README.md, not + * nested READMEs. The check is path-shape only — basename match + parent + * directory ≠ another README's parent. + */ +export function isRootReadme(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/') + if (path.basename(normalized) !== 'README.md') { + return false + } + const dir = path.dirname(normalized) + // Nested-README markers: any path segment that says "this is a + // scoped doc, not the repo root." + const segments = dir.split('/').filter(Boolean) + const SCOPED_PARENTS = new Set([ + '.claude', + 'apps', + 'crates', + 'docs', + 'examples', + 'packages', + 'pkg-node', + 'scripts', + 'template', + 'test', + 'tools', + ]) + for (const seg of segments) { + if (SCOPED_PARENTS.has(seg)) { + return false + } + } + return true +} + +/** + * Compute the post-edit text for an Edit (splice old_string → new_string + * against the on-disk file) or a Write (just `content`). Returns undefined when + * the post-edit text can't be reliably computed (Edit against a file that + * doesn't exist, or old_string not found). + */ +export function computePostEditText( + toolName: string, + filePath: string, + newString: string | undefined, + oldString: string | undefined, + content: string | undefined, +): string | undefined { + if (toolName === 'Write') { + return content + } + if (toolName === 'Edit' || toolName === 'MultiEdit') { + if (!existsSync(filePath)) { + // Edit against a non-existent file is unusual; let it through. + return undefined + } + let onDisk: string + try { + onDisk = readFileSync(filePath, 'utf8') + } catch { + return undefined + } + if (oldString === undefined || newString === undefined) { + return undefined + } + const idx = onDisk.indexOf(oldString) + if (idx === -1) { + return undefined + } + return ( + onDisk.slice(0, idx) + newString + onDisk.slice(idx + oldString.length) + ) + } + return undefined +} + +interface ShapeFinding { + kind: 'missing-section' | 'wheelhouse-leak' | 'relative-sibling' + detail: string +} + +export function findShapeViolations(text: string): ShapeFinding[] { + const lines = text.split('\n') + const findings: ShapeFinding[] = [] + + const headings: string[] = [] + for (let i = 0, { length } = lines; i < length; i += 1) { + const m = /^##\s+(.+?)\s*$/.exec(lines[i] ?? '') + if (m && m[1]) { + headings.push(m[1]) + } + } + let cursor = 0 + for (let r = 0, { length } = REQUIRED_SECTIONS; r < length; r += 1) { + const want = REQUIRED_SECTIONS[r] + let found = -1 + for (let h = cursor; h < headings.length; h += 1) { + if (headings[h] === want) { + found = h + break + } + } + if (found === -1) { + findings.push({ + kind: 'missing-section', + detail: `Missing canonical section "## ${want}" (or out of order)`, + }) + break + } + cursor = found + 1 + } + + let inFence = false + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i] ?? '' + if (/^\s*(?:```|~~~)/.test(line)) { + inFence = !inFence + continue + } + if (inFence) { + continue + } + if (WHEELHOUSE_LEAK_RE.test(line)) { + findings.push({ + kind: 'wheelhouse-leak', + detail: `Line ${i + 1} mentions socket-wheelhouse: ${line.trim().slice(0, 120)}`, + }) + break + } + } + + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i] ?? '' + let matched = false + for (let j = 0, jl = SIBLING_PATH_RES.length; j < jl; j += 1) { + if (SIBLING_PATH_RES[j]!.test(line)) { + matched = true + break + } + } + if (matched) { + findings.push({ + kind: 'relative-sibling', + detail: `Line ${i + 1} invokes a sibling-relative path: ${line.trim().slice(0, 120)}`, + }) + break + } + } + + return findings +} + +async function main(): Promise { + const raw = await readStdin() + if (!raw.trim()) { + return 0 + } + + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.stderr.write( + 'readme-fleet-shape-guard: failed to parse stdin payload — fail-open\n', + ) + return 0 + } + + const tool = payload.tool_name + if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { + return 0 + } + + const filePath = payload.tool_input?.file_path + if (!filePath || !isRootReadme(filePath)) { + return 0 + } + + const postEdit = computePostEditText( + tool, + filePath, + payload.tool_input?.new_string, + payload.tool_input?.old_string, + payload.tool_input?.content, + ) + if (postEdit === undefined) { + return 0 + } + + const findings = findShapeViolations(postEdit) + if (findings.length === 0) { + return 0 + } + + if ( + bypassPhrasePresent( + payload.transcript_path, + BYPASS_PHRASE, + BYPASS_LOOKBACK_USER_TURNS, + ) + ) { + return 0 + } + + const lines: string[] = [ + `🚨 readme-fleet-shape-guard: blocked Edit/Write of root README.md.`, + ``, + `File: ${filePath}`, + ``, + `Violations:`, + ] + for (let i = 0, { length } = findings; i < length; i += 1) { + lines.push(` - ${findings[i]!.detail}`) + } + lines.push(``) + lines.push( + `Per the fleet "Canonical README" rule (CLAUDE.md → Canonical README),`, + ) + lines.push(`root README.md must follow the skeleton at:`) + lines.push(` socket-wheelhouse/template/README.md`) + lines.push(``) + lines.push(`Required sections in order:`) + for (let i = 0, { length } = REQUIRED_SECTIONS; i < length; i += 1) { + lines.push(` ${i + 1}. ## ${REQUIRED_SECTIONS[i]}`) + } + lines.push(``) + lines.push( + `One-shot bypass (rare): user types "${BYPASS_PHRASE}" verbatim in a recent message.`, + ) + lines.push(``) + process.stderr.write(`${lines.join('\n')}`) + return 2 +} + +main().then( + code => process.exit(code), + err => { + process.stderr.write( + `readme-fleet-shape-guard: hook error — fail-open: ${String(err)}\n`, + ) + process.exit(0) + }, +) diff --git a/.claude/hooks/readme-fleet-shape-guard/package.json b/.claude/hooks/readme-fleet-shape-guard/package.json new file mode 100644 index 0000000..5aa4206 --- /dev/null +++ b/.claude/hooks/readme-fleet-shape-guard/package.json @@ -0,0 +1,18 @@ +{ + "name": "hook-readme-fleet-shape-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "dependencies": { + "@socketsecurity/lib-stable": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts b/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts new file mode 100644 index 0000000..76ab7c1 --- /dev/null +++ b/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts @@ -0,0 +1,139 @@ +// node --test specs for the readme-fleet-shape-guard hook. + +import test from 'node:test' +import assert from 'node:assert/strict' +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +const CANONICAL_README = [ + '# foo', + '', + '## Why this repo exists', + '', + 'A thing.', + '', + '## Install', + '', + '```sh', + 'npm install foo', + '```', + '', + '## Usage', + '', + '```js', + 'const foo = require("foo")', + '```', + '', + '## Development', + '', + 'pnpm install', + '', + '## License', + '', + 'MIT', + '', +].join('\n') + +test('non-Edit/Write tool calls pass through', async () => { + const result = await runHook({ + tool_input: { command: 'ls' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('nested README is ignored', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/packages/bar/README.md', + content: '# bar\n\nNo canonical sections at all.\n', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 0) +}) + +test('canonical root README passes', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/README.md', + content: CANONICAL_README, + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 0) +}) + +test('missing canonical section is blocked', async () => { + const broken = CANONICAL_README.replace('## Install', '## Setup') + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/README.md', + content: broken, + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /readme-fleet-shape-guard/) + assert.match(result.stderr, /Missing canonical section "## Install"/) +}) + +test('socket-wheelhouse mention is blocked', async () => { + const leaky = CANONICAL_README.replace( + 'A thing.', + 'A thing. See socket-wheelhouse for details.', + ) + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/README.md', + content: leaky, + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /socket-wheelhouse/) +}) + +test('relative sibling script is blocked', async () => { + const sibling = CANONICAL_README.replace( + 'pnpm install', + 'node ../socket-bar/scripts/foo.mts', + ) + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/README.md', + content: sibling, + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /sibling-relative path/) +}) diff --git a/.claude/hooks/readme-fleet-shape-guard/tsconfig.json b/.claude/hooks/readme-fleet-shape-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/readme-fleet-shape-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/release-workflow-guard/index.mts b/.claude/hooks/release-workflow-guard/index.mts index 8c16133..afa7465 100644 --- a/.claude/hooks/release-workflow-guard/index.mts +++ b/.claude/hooks/release-workflow-guard/index.mts @@ -75,7 +75,7 @@ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import process from 'node:process' -import { buildQuoteMask } from '../_shared/bash-quote-mask.mts' +import { commandsFor, parseCommands } from '../_shared/shell-command.mts' import { bypassPhraseRemaining } from '../_shared/transcript.mts' type ToolInput = { @@ -216,16 +216,26 @@ export function countPriorDispatches( return count } -// `gh workflow run ` / `gh workflow dispatch `. -// The captured workflow argument is reported back so the user can -// see what was blocked. -const GH_WORKFLOW_DISPATCH_RE = - /\bgh\s+workflow\s+(?:dispatch|run)\b(?:\s+(?:--field|--ref|--repo|-f)\s+\S+)*\s+(['"]?)([^\s'"]+)\1/g - -// `gh api .../actions/workflows//dispatches` (POST/PUT). -// The path component implies dispatch — no need to also match -X. -const GH_API_WORKFLOW_DISPATCH_RE = - /\bgh\s+api\b[^|]*?\/actions\/workflows\/([^/\s]+)\/dispatches\b/g +// Flags on `gh workflow run/dispatch` that take a value argument — so +// the value isn't mistaken for the workflow target. `gh workflow run +// publish.yml -f dry-run=true --ref main` → target is `publish.yml`. +const GH_WORKFLOW_VALUE_FLAGS = new Set([ + '--field', + '-f', + '-F', + '--raw-field', + '--ref', + '-r', + '--repo', + '-R', + '--json', +]) + +// `gh api` path that names a workflow dispatch endpoint: +// `.../actions/workflows//dispatches`. The path component implies +// dispatch — no need to also inspect -X. +const GH_API_DISPATCH_PATH_RE = + /\/actions\/workflows\/([^/\s]+)\/dispatches\b/ // Dry-run input detection. The fleet standardized on `dry-run` // (kebab-case) — see socket-registry's shared actions and every @@ -531,62 +541,108 @@ export function isGhReleaseOnly( return classifyWorkflow(workflow, resolveSearchRoots(command)) === 'gh' } -export function detectDispatch(command: string): DispatchResult { - // We can't `replace(/\s+/g, ' ')` first because that would offset - // the quote mask from the original string. Match against the raw - // command and use the mask to filter false-positives. - const mask = buildQuoteMask(command) +// Pull the workflow target token out of a parsed `gh workflow +// run/dispatch` arg list. Skips the `workflow` + `run`/`dispatch` +// subcommand words and any value-taking flag + its value; the first +// remaining bare positional is the target (`publish.yml`, `publish`, +// or a numeric id). +function extractWorkflowTarget(args: readonly string[]): string | undefined { + // Locate the run/dispatch subcommand index after the `workflow` word. + const wfIdx = args.indexOf('workflow') + if (wfIdx === -1) { + return undefined + } + let i = wfIdx + 1 + // The subcommand may be `run` or `dispatch`; skip exactly one. + if (args[i] === 'run' || args[i] === 'dispatch') { + i += 1 + } else { + return undefined + } + for (let { length } = args; i < length; i += 1) { + const arg = args[i]! + // `--flag=value` form consumes its own value. + if (arg.startsWith('--') && arg.includes('=')) { + continue + } + if (GH_WORKFLOW_VALUE_FLAGS.has(arg)) { + // Skip the flag's value token too. + i += 1 + continue + } + if (arg.startsWith('-')) { + // A bare flag with no value (rare here) — skip just the flag. + continue + } + return arg + } + return undefined +} - // The /g-flag regex is a module-scoped singleton; `.exec()` advances - // `lastIndex` and only resets when it returns null at end-of-input. - // If our previous call broke out of the loop early (because we found - // a quote-masked match), `lastIndex` is left mid-string and the next - // `detectDispatch` call would resume from there instead of scanning - // the whole command. Reset before each scan to make the regex - // stateless from the caller's perspective. - GH_WORKFLOW_DISPATCH_RE.lastIndex = 0 - let cliMatch: RegExpExecArray | null - while ((cliMatch = GH_WORKFLOW_DISPATCH_RE.exec(command))) { - if (!mask[cliMatch.index]) { - const workflow = cliMatch[2] - if (isVerifiableDryRun(command, workflow)) { - return { - allowedReason: - 'verifiable dry-run (-f dry-run=true + workflow declares dry-run input)', - blocked: false, - shape: 'gh workflow run/dispatch', - workflow, +export function detectDispatch(command: string): DispatchResult { + // Parser-based: each real `gh` invocation is inspected on its own + // args, so a quoted "gh workflow run" in a message body or a sibling + // command's string can't false-trigger, and `$(…)` / chains are seen + // through. No module-scoped /g-regex `lastIndex` state to manage. + // + // Obfuscation guard: when `gh` is produced by a command substitution + // (`$(echo gh) workflow run …`), shell-quote strands `workflow` as + // the command's binary. Treat that shape as a dispatch too — a + // security guard should block-the-default on an obfuscated `gh` + // rather than wave it through. + const ghCommands = commandsFor(command, 'gh') + const obfuscatedWorkflowCommands = parseCommands(command).filter( + c => + c.binary === 'workflow' && + (c.args[0] === 'run' || c.args[0] === 'dispatch'), + ) + for (const c of [...ghCommands, ...obfuscatedWorkflowCommands]) { + // Normalize: gh commands carry `workflow` in args; the obfuscated + // shape carries it as the binary with run/dispatch in args[0]. Build + // a uniform arg list that always starts at `workflow`. + const wfArgs = + c.binary === 'workflow' ? ['workflow', ...c.args] : c.args + if (wfArgs.includes('workflow')) { + const workflow = extractWorkflowTarget(wfArgs) + if (workflow) { + if (isVerifiableDryRun(command, workflow)) { + return { + allowedReason: + 'verifiable dry-run (-f dry-run=true + workflow declares dry-run input)', + blocked: false, + shape: 'gh workflow run/dispatch', + workflow, + } + } + if (isGhReleaseOnly(command, workflow)) { + return { + allowedReason: + 'GitHub-release-only workflow (no npm publish; reversible via `gh release delete --cleanup-tag`)', + blocked: false, + shape: 'gh workflow run/dispatch', + workflow, + } } - } - if (isGhReleaseOnly(command, workflow)) { return { - allowedReason: - 'GitHub-release-only workflow (no npm publish; reversible via `gh release delete --cleanup-tag`)', - blocked: false, + blocked: true, shape: 'gh workflow run/dispatch', workflow, } } - return { - blocked: true, - shape: 'gh workflow run/dispatch', - workflow, - } } - } - - // Same /g-flag reset rationale as above — keep the regex stateless - // across calls. The dry-run bypass intentionally doesn't apply to - // `gh api .../dispatches` — that path takes inputs as a JSON body, - // which is harder to verify safely; route those through the user. - GH_API_WORKFLOW_DISPATCH_RE.lastIndex = 0 - let apiMatch: RegExpExecArray | null - while ((apiMatch = GH_API_WORKFLOW_DISPATCH_RE.exec(command))) { - if (!mask[apiMatch.index]) { - return { - blocked: true, - shape: 'gh api .../dispatches', - workflow: apiMatch[1], + // `gh api .../actions/workflows//dispatches`. The dry-run + // bypass intentionally doesn't apply — that path takes inputs as a + // JSON body, harder to verify; route those through the user. + if (c.args.includes('api')) { + for (let i = 0, { length } = c.args; i < length; i += 1) { + const m = GH_API_DISPATCH_PATH_RE.exec(c.args[i]!) + if (m) { + return { + blocked: true, + shape: 'gh api .../dispatches', + workflow: m[1], + } + } } } } diff --git a/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts b/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts index f2a319a..e9b25f6 100644 --- a/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts +++ b/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts @@ -15,8 +15,9 @@ import process, { execPath } from 'node:process' import { afterEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import { safeDelete } from '@socketsecurity/lib-stable/fs' -import { isSpawnError, spawn } from '@socketsecurity/lib-stable/spawn' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' +import { isSpawnError } from '@socketsecurity/lib-stable/process/spawn/errors' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' const hookScript = new URL('../index.mts', import.meta.url).pathname @@ -176,6 +177,26 @@ describe('release-workflow-guard hook', () => { const r = await runHook('git fetch && gh workflow run release.yml') assert.equal(r.code, 2) }) + + it('gh workflow run with value flags BEFORE the target', async () => { + // Parser skips each value-taking flag + its value, so the target + // is found wherever it sits in the arg list. + const r = await runHook( + 'gh workflow run --ref main -f mode=prod release.yml', + ) + assert.equal(r.code, 2) + assert.match(r.stderr, /release\.yml/) + }) + + it('blocks an obfuscated `$(echo gh) workflow run` dispatch', async () => { + // shell-quote strands `workflow` as the binary when `gh` is + // produced by a substitution. The guard treats that shape as a + // dispatch too — a security guard must block-the-default on an + // obfuscated `gh`, not wave it through. + const r = await runHook('$(echo gh) workflow run release.yml') + assert.equal(r.code, 2) + assert.match(r.stderr, /release\.yml/) + }) }) describe('allows benign commands', () => { @@ -704,7 +725,9 @@ describe('release-workflow-guard hook', () => { // Create a sibling project named "socket-other" alongside the // primary fixture; place a stubs.yml in the sibling. The hook // must read the sibling, not the primary. - const projectsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'rwg-roots-')) + const projectsRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'rwg-roots-'), + ) const primaryDir = path.join(projectsRoot, 'socket-btm') const siblingDir = path.join(projectsRoot, 'socket-other') await fs.mkdir(path.join(primaryDir, '.github', 'workflows'), { diff --git a/.claude/hooks/scan-label-in-commit-guard/README.md b/.claude/hooks/scan-label-in-commit-guard/README.md new file mode 100644 index 0000000..0af75b6 --- /dev/null +++ b/.claude/hooks/scan-label-in-commit-guard/README.md @@ -0,0 +1,53 @@ +# scan-label-in-commit-guard + +`PreToolUse(Bash)` blocker that refuses `git commit` invocations +whose message body contains scan-report-internal labels (`B1`, `M9`, +`H3`, `L4`). + +## Why + +`/scanning-quality` and `/scanning-security` assign scratch-pad IDs +like `B5` ("Blocker #5") or `M9` ("Medium #9") to findings inside a +review session. The label has meaning **only within the report** — +a future reader of `git log` doesn't have the report and cannot +decode "fix B5" or "addresses M9". + +The right shape inlines the actual finding text: + +``` +✗ fix(http-request): B5 download truncation race +✓ fix(http-request/download): settle on fileStream finish, not res end +``` + +## Detection + +Case-sensitive `\b[BMHL]\d+\b` as a standalone word. The hook +extracts the message body from: + +- `git commit -m ""` (single or repeated `-m`) +- `git commit --message=` / `--message ` +- `git commit -F ` / `--file=` / `--file ` + +`git commit` without `-m`/`-F` opens the editor — those messages are +reviewed by the operator, so the hook doesn't fire. + +Fenced code blocks (` ``` `) are stripped before scanning so +labels inside log output / quoted fixtures don't trigger the rule. + +## What's not flagged + +- Lowercase: `b1`, `m9` are not report labels +- 5+ digit IDs: `B12345` is too long to be a report label +- `GHSA-B1-xyz`-style identifiers (label is part of a larger token) +- Anything inside ` ``` ` fences + +## Bypass + +Type the canonical phrase verbatim in your next user turn: + +``` +Allow scan-label-in-commit bypass +``` + +Use when the label is genuinely meaningful in the message (e.g. citing +a real internal advisory ID that happens to match the shape). diff --git a/.claude/hooks/scan-label-in-commit-guard/index.mts b/.claude/hooks/scan-label-in-commit-guard/index.mts new file mode 100644 index 0000000..e8e7a22 --- /dev/null +++ b/.claude/hooks/scan-label-in-commit-guard/index.mts @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — scan-label-in-commit-guard. +// +// Blocks `git commit` invocations whose message body contains +// scan-report-internal labels (B1, B2, …, M3, H5, L7). These are +// the scratch-pad IDs the `/scanning-quality` and `/scanning-security` +// skills assign to findings inside a single review session. They have +// no meaning outside that session — a future reader of `git log` who +// doesn't have the original report can't decode "fix B5" or +// "addresses M9". +// +// The right shape is to inline the actual finding text: +// +// ✗ fix(http-request): B5 download truncation race +// ✓ fix(http-request/download): settle on fileStream finish, not res end +// +// Detection — the message is sourced from one of: +// - `git commit -m ""` (single -m or repeated) +// - `git commit --message=` +// - `git commit -F ` / `git commit --file=` — read file +// +// Pattern: case-sensitive `\b[BMHL]\d+\b` as a standalone word. +// - B1, M9, H3, L4 → flag +// - 'B' alone, 'B12345' (5+ digits = likely a real ID), 'GHSA-…' → don't flag +// - Inside fenced code blocks (``` … ```) → don't flag (the operator +// is quoting test output / SQL / etc.) +// +// Bypass: type "Allow scan-label-in-commit bypass" in a recent user +// message. Use when the label is genuinely meaningful (e.g. citing a +// specific advisory ID that happens to match the shape). +// +// Exit codes: +// 0 — pass. +// 2 — block. +// +// Fails open on malformed payloads (exit 0 + stderr log). + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { commandsFor } from '../_shared/shell-command.mts' +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly command?: string | undefined + } + | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined + readonly cwd?: string | undefined +} + +interface Hit { + readonly label: string + readonly line: number + readonly snippet: string +} + +const BYPASS_PHRASE = 'Allow scan-label-in-commit bypass' + +// Match standalone scan-report-internal IDs: B/M/H/L (Blocker / +// Medium / High / Low) followed by 1–4 digits. The lookbehind / +// lookahead pair excludes `B12345` (5+ digits) and `GHSA-B1-…` / +// `branch-B12` shapes where a hyphen sits next to the label. +// Case-sensitive — lowercase `b1` is not a report label. +const LABEL_RE = /(?() + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + let m: RegExpExecArray | null + LABEL_RE.lastIndex = 0 + while ((m = LABEL_RE.exec(line)) !== null) { + const label = m[0] + const key = `${i}:${label}` + if (seen.has(key)) { + continue + } + seen.add(key) + hits.push({ + label, + line: i + 1, + snippet: line.length > 80 ? line.slice(0, 77) + '…' : line, + }) + } + } + return hits +} + +/** + * Pull the commit message from a `git commit …` command line. Returns the + * message text or `undefined` if the command doesn't carry an inline message + * (e.g. uses `-e` to open the editor — those messages are reviewed by the + * operator, no need to flag). + * + * Handles `-m "msg"`, `-m msg`, `--message=msg`, `--message msg`, `-F file`, + * `--file=file`. For file-form invocations, reads the file relative to `cwd`. + */ +export function extractCommitMessage( + command: string, + cwd: string, +): string | undefined { + // Inspect each real `git commit` invocation. The parser strips quotes + // and scopes args to the command that owns them, so a `-m` inside a + // sibling command or a quoted body can't leak in. + for (const c of commandsFor(command, 'git')) { + if (!c.args.includes('commit')) { + continue + } + const { args } = c + // Collect every inline message: `-m `, `--message `, + // `--message=` (repeated -m forms join with a blank line, the + // same way git concatenates multiple -m paragraphs). + const messages: string[] = [] + let fileArg: string | undefined + for (let i = 0, { length } = args; i < length; i += 1) { + const arg = args[i]! + if (arg === '-m' || arg === '--message') { + const next = args[i + 1] + if (next !== undefined) { + messages.push(next) + i += 1 + } + continue + } + if (arg.startsWith('--message=')) { + messages.push(arg.slice('--message='.length)) + continue + } + if (arg === '-F' || arg === '--file') { + const next = args[i + 1] + if (next !== undefined) { + fileArg = next + i += 1 + } + continue + } + if (arg.startsWith('--file=')) { + fileArg = arg.slice('--file='.length) + continue + } + } + if (messages.length > 0) { + return messages.join('\n\n') + } + if (fileArg !== undefined) { + const filePath = path.isAbsolute(fileArg) + ? fileArg + : path.join(cwd, fileArg) + if (existsSync(filePath)) { + try { + return readFileSync(filePath, 'utf8') + } catch { + return undefined + } + } + } + } + return undefined +} + +function handlePayload(payloadRaw: string): number { + let payload: ToolInput + try { + payload = JSON.parse(payloadRaw) as ToolInput + } catch { + return 0 + } + if (payload.tool_name !== 'Bash') { + return 0 + } + const command = payload.tool_input?.command ?? '' + if (!command) { + return 0 + } + const cwd = payload.cwd ?? process.cwd() + const body = extractCommitMessage(command, cwd) + if (!body) { + return 0 + } + const hits = findScanLabels(body) + if (hits.length === 0) { + return 0 + } + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + return 0 + } + const lines: string[] = [] + lines.push( + '[scan-label-in-commit-guard] Blocked: scan-report-internal label in commit message.', + ) + lines.push('') + for (let i = 0, { length } = hits; i < length; i += 1) { + const h = hits[i]! + lines.push(` Line ${h.line}: ${h.label} — "${h.snippet}"`) + } + lines.push('') + lines.push(' Labels like B1 / M9 / H3 / L4 come from /scanning-quality and') + lines.push(' /scanning-security reports. They are scratch-pad IDs that mean') + lines.push(' nothing outside the original session — a future reader of') + lines.push(' `git log` who does not have the report cannot decode them.') + lines.push('') + lines.push(' Rewrite the message to inline the actual finding text:') + lines.push(' ✗ fix(http-request): B5 download truncation race') + lines.push( + ' ✓ fix(http-request/download): settle on fileStream finish, not res end', + ) + lines.push('') + lines.push(' Bypass (e.g. citing a real advisory ID that happens to match):') + lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) + process.stderr.write(lines.join('\n') + '\n') + return 2 +} + +export { handlePayload } + +// CLI entrypoint — only fires when this file is the main module. Tests +// import `findScanLabels` / `extractCommitMessage` directly without +// triggering the stdin reader (which would never see an `end` event +// in test env and hang the process). +if (process.argv[1] && process.argv[1].endsWith('index.mts')) { + let payloadRaw = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + payloadRaw += chunk + }) + process.stdin.on('end', () => { + try { + process.exit(handlePayload(payloadRaw)) + } catch (e) { + process.stderr.write( + `[scan-label-in-commit-guard] hook error (allowing): ${e}\n`, + ) + process.exit(0) + } + }) +} diff --git a/.claude/hooks/scan-label-in-commit-guard/package.json b/.claude/hooks/scan-label-in-commit-guard/package.json new file mode 100644 index 0000000..bdf2b33 --- /dev/null +++ b/.claude/hooks/scan-label-in-commit-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-scan-label-in-commit-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts b/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts new file mode 100644 index 0000000..ffe1010 --- /dev/null +++ b/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts @@ -0,0 +1,177 @@ +/** + * @file Unit tests for findScanLabels + extractCommitMessage. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { extractCommitMessage, findScanLabels } from '../index.mts' + +// ── findScanLabels ── + +test('flags single B-label in prose', () => { + const hits = findScanLabels('fix(http): B5 download truncation race') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.label, 'B5') +}) + +test('flags multiple labels across lines', () => { + const body = `fix(security): land B1 + M9 fixes + +Also addresses H3 (rc file mode).` + const hits = findScanLabels(body) + assert.equal(hits.length, 3) + const labels = hits.map(h => h.label).toSorted() + assert.deepEqual(labels, ['B1', 'H3', 'M9']) +}) + +test('does not flag lowercase', () => { + const hits = findScanLabels('fix b1 bug') + assert.equal(hits.length, 0) +}) + +test('does not flag 5+ digit IDs', () => { + const hits = findScanLabels('Refs B12345 (a real internal ID)') + assert.equal(hits.length, 0) +}) + +test('does not flag GHSA-style identifiers', () => { + const hits = findScanLabels('Bump for GHSA-B1-xyz advisory') + assert.equal(hits.length, 0) +}) + +test('does not flag inside fenced code block', () => { + const body = `chore: pin pnpm + +Output for reference: +\`\`\` +B1 = expected +M9 = expected +\`\`\` + +No real labels here.` + const hits = findScanLabels(body) + assert.equal(hits.length, 0) +}) + +test('flags label before fenced block', () => { + const body = `fix B5 issue + +\`\`\` +log content +\`\`\`` + const hits = findScanLabels(body) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.label, 'B5') +}) + +test('flags label after fenced block', () => { + const body = `\`\`\` +output +\`\`\` + +Closes M3.` + const hits = findScanLabels(body) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.label, 'M3') +}) + +test('deduplicates same label same line', () => { + // Same label twice on one line dedups to a single hit (the dedup key + // is `${line}:${label}` so the operator gets one entry per offending + // line, not one per character offset). + const hits = findScanLabels('fix B1 and B1 again') + assert.equal(hits.length, 1) +}) + +// ── extractCommitMessage ── + +test('extracts -m "msg"', () => { + const msg = extractCommitMessage('git commit -m "fix B5 issue"', '/tmp') + assert.equal(msg, 'fix B5 issue') +}) + +test("extracts -m 'msg' (single quotes)", () => { + const msg = extractCommitMessage("git commit -m 'fix M9 issue'", '/tmp') + assert.equal(msg, 'fix M9 issue') +}) + +test('extracts --message=msg', () => { + const msg = extractCommitMessage( + 'git commit --message="addresses H3"', + '/tmp', + ) + assert.equal(msg, 'addresses H3') +}) + +test('returns undefined for non-commit command', () => { + assert.equal(extractCommitMessage('git push origin main', '/tmp'), undefined) + assert.equal(extractCommitMessage('ls -la', '/tmp'), undefined) +}) + +test('returns undefined for `git commit` with no -m/-F (editor mode)', () => { + assert.equal(extractCommitMessage('git commit', '/tmp'), undefined) + assert.equal(extractCommitMessage('git commit --amend', '/tmp'), undefined) +}) + +test('extracts -F file content', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-msg-test-')) + try { + const file = path.join(dir, 'msg.txt') + writeFileSync(file, 'fix(http): B5 + M9 issues') + const msg = extractCommitMessage(`git commit -F ${file}`, dir) + assert.equal(msg, 'fix(http): B5 + M9 issues') + } finally { + rmSync(dir, { force: true, recursive: true }) + } +}) + +test('extracts --file= file content', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-msg-test-')) + try { + const file = path.join(dir, 'msg.txt') + writeFileSync(file, 'fix L7') + const msg = extractCommitMessage(`git commit --file=${file}`, dir) + assert.equal(msg, 'fix L7') + } finally { + rmSync(dir, { force: true, recursive: true }) + } +}) + +test('returns undefined if -F file does not exist', () => { + const msg = extractCommitMessage( + 'git commit -F /nonexistent-path-for-test', + '/tmp', + ) + assert.equal(msg, undefined) +}) + +test('multiple -m flags concatenate', () => { + const msg = extractCommitMessage( + 'git commit -m "title B1" -m "body M9"', + '/tmp', + ) + assert.match(msg!, /B1/) + assert.match(msg!, /M9/) +}) + +test('extracts commit message from a chained command', () => { + // Parser sees through `cd … &&` — the commit message is read from the + // git invocation, not the chain prefix. + const msg = extractCommitMessage('cd /repo && git commit -m "fix B5"', '/tmp') + assert.equal(msg, 'fix B5') +}) + +test('does not read a -m from a SEPARATE sibling command', () => { + // The `-m` belongs to the preceding `mail` command, not `git commit`. + // The parser scopes args per-invocation, so the commit message is + // empty (editor mode) and nothing leaks across the chain. + const msg = extractCommitMessage( + 'mail -m "B5 in subject" && git commit', + '/tmp', + ) + assert.equal(msg, undefined) +}) diff --git a/.claude/hooks/scan-label-in-commit-guard/tsconfig.json b/.claude/hooks/scan-label-in-commit-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/scan-label-in-commit-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-basics-tools/README.md b/.claude/hooks/setup-basics-tools/README.md new file mode 100644 index 0000000..99cabef --- /dev/null +++ b/.claude/hooks/setup-basics-tools/README.md @@ -0,0 +1,23 @@ +# setup-basics-tools + +Operator-invoked installer for the **socket-basics workflow stack**: +TruffleHog, Trivy, OpenGrep, and uv. Slim leaf of the +`setup-security-tools` umbrella. + +## When to use + +```sh +node .claude/hooks/setup-basics-tools/install.mts +``` + +For the full setup (firewall + scanners + socket-basics + misc), use +`node .claude/hooks/setup-security-tools/install.mts`. + +## What gets installed + +| Tool | Source | Purpose | +| ---------- | ----------------------------------- | ------------------------------------------------------------------- | +| TruffleHog | `github:trufflesecurity/trufflehog` | Secrets scanner | +| Trivy | `github:aquasecurity/trivy` | Container / IaC / SBOM vuln scanner | +| OpenGrep | `github:opengrep/opengrep` | SAST (semgrep fork) | +| uv | `github:astral-sh/uv` | Python package manager (used by socket-basics for Python bootstrap) | diff --git a/.claude/hooks/setup-basics-tools/install.mts b/.claude/hooks/setup-basics-tools/install.mts new file mode 100644 index 0000000..64fc0df --- /dev/null +++ b/.claude/hooks/setup-basics-tools/install.mts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * @file Install-only entry point for the socket-basics workflow stack: + * TruffleHog (secrets scanner), Trivy (vuln/SBOM scanner), OpenGrep (SAST), + * and uv (Python package manager bootstrap). Slim leaf of the + * `setup-security-tools` umbrella. Run via: node + * .claude/hooks/setup-basics-tools/install.mts For the full setup (firewall + + * scanners + socket-basics + misc), use `node + * .claude/hooks/setup-security-tools/install.mts`. + */ + +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +const logger = getDefaultLogger() + +async function main(): Promise { + logger.log('socket-basics tools — install / verify') + logger.log('') + + const { setupTrufflehog, setupTrivy, setupOpengrep, setupUv } = + (await import('../setup-security-tools/lib/installers.mts')) as { + setupTrufflehog: () => Promise + setupTrivy: () => Promise + setupOpengrep: () => Promise + setupUv: () => Promise + } + + const [trufflehogOk, trivyOk, opengrepOk, uvOk] = await Promise.all([ + setupTrufflehog(), + setupTrivy(), + setupOpengrep(), + setupUv(), + ]) + logger.log('') + + logger.log('=== Summary ===') + logger.log(`OpenGrep: ${opengrepOk ? 'ready' : 'FAILED'}`) + logger.log(`Trivy: ${trivyOk ? 'ready' : 'FAILED'}`) + logger.log(`TruffleHog: ${trufflehogOk ? 'ready' : 'FAILED'}`) + logger.log(`uv: ${uvOk ? 'ready' : 'FAILED'}`) + + if (!(trufflehogOk && trivyOk && opengrepOk && uvOk)) { + process.exitCode = 1 + } +} + +main().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + logger.error(`setup-basics-tools install: ${msg}`) + process.exitCode = 1 +}) diff --git a/.claude/hooks/setup-basics-tools/package.json b/.claude/hooks/setup-basics-tools/package.json new file mode 100644 index 0000000..139639b --- /dev/null +++ b/.claude/hooks/setup-basics-tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "hook-setup-basics-tools", + "private": true, + "type": "module", + "main": "./install.mts", + "exports": { + ".": "./install.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@socketsecurity/lib-stable": "catalog:", + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/setup-basics-tools/tsconfig.json b/.claude/hooks/setup-basics-tools/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/setup-basics-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-claude-scanners/README.md b/.claude/hooks/setup-claude-scanners/README.md new file mode 100644 index 0000000..4fe2f4a --- /dev/null +++ b/.claude/hooks/setup-claude-scanners/README.md @@ -0,0 +1,39 @@ +# setup-claude-scanners + +Operator-invoked installer for **AgentShield** + **zizmor** — the two +claude-config / GitHub-Actions scanners. Slim leaf of the +`setup-security-tools` umbrella. + +## When to use + +- You want to install or refresh ONLY the scanner surface + (AgentShield + zizmor) without re-running the firewall / + socket-basics / misc installers. +- You're onboarding a fresh worktree where the only thing you need + scanning right now is claude-config + workflow YAML. + +```sh +node .claude/hooks/setup-claude-scanners/install.mts +``` + +For the full setup (firewall + scanners + socket-basics + misc), use +`node .claude/hooks/setup-security-tools/install.mts`. + +## Relationship to setup-security-tools + +The umbrella `setup-security-tools/install.mts` does everything this +leaf does PLUS sfw (firewall) + socket-basics tools (TruffleHog, +Trivy, OpenGrep, uv) + misc tools (cdxgen, synp, janus). + +This leaf is a thin re-entry point that imports `setupAgentShield` + +- `setupZizmor` from the umbrella's `lib/installers.mts` and runs + ONLY those. No token resolution / keychain / shell-rc plumbing is + involved — the two scanners are auth-free. + +## What gets installed + +| Tool | Source | Purpose | +| ----------- | --------------------------------------- | ------------------------------------------------------------- | +| AgentShield | `pkg:npm/ecc-agentshield@1.4.0` via dlx | Claude AI config security scanner (prompt injection, secrets) | +| zizmor | `github:zizmorcore/zizmor` GH-release | GitHub Actions security scanner | diff --git a/.claude/hooks/setup-claude-scanners/install.mts b/.claude/hooks/setup-claude-scanners/install.mts new file mode 100644 index 0000000..02081e9 --- /dev/null +++ b/.claude/hooks/setup-claude-scanners/install.mts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * @file Install-only entry point for AgentShield + zizmor — the two + * claude-config / GitHub-Actions scanners. Slim leaf of the + * `setup-security-tools` umbrella. Run via: node + * .claude/hooks/setup-claude-scanners/install.mts For the full setup + * (firewall + scanners + socket-basics + misc), use `node + * .claude/hooks/setup-security-tools/install.mts`. + */ + +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +const logger = getDefaultLogger() + +async function main(): Promise { + logger.log('Claude scanners — install / verify') + logger.log('') + + const { setupAgentShield, setupZizmor } = + (await import('../setup-security-tools/lib/installers.mts')) as { + setupAgentShield: () => Promise + setupZizmor: () => Promise + } + + const agentshieldOk = await setupAgentShield() + logger.log('') + const zizmorOk = await setupZizmor() + logger.log('') + + logger.log('=== Summary ===') + logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) + logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) + + if (!(agentshieldOk && zizmorOk)) { + process.exitCode = 1 + } +} + +main().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + logger.error(`setup-claude-scanners install: ${msg}`) + process.exitCode = 1 +}) diff --git a/.claude/hooks/setup-claude-scanners/package.json b/.claude/hooks/setup-claude-scanners/package.json new file mode 100644 index 0000000..c8e5359 --- /dev/null +++ b/.claude/hooks/setup-claude-scanners/package.json @@ -0,0 +1,16 @@ +{ + "name": "hook-setup-claude-scanners", + "private": true, + "type": "module", + "main": "./install.mts", + "exports": { + ".": "./install.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@socketsecurity/lib-stable": "catalog:", + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/setup-claude-scanners/tsconfig.json b/.claude/hooks/setup-claude-scanners/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/setup-claude-scanners/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-firewall/README.md b/.claude/hooks/setup-firewall/README.md new file mode 100644 index 0000000..2e09edc --- /dev/null +++ b/.claude/hooks/setup-firewall/README.md @@ -0,0 +1,40 @@ +# setup-firewall + +Operator-invoked installer for **Socket Firewall** (sfw enterprise + +free). Slim leaf of the `setup-security-tools` umbrella. + +## When to use + +- You want to install or refresh ONLY the firewall surface without + re-running the AgentShield / zizmor / socket-basics tool + installers. +- You're rotating `SOCKET_API_KEY` and want sfw to re-resolve + enterprise vs free without touching everything else. + +```sh +# Install / verify +node .claude/hooks/setup-firewall/install.mts + +# Rotate the API token (re-prompts; overwrites keychain) +node .claude/hooks/setup-firewall/install.mts --rotate +``` + +## Relationship to setup-security-tools + +The umbrella `setup-security-tools/install.mts` does everything this +leaf does PLUS AgentShield + zizmor + socket-basics tools (TruffleHog, +Trivy, OpenGrep, uv) + a few misc tools (cdxgen, synp, janus). + +This leaf is a thin re-entry point that imports from the umbrella's +`lib/installers.mts` and runs ONLY the firewall installer. The token +resolution / keychain / shell-rc bridge / --rotate prompt all use the +umbrella's exported helpers — single source of truth. + +## What gets installed + +| Surface | Source | +| ------------------------------------------------------------------ | ------------------------------------------------------------------- | +| sfw binary (enterprise or free, depending on token) | github:SocketDev/firewall-release (enterprise) / SocketDev/sfw-free | +| PATH shims for npm / pnpm / yarn / pip / uv / cargo / etc. | `~/.socket/sfw/shims/` | +| Shell-rc env block (`~/.zshenv` on macOS) | `setup-security-tools/lib/shell-rc-bridge.mts` | +| OS keychain entry (macOS Keychain / libsecret / CredentialManager) | `setup-security-tools/lib/token-storage.mts` | diff --git a/.claude/hooks/setup-firewall/install.mts b/.claude/hooks/setup-firewall/install.mts new file mode 100644 index 0000000..2369181 --- /dev/null +++ b/.claude/hooks/setup-firewall/install.mts @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * @file Install-only entry point for Socket Firewall (sfw enterprise + free). + * Slim leaf of the setup-security-tools umbrella — for operators who want to + * install / refresh ONLY the firewall surface without re-running the + * AgentShield / zizmor / socket-basics tool installers. The actual installer + * code lives in `../setup-security-tools/lib/installers.mts`. This entry + * point exists so operators can scope their setup precisely: node + * .claude/hooks/setup-firewall/install.mts For the full setup, use `node + * .claude/hooks/setup-security-tools/install.mts` which sequences this leaf + * alongside the others. --rotate is honored here too — re-prompts for + * SOCKET_API_KEY and overwrites the OS keychain entry, just like the + * umbrella's --rotate path. + */ + +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +import { findApiToken } from '../setup-security-tools/lib/api-token.mts' +import { + offerTokenPrompt, + parseArgs, + promptAndPersist, + wireBridgeIntoShellRc, +} from '../setup-security-tools/lib/operator-prompts.mts' + +const logger = getDefaultLogger() + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + logger.log('Socket Firewall — install / verify') + logger.log('') + + let apiToken: string | undefined + if (args.rotate) { + const fresh = await promptAndPersist(logger, 'rotate') + if (fresh) { + apiToken = fresh + } else { + const lookup = findApiToken() + apiToken = lookup.token + if (apiToken && lookup.source) { + logger.log(`Keeping existing SOCKET_API_KEY (via ${lookup.source}).`) + } + } + } else { + const lookup = findApiToken() + apiToken = lookup.token + if (apiToken && lookup.source) { + logger.log(`SOCKET_API_KEY: found via ${lookup.source}.`) + } else { + apiToken = await offerTokenPrompt(logger) + } + } + + if (apiToken) { + wireBridgeIntoShellRc(logger, apiToken) + } + + const { setupSfw } = + (await import('../setup-security-tools/lib/installers.mts')) as { + setupSfw: (apiToken: string | undefined) => Promise + } + + const sfwOk = await setupSfw(apiToken) + logger.log('') + logger.log('=== Summary ===') + logger.log(`SFW: ${sfwOk ? 'ready' : 'FAILED'}`) + if (!sfwOk) { + process.exitCode = 1 + } +} + +main().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + logger.error(`setup-firewall install: ${msg}`) + process.exitCode = 1 +}) diff --git a/.claude/hooks/setup-firewall/package.json b/.claude/hooks/setup-firewall/package.json new file mode 100644 index 0000000..cdfc9e3 --- /dev/null +++ b/.claude/hooks/setup-firewall/package.json @@ -0,0 +1,16 @@ +{ + "name": "hook-setup-firewall", + "private": true, + "type": "module", + "main": "./install.mts", + "exports": { + ".": "./install.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@socketsecurity/lib-stable": "catalog:", + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/setup-firewall/tsconfig.json b/.claude/hooks/setup-firewall/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/setup-firewall/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-misc-tools/README.md b/.claude/hooks/setup-misc-tools/README.md new file mode 100644 index 0000000..287134b --- /dev/null +++ b/.claude/hooks/setup-misc-tools/README.md @@ -0,0 +1,21 @@ +# setup-misc-tools + +Operator-invoked installer for one-off tools: **cdxgen**, **synp**, +and **janus**. Slim leaf of the `setup-security-tools` umbrella. + +## When to use + +```sh +node .claude/hooks/setup-misc-tools/install.mts +``` + +For the full setup (firewall + scanners + socket-basics + misc), use +`node .claude/hooks/setup-security-tools/install.mts`. + +## What gets installed + +| Tool | Source | Purpose | +| ------ | ------------------------------------------ | ---------------------------------------------------------- | +| cdxgen | `github:CycloneDX/cdxgen` (slim SEA) | CycloneDX SBOM generator (used by `socket scan sbom`) | +| synp | `pkg:npm/synp@1.9.14` via dlx | yarn.lock ↔ package-lock.json converter (cross-PM interop) | +| janus | `github:divmain/janus` (darwin-arm64 only) | Tool that some Socket workflows opt into | diff --git a/.claude/hooks/setup-misc-tools/install.mts b/.claude/hooks/setup-misc-tools/install.mts new file mode 100644 index 0000000..919cd03 --- /dev/null +++ b/.claude/hooks/setup-misc-tools/install.mts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * @file Install-only entry point for one-off tools: cdxgen (SBOM), synp + * (lockfile interop), and janus. Slim leaf of the `setup-security-tools` + * umbrella. Run via: node .claude/hooks/setup-misc-tools/install.mts For the + * full setup (firewall + scanners + socket-basics + misc), use `node + * .claude/hooks/setup-security-tools/install.mts`. + */ + +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +const logger = getDefaultLogger() + +async function main(): Promise { + logger.log('misc tools — install / verify') + logger.log('') + + const { setupCdxgen, setupSynp, setupJanus } = + (await import('../setup-security-tools/lib/installers.mts')) as { + setupCdxgen: () => Promise + setupSynp: () => Promise + setupJanus: () => Promise + } + + const [cdxgenOk, synpOk, janusOk] = await Promise.all([ + setupCdxgen(), + setupSynp(), + setupJanus(), + ]) + logger.log('') + + logger.log('=== Summary ===') + logger.log(`cdxgen: ${cdxgenOk ? 'ready' : 'FAILED'}`) + logger.log(`janus: ${janusOk ? 'ready' : 'FAILED'}`) + logger.log(`synp: ${synpOk ? 'ready' : 'FAILED'}`) + + if (!(cdxgenOk && synpOk && janusOk)) { + process.exitCode = 1 + } +} + +main().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + logger.error(`setup-misc-tools install: ${msg}`) + process.exitCode = 1 +}) diff --git a/.claude/hooks/setup-misc-tools/package.json b/.claude/hooks/setup-misc-tools/package.json new file mode 100644 index 0000000..6926823 --- /dev/null +++ b/.claude/hooks/setup-misc-tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "hook-setup-misc-tools", + "private": true, + "type": "module", + "main": "./install.mts", + "exports": { + ".": "./install.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@socketsecurity/lib-stable": "catalog:", + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/setup-misc-tools/tsconfig.json b/.claude/hooks/setup-misc-tools/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/setup-misc-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts index ca59de9..9de8456 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/setup-security-tools/index.mts @@ -38,7 +38,7 @@ // Fails open on every error (exit 0 + stderr log). The hook must // not block the conversation on its own bugs. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' import process from 'node:process' diff --git a/.claude/hooks/setup-security-tools/install.mts b/.claude/hooks/setup-security-tools/install.mts index 66e1b2c..cac5d0a 100644 --- a/.claude/hooks/setup-security-tools/install.mts +++ b/.claude/hooks/setup-security-tools/install.mts @@ -28,19 +28,17 @@ import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' import process from 'node:process' -import readline from 'node:readline' import { fileURLToPath } from 'node:url' -import { getCI } from '@socketsecurity/lib-stable/env/ci' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { findApiToken } from './lib/api-token.mts' -import { installShellRcBridge } from './lib/shell-rc-bridge.mts' -import type { BridgeWriteResult } from './lib/shell-rc-bridge.mts' import { - keychainAvailable, - writeTokenToKeychain, -} from './lib/token-storage.mts' + offerTokenPrompt, + parseArgs, + promptAndPersist, + wireBridgeIntoShellRc, +} from './lib/operator-prompts.mts' const logger = getDefaultLogger() const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -86,201 +84,6 @@ export async function findBrokenShims(): Promise { return broken } -export async function offerTokenPrompt(): Promise { - return promptAndPersist('missing') -} - -interface CliArgs { - rotate: boolean -} - -export function parseArgs(argv: readonly string[]): CliArgs { - let rotate = false - for (let i = 0, { length } = argv; i < length; i += 1) { - const arg = argv[i]! - if (arg === '--rotate' || arg === '--update-token') { - rotate = true - } - } - return { rotate } -} - -/** - * Shared prompt-and-persist body used by both the "no token found" and the - * explicit `--rotate` paths. The `reason` strings differ but the gating + the - * prompt + the keychain write are identical. - */ -export async function promptAndPersist( - reason: 'missing' | 'rotate', -): Promise { - if (getCI()) { - logger.log( - 'CI environment detected — skipping the SOCKET_API_KEY prompt. ' + - 'Falling back to sfw-free.', - ) - return undefined - } - if (!process.stdin.isTTY) { - logger.log( - 'No TTY — skipping the SOCKET_API_KEY prompt. ' + - 'Falling back to sfw-free. Set SOCKET_API_KEY in env or run ' + - 'this script interactively to persist it to the OS keychain.', - ) - return undefined - } - const kc = keychainAvailable() - if (!kc.available) { - logger.warn( - `OS keychain tool '${kc.toolName}' is not available. ${ - kc.installHint ?? '' - }`, - ) - logger.log('Falling back to sfw-free.') - return undefined - } - logger.log('') - if (reason === 'rotate') { - logger.log( - `Rotating SOCKET_API_KEY — the keychain entry will be overwritten ` + - `via ${kc.toolName}.`, - ) - } else { - logger.log('Socket API token not found in env, .env, or the OS keychain.') - logger.log( - 'A token unlocks sfw-enterprise (org-aware malware scanning). ' + - `It will be stored securely via ${kc.toolName}.`, - ) - } - logger.log( - 'Get a token at https://socket.dev/dashboard or press Enter to skip' + - (reason === 'rotate' - ? ' (the existing keychain entry stays in place).' - : ' and use sfw-free.'), - ) - logger.log('') - const answer = await promptSecret('SOCKET_API_KEY (input hidden): ') - if (!answer) { - if (reason === 'rotate') { - logger.log('No token entered. Keychain unchanged.') - } else { - logger.log('No token entered. Falling back to sfw-free.') - } - return undefined - } - try { - writeTokenToKeychain(answer) - if (reason === 'rotate') { - logger.success(`SOCKET_API_KEY rotated and persisted via ${kc.toolName}.`) - } - } catch (e) { - logger.error( - `Failed to persist token to keychain: ${(e as Error).message}. ` + - 'Continuing with the value for this session only — it will not ' + - 'persist across runs until the keychain tool is available.', - ) - } - return answer -} - -/** - * Read a secret from the TTY without echoing it. Wraps node:readline with - * custom output muting — typed characters never appear on screen and never end - * up in shell history. - * - * Caller must verify `process.stdin.isTTY` before invoking. - */ -export async function promptSecret(prompt: string): Promise { - // Custom output stream that swallows everything written to stdout - // during the prompt — that's how readline echoes typed characters, - // and we want them invisible. - const muted = new (class extends (await import('node:stream')).Writable { - override _write(_chunk: unknown, _enc: unknown, cb: () => void): void { - cb() - } - })() - const rl = readline.createInterface({ - input: process.stdin, - output: muted, - terminal: true, - }) - // The prompt itself is written directly to stderr so it shows up - // even though readline's echo is muted. - process.stderr.write(prompt) - try { - return await new Promise(resolve => { - rl.question('', answer => { - process.stderr.write('\n') - resolve(answer.trim()) - }) - }) - } finally { - rl.close() - } -} - -/** - * Print a one-paragraph summary of what the shell-rc bridge did (or didn't do), - * with a copy-pasteable next step. Splitting this out keeps `promptAndPersist` - * readable and gives the rotate path the same instruction without duplicating - * the prose. - */ -export function reportBridgeOutcome( - bridge: BridgeWriteResult | undefined, -): void { - if (!bridge) { - // Non-macOS or no rc detectable — fall through to a manual line - // the user can paste. We hand the user a literal-export template - // (not a keychain-read) because re-reading the keychain on every - // shell triggers an auth prompt on macOS. - logger.log('') - logger.log( - 'Add this to your shell rc / .zshenv so SOCKET_API_KEY is exported ' + - 'each session (every Socket tool reads it without a fallback chain):', - ) - logger.log(" export SOCKET_API_KEY=''") - return - } - if (bridge.outcome === 'unchanged') { - logger.log( - `Shell-rc env block already canonical at ${bridge.rcPath} — no change.`, - ) - } else if (bridge.outcome === 'updated') { - logger.success( - `Updated the shell-rc env block at ${bridge.rcPath}. ` + - 'Run `source ' + - bridge.rcPath + - '` (or open a new shell) so SOCKET_API_KEY gets exported.', - ) - } else { - logger.success( - `Wrote the shell-rc env block to ${bridge.rcPath}. ` + - 'Run `source ' + - bridge.rcPath + - '` (or open a new shell) so SOCKET_API_KEY gets exported.', - ) - } -} - -/** - * Write (or refresh) the keychain → shell-env bridge block in the user's shell - * rc. Idempotent: re-running install.mts on an already- wired rc is a no-op. - * Called from main() on every invocation so the bridge gets installed whether - * or not the user just entered a fresh token via the prompt — keychain hits - * from env/.env/keychain still need the bridge to actually reach the shell of - * every NEW session. - */ -export function wireBridgeIntoShellRc(token: string): void { - try { - const bridge = installShellRcBridge(token) - reportBridgeOutcome(bridge) - } catch (e) { - logger.warn( - `Failed to write the shell-rc env block: ${(e as Error).message}. ` + - 'You will need to export SOCKET_API_KEY manually for Socket tools to pick it up.', - ) - } -} - async function main(): Promise { const args = parseArgs(process.argv.slice(2)) logger.log('Socket security tools — install / verify') @@ -294,7 +97,7 @@ async function main(): Promise { // existing keychain value stays in place — we fall through to the // normal lookup below so downstream installers still get the // pre-rotation token. - const fresh = await promptAndPersist('rotate') + const fresh = await promptAndPersist(logger, 'rotate') if (fresh) { apiToken = fresh } else { @@ -311,7 +114,7 @@ async function main(): Promise { if (apiToken && lookup.source) { logger.log(`SOCKET_API_KEY: found via ${lookup.source}.`) } else { - apiToken = await offerTokenPrompt() + apiToken = await offerTokenPrompt(logger) } } @@ -326,7 +129,7 @@ async function main(): Promise { // 2026-05-15 incident memory). Idempotent: same-value re-run is // outcome=unchanged. Rotate writes a fresh block. if (apiToken) { - wireBridgeIntoShellRc(apiToken) + wireBridgeIntoShellRc(logger, apiToken) } // Broken-shim detection. When the dlx cache rotates (cleanup, manifest diff --git a/.claude/hooks/setup-security-tools/lib/api-token.mts b/.claude/hooks/setup-security-tools/lib/api-token.mts index 752ae47..a8caff9 100644 --- a/.claude/hooks/setup-security-tools/lib/api-token.mts +++ b/.claude/hooks/setup-security-tools/lib/api-token.mts @@ -1,43 +1,34 @@ /** * @file Single source of truth for "what's the Socket API token?" Resolution - * order (first hit wins): - * - * 1. `SOCKET_API_KEY` env var (primary — universally supported across Socket - * tools; what setup-security-tools' install.mts writes to both the OS - * keychain and the shell-rc bridge). - * 2. `SOCKET_API_TOKEN` env var (forward-canonical name targeted by fleet docs / - * workflow inputs / .env.example; accepted so consumers that set the - * forward-canonical name explicitly still resolve a value). - * 3. OS keychain (macOS Keychain / Linux libsecret / Windows CredentialManager). - * Returns `undefined` when no token is found. Never throws — callers - * decide how to react (use free SFW, skip auth-gated install, prompt). - * **No `.env` / `.env.local` reads.** Dotfiles leak — they get - * accidentally committed, read by every dev tool that walks the project - * dir, swept into log scrapers. Tokens belong in env (for CI) or in the OS - * keychain (for dev local). The canonical resolution chain stays explicit: - * env → keychain → prompt. **Module-scope cache.** Each successful - * resolution is memoized for the lifetime of the process. Reason: every - * `security find-generic- password` call on macOS triggers a fresh - * Keychain ACL check, which surfaces the "this app wants to access your - * keychain" dialog. A pre-commit hook + commit-msg hook + post-commit - * invocation can fire three keychain reads in 200ms — each one its own - * prompt. The cache collapses N reads per process to 1. Also propagates - * the resolved token into `process.env.SOCKET_API_KEY` so child processes - * (spawned by the same hook chain) inherit it instead of re-querying. + * order (first hit wins): env → keychain. External fleet docs / workflow + * inputs / .env.example use SOCKET_API_TOKEN (the promoted name); internally + * we read both SOCKET_API_TOKEN and SOCKET_API_KEY because every Socket tool + * supports SOCKET_API_KEY (CLI, SDK, sfw, fleet scripts). Returns `undefined` + * when no token is found. Never throws — callers decide how to react (use + * free SFW, skip auth-gated install, prompt). **No `.env` / `.env.local` + * reads.** Dotfiles leak — they get accidentally committed, read by every dev + * tool that walks the project dir, swept into log scrapers. Tokens belong in + * env (for CI) or in the OS keychain (for dev local). **Module- scope + * cache.** Each successful resolution is memoized for the lifetime of the + * process. Reason: every `security find-generic-password` call on macOS + * triggers a fresh Keychain ACL check, which surfaces the "this app wants to + * access your keychain" dialog. A pre-commit hook + commit-msg hook + + * post-commit invocation can fire three keychain reads in 200ms — each one + * its own prompt. The cache collapses N reads per process to 1. Also + * propagates the resolved token into both env names so child processes + * inherit it regardless of which name they read. */ import { readTokenFromKeychain } from './token-storage.mts' -const PRIMARY = 'SOCKET_API_TOKEN' -const FORWARD_CANONICAL = 'SOCKET_API_TOKEN' +// Both names are checked at read time — first env hit wins. Storage layer +// (token-storage.mts) writes ONLY SOCKET_API_KEY to keep macOS Keychain +// rotation to a single auth prompt. +const ENV_NAMES = ['SOCKET_API_TOKEN', 'SOCKET_API_KEY'] as const export interface TokenLookup { readonly token: string | undefined - readonly source: - | 'env-primary' - | 'env-forward-canonical' - | 'keychain' - | undefined + readonly source: 'env' | 'keychain' | undefined } // Module-scope cache: the result of the FIRST findApiToken() call is @@ -52,7 +43,7 @@ let cached: TokenLookup | null | undefined * call this. Used by `--rotate` flows that need to re-prompt after wiping the * keychain entry. */ -export function _resetApiTokenCacheForTesting(): void { +export function resetApiTokenCacheForTesting(): void { cached = undefined } @@ -61,21 +52,16 @@ export function findApiToken(): TokenLookup { return cached === null ? { token: undefined, source: undefined } : cached } - // 1. Env — primary slot first, then forward-canonical fallback. - const envPrimary = process.env[PRIMARY] - if (envPrimary) { - propagateToEnv(envPrimary) - cached = { token: envPrimary, source: 'env-primary' } - return cached - } - const envForwardCanonical = process.env[FORWARD_CANONICAL] - if (envForwardCanonical) { - propagateToEnv(envForwardCanonical) - cached = { token: envForwardCanonical, source: 'env-forward-canonical' } - return cached + for (let i = 0, { length } = ENV_NAMES; i < length; i += 1) { + const name = ENV_NAMES[i]! + const value = process.env[name] + if (value) { + propagateToEnv(value) + cached = { token: value, source: 'env' } + return cached + } } - // 2. OS keychain. const fromKeychain = readTokenFromKeychain() if (fromKeychain) { propagateToEnv(fromKeychain) @@ -88,21 +74,16 @@ export function findApiToken(): TokenLookup { } /** - * Populate BOTH `SOCKET_API_KEY` (primary) and `SOCKET_API_TOKEN` - * (forward-canonical) in `process.env` so any spawned child resolves a value - * under whichever name it reads. The keychain-side mirror was removed at the - * storage layer (one stored slot = one macOS Keychain auth prompt), but env - * propagation here is free in-process and helps consumers that haven't migrated - * to SOCKET_API_KEY yet. - * - * Idempotent — already-set values are left alone (so the user's explicit env - * value isn't clobbered by a keychain read). + * Populate both SOCKET_API_TOKEN and SOCKET_API_KEY in `process.env` so any + * spawned child resolves a value under whichever name it reads. Idempotent — + * already-set values are left alone (so the user's explicit env value isn't + * clobbered by a keychain read). */ export function propagateToEnv(token: string): void { - if (!process.env[PRIMARY]) { - process.env[PRIMARY] = token - } - if (!process.env[FORWARD_CANONICAL]) { - process.env[FORWARD_CANONICAL] = token + for (let i = 0, { length } = ENV_NAMES; i < length; i += 1) { + const name = ENV_NAMES[i]! + if (!process.env[name]) { + process.env[name] = token + } } } diff --git a/.claude/hooks/setup-security-tools/lib/installers.mts b/.claude/hooks/setup-security-tools/lib/installers.mts index dedd5e5..597abab 100644 --- a/.claude/hooks/setup-security-tools/lib/installers.mts +++ b/.claude/hooks/setup-security-tools/lib/installers.mts @@ -13,6 +13,8 @@ // in env / .env / .env.local. import { existsSync, promises as fs, readFileSync } from 'node:fs' + +import { findApiToken as findApiTokenCanonical } from './api-token.mts' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -21,15 +23,15 @@ import { fileURLToPath } from 'node:url' import { PackageURL } from '@socketregistry/packageurl-js-stable' import { Type } from '@sinclair/typebox' -import { whichSync } from '@socketsecurity/lib-stable/bin' +import { whichSync } from '@socketsecurity/lib-stable/bin/which' import { downloadBinary } from '@socketsecurity/lib-stable/dlx/binary' import { downloadPackage } from '@socketsecurity/lib-stable/dlx/package' import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeDelete } from '@socketsecurity/lib-stable/fs' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' import { getSocketHomePath } from '@socketsecurity/lib-stable/paths/socket' -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { parseSchema } from '@socketsecurity/lib-stable/schema/parse' const logger = getDefaultLogger() @@ -90,55 +92,27 @@ const JANUS = config.tools['janus']! export async function checkZizmorVersion(binPath: string): Promise { try { const result = await spawn(binPath, ['--version'], { stdio: 'pipe' }) - const output = - typeof result.stdout === 'string' - ? result.stdout.trim() - : result.stdout.toString().trim() + const output = String(result.stdout).trim() return ZIZMOR.version ? output.includes(ZIZMOR.version) : false } catch { return false } } +/** + * Resolve the Socket API token from env → keychain. Re-exported from + * `lib/api-token.mts` so call sites can keep importing `findApiToken` from + * `installers.mts` (back-compat) while the canonical resolver stays a single + * source of truth. + * + * The previous in-file implementation read `.env` / `.env.local` which is a + * CLAUDE.md token-hygiene violation (dotfiles leak; tokens belong in env or the + * OS keychain). It also skipped the keychain entirely, which caused + * sfw-enterprise → sfw-free silent downgrades when the token was only in the + * macOS Keychain. + */ export function findApiToken(): string | undefined { - // SOCKET_API_TOKEN is the canonical fleet name; SOCKET_API_KEY remains - // universally supported across Socket tools (CLI, SDK, sfw, fleet scripts) - // as the legacy alias. - const envToken = - process.env['SOCKET_API_TOKEN'] ?? process.env['SOCKET_API_KEY'] - if (envToken) { - return envToken - } - const projectDir = process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() - for (const filename of ['.env.local', '.env']) { - const filepath = path.join(projectDir, filename) - if (existsSync(filepath)) { - try { - const content = readFileSync(filepath, 'utf8') - const match = - /^SOCKET_API_KEY\s*=\s*(.+)$/m.exec(content) ?? - /^SOCKET_API_TOKEN\s*=\s*(.+)$/m.exec(content) - if (match) { - return match[1]! - .replace(/\s*#.*$/, '') // Strip inline comments. - .trim() // Strip whitespace before quote removal. - .replace(/^["']|["']$/g, '') // Strip surrounding quotes. - } - } catch (e) { - // We already checked existsSync; ENOENT here means a race with - // an external delete (rare, ignorable). Anything else (EACCES, - // EISDIR, decode failure) is a real signal — log it so the - // operator can fix the perms / encoding instead of wondering - // why their .env-stored token isn't being picked up. - const code = (e as NodeJS.ErrnoException).code - if (code !== 'ENOENT') { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(`could not read ${filepath}: ${msg}`) - } - } - } - } - return undefined + return findApiTokenCanonical().token } type ToolEntry = (typeof config.tools)[string] @@ -253,7 +227,9 @@ export async function installGitHubReleaseTool( return true } - const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), `${name}-extract-`)) + const extractDir = await fs.mkdtemp( + path.join(os.tmpdir(), `${name}-extract-`), + ) try { if (isZip) { if (process.platform === 'win32') { @@ -350,7 +326,9 @@ export async function installGitHubReleaseToolWithTag( } const isZip = asset.endsWith('.zip') - const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), `${name}-extract-`)) + const extractDir = await fs.mkdtemp( + path.join(os.tmpdir(), `${name}-extract-`), + ) try { if (isZip) { if (process.platform === 'win32') { @@ -407,7 +385,7 @@ export async function setupAgentShield(): Promise { const version = AGENTSHIELD.version ?? purl.version const packageSpec = version ? `${npmPackage}@${version}` : npmPackage - logger.log(`Installing ${packageSpec} via dlx...`) + logger.log(`Installing ${packageSpec} via dlx…`) const { binaryPath, installed } = await downloadPackage({ package: packageSpec, binaryName: 'agentshield', @@ -547,7 +525,7 @@ export async function setupNpmTool( : purl.name! const version = tool.version ?? purl.version const packageSpec = version ? `${npmPackage}@${version}` : npmPackage - logger.log(`Installing ${packageSpec} via dlx...`) + logger.log(`Installing ${packageSpec} via dlx…`) const { binaryPath, installed } = await downloadPackage({ package: packageSpec, binaryName: name, @@ -636,26 +614,20 @@ export async function setupSfw(apiToken: string | undefined): Promise { ] if (isEnterprise) { // Read API token from env at runtime — never embed secrets in - // scripts. SOCKET_API_KEY is the primary slot (universally - // supported); SOCKET_API_TOKEN is the forward-canonical name - // accepted as a secondary read. Whichever name is set gets - // exported under both so downstream tools see the value - // regardless of which name they read. + // scripts. Either SOCKET_API_KEY or SOCKET_API_TOKEN is accepted; + // whichever is set gets exported under both so downstream tools + // see the value regardless of which name they read. + // + // Dotfile fallback (`.env` / `.env.local`) is intentionally NOT + // checked here per CLAUDE.md token-hygiene: tokens belong in env + // (CI) or the OS keychain (dev local), never in dotfiles. The + // shell-rc bridge installed by setup-security-tools writes the + // export line into ~/.zshenv so every new shell already has the + // env var set. bashLines.push( 'if [ -z "$SOCKET_API_KEY" ] && [ -n "$SOCKET_API_TOKEN" ]; then', ' SOCKET_API_KEY="$SOCKET_API_TOKEN"', 'fi', - 'if [ -z "$SOCKET_API_KEY" ]; then', - ' for f in .env.local .env; do', - ' if [ -f "$f" ]; then', - ' _val="$(grep -m1 "^SOCKET_API_KEY\\s*=" "$f" | sed "s/^[^=]*=\\s*//" | sed "s/\\s*#.*//" | sed "s/^["\\x27]\\(.*\\)["\\x27]$/\\1/")"', - ' if [ -z "$_val" ]; then', - ' _val="$(grep -m1 "^SOCKET_API_TOKEN\\s*=" "$f" | sed "s/^[^=]*=\\s*//" | sed "s/\\s*#.*//" | sed "s/^["\\x27]\\(.*\\)["\\x27]$/\\1/")"', - ' fi', - ' if [ -n "$_val" ]; then SOCKET_API_KEY="$_val"; break; fi', - ' fi', - ' done', - 'fi', 'if [ -n "$SOCKET_API_KEY" ]; then', ' export SOCKET_API_KEY', ' SOCKET_API_TOKEN="$SOCKET_API_KEY"', @@ -678,26 +650,16 @@ export async function setupSfw(apiToken: string | undefined): Promise { if (isWindows) { let cmdApiTokenBlock = '' if (isEnterprise) { - // Read API token from .env files at runtime — mirrors the bash - // shim logic. SOCKET_API_KEY is the primary slot (universally - // supported); SOCKET_API_TOKEN is the forward-canonical name - // accepted as a secondary read. + // Mirror the bash-shim env-only resolution. Dotfile fallback + // (`.env` / `.env.local`) is intentionally not read here — see + // the bash-shim comment for the token-hygiene rationale. The + // Windows CredentialManager shell-rc bridge installed by + // setup-security-tools writes the env var for every new + // session. cmdApiTokenBlock = `if not defined SOCKET_API_KEY (\r\n` + ` if defined SOCKET_API_TOKEN set "SOCKET_API_KEY=%SOCKET_API_TOKEN%"\r\n` + `)\r\n` + - `if not defined SOCKET_API_KEY (\r\n` + - ` for %%F in (.env.local .env) do (\r\n` + - ` if exist "%%F" (\r\n` + - ` for /f "tokens=1,* delims==" %%A in ('findstr /b "SOCKET_API_KEY" "%%F"') do (\r\n` + - ` set "SOCKET_API_KEY=%%B"\r\n` + - ` )\r\n` + - ` for /f "tokens=1,* delims==" %%A in ('findstr /b "SOCKET_API_TOKEN" "%%F"') do (\r\n` + - ` if not defined SOCKET_API_KEY set "SOCKET_API_KEY=%%B"\r\n` + - ` )\r\n` + - ` )\r\n` + - ` )\r\n` + - `)\r\n` + `if defined SOCKET_API_KEY set "SOCKET_API_TOKEN=%SOCKET_API_KEY%"\r\n` } const cmdContent = @@ -860,7 +822,7 @@ export async function setupZizmor(): Promise { } async function main(): Promise { - logger.log('Setting up Socket security tools...') + logger.log('Setting up Socket security tools…') logger.log('') const apiToken = findApiToken() diff --git a/.claude/hooks/setup-security-tools/lib/operator-prompts.mts b/.claude/hooks/setup-security-tools/lib/operator-prompts.mts new file mode 100644 index 0000000..a4e8b05 --- /dev/null +++ b/.claude/hooks/setup-security-tools/lib/operator-prompts.mts @@ -0,0 +1,220 @@ +/** + * @file Operator-prompt helpers shared between the setup-security-tools + * umbrella's install.mts and the scoped leaves (setup-firewall, etc.). Each + * helper here is library-shaped: no top-level side effects, no process.exit, + * no implicit logger ownership. Callers pass their own logger so each + * entrypoint can label its prompts/outputs differently. What's intentionally + * NOT here: + * + * - `findBrokenShims()` — only used by the umbrella to print a pre-install + * warning. Stays in install.mts. + * - `main()` — orchestration, not a helper. + */ + +import process from 'node:process' +import readline from 'node:readline' + +import { getCI } from '@socketsecurity/lib-stable/env/ci' +import type { Logger } from '@socketsecurity/lib-stable/logger/logger' + +import { installShellRcBridge } from './shell-rc-bridge.mts' +import type { BridgeWriteResult } from './shell-rc-bridge.mts' +import { keychainAvailable, writeTokenToKeychain } from './token-storage.mts' + +export interface CliArgs { + readonly rotate: boolean +} + +export function parseArgs(argv: readonly string[]): CliArgs { + let rotate = false + for (let i = 0, { length } = argv; i < length; i += 1) { + const arg = argv[i]! + if (arg === '--rotate' || arg === '--update-token') { + rotate = true + } + } + return { rotate } +} + +/** + * Read a secret from the TTY without echoing it. Wraps node:readline with + * custom output muting — typed characters never appear on screen and never end + * up in shell history. + * + * Caller must verify `process.stdin.isTTY` before invoking. + */ +export async function promptSecret(prompt: string): Promise { + // Custom output stream that swallows everything written to stdout + // during the prompt — that's how readline echoes typed characters, + // and we want them invisible. + const muted = new (class extends (await import('node:stream')).Writable { + override _write(_chunk: unknown, _enc: unknown, cb: () => void): void { + cb() + } + })() + const rl = readline.createInterface({ + input: process.stdin, + output: muted, + terminal: true, + }) + // The prompt itself is written directly to stderr so it shows up + // even though readline's echo is muted. + process.stderr.write(prompt) + try { + return await new Promise(resolve => { + rl.question('', answer => { + process.stderr.write('\n') + resolve(answer.trim()) + }) + }) + } finally { + rl.close() + } +} + +/** + * Shared prompt-and-persist body used by both the "no token found" and the + * explicit `--rotate` paths. The `reason` strings differ but the gating + the + * prompt + the keychain write are identical. + */ +export async function promptAndPersist( + logger: Logger, + reason: 'missing' | 'rotate', +): Promise { + if (getCI()) { + logger.log( + 'CI environment detected — skipping the SOCKET_API_KEY prompt. ' + + 'Falling back to sfw-free.', + ) + return undefined + } + if (!process.stdin.isTTY) { + logger.log( + 'No TTY — skipping the SOCKET_API_KEY prompt. ' + + 'Falling back to sfw-free. Set SOCKET_API_KEY in env or run ' + + 'this script interactively to persist it to the OS keychain.', + ) + return undefined + } + const kc = keychainAvailable() + if (!kc.available) { + logger.warn( + `OS keychain tool '${kc.toolName}' is not available. ${ + kc.installHint ?? '' + }`, + ) + logger.log('Falling back to sfw-free.') + return undefined + } + logger.log('') + if (reason === 'rotate') { + logger.log( + `Rotating SOCKET_API_KEY — the keychain entry will be overwritten ` + + `via ${kc.toolName}.`, + ) + } else { + logger.log('Socket API token not found in env, .env, or the OS keychain.') + logger.log( + 'A token unlocks sfw-enterprise (org-aware malware scanning). ' + + `It will be stored securely via ${kc.toolName}.`, + ) + } + logger.log( + 'Get a token at https://socket.dev/dashboard or press Enter to skip' + + (reason === 'rotate' + ? ' (the existing keychain entry stays in place).' + : ' and use sfw-free.'), + ) + logger.log('') + const answer = await promptSecret('SOCKET_API_KEY (input hidden): ') + if (!answer) { + if (reason === 'rotate') { + logger.log('No token entered. Keychain unchanged.') + } else { + logger.log('No token entered. Falling back to sfw-free.') + } + return undefined + } + try { + writeTokenToKeychain(answer) + if (reason === 'rotate') { + logger.success(`SOCKET_API_KEY rotated and persisted via ${kc.toolName}.`) + } + } catch (e) { + logger.error( + `Failed to persist token to keychain: ${(e as Error).message}. ` + + 'Continuing with the value for this session only — it will not ' + + 'persist across runs until the keychain tool is available.', + ) + } + return answer +} + +/** + * Thin alias for the "no token found" prompt path. Same shape as + * `promptAndPersist(logger, 'missing')` but reads better at call sites that are + * only ever in the missing-token branch. + */ +export async function offerTokenPrompt( + logger: Logger, +): Promise { + return promptAndPersist(logger, 'missing') +} + +/** + * Print a one-paragraph summary of what the shell-rc bridge did (or didn't do), + * with a copy-pasteable next step. + */ +export function reportBridgeOutcome( + logger: Logger, + bridge: BridgeWriteResult | undefined, +): void { + if (!bridge) { + // Non-macOS or no rc detectable — fall through to a manual line + // the user can paste. We hand the user a literal-export template + // (not a keychain-read) because re-reading the keychain on every + // shell triggers an auth prompt on macOS. + logger.log('') + logger.log( + 'Add this to your shell rc / .zshenv so SOCKET_API_KEY is exported ' + + 'each session (every Socket tool reads it without a fallback chain):', + ) + logger.log(" export SOCKET_API_KEY=''") + return + } + if (bridge.outcome === 'unchanged') { + logger.log( + `Shell-rc env block already canonical at ${bridge.rcPath} — no change.`, + ) + } else if (bridge.outcome === 'updated') { + logger.success( + `Updated the shell-rc env block at ${bridge.rcPath}. ` + + 'Run `source ' + + bridge.rcPath + + '` (or open a new shell) so SOCKET_API_KEY gets exported.', + ) + } else { + logger.success( + `Wrote the shell-rc env block to ${bridge.rcPath}. ` + + 'Run `source ' + + bridge.rcPath + + '` (or open a new shell) so SOCKET_API_KEY gets exported.', + ) + } +} + +/** + * Write (or refresh) the keychain → shell-env bridge block in the user's shell + * rc. Idempotent: re-running on an already-wired rc is a no-op. + */ +export function wireBridgeIntoShellRc(logger: Logger, token: string): void { + try { + const bridge = installShellRcBridge(token) + reportBridgeOutcome(logger, bridge) + } catch (e) { + logger.warn( + `Failed to write the shell-rc env block: ${(e as Error).message}. ` + + 'You will need to export SOCKET_API_KEY manually for Socket tools to pick it up.', + ) + } +} diff --git a/.claude/hooks/setup-security-tools/lib/token-storage.mts b/.claude/hooks/setup-security-tools/lib/token-storage.mts index a96f9d6..c42d187 100644 --- a/.claude/hooks/setup-security-tools/lib/token-storage.mts +++ b/.claude/hooks/setup-security-tools/lib/token-storage.mts @@ -17,7 +17,7 @@ * persistence failed. */ -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { existsSync, mkdirSync, @@ -175,7 +175,7 @@ export function readLinux(account: string): string | undefined { const r = spawnSync( 'secret-tool', ['lookup', 'service', SERVICE, 'user', account], - {stdio: ['ignore', 'pipe', 'pipe'] }, + { stdio: ['ignore', 'pipe', 'pipe'] }, ) if (r.status !== 0) { // secret-tool exits 1 when the entry doesn't exist AND when the @@ -193,7 +193,7 @@ export function readMacOS(account: string): string | undefined { const r = spawnSync( 'security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w'], - {stdio: ['ignore', 'pipe', 'pipe'] }, + { stdio: ['ignore', 'pipe', 'pipe'] }, ) if (r.status !== 0) { return undefined @@ -247,7 +247,7 @@ export function readWindows(account: string): string | undefined { '-Command', `try { (Get-StoredCredential -Target '${SERVICE}:${account}').Password | ConvertFrom-SecureString -AsPlainText } catch { exit 1 }`, ], - {stdio: ['ignore', 'pipe', 'pipe'] }, + { stdio: ['ignore', 'pipe', 'pipe'] }, ) if (ps.status === 0) { const out = String(ps.stdout).trim() @@ -292,7 +292,6 @@ export function writeLinux(token: string, account: string): void { 'secret-tool', ['store', '--label=Socket API token', 'service', SERVICE, 'user', account], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: token, stdio: ['pipe', 'pipe', 'pipe'], }, @@ -327,7 +326,7 @@ export function writeMacOS(token: string, account: string): void { '-l', 'Socket API token', ], - {stdio: ['ignore', 'pipe', 'pipe'] }, + { stdio: ['ignore', 'pipe', 'pipe'] }, ) if (r.status !== 0) { throw new Error( @@ -389,7 +388,6 @@ export function writeWindows(token: string, account: string): void { } catch { exit 1 } ` const ps = spawnSync('powershell', ['-NoProfile', '-Command', psScript], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: token, stdio: ['pipe', 'pipe', 'pipe'], }) @@ -420,7 +418,6 @@ export function writeWindowsDpapiFile(token: string): void { [Convert]::ToBase64String($protected) | Set-Content -Path '${filePath.replace(/'/g, "''")}' -NoNewline ` const ps = spawnSync('powershell', ['-NoProfile', '-Command', psScript], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: token, stdio: ['pipe', 'pipe', 'pipe'], }) diff --git a/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts b/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts index d249e8f..e235d97 100644 --- a/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts +++ b/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import os from 'node:os' import path from 'node:path' import { test } from 'node:test' @@ -24,6 +24,11 @@ test('parses without syntax errors (node --check)', async () => { const child = spawn(process.execPath, ['--check', SCRIPT], { stdio: ['ignore', 'ignore', 'pipe'], }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/setup-signing/README.md b/.claude/hooks/setup-signing/README.md new file mode 100644 index 0000000..7645fd1 --- /dev/null +++ b/.claude/hooks/setup-signing/README.md @@ -0,0 +1,60 @@ +# setup-signing + +Install-only helper that configures git commit signing. Paired with +the pre-commit signing-config gate and pre-push signed-commits +enforcement — those hooks REQUIRE signing; this helper makes the +one-time setup mechanical. + +## Usage + +```sh +node .claude/hooks/setup-signing/install.mts # detect + configure +node .claude/hooks/setup-signing/install.mts --check # report status; exit 0 if configured, 1 if not +node .claude/hooks/setup-signing/install.mts --force # overwrite existing config +``` + +## Detection order + +The helper picks the FIRST available signing method in this order: + +1. **1Password SSH agent** — checks the agent socket and queries + `ssh-add -L`. Recommended path: keys never touch disk, biometric + unlock on use. +2. **SSH key on disk** — `~/.ssh/id_ed25519.pub` (preferred), then + `id_ecdsa.pub`, then `id_rsa.pub`. Sets `user.signingkey` to the + `.pub` path (git's documented convention for SSH signing). +3. **GPG secret key** — `gpg --list-secret-keys --with-colons` first + `sec:` entry. Sets `user.signingkey` to the long key ID and + `gpg.format=openpgp`. + +If none of these are detected, the helper prints setup instructions +for each path and exits 1. + +## What it sets + +For SSH: + +``` +git config --global commit.gpgsign true +git config --global user.signingkey +git config --global gpg.format ssh +# If 1Password path on macOS: +git config --global gpg.ssh.program /Applications/1Password.app/Contents/MacOS/op-ssh-sign +``` + +For GPG: + +``` +git config --global commit.gpgsign true +git config --global user.signingkey +git config --global gpg.format openpgp +``` + +## What it does NOT do + +- **Never generates keys.** Key creation is the user's call. +- **Never uploads keys to GitHub.** The user uploads the public key as + a Signing Key at https://github.com/settings/keys to get the + "Verified" badge on commits. +- **Never disables an existing config.** Without `--force`, the + helper exits early if signing is already configured. diff --git a/.claude/hooks/setup-signing/install.mts b/.claude/hooks/setup-signing/install.mts new file mode 100644 index 0000000..b229ae4 --- /dev/null +++ b/.claude/hooks/setup-signing/install.mts @@ -0,0 +1,287 @@ +#!/usr/bin/env node +/** + * @file Install-only entry point for commit-signing setup. Detects which + * signing method is locally available (SSH keys via 1Password / agent / + * ~/.ssh, GPG via gpg-agent, plain GPG key), and walks the user through `git + * config user.signingkey` + `git config commit.gpgsign true` + `git config + * gpg.format` (ssh|openpgp). Paired with the pre-commit signing-config gate + * and the pre-push signed-commits enforcement. Without signing set up, those + * hooks block commits / pushes; this helper makes the one-time setup + * mechanical. Usage: node .claude/hooks/setup-signing/install.mts node + * .claude/hooks/setup-signing/install.mts --check # report only node + * .claude/hooks/setup-signing/install.mts --force # overwrite existing config + * Auto-detection order (first hit wins): + * + * 1. 1Password SSH agent (SOCK at ~/Library/Group Containers/.../agent.sock). If + * present + has keys, recommend SSH signing routed through 1Password. + * Pros: keys never touch disk; biometric unlock on use. + * 2. ssh-agent or running gpg-agent with loaded keys. SSH preferred over GPG + * when both exist (simpler keyring, no expiry headaches). + * 3. ~/.ssh/id_ed25519.pub (or id_rsa.pub) on disk. Recommend SSH signing using + * that key. + * 4. `gpg --list-secret-keys` produces output. Recommend GPG signing with the + * first secret key. + * 5. Nothing found. Print the setup choices and exit. The helper NEVER generates + * new keys. Key creation is the user's call — the helper only configures + * git to USE keys the user already has. + */ + +import { existsSync } from 'node:fs' +import { homedir, platform } from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +const logger = getDefaultLogger() + +interface CliArgs { + check: boolean + force: boolean +} + +function parseArgs(argv: readonly string[]): CliArgs { + return { + check: argv.includes('--check'), + force: argv.includes('--force'), + } +} + +type SigningFormat = 'ssh' | 'openpgp' + +interface CurrentConfig { + gpgsign: string + signingkey: string + format: string +} + +function readCurrentConfig(): CurrentConfig { + const get = (key: string): string => { + const r = spawnSync('git', ['config', '--global', '--get', key], { + stdio: 'pipe', + stdioString: true, + }) + return r.status === 0 ? String(r.stdout ?? '').trim() : '' + } + return { + gpgsign: get('commit.gpgsign'), + signingkey: get('user.signingkey'), + format: get('gpg.format') || 'openpgp', // git's default + } +} + +interface DetectedSigner { + format: SigningFormat + // The literal `user.signingkey` value to set. + key: string + // Human-readable origin (1Password, ssh-agent, ~/.ssh/id_ed25519.pub, gpg). + source: string +} + +function detect1PasswordSshAgent(): DetectedSigner | undefined { + // macOS: ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock + // Linux: ~/.1password/agent.sock + // Windows: \\\\.\\pipe\\openssh-ssh-agent (different mechanism, skip detection) + let sock: string | undefined + if (platform() === 'darwin') { + sock = path.join( + homedir(), + 'Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock', + ) + } else if (platform() === 'linux') { + sock = path.join(homedir(), '.1password/agent.sock') + } + if (!sock || !existsSync(sock)) { + return undefined + } + // Ask the agent what keys it has. SSH_AUTH_SOCK pointed at 1Password's sock. + const r = spawnSync('ssh-add', ['-L'], { + stdio: 'pipe', + stdioString: true, + env: { ...process.env, SSH_AUTH_SOCK: sock }, + timeout: 5_000, + }) + if (r.status !== 0) { + return undefined + } + // First public-key line is the one to use. + const line = String(r.stdout ?? '') + .split('\n') + .find(l => l.startsWith('ssh-') || l.startsWith('ecdsa-')) + if (!line) { + return undefined + } + return { + format: 'ssh', + // For SSH signing, user.signingkey is the public key string itself + // (or a path to a .pub file). Inline is simpler. + key: line.trim(), + source: '1Password SSH agent', + } +} + +function detectSshKeyOnDisk(): DetectedSigner | undefined { + // Prefer ed25519 over rsa. + const candidates = ['id_ed25519.pub', 'id_ecdsa.pub', 'id_rsa.pub'] + for (const name of candidates) { + const p = path.join(homedir(), '.ssh', name) + if (existsSync(p)) { + return { + format: 'ssh', + // Pointing user.signingkey at the .pub file is the documented git + // convention for SSH signing (git reads the public key from the + // file at sign time). + key: p, + source: `~/.ssh/${name}`, + } + } + } + return undefined +} + +function detectGpgKey(): DetectedSigner | undefined { + const r = spawnSync( + 'gpg', + ['--list-secret-keys', '--keyid-format=long', '--with-colons'], + { + stdio: 'pipe', + stdioString: true, + timeout: 5_000, + }, + ) + if (r.status !== 0) { + return undefined + } + // Parse `--with-colons` machine output. Lines starting with "sec:" are + // secret keys; field 5 is the keygrip / long ID. + const lines = String(r.stdout ?? '').split('\n') + for (const line of lines) { + if (line.startsWith('sec:')) { + const fields = line.split(':') + const keyId = fields[4] + if (keyId) { + return { format: 'openpgp', key: keyId, source: 'gpg secret key' } + } + } + } + return undefined +} + +function detectSigner(): DetectedSigner | undefined { + return detect1PasswordSshAgent() ?? detectSshKeyOnDisk() ?? detectGpgKey() +} + +function configure(signer: DetectedSigner): void { + const set = (key: string, value: string): void => { + spawnSync('git', ['config', '--global', key, value], { stdio: 'inherit' }) + } + set('commit.gpgsign', 'true') + set('user.signingkey', signer.key) + set('gpg.format', signer.format) + if (signer.format === 'ssh' && signer.source === '1Password SSH agent') { + // SSH signing additionally needs a program that can verify signatures + // (op-ssh-sign for 1Password). git uses gpg.ssh.program for signing + // operations. + if (platform() === 'darwin') { + const opSign = '/Applications/1Password.app/Contents/MacOS/op-ssh-sign' + if (existsSync(opSign)) { + set('gpg.ssh.program', opSign) + } + } + } +} + +function reportConfig(c: CurrentConfig): void { + logger.log(` commit.gpgsign: ${c.gpgsign || '(unset)'}`) + logger.log(` user.signingkey: ${c.signingkey || '(unset)'}`) + logger.log(` gpg.format: ${c.format}`) +} + +function reportManualSteps(): void { + logger.log('No usable signing key detected. Choose one:') + logger.log('') + logger.log('Option A — 1Password SSH signing (recommended)') + logger.log(' 1. Open 1Password → Settings → Developer → enable SSH agent') + logger.log( + ' 2. Add SOCK to your shell: export SSH_AUTH_SOCK=~/Library/Group\\ Containers/2BUA8C4S2C.com.1password/t/agent.sock', + ) + logger.log( + ' 3. Create or import an SSH key in 1Password → run this helper again', + ) + logger.log('') + logger.log('Option B — Existing SSH key on disk') + logger.log(' 1. Confirm ~/.ssh/id_ed25519.pub exists') + logger.log(' 2. Run this helper again') + logger.log('') + logger.log('Option C — GPG') + logger.log( + ' 1. Generate: gpg --full-generate-key (RSA 4096 or Ed25519, no expiry preferred for personal use)', + ) + logger.log(' 2. Upload public key to GitHub → Settings → SSH and GPG keys') + logger.log(' 3. Run this helper again') + logger.log('') + logger.log('GitHub-side note: upload the corresponding PUBLIC key as a') + logger.log( + 'Signing Key at https://github.com/settings/keys for "Verified" badges', + ) + logger.log('on web-rendered commits.') +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + logger.log('Commit signing — install / verify') + logger.log('') + + const before = readCurrentConfig() + logger.log('Current git config:') + reportConfig(before) + logger.log('') + + const alreadyConfigured = + before.gpgsign.toLowerCase() === 'true' && Boolean(before.signingkey) + if (alreadyConfigured && !args.force) { + logger.log( + 'Signing is already configured. Pass --force to re-detect and overwrite.', + ) + if (args.check) { + process.exit(0) + } + process.exit(0) + } + + if (args.check) { + logger.log('Signing is NOT configured (or partial).') + process.exit(1) + } + + const signer = detectSigner() + if (!signer) { + reportManualSteps() + process.exit(1) + } + + logger.log(`Detected signer: ${signer.source} (${signer.format})`) + logger.log(`Setting user.signingkey to:`) + logger.log(` ${signer.key}`) + logger.log('') + configure(signer) + + const after = readCurrentConfig() + logger.log('Updated git config:') + reportConfig(after) + logger.log('') + logger.log( + 'Done. The next commit will be signed automatically. Pre-commit and', + ) + logger.log('pre-push gates will accept it.') + logger.log('') + logger.log('GitHub-side: upload the public key as a Signing Key at') + logger.log(' https://github.com/settings/keys') + logger.log('so commits show as "Verified" in the GitHub UI.') +} + +main().catch(err => { + logger.error(String(err?.message ?? err)) + process.exit(1) +}) diff --git a/.claude/hooks/setup-signing/package.json b/.claude/hooks/setup-signing/package.json new file mode 100644 index 0000000..256f60e --- /dev/null +++ b/.claude/hooks/setup-signing/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-setup-signing", + "private": true, + "type": "module", + "main": "./install.mts", + "exports": { + ".": "./install.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/setup-signing/tsconfig.json b/.claude/hooks/setup-signing/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/setup-signing/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/index.mts b/.claude/hooks/soak-exclude-date-annotation-guard/index.mts index 9f46c50..577039f 100644 --- a/.claude/hooks/soak-exclude-date-annotation-guard/index.mts +++ b/.claude/hooks/soak-exclude-date-annotation-guard/index.mts @@ -34,7 +34,10 @@ import process from 'node:process' +import { bypassPhrasePresent } from '../_shared/transcript.mts' + const ALLOW_MARKER = '# socket-hook: allow soak-exclude-no-date-annotation' +const BYPASS_PHRASE = 'Allow soak-exclude-no-date-annotation bypass' // Matches the section header for the soak-exclude block. const SECTION_HEADER = /^minimumReleaseAgeExclude:\s*$/ @@ -65,6 +68,7 @@ const ANNOTATION_RE = interface Hook { tool_name?: string | undefined + transcript_path?: string | undefined tool_input?: | { file_path?: string | undefined @@ -158,6 +162,9 @@ function main(): void { if (orphans.length === 0) { process.exit(0) } + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + process.exit(0) + } const today = new Date().toISOString().slice(0, 10) const exampleRemovable = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) .toISOString() diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts b/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts index d3aa28e..e84477c 100644 --- a/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts +++ b/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { describe, test } from 'node:test' import { fileURLToPath } from 'node:url' @@ -20,6 +20,11 @@ interface RunResult { function runHook(payload: object): Promise { return new Promise((resolve, reject) => { const child = spawn('node', [HOOK], { stdio: ['pipe', 'pipe', 'pipe'] }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/socket-token-minifier-start/index.mts b/.claude/hooks/socket-token-minifier-start/index.mts index a51b35b..9778d12 100644 --- a/.claude/hooks/socket-token-minifier-start/index.mts +++ b/.claude/hooks/socket-token-minifier-start/index.mts @@ -17,13 +17,13 @@ // Time budget: ~3 seconds total. Anything slower than that holds the // SessionStart hook chain and the user feels it. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { appendFileSync, existsSync } from 'node:fs' import http from 'node:http' import path from 'node:path' import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { getSocketAppDir } from '@socketsecurity/lib-stable/paths/socket' const logger = getDefaultLogger() diff --git a/.claude/hooks/squash-history-reminder/README.md b/.claude/hooks/squash-history-reminder/README.md new file mode 100644 index 0000000..22d6140 --- /dev/null +++ b/.claude/hooks/squash-history-reminder/README.md @@ -0,0 +1,36 @@ +# squash-history-reminder + +Stop hook that nudges the operator toward the `squashing-history` skill when an opted-in fleet repo's default branch has grown beyond a configurable commit threshold. + +## Why + +A subset of fleet repos (currently `socket-addon`, `socket-bin`, `socket-btm`, `sdxgen`, `stuie`) periodically squash the default branch to a single "Initial commit" — the convention exists for repos where deep history is more confusing than useful (binary publishing forwards, scratchpad tooling, etc.). The opt-in is declared centrally in `template/.claude/skills/cascading-fleet/lib/fleet-repos.json` under each repo's `optIns: ['squash-history']` array. + +The hook is a soft reminder, not a blocker. It fires at end-of-turn when all three are true: + +1. The current repo is on the opt-in list. +2. The current branch is the repo's default branch (`main` / `master` — resolved per the fleet's _Default branch fallback_ rule). +3. The default branch has > `SOCKET_SQUASH_HISTORY_COMMIT_THRESHOLD` commits (default 50). + +When all three fire, stderr emits a one-paragraph reminder pointing at the `squashing-history` skill. + +## Bypass + +User types **`Allow squash-history-reminder bypass`** verbatim in a recent message (within the last 8 user turns). Case-sensitive; paraphrases don't count. + +Or set `SOCKET_SQUASH_HISTORY_REMINDER_DISABLED=1` in the env to disable entirely. + +## Configuration + +- `SOCKET_SQUASH_HISTORY_COMMIT_THRESHOLD` — integer; default 50. Below this count, the hook stays silent. +- `SOCKET_SQUASH_HISTORY_REMINDER_DISABLED` — any truthy value short-circuits the hook. + +## Failing open + +The hook fails open on its own bugs (the catch in `main()`). A buggy hook can never block the session. + +## Related + +- `.claude/skills/squashing-history/SKILL.md` — the canonical squash-history skill (does the actual work). +- `.claude/skills/cascading-fleet/lib/fleet-repos.json` — the roster + opt-in declarations. +- `.claude/hooks/default-branch-guard/` — sibling hook that enforces `main → master` fallback wherever the default branch is hard-coded. diff --git a/.claude/hooks/squash-history-reminder/index.mts b/.claude/hooks/squash-history-reminder/index.mts new file mode 100644 index 0000000..07b4631 --- /dev/null +++ b/.claude/hooks/squash-history-reminder/index.mts @@ -0,0 +1,220 @@ +#!/usr/bin/env node +// Claude Code Stop hook — squash-history-reminder. +// +// Reminds the operator about the `squashing-history` skill when: +// 1. The current repo's `name` (from the local git remote OR +// basename of the working tree) is listed in the fleet +// roster's `optIns: ['squash-history']` set. +// 2. The current branch is the repo's default branch (per the +// fleet's _Default branch fallback_ rule — main → master). +// 3. The default branch has more than HISTORY_COMMIT_THRESHOLD +// commits (default 50). Tunable via env. +// +// The reminder is a soft one-liner; pairs with the +// `template/.claude/skills/squashing-history/SKILL.md` skill that +// does the actual squash. +// +// Bypass phrase: `Allow squash-history-reminder bypass`. Disable +// entirely via SOCKET_SQUASH_HISTORY_REMINDER_DISABLED. + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +// prefer-async-spawn: sync-required — hook fires synchronously at +// turn-end and must finish before stdin/stdout close. +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +const BYPASS_PHRASE = 'Allow squash-history-reminder bypass' +const BYPASS_LOOKBACK_USER_TURNS = 8 +const HISTORY_COMMIT_THRESHOLD = Number.parseInt( + process.env['SOCKET_SQUASH_HISTORY_COMMIT_THRESHOLD'] ?? '50', + 10, +) + +interface StopPayload { + readonly transcript_path?: string | undefined + readonly cwd?: string | undefined +} + +interface FleetRepo { + readonly name: string + readonly optIns?: readonly string[] | undefined +} + +interface FleetRoster { + readonly repos: readonly FleetRepo[] +} + +function gitSafe(cwd: string, args: string[]): string | undefined { + const r = spawnSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (r.status !== 0 || typeof r.stdout !== 'string') { + return undefined + } + return r.stdout.trim() +} + +/** + * Identify the canonical repo name. Prefer the GitHub remote (handles checkout + * dir renames like `socket-cli-fix-foo`); fall back to the working-tree + * basename. + */ +export function resolveRepoName(cwd: string): string | undefined { + const remote = gitSafe(cwd, ['config', '--get', 'remote.origin.url']) + if (remote) { + // git@github.com:Org/repo.git OR https://github.com/Org/repo(.git)? + const m = /[/:]([^/:]+?)(?:\.git)?$/.exec(remote) + if (m && m[1]) { + return m[1] + } + } + const base = path.basename(cwd) + return base || undefined +} + +export function readRoster(rosterPath: string): FleetRoster | undefined { + if (!existsSync(rosterPath)) { + return undefined + } + try { + const raw = readFileSync(rosterPath, 'utf8') + return JSON.parse(raw) as FleetRoster + } catch { + return undefined + } +} + +export function isOptedIn( + roster: FleetRoster, + repoName: string, + optIn: string, +): boolean { + for (let i = 0, { length } = roster.repos; i < length; i += 1) { + const r = roster.repos[i]! + if (r.name === repoName) { + return (r.optIns ?? []).includes(optIn) + } + } + return false +} + +function defaultBranch(cwd: string): string { + const sym = gitSafe(cwd, ['symbolic-ref', 'refs/remotes/origin/HEAD']) + if (sym) { + return sym.replace(/^refs\/remotes\/origin\//, '') + } + for (const candidate of ['main', 'master']) { + if ( + gitSafe(cwd, [ + 'show-ref', + '--verify', + '--quiet', + `refs/remotes/origin/${candidate}`, + ]) !== undefined + ) { + return candidate + } + } + return 'main' +} + +function currentBranch(cwd: string): string | undefined { + return gitSafe(cwd, ['branch', '--show-current']) +} + +function commitCount(cwd: string, ref: string): number { + const out = gitSafe(cwd, ['rev-list', '--count', ref]) + if (out === undefined) { + return 0 + } + const n = Number.parseInt(out, 10) + return Number.isFinite(n) ? n : 0 +} + +async function main(): Promise { + if (process.env['SOCKET_SQUASH_HISTORY_REMINDER_DISABLED']) { + return + } + const raw = await readStdin() + if (!raw.trim()) { + return + } + let payload: StopPayload + try { + payload = JSON.parse(raw) as StopPayload + } catch { + return + } + const cwd = payload.cwd ?? process.cwd() + + const repoRoot = gitSafe(cwd, ['rev-parse', '--show-toplevel']) ?? cwd + const rosterCandidates = [ + path.join( + repoRoot, + 'template/.claude/skills/cascading-fleet/lib/fleet-repos.json', + ), + path.join(repoRoot, '.claude/skills/cascading-fleet/lib/fleet-repos.json'), + ] + let roster: FleetRoster | undefined + for (let i = 0, { length } = rosterCandidates; i < length; i += 1) { + roster = readRoster(rosterCandidates[i]!) + if (roster) { + break + } + } + if (!roster) { + return + } + + const repoName = resolveRepoName(repoRoot) + if (!repoName) { + return + } + if (!isOptedIn(roster, repoName, 'squash-history')) { + return + } + + const branch = currentBranch(repoRoot) + const base = defaultBranch(repoRoot) + if (branch !== base) { + return + } + + const count = commitCount(repoRoot, branch) + if (count <= HISTORY_COMMIT_THRESHOLD) { + return + } + + if ( + bypassPhrasePresent( + payload.transcript_path, + BYPASS_PHRASE, + BYPASS_LOOKBACK_USER_TURNS, + ) + ) { + return + } + + process.stderr.write( + [ + `💡 squash-history-reminder: ${repoName} is opted into the squash-history convention.`, + ` The default branch \`${branch}\` has ${count} commits (threshold ${HISTORY_COMMIT_THRESHOLD}).`, + ` Consider running the \`squashing-history\` skill to collapse to a single Initial commit.`, + ` Skill: .claude/skills/squashing-history/SKILL.md`, + ` Suppress for this session: type "${BYPASS_PHRASE}" verbatim, or set`, + ` SOCKET_SQUASH_HISTORY_REMINDER_DISABLED=1 to disable entirely.`, + '', + ].join('\n'), + ) +} + +main().catch(e => { + process.stderr.write( + `squash-history-reminder: hook error (continuing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/squash-history-reminder/package.json b/.claude/hooks/squash-history-reminder/package.json new file mode 100644 index 0000000..e3f4af7 --- /dev/null +++ b/.claude/hooks/squash-history-reminder/package.json @@ -0,0 +1,18 @@ +{ + "name": "hook-squash-history-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "dependencies": { + "@socketsecurity/lib-stable": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/squash-history-reminder/test/index.test.mts b/.claude/hooks/squash-history-reminder/test/index.test.mts new file mode 100644 index 0000000..189593c --- /dev/null +++ b/.claude/hooks/squash-history-reminder/test/index.test.mts @@ -0,0 +1,51 @@ +// node --test specs for squash-history-reminder hook helpers. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { isOptedIn, resolveRepoName } from '../index.mts' + +test('isOptedIn returns true for an opted-in repo', () => { + const roster = { + repos: [ + { name: 'socket-btm', optIns: ['squash-history'] }, + { name: 'socket-cli' }, + ], + } + assert.strictEqual(isOptedIn(roster, 'socket-btm', 'squash-history'), true) +}) + +test('isOptedIn returns false for a non-opted-in repo', () => { + const roster = { + repos: [ + { name: 'socket-btm', optIns: ['squash-history'] }, + { name: 'socket-cli' }, + ], + } + assert.strictEqual(isOptedIn(roster, 'socket-cli', 'squash-history'), false) +}) + +test('isOptedIn returns false for a repo missing from the roster', () => { + const roster = { + repos: [{ name: 'socket-btm', optIns: ['squash-history'] }], + } + assert.strictEqual(isOptedIn(roster, 'unknown-repo', 'squash-history'), false) +}) + +test('isOptedIn returns false for a different opt-in name', () => { + const roster = { + repos: [{ name: 'socket-btm', optIns: ['squash-history'] }], + } + assert.strictEqual(isOptedIn(roster, 'socket-btm', 'other-opt-in'), false) +}) + +test('resolveRepoName falls back to cwd basename if no git remote', () => { + // Use a real path to verify basename extraction; the function tries + // git first but will silently fail in /tmp (no remote configured). + const result = resolveRepoName('/tmp/socket-imaginary') + // Result is either the basename OR a real remote name if /tmp happens + // to be inside a git checkout (unlikely). Both are valid; the + // important thing is the function returns *something* string-shaped. + assert.strictEqual(typeof result, 'string') + assert.ok((result?.length ?? 0) > 0) +}) diff --git a/.claude/hooks/squash-history-reminder/tsconfig.json b/.claude/hooks/squash-history-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/squash-history-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/stale-process-sweeper/index.mts b/.claude/hooks/stale-process-sweeper/index.mts index 5f476df..9b367e4 100644 --- a/.claude/hooks/stale-process-sweeper/index.mts +++ b/.claude/hooks/stale-process-sweeper/index.mts @@ -31,7 +31,7 @@ // Stop hooks receive JSON on stdin (we don't read it; the body // shape is irrelevant to our work) and exit code is advisory. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' // Process-name patterns that indicate a stale test/build worker. diff --git a/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts b/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts index 4f7adfa..bdcd520 100644 --- a/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts +++ b/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts @@ -1,7 +1,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { fileURLToPath } from 'node:url' import path from 'node:path' import { test } from 'node:test' @@ -17,6 +17,11 @@ function runHook(): Promise<{ code: number; stderr: string }> { const child = spawn(process.execPath, [HOOK], { stdio: ['pipe', 'ignore', 'pipe'], }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) let stderr = '' child.process.stderr!.on('data', d => { stderr += d.toString() diff --git a/.claude/hooks/sweep-ds-store/README.md b/.claude/hooks/sweep-ds-store/README.md new file mode 100644 index 0000000..eb8fdd5 --- /dev/null +++ b/.claude/hooks/sweep-ds-store/README.md @@ -0,0 +1,45 @@ +# sweep-ds-store + +Stop hook that sweeps `.DS_Store` files at turn-end. Excludes `.git/` +and `node_modules/`. Silent on the happy path; logs sweep count when +files are found. + +## Why + +`.DS_Store` is gitignored fleet-wide, but the files still exist on +disk. They surface in: + +- `find` output, polluting search results +- `git status --ignored` reports +- non-git tooling (rsync, tar, zip artifacts) +- Spotlight indexing churn + +The right fix is to delete them, not just ignore them. The hook runs +at every turn-end (the same time `stale-process-sweeper` runs), so +files Finder created mid-session are gone before the next turn. + +## Behavior + +- Walks the worktree starting at `$CLAUDE_PROJECT_DIR` (or `cwd` as + fallback) +- Skips `.git/` and `node_modules/` subtrees +- Doesn't follow symlinks +- Max depth: 12 (defense against pathological symlink loops) +- Per-file delete errors are logged but never block the hook + +## Output + +Silent unless files were found. Output goes to stderr: + +``` +[sweep-ds-store] swept 3 .DS_Store file(s): + .DS_Store + src/.DS_Store + test/fixtures/.DS_Store +``` + +## Bypass + +None — `.DS_Store` is never wanted in a repo. If you have a reason +to keep one (very rare; testing macOS-specific tooling), name it +`.DS_Store.fixture` and adjust the test. diff --git a/.claude/hooks/sweep-ds-store/index.mts b/.claude/hooks/sweep-ds-store/index.mts new file mode 100644 index 0000000..c5632e6 --- /dev/null +++ b/.claude/hooks/sweep-ds-store/index.mts @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Claude Code Stop hook — sweep-ds-store. +// +// Fires at turn-end. Walks the worktree (current working directory) +// and deletes any `.DS_Store` files Finder created mid-session. +// Excludes `.git/` and `node_modules/` so we don't churn through +// directories full of vendor noise. +// +// Why a hook instead of `.gitignore` alone: +// `.DS_Store` is gitignored fleet-wide, but the FILES themselves +// still exist on disk. They surface in: +// - `find` output, polluting search results +// - `git status --ignored` reports +// - non-git tooling (rsync, tar, zip) +// - Spotlight indexing churn +// The right fix is to delete them, not just ignore them. +// +// Silent on the happy path. When files are found, logs: +// +// [sweep-ds-store] swept N .DS_Store file(s): +// ./path/to/.DS_Store +// ... +// +// No bypass — `.DS_Store` is never wanted in a repo. If you have a +// reason to keep one (very rare — testing macOS-specific code), use +// a name like `.DS_Store.fixture` and adjust the test fixture. +// +// Stop hooks receive a JSON payload on stdin but the body shape is +// irrelevant here; we ignore it. Drains the pipe so the upstream +// doesn't buffer-stall. + +import { existsSync, promises as fs } from 'node:fs' +import type { Dirent } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' + +const TARGET = '.DS_Store' +const EXCLUDE_DIRS = new Set(['.git', 'node_modules']) +const MAX_DEPTH = 12 + +interface SweepResult { + readonly swept: readonly string[] + readonly errors: readonly string[] +} + +/** + * Recursively walk `root`, deleting every `.DS_Store` found. Returns the list + * of deleted paths (relative to `root`) and any per-file delete errors. Never + * throws — Stop hooks must not block the conversation on their own bugs. + * + * `MAX_DEPTH` is a defense against pathological symlink loops; the worktrees we + * run on don't nest anywhere near that deep. + */ +export async function sweepDsStore(root: string): Promise { + const swept: string[] = [] + const errors: string[] = [] + await walk(root, root, 0, swept, errors) + return { swept, errors } +} + +async function walk( + root: string, + dir: string, + depth: number, + swept: string[], + errors: string[], +): Promise { + if (depth > MAX_DEPTH) { + return + } + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + // Permission denied, race with another process, etc. Skip the + // dir; never block the hook. + return + } + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i]! + const name = entry.name + const full = path.join(dir, name) + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(name)) { + continue + } + // Avoid following symlinks — keeps the walk to the working + // tree, not whatever a symlink points at. + if (entry.isSymbolicLink()) { + continue + } + await walk(root, full, depth + 1, swept, errors) + continue + } + if (name === TARGET) { + try { + await safeDelete(full) + swept.push(path.relative(root, full)) + } catch (e) { + errors.push(`${path.relative(root, full)}: ${(e as Error).message}`) + } + } + } +} + +async function main(): Promise { + // Drain stdin so the upstream pipe doesn't buffer-stall, but ignore + // the body — Stop hooks pass a JSON payload that we don't need. + process.stdin.resume() + process.stdin.on('data', () => {}) + // Short timeout — if stdin never closes we still want to run. + await new Promise(resolve => { + process.stdin.on('end', () => resolve()) + setTimeout(() => resolve(), 100) + }) + + const root = process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() + if (!existsSync(root)) { + return + } + const { swept, errors } = await sweepDsStore(root) + if (swept.length === 0 && errors.length === 0) { + return + } + const lines: string[] = [] + if (swept.length > 0) { + lines.push(`[sweep-ds-store] swept ${swept.length} .DS_Store file(s):`) + for (let i = 0, { length } = swept; i < length; i += 1) { + lines.push(` ${swept[i]!}`) + } + } + if (errors.length > 0) { + lines.push(`[sweep-ds-store] ${errors.length} delete error(s):`) + for (let i = 0, { length } = errors; i < length; i += 1) { + lines.push(` ${errors[i]!}`) + } + } + process.stderr.write(lines.join(os.EOL) + os.EOL) +} + +// CLI entrypoint — only fires when this file is the main module so +// the test importer can pull `sweepDsStore` without triggering the +// stdin reader. +if (process.argv[1] && process.argv[1].endsWith('index.mts')) { + main().catch(e => { + process.stderr.write( + `[sweep-ds-store] hook error (allowing): ${(e as Error).message}${os.EOL}`, + ) + }) +} diff --git a/.claude/hooks/sweep-ds-store/package.json b/.claude/hooks/sweep-ds-store/package.json new file mode 100644 index 0000000..cdfcfeb --- /dev/null +++ b/.claude/hooks/sweep-ds-store/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-sweep-ds-store", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/sweep-ds-store/test/index.test.mts b/.claude/hooks/sweep-ds-store/test/index.test.mts new file mode 100644 index 0000000..005bdd1 --- /dev/null +++ b/.claude/hooks/sweep-ds-store/test/index.test.mts @@ -0,0 +1,115 @@ +/** + * @file Unit tests for sweepDsStore — the recursive .DS_Store remover used by + * the Stop hook. Uses real temp dirs (cheap, < 50ms total) rather than + * mocks. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { sweepDsStore } from '../index.mts' + +function setup(): string { + return mkdtempSync(path.join(os.tmpdir(), 'sweep-ds-store-test-')) +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { force: true, recursive: true }) + } catch { + // best-effort + } +} + +test('sweeps a top-level .DS_Store', async () => { + const root = setup() + try { + writeFileSync(path.join(root, '.DS_Store'), 'binary-junk') + const result = await sweepDsStore(root) + assert.equal(result.swept.length, 1) + assert.equal(result.swept[0], '.DS_Store') + assert.equal(existsSync(path.join(root, '.DS_Store')), false) + } finally { + cleanup(root) + } +}) + +test('sweeps nested .DS_Store files', async () => { + const root = setup() + try { + mkdirSync(path.join(root, 'a', 'b'), { recursive: true }) + writeFileSync(path.join(root, '.DS_Store'), 'x') + writeFileSync(path.join(root, 'a', '.DS_Store'), 'x') + writeFileSync(path.join(root, 'a', 'b', '.DS_Store'), 'x') + const result = await sweepDsStore(root) + assert.equal(result.swept.length, 3) + assert.equal(existsSync(path.join(root, 'a', 'b', '.DS_Store')), false) + } finally { + cleanup(root) + } +}) + +test('skips .git/', async () => { + const root = setup() + try { + mkdirSync(path.join(root, '.git'), { recursive: true }) + writeFileSync(path.join(root, '.git', '.DS_Store'), 'x') + writeFileSync(path.join(root, '.DS_Store'), 'x') + const result = await sweepDsStore(root) + assert.equal(result.swept.length, 1) + assert.equal(result.swept[0], '.DS_Store') + // .git/.DS_Store still exists + assert.equal(existsSync(path.join(root, '.git', '.DS_Store')), true) + } finally { + cleanup(root) + } +}) + +test('skips node_modules/', async () => { + const root = setup() + try { + mkdirSync(path.join(root, 'node_modules', 'foo'), { recursive: true }) + writeFileSync(path.join(root, 'node_modules', 'foo', '.DS_Store'), 'x') + writeFileSync(path.join(root, '.DS_Store'), 'x') + const result = await sweepDsStore(root) + assert.equal(result.swept.length, 1) + assert.equal(result.swept[0], '.DS_Store') + } finally { + cleanup(root) + } +}) + +test('ignores other files with similar names', async () => { + const root = setup() + try { + writeFileSync(path.join(root, '.DS_Store.fixture'), 'x') + writeFileSync(path.join(root, '_DS_Store'), 'x') + writeFileSync(path.join(root, '.ds_store'), 'x') + const result = await sweepDsStore(root) + assert.equal(result.swept.length, 0) + assert.equal(existsSync(path.join(root, '.DS_Store.fixture')), true) + } finally { + cleanup(root) + } +}) + +test('empty directory tree returns empty result', async () => { + const root = setup() + try { + const result = await sweepDsStore(root) + assert.equal(result.swept.length, 0) + assert.equal(result.errors.length, 0) + } finally { + cleanup(root) + } +}) + +test('non-existent root does not throw', async () => { + const result = await sweepDsStore('/nonexistent-path-for-test') + assert.equal(result.swept.length, 0) + assert.equal(result.errors.length, 0) +}) diff --git a/.claude/hooks/sweep-ds-store/tsconfig.json b/.claude/hooks/sweep-ds-store/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/sweep-ds-store/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/token-guard/README.md b/.claude/hooks/token-guard/README.md index 91cbfb0..ab713a2 100644 --- a/.claude/hooks/token-guard/README.md +++ b/.claude/hooks/token-guard/README.md @@ -44,18 +44,18 @@ If a literal value matching one of these prefixes appears in a Bash command, it gets blocked outright (the assumption being that a value this shape is not idle text): -| Provider | Prefix | -| ------------------ | ----------------------------------------------------- | -| Val Town | `vtwn_` | -| Linear | `lin_api_` | -| OpenAI / Anthropic | `sk-` (20+ chars) | -| Stripe | `sk_live_`, `sk_test_`, `pk_live_`, `rk_live_` | -| GitHub | `ghp_`, `gho_`, `ghs_`, `ghu_`, `ghr_`, `github_pat_` | -| GitLab | `glpat-` | -| AWS | `AKIA…` | -| Slack | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` | -| Google | `AIza…` | -| JWTs | three-segment `eyJ…` | +| Provider | Prefix | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Val Town | `vtwn_` | +| Linear | `lin_api_` | +| OpenAI / Anthropic | `sk-` (20+ chars) | +| Stripe | `sk_live_`, `sk_test_`, `pk_live_`, `rk_live_` | +| GitHub | `ghp_`, `gho_`, `ghs_`, `ghu_`, `ghr_`, `github_pat_` (`ghs_`/`ghu_` match both classic opaque + new JWT format per the 2026-05-15 token-format rollout) | +| GitLab | `glpat-` | +| AWS | `AKIA…` | +| Slack | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` | +| Google | `AIza…` | +| JWTs | three-segment `eyJ…` | ## Fail-open on hook bugs diff --git a/.claude/hooks/token-guard/index.mts b/.claude/hooks/token-guard/index.mts index d8026dd..cec194c 100644 --- a/.claude/hooks/token-guard/index.mts +++ b/.claude/hooks/token-guard/index.mts @@ -79,8 +79,17 @@ const LITERAL_TOKEN_PATTERNS: Array<[RegExp, string]> = [ [/\brk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live restricted (rk_live_)'], [/\bghp_[A-Za-z0-9]{30,}/, 'GitHub personal access token (ghp_)'], [/\bgho_[A-Za-z0-9]{30,}/, 'GitHub OAuth token (gho_)'], - [/\bghs_[A-Za-z0-9]{30,}/, 'GitHub app server token (ghs_)'], - [/\bghu_[A-Za-z0-9]{30,}/, 'GitHub user access token (ghu_)'], + // ghs_ and ghu_ char classes include `.` and `_` to match both the + // classic opaque format AND the new stateless JWT format GitHub is + // rolling out (announced 2026-04, opt-in via X-GitHub-Stateless-S2S-Token + // header per 2026-05-15 changelog). JWT-format tokens are ~520 chars + // and contain two dots; classic opaque tokens are short and have no + // dots. The recommended regex from GitHub's docs is + // `ghs_[A-Za-z0-9\._]{36,}` — 36 is the minimum for both formats. + // Same applies to ghu_ prophylactically since user-to-server tokens + // are scheduled for the same format change (timing TBD per changelog). + [/\bghs_[A-Za-z0-9._]{36,}/, 'GitHub app server token (ghs_)'], + [/\bghu_[A-Za-z0-9._]{36,}/, 'GitHub user access token (ghu_)'], [/\bghr_[A-Za-z0-9]{30,}/, 'GitHub refresh token (ghr_)'], [/\bgithub_pat_[A-Za-z0-9_]{20,}/, 'GitHub fine-grained PAT'], [/\bglpat-[A-Za-z0-9_-]{16,}/, 'GitLab PAT (glpat-)'], diff --git a/.claude/hooks/token-guard/test/token-guard.test.mts b/.claude/hooks/token-guard/test/token-guard.test.mts index 1270e34..475cafb 100644 --- a/.claude/hooks/token-guard/test/token-guard.test.mts +++ b/.claude/hooks/token-guard/test/token-guard.test.mts @@ -8,8 +8,8 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { whichSync } from '@socketsecurity/lib-stable/bin' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { whichSync } from '@socketsecurity/lib-stable/bin/which' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' const hookScript = new URL('../index.mts', import.meta.url).pathname const nodeBinRaw = whichSync('node') @@ -31,7 +31,6 @@ function runHook( tool_input: { command }, }) const result = spawnSync(nodeBin, [hookScript], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input, timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'], @@ -103,6 +102,38 @@ describe('token-guard hook', () => { assert.equal(r.code, 2) assert.match(r.stderr, /GitHub personal access token/) }) + it('GitHub app server token (ghs_) — classic opaque format', () => { + // Classic format: opaque string, no dots, no underscores. Real + // `ghs_` server tokens are 36+ chars after the prefix; the + // minimum-length floor in the regex matches both classic and + // new JWT-format tokens. + const r = runHook('echo ghs_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij') + assert.equal(r.code, 2) + assert.match(r.stderr, /GitHub app server token/) + }) + it('GitHub app server token (ghs_) — new JWT format with dots', () => { + // New stateless JWT format (2026 rollout): ghs_ prefix + JWT body + // with two dots. Recommended detection regex per GitHub docs is + // `ghs_[A-Za-z0-9\._]{36,}`. Real JWTs are ~520 chars; this fixture + // is a shorter synthetic that still hits both characteristics + // (length >= 36, contains dots). + const r = runHook( + 'echo ghs_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_part_abcdef123456', + ) + assert.equal(r.code, 2) + assert.match(r.stderr, /GitHub app server token/) + }) + it('GitHub user access token (ghu_) — JWT format prophylactic', () => { + // User-to-server tokens are scheduled for the same JWT format + // change per the 2026-05-15 changelog (timing TBD). The ghu_ + // pattern uses the same char class so the future rollout is + // covered when it ships. + const r = runHook( + 'echo ghu_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature_part_abcdef123456', + ) + assert.equal(r.code, 2) + assert.match(r.stderr, /GitHub user access token/) + }) it('AWS access key', () => { const r = runHook('echo AKIAIOSFODNN7EXAMPLE') assert.equal(r.code, 2) @@ -196,7 +227,6 @@ describe('token-guard hook', () => { describe('fails open on malformed input', () => { it('empty stdin', () => { const r = spawnSync(nodeBin, [hookScript], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '', timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'], @@ -205,7 +235,6 @@ describe('token-guard hook', () => { }) it('non-JSON stdin', () => { const r = spawnSync(nodeBin, [hookScript], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: 'not json', timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/.claude/hooks/variant-analysis-reminder/test/index.test.mts b/.claude/hooks/variant-analysis-reminder/test/index.test.mts index c73bb08..cbffdd1 100644 --- a/.claude/hooks/variant-analysis-reminder/test/index.test.mts +++ b/.claude/hooks/variant-analysis-reminder/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -46,7 +46,6 @@ function makeTranscript( function runHook(transcriptPath: string): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: transcriptPath }), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -172,7 +171,6 @@ test('disabled env var short-circuits', () => { const { path: p, cleanup } = makeTranscript('Critical: bug found') try { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ transcript_path: p }), env: { ...process.env, SOCKET_VARIANT_ANALYSIS_REMINDER_DISABLED: '1' }, }) diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts b/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts index 333beef..7f15903 100644 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts +++ b/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts @@ -18,7 +18,7 @@ // // No-op when the staged set is purely non-UI source. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { readFileSync } from 'node:fs' import process from 'node:process' @@ -33,7 +33,7 @@ interface ToolInput { // Files whose changes likely affect rendered output. const UI_FILE_RE = - /\.(astro|css|ejs|handlebars|hbs|htm|html|less|njk|sass|scss|svelte|vue)$/i + /\.(?:astro|css|ejs|handlebars|hbs|htm|html|less|njk|sass|scss|svelte|vue)$/i // Build-script patterns. Conservative — match the common fleet shapes: // `pnpm run build`, `pnpm build`, `node scripts/.mts`, `pnpm tour`, diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts b/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts index fbaefb7..5d19bab 100644 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts +++ b/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts @@ -1,6 +1,9 @@ // node --test specs for the verify-rendered-output-before-commit-reminder hook. -import { spawn, spawnSync } from '@socketsecurity/lib-stable/spawn' +import { + spawn, + spawnSync, +} from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -37,6 +40,11 @@ function mkTranscript(entries: object[]): string { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { @@ -101,7 +109,9 @@ test('commit with UI files + recent build + no verify — reminder fires', async ]), }) assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('verify-rendered-output-before-commit-reminder')) + assert.ok( + String(r.stderr).includes('verify-rendered-output-before-commit-reminder'), + ) }) test('commit with UI files + build + later user verify — no reminder', async () => { diff --git a/.claude/hooks/version-bump-order-guard/index.mts b/.claude/hooks/version-bump-order-guard/index.mts index 130e328..037ebb0 100644 --- a/.claude/hooks/version-bump-order-guard/index.mts +++ b/.claude/hooks/version-bump-order-guard/index.mts @@ -21,9 +21,10 @@ // Bypass: "Allow version-bump-order bypass" in a recent user turn, or // SOCKET_VERSION_BUMP_ORDER_GUARD_DISABLED=1. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' +import { commandsFor } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' interface PreToolUsePayload { @@ -39,14 +40,21 @@ const BYPASS_PHRASES = [ 'Allow versionbumporder bypass', ] as const -// `git tag ` (also `git tag -a`, `git tag -s`, etc.). We want -// version tags specifically (`vX.Y.Z`). -const VERSION_TAG_RE = /\bgit\s+tag\b[^|;&\n]*\bv\d+\.\d+\.\d+\b/ +// `git tag ` (also `git tag -a`, `git tag -s`, etc.) creating a +// version tag (`vX.Y.Z`). Parser-based: a real `git` command with a +// `tag` arg and a version-shaped arg — so a quoted "git tag v1.2.3" in +// a message or a sibling command's string isn't a false trigger. +const VERSION_ARG_RE = /^v\d+\.\d+\.\d+$/ +function isVersionTagCommand(command: string): boolean { + return commandsFor(command, 'git').some( + c => c.args.includes('tag') && c.args.some(a => VERSION_ARG_RE.test(a)), + ) +} // Subject patterns that count as a "bump commit". Matches Keep-a- // Changelog style and Conventional Commits style. const BUMP_SUBJECT_RE = - /^(chore(?:\([\w-]+\))?:\s+(?:bump version to|release)\s+v?\d+\.\d+\.\d+|chore(?:\([\w-]+\))?:\s+v?\d+\.\d+\.\d+\s+release)/i + /^(?:chore(?:\([\w-]+\))?:\s+(?:bump version to|release)\s+v?\d+\.\d+\.\d+|chore(?:\([\w-]+\))?:\s+v?\d+\.\d+\.\d+\s+release)/i async function main(): Promise { if (process.env['SOCKET_VERSION_BUMP_ORDER_GUARD_DISABLED']) { @@ -66,7 +74,7 @@ async function main(): Promise { if (typeof command !== 'string') { process.exit(0) } - if (!VERSION_TAG_RE.test(command)) { + if (!isVersionTagCommand(command)) { process.exit(0) } if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { @@ -75,11 +83,7 @@ async function main(): Promise { // Read the most-recent commit subject from HEAD. const opts = payload.cwd ? { cwd: payload.cwd } : {} - const subjectResult = spawnSync( - 'git', - ['log', '-1', '--pretty=%s'], - opts, - ) + const subjectResult = spawnSync('git', ['log', '-1', '--pretty=%s'], opts) if (subjectResult.status !== 0) { // Not a git repo or git unavailable — fail open. process.exit(0) diff --git a/.claude/hooks/version-bump-order-guard/test/index.test.mts b/.claude/hooks/version-bump-order-guard/test/index.test.mts index 2e3c810..283970c 100644 --- a/.claude/hooks/version-bump-order-guard/test/index.test.mts +++ b/.claude/hooks/version-bump-order-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -46,7 +46,6 @@ function runHook( extraEnv: Record = {}, ): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, @@ -112,7 +111,6 @@ test('ALLOWS git tag with non-version label (no enforcement)', () => { test('IGNORES non-Bash tools', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify({ tool_name: 'Write', tool_input: { command: 'git tag v1.0.0' }, diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts b/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts index b58e240..a80f20f 100644 --- a/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts +++ b/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -40,6 +40,11 @@ function makeFixture(opts: FixtureOpts): { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts b/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts index 2558e3c..f203370 100644 --- a/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts +++ b/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,7 +9,6 @@ const HOOK_PATH = path.join(__dirname, '..', 'index.mts') function runHook(payload: object): { stderr: string; exitCode: number } { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: JSON.stringify(payload), }) return { stderr: String(result.stderr), exitCode: result.status ?? -1 } @@ -127,7 +126,6 @@ test('ignores non-Edit/Write tool calls', () => { test('fails open on bad JSON', () => { const result = spawnSync('node', [HOOK_PATH], { - // @ts-expect-error TS2353 -- lib v5 SpawnSyncOptions omits "input"; v6 exposes it. Runtime accepts it. input: '{not-json}', }) assert.equal(result.status, 0) diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts b/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts index 2432a67..8e4ef35 100644 --- a/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts +++ b/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts @@ -3,7 +3,7 @@ // prefer-async-spawn: streaming-stdio-required — test spawns child // subprocess and pipes stdin/stdout/stderr; Node spawn returns the // ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -27,6 +27,11 @@ function tmpWorkflow(content: string): string { async function runHook(payload: Record): Promise { const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) child.stdin!.end(JSON.stringify(payload)) let stderr = '' child.process.stderr!.on('data', chunk => { diff --git a/.claude/settings.json b/.claude/settings.json index b07aef2..3d44818 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,6 +20,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/cross-repo-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-disable-lint-rule-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gitmodules-comment-guard/index.mts" @@ -40,6 +44,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minimum-release-age-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-fleet-fork-guard/index.mts" @@ -80,6 +88,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pull-request-target-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/readme-fleet-shape-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-uses-comment-guard/index.mts" @@ -96,6 +108,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/immutable-release-pattern-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inline-script-defer-guard/index.mts" @@ -147,6 +163,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-author-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-message-format-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/concurrent-cargo-build-guard/index.mts" @@ -155,10 +175,22 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/default-branch-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gh-token-hygiene-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-blind-keychain-read-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-experimental-strip-types-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-empty-commit-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/node-modules-staging-guard/index.mts" @@ -167,6 +199,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pr-vs-push-default-reminder/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-non-fleet-push-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-external-issue-ref-guard/index.mts" @@ -195,6 +231,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/release-workflow-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scan-label-in-commit-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/token-guard/index.mts" @@ -233,6 +273,19 @@ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/actionlint-on-workflow-edit/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/extension-build-current-guard/index.mts" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enterprise-push-property-reminder/index.mts" } ] } @@ -256,6 +309,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/compound-lessons-reminder/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-stop-mid-queue-reminder/index.mts" @@ -276,6 +333,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/file-size-reminder/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/follow-direct-imperative-reminder/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/identifying-users-reminder/index.mts" @@ -300,10 +361,18 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/setup-security-tools/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/squash-history-reminder/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stale-process-sweeper/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/sweep-ds-store/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/variant-analysis-reminder/index.mts" diff --git a/.claude/skills/_shared/scripts/git-default-branch.mts b/.claude/skills/_shared/scripts/git-default-branch.mts index 81c41b3..a2d705a 100644 --- a/.claude/skills/_shared/scripts/git-default-branch.mts +++ b/.claude/skills/_shared/scripts/git-default-branch.mts @@ -9,7 +9,8 @@ * Cross-platform: shells out to git via @socketsecurity/lib/spawn, which works * the same on macOS / Linux / Windows. */ -import { isSpawnError, spawn } from '@socketsecurity/lib/spawn' +import { isSpawnError } from '@socketsecurity/lib/process/spawn/errors' +import { spawn } from '@socketsecurity/lib/process/spawn/child' export type ResolveDefaultBranchOptions = { /** diff --git a/.claude/skills/_shared/scripts/logger-guardrails.mts b/.claude/skills/_shared/scripts/logger-guardrails.mts index 3654cc9..89f53b4 100644 --- a/.claude/skills/_shared/scripts/logger-guardrails.mts +++ b/.claude/skills/_shared/scripts/logger-guardrails.mts @@ -4,7 +4,7 @@ * Five checks, one pass: * * 1. **Status-symbol emoji** (✓ ✔ ❌ ✗ ⚠ ⚠️ ❗ ✅ ❎ ☑) — banned. The - * `@socketsecurity/lib/logger` package owns the visual prefix via + * `@socketsecurity/lib/logger/default` package owns the visual prefix via * `logger.success()` / `logger.fail()` / `logger.warn()` etc. Hand-rolling * the symbols fragments the visual style and bypasses theme-aware color. * 2. **`console.log` / `console.error` / `console.warn` / `console.info` / @@ -225,7 +225,7 @@ export async function checkLoggerGuardrails( export const GUARDRAIL_FIX_HINTS: Readonly> = { 'console-call': - 'Use logger from @socketsecurity/lib/logger: import { getDefaultLogger } from "@socketsecurity/lib/logger"; const logger = getDefaultLogger(); then logger.success(...) / logger.fail(...) / logger.warn(...) / logger.info(...) / logger.log(...).', + 'Use logger from @socketsecurity/lib/logger/default: import { getDefaultLogger } from "@socketsecurity/lib/logger/default"; const logger = getDefaultLogger(); then logger.success(...) / logger.fail(...) / logger.warn(...) / logger.info(...) / logger.log(...).', 'dynamic-import': "Use a static `import` statement at the top of the file. Dynamic `import()` is only allowed inside bundled code (src/ or bundler configs); script files run directly via `node` and don't need lazy resolution.", 'inline-logger': diff --git a/.claude/skills/_shared/scripts/resolve-tools.mts b/.claude/skills/_shared/scripts/resolve-tools.mts index acb380a..4a19a26 100644 --- a/.claude/skills/_shared/scripts/resolve-tools.mts +++ b/.claude/skills/_shared/scripts/resolve-tools.mts @@ -20,7 +20,7 @@ import { existsSync } from 'node:fs' import path from 'node:path' -import { spawn } from '@socketsecurity/lib/spawn' +import { spawn } from '@socketsecurity/lib/process/spawn/child' /** * Result of a resolver. `args` is the full argv passed to `pnpm exec`, diff --git a/.claude/skills/_shared/variant-analysis.md b/.claude/skills/_shared/variant-analysis.md index ec9a666..46308c7 100644 --- a/.claude/skills/_shared/variant-analysis.md +++ b/.claude/skills/_shared/variant-analysis.md @@ -44,7 +44,7 @@ Variants should be batched into the same fix commit when mechanical (one find/re ## Don't - Don't variant-hunt for style nits. Reserve this for correctness / security / fleet-drift findings. -- Don't expand the search radius past one repo without writing it down — cross-fleet variants get a `chore(sync): cascade ` PR per the _Drift watch_ rule. +- Don't expand the search radius past one repo without writing it down — cross-fleet variants get a `chore(wheelhouse): cascade ` PR per the _Drift watch_ rule. - Don't skip the search because the finding "looks unique." Looking unique is exactly when the search pays off. ## Trail-of-Bits influence diff --git a/.claude/skills/auditing-gha-settings/SKILL.md b/.claude/skills/auditing-gha-settings/SKILL.md index f0108d9..ece1c21 100644 --- a/.claude/skills/auditing-gha-settings/SKILL.md +++ b/.claude/skills/auditing-gha-settings/SKILL.md @@ -1,19 +1,19 @@ --- name: auditing-gha-settings -description: Audits a repo's GitHub Actions permissions + allowlist against the fleet baseline. Reports drift only — fixes are manual in Settings → Actions because flipping these silently is unsafe. Use when a CI failure looks like "action X is not allowed to be used", when onboarding a new fleet repo, or as a periodic fleet-wide health check. +description: Audits a repo's GitHub Actions permissions + allowlist against the fleet baseline. Reports drift only. Fixes are manual in Settings → Actions because flipping these silently is unsafe. Use when a CI failure looks like "action X is not allowed to be used", when onboarding a new fleet repo, or as a periodic fleet-wide health check. user-invocable: true allowed-tools: Read, Grep, Glob, Bash(gh:*), Bash(node:*), Bash(jq:*) --- # auditing-gha-settings -Diff a fleet repo's GitHub Actions repository-level settings against the canonical baseline. Read-only — surfaces what to change, doesn't change it. +Diff a fleet repo's GitHub Actions repository-level settings against the canonical baseline. Read-only: surfaces what to change, doesn't change it. ## When to use -- **"action X is not allowed to be used" CI failure** — the allowlist is missing an entry, or the policy got flipped from `selected` to `local_only`. -- **Onboarding a new fleet repo** — before the first CI run, confirm the new repo matches the baseline so the first push doesn't hit policy errors. -- **Periodic fleet health check** — drift accumulates; somebody adds a workflow that needs a new action and silently flips `verified_allowed: true` to make it work instead of adding the explicit pattern. +- **"action X is not allowed to be used" CI failure**: the allowlist is missing an entry, or the policy got flipped from `selected` to `local_only`. +- **Onboarding a new fleet repo**: before the first CI run, confirm the new repo matches the baseline so the first push doesn't hit policy errors. +- **Periodic fleet health check**: drift accumulates. Somebody adds a workflow that needs a new action and silently flips `verified_allowed: true` to make it work instead of adding the explicit pattern. ## What the baseline checks @@ -43,7 +43,7 @@ The **canonical patterns** (every fleet repo must have all of these): - `depot/setup-action@*` - `github/codeql-action/upload-sarif@*` -Extras beyond the canonical set are tolerated (reported as info, not failure). A repo may legitimately pin a one-off action — but each extra should map to a real consumer; orphans should be pruned. +Extras beyond the canonical set are tolerated (reported as info, not failure). A repo may pin a one-off action, but each extra should map to a real consumer; orphans should be pruned. **Third-party actions are NOT on the allowlist.** Anything outside `actions/`, `github/`, and `depot/` should be ported to a hand-rolled composite under `SocketDev/socket-registry/.github/actions/` rather than added here. The current set of socket-registry composite replacements: @@ -57,7 +57,7 @@ Extras beyond the canonical set are tolerated (reported as info, not failure). A | `softprops/action-gh-release` | `create-gh-release` | | `Swatinem/rust-cache` | `setup-rust-cache` | -Note: `enabled: false` from the per-repo API does NOT mean Actions are disabled — it means the per-repo override is unset and org-level policy is in effect. The skill explains this in its output. +Note: `enabled: false` from the per-repo API does NOT mean Actions are disabled. It means the per-repo override is unset and org-level policy is in effect. The skill explains this in its output. ## How to invoke @@ -75,9 +75,10 @@ Or all-at-once with the canonical fleet list (manual today; the orchestrator ski SocketDev/socket-sdk-js \ SocketDev/socket-sdxgen \ SocketDev/socket-stuie \ + SocketDev/socket-vscode \ + SocketDev/socket-webext \ SocketDev/socket-wheelhouse \ - SocketDev/ultrathink \ - SocketDev/vscode-socket-security + SocketDev/ultrathink For machine-readable output (one finding per repo): @@ -85,17 +86,17 @@ For machine-readable output (one finding per repo): ## How to fix the findings -Each finding line names the exact toggle to flip. The fix is **manual**: the runner does not write — flipping these silently is a credible attack vector and should always be a human action. +Each finding line names the exact toggle to flip. The fix is **manual**: the runner does not write. Flipping these silently is a credible attack vector and should always be a human action. Two paths: -1. **Web UI (preferred)** — Repo → Settings → Actions → General. The settings map 1:1 with the audit findings: +1. **Web UI (preferred)**: Repo → Settings → Actions → General. The settings map 1:1 with the audit findings: - "Allow enterprise, and select non-enterprise, actions and reusable workflows" → flips `allowed_actions` to `selected`. - Uncheck "Allow actions created by GitHub" → `github_owned_allowed: false`. - Uncheck "Allow Marketplace actions by verified creators" → `verified_allowed: false`. - - "Allow specified actions and reusable workflows" textarea — paste the canonical patterns list (one per line). Existing extras can stay; remove only ones with no consumer. + - "Allow specified actions and reusable workflows" textarea: paste the canonical patterns list (one per line). Existing extras can stay; remove only ones with no consumer. -2. **`gh api` PUT (admin-scoped tokens only)** — surfaced for completeness; prefer the UI: +2. **`gh api` PUT (admin-scoped tokens only)**: surfaced for completeness; prefer the UI: gh api -X PUT repos///actions/permissions \ -F enabled=true -F allowed_actions=selected @@ -105,14 +106,14 @@ Two paths: -f patterns_allowed[]='actions/cache/save@*' \ # ...one -f per canonical pattern... - The whole-list replace semantics on the selected-actions endpoint mean **omitting a repo's existing extras drops them** — preserve them when relevant. + The whole-list replace semantics on the selected-actions endpoint mean **omitting a repo's existing extras drops them**. Preserve them when relevant. ## Anti-patterns - **Auto-PUT-ing the baseline from a script.** Don't. The settings affect every workflow on the repo and a wrong setting silently weakens supply-chain posture. The user runs the audit, the user fixes. -- **Adding an action to the allowlist to make a one-off workflow happy.** First ask: should the workflow use a shared socket-registry workflow that already references an approved action? Adding entries to the canonical set means cascading them to every consumer org — a real commitment. +- **Adding an action to the allowlist to make a one-off workflow happy.** First ask: should the workflow use a shared socket-registry workflow that already references an approved action? Adding entries to the canonical set means cascading them to every consumer org. A real commitment. - **Treating the audit as a security review.** It checks policy state, not workflow content. A workflow that uses an allowed action insecurely (e.g. `pull_request_target` + `actions/checkout` of untrusted ref) is invisible to this audit; that's `pull-request-target-guard`'s job. ## Companion: `greening-ci` -If a CI failure shows `action is not allowed by enterprise admin` or `not allowed to be used in this repository`, that's an allowlist gap — run this audit, fix the gap manually, then re-run `/green-ci` to confirm the build goes green. +If a CI failure shows `action is not allowed by enterprise admin` or `not allowed to be used in this repository`, that's an allowlist gap. Run this audit, fix the gap manually, then re-run `/green-ci` to confirm the build goes green. diff --git a/.claude/skills/auditing-gha-settings/run.mts b/.claude/skills/auditing-gha-settings/run.mts index 0cbda84..7f55c91 100644 --- a/.claude/skills/auditing-gha-settings/run.mts +++ b/.claude/skills/auditing-gha-settings/run.mts @@ -21,8 +21,8 @@ import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { spawn } from '@socketsecurity/lib/spawn' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { spawn } from '@socketsecurity/lib/process/spawn/child' const logger = getDefaultLogger() diff --git a/.claude/skills/cascading-fleet/SKILL.md b/.claude/skills/cascading-fleet/SKILL.md index 378d31c..bfe6dfa 100644 --- a/.claude/skills/cascading-fleet/SKILL.md +++ b/.claude/skills/cascading-fleet/SKILL.md @@ -7,7 +7,7 @@ allowed-tools: Bash(git fetch:*), Bash(git worktree:*), Bash(git branch:*), Bash # cascading-fleet -The fleet runs on `chore(sync): cascade fleet template@` commits — every wheelhouse template change has to land in every fleet repo to take effect. This skill packages the operation so it isn't recreated ad-hoc per session. +The fleet runs on `chore(wheelhouse): cascade template@` commits. Every wheelhouse template change has to land in every fleet repo to take effect. This skill packages the operation so it isn't recreated ad-hoc per session. ## When to use @@ -15,26 +15,26 @@ The fleet runs on `chore(sync): cascade fleet template@` commits — every - A `socket-registry` pin chain (the multi-layer setup-and-install → setup → checkout pin graph) needs propagation. - Batching multiple template SHAs into one wave. -Never use this skill while another cascade is in flight (each cascade creates a `chore/sync-` branch per repo; concurrent runs collide). +Never use this skill while another cascade is in flight (each cascade creates a `chore/wheelhouse-` branch per repo; concurrent runs collide). ## Two modes -### Mode 1 — `template` (outer cascade, default) +### Mode 1: `template` (outer cascade, default) Propagates a `socket-wheelhouse/template/` SHA to every fleet repo. The flow: 1. For each fleet repo: -2. Worktree off `origin/` on a fresh `chore/sync-` branch. +2. Worktree off `origin/` on a fresh `chore/wheelhouse-` branch. 3. Run `socket-wheelhouse/scripts/sync-scaffolding/cli.mts --target --fix`. -4. If the cascade modified anything: surgical-stage with `FLEET_SYNC=1 git add --update`, commit `chore(sync): cascade fleet template@`, push direct to base. +4. If the cascade modified anything: surgical-stage with `FLEET_SYNC=1 git add --update`, commit `chore(wheelhouse): cascade template@`, push direct to base. 5. If direct push is rejected: push the branch, open a PR. 6. Clean up the worktree + the temp branch. -The `FLEET_SYNC=1` sentinel is recognized by the wheelhouse `no-revert-guard` + `overeager-staging-guard` hooks. It allowlists exactly: `git commit --no-verify` whose message starts with `chore(sync): cascade fleet template@`, `git push --no-verify`, and `git add -A`/`-u`/`.`. Nothing else. +The `FLEET_SYNC=1` sentinel is recognized by the wheelhouse `no-revert-guard` + `overeager-staging-guard` hooks. It allowlists exactly: `git commit --no-verify` whose message starts with `chore(wheelhouse): cascade template@`, `git push --no-verify`, and `git add -A`/`-u`/`.`. Nothing else. -### Mode 2 — `registry-pins` +### Mode 2: `registry-pins` -Propagates a `socket-registry` pin chain through the fleet. Different shape — uses `scripts/cascade-registry-pins.mts --sha ` to walk the per-repo workflow pins. Documented here for completeness; the cascade script in `lib/cascade-template.sh` covers Mode 1, and a future `lib/cascade-registry-pins.sh` will cover Mode 2. +Propagates a `socket-registry` pin chain through the fleet. Different shape: uses `scripts/cascade-registry-pins.mts --sha ` to walk the per-repo workflow pins. Documented here for completeness; the cascade script in `lib/cascade-template.mts` covers Mode 1, and a future `lib/cascade-registry-pins.mts` will cover Mode 2. For now, the registry-pin cascade is two steps documented inline: @@ -49,15 +49,15 @@ Skipping Step 1 means Step 3 propagates a SHA whose dependency graph still pins ## How to invoke ```bash -# Mode 1 — propagate wheelhouse template SHA -bash .claude/skills/cascading-fleet/lib/cascade-template.sh +# Mode 1: propagate wheelhouse template SHA +node .claude/skills/cascading-fleet/lib/cascade-template.mts ``` The script reads the fleet-repo list from `lib/fleet-repos.txt` (single source of truth), iterates, and writes a per-repo result line to stdout. Output also tees to `/tmp/cascade-.log` for post-hoc inspection. -## Worktree cleanup — the branch-cleanup bug +## Worktree cleanup: the branch-cleanup bug -A subtle gotcha: the script's pre-clean step (`git branch -D `) MUST run from `${src}` (the source repo), not from `/tmp` or the worktree directory. If the loop crashes mid-iteration before `cd`-ing into the worktree, a stale `chore/sync-` branch can be left behind. The provided script handles this — but if you write a one-off cascade, make sure your cleanup runs from the right cwd. +A subtle gotcha: the script's pre-clean step (`git branch -D `) MUST run from `${src}` (the source repo), not from `/tmp` or the worktree directory. If the loop crashes mid-iteration before `cd`-ing into the worktree, a stale `chore/wheelhouse-` branch can be left behind. The provided script handles this. If you write a one-off cascade, make sure your cleanup runs from the right cwd. ## Soak time before catalog cascades @@ -65,7 +65,7 @@ If the wheelhouse template change includes a `@socketsecurity/lib` catalog bump ## Stop conditions -- Branch already exists in a fleet repo (`fatal: a branch named 'chore/sync-' already exists`): pre-clean from `${src}` then retry that repo only. +- Branch already exists in a fleet repo (`fatal: a branch named 'chore/wheelhouse-' already exists`): pre-clean from `${src}` then retry that repo only. - Worktree-add fails: another worktree at the target path; cleanup with `git worktree remove --force `. - Push rejected on direct base: the script automatically falls back to PR. Confirm via the PR URL printed to stdout. diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.mts b/.claude/skills/cascading-fleet/lib/cascade-template.mts new file mode 100644 index 0000000..9c0e5b9 --- /dev/null +++ b/.claude/skills/cascading-fleet/lib/cascade-template.mts @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * @file Fleet cascade — propagate a socket-wheelhouse/template/ SHA to every + * fleet repo. Uses the FLEET_SYNC=1 sentinel to bypass the no-revert-guard / + * overeager-staging-guard hooks without per-repo Allow-bypass phrases. + * Replaces the original cascade-template.sh; the fleet convention is `.mts` + * for all runners. Usage: node + * .claude/skills/cascading-fleet/lib/cascade-template.mts + * Reads the canonical fleet-repo list from `/fleet-repos.txt`. Each + * repo's worktree is created off `origin/`, the wheelhouse + * sync-scaffolding CLI runs, the resulting changes are committed, and the + * script tries a direct push first, falling back to opening a PR on + * rejection. + */ + +// prefer-async-spawn: sync-required — cascade orchestrator runs +// sequentially across repos with exit-code gating; async would +// complicate the linear pipeline for no real concurrency win. +// prefer-spawn-over-execsync: same — top-level sync CLI flow. +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { + appendFileSync, + existsSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +const logger = getDefaultLogger() + +const LOG_PATH_PREFIX = '/tmp/cascade-' + +function usage(): never { + logger.error(`usage: ${process.argv[1]} `) + process.exit(2) +} + +const TEMPLATE_SHA = process.argv[2] +if (!TEMPLATE_SHA) { + usage() +} + +const SCRIPT_DIR = import.meta.dirname +const FLEET_REPOS_FILE = path.join(SCRIPT_DIR, 'fleet-repos.txt') +const PROJECTS = process.env['PROJECTS'] || path.join(os.homedir(), 'projects') +// socket-hook: allow cross-repo +const WH_SCRIPT = path.join( + PROJECTS, + 'socket-wheelhouse', + 'scripts', + 'sync-scaffolding', + 'cli.mts', +) +// socket-hook: allow cross-repo +const CLEANUP_SCRIPT = path.join( + PROJECTS, + 'socket-wheelhouse', + 'scripts', + 'fleet', + 'cleanup-stranded.mts', +) + +// Prepend the active Node version's bin dir to PATH so the `node` invoked by +// the wheelhouse CLI matches the operator's expected toolchain (avoids the +// pre-commit hook's "wrong Node" fallback). Honors NVM_BIN when set; otherwise +// leaves PATH alone so a Volta / homebrew / system Node still resolves. +if (process.env['NVM_BIN']) { + process.env['PATH'] = `${process.env['NVM_BIN']}:${process.env['PATH'] || ''}` +} + +if (!existsSync(FLEET_REPOS_FILE)) { + logger.error(`fleet-repos.txt not found at ${FLEET_REPOS_FILE}`) + process.exit(2) +} +if (!existsSync(WH_SCRIPT)) { + logger.error(`wheelhouse sync-scaffolding CLI not found at ${WH_SCRIPT}`) + logger.error( + 'set PROJECTS= before retrying', + ) + process.exit(2) +} +// CLEANUP_SCRIPT is optional — older wheelhouse checkouts won't have it. +// When missing, skip auto-cleanup; the cascade still runs. + +const LOG_FILE = `${LOG_PATH_PREFIX}${TEMPLATE_SHA}.log` +writeFileSync(LOG_FILE, '') + +function log(line: string): void { + logger.info(line) + appendFileSync(LOG_FILE, `${line}\n`) +} + +const RESULTS: string[] = [] + +log(`══ Cascade ${TEMPLATE_SHA} ══`) +log(`Log: ${LOG_FILE}`) +log('') + +// Resolve a canonical fleet repo name to a local primary checkout. Mirrors +// scripts/sync-scaffolding/discover.mts directoryAliasesFor(): canonical +// `socket-` also resolves to `${PROJECTS}//`; canonical `` (no +// socket- prefix — sdxgen, stuie, ultrathink) also resolves to +// `${PROJECTS}/socket-/`. First primary checkout wins. Returns undefined +// when no primary checkout exists. +function resolveLocalCheckout(canonical: string): string | undefined { + let candidate = path.join(PROJECTS, canonical) + if (existsSync(path.join(candidate, '.git'))) { + return candidate + } + candidate = canonical.startsWith('socket-') + ? path.join(PROJECTS, canonical.slice('socket-'.length)) + : path.join(PROJECTS, `socket-${canonical}`) + if (existsSync(path.join(candidate, '.git'))) { + return candidate + } + return undefined +} + +type RunResult = { + status: number + stdout: string + stderr: string +} + +function run( + cmd: string, + args: string[], + opts: { cwd: string; env?: NodeJS.ProcessEnv | undefined } = { + cwd: process.cwd(), + }, +): RunResult { + const r = spawnSync(cmd, args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + encoding: 'utf8', + }) + return { + status: r.status ?? 1, + stdout: r.stdout ?? '', + stderr: r.stderr ?? '', + } +} + +function logTail(out: string, n: number): void { + const lines = out.split('\n').filter(Boolean) + for (const line of lines.slice(-n)) { + log(line) + } +} + +function git(cwd: string, args: string[]): RunResult { + return run('git', args, { cwd }) +} + +function gitSilent(cwd: string, args: string[]): void { + // Used for best-effort cleanup that should not pollute output on failure + // (mirrors `2>/dev/null` in the original bash). + spawnSync('git', args, { cwd, stdio: 'ignore' }) +} + +function resolveBase(src: string): string { + const sym = git(src, ['symbolic-ref', 'refs/remotes/origin/HEAD']) + if (sym.status === 0) { + return sym.stdout.trim().replace(/^refs\/remotes\/origin\//, '') + } + for (const candidate of ['main', 'master']) { + if ( + git(src, [ + 'show-ref', + '--verify', + '--quiet', + `refs/remotes/origin/${candidate}`, + ]).status === 0 + ) { + return candidate + } + } + return 'main' +} + +const fleetReposRaw = readFileSync(FLEET_REPOS_FILE, 'utf8').split('\n') + +for (const rawLine of fleetReposRaw) { + const repo = rawLine.trim() + if (!repo || repo.startsWith('#')) { + continue + } + + const src = resolveLocalCheckout(repo) + const wt = path.join('/tmp', `cascade-${repo}-${process.pid}`) + log(`── ${repo} ──`) + + if (!src) { + RESULTS.push(`${repo}|skip:no-git`) + continue + } + + const base = resolveBase(src) + git(src, ['fetch', 'origin', base, '--quiet']) + + // Auto-clean stranded cascade artifacts from earlier waves. Safety rails + // inside the script bail the repo (no-op) if anything looks ambiguous; + // only removes commits matching the cascade subject regex, authored by a + // trusted identity, touching only cascade-allowlisted files, and whose + // template SHA strictly precedes origin's current cascade SHA. + if (existsSync(CLEANUP_SCRIPT)) { + const cleanup = run('node', [CLEANUP_SCRIPT, '--target', src], { cwd: src }) + logTail(cleanup.stdout + cleanup.stderr, 3) + } + + // Branch name reads `chore/wheelhouse-` — keeps the `chore/` + // namespace convention and names the source explicitly. Replaces + // the older `chore/sync-` form (no back-compat retained; + // pre-rename stranded branches need a one-time hand cleanup). + const branch = `chore/wheelhouse-${TEMPLATE_SHA}` + + gitSilent(src, ['worktree', 'remove', '--force', wt]) + gitSilent(src, ['branch', '-D', branch]) + + const wtAdd = git(src, [ + 'worktree', + 'add', + '-b', + branch, + wt, + `origin/${base}`, + ]) + if (wtAdd.status !== 0) { + logTail(wtAdd.stdout + wtAdd.stderr, 1) + RESULTS.push(`${repo}|fail:worktree`) + continue + } + logTail(wtAdd.stdout + wtAdd.stderr, 1) + + const sync = run('node', [WH_SCRIPT, '--target', wt, '--fix'], { cwd: wt }) + logTail(sync.stdout + sync.stderr, 3) + + const aheadOut = git(wt, ['rev-list', '--count', `origin/${base}..HEAD`]) + const ahead = + aheadOut.status === 0 ? parseInt(aheadOut.stdout.trim(), 10) || 0 : 0 + if (ahead === 0) { + const dirty = git(wt, ['status', '--porcelain']).stdout.trim() + if (!dirty) { + RESULTS.push(`${repo}|noop`) + gitSilent(src, ['worktree', 'remove', '--force', wt]) + gitSilent(src, ['branch', '-D', branch]) + continue + } + // FLEET_SYNC=1 + CI=true env is required: the sentinel allowlists exactly + // this commit through the no-revert-guard / overeager-staging-guard + // hooks. CI=true suppresses interactive pre-commit hook prompts. + const stageEnv = { ...process.env, FLEET_SYNC: '1', CI: 'true' } + git(wt, ['add', '--update']) + const commit = run( + 'git', + [ + 'commit', + '--no-verify', + '-m', + `chore(wheelhouse): cascade template@${TEMPLATE_SHA}`, + ], + { cwd: wt, env: stageEnv }, + ) + logTail(commit.stdout + commit.stderr, 2) + if (commit.status !== 0) { + RESULTS.push(`${repo}|fail:commit`) + gitSilent(src, ['worktree', 'remove', '--force', wt]) + gitSilent(src, ['branch', '-D', branch]) + continue + } + } + + const pushEnv = { ...process.env, FLEET_SYNC: '1' } + const push = run('git', ['push', '--no-verify', 'origin', `HEAD:${base}`], { + cwd: wt, + env: pushEnv, + }) + logTail(push.stdout + push.stderr, 2) + if (push.status === 0) { + RESULTS.push(`${repo}|push:${base}`) + } else { + const branchPush = run( + 'git', + ['push', '--no-verify', '-u', 'origin', branch], + { cwd: wt, env: pushEnv }, + ) + logTail(branchPush.stdout + branchPush.stderr, 2) + if (branchPush.status === 0) { + const prCreate = run( + 'gh', + [ + 'pr', + 'create', + '--repo', + `SocketDev/${repo}`, + '--base', + base, + '--head', + branch, + '--title', + `chore(wheelhouse): cascade template@${TEMPLATE_SHA}`, + '--body', + `Auto-cascade of socket-wheelhouse@${TEMPLATE_SHA}.`, + ], + { cwd: wt }, + ) + const prUrl = + (prCreate.stdout + prCreate.stderr) + .trim() + .split('\n') + .filter(Boolean) + .slice(-1)[0] ?? '' + RESULTS.push(`${repo}|pr:${prUrl}`) + } else { + RESULTS.push(`${repo}|fail:push+pr`) + } + } + + gitSilent(src, ['worktree', 'remove', '--force', wt]) + gitSilent(src, ['branch', '-D', branch]) +} + +log('') +log('════ RESULTS ════') +for (let i = 0, { length } = RESULTS; i < length; i += 1) { + const entry = RESULTS[i]! + log(` ${entry}`) +} diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.json b/.claude/skills/cascading-fleet/lib/fleet-repos.json new file mode 100644 index 0000000..627f86d --- /dev/null +++ b/.claude/skills/cascading-fleet/lib/fleet-repos.json @@ -0,0 +1,58 @@ +{ + "$schema": "./fleet-repos.schema.json", + "repos": [ + { + "name": "socket-addon", + "description": "NAPI .node binaries for @socketaddon/* npm packages", + "optIns": ["squash-history"] + }, + { + "name": "socket-bin", + "description": "SEA-packed CLI distributions for @socketbin/* packages", + "optIns": ["squash-history"] + }, + { + "name": "socket-btm", + "description": "Build toolchain — produces signed prebuilt binaries for @socketaddon/* and @socketbin/*", + "optIns": ["squash-history"] + }, + { + "name": "socket-cli", + "description": "Command-line interface for socket.dev security analysis" + }, + { + "name": "socket-lib", + "description": "Core library: fs, processes, HTTP, logging, env detection" + }, + { + "name": "socket-mcp", + "description": "Model Context Protocol server for socket.dev integration" + }, + { + "name": "socket-packageurl-js", + "description": "purl spec implementation for JavaScript" + }, + { + "name": "socket-registry", + "description": "Optimized package overrides for Socket Optimize" + }, + { + "name": "socket-sdk-js", + "description": "JavaScript SDK for the socket.dev API" + }, + { + "name": "sdxgen", + "description": "CycloneDX and SPDX manifest generator (Socket dx gen)", + "optIns": ["squash-history"] + }, + { + "name": "stuie", + "description": "Terminal UI library: OpenTUI + yoga-layout + React", + "optIns": ["squash-history"] + }, + { + "name": "socket-wheelhouse", + "description": "Internal scaffolding template for socket-* repos" + } + ] +} diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.txt b/.claude/skills/cascading-fleet/lib/fleet-repos.txt index 52aaf95..84ea8f1 100644 --- a/.claude/skills/cascading-fleet/lib/fleet-repos.txt +++ b/.claude/skills/cascading-fleet/lib/fleet-repos.txt @@ -1,4 +1,5 @@ socket-addon +socket-bin socket-btm socket-cli socket-lib @@ -6,5 +7,5 @@ socket-mcp socket-packageurl-js socket-registry socket-sdk-js -socket-sdxgen -socket-stuie +sdxgen +stuie diff --git a/.claude/skills/cleaning-redundant-ci/SKILL.md b/.claude/skills/cleaning-redundant-ci/SKILL.md new file mode 100644 index 0000000..1c92189 --- /dev/null +++ b/.claude/skills/cleaning-redundant-ci/SKILL.md @@ -0,0 +1,120 @@ +--- +name: cleaning-redundant-ci +description: Sweeps a fleet repo (or every fleet repo) for redundant CI surface. Three classes: orphan workflow YAML files (lint.yml / check.yml / type.yml / test.yml that the unified ci.yml replaced), GitHub-Dependabot auto-fix PRs that the fleet handles via /updating-security, and stale workflow run history in the Actions sidebar. Deletes the YAML files, disables Dependabot automated-security-fixes via gh api, and reports anything that needs a manual UI toggle. Once-and-never-again sweep meant to leave a repo clean. +user-invocable: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash(gh:*), Bash(git:*), Bash(ls:*), Bash(rm:*), Bash(find:*), Bash(jq:*) +--- + +# cleaning-redundant-ci + +Audit + clean redundant CI surface on a Socket fleet repo. Three +target classes: + +1. **Orphan workflow YAML files**: `lint.yml`, `check.yml`, `type.yml`, `test.yml`. The fleet consolidated those into the shared `ci.yml` (via `SocketDev/socket-registry/.github/workflows/ci.yml`) long ago. Any per-repo file with those names is a leftover from pre-consolidation days. Delete them. + +2. **GitHub-Dependabot automated security PRs**: the fleet pattern is to handle vulnerability fixes via `/updating-security` (pnpm `overrides:` for transitive deps), not via auto-PRs from Dependabot. The `dependabot.yml` no-op file (`open-pull-requests-limit: 0`) suppresses version-update PRs but does NOT suppress security PRs. Those flow from a separate repo-settings toggle (`automated-security-fixes`). Disable via `gh api -X DELETE /repos/{owner}/{repo}/automated-security-fixes`. + +3. **Stale workflow run history**: when a workflow YAML gets deleted, the **runs** stay listed in the Actions sidebar forever (the workflow appears as a name with no associated file). Delete the workflow record via `gh api /repos/{owner}/{repo}/actions/workflows/{id} -X DELETE` to remove the sidebar entry. + +## When to use + +- **Onboarding a new fleet repo**: sweep once on first integration to clear any pre-fleet CI baggage. +- **After a CI consolidation cascade**: when the fleet retires a workflow shape (e.g. the lint/check/type/test → unified ci.yml migration), run this skill on every fleet repo to clean up the per-repo leftovers. +- **Periodic fleet-wide health check**: run quarterly to catch drift (someone adds a per-repo `lint.yml` to scratch an itch, forgetting the unified ci.yml already covers it). + +## What it does NOT do + +- **Touch the `dependabot.yml` file.** That file MUST exist (GitHub + refuses to fully disable Dependabot without it) and the fleet + convention is to ship it pre-configured with + `open-pull-requests-limit: 0`. The skill leaves the file alone; + only the `automated-security-fixes` toggle is acted on. +- **Touch `SocketDev/workflows`.** Don't edit org-level required workflows from this skill. The org config is the source of truth for what runs cross-repo, and silent edits are unsafe. +- **Delete legitimate per-repo workflows.** socket-btm's per-binary build dispatchers (`curl.yml`, `lief.yml`, etc.), ultrathink's `build-*.yml`, socket-packageurl-js's `pages.yml` /`valtown.yml`, socket-registry's `_local-not-for-reuse-*.yml` dogfood copies all stay. The skill only matches the four canonical orphan names. + +## Phases + +### Phase 1: inventory + +```sh +# Orphan YAML files +ls .github/workflows/ | grep -E '^(lint|check|type|test)\.ya?ml$' + +# Workflow records (live + stale) +gh api "repos/{owner}/{repo}/actions/workflows" --paginate \ + --jq '.workflows[] | "\(.id)\t\(.state)\t\(.name)\t\(.path)"' + +# Dependabot automated-security-fixes state +gh api "repos/{owner}/{repo}/automated-security-fixes" --jq .enabled +``` + +Categorise each finding: + +- **delete-file**: orphan YAML on disk +- **delete-record**: workflow record whose `.path` no longer exists in the repo OR whose name matches the orphan pattern +- **toggle-off**: `automated-security-fixes: true` + +### Phase 2: file deletions (commit + push) + +```sh +git rm .github/workflows/{lint,check,type,test}.yml 2>/dev/null +git commit -m "chore(ci): remove orphan {lint,check,type,test} workflows (consolidated into ci.yml)" +``` + +One commit per repo, conventional-commit subject. Push directly to +main per fleet policy (or fall back to PR if branch protection +requires). + +### Phase 3: workflow record deletions (gh api) + +For each delete-record finding: + +```sh +gh api -X DELETE "repos/{owner}/{repo}/actions/workflows/{id}" +``` + +GitHub returns 204 on success. The record disappears from the +Actions sidebar. Runs associated with the workflow remain in their +own URLs but stop showing in the per-workflow filter. + +Skip workflow records that match `dynamic/dependabot/...`. Those are GitHub-managed and can't be deleted via API. They'll stop appearing on their own once Dependabot has nothing to do (after Phase 4). + +### Phase 4: disable Dependabot automated-security-fixes + +```sh +gh api -X DELETE "repos/{owner}/{repo}/automated-security-fixes" +``` + +204 = disabled. Going forward, security advisories are visible in +the Security tab (via the `vulnerability-alerts` setting, which +stays on) but won't open auto-PRs. The fleet's `/updating-security` +skill is the canonical path for resolving them. + +### Phase 5: report + +For each repo: list what was deleted, what was disabled, and what needs manual UI action (rare; most things this skill touches are API-actionable). + +## Fleet-wide invocation + +```sh +# One repo +/cleaning-redundant-ci socket-foo + +# All fleet repos (reads template/.claude/skills/cascading-fleet/lib/fleet-repos.json) +/cleaning-redundant-ci --all +``` + +The fleet-roster path is the canonical list. Same file the cascade mechanism uses. Don't hard-code a repo list inside this skill. + +## Safety + +- **Read-only inventory first.** Print findings before any deletion. +- **Per-repo confirmation** in interactive mode; `--yes` to skip. +- **Direct push to main, fall back to PR** per fleet policy. Never + force-push. +- **Never edit `dependabot.yml`.** Only the `automated-security-fixes` toggle. The .yml is structurally required. +- **Never touch `SocketDev/workflows`.** Org-required workflows are out of scope. + +## Why a skill, not a hook + +This is operator-invoked maintenance, not edit-time enforcement. Hooks are the wrong shape: there's no `gh commit` or `gh push` event that should trigger a fleet-wide CI audit. Skills are user-callable, run on demand, and produce a one-shot report. diff --git a/.claude/skills/driving-cursor-bugbot/SKILL.md b/.claude/skills/driving-cursor-bugbot/SKILL.md index 7202a48..d93ee1d 100644 --- a/.claude/skills/driving-cursor-bugbot/SKILL.md +++ b/.claude/skills/driving-cursor-bugbot/SKILL.md @@ -60,13 +60,13 @@ To check `already-fixed`: read `git log` on the PR branch since the comment's `c - **Reply first, resolve second.** Resolving without a written reply leaves future readers blind. - **One commit per `real` finding.** Don't bundle. Conventional Commits: `fix(): address Bugbot finding on :`. - **Push after each fix; reply with the new commit SHA.** The reply cites the SHA, so the SHA must already be pushed. -- **Propagate canonical fixes.** When the file lives under `.claude/hooks/`, `.claude/skills/`, or `.git-hooks/`, fix at `socket-wheelhouse/template/` first, then sync to consumers — drifting fleet copies is the larger bug. +- **Propagate canonical fixes.** When the file lives under `.claude/hooks/`, `.claude/skills/`, or `.git-hooks/`, fix at `socket-wheelhouse/template/` first, then sync to consumers. Drifting fleet copies is the larger bug. ## When to use -- **After `gh pr create`** — Bugbot reviews most PRs within ~1 minute. -- **After pushing a Bugbot-related fix** — confirms the new HEAD didn't introduce new findings. -- **Before merging** — sweep open Bugbot threads. CLAUDE.md merge protocol depends on threads being resolved (replied to, not necessarily approved). +- **After `gh pr create`**: Bugbot reviews most PRs within ~1 minute. +- **After pushing a Bugbot-related fix**: confirms the new HEAD didn't introduce new findings. +- **Before merging**: sweep open Bugbot threads. CLAUDE.md merge protocol depends on threads being resolved (replied to, not necessarily approved). ## Success criteria diff --git a/.claude/skills/greening-ci/SKILL.md b/.claude/skills/greening-ci/SKILL.md index 27a6a06..fd7aca4 100644 --- a/.claude/skills/greening-ci/SKILL.md +++ b/.claude/skills/greening-ci/SKILL.md @@ -1,6 +1,6 @@ --- name: greening-ci -description: Drive a target repo's CI back to green. Watches GitHub Actions, surfaces the first failure log, fixes it locally, commits + pushes, and re-watches until the run lands green (or a wall-clock budget expires). Three modes — fast (ci.yml), release (build-server matrices, fail-fast 30s polls then cool down on first success), cool (just confirm the rest of a matrix). Use when main goes red, when a build-server dispatch is failing, or when babysitting a freshly-pushed fix to verify it lands green. +description: Drive a target repo's CI back to green. Watches GitHub Actions, surfaces the first failure log, fixes it locally, commits + pushes, and re-watches until the run lands green (or a wall-clock budget expires). Three modes: fast (ci.yml), release (build-server matrices, fail-fast 30s polls then cool down on first success), cool (just confirm the rest of a matrix). Use when main goes red, when a build-server dispatch is failing, or when babysitting a freshly-pushed fix to verify it lands green. user-invocable: true allowed-tools: Read, Grep, Glob, Edit, Write, Bash(gh:*), Bash(git:*), Bash(node:*), Bash(pnpm:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(cat:*), Bash(head:*), Bash(tail:*) --- @@ -13,15 +13,15 @@ Watch a target repo's CI, surface failures the moment they land, and drive a fix - **main is red.** Don't move on with new work while the trunk is broken. Run `/green-ci` to lock onto the failing run, fix it, push, and confirm green before resuming. - **Build-server matrix dispatched and might fail fast.** Release builds (curl, lief, binsuite, node-smol) have one matrix slot that usually fails first. Use `--mode=release` to learn the failure ~5 minutes before the whole matrix finishes. -- **Verifying a just-pushed fix.** Push a fix, then run the skill — it'll poll, confirm the run lands green, and exit. No more "did my fix actually work" guessing. +- **Verifying a just-pushed fix.** Push a fix, then run the skill. It'll poll, confirm the run lands green, and exit. No more "did my fix actually work" guessing. ## Three modes | Mode | Poll interval | Stop trigger | When to pick | | --------- | ------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `fast` | 30s | Any job fails OR whole run completes | Default. `ci.yml` watching — surface the failure as soon as one job lands. | +| `fast` | 30s | Any job fails OR whole run completes | Default. `ci.yml` watching: surface the failure as soon as one job lands. | | `release` | 30s | Any job fails OR any job succeeds | Build-server matrices. Matrix slots run in parallel; one slot's outcome is enough to start reacting. | -| `cool` | 120s | Whole run completes | After `release` reported a first success — just confirming the rest of the matrix. No fast polls. | +| `cool` | 120s | Whole run completes | After `release` reported a first success: just confirming the rest of the matrix. No fast polls. | The skill picks `fast` by default. After running `release` and getting a first success, the orchestrator (the agent invoking this skill) flips to `cool` for the remainder. @@ -42,10 +42,10 @@ The skill picks `fast` by default. After running `release` and getting a first s } ``` 3. Branch on `conclusion`: - - `"success"` — done. Report and exit. - - `"failure"` — read the log tail at `failedJobs[0].logTailPath`, classify the failure, fix locally in the target repo (which may be the current checkout or a worktree), commit + push, then re-invoke this skill to confirm green. - - `null` (still running, but a job already failed) — same as `"failure"` for fix-and-push purposes. The whole run will be cancelled once main's protection kicks in; don't wait for it. - - `"cancelled"` / `"skipped"` — report, ask the user; don't auto-fix. + - `"success"`: done. Report and exit. + - `"failure"`: read the log tail at `failedJobs[0].logTailPath`, classify the failure, fix locally in the target repo (which may be the current checkout or a worktree), commit + push, then re-invoke this skill to confirm green. + - `null` (still running, but a job already failed): same as `"failure"` for fix-and-push purposes. The whole run will be cancelled once main's protection kicks in; don't wait for it. + - `"cancelled"` / `"skipped"`: report, ask the user; don't auto-fix. ## Failure-classification table @@ -56,26 +56,26 @@ The log tail almost always ends in one of these patterns. The skill calls these | `× @socketsecurity/lib not resolvable from /home/runner/work/...` | Root `package.json` is missing the runtime dep the setup action requires. | Add `"@socketsecurity/lib": "catalog:"` next to `lib-stable` in the root `package.json` + catalog entry. | | `Error: Cannot find module '...'` during a `node` step | Missing dep / wrong import path / unbuilt artifact. | Trace the import to its package, add the dep, `pnpm install`, push. | | `pnpm: command not found` / `pnpm exec ...` exits 127 | `packageManager` mismatch / corepack disabled. | Confirm `packageManager` in root `package.json` matches the workflow's expected pnpm. | -| `npm ERR! 401`/`403` reaching `registry.npmjs.org` | Stale `NPM_TOKEN` secret, scoped-package permission drift, or registry filter. | Surface to user — token rotation is out of scope for an auto-fix. | -| `error: process "/bin/sh -c ..." did not complete successfully` | Docker build step crashed — read the inner `RUN` for the real error. | Read the Docker context for what `RUN` produced the exit code; fix that. | -| `Failed to restore from cache` followed by `Process completed with exit code 1` | Cache miss + the build doesn't degrade — it errors. | Bump the `cache-versions.json` entry to invalidate, OR fix the degraded-mode code path. | -| `denied by enterprise admin` / `not allowed to be used` | GH Actions allowlist missing an action. See `auditing-gha-settings`. | Add the action to the org allowlist. The repo can't fix this — escalate. | +| `npm ERR! 401`/`403` reaching `registry.npmjs.org` | Stale `NPM_TOKEN` secret, scoped-package permission drift, or registry filter. | Surface to user; token rotation is out of scope for an auto-fix. | +| `error: process "/bin/sh -c ..." did not complete successfully` | Docker build step crashed; read the inner `RUN` for the real error. | Read the Docker context for what `RUN` produced the exit code; fix that. | +| `Failed to restore from cache` followed by `Process completed with exit code 1` | Cache miss + the build doesn't degrade: it errors. | Bump the `cache-versions.json` entry to invalidate, OR fix the degraded-mode code path. | +| `denied by enterprise admin` / `not allowed to be used` | GH Actions allowlist missing an action. See `auditing-gha-settings`. | Add the action to the org allowlist. The repo can't fix this; escalate. | When the pattern isn't in the table, fall back to careful read-through of the log tail. Don't guess. ## Wall-clock budgets -Every invocation carries a `--budget-sec` (default 1800 = 30 min) so a stuck run doesn't park the loop forever. When the budget expires, the skill emits its last snapshot and exits — the orchestrator can re-invoke with a longer budget if the run is legitimately slow (build-server matrices routinely take 30-60min). +Every invocation carries a `--budget-sec` (default 1800 = 30 min) so a stuck run doesn't park the loop forever. When the budget expires, the skill emits its last snapshot and exits. The orchestrator can re-invoke with a longer budget if the run is slow (build-server matrices routinely take 30-60min). Budget tiers: - `fast` ci.yml watching: **30 min** is plenty. If ci.yml hasn't finished in 30min, something's wrong upstream (runner queue depth, broken cache step). - `release` build matrix: **60 min**. Most build-server matrices finish in 20–45min; 60min covers the worst case. -- `cool` confirmation: **30 min** is fine — at this point you've already seen one success, you just want the rest. +- `cool` confirmation: **30 min** is fine. At this point you've already seen one success; you just want the rest. ## Companion: `auditing-gha-settings` -Some CI failures aren't code — they're GitHub Actions policy. If you see `denied by enterprise admin` or `the action is not allowed to be used`, that's a GH org-level setting drift, not a code fix. Run `/audit-gha-settings ` (when available) to diff the repo's policy + allowlist against the fleet baseline. The current baseline must include: +Some CI failures aren't code; they're GitHub Actions policy. If you see `denied by enterprise admin` or `the action is not allowed to be used`, that's a GH org-level setting drift, not a code fix. Run `/audit-gha-settings ` (when available) to diff the repo's policy + allowlist against the fleet baseline. The current baseline must include: - Policy: **Allow enterprise, and select non-enterprise, actions and reusable workflows** - Allowlist (each must be present and active): @@ -99,7 +99,7 @@ Each entry is here because at least one fleet workflow references it through the ## Anti-patterns -- **Auto-merging from a worktree without confirming the target main is current.** Always `git fetch origin main` before pushing the fix — the fleet has heavy commit traffic. +- **Auto-merging from a worktree without confirming the target main is current.** Always `git fetch origin main` before pushing the fix. The fleet has heavy commit traffic. - **Treating a `cancelled` run as a failure.** Someone (or branch protection) cancelled it. Re-run if needed; don't apply a code fix. - **Polling faster than 30s.** GH's rate limit is generous but not infinite. The `run.mts` runner enforces 30s minimum. - **Ignoring matrix slot interdependencies.** If `lief-darwin-arm64` fails because `lief-darwin-x64` produced a bad cache, fixing the arm64 slot won't help. Read both slots' logs before fixing. diff --git a/.claude/skills/greening-ci/run.mts b/.claude/skills/greening-ci/run.mts index 1b4a7f3..44382df 100644 --- a/.claude/skills/greening-ci/run.mts +++ b/.claude/skills/greening-ci/run.mts @@ -32,8 +32,8 @@ import os from 'node:os' import path from 'node:path' import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { spawn } from '@socketsecurity/lib/spawn' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { spawn } from '@socketsecurity/lib/process/spawn/child' const logger = getDefaultLogger() diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/guarding-paths/SKILL.md index bc91271..4f3a428 100644 --- a/.claude/skills/guarding-paths/SKILL.md +++ b/.claude/skills/guarding-paths/SKILL.md @@ -1,13 +1,13 @@ --- name: guarding-paths -description: Audits and fixes path duplication in a Socket repo. Applies the strict "1 path, 1 reference" rule — every build/test/runtime/config path is constructed exactly once; everywhere else references the constructed value. Default mode finds and fixes; `check` mode reports only; `install` mode drops the gate + hook + rule into a fresh repo. Use when path drift surfaces from `pnpm check`, when a new sibling package needs path conventions, or when bootstrapping a fresh Socket repo. +description: Audits and fixes path duplication in a Socket repo. Applies the strict "1 path, 1 reference" rule: every build/test/runtime/config path is constructed exactly once; everywhere else references the constructed value. Default mode finds and fixes; `check` mode reports only; `install` mode drops the gate + hook + rule into a fresh repo. Use when path drift surfaces from `pnpm check`, when a new sibling package needs path conventions, or when bootstrapping a fresh Socket repo. user-invocable: true allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(node scripts/check-paths:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(git:*) --- # guarding-paths -**Mantra: 1 path, 1 reference.** A path is constructed exactly once; everywhere else references the constructed value. Re-constructing the same path twice is the violation; referencing the constructed value many times is fine. +**Mantra: 1 path, 1 reference.** A path is constructed exactly once; everywhere else references the constructed value. Re-constructing the same path twice is the violation. Referencing the constructed value many times is fine. ## Modes @@ -22,11 +22,11 @@ allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm r The strategy lives in three artifacts that ship together: -1. **CLAUDE.md rule** — the mantra and detection rules in plain language. Every fleet repo's CLAUDE.md carries `## 1 path, 1 reference`. Synced from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md). -2. **Hook** — `.claude/hooks/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. -3. **Gate** — `scripts/check-paths.mts` runs in `pnpm check` (and CI). Whole-repo scan. Fails the build on any unsanctioned violation. +1. **CLAUDE.md rule**: the mantra and detection rules in plain language. Every fleet repo's CLAUDE.md carries `## 1 path, 1 reference`. Synced from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md). +2. **Hook**: `.claude/hooks/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. +3. **Gate**: `scripts/check-paths.mts` runs in `pnpm check` (and CI). Whole-repo scan. Fails the build on any unsanctioned violation. -The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/path-guard/segments.mts` — a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. +The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/path-guard/segments.mts`: a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. This skill is the **audit-and-fix workflow** that makes a repo conform initially and validates conformance over time. @@ -97,10 +97,10 @@ For Socket repos that don't yet have the gate: ## Allowlisting a finding -Genuine exemptions are rare — most "false positives" should be reported as gate bugs. When needed, add an entry to `.github/paths-allowlist.yml`. Two ways to pin: +Genuine exemptions are rare; most "false positives" should be reported as gate bugs. When needed, add an entry to `.github/paths-allowlist.yml`. Two ways to pin: -- **`line:`** — exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces. -- **`snippet_hash:`** — 12-char SHA-256 prefix of the offending snippet (whitespace-normalized). Drift-resistant — survives reformatting, but any content-changing edit invalidates it. Get the hash via `pnpm run check:paths --show-hashes`. +- **`line:`**: exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces. +- **`snippet_hash:`**: 12-char SHA-256 prefix of the offending snippet (whitespace-normalized). Drift-resistant: survives reformatting, but any content-changing edit invalidates it. Get the hash via `pnpm run check:paths --show-hashes`. Both may be set — either matching is sufficient. Prefer `snippet_hash` over raw `line:` when the exemption is expected to outlive routine reformatting; prefer `line:` when you specifically _want_ the entry to fall off after any nearby edit. @@ -110,11 +110,11 @@ Both may be set — either matching is sufficient. Prefer `snippet_hash` over ra - **Re-run the gate before each commit.** A green `pnpm run check:paths` is the entry criterion. - **Don't leave a partial fix uncommitted across phases.** Commit what's done on `chore/paths-audit-wip` if the audit gets interrupted. -Conventional commit shape: `fix(paths): rule A — extract foo build paths into scripts/paths.mts`. +Conventional commit shape: `fix(paths): rule A: extract foo build paths into scripts/paths.mts`. ## Tie-in with `scanning-quality` -`/scanning-quality` calls `pnpm run check:paths --json` as one of its sub-scans and surfaces findings in its A-F report. The full audit-and-fix workflow lives here; `scanning-quality` only _detects_ during periodic scans. +`/scanning-quality` calls `pnpm run check:paths --json` as one of its sub-scans and surfaces findings in its A-F report. The full audit-and-fix workflow lives here. `scanning-quality` only _detects_ during periodic scans. ## Fix patterns diff --git a/.claude/skills/locking-down-programmatic-claude/SKILL.md b/.claude/skills/locking-down-programmatic-claude/SKILL.md index 578762a..6cf825c 100644 --- a/.claude/skills/locking-down-programmatic-claude/SKILL.md +++ b/.claude/skills/locking-down-programmatic-claude/SKILL.md @@ -9,18 +9,43 @@ allowed-tools: Read, Grep, Glob **Rule:** every programmatic Claude callsite sets four flags. Skip any one and a future edit silently widens the surface. +## First: prefer the lib helper — don't hand-roll the flags + +🚨 For Node scripts / hooks, use **`spawnAiAgent` from `@socketsecurity/lib-stable/ai/spawn`** with a tier from the `AI_PROFILE` ladder in `@socketsecurity/lib-stable/ai/profiles`. It enforces the four flags at the type level (`SpawnAiAgentOptions` requires `tools` / `disallow` / `permissionMode`), translates them per-agent (claude / codex / gemini / opencode), and owns `--no-session-persistence`, `--add-dir`, and the 529-overload retry. Hand-rolling a `spawn('claude', [...flags])` is how the flag set drifts — and the `prefer-async-spawn` lint rule flags the raw spawn anyway. + +```ts +import { AI_PROFILE } from '@socketsecurity/lib-stable/ai/profiles' +import { spawnAiAgent } from '@socketsecurity/lib-stable/ai/spawn' + +const { exitCode, stdout } = await spawnAiAgent({ + ...AI_PROFILE.read, // or .edit / .create / .full + prompt: '…', + cwd: repoRoot, + timeoutMs: 10 * 60 * 1000, +}) +``` + +`AI_PROFILE` is a capability ladder, least → most capable — pick the narrowest tier that works: + +- `.read` — scan / classify. Read/Grep/Glob/WebFetch/WebSearch. No Edit/Write/Bash. +- `.edit` — in-place edits only. Read/Edit/Grep/Glob. No Write/MultiEdit/Bash (can't create files). +- `.create` — edit AND create files. Adds Write/MultiEdit. Still no Bash. +- `.full` — `.create` + Bash allowlisted to git/pnpm/node. + +Every tier also denies `Agent` (no sub-agent escape). Spread a tier and override per call (`tools`/`disallow` to tighten further, `model`, `addDirs`). The raw SDK/CLI recipes below are the underlying contract — reach for them only when you genuinely can't use the helper (e.g. a workflow-YAML `run:` step with no Node). + ## The four flags -| Layer | SDK option | CLI flag | What it does | -| ------------ | --------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------ | -| Definition | `tools` | `--tools` | Base set the model is told about. Tools not listed are invisible — no `tool_use` block possible. | -| Auto-approve | `allowedTools` | `--allowedTools` | Step 4. Listed tools run without invoking `canUseTool`. | -| Deny | `disallowedTools` | `--disallowedTools` | Step 2. Wins even against `bypassPermissions`. Defense-in-depth. | -| Mode | `permissionMode: 'dontAsk'` | `--permission-mode dontAsk` | Step 3. Unmatched tools denied without falling through to a missing `canUseTool`. | +| Layer | SDK option | CLI flag | What it does | +| ------------ | --------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | +| Definition | `tools` | `--tools` | Base set the model is told about. Tools not listed are invisible. No `tool_use` block possible. | +| Auto-approve | `allowedTools` | `--allowedTools` | Step 4. Listed tools run without invoking `canUseTool`. | +| Deny | `disallowedTools` | `--disallowedTools` | Step 2. Wins even against `bypassPermissions`. Defense-in-depth. | +| Mode | `permissionMode: 'dontAsk'` | `--permission-mode dontAsk` | Step 3. Unmatched tools denied without falling through to a missing `canUseTool`. | -The official permission flow (1) hooks → (2) deny rules → (3) permission mode → (4) allow rules → (5) `canUseTool`. In `dontAsk` mode step 5 is skipped — denied. The doc states verbatim: _"`allowedTools` and `disallowedTools` ... control whether a tool call is approved, not whether the tool is available."_ Availability is `tools`. +The official permission flow (1) hooks → (2) deny rules → (3) permission mode → (4) allow rules → (5) `canUseTool`. In `dontAsk` mode step 5 is skipped (denied). The doc states verbatim: _"`allowedTools` and `disallowedTools` ... control whether a tool call is approved, not whether the tool is available."_ Availability is `tools`. -## Recipe — read-only agent (audit, classify, summarize) +## Recipe: read-only agent (audit, classify, summarize) ```ts import { query } from '@anthropic-ai/claude-agent-sdk' @@ -58,7 +83,7 @@ claude --print \ "" ``` -## Recipe — agent that needs Bash (e.g. `/updating`: pnpm + git + jq) +## Recipe: agent that needs Bash (e.g. `/updating`: pnpm + git + jq) Narrow `Bash(...)` patterns surgically. Block dangerous Bash patterns explicitly. Fleet rules: no `npx`/`pnpm dlx`/`yarn dlx`; no `curl`/`wget` exfil; no destructive `rm -rf`; no `sudo`. Build the deny list as shell vars so the `npx`/`dlx` denials can carry the `# zizmor:` exemption marker (the pre-commit `scanNpxDlx` hook treats those literal strings as the prohibited tools, not as exemptions, unless the line is tagged): @@ -76,18 +101,18 @@ claude --print \ ## Never -- ❌ `permissionMode: 'default'` in headless contexts — falls through to a missing `canUseTool`. Behavior undefined. +- ❌ `permissionMode: 'default'` in headless contexts; falls through to a missing `canUseTool`. Behavior undefined. - ❌ `permissionMode: 'bypassPermissions'` / `allowDangerouslySkipPermissions: true`. -- ❌ Omitting `tools` — SDK default is the full claude_code preset. -- ❌ `Agent` / `Task` permitted — sub-agents inherit modes and can escape per-subagent restrictions when the parent is `bypassPermissions`/`acceptEdits`/`auto`. +- ❌ Omitting `tools`; SDK default is the full claude_code preset. +- ❌ `Agent` / `Task` permitted; sub-agents inherit modes and can escape per-subagent restrictions when the parent is `bypassPermissions`/`acceptEdits`/`auto`. ## Reference implementation -`socket-lib/tools/prim/src/disambiguate.mts` — canonical SDK-form callsite. The file header documents each flag against the eval-flow step it enforces. +`socket-lib/tools/prim/src/disambiguate.mts`: canonical SDK-form callsite. The file header documents each flag against the eval-flow step it enforces. -`socket-lib/tools/prim/test/disambiguate.test.mts` — source-text guards that fail the build if `BASE_TOOLS` widens, if `tools: BASE_TOOLS` is unwired, if `permissionMode` drifts from `'dontAsk'`, or if `bypassPermissions` / `allowDangerouslySkipPermissions: true` ever appears. Mirror this pattern in any new callsite. +`socket-lib/tools/prim/test/disambiguate.test.mts`: source-text guards that fail the build if `BASE_TOOLS` widens, if `tools: BASE_TOOLS` is unwired, if `permissionMode` drifts from `'dontAsk'`, or if `bypassPermissions` / `allowDangerouslySkipPermissions: true` ever appears. Mirror this pattern in any new callsite. ## Existing fleet callsites -- `socket-registry/.github/workflows/weekly-update.yml` — two `claude --print` invocations (run `/updating` skill, fix test failures). Bash recipe above. -- `socket-lib/tools/prim/src/disambiguate.mts` — read-only recipe above (`query()` SDK form). +- `socket-registry/.github/workflows/weekly-update.yml`: two `claude --print` invocations (run `/updating` skill, fix test failures). Bash recipe above. +- `socket-lib/tools/prim/src/disambiguate.mts`: read-only recipe above (`query()` SDK form). diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/prose/SKILL.md new file mode 100644 index 0000000..8d740ae --- /dev/null +++ b/.claude/skills/prose/SKILL.md @@ -0,0 +1,116 @@ +--- +name: prose +description: Removes AI writing patterns from prose. Use when drafting, editing, or reviewing essays, blog posts, docs, release notes, commit message bodies, PR descriptions, CHANGELOG entries, README content, or any human-facing text that reads AI-generated: hedged, metronomic, padded with throat-clearing, or full of em-dashes, adverbs, and "not X, it's Y" contrasts. +user-invocable: true +allowed-tools: Read, Edit, Write, Grep +--- + +# prose + +Eliminate AI writing patterns from prose. + +Hardik Pandya wrote the upstream version (`stop-slop`). MIT-licensed. Source: https://github.com/hardikpandya/stop-slop. Core rules + references run verbatim. Edit only in `socket-wheelhouse/template/`; the cascade refreshes downstream copies. + +## Fleet surfaces + +Apply this skill when you write: + +- Commit message bodies (multi-paragraph). Subject lines stay terse and imperative per `commit-message-format-guard`. +- PR descriptions (`gh pr create --body`, `gh pr edit --body`). +- CHANGELOG entries. +- README sections. +- `docs/` markdown. +- GitHub Release notes. + +## When to skip this skill + +- Code, code comments, or structured data. +- JSON, YAML, TOML. +- `chore(wheelhouse): cascade template@` commits. sync-scaffolding generates them with a fixed shape. +- Bot output: Dependabot PRs, release auto-notes from PR titles. +- Transcripts and direct quotes (preserve voice verbatim). +- API reference prose where precision matters more than rhythm. + +## Instructions + +1. Apply the Core Rules to every paragraph, in order. +2. Run the Quick Checks on the full draft. +3. Score with the Scoring table; if it totals below 35/50, revise and re-score. +4. Stop when the draft reads like a person wrote it. Further edits risk over-polishing. + +If an edit changes meaning or loses the author's voice, revert it. Never rewrite a direct quote. + +## Core Rules + +1. **Cut filler phrases.** Remove throat-clearing openers, emphasis crutches, and all adverbs. See [references/phrases.md](references/phrases.md). + +2. **Break formulaic structures.** Avoid binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency. See [references/structures.md](references/structures.md). + +3. **Use active voice.** Every sentence needs a human subject doing something. No passive constructions. No inanimate objects performing human actions ("the complaint becomes a fix"). + +4. **Be specific.** No vague declaratives ("The reasons are structural"). Name the specific thing. No lazy extremes ("every," "always," "never") doing vague work. + +5. **Put the reader in the room.** No narrator-from-a-distance voice. "You" beats "People." Specifics beat abstractions. + +6. **Vary rhythm.** Mix sentence lengths. Two items beat three. End paragraphs differently. No em dashes. + +7. **Trust readers.** State facts directly. Skip softening, justification, hand-holding. + +8. **Cut quotables.** If it sounds like a pull-quote, rewrite it. + +## Quick Checks + +Before delivering prose: + +- Any adverbs? Kill them. +- Any passive voice? Find the actor, make them the subject. +- Inanimate thing doing a human verb ("the decision emerges")? Name the person. +- Sentence starts with a Wh- word? Restructure it. +- Any "here's what/this/that" throat-clearing? Cut to the point. +- Any "not X, it's Y" contrasts? State Y directly. +- Three consecutive sentences match length? Break one. +- Paragraph ends with punchy one-liner? Vary it. +- Em-dash anywhere? Remove it. +- Vague declarative ("The implications are significant")? Name the specific implication. +- Narrator-from-a-distance ("Nobody designed this")? Put the reader in the scene. +- Meta-joiners ("The rest of this essay...")? Delete. Let the essay move. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +| ------------ | ----------------------------- | +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Example + +**Before:** + +``` +Here's the thing: building products is hard. Not because the +technology is complex. Because people are complex. Let that sink in. +``` + +**After:** + +``` +Building products is hard. Technology is manageable. People aren't. +``` + +Removed the opener, the binary contrast, and the emphasis crutch. Two direct statements, same meaning. + +See [references/examples.md](references/examples.md) for more. + +## Edge cases + +- **Direct quotes**: leave them alone; quoting a hedging speaker verbatim is not slop. +- **Technical prose where precision > rhythm**: API reference sentences can be metronomic; don't force variation that loses accuracy. +- **Lists and tables**: structural repetition is the point; don't "vary rhythm" inside a parameter list. +- **First-person personal voice**: `you`/`I` is fine; don't strip writer presence in the name of directness. diff --git a/.claude/skills/prose/references/examples.md b/.claude/skills/prose/references/examples.md new file mode 100644 index 0000000..bc74d17 --- /dev/null +++ b/.claude/skills/prose/references/examples.md @@ -0,0 +1,69 @@ +# Before/After Examples + +## Example 1: Throat-Clearing + Binary Contrast + +**Before:** + +> "Here's the thing: building products is hard. Not because the technology is complex. Because people are complex. Let that sink in." + +**After:** + +> "Building products is hard. Technology is manageable. People aren't." + +**Changes:** Removed opener, binary contrast structure, and emphasis crutch. Direct statements. + +--- + +## Example 2: Filler + Unnecessary Reassurance + +**Before:** + +> "It turns out that most teams struggle with alignment. The uncomfortable truth is that nobody wants to admit they're confused. And that's okay." + +**After:** + +> "Teams struggle with alignment. Nobody admits confusion." + +**Changes:** Cut hedging ("most"), removed throat-clearing phrases, deleted permission-granting ending. + +--- + +## Example 3: Business Jargon Stack + +**Before:** + +> "In today's fast-paced landscape, we need to lean into discomfort and navigate uncertainty with clarity. This matters because your competition isn't waiting." + +**After:** + +> "Move faster. Your competition is." + +**Changes:** Eliminated jargon entirely. Core message in six words. + +--- + +## Example 4: Dramatic Fragmentation + +**Before:** + +> "Speed. Quality. Cost. You can only pick two. That's it. That's the tradeoff." + +**After:** + +> "Speed, quality, cost—pick two." + +**Changes:** Single sentence. No performative emphasis. + +--- + +## Example 5: Rhetorical Setup + +**Before:** + +> "What if I told you that the best teams don't optimize for productivity? Here's what I mean: they optimize for learning. Think about it." + +**After:** + +> "The best teams optimize for learning, not productivity." + +**Changes:** Direct claim. No rhetorical scaffolding. diff --git a/.claude/skills/prose/references/phrases.md b/.claude/skills/prose/references/phrases.md new file mode 100644 index 0000000..d081bf8 --- /dev/null +++ b/.claude/skills/prose/references/phrases.md @@ -0,0 +1,154 @@ +# Phrases to Remove + +## Contents + +- Throat-Clearing Openers +- Emphasis Crutches +- Business Jargon +- Adverbs +- Meta-Commentary +- Performative Emphasis +- Telling Instead of Showing +- Vague Declaratives +- Email Pleasantries +- Letter Announcements + +## Throat-Clearing Openers + +Remove these announcement phrases. State the content directly. + +- "Here's the thing:" +- "Here's what [X]" +- "Here's this [X]" +- "Here's that [X]" +- "Here's why [X]" +- "The uncomfortable truth is" +- "It turns out" +- "The real [X] is" +- "Let me be clear" +- "The truth is," +- "I'll say it again:" +- "I'm going to be honest" +- "Can we talk about" +- "Here's what I find interesting" +- "Here's the problem though" + +Any "here's what/this/that" construction is throat-clearing before the point. Cut it and state the point. + +## Emphasis Crutches + +These add no meaning. Delete them. + +- "Full stop." / "Period." +- "Let that sink in." +- "This matters because" +- "Make no mistake" +- "Here's why that matters" + +## Business Jargon + +Replace with plain language. + +| Avoid | Use instead | +| --------------------- | ---------------------- | +| Navigate (challenges) | Handle, address | +| Unpack (analysis) | Explain, examine | +| Lean into | Accept, embrace | +| Landscape (context) | Situation, field | +| Game-changer | Significant, important | +| Double down | Commit, increase | +| Deep dive | Analysis, examination | +| Take a step back | Reconsider | +| Moving forward | Next, from now | +| Circle back | Return to, revisit | +| On the same page | Aligned, agreed | + +## Adverbs + +Kill all adverbs. No -ly words. No softeners, no intensifiers, no hedges. + +Specific offenders: + +- "really" +- "just" +- "literally" +- "genuinely" +- "honestly" +- "simply" +- "actually" +- "deeply" +- "truly" +- "fundamentally" +- "inherently" +- "inevitably" +- "interestingly" +- "importantly" +- "crucially" + +Also cut these filler phrases: + +- "At its core" +- "In today's [X]" +- "It's worth noting" +- "At the end of the day" +- "When it comes to" +- "In a world where" +- "The reality is" + +## Meta-Commentary + +Remove self-referential asides. The essay should move, not announce its own structure. + +- "Hint:" +- "Plot twist:" / "Spoiler:" +- "You already know this, but" +- "But that's another post" +- "X is a feature, not a bug" +- "Dressed up as" +- "The rest of this essay explains..." +- "Let me walk you through..." +- "In this section, we'll..." +- "As we'll see..." +- "I want to explore..." + +## Performative Emphasis + +False intimacy or manufactured sincerity: + +- "creeps in" +- "I promise" +- "They exist, I promise" + +## Telling Instead of Showing + +Announcing difficulty or significance rather than demonstrating it: + +- "This is genuinely hard" +- "This is what leadership actually looks like" +- "This is what X actually looks like" +- "actually matters" + +## Vague Declaratives + +Sentences that announce importance without naming the specific thing. Kill these. + +- "The reasons are structural" +- "The implications are significant" +- "This is the deepest problem" +- "The stakes are high" +- "The consequences are real" + +If a sentence says something is important/deep/structural without showing the specific thing, cut it or replace it with the specific thing. + +## Email Pleasantries + +- "I hope this email finds you well" +- "I hope you're doing well" +- "I hope all is well" + +## Letter Announcements + +- "I am writing this letter..." +- "I am writing to inform you..." +- "Writing this to inform you..." +- "I wanted to reach out..." diff --git a/.claude/skills/prose/references/structures.md b/.claude/skills/prose/references/structures.md new file mode 100644 index 0000000..53121b4 --- /dev/null +++ b/.claude/skills/prose/references/structures.md @@ -0,0 +1,201 @@ +# Structures to Avoid + +## Contents + +- Binary Contrasts +- Negative Listing +- Dramatic Fragmentation +- Rhetorical Setups +- Formulaic Constructions +- False Agency +- Narrator-from-a-Distance +- Passive Voice +- Sentence Starters to Avoid +- Rhythm Patterns +- Word Patterns +- Transformation Chains +- Before/After Framing +- Corrective Reveals +- Forced Cohesion + +## Binary Contrasts + +These create false drama. State the point directly. + +| Pattern | Problem | +| ------------------------------------------------------------- | ------------------------------ | +| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal | +| "[X] isn't the problem. [Y] is." | Formulaic reframe | +| "The answer isn't X. It's Y." | Predictable pivot | +| "It feels like X. It's actually Y." | Setup/reveal cliche | +| "The question isn't X. It's Y." | Rhetorical misdirection | +| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast | +| "It's not this. It's that." | Same formula, different words | +| "stops being X and starts being Y" | False transformation arc | +| "doesn't mean X, but actually Y" | Negation-then-assertion crutch | +| "is about X but not Y" | False distinction | +| "not just X but also Y" | Additive hedge | + +**Instead:** State Y directly. "The problem is Y." "Y matters here." Drop the negation entirely. + +## Negative Listing + +Listing what something is _not_ before revealing what it _is_. A rhetorical striptease. + +| Pattern | Problem | +| ------------------------------------- | --------------------------------- | +| "Not a X... Not a Y... A Z." | Dramatic buildup through negation | +| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense | + +**Instead:** State Z. The reader doesn't need the runway. + +## Dramatic Fragmentation + +Sentence fragments for emphasis read as manufactured profundity. + +| Pattern | Problem | +| ---------------------------------------- | ----------------------- | +| "[Noun]. That's it. That's the [thing]." | Performative simplicity | +| "X. And Y. And Z." | Staccato drama | +| "This unlocks something. [Word]." | Artificial revelation | + +**Instead:** Complete sentences. Trust content over presentation. + +## Rhetorical Setups + +These announce insight rather than deliver it. + +| Pattern | Problem | +| --------------------- | ---------------------- | +| "What if [reframe]?" | Socratic posturing | +| "Here's what I mean:" | Redundant preview | +| "Think about it:" | Condescending prompt | +| "And that's okay." | Unnecessary permission | + +**Instead:** Make the point. Let readers draw conclusions. + +## Formulaic Constructions + +| Pattern | Problem | +| ------------------------- | --------------------------- | +| "By the time X, I was Y." | Narrative template | +| "X that isn't Y" | Indirect. Say "X is broken" | + +## False Agency + +Giving inanimate things human verbs. Complaints don't "become" fixes. Bets don't "live or die." Decisions don't "emerge." A person does something to make those things happen. AI loves this because it avoids naming the actor. + +| Pattern | Problem | +| ------------------------------- | ----------------------------------------------------------------- | +| "a complaint becomes a fix" | The complaint did nothing. Someone fixed it. | +| "a bet lives or dies in days" | Bets don't have lifespans. Someone kills the project or ships it. | +| "the decision emerges" | Decisions don't emerge. Someone decides. | +| "the culture shifts" | Cultures don't shift on their own. People change behavior. | +| "the conversation moves toward" | Conversations don't move. Someone steers. | +| "the data tells us" | Data sits there. Someone reads it and draws a conclusion. | +| "the market rewards" | Markets don't reward. Buyers pay for things. | + +**Instead:** Name the human. "The team fixed it that week" beats "the complaint becomes a fix." If no specific person fits, use "you" to put the reader in the seat. + +## Narrator-from-a-Distance + +Floating above the scene instead of putting the reader in it. + +| Pattern | Problem | +| ------------------------- | ----------------------- | +| "Nobody designed this." | Disembodied observation | +| "This happens because..." | Lecturer voice | +| "This is why..." | Same | +| "People tend to..." | Armchair sociologist | + +**Instead:** Put the reader in the room. "You don't sit down one day and decide to..." beats "Nobody designed this." + +## Passive Voice + +Every sentence needs a subject doing something. Passive voice hides the actor and drains energy. + +| Pattern | Fix | +| -------------------------- | -------------------- | +| "X was created" | Name who created it | +| "It is believed that" | Name who believes it | +| "Mistakes were made" | Name who made them | +| "The decision was reached" | Name who decided | + +**Instead:** Find the actor. Put them at the front of the sentence. + +## Sentence Starters to Avoid + +| Pattern | Fix | +| --------------------------------------------------------------- | ----------------------------------------------- | +| Sentences starting with What, When, Where, Which, Who, Why, How | Restructure. Lead with the subject or the verb. | +| Paragraphs starting with "So" | Start with content | +| Sentences starting with "Look," | Remove | + +Wh- openers become a crutch. "What makes this hard is..." becomes "The constraint is..." or better, name the specific constraint. + +## Rhythm Patterns + +| Pattern | Fix | +| ------------------------------ | --------------------------------------------------- | +| Three-item lists | Use two items or one | +| Questions answered immediately | Let questions breathe or cut them | +| Every paragraph ends punchily | Vary endings | +| Em-dashes | Remove. Use commas or periods. No em dashes at all. | +| Staccato fragmentation | Don't stack short punchy sentences | +| "Not always. Not perfectly." | Hedging disguised as reassurance | + +## Word Patterns + +| Pattern | Problem | +| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. | +| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. | + +## Transformation Chains + +Words that link end-to-end, creating false momentum. + +| Pattern | Problem | +| ---------------------------------------------------------------- | ---------------------- | +| "X became Y. Y became Z." | Artificial momentum | +| "Friction becomes flow. Flow becomes speed." | Chain linking | +| "Word slop became legibility. Legibility became clarity." | False progression | +| "Bottlenecks become opportunities. Opportunities become growth." | Manufactured causation | + +**Instead:** State the outcome directly. "The process is now faster." + +## Before/After Framing + +False historical contrast to manufacture significance. + +| Pattern | Problem | +| ----------------------------------------- | ------------------------ | +| "Before X, it was Y." | Manufactured history | +| "Before AI, it was manual." | False transformation arc | +| "Before this framework, teams struggled." | Exaggerated contrast | + +**Instead:** Describe the current state. Skip the manufactured history. + +## Corrective Reveals + +Dramatic "truth telling" structure that positions the writer as enlightened. + +| Pattern | Problem | +| --------------------------------------------------- | -------------------- | +| "You've been told X. Here's the truth: Y." | Theatrical setup | +| "You've been told a lie. Here is the actual truth." | False authority | +| "Everyone says X. They're wrong." | Contrarian posturing | + +**Instead:** State Y directly without the theatrical setup. + +## Forced Cohesion + +Artificially linking separate ideas to sound profound. + +| Pattern | Problem | +| --------------------------------------- | ----------------------- | +| "You can't have X without Y." | False interdependence | +| "You can't have one without the other." | Manufactured connection | +| "These two things are linked." | Vague binding | + +**Instead:** If they're truly linked, the connection will be clear from context. diff --git a/.claude/skills/refreshing-history/SKILL.md b/.claude/skills/refreshing-history/SKILL.md index 858a803..f1ea818 100644 --- a/.claude/skills/refreshing-history/SKILL.md +++ b/.claude/skills/refreshing-history/SKILL.md @@ -44,11 +44,11 @@ The runner walks 10 phases end-to-end. See [`run.mts`](run.mts) for the implemen ## Hard requirements -- **Default-branch fallback** — never hard-code `main` or `master`; the runner resolves `$BASE` via `git symbolic-ref refs/remotes/origin/HEAD`. -- **Worktree-only** — the primary checkout is never touched (parallel-Claude rule). -- **Remote backup before destruction** — without it, recovery requires reflog access from the machine that ran the squash. -- **Signed commit** — pass `-S` explicitly to `commit-tree`; the bare config flag is unreliable for plumbing. -- **Integrity check before push** — pre-squash tree must equal post-squash tree (modulo submodules). +- **Default-branch fallback**: never hard-code `main` or `master`; the runner resolves `$BASE` via `git symbolic-ref refs/remotes/origin/HEAD`. +- **Worktree-only**: the primary checkout is never touched (parallel-Claude rule). +- **Remote backup before destruction**: without it, recovery requires reflog access from the machine that ran the squash. +- **Signed commit**: pass `-S` explicitly to `commit-tree`; the bare config flag is unreliable for plumbing. +- **Integrity check before push**: pre-squash tree must equal post-squash tree (modulo submodules). ## Recovery @@ -64,7 +64,7 @@ The backup ref persists indefinitely on the remote until manually deleted. ## Cross-fleet orchestration -Run via `socket-wheelhouse/scripts/run-skill-fleet.mts` to dispatch one job per repo in parallel — useful for refreshing multiple repos in one wave. +Run via `socket-wheelhouse/scripts/run-skill-fleet.mts` to dispatch one job per repo in parallel. Useful for refreshing multiple repos in one wave. ## Success criteria diff --git a/.claude/skills/refreshing-history/run.mts b/.claude/skills/refreshing-history/run.mts index 1c39d94..f22651f 100644 --- a/.claude/skills/refreshing-history/run.mts +++ b/.claude/skills/refreshing-history/run.mts @@ -23,13 +23,15 @@ */ import path from 'node:path' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' const logger = getDefaultLogger() import process from 'node:process' -import { errorMessage, isError } from '@socketsecurity/lib/errors' -import { isSpawnError, spawn } from '@socketsecurity/lib/spawn' +import { errorMessage } from '@socketsecurity/lib/errors' +import { isError } from '@socketsecurity/lib/errors/predicates' +import { isSpawnError } from '@socketsecurity/lib/process/spawn/errors' +import { spawn } from '@socketsecurity/lib/process/spawn/child' import { resolveDefaultBranch } from '../_shared/scripts/git-default-branch.mts' diff --git a/.claude/skills/reviewing-code/SKILL.md b/.claude/skills/reviewing-code/SKILL.md index 9a1a8df..bf8ff87 100644 --- a/.claude/skills/reviewing-code/SKILL.md +++ b/.claude/skills/reviewing-code/SKILL.md @@ -13,7 +13,7 @@ Four-pass multi-agent code review of the current branch against a base ref. Each - Reviewing a feature branch before opening (or after updating) a PR. - Getting a second-and-third opinion from a different agent than the one currently editing. -- Surfacing real bugs / regressions / data-integrity issues — not style. +- Surfacing real bugs / regressions / data-integrity issues, not style. - Establishing a paper trail for a tricky migration or compatibility-path change. ## Default pipeline @@ -29,9 +29,9 @@ Per-role fallback order, hybrid-backend handling (`opencode`), and the graceful- ## Variant analysis on confirmed findings -For every High / Critical finding the verify pass marks `CONFIRMED`, run a variant search before closing the report — the same shape often hides elsewhere in the repo. The discipline (what to search for, how to scope, when to skip) lives in [`_shared/variant-analysis.md`](../_shared/variant-analysis.md). Append a `## Variant Analysis` section per finding when variants are found; omit the section when there are none rather than emit an empty header. +For every High / Critical finding the verify pass marks `CONFIRMED`, run a variant search before closing the report. The same shape often hides elsewhere in the repo. The discipline (what to search for, how to scope, when to skip) lives in [`_shared/variant-analysis.md`](../_shared/variant-analysis.md). Append a `## Variant Analysis` section per finding when variants are found; omit the section when there are none rather than emit an empty header. -For security-class diffs specifically, run [`scanning-quality/scans/differential.md`](../scanning-quality/scans/differential.md) alongside this skill — that scan is the security-regression cousin to this skill's general review. +For security-class diffs specifically, run [`scanning-quality/scans/differential.md`](../scanning-quality/scans/differential.md) alongside this skill. That scan is the security-regression cousin to this skill's general review. ## Compounding lessons @@ -102,4 +102,4 @@ A single markdown file (`docs/-review-findings.md` by default) with 4. Writes per-pass prompts to a temp dir and runs the agent non-interactively. 5. Folds outputs into the final report. -The prompts live in the runner — single source of truth so the pipeline and the prompts can't drift apart. +The prompts live in the runner: single source of truth so the pipeline and the prompts can't drift apart. diff --git a/.claude/skills/reviewing-code/run.mts b/.claude/skills/reviewing-code/run.mts index 2963c4a..ec4802c 100644 --- a/.claude/skills/reviewing-code/run.mts +++ b/.claude/skills/reviewing-code/run.mts @@ -17,10 +17,11 @@ import os from 'node:os' import path from 'node:path' import process from 'node:process' -import { which } from '@socketsecurity/lib/bin' -import { safeDelete } from '@socketsecurity/lib/fs' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { isSpawnError, spawn } from '@socketsecurity/lib/spawn' +import { which } from '@socketsecurity/lib/bin/which' +import { safeDelete } from '@socketsecurity/lib/fs/safe' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { isSpawnError } from '@socketsecurity/lib/process/spawn/errors' +import { spawn } from '@socketsecurity/lib/process/spawn/child' const logger = getDefaultLogger() diff --git a/.claude/skills/running-test262/SKILL.md b/.claude/skills/running-test262/SKILL.md index a255cbe..c2c9d45 100644 --- a/.claude/skills/running-test262/SKILL.md +++ b/.claude/skills/running-test262/SKILL.md @@ -1,6 +1,6 @@ --- name: running-test262 -description: Run the test262 conformance suite against fleet parsers / runtimes (ultrathink acorn variants, socket-btm temporal-infra, future ports) using each repo's canonical runner. Never write homebrew test262 runners — every parser/runtime in the fleet ships a runner under `test/scripts/test262-*.mts` and an allowlist / unsupported-features config. Use this skill when asked to run spec tests, check conformance, debug a failing test262 case, or compare a parser against a reference implementation. +description: Run the test262 conformance suite against fleet parsers / runtimes (ultrathink acorn variants, socket-btm temporal-infra, future ports) using each repo's canonical runner. Never write homebrew test262 runners. Every parser/runtime in the fleet ships a runner under `test/scripts/test262-*.mts` and an unsupported-features config. Use this skill when asked to run spec tests, check conformance, debug a failing test262 case, or compare a parser against a reference implementation. user-invocable: true allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(find:*), Read --- @@ -9,59 +9,105 @@ allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(ls:*), Bash(cat:*), Bash(grep:*) The fleet has multiple parsers + runtimes that conform to ECMA262 or to a TC39 proposal: -- `ultrathink/packages/acorn/` — the JS parser, multiple lang ports (cpp/go/rust/wasm). -- `ultrathink/packages/test262-parser-runner/` — the canonical runner package. -- `socket-btm/packages/temporal-infra/` — Temporal-proposal C++ port. -- (Future) `@ultrathink/acorn` standalone package once it ships. +- `ultrathink/packages/acorn/`: the JS parser, multiple lang ports (cpp/go/rust/typescript). +- `ultrathink/packages/test262-parser-runner/`: the canonical shared runner package. +- `socket-btm/packages/temporal-infra/`: Temporal-proposal C++ port. -Every one of them ships its own `scripts/test262-*.mts` runner + an allowlist or `unsupported-features` config. Running test262 by hand (downloading the suite, scanning the metadata blocks, running each test) is the wrong shape — the runners already encode the suite-traversal, the per-feature skip logic, the harness setup, and the result-aggregation. Always reach for the existing runner. +Every one of them ships its own `scripts/test262-*.mts` runner + an `unsupported-features` config. Running test262 by hand (downloading the suite, scanning the metadata blocks, running each test) is the wrong shape. The runners already encode the suite-traversal, the per-feature skip logic, the harness setup, and the result-aggregation. Always reach for the existing runner. -## When to use +## Test262 submodule pin -- "Run the spec tests" / "check test262 conformance" / "are we passing the suite?" -- "This failing test262 case keeps tripping the parser — help debug." -- "Compare this parser against the reference implementation on test262." -- Asked to add a runner for a new language port — use the existing runners as the template; never start fresh. +The fleet pins to a shared `tc39/test262` SHA. As of 2026-05-21 both ultrathink + socket-btm pin `7e115f46a`. When bumping in one repo, bump in the other so cross-fleet comparison stays apples-to-apples. + +Annotation lives in each repo's `.gitmodules` with the pattern `# test262-YYYY.MM.DD` (commit-date of the pinned SHA, enforced by the `gitmodules-comment-guard` hook). + +## 🚨 Strict allowlist policy + +**An allowlist entry is ONLY for non-parser test fails.** Anything a parser should handle MUST NOT be allowlisted; it must be fixed in the parser. This is strict; the runners enforce it via design choices below. + +What counts as "non-parser": + +- **Unimplemented TC39 feature**: the proposal is at Stage 3+ but we haven't ported the grammar yet (decorators, source-phase imports). Goes in `test262-config/test262.unsupported-features` keyed on the TC39 feature name (NOT a test path). +- **Runner / harness bug**: the test runner itself produces a false signal (e.g. async-throws semantics, error-name matching). Fix the runner, don't allow-list the symptom. +- **Runtime-only test**: the test exercises a runtime API (`Reflect.*`, `Temporal.*`) that the parser-conformance run can't evaluate. The runners skip these by classification, not per-path allowlist. + +What does NOT count and must be fixed in the parser: + +- "Parser rejects valid input." Fix the parser. +- "Parser accepts invalid input." Fix the parser. +- "Parser produces wrong AST shape." Fix the parser. +- "Cross-impl divergence: Rust + TS pass, Go fails." Fix Go. + +If you feel tempted to add a per-test-path allowlist entry, the answer is almost always "the parser needs fixing." The `unsupported-features` file is the only escape valve and it's feature-name-keyed by design. You can't sneak a parser bug past it. ## Canonical runners per repo -| Repo | Runner | Allowlist / config | -| --------------------------------------------- | ------------------------------------------ | --------------------------------------------- | -| ultrathink/packages/acorn (TS/wasm) | `test/scripts/test262-runner.mts` | `test262-config/test262.unsupported-features` | -| ultrathink/packages/acorn (cpp/go/rust ports) | `lang//scripts/test262.mjs` | per-lang config | -| ultrathink/packages/test262-parser-runner | `bin/test262-parser-runner.mts` | passed via flags | -| socket-btm/packages/temporal-infra | `test/scripts/test262-temporal-runner.mts` | `test262-config/test262.allowlist` | +| Repo | Runner | Skip config | +| --------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| ultrathink/packages/acorn (multi-lane driver) | `test/test262-compare.mts` | per-lane runner config (inherits unsupported-features) | +| ultrathink/packages/acorn (per-lane) | `lang//scripts/test262.mts` | `test262-config/test262.unsupported-features` (feature-name-keyed) | +| ultrathink/packages/test262-parser-runner | `bin/test262-parser-runner.mts` | passed via flags | +| socket-btm/packages/temporal-infra | `test/scripts/test262-temporal-runner.mts` | `test262-config/test262.allowlist` (Temporal-only path allowlist; reviewed manually for non-parser-fail justification) | ## Invocation patterns -### Full run +### Multi-lane (recommended for cross-lane parity checks) ```bash -# ultrathink acorn (TS) -cd packages/acorn && node test/scripts/test262-runner.mts +cd packages/acorn -# socket-btm temporal-infra -cd packages/temporal-infra && node test/scripts/test262-temporal-runner.mts +# All 4 lanes, full suite +node test/test262-compare.mts + +# Subset of lanes +node test/test262-compare.mts --lane rust,go + +# All lanes, filtered to a single category +node test/test262-compare.mts --include 'language/expressions/await' -# acorn lang ports -cd packages/acorn/lang/cpp && node scripts/test262.mjs -cd packages/acorn/lang/go && node scripts/test262.mjs -cd packages/acorn/lang/rust && node scripts/test262.mjs +# Single test path, all lanes +node test/test262-compare.mts test/language/statements/class/private-method.js +``` + +Lanes: `rust`, `go`, `cpp`, `typescript`. Flags forward to each per-lane runner. + +### Single-lane + +```bash +# Per-lane direct invocation +cd packages/acorn/lang/rust && node scripts/test262.mts +cd packages/acorn/lang/go && node scripts/test262.mts +cd packages/acorn/lang/cpp && node scripts/test262.mts +cd packages/acorn/lang/typescript && node scripts/test262.mts + +# socket-btm temporal-infra +cd socket-btm/packages/temporal-infra && node test/scripts/test262-temporal-runner.mts ``` ### Single-case debug -Pass the test path to the runner: +Pass the test path positionally: ```bash -node test/scripts/test262-runner.mts test/language/expressions/await/await-in-nested-function.js +# Single lane +node scripts/test262.mts test/language/expressions/await/await-in-nested-function.js + +# All lanes +node test/test262-compare.mts test/language/expressions/await/await-in-nested-function.js ``` -The runner reports the AST diff, the harness state, and whether the test is in the allowlist / unsupported-features set. +### Targeted filtering + +```bash +node scripts/test262.mts --include 'export' # regex on path +node scripts/test262.mts --exclude 'surrogate' # regex on path +node scripts/test262.mts --category module # named feature group +node scripts/test262.mts --include 'class' --exclude 'async' +``` ### Vitest-integrated mode -Each repo also wires a vitest test that wraps the runner — useful for CI integration and selective re-runs: +Each repo also wires a vitest test that wraps the runner. Useful for CI integration and selective re-runs: ```bash pnpm exec vitest run test/unit/test262.test.mts # ultrathink acorn @@ -72,7 +118,8 @@ pnpm exec vitest run test/unit/test262-temporal.test.mts # socket-btm tempora - **Submodule missing.** The test262 suite is a git submodule. If the runner errors with "test262 suite not found", run `git submodule update --init --recursive`. - **Feature classification drift.** The runner uses each test's metadata block (`/*--- features: [...] ---*/`) to decide whether to run or skip. If a new TC39 feature is added upstream, classify it in the `unsupported-features` config first; do not let the runner silently pass tests for features the parser doesn't implement. -- **Allowlist drift.** When a test starts passing that was previously failing, the allowlist still includes it — clean it up by removing from the allowlist so the suite gates on the new behavior. +- **"Allowlist drift": does NOT apply here.** The acorn lanes don't carry a per-test-path allowlist. If a test starts passing or failing, that's the parser's behavior; either the parser is correct and the test is correct (good), or one of them is wrong and that's a bug. +- **Cross-fleet drift.** ultrathink and socket-btm should pin the same `tc39/test262` SHA. If you're investigating a flaky test, double-check both `.gitmodules` files first. ## Never write a homebrew runner @@ -82,3 +129,4 @@ The existing runners encode dozens of edge cases (strict-mode harness wrapping, - TC39 test262 spec: https://github.com/tc39/test262 - Each runner's source is the source of truth for invocation flags and exit-code conventions; cat the runner first if the invocation is unclear. +- Strict allowlist policy + multi-lane behavior + `tc39/test262` pin date all encoded in this skill. Read this skill before touching either system. diff --git a/.claude/skills/scanning-quality/SKILL.md b/.claude/skills/scanning-quality/SKILL.md index 3b4b01f..5a6d8b4 100644 --- a/.claude/skills/scanning-quality/SKILL.md +++ b/.claude/skills/scanning-quality/SKILL.md @@ -11,8 +11,8 @@ Quality analysis across the codebase using specialized Task agents. Cleans up ju ## Modes -- **Default (interactive)** — `AskUserQuestion` is used to confirm cleanup deletions and to pick scan scope. -- **Non-interactive** — `/scanning-quality non-interactive` (or any of the aliases below) skips every `AskUserQuestion` and applies safe defaults: scan scope = all types, cleanup = leave junk files in place (don't delete without confirmation), report-save = yes (`reports/scanning-quality-YYYY-MM-DD.md`). Use this when running headlessly (CI cron, programmatic Claude, any non-TTY driver). The four-flag programmatic-Claude lockdown rule already strips `AskUserQuestion`, so headless runs default to non-interactive automatically — but call it out explicitly so future readers understand the contract. +- **Default (interactive)**: `AskUserQuestion` is used to confirm cleanup deletions and to pick scan scope. +- **Non-interactive**: `/scanning-quality non-interactive` (or any of the aliases below) skips every `AskUserQuestion` and applies safe defaults: scan scope = all types, cleanup = leave junk files in place (don't delete without confirmation), report-save = yes (`reports/scanning-quality-YYYY-MM-DD.md`). Use this when running headlessly (CI cron, programmatic Claude, any non-TTY driver). The four-flag programmatic-Claude lockdown rule already strips `AskUserQuestion`, so headless runs default to non-interactive automatically. Call it out explicitly so future readers understand the contract. Detect non-interactive mode via any of: `--non-interactive` argument, `non-interactive` argument, `SCANNING_QUALITY_NONINTERACTIVE=1` env var, or absence of `AskUserQuestion` in the available tool surface. @@ -31,12 +31,13 @@ Legacy scan types (agent prompts in `reference.md`): Modular scan types (one file per type under `scans/`, easier to extend than the monolithic `reference.md`): -9. **variant-analysis** — for each High/Critical finding from above, search the rest of the repo for the same shape. See [`scans/variant-analysis.md`](scans/variant-analysis.md). -10. **insecure-defaults** — fail-open defaults, hardcoded credentials, lazy fallbacks. See [`scans/insecure-defaults.md`](scans/insecure-defaults.md). -11. **differential** — security-focused diff against a base ref. See [`scans/differential.md`](scans/differential.md). -12. **bundle-trim** — for repos that ship a built bundle (today: rolldown), identify unused module paths the bundler statically pulled in but the runtime never reaches. Reports candidates; the trim loop itself lives in the [`trimming-bundle`](../trimming-bundle/SKILL.md) skill. See [`scans/bundle-trim.md`](scans/bundle-trim.md). +9. **variant-analysis**: for each High/Critical finding from above, search the rest of the repo for the same shape. See [`scans/variant-analysis.md`](scans/variant-analysis.md). +10. **insecure-defaults**: fail-open defaults, hardcoded credentials, lazy fallbacks. See [`scans/insecure-defaults.md`](scans/insecure-defaults.md). +11. **differential**: security-focused diff against a base ref. See [`scans/differential.md`](scans/differential.md). +12. **bundle-trim**: for repos that ship a built bundle (today: rolldown), identify unused module paths the bundler statically pulled in but the runtime never reaches. Reports candidates; the trim loop itself lives in the [`trimming-bundle`](../trimming-bundle/SKILL.md) skill. See [`scans/bundle-trim.md`](scans/bundle-trim.md). +13. **deadcode-removal**: surface dead source files, test-only helpers, stale `// eslint-disable` / `// oxlint-disable` directives, and dead string-literal constants. Captures the fleet rule that `socket/export-top-level-functions` REQUIRES `export` on helpers (exports exist for tests), so the scan never recommends dropping `export` to colocate. See [`scans/deadcode-removal.md`](scans/deadcode-removal.md). -Adding a new scan type: drop a file under `scans/.md` describing mission, method, output shape, when-to-skip — same shape as the three above. The orchestrator picks them up by directory listing; no edits to this SKILL.md needed beyond appending to the list. +Adding a new scan type: drop a file under `scans/.md` describing mission, method, output shape, when-to-skip; same shape as the three above. The orchestrator picks them up by directory listing; no edits to this SKILL.md needed beyond appending to the list. The split exists because adding a 12th, 15th, 20th scan type into `reference.md` produces exactly the "this and also that and also the other thing" file CLAUDE.md's File-size rule warns about. Per-type files keep each scan reviewable in isolation. @@ -60,11 +61,11 @@ Only update the current repository. Continue even if update fails. ### Phase 3: Install zizmor -Install zizmor for GitHub Actions security scanning, respecting the soak time — pnpm-workspace.yaml `minimumReleaseAge` in minutes, default 10080 (= 7 days). Query GitHub releases, find the latest stable release older than the threshold, and install via pipx/uvx. Skip the security scan if no release meets the soak requirement. +Install zizmor for GitHub Actions security scanning, respecting the soak time (pnpm-workspace.yaml `minimumReleaseAge` in minutes, default 10080 = 7 days). Query GitHub releases, find the latest stable release older than the threshold, and install via pipx/uvx. Skip the security scan if no release meets the soak requirement. ### Phase 4: Repository Cleanup -Find junk files (interactive mode confirms each batch via `AskUserQuestion`; non-interactive mode lists what was found in the report and leaves them in place — don't delete files without explicit confirmation, even on a clean dirty-tree): +Find junk files (interactive mode confirms each batch via `AskUserQuestion`; non-interactive mode lists what was found in the report and leaves them in place; don't delete files without explicit confirmation, even on a clean dirty-tree): - SCREAMING_TEXT.md files outside `.claude/` and `docs/` - Test files in wrong locations @@ -77,20 +78,20 @@ Find junk files (interactive mode confirms each batch via `AskUserQuestion`; non node scripts/check-paths.mts ``` -Report errors as Critical findings. Warnings are Low findings. (The fleet's structural validator is `check-paths.mts`, the path-hygiene gate. If a repo has a richer structural validator under a different name, run that instead — but every fleet repo ships `check-paths.mts`.) +Report errors as Critical findings. Warnings are Low findings. (The fleet's structural validator is `check-paths.mts`, the path-hygiene gate. If a repo has a richer structural validator under a different name, run that instead. Every fleet repo ships `check-paths.mts`.) ### Phase 6: Determine Scan Scope In **interactive** mode, ask the user which scans to run via `AskUserQuestion` (multiSelect). Default: all scans. -In **non-interactive** mode, run all scan types — no prompt. +In **non-interactive** mode, run all scan types; no prompt. ### Phase 7: Execute Scans For each enabled scan type, spawn a Task agent with the corresponding prompt: -- Legacy types (1–8) — prompt from `reference.md`. -- Modular types (9+) — prompt from `scans/.md`. +- Legacy types (1–8): prompt from `reference.md`. +- Modular types (9+): prompt from `scans/.md`. Run sequentially in priority order: critical, logic, cache, workflow, security, then the modular scans (variant-analysis depends on earlier findings so runs after them; insecure-defaults and differential are independent), then documentation last. @@ -113,8 +114,8 @@ Report final metrics: dependency updates, structural validation results, cleanup ## Commit cadence -This skill is read-only — it scans and reports, it doesn't fix. Cadence rules apply to _handing the report off_, not to fixes: +This skill is read-only. It scans and reports, it doesn't fix. Cadence rules apply to _handing the report off_, not to fixes: - **Save the report before acting on it.** If the user opts to save (`reports/scanning-quality-YYYY-MM-DD.md`), commit the report file in its own commit (`docs(reports): scanning-quality YYYY-MM-DD`). That snapshot is referenceable later when fixes land. -- **Don't fix in-skill.** If findings need fixes, hand off to the appropriate skill — `/guarding-paths` for path drift, `refactor-cleaner` agent via `/quality-loop` for code-quality findings — and commit those fixes per that skill's own cadence rules. Don't bundle scan + fixes in one commit. +- **Don't fix in-skill.** If findings need fixes, hand off to the appropriate skill (`/guarding-paths` for path drift, `refactor-cleaner` agent via `/quality-loop` for code-quality findings) and commit those fixes per that skill's own cadence rules. Don't bundle scan + fixes in one commit. - **One report per scan run.** Re-running the skill produces a new report; don't overwrite the previous one's git history. Commit each fresh report so the trend line is visible. diff --git a/.claude/skills/scanning-security/SKILL.md b/.claude/skills/scanning-security/SKILL.md index c7cd91c..489c5ff 100644 --- a/.claude/skills/scanning-security/SKILL.md +++ b/.claude/skills/scanning-security/SKILL.md @@ -1,6 +1,6 @@ --- name: scanning-security -description: Runs a multi-tool security scan — AgentShield for Claude config, zizmor for GitHub Actions, and optionally Socket CLI for dependency scanning. Produces an A-F graded security report. Use after modifying `.claude/` config, hooks, agents, or GitHub Actions workflows, and before releases. +description: Runs a multi-tool security scan: AgentShield for Claude config, zizmor for GitHub Actions, and optionally Socket CLI for dependency scanning. Produces an A-F graded security report. Use after modifying `.claude/` config, hooks, agents, or GitHub Actions workflows, and before releases. user-invocable: true allowed-tools: Task, Read, Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(find .cache/external-tools/zizmor:*) --- @@ -81,7 +81,7 @@ The agent: 2. Calculates an A-F grade per `_shared/report-format.md` 3. Generates a prioritized report (CRITICAL first) 4. Suggests fixes for HIGH and CRITICAL findings -5. For every Critical / High finding, runs variant analysis per [`_shared/variant-analysis.md`](../_shared/variant-analysis.md) — the same misconfiguration likely exists in sibling workflow files, sibling Claude config blocks, or other repos. +5. For every Critical / High finding, runs variant analysis per [`_shared/variant-analysis.md`](../_shared/variant-analysis.md). The same misconfiguration likely exists in sibling workflow files, sibling Claude config blocks, or other repos. Output a HANDOFF block per `_shared/report-format.md` for pipeline chaining. @@ -91,15 +91,15 @@ Update queue: `status: done`, write `findings_count` and final grade. Code-side security (insecure defaults, fail-open patterns, security-regression in a diff) lives in `scanning-quality`'s modular scans: -- [`scanning-quality/scans/insecure-defaults.md`](../scanning-quality/scans/insecure-defaults.md) — code-side fail-open defaults. -- [`scanning-quality/scans/differential.md`](../scanning-quality/scans/differential.md) — security regressions introduced by the current diff. +- [`scanning-quality/scans/insecure-defaults.md`](../scanning-quality/scans/insecure-defaults.md): code-side fail-open defaults. +- [`scanning-quality/scans/differential.md`](../scanning-quality/scans/differential.md): security regressions introduced by the current diff. This skill stays focused on **config security** (Claude config + GitHub Actions). The split keeps the surface predictable: `scanning-security` = "is the harness safe?", `scanning-quality/scans/` = "is the code safe?". ## Commit cadence -This skill is read-only — scan + grade + report, no fixes. Cadence rules apply to handing the report off: +This skill is read-only: scan + grade + report, no fixes. Cadence rules apply to handing the report off: -- **Save the report before acting.** Commit the report file in its own commit (`docs(reports): scanning-security YYYY-MM-DD — grade `). The grade in the message makes the trend visible without opening the file. +- **Save the report before acting.** Commit the report file in its own commit (`docs(reports): scanning-security YYYY-MM-DD: grade `). The grade in the message makes the trend visible without opening the file. - **Don't fix in-skill.** Security findings need careful per-finding triage; they're not safe to batch-fix mechanically. Open per-finding fixes as separate commits driven by the appropriate skill (or hand-edit when the fix is a one-liner like a workflow SHA bump). - **One report per scan run.** Re-running produces a new report; commit each so the security trend line is auditable. diff --git a/.claude/skills/updating-coverage/SKILL.md b/.claude/skills/updating-coverage/SKILL.md new file mode 100644 index 0000000..3b60da0 --- /dev/null +++ b/.claude/skills/updating-coverage/SKILL.md @@ -0,0 +1,116 @@ +--- +name: updating-coverage +description: Refresh the coverage badge in the root README by running the repo's coverage script and rewriting the `![Coverage](https://img.shields.io/badge/coverage-%25-brightgreen)` line. Sibling of `updating-security` / `updating-lockstep` under the `updating` umbrella. +user-invocable: true +allowed-tools: Read, Edit, Bash(pnpm run cover:*), Bash(pnpm run coverage:*), Bash(pnpm run test:cover:*), Bash(node:*), Bash(git:*), Bash(jq:*), Bash(cat:*) +--- + +# updating-coverage + +Runs the repo's coverage script and rewrites the README badge so the published number matches reality. Invoked directly via `/update-coverage` or as a phase of the `updating` umbrella. + +## When to use + +- After landing a substantial change to test coverage (added a major + feature with tests, removed a large untested module). +- Pre-release, to refresh the public badge. +- As part of `updating` umbrella flow when the repo declares a + coverage script. + +## What it does NOT do + +- **Generate coverage from scratch.** This skill consumes the output of the repo's existing coverage tooling (vitest / c8 / istanbul / node-test coverage). If no coverage script is declared in `package.json`, the skill reports that and exits. +- **Compute coverage thresholds.** The badge reflects what the + tooling reports; tightening the threshold is a separate decision + in the repo's vitest/c8 config. +- **Modify nested READMEs.** Only the repo-root `README.md` is + rewritten. Nested READMEs under `packages/*` have their own + badges and lifecycles. + +## Phases + +| # | Phase | Outcome | +| --- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Discovery | Find the coverage script in `package.json` (`cover` / `coverage` / `test:cover`, in that preference). | +| 2 | Run | `pnpm run