@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.
Files changed (78) hide show
  1. package/.githooks/pre-commit +2 -0
  2. package/.githooks/pre-push +2 -0
  3. package/CHANGELOG.md +81 -0
  4. package/LICENSE +1 -1
  5. package/README.md +158 -170
  6. package/RELEASE_WORKSPACE_RULES.md +179 -0
  7. package/Release-README.md +67 -0
  8. package/WORKSPACE.md +422 -0
  9. package/_proof.mjs +246 -0
  10. package/assets/runtime-boundary.svg +36 -36
  11. package/bin/lbe.js +12 -0
  12. package/config/identity.config.json +3 -0
  13. package/config/policy.default.json +24 -0
  14. package/dist/cli/lbe.js +4431 -0
  15. package/dist/hooks/register.cjs +505 -0
  16. package/dist/state/appendCentral.cjs +87 -0
  17. package/dist/state/index.cjs +101 -0
  18. package/exec/cli.js +472 -0
  19. package/exec/index.js +2 -0
  20. package/index.js +24 -0
  21. package/npm-pack.json +0 -0
  22. package/package.json +77 -45
  23. package/release/README.md +216 -0
  24. package/release/TRUST.md +90 -0
  25. package/release/exec-README.md +215 -0
  26. package/release/exec-types.d.ts +50 -0
  27. package/release-exec/LICENSE +1 -0
  28. package/release-exec/README.md +215 -0
  29. package/release-exec/assets/lbe-gates.jpg +0 -0
  30. package/release-exec/assets/lbe-gates.png +0 -0
  31. package/release-exec/assets/runtime-boundary.svg +36 -0
  32. package/release-exec/assets/story-allow.jpg +0 -0
  33. package/release-exec/assets/story-allow.png +0 -0
  34. package/release-exec/assets/story-deny.jpg +0 -0
  35. package/release-exec/assets/story-deny.png +0 -0
  36. package/release-exec/dist/cli.js +2841 -0
  37. package/release-exec/dist/index.js +1835 -0
  38. package/release-exec/dist/lbe_engine.wasm +0 -0
  39. package/{dist → release-exec/dist}/wasm.lock.json +4 -5
  40. package/release-exec/hooks/register.cjs +473 -0
  41. package/release-exec/package.json +35 -0
  42. package/release-exec/types.d.ts +50 -0
  43. package/runtime/engine.js +322 -0
  44. package/runtime/lbe_engine.wasm +0 -0
  45. package/src/cli/commands/assertConsumer.js +198 -0
  46. package/src/cli/commands/auditVerify.js +36 -0
  47. package/src/cli/commands/dryrun.js +175 -0
  48. package/src/cli/commands/health.js +153 -0
  49. package/src/cli/commands/init.js +306 -0
  50. package/src/cli/commands/integrityCheck.js +57 -0
  51. package/src/cli/commands/logs.js +53 -0
  52. package/src/cli/commands/openState.js +44 -0
  53. package/src/cli/commands/policyAdd.js +8 -0
  54. package/src/cli/commands/policyMode.js +7 -0
  55. package/src/cli/commands/policySign.js +72 -0
  56. package/src/cli/commands/proof.js +102 -0
  57. package/src/cli/commands/run.js +342 -0
  58. package/src/cli/commands/status.js +73 -0
  59. package/src/cli/commands/verify.js +144 -0
  60. package/src/cli/main.js +181 -0
  61. package/src/cli/parseArgs.js +115 -0
  62. package/src/exec/localExecutor.js +289 -0
  63. package/src/hooks/register.cjs +505 -0
  64. package/src/state/appendCentral.cjs +87 -0
  65. package/src/state/fileIndex.js +140 -0
  66. package/src/state/index.cjs +101 -0
  67. package/src/state/index.js +65 -0
  68. package/src/state/intentRegistry.js +84 -0
  69. package/src/state/migration.js +112 -0
  70. package/src/state/proofRunner.js +246 -0
  71. package/src/state/stateRoot.js +40 -0
  72. package/src/state/targetRegistry.js +109 -0
  73. package/src/state/workspaceId.js +40 -0
  74. package/src/state/workspaceRegistry.js +65 -0
  75. package/types.d.ts +175 -2
  76. package/dist/cli.js +0 -141
  77. package/dist/index.js +0 -52
  78. /package/dist/{lbe_engine.wasm → cli/lbe_engine.wasm} +0 -0
