@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.
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +2 -0
- package/CHANGELOG.md +75 -0
- package/LICENSE +1 -1
- package/README.md +127 -154
- package/RELEASE_WORKSPACE_RULES.md +110 -0
- package/Release-README.md +65 -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 +4432 -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/lbe.audit.jsonl +46 -0
- package/package.json +48 -16
- 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 +122 -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 +83 -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 +108 -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,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
|
-
|
|
2
|
-
|
|
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;
|