@letterblack/lbe-core 1.3.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 (79) hide show
  1. package/.githooks/pre-commit +2 -0
  2. package/.githooks/pre-push +2 -0
  3. package/CHANGELOG.md +57 -0
  4. package/LICENSE +1 -0
  5. package/README.md +506 -0
  6. package/Release-README.md +339 -0
  7. package/WORKSPACE.md +422 -0
  8. package/_proof.mjs +246 -0
  9. package/assets/lbe-gates.jpg +0 -0
  10. package/assets/lbe-gates.png +0 -0
  11. package/assets/runtime-boundary.svg +36 -0
  12. package/assets/story-allow.jpg +0 -0
  13. package/assets/story-allow.png +0 -0
  14. package/assets/story-deny.jpg +0 -0
  15. package/assets/story-deny.png +0 -0
  16. package/bin/lbe.js +12 -0
  17. package/config/identity.config.json +3 -0
  18. package/config/policy.default.json +24 -0
  19. package/dist/cli/lbe.js +4274 -0
  20. package/dist/hooks/register.cjs +505 -0
  21. package/dist/state/appendCentral.cjs +87 -0
  22. package/dist/state/index.cjs +101 -0
  23. package/exec/cli.js +472 -0
  24. package/exec/index.js +2 -0
  25. package/index.js +24 -0
  26. package/lbe.audit.jsonl +46 -0
  27. package/package.json +76 -0
  28. package/release/README.md +216 -0
  29. package/release/TRUST.md +90 -0
  30. package/release/exec-README.md +215 -0
  31. package/release/exec-types.d.ts +50 -0
  32. package/release-exec/LICENSE +1 -0
  33. package/release-exec/README.md +215 -0
  34. package/release-exec/assets/lbe-gates.jpg +0 -0
  35. package/release-exec/assets/lbe-gates.png +0 -0
  36. package/release-exec/assets/runtime-boundary.svg +36 -0
  37. package/release-exec/assets/story-allow.jpg +0 -0
  38. package/release-exec/assets/story-allow.png +0 -0
  39. package/release-exec/assets/story-deny.jpg +0 -0
  40. package/release-exec/assets/story-deny.png +0 -0
  41. package/release-exec/dist/cli.js +2841 -0
  42. package/release-exec/dist/index.js +1835 -0
  43. package/release-exec/dist/lbe_engine.wasm +0 -0
  44. package/release-exec/dist/wasm.lock.json +4 -0
  45. package/release-exec/hooks/register.cjs +473 -0
  46. package/release-exec/package.json +35 -0
  47. package/release-exec/types.d.ts +50 -0
  48. package/runtime/engine.js +322 -0
  49. package/runtime/lbe_engine.wasm +0 -0
  50. package/src/cli/commands/auditVerify.js +36 -0
  51. package/src/cli/commands/dryrun.js +175 -0
  52. package/src/cli/commands/health.js +153 -0
  53. package/src/cli/commands/init.js +306 -0
  54. package/src/cli/commands/integrityCheck.js +57 -0
  55. package/src/cli/commands/logs.js +53 -0
  56. package/src/cli/commands/openState.js +44 -0
  57. package/src/cli/commands/policyAdd.js +8 -0
  58. package/src/cli/commands/policyMode.js +7 -0
  59. package/src/cli/commands/policySign.js +72 -0
  60. package/src/cli/commands/proof.js +122 -0
  61. package/src/cli/commands/run.js +342 -0
  62. package/src/cli/commands/status.js +73 -0
  63. package/src/cli/commands/verify.js +144 -0
  64. package/src/cli/main.js +176 -0
  65. package/src/cli/parseArgs.js +114 -0
  66. package/src/exec/localExecutor.js +289 -0
  67. package/src/hooks/register.cjs +505 -0
  68. package/src/state/appendCentral.cjs +87 -0
  69. package/src/state/fileIndex.js +140 -0
  70. package/src/state/index.cjs +101 -0
  71. package/src/state/index.js +65 -0
  72. package/src/state/intentRegistry.js +83 -0
  73. package/src/state/migration.js +112 -0
  74. package/src/state/proofRunner.js +246 -0
  75. package/src/state/stateRoot.js +40 -0
  76. package/src/state/targetRegistry.js +108 -0
  77. package/src/state/workspaceId.js +40 -0
  78. package/src/state/workspaceRegistry.js +65 -0
  79. package/types.d.ts +175 -0
