@atlasnomos/atlas 1.1.15 → 10.0.1

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.
@@ -15,6 +15,12 @@ const path = require('path');
15
15
  const { SecretRedactor } = require('../../core/SecretRedactor');
16
16
  const { printDevModeWarning } = require('../io/GovernanceWarning');
17
17
 
18
+ // Ring-0 Mode System (PROD enforcement)
19
+ const { getActiveMode, isProdOrHigher, isModeLocked } = require('../modes/ActiveMode');
20
+
21
+ // Sentinel Key Registry (PROD hardening - cryptographic trust)
22
+ const { getInstance: getKeyRegistry } = require('../sentinel/KeyRegistry');
23
+
18
24
  // ═══════════════════════════════════════════════════════════════
19
25
  // HASH-CHAINED AUDIT LOG
20
26
  // ═══════════════════════════════════════════════════════════════
@@ -317,6 +323,32 @@ class SentinelInterface {
317
323
  const healthy = await this.isHealthy();
318
324
  if (!healthy) {
319
325
  log('Health check failed');
326
+
327
+ // PROD ENFORCEMENT: Fail-close on Sentinel disconnection
328
+ if (isModeLocked() && isProdOrHigher()) {
329
+ const mode = getActiveMode();
330
+ if (mode.requireSentinelApproval) {
331
+ log('PROD ENFORCEMENT: Sentinel required but unavailable - BLOCKING');
332
+ log('PROD ENFORCEMENT: Sentinel required but unavailable - BLOCKING');
333
+ // R-04 FIX: Sentinel Halt Distinction (Unreachable)
334
+ this._logAudit({
335
+ event: 'SENTINEL_UNREACHABLE',
336
+ traceId,
337
+ action,
338
+ verdict: 'BLOCK',
339
+ reason: 'SENTINEL_CONNECTION_FAILURE',
340
+ tier: this.tierManager.getTier(),
341
+ failClose: true
342
+ });
343
+ return {
344
+ approved: false,
345
+ error: 'SENTINEL_UNREACHABLE',
346
+ failClose: true,
347
+ tier: this.tierManager.getTier()
348
+ };
349
+ }
350
+ }
351
+
320
352
  return { approved: false, error: 'SENTINEL_DISCONNECTED' };
321
353
  }
322
354
  }
@@ -345,15 +377,57 @@ class SentinelInterface {
345
377
  const valid = this.verifySignature(response.signed_payload, response.signature);
346
378
  if (!valid) panic('SENTINEL_SIGNATURE_INVALID', { traceId });
347
379
 
380
+ // PROD HARDENING: Verify key is in authorized registry
381
+ // WHY: Ensures the signing key is known, active, and not revoked
382
+ const keyRegistry = getKeyRegistry();
383
+ const keyId = this.publicKeyHex ? this.publicKeyHex.substring(0, 16) : 'unknown';
384
+ const keyVerification = keyRegistry.enforceKeyValidity(keyId, { traceId, action });
385
+
348
386
  nonceRegistry.markUsed(response.nonce);
349
- this._logAudit({ traceId, action, decision: 'APPROVED', hash: crypto.createHash('sha256').update(JSON.stringify(response.signed_payload)).digest('hex') });
350
387
 
351
- return { approved: true, attested: true, token: response, signature: response.signature, payload: response.signed_payload };
388
+ // Build approval_proof for hash-chained audit log (PROD requirement)
389
+ const approvalProof = {
390
+ sentinel_payload_hash: crypto.createHash('sha256').update(JSON.stringify(response.signed_payload)).digest('hex'),
391
+ sentinel_signature: response.signature,
392
+ sentinel_key_id: this.publicKeyHex ? this.publicKeyHex.substring(0, 16) : 'unknown',
393
+ alg: 'ed25519',
394
+ issued_at: new Date().toISOString(),
395
+ expires_at: new Date(response.expires_at * 1000).toISOString(),
396
+ nonce: response.nonce
397
+ };
398
+
399
+ // Log EXECUTION_DECISION with approval_proof to hash-chained log
400
+ this._logAudit({
401
+ event: 'EXECUTION_DECISION',
402
+ traceId,
403
+ action,
404
+ verdict: 'PASS',
405
+ approval_proof: approvalProof,
406
+ tier: this.tierManager.getTier(),
407
+ attested: true
408
+ });
409
+
410
+ return {
411
+ approved: true,
412
+ attested: true,
413
+ token: response,
414
+ signature: response.signature,
415
+ payload: response.signed_payload,
416
+ approval_proof: approvalProof // Include in response for caller access
417
+ };
352
418
  } else {
353
419
  log(`Denied branch. Reason: ${response.reason}`);
354
420
  const deniedLogPath = path.join(process.cwd(), '.atlas', 'logs', 'trace_debug.log');
355
421
  fs.appendFileSync(deniedLogPath, `[DENIED] Reason='${response.reason}'\n`);
356
- this._logAudit({ traceId, action, decision: 'DENIED', reason: response.reason });
422
+
423
+ // R-04 FIX: Sentinel Halt Distinction (Denied)
424
+ this._logAudit({
425
+ event: 'SENTINEL_DENIED',
426
+ traceId,
427
+ action,
428
+ reason: response.reason,
429
+ tier: this.tierManager.getTier()
430
+ });
357
431
  return { approved: false, reason: response.reason };
358
432
  }