@@ -0,0 +1,289 @@
1
+ // Trusted, in-process execution controller. Agents submit simple requests;
2
+ // this controller creates the timestamp/nonce/signature envelope locally.
3
+ import crypto from 'crypto';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { generateKeyPair, signEd25519 } from '../core/signature.js';
7
+ import { validateCommand } from '../core/validator.js';
8
+ import { executeAdapter } from '../adapters/index.js';
9
+ import { appendAudit, verifyAuditLogIntegrity } from '../core/auditLog.js';
10
+ import { addLocalPolicyRule, auditLocalPolicy, evaluateLocalPolicy, loadLocalPolicy, proposePolicyRule } from '../core/localPolicy.js';
11
+
12
+ const INTENTS = {
13
+ read_file: { id: 'READ_FILE', adapter: 'file', action: 'read' },
14
+ write_file: { id: 'WRITE_FILE', adapter: 'file', action: 'write' },
15
+ patch_file: { id: 'PATCH_FILE', adapter: 'file', action: 'patch' },
16
+ delete_file: { id: 'DELETE_FILE', adapter: 'file', action: 'delete' },
17
+ run_shell: { id: 'RUN_SHELL', adapter: 'shell', action: 'run' }
18
+ };
19
+
20
+ const MUTATIONS = new Set(['write_file', 'patch_file', 'delete_file']);
21
+
22
+ function error(code, message, recoverable = false) {
23
+ return { ok: false, decision: 'deny', executed: false, dryRun: false, error: { code, message, recoverable } };
24
+ }
25
+
26
+ function commandPolicy(rootDir, actor, shell = {}) {
27
+ const now = new Date();
28
+ const expires = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
29
+ return {
30
+ version: 1,
31
+ default: 'DENY',
32
+ requesters: {
33
+ [actor]: {
34
+ allowCommands: Object.values(INTENTS).map(item => item.id),
35
+ allowAdapters: ['file', 'shell'],
36
+ filesystem: { roots: [rootDir], denyPatterns: [] },
37
+ exec: { allowCmds: shell.allowCommands || [], denyCmds: shell.denyCommands || [] },
38
+ rateLimit: { windowSec: 60, maxRequests: shell.maxRequests || 60 }
39
+ }
40
+ },
41
+ security: { maxClockSkewSec: 600, defaultRateLimit: { windowSec: 60, maxRequests: 60 } },
42
+ _keyWindow: { notBefore: now.toISOString(), expiresAt: expires.toISOString() }
43
+ };
44
+ }
45
+
46
+ function physicalPath(candidate) {
47
+ let current = path.resolve(candidate);
48
+ const suffix = [];
49
+ while (!fs.existsSync(current)) {
50
+ const parent = path.dirname(current);
51
+ if (parent === current) break;
52
+ suffix.unshift(path.basename(current));
53
+ current = parent;
54
+ }
55
+ try { current = fs.realpathSync(current); } catch { /* lexical fallback */ }
56
+ return path.join(current, ...suffix);
57
+ }
58
+
59
+ function underRoot(candidate, root) {
60
+ const target = physicalPath(candidate);
61
+ const resolvedRoot = physicalPath(root);
62
+ return target === resolvedRoot || target.startsWith(resolvedRoot + path.sep);
63
+ }
64
+
65
+ const FORBIDDEN_CONTENT = [
66
+ /\beval\s*\(/i, /\bFunction\s*\(/i, /\bexec\s*\(/i,
67
+ /\brequire\s*\(/, /\bimport\s*\(/, /\bchild_process\b/,
68
+ /\b__proto__\b/, /\bconstructor\s*\[/, /evalScript/i,
69
+ ];
70
+
71
+ function scanContent(value, fieldName) {
72
+ if (typeof value !== 'string') return null;
73
+ for (const pattern of FORBIDDEN_CONTENT) {
74
+ if (pattern.test(value)) {
75
+ return error('PAYLOAD_CONTENT_REJECTED', `Forbidden pattern in ${fieldName}: ${pattern}`);
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function normalize(rootDir, request, shell = {}) {
82
+ if (!request || typeof request !== 'object') return { error: error('REQUEST_INVALID', 'request must be an object') };
83
+ const detail = INTENTS[request.intent];
84
+ if (!detail) return { error: error('INTENT_UNSUPPORTED', `Unsupported intent '${request.intent}'`) };
85
+ const actor = typeof request.actor === 'string' && request.actor ? request.actor : 'agent:local';
86
+ let target = null;
87
+ if (detail.adapter === 'file') {
88
+ if (typeof request.target !== 'string' || !request.target) return { error: error('TARGET_REQUIRED', 'target is required for file intents') };
89
+ target = path.resolve(rootDir, request.target);
90
+ if (!underRoot(target, rootDir)) return { error: error('PATH_OUTSIDE_ROOT', 'target is outside project root') };
91
+ if (['write_file', 'patch_file'].includes(request.intent) && typeof request.content !== 'string') {
92
+ return { error: error('CONTENT_REQUIRED', 'content is required for write and patch') };
93
+ }
94
+ const contentScan = scanContent(request.content, 'content');
95
+ if (contentScan) return { error: contentScan };
96
+ }
97
+ let command = null;
98
+ if (detail.adapter === 'shell') {
99
+ command = request.command;
100
+ if (!command || typeof command.cmd !== 'string' || !Array.isArray(command.args) || command.args.some(arg => typeof arg !== 'string')) {
101
+ return { error: error('COMMAND_INVALID', 'command requires cmd and string args') };
102
+ }
103
+ const cwd = path.resolve(rootDir, command.cwd || '.');
104
+ if (!underRoot(cwd, rootDir)) return { error: error('CWD_OUTSIDE_ROOT', 'command cwd is outside project root') };
105
+ if (!Array.isArray(shell.allowCommands) || !shell.allowCommands.includes(command.cmd)) {
106
+ return { error: error('SHELL_NOT_ALLOWLISTED', `command '${command.cmd}' is not explicitly allowlisted`) };
107
+ }
108
+ if (shell.denyCommands?.includes(command.cmd)) return { error: error('SHELL_DENIED', `command '${command.cmd}' is denied`) };
109
+ command = { ...command, cwd, timeoutMs: Math.min(Math.max(command.timeoutMs || 30000, 1), 30000), maxOutputBytes: Math.min(Math.max(command.maxOutputBytes || 1024 * 1024, 1024), 1024 * 1024) };
110
+ }
111
+ return { actor, detail, target, command, request };
112
+ }
113
+
114
+ function envelope(normalized, keyId, secretKey) {
115
+ const { actor, detail, target, command, request } = normalized;
116
+ const body = {
117
+ id: detail.id,
118
+ risk: MUTATIONS.has(request.intent) ? 'MEDIUM' : 'LOW',
119
+ commandId: crypto.randomUUID(),
120
+ requesterId: actor,
121
+ sessionId: 'local-host',
122
+ timestamp: Math.floor(Date.now() / 1000),
123
+ nonce: crypto.randomBytes(32).toString('hex'),
124
+ requires: ['policy', 'signature'],
125
+ payload: {
126
+ adapter: detail.adapter,
127
+ action: detail.action,
128
+ target,
129
+ content: request.content,
130
+ cmd: command?.cmd,
131
+ args: command?.args,
132
+ timeoutMs: command?.timeoutMs,
133
+ maxOutputBytes: command?.maxOutputBytes,
134
+ cwd: command?.cwd || (target ? path.dirname(target) : process.cwd())
135
+ }
136
+ };
137
+ const signed = signEd25519({ payloadObj: body, secretKeyB64: secretKey });
138
+ if (signed.error) throw new Error(signed.error);
139
+ return { ...body, signature: { alg: 'ed25519', keyId, sig: signed.signature } };
140
+ }
141
+
142
+ export function createLocalExecutor(options = {}) {
143
+ const rootDir = path.resolve(options.rootDir || process.cwd());
144
+ const keyId = options.keyId || 'host:local-exec';
145
+ const keyPair = options.keyPair || generateKeyPair();
146
+ const shell = options.shell || {};
147
+
148
+ function prepare(request, { recordNonce = false } = {}) {
149
+ const normalized = normalize(rootDir, request, shell);
150
+ if (normalized.error) return normalized;
151
+ // When no mode is passed and no policy file exists, default to enforce.
152
+ // Observe mode activates only when explicitly set via options.mode or via
153
+ // npx lbe init which writes lbe.policy.json with mode:'observe'.
154
+ const local = loadLocalPolicy(rootDir, options.mode || 'enforce');
155
+ const localDecision = evaluateLocalPolicy(local.policy, rootDir, { target: normalized.target, command: normalized.command?.cmd });
156
+ const localBlocked = local.policy.mode === 'enforce' && !localDecision.allowed;
157
+ if (localBlocked) return { error: error('LOCAL_POLICY_DENY', `Blocked by rule(s): ${localDecision.winningRules.map(rule => rule.id).join(', ')}`), local, localDecision, normalized };
158
+ const policy = commandPolicy(rootDir, normalized.actor, shell);
159
+ const keyStore = { defaultKeyId: keyId, trustedKeys: { [keyId]: { publicKey: keyPair.publicKey, notBefore: policy._keyWindow.notBefore, expiresAt: policy._keyWindow.expiresAt, deprecated: false } } };
160
+ delete policy._keyWindow;
161
+ const proposal = envelope(normalized, keyId, keyPair.secretKey);
162
+ const nonceDb = { entries: [] };
163
+ const validation = validateCommand({ commandObj: proposal, keyStore, nonceDb: recordNonce ? nonceDb : { entries: [] }, policy });
164
+ if (!validation.valid) return { error: error(validation.errors[0]?.type || 'VALIDATION_FAILED', validation.errors[0]?.message || 'Validation failed'), local, localDecision, normalized, proposal, policy, validation };
165
+ return { local, localDecision, normalized, proposal, policy, validation };
166
+ }
167
+
168
+ // Sync decision path — for CJS preload hooks that cannot await.
169
+ // Uses local policy only (no WASM, no nonce, no signature).
170
+ function evaluateSync(action) {
171
+ const local = loadLocalPolicy(rootDir, options.mode || 'observe');
172
+ const mode = local.policy.mode;
173
+ let target = null;
174
+ let command = null;
175
+ if (action.path) {
176
+ try {
177
+ target = path.resolve(rootDir, action.path);
178
+ if (!underRoot(target, rootDir)) {
179
+ return { decision: 'deny', deny: true, matchedRules: ['path:outside_root'], mode, enforced: mode === 'enforce', reason: 'PATH_OUTSIDE_ROOT' };
180
+ }
181
+ } catch (e) { /* ignore resolution errors, fall through to policy check */ }
182
+ }
183
+ if (action.cmd) command = action.cmd;
184
+ const localDecision = evaluateLocalPolicy(local.policy, rootDir, { target, command });
185
+ const isDeny = !localDecision.allowed;
186
+ return {
187
+ decision: isDeny ? 'deny' : 'allow',
188
+ deny: isDeny,
189
+ matchedRules: localDecision.winningRules.map(r => r.id),
190
+ mode,
191
+ enforced: mode === 'enforce',
192
+ };
193
+ }
194
+
195
+ // Sync audit write to unified event log.
196
+ // Uses openSync/writeSync/closeSync to bypass JS wrappers and avoid recursion
197
+ // if the CJS preload hook has patched fs.writeFileSync in this process.
198
+ function auditSync(entry) {
199
+ const eventsPath = path.join(rootDir, '.lbe', 'events.jsonl');
200
+ const dir = path.dirname(eventsPath);
201
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
202
+ const line = JSON.stringify({ ts: Math.floor(Date.now() / 1000), ...entry }) + '\n';
203
+ const fd = fs.openSync(eventsPath, 'a');
204
+ try { fs.writeSync(fd, line); } finally { fs.closeSync(fd); }
205
+ }
206
+
207
+ async function dryRun(request) {
208
+ const prepared = prepare(request);
209
+ if (prepared.error) return { ...prepared.error, dryRun: true };
210
+ return {
211
+ ok: true,
212
+ decision: prepared.local.policy.mode === 'observe' ? 'observe' : 'allow',
213
+ executed: false,
214
+ dryRun: true,
215
+ matchedRules: prepared.localDecision.winningRules.map(rule => rule.id),
216
+ rollback: { available: MUTATIONS.has(prepared.normalized.request.intent), performed: false }
217
+ };
218
+ }
219
+
220
+ async function execute(request) {
221
+ const prepared = prepare(request, { recordNonce: true });
222
+ if (prepared.error) {
223
+ auditLocalPolicy(rootDir, { action: request?.intent, actor: request?.actor || 'agent:local', decision: 'deny', error: prepared.error.error.code });
224
+ return prepared.error;
225
+ }
226
+ // Observer mode: validate and audit but never mutate state.
227
+ if (prepared.local.policy.mode === 'observe') {
228
+ appendAudit(path.join(rootDir, '.lbe/audit.jsonl'), {
229
+ kind: 'local_execution', commandId: prepared.proposal.commandId, requesterId: prepared.normalized.actor,
230
+ intent: prepared.normalized.request.intent, decision: 'observe', status: 'observed'
231
+ });
232
+ return {
233
+ ok: true, decision: 'observe', executed: false, dryRun: false,
234
+ matchedRules: prepared.localDecision.winningRules.map(r => r.id),
235
+ rollback: { available: false, performed: false }
236
+ };
237
+ }
238
+ const requester = prepared.policy.requesters[prepared.normalized.actor];
239
+ const adapterResult = await executeAdapter(prepared.normalized.detail.adapter, prepared.proposal, prepared.policy, requester);
240
+ const ok = adapterResult.status === 'completed';
241
+ const audit = appendAudit(path.join(rootDir, '.lbe/audit.jsonl'), {
242
+ kind: 'local_execution', commandId: prepared.proposal.commandId, requesterId: prepared.normalized.actor,
243
+ intent: prepared.normalized.request.intent, decision: ok ? 'allow' : 'deny', status: adapterResult.status
244
+ });
245
+ return {
246
+ ok,
247
+ decision: ok ? 'allow' : 'deny',
248
+ executed: ok,
249
+ dryRun: false,
250
+ matchedRules: prepared.localDecision.winningRules.map(rule => rule.id),
251
+ auditId: audit.hash,
252
+ rollback: { available: MUTATIONS.has(prepared.normalized.request.intent), performed: false, backupId: adapterResult.backup?.hash },
253
+ ...(ok ? {} : { error: { code: adapterResult.errorCode || 'EXECUTION_FAILED', message: adapterResult.error || 'Execution failed', recoverable: true } })
254
+ };
255
+ }
256
+
257
+ // Convenience methods — agents use these; internals stay hidden
258
+ const writeFile = (target, content) => execute({ intent: 'write_file', target, content });
259
+ const readFile = (target) => execute({ intent: 'read_file', target });
260
+ const patchFile = (target, content) => execute({ intent: 'patch_file', target, content });
261
+ const deleteFile = (target) => execute({ intent: 'delete_file', target });
262
+ const runShell = (cmd, args = [], opts = {}) =>
263
+ execute({ intent: 'run_shell', command: { cmd, args, ...opts } });
264
+
265
+ return {
266
+ rootDir,
267
+ // High-level API — use these
268
+ writeFile,
269
+ readFile,
270
+ patchFile,
271
+ deleteFile,
272
+ runShell,
273
+ // Low-level API — for advanced use
274
+ validate: async request => {
275
+ const preview = await dryRun(request);
276
+ return { ...preview, dryRun: false, executed: false };
277
+ },
278
+ dryRun,
279
+ execute,
280
+ policy: {
281
+ read: () => loadLocalPolicy(rootDir, options.mode || 'enforce').policy,
282
+ proposeRule: proposePolicyRule,
283
+ addRule: rule => addLocalPolicyRule(rootDir, rule, options.mode || 'enforce')
284
+ },
285
+ audit: { verify: () => verifyAuditLogIntegrity(path.join(rootDir, '.lbe/audit.jsonl')) },
286
+ evaluateSync,
287
+ auditSync,
288
+ };
289
+ }