specpipe 1.0.1 → 1.0.2

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 (46) hide show
  1. package/README.md +111 -311
  2. package/package.json +2 -1
  3. package/src/cli.js +16 -6
  4. package/src/commands/diff.js +1 -1
  5. package/src/commands/init-agents.js +40 -20
  6. package/src/commands/init-global.js +88 -33
  7. package/src/commands/init-interactive.js +71 -0
  8. package/src/commands/init.js +61 -22
  9. package/src/commands/remove.js +159 -49
  10. package/src/commands/upgrade.js +21 -56
  11. package/src/lib/agent-guards.js +34 -78
  12. package/src/lib/agent-install.js +38 -25
  13. package/src/lib/agents.js +53 -11
  14. package/src/lib/claude-global.js +50 -77
  15. package/src/lib/hooks.js +203 -0
  16. package/src/lib/installer.js +73 -61
  17. package/src/lib/reconcile.js +13 -8
  18. package/templates/{.claude/hooks → hooks}/file-guard.js +26 -21
  19. package/templates/hooks/specpipe-read-guard.sh +94 -21
  20. package/templates/hooks/specpipe-shell-guard.sh +121 -29
  21. package/templates/rules/specpipe-rules.md +77 -0
  22. package/templates/skills/sp-build/SKILL.md +101 -1
  23. package/templates/skills/sp-build-behavior-matrix/SKILL.md +876 -0
  24. package/templates/skills/sp-challenge/SKILL.md +34 -0
  25. package/templates/skills/sp-challenge-behavior-matrix/SKILL.md +289 -0
  26. package/templates/skills/sp-explore/SKILL.md +132 -0
  27. package/templates/skills/sp-explore-behavior-matrix/SKILL.md +862 -0
  28. package/templates/skills/sp-fix/SKILL.md +73 -1
  29. package/templates/skills/sp-fix-behavior-matrix/SKILL.md +338 -0
  30. package/templates/skills/sp-investigate/SKILL.md +70 -0
  31. package/templates/skills/sp-investigate-behavior-matrix/SKILL.md +718 -0
  32. package/templates/skills/sp-plan/SKILL.md +90 -0
  33. package/templates/skills/sp-plan-behavior-matrix/SKILL.md +1037 -0
  34. package/templates/skills/sp-review/SKILL.md +29 -3
  35. package/templates/skills/sp-review-behavior-matrix/SKILL.md +294 -0
  36. package/templates/.claude/CLAUDE.md +0 -79
  37. package/templates/.claude/hooks/path-guard.sh +0 -118
  38. package/templates/.claude/hooks/self-review.sh +0 -27
  39. package/templates/.claude/hooks/sensitive-guard.sh +0 -227
  40. package/templates/.claude/settings.json +0 -68
  41. package/templates/docs/WORKFLOW.md +0 -325
  42. package/templates/docs/specs/.gitkeep +0 -0
  43. package/templates/rules/specpipe-guards.md +0 -40
  44. package/templates/scripts/test-hooks.sh +0 -66
  45. /package/templates/{.claude/hooks → hooks}/comment-guard.js +0 -0
  46. /package/templates/{.claude/hooks → hooks}/glob-guard.js +0 -0
@@ -11,14 +11,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  * Component → file mappings.
12
12
  */
13
13
  export const COMPONENTS = {
14
- hooks: [
15
- '.claude/hooks/file-guard.js',
16
- '.claude/hooks/path-guard.sh',
17
- '.claude/hooks/comment-guard.js',
18
- '.claude/hooks/glob-guard.js',
19
- '.claude/hooks/self-review.sh',
20
- '.claude/hooks/sensitive-guard.sh',
21
- ],
14
+ // Hooks + settings.json are no longer static files — they're emitted per agent
15
+ // from the hook registry (hooks.js) via installAgentHooks. Kept as an (empty)
16
+ // component so `--only hooks` still resolves; init routes it to the emitter.
17
+ hooks: [],
22
18
  skills: [
23
19
  'skills/sp-explore/SKILL.md',
24
20
  'skills/sp-scaffold/SKILL.md',
@@ -44,31 +40,54 @@ export const COMPONENTS = {
44
40
  'skills/sp-md-render/components.md',
45
41
  'skills/sp-humanize/SKILL.md',
46
42
  ],
47
- config: [
48
- '.claude/settings.json',
49
- '.claude/CLAUDE.md',
50
- ],
51
- docs: [
52
- 'docs/WORKFLOW.md',
53
- ],
43
+ // CLAUDE.md is no longer a static file — it's emitted from the single rules source
44
+ // (kit/rules/specpipe-rules.md) as a marked section, like every other agent's rules.
45
+ config: [],
46
+ // docs/WORKFLOW.md was dropped — its content is covered by the skills (detailed) and
47
+ // the rules hub's workflow table. The user's docs/ holds only their own specs.
48
+ docs: [],
54
49
  };
55
50
 
51
+ // ── Skill selection ─────────────────────────────────────────────────────────
52
+ // Skills installed by default but safe to drop — standalone, not part of the
53
+ // spec→build→review pipeline. Tagged "(optional)" in the interactive picker.
54
+ export const OPTIONAL_SKILLS = ['sp-spec-render', 'sp-md-render', 'sp-humanize'];
55
+
56
+ /** Every skill name (sp-*), derived from the skill component list. */
57
+ export const ALL_SKILL_NAMES = [...new Set(COMPONENTS.skills.map((p) => p.split('/')[1]))];
58
+
56
59
  /**
57
- * Placeholder directories to create.
60
+ * Resolve a `--skills` value into a Set of selected skill names, or null = all.
61
+ * Accepts 'all', 'core' (all minus OPTIONAL_SKILLS), or a comma list of names
62
+ * (with or without the `sp-` prefix). Throws on an unknown name.
58
63
  */
59
- export const PLACEHOLDER_DIRS = [
60
- 'docs/specs',
61
- 'docs/test-plans',
62
- ];
64
+ export function resolveSkills(spec) {
65
+ if (!spec || spec === 'all') return null;
66
+ if (spec === 'core') return new Set(ALL_SKILL_NAMES.filter((n) => !OPTIONAL_SKILLS.includes(n)));
67
+ const names = spec.split(',').map((s) => s.trim()).filter(Boolean)
68
+ .map((n) => (n.startsWith('sp-') ? n : `sp-${n}`));
69
+ const unknown = names.filter((n) => !ALL_SKILL_NAMES.includes(n));
70
+ if (unknown.length) {
71
+ throw new Error(`Unknown skill(s): ${unknown.join(', ')}. Valid: ${ALL_SKILL_NAMES.join(', ')}, all, core`);
72
+ }
73
+ return new Set(names);
74
+ }
63
75
 
64
76
  /**
65
- * Files that need +x permission.
77
+ * Whether a template file path is allowed under a skill selection (null = all).
78
+ * Non-skill files (hooks, config, docs) always pass; skill files pass only when
79
+ * their skill name is in the set.
66
80
  */
67
- export const EXECUTABLE_FILES = [
68
- '.claude/hooks/path-guard.sh',
69
- '.claude/hooks/self-review.sh',
70
- '.claude/hooks/sensitive-guard.sh',
71
- ];
81
+ export function skillAllowed(filePath, skillsSet) {
82
+ if (!skillsSet) return true;
83
+ const m = filePath.replace(/\\/g, '/').match(/^skills\/([^/]+)\//);
84
+ return !m || skillsSet.has(m[1]);
85
+ }
86
+
87
+
88
+ // Files needing +x. Empty: guard scripts get +x at emit time (installAgentHooks
89
+ // chmods them); none are installed as plain COMPONENTS files anymore.
90
+ export const EXECUTABLE_FILES = [];
72
91
 
73
92
  /**
74
93
  * Get path to kit (templates) directory.
@@ -135,24 +154,10 @@ export async function installFile(relativePath, targetDir, { force = false } = {
135
154
  // re-exported here so callers keep importing from installer.js.
136
155
  export {
137
156
  installSkillForAgent, installAgentSkills, installAgentRules,
138
- mergeAgentsMdGuards, stripAgentsMdGuards,
157
+ mergeRulesSection, stripRulesSection,
139
158
  installAgentHooks, removeAgentHooks,
140
159
  } from './agent-install.js';
141
160
 
142
- /**
143
- * Create a placeholder directory with .gitkeep.
144
- */
145
- export async function ensurePlaceholderDir(dir, targetDir) {
146
- const fullPath = join(targetDir, dir);
147
- if (existsSync(fullPath)) {
148
- log.skip(`${dir}/ (exists)`);
149
- return;
150
- }
151
- await mkdir(fullPath, { recursive: true });
152
- await writeFile(join(fullPath, '.gitkeep'), '');
153
- log.make(`${dir}/`);
154
- }
155
-
156
161
  /**
157
162
  * Set executable permissions on relevant files.
158
163
  */
@@ -167,28 +172,34 @@ export async function setPermissions(targetDir) {
167
172
  }
168
173
  }
169
174
 
175
+ // Every file the rules section can land in (per agent). fillTemplate fills the
176
+ // detected Project Info into whichever ones exist.
177
+ export const RULE_FILES = [
178
+ '.claude/CLAUDE.md',
179
+ 'AGENTS.md',
180
+ '.cursor/rules/specpipe-rules.mdc',
181
+ '.agents/rules/specpipe-rules.md',
182
+ 'SPECPIPE-RULES.md',
183
+ ];
184
+
170
185
  /**
171
- * Fill [CUSTOMIZE] placeholders in CLAUDE.md with detected project info.
186
+ * Fill the `[CUSTOMIZE]` Project Info placeholders in every installed rules file with
187
+ * the detected project info. Rules are emitted per agent (CLAUDE.md, AGENTS.md, …), so
188
+ * fill all of them, not just CLAUDE.md.
172
189
  */
173
190
  export async function fillTemplate(targetDir, projectInfo) {
174
191
  if (!projectInfo) return;
175
-
176
- const claudeMdPath = join(targetDir, '.claude/CLAUDE.md');
177
- try {
178
- let content = await readFile(claudeMdPath, 'utf-8');
179
- content = content
180
- .replace(/\[CUSTOMIZE\] Language:.*/, `**Language:** ${projectInfo.lang}`)
181
- .replace(/\[CUSTOMIZE\] Test framework:.*/, `**Test framework:** ${projectInfo.framework}`)
182
- .replace(/\[CUSTOMIZE\] Source directory:.*/, `**Source directory:** ${projectInfo.srcDir}`)
183
- .replace(/\[CUSTOMIZE\] Test directory:.*/, `**Test directory:** ${projectInfo.testDir}`)
184
- // Also handle the format without [CUSTOMIZE] prefix
185
- .replace(/\*\*Language:\*\* \[CUSTOMIZE\]/, `**Language:** ${projectInfo.lang}`)
186
- .replace(/\*\*Test framework:\*\* \[CUSTOMIZE\]/, `**Test framework:** ${projectInfo.framework}`)
187
- .replace(/\*\*Source directory:\*\* \[CUSTOMIZE\]/, `**Source directory:** ${projectInfo.srcDir}`)
188
- .replace(/\*\*Test directory:\*\* \[CUSTOMIZE\]/, `**Test directory:** ${projectInfo.testDir}`);
189
- await writeFile(claudeMdPath, content);
190
- } catch {
191
- // CLAUDE.md might not exist
192
+ for (const rel of RULE_FILES) {
193
+ const p = join(targetDir, rel);
194
+ try {
195
+ const before = await readFile(p, 'utf-8');
196
+ const after = before
197
+ .replace(/\*\*Language:\*\* \[CUSTOMIZE\]/, `**Language:** ${projectInfo.lang}`)
198
+ .replace(/\*\*Test framework:\*\* \[CUSTOMIZE\]/, `**Test framework:** ${projectInfo.framework}`)
199
+ .replace(/\*\*Source directory:\*\* \[CUSTOMIZE\]/, `**Source directory:** ${projectInfo.srcDir}`)
200
+ .replace(/\*\*Test directory:\*\* \[CUSTOMIZE\]/, `**Test directory:** ${projectInfo.testDir}`);
201
+ if (after !== before) await writeFile(p, after);
202
+ } catch { /* file not installed for this agent — skip */ }
192
203
  }
193
204
  }
194
205
 
@@ -208,6 +219,7 @@ export async function verifySettingsJson(targetDir) {
208
219
  // Claude's global install (~/.claude/skills + hooks + settings.json) lives in
209
220
  // claude-global.js; re-exported here so callers keep importing from installer.js.
210
221
  export {
211
- getGlobalSkillsDir, getGlobalHooksDir, installHookGlobal,
212
- mergeGlobalSettings, removeGlobalHooksFromSettings, installSkillGlobal,
222
+ getGlobalHooksDir, installHookGlobal,
223
+ mergeGlobalSettings, removeGlobalHooksFromSettings,
224
+ installSkillGlobalForAgent,
213
225
  } from './claude-global.js';
@@ -1,10 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { hashContent } from './hasher.js';
4
- import { getAllFiles, COMPONENTS, getTemplateDir } from './installer.js';
5
- import { emitFile, emitRules } from './agents.js';
4
+ import { getAllFiles, COMPONENTS, getTemplateDir, skillAllowed } from './installer.js';
5
+ import { emitFile, emitRules, AGENTS } from './agents.js';
6
6
 
7
- export const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
7
+ export const RULES_TEMPLATE_REL = 'rules/specpipe-rules.md';
8
8
 
9
9
  /**
10
10
  * Template files a given agent receives. Claude gets the full kit
@@ -13,7 +13,11 @@ export const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
13
13
  * hooks are Claude-specific.
14
14
  */
15
15
  export function templateFilesForAgent(agentId) {
16
- return agentId === 'claude' ? getAllFiles() : COMPONENTS.skills;
16
+ if (agentId === 'claude') return getAllFiles();
17
+ // Agents that don't read project-local skills (Hermes scans only ~/.hermes/skills/)
18
+ // get no per-project skill files — they'd be dead. Their rules doc is still emitted.
19
+ if (AGENTS[agentId]?.perProjectSkills === false) return [];
20
+ return COMPONENTS.skills;
17
21
  }
18
22
 
19
23
  /**
@@ -22,13 +26,14 @@ export function templateFilesForAgent(agentId) {
22
26
  * @returns {Promise<Map<string, {agent, templateRel, content, kitHash}>>}
23
27
  * keyed by installed (on-disk) relative path.
24
28
  */
25
- export async function computeDesired(agents) {
29
+ export async function computeDesired(agents, skillsSet = null) {
26
30
  const dir = getTemplateDir();
27
31
  const desired = new Map();
28
- const guardsBody = await readFile(join(dir, GUARDS_TEMPLATE_REL), 'utf-8');
32
+ const guardsBody = await readFile(join(dir, RULES_TEMPLATE_REL), 'utf-8');
29
33
 
30
34
  for (const agent of agents) {
31
35
  for (const templateRel of templateFilesForAgent(agent)) {
36
+ if (!skillAllowed(templateRel, skillsSet)) continue;
32
37
  const content = await readFile(join(dir, templateRel), 'utf-8');
33
38
  const emitted = emitFile(agent, templateRel, content);
34
39
  desired.set(emitted.path, {
@@ -43,10 +48,10 @@ export async function computeDesired(agents) {
43
48
  // are reconciled like any other file. Codex's AGENTS.md is shared, not owned
44
49
  // here — it's merged/stripped separately.
45
50
  const rules = emitRules(agent, guardsBody);
46
- if (rules && rules.mode !== 'agents-md') {
51
+ if (rules && rules.mode !== 'merge') {
47
52
  desired.set(rules.path, {
48
53
  agent,
49
- templateRel: GUARDS_TEMPLATE_REL,
54
+ templateRel: RULES_TEMPLATE_REL,
50
55
  content: rules.content,
51
56
  kitHash: hashContent(rules.content),
52
57
  });
@@ -147,10 +147,18 @@ function main() {
147
147
  const filePath = payload.tool_input?.file_path;
148
148
  if (!filePath) process.exit(0);
149
149
 
150
- // Skip files outside the project directory (e.g. ~/.claude/plans/)
151
- const projectDir = process.cwd() + path.sep;
150
+ // Cursor's generic postToolUse fires for EVERY tool (Read, Grep, Shell, …); only act
151
+ // on writes/edits. Claude's PostToolUse matcher already restricts this, so tool_name is
152
+ // either a write-ish name or absent there — both pass.
153
+ const toolName = payload.tool_name;
154
+ if (toolName && !/^(Write|Edit|MultiEdit|write_to_file|replace_file_content)/i.test(toolName)) process.exit(0);
155
+
156
+ // Skip files outside the project directory (e.g. ~/.claude/plans/). Cursor passes the
157
+ // project root in workspace_roots; otherwise fall back to cwd.
158
+ const projectRoot = (Array.isArray(payload.workspace_roots) && payload.workspace_roots[0]) || process.cwd();
159
+ const projectDir = projectRoot + path.sep;
152
160
  const resolvedFile = path.resolve(filePath);
153
- if (!resolvedFile.startsWith(projectDir) && resolvedFile !== process.cwd()) process.exit(0);
161
+ if (!resolvedFile.startsWith(projectDir) && resolvedFile !== projectRoot) process.exit(0);
154
162
 
155
163
  // Skip non-source-code files (docs, configs, templates are naturally long)
156
164
  const ext = path.extname(filePath).toLowerCase();
@@ -173,14 +181,8 @@ function main() {
173
181
  try {
174
182
  const stat = fs.statSync(filePath);
175
183
  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
+ const rel = path.relative(projectRoot, filePath);
185
+ emitWarning(`Warning: ${rel} is ${Math.round(stat.size / 1024)}KB. Consider splitting into smaller modules.`, payload);
184
186
  process.exit(0);
185
187
  }
186
188
  const buf = fs.readFileSync(filePath);
@@ -194,18 +196,21 @@ function main() {
194
196
  if (lineCount <= THRESHOLD) process.exit(0);
195
197
 
196
198
  // 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
+ const rel = path.relative(projectRoot, filePath);
200
+ emitWarning(`Warning: ${rel} has ${lineCount} lines (threshold: ${THRESHOLD}). Consider splitting into smaller, focused modules.`, payload);
201
+ }
199
202
 
200
- process.stdout.write(
201
- JSON.stringify({
203
+ // Inject an advisory warning in the agent's native shape. Cursor's postToolUse reads
204
+ // `additional_context`; Claude (and Codex PostToolUse) read hookSpecificOutput.additionalContext.
205
+ function emitWarning(warning, payload) {
206
+ if (payload.cursor_version || payload.hook_event_name === "postToolUse") {
207
+ process.stdout.write(JSON.stringify({ additional_context: warning }) + "\n");
208
+ } else {
209
+ process.stdout.write(JSON.stringify({
202
210
  continue: true,
203
- hookSpecificOutput: {
204
- hookEventName: "PostToolUse",
205
- additionalContext: warning,
206
- },
207
- }) + "\n"
208
- );
211
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: warning },
212
+ }) + "\n");
213
+ }
209
214
  }
210
215
 
211
216
  try {
@@ -1,42 +1,115 @@
1
1
  #!/usr/bin/env bash
2
2
  # specpipe-read-guard.sh — blocking pre-file-read hook (enforced guardrail).
3
3
  #
4
- # For agents whose pre-read payload puts the path at .file_path (Cursor
5
- # beforeReadFile) or .tool_input.file_path (Claude/Codex Read). Blocks (exit 2)
6
- # reads of secret files; allows *.example / *.sample / *.template.
4
+ # The single file-access guard for every agent. Reads the target path from
5
+ # whichever shape the agent's payload uses:
6
+ # .tool_input.file_path (Claude/Codex Read/Write/Edit) · .file_path (Cursor beforeReadFile)
7
+ #
8
+ # Blocks (exit 2) reads/writes of secret files: .env, private keys, credentials,
9
+ # tokens. Allows *.example / *.sample / *.template. Honors .agentignore.
7
10
  #
8
11
  # Exit codes: 0 = allow, 2 = block (reason on stderr).
12
+ # Env: SENSITIVE_GUARD_EXTRA — extra pipe-separated path patterns to block.
9
13
  set -euo pipefail
10
14
 
11
15
  INPUT=$(cat)
12
16
  [[ -z "$INPUT" ]] && exit 0
13
17
 
18
+ # Security guard: warn loudly if Node is missing rather than silently allowing.
19
+ if ! command -v node &>/dev/null; then
20
+ echo "WARNING: read-guard degraded — Node.js not found. Sensitive files are NOT fully protected." >&2
21
+ exit 0
22
+ fi
23
+
14
24
  extract_path() {
15
- if command -v node &>/dev/null; then
16
- printf '%s' "$1" | node -e "
17
- try {
18
- const d = JSON.parse(require('fs').readFileSync(0,'utf-8'));
19
- const p = d.file_path ?? d.tool_input?.file_path ?? d.path;
20
- if (typeof p === 'string') process.stdout.write(p);
21
- } catch {}
22
- " 2>/dev/null
23
- else
24
- printf '%s' "$1" | grep -oE '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed -E 's/.*:[[:space:]]*\"//;s/\"$//'
25
+ printf '%s' "$1" | node -e "
26
+ try {
27
+ const d = JSON.parse(require('fs').readFileSync(0,'utf-8'));
28
+ const p = d.tool_input?.file_path ?? d.file_path ?? d.tool_input?.path ?? d.path;
29
+ if (typeof p === 'string') process.stdout.write(p);
30
+ } catch {}
31
+ " 2>/dev/null
32
+ }
33
+
34
+ FILE_PATH=$(extract_path "$INPUT") || exit 0
35
+ [[ -z "$FILE_PATH" ]] && exit 0
36
+
37
+ # ─── Fast-path: obviously safe source/doc files (json still checked) ─
38
+ fast_path_safe() {
39
+ local ext="${1##*.}"
40
+ case "$ext" in
41
+ md|ts|tsx|js|jsx|css|scss|html|svg|yaml|yml|toml|xml|txt|sh|py|rb|rs|go|java|kt|swift|c|cpp|h|hpp|cs|vue|svelte|astro)
42
+ return 0 ;;
43
+ esac
44
+ return 1
45
+ }
46
+
47
+ # ─── Sensitive filename detection ───────────────────────────────────
48
+ is_sensitive() {
49
+ local filepath="$1" basename
50
+ basename=$(basename "$filepath" 2>/dev/null) || return 1
51
+
52
+ case "$basename" in
53
+ .env|.env.local|.env.development|.env.production|.env.staging|.env.test) return 0 ;;
54
+ .npmrc|.pypirc|.netrc) return 0 ;;
55
+ id_rsa|id_ecdsa|id_ed25519|id_dsa) return 0 ;;
56
+ serviceAccountKey.json|service-account*.json) return 0 ;;
57
+ config.json) [[ "$filepath" == *".docker/config.json"* ]] && return 0 ;;
58
+ esac
59
+ case "$basename" in
60
+ *.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|*.truststore) return 0 ;;
61
+ *_rsa|*_ecdsa|*_ed25519|*_dsa) return 0 ;;
62
+ esac
63
+ local lower
64
+ lower=$(echo "$basename" | tr '[:upper:]' '[:lower:]')
65
+ case "$lower" in
66
+ *credential*|*secret*|*private_key*|*privatekey*) return 0 ;;
67
+ firebase-adminsdk*) return 0 ;;
68
+ esac
69
+ if [[ "$basename" =~ ^\.env\. ]]; then
70
+ case "$basename" in
71
+ .env.example|.env.sample|.env.template) return 1 ;;
72
+ *) return 0 ;;
73
+ esac
25
74
  fi
75
+ if [[ -n "${SENSITIVE_GUARD_EXTRA:-}" ]] && printf '%s\n' "$filepath" | grep -qE "$SENSITIVE_GUARD_EXTRA"; then
76
+ return 0
77
+ fi
78
+ return 1
26
79
  }
27
80
 
28
- P=$(extract_path "$INPUT") || exit 0
29
- [[ -z "$P" ]] && exit 0
81
+ # ─── .agentignore / .aiignore / .cursorignore ───────────────────────
82
+ check_agentignore() {
83
+ local filepath="$1" ignorefile=""
84
+ for candidate in .agentignore .aiignore .cursorignore; do
85
+ [[ -f "$candidate" ]] && { ignorefile="$candidate"; break; }
86
+ done
87
+ [[ -z "$ignorefile" ]] && return 1
88
+
89
+ local normalized_fp normalized_pwd relpath
90
+ normalized_fp=$(printf '%s' "$filepath" | tr '\\' '/')
91
+ normalized_pwd=$(pwd | tr '\\' '/')
92
+ relpath=$(printf '%s' "$normalized_fp" | sed "s|^${normalized_pwd}/||") 2>/dev/null || relpath="$filepath"
30
93
 
31
- # Allow example/template variants.
32
- case "$P" in
94
+ while IFS= read -r pattern || [[ -n "$pattern" ]]; do
95
+ [[ -z "$pattern" || "$pattern" == \#* ]] && continue
96
+ if [[ "$relpath" == $pattern ]] || [[ "$(basename "$relpath")" == $pattern ]]; then
97
+ return 0
98
+ fi
99
+ done < "$ignorefile"
100
+ return 1
101
+ }
102
+
103
+ # ─── Allow example/template variants outright ───────────────────────
104
+ case "$FILE_PATH" in
33
105
  *.example|*.sample|*.template) exit 0 ;;
34
106
  esac
35
107
 
36
- SECRET="(\.env)($|\.[A-Za-z0-9]+$)|\.(pem|key|p12|pfx|keystore)$|id_(rsa|ed25519|ecdsa)$|(credentials|secrets?)\.(json|ya?ml|toml|txt)$"
37
- if printf '%s\n' "$P" | grep -qiE "$SECRET"; then
38
- echo "Blocked: '$P' is a secret file. Use its .example variant, or ask the user first." >&2
39
- exit 2
108
+ if ! fast_path_safe "$FILE_PATH"; then
109
+ if is_sensitive "$FILE_PATH" || check_agentignore "$FILE_PATH"; then
110
+ echo "Blocked: '$FILE_PATH' is a sensitive file (secrets, keys, or credentials). Use its .example variant, or ask the user first." >&2
111
+ exit 2
112
+ fi
40
113
  fi
41
114
 
42
115
  exit 0
@@ -1,29 +1,68 @@
1
1
  #!/usr/bin/env bash
2
2
  # specpipe-shell-guard.sh — blocking pre-shell/pre-tool hook (enforced guardrail).
3
3
  #
4
- # Portable across agents whose hook payload puts the shell command at either
5
- # .tool_input.command (Codex PreToolUse, Claude PreToolUse)
4
+ # The single shell guard for every agent. Reads the command from whichever shape
5
+ # the agent's hook payload uses:
6
+ # .tool_input.command (Claude PreToolUse, Codex PreToolUse)
6
7
  # .command (Cursor beforeShellExecution)
7
- # Blocks (exit 2) commands that explore wasteful directories or touch secrets.
8
+ #
9
+ # Two protections:
10
+ # 1. Secrets — commands that read/copy credential files (.env, keys, …).
11
+ # SECRET_POLICY=block (default) → exit 2; =warn → warn on stderr, exit 0
12
+ # (the approval flow: Claude asks the user, then may `cat .env`).
13
+ # 2. Wasteful dirs — exploring node_modules / build output / caches, which
14
+ # burns tokens. Always blocks (exit 2) when an exploration verb is present.
8
15
  #
9
16
  # Exit codes: 0 = allow, 2 = block (reason on stderr). Exit 2 is the portable
10
17
  # block primitive honored by Claude, Codex, and Cursor.
18
+ #
19
+ # Env:
20
+ # SECRET_POLICY block (default) | warn
21
+ # PATH_GUARD_EXTRA extra pipe-separated dir patterns to block
22
+ # SENSITIVE_GUARD_EXTRA extra pipe-separated secret patterns to block
11
23
  set -euo pipefail
12
24
 
13
25
  INPUT=$(cat)
14
26
  [[ -z "$INPUT" ]] && exit 0
27
+ POLICY="${SECRET_POLICY:-block}"
28
+
29
+ # Antigravity honors a stdout JSON decision ({"decision":"deny","reason":…}), NOT exit
30
+ # codes — a non-zero exit is logged as a hook failure and falls through to its native
31
+ # permission prompt. Detect its payload shape so block() emits the right thing.
32
+ IS_ANTIGRAVITY=0
33
+ printf '%s' "$INPUT" | grep -q '"toolCall"' && IS_ANTIGRAVITY=1
34
+
35
+ # Block primitive. Antigravity → stdout JSON deny (+ exit 0, clean). Everyone else →
36
+ # reason on stderr + exit 2 (honored by Claude/Codex directly, Cursor via failClosed).
37
+ block() {
38
+ local reason="$1"
39
+ if [[ "$IS_ANTIGRAVITY" == "1" ]]; then
40
+ local esc; esc=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g')
41
+ printf '{"decision":"deny","reason":"%s"}\n' "$esc"
42
+ exit 0
43
+ fi
44
+ echo "$reason" >&2
45
+ exit 2
46
+ }
15
47
 
48
+ # ─── Extract command (multi-payload) ────────────────────────────────
49
+ # Covers every agent's hook payload shape:
50
+ # .tool_input.command Claude / Codex (PreToolUse Bash)
51
+ # .command Cursor (beforeShellExecution)
52
+ # .tool_args.CommandLine Antigravity (PreToolUse run_command) — verified 2026
16
53
  extract_command() {
17
54
  if command -v node &>/dev/null; then
18
55
  printf '%s' "$1" | node -e "
19
56
  try {
20
57
  const d = JSON.parse(require('fs').readFileSync(0,'utf-8'));
21
- const c = d.tool_input?.command ?? d.command;
58
+ const a = d.toolCall?.args ?? {}; // Antigravity 1.0.13: { toolCall: { args: { CommandLine } } }
59
+ const c = d.tool_input?.command ?? d.command ?? d.tool_args?.CommandLine
60
+ ?? a.CommandLine ?? a.Command ?? a.command;
22
61
  if (typeof c === 'string') process.stdout.write(c);
23
62
  } catch {}
24
63
  " 2>/dev/null
25
64
  else
26
- printf '%s' "$1" | grep -oE '\"command\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed -E 's/.*:[[:space:]]*\"//;s/\"$//'
65
+ printf '%s' "$1" | grep -oE '"(command|CommandLine)"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"//;s/"$//'
27
66
  fi
28
67
  }
29
68
 
@@ -32,34 +71,87 @@ COMMAND=$(extract_command "$INPUT") || exit 0
32
71
 
33
72
  SEP="[/\\\\]"
34
73
 
35
- # Secrets: block reading/copying credential files (allow *.example / *.sample).
36
- SECRET="(^|[ /\\\\\"'])(\.env)($|[ /\\\\\"'.])"
37
- SECRET+="|(^|[ /\\\\])\.env\.[A-Za-z0-9]+"
38
- SECRET+="|\.(pem|key|p12|pfx|keystore)(\b|$)"
39
- SECRET+="|(^|[ /\\\\])id_(rsa|ed25519|ecdsa)"
40
- SECRET+="|(credentials|secrets?)\.(json|ya?ml|toml|txt)"
41
- if printf '%s\n' "$COMMAND" | grep -qiE '(^|[ |;&`(])(cat|less|more|head|tail|bat|cp|nano|vi|vim|grep|rg|strings|xxd|od|base64)([ ])'; then
42
- CLEAN=$(printf '%s\n' "$COMMAND" | sed -E 's/\.env\.(example|sample|template)//g')
43
- if printf '%s\n' "$CLEAN" | grep -qiE "$SECRET"; then
44
- echo "Blocked: command accesses a secret file (.env / key / credentials). Use .env.example, or ask the user first." >&2
45
- exit 2
74
+ # ─── Secrets in the command ─────────────────────────────────────────
75
+ # Only flag when a read/copy verb is present (so "echo use .env.example" or
76
+ # variable assignments don't trip the guard).
77
+ handle_secret() {
78
+ local match="$1"
79
+ if [[ "$POLICY" == "warn" ]]; then
80
+ echo "Warning: '$match' is a sensitive file. If the user approved this access, proceed. Otherwise ask the user first before reading secrets." >&2
81
+ exit 0
46
82
  fi
47
- fi
83
+ block "Blocked: command accesses a secret file ('$match'). Use its .example variant, or ask the user first."
84
+ }
48
85
 
49
- # Wasteful directories: only when an exploration verb is present.
50
- EXPLORE="(^|[[:space:]|;&\`(])(ls|ll|la|find|cat|head|tail|less|more|wc|stat|du|tree|bat|od|xxd|hexdump|nl)([[:space:]]|$)"
51
- printf '%s\n' "$COMMAND" | grep -qE "$EXPLORE" || exit 0
86
+ # No verb gate here (matches the original sensitive-guard): a secret referenced
87
+ # anywhere in the command is flagged — `ssh -i id_rsa`, `openssl -in cert.pem`,
88
+ # `gcloud --key-file=…` included. The .example/.sample/.template strip avoids the
89
+ # obvious false positives.
90
+ CLEAN=$(printf '%s\n' "$COMMAND" | sed -E 's/\.env\.(example|sample|template)//g')
52
91
 
92
+ SENSITIVE_IN_CMD=$(printf '%s\n' "$CLEAN" | grep -oE '[\./[:alnum:]_-]*\.env([\.[:alnum:]_-]*)?' | head -5) || true
93
+ if [[ -n "$SENSITIVE_IN_CMD" ]]; then
94
+ while IFS= read -r m; do
95
+ [[ -z "$m" ]] && continue
96
+ case "$m" in *.example|*.sample|*.template) continue ;; esac
97
+ handle_secret "$m"
98
+ done <<< "$SENSITIVE_IN_CMD"
99
+ fi
100
+ KEY_IN_CMD=$(printf '%s\n' "$CLEAN" | grep -oE '[[:alnum:]_./-]*\.(pem|key|p12|pfx|jks|keystore)($|[^[:alnum:]])' | head -3) || true
101
+ [[ -n "$KEY_IN_CMD" ]] && handle_secret "$(printf '%s' "$KEY_IN_CMD" | head -1)"
102
+ NAME_IN_CMD=$(printf '%s\n' "$CLEAN" | grep -oiE '(id_rsa|id_ecdsa|id_ed25519|id_dsa|serviceAccountKey\.json|service-account[[:alnum:]_-]*\.json|\.npmrc|\.pypirc|\.netrc)' | head -3) || true
103
+ [[ -n "$NAME_IN_CMD" ]] && handle_secret "$(printf '%s' "$NAME_IN_CMD" | head -1)"
104
+ CRED_IN_CMD=$(printf '%s\n' "$CLEAN" | grep -oiE '[[:alnum:]_./-]*(credential|secret|private_key|privatekey)[[:alnum:]_./-]*' | head -3) || true
105
+ [[ -n "$CRED_IN_CMD" ]] && handle_secret "$(printf '%s' "$CRED_IN_CMD" | head -1)"
106
+ if [[ -n "${SENSITIVE_GUARD_EXTRA:-}" ]] && printf '%s\n' "$CLEAN" | grep -qE "$SENSITIVE_GUARD_EXTRA"; then
107
+ handle_secret "$(printf '%s\n' "$CLEAN" | grep -oE "$SENSITIVE_GUARD_EXTRA" | head -1)"
108
+ fi
109
+
110
+ # ─── Wasteful directories ───────────────────────────────────────────
53
111
  BLOCKED="(^|[ /\\\\])node_modules(${SEP}|$| )"
54
- BLOCKED+="|(__pycache__)|\.git${SEP}(objects|refs)"
55
- BLOCKED+="|(^|[ /\\\\])dist${SEP}|(^|[ /\\\\])build${SEP}|\.next${SEP}"
56
- BLOCKED+="|(^|[ /\\\\])vendor(${SEP}|$| )|(^|[ /\\\\])target${SEP}"
57
- BLOCKED+="|(^|[ /\\\\])\.venv${SEP}|(^|[ /\\\\])venv${SEP}|\.pytest_cache${SEP}|\.cache(${SEP}|$| )"
58
- CLEAN=$(printf '%s\n' "$COMMAND" | sed -E "s|node_modules[/\\]\.bin[/\\][^[:space:]]*||g")
59
- if printf '%s\n' "$CLEAN" | grep -qE "$BLOCKED"; then
60
- M=$(printf '%s\n' "$COMMAND" | grep -oE "$BLOCKED" | head -1)
61
- echo "Blocked: command explores '$M' — a large/generated directory. Use scoped paths or Grep." >&2
62
- exit 2
112
+ BLOCKED+="|(__pycache__)"
113
+ BLOCKED+="|\.git${SEP}(objects|refs)"
114
+ BLOCKED+="|(^|[ /\\\\])dist${SEP}"
115
+ BLOCKED+="|(^|[ /\\\\])build${SEP}"
116
+ BLOCKED+="|\.next${SEP}"
117
+ BLOCKED+="|(^|[ /\\\\])vendor(${SEP}|$| )"
118
+ BLOCKED+="|(^|[ /\\\\])Pods(${SEP}|$| )"
119
+ BLOCKED+="|\.build${SEP}"
120
+ BLOCKED+="|DerivedData"
121
+ BLOCKED+="|\.gradle${SEP}"
122
+ BLOCKED+="|(^|[ /\\\\])target${SEP}"
123
+ BLOCKED+="|\.nuget"
124
+ BLOCKED+="|\.cache(${SEP}|$| )"
125
+ BLOCKED+="|(^|[ /\\\\])\.venv${SEP}"
126
+ BLOCKED+="|(^|[ /\\\\])venv${SEP}"
127
+ BLOCKED+="|\.mypy_cache${SEP}"
128
+ BLOCKED+="|\.pytest_cache${SEP}"
129
+ BLOCKED+="|\.ruff_cache${SEP}"
130
+ BLOCKED+="|\.egg-info(${SEP}|$| )"
131
+ BLOCKED+="|(^|[ /\\\\])bin${SEP}(Debug|Release|net|x64|x86)"
132
+ BLOCKED+="|(^|[ /\\\\])obj${SEP}(Debug|Release|net)"
133
+ BLOCKED+="|\.nuxt${SEP}"
134
+ BLOCKED+="|\.svelte-kit${SEP}"
135
+ BLOCKED+="|\.parcel-cache${SEP}"
136
+ BLOCKED+="|\.turbo${SEP}"
137
+ BLOCKED+="|(^|[ /\\\\])out${SEP}(server|static|_next)"
138
+ BLOCKED+="|\.bundle${SEP}"
139
+
140
+ if [[ -n "${PATH_GUARD_EXTRA:-}" ]]; then
141
+ BLOCKED+="|$PATH_GUARD_EXTRA"
142
+ fi
143
+
144
+ EXPLORE_VERB_RE="(^|[[:space:]|;&\`(])(ls|ll|la|find|cat|head|tail|less|more|wc|stat|du|tree|bat|od|xxd|hexdump|nl)([[:space:]]|$)"
145
+ if ! printf '%s\n' "$COMMAND" | grep -qE "$EXPLORE_VERB_RE"; then
146
+ exit 0
147
+ fi
148
+
149
+ # Strip node_modules/.bin/<binary> — executing an installed binary isn't exploration.
150
+ COMMAND_FOR_CHECK=$(printf '%s\n' "$COMMAND" | sed -E "s|node_modules[/\\]\.bin[/\\][^[:space:]]*||g")
151
+
152
+ if printf '%s\n' "$COMMAND_FOR_CHECK" | grep -qE "$BLOCKED"; then
153
+ MATCHED=$(printf '%s\n' "$COMMAND" | grep -oE "$BLOCKED" | head -1)
154
+ block "Blocked: command references '$MATCHED' — this directory is typically large and exploring it wastes tokens. Use Glob or Grep tools instead."
63
155
  fi
64
156
 
65
157
  exit 0