@letterblack/lbe-exec 1.2.10 → 1.2.12
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/README.md +96 -139
- package/dist/cli.js +251 -24
- package/dist/index.js +40 -1
- package/hooks/register.cjs +473 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,133 +1,19 @@
|
|
|
1
1
|
# @letterblack/lbe-exec
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
LBE puts a local policy gate between what an AI agent proposes and what the system actually executes. Every action — file write, shell command, anything — is validated locally before it runs. No cloud service. No daemon.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
> Use `@letterblack/lbe-sdk` if you only need validation decisions inside your own tool.
|
|
5
|
+
`lbe-exec` is the full in-process controller. It handles signing, execution, and auditing for you — your agent code just calls `lbe.writeFile()` or `lbe.runShell()`.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
through a local validation gate before it can execute. One package, any agent,
|
|
10
|
-
zero cloud dependency, no daemon required.
|
|
7
|
+
> **Used in production:** LBE is the safety engine inside [Letterblack for After Effects](https://letterblack.net) — every AI-generated script and automation command passes through it before touching a live project.
|
|
11
8
|
|
|
12
9
|
---
|
|
13
10
|
|
|
14
|
-
##
|
|
11
|
+
## Which package do you need?
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
and they will, the moment they are given a reason to. The host is the only
|
|
18
|
-
thing standing between a plausible-looking request and irreversible damage.
|
|
19
|
-
|
|
20
|
-
That is not enough.
|
|
21
|
-
|
|
22
|
-
`@letterblack/lbe-exec` puts a deterministic local gate between what the agent
|
|
23
|
-
proposes and what the system actually executes — fully in-process, no cloud
|
|
24
|
-
dependency, no daemon required.
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## How every request travels
|
|
29
|
-
|
|
30
|
-

|
|
31
|
-
|
|
32
|
-
Every request the agent produces enters a 7-gate pipeline. A failure at any
|
|
33
|
-
gate returns a structured denial — the remaining gates are not evaluated.
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
[1] Schema required fields and structural validity
|
|
37
|
-
↓
|
|
38
|
-
[2] Timestamp permitted clock-skew window (±10 minutes)
|
|
39
|
-
↓
|
|
40
|
-
[3] Key lifecycle trusted key, active, not expired
|
|
41
|
-
↓
|
|
42
|
-
[4] Signature Ed25519 request authenticity (signed locally, no network)
|
|
43
|
-
↓
|
|
44
|
-
[5] Rate limit per-requester sliding-window limit
|
|
45
|
-
↓
|
|
46
|
-
[6] Nonce single-use replay protection
|
|
47
|
-
↓
|
|
48
|
-
[7] Policy configured authorization (deny-wins)
|
|
49
|
-
↓
|
|
50
|
-
allow / deny / error — structured result returned to host
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
The executor signs every request with a host-held key before validation.
|
|
54
|
-
No key material leaves the process.
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## When a request is approved
|
|
59
|
-
|
|
60
|
-

|
|
61
|
-
|
|
62
|
-
When a request clears every gate:
|
|
63
|
-
|
|
64
|
-
1. The agent calls a convenience method — `lbe.writeFile()`, `lbe.runShell()`, etc.
|
|
65
|
-
2. The executor constructs and signs the request locally with a host-held Ed25519 key.
|
|
66
|
-
3. All seven gates pass. The project policy approves the action.
|
|
67
|
-
4. The write or command executes inside the configured project root.
|
|
68
|
-
5. The audit chain is extended — every approved action appends a hash-linked
|
|
69
|
-
entry to `.lbe/audit.jsonl`, permanently verifiable, impossible to silently remove.
|
|
70
|
-
6. A structured result returns: whether it succeeded, which rules matched, and
|
|
71
|
-
the audit entry identifier.
|
|
72
|
-
|
|
73
|
-
The application stays in control. The executor decides whether the action was
|
|
74
|
-
permitted and hands the answer back.
|
|
75
|
-
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
## When a request is blocked
|
|
79
|
-
|
|
80
|
-

|
|
81
|
-
|
|
82
|
-
When a request fails any gate:
|
|
83
|
-
|
|
84
|
-
1. The agent attempts an action — whether by mistake, misconfiguration,
|
|
85
|
-
or a deliberate bypass attempt.
|
|
86
|
-
2. The policy gate closes immediately. The request is denied before any adapter
|
|
87
|
-
is reached.
|
|
88
|
-
3. The shell is untouched. The filesystem is unchanged. Nothing that should not
|
|
89
|
-
have happened, happened.
|
|
90
|
-
4. The denial is written to the immutable audit log — chain sealed, evidence
|
|
91
|
-
preserved.
|
|
92
|
-
5. The final state is clean, verifiable, and consistent.
|
|
93
|
-
|
|
94
|
-
No partial execution. No silent failures. Denial is a first-class outcome, not
|
|
95
|
-
an error.
|
|
96
|
-
|
|
97
|
-
---
|
|
98
|
-
|
|
99
|
-
## Policy — who writes the rules
|
|
100
|
-
|
|
101
|
-
Only the host application writes policy. Agents may propose a rule — the
|
|
102
|
-
proposal is returned as a plain object for the host to review. Until the host
|
|
103
|
-
explicitly accepts and writes it, the proposal has no effect on any gate.
|
|
104
|
-
|
|
105
|
-
This separation is intentional. An agent that can write its own policy rules
|
|
106
|
-
is an agent that can grant itself permission.
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
## What this covers
|
|
111
|
-
|
|
112
|
-
| Threat | Gate |
|
|
13
|
+
| I want… | Package |
|
|
113
14
|
|---|---|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
| Tampered or expired key | Identity — key lifecycle |
|
|
117
|
-
| Excessive requests | Identity — rate limit |
|
|
118
|
-
| Action not permitted by project policy | Policy — deny-wins evaluation |
|
|
119
|
-
| Unauthorized shell command | Scope — explicit command allowlist |
|
|
120
|
-
| Injected payload (eval, exec, __proto__) | Content scan before pipeline |
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## Observer mode — start here
|
|
125
|
-
|
|
126
|
-
Not ready to block? Start in observer mode. Every request is fully validated
|
|
127
|
-
and logged exactly as it would be in enforcement — but nothing is blocked.
|
|
128
|
-
|
|
129
|
-
Watch what the agent is doing before you decide what to deny. Your rules take
|
|
130
|
-
effect the moment you switch to enforcement.
|
|
15
|
+
| LBE to handle file writes and shell commands for me (full controller) | `@letterblack/lbe-exec` ← you are here |
|
|
16
|
+
| Just the allow/deny decision — I'll execute it myself | `@letterblack/lbe-sdk` |
|
|
131
17
|
|
|
132
18
|
---
|
|
133
19
|
|
|
@@ -138,39 +24,36 @@ npm install @letterblack/lbe-exec
|
|
|
138
24
|
npx lbe-exec init
|
|
139
25
|
```
|
|
140
26
|
|
|
141
|
-
`npx lbe-exec init`
|
|
142
|
-
mode, generates `CLAUDE.md` and `.github/copilot-instructions.md` so every AI
|
|
143
|
-
agent automatically discovers and follows governance, and creates
|
|
144
|
-
`.lbe/AGENT_CONTRACT.md` as a machine-readable contract.
|
|
27
|
+
`npx lbe-exec init` creates `lbe.policy.json` in observer mode, generates `CLAUDE.md` and `.github/copilot-instructions.md` so AI agents automatically discover and follow governance, and writes `.lbe/AGENT_CONTRACT.md` as a machine-readable contract.
|
|
145
28
|
|
|
146
29
|
Requires Node.js ≥ 20.9.0.
|
|
147
30
|
|
|
148
31
|
---
|
|
149
32
|
|
|
150
|
-
##
|
|
33
|
+
## Quick start
|
|
151
34
|
|
|
152
35
|
```js
|
|
153
36
|
import { createLocalExecutor } from '@letterblack/lbe-exec';
|
|
154
37
|
|
|
155
38
|
const lbe = createLocalExecutor({ rootDir: process.cwd() });
|
|
156
39
|
|
|
157
|
-
// Every call routes through the full 7-gate pipeline
|
|
40
|
+
// Every call routes through the full 7-gate pipeline automatically
|
|
158
41
|
await lbe.writeFile('output/report.md', content);
|
|
159
42
|
await lbe.readFile('src/config.json');
|
|
160
43
|
await lbe.patchFile('src/index.js', patch);
|
|
161
44
|
await lbe.deleteFile('tmp/scratch.txt');
|
|
162
45
|
await lbe.runShell('node', ['scripts/build.js']);
|
|
163
46
|
|
|
164
|
-
// Result shape
|
|
165
|
-
const result = await lbe.writeFile('output/result.md', data);
|
|
47
|
+
// Result shape — same for every method
|
|
166
48
|
// { ok: true, decision: 'allow', executed: true, auditId: '...' }
|
|
167
49
|
// { ok: false, decision: 'deny', executed: false, error: { code, message } }
|
|
168
50
|
```
|
|
169
51
|
|
|
170
|
-
No knowledge of the pipeline, request format, or policy internals required.
|
|
171
|
-
|
|
52
|
+
No knowledge of the pipeline, request format, or policy internals required. All signing, validation, and auditing happens automatically.
|
|
53
|
+
|
|
54
|
+
---
|
|
172
55
|
|
|
173
|
-
|
|
56
|
+
## Options
|
|
174
57
|
|
|
175
58
|
```js
|
|
176
59
|
const lbe = createLocalExecutor({
|
|
@@ -184,7 +67,11 @@ const lbe = createLocalExecutor({
|
|
|
184
67
|
});
|
|
185
68
|
```
|
|
186
69
|
|
|
187
|
-
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Policy management
|
|
73
|
+
|
|
74
|
+
Only the host application writes policy. Agents may propose a rule — the proposal is returned as a plain object for the host to review. Until the host explicitly accepts and writes it, the proposal has no effect.
|
|
188
75
|
|
|
189
76
|
```js
|
|
190
77
|
// Propose a rule — returns an object for the host to review, writes nothing
|
|
@@ -207,16 +94,20 @@ lbe.audit.verify();
|
|
|
207
94
|
|
|
208
95
|
---
|
|
209
96
|
|
|
210
|
-
##
|
|
97
|
+
## Observer mode — start here
|
|
98
|
+
|
|
99
|
+
Not ready to block? Start in observer mode. Every request is fully validated and logged exactly as it would be in enforcement — but nothing is blocked. Watch what the agent is doing before you decide what to deny.
|
|
211
100
|
|
|
212
101
|
```bash
|
|
213
102
|
npx lbe-exec init # create lbe.policy.json in observer mode
|
|
214
|
-
npx lbe-exec status # show mode, rule count, and audit entry count
|
|
215
103
|
npx lbe-exec enforce # switch to blocking
|
|
216
104
|
npx lbe-exec observe # switch back to advisory
|
|
217
|
-
npx lbe-exec policy # list active rules
|
|
218
105
|
```
|
|
219
106
|
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## CLI reference
|
|
110
|
+
|
|
220
111
|
| Command | Purpose |
|
|
221
112
|
|---|---|
|
|
222
113
|
| `npx lbe-exec init` | Bootstrap governance — policy, keys, agent files |
|
|
@@ -228,6 +119,74 @@ npx lbe-exec policy # list active rules
|
|
|
228
119
|
|
|
229
120
|
---
|
|
230
121
|
|
|
122
|
+
## How the gate pipeline works
|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
Every request enters a 7-gate pipeline. A failure at any gate returns a structured denial — the remaining gates are not evaluated.
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
[1] Schema required fields and structural validity
|
|
130
|
+
↓
|
|
131
|
+
[2] Timestamp permitted clock-skew window (±10 minutes)
|
|
132
|
+
↓
|
|
133
|
+
[3] Key lifecycle trusted key, active, not expired
|
|
134
|
+
↓
|
|
135
|
+
[4] Signature Ed25519 request authenticity (signed locally, no network)
|
|
136
|
+
↓
|
|
137
|
+
[5] Rate limit per-requester sliding-window limit
|
|
138
|
+
↓
|
|
139
|
+
[6] Nonce single-use replay protection
|
|
140
|
+
↓
|
|
141
|
+
[7] Policy configured authorization (deny-wins)
|
|
142
|
+
↓
|
|
143
|
+
allow / deny / error — structured result returned to host
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The executor signs every request with a host-held key before validation. No key material leaves the process.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## When a request is approved
|
|
151
|
+
|
|
152
|
+

|
|
153
|
+
|
|
154
|
+
1. The agent calls a convenience method — `lbe.writeFile()`, `lbe.runShell()`, etc.
|
|
155
|
+
2. The executor constructs and signs the request locally with a host-held Ed25519 key.
|
|
156
|
+
3. All seven gates pass. The project policy approves the action.
|
|
157
|
+
4. The write or command executes inside the configured project root.
|
|
158
|
+
5. The audit chain is extended — every approved action appends a hash-linked entry to `.lbe/audit.jsonl`, permanently verifiable, impossible to silently remove.
|
|
159
|
+
6. A structured result returns: whether it succeeded, which rules matched, and the audit entry identifier.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## When a request is blocked
|
|
164
|
+
|
|
165
|
+

|
|
166
|
+
|
|
167
|
+
1. The agent attempts an action — whether by mistake, misconfiguration, or a deliberate bypass attempt.
|
|
168
|
+
2. The policy gate closes immediately. The request is denied before any adapter is reached.
|
|
169
|
+
3. The shell is untouched. The filesystem is unchanged.
|
|
170
|
+
4. The denial is written to the immutable audit log — chain sealed, evidence preserved.
|
|
171
|
+
|
|
172
|
+
No partial execution. No silent failures. Denial is a first-class outcome, not an error.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## What this covers
|
|
177
|
+
|
|
178
|
+
| Threat | Gate |
|
|
179
|
+
|---|---|
|
|
180
|
+
| Agent writes outside the project root | Scope — sandbox path check |
|
|
181
|
+
| Replayed or stale request | Identity — nonce and timestamp |
|
|
182
|
+
| Tampered or expired key | Identity — key lifecycle |
|
|
183
|
+
| Excessive requests | Identity — rate limit |
|
|
184
|
+
| Action not permitted by project policy | Policy — deny-wins evaluation |
|
|
185
|
+
| Unauthorized shell command | Scope — explicit command allowlist |
|
|
186
|
+
| Injected payload (eval, exec, __proto__) | Content scan before pipeline |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
231
190
|
## What ships
|
|
232
191
|
|
|
233
192
|
```
|
|
@@ -251,8 +210,6 @@ Source code, tests, keys, and runtime state are not included.
|
|
|
251
210
|
|
|
252
211
|
## Limits
|
|
253
212
|
|
|
254
|
-
This package governs actions routed through its executor. It does not provide
|
|
255
|
-
kernel-level process isolation, network-egress control, multi-tenant separation,
|
|
256
|
-
or a hosted control plane.
|
|
213
|
+
This package governs actions routed through its executor. It does not provide kernel-level process isolation, network-egress control, multi-tenant separation, or a hosted control plane.
|
|
257
214
|
|
|
258
215
|
For the raw WASM runtime without a controller, see `@letterblack/lbe-sdk`.
|
package/dist/cli.js
CHANGED
|
@@ -1762,6 +1762,43 @@ function createLocalExecutor(options = {}) {
|
|
|
1762
1762
|
if (!validation.valid) return { error: error(validation.errors[0]?.type || "VALIDATION_FAILED", validation.errors[0]?.message || "Validation failed"), local, localDecision, normalized, proposal, policy, validation };
|
|
1763
1763
|
return { local, localDecision, normalized, proposal, policy, validation };
|
|
1764
1764
|
}
|
|
1765
|
+
function evaluateSync(action) {
|
|
1766
|
+
const local = loadLocalPolicy(rootDir, options.mode || "observe");
|
|
1767
|
+
const mode = local.policy.mode;
|
|
1768
|
+
let target = null;
|
|
1769
|
+
let command = null;
|
|
1770
|
+
if (action.path) {
|
|
1771
|
+
try {
|
|
1772
|
+
target = path14.resolve(rootDir, action.path);
|
|
1773
|
+
if (!underRoot(target, rootDir)) {
|
|
1774
|
+
return { decision: "deny", deny: true, matchedRules: ["path:outside_root"], mode, enforced: mode === "enforce", reason: "PATH_OUTSIDE_ROOT" };
|
|
1775
|
+
}
|
|
1776
|
+
} catch (e) {
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
if (action.cmd) command = action.cmd;
|
|
1780
|
+
const localDecision = evaluateLocalPolicy(local.policy, rootDir, { target, command });
|
|
1781
|
+
const isDeny = !localDecision.allowed;
|
|
1782
|
+
return {
|
|
1783
|
+
decision: isDeny ? "deny" : "allow",
|
|
1784
|
+
deny: isDeny,
|
|
1785
|
+
matchedRules: localDecision.winningRules.map((r) => r.id),
|
|
1786
|
+
mode,
|
|
1787
|
+
enforced: mode === "enforce"
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
function auditSync(entry) {
|
|
1791
|
+
const eventsPath = path14.join(rootDir, ".lbe", "events.jsonl");
|
|
1792
|
+
const dir = path14.dirname(eventsPath);
|
|
1793
|
+
if (!fs13.existsSync(dir)) fs13.mkdirSync(dir, { recursive: true });
|
|
1794
|
+
const line = JSON.stringify({ ts: Math.floor(Date.now() / 1e3), ...entry }) + "\n";
|
|
1795
|
+
const fd = fs13.openSync(eventsPath, "a");
|
|
1796
|
+
try {
|
|
1797
|
+
fs13.writeSync(fd, line);
|
|
1798
|
+
} finally {
|
|
1799
|
+
fs13.closeSync(fd);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1765
1802
|
async function dryRun(request) {
|
|
1766
1803
|
const prepared = prepare(request);
|
|
1767
1804
|
if (prepared.error) return { ...prepared.error, dryRun: true };
|
|
@@ -1845,7 +1882,9 @@ function createLocalExecutor(options = {}) {
|
|
|
1845
1882
|
proposeRule: proposePolicyRule,
|
|
1846
1883
|
addRule: (rule) => addLocalPolicyRule(rootDir, rule, options.mode || "enforce")
|
|
1847
1884
|
},
|
|
1848
|
-
audit: { verify: () => verifyAuditLogIntegrity(path14.join(rootDir, ".lbe/audit.jsonl")) }
|
|
1885
|
+
audit: { verify: () => verifyAuditLogIntegrity(path14.join(rootDir, ".lbe/audit.jsonl")) },
|
|
1886
|
+
evaluateSync,
|
|
1887
|
+
auditSync
|
|
1849
1888
|
};
|
|
1850
1889
|
}
|
|
1851
1890
|
var INTENTS, MUTATIONS, FORBIDDEN_CONTENT;
|
|
@@ -1881,6 +1920,8 @@ var init_localExecutor = __esm({
|
|
|
1881
1920
|
// exec/cli.js
|
|
1882
1921
|
import fs14 from "fs";
|
|
1883
1922
|
import path15 from "path";
|
|
1923
|
+
import { spawnSync as spawnSync2, spawn } from "child_process";
|
|
1924
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1884
1925
|
|
|
1885
1926
|
// src/cli/commands/init.js
|
|
1886
1927
|
init_signature();
|
|
@@ -2528,6 +2569,8 @@ var [, , cmd, ...rest] = process.argv;
|
|
|
2528
2569
|
var opts = Object.fromEntries(
|
|
2529
2570
|
rest.flatMap((v, i, a) => v.startsWith("--") ? [[v.slice(2), a[i + 1] ?? true]] : [])
|
|
2530
2571
|
);
|
|
2572
|
+
var positional = rest.filter((v) => !v.startsWith("--") && rest[rest.indexOf(v) - 1]?.startsWith("--") === false);
|
|
2573
|
+
var __dir = path15.dirname(fileURLToPath2(import.meta.url));
|
|
2531
2574
|
function loadPolicy() {
|
|
2532
2575
|
const p = path15.join(process.cwd(), "lbe.policy.json");
|
|
2533
2576
|
return fs14.existsSync(p) ? JSON.parse(fs14.readFileSync(p, "utf8")) : null;
|
|
@@ -2537,36 +2580,216 @@ function countAudit() {
|
|
|
2537
2580
|
if (!fs14.existsSync(p)) return 0;
|
|
2538
2581
|
return fs14.readFileSync(p, "utf8").split("\n").filter((l) => l.trim()).length;
|
|
2539
2582
|
}
|
|
2583
|
+
function findHookPath() {
|
|
2584
|
+
const candidates = [
|
|
2585
|
+
path15.resolve(__dir, "../hooks/register.cjs"),
|
|
2586
|
+
// npm: dist/ → ../hooks/
|
|
2587
|
+
path15.resolve(__dir, "../src/hooks/register.cjs")
|
|
2588
|
+
// dev: exec/ → ../src/hooks/
|
|
2589
|
+
];
|
|
2590
|
+
return candidates.find((p) => fs14.existsSync(p)) || candidates[0];
|
|
2591
|
+
}
|
|
2592
|
+
function detectNodeScripts(scripts) {
|
|
2593
|
+
const pattern = /(?:^|\s)node\s+(\S+)/;
|
|
2594
|
+
return Object.entries(scripts || {}).filter(([name, cmd2]) => {
|
|
2595
|
+
if (name.includes(":lbe") || name.startsWith("lbe")) return false;
|
|
2596
|
+
return pattern.test(cmd2);
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
function extractNodeArgs(cmd2) {
|
|
2600
|
+
const match = cmd2.match(/(?:^|\s)node\s+(.+)/);
|
|
2601
|
+
return match ? match[1].trim() : null;
|
|
2602
|
+
}
|
|
2603
|
+
function injectScripts(wrapScript) {
|
|
2604
|
+
const pkgPath = path15.join(process.cwd(), "package.json");
|
|
2605
|
+
if (!fs14.existsSync(pkgPath)) return [];
|
|
2606
|
+
const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf8"));
|
|
2607
|
+
const scripts = pkg.scripts || {};
|
|
2608
|
+
const added = [];
|
|
2609
|
+
if (wrapScript) {
|
|
2610
|
+
const original = scripts[wrapScript];
|
|
2611
|
+
if (!original) {
|
|
2612
|
+
console.error(`No script named "${wrapScript}" found.`);
|
|
2613
|
+
return [];
|
|
2614
|
+
}
|
|
2615
|
+
const args = extractNodeArgs(original);
|
|
2616
|
+
if (!args) {
|
|
2617
|
+
console.error(`Script "${wrapScript}" does not look like a node script.`);
|
|
2618
|
+
return [];
|
|
2619
|
+
}
|
|
2620
|
+
scripts[wrapScript] = `lbe-exec run-node --mode observe ${args}`;
|
|
2621
|
+
added.push(wrapScript);
|
|
2622
|
+
} else {
|
|
2623
|
+
const candidates = detectNodeScripts(scripts);
|
|
2624
|
+
for (const [name, scriptCmd] of candidates) {
|
|
2625
|
+
const args = extractNodeArgs(scriptCmd);
|
|
2626
|
+
if (!args) continue;
|
|
2627
|
+
const lbeName = name + ":lbe";
|
|
2628
|
+
const lbeEnforceName = name + ":lbe:enforce";
|
|
2629
|
+
if (!scripts[lbeName]) {
|
|
2630
|
+
scripts[lbeName] = `lbe-exec run-node --mode observe ${args}`;
|
|
2631
|
+
added.push(lbeName);
|
|
2632
|
+
}
|
|
2633
|
+
if (!scripts[lbeEnforceName]) {
|
|
2634
|
+
scripts[lbeEnforceName] = `lbe-exec run-node --mode enforce ${args}`;
|
|
2635
|
+
added.push(lbeEnforceName);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
if (!scripts["lbe:status"]) {
|
|
2640
|
+
scripts["lbe:status"] = "lbe-exec status";
|
|
2641
|
+
added.push("lbe:status");
|
|
2642
|
+
}
|
|
2643
|
+
if (!scripts["lbe:audit"]) {
|
|
2644
|
+
scripts["lbe:audit"] = "lbe-exec audit";
|
|
2645
|
+
added.push("lbe:audit");
|
|
2646
|
+
}
|
|
2647
|
+
if (added.length) {
|
|
2648
|
+
pkg.scripts = scripts;
|
|
2649
|
+
fs14.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
2650
|
+
for (const s of added) console.log(` added: ${s}`);
|
|
2651
|
+
}
|
|
2652
|
+
return added;
|
|
2653
|
+
}
|
|
2540
2654
|
switch (cmd) {
|
|
2541
|
-
case "
|
|
2542
|
-
|
|
2543
|
-
|
|
2655
|
+
case "run-node": {
|
|
2656
|
+
const mode = opts.mode || "observe";
|
|
2657
|
+
if (!["observe", "enforce"].includes(mode)) {
|
|
2658
|
+
console.error("--mode must be observe or enforce");
|
|
2659
|
+
process.exit(1);
|
|
2660
|
+
}
|
|
2661
|
+
const scriptIdx = rest.findIndex((v, i) => !v.startsWith("--") && (i === 0 || !rest[i - 1].startsWith("--")));
|
|
2662
|
+
if (scriptIdx === -1) {
|
|
2663
|
+
console.error("Usage: lbe-exec run-node [--mode observe|enforce] <script> [...args]");
|
|
2544
2664
|
process.exit(1);
|
|
2665
|
+
}
|
|
2666
|
+
const scriptAndArgs = rest.slice(scriptIdx);
|
|
2667
|
+
const hookPath = findHookPath();
|
|
2668
|
+
if (!fs14.existsSync(hookPath)) {
|
|
2669
|
+
console.error("Hook not found: " + hookPath + "\nRun: npm install @letterblack/lbe-exec");
|
|
2670
|
+
process.exit(1);
|
|
2671
|
+
}
|
|
2672
|
+
const child = spawn(process.execPath, ["--require", hookPath, ...scriptAndArgs], {
|
|
2673
|
+
stdio: "inherit",
|
|
2674
|
+
env: { ...process.env, LBE_MODE: mode, LBE_ROOT: process.cwd() }
|
|
2545
2675
|
});
|
|
2676
|
+
child.on("close", (code) => process.exit(code ?? 0));
|
|
2546
2677
|
break;
|
|
2547
|
-
|
|
2548
|
-
case "
|
|
2549
|
-
|
|
2550
|
-
|
|
2678
|
+
}
|
|
2679
|
+
case "npm": {
|
|
2680
|
+
console.error('[lbe] Note: Use "lbe-exec run-node" for reliable hook preload.');
|
|
2681
|
+
console.error("[lbe] NODE_OPTIONS --require may not fire for all npm lifecycle hooks.\n");
|
|
2682
|
+
const hookPath = findHookPath();
|
|
2683
|
+
if (!fs14.existsSync(hookPath)) {
|
|
2684
|
+
console.error("Hook not found: " + hookPath);
|
|
2551
2685
|
process.exit(1);
|
|
2686
|
+
}
|
|
2687
|
+
const existing = process.env.NODE_OPTIONS || "";
|
|
2688
|
+
const hookFlag = "--require " + hookPath;
|
|
2689
|
+
const nodeOptions = existing.includes(hookFlag) ? existing : (existing + " " + hookFlag).trim();
|
|
2690
|
+
const npmArgs = rest;
|
|
2691
|
+
const isWindows = process.platform === "win32";
|
|
2692
|
+
const child = spawn(isWindows ? "npm.cmd" : "npm", npmArgs, {
|
|
2693
|
+
stdio: "inherit",
|
|
2694
|
+
shell: false,
|
|
2695
|
+
env: { ...process.env, NODE_OPTIONS: nodeOptions, LBE_MODE: opts.mode || "observe", LBE_ROOT: process.cwd() }
|
|
2552
2696
|
});
|
|
2697
|
+
child.on("close", (code) => process.exit(code ?? 0));
|
|
2553
2698
|
break;
|
|
2699
|
+
}
|
|
2554
2700
|
case "status": {
|
|
2555
2701
|
const policy = loadPolicy();
|
|
2556
|
-
|
|
2557
|
-
|
|
2702
|
+
console.log("\u2500\u2500 LBE Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
2703
|
+
console.log("workspace: " + process.cwd());
|
|
2704
|
+
if (policy) {
|
|
2705
|
+
console.log("mode: " + policy.mode);
|
|
2706
|
+
console.log("rules: " + (policy.rules?.length ?? 0));
|
|
2707
|
+
console.log("audit: " + countAudit() + " entries (.lbe/audit.jsonl)");
|
|
2708
|
+
} else {
|
|
2709
|
+
console.log("policy: not found \u2014 run: npx lbe-exec init");
|
|
2710
|
+
}
|
|
2711
|
+
const statusFile = path15.join(process.cwd(), ".lbe", "runtime", "hook-status.json");
|
|
2712
|
+
if (!fs14.existsSync(statusFile)) {
|
|
2713
|
+
console.log("\nhook: inactive \u2014 use: npx lbe-exec run-node ./agent.js");
|
|
2714
|
+
break;
|
|
2715
|
+
}
|
|
2716
|
+
let hookStatus;
|
|
2717
|
+
try {
|
|
2718
|
+
hookStatus = JSON.parse(fs14.readFileSync(statusFile, "utf8"));
|
|
2719
|
+
} catch (e) {
|
|
2720
|
+
console.log("\nhook: status file unreadable \u2014 " + e.message);
|
|
2721
|
+
break;
|
|
2722
|
+
}
|
|
2723
|
+
let pidAlive = false;
|
|
2724
|
+
try {
|
|
2725
|
+
process.kill(hookStatus.pid, 0);
|
|
2726
|
+
pidAlive = true;
|
|
2727
|
+
} catch (_) {
|
|
2728
|
+
}
|
|
2729
|
+
console.log("\nhook: " + (pidAlive ? "ACTIVE" : "stale (process exited)"));
|
|
2730
|
+
console.log("hook pid: " + hookStatus.pid + (pidAlive ? " (alive)" : " (gone)"));
|
|
2731
|
+
console.log("hook mode: " + hookStatus.mode);
|
|
2732
|
+
console.log("hook root: " + hookStatus.root);
|
|
2733
|
+
console.log("hook start: " + hookStatus.started_at);
|
|
2734
|
+
if (hookStatus.patched) {
|
|
2735
|
+
console.log("\nPatched functions:");
|
|
2736
|
+
for (const [fn, active] of Object.entries(hookStatus.patched)) {
|
|
2737
|
+
console.log(" " + (active ? "\u2713" : "\u2013") + " " + fn);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
console.log("\nevents log: .lbe/events.jsonl");
|
|
2741
|
+
break;
|
|
2742
|
+
}
|
|
2743
|
+
case "audit": {
|
|
2744
|
+
const eventsPath = path15.join(process.cwd(), ".lbe", "events.jsonl");
|
|
2745
|
+
if (!fs14.existsSync(eventsPath)) {
|
|
2746
|
+
console.log("No events log found. Run an agent with: npx lbe-exec run-node ./agent.js");
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2749
|
+
const lines = fs14.readFileSync(eventsPath, "utf8").split("\n").filter((l) => l.trim());
|
|
2750
|
+
if (!lines.length) {
|
|
2751
|
+
console.log("No events recorded yet.");
|
|
2558
2752
|
break;
|
|
2559
2753
|
}
|
|
2560
|
-
console.log(
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2754
|
+
console.log("\u2500\u2500 LBE Event Log (" + lines.length + " entries) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
2755
|
+
for (const line of lines) {
|
|
2756
|
+
try {
|
|
2757
|
+
const e = JSON.parse(line);
|
|
2758
|
+
const ts = new Date(e.ts * 1e3).toISOString().replace("T", " ").slice(0, 19);
|
|
2759
|
+
const target = e.path || e.cmd || "?";
|
|
2760
|
+
const status = e.enforced && e.decision === "deny" ? "BLOCKED" : e.decision === "deny" ? "WOULD-BLOCK" : "allowed";
|
|
2761
|
+
console.log(`${ts} [${e.mode}] ${e.action} ${target} \u2192 ${status}`);
|
|
2762
|
+
} catch (_) {
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2564
2765
|
break;
|
|
2565
2766
|
}
|
|
2767
|
+
case "init":
|
|
2768
|
+
initCommand(opts).then(() => {
|
|
2769
|
+
const added = injectScripts(opts.wrap || null);
|
|
2770
|
+
if (added.length) {
|
|
2771
|
+
console.log("\n\u2713 Added LBE script variants to package.json");
|
|
2772
|
+
console.log(" Run your agent through LBE: npm run <name>:lbe");
|
|
2773
|
+
} else {
|
|
2774
|
+
console.log("\nNo node agent scripts detected in package.json.");
|
|
2775
|
+
console.log("Use: npx lbe-exec run-node [--mode observe|enforce] ./your-agent.js");
|
|
2776
|
+
}
|
|
2777
|
+
}).catch((e) => {
|
|
2778
|
+
console.error(e.message);
|
|
2779
|
+
process.exit(1);
|
|
2780
|
+
});
|
|
2781
|
+
break;
|
|
2782
|
+
case "observe":
|
|
2783
|
+
case "enforce":
|
|
2784
|
+
policyModeCommand(cmd, opts).catch((e) => {
|
|
2785
|
+
console.error(e.message);
|
|
2786
|
+
process.exit(1);
|
|
2787
|
+
});
|
|
2788
|
+
break;
|
|
2566
2789
|
case "policy": {
|
|
2567
2790
|
const policy = loadPolicy();
|
|
2568
2791
|
if (!policy) {
|
|
2569
|
-
console.log("No lbe.policy.json found. Run: npx lbe init");
|
|
2792
|
+
console.log("No lbe.policy.json found. Run: npx lbe-exec init");
|
|
2570
2793
|
break;
|
|
2571
2794
|
}
|
|
2572
2795
|
if (!policy.rules?.length) {
|
|
@@ -2598,17 +2821,21 @@ switch (cmd) {
|
|
|
2598
2821
|
break;
|
|
2599
2822
|
}
|
|
2600
2823
|
default:
|
|
2601
|
-
console.log("Usage: lbe <command>\n");
|
|
2602
|
-
console.log(" init
|
|
2603
|
-
console.log("
|
|
2604
|
-
console.log("
|
|
2605
|
-
console.log("
|
|
2606
|
-
console.log("
|
|
2607
|
-
console.log("
|
|
2824
|
+
console.log("Usage: lbe-exec <command>\n");
|
|
2825
|
+
console.log(" init Bootstrap governance \u2014 policy, keys, agent files");
|
|
2826
|
+
console.log(" run-node Run a Node.js agent under LBE governance");
|
|
2827
|
+
console.log(" [--mode observe|enforce] <script> [...args]");
|
|
2828
|
+
console.log(" npm Wrap npm command with LBE hook (via NODE_OPTIONS)");
|
|
2829
|
+
console.log(" [...npm-args]");
|
|
2830
|
+
console.log(" status Show workspace, mode, hook state, patched functions");
|
|
2831
|
+
console.log(" audit Show unified event log (.lbe/events.jsonl)");
|
|
2832
|
+
console.log(" policy List active policy rules");
|
|
2833
|
+
console.log(" observe Switch to observer mode (log only, nothing blocked)");
|
|
2834
|
+
console.log(" enforce Switch to enforcement mode (violations blocked)");
|
|
2835
|
+
console.log(" execute Send a JSON request from stdin or --input file");
|
|
2608
2836
|
console.log("\nCLI: npx lbe-exec <command>");
|
|
2609
2837
|
if (cmd && cmd !== "--help" && cmd !== "help") {
|
|
2610
|
-
console.error(
|
|
2611
|
-
Unknown command: ${cmd}`);
|
|
2838
|
+
console.error("\nUnknown command: " + cmd);
|
|
2612
2839
|
process.exit(1);
|
|
2613
2840
|
}
|
|
2614
2841
|
}
|
package/dist/index.js
CHANGED
|
@@ -1705,6 +1705,43 @@ function createLocalExecutor(options = {}) {
|
|
|
1705
1705
|
if (!validation.valid) return { error: error(validation.errors[0]?.type || "VALIDATION_FAILED", validation.errors[0]?.message || "Validation failed"), local, localDecision, normalized, proposal, policy, validation };
|
|
1706
1706
|
return { local, localDecision, normalized, proposal, policy, validation };
|
|
1707
1707
|
}
|
|
1708
|
+
function evaluateSync(action) {
|
|
1709
|
+
const local = loadLocalPolicy(rootDir, options.mode || "observe");
|
|
1710
|
+
const mode = local.policy.mode;
|
|
1711
|
+
let target = null;
|
|
1712
|
+
let command = null;
|
|
1713
|
+
if (action.path) {
|
|
1714
|
+
try {
|
|
1715
|
+
target = path10.resolve(rootDir, action.path);
|
|
1716
|
+
if (!underRoot(target, rootDir)) {
|
|
1717
|
+
return { decision: "deny", deny: true, matchedRules: ["path:outside_root"], mode, enforced: mode === "enforce", reason: "PATH_OUTSIDE_ROOT" };
|
|
1718
|
+
}
|
|
1719
|
+
} catch (e) {
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
if (action.cmd) command = action.cmd;
|
|
1723
|
+
const localDecision = evaluateLocalPolicy(local.policy, rootDir, { target, command });
|
|
1724
|
+
const isDeny = !localDecision.allowed;
|
|
1725
|
+
return {
|
|
1726
|
+
decision: isDeny ? "deny" : "allow",
|
|
1727
|
+
deny: isDeny,
|
|
1728
|
+
matchedRules: localDecision.winningRules.map((r) => r.id),
|
|
1729
|
+
mode,
|
|
1730
|
+
enforced: mode === "enforce"
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
function auditSync(entry) {
|
|
1734
|
+
const eventsPath = path10.join(rootDir, ".lbe", "events.jsonl");
|
|
1735
|
+
const dir = path10.dirname(eventsPath);
|
|
1736
|
+
if (!fs9.existsSync(dir)) fs9.mkdirSync(dir, { recursive: true });
|
|
1737
|
+
const line = JSON.stringify({ ts: Math.floor(Date.now() / 1e3), ...entry }) + "\n";
|
|
1738
|
+
const fd = fs9.openSync(eventsPath, "a");
|
|
1739
|
+
try {
|
|
1740
|
+
fs9.writeSync(fd, line);
|
|
1741
|
+
} finally {
|
|
1742
|
+
fs9.closeSync(fd);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1708
1745
|
async function dryRun(request) {
|
|
1709
1746
|
const prepared = prepare(request);
|
|
1710
1747
|
if (prepared.error) return { ...prepared.error, dryRun: true };
|
|
@@ -1788,7 +1825,9 @@ function createLocalExecutor(options = {}) {
|
|
|
1788
1825
|
proposeRule: proposePolicyRule,
|
|
1789
1826
|
addRule: (rule) => addLocalPolicyRule(rootDir, rule, options.mode || "enforce")
|
|
1790
1827
|
},
|
|
1791
|
-
audit: { verify: () => verifyAuditLogIntegrity(path10.join(rootDir, ".lbe/audit.jsonl")) }
|
|
1828
|
+
audit: { verify: () => verifyAuditLogIntegrity(path10.join(rootDir, ".lbe/audit.jsonl")) },
|
|
1829
|
+
evaluateSync,
|
|
1830
|
+
auditSync
|
|
1792
1831
|
};
|
|
1793
1832
|
}
|
|
1794
1833
|
export {
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// LBE Agent Bridge — CJS preload hook
|
|
3
|
+
// Load with: node --require @letterblack/lbe-exec/hooks/register.cjs agent.js
|
|
4
|
+
// Or via: npx lbe-exec run-node [--mode observe|enforce] agent.js
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { EventEmitter } = require('events');
|
|
9
|
+
const { Readable } = require('stream');
|
|
10
|
+
|
|
11
|
+
const ROOT_DIR = process.env.LBE_ROOT || process.cwd();
|
|
12
|
+
const MODE = process.env.LBE_MODE || 'observe';
|
|
13
|
+
|
|
14
|
+
// ── Policy loader (inline CJS — ESM cannot be require()'d synchronously) ────
|
|
15
|
+
|
|
16
|
+
function loadPolicy() {
|
|
17
|
+
const policyPath = path.join(ROOT_DIR, 'lbe.policy.json');
|
|
18
|
+
try {
|
|
19
|
+
if (fs.existsSync(policyPath)) {
|
|
20
|
+
return JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
} catch (e) { /* fall through to default */ }
|
|
23
|
+
return { version: 1, mode: MODE, workspace: ROOT_DIR, rules: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function globToRegex(pattern) {
|
|
27
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
return new RegExp('^' + escaped
|
|
29
|
+
.replace(/\*\*\//g, '(?:.*/)?')
|
|
30
|
+
.replace(/\*\*/g, '.*')
|
|
31
|
+
.replace(/\*/g, '[^/]*') + '$');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function evaluatePolicy(action) {
|
|
35
|
+
const policy = loadPolicy();
|
|
36
|
+
const mode = policy.mode || MODE;
|
|
37
|
+
const rules = Array.isArray(policy.rules) ? policy.rules : [];
|
|
38
|
+
|
|
39
|
+
if (action.path) {
|
|
40
|
+
try {
|
|
41
|
+
const abs = path.resolve(ROOT_DIR, action.path);
|
|
42
|
+
const sep = path.sep;
|
|
43
|
+
if (!abs.startsWith(ROOT_DIR + sep) && abs !== ROOT_DIR) {
|
|
44
|
+
return { decision: 'deny', deny: true, reason: 'PATH_OUTSIDE_ROOT', matchedRules: ['path:outside_root'], mode, enforced: mode === 'enforce' };
|
|
45
|
+
}
|
|
46
|
+
const rel = path.relative(ROOT_DIR, abs).split(sep).join('/');
|
|
47
|
+
const matched = rules.filter(r => r.type === 'path' && globToRegex(r.pattern).test(rel));
|
|
48
|
+
const denied = matched.filter(r => r.effect === 'deny');
|
|
49
|
+
const isDeny = denied.length > 0;
|
|
50
|
+
return {
|
|
51
|
+
decision: isDeny ? 'deny' : 'allow', deny: isDeny,
|
|
52
|
+
matchedRules: (isDeny ? denied : matched.filter(r => r.effect === 'allow')).map(r => r.id),
|
|
53
|
+
mode, enforced: mode === 'enforce',
|
|
54
|
+
};
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (mode === 'enforce') {
|
|
57
|
+
return { decision: 'deny', deny: true, reason: 'PATH_RESOLUTION_ERROR', matchedRules: [], mode, enforced: true };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (action.cmd) {
|
|
63
|
+
const matched = rules.filter(r => r.type === 'command' && globToRegex(r.pattern).test(String(action.cmd)));
|
|
64
|
+
const denied = matched.filter(r => r.effect === 'deny');
|
|
65
|
+
const isDeny = denied.length > 0;
|
|
66
|
+
return {
|
|
67
|
+
decision: isDeny ? 'deny' : 'allow', deny: isDeny,
|
|
68
|
+
matchedRules: (isDeny ? denied : matched.filter(r => r.effect === 'allow')).map(r => r.id),
|
|
69
|
+
mode, enforced: mode === 'enforce',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { decision: 'allow', deny: false, matchedRules: [], mode, enforced: mode === 'enforce' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Audit writer ──────────────────────────────────────────────────────────────
|
|
77
|
+
// fs.appendFileSync → fs.writeFileSync (Node.js internal) → would recurse.
|
|
78
|
+
// fs.openSync/writeSync/closeSync are low-level bindings — never call back into JS.
|
|
79
|
+
// Re-entrant guard prevents recursion from any code path we missed.
|
|
80
|
+
|
|
81
|
+
var _auditInFlight = false;
|
|
82
|
+
|
|
83
|
+
function auditEvent(entry) {
|
|
84
|
+
if (_auditInFlight) return;
|
|
85
|
+
_auditInFlight = true;
|
|
86
|
+
try {
|
|
87
|
+
var eventsPath = path.join(ROOT_DIR, '.lbe', 'events.jsonl');
|
|
88
|
+
var dir = path.dirname(eventsPath);
|
|
89
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
90
|
+
var line = JSON.stringify({ ts: Math.floor(Date.now() / 1000), ...entry }) + '\n';
|
|
91
|
+
// Use open/write/close directly — bypasses all JS wrappers including writeFileSync
|
|
92
|
+
var fd = fs.openSync(eventsPath, 'a');
|
|
93
|
+
try { fs.writeSync(fd, line); } finally { fs.closeSync(fd); }
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn('[lbe] audit write failed:', e.message);
|
|
96
|
+
} finally {
|
|
97
|
+
_auditInFlight = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class LBEPermissionError extends Error {
|
|
102
|
+
constructor(decision, action) {
|
|
103
|
+
const target = action.path || action.cmd || 'unknown';
|
|
104
|
+
super('[LBE:' + decision.mode + '] DENIED ' + action.action + ' on ' + target);
|
|
105
|
+
this.name = 'LBEPermissionError';
|
|
106
|
+
this.code = 'LBE_PERMISSION_DENIED';
|
|
107
|
+
this.lbeDecision = decision;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Originals — captured BEFORE any patch is applied ────────────────────────
|
|
112
|
+
|
|
113
|
+
const origFs = {
|
|
114
|
+
writeFile: fs.writeFile.bind(fs),
|
|
115
|
+
writeFileSync: fs.writeFileSync.bind(fs),
|
|
116
|
+
rm: fs.rm ? fs.rm.bind(fs) : null,
|
|
117
|
+
rmSync: fs.rmSync ? fs.rmSync.bind(fs) : null,
|
|
118
|
+
unlink: fs.unlink.bind(fs),
|
|
119
|
+
unlinkSync: fs.unlinkSync.bind(fs),
|
|
120
|
+
rename: fs.rename.bind(fs),
|
|
121
|
+
renameSync: fs.renameSync.bind(fs),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const origPromises = {
|
|
125
|
+
writeFile: fs.promises.writeFile.bind(fs.promises),
|
|
126
|
+
rm: fs.promises.rm ? fs.promises.rm.bind(fs.promises) : null,
|
|
127
|
+
unlink: fs.promises.unlink.bind(fs.promises),
|
|
128
|
+
rename: fs.promises.rename.bind(fs.promises),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const cp = require('child_process');
|
|
132
|
+
const origCp = {
|
|
133
|
+
spawn: cp.spawn.bind(cp),
|
|
134
|
+
spawnSync: cp.spawnSync.bind(cp),
|
|
135
|
+
exec: cp.exec.bind(cp),
|
|
136
|
+
execSync: cp.execSync.bind(cp),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ── Denied spawn stub — EventEmitter-compatible ───────────────────────────────
|
|
140
|
+
|
|
141
|
+
function makeFailedChildProcess(err) {
|
|
142
|
+
const emitter = new EventEmitter();
|
|
143
|
+
emitter.pid = null;
|
|
144
|
+
emitter.killed = false;
|
|
145
|
+
emitter.exitCode = 1;
|
|
146
|
+
emitter.signalCode = null;
|
|
147
|
+
emitter.stdout = new Readable({ read() {} });
|
|
148
|
+
emitter.stderr = new Readable({ read() {} });
|
|
149
|
+
emitter.stdin = { write() { return false; }, end() {}, destroy() {} };
|
|
150
|
+
emitter.kill = () => false;
|
|
151
|
+
emitter.ref = () => emitter;
|
|
152
|
+
emitter.unref = () => emitter;
|
|
153
|
+
process.nextTick(function () {
|
|
154
|
+
emitter.stdout.push(null);
|
|
155
|
+
emitter.stderr.push(null);
|
|
156
|
+
emitter.emit('error', err);
|
|
157
|
+
emitter.emit('close', 1, null);
|
|
158
|
+
emitter.emit('exit', 1, null);
|
|
159
|
+
});
|
|
160
|
+
return emitter;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Decision + pre-block audit ────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function decide(action) {
|
|
166
|
+
var decision;
|
|
167
|
+
try {
|
|
168
|
+
decision = evaluatePolicy(action);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (MODE === 'enforce') {
|
|
171
|
+
var failErr = new LBEPermissionError({ mode: 'enforce', enforced: true }, action);
|
|
172
|
+
try { auditEvent({ action: action.action, path: action.path, cmd: action.cmd, actor: 'agent:lbe-hooks', decision: 'deny', mode: 'enforce', enforced: true, executed: false, matched_rules: [], error: e.message }); } catch (_) {}
|
|
173
|
+
return { blocked: true, error: failErr };
|
|
174
|
+
}
|
|
175
|
+
console.warn('[lbe] policy evaluation failed (observe mode, allowing):', e.message);
|
|
176
|
+
return { blocked: false, decision: { decision: 'allow', deny: false, matchedRules: [], mode: 'observe', enforced: false } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (decision.deny && decision.enforced) {
|
|
180
|
+
var err = new LBEPermissionError(decision, action);
|
|
181
|
+
try { auditEvent({ action: action.action, path: action.path, cmd: action.cmd, actor: 'agent:lbe-hooks', decision: 'deny', mode: decision.mode, enforced: true, executed: false, matched_rules: decision.matchedRules }); } catch (_) {}
|
|
182
|
+
return { blocked: true, error: err };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { blocked: false, decision: decision };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function postAudit(action, decision, executed, extra) {
|
|
189
|
+
try {
|
|
190
|
+
auditEvent(Object.assign({ action: action.action, path: action.path, cmd: action.cmd, actor: 'agent:lbe-hooks', decision: decision.decision, mode: decision.mode, enforced: decision.enforced, executed: executed, matched_rules: decision.matchedRules }, extra || {}));
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn('[lbe] post-action audit failed (result unaffected):', e.message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Patch fs callbacks ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
fs.writeFile = function lbeWriteFile(filePath, data, options, callback) {
|
|
199
|
+
if (typeof options === 'function') { callback = options; options = undefined; }
|
|
200
|
+
var action = { action: 'file_write', path: String(filePath) };
|
|
201
|
+
var check = decide(action);
|
|
202
|
+
if (check.blocked) { process.nextTick(function () { callback(check.error); }); return; }
|
|
203
|
+
function done(err) {
|
|
204
|
+
postAudit(action, check.decision, !err, err ? { error: err.message } : null);
|
|
205
|
+
callback(err);
|
|
206
|
+
}
|
|
207
|
+
if (options !== undefined) { origFs.writeFile(filePath, data, options, done); }
|
|
208
|
+
else { origFs.writeFile(filePath, data, done); }
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
fs.writeFileSync = function lbeWriteFileSync(filePath, data, options) {
|
|
212
|
+
var action = { action: 'file_write', path: String(filePath) };
|
|
213
|
+
var check = decide(action);
|
|
214
|
+
if (check.blocked) { throw check.error; }
|
|
215
|
+
try {
|
|
216
|
+
var result = options !== undefined ? origFs.writeFileSync(filePath, data, options) : origFs.writeFileSync(filePath, data);
|
|
217
|
+
postAudit(action, check.decision, true);
|
|
218
|
+
return result;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (origFs.rm) {
|
|
226
|
+
fs.rm = function lbeRm(filePath, options, callback) {
|
|
227
|
+
if (typeof options === 'function') { callback = options; options = undefined; }
|
|
228
|
+
var action = { action: 'file_delete', path: String(filePath) };
|
|
229
|
+
var check = decide(action);
|
|
230
|
+
if (check.blocked) { process.nextTick(function () { callback(check.error); }); return; }
|
|
231
|
+
function done(err) {
|
|
232
|
+
postAudit(action, check.decision, !err, err ? { error: err.message } : null);
|
|
233
|
+
callback(err);
|
|
234
|
+
}
|
|
235
|
+
if (options !== undefined) { origFs.rm(filePath, options, done); }
|
|
236
|
+
else { origFs.rm(filePath, done); }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (origFs.rmSync) {
|
|
241
|
+
fs.rmSync = function lbeRmSync(filePath, options) {
|
|
242
|
+
var action = { action: 'file_delete', path: String(filePath) };
|
|
243
|
+
var check = decide(action);
|
|
244
|
+
if (check.blocked) { throw check.error; }
|
|
245
|
+
try {
|
|
246
|
+
var result = options !== undefined ? origFs.rmSync(filePath, options) : origFs.rmSync(filePath);
|
|
247
|
+
postAudit(action, check.decision, true);
|
|
248
|
+
return result;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
251
|
+
throw e;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fs.unlink = function lbeUnlink(filePath, options, callback) {
|
|
257
|
+
if (typeof options === 'function') { callback = options; options = undefined; }
|
|
258
|
+
var action = { action: 'file_delete', path: String(filePath) };
|
|
259
|
+
var check = decide(action);
|
|
260
|
+
if (check.blocked) { process.nextTick(function () { callback(check.error); }); return; }
|
|
261
|
+
origFs.unlink(filePath, function done(err) {
|
|
262
|
+
postAudit(action, check.decision, !err, err ? { error: err.message } : null);
|
|
263
|
+
callback(err);
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
fs.unlinkSync = function lbeUnlinkSync(filePath) {
|
|
268
|
+
var action = { action: 'file_delete', path: String(filePath) };
|
|
269
|
+
var check = decide(action);
|
|
270
|
+
if (check.blocked) { throw check.error; }
|
|
271
|
+
try {
|
|
272
|
+
var result = origFs.unlinkSync(filePath);
|
|
273
|
+
postAudit(action, check.decision, true);
|
|
274
|
+
return result;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
fs.rename = function lbeRename(oldPath, newPath, options, callback) {
|
|
282
|
+
if (typeof options === 'function') { callback = options; options = undefined; }
|
|
283
|
+
var action = { action: 'file_rename', path: String(oldPath), dest: String(newPath) };
|
|
284
|
+
var check = decide(action);
|
|
285
|
+
if (check.blocked) { process.nextTick(function () { callback(check.error); }); return; }
|
|
286
|
+
origFs.rename(oldPath, newPath, function done(err) {
|
|
287
|
+
postAudit(action, check.decision, !err, err ? { error: err.message } : null);
|
|
288
|
+
callback(err);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
fs.renameSync = function lbeRenameSync(oldPath, newPath) {
|
|
293
|
+
var action = { action: 'file_rename', path: String(oldPath), dest: String(newPath) };
|
|
294
|
+
var check = decide(action);
|
|
295
|
+
if (check.blocked) { throw check.error; }
|
|
296
|
+
try {
|
|
297
|
+
var result = origFs.renameSync(oldPath, newPath);
|
|
298
|
+
postAudit(action, check.decision, true);
|
|
299
|
+
return result;
|
|
300
|
+
} catch (e) {
|
|
301
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
302
|
+
throw e;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// ── Patch fs.promises ─────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
fs.promises.writeFile = async function lbePromisesWriteFile(filePath, data, options) {
|
|
309
|
+
var action = { action: 'file_write', path: String(filePath) };
|
|
310
|
+
var check = decide(action);
|
|
311
|
+
if (check.blocked) { throw check.error; }
|
|
312
|
+
try {
|
|
313
|
+
var result = options !== undefined
|
|
314
|
+
? await origPromises.writeFile(filePath, data, options)
|
|
315
|
+
: await origPromises.writeFile(filePath, data);
|
|
316
|
+
postAudit(action, check.decision, true);
|
|
317
|
+
return result;
|
|
318
|
+
} catch (e) {
|
|
319
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (origPromises.rm) {
|
|
325
|
+
fs.promises.rm = async function lbePromisesRm(filePath, options) {
|
|
326
|
+
var action = { action: 'file_delete', path: String(filePath) };
|
|
327
|
+
var check = decide(action);
|
|
328
|
+
if (check.blocked) { throw check.error; }
|
|
329
|
+
try {
|
|
330
|
+
var result = options !== undefined
|
|
331
|
+
? await origPromises.rm(filePath, options)
|
|
332
|
+
: await origPromises.rm(filePath);
|
|
333
|
+
postAudit(action, check.decision, true);
|
|
334
|
+
return result;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
337
|
+
throw e;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fs.promises.unlink = async function lbePromisesUnlink(filePath) {
|
|
343
|
+
var action = { action: 'file_delete', path: String(filePath) };
|
|
344
|
+
var check = decide(action);
|
|
345
|
+
if (check.blocked) { throw check.error; }
|
|
346
|
+
try {
|
|
347
|
+
var result = await origPromises.unlink(filePath);
|
|
348
|
+
postAudit(action, check.decision, true);
|
|
349
|
+
return result;
|
|
350
|
+
} catch (e) {
|
|
351
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
fs.promises.rename = async function lbePromisesRename(oldPath, newPath) {
|
|
357
|
+
var action = { action: 'file_rename', path: String(oldPath), dest: String(newPath) };
|
|
358
|
+
var check = decide(action);
|
|
359
|
+
if (check.blocked) { throw check.error; }
|
|
360
|
+
try {
|
|
361
|
+
var result = await origPromises.rename(oldPath, newPath);
|
|
362
|
+
postAudit(action, check.decision, true);
|
|
363
|
+
return result;
|
|
364
|
+
} catch (e) {
|
|
365
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
366
|
+
throw e;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// ── Patch child_process ───────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
cp.spawn = function lbeSpawn(cmd, args, options) {
|
|
373
|
+
if (args && !Array.isArray(args)) { options = args; args = []; }
|
|
374
|
+
var cwd = options && options.cwd ? String(options.cwd) : ROOT_DIR;
|
|
375
|
+
var shell = !!(options && options.shell);
|
|
376
|
+
var action = { action: 'run_shell', cmd: String(cmd), args: args || [], cwd: cwd, shell: shell };
|
|
377
|
+
var check = decide(action);
|
|
378
|
+
if (check.blocked) { return makeFailedChildProcess(check.error); }
|
|
379
|
+
var child = origCp.spawn(cmd, args || [], options || {});
|
|
380
|
+
child.on('close', function (code) { postAudit(action, check.decision, code === 0, { exit_code: code }); });
|
|
381
|
+
return child;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
cp.spawnSync = function lbeSpawnSync(cmd, args, options) {
|
|
385
|
+
if (args && !Array.isArray(args)) { options = args; args = []; }
|
|
386
|
+
var cwd = options && options.cwd ? String(options.cwd) : ROOT_DIR;
|
|
387
|
+
var shell = !!(options && options.shell);
|
|
388
|
+
var action = { action: 'run_shell', cmd: String(cmd), args: args || [], cwd: cwd, shell: shell };
|
|
389
|
+
var check = decide(action);
|
|
390
|
+
if (check.blocked) {
|
|
391
|
+
return { pid: 0, output: [null, Buffer.alloc(0), Buffer.alloc(0)], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), status: 1, signal: null, error: check.error };
|
|
392
|
+
}
|
|
393
|
+
var result = origCp.spawnSync(cmd, args || [], options || {});
|
|
394
|
+
postAudit(action, check.decision, result.status === 0, { exit_code: result.status });
|
|
395
|
+
return result;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
cp.exec = function lbeExec(command, options, callback) {
|
|
399
|
+
if (typeof options === 'function') { callback = options; options = undefined; }
|
|
400
|
+
var cwd = options && options.cwd ? String(options.cwd) : ROOT_DIR;
|
|
401
|
+
var action = { action: 'run_shell', cmd: String(command), args: [], cwd: cwd, shell: true };
|
|
402
|
+
var check = decide(action);
|
|
403
|
+
if (check.blocked) {
|
|
404
|
+
process.nextTick(function () { if (callback) callback(check.error, '', ''); });
|
|
405
|
+
return makeFailedChildProcess(check.error);
|
|
406
|
+
}
|
|
407
|
+
var cb = function done(err, stdout, stderr) {
|
|
408
|
+
postAudit(action, check.decision, !err, err ? { error: err.message } : null);
|
|
409
|
+
if (callback) callback(err, stdout, stderr);
|
|
410
|
+
};
|
|
411
|
+
return options !== undefined ? origCp.exec(command, options, cb) : origCp.exec(command, cb);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
cp.execSync = function lbeExecSync(command, options) {
|
|
415
|
+
var cwd = options && options.cwd ? String(options.cwd) : ROOT_DIR;
|
|
416
|
+
var action = { action: 'run_shell', cmd: String(command), args: [], cwd: cwd, shell: true };
|
|
417
|
+
var check = decide(action);
|
|
418
|
+
if (check.blocked) { throw check.error; }
|
|
419
|
+
try {
|
|
420
|
+
var result = options !== undefined ? origCp.execSync(command, options) : origCp.execSync(command);
|
|
421
|
+
postAudit(action, check.decision, true);
|
|
422
|
+
return result;
|
|
423
|
+
} catch (e) {
|
|
424
|
+
postAudit(action, check.decision, false, { error: e.message });
|
|
425
|
+
throw e;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// ── Write hook-status.json (uses origFs — captured before patching) ───────────
|
|
430
|
+
|
|
431
|
+
process.env.LBE_HOOK_ACTIVE = '1';
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
var statusDir = path.join(ROOT_DIR, '.lbe', 'runtime');
|
|
435
|
+
if (!fs.existsSync(statusDir)) fs.mkdirSync(statusDir, { recursive: true });
|
|
436
|
+
var status = {
|
|
437
|
+
active: true,
|
|
438
|
+
pid: process.pid,
|
|
439
|
+
started_at: new Date().toISOString(),
|
|
440
|
+
mode: MODE,
|
|
441
|
+
root: ROOT_DIR,
|
|
442
|
+
patched: {
|
|
443
|
+
'fs.writeFile': true,
|
|
444
|
+
'fs.writeFileSync': true,
|
|
445
|
+
'fs.rm': !!origFs.rm,
|
|
446
|
+
'fs.rmSync': !!origFs.rmSync,
|
|
447
|
+
'fs.unlink': true,
|
|
448
|
+
'fs.unlinkSync': true,
|
|
449
|
+
'fs.rename': true,
|
|
450
|
+
'fs.renameSync': true,
|
|
451
|
+
'fs.promises.writeFile': true,
|
|
452
|
+
'fs.promises.rm': !!origPromises.rm,
|
|
453
|
+
'fs.promises.unlink': true,
|
|
454
|
+
'fs.promises.rename': true,
|
|
455
|
+
'child_process.spawn': true,
|
|
456
|
+
'child_process.spawnSync': true,
|
|
457
|
+
'child_process.exec': true,
|
|
458
|
+
'child_process.execSync': true,
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
// Use original writeFileSync (pre-patch) to avoid triggering our own hook
|
|
462
|
+
origFs.writeFileSync(path.join(statusDir, 'hook-status.json'), JSON.stringify(status, null, 2) + '\n', 'utf8');
|
|
463
|
+
} catch (e) {
|
|
464
|
+
console.warn('[lbe] could not write hook-status.json:', e.message);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
if (MODE === 'observe') {
|
|
470
|
+
process.stderr.write('[lbe] OBSERVE mode — no actions blocked, all writes logged to .lbe/events.jsonl\n');
|
|
471
|
+
} else if (MODE === 'enforce') {
|
|
472
|
+
process.stderr.write('[lbe] ENFORCE mode — policy denials will block execution\n');
|
|
473
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letterblack/lbe-exec",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.12",
|
|
4
4
|
"description": "Local host-signed execution layer for LetterBlack LBE.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./types.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./hooks/register.cjs": "./hooks/register.cjs"
|
|
13
14
|
},
|
|
14
15
|
"bin": {
|
|
15
16
|
"lbe-exec": "dist/cli.js"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"dist/",
|
|
20
|
+
"hooks/",
|
|
19
21
|
"assets/",
|
|
20
22
|
"README.md",
|
|
21
23
|
"types.d.ts",
|