@letterblack/lbe-core 1.3.4 → 1.3.6
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/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +2 -0
- package/CHANGELOG.md +81 -0
- package/LICENSE +1 -1
- package/README.md +158 -170
- package/RELEASE_WORKSPACE_RULES.md +179 -0
- package/Release-README.md +67 -0
- package/WORKSPACE.md +422 -0
- package/_proof.mjs +246 -0
- package/assets/runtime-boundary.svg +36 -36
- package/bin/lbe.js +12 -0
- package/config/identity.config.json +3 -0
- package/config/policy.default.json +24 -0
- package/dist/cli/lbe.js +4431 -0
- package/dist/hooks/register.cjs +505 -0
- package/dist/state/appendCentral.cjs +87 -0
- package/dist/state/index.cjs +101 -0
- package/exec/cli.js +472 -0
- package/exec/index.js +2 -0
- package/index.js +24 -0
- package/npm-pack.json +0 -0
- package/package.json +77 -45
- package/release/README.md +216 -0
- package/release/TRUST.md +90 -0
- package/release/exec-README.md +215 -0
- package/release/exec-types.d.ts +50 -0
- package/release-exec/LICENSE +1 -0
- package/release-exec/README.md +215 -0
- package/release-exec/assets/lbe-gates.jpg +0 -0
- package/release-exec/assets/lbe-gates.png +0 -0
- package/release-exec/assets/runtime-boundary.svg +36 -0
- package/release-exec/assets/story-allow.jpg +0 -0
- package/release-exec/assets/story-allow.png +0 -0
- package/release-exec/assets/story-deny.jpg +0 -0
- package/release-exec/assets/story-deny.png +0 -0
- package/release-exec/dist/cli.js +2841 -0
- package/release-exec/dist/index.js +1835 -0
- package/release-exec/dist/lbe_engine.wasm +0 -0
- package/{dist → release-exec/dist}/wasm.lock.json +4 -5
- package/release-exec/hooks/register.cjs +473 -0
- package/release-exec/package.json +35 -0
- package/release-exec/types.d.ts +50 -0
- package/runtime/engine.js +322 -0
- package/runtime/lbe_engine.wasm +0 -0
- package/src/cli/commands/assertConsumer.js +198 -0
- package/src/cli/commands/auditVerify.js +36 -0
- package/src/cli/commands/dryrun.js +175 -0
- package/src/cli/commands/health.js +153 -0
- package/src/cli/commands/init.js +306 -0
- package/src/cli/commands/integrityCheck.js +57 -0
- package/src/cli/commands/logs.js +53 -0
- package/src/cli/commands/openState.js +44 -0
- package/src/cli/commands/policyAdd.js +8 -0
- package/src/cli/commands/policyMode.js +7 -0
- package/src/cli/commands/policySign.js +72 -0
- package/src/cli/commands/proof.js +102 -0
- package/src/cli/commands/run.js +342 -0
- package/src/cli/commands/status.js +73 -0
- package/src/cli/commands/verify.js +144 -0
- package/src/cli/main.js +181 -0
- package/src/cli/parseArgs.js +115 -0
- package/src/exec/localExecutor.js +289 -0
- package/src/hooks/register.cjs +505 -0
- package/src/state/appendCentral.cjs +87 -0
- package/src/state/fileIndex.js +140 -0
- package/src/state/index.cjs +101 -0
- package/src/state/index.js +65 -0
- package/src/state/intentRegistry.js +84 -0
- package/src/state/migration.js +112 -0
- package/src/state/proofRunner.js +246 -0
- package/src/state/stateRoot.js +40 -0
- package/src/state/targetRegistry.js +109 -0
- package/src/state/workspaceId.js +40 -0
- package/src/state/workspaceRegistry.js +65 -0
- package/types.d.ts +175 -2
- package/dist/cli.js +0 -141
- package/dist/index.js +0 -52
- /package/dist/{lbe_engine.wasm → cli/lbe_engine.wasm} +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveWorkspaceState } from '../../state/index.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LIMIT = 20;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* lbe logs [--limit <n>]
|
|
9
|
+
*
|
|
10
|
+
* Reads central lbe-events.jsonl and prints the last N entries.
|
|
11
|
+
* If the file does not exist, prints a clear "not yet" message — it means
|
|
12
|
+
* hook dual-write (alpha2 of the hook change) has not been enabled yet.
|
|
13
|
+
*
|
|
14
|
+
* Reads only. Does not write, migrate, or modify anything.
|
|
15
|
+
*
|
|
16
|
+
* @returns {{ eventsPath, count, entries, missing }}
|
|
17
|
+
*/
|
|
18
|
+
export async function logsCommand(opts) {
|
|
19
|
+
const workspaceRoot = path.resolve(opts.root || process.cwd());
|
|
20
|
+
const { paths } = resolveWorkspaceState(workspaceRoot);
|
|
21
|
+
const limit = opts.limit ? parseInt(opts.limit, 10) : DEFAULT_LIMIT;
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(paths.events)) {
|
|
24
|
+
console.log('\nLBE Central Logs');
|
|
25
|
+
console.log(' No central logs yet. Hook dual-write not enabled.');
|
|
26
|
+
console.log(` Expected at: ${paths.events}`);
|
|
27
|
+
console.log('');
|
|
28
|
+
return { eventsPath: paths.events, count: 0, entries: [], missing: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const raw = fs.readFileSync(paths.events, 'utf8').trim();
|
|
32
|
+
const lines = raw ? raw.split('\n') : [];
|
|
33
|
+
|
|
34
|
+
const entries = lines
|
|
35
|
+
.map(line => { try { return JSON.parse(line); } catch (_) { return null; } })
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
|
|
38
|
+
const tail = entries.slice(-limit);
|
|
39
|
+
|
|
40
|
+
console.log(`\nLBE Central Logs — last ${tail.length} of ${entries.length} entries`);
|
|
41
|
+
console.log(` source: ${paths.events}\n`);
|
|
42
|
+
|
|
43
|
+
for (const entry of tail) {
|
|
44
|
+
const ts = entry.ts ? new Date(entry.ts * 1000).toISOString() : '?';
|
|
45
|
+
const action = entry.action || '?';
|
|
46
|
+
const dec = entry.decision || '?';
|
|
47
|
+
const target = entry.path || entry.cmd || '';
|
|
48
|
+
console.log(` [${ts}] ${dec.toUpperCase().padEnd(5)} ${action} ${target}`);
|
|
49
|
+
}
|
|
50
|
+
console.log('');
|
|
51
|
+
|
|
52
|
+
return { eventsPath: paths.events, count: entries.length, entries: tail, missing: false };
|
|
53
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveWorkspaceState } from '../../state/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* lbe open-state
|
|
7
|
+
*
|
|
8
|
+
* Resolves the central state directory and opens it in the system file manager.
|
|
9
|
+
* Always prints the path regardless of whether the open succeeds — so the user
|
|
10
|
+
* can copy-paste it even if the file manager call fails.
|
|
11
|
+
*
|
|
12
|
+
* Set env LBE_NO_OPEN=1 to skip the subprocess (used in tests and CI).
|
|
13
|
+
*
|
|
14
|
+
* @returns {{ stateDir, opened }}
|
|
15
|
+
*/
|
|
16
|
+
export async function openStateCommand(opts) {
|
|
17
|
+
const workspaceRoot = path.resolve(opts.root || process.cwd());
|
|
18
|
+
const { stateDir } = resolveWorkspaceState(workspaceRoot);
|
|
19
|
+
|
|
20
|
+
console.log(`\nLBE Central State Directory`);
|
|
21
|
+
console.log(` ${stateDir}\n`);
|
|
22
|
+
|
|
23
|
+
if (process.env.LBE_NO_OPEN === '1') {
|
|
24
|
+
return { stateDir, opened: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let opened = false;
|
|
28
|
+
try {
|
|
29
|
+
if (process.platform === 'win32') {
|
|
30
|
+
spawnSync('explorer.exe', [stateDir], { detached: true, stdio: 'ignore' });
|
|
31
|
+
opened = true;
|
|
32
|
+
} else if (process.platform === 'darwin') {
|
|
33
|
+
spawnSync('open', [stateDir], { detached: true, stdio: 'ignore' });
|
|
34
|
+
opened = true;
|
|
35
|
+
} else {
|
|
36
|
+
spawnSync('xdg-open', [stateDir], { detached: true, stdio: 'ignore' });
|
|
37
|
+
opened = true;
|
|
38
|
+
}
|
|
39
|
+
} catch (_) {
|
|
40
|
+
// Non-fatal — path was already printed above.
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { stateDir, opened };
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { addLocalPolicyRule } from '../../core/localPolicy.js';
|
|
2
|
+
|
|
3
|
+
export async function policyAddCommand(opts = {}) {
|
|
4
|
+
const result = addLocalPolicyRule(opts.root || process.cwd(), {
|
|
5
|
+
effect: opts.effect, type: opts.type, pattern: opts.pattern, from: opts.from
|
|
6
|
+
}, opts.mode);
|
|
7
|
+
console.log(JSON.stringify(result, null, 2));
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { loadLocalPolicy, writeLocalPolicy } from '../../core/localPolicy.js';
|
|
2
|
+
|
|
3
|
+
export async function policyModeCommand(mode, opts = {}) {
|
|
4
|
+
const loaded = loadLocalPolicy(opts.root || process.cwd(), mode);
|
|
5
|
+
writeLocalPolicy(loaded.root, { ...loaded.policy, mode });
|
|
6
|
+
console.log(JSON.stringify({ mode, policy: loaded.policyPath }, null, 2));
|
|
7
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/cli/commands/policySign.js
|
|
2
|
+
// Sign policy.json and write signature envelope
|
|
3
|
+
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { createPolicySignatureEnvelope } from '../../core/policySignature.js';
|
|
7
|
+
|
|
8
|
+
export async function policySignCommand(opts) {
|
|
9
|
+
const policyPath = path.resolve(opts.config || opts.policy || '.lbe/config/policy.default.json');
|
|
10
|
+
const sigPath = path.resolve(opts['policy-sig'] || '.lbe/config/policy.sig.json');
|
|
11
|
+
const secretKeyPath = path.resolve(opts['secret-key-file'] || '.lbe/keys/secret.key');
|
|
12
|
+
const keyId = String(opts['policy-key-id'] || 'policy-signer-v1-2026Q1');
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(policyPath)) {
|
|
15
|
+
console.error(JSON.stringify({
|
|
16
|
+
status: 'error',
|
|
17
|
+
error: 'POLICY_FILE_MISSING',
|
|
18
|
+
message: `Policy file not found: ${policyPath}`
|
|
19
|
+
}, null, 2));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(secretKeyPath)) {
|
|
24
|
+
console.error(JSON.stringify({
|
|
25
|
+
status: 'error',
|
|
26
|
+
error: 'SECRET_KEY_MISSING',
|
|
27
|
+
message: `Secret key file not found: ${secretKeyPath}`
|
|
28
|
+
}, null, 2));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const policyObj = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
33
|
+
if (typeof policyObj.version === 'undefined' || typeof policyObj.createdAt === 'undefined') {
|
|
34
|
+
console.error(JSON.stringify({
|
|
35
|
+
status: 'error',
|
|
36
|
+
error: 'POLICY_VERSION_METADATA_MISSING',
|
|
37
|
+
message: 'Policy must include version and createdAt before signing'
|
|
38
|
+
}, null, 2));
|
|
39
|
+
process.exit(8);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const secretKeyB64 = fs.readFileSync(secretKeyPath, 'utf8').trim();
|
|
43
|
+
const signResult = createPolicySignatureEnvelope({
|
|
44
|
+
policyObj,
|
|
45
|
+
secretKeyB64,
|
|
46
|
+
keyId
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!signResult.ok) {
|
|
50
|
+
console.error(JSON.stringify({
|
|
51
|
+
status: 'error',
|
|
52
|
+
error: signResult.reason || 'POLICY_SIGN_FAILED',
|
|
53
|
+
message: signResult.message
|
|
54
|
+
}, null, 2));
|
|
55
|
+
process.exit(8);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const outDir = path.dirname(sigPath);
|
|
59
|
+
if (!fs.existsSync(outDir)) {
|
|
60
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
fs.writeFileSync(sigPath, JSON.stringify(signResult.envelope, null, 2));
|
|
63
|
+
|
|
64
|
+
console.log(JSON.stringify({
|
|
65
|
+
status: 'ok',
|
|
66
|
+
message: 'Policy signature written',
|
|
67
|
+
policy: policyPath,
|
|
68
|
+
policySig: sigPath,
|
|
69
|
+
keyId
|
|
70
|
+
}, null, 2));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveWorkspaceState } from '../../state/index.js';
|
|
4
|
+
import { loadLatestProof } from '../../state/proofRunner.js';
|
|
5
|
+
|
|
6
|
+
function buildPublicProof(proof, targets) {
|
|
7
|
+
const lastTarget = Array.isArray(targets) && targets.length > 0
|
|
8
|
+
? targets[targets.length - 1]
|
|
9
|
+
: null;
|
|
10
|
+
|
|
11
|
+
// Redact: only expose safe, non-identifying fields
|
|
12
|
+
const pub = {
|
|
13
|
+
result: proof.result,
|
|
14
|
+
profile: proof.profile,
|
|
15
|
+
checks: proof.checks_run || [],
|
|
16
|
+
allow_deny: (proof.failures && proof.failures.length > 0) ? 'deny' : 'allow',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
if (lastTarget) {
|
|
20
|
+
pub.target_type = lastTarget.kind || null;
|
|
21
|
+
pub.target_label = lastTarget.label || null;
|
|
22
|
+
// component_file is a relative path — safe to include as-is
|
|
23
|
+
pub.target_file = lastTarget.component_file || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Include failure reasons but NOT the raw file paths
|
|
27
|
+
if (proof.failures && proof.failures.length > 0) {
|
|
28
|
+
pub.failure_reasons = proof.failures.map(f => ({ check: f.check, reason: f.reason }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return pub;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* lbe proof [--json] [--public] [--root <path>]
|
|
36
|
+
*
|
|
37
|
+
* Reads proof/latest.json from the central state store and prints it.
|
|
38
|
+
* Does not re-run proof; use the programmatic API for that.
|
|
39
|
+
*
|
|
40
|
+
* @returns {{ result, profile, found } | null}
|
|
41
|
+
*/
|
|
42
|
+
export async function proofCommand(opts) {
|
|
43
|
+
const workspaceRoot = path.resolve(opts.root || process.cwd());
|
|
44
|
+
const { stateDir } = resolveWorkspaceState(workspaceRoot);
|
|
45
|
+
|
|
46
|
+
const proof = loadLatestProof(stateDir);
|
|
47
|
+
const isPublic = opts.public === true || opts.public === 'true';
|
|
48
|
+
const isJson = opts.json === true || opts.json === 'true' || isPublic;
|
|
49
|
+
|
|
50
|
+
if (!proof) {
|
|
51
|
+
if (isJson) {
|
|
52
|
+
console.log(JSON.stringify({ found: false, message: 'No proof record found. Run lbe proof after using the hook.' }, null, 2));
|
|
53
|
+
} else {
|
|
54
|
+
console.log('\nNo proof record found.');
|
|
55
|
+
console.log('Use the hook-protected workflow and then run: lbe proof\n');
|
|
56
|
+
}
|
|
57
|
+
return { found: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Load targets for public redaction
|
|
61
|
+
let targets = [];
|
|
62
|
+
if (isPublic) {
|
|
63
|
+
const targetPath = path.join(stateDir, 'target_registry.jsonl');
|
|
64
|
+
if (fs.existsSync(targetPath)) {
|
|
65
|
+
const raw = fs.readFileSync(targetPath, 'utf8').trim();
|
|
66
|
+
targets = raw ? raw.split('\n').reduce((acc, l) => {
|
|
67
|
+
try { acc.push(JSON.parse(l)); } catch (_) { /* ignore */ }
|
|
68
|
+
return acc;
|
|
69
|
+
}, []) : [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isPublic) {
|
|
74
|
+
const pub = buildPublicProof(proof, targets);
|
|
75
|
+
console.log(JSON.stringify(pub, null, 2));
|
|
76
|
+
return { found: true, result: proof.result, profile: proof.profile, public: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isJson) {
|
|
80
|
+
console.log(JSON.stringify(proof, null, 2));
|
|
81
|
+
return { found: true, result: proof.result, profile: proof.profile };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Human-readable output
|
|
85
|
+
const resultMark = proof.result === 'PASS' ? '✓' : proof.result === 'WEAK_PROOF' ? '⚠' : '✗';
|
|
86
|
+
const workspace = path.basename(workspaceRoot);
|
|
87
|
+
console.log(`\nLBE Proof — ${workspace}`);
|
|
88
|
+
console.log(` Result ${resultMark} ${proof.result}`);
|
|
89
|
+
console.log(` Profile ${proof.profile}`);
|
|
90
|
+
console.log(` Changed files ${(proof.files_changed || []).length}`);
|
|
91
|
+
console.log(` Checks run ${(proof.checks_run || []).join(', ')}`);
|
|
92
|
+
if (proof.failures && proof.failures.length > 0) {
|
|
93
|
+
console.log(` Failures ${proof.failures.length}`);
|
|
94
|
+
for (const f of proof.failures) {
|
|
95
|
+
console.log(` • [${f.check}] ${f.reason}${f.file ? ': ' + f.file : ''}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
console.log(` Recorded at ${proof.ts}`);
|
|
99
|
+
console.log('');
|
|
100
|
+
|
|
101
|
+
return { found: true, result: proof.result, profile: proof.profile };
|
|
102
|
+
}
|
|
@@ -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
|
+
}
|