359
433
  } catch (e) {
@@ -399,6 +473,38 @@ class SentinelInterface {
399
473
  }
400
474
  }
401
475
 
476
+ /**
477
+ * Public accessor for audit logging (PROD Hardening).
478
+ * Allows other kernel modules to write to the main hash-chained log.
479
+ * @param {Object} entry - Log entry
480
+ */
481
+ logAudit(entry) {
482
+ this._logAudit(entry);
483
+ }
484
+
485
+ /**
486
+ * Create an anchor to a secondary chain (PROD Hardening).
487
+ * Preserves causal consistency across split logs.
488
+ * @param {string} source - Source chain name (e.g. CostTracker)
489
+ * @param {string} hash - Hash of the event in the secondary chain
490
+ * @param {number} seq - Sequence number in the secondary chain
491
+ */
492
+ logAnchor(source, hash, seq) {
493
+ try {
494
+ this._logAudit({
495
+ event: 'CHAIN_ANCHOR',
496
+ source_chain: source,
497
+ anchored_hash: hash,
498
+ anchored_seq: seq,
499
+ tier: this.tierManager.getTier(),
500
+ timestamp: new Date().toISOString()
501
+ });
502
+ } catch (e) {
503
+ // Anchor failure is non-fatal but noisy
504
+ console.error(`[SentinelInterface] Failed to anchor ${source}: ${e.message}`);
505
+ }
506
+ }
507
+
402
508
  // ═══════════════════════════════════════════════════════════════
403
509
  // P0.3 - TIMEOUT CONFIGURATION
404
510
  // ═══════════════════════════════════════════════════════════════
@@ -516,6 +622,77 @@ class SentinelInterface {
516
622
  }
517
623
  return { success: false, error: response.detail || 'MODE_SWITCH_FAILED' };
518
624
  }
625
+
626
+ /**
627
+ * AURORA: Verify a Standing Automation Grant.
628
+ *
629
+ * @param {Object} grant - The full grant object
630
+ * @returns {Promise<boolean>} True if valid and signed by active key
631
+ */
632
+ async verifyAutomationGrant(grant) {
633
+ // PROD ENFORCEMENT: Only valid in PROD/FORTRESS
634
+ if (!isProdOrHigher()) return false;
635
+
636
+ if (!this.connected) {
637
+ await this.isHealthy();
638
+ }
639
+
640
+ const { AutomationSchema } = require('../automation/AutomationSchema');
641
+
642
+ try {
643
+ // 1. Schema Validation
644
+ AutomationSchema.validateGrant(grant);
645
+
646
+ // 2. Check Key Validity
647
+ const keyRegistry = getKeyRegistry();
648
+ const keyCheck = keyRegistry.enforceKeyValidity(grant.sentinel_key_id, { context: 'AUTOMATION_VERIFY' });
649
+ if (!keyCheck.valid) {
650
+ panic('AUTOMATION_KEY_INVALID', { keyId: grant.sentinel_key_id });
651
+ }
652
+
653
+ // 3. Verify Signature
654
+ // Clone and remove signature for verification
655
+ const payload = { ...grant };
656
+ delete payload.sentinel_signature;
657
+
658
+ // Canonicalize
659
+ const canonical = this._deepSort(payload);
660
+ const message = JSON.stringify(canonical);
661
+ const messageBuffer = Buffer.from(message, 'utf-8');
662
+ const signature = Buffer.from(grant.sentinel_signature, 'hex');
663
+
664
+ // Get Key Buffer
665
+ const publicKeyBuffer = keyRegistry.getPublicKey(grant.sentinel_key_id);
666
+ if (!publicKeyBuffer) return false;
667
+
668
+ // Ed25519 SPKI header
669
+ const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
670
+ const spkiKey = Buffer.concat([spkiHeader, publicKeyBuffer]);
671
+
672
+ const keyObject = crypto.createPublicKey({
673
+ key: spkiKey,
674
+ format: 'der',
675
+ type: 'spki'
676
+ });
677
+
678
+ const valid = crypto.verify(undefined, messageBuffer, keyObject, signature);
679
+
680
+ if (!valid) {
681
+ this._logAudit({
682
+ event: 'AUTOMATION_SIGNATURE_FAIL',
683
+ grantId: grant.grant_id,
684
+ keyId: grant.sentinel_key_id
685
+ });
686
+ return false;
687
+ }
688
+
689
+ return true;
690
+
691
+ } catch (e) {
692
+ console.error('[Sentinel] Automation Verification Error:', e.message);
693
+ return false;
694
+ }
695
+ }
519
696
  }
