specpipe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // file-guard.js — PostToolUse hook for Claude Code
3
+ //
4
+ // Warns when a Write/Edit operation produces a source code file exceeding a line threshold.
5
+ // Only checks source code files — docs (.md), configs (.json/.yaml/.toml), and templates
6
+ // are intentionally excluded since they are naturally long.
7
+ // Non-blocking: always exits 0 and injects advisory context.
8
+ //
9
+ // Environment:
10
+ // FILE_GUARD_THRESHOLD — max lines before warning (default: 350)
11
+ // FILE_GUARD_EXCLUDE — comma-separated globs to skip (e.g. "*.generated.swift,*.pb.go")
12
+
13
+ "use strict";
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+
18
+ const THRESHOLD = parseInt(process.env.FILE_GUARD_THRESHOLD, 10) || 350;
19
+
20
+ // Only warn for source code files — docs, configs, and templates are naturally long
21
+ const SOURCE_EXTENSIONS = new Set([
22
+ // JavaScript / TypeScript
23
+ ".js", ".mjs", ".cjs", ".jsx",
24
+ ".ts", ".tsx", ".mts", ".cts",
25
+ // Frontend frameworks
26
+ ".vue", ".svelte", ".astro",
27
+ // Python
28
+ ".py", ".pyw", ".pyi", ".pyx", ".pxd",
29
+ // PHP
30
+ ".php", ".php3", ".php4", ".php5", ".php7", ".php8", ".phtml",
31
+ // Ruby
32
+ ".rb", ".rbw",
33
+ // Rust
34
+ ".rs",
35
+ // Go
36
+ ".go",
37
+ // Swift
38
+ ".swift",
39
+ // Kotlin
40
+ ".kt", ".kts",
41
+ // Java
42
+ ".java",
43
+ // C#
44
+ ".cs", ".csx",
45
+ // C / C++
46
+ ".c", ".h", ".cc", ".cpp", ".cxx", ".c++", ".hpp", ".hh", ".hxx", ".h++",
47
+ // Objective-C
48
+ ".m", ".mm",
49
+ // Dart
50
+ ".dart",
51
+ // Elixir
52
+ ".ex", ".exs",
53
+ // Scala
54
+ ".scala", ".sc",
55
+ // Groovy
56
+ ".groovy",
57
+ // Clojure
58
+ ".clj", ".cljs", ".cljc",
59
+ // Haskell
60
+ ".hs", ".lhs",
61
+ // F#
62
+ ".fs", ".fsx", ".fsi",
63
+ // OCaml
64
+ ".ml", ".mli", ".mll", ".mly",
65
+ // Erlang
66
+ ".erl", ".hrl",
67
+ // Lua
68
+ ".lua",
69
+ // R
70
+ ".r",
71
+ // Julia
72
+ ".jl",
73
+ // Nim
74
+ ".nim", ".nims",
75
+ // Zig
76
+ ".zig",
77
+ // Crystal
78
+ ".cr",
79
+ // Perl
80
+ ".pl", ".pm",
81
+ // Solidity
82
+ ".sol",
83
+ // PowerShell
84
+ ".ps1", ".psm1", ".psd1",
85
+ // PureScript
86
+ ".purs",
87
+ // Elm
88
+ ".elm",
89
+ // ReScript / ReasonML
90
+ ".res", ".resi",
91
+ // Lisp / Scheme / Racket
92
+ ".lisp", ".lsp", ".cl", ".el", ".scm", ".ss", ".rkt",
93
+ // Prolog
94
+ ".pro",
95
+ // Fortran
96
+ ".f", ".f90", ".f95", ".f03", ".f08",
97
+ // Pascal / Delphi
98
+ ".pas", ".pp",
99
+ // VB.NET
100
+ ".vb",
101
+ // Arduino
102
+ ".ino",
103
+ ]);
104
+
105
+ const EXCLUDE = (process.env.FILE_GUARD_EXCLUDE || "")
106
+ .split(",")
107
+ .map((g) => g.trim())
108
+ .filter(Boolean);
109
+
110
+ function matchesExclude(filePath) {
111
+ const name = path.basename(filePath);
112
+ return EXCLUDE.some((pattern) => {
113
+ // Simple glob: *.ext or exact match
114
+ if (pattern.startsWith("*")) {
115
+ return name.endsWith(pattern.slice(1));
116
+ }
117
+ return name === pattern;
118
+ });
119
+ }
120
+
121
+ function isBinary(buf) {
122
+ // Check first 512 bytes for null bytes (common binary indicator)
123
+ const check = buf.slice(0, 512);
124
+ for (let i = 0; i < check.length; i++) {
125
+ if (check[i] === 0) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ function main() {
131
+ let input;
132
+ try {
133
+ input = fs.readFileSync(0, "utf-8").trim();
134
+ } catch {
135
+ process.exit(0);
136
+ }
137
+
138
+ if (!input) process.exit(0);
139
+
140
+ let payload;
141
+ try {
142
+ payload = JSON.parse(input);
143
+ } catch {
144
+ process.exit(0);
145
+ }
146
+
147
+ const filePath = payload.tool_input?.file_path;
148
+ if (!filePath) process.exit(0);
149
+
150
+ // Skip files outside the project directory (e.g. ~/.claude/plans/)
151
+ const projectDir = process.cwd() + path.sep;
152
+ const resolvedFile = path.resolve(filePath);
153
+ if (!resolvedFile.startsWith(projectDir) && resolvedFile !== process.cwd()) process.exit(0);
154
+
155
+ // Skip non-source-code files (docs, configs, templates are naturally long)
156
+ const ext = path.extname(filePath).toLowerCase();
157
+ if (!SOURCE_EXTENSIONS.has(ext)) process.exit(0);
158
+
159
+ // Skip excluded patterns
160
+ if (matchesExclude(filePath)) process.exit(0);
161
+
162
+ // Skip if file doesn't exist (deleted?) or is a symlink to outside project
163
+ try {
164
+ const stat = fs.lstatSync(filePath);
165
+ if (stat.isSymbolicLink() || !stat.isFile()) process.exit(0);
166
+ } catch {
167
+ process.exit(0);
168
+ }
169
+
170
+ // Cap read at 1MB to avoid OOM on huge files
171
+ const MAX_BYTES = 1024 * 1024;
172
+ let content;
173
+ try {
174
+ const stat = fs.statSync(filePath);
175
+ if (stat.size > MAX_BYTES) {
176
+ const rel = path.relative(process.cwd(), filePath);
177
+ process.stdout.write(JSON.stringify({
178
+ continue: true,
179
+ hookSpecificOutput: {
180
+ hookEventName: "PostToolUse",
181
+ additionalContext: `Warning: ${rel} is ${Math.round(stat.size / 1024)}KB. Consider splitting into smaller modules.`,
182
+ },
183
+ }) + "\n");
184
+ process.exit(0);
185
+ }
186
+ const buf = fs.readFileSync(filePath);
187
+ if (isBinary(buf)) process.exit(0);
188
+ content = buf.toString("utf-8");
189
+ } catch {
190
+ process.exit(0);
191
+ }
192
+
193
+ const lineCount = content.split("\n").length;
194
+ if (lineCount <= THRESHOLD) process.exit(0);
195
+
196
+ // Inject non-blocking warning
197
+ const rel = path.relative(process.cwd(), filePath);
198
+ const warning = `Warning: ${rel} has ${lineCount} lines (threshold: ${THRESHOLD}). Consider splitting into smaller, focused modules.`;
199
+
200
+ process.stdout.write(
201
+ JSON.stringify({
202
+ continue: true,
203
+ hookSpecificOutput: {
204
+ hookEventName: "PostToolUse",
205
+ additionalContext: warning,
206
+ },
207
+ }) + "\n"
208
+ );
209
+ }
210
+
211
+ try {
212
+ main();
213
+ } catch {
214
+ // Never block on error
215
+ }
216
+ process.exit(0);
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // glob-guard.js — PreToolUse hook for Claude Code
3
+ //
4
+ // Blocks overly broad glob patterns (e.g. **/*.ts at project root) that would
5
+ // return thousands of files and fill the context window. Suggests scoped
6
+ // alternatives instead.
7
+ //
8
+ // Blocking: Yes — exits 2 when a broad pattern at a high-level path is detected.
9
+ // Event: PreToolUse on Glob
10
+
11
+ "use strict";
12
+
13
+ const fs = require("fs");
14
+
15
+ // Patterns that match too many files when run at project root
16
+ const BROAD_PATTERNS = [
17
+ /^\*\*$/, // **
18
+ /^\*$/, // *
19
+ /^\*\*\/\*$/, // **/*
20
+ /^\*\.\w+$/, // *.ts, *.js
21
+ /^\*\.\{[^}]+\}$/, // *.{ts,js}
22
+ /^\*\*\/\*\.\w+$/, // **/*.ts
23
+ /^\*\*\/\*\.\{[^}]+\}$/, // **/*.{ts,tsx}
24
+ /^\*\*\/\.\*$/, // **/.* (all dotfiles)
25
+ ];
26
+
27
+ // Directories that indicate an intentional, scoped search
28
+ const SCOPED_DIRS = [
29
+ "src", "lib", "app", "apps", "packages", "components", "pages",
30
+ "api", "server", "client", "web", "mobile", "shared", "common",
31
+ "utils", "helpers", "services", "hooks", "store", "routes",
32
+ "models", "controllers", "views", "tests", "__tests__", "spec",
33
+ "Sources", "Tests", "cmd", "pkg", "internal",
34
+ ];
35
+
36
+ // Allow project-specific scoped dirs via env var
37
+ // e.g. GLOB_GUARD_SCOPED_DIRS=Feature,Domain,Presentation
38
+ const extraDirs = (process.env.GLOB_GUARD_SCOPED_DIRS || "")
39
+ .split(",")
40
+ .map((d) => d.trim())
41
+ .filter(Boolean);
42
+ if (extraDirs.length > 0) SCOPED_DIRS.push(...extraDirs);
43
+
44
+ function isBroadPattern(pattern) {
45
+ if (!pattern) return false;
46
+ return BROAD_PATTERNS.some((re) => re.test(pattern.trim()));
47
+ }
48
+
49
+ function startsWithScopedDir(pattern) {
50
+ if (!pattern) return false;
51
+ // Only allow dirs explicitly in SCOPED_DIRS — not arbitrary dirs like node_modules/
52
+ return SCOPED_DIRS.some(
53
+ (d) => pattern.startsWith(d + "/") || pattern.startsWith("./" + d + "/")
54
+ );
55
+ }
56
+
57
+ function isRootLevel(basePath) {
58
+ if (!basePath) return true;
59
+ const norm = basePath.replace(/\\/g, "/").replace(/\/+$/, "");
60
+ if (!norm || norm === ".") return true;
61
+ const segments = norm.split("/").filter((s) => s && s !== ".");
62
+ if (segments.length === 0) return true;
63
+ if (segments.length === 1 && !SCOPED_DIRS.includes(segments[0])) return true;
64
+ return false;
65
+ }
66
+
67
+ function suggest(pattern) {
68
+ let ext = "";
69
+ const m = pattern.match(/\*\.(\{[^}]+\}|\w+)$/);
70
+ if (m) ext = m[1];
71
+ const dirs = ["src", "lib", "app", "tests"];
72
+ return ext
73
+ ? dirs.map((d) => `${d}/**/*.${ext}`).slice(0, 3)
74
+ : dirs.map((d) => `${d}/**/*`).slice(0, 3);
75
+ }
76
+
77
+ function main() {
78
+ let input;
79
+ try { input = fs.readFileSync(0, "utf-8").trim(); } catch { process.exit(0); }
80
+ if (!input) process.exit(0);
81
+
82
+ let payload;
83
+ try { payload = JSON.parse(input); } catch { process.exit(0); }
84
+
85
+ const pattern = payload.tool_input?.pattern;
86
+ const basePath = payload.tool_input?.path;
87
+
88
+ if (!pattern) process.exit(0);
89
+ if (startsWithScopedDir(pattern)) process.exit(0);
90
+ if (!isBroadPattern(pattern)) process.exit(0);
91
+ if (!isRootLevel(basePath)) process.exit(0);
92
+
93
+ const alt = suggest(pattern);
94
+ process.stderr.write(
95
+ [
96
+ `Blocked: '${pattern}' is too broad for ${basePath || "project root"} — would fill the context window.`,
97
+ "Use a scoped pattern instead:",
98
+ ...alt.map((s) => ` - ${s}`),
99
+ ].join("\n") + "\n"
100
+ );
101
+ process.exit(2);
102
+ }
103
+
104
+ try { main(); } catch { process.exit(0); }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bash
2
+ # path-guard.sh — PreToolUse hook for Claude Code
3
+ #
4
+ # Blocks Bash commands that target directories known to be large and wasteful
5
+ # to explore (node_modules, build artifacts, .git internals, etc.).
6
+ #
7
+ # Exit codes:
8
+ # 0 — command allowed
9
+ # 2 — command blocked (policy)
10
+ #
11
+ # Environment:
12
+ # PATH_GUARD_EXTRA — additional pipe-separated patterns to block
13
+ # e.g. "\.terraform|\.vagrant"
14
+
15
+ set -euo pipefail
16
+
17
+ # Windows note: this hook requires bash (WSL or Git Bash).
18
+ # On Windows without bash, Claude Code will fail to run this hook and skip it silently.
19
+ # Install WSL or Git Bash and ensure `bash` is in PATH to activate protection.
20
+
21
+ # ─── Read hook payload from stdin ───────────────────────────────────
22
+
23
+ INPUT=$(cat)
24
+ [[ -z "$INPUT" ]] && exit 0
25
+
26
+ # Extract command from JSON — try node first, fall back to grep/sed
27
+ extract_command() {
28
+ if command -v node &>/dev/null; then
29
+ printf '%s' "$1" | node -e "
30
+ try {
31
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
32
+ const cmd = d.tool_input?.command;
33
+ if (typeof cmd === 'string') process.stdout.write(cmd);
34
+ } catch {}
35
+ " 2>/dev/null
36
+ else
37
+ # Lightweight fallback: extract "command":"..." from JSON
38
+ printf '%s' "$1" | grep -o '"command":"[^"]*"' | head -1 | sed 's/^"command":"//;s/"$//' 2>/dev/null
39
+ fi
40
+ }
41
+
42
+ COMMAND=$(extract_command "$INPUT") || exit 0
43
+
44
+ [[ -z "$COMMAND" ]] && exit 0
45
+
46
+ # ─── Blocked directory patterns ─────────────────────────────────────
47
+
48
+ # Use explicit path separators to avoid substring false positives.
49
+ # [/\\] matches both forward slash (Unix/macOS) and backslash (Windows Git Bash).
50
+ # e.g. "build/" should not match "rebuild/src" or "my-build-tool"
51
+ SEP="[/\\\\]"
52
+ BLOCKED="(^|[ /\\\\])node_modules(${SEP}|$| )"
53
+ BLOCKED+="|(__pycache__)"
54
+ BLOCKED+="|\.git${SEP}(objects|refs)"
55
+ BLOCKED+="|(^|[ /\\\\])dist${SEP}"
56
+ BLOCKED+="|(^|[ /\\\\])build${SEP}"
57
+ BLOCKED+="|\.next${SEP}"
58
+ BLOCKED+="|(^|[ /\\\\])vendor(${SEP}|$| )"
59
+ BLOCKED+="|(^|[ /\\\\])Pods(${SEP}|$| )"
60
+ BLOCKED+="|\.build${SEP}"
61
+ BLOCKED+="|DerivedData"
62
+ BLOCKED+="|\.gradle${SEP}"
63
+ BLOCKED+="|(^|[ /\\\\])target${SEP}"
64
+ BLOCKED+="|\.nuget"
65
+ BLOCKED+="|\.cache(${SEP}|$| )"
66
+ # Python
67
+ BLOCKED+="|(^|[ /\\\\])\.venv${SEP}"
68
+ BLOCKED+="|(^|[ /\\\\])venv${SEP}"
69
+ BLOCKED+="|\.mypy_cache${SEP}"
70
+ BLOCKED+="|\.pytest_cache${SEP}"
71
+ BLOCKED+="|\.ruff_cache${SEP}"
72
+ BLOCKED+="|\.egg-info(${SEP}|$| )"
73
+ # C# .NET (match .NET-specific subdirs to avoid false positives on generic bin/)
74
+ BLOCKED+="|(^|[ /\\\\])bin${SEP}(Debug|Release|net|x64|x86)"
75
+ BLOCKED+="|(^|[ /\\\\])obj${SEP}(Debug|Release|net)"
76
+ # Node.js frameworks
77
+ BLOCKED+="|\.nuxt${SEP}"
78
+ BLOCKED+="|\.svelte-kit${SEP}"
79
+ BLOCKED+="|\.parcel-cache${SEP}"
80
+ BLOCKED+="|\.turbo${SEP}"
81
+ BLOCKED+="|(^|[ /\\\\])out${SEP}(server|static|_next)"
82
+ # Ruby
83
+ BLOCKED+="|\.bundle${SEP}"
84
+
85
+ # Append project-specific patterns from env
86
+ if [[ -n "${PATH_GUARD_EXTRA:-}" ]]; then
87
+ BLOCKED+="|$PATH_GUARD_EXTRA"
88
+ fi
89
+
90
+ # ─── Exploration-verb pre-check ─────────────────────────────────────
91
+ # Only apply blocked-path rules when the command contains a file-reading
92
+ # or directory-listing verb. This avoids false positives for commands that
93
+ # merely REFERENCE a path through a blocked directory without exploring it
94
+ # (e.g. variable assignments, [ -x "$B" ] binary existence checks, or
95
+ # running a compiled binary whose path happens to include /dist/).
96
+ #
97
+ # Verbs that indicate directory/file exploration:
98
+ EXPLORE_VERB_RE="(^|[[:space:]|;&\`(])(ls|ll|la|find|cat|head|tail|less|more|wc|stat|du|tree|bat|od|xxd|hexdump|nl)([[:space:]]|$)"
99
+
100
+ if ! printf '%s\n' "$COMMAND" | grep -qE "$EXPLORE_VERB_RE"; then
101
+ exit 0
102
+ fi
103
+
104
+ # ─── Match and block ────────────────────────────────────────────────
105
+
106
+ # Strip node_modules/.bin/<binary> references before checking blocked paths.
107
+ # Executing an installed binary (e.g. node_modules/.bin/playwright) is not
108
+ # directory exploration — only the binary itself is referenced, not the tree.
109
+ COMMAND_FOR_CHECK=$(printf '%s\n' "$COMMAND" | sed -E "s|node_modules[/\\]\.bin[/\\][^[:space:]]*||g")
110
+
111
+ if printf '%s\n' "$COMMAND_FOR_CHECK" | grep -qE "$BLOCKED"; then
112
+ # Extract which pattern matched for a useful error message
113
+ MATCHED=$(printf '%s\n' "$COMMAND" | grep -oE "$BLOCKED" | head -1)
114
+ echo "Blocked: command references '$MATCHED' — this directory is typically large and exploring it wastes tokens. Use Glob or Grep tools instead." >&2
115
+ exit 2
116
+ fi
117
+
118
+ exit 0
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # self-review.sh — Stop hook for Claude Code
3
+ #
4
+ # Injects a self-review checklist when Claude is about to finish.
5
+ # Non-blocking: always exits 0, just adds context for Claude to consider.
6
+ #
7
+ # Environment:
8
+ # SELF_REVIEW_ENABLED — set to "false" to disable (default: true)
9
+
10
+ # No set -euo pipefail — this hook must NEVER fail
11
+ # Windows note: requires bash (WSL or Git Bash). Silently skipped on Windows native.
12
+
13
+ # Check if disabled
14
+ if [[ "${SELF_REVIEW_ENABLED:-true}" == "false" ]]; then
15
+ exit 0
16
+ fi
17
+
18
+ # Read stdin (Stop event payload — may be empty or minimal)
19
+ cat > /dev/null 2>&1 || true
20
+
21
+ # Inject self-review checklist as context
22
+ cat <<'REVIEW_JSON'
23
+ {
24
+ "continue": true,
25
+ "systemMessage": "Self-review before finishing:\n1. Did you leave any TODO/FIXME comments that should be resolved now?\n2. Did you create mock or fake implementations just to pass tests?\n3. Did you replace real code with placeholder comments like '// ... existing code'?\n4. Do all changed files compile and typecheck cleanly?\n5. Did you run the full test suite, not just the new tests?\n6. Are there any files you modified but forgot to include in the summary?"
26
+ }
27
+ REVIEW_JSON
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env bash
2
+ # sensitive-guard.sh — PreToolUse hook for Claude Code
3
+ #
4
+ # Blocks access to sensitive files: .env, private keys, credentials, tokens.
5
+ # Supports .agentignore for project-specific patterns.
6
+ #
7
+ # Exit codes:
8
+ # 0 — access allowed
9
+ # 2 — access blocked (sensitive file)
10
+ #
11
+ # Environment:
12
+ # SENSITIVE_GUARD_EXTRA — additional pipe-separated filename patterns to block
13
+
14
+ set -euo pipefail
15
+
16
+ # Windows note: this hook requires bash (WSL or Git Bash).
17
+ # On Windows without bash, Claude Code will fail to run this hook and skip it silently.
18
+ # Install WSL or Git Bash and ensure `bash` is in PATH to activate protection.
19
+
20
+ # ─── Read hook payload from stdin ───────────────────────────────────
21
+
22
+ INPUT=$(cat)
23
+ [[ -z "$INPUT" ]] && exit 0
24
+
25
+ # Check Node.js availability — security hook should warn loudly if disabled
26
+ if ! command -v node &>/dev/null; then
27
+ echo "WARNING: sensitive-guard disabled — Node.js not found. Sensitive files are NOT protected." >&2
28
+ exit 0
29
+ fi
30
+
31
+ # Extract file path and/or command using inline Node.js
32
+ PARSED=$(printf '%s' "$INPUT" | node -e "
33
+ try {
34
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
35
+ const fp = d.tool_input?.file_path || d.tool_input?.path || '';
36
+ const cmd = d.tool_input?.command || '';
37
+ const pat = d.tool_input?.pattern || '';
38
+ process.stdout.write(fp + '\n' + cmd + '\n' + pat);
39
+ } catch { process.exit(0); }
40
+ " 2>/dev/null) || exit 0
41
+
42
+ FILE_PATH=$(printf '%s' "$PARSED" | sed -n '1p')
43
+ COMMAND=$(printf '%s' "$PARSED" | sed -n '2p')
44
+ PATTERN=$(printf '%s' "$PARSED" | sed -n '3p')
45
+
46
+ # ─── Sensitive filename patterns ────────────────────────────────────
47
+
48
+ # Returns 0 (true) if the path matches a sensitive pattern
49
+ is_sensitive() {
50
+ local filepath="$1"
51
+ local basename
52
+ basename=$(basename "$filepath" 2>/dev/null) || return 1
53
+
54
+ # Exact filenames (basename match)
55
+ case "$basename" in
56
+ .env|.env.local|.env.development|.env.production|.env.staging|.env.test)
57
+ return 0 ;;
58
+ .npmrc|.pypirc|.netrc)
59
+ return 0 ;;
60
+ id_rsa|id_ecdsa|id_ed25519|id_dsa)
61
+ return 0 ;;
62
+ serviceAccountKey.json|service-account*.json)
63
+ return 0 ;;
64
+ config.json)
65
+ # config.json only sensitive inside .docker/
66
+ [[ "$filepath" == *".docker/config.json"* ]] && return 0
67
+ ;;
68
+ esac
69
+
70
+ # Extension patterns
71
+ case "$basename" in
72
+ *.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|*.truststore)
73
+ return 0 ;;
74
+ *_rsa|*_ecdsa|*_ed25519|*_dsa)
75
+ return 0 ;;
76
+ esac
77
+
78
+ # Substring patterns (case-insensitive via bash)
79
+ local lower
80
+ lower=$(echo "$basename" | tr '[:upper:]' '[:lower:]')
81
+ case "$lower" in
82
+ *credential*|*secret*|*private_key*|*privatekey*)
83
+ return 0 ;;
84
+ firebase-adminsdk*)
85
+ return 0 ;;
86
+ esac
87
+
88
+ # .env.* but NOT .env.example or .env.sample or .env.template
89
+ if [[ "$basename" =~ ^\.env\. ]]; then
90
+ case "$basename" in
91
+ .env.example|.env.sample|.env.template) return 1 ;;
92
+ *) return 0 ;;
93
+ esac
94
+ fi
95
+
96
+ # Extra patterns from env var
97
+ if [[ -n "${SENSITIVE_GUARD_EXTRA:-}" ]]; then
98
+ if printf '%s\n' "$filepath" | grep -qE "$SENSITIVE_GUARD_EXTRA"; then
99
+ return 0
100
+ fi
101
+ fi
102
+
103
+ return 1
104
+ }
105
+
106
+ # ─── Check .agentignore ────────────────────────────────────────────
107
+
108
+ check_agentignore() {
109
+ local filepath="$1"
110
+ local ignorefile=""
111
+
112
+ # Look for ignore files in project root
113
+ for candidate in .agentignore .aiignore .cursorignore; do
114
+ if [[ -f "$candidate" ]]; then
115
+ ignorefile="$candidate"
116
+ break
117
+ fi
118
+ done
119
+
120
+ [[ -z "$ignorefile" ]] && return 1
121
+
122
+ # Simple line-by-line match (not full gitignore glob, but covers common cases)
123
+ local relpath
124
+ # Normalize separators to forward slash before stripping prefix (handles Git Bash on Windows)
125
+ local normalized_fp normalized_pwd
126
+ normalized_fp=$(printf '%s' "$filepath" | tr '\\' '/')
127
+ normalized_pwd=$(pwd | tr '\\' '/')
128
+ relpath=$(printf '%s' "$normalized_fp" | sed "s|^${normalized_pwd}/||") 2>/dev/null || relpath="$filepath"
129
+
130
+ while IFS= read -r pattern || [[ -n "$pattern" ]]; do
131
+ # Skip comments and empty lines
132
+ [[ -z "$pattern" || "$pattern" == \#* ]] && continue
133
+ # Simple glob match
134
+ if [[ "$relpath" == $pattern ]] || [[ "$(basename "$relpath")" == $pattern ]]; then
135
+ return 0
136
+ fi
137
+ done < "$ignorefile"
138
+
139
+ return 1
140
+ }
141
+
142
+ # ─── Check file path access ────────────────────────────────────────
143
+
144
+ block_with_message() {
145
+ local filepath="$1"
146
+ echo "Blocked: '$filepath' is a sensitive file (secrets, keys, or credentials). Access denied to protect sensitive data. Use .env.example for templates instead." >&2
147
+ exit 2
148
+ }
149
+
150
+ warn_with_message() {
151
+ local filepath="$1"
152
+ echo "Warning: '$filepath' is a sensitive file. If the user approved this access, proceed. Otherwise, ask the user for permission first via AskUserQuestion before reading sensitive files." >&2
153
+ # Warn only — exit 0 allows the command to proceed
154
+ # This enables the flow: Block Read → Claude asks user → User approves → Claude uses bash cat
155
+ exit 0
156
+ }
157
+
158
+ # ─── Fast-path: skip obviously safe files ──────────────────────────
159
+
160
+ fast_path_safe() {
161
+ local ext="${1##*.}"
162
+ case "$ext" in
163
+ md|ts|tsx|js|jsx|css|scss|html|svg|json|yaml|yml|toml|xml|txt|sh|py|rb|rs|go|java|kt|swift|c|cpp|h|hpp|cs|vue|svelte|astro)
164
+ # But json could be sensitive — check name
165
+ if [[ "$ext" == "json" ]]; then
166
+ return 1 # not fast-path safe, need full check
167
+ fi
168
+ return 0 ;;
169
+ esac
170
+ return 1
171
+ }
172
+
173
+ # ─── Check direct file access (Read/Write/Edit) → BLOCK ────────────
174
+
175
+ if [[ -n "$FILE_PATH" ]]; then
176
+ if ! fast_path_safe "$FILE_PATH"; then
177
+ if is_sensitive "$FILE_PATH" || check_agentignore "$FILE_PATH"; then
178
+ block_with_message "$FILE_PATH"
179
+ fi
180
+ fi
181
+ fi
182
+
183
+ # ─── Check bash commands → WARN only (allows approved access) ──────
184
+
185
+ if [[ -n "$COMMAND" ]]; then
186
+ # Extract .env file references from commands
187
+ SENSITIVE_IN_CMD=$(printf '%s\n' "$COMMAND" | grep -oE '[\./[:alnum:]_-]*\.env[\.[:alnum:]_-]*' | head -5) || true
188
+
189
+ if [[ -n "$SENSITIVE_IN_CMD" ]]; then
190
+ while IFS= read -r match; do
191
+ case "$match" in
192
+ *.example|*.sample|*.template) continue ;;
193
+ esac
194
+ if is_sensitive "$match"; then
195
+ warn_with_message "$match"
196
+ fi
197
+ done <<< "$SENSITIVE_IN_CMD"
198
+ fi
199
+
200
+ # Check for key/cert file references in commands → also warn only
201
+ KEY_IN_CMD=$(printf '%s\n' "$COMMAND" | grep -oE '[[:alnum:]_./-]*\.(pem|key|p12|pfx|jks|keystore)' | head -3) || true
202
+ if [[ -n "$KEY_IN_CMD" ]]; then
203
+ while IFS= read -r match; do
204
+ warn_with_message "$match"
205
+ done <<< "$KEY_IN_CMD"
206
+ fi
207
+
208
+ # Check for SSH keys, credentials, service accounts in commands
209
+ SENSITIVE_NAMES=$(printf '%s\n' "$COMMAND" | grep -oiE '(id_rsa|id_ecdsa|id_ed25519|id_dsa|serviceAccountKey\.json|service-account[[:alnum:]_-]*\.json|\.npmrc|\.pypirc|\.netrc)' | head -3) || true
210
+ if [[ -n "$SENSITIVE_NAMES" ]]; then
211
+ while IFS= read -r match; do
212
+ warn_with_message "$match"
213
+ done <<< "$SENSITIVE_NAMES"
214
+ fi
215
+
216
+ # Check for credential/secret keywords in file arguments
217
+ CRED_FILES=$(printf '%s\n' "$COMMAND" | grep -oiE '[[:alnum:]_./-]*(credential|secret|private_key|privatekey)[[:alnum:]_./-]*' | head -3) || true
218
+ if [[ -n "$CRED_FILES" ]]; then
219
+ while IFS= read -r match; do
220
+ warn_with_message "$match"
221
+ done <<< "$CRED_FILES"
222
+ fi
223
+ fi
224
+
225
+ # ─── All checks passed ─────────────────────────────────────────────
226
+
227
+ exit 0