@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
package/WORKSPACE.md ADDED
@@ -0,0 +1,422 @@
1
+ # LetterBlack LBE — Workspace Instructions
2
+
3
+ This document covers every aspect of the private workspace:
4
+ structure, development, testing, build, release, and governance.
5
+
6
+ ---
7
+
8
+ ## What this is
9
+
10
+ **LBE (Local-first execution Governance Engine)** puts a deterministic
11
+ validation gate between what an AI agent proposes and what the host system
12
+ actually executes. Every action — file write, shell command, anything — passes
13
+ a 7-gate pipeline before execution. Nothing runs unless the gate passes.
14
+
15
+ There are two published packages and one private source workspace:
16
+
17
+ | Package | npm | What it is |
18
+ |---|---|---|
19
+ | `@letterblack/lbe-sdk` | published | WASM runtime + CLI only. Raw `execute()` function, no controller. |
20
+ | `@letterblack/lbe-exec` | published | Full in-process controller. `createLocalExecutor()`, policy, audit, sandbox. |
21
+ | `letterblack-lbe-core` | private | This workspace. Source for both packages. Never published. |
22
+
23
+ ---
24
+
25
+ ## Workspace layout
26
+
27
+ ```
28
+ letterblack-sentinel/
29
+
30
+ ├── assets/ Visual assets (source of truth — built into both packages)
31
+ │ ├── lbe-gates.png Gate sequence diagram (Request → Policy → Identity → Scope → Action)
32
+ │ ├── story-allow.png Happy-path storyboard (6 panels)
33
+ │ ├── story-deny.png Deny-path storyboard (6 panels)
34
+ │ └── runtime-boundary.svg WASM boundary diagram
35
+
36
+ ├── src/
37
+ │ ├── core/ Private controller internals
38
+ │ │ ├── validator.js 7-gate orchestrator — extracts flags, calls WASM
39
+ │ │ ├── localPolicy.js lbe.policy.json read/write, deny-wins evaluation
40
+ │ │ ├── auditLog.js SHA-256 hash-chained JSONL append
41
+ │ │ ├── signature.js Ed25519 sign/verify (tweetnacl)
42
+ │ │ ├── atomicWrite.js Write-then-rename for all state files
43
+ │ │ ├── policyEngine.js Deployment policy evaluation
44
+ │ │ ├── policySignature.js Policy signing and tamper detection
45
+ │ │ ├── policyVersionGuard.js Version bump / rollback protection
46
+ │ │ ├── nonceStore.js Single-use nonce registry
47
+ │ │ ├── requestRateLimiter.js Per-requester rate limiting
48
+ │ │ ├── invariants.js Invariant gate (key present? policy signed?)
49
+ │ │ ├── backup.js Pre-write backup and restore
50
+ │ │ ├── trustedKeys.js Key store management
51
+ │ │ ├── integrity.js Workspace integrity manifest
52
+ │ │ ├── workspaceScanner.js Workspace file enumeration
53
+ │ │ ├── schema.js Request schema constants
54
+ │ │ ├── logger.js Structured logger
55
+ │ │ ├── deepFreeze.js Freeze policy/config objects
56
+ │ │ └── approval-token.js Short-lived approval tokens
57
+ │ │
58
+ │ ├── adapters/ Execution adapters (decide nothing, only execute)
59
+ │ │ ├── fileAdapter.js read / write / patch / delete inside rootDir
60
+ │ │ ├── shellAdapter.js run an allowlisted shell command
61
+ │ │ ├── noopAdapter.js dry-run / observer sink
62
+ │ │ └── index.js Adapter router
63
+ │ │
64
+ │ ├── exec/
65
+ │ │ └── localExecutor.js createLocalExecutor() — in-process controller
66
+ │ │ (source for @letterblack/lbe-exec)
67
+ │ │
68
+ │ └── cli/
69
+ │ ├── parseArgs.js Argument parser
70
+ │ └── commands/ One file per CLI command
71
+ │ ├── init.js npx lbe init
72
+ │ ├── policyMode.js npx lbe observe / enforce
73
+ │ ├── policyAdd.js npx lbe policy add
74
+ │ ├── run.js npx lbe execute
75
+ │ ├── dryrun.js npx lbe dryrun
76
+ │ ├── verify.js npx lbe verify
77
+ │ ├── health.js npx lbe health
78
+ │ ├── auditVerify.js npx lbe audit-verify
79
+ │ ├── integrityCheck.js npx lbe integrity-check
80
+ │ └── policySign.js npx lbe policy-sign
81
+
82
+ ├── exec/
83
+ │ └── index.js Public entrypoint for @letterblack/lbe-exec
84
+
85
+ ├── runtime/
86
+ │ ├── lbe_engine.wasm Compiled WASM binary (output of build:engine)
87
+ │ └── engine.js JS WASM loader — exposes validate_pipeline etc.
88
+
89
+ ├── native/
90
+ │ └── lbe-engine/ Rust crate — compiled to lbe_engine.wasm
91
+ │ ├── Cargo.toml crate-type = cdylib, publish = false
92
+ │ └── src/lib.rs All governance decision logic lives here
93
+
94
+ ├── bin/
95
+ │ └── lbe.js CLI entry (private — never ships in public packages)
96
+
97
+ ├── config/
98
+ │ ├── policy.default.json Default policy template (ships in lbe-sdk)
99
+ │ └── identity.config.json Workspace identity config (private)
100
+
101
+ ├── test/
102
+ │ ├── local-executor.test.js 40 tests — observe/enforce/deny-wins/sandbox/audit
103
+ │ ├── local-policy.test.js Policy file read/write/evaluate
104
+ │ ├── public-api.test.js WASM execute() contract
105
+ │ ├── security-invariants.test.js Key lifecycle, policy signature, version guard
106
+ │ ├── invariant-backup.test.js Backup and rollback
107
+ │ └── shell-adapter.test.js Shell metacharacter safety
108
+
109
+ ├── scripts/
110
+ │ ├── build-engine.js cargo build → runtime/lbe_engine.wasm
111
+ │ ├── build-public-sdk.mjs Builds release-public/ (@letterblack/lbe-sdk)
112
+ │ ├── build-public-exec.mjs Builds release-exec/ (@letterblack/lbe-exec)
113
+ │ ├── check-public-artifact.mjs Validates release-public/ before publish
114
+ │ ├── check-public-exec.mjs Validates release-exec/ before publish
115
+ │ ├── mainhead-guard.mjs Blocks commits/pushes from non-main branches
116
+ │ └── install-git-hooks.mjs Installs pre-commit and pre-push hooks
117
+
118
+ ├── docs/
119
+ │ ├── decisions/
120
+ │ │ ├── ADR-001-remove-mcp-execution-surface.md
121
+ │ │ ├── ADR-002-remove-http-server-surface.md
122
+ │ │ └── ADR-003-sdk-only-product-boundary.md
123
+ │ └── governance/
124
+ │ └── mainhead.md Branch authority rules
125
+
126
+ ├── release/ README and type templates (source for builds)
127
+ │ ├── README.md Template for @letterblack/lbe-sdk README
128
+ │ ├── exec-README.md Template for @letterblack/lbe-exec README
129
+ │ └── exec-types.d.ts Type declarations for @letterblack/lbe-exec
130
+
131
+ ├── release-public/ Built artifact — @letterblack/lbe-sdk (never edit directly)
132
+ ├── release-exec/ Built artifact — @letterblack/lbe-exec (never edit directly)
133
+
134
+ ├── types.d.ts Private full type declarations
135
+ ├── index.js Private package entry
136
+ ├── package.json Private workspace package (letterblack-lbe-core)
137
+ ├── CHANGELOG.md
138
+ └── WORKSPACE.md This file
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Prerequisites
144
+
145
+ | Tool | Version | Purpose |
146
+ |---|---|---|
147
+ | Node.js | ≥ 20.9.0 | Runtime and test runner |
148
+ | Rust / cargo | stable | Compile WASM engine |
149
+ | rustup target | `wasm32-unknown-unknown` | WASM build target (auto-added by build:engine) |
150
+ | npm | any | Package management and publish |
151
+
152
+ ```bash
153
+ npm install
154
+ npm run hooks:install # installs pre-commit and pre-push git hooks — do this once after clone
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Development commands
160
+
161
+ ### Daily workflow
162
+
163
+ ```bash
164
+ npm test # run all 40 tests
165
+ npm run lint # ESLint on src/ and bin/
166
+ npm run validate:all # mainhead guard + engine check + lint + test (full gate)
167
+ ```
168
+
169
+ ### WASM engine
170
+
171
+ ```bash
172
+ npm run build:engine # compile native/lbe-engine → runtime/lbe_engine.wasm
173
+ npm run engine:check # verify the WASM binary loads and exports the expected functions
174
+ ```
175
+
176
+ Only needed when `native/lbe-engine/src/lib.rs` changes. The compiled binary
177
+ `runtime/lbe_engine.wasm` is committed and ships in both packages.
178
+
179
+ ### CLI (local development)
180
+
181
+ ```bash
182
+ node bin/lbe.js init # create lbe.policy.json in observer mode
183
+ node bin/lbe.js status # mode, rule count, audit entry count
184
+ node bin/lbe.js policy # list rules
185
+ node bin/lbe.js observe # switch to observer mode
186
+ node bin/lbe.js enforce # switch to enforcement mode
187
+ node bin/lbe.js health --json true
188
+ node bin/lbe.js audit-verify
189
+ ```
190
+
191
+ Or via npm aliases: `npm run init`, `npm run verify`, `npm run health`.
192
+
193
+ ---
194
+
195
+ ## Test suite
196
+
197
+ **40 tests, 0 failures.** Run with `npm test` (uses native Node.js test runner).
198
+
199
+ | File | Coverage |
200
+ |---|---|
201
+ | `local-executor.test.js` | observe mode, enforce mode, deny-wins, sandbox, advisory vs controller split, shell allowlist, symlink escape, dryRun, audit |
202
+ | `local-policy.test.js` | policy load/write/evaluate, deny-wins, observer/enforce defaults |
203
+ | `public-api.test.js` | raw WASM `execute()` input/output contract |
204
+ | `security-invariants.test.js` | Ed25519 key lifecycle, policy signature tamper, version rollback guard |
205
+ | `invariant-backup.test.js` | backup creation, restore on failure, atomic write |
206
+ | `shell-adapter.test.js` | metacharacter arguments treated as data, not shell syntax |
207
+
208
+ ---
209
+
210
+ ## Architecture — the 7-gate pipeline
211
+
212
+ Every request to `execute()` passes these gates in order. A failure at any
213
+ gate returns a structured deny — the remaining gates are not evaluated.
214
+
215
+ ```
216
+ [1] Schema — required fields, structural validity
217
+ [2] Timestamp — permitted clock-skew window (±10 minutes)
218
+ [3] Key lifecycle — trusted key, active, not expired
219
+ [4] Signature — Ed25519 signature verified against key store
220
+ [5] Rate limit — per-requester sliding-window limit
221
+ [6] Nonce — single-use commandId replay protection
222
+ [7] Policy — configured authorization decision (deny-wins)
223
+ ```
224
+
225
+ The WASM engine (`runtime/lbe_engine.wasm`) owns all gate decisions.
226
+ `src/core/validator.js` extracts boolean flag sets from the command object
227
+ and calls the WASM functions. The JS layer does IO; the Rust layer decides.
228
+
229
+ ### Local policy layer (lbe-exec only)
230
+
231
+ Before the 7-gate pipeline runs, `createLocalExecutor()` evaluates
232
+ `lbe.policy.json`. This is a separate, simpler rule ledger:
233
+
234
+ - Rules have `effect: 'allow' | 'deny'`, `type: 'path' | 'command'`, `pattern`
235
+ - **Deny always wins** over allow regardless of rule order
236
+ - Only the host application writes rules (`addRule()`). Agents may only call
237
+ `proposeRule()` which returns a proposal object — it never writes to disk.
238
+
239
+ ### Observer vs enforce mode
240
+
241
+ | Mode | Gates run | Audit written | Mutations |
242
+ |---|---|---|---|
243
+ | `observe` | yes | yes | **no** — short-circuits before adapter |
244
+ | `enforce` | yes | yes | yes — adapter executes on pass |
245
+
246
+ Default when no `lbe.policy.json` exists: **enforce**.
247
+ `npx lbe init` creates `lbe.policy.json` in **observe** mode.
248
+
249
+ ### Project-root sandbox
250
+
251
+ All file operations are constrained to `rootDir`. The check resolves
252
+ symlinks before comparing paths (`physicalPath()`), so symlink escapes and
253
+ `../` traversal are blocked before policy is consulted.
254
+
255
+ ---
256
+
257
+ ## Key security properties
258
+
259
+ | Property | Implementation |
260
+ |---|---|
261
+ | Ed25519 signatures | Every proposal signed; WASM verifies before any gate runs |
262
+ | Nonce replay protection | Each `commandId` is single-use |
263
+ | Rate limiting | Per-requester sliding window, configurable |
264
+ | Timestamp skew guard | Rejects proposals outside ±10 minutes |
265
+ | Policy signature verification | Policy file is signed; tampering fails the invariant gate |
266
+ | Immutable audit trail | SHA-256 hash-chained JSONL; deletion or edit is detectable |
267
+ | Atomic writes | All state files use write-then-rename; no partial state |
268
+ | Project-root sandbox | Symlink-safe path resolution in `physicalPath()` |
269
+ | Deny-wins conflict resolution | When allow + deny both match, deny always takes precedence |
270
+
271
+ ---
272
+
273
+ ## Build system
274
+
275
+ ### @letterblack/lbe-sdk
276
+
277
+ ```bash
278
+ npm run build:public-sdk # → release-public/
279
+ npm run verify:public-sdk # build + validate + npm pack --dry-run
280
+ ```
281
+
282
+ What the build does:
283
+ 1. Bundles `index.js` and `bin/lbe.js` with esbuild → `release-public/dist/`
284
+ 2. Copies `runtime/lbe_engine.wasm` and generates `wasm.lock.json`
285
+ 3. Copies `assets/` from workspace root → `release-public/assets/`
286
+ 4. Copies `release/README.md` (replaces `{{PACKAGE_NAME}}` placeholder) → `release-public/README.md`
287
+ 5. Copies `config/policy.default.json` and `types.d.ts`
288
+ 6. Writes `release-public/package.json` from workspace `package.json`
289
+
290
+ The validator (`check-public-artifact.mjs`) confirms:
291
+ - No `src/`, `test/`, `scripts/`, `keys/`, `data/` paths in the artifact
292
+ - No private path markers in the bundled JS
293
+ - WASM SHA-256 matches `wasm.lock.json`
294
+ - All required files present
295
+
296
+ ### @letterblack/lbe-exec
297
+
298
+ ```bash
299
+ npm run build:public-exec # → release-exec/
300
+ npm run verify:public-exec # build + validate + npm pack --dry-run
301
+ ```
302
+
303
+ What the build does:
304
+ 1. Bundles `exec/index.js` with esbuild → `release-exec/dist/index.js`
305
+ (external: tweetnacl, json-canonicalize — these are runtime dependencies)
306
+ 2. Copies `runtime/lbe_engine.wasm` and generates `wasm.lock.json`
307
+ 3. Copies `assets/` from workspace root → `release-exec/assets/`
308
+ 4. Copies `release/exec-README.md` → `release-exec/README.md`
309
+ 5. Copies `release/exec-types.d.ts` → `release-exec/types.d.ts`
310
+ 6. Writes `release-exec/package.json` from workspace `package.json`
311
+
312
+ The validator (`check-public-exec.mjs`) confirms:
313
+ - No private paths or markers in the bundle
314
+ - WASM SHA-256 matches `wasm.lock.json`
315
+ - All required files present
316
+
317
+ ### What never ships
318
+
319
+ Neither package contains: `src/`, `test/`, `scripts/`, `keys/`, `data/`,
320
+ `node_modules/`, source maps, or private adapters.
321
+
322
+ ---
323
+
324
+ ## Release process
325
+
326
+ Both packages are published from their respective `release-*/` directories.
327
+ **Never publish from the workspace root.**
328
+
329
+ ### Full release checklist
330
+
331
+ ```bash
332
+ # 1. All gates pass
333
+ npm run validate:all
334
+
335
+ # 2. Version bump — update in package.json (single source of truth)
336
+ # Build scripts read this version for both release packages.
337
+
338
+ # 3. Build and validate both artifacts
339
+ npm run verify:public-sdk
340
+ npm run verify:public-exec
341
+
342
+ # 4. Commit and push to main
343
+ git add -p
344
+ git commit -m "release: vX.Y.Z"
345
+ git push
346
+
347
+ # 5. Tag
348
+ git tag vX.Y.Z
349
+ git push --tags
350
+
351
+ # 6. Publish (from release directories, not workspace root)
352
+ cd release-public && npm publish
353
+ cd ../release-exec && npm publish
354
+ ```
355
+
356
+ **Version is set once** in `package.json` at the workspace root.
357
+ Both build scripts read `sourcePackage.version` from there — no manual
358
+ sync needed between the two release `package.json` files.
359
+
360
+ ### Auth
361
+
362
+ ```bash
363
+ npm login # or set NPM_TOKEN environment variable
364
+ # registry: https://registry.npmjs.org/
365
+ ```
366
+
367
+ ### Current published versions
368
+
369
+ | Package | Version |
370
+ |---|---|
371
+ | `@letterblack/lbe-sdk` | 1.2.3 |
372
+ | `@letterblack/lbe-exec` | 1.2.3 |
373
+
374
+ ---
375
+
376
+ ## Branch governance
377
+
378
+ `main` is the only authoritative branch. The pre-commit and pre-push hooks
379
+ run `scripts/mainhead-guard.mjs` and will block any commit or push from a
380
+ non-main branch or linked worktree.
381
+
382
+ Rules:
383
+ - Never commit from a feature branch — commit directly to `main`
384
+ - Never use `--no-verify` to skip hooks — fix the underlying issue
385
+ - Never publish from a branch, tag, or worktree other than `main`
386
+ - The `mainhead-guard` check is also run at the start of the build scripts
387
+
388
+ Install the hooks once after cloning:
389
+
390
+ ```bash
391
+ npm run hooks:install
392
+ ```
393
+
394
+ ---
395
+
396
+ ## README authoring
397
+
398
+ Source files (edit these — never edit the built outputs directly):
399
+
400
+ | Source | Builds into |
401
+ |---|---|
402
+ | `release/README.md` | `release-public/README.md` (with `{{PACKAGE_NAME}}` replaced) |
403
+ | `release/exec-README.md` | `release-exec/README.md` |
404
+ | `release/exec-types.d.ts` | `release-exec/types.d.ts` |
405
+ | `types.d.ts` | private package only |
406
+
407
+ Images live in `assets/` at the workspace root and are copied into both
408
+ release packages during build. Reference them in READMEs as `assets/<file>`.
409
+
410
+ ---
411
+
412
+ ## What is not in scope
413
+
414
+ LBE governs actions routed through its runtime. It does not provide:
415
+
416
+ - Kernel-level process isolation
417
+ - Network egress control
418
+ - Multi-tenant workload separation
419
+ - A hosted control plane, daemon, or HTTP API
420
+ - An MCP server surface (see ADR-001)
421
+
422
+ See `docs/decisions/` for the rationale behind each removed surface.
package/_proof.mjs ADDED
@@ -0,0 +1,246 @@
1
+ // Proof matrix for LBE preload hook
2
+ // Run with: node _proof.mjs
3
+ // All tests use isolated temp dirs. No shared state.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { spawnSync, spawn } from 'child_process';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
12
+ const HOOK = path.join(ROOT, 'src', 'hooks', 'register.cjs');
13
+ const HOOK_FWD = HOOK.replace(/\\/g, '/');
14
+
15
+ let passed = 0, failed = 0;
16
+
17
+ function result(name, ok, detail = '') {
18
+ const mark = ok ? '✓' : '✗';
19
+ console.log(` ${mark} ${name}${detail ? ' — ' + detail : ''}`);
20
+ if (ok) passed++; else failed++;
21
+ }
22
+
23
+ function tmpDir(policy = null) {
24
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lbe-proof-'));
25
+ fs.mkdirSync(path.join(d, '.lbe'), { recursive: true });
26
+ fs.writeFileSync(path.join(d, '.lbe', 'policy.json'),
27
+ JSON.stringify(policy || { version: 1, mode: 'observe', workspace: d, rules: [] }));
28
+ return d;
29
+ }
30
+
31
+ function runNode(dir, code, mode = 'observe') {
32
+ return spawnSync(process.execPath, ['-e', code], {
33
+ encoding: 'utf8',
34
+ env: {
35
+ ...process.env,
36
+ NODE_OPTIONS: `--require "${HOOK_FWD}"`,
37
+ LBE_ROOT: dir,
38
+ LBE_MODE: mode,
39
+ },
40
+ cwd: dir,
41
+ });
42
+ }
43
+
44
+ function auditEntries(dir) {
45
+ const f = path.join(dir, '.lbe', 'events.jsonl');
46
+ if (!fs.existsSync(f)) return [];
47
+ return fs.readFileSync(f, 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
48
+ }
49
+
50
+ // ── 1. fs.writeFile (callback) ──────────────────────────────────────────────
51
+ {
52
+ const dir = tmpDir();
53
+ const r = runNode(dir, `
54
+ const fs = require('fs');
55
+ fs.writeFile('out.txt', 'hello', err => { if (err) process.exit(1); });
56
+ `);
57
+ const entries = auditEntries(dir);
58
+ const wrote = fs.existsSync(path.join(dir, 'out.txt'));
59
+ const logged = entries.some(e => e.action === 'file_write');
60
+ result('fs.writeFile', wrote && logged && r.status === 0,
61
+ wrote ? (logged ? 'file written + logged' : 'file written, NOT logged') : 'file not written');
62
+ }
63
+
64
+ // ── 2. fs.promises.writeFile ────────────────────────────────────────────────
65
+ {
66
+ const dir = tmpDir();
67
+ const r = runNode(dir, `
68
+ const fs = require('fs');
69
+ (async () => {
70
+ await fs.promises.writeFile('out2.txt', 'async');
71
+ })().catch(() => process.exit(1));
72
+ `);
73
+ // Give async time to flush
74
+ const entries = auditEntries(dir);
75
+ const wrote = fs.existsSync(path.join(dir, 'out2.txt'));
76
+ const logged = entries.some(e => e.action === 'file_write');
77
+ result('fs.promises.writeFile', wrote && logged && r.status === 0,
78
+ wrote ? (logged ? 'file written + logged' : 'file written, NOT logged') : 'file not written');
79
+ }
80
+
81
+ // ── 3. fs.rm ────────────────────────────────────────────────────────────────
82
+ {
83
+ const dir = tmpDir();
84
+ fs.writeFileSync(path.join(dir, 'del.txt'), 'x');
85
+ const r = runNode(dir, `
86
+ const fs = require('fs');
87
+ fs.rm('del.txt', err => { if (err) process.exit(1); });
88
+ `);
89
+ const entries = auditEntries(dir);
90
+ const deleted = !fs.existsSync(path.join(dir, 'del.txt'));
91
+ const logged = entries.some(e => e.action === 'file_delete');
92
+ result('fs.rm', deleted && logged && r.status === 0,
93
+ deleted ? (logged ? 'file deleted + logged' : 'file deleted, NOT logged') : 'file not deleted');
94
+ }
95
+
96
+ // ── 4. fs.rename ────────────────────────────────────────────────────────────
97
+ {
98
+ const dir = tmpDir();
99
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'x');
100
+ const r = runNode(dir, `
101
+ const fs = require('fs');
102
+ fs.rename('a.txt', 'b.txt', err => { if (err) process.exit(1); });
103
+ `);
104
+ const entries = auditEntries(dir);
105
+ const renamed = !fs.existsSync(path.join(dir, 'a.txt')) && fs.existsSync(path.join(dir, 'b.txt'));
106
+ const logged = entries.some(e => e.action === 'file_rename');
107
+ result('fs.rename', renamed && logged && r.status === 0,
108
+ renamed ? (logged ? 'renamed + logged' : 'renamed, NOT logged') : 'rename failed');
109
+ }
110
+
111
+ // ── 5. child_process.spawn ──────────────────────────────────────────────────
112
+ {
113
+ const dir = tmpDir();
114
+ const r = runNode(dir, `
115
+ const { spawn } = require('child_process');
116
+ const child = spawn(process.execPath, ['--version']);
117
+ let out = '';
118
+ child.stdout.on('data', d => out += d);
119
+ child.on('close', code => {
120
+ if (code !== 0) process.exit(1);
121
+ });
122
+ `);
123
+ const entries = auditEntries(dir);
124
+ const logged = entries.some(e => e.action === 'run_shell');
125
+ result('spawn', r.status === 0 && logged,
126
+ logged ? 'spawn logged' : 'spawn NOT logged');
127
+ }
128
+
129
+ // ── 6. child_process.exec ───────────────────────────────────────────────────
130
+ {
131
+ const dir = tmpDir();
132
+ // execSync is synchronous — quote path for Windows spaces
133
+ const r = runNode(dir, `
134
+ const { execSync } = require('child_process');
135
+ execSync('"' + process.execPath + '" --version');
136
+ `);
137
+ const entries = auditEntries(dir);
138
+ const logged = entries.some(e => e.action === 'run_shell');
139
+ result('exec / execSync', r.status === 0 && logged,
140
+ logged ? 'execSync logged' : 'execSync NOT logged');
141
+ }
142
+
143
+ // ── 7. observe mode — writes still execute ──────────────────────────────────
144
+ {
145
+ const dir = tmpDir();
146
+ const r = runNode(dir, `
147
+ require('fs').writeFileSync('observe.txt', 'x');
148
+ `, 'observe');
149
+ const entries = auditEntries(dir);
150
+ const wrote = fs.existsSync(path.join(dir, 'observe.txt'));
151
+ const e = entries.find(e => e.action === 'file_write');
152
+ const enforced = e && e.enforced === false;
153
+ result('observe mode (write executes)', wrote && enforced,
154
+ wrote ? (enforced ? 'executed=true, enforced=false' : 'wrong enforced flag') : 'file not written');
155
+ }
156
+
157
+ // ── 8. enforce mode — deny rule blocks write ─────────────────────────────────
158
+ {
159
+ const dir = tmpDir();
160
+ // Overwrite policy with deny-all enforce rule
161
+ fs.writeFileSync(path.join(dir, '.lbe', 'policy.json'), JSON.stringify({
162
+ version: 1, mode: 'enforce', workspace: dir,
163
+ rules: [{ id: 'r1', effect: 'deny', type: 'path', pattern: '**', from: 'test' }]
164
+ }));
165
+ const r = runNode(dir, `
166
+ try { require('fs').writeFileSync('blocked.txt', 'x'); }
167
+ catch(e) { /* expected */ }
168
+ `, 'enforce');
169
+ const entries = auditEntries(dir);
170
+ const notWritten = !fs.existsSync(path.join(dir, 'blocked.txt'));
171
+ const e = entries.find(e => e.decision === 'deny');
172
+ result('enforce mode (deny blocks)', notWritten && !!e,
173
+ notWritten ? (e ? 'blocked + logged' : 'blocked, NOT logged') : 'file was written (not blocked)');
174
+ }
175
+
176
+ // ── 9. no recursion — one write = one audit entry ───────────────────────────
177
+ {
178
+ const dir = tmpDir();
179
+ const r = runNode(dir, `
180
+ require('fs').writeFileSync('single.txt', 'x');
181
+ `);
182
+ const entries = auditEntries(dir).filter(e => e.action === 'file_write' && e.path && e.path.endsWith('single.txt'));
183
+ result('no recursion', entries.length === 1,
184
+ `${entries.length} audit entries for 1 writeFileSync (expected 1)`);
185
+ }
186
+
187
+ // ── 10. status PID detection ────────────────────────────────────────────────
188
+ {
189
+ const dir = tmpDir();
190
+ // Start a background node process with the hook
191
+ const child = spawn(process.execPath, ['-e', `
192
+ // keep alive for 3 seconds
193
+ setTimeout(() => {}, 3000);
194
+ `], {
195
+ env: { ...process.env, NODE_OPTIONS: `--require "${HOOK_FWD}"`, LBE_ROOT: dir, LBE_MODE: 'observe' },
196
+ cwd: dir,
197
+ stdio: 'ignore',
198
+ detached: false,
199
+ });
200
+
201
+ // Wait briefly for hook to write status file
202
+ await new Promise(r => setTimeout(r, 800));
203
+
204
+ const statusFile = path.join(dir, '.lbe', 'runtime', 'hook-status.json');
205
+ let pidAlive = false;
206
+ let statusOk = false;
207
+ if (fs.existsSync(statusFile)) {
208
+ const s = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
209
+ statusOk = true;
210
+ try { process.kill(s.pid, 0); pidAlive = true; } catch (_) {}
211
+ }
212
+
213
+ result('status PID (alive)', statusOk && pidAlive,
214
+ statusOk ? (pidAlive ? `PID ${JSON.parse(fs.readFileSync(statusFile,'utf8')).pid} alive` : 'PID dead') : 'hook-status.json missing');
215
+
216
+ child.kill();
217
+ await new Promise(r => setTimeout(r, 300));
218
+
219
+ // After kill, PID should be gone
220
+ let pidGone = false;
221
+ if (fs.existsSync(statusFile)) {
222
+ const s = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
223
+ try { process.kill(s.pid, 0); } catch (_) { pidGone = true; }
224
+ }
225
+ result('status PID (stale after kill)', statusOk && pidGone,
226
+ pidGone ? 'PID correctly gone' : 'PID still appears alive');
227
+ }
228
+
229
+ // ── 11. clean workspace ──────────────────────────────────────────────────────
230
+ {
231
+ const dir = tmpDir();
232
+ runNode(dir, `require('fs').writeFileSync('x.txt','x');`);
233
+
234
+ const rootFiles = fs.readdirSync(dir);
235
+ const lbeFiles = fs.existsSync(path.join(dir, '.lbe')) ? fs.readdirSync(path.join(dir, '.lbe')) : [];
236
+ const badRootFiles = rootFiles.filter(f =>
237
+ f !== '.lbe' && f !== 'x.txt' &&
238
+ (f.startsWith('lbe') || f === 'CLAUDE.md' || f.startsWith('_lbe'))
239
+ );
240
+ result('clean workspace', badRootFiles.length === 0,
241
+ badRootFiles.length ? 'unexpected root files: ' + badRootFiles.join(', ') : 'no LBE pollution in root');
242
+ }
243
+
244
+ // ── Summary ──────────────────────────────────────────────────────────────────
245
+ console.log(`\n${passed + failed} tests ✓ ${passed} passed ${failed > 0 ? '✗ ' + failed + ' failed' : ''}`);
246
+ if (failed > 0) process.exit(1);