sinapse-ai 1.20.0 → 1.21.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 (80) hide show
  1. package/.sinapse-ai/core/atlas/render-html.js +10 -8
  2. package/.sinapse-ai/core/orchestration/brownfield-progress.js +219 -0
  3. package/.sinapse-ai/core/orchestration/doc-first-resolver.js +3 -0
  4. package/.sinapse-ai/core/orchestration/greenfield-handler.js +97 -0
  5. package/.sinapse-ai/core/orchestration/workflow-executor.js +17 -1
  6. package/.sinapse-ai/core/registry/service-registry.json +2 -32
  7. package/.sinapse-ai/data/entity-registry.yaml +473 -203
  8. package/.sinapse-ai/development/agents/analyst.md +18 -34
  9. package/.sinapse-ai/development/agents/architect.md +18 -34
  10. package/.sinapse-ai/development/agents/data-engineer.md +15 -28
  11. package/.sinapse-ai/development/agents/developer.md +0 -6
  12. package/.sinapse-ai/development/agents/devops.md +15 -28
  13. package/.sinapse-ai/development/agents/product-lead.md +18 -34
  14. package/.sinapse-ai/development/agents/project-lead.md +18 -34
  15. package/.sinapse-ai/development/agents/quality-gate.md +18 -34
  16. package/.sinapse-ai/development/agents/squad-creator.md +15 -27
  17. package/.sinapse-ai/development/agents/ux-design-expert.md +15 -27
  18. package/.sinapse-ai/development/scripts/populate-entity-registry.js +3 -0
  19. package/.sinapse-ai/development/tasks/execute-epic-plan.md +3 -4
  20. package/.sinapse-ai/development/workflows/brownfield-discovery.yaml +16 -0
  21. package/.sinapse-ai/development/workflows/epic-orchestration.yaml +11 -1
  22. package/.sinapse-ai/git-hooks/lib/staged-secret-scan.js +3 -1
  23. package/.sinapse-ai/install-manifest.yaml +52 -80
  24. package/.sinapse-ai/product/templates/personalized-agent-template.md +3 -5
  25. package/.sinapse-ai/workflow-intelligence/index.js +3 -21
  26. package/AGENTS.md +2 -2
  27. package/CHANGELOG.md +56 -22
  28. package/bin/commands/install.js +5 -4
  29. package/bin/lib/command-generator.js +4 -4
  30. package/bin/lib/fs-utils.js +18 -0
  31. package/bin/lib/setup-statusline.js +3 -1
  32. package/bin/modules/chrome-brain-installer.js +5 -3
  33. package/bin/modules/mcp-installer.js +3 -6
  34. package/bin/postinstall.js +2 -1
  35. package/bin/sinapse-init.js +8 -9
  36. package/bin/sinapse.js +3 -1
  37. package/bin/utils/staged-secret-scan.js +3 -1
  38. package/docs/framework/atlas/OPERATING-ATLAS.md +1 -1
  39. package/docs/framework/atlas/atlas-data.json +22 -22
  40. package/docs/framework/atlas/atlas.html +12 -10
  41. package/docs/framework/source-tree.md +1 -2
  42. package/package.json +3 -1
  43. package/packages/installer/src/config/configure-environment.js +8 -6
  44. package/packages/installer/src/installer/brownfield-upgrader.js +5 -3
  45. package/packages/installer/src/installer/file-hasher.js +29 -21
  46. package/packages/installer/src/installer/manifest-signature.js +61 -49
  47. package/packages/installer/src/installer/post-install-validator.js +24 -17
  48. package/packages/installer/src/merger/parsers/markdown-section-parser.js +3 -2
  49. package/packages/installer/src/updater/index.js +7 -1
  50. package/packages/installer/src/utils/atomic-write.js +35 -0
  51. package/packages/installer/src/wizard/index.js +5 -3
  52. package/packages/sinapse-install/src/capabilities/chrome-brain.js +5 -3
  53. package/packages/sinapse-install/src/installer.js +3 -1
  54. package/packages/sinapse-install/src/utils/atomic-write.js +67 -0
  55. package/scripts/apply-persona-disclaimer.js +2 -1
  56. package/scripts/eval-e2e.js +149 -0
  57. package/scripts/sinapse-patch.js +2 -1
  58. package/scripts/sync-squad-yaml-components.js +2 -1
  59. package/scripts/validate-agents-md.js +2 -1
  60. package/scripts/validate-all.js +3 -0
  61. package/scripts/validate-article-iv.js +289 -0
  62. package/scripts/validate-article-xi.js +10 -8
  63. package/scripts/validate-evals.js +9 -2
  64. package/scripts/validate-no-external-refs.js +14 -8
  65. package/scripts/wave-gate.js +207 -0
  66. package/squads/claude-code-mastery/agents/claude-mastery-chief.md +3 -8
  67. package/squads/claude-code-mastery/agents/config-engineer.md +5 -12
  68. package/squads/claude-code-mastery/agents/hooks-architect.md +5 -11
  69. package/squads/claude-code-mastery/agents/mcp-integrator.md +5 -12
  70. package/squads/claude-code-mastery/agents/project-integrator.md +5 -12
  71. package/squads/claude-code-mastery/agents/roadmap-sentinel.md +5 -12
  72. package/squads/claude-code-mastery/agents/skill-craftsman.md +5 -11
  73. package/squads/claude-code-mastery/agents/swarm-orqx.md +15 -28
  74. package/.sinapse-ai/core/execution/context-injector.js +0 -539
  75. package/.sinapse-ai/core/execution/parallel-executor.js +0 -301
  76. package/.sinapse-ai/core/execution/parallel-monitor.js +0 -432
  77. package/.sinapse-ai/core/execution/semantic-merge-engine.js +0 -1750
  78. package/.sinapse-ai/core/execution/wave-executor.js +0 -403
  79. package/.sinapse-ai/development/tasks/waves.md +0 -205
  80. package/.sinapse-ai/workflow-intelligence/engine/wave-analyzer.js +0 -692
@@ -752,8 +752,7 @@ outputs/ # Runtime outputs (gitignored)
752
752
  │ ├── engine/ # Core WIS engines
753
753
  │ │ ├── confidence-scorer.js # Pattern confidence scoring
754
754
  │ │ ├── output-formatter.js # Output formatting
755
- │ │ ├── suggestion-engine.js # Intelligent suggestions
756
- │ │ └── wave-analyzer.js # Wave pattern analysis
755
+ │ │ └── suggestion-engine.js # Intelligent suggestions
757
756
  │ ├── learning/ # Machine learning components
758
757
  │ │ ├── capture-hook.js # Pattern capture hooks
759
758
  │ │ ├── index.js # Learning module entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinapse-ai",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "SINAPSE AI: Framework de orquestracao de IA — 17 squads, 172 agentes especializados",
5
5
  "bin": {
6
6
  "sinapse": "bin/sinapse.js",
@@ -86,6 +86,7 @@
86
86
  "test:coverage": "jest --coverage",
87
87
  "test:health-check": "jest tests/health-check",
88
88
  "eval": "node scripts/eval-runner.js",
89
+ "eval:e2e": "node scripts/eval-e2e.js",
89
90
  "validate:evals": "node scripts/validate-evals.js",
90
91
  "validate:schemas": "node scripts/validate-schemas.js",
91
92
  "lint": "eslint . --cache --cache-location .eslintcache",
@@ -97,6 +98,7 @@
97
98
  "validate:article-vii": "node scripts/validate-article-vii.js",
98
99
  "validate:article-viii": "node scripts/validate-article-viii.js",
99
100
  "validate:article-xi": "node scripts/validate-article-xi.js",
101
+ "validate:article-iv": "node scripts/validate-article-iv.js",
100
102
  "reconcile:squads": "node scripts/reconcile-squad-manifests.js",
101
103
  "apply:persona-disclaimer": "node scripts/apply-persona-disclaimer.js",
102
104
  "validate:manifest": "node scripts/validate-manifest.js",
@@ -23,6 +23,7 @@ const {
23
23
  sanitizeInput,
24
24
  } = require('./validation/config-validator');
25
25
  const { getMergeStrategy, hasMergeStrategy } = require('../merger/index.js');
26
+ const { atomicWriteFileSync } = require('../utils/atomic-write');
26
27
 
27
28
  /**
28
29
  * Configure environment files (.env and core-config.yaml)
@@ -110,7 +111,8 @@ async function configureEnvironment(options = {}) {
110
111
  const merger = getMergeStrategy(envPath);
111
112
  const mergeResult = await merger.merge(existingContent, envContent);
112
113
 
113
- await fs.writeFile(envPath, mergeResult.content, { encoding: 'utf8' });
114
+ // Atomic (tmp+rename): .env carries user secrets — never leave it torn
115
+ atomicWriteFileSync(envPath, mergeResult.content, 'utf8');
114
116
  results.envCreated = true;
115
117
 
116
118
  console.log('✅ Merged .env file');
@@ -119,8 +121,8 @@ async function configureEnvironment(options = {}) {
119
121
  console.log(` ⚠️ Suggestions: ${mergeResult.stats.conflicts} (see comments in file)`);
120
122
  }
121
123
  } else {
122
- // Create new or overwrite
123
- await fs.writeFile(envPath, envContent, { encoding: 'utf8' });
124
+ // Create new or overwrite — atomic (tmp+rename)
125
+ atomicWriteFileSync(envPath, envContent, 'utf8');
124
126
  results.envCreated = true;
125
127
  console.log('✅ Created .env file');
126
128
  }
@@ -133,7 +135,7 @@ async function configureEnvironment(options = {}) {
133
135
  // Step 4: Generate and write .env.example
134
136
  const envExamplePath = path.join(targetDir, '.env.example');
135
137
  const envExampleContent = generateEnvExample();
136
- await fs.writeFile(envExamplePath, envExampleContent, { encoding: 'utf8' });
138
+ atomicWriteFileSync(envExamplePath, envExampleContent, 'utf8');
137
139
  results.envExampleCreated = true;
138
140
  console.log('✅ Created .env.example file');
139
141
 
@@ -187,7 +189,7 @@ async function configureEnvironment(options = {}) {
187
189
  throw new Error('Generated core-config.yaml has invalid structure');
188
190
  }
189
191
 
190
- await fs.writeFile(coreConfigPath, coreConfigContent, { encoding: 'utf8' });
192
+ atomicWriteFileSync(coreConfigPath, coreConfigContent, 'utf8');
191
193
  results.coreConfigCreated = true;
192
194
  console.log('✅ Created .sinapse-ai/core-config.yaml');
193
195
 
@@ -359,7 +361,7 @@ async function updateGitignore(targetDir) {
359
361
 
360
362
  newContent += '\n';
361
363
 
362
- await fs.writeFile(gitignorePath, newContent, { encoding: 'utf8' });
364
+ atomicWriteFileSync(gitignorePath, newContent, 'utf8');
363
365
  }
364
366
  }
365
367
 
@@ -12,6 +12,7 @@ const yaml = require('js-yaml');
12
12
  const semver = require('semver');
13
13
  const { hashFile, hashesMatch } = require('./file-hasher');
14
14
  const { YamlMerger } = require('../merger/strategies/yaml-merger.js');
15
+ const { atomicWriteFileSync } = require('../utils/atomic-write');
15
16
 
16
17
  /**
17
18
  * Upgrade report structure
@@ -278,8 +279,8 @@ async function applyUpgrade(report, sourceDir, targetDir, options = {}) {
278
279
  const merger = new YamlMerger();
279
280
  const mergeResult = await merger.merge(sourceContent, targetContent);
280
281
 
281
- // Write merged content
282
- fs.writeFileSync(targetPath, mergeResult.content, 'utf8');
282
+ // Write merged content — atomic (tmp+rename); backup taken above
283
+ atomicWriteFileSync(targetPath, mergeResult.content, 'utf8');
283
284
 
284
285
  // Log conflict warnings
285
286
  const conflicts = mergeResult.changes.filter(c => c.type === 'conflict');
@@ -379,7 +380,8 @@ function updateInstalledManifest(targetDir, sourceManifest, sourcePackage) {
379
380
  `;
380
381
 
381
382
  fs.ensureDirSync(path.dirname(installedManifestPath));
382
- fs.writeFileSync(installedManifestPath, header + yamlContent, 'utf8');
383
+ // Atomic (tmp+rename): the installed manifest drives future upgrades
384
+ atomicWriteFileSync(installedManifestPath, header + yamlContent, 'utf8');
383
385
 
384
386
  return installedManifestPath;
385
387
  }
@@ -62,24 +62,29 @@ function removeBOM(content) {
62
62
  * @throws {Error} - If file cannot be read
63
63
  */
64
64
  function hashFile(filePath) {
65
- if (!fs.existsSync(filePath)) {
66
- throw new Error(`File not found: ${filePath}`);
67
- }
68
-
69
- const stats = fs.statSync(filePath);
70
- if (stats.isDirectory()) {
71
- throw new Error(`Cannot hash directory: ${filePath}`);
65
+ // SECURITY [TOCTOU]: single read — the hashed bytes are exactly what was
66
+ // read, with no exists/stat/read window for a concurrent writer to exploit.
67
+ let raw;
68
+ try {
69
+ raw = fs.readFileSync(filePath);
70
+ } catch (error) {
71
+ if (error.code === 'ENOENT') {
72
+ throw new Error(`File not found: ${filePath}`);
73
+ }
74
+ if (error.code === 'EISDIR') {
75
+ throw new Error(`Cannot hash directory: ${filePath}`);
76
+ }
77
+ throw error;
72
78
  }
73
79
 
74
80
  let content;
75
81
 
76
82
  if (isBinaryFile(filePath)) {
77
83
  // Binary files: hash raw bytes
78
- content = fs.readFileSync(filePath);
84
+ content = raw;
79
85
  } else {
80
86
  // Text files: normalize line endings and remove BOM
81
- const rawContent = fs.readFileSync(filePath, 'utf8');
82
- const withoutBOM = removeBOM(rawContent);
87
+ const withoutBOM = removeBOM(raw.toString('utf8'));
83
88
  const normalized = normalizeLineEndings(withoutBOM);
84
89
  content = Buffer.from(normalized, 'utf8');
85
90
  }
@@ -96,25 +101,28 @@ function hashFile(filePath) {
96
101
  * @throws {Error} - If file cannot be read
97
102
  */
98
103
  async function hashFileAsync(filePath) {
99
- const exists = await fs.pathExists(filePath);
100
- if (!exists) {
101
- throw new Error(`File not found: ${filePath}`);
102
- }
103
-
104
- const stats = await fs.stat(filePath);
105
- if (stats.isDirectory()) {
106
- throw new Error(`Cannot hash directory: ${filePath}`);
104
+ // SECURITY [TOCTOU]: single read (see hashFile) — no exists/stat/read window.
105
+ let raw;
106
+ try {
107
+ raw = await fs.readFile(filePath);
108
+ } catch (error) {
109
+ if (error.code === 'ENOENT') {
110
+ throw new Error(`File not found: ${filePath}`);
111
+ }
112
+ if (error.code === 'EISDIR') {
113
+ throw new Error(`Cannot hash directory: ${filePath}`);
114
+ }
115
+ throw error;
107
116
  }
108
117
 
109
118
  let content;
110
119
 
111
120
  if (isBinaryFile(filePath)) {
112
121
  // Binary files: hash raw bytes
113
- content = await fs.readFile(filePath);
122
+ content = raw;
114
123
  } else {
115
124
  // Text files: normalize line endings and remove BOM
116
- const rawContent = await fs.readFile(filePath, 'utf8');
117
- const withoutBOM = removeBOM(rawContent);
125
+ const withoutBOM = removeBOM(raw.toString('utf8'));
118
126
  const normalized = normalizeLineEndings(withoutBOM);
119
127
  content = Buffer.from(normalized, 'utf8');
120
128
  }
@@ -285,82 +285,94 @@ function signatureExists(manifestPath) {
285
285
  * @param {Object} [options.publicKey] - Override public key (testing only)
286
286
  * @returns {Object} { content: Buffer, verified: boolean, error: string|null }
287
287
  */
288
- function loadAndVerifyManifest(manifestPath, options = {}) {
289
- const requireSignature = options.requireSignature !== false;
290
- const signaturePath = manifestPath + '.minisig';
291
-
292
- // Check manifest exists
293
- if (!fs.existsSync(manifestPath)) {
288
+ /**
289
+ * Open + fstat + read on a SINGLE file descriptor.
290
+ *
291
+ * SECURITY [TOCTOU]: size check (DoS guard) and read happen on the same open
292
+ * fd, so a concurrent writer cannot swap the file between the check and the
293
+ * use — the buffer verified downstream is exactly the bytes that were sized.
294
+ *
295
+ * @param {string} filePath - File to read
296
+ * @param {number} maxSize - Maximum allowed size in bytes
297
+ * @param {string} label - Human label used in error messages (e.g. "Manifest file")
298
+ * @returns {{content: Buffer|null, error: string|null, missing: boolean}}
299
+ */
300
+ function readFileCapped(filePath, maxSize, label) {
301
+ let fd;
302
+ try {
303
+ fd = fs.openSync(filePath, 'r');
304
+ } catch (error) {
294
305
  return {
295
306
  content: null,
296
- verified: false,
297
- error: 'Manifest file not found',
307
+ error: `Cannot read ${label.toLowerCase()}: ${error.message}`,
308
+ missing: error.code === 'ENOENT',
298
309
  };
299
310
  }
300
-
301
- // SECURITY [DOS-1]: Check manifest file size BEFORE reading into memory
302
- // This prevents DoS attacks via oversized manifest files
303
- let manifestStat;
304
311
  try {
305
- manifestStat = fs.statSync(manifestPath);
312
+ const stat = fs.fstatSync(fd);
313
+ if (stat.size > maxSize) {
314
+ return {
315
+ content: null,
316
+ error: `${label} exceeds maximum size (${maxSize} bytes)`,
317
+ missing: false,
318
+ };
319
+ }
320
+ const content = Buffer.alloc(stat.size);
321
+ fs.readSync(fd, content, 0, stat.size, 0);
322
+ return { content, error: null, missing: false };
306
323
  } catch (error) {
307
324
  return {
308
325
  content: null,
309
- verified: false,
310
- error: `Cannot stat manifest file: ${error.message}`,
326
+ error: `Cannot read ${label.toLowerCase()}: ${error.message}`,
327
+ missing: false,
311
328
  };
329
+ } finally {
330
+ fs.closeSync(fd);
312
331
  }
332
+ }
313
333
 
314
- if (manifestStat.size > SignatureLimits.MAX_MANIFEST_SIZE) {
334
+ function loadAndVerifyManifest(manifestPath, options = {}) {
335
+ const requireSignature = options.requireSignature !== false;
336
+ const signaturePath = manifestPath + '.minisig';
337
+
338
+ // SECURITY [DOS-1] + [TOCTOU]: size is validated on the same fd that is
339
+ // read, so the verified buffer cannot be swapped after the check.
340
+ const manifest = readFileCapped(manifestPath, SignatureLimits.MAX_MANIFEST_SIZE, 'Manifest file');
341
+ if (manifest.content === null) {
315
342
  return {
316
343
  content: null,
317
344
  verified: false,
318
- error: `Manifest file exceeds maximum size (${SignatureLimits.MAX_MANIFEST_SIZE} bytes)`,
345
+ error: manifest.missing ? 'Manifest file not found' : manifest.error,
319
346
  };
320
347
  }
321
348
 
322
- // Check signature exists
323
- if (!fs.existsSync(signaturePath)) {
324
- if (requireSignature) {
349
+ // SECURITY [DOS-2] + [TOCTOU]: same single-fd pattern for the signature
350
+ const signature = readFileCapped(signaturePath, SignatureLimits.MAX_SIGNATURE_SIZE, 'Signature file');
351
+ if (signature.content === null) {
352
+ if (signature.missing) {
353
+ if (requireSignature) {
354
+ return {
355
+ content: null,
356
+ verified: false,
357
+ error: 'Manifest signature file not found (.minisig)',
358
+ };
359
+ }
360
+ // Allow unsigned in dev mode (requireSignature=false)
325
361
  return {
326
- content: null,
362
+ content: manifest.content,
327
363
  verified: false,
328
- error: 'Manifest signature file not found (.minisig)',
364
+ error: null,
329
365
  };
330
366
  }
331
- // Allow unsigned in dev mode (requireSignature=false)
332
- // Size already validated above
333
- return {
334
- content: fs.readFileSync(manifestPath),
335
- verified: false,
336
- error: null,
337
- };
338
- }
339
-
340
- // SECURITY [DOS-2]: Check signature file size BEFORE reading
341
- // Signature files should be small (~200 bytes typical)
342
- let signatureStat;
343
- try {
344
- signatureStat = fs.statSync(signaturePath);
345
- } catch (error) {
346
- return {
347
- content: null,
348
- verified: false,
349
- error: `Cannot stat signature file: ${error.message}`,
350
- };
351
- }
352
-
353
- if (signatureStat.size > SignatureLimits.MAX_SIGNATURE_SIZE) {
354
367
  return {
355
368
  content: null,
356
369
  verified: false,
357
- error: `Signature file exceeds maximum size (${SignatureLimits.MAX_SIGNATURE_SIZE} bytes)`,
370
+ error: signature.error,
358
371
  };
359
372
  }
360
373
 
361
- // Load files - sizes validated above
362
- const manifestContent = fs.readFileSync(manifestPath);
363
- const signatureContent = fs.readFileSync(signaturePath, 'utf8');
374
+ const manifestContent = manifest.content;
375
+ const signatureContent = signature.content.toString('utf8');
364
376
 
365
377
  // Verify signature BEFORE any parsing
366
378
  const verifyResult = verifyManifestSignature(manifestContent, signatureContent, options);
@@ -456,21 +456,30 @@ class PostInstallValidator {
456
456
  // Development mode: signature not required (NOT for production)
457
457
  this.log('WARNING: Signature verification disabled - development mode only');
458
458
  try {
459
- // SECURITY [DOS-3]: Check file size BEFORE reading into memory
460
- const manifestStat = fs.statSync(manifestPath);
461
- if (manifestStat.size > SecurityLimits.MAX_MANIFEST_SIZE) {
462
- this.issues.push({
463
- type: IssueType.INVALID_MANIFEST,
464
- severity: Severity.CRITICAL,
465
- message: 'Manifest file exceeds maximum size',
466
- details: `Size: ${manifestStat.size} bytes, Max: ${SecurityLimits.MAX_MANIFEST_SIZE} bytes`,
467
- remediation: 'Use a valid manifest file from the official source',
468
- relativePath: null,
469
- });
470
- return null;
471
- }
459
+ // SECURITY [DOS-3] + [TOCTOU]: fstat + read on the same open fd, so the
460
+ // size check and the parsed bytes refer to the same file version.
461
+ const fd = fs.openSync(manifestPath, 'r');
462
+ let content;
463
+ try {
464
+ const manifestStat = fs.fstatSync(fd);
465
+ if (manifestStat.size > SecurityLimits.MAX_MANIFEST_SIZE) {
466
+ this.issues.push({
467
+ type: IssueType.INVALID_MANIFEST,
468
+ severity: Severity.CRITICAL,
469
+ message: 'Manifest file exceeds maximum size',
470
+ details: `Size: ${manifestStat.size} bytes, Max: ${SecurityLimits.MAX_MANIFEST_SIZE} bytes`,
471
+ remediation: 'Use a valid manifest file from the official source',
472
+ relativePath: null,
473
+ });
474
+ return null;
475
+ }
472
476
 
473
- const content = fs.readFileSync(manifestPath, 'utf8');
477
+ const buf = Buffer.alloc(manifestStat.size);
478
+ fs.readSync(fd, buf, 0, manifestStat.size, 0);
479
+ content = buf.toString('utf8');
480
+ } finally {
481
+ fs.closeSync(fd);
482
+ }
474
483
  return this.parseManifestContent(content);
475
484
  } catch (error) {
476
485
  this.issues.push({
@@ -1394,9 +1403,7 @@ class PostInstallValidator {
1394
1403
  */
1395
1404
  function formatReport(report, options = {}) {
1396
1405
  const useColors = options.colors !== false;
1397
- // Note: detailed option is parsed from CLI but detailed view is handled by caller
1398
- // eslint-disable-next-line no-unused-vars
1399
- const detailed = options.detailed === true;
1406
+ // Note: options.detailed is parsed from CLI but the detailed view is handled by the caller
1400
1407
 
1401
1408
  const c = {
1402
1409
  reset: useColors ? '\x1b[0m' : '',
@@ -140,8 +140,9 @@ function parseMarkdownSections(content) {
140
140
  result.preamble.push(line);
141
141
  } else if (currentSection) {
142
142
  currentSection.lines.push(line);
143
- } else if (!sinapseSection) {
144
- // Content after an SINAPSE section but before next section
143
+ } else {
144
+ // Content after an SINAPSE section but before next section (sinapseSection
145
+ // is always null here — the in-section branch above `continue`s).
145
146
  // This shouldn't happen in well-formed files, but handle it
146
147
  result.preamble.push(line);
147
148
  }
@@ -780,11 +780,17 @@ class SINAPSEUpdater {
780
780
  /**
781
781
  * Log if verbose
782
782
  *
783
+ * Messages may interpolate error text or npm registry responses; control
784
+ * characters (ANSI escapes, CR) are stripped so external data cannot forge
785
+ * log lines in the user's terminal (CWE-117).
786
+ *
783
787
  * @param {string} message - Message
784
788
  */
785
789
  log(message) {
786
790
  if (this.options.verbose) {
787
- console.log(`[SINAPSEUpdater] ${message}`);
791
+ // eslint-disable-next-line no-control-regex
792
+ const sanitized = String(message).replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
793
+ console.log(`[SINAPSEUpdater] ${sanitized}`);
788
794
  }
789
795
  }
790
796
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Atomic write bridge — Onda B2 (AF-20260703)
3
+ *
4
+ * Thin pointer to the canonical tmp+rename util in
5
+ * `.sinapse-ai/core/synapse/utils/atomic-write.js` (ships in the same npm
6
+ * package as this workspace). Fail-open: if the canonical util can't be
7
+ * resolved, fall back to a plain write (the pre-hardening behavior) rather
8
+ * than breaking the installer.
9
+ *
10
+ * @module src/utils/atomic-write
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ let _atomicWriteSync = null;
19
+ try {
20
+ // packages/installer/src/utils → repo/package root is 4 levels up
21
+ _atomicWriteSync = require(path.join(
22
+ __dirname, '..', '..', '..', '..',
23
+ '.sinapse-ai', 'core', 'synapse', 'utils', 'atomic-write',
24
+ )).atomicWriteSync;
25
+ } catch { /* fallback below */ }
26
+
27
+ function atomicWriteFileSync(filePath, data, encoding = 'utf8') {
28
+ if (_atomicWriteSync) {
29
+ _atomicWriteSync(filePath, data, encoding);
30
+ } else {
31
+ fs.writeFileSync(filePath, data, encoding);
32
+ }
33
+ }
34
+
35
+ module.exports = { atomicWriteFileSync };
@@ -13,6 +13,7 @@ const path = require('path');
13
13
  const fse = require('fs-extra');
14
14
  const { execSync } = require('child_process');
15
15
  const { colors } = require('../utils/sinapse-colors');
16
+ const { atomicWriteFileSync } = require('../utils/atomic-write');
16
17
  const {
17
18
  getLanguageQuestion,
18
19
  getLLMQuestion,
@@ -78,7 +79,8 @@ async function writeClaudeSettings(language, projectDir = process.cwd()) {
78
79
  const claudeLanguage = LANGUAGE_MAP[language] || language;
79
80
  settings.language = claudeLanguage;
80
81
 
81
- await fse.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
82
+ // Atomic (tmp+rename): user settings.json must never be torn
83
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
82
84
  return true;
83
85
  } catch {
84
86
  // Non-blocking: language is a preference, not critical
@@ -366,7 +368,7 @@ async function installGlobalAgents() {
366
368
  try {
367
369
  const content = buildAgentTemplate(agent.id, agent.squad, homedir);
368
370
  const filePath = path.join(agentsDir, `${agent.id}.md`);
369
- await fse.writeFile(filePath, content, 'utf8');
371
+ atomicWriteFileSync(filePath, content, 'utf8');
370
372
  count++;
371
373
  } catch (err) {
372
374
  errors.push(`${agent.id}: ${err.message}`);
@@ -650,7 +652,7 @@ async function runWizard(options = {}) {
650
652
  '# User-Defined Preferred Patterns and Preferences',
651
653
  '# User-Defined Preferred Patterns and Preferences' + activePresetSection,
652
654
  );
653
- await fse.writeFile(techPrefsFile, techPrefsContent, 'utf8');
655
+ atomicWriteFileSync(techPrefsFile, techPrefsContent, 'utf8');
654
656
  }
655
657
  }
656
658
  }
@@ -19,6 +19,7 @@ const path = require('path');
19
19
  const os = require('os');
20
20
  const { execSync } = require('child_process');
21
21
  const { detectOS, OS_TYPE } = require('../os-detector');
22
+ const { atomicWriteFileSync } = require('../utils/atomic-write');
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Constants
@@ -339,7 +340,8 @@ function readJsonSafe(filePath) {
339
340
  function writeJson(filePath, data) {
340
341
  const dir = path.dirname(filePath);
341
342
  fs.mkdirSync(dir, { recursive: true });
342
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
343
+ // Atomic (tmp+rename): these are user config files (settings.json, .mcp.json)
344
+ atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
343
345
  }
344
346
 
345
347
  /**
@@ -755,7 +757,7 @@ function installChromeBrain(options = {}) {
755
757
  if (dryRun) {
756
758
  LOG.info(`[DRY-RUN] Would create ${filePath}`);
757
759
  } else {
758
- fs.writeFileSync(filePath, content, 'utf8');
760
+ atomicWriteFileSync(filePath, content, 'utf8');
759
761
  // Make executable on Unix
760
762
  if (osInfo.type !== OS_TYPE.WINDOWS) {
761
763
  fs.chmodSync(filePath, 0o755);
@@ -848,7 +850,7 @@ function installChromeBrain(options = {}) {
848
850
  LOG.info(`[DRY-RUN] Would create ${file.label}`);
849
851
  } else {
850
852
  fs.mkdirSync(path.dirname(file.path), { recursive: true });
851
- fs.writeFileSync(file.path, file.content, 'utf8');
853
+ atomicWriteFileSync(file.path, file.content, 'utf8');
852
854
  LOG.ok(file.label);
853
855
  }
854
856
  } catch (err) {
@@ -23,6 +23,7 @@ const { intro, outro, select, confirm, note, isCancel, cancel } = require('@clac
23
23
  const { execa } = require('execa');
24
24
 
25
25
  const { detectOS, getOSDisplayName } = require('./os-detector');
26
+ const { atomicWriteFileSync } = require('./utils/atomic-write');
26
27
  const { checkAllDependencies, displayResults } = require('./dep-checker');
27
28
 
28
29
  /**
@@ -148,7 +149,8 @@ async function createUserConfigDirect(profile, logger, dryRun) {
148
149
 
149
150
  const yaml = require('js-yaml');
150
151
  const yamlContent = yaml.dump(config, { lineWidth: -1 });
151
- await fs.writeFile(userConfigPath, yamlContent, 'utf8');
152
+ // Atomic (tmp+rename): user config must never be torn
153
+ atomicWriteFileSync(userConfigPath, yamlContent, 'utf8');
152
154
 
153
155
  logger.success(`User config created at ${userConfigPath}`);
154
156
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Atomic write (tmp + rename) — self-contained copy for @sinapse/sinapse-install.
3
+ *
4
+ * This package publishes on its own, so it cannot require the canonical util
5
+ * in `.sinapse-ai/core/synapse/utils/atomic-write.js` (main sinapse-ai
6
+ * package). Keep the two in sync if the pattern evolves.
7
+ *
8
+ * Onda B2 (AF-20260703): a crash or concurrent reader must never see a torn
9
+ * config file.
10
+ *
11
+ * @module src/utils/atomic-write
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const IS_WINDOWS = process.platform === 'win32';
20
+ const TRANSIENT_LOCK_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY']);
21
+
22
+ function sleepSync(ms) {
23
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
24
+ }
25
+
26
+ function retrySync(fn, retries = 3, delayMs = 120) {
27
+ for (let attempt = 0; ; attempt++) {
28
+ try {
29
+ return fn();
30
+ } catch (err) {
31
+ if (!TRANSIENT_LOCK_CODES.has(err.code) || attempt >= retries) {
32
+ throw err;
33
+ }
34
+ sleepSync(delayMs * (attempt + 1));
35
+ }
36
+ }
37
+ }
38
+
39
+ function atomicWriteFileSync(filePath, data, encoding = 'utf8') {
40
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
41
+ try {
42
+ const dir = path.dirname(filePath);
43
+ if (!fs.existsSync(dir)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+ fs.writeFileSync(tmpPath, data, encoding);
47
+ if (IS_WINDOWS) {
48
+ retrySync(() => {
49
+ try {
50
+ fs.unlinkSync(filePath);
51
+ } catch (err) {
52
+ if (err.code !== 'ENOENT') {
53
+ throw err;
54
+ }
55
+ }
56
+ });
57
+ }
58
+ retrySync(() => fs.renameSync(tmpPath, filePath));
59
+ } catch (error) {
60
+ try {
61
+ fs.unlinkSync(tmpPath);
62
+ } catch { /* ignore cleanup errors */ }
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ module.exports = { atomicWriteFileSync };
@@ -16,6 +16,7 @@
16
16
 
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
+ const { atomicWriteSync } = require('../.sinapse-ai/core/synapse/utils/atomic-write');
19
20
 
20
21
  const ROOT = path.resolve(__dirname, '..');
21
22
 
@@ -85,7 +86,7 @@ function ensureNotice(filePath, realName) {
85
86
  next = notice + '\n\n' + content;
86
87
  }
87
88
 
88
- fs.writeFileSync(filePath, next);
89
+ atomicWriteSync(filePath, next);
89
90
  return { status: 'inserted', filePath };
90
91
  }
91
92