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.
- package/README.md +1319 -0
- package/bin/devkit.js +3 -0
- package/package.json +61 -0
- package/src/cli.js +76 -0
- package/src/commands/check.js +33 -0
- package/src/commands/diff.js +84 -0
- package/src/commands/init-adopt.js +54 -0
- package/src/commands/init-agents.js +118 -0
- package/src/commands/init-global.js +102 -0
- package/src/commands/init.js +311 -0
- package/src/commands/list.js +54 -0
- package/src/commands/remove.js +133 -0
- package/src/commands/upgrade.js +215 -0
- package/src/lib/agent-guards.js +100 -0
- package/src/lib/agent-install.js +161 -0
- package/src/lib/agents.js +280 -0
- package/src/lib/claude-global.js +183 -0
- package/src/lib/detector.js +93 -0
- package/src/lib/hasher.js +21 -0
- package/src/lib/installer.js +213 -0
- package/src/lib/logger.js +16 -0
- package/src/lib/manifest.js +102 -0
- package/src/lib/reconcile.js +56 -0
- package/templates/.claude/CLAUDE.md +79 -0
- package/templates/.claude/hooks/comment-guard.js +126 -0
- package/templates/.claude/hooks/file-guard.js +216 -0
- package/templates/.claude/hooks/glob-guard.js +104 -0
- package/templates/.claude/hooks/path-guard.sh +118 -0
- package/templates/.claude/hooks/self-review.sh +27 -0
- package/templates/.claude/hooks/sensitive-guard.sh +227 -0
- package/templates/.claude/settings.json +68 -0
- package/templates/docs/WORKFLOW.md +325 -0
- package/templates/docs/specs/.gitkeep +0 -0
- package/templates/hooks/specpipe-read-guard.sh +42 -0
- package/templates/hooks/specpipe-shell-guard.sh +65 -0
- package/templates/rules/specpipe-guards.md +40 -0
- package/templates/scripts/test-hooks.sh +66 -0
- package/templates/skills/sp-build/SKILL.md +776 -0
- package/templates/skills/sp-challenge/SKILL.md +255 -0
- package/templates/skills/sp-commit/SKILL.md +174 -0
- package/templates/skills/sp-explore/SKILL.md +730 -0
- package/templates/skills/sp-fix/SKILL.md +266 -0
- package/templates/skills/sp-humanize/SKILL.md +212 -0
- package/templates/skills/sp-investigate/SKILL.md +648 -0
- package/templates/skills/sp-md-render/SKILL.md +200 -0
- package/templates/skills/sp-md-render/components.md +415 -0
- package/templates/skills/sp-md-render/template.html +283 -0
- package/templates/skills/sp-plan/SKILL.md +947 -0
- package/templates/skills/sp-review/SKILL.md +268 -0
- package/templates/skills/sp-scaffold/SKILL.md +237 -0
- package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
- package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
- package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
- package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
- package/templates/skills/sp-spec-render/SKILL.md +254 -0
- package/templates/skills/sp-spec-render/components.md +418 -0
- package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
- package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
- package/templates/skills/sp-spec-render/template.html +222 -0
- 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
|