sinapse-ai 1.6.1 → 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.
Files changed (131) hide show
  1. package/.claude/CLAUDE.md +5 -11
  2. package/.claude/hooks/README.md +14 -1
  3. package/.claude/hooks/code-intel-pretool.cjs +115 -0
  4. package/.claude/hooks/enforce-delegation.cjs +31 -3
  5. package/.claude/hooks/enforce-framework-boundary.cjs +324 -0
  6. package/.claude/hooks/enforce-permission-mode.cjs +249 -0
  7. package/.claude/hooks/secret-scanning.cjs +34 -43
  8. package/.claude/hooks/synapse-engine.cjs +23 -23
  9. package/.claude/hooks/telemetry-post-tool.cjs +128 -0
  10. package/.claude/hooks/telemetry-stop.cjs +132 -0
  11. package/.claude/hooks/verify-packages.cjs +9 -2
  12. package/.claude/rules/documentation-first.md +1 -1
  13. package/.claude/rules/hook-governance.md +2 -0
  14. package/.sinapse-ai/cli/commands/health/index.js +24 -0
  15. package/.sinapse-ai/core/README.md +11 -0
  16. package/.sinapse-ai/core/config/config-loader.js +19 -0
  17. package/.sinapse-ai/core/config/merge-utils.js +8 -0
  18. package/.sinapse-ai/core/errors/constants.js +147 -0
  19. package/.sinapse-ai/core/errors/error-registry.js +176 -0
  20. package/.sinapse-ai/core/errors/index.js +50 -0
  21. package/.sinapse-ai/core/errors/serializer.js +147 -0
  22. package/.sinapse-ai/core/errors/sinapse-error.js +144 -0
  23. package/.sinapse-ai/core/errors/utils.js +187 -0
  24. package/.sinapse-ai/core/execution/build-orchestrator.js +47 -49
  25. package/.sinapse-ai/core/execution/build-state-manager.js +183 -31
  26. package/.sinapse-ai/core/execution/parallel-executor.js +7 -1
  27. package/.sinapse-ai/core/execution/semantic-merge-engine.js +26 -14
  28. package/.sinapse-ai/core/execution/subagent-dispatcher.js +201 -60
  29. package/.sinapse-ai/core/execution/wave-executor.js +4 -1
  30. package/.sinapse-ai/core/grounding/README.md +71 -11
  31. package/.sinapse-ai/core/health-check/checks/project/framework-config.js +38 -2
  32. package/.sinapse-ai/core/health-check/checks/project/package-json.js +47 -3
  33. package/.sinapse-ai/core/health-check/checks/services/gemini-cli.js +117 -0
  34. package/.sinapse-ai/core/health-check/checks/services/index.js +2 -0
  35. package/.sinapse-ai/core/health-check/healers/index.js +40 -3
  36. package/.sinapse-ai/core/ideation/ideation-engine.js +212 -107
  37. package/.sinapse-ai/core/ids/gate-evaluator.js +318 -0
  38. package/.sinapse-ai/core/ids/gates/g5-semantic-handshake.js +190 -0
  39. package/.sinapse-ai/core/ids/gates/g6-ci-integrity.js +162 -0
  40. package/.sinapse-ai/core/ids/index.js +30 -0
  41. package/.sinapse-ai/core/memory/__tests__/active-modules.verify.js +11 -0
  42. package/.sinapse-ai/core/memory/gotchas-memory.js +37 -2
  43. package/.sinapse-ai/core/orchestration/agent-invoker.js +29 -6
  44. package/.sinapse-ai/core/orchestration/brownfield-handler.js +36 -3
  45. package/.sinapse-ai/core/orchestration/condition-evaluator.js +57 -0
  46. package/.sinapse-ai/core/orchestration/executors/epic-3-executor.js +76 -5
  47. package/.sinapse-ai/core/orchestration/executors/epic-4-executor.js +63 -17
  48. package/.sinapse-ai/core/orchestration/executors/epic-6-executor.js +153 -41
  49. package/.sinapse-ai/core/orchestration/executors/epic-executor.js +40 -0
  50. package/.sinapse-ai/core/orchestration/greenfield-handler.js +87 -3
  51. package/.sinapse-ai/core/orchestration/master-orchestrator.js +150 -10
  52. package/.sinapse-ai/core/orchestration/parallel-executor.js +6 -1
  53. package/.sinapse-ai/core/orchestration/recovery-handler.js +81 -8
  54. package/.sinapse-ai/core/orchestration/workflow-executor.js +41 -0
  55. package/.sinapse-ai/core/registry/registry-loader.js +71 -5
  56. package/.sinapse-ai/core/registry/squad-agent-resolver.js +253 -0
  57. package/.sinapse-ai/core/synapse/context/context-tracker.js +104 -9
  58. package/.sinapse-ai/core/synapse/context/index.js +19 -0
  59. package/.sinapse-ai/core/synapse/context/semantic-handshake-engine.js +555 -0
  60. package/.sinapse-ai/core/synapse/diagnostics/collectors/pipeline-collector.js +4 -2
  61. package/.sinapse-ai/core/synapse/engine.js +43 -3
  62. package/.sinapse-ai/core/telemetry/ids-sink.js +188 -0
  63. package/.sinapse-ai/core/utils/output-formatter.js +8 -290
  64. package/.sinapse-ai/core/utils/spawn-safe.js +186 -0
  65. package/.sinapse-ai/core-config.yaml +68 -1
  66. package/.sinapse-ai/data/entity-registry.yaml +15082 -13618
  67. package/.sinapse-ai/data/registry-update-log.jsonl +143 -0
  68. package/.sinapse-ai/development/agents/developer.md +2 -0
  69. package/.sinapse-ai/development/agents/devops.md +9 -0
  70. package/.sinapse-ai/development/external-executors/README.md +18 -0
  71. package/.sinapse-ai/development/external-executors/codex.md +56 -0
  72. package/.sinapse-ai/development/scripts/populate-entity-registry.js +65 -9
  73. package/.sinapse-ai/development/scripts/squad/squad-downloader.js +169 -14
  74. package/.sinapse-ai/development/tasks/delegate-to-external-executor.md +152 -0
  75. package/.sinapse-ai/development/tasks/github-devops-pre-push-quality-gate.md +46 -29
  76. package/.sinapse-ai/development/tasks/update-sinapse.md +3 -3
  77. package/.sinapse-ai/hooks/sinapse-brand-grounding.cjs +4 -7
  78. package/.sinapse-ai/hooks/sinapse-ds-grounding.cjs +5 -8
  79. package/.sinapse-ai/hooks/sinapse-vault-grounding.cjs +6 -9
  80. package/.sinapse-ai/infrastructure/integrations/ai-providers/ai-provider-factory.js +4 -1
  81. package/.sinapse-ai/infrastructure/integrations/ai-providers/claude-provider.js +57 -55
  82. package/.sinapse-ai/infrastructure/integrations/pm-adapters/github-adapter.js +9 -7
  83. package/.sinapse-ai/infrastructure/scripts/ide-sync/gemini-commands.js +298 -0
  84. package/.sinapse-ai/infrastructure/scripts/ide-sync/index.js +127 -6
  85. package/.sinapse-ai/infrastructure/scripts/ide-sync/persona-renderer.js +97 -0
  86. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/antigravity.js +121 -0
  87. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/cursor.js +119 -0
  88. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/github-copilot.js +191 -0
  89. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/kimi.js +448 -0
  90. package/.sinapse-ai/install-manifest.yaml +218 -114
  91. package/.sinapse-ai/product/templates/engine/renderer.js +20 -1
  92. package/.sinapse-ai/scripts/pm.sh +18 -6
  93. package/bin/cli.js +17 -0
  94. package/bin/commands/agents.js +96 -0
  95. package/bin/commands/doctor.js +15 -0
  96. package/bin/commands/ideate.js +129 -0
  97. package/bin/commands/uninstall.js +40 -0
  98. package/bin/postinstall.js +50 -4
  99. package/bin/sinapse.js +146 -2
  100. package/bin/utils/secret-scanner-core.js +253 -0
  101. package/bin/utils/staged-secret-scan.js +106 -40
  102. package/docs/framework/collaboration-autonomy-plan.md +18 -18
  103. package/docs/guides/parallel-workflow.md +6 -6
  104. package/package.json +22 -5
  105. package/packages/installer/src/installer/git-hooks-installer.js +384 -0
  106. package/packages/installer/src/installer/sinapse-ai-installer.js +16 -0
  107. package/packages/installer/src/wizard/ide-config-generator.js +23 -0
  108. package/packages/installer/src/wizard/validators.js +38 -1
  109. package/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js +5 -1
  110. package/packages/installer/tests/unit/doctor/doctor-checks.test.js +44 -22
  111. package/packages/installer/tests/unit/git-hooks-installer.test.js +262 -0
  112. package/scripts/eval-runner.js +422 -0
  113. package/scripts/generate-install-manifest.js +13 -9
  114. package/scripts/generate-synapse-runtime.js +51 -0
  115. package/scripts/regenerate-orqx-stubs.ps1 +6 -5
  116. package/scripts/validate-all.js +1 -0
  117. package/scripts/validate-evals.js +466 -0
  118. package/scripts/validate-schemas.js +539 -0
  119. package/scripts/validate-squad-orqx.js +9 -2
  120. package/squads/claude-code-mastery/knowledge-base/memory-systems-reference.md +1 -1
  121. package/squads/squad-brand/templates/client-delivery-template.md +1 -1
  122. package/squads/squad-content/knowledge-base/social-compression-framework.md +1 -1
  123. package/squads/squad-council/knowledge-base/brand-strategy-models.md +1 -1
  124. package/.sinapse-ai/development/scripts/elicitation-engine.js +0 -385
  125. package/.sinapse-ai/development/scripts/elicitation-session-manager.js +0 -300
  126. package/.sinapse-ai/development/tasks/test-validation-task.md +0 -172
  127. package/docs/chrome-brain-upgrade-plan.md +0 -624
  128. package/docs/constitution-compliance.md +0 -87
  129. package/docs/mega-upgrade-orchestration-plan.md +0 -71
  130. package/docs/research-synthesis-for-upgrade.md +0 -511
  131. package/docs/security-audit-report.md +0 -306
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 & Response Format (NON-NEGOTIABLE)
93
+ ## Token Economy & Delegation (NON-NEGOTIABLE)
92
94
 
93
- Auto-applied to all agents: `~/.claude/rules/token-economy.md` + `~/.claude/rules/response-format.md`. Compact at 60%, model route haiku/sonnet/opus, no preamble, no trailing summary.
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*
@@ -10,7 +10,8 @@ UserPromptSubmit Hooks
10
10
 
11
11
  PreToolUse Hooks
12
12
  ├── Read → read-protection.py
13
- ├── Write|Edit → enforce-architecture-first.cjs
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 session state is unreadable or agent is unknown, allow.
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
- * Read the active agent from session state.
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();