@@ -0,0 +1,342 @@
1
+ // src/cli/commands/run.js
2
+ // Validate and execute a proposal
3
+
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import crypto from 'crypto';
7
+ import { validateCommand } from '../../core/validator.js';
8
+ import { NonceStore } from '../../core/nonceStore.js';
9
+ import { executeAdapter } from '../../adapters/index.js';
10
+ import { appendAudit } from '../../core/auditLog.js';
11
+ import { loadKeysStore } from '../../core/trustedKeys.js';
12
+ import { RequestRateLimiter } from '../../core/requestRateLimiter.js';
13
+ import { verifyPolicySignature } from '../../core/policySignature.js';
14
+ import { validateAndUpdatePolicyVersionState } from '../../core/policyVersionGuard.js';
15
+ import { getApprovalManager } from '../../core/approval-token.js';
16
+ import { createBackup, restoreBackup } from '../../core/backup.js';
17
+ import { loadLocalPolicy, evaluateLocalPolicy, auditLocalPolicy } from '../../core/localPolicy.js';
18
+
19
+ function sha256(obj) {
20
+ return crypto.createHash('sha256').update(JSON.stringify(obj)).digest('hex');
21
+ }
22
+
23
+ export async function runCommand(opts) {
24
+ const { in: inFile } = opts;
25
+ const config = opts.config || opts.policy;
26
+ const pubKey = opts['pub-key'];
27
+ const keysStorePath = opts['keys-store'] || path.resolve('.lbe/config/keys.json');
28
+ const policySigPath = opts['policy-sig'] || path.resolve('.lbe/config/policy.sig.json');
29
+ const policyStatePath = opts['policy-state'] || path.resolve('.lbe/data/policy.state.json');
30
+ const allowUnsignedPolicy = opts['policy-unsigned-ok'] === true || String(opts['policy-unsigned-ok']).toLowerCase() === 'true';
31
+ // Validate required arguments
32
+ if (!inFile) {
33
+ console.error('Error: --in <file> is required');
34
+ process.exit(1);
35
+ }
36
+
37
+ // Read proposal file
38
+ let proposal;
39
+ try {
40
+ const filePath = path.resolve(inFile);
41
+ const content = fs.readFileSync(filePath, 'utf-8');
42
+ proposal = JSON.parse(content);
43
+ } catch (error) {
44
+ console.error(JSON.stringify({
45
+ status: 'error',
46
+ error: 'INVALID_PROPOSAL_FILE',
47
+ message: error.message
48
+ }));
49
+ process.exit(5);
50
+ }
51
+
52
+ // Load policy
53
+ let policy;
54
+ try {
55
+ const policyPath = config || path.resolve('.lbe/config/policy.default.json');
56
+ if (!fs.existsSync(policyPath)) {
57
+ console.error(JSON.stringify({
58
+ status: 'error',
59
+ error: 'MISSING_POLICY',
60
+ message: `Policy file not found: ${policyPath}`
61
+ }));
62
+ process.exit(1);
63
+ }
64
+ const policyContent = fs.readFileSync(policyPath, 'utf-8');
65
+ policy = JSON.parse(policyContent);
66
+ } catch (error) {
67
+ console.error(JSON.stringify({
68
+ status: 'error',
69
+ error: 'INVALID_POLICY',
70
+ message: error.message
71
+ }));
72
+ process.exit(1);
73
+ }
74
+
75
+ // Project-local rules are controller-owned and take precedence over any
76
+ // observer allow. Observe mode records a would-deny decision only.
77
+ const rootDir = process.cwd();
78
+ let localPolicy;
79
+ try {
80
+ localPolicy = loadLocalPolicy(rootDir);
81
+ } catch (error) {
82
+ console.error(JSON.stringify({ status: 'error', error: 'LOCAL_POLICY_INVALID', message: error.message }));
83
+ process.exit(1);
84
+ }
85
+ const localDecision = evaluateLocalPolicy(localPolicy.policy, rootDir, {
86
+ target: proposal.payload?.target,
87
+ command: proposal.payload?.cmd
88
+ });
89
+ const localBlocked = localPolicy.policy.mode === 'enforce' && !localDecision.allowed;
90
+ auditLocalPolicy(rootDir, {
91
+ commandId: proposal.commandId || 'N/A', requesterId: proposal.requesterId || 'unknown',
92
+ mode: localPolicy.policy.mode, decision: localBlocked ? 'deny' : 'allow',
93
+ wouldDeny: !localDecision.allowed, ruleIds: localDecision.winningRules.map(rule => rule.id)
94
+ });
95
+ if (localBlocked) {
96
+ console.error(JSON.stringify({ status: 'blocked', error: 'LOCAL_POLICY_DENY', ruleIds: localDecision.winningRules.map(rule => rule.id) }, null, 2));
97
+ process.exit(2);
98
+ }
99
+
100
+ // Load key store (preferred) with legacy pub-key fallback
101
+ const keyStoreResult = loadKeysStore(keysStorePath);
102
+ const keyStore = keyStoreResult.ok ? keyStoreResult.store : null;
103
+
104
+ // Preflight: policy signature verification (strict by default)
105
+ const policySigCheck = verifyPolicySignature({
106
+ policyObj: policy,
107
+ keyStore,
108
+ policySigPath,
109
+ allowUnsigned: allowUnsignedPolicy
110
+ });
111
+ if (!policySigCheck.ok) {
112
+ console.error(JSON.stringify({
113
+ status: 'error',
114
+ error: policySigCheck.reason,
115
+ message: policySigCheck.message
116
+ }, null, 2));
117
+ process.exit(8);
118
+ }
119
+
120
+ const versionCheck = validateAndUpdatePolicyVersionState({
121
+ policyObj: policy,
122
+ statePath: policyStatePath,
123
+ maxCreatedAtSkewSec: policy?.security?.maxPolicyCreatedAtSkewSec
124
+ });
125
+ if (!versionCheck.ok) {
126
+ console.error(JSON.stringify({
127
+ status: 'error',
128
+ error: versionCheck.reason,
129
+ message: versionCheck.message
130
+ }, null, 2));
131
+ process.exit(8);
132
+ }
133
+
134
+ // Load nonce store
135
+ const nonceDb = new NonceStore(path.resolve('.lbe/data/nonce.db.json'));
136
+ await nonceDb.load();
137
+
138
+ if (!keyStore && !pubKey) {
139
+ console.error(JSON.stringify({
140
+ status: 'error',
141
+ error: 'MISSING_KEY_MATERIAL',
142
+ message: `${keyStoreResult.message}. Provide --pub-key/--pub-key-file or create .lbe/config/keys.json`
143
+ }));
144
+ process.exit(1);
145
+ }
146
+
147
+ // Load requester rate limiter
148
+ const rateLimiter = new RequestRateLimiter(path.resolve('.lbe/data/rate-limit.db.json'));
149
+ await rateLimiter.load();
150
+
151
+ // Validate command
152
+ const validateResult = validateCommand({
153
+ commandObj: proposal,
154
+ pubKeyB64: pubKey,
155
+ keyStore,
156
+ nonceDb,
157
+ policy,
158
+ rateLimiter
159
+ });
160
+
161
+ if (!validateResult.valid) {
162
+ // Persist state from checks that may record entries prior to rejection.
163
+ try {
164
+ await nonceDb.save();
165
+ await rateLimiter.save();
166
+ } catch {
167
+ // Continue with rejection path even if state persistence fails.
168
+ }
169
+
170
+ const output = {
171
+ status: 'invalid',
172
+ commandId: proposal.commandId || 'N/A',
173
+ checks: validateResult.checks,
174
+ errors: validateResult.errors || [],
175
+ executionResult: null
176
+ };
177
+ console.error(JSON.stringify(output, null, 2));
178
+
179
+ // Load audit log and append this rejection
180
+ const auditPath = path.resolve('.lbe/data/audit.log.jsonl');
181
+ try {
182
+ appendAudit(auditPath, {
183
+ commandId: proposal.commandId || 'N/A',
184
+ status: 'rejected',
185
+ requesterId: proposal.requesterId || 'unknown',
186
+ payloadHash: sha256(proposal),
187
+ reason: validateResult.checks,
188
+ timestamp: new Date().toISOString()
189
+ });
190
+ } catch (auditErr) {
191
+ console.error(JSON.stringify({
192
+ status: 'error',
193
+ error: 'AUDIT_WRITE_FAILED',
194
+ message: auditErr.message
195
+ }));
196
+ process.exit(10);
197
+ }
198
+
199
+ if (validateResult.checks.schema === false) process.exit(5);
200
+ if (validateResult.checks.signature === false) process.exit(3);
201
+ if (validateResult.checks.nonce === false) process.exit(4);
202
+ if (validateResult.checks.timestamp === false) process.exit(6);
203
+ if (validateResult.checks.rateLimit === false) process.exit(7);
204
+ if (validateResult.checks.policy === false) process.exit(2);
205
+ process.exit(9);
206
+ }
207
+
208
+ const risk = validateResult.risk || 'LOW';
209
+ const adapterName = proposal.payload.adapter || 'shell';
210
+ const requesterPolicy = policy.requesters?.[proposal.requesterId];
211
+
212
+ // Approval gate — pause if the requester policy marks this risk level for approval
213
+ const approvalRule = requesterPolicy?.requireApproval;
214
+ const approvalRequired = approvalRule === true
215
+ || (Array.isArray(approvalRule) && (
216
+ approvalRule.includes(risk)
217
+ || approvalRule.includes('*')
218
+ || (['HIGH', 'CRITICAL'].includes(risk) && approvalRule.includes('HIGH+'))
219
+ ));
220
+
221
+ if (approvalRequired) {
222
+ const mgr = getApprovalManager();
223
+ const tokenId = mgr.createToken(proposal.commandId, {
224
+ requesterId: proposal.requesterId,
225
+ adapter: adapterName,
226
+ risk
227
+ });
228
+
229
+ await nonceDb.save().catch(() => {});
230
+ await rateLimiter.save().catch(() => {});
231
+
232
+ console.log(JSON.stringify({
233
+ status: 'approval_required',
234
+ commandId: proposal.commandId || 'N/A',
235
+ risk,
236
+ approvalToken: tokenId,
237
+ message: `${risk} risk operation requires operator approval. Approve with: lbe approve --token ${tokenId}`
238
+ }, null, 2));
239
+ process.exit(11);
240
+ }
241
+
242
+ // Backup — create before execution for file adapter or when --backup flag is set
243
+ let backup = null;
244
+ const shouldBackup = opts.backup === true || adapterName === 'file';
245
+ if (shouldBackup && proposal.payload.target) {
246
+ try {
247
+ backup = createBackup(path.resolve(proposal.payload.target));
248
+ } catch {
249
+ // Non-fatal — execution continues without backup
250
+ }
251
+ }
252
+
253
+ // Execute with appropriate adapter
254
+ let executionResult;
255
+ try {
256
+ executionResult = await executeAdapter(adapterName, proposal, policy, requesterPolicy);
257
+ } catch (error) {
258
+ executionResult = {
259
+ adapter: adapterName,
260
+ status: 'error',
261
+ error: error.message,
262
+ exitCode: 1
263
+ };
264
+ }
265
+
266
+ const executionFailed = executionResult.status === 'error' || executionResult.exitCode !== 0;
267
+
268
+ // Rollback on failure — restore backup if execution failed and we have one
269
+ let rollbackResult = null;
270
+ if (executionFailed && backup && opts['rollback-on-failure'] !== false) {
271
+ try {
272
+ rollbackResult = restoreBackup(backup);
273
+ } catch (e) {
274
+ rollbackResult = { restored: false, error: e.message };
275
+ }
276
+ }
277
+
278
+ // Post-execution validation — verify target exists after a write/patch
279
+ let postValidation = null;
280
+ if (!executionFailed && proposal.payload.target) {
281
+ const writeActions = ['write', 'patch'];
282
+ if (writeActions.includes(proposal.payload.action)) {
283
+ const exists = fs.existsSync(path.resolve(proposal.payload.target));
284
+ postValidation = { ok: exists, check: 'target_exists', target: proposal.payload.target };
285
+ if (!exists && backup) {
286
+ rollbackResult = restoreBackup(backup);
287
+ executionResult.status = 'error';
288
+ }
289
+ }
290
+ }
291
+
292
+ // Log to audit trail
293
+ // payloadHash: SHA-256 of the validated proposal — proves what the adapter received
294
+ // executionHash: SHA-256 of the adapter result — proves what the adapter returned
295
+ // Together these bind the validation result to the execution result in the immutable log
296
+ const auditPath = path.resolve('.lbe/data/audit.log.jsonl');
297
+ try {
298
+ appendAudit(auditPath, {
299
+ commandId: proposal.commandId || 'N/A',
300
+ status: rollbackResult?.restored ? 'rolled_back' : (executionResult.status || 'completed'),
301
+ requesterId: proposal.requesterId || 'unknown',
302
+ payloadHash: sha256(proposal),
303
+ executionHash: sha256(executionResult),
304
+ adapter: executionResult.adapter,
305
+ riskLevel: risk,
306
+ exitCode: executionResult.exitCode || 0,
307
+ rolledBack: rollbackResult?.restored || false,
308
+ timestamp: new Date().toISOString()
309
+ });
310
+ } catch (auditErr) {
311
+ console.error(JSON.stringify({
312
+ status: 'error',
313
+ error: 'AUDIT_WRITE_FAILED',
314
+ message: auditErr.message
315
+ }));
316
+ process.exit(10);
317
+ }
318
+
319
+ // Save nonce DB (records the nonce as used)
320
+ await nonceDb.save();
321
+ await rateLimiter.save();
322
+
323
+ // Output structured result
324
+ const output = {
325
+ status: executionFailed || (postValidation && !postValidation.ok) ? 'failed' : 'executed',
326
+ commandId: proposal.commandId || 'N/A',
327
+ risk,
328
+ checks: validateResult.checks,
329
+ executionResult: {
330
+ adapter: executionResult.adapter,
331
+ status: executionResult.status || 'completed',
332
+ output: executionResult.output || executionResult.error || '',
333
+ exitCode: executionResult.exitCode || 0
334
+ },
335
+ backup: backup ? { path: backup.backupPath, existed: backup.existed, hash: backup.hash } : null,
336
+ rollback: rollbackResult,
337
+ postValidation
338
+ };
339
+
340
+ console.log(JSON.stringify(output, null, 2));
341
+ process.exit(executionResult.exitCode || 0);
342
+ }
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { resolveWorkspaceState } from '../../state/index.js';
4
+ import { stateRoot } from '../../state/stateRoot.js';
5
+ import { isWorkspaceRegistryReadable, listWorkspaces } from '../../state/workspaceRegistry.js';
6
+
7
+ /**
8
+ * lbe status
9
+ *
10
+ * Resolves central state for the workspace and prints a summary.
11
+ * Policy authority stays in .lbe/policy.json — this command reads it
12
+ * for display only. It does not modify or migrate anything.
13
+ *
14
+ * @returns {{ workspaceId, stateDir, policySource, policyMode, hasProof, hasEvents }}
15
+ */
16
+ export async function statusCommand(opts) {
17
+ if (opts.all) {
18
+ const registryPath = opts.registryPath || path.join(stateRoot(), 'registry.json');
19
+ if (!isWorkspaceRegistryReadable(registryPath)) {
20
+ console.log('Workspace registry unreadable');
21
+ return { workspaces: [], registryReadable: false };
22
+ }
23
+
24
+ const workspaces = listWorkspaces(registryPath);
25
+ if (workspaces.length === 0) {
26
+ console.log('No known workspaces yet');
27
+ return { workspaces, registryReadable: true };
28
+ }
29
+
30
+ console.log('\nKnown LBE workspaces');
31
+ for (const workspace of workspaces) {
32
+ console.log(` ${workspace.alias}`);
33
+ console.log(` workspace_id ${workspace.workspaceId}`);
34
+ console.log(` path ${workspace.path}`);
35
+ console.log(` last_active ${workspace.last_active}`);
36
+ }
37
+ console.log('');
38
+ return { workspaces, registryReadable: true };
39
+ }
40
+
41
+ const workspaceRoot = path.resolve(opts.root || process.cwd());
42
+ const { stateDir, workspaceId, paths } = resolveWorkspaceState(workspaceRoot);
43
+
44
+ // ── Policy source (read-only, .lbe/policy.json is authoritative) ──────────
45
+ const policyPath = path.join(workspaceRoot, '.lbe', 'policy.json');
46
+ let policySource = 'not found';
47
+ let policyMode = 'unknown';
48
+ if (fs.existsSync(policyPath)) {
49
+ try {
50
+ const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
51
+ policyMode = policy.mode || 'unknown';
52
+ policySource = policyPath;
53
+ } catch (_) {
54
+ policySource = policyPath + ' (unreadable)';
55
+ }
56
+ }
57
+
58
+ // ── Central state presence ─────────────────────────────────────────────────
59
+ const hasProof = fs.existsSync(paths.proofLatest);
60
+ const hasEvents = fs.existsSync(paths.events);
61
+
62
+ // ── Output ─────────────────────────────────────────────────────────────────
63
+ console.log(`\nLBE Central State — ${workspaceRoot}`);
64
+ console.log(` workspace_id ${workspaceId}`);
65
+ console.log(` state_dir ${stateDir}`);
66
+ console.log(` policy_source ${policySource}`);
67
+ console.log(` policy_mode ${policyMode}`);
68
+ console.log(` central_proof ${hasProof ? paths.proofLatest : 'No central proof yet'}`);
69
+ console.log(` central_logs ${hasEvents ? paths.events : 'No central logs yet. Hook dual-write not enabled.'}`);
70
+ console.log('');
71
+
72
+ return { workspaceId, stateDir, policySource, policyMode, hasProof, hasEvents };
73
+ }
@@ -0,0 +1,144 @@
1
+ // src/cli/commands/verify.js
2
+ // Validate a proposal without executing
3
+
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { validateCommand } from '../../core/validator.js';
7
+ import { NonceStore } from '../../core/nonceStore.js';
8
+ import { loadKeysStore } from '../../core/trustedKeys.js';
9
+ import { verifyPolicySignature } from '../../core/policySignature.js';
10
+ import { validateAndUpdatePolicyVersionState } from '../../core/policyVersionGuard.js';
11
+
12
+ export async function verifyCommand(opts) {
13
+ const { in: inFile } = opts;
14
+ const config = opts.config || opts.policy;
15
+ const pubKey = opts['pub-key'];
16
+ const keysStorePath = opts['keys-store'] || path.resolve('.lbe/config/keys.json');
17
+ const policySigPath = opts['policy-sig'] || path.resolve('.lbe/config/policy.sig.json');
18
+ const policyStatePath = opts['policy-state'] || path.resolve('.lbe/data/policy.state.json');
19
+ const allowUnsignedPolicy = opts['policy-unsigned-ok'] === true || String(opts['policy-unsigned-ok']).toLowerCase() === 'true';
20
+ // Validate required arguments
21
+ if (!inFile) {
22
+ console.error('Error: --in <file> is required');
23
+ process.exit(1);
24
+ }
25
+
26
+ // Read proposal file
27
+ let proposal;
28
+ try {
29
+ const filePath = path.resolve(inFile);
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ proposal = JSON.parse(content);
32
+ } catch (error) {
33
+ console.error(JSON.stringify({
34
+ status: 'error',
35
+ error: 'INVALID_PROPOSAL_FILE',
36
+ message: error.message
37
+ }));
38
+ process.exit(5);
39
+ }
40
+
41
+ // Load policy
42
+ let policy;
43
+ try {
44
+ const policyPath = config || path.resolve('.lbe/config/policy.default.json');
45
+ if (!fs.existsSync(policyPath)) {
46
+ console.error(JSON.stringify({
47
+ status: 'error',
48
+ error: 'MISSING_POLICY',
49
+ message: `Policy file not found: ${policyPath}`
50
+ }));
51
+ process.exit(1);
52
+ }
53
+ const policyContent = fs.readFileSync(policyPath, 'utf-8');
54
+ policy = JSON.parse(policyContent);
55
+ } catch (error) {
56
+ console.error(JSON.stringify({
57
+ status: 'error',
58
+ error: 'INVALID_POLICY',
59
+ message: error.message
60
+ }));
61
+ process.exit(1);
62
+ }
63
+
64
+ // Load key store (preferred) with legacy pub-key fallback
65
+ const keyStoreResult = loadKeysStore(keysStorePath);
66
+ const keyStore = keyStoreResult.ok ? keyStoreResult.store : null;
67
+
68
+ // Preflight: policy signature verification (strict by default)
69
+ const policySigCheck = verifyPolicySignature({
70
+ policyObj: policy,
71
+ keyStore,
72
+ policySigPath,
73
+ allowUnsigned: allowUnsignedPolicy
74
+ });
75
+ if (!policySigCheck.ok) {
76
+ console.error(JSON.stringify({
77
+ status: 'error',
78
+ error: policySigCheck.reason,
79
+ message: policySigCheck.message
80
+ }, null, 2));
81
+ process.exit(8);
82
+ }
83
+
84
+ const versionCheck = validateAndUpdatePolicyVersionState({
85
+ policyObj: policy,
86
+ statePath: policyStatePath,
87
+ maxCreatedAtSkewSec: policy?.security?.maxPolicyCreatedAtSkewSec
88
+ });
89
+ if (!versionCheck.ok) {
90
+ console.error(JSON.stringify({
91
+ status: 'error',
92
+ error: versionCheck.reason,
93
+ message: versionCheck.message
94
+ }, null, 2));
95
+ process.exit(8);
96
+ }
97
+
98
+ // Load nonce store
99
+ const nonceDb = new NonceStore(path.resolve('.lbe/data/nonce.db.json'));
100
+ await nonceDb.load();
101
+
102
+ if (!keyStore && !pubKey) {
103
+ console.error(JSON.stringify({
104
+ status: 'error',
105
+ error: 'MISSING_KEY_MATERIAL',
106
+ message: `${keyStoreResult.message}. Provide --pub-key/--pub-key-file or create .lbe/config/keys.json`
107
+ }));
108
+ process.exit(1);
109
+ }
110
+
111
+ // Validate command
112
+ const result = validateCommand({
113
+ commandObj: proposal,
114
+ pubKeyB64: pubKey,
115
+ keyStore,
116
+ nonceDb,
117
+ policy
118
+ });
119
+
120
+ // Output structured result
121
+ const output = {
122
+ status: result.valid ? 'valid' : 'invalid',
123
+ commandId: proposal.commandId || 'N/A',
124
+ checks: result.checks,
125
+ errors: result.errors || [],
126
+ risk: result.risk || 'UNKNOWN'
127
+ };
128
+
129
+ console.log(JSON.stringify(output, null, 2));
130
+
131
+ // Exit with appropriate code
132
+ if (!result.valid) {
133
+ // Determine which validation failed for exit code
134
+ if (result.checks.schema === false) process.exit(5); // Schema error
135
+ if (result.checks.signature === false) process.exit(3); // Signature error
136
+ if (result.checks.nonce === false) process.exit(4); // Replay detected
137
+ if (result.checks.timestamp === false) process.exit(6); // Clock skew
138
+ if (result.checks.rateLimit === false) process.exit(7); // Rate limited
139
+ if (result.checks.policy === false) process.exit(2); // Policy blocked
140
+ process.exit(9); // Generic error
141
+ }
142
+
143
+ process.exit(0);
144
+ }