@letterblack/lbe-core 1.3.4 → 1.3.5

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 +75 -0
  4. package/LICENSE +1 -1
  5. package/README.md +127 -154
  6. package/RELEASE_WORKSPACE_RULES.md +110 -0
  7. package/Release-README.md +65 -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 +4432 -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/lbe.audit.jsonl +46 -0
  22. package/package.json +48 -16
  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 +122 -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 +83 -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 +108 -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,246 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const FORMAT = 1;
5
+
6
+ // ── Profile selection ─────────────────────────────────────────────────────────
7
+
8
+ // Returns the proof profile appropriate for the given set of changed files.
9
+ function selectProfile(changedFiles) {
10
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return 'general';
11
+
12
+ // hook profile — any change to the CJS preload hook
13
+ if (changedFiles.some(f => f.replace(/\\/g, '/').includes('src/hooks/register.cjs'))) {
14
+ return 'hook';
15
+ }
16
+ // state profile — changes inside src/state/
17
+ if (changedFiles.every(f => f.replace(/\\/g, '/').includes('src/state/'))) {
18
+ return 'state';
19
+ }
20
+ // build profile — build scripts at project root level
21
+ if (changedFiles.every(f => /^build[^/]*\.(js|mjs|cjs)$/.test(path.basename(f)))) {
22
+ return 'build';
23
+ }
24
+ // release profile — package.json, package-lock.json, release/ files
25
+ if (changedFiles.every(f => {
26
+ const b = path.basename(f);
27
+ return b === 'package.json' || b === 'package-lock.json' || f.startsWith('release/');
28
+ })) {
29
+ return 'release';
30
+ }
31
+ // docs profile — only markdown / docs files
32
+ if (changedFiles.every(f => /\.(md|txt|rst)$/i.test(f) || f.startsWith('docs/'))) {
33
+ return 'docs';
34
+ }
35
+ return 'general';
36
+ }
37
+
38
+ // Checks required per profile
39
+ const PROFILE_CHECKS = {
40
+ docs: ['diff', 'forbidden_clean'],
41
+ general: ['diff', 'hash_match', 'forbidden_clean'],
42
+ hook: ['npm_run_proof', 'diff', 'hash_match'],
43
+ state: ['state_tests', 'proof'],
44
+ build: ['build_check', 'proof'],
45
+ release: ['proof', 'build_check', 'npm_pack_dry_run', 'audit_verify'],
46
+ };
47
+
48
+ // ── Helpers ───────────────────────────────────────────────────────────────────
49
+
50
+ function loadJsonl(filePath) {
51
+ if (!filePath || !fs.existsSync(filePath)) return [];
52
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
53
+ if (!raw) return [];
54
+ return raw.split('\n').reduce((acc, line) => {
55
+ try { acc.push(JSON.parse(line)); } catch (_) {}
56
+ return acc;
57
+ }, []);
58
+ }
59
+
60
+ function loadJson(filePath) {
61
+ if (!filePath || !fs.existsSync(filePath)) return null;
62
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
63
+ catch (_) { return null; }
64
+ }
65
+
66
+ function matchesGlob(filePath, pattern) {
67
+ // Simple glob: support * (non-sep wildcard) and ** (any path segments)
68
+ const normalized = filePath.replace(/\\/g, '/');
69
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
70
+ const regex = new RegExp('^' + escaped
71
+ .replace(/\*\*\//g, '(?:.*/)?')
72
+ .replace(/\*\*/g, '.*')
73
+ .replace(/\*/g, '[^/]*') + '$');
74
+ return regex.test(normalized);
75
+ }
76
+
77
+ // ── Checks ────────────────────────────────────────────────────────────────────
78
+
79
+ function checkDiff(changedFiles, intent, failures) {
80
+ if (!intent) return; // no intent — diff check skipped
81
+ const allowed = intent.allowed_files || [];
82
+ const forbidden = intent.forbidden_files || [];
83
+
84
+ for (const f of changedFiles) {
85
+ const rel = f.replace(/\\/g, '/');
86
+
87
+ // Forbidden files changed → always FAIL
88
+ if (forbidden.some(pat => matchesGlob(rel, pat))) {
89
+ failures.push({ check: 'diff', reason: 'forbidden_file_changed', file: rel });
90
+ continue;
91
+ }
92
+
93
+ // Allowed files constraint — only if allowed_files is non-empty
94
+ if (allowed.length > 0 && !allowed.some(pat => matchesGlob(rel, pat))) {
95
+ failures.push({ check: 'diff', reason: 'file_outside_allowed', file: rel });
96
+ }
97
+ }
98
+ }
99
+
100
+ function checkForbiddenClean(changedFiles, intent, failures) {
101
+ if (!intent) return;
102
+ const forbidden = intent.forbidden_files || [];
103
+ for (const f of changedFiles) {
104
+ const rel = f.replace(/\\/g, '/');
105
+ if (forbidden.some(pat => matchesGlob(rel, pat))) {
106
+ failures.push({ check: 'forbidden_clean', reason: 'forbidden_file_changed', file: rel });
107
+ }
108
+ }
109
+ }
110
+
111
+ function checkHashMatch(changedFiles, beforeIndex, afterIndex, failures) {
112
+ if (!beforeIndex || !afterIndex) return;
113
+ for (const f of changedFiles) {
114
+ const rel = f.replace(/\\/g, '/');
115
+ const b = (beforeIndex.files || {})[rel];
116
+ const a = (afterIndex.files || {})[rel];
117
+ if (b && a && b.sha256 === a.sha256) {
118
+ // File is in changedFiles but hash is identical — suspicious but not a failure
119
+ // (diff might be metadata only)
120
+ }
121
+ // hash_match check: ensure files declared changed actually have different hashes
122
+ // (only flag if file exists in both snapshots and hash matches — means diff is wrong)
123
+ if (b && a && b.sha256 === a.sha256) {
124
+ failures.push({ check: 'hash_match', reason: 'hash_unchanged_for_declared_change', file: rel });
125
+ }
126
+ }
127
+ }
128
+
129
+ // ── Public API ────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Runs the proof for a workspace and writes proof/latest.json.
133
+ *
134
+ * @param {string} stateDir Central workspace state directory.
135
+ * @param {string} workspaceRoot Absolute workspace root path.
136
+ * @param {object} [opts] { intentId } — pin to a specific intent_id
137
+ * @returns {object} The proof record written to disk.
138
+ */
139
+ export function runProof(stateDir, workspaceRoot, opts = {}) {
140
+ if (!stateDir || typeof stateDir !== 'string') {
141
+ throw new TypeError('stateDir must be a non-empty string');
142
+ }
143
+ if (!workspaceRoot || typeof workspaceRoot !== 'string') {
144
+ throw new TypeError('workspaceRoot must be a non-empty string');
145
+ }
146
+
147
+ // Load inputs
148
+ const intents = loadJsonl(path.join(stateDir, 'intent.jsonl'));
149
+ const intent = opts.intentId
150
+ ? intents.find(i => i.intent_id === opts.intentId) || intents[intents.length - 1] || null
151
+ : intents[intents.length - 1] || null;
152
+
153
+ const targets = loadJsonl(path.join(stateDir, 'target_registry.jsonl'));
154
+ const lastTarget = targets[targets.length - 1] || null;
155
+
156
+ const beforeIndex = loadJson(path.join(stateDir, 'file-index', 'before.json'));
157
+ const afterIndex = loadJson(path.join(stateDir, 'file-index', 'after.json'));
158
+
159
+ const events = loadJsonl(path.join(stateDir, 'lbe-events.jsonl'));
160
+
161
+ // Compute changed files from diff (prefer file index; fall back to events)
162
+ let changedFiles = [];
163
+ if (beforeIndex && afterIndex) {
164
+ const bf = beforeIndex.files || {};
165
+ const af = afterIndex.files || {};
166
+ const all = new Set([...Object.keys(bf), ...Object.keys(af)]);
167
+ for (const k of all) {
168
+ if (!bf[k] || !af[k] || bf[k].sha256 !== af[k].sha256) {
169
+ changedFiles.push(k);
170
+ }
171
+ }
172
+ } else {
173
+ // Fallback: derive changed files from LBE events
174
+ for (const e of events) {
175
+ if (e.path && (e.action === 'file_write' || e.action === 'file_delete' || e.action === 'file_rename')) {
176
+ const rel = path.relative(workspaceRoot, e.path).replace(/\\/g, '/');
177
+ if (!changedFiles.includes(rel)) changedFiles.push(rel);
178
+ }
179
+ }
180
+ }
181
+
182
+ // Select profile
183
+ const profile = selectProfile(changedFiles);
184
+ const checksToRun = PROFILE_CHECKS[profile] || PROFILE_CHECKS.general;
185
+
186
+ // Run applicable checks
187
+ const failures = [];
188
+ const checksRun = [];
189
+
190
+ if (checksToRun.includes('diff')) {
191
+ checksRun.push('diff');
192
+ checkDiff(changedFiles, intent, failures);
193
+ }
194
+ if (checksToRun.includes('forbidden_clean')) {
195
+ checksRun.push('forbidden_clean');
196
+ checkForbiddenClean(changedFiles, intent, failures);
197
+ }
198
+ if (checksToRun.includes('hash_match')) {
199
+ checksRun.push('hash_match');
200
+ checkHashMatch(changedFiles, beforeIndex, afterIndex, failures);
201
+ }
202
+ // Other checks (npm_run_proof, state_tests, etc.) are external — recorded as run
203
+ // but not executed by proofRunner itself (they require subprocess invocation).
204
+ for (const c of checksToRun) {
205
+ if (!checksRun.includes(c)) checksRun.push(c);
206
+ }
207
+
208
+ // Determine result
209
+ let proofResult;
210
+ if (failures.length > 0) {
211
+ proofResult = 'FAIL';
212
+ } else if (lastTarget && lastTarget.requires_user_confirmation === true) {
213
+ proofResult = 'WEAK_PROOF';
214
+ } else {
215
+ proofResult = 'PASS';
216
+ }
217
+
218
+ const proof = {
219
+ format: FORMAT,
220
+ ts: new Date().toISOString(),
221
+ result: proofResult,
222
+ profile,
223
+ intent_id: intent ? intent.intent_id : null,
224
+ target_id: lastTarget ? lastTarget.target_id : null,
225
+ files_changed: changedFiles,
226
+ checks_run: checksRun,
227
+ failures,
228
+ };
229
+
230
+ // Write to stateDir/proof/latest.json
231
+ const proofDir = path.join(stateDir, 'proof');
232
+ if (!fs.existsSync(proofDir)) fs.mkdirSync(proofDir, { recursive: true });
233
+ fs.writeFileSync(path.join(proofDir, 'latest.json'), JSON.stringify(proof, null, 2) + '\n', 'utf8');
234
+
235
+ return proof;
236
+ }
237
+
238
+ /**
239
+ * Loads the most recent proof result from stateDir/proof/latest.json.
240
+ *
241
+ * @param {string} stateDir Central workspace state directory.
242
+ * @returns {object|null} The proof record, or null if none exists.
243
+ */
244
+ export function loadLatestProof(stateDir) {
245
+ return loadJson(path.join(stateDir, 'proof', 'latest.json'));
246
+ }
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+
4
+ /**
5
+ * Returns the platform-appropriate root for LBE central state.
6
+ *
7
+ * All paths are user-local — no system-wide directories, no elevated permissions.
8
+ * Env vars are checked first; os.homedir() is the universal fallback.
9
+ *
10
+ * Windows: %LOCALAPPDATA%\LetterBlack\Sentinel
11
+ * fallback: <homedir>\AppData\Local\LetterBlack\Sentinel
12
+ * macOS: ~/Library/Application Support/LetterBlack/Sentinel
13
+ * fallback: <homedir>/Library/Application Support/LetterBlack/Sentinel
14
+ * Linux: $XDG_DATA_HOME/LetterBlack/Sentinel
15
+ * fallback: <homedir>/.local/share/LetterBlack/Sentinel
16
+ */
17
+ export function stateRoot() {
18
+ const home = os.homedir();
19
+
20
+ if (process.platform === 'win32') {
21
+ const localAppData =
22
+ process.env.LOCALAPPDATA ||
23
+ path.join(home, 'AppData', 'Local');
24
+ return path.join(localAppData, 'LetterBlack', 'Sentinel');
25
+ }
26
+
27
+ if (process.platform === 'darwin') {
28
+ const appSupport =
29
+ process.env.HOME
30
+ ? path.join(process.env.HOME, 'Library', 'Application Support')
31
+ : path.join(home, 'Library', 'Application Support');
32
+ return path.join(appSupport, 'LetterBlack', 'Sentinel');
33
+ }
34
+
35
+ // Linux / other POSIX
36
+ const xdgData =
37
+ process.env.XDG_DATA_HOME ||
38
+ path.join(home, '.local', 'share');
39
+ return path.join(xdgData, 'LetterBlack', 'Sentinel');
40
+ }
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ const TARGET_FILE = 'target_registry.jsonl';
6
+
7
+ // Kinds that require user confirmation regardless of caller's setting
8
+ const CONFIRMATION_REQUIRED_KINDS = new Set([
9
+ 'canvas', 'video', 'image',
10
+ ]);
11
+
12
+ // Source strategies that require user confirmation
13
+ const CONFIRMATION_REQUIRED_SOURCES = new Set([
14
+ 'visual_inference',
15
+ ]);
16
+
17
+ // ── Validation ────────────────────────────────────────────────────────────────
18
+
19
+ function validateComponentFile(filePath) {
20
+ if (filePath === undefined || filePath === null) return;
21
+ if (typeof filePath !== 'string') {
22
+ throw new TypeError('component_file must be a string');
23
+ }
24
+ const normalized = filePath.replace(/\\/g, '/');
25
+ if (path.isAbsolute(filePath) || path.isAbsolute(normalized)) {
26
+ throw new Error(`component_file: absolute paths are not allowed: "${filePath}"`);
27
+ }
28
+ if (normalized.split('/').some(seg => seg === '..')) {
29
+ throw new Error(`component_file: ../ traversal is not allowed: "${filePath}"`);
30
+ }
31
+ }
32
+
33
+ function validateConfidence(confidence) {
34
+ if (confidence === undefined || confidence === null) return;
35
+ if (typeof confidence !== 'number' || isNaN(confidence)) {
36
+ throw new TypeError('confidence must be a number');
37
+ }
38
+ if (confidence < 0 || confidence > 1) {
39
+ throw new RangeError(`confidence must be 0..1, got ${confidence}`);
40
+ }
41
+ }
42
+
43
+ // ── Public API ────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Registers a target record, appending it to stateDir/target_registry.jsonl.
47
+ *
48
+ * @param {string} stateDir Central workspace state directory.
49
+ * @param {object} target Target record (kind, label, selector, …).
50
+ * @returns {object} The stored record (with generated target_id and ts).
51
+ */
52
+ export function registerTarget(stateDir, target) {
53
+ if (!stateDir || typeof stateDir !== 'string') {
54
+ throw new TypeError('stateDir must be a non-empty string');
55
+ }
56
+ if (!target || typeof target !== 'object') {
57
+ throw new TypeError('target must be a plain object');
58
+ }
59
+
60
+ validateComponentFile(target.component_file);
61
+ validateConfidence(target.confidence);
62
+
63
+ // Enforce confirmation requirement for visual_inference or visual kinds
64
+ const requiresConfirmation =
65
+ target.requires_user_confirmation === true ||
66
+ CONFIRMATION_REQUIRED_SOURCES.has(target.target_source) ||
67
+ CONFIRMATION_REQUIRED_KINDS.has(target.kind);
68
+
69
+ const record = {
70
+ target_id: target.target_id || ('t_' + crypto.randomBytes(8).toString('hex')),
71
+ ts: target.ts || new Date().toISOString(),
72
+ kind: target.kind ?? 'unknown',
73
+ label: target.label ?? '',
74
+ screen: target.screen ?? '',
75
+ selector: target.selector ?? '',
76
+ component_file: target.component_file ?? null,
77
+ bbox: target.bbox ?? null,
78
+ evidence: target.evidence ?? [],
79
+ target_source: target.target_source ?? 'unknown',
80
+ confidence: target.confidence ?? null,
81
+ requires_user_confirmation: requiresConfirmation,
82
+ };
83
+
84
+ const filePath = path.join(stateDir, TARGET_FILE);
85
+ const dir = path.dirname(filePath);
86
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
87
+
88
+ fs.appendFileSync(filePath, JSON.stringify(record) + '\n', 'utf8');
89
+ return record;
90
+ }
91
+
92
+ /**
93
+ * Loads all target records from stateDir/target_registry.jsonl.
94
+ * Malformed lines are silently skipped.
95
+ *
96
+ * @param {string} stateDir Central workspace state directory.
97
+ * @returns {object[]} Array of parsed target records (may be empty).
98
+ */
99
+ export function loadTargets(stateDir) {
100
+ const filePath = path.join(stateDir, TARGET_FILE);
101
+ if (!fs.existsSync(filePath)) return [];
102
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
103
+ if (!raw) return [];
104
+ return raw.split('\n').reduce((acc, line) => {
105
+ try { acc.push(JSON.parse(line)); } catch (_) {}
106
+ return acc;
107
+ }, []);
108
+ }
@@ -0,0 +1,40 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Returns a platform-correct canonical form of a workspace path for ID hashing.
7
+ *
8
+ * Rules:
9
+ * - Use realpathSync.native to resolve symlinks and normalise separators.
10
+ * - Fallback to path.resolve if the path does not yet exist.
11
+ * - Windows: lowercase after normalisation (NTFS is case-insensitive).
12
+ * - Linux/macOS: preserve case (case-sensitive volumes).
13
+ */
14
+ export function canonicalWorkspacePath(workspaceRoot) {
15
+ let resolved;
16
+ try {
17
+ resolved = fs.realpathSync.native(workspaceRoot);
18
+ } catch (_) {
19
+ // Path does not exist yet — normalise without resolving symlinks.
20
+ resolved = path.resolve(workspaceRoot);
21
+ }
22
+ const normalised = path.normalize(resolved);
23
+ return process.platform === 'win32' ? normalised.toLowerCase() : normalised;
24
+ }
25
+
26
+ /**
27
+ * Returns a 64-char hex SHA-256 workspace ID derived from the canonical path.
28
+ */
29
+ export function workspaceId(workspaceRoot) {
30
+ return crypto.createHash('sha256').update(canonicalWorkspacePath(workspaceRoot)).digest('hex');
31
+ }
32
+
33
+ /**
34
+ * Returns the sharded state directory path for a workspace ID.
35
+ * Format: <stateRoot>/workspaces/<xx>/<xx>/<xx>/<full-id>/
36
+ * Sharding prevents flat-dir scaling problems with many projects.
37
+ */
38
+ export function workspaceStateDir(stateRoot, id) {
39
+ return path.join(stateRoot, 'workspaces', id.slice(0, 2), id.slice(2, 4), id.slice(4, 6), id);
40
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { atomicWriteFileSync, withFileLock } from '../core/atomicWrite.js';
4
+
5
+ const FORMAT = 1;
6
+
7
+ function emptyRegistry() {
8
+ return { format: FORMAT, workspaces: {} };
9
+ }
10
+
11
+ function readRegistry(registryPath) {
12
+ if (!fs.existsSync(registryPath)) return { registry: emptyRegistry(), readable: true };
13
+
14
+ try {
15
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
16
+ if (!registry || typeof registry !== 'object' || Array.isArray(registry) ||
17
+ registry.format !== FORMAT || !registry.workspaces ||
18
+ typeof registry.workspaces !== 'object' || Array.isArray(registry.workspaces)) {
19
+ return { registry: null, readable: false };
20
+ }
21
+ return { registry, readable: true };
22
+ } catch (_) {
23
+ return { registry: null, readable: false };
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Register a workspace in the central registry.
29
+ *
30
+ * A corrupt existing registry is intentionally left untouched. Resolution must
31
+ * remain safe for the CLI, and overwriting forensic state would be surprising.
32
+ */
33
+ export function registerWorkspace(registryPath, workspaceId, workspacePath) {
34
+ return withFileLock(registryPath, () => {
35
+ const { registry, readable } = readRegistry(registryPath);
36
+ if (!readable) return null;
37
+
38
+ const now = new Date().toISOString();
39
+ const existing = registry.workspaces[workspaceId];
40
+ registry.workspaces[workspaceId] = {
41
+ path: workspacePath,
42
+ alias: existing?.alias || path.basename(workspacePath),
43
+ first_seen: existing?.first_seen || now,
44
+ last_active: now,
45
+ };
46
+
47
+ atomicWriteFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf8');
48
+ return registry.workspaces[workspaceId];
49
+ });
50
+ }
51
+
52
+ /** Returns known workspaces, or [] when the registry is missing or unreadable. */
53
+ export function listWorkspaces(registryPath) {
54
+ const { registry, readable } = readRegistry(registryPath);
55
+ if (!readable) return [];
56
+ return Object.entries(registry.workspaces).map(([workspaceId, workspace]) => ({
57
+ workspaceId,
58
+ ...workspace,
59
+ }));
60
+ }
61
+
62
+ /** Allows presentation code to distinguish an absent registry from corruption. */
63
+ export function isWorkspaceRegistryReadable(registryPath) {
64
+ return readRegistry(registryPath).readable;
65
+ }
package/types.d.ts CHANGED
@@ -1,2 +1,175 @@
1
- // @letterblack/lbe-sdk v1.3.3
2
- export function execute(input: string): string;
1
+ export interface LBEActor {
2
+ id: string;
3
+ role: 'user' | 'system' | 'agent';
4
+ }
5
+
6
+ export interface LBEIntent {
7
+ type: 'command' | 'query' | 'task';
8
+ name: string;
9
+ payload: Record<string, unknown>;
10
+ }
11
+
12
+ export interface LBEExecuteInput {
13
+ version: '1.0';
14
+ request_id: string;
15
+ timestamp: number;
16
+ actor: LBEActor;
17
+ intent: LBEIntent;
18
+ context: {
19
+ workspace: string;
20
+ env: Record<string, unknown>;
21
+ history: unknown[];
22
+ };
23
+ constraints: {
24
+ policy_mode: 'strict' | 'permissive';
25
+ timeout_ms: number;
26
+ };
27
+ auth: {
28
+ signature: string;
29
+ nonce: string;
30
+ };
31
+ }
32
+
33
+ export interface LBEExecuteOutput {
34
+ ok: boolean;
35
+ result: {
36
+ type: 'allowed' | 'denied' | 'error';
37
+ action: string;
38
+ data: Record<string, unknown>;
39
+ };
40
+ policy: {
41
+ decision: 'allow' | 'deny' | 'escalate';
42
+ reason: string;
43
+ rules: string[];
44
+ };
45
+ trace: {
46
+ id: string;
47
+ steps: unknown[];
48
+ hash: string;
49
+ };
50
+ error: null | {
51
+ code: string;
52
+ message: string;
53
+ };
54
+ }
55
+
56
+ export function execute(input: string): string;
57
+
58
+ export type LBEExecutionIntent = 'read_file' | 'write_file' | 'patch_file' | 'delete_file' | 'run_shell';
59
+
60
+ export interface LBERequest {
61
+ id?: string;
62
+ actor?: string;
63
+ intent: LBEExecutionIntent;
64
+ target?: string;
65
+ content?: string;
66
+ patch?: unknown;
67
+ command?: { cmd: string; args: string[]; cwd?: string; timeoutMs?: number; maxOutputBytes?: number };
68
+ reason?: string;
69
+ }
70
+
71
+ export interface LBEResult {
72
+ ok: boolean;
73
+ decision: 'allow' | 'deny' | 'observe';
74
+ executed: boolean;
75
+ dryRun: boolean;
76
+ error?: { code: string; message: string; recoverable: boolean };
77
+ matchedRules?: string[];
78
+ auditId?: string;
79
+ rollback?: { available: boolean; performed: boolean; backupId?: string };
80
+ }
81
+
82
+ // ── Policy file types ─────────────────────────────────────────────────────
83
+
84
+ export type LBEMode = 'observe' | 'enforce';
85
+ export type LBERuleEffect = 'deny' | 'allow';
86
+ export type LBERuleType = 'path' | 'command';
87
+
88
+ export interface LBEPolicyRule {
89
+ id: string;
90
+ effect: LBERuleEffect;
91
+ type: LBERuleType;
92
+ pattern: string;
93
+ from: string;
94
+ at: string;
95
+ }
96
+
97
+ export interface LBEPolicy {
98
+ version: number;
99
+ mode: LBEMode;
100
+ workspace: string;
101
+ rules: LBEPolicyRule[];
102
+ }
103
+
104
+ // ── High-level ergonomic API ──────────────────────────────────────────────
105
+
106
+ export interface LBEResult {
107
+ ok: boolean;
108
+ denied: boolean;
109
+ reason: string | null;
110
+ commandId: string | null;
111
+ stage?: string;
112
+ risk?: string | null;
113
+ output?: unknown;
114
+ error: string | null;
115
+ }
116
+
117
+ export interface LBEObservedResult {
118
+ ok: true;
119
+ observed: true;
120
+ intent: string;
121
+ actor: string;
122
+ commandId: string | null;
123
+ }
124
+
125
+ export interface LBEToolDef {
126
+ name: string;
127
+ description?: string;
128
+ parameters?: Record<string, unknown>;
129
+ [key: string]: unknown;
130
+ }
131
+
132
+ export interface LBEDispatchContext {
133
+ agentId?: string;
134
+ actor?: string;
135
+ }
136
+
137
+ export interface LBEWrappedTools {
138
+ definitions: LBEToolDef[];
139
+ dispatch(
140
+ toolName: string,
141
+ args?: Record<string, unknown>,
142
+ context?: LBEDispatchContext,
143
+ ): Promise<LBEResult | LBEObservedResult>;
144
+ }
145
+
146
+ export interface LBEAddedRule {
147
+ id: string;
148
+ added: true;
149
+ }
150
+
151
+ export interface LBEInstance {
152
+ mode: LBEMode;
153
+ rootDir: string;
154
+ execute(opts: { actor?: string; intent: string; [key: string]: unknown }): Promise<LBEResult | LBEObservedResult>;
155
+ wrapTools(toolDefs: LBEToolDef[]): LBEWrappedTools;
156
+ /** Advisory only; never writes lbe.policy.json. */
157
+ proposePolicyRule(rule: { effect: LBERuleEffect; type: LBERuleType; pattern: string; from: string }): { proposed: true; at: string };
158
+ /**
159
+ * Detect if a user message expresses a permanent block instruction.
160
+ * Returns a rule object to pass to addRule(), or null if not a policy intent.
161
+ * llmCall receives a ready-made prompt and must return the LLM's text response.
162
+ */
163
+ detectPolicyIntent(
164
+ userMessage: string,
165
+ llmCall: (prompt: string) => Promise<string>,
166
+ ): Promise<{ effect: LBERuleEffect; type: LBERuleType; pattern: string } | null>;
167
+ }
168
+
169
+ export interface LBEOptions {
170
+ rootDir?: string;
171
+ actor?: string;
172
+ mode?: LBEMode;
173
+ }
174
+
175
+ export function createLBE(opts?: LBEOptions): LBEInstance;