@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.
- package/README.md +113 -57
- package/bin/atlas +1 -1
- package/config/sentinel-keys.json +15 -0
- package/config/trust-boundaries.json +64 -0
- package/index.js +10 -0
- package/package.json +7 -3
- package/src/core/SecretRedactor.js +81 -1
- package/src/enforcement/RuntimeSandbox.js +155 -26
- package/src/interface/cli/commands/BuildCommand.js +68 -35
- package/src/kernel/automation/AutomationRegistry.js +159 -0
- package/src/kernel/automation/AutomationSchema.js +109 -0
- package/src/kernel/automation/AutomationVerifier.js +135 -0
- package/src/kernel/automation/LastRunLedger.js +220 -0
- package/src/kernel/boot/Bootstrap.js +105 -0
- package/src/kernel/constitution/SentinelInterface.js +180 -3
- package/src/kernel/constitution/invariants/automation.invariants.js +126 -0
- package/src/kernel/constitution/invariants/frequency.invariants.js +77 -0
- package/src/kernel/constitution/invariants/registry.invariants.js +70 -0
- package/src/kernel/crypto/DirectoryHasher.js +101 -0
- package/src/kernel/io/AuditSink.js +95 -1
- package/src/kernel/io/DecisionTrace.js +19 -0
- package/src/kernel/io/HashChainedLog.js +57 -3
- package/src/kernel/modes/ActiveMode.js +158 -0
- package/src/kernel/modes/dev.js +33 -0
- package/src/kernel/modes/fortress.js +33 -0
- package/src/kernel/modes/prod.js +41 -0
- package/src/kernel/sentinel/KeyRegistry.js +399 -0
- package/src/monitoring/CostTracker.js +70 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|