520
697
 
521
698
  let _instance = null;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ATLAS v10 — Automation Invariants (Constitution)
3
+ *
4
+ * PURPOSE: Ring-0 Logic Gates for Automation Authority.
5
+ *
6
+ * INVARIANTS:
7
+ * - INV-AUTO-001: Execution requires valid Sentinel Grant.
8
+ * - INV-AUTO-002: Automation cannot mutate policy.
9
+ * - INV-AUTO-003: Automation cannot create automations.
10
+ * - INV-AUTO-004: Automation execution must be replay-deterministic.
11
+ * - INV-AUTO-005: Automation scope limits (Cost/Runtime) cannot be exceeded.
12
+ *
13
+ * @module kernel/constitution/invariants/automation.invariants
14
+ */
15
+
16
+ const { isProdOrHigher } = require('../../modes/ActiveMode');
17
+ const { panic, assertInvariant } = require('../../boot/HardKill');
18
+
19
+ const AUTOMATION_INVARIANTS = {
20
+ GRANT_REQUIRED: 'INV-AUTO-001',
21
+ IMMUTABLE_POLICY: 'INV-AUTO-002',
22
+ NO_RECURSION: 'INV-AUTO-003',
23
+ REPLAY_DETERMINISM: 'INV-AUTO-004',
24
+ SCOPE_LIMITS: 'INV-AUTO-005'
25
+ };
26
+
27
+ /**
28
+ * INV-AUTO-001: Assert that a valid grant exists for this execution context.
29
+ *
30
+ * @param {Object} grant - The loaded grant object
31
+ * @param {string} traceId - Execution Trace ID
32
+ */
33
+ function assertAutomationGrant(grant, traceId) {
34
+ if (!isProdOrHigher()) return; // DEV bypass allowed (technically DEV shouldn't be here, but defensive)
35
+
36
+ assertInvariant(
37
+ !!grant && !!grant.grant_id && !!grant.sentinel_signature,
38
+ AUTOMATION_INVARIANTS.GRANT_REQUIRED,
39
+ { traceId, reason: 'Missing or malformed grant' },
40
+ true // Terminal Panic
41
+ );
42
+ }
43
+
44
+ /**
45
+ * INV-AUTO-002: Assert that the proposed action is NOT a policy mutation.
46
+ *
47
+ * @param {string} action - Action identifier
48
+ */
49
+ function assertImmutablePolicy(action) {
50
+ if (!isProdOrHigher()) return;
51
+
52
+ if (action.includes('POLICY_UPDATE') || action.includes('WEAKEN_INVARIANT')) {
53
+ panic(AUTOMATION_INVARIANTS.IMMUTABLE_POLICY, {
54
+ reason: 'Automation attempted to mutate policy',
55
+ action
56
+ });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * INV-AUTO-003: Assert that the proposed action is NOT creating new automation.
62
+ *
63
+ * @param {string} action - Action identifier
64
+ */
65
+ function assertNoRecursion(action) {
66
+ if (!isProdOrHigher()) return;
67
+
68
+ if (action.includes('REGISTER_AUTOMATION') || action.includes('GRANT_ISSUE')) {
69
+ panic(AUTOMATION_INVARIANTS.NO_RECURSION, {
70
+ reason: 'Automation attempted to create new automation',
71
+ action
72
+ });
73
+ }
74
+ }
75
+
76
+ /**
77
+ * INV-AUTO-004: Assert that the execution environment is replay-deterministic.
78
+ *
79
+ * @param {Object} entropyContext - The entropy context used for seeding
80
+ */
81
+ function assertReplayDeterminism(entropyContext) {
82
+ if (!isProdOrHigher()) return;
83
+
84
+ // In PROD/FORTRESS, entropy must be derived, not random.
85
+ if (!entropyContext || !entropyContext.deterministic || !entropyContext.seedHash) {
86
+ panic(AUTOMATION_INVARIANTS.REPLAY_DETERMINISM, {
87
+ reason: 'Automation execution attempted without deterministic seeding',
88
+ context: entropyContext
89
+ });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * INV-AUTO-005: Assert that execution limits (Cost, Runtime) are respected.
95
+ *
96
+ * @param {Object} scope - The grant scope
97
+ * @param {Object} usage - Current usage metrics
98
+ */
99
+ function assertScopeLimits(scope, usage) {
100
+ if (!isProdOrHigher()) return;
101
+
102
+ if (usage.estimatedCost > scope.max_cost) {
103
+ panic(AUTOMATION_INVARIANTS.SCOPE_LIMITS, {
104
+ reason: 'Cost limit exceeded',
105
+ limit: scope.max_cost,
106
+ actual: usage.estimatedCost
107
+ });
108
+ }
109
+
110
+ if (usage.runtimeMs > scope.max_runtime_ms) {
111
+ panic(AUTOMATION_INVARIANTS.SCOPE_LIMITS, {
112
+ reason: 'Runtime limit exceeded',
113
+ limit: scope.max_runtime_ms,
114
+ actual: usage.runtimeMs
115
+ });
116
+ }
117
+ }
118
+
119
+ module.exports = {
120
+ AUTOMATION_INVARIANTS,
121
+ assertAutomationGrant,
122
+ assertImmutablePolicy,
123
+ assertNoRecursion,
124
+ assertReplayDeterminism,
125
+ assertScopeLimits
126
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * ATLAS v10.2 — Frequency Invariants (Constitution)
3
+ *
4
+ * PURPOSE: Ring-0 Enforcement of Automation Frequency Limits.
5
+ *
6
+ * INVARIANTS:
7
+ * - INV-AUTO-FREQ-001: Automation execution MUST NOT occur more frequently than the grant allows.
8
+ *
9
+ * @module kernel/constitution/invariants/frequency.invariants
10
+ */
11
+
12
+ const { isProdOrHigher } = require('../../modes/ActiveMode');
13
+ const SentinelInterface = require('../SentinelInterface');
14
+ const { getInstance: getLedger } = require('../../automation/LastRunLedger');
15
+
16
+ const FREQUENCY_INVARIANTS = {
17
+ FREQUENCY_LIMIT: 'INV-AUTO-FREQ-001'
18
+ };
19
+
20
+ /**
21
+ * INV-AUTO-FREQ-001: Check if automation can execute within its frequency window.
22
+ *
23
+ * Unlike other invariants, this does NOT panic. It BLOCKS and emits a structured event.
24
+ *
25
+ * @param {Object} grant - The automation grant
26
+ * @returns {{ allowed: boolean, reason?: string, violation?: Object }}
27
+ */
28
+ function checkFrequencyLimit(grant) {
29
+ if (!isProdOrHigher()) {
30
+ // In DEV, we should never reach here. But if we do, block.
31
+ return { allowed: false, reason: 'DEV_MODE_VIOLATION' };
32
+ }
33
+
34
+ const grantId = grant.grant_id;
35
+ const maxFrequency = grant.scope?.max_frequency;
36
+
37
+ if (!maxFrequency) {
38
+ // No frequency limit defined = always allowed
39
+ return { allowed: true };
40
+ }
41
+
42
+ const ledger = getLedger();
43
+ const check = ledger.canExecute(grantId, maxFrequency);
44
+
45
+ if (!check.allowed) {
46
+ // Emit structured violation event
47
+ const sentinel = SentinelInterface.getInstance();
48
+ sentinel.logAudit({
49
+ event: 'AUTOMATION_FREQUENCY_VIOLATION',
50
+ invariant: FREQUENCY_INVARIANTS.FREQUENCY_LIMIT,
51
+ grant_id: grantId,
52
+ frequency_window: maxFrequency,
53
+ last_execution_at: check.lastExecutionAt || null,
54
+ next_allowed_at: check.nextAllowedAt || null,
55
+ reason: check.reason,
56
+ timestamp: new Date().toISOString()
57
+ });
58
+
59
+ return {
60
+ allowed: false,
61
+ reason: check.reason,
62
+ violation: {
63
+ invariant: FREQUENCY_INVARIANTS.FREQUENCY_LIMIT,
64
+ grantId,
65
+ frequencyWindow: maxFrequency,
66
+ lastExecutionAt: check.lastExecutionAt
67
+ }
68
+ };
69
+ }
70
+
71
+ return { allowed: true };
72
+ }
73
+
74
+ module.exports = {
75
+ FREQUENCY_INVARIANTS,
76
+ checkFrequencyLimit
77
+ };
@@ -262,6 +262,75 @@ const AUDIT_INVARIANTS = [
262
262
  }
263
263
  ];
264
264
 
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+ // POLICY PINNING INVARIANTS (PROD Hardening)
267
+ // ═══════════════════════════════════════════════════════════════════════════
268
+
269
+ // WHY: Ensures the policy hash observed at AUDIT_RESERVATION matches
270
+ // the policy hash used in every subsequent EXECUTION_DECISION.
271
+ // Any mismatch indicates policy tampering mid-execution.
272
+ // In PROD: Mismatch → INVARIANT_VIOLATION → KERNEL_PANIC
273
+
274
+ const POLICY_PINNING_INVARIANTS = [
275
+ {
276
+ id: 'INV-POLICY-PIN-001',
277
+ category: INVARIANT_CATEGORIES.AUDIT,
278
+ scope: INVARIANT_SCOPES.AUDIT,
279
+ trigger: INVARIANT_TRIGGERS.BEFORE_EXECUTION,
280
+ enforcement: INVARIANT_ENFORCEMENT.HARD_HALT,
281
+ terminal: true,
282
+ ring: 0,
283
+ description: 'Policy hash at EXECUTION_DECISION must match hash at AUDIT_RESERVATION',
284
+ predicate: (context) => {
285
+ // NO BYPASS. Policy pinning is existence-critical in PROD.
286
+
287
+ const {
288
+ policyHashAtReservation, // Hash captured at AUDIT_RESERVATION
289
+ currentPolicyHash, // Hash at current EXECUTION_DECISION
290
+ executionId
291
+ } = context;
292
+
293
+ // 1. If no reservation policy hash, skip (first execution or DEV)
294
+ if (!policyHashAtReservation) {
295
+ return { valid: true, reason: 'NO_RESERVATION_HASH' };
296
+ }
297
+
298
+ // 2. If no current policy hash, fail closed in PROD
299
+ if (!currentPolicyHash) {
300
+ // Check if PROD mode
301
+ const { isProdOrHigher, isModeLocked } = require('../../modes/ActiveMode');
302
+ if (isModeLocked() && isProdOrHigher()) {
303
+ return {
304
+ valid: false,
305
+ reason: 'POLICY_HASH_MISSING: No policy hash at EXECUTION_DECISION',
306
+ evidence: {
307
+ executionId,
308
+ policyHashAtReservation
309
+ }
310
+ };
311
+ }
312
+ return { valid: true, reason: 'DEV_MODE_SKIP' };
313
+ }
314
+
315
+ // 3. Compare hashes — MUST match exactly
316
+ if (policyHashAtReservation !== currentPolicyHash) {
317
+ return {
318
+ valid: false,
319
+ reason: 'POLICY_HASH_MISMATCH: Policy changed between reservation and execution',
320
+ evidence: {
321
+ executionId,
322
+ hashAtReservation: policyHashAtReservation,
323
+ hashAtExecution: currentPolicyHash,
324
+ delta: 'POLICY_TAMPERED_MID_EXECUTION'
325
+ }
326
+ };
327
+ }
328
+
329
+ return { valid: true };
330
+ }
331
+ }
332
+ ];
333
+
265
334
  // ═══════════════════════════════════════════════════════════════════════════
266
335
  // COST INVARIANTS (Reservation + Settlement Model)
267
336
  // ═══════════════════════════════════════════════════════════════════════════
@@ -859,6 +928,7 @@ const FAILURE_INVARIANTS = [
859
928
 
860
929
  const REGISTRY_INVARIANTS = [
861
930
  ...AUDIT_INVARIANTS,
931
+ ...POLICY_PINNING_INVARIANTS,
862
932
  ...COST_INVARIANTS,
863
933
  ...MCP_INVARIANTS,
864
934
  ...FALLBACK_INVARIANTS,
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ATLAS v10 — Directory Hasher (Ring-0 Crypto)
3
+ *
4
+ * PURPOSE: Compute deterministic, cryptographic hash of a directory tree.
5
+ * Used for Boot Trust Sealing to anchor configuration state.
6
+ *
7
+ * SECURITY INVARIANTS:
8
+ * - Deterministic traversal (Lexicographically sorted)
9
+ * - Content + Metadata binding (Permissions, Size)
10
+ * - Symlink Ban (Fail-Close on symlinks to prevent aliasing attacks)
11
+ * - SHA-256 Strength
12
+ *
13
+ * @module kernel/crypto/DirectoryHasher
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ class DirectoryHasher {
21
+
22
+ /**
23
+ * Compute SHA-256 hash of a directory recursively.
24
+ *
25
+ * @param {string} dirPath - Absolute path to directory
26
+ * @returns {string} Hex string of SHA-256 hash
27
+ * @throws {Error} If symlink found or read error
28
+ */
29
+ static hashDir(dirPath) {
30
+ if (!fs.existsSync(dirPath)) {
31
+ throw new Error(`Directory not found: ${dirPath}`);
32
+ }
33
+
34
+ const hasher = crypto.createHash('sha256');
35
+
36
+ // Phase 1: Collect all files (Deterministic Traversal)
37
+ const entries = this._collectEntries(dirPath, dirPath);
38
+
39
+ // Phase 2: Sort entries lexicographically by relative path
40
+ // WHY: Filesystem enumeration order is undefined; sorting ensures determinism.
41
+ entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
42
+
43
+ // Phase 3: Update Hash
44
+ for (const entry of entries) {
45
+ // Header: Path | Mode | Size
46
+ // Null pointer separation to prevent collisions
47
+ hasher.update(`${entry.relativePath}\0${entry.mode}\0${entry.size}\0`);
48
+
49
+ // Content
50
+ const content = fs.readFileSync(entry.absolutePath);
51
+ hasher.update(content);
52
+
53
+ // Delimiter
54
+ hasher.update('\n');
55
+ }
56
+
57
+ return hasher.digest('hex');
58
+ }
59
+
60
+ /**
61
+ * Recursive directory walker.
62
+ * @private
63
+ */
64
+ static _collectEntries(currentPath, rootPath, collected = []) {
65
+ const stats = fs.lstatSync(currentPath);
66
+
67
+ // SECURITY: Ban Symlinks
68
+ // WHY: Symlinks can point outside trust boundaries or create cycles.
69
+ // In stable server configs, they are unnecessary and risky.
70
+ if (stats.isSymbolicLink()) {
71
+ throw new Error(`SECURITY VIOLATION: Symlink detected at ${currentPath}. Symlinks are prohibited in trusted config directories.`);
72
+ }
73
+
74
+ if (stats.isDirectory()) {
75
+ const children = fs.readdirSync(currentPath);
76
+ for (const child of children) {
77
+ // Ignore standard noise if necessary, but for Trust Seal we prefer exactness.
78
+ // We typically exclude .DS_Store or Thumbs.db in the caller or here.
79
+ // For PROD Strictness: We INCLUDE everything. If a rogue file exists, the hash SHOULD change.
80
+ if (child === '.DS_Store' || child === 'Thumbs.db') continue;
81
+
82
+ const childPath = path.join(currentPath, child);
83
+ this._collectEntries(childPath, rootPath, collected);
84
+ }
85
+ } else if (stats.isFile()) {
86
+ // Normalize relative path to POSIX style (forward slashes) for cross-platform determinism
87
+ const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
88
+
89
+ collected.push({
90
+ relativePath,
91
+ absolutePath: currentPath,
92
+ mode: stats.mode, // File permissions
93
+ size: stats.size
94
+ });
95
+ }
96
+
97
+ return collected;
98
+ }
99
+ }
100
+
101
+ module.exports = { DirectoryHasher };