specpipe 1.0.0 → 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.
- package/README.md +116 -1220
- package/package.json +3 -2
- package/src/cli.js +16 -6
- package/src/commands/diff.js +1 -1
- package/src/commands/init-agents.js +40 -20
- package/src/commands/init-global.js +88 -33
- package/src/commands/init-interactive.js +71 -0
- package/src/commands/init.js +61 -22
- package/src/commands/remove.js +159 -49
- package/src/commands/upgrade.js +21 -56
- package/src/lib/agent-guards.js +34 -78
- package/src/lib/agent-install.js +38 -25
- package/src/lib/agents.js +53 -11
- package/src/lib/claude-global.js +50 -77
- package/src/lib/hooks.js +203 -0
- package/src/lib/installer.js +73 -61
- package/src/lib/reconcile.js +13 -8
- package/templates/{.claude/hooks → hooks}/file-guard.js +26 -21
- package/templates/hooks/specpipe-read-guard.sh +94 -21
- package/templates/hooks/specpipe-shell-guard.sh +121 -29
- package/templates/rules/specpipe-rules.md +77 -0
- package/templates/skills/sp-build/SKILL.md +101 -1
- package/templates/skills/sp-build-behavior-matrix/SKILL.md +876 -0
- package/templates/skills/sp-challenge/SKILL.md +34 -0
- package/templates/skills/sp-challenge-behavior-matrix/SKILL.md +289 -0
- package/templates/skills/sp-explore/SKILL.md +132 -0
- package/templates/skills/sp-explore-behavior-matrix/SKILL.md +862 -0
- package/templates/skills/sp-fix/SKILL.md +73 -1
- package/templates/skills/sp-fix-behavior-matrix/SKILL.md +338 -0
- package/templates/skills/sp-investigate/SKILL.md +70 -0
- package/templates/skills/sp-investigate-behavior-matrix/SKILL.md +718 -0
- package/templates/skills/sp-plan/SKILL.md +90 -0
- package/templates/skills/sp-plan-behavior-matrix/SKILL.md +1037 -0
- package/templates/skills/sp-review/SKILL.md +29 -3
- package/templates/skills/sp-review-behavior-matrix/SKILL.md +294 -0
- package/templates/.claude/CLAUDE.md +0 -79
- package/templates/.claude/hooks/path-guard.sh +0 -118
- package/templates/.claude/hooks/self-review.sh +0 -27
- package/templates/.claude/hooks/sensitive-guard.sh +0 -227
- package/templates/.claude/settings.json +0 -68
- package/templates/docs/WORKFLOW.md +0 -325
- package/templates/docs/specs/.gitkeep +0 -0
- package/templates/rules/specpipe-guards.md +0 -40
- package/templates/scripts/test-hooks.sh +0 -66
- /package/templates/{.claude/hooks → hooks}/comment-guard.js +0 -0
- /package/templates/{.claude/hooks → hooks}/glob-guard.js +0 -0
package/src/lib/installer.js
CHANGED
|
@@ -11,14 +11,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
11
11
|
* Component → file mappings.
|
|
12
12
|
*/
|
|
13
13
|
export const COMPONENTS = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
docs
|
|
52
|
-
|
|
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
|
-
*
|
|
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
|
|
60
|
-
'
|
|
61
|
-
'
|
|
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
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
'
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
212
|
-
mergeGlobalSettings, removeGlobalHooksFromSettings,
|
|
222
|
+
getGlobalHooksDir, installHookGlobal,
|
|
223
|
+
mergeGlobalSettings, removeGlobalHooksFromSettings,
|
|
224
|
+
installSkillGlobalForAgent,
|
|
213
225
|
} from './claude-global.js';
|
package/src/lib/reconcile.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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 !== '
|
|
51
|
+
if (rules && rules.mode !== 'merge') {
|
|
47
52
|
desired.set(rules.path, {
|
|
48
53
|
agent,
|
|
49
|
-
templateRel:
|
|
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
|
-
//
|
|
151
|
-
|
|
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 !==
|
|
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(
|
|
177
|
-
|
|
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(
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
if
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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 '
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
83
|
+
block "Blocked: command accesses a secret file ('$match'). Use its .example variant, or ask the user first."
|
|
84
|
+
}
|
|
48
85
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
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__)
|
|
55
|
-
BLOCKED+="
|
|
56
|
-
BLOCKED+="|(^|[ /\\\\])
|
|
57
|
-
BLOCKED+="|(^|[ /\\\\])
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|