sinapse-ai 7.5.2 → 7.7.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/.claude/hooks/enforce-architecture-first.cjs +144 -0
- package/.claude/hooks/enforce-delegation.cjs +142 -0
- package/.claude/hooks/enforce-story-gate.cjs +194 -0
- package/.claude/hooks/secret-scanning.cjs +155 -0
- package/.claude/hooks/write-path-validation.cjs +132 -0
- package/.claude/rules/agent-handoff.md +12 -0
- package/.claude/rules/cross-squad-routing.md +47 -0
- package/.claude/rules/hook-governance.md +61 -0
- package/.claude/rules/security-scanning.md +42 -0
- package/.sinapse-ai/cli/commands/health/index.js +237 -0
- package/.sinapse-ai/cli/commands/performance/index.js +157 -0
- package/.sinapse-ai/cli/commands/routing-intel/index.js +176 -0
- package/.sinapse-ai/development/templates/agent-handoff-tmpl.yaml +3 -0
- package/.sinapse-ai/install-manifest.yaml +17 -5
- package/bin/sinapse.js +34 -0
- package/package.json +1 -1
- package/squads/squad-claude/agents/swarm-orqx.md +28 -0
- package/squads/squad-claude/preferences/README.md +15 -0
- package/squads/squad-claude/squad.yaml +13 -0
- package/squads/squad-commercial/agents/commercial-orqx.md +44 -3
- package/squads/squad-commercial/squad.yaml +7 -0
- package/squads/squad-council/preferences/README.md +15 -0
- package/squads/squad-council/squad.yaml +7 -0
- package/squads/squad-cybersecurity/preferences/README.md +15 -0
- package/squads/squad-cybersecurity/squad.yaml +12 -0
- package/squads/squad-finance/preferences/README.md +15 -0
- package/squads/squad-growth/agents/growth-orqx.md +30 -0
- package/squads/squad-growth/squad.yaml +7 -0
- package/squads/squad-paidmedia/preferences/README.md +15 -0
- package/squads/squad-product/squad.yaml +7 -0
- package/squads/squad-storytelling/preferences/README.md +15 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook: Enforce Architecture-First Development (CJS port)
|
|
6
|
+
*
|
|
7
|
+
* RULE: Code in protected paths can only be created/edited if prior
|
|
8
|
+
* architecture documentation exists.
|
|
9
|
+
*
|
|
10
|
+
* Protocol (Claude Code PreToolUse):
|
|
11
|
+
* exit 0 → allow
|
|
12
|
+
* exit 2 → block (message shown to model via stderr)
|
|
13
|
+
*
|
|
14
|
+
* Fail-open: if parsing fails or project root is unresolvable, allow.
|
|
15
|
+
*
|
|
16
|
+
* @module enforce-architecture-first
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Configuration: paths that REQUIRE prior documentation
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const PROTECTED_PATHS = [
|
|
27
|
+
{
|
|
28
|
+
pattern: 'supabase/functions/',
|
|
29
|
+
docPatterns: [
|
|
30
|
+
'docs/architecture/{name}.md',
|
|
31
|
+
'docs/architecture/{name}-architecture.md',
|
|
32
|
+
'docs/approved-plans/{name}.md',
|
|
33
|
+
],
|
|
34
|
+
extractName(p) {
|
|
35
|
+
const idx = p.indexOf('supabase/functions/');
|
|
36
|
+
if (idx === -1) return null;
|
|
37
|
+
return p.slice(idx + 'supabase/functions/'.length).split('/')[0] || null;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
pattern: 'supabase/migrations/',
|
|
42
|
+
docPatterns: [
|
|
43
|
+
'docs/approved-plans/migration-{name}.md',
|
|
44
|
+
'docs/architecture/database-changes.md',
|
|
45
|
+
],
|
|
46
|
+
extractName(p) {
|
|
47
|
+
const idx = p.indexOf('supabase/migrations/');
|
|
48
|
+
if (idx === -1) return null;
|
|
49
|
+
return path.basename(p, path.extname(p));
|
|
50
|
+
},
|
|
51
|
+
allowIfExists: true,
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const ALWAYS_ALLOWED = [
|
|
56
|
+
'.claude/', 'docs/', 'outputs/', 'squads/', '.sinapse-ai/',
|
|
57
|
+
'.sinapse-custom/', 'node_modules/', '.git/',
|
|
58
|
+
'package.json', 'package-lock.json', 'tsconfig.json', '.env', 'README.md',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function projectRoot() {
|
|
66
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function relativize(filePath, root) {
|
|
70
|
+
if (filePath.startsWith(root)) {
|
|
71
|
+
return filePath.slice(root.length).replace(/^[/\\]+/, '');
|
|
72
|
+
}
|
|
73
|
+
return filePath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAlwaysAllowed(rel) {
|
|
77
|
+
return ALWAYS_ALLOWED.some((a) => rel.includes(a));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findProtection(rel) {
|
|
81
|
+
return PROTECTED_PATHS.find((p) => rel.includes(p.pattern)) || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function docExists(rel, protection, root) {
|
|
85
|
+
const name = protection.extractName(rel);
|
|
86
|
+
if (!name) return true;
|
|
87
|
+
|
|
88
|
+
for (const dp of protection.docPatterns) {
|
|
89
|
+
const docPath = path.join(root, dp.replace('{name}', name));
|
|
90
|
+
if (fs.existsSync(docPath)) return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (protection.allowIfExists) {
|
|
94
|
+
const full = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
95
|
+
if (fs.existsSync(full)) return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Main
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function main() {
|
|
106
|
+
let input;
|
|
107
|
+
try {
|
|
108
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
109
|
+
} catch {
|
|
110
|
+
process.exit(0); // fail-open
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const toolName = input.tool_name || '';
|
|
114
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const filePath = (input.tool_input || {}).file_path || '';
|
|
119
|
+
if (!filePath) process.exit(0);
|
|
120
|
+
|
|
121
|
+
const root = projectRoot();
|
|
122
|
+
const rel = relativize(filePath, root);
|
|
123
|
+
|
|
124
|
+
if (isAlwaysAllowed(rel)) process.exit(0);
|
|
125
|
+
|
|
126
|
+
const protection = findProtection(rel);
|
|
127
|
+
if (!protection) process.exit(0);
|
|
128
|
+
|
|
129
|
+
if (docExists(rel, protection, root)) process.exit(0);
|
|
130
|
+
|
|
131
|
+
// BLOCK
|
|
132
|
+
const name = protection.extractName(rel) || 'unknown';
|
|
133
|
+
const accepted = protection.docPatterns.map((d) => ` - ${d.replace('{name}', name)}`).join('\n');
|
|
134
|
+
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`\nARCHITECTURE-FIRST BLOCK: Documentation required before code.\n` +
|
|
137
|
+
`File: ${rel}\n` +
|
|
138
|
+
`Create one of:\n${accepted}\n` +
|
|
139
|
+
`Then retry the operation.\n`,
|
|
140
|
+
);
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main();
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook: Enforce Mandatory Delegation — Constitution Article VIII
|
|
6
|
+
*
|
|
7
|
+
* RULE: Orchestrator agents (*-orqx) must NEVER execute domain work directly.
|
|
8
|
+
* They can only read, search, and delegate via Agent/SendMessage.
|
|
9
|
+
*
|
|
10
|
+
* Protocol (Claude Code PreToolUse):
|
|
11
|
+
* exit 0 → allow
|
|
12
|
+
* exit 2 → block (message shown to model via stderr)
|
|
13
|
+
*
|
|
14
|
+
* Fail-open: if session state is unreadable or agent is unknown, allow.
|
|
15
|
+
*
|
|
16
|
+
* Exception: sinapse-orqx is allowed Write/Edit in .sinapse-ai/ paths
|
|
17
|
+
* (framework governance — operates above the story layer).
|
|
18
|
+
*
|
|
19
|
+
* @module enforce-delegation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Configuration
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Agent IDs that are orchestrators (must delegate, never execute). */
|
|
30
|
+
const ORCHESTRATOR_PATTERN = /-orqx$/;
|
|
31
|
+
|
|
32
|
+
/** Tools that orchestrators are NOT allowed to use. */
|
|
33
|
+
const BLOCKED_TOOLS = ['Write', 'Edit', 'Bash', 'NotebookEdit'];
|
|
34
|
+
|
|
35
|
+
/** Paths where sinapse-orqx IS allowed to Write/Edit (framework governance). */
|
|
36
|
+
const FRAMEWORK_GOVERNANCE_PATHS = [
|
|
37
|
+
'.sinapse-ai/', '.claude/', '.sinapse/', 'bin/',
|
|
38
|
+
'package.json', 'core-config.yaml',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function projectRoot() {
|
|
46
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function relativize(filePath, root) {
|
|
50
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
51
|
+
const normalizedRoot = root.replace(/\\/g, '/');
|
|
52
|
+
if (normalized.startsWith(normalizedRoot)) {
|
|
53
|
+
return normalized.slice(normalizedRoot.length).replace(/^\/+/, '');
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the active agent from session state.
|
|
60
|
+
* Returns the agent ID string or null if unknown.
|
|
61
|
+
*/
|
|
62
|
+
function getActiveAgent(root) {
|
|
63
|
+
const sessionStatePath = path.join(root, '.sinapse', 'session-state.json');
|
|
64
|
+
try {
|
|
65
|
+
const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
|
|
66
|
+
return state.lastAgent || null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null; // fail-open
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isOrchestrator(agentId) {
|
|
73
|
+
return ORCHESTRATOR_PATTERN.test(agentId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isFrameworkGovernancePath(rel) {
|
|
77
|
+
return FRAMEWORK_GOVERNANCE_PATHS.some((fp) => rel.startsWith(fp) || rel === fp);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Main
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function main() {
|
|
85
|
+
let input;
|
|
86
|
+
try {
|
|
87
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
process.exit(0); // fail-open
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const toolName = input.tool_name || '';
|
|
93
|
+
|
|
94
|
+
// Only intercept domain-execution tools
|
|
95
|
+
if (!BLOCKED_TOOLS.includes(toolName)) {
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const root = projectRoot();
|
|
100
|
+
const agentId = getActiveAgent(root);
|
|
101
|
+
|
|
102
|
+
// If no agent tracked or not an orchestrator, allow
|
|
103
|
+
if (!agentId || !isOrchestrator(agentId)) {
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Special case: sinapse-orqx allowed for framework governance
|
|
108
|
+
if (agentId === 'sinapse-orqx') {
|
|
109
|
+
if (toolName === 'Write' || toolName === 'Edit') {
|
|
110
|
+
const filePath = (input.tool_input || {}).file_path || '';
|
|
111
|
+
if (filePath) {
|
|
112
|
+
const rel = relativize(filePath, root);
|
|
113
|
+
if (isFrameworkGovernancePath(rel)) {
|
|
114
|
+
process.exit(0); // Framework governance exception
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// sinapse-orqx blocked for non-governance Write/Edit and all Bash
|
|
119
|
+
// (it should delegate to @developer or @devops)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// BLOCK
|
|
123
|
+
const delegationMap = {
|
|
124
|
+
Write: '@developer (Dex)',
|
|
125
|
+
Edit: '@developer (Dex)',
|
|
126
|
+
Bash: '@developer (Dex) or @devops (Gage)',
|
|
127
|
+
NotebookEdit: '@developer (Dex)',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const delegate = delegationMap[toolName] || '@developer';
|
|
131
|
+
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
`\nMANDATORY DELEGATION BLOCK (Constitution Article VIII)\n` +
|
|
134
|
+
`Agent: ${agentId} (orchestrator)\n` +
|
|
135
|
+
`Tool: ${toolName}\n` +
|
|
136
|
+
`Orchestrators NEVER execute domain work directly.\n` +
|
|
137
|
+
`Delegate to ${delegate} for this operation.\n`,
|
|
138
|
+
);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main();
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook: Enforce Story Gate — Constitution Article III (Documentation-First)
|
|
6
|
+
*
|
|
7
|
+
* RULE: Code files in implementation paths cannot be created/edited unless
|
|
8
|
+
* a story exists in docs/stories/ with status >= Ready.
|
|
9
|
+
*
|
|
10
|
+
* Protocol (Claude Code PreToolUse):
|
|
11
|
+
* exit 0 → allow
|
|
12
|
+
* exit 2 → block (message shown to model via stderr)
|
|
13
|
+
*
|
|
14
|
+
* Fail-open: if session state is unreadable or story status is indeterminate,
|
|
15
|
+
* the hook allows the operation (never blocks productive work).
|
|
16
|
+
*
|
|
17
|
+
* @module enforce-story-gate
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Configuration
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Paths that require an active story before code changes. */
|
|
28
|
+
const CODE_PATHS = [
|
|
29
|
+
'packages/', 'src/', 'app/', 'lib/', 'bin/',
|
|
30
|
+
'components/', 'pages/', 'api/', 'services/',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Paths always exempt from story requirement. */
|
|
34
|
+
const EXEMPT_PATHS = [
|
|
35
|
+
'.claude/', '.sinapse-ai/', '.sinapse/', '.sinapse-custom/',
|
|
36
|
+
'docs/', 'tests/', '__tests__/', 'test/',
|
|
37
|
+
'node_modules/', '.git/', 'squads/', 'outputs/',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Config files always exempt. */
|
|
41
|
+
const EXEMPT_FILES = [
|
|
42
|
+
'package.json', 'package-lock.json', 'tsconfig.json',
|
|
43
|
+
'.env', '.env.local', '.env.example',
|
|
44
|
+
'.gitignore', '.eslintrc', '.prettierrc',
|
|
45
|
+
'README.md', 'CHANGELOG.md',
|
|
46
|
+
'jest.config.js', 'jest.config.ts',
|
|
47
|
+
'vite.config.ts', 'next.config.js', 'next.config.mjs',
|
|
48
|
+
'tailwind.config.js', 'tailwind.config.ts',
|
|
49
|
+
'postcss.config.js', 'postcss.config.cjs',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Story statuses that allow implementation. */
|
|
53
|
+
const VALID_STATUSES = ['ready', 'inprogress', 'in progress', 'in_progress', 'inreview', 'in review', 'in_review', 'done'];
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function projectRoot() {
|
|
60
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relativize(filePath, root) {
|
|
64
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
65
|
+
const normalizedRoot = root.replace(/\\/g, '/');
|
|
66
|
+
if (normalized.startsWith(normalizedRoot)) {
|
|
67
|
+
return normalized.slice(normalizedRoot.length).replace(/^\/+/, '');
|
|
68
|
+
}
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isExempt(rel) {
|
|
73
|
+
const basename = path.basename(rel);
|
|
74
|
+
if (EXEMPT_FILES.includes(basename)) return true;
|
|
75
|
+
return EXEMPT_PATHS.some((ep) => rel.startsWith(ep));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isCodePath(rel) {
|
|
79
|
+
return CODE_PATHS.some((cp) => rel.startsWith(cp));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if there's an active story with valid status.
|
|
84
|
+
* Reads .sinapse/session-state.json for story context,
|
|
85
|
+
* then scans docs/stories/ for any story with status >= Ready.
|
|
86
|
+
*/
|
|
87
|
+
function hasActiveStory(root) {
|
|
88
|
+
// Strategy 1: Check session state for active story
|
|
89
|
+
const sessionStatePath = path.join(root, '.sinapse', 'session-state.json');
|
|
90
|
+
try {
|
|
91
|
+
const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
|
|
92
|
+
if (state.activeStory && state.activeStory.status) {
|
|
93
|
+
const status = state.activeStory.status.toLowerCase().replace(/[\s_-]+/g, '');
|
|
94
|
+
if (VALID_STATUSES.some((vs) => vs.replace(/[\s_-]+/g, '') === status)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// No session state or invalid — continue to Strategy 2
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Strategy 2: Scan docs/stories/ for any story file
|
|
103
|
+
const storiesDir = path.join(root, 'docs', 'stories');
|
|
104
|
+
try {
|
|
105
|
+
if (!fs.existsSync(storiesDir)) return false;
|
|
106
|
+
|
|
107
|
+
// Recursively find .md files
|
|
108
|
+
const files = walkSync(storiesDir, '.md');
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
112
|
+
// Look for status field in YAML frontmatter or markdown
|
|
113
|
+
const statusMatch = content.match(/status:\s*["']?(\w[\w\s]*\w?)["']?/i);
|
|
114
|
+
if (statusMatch) {
|
|
115
|
+
const status = statusMatch[1].toLowerCase().replace(/[\s_-]+/g, '');
|
|
116
|
+
if (VALID_STATUSES.some((vs) => vs.replace(/[\s_-]+/g, '') === status)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Skip unreadable files
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Can't scan — fail-open
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Simple recursive file walker. */
|
|
133
|
+
function walkSync(dir, ext) {
|
|
134
|
+
const results = [];
|
|
135
|
+
try {
|
|
136
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const full = path.join(dir, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
results.push(...walkSync(full, ext));
|
|
140
|
+
} else if (entry.name.endsWith(ext)) {
|
|
141
|
+
results.push(full);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Skip inaccessible dirs
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Main
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function main() {
|
|
155
|
+
let input;
|
|
156
|
+
try {
|
|
157
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
158
|
+
} catch {
|
|
159
|
+
process.exit(0); // fail-open
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const toolName = input.tool_name || '';
|
|
163
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const filePath = (input.tool_input || {}).file_path || '';
|
|
168
|
+
if (!filePath) process.exit(0);
|
|
169
|
+
|
|
170
|
+
const root = projectRoot();
|
|
171
|
+
const rel = relativize(filePath, root);
|
|
172
|
+
|
|
173
|
+
// Exempt paths and files
|
|
174
|
+
if (isExempt(rel)) process.exit(0);
|
|
175
|
+
|
|
176
|
+
// Only enforce on code paths
|
|
177
|
+
if (!isCodePath(rel)) process.exit(0);
|
|
178
|
+
|
|
179
|
+
// Check for active story
|
|
180
|
+
if (hasActiveStory(root)) process.exit(0);
|
|
181
|
+
|
|
182
|
+
// BLOCK
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`\nDOCUMENTATION-FIRST BLOCK (Constitution Article III)\n` +
|
|
185
|
+
`File: ${rel}\n` +
|
|
186
|
+
`No active story found with status >= Ready in docs/stories/.\n` +
|
|
187
|
+
`Create a story first: @sprint-lead *draft\n` +
|
|
188
|
+
`Then validate it: @product-lead *validate\n` +
|
|
189
|
+
`Only then can implementation proceed.\n`,
|
|
190
|
+
);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook: Secret Scanning
|
|
6
|
+
*
|
|
7
|
+
* RULE: Detect and block potential secrets/credentials from being written to files.
|
|
8
|
+
*
|
|
9
|
+
* Protocol (Claude Code PreToolUse):
|
|
10
|
+
* exit 0 → allow
|
|
11
|
+
* exit 2 → block (message shown to model via stderr)
|
|
12
|
+
*
|
|
13
|
+
* Fail-open: if parsing fails, allow.
|
|
14
|
+
*
|
|
15
|
+
* @module secret-scanning
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Secret Patterns — ordered by severity
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const SECRET_PATTERNS = [
|
|
26
|
+
// API Keys & Tokens
|
|
27
|
+
{ name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/ },
|
|
28
|
+
{ name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|secret_key)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/i },
|
|
29
|
+
{ name: 'GitHub Token', pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
|
|
30
|
+
{ name: 'GitHub OAuth', pattern: /gho_[A-Za-z0-9_]{36,}/ },
|
|
31
|
+
{ name: 'Slack Token', pattern: /xox[bpors]-[0-9]{10,}-[A-Za-z0-9-]+/ },
|
|
32
|
+
{ name: 'Stripe Key', pattern: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
|
|
33
|
+
{ name: 'OpenAI Key', pattern: /sk-[A-Za-z0-9]{20,}/ },
|
|
34
|
+
{ name: 'Anthropic Key', pattern: /sk-ant-[A-Za-z0-9-]{20,}/ },
|
|
35
|
+
{ name: 'Supabase Key', pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}/ },
|
|
36
|
+
{ name: 'Google API Key', pattern: /AIza[0-9A-Za-z_-]{35}/ },
|
|
37
|
+
{ name: 'Vercel Token', pattern: /vercel_[A-Za-z0-9]{20,}/ },
|
|
38
|
+
|
|
39
|
+
// Private Keys
|
|
40
|
+
{ name: 'RSA Private Key', pattern: /-----BEGIN RSA PRIVATE KEY-----/ },
|
|
41
|
+
{ name: 'SSH Private Key', pattern: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
|
|
42
|
+
{ name: 'PGP Private Key', pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/ },
|
|
43
|
+
{ name: 'EC Private Key', pattern: /-----BEGIN EC PRIVATE KEY-----/ },
|
|
44
|
+
|
|
45
|
+
// Connection Strings
|
|
46
|
+
{ name: 'DB Connection String', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^:]+:[^@]+@[^/\s]+/i },
|
|
47
|
+
{ name: 'Supabase DB URL', pattern: /postgresql:\/\/postgres\.[A-Za-z0-9]+:[^@]+@/i },
|
|
48
|
+
|
|
49
|
+
// Generic Patterns (broader, lower confidence)
|
|
50
|
+
{ name: 'Hardcoded Password', pattern: /(?:password|passwd|pwd)\s*[=:]\s*['"][^'"]{8,}['"]/i },
|
|
51
|
+
{ name: 'Bearer Token', pattern: /[Bb]earer\s+[A-Za-z0-9_\-.]{20,}/ },
|
|
52
|
+
{ name: 'Basic Auth', pattern: /[Bb]asic\s+[A-Za-z0-9+/=]{20,}/ },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Files that are expected to contain secret-like patterns */
|
|
56
|
+
const EXEMPT_PATHS = [
|
|
57
|
+
'.env.example', '.env.template', '.env.sample',
|
|
58
|
+
'node_modules/', '.git/',
|
|
59
|
+
'.claude/hooks/', // Hook scripts may reference patterns
|
|
60
|
+
'test/', 'tests/', '__tests__/',
|
|
61
|
+
'.sinapse-ai/core/', // Framework core may have validators
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** File extensions to scan */
|
|
65
|
+
const SCANNABLE_EXTENSIONS = [
|
|
66
|
+
'.ts', '.tsx', '.js', '.jsx', '.cjs', '.mjs',
|
|
67
|
+
'.json', '.yaml', '.yml', '.toml',
|
|
68
|
+
'.env', '.sh', '.bash', '.py',
|
|
69
|
+
'.md', '.txt', '.cfg', '.conf', '.ini',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Helpers
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function projectRoot() {
|
|
77
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function relativize(filePath, root) {
|
|
81
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
82
|
+
const normalizedRoot = root.replace(/\\/g, '/');
|
|
83
|
+
if (normalized.startsWith(normalizedRoot)) {
|
|
84
|
+
return normalized.slice(normalizedRoot.length).replace(/^\/+/, '');
|
|
85
|
+
}
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isExempt(rel) {
|
|
90
|
+
return EXEMPT_PATHS.some((ep) => rel.includes(ep));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isScannable(rel) {
|
|
94
|
+
return SCANNABLE_EXTENSIONS.some((ext) => rel.endsWith(ext));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scanForSecrets(content) {
|
|
98
|
+
const findings = [];
|
|
99
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
100
|
+
if (pattern.test(content)) {
|
|
101
|
+
findings.push(name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Main
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function main() {
|
|
112
|
+
let input;
|
|
113
|
+
try {
|
|
114
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
115
|
+
} catch {
|
|
116
|
+
process.exit(0); // fail-open
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toolName = input.tool_name || '';
|
|
120
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const toolInput = input.tool_input || {};
|
|
125
|
+
const filePath = toolInput.file_path || '';
|
|
126
|
+
if (!filePath) process.exit(0);
|
|
127
|
+
|
|
128
|
+
const root = projectRoot();
|
|
129
|
+
const rel = relativize(filePath, root);
|
|
130
|
+
|
|
131
|
+
if (isExempt(rel)) process.exit(0);
|
|
132
|
+
if (!isScannable(rel)) process.exit(0);
|
|
133
|
+
|
|
134
|
+
// Scan content being written
|
|
135
|
+
const content = toolInput.content || toolInput.new_string || '';
|
|
136
|
+
if (!content) process.exit(0);
|
|
137
|
+
|
|
138
|
+
const findings = scanForSecrets(content);
|
|
139
|
+
if (findings.length === 0) process.exit(0);
|
|
140
|
+
|
|
141
|
+
// BLOCK
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
`\nSECRET SCANNING BLOCK: Potential secrets detected!\n` +
|
|
144
|
+
`File: ${rel}\n` +
|
|
145
|
+
`Found: ${findings.join(', ')}\n` +
|
|
146
|
+
`\n` +
|
|
147
|
+
`DO NOT commit secrets to code. Instead:\n` +
|
|
148
|
+
` - Use environment variables (.env) for local dev\n` +
|
|
149
|
+
` - Use .env.example with placeholder values for templates\n` +
|
|
150
|
+
` - Use secret managers for production (Supabase Vault, etc.)\n`,
|
|
151
|
+
);
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main();
|