sinapse-ai 1.7.0 → 1.8.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/CLAUDE.md +5 -11
- package/.claude/hooks/README.md +14 -1
- package/.claude/hooks/code-intel-pretool.cjs +115 -0
- package/.claude/hooks/enforce-delegation.cjs +31 -3
- package/.claude/hooks/enforce-framework-boundary.cjs +324 -0
- package/.claude/hooks/enforce-permission-mode.cjs +249 -0
- package/.claude/hooks/secret-scanning.cjs +34 -43
- package/.claude/hooks/synapse-engine.cjs +23 -23
- package/.claude/hooks/telemetry-post-tool.cjs +128 -0
- package/.claude/hooks/telemetry-stop.cjs +132 -0
- package/.claude/hooks/verify-packages.cjs +9 -2
- package/.claude/rules/hook-governance.md +2 -0
- package/.sinapse-ai/cli/commands/health/index.js +24 -0
- package/.sinapse-ai/core/README.md +11 -0
- package/.sinapse-ai/core/config/config-loader.js +19 -0
- package/.sinapse-ai/core/execution/build-orchestrator.js +4 -1
- package/.sinapse-ai/core/execution/parallel-executor.js +7 -1
- package/.sinapse-ai/core/execution/subagent-dispatcher.js +126 -28
- package/.sinapse-ai/core/execution/wave-executor.js +4 -1
- package/.sinapse-ai/core/grounding/README.md +71 -11
- package/.sinapse-ai/core/health-check/checks/project/framework-config.js +38 -2
- package/.sinapse-ai/core/health-check/checks/project/package-json.js +47 -3
- package/.sinapse-ai/core/health-check/checks/services/gemini-cli.js +117 -0
- package/.sinapse-ai/core/health-check/checks/services/index.js +2 -0
- package/.sinapse-ai/core/health-check/healers/index.js +40 -3
- package/.sinapse-ai/core/ideation/ideation-engine.js +170 -121
- package/.sinapse-ai/core/ids/gate-evaluator.js +318 -0
- package/.sinapse-ai/core/ids/gates/g5-semantic-handshake.js +190 -0
- package/.sinapse-ai/core/ids/gates/g6-ci-integrity.js +162 -0
- package/.sinapse-ai/core/ids/index.js +30 -0
- package/.sinapse-ai/core/memory/__tests__/active-modules.verify.js +11 -0
- package/.sinapse-ai/core/orchestration/agent-invoker.js +29 -6
- package/.sinapse-ai/core/orchestration/brownfield-handler.js +36 -3
- package/.sinapse-ai/core/orchestration/executors/epic-3-executor.js +76 -5
- package/.sinapse-ai/core/orchestration/executors/epic-4-executor.js +63 -17
- package/.sinapse-ai/core/orchestration/executors/epic-6-executor.js +153 -41
- package/.sinapse-ai/core/orchestration/executors/epic-executor.js +40 -0
- package/.sinapse-ai/core/orchestration/greenfield-handler.js +87 -3
- package/.sinapse-ai/core/orchestration/master-orchestrator.js +105 -7
- package/.sinapse-ai/core/orchestration/parallel-executor.js +6 -1
- package/.sinapse-ai/core/orchestration/workflow-executor.js +41 -0
- package/.sinapse-ai/core/registry/squad-agent-resolver.js +253 -0
- package/.sinapse-ai/core/telemetry/ids-sink.js +188 -0
- package/.sinapse-ai/core/utils/output-formatter.js +8 -290
- package/.sinapse-ai/core-config.yaml +49 -1
- package/.sinapse-ai/data/entity-registry.yaml +15081 -13735
- package/.sinapse-ai/data/registry-update-log.jsonl +86 -0
- package/.sinapse-ai/development/agents/developer.md +2 -0
- package/.sinapse-ai/development/agents/devops.md +9 -0
- package/.sinapse-ai/development/external-executors/README.md +18 -0
- package/.sinapse-ai/development/external-executors/codex.md +56 -0
- package/.sinapse-ai/development/scripts/populate-entity-registry.js +65 -9
- package/.sinapse-ai/development/scripts/squad/squad-downloader.js +54 -11
- package/.sinapse-ai/development/tasks/delegate-to-external-executor.md +152 -0
- package/.sinapse-ai/development/tasks/github-devops-pre-push-quality-gate.md +46 -29
- package/.sinapse-ai/development/tasks/update-sinapse.md +3 -3
- package/.sinapse-ai/hooks/sinapse-brand-grounding.cjs +4 -7
- package/.sinapse-ai/hooks/sinapse-ds-grounding.cjs +4 -7
- package/.sinapse-ai/hooks/sinapse-vault-grounding.cjs +4 -7
- package/.sinapse-ai/infrastructure/integrations/ai-providers/ai-provider-factory.js +4 -1
- package/.sinapse-ai/infrastructure/integrations/ai-providers/claude-provider.js +57 -55
- package/.sinapse-ai/infrastructure/scripts/ide-sync/gemini-commands.js +298 -0
- package/.sinapse-ai/infrastructure/scripts/ide-sync/index.js +127 -6
- package/.sinapse-ai/infrastructure/scripts/ide-sync/persona-renderer.js +97 -0
- package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/antigravity.js +121 -0
- package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/cursor.js +119 -0
- package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/github-copilot.js +191 -0
- package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/kimi.js +448 -0
- package/.sinapse-ai/install-manifest.yaml +158 -90
- package/.sinapse-ai/scripts/pm.sh +18 -6
- package/bin/cli.js +17 -0
- package/bin/commands/agents.js +96 -0
- package/bin/commands/doctor.js +15 -0
- package/bin/commands/ideate.js +129 -0
- package/bin/commands/uninstall.js +40 -0
- package/bin/postinstall.js +50 -4
- package/bin/sinapse.js +146 -2
- package/bin/utils/secret-scanner-core.js +253 -0
- package/bin/utils/staged-secret-scan.js +106 -40
- package/package.json +13 -3
- package/packages/installer/src/installer/git-hooks-installer.js +384 -0
- package/packages/installer/src/installer/sinapse-ai-installer.js +16 -0
- package/packages/installer/src/wizard/ide-config-generator.js +23 -0
- package/packages/installer/src/wizard/validators.js +38 -1
- package/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js +5 -1
- package/packages/installer/tests/unit/git-hooks-installer.test.js +262 -0
- package/scripts/eval-runner.js +422 -0
- package/scripts/generate-install-manifest.js +13 -9
- package/scripts/generate-synapse-runtime.js +51 -0
- package/scripts/validate-all.js +1 -0
- package/scripts/validate-evals.js +466 -0
- package/scripts/validate-schemas.js +539 -0
- package/scripts/validate-squad-orqx.js +9 -2
- package/.sinapse-ai/development/scripts/elicitation-engine.js +0 -385
- package/.sinapse-ai/development/scripts/elicitation-session-manager.js +0 -300
- package/.sinapse-ai/development/tasks/test-validation-task.md +0 -172
package/.claude/CLAUDE.md
CHANGED
|
@@ -31,6 +31,8 @@ docs/stories/ # Development stories
|
|
|
31
31
|
packages/ # Shared packages
|
|
32
32
|
squads/ # Squad expansions
|
|
33
33
|
tests/ # Tests
|
|
34
|
+
governance/ # Framework evolution pipeline
|
|
35
|
+
audits/ # Framework-level AuditFindings
|
|
34
36
|
```
|
|
35
37
|
|
|
36
38
|
## Framework Boundary (L1-L4)
|
|
@@ -38,7 +40,7 @@ tests/ # Tests
|
|
|
38
40
|
| Layer | Mutability | Key Paths |
|
|
39
41
|
|-------|-----------|-----------|
|
|
40
42
|
| L1 Core | NEVER | `.sinapse-ai/core/`, `bin/sinapse*.js` |
|
|
41
|
-
| L2 Templates | NEVER | `.sinapse-ai/development/{tasks,templates,checklists,workflows}/` |
|
|
43
|
+
| L2 Templates | NEVER | `.sinapse-ai/development/{tasks,templates,checklists,workflows,external-executors}/` |
|
|
42
44
|
| L3 Config | Mutable | `.sinapse-ai/data/`, `core-config.yaml` |
|
|
43
45
|
| L4 Runtime | ALWAYS | `docs/stories/`, `packages/`, `squads/`, `tests/` |
|
|
44
46
|
|
|
@@ -88,17 +90,9 @@ Use Grep (not grep), Read (not cat), Edit (not sed), Glob (not find). Prefer nat
|
|
|
88
90
|
- Agent memory in `.sinapse-ai/development/agents/{id}/MEMORY.md`
|
|
89
91
|
- **Memory as hints:** Memory entries are hints, NOT ground truth. Always verify against actual codebase before acting on remembered facts.
|
|
90
92
|
|
|
91
|
-
## Token Economy &
|
|
93
|
+
## Token Economy & Delegation (NON-NEGOTIABLE)
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
## Delegation & Anti-Hallucination
|
|
96
|
-
|
|
97
|
-
- Persona switch for sequential work, sub-agent only for parallel (20K+ tokens each)
|
|
98
|
-
- Model routing: `haiku` routine, `sonnet` standard, `opus` complex
|
|
99
|
-
- Sub-agents announce their model for visual verification via statusline
|
|
100
|
-
- `npm view {pkg}` before adding deps. Cite file:line for claims.
|
|
101
|
-
- Mark uncertain claims with [NEEDS VERIFICATION]. Compact at 60%.
|
|
95
|
+
Rules: `~/.claude/rules/token-economy.md` + `response-format.md`. Compact at 60%, no preamble/trailing summary. Model routing: `haiku` routine, `sonnet` standard, `opus` complex; sub-agents announce model via statusline (sub-agent only for parallel, 20K+ each). Persona switch for sequential work. `npm view {pkg}` before deps; cite file:line; mark uncertain with [NEEDS VERIFICATION].
|
|
102
96
|
|
|
103
97
|
---
|
|
104
98
|
*SINAPSE v6.0 — CLI First | Observability Second | UI Third*
|
package/.claude/hooks/README.md
CHANGED
|
@@ -10,7 +10,8 @@ UserPromptSubmit Hooks
|
|
|
10
10
|
|
|
11
11
|
PreToolUse Hooks
|
|
12
12
|
├── Read → read-protection.py
|
|
13
|
-
├── Write|Edit → enforce-
|
|
13
|
+
├── Write|Edit → enforce-framework-boundary.cjs
|
|
14
|
+
│ → enforce-architecture-first.cjs
|
|
14
15
|
│ → write-path-validation.cjs
|
|
15
16
|
│ → enforce-story-gate.cjs
|
|
16
17
|
│ → enforce-nsn-guard.cjs
|
|
@@ -110,6 +111,17 @@ Impede criação de mind clones sem DNA extraído previamente.
|
|
|
110
111
|
1. Execute o pipeline de extração de DNA: `/squad-creator` → `*collect-sources` → `*extract-voice-dna` → `*extract-thinking-dna`
|
|
111
112
|
2. OU se é agent funcional, renomeie com sufixo apropriado
|
|
112
113
|
|
|
114
|
+
### 7. enforce-framework-boundary.cjs
|
|
115
|
+
**Trigger:** `Write|Edit` (cobre `Write`, `Edit` e `NotebookEdit` internamente)
|
|
116
|
+
**Comportamento:** BLOQUEIA (exit 2)
|
|
117
|
+
|
|
118
|
+
Camada runtime de defesa em profundidade do boundary L1-L4. Lê o bloco `boundary` de `.sinapse-ai/core-config.yaml` dinamicamente a cada Write/Edit e bloqueia edições em paths protegidos (núcleo intocável L1/L2) quando `boundary.frameworkProtection: true`.
|
|
119
|
+
|
|
120
|
+
- **Exceptions vencem:** se o path casa um glob de `exceptions`, libera (exit 0) mesmo dentro de dir protegido.
|
|
121
|
+
- **Toggle respeitado:** com `frameworkProtection: false` (modo contribuidor), nunca bloqueia.
|
|
122
|
+
- **Fail-open total:** js-yaml ausente, config ausente/ilegível, YAML malformado, bloco `boundary` ausente, ou path ausente/escapando a raiz → exit 0.
|
|
123
|
+
- **Independente** do gerador estático `generate-settings-json.js` (que assa deny/allow no settings.json no install). Os dois leem a MESMA fonte (bloco `boundary`).
|
|
124
|
+
|
|
113
125
|
## Exit Codes
|
|
114
126
|
|
|
115
127
|
| Code | Significado |
|
|
@@ -157,6 +169,7 @@ Hooks são registrados em `.claude/settings.json` (framework, commitado) ou `.cl
|
|
|
157
169
|
| `synapse-engine.cjs` | `UserPromptSubmit` | — | SYNAPSE context engine |
|
|
158
170
|
| `code-intel-pretool.cjs` | `PreToolUse` | `Write\|Edit` | Code intelligence injection |
|
|
159
171
|
| `precompact-session-digest.cjs` | `PreCompact` | — | Session digest capture |
|
|
172
|
+
| `enforce-framework-boundary.cjs` | `PreToolUse` | `Write\|Edit` | Boundary L1-L4 (BLOCK quando `frameworkProtection=true`) |
|
|
160
173
|
|
|
161
174
|
### Exemplo de Configuração
|
|
162
175
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Code Intelligence Hook Entry Point — PreToolUse (Write|Edit)
|
|
6
|
+
*
|
|
7
|
+
* Thin wrapper that reads the PreToolUse JSON from stdin, resolves code
|
|
8
|
+
* intelligence for the file being written/edited, and injects an
|
|
9
|
+
* <code-intel-context> block as additionalContext so the model sees the
|
|
10
|
+
* existing entity, its references, and dependencies before editing.
|
|
11
|
+
*
|
|
12
|
+
* Design (mirrors synapse-engine.cjs + hook-governance.md):
|
|
13
|
+
* - Fail-open: any error or missing data → empty stdout, exit 0 (never blocks)
|
|
14
|
+
* - Fast: the runtime resolver targets < 500ms; 5s hard safety timeout here
|
|
15
|
+
* - Silent on no-data: empty stdout is a valid "no context"
|
|
16
|
+
*
|
|
17
|
+
* @module code-intel-pretool-hook
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const {
|
|
22
|
+
resolveCodeIntel,
|
|
23
|
+
formatAsXml,
|
|
24
|
+
} = require(
|
|
25
|
+
path.join(__dirname, '..', '..', '.sinapse-ai', 'core', 'code-intel', 'hook-runtime.js'),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** Safety timeout (ms) — defense-in-depth; Claude Code also manages hook timeout. */
|
|
29
|
+
const HOOK_TIMEOUT_MS = 5000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read all data from stdin as a JSON object.
|
|
33
|
+
* @returns {Promise<object>} Parsed JSON input
|
|
34
|
+
*/
|
|
35
|
+
function readStdin() {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let data = '';
|
|
38
|
+
process.stdin.setEncoding('utf8');
|
|
39
|
+
process.stdin.on('error', (e) => reject(e));
|
|
40
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
41
|
+
process.stdin.on('end', () => {
|
|
42
|
+
try { resolve(JSON.parse(data)); }
|
|
43
|
+
catch (e) { reject(e); }
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write to stdout robustly across real and mocked (Jest) streams. */
|
|
49
|
+
function writeStdout(output) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let settled = false;
|
|
52
|
+
const finish = (err) => {
|
|
53
|
+
if (settled) return;
|
|
54
|
+
settled = true;
|
|
55
|
+
if (err) reject(err); else resolve();
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const flushed = process.stdout.write(output, (err) => finish(err));
|
|
59
|
+
if (flushed) setImmediate(() => finish());
|
|
60
|
+
else if (typeof process.stdout.once === 'function') process.stdout.once('drain', () => finish());
|
|
61
|
+
} catch (err) {
|
|
62
|
+
finish(err);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Main hook execution pipeline. */
|
|
68
|
+
async function main() {
|
|
69
|
+
const input = await readStdin();
|
|
70
|
+
|
|
71
|
+
// PreToolUse payload: the target path lives in tool_input.file_path
|
|
72
|
+
// (Write/Edit). Bail silently if absent — nothing to enrich.
|
|
73
|
+
const toolInput = input && input.tool_input ? input.tool_input : {};
|
|
74
|
+
const filePath = toolInput.file_path || toolInput.path || '';
|
|
75
|
+
const cwd = input.cwd || process.cwd();
|
|
76
|
+
if (!filePath) return;
|
|
77
|
+
|
|
78
|
+
const intel = await resolveCodeIntel(filePath, cwd);
|
|
79
|
+
if (!intel) return;
|
|
80
|
+
|
|
81
|
+
const xml = formatAsXml(intel, filePath);
|
|
82
|
+
// Empty/insufficient intel → formatAsXml returns null → valid "no context".
|
|
83
|
+
if (!xml) return;
|
|
84
|
+
|
|
85
|
+
const output = JSON.stringify({
|
|
86
|
+
hookSpecificOutput: {
|
|
87
|
+
hookEventName: 'PreToolUse',
|
|
88
|
+
additionalContext: xml,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
await writeStdout(output);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Entry point runner — lets Node exit naturally after stdout flush. */
|
|
95
|
+
function run() {
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
// Enforce the hard limit even if stdout backpressure leaves handles open.
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}, HOOK_TIMEOUT_MS);
|
|
100
|
+
timer.unref();
|
|
101
|
+
main()
|
|
102
|
+
.then(() => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
process.exitCode = 0;
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
// Silent exit — stderr would surface as a "hook error" in the Claude Code UI.
|
|
109
|
+
process.exitCode = 0;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (require.main === module) run();
|
|
114
|
+
|
|
115
|
+
module.exports = { readStdin, main, run, HOOK_TIMEOUT_MS };
|
|
@@ -11,7 +11,23 @@
|
|
|
11
11
|
* exit 0 → allow
|
|
12
12
|
* exit 2 → block (message shown to model via stderr)
|
|
13
13
|
*
|
|
14
|
-
* Fail-open: if
|
|
14
|
+
* Fail-open: if the active agent is unknown, allow (Hook Design Principle #1).
|
|
15
|
+
*
|
|
16
|
+
* Active-agent signal (first match wins):
|
|
17
|
+
* 1. process.env.SINAPSE_ACTIVE_AGENT — explicit signal set by the
|
|
18
|
+
* orchestration layer when it activates/spawns an agent. This is the
|
|
19
|
+
* reliable channel; the session-state file is a secondary fallback.
|
|
20
|
+
* 2. .sinapse/session-state.json → { lastAgent } — written by the session
|
|
21
|
+
* lifecycle when available.
|
|
22
|
+
* Neither present → unknown agent → fail-open (allow).
|
|
23
|
+
*
|
|
24
|
+
* NOTE (audit 2026-06-11, Article VIII): the AUTONOMOUS path now populates the
|
|
25
|
+
* signal — the SubagentDispatcher sets SINAPSE_ACTIVE_AGENT in the env of every
|
|
26
|
+
* agent it spawns, so this hook enforces correctly when the engine spawns an
|
|
27
|
+
* orchestrator. The INTERACTIVE path (a human typing `@some-orqx` in chat) is
|
|
28
|
+
* intentionally NOT wired here: enforcing it would block a solo operator's own
|
|
29
|
+
* edits, so it stays opt-in (set SINAPSE_ACTIVE_AGENT yourself, or add a
|
|
30
|
+
* prompt-parsing writer) rather than changing chat behavior by default.
|
|
15
31
|
*
|
|
16
32
|
* Exception: sinapse-orqx is allowed Write/Edit in .sinapse-ai/ paths
|
|
17
33
|
* (framework governance — operates above the story layer).
|
|
@@ -56,11 +72,23 @@ function relativize(filePath, root) {
|
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
/**
|
|
59
|
-
*
|
|
60
|
-
* Returns the agent ID string or null if unknown.
|
|
75
|
+
* Resolve the active agent id. Explicit env signal wins; session-state file is
|
|
76
|
+
* the fallback. Returns the agent ID string or null if unknown (fail-open).
|
|
77
|
+
* @param {string} root - Project root
|
|
78
|
+
* @returns {string|null}
|
|
61
79
|
*/
|
|
62
80
|
function getActiveAgent(root) {
|
|
81
|
+
// 1. Explicit, reliable signal from the orchestration layer.
|
|
82
|
+
const envAgent = (process.env.SINAPSE_ACTIVE_AGENT || '').trim();
|
|
83
|
+
if (envAgent) return envAgent;
|
|
84
|
+
|
|
85
|
+
// 2. Session-state file fallback. Validate the resolved path stays within the
|
|
86
|
+
// project (defense-in-depth against a manipulated root — P2-003).
|
|
63
87
|
const sessionStatePath = path.join(root, '.sinapse', 'session-state.json');
|
|
88
|
+
const resolvedRoot = path.resolve(root);
|
|
89
|
+
if (!path.resolve(sessionStatePath).startsWith(resolvedRoot)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
64
92
|
try {
|
|
65
93
|
const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
|
|
66
94
|
return state.lastAgent || null;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook: Enforce Framework Boundary — PreToolUse Write|Edit guard
|
|
6
|
+
*
|
|
7
|
+
* RULE: When boundary.frameworkProtection is true in core-config.yaml, Write/Edit
|
|
8
|
+
* operations on L1/L2 framework paths ('protected' globs) are BLOCKED —
|
|
9
|
+
* UNLESS the path also matches an 'exceptions' glob (exceptions WIN).
|
|
10
|
+
*
|
|
11
|
+
* This is the RUNTIME defense-in-depth layer for the framework boundary. The
|
|
12
|
+
* static deny-rule generator (.sinapse-ai/infrastructure/scripts/generate-settings-json.js)
|
|
13
|
+
* bakes deny/allow into .claude/settings.json at install time; this hook reads
|
|
14
|
+
* the boundary config DYNAMICALLY at every Write/Edit, so it stays correct even
|
|
15
|
+
* if settings.json was never regenerated. The two layers are independent and
|
|
16
|
+
* read the SAME single source of truth (boundary block in core-config.yaml).
|
|
17
|
+
*
|
|
18
|
+
* Protocol (Claude Code PreToolUse):
|
|
19
|
+
* exit 0 → allow (operation proceeds; silent on allow)
|
|
20
|
+
* exit 2 → block (message shown to model via stderr)
|
|
21
|
+
*
|
|
22
|
+
* Fail-open policy (per hook-governance.md principle #1):
|
|
23
|
+
* - Only acts on Write / Edit / NotebookEdit; any other tool → exit 0.
|
|
24
|
+
* - Missing/unreadable core-config.yaml → exit 0.
|
|
25
|
+
* - Missing js-yaml dependency → exit 0.
|
|
26
|
+
* - Missing/invalid boundary block → exit 0.
|
|
27
|
+
* - frameworkProtection !== true → exit 0 (toggle respected).
|
|
28
|
+
* - No file_path → exit 0.
|
|
29
|
+
* - Any unexpected error → exit 0.
|
|
30
|
+
*
|
|
31
|
+
* Design constraints: < 5s, deterministic, no side effects (read-only).
|
|
32
|
+
*
|
|
33
|
+
* @module enforce-framework-boundary
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
const CORE_CONFIG_FILE = path.join('.sinapse-ai', 'core-config.yaml');
|
|
40
|
+
const WRITE_TOOLS = ['Write', 'Edit', 'NotebookEdit'];
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Project root resolution
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function projectRoot() {
|
|
47
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Boundary config loader (js-yaml in try/catch; fail-open if absent)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the boundary block from core-config.yaml.
|
|
56
|
+
* Returns null on any failure (missing file, missing js-yaml, parse error,
|
|
57
|
+
* absent boundary section) so the caller can fail-open.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} root project root
|
|
60
|
+
* @returns {{ frameworkProtection: boolean, protected: string[], exceptions: string[] } | null}
|
|
61
|
+
*/
|
|
62
|
+
function loadBoundary(root) {
|
|
63
|
+
let yaml;
|
|
64
|
+
try {
|
|
65
|
+
// js-yaml is a repo dependency; in installed user projects it resolves via
|
|
66
|
+
// the sinapse-ai node_modules. If unavailable for any reason → fail-open.
|
|
67
|
+
yaml = require('js-yaml');
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const configPath = path.join(root, CORE_CONFIG_FILE);
|
|
73
|
+
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
// Cap size before reading/parsing (P3-003): a pathological core-config.yaml
|
|
77
|
+
// (e.g. 10k nested keys) shouldn't be able to stall this PreToolUse hook.
|
|
78
|
+
const stat = fs.statSync(configPath);
|
|
79
|
+
if (stat.size > 1024 * 1024) {
|
|
80
|
+
return null; // > 1 MB → treat as unreadable rather than risk a parse DoS
|
|
81
|
+
}
|
|
82
|
+
content = fs.readFileSync(configPath, 'utf8');
|
|
83
|
+
} catch {
|
|
84
|
+
return null; // missing or unreadable
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let config;
|
|
88
|
+
try {
|
|
89
|
+
config = yaml.load(content);
|
|
90
|
+
} catch {
|
|
91
|
+
return null; // malformed YAML
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!config || typeof config !== 'object' || !config.boundary) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const b = config.boundary;
|
|
99
|
+
return {
|
|
100
|
+
// Strictly require === true; anything else (false, undefined, "true"
|
|
101
|
+
// string, etc.) is treated as "not protected" → fail-open at the caller.
|
|
102
|
+
frameworkProtection: b.frameworkProtection === true,
|
|
103
|
+
protected: Array.isArray(b.protected) ? b.protected : [],
|
|
104
|
+
exceptions: Array.isArray(b.exceptions) ? b.exceptions : [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Path normalization (absolute → relative-to-root, POSIX, traversal-safe)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalize a tool file_path into a clean project-relative POSIX path.
|
|
114
|
+
*
|
|
115
|
+
* Steps:
|
|
116
|
+
* - If absolute, make it relative to the project root.
|
|
117
|
+
* - Convert backslashes to forward slashes.
|
|
118
|
+
* - Collapse a leading "./" and any leading "/".
|
|
119
|
+
* - Reject traversal: if the resolved path escapes the root (leading "..")
|
|
120
|
+
* we return null (caller fails-open — we don't block paths we can't
|
|
121
|
+
* confidently place inside the project).
|
|
122
|
+
*
|
|
123
|
+
* @param {string} filePath raw file_path from tool_input
|
|
124
|
+
* @param {string} root project root
|
|
125
|
+
* @returns {string | null} clean relative POSIX path, or null if unresolvable/escaping
|
|
126
|
+
*/
|
|
127
|
+
function toRelativePosix(filePath, root) {
|
|
128
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
129
|
+
|
|
130
|
+
let rel;
|
|
131
|
+
if (path.isAbsolute(filePath)) {
|
|
132
|
+
rel = path.relative(root, filePath);
|
|
133
|
+
} else {
|
|
134
|
+
rel = filePath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Normalize OS separators and collapse "." / ".." segments deterministically.
|
|
138
|
+
rel = path.normalize(rel).replace(/\\/g, '/');
|
|
139
|
+
|
|
140
|
+
// Strip leading "./"
|
|
141
|
+
rel = rel.replace(/^\.\//, '');
|
|
142
|
+
// Strip leading slash (defensive)
|
|
143
|
+
rel = rel.replace(/^\/+/, '');
|
|
144
|
+
|
|
145
|
+
// Traversal: a normalized relative path that still starts with ".." escapes
|
|
146
|
+
// the project root. We cannot confidently classify it → signal fail-open.
|
|
147
|
+
if (rel === '..' || rel.startsWith('../')) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return rel;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Glob matcher (** and *) — own implementation, NO minimatch
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Convert a glob (supporting ** and *) into an anchored RegExp.
|
|
160
|
+
*
|
|
161
|
+
* Semantics (matching generate-settings-json + Claude permission globs):
|
|
162
|
+
* ** → matches any characters across any depth, INCLUDING path separators
|
|
163
|
+
* (and zero characters).
|
|
164
|
+
* * → matches any characters within a single path segment (no "/").
|
|
165
|
+
* All other regex-special characters are escaped literally.
|
|
166
|
+
*
|
|
167
|
+
* Examples:
|
|
168
|
+
* ".sinapse-ai/core/**" matches ".sinapse-ai/core/a/b.js"
|
|
169
|
+
* ".sinapse-ai/development/agents/<seg>/MEMORY.md"
|
|
170
|
+
* matches ".sinapse-ai/development/agents/dev/MEMORY.md"
|
|
171
|
+
* but NOT ".sinapse-ai/development/agents/a/b/MEMORY.md"
|
|
172
|
+
* ".sinapse-ai/constitution.md" matches itself exactly.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} glob
|
|
175
|
+
* @returns {RegExp}
|
|
176
|
+
*/
|
|
177
|
+
function globToRegExp(glob) {
|
|
178
|
+
let re = '';
|
|
179
|
+
for (let i = 0; i < glob.length; i++) {
|
|
180
|
+
const c = glob[i];
|
|
181
|
+
if (c === '*') {
|
|
182
|
+
if (glob[i + 1] === '*') {
|
|
183
|
+
// "**" → any chars, any depth (including separators), zero or more.
|
|
184
|
+
re += '.*';
|
|
185
|
+
i++; // consume the second '*'
|
|
186
|
+
// Swallow an immediate "/" after "**" so that "a/**/b" also matches
|
|
187
|
+
// "a/b" (zero intermediate segments).
|
|
188
|
+
if (glob[i + 1] === '/') {
|
|
189
|
+
re += '(?:/)?';
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
// single "*" → any chars except path separator
|
|
194
|
+
re += '[^/]*';
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
// Escape regex-special characters
|
|
198
|
+
re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return new RegExp('^' + re + '$');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Does a normalized relative path match ANY glob in the list?
|
|
206
|
+
*
|
|
207
|
+
* @param {string} relPath project-relative POSIX path
|
|
208
|
+
* @param {string[]} globs
|
|
209
|
+
* @returns {string | null} the first matching glob, or null
|
|
210
|
+
*/
|
|
211
|
+
function firstMatch(relPath, globs) {
|
|
212
|
+
for (const g of globs) {
|
|
213
|
+
if (typeof g !== 'string' || g.length === 0) continue;
|
|
214
|
+
try {
|
|
215
|
+
if (globToRegExp(g).test(relPath)) return g;
|
|
216
|
+
} catch {
|
|
217
|
+
// A malformed glob never blocks — skip it (fail-open per-glob).
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Main
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function main() {
|
|
228
|
+
// Parse stdin — fail-open on any parse error
|
|
229
|
+
let input;
|
|
230
|
+
try {
|
|
231
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
232
|
+
} catch {
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const toolName = (input && input.tool_name ? String(input.tool_name) : '').trim();
|
|
237
|
+
const toolInput = (input && input.tool_input) || {};
|
|
238
|
+
|
|
239
|
+
// Only enforce on write-class tools. Everything else → allow.
|
|
240
|
+
if (!WRITE_TOOLS.includes(toolName)) {
|
|
241
|
+
process.exit(0);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const root = projectRoot();
|
|
245
|
+
|
|
246
|
+
// Load boundary — fail-open if anything is off.
|
|
247
|
+
let boundary;
|
|
248
|
+
try {
|
|
249
|
+
boundary = loadBoundary(root);
|
|
250
|
+
} catch {
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
if (!boundary) {
|
|
254
|
+
process.exit(0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Toggle respected: only enforce when explicitly enabled.
|
|
258
|
+
if (boundary.frameworkProtection !== true) {
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (boundary.protected.length === 0) {
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Resolve the target file path.
|
|
267
|
+
// NotebookEdit uses notebook_path; Write/Edit use file_path.
|
|
268
|
+
const rawPath = toolInput.file_path || toolInput.notebook_path;
|
|
269
|
+
|
|
270
|
+
let relPath;
|
|
271
|
+
try {
|
|
272
|
+
relPath = toRelativePosix(rawPath, root);
|
|
273
|
+
} catch {
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
if (!relPath) {
|
|
277
|
+
// No path or unresolvable/escaping path → fail-open.
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Exceptions WIN: if the path matches any exception, allow.
|
|
282
|
+
let exceptionHit = null;
|
|
283
|
+
try {
|
|
284
|
+
exceptionHit = firstMatch(relPath, boundary.exceptions);
|
|
285
|
+
} catch {
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
if (exceptionHit) {
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Protected check.
|
|
293
|
+
let protectedHit = null;
|
|
294
|
+
try {
|
|
295
|
+
protectedHit = firstMatch(relPath, boundary.protected);
|
|
296
|
+
} catch {
|
|
297
|
+
process.exit(0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!protectedHit) {
|
|
301
|
+
// Not a protected path → allow.
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// BLOCK — exit 2 with a clear message.
|
|
306
|
+
process.stderr.write(
|
|
307
|
+
'\nFRAMEWORK-BOUNDARY BLOCK [L1/L2 protected]\n' +
|
|
308
|
+
`Tool: ${toolName}\n` +
|
|
309
|
+
`File: ${relPath}\n` +
|
|
310
|
+
`Matched protected rule: ${protectedHit}\n` +
|
|
311
|
+
'\n' +
|
|
312
|
+
'This path is a protected framework artifact (Constitution Art. II —\n' +
|
|
313
|
+
'Agent Authority / L1-L2 boundary). Direct edits are blocked while\n' +
|
|
314
|
+
'boundary.frameworkProtection is enabled.\n' +
|
|
315
|
+
'\n' +
|
|
316
|
+
'To change it:\n' +
|
|
317
|
+
' - Edit it through a story (the proper SDC flow), OR\n' +
|
|
318
|
+
' - If you are a framework contributor, set\n' +
|
|
319
|
+
' boundary.frameworkProtection: false in .sinapse-ai/core-config.yaml.\n',
|
|
320
|
+
);
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
main();
|