bare-agent 0.6.2 → 0.7.0
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 +3 -2
- package/index.js +2 -0
- package/package.json +3 -2
- package/src/checkpoint.js +38 -4
- package/src/errors.js +7 -0
- package/src/loop.js +83 -24
- package/src/policy.js +132 -0
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
60
60
|
|
|
61
61
|
| Component | What it does |
|
|
62
62
|
|---|---|
|
|
63
|
-
| **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Throws on error by default. Returns estimated USD cost per run. Loop-level `policy` + `audit` gate every tool call (native, MCP, browsing, mobile, user-defined) through one hook
|
|
63
|
+
| **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Throws on error by default. Returns estimated USD cost per run. Loop-level `policy(toolName, args, ctx)` + `audit` gate every tool call (native, MCP, browsing, mobile, user-defined) through one hook — `ctx` is a per-call opaque blob for multi-tenant routing. `maxCost` cap catches runaway loops before they burn budget. Unified `loop:error` + `onError` surface every previously silent failure (audit write, callback throw, Checkpoint timeout) |
|
|
64
64
|
| **Planner** | Break a goal into a step DAG via LLM. Built-in caching (`cacheTTL`) |
|
|
65
65
|
| **runPlan** | Execute steps in parallel waves. Dependency-aware, failure propagation, per-step retry |
|
|
66
66
|
| **Retry** | Exponential/linear backoff with jitter. Respects `err.retryable` |
|
|
@@ -71,7 +71,8 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
71
71
|
| **Checkpoint** | Human approval gate. You provide the transport — terminal, Telegram, Slack, whatever |
|
|
72
72
|
| **Scheduler** | Cron (`0 9 * * 1-5`) or relative (`2h`, `30m`). Persisted jobs survive restarts |
|
|
73
73
|
| **Stream** | Structured event emitter. Pipe as JSONL, subscribe in-process, or custom transport |
|
|
74
|
-
| **Errors** | Typed hierarchy — `ProviderError`, `ToolError`, `TimeoutError`, `MaxRoundsError`, `CircuitOpenError` |
|
|
74
|
+
| **Errors** | Typed hierarchy — `ProviderError`, `ToolError`, `TimeoutError`, `MaxRoundsError`, `MaxCostError`, `CircuitOpenError`, `ValidationError` |
|
|
75
|
+
| **Policy helpers** | `pathAllowlist`, `commandAllowlist`, `combinePolicies` — composable predicates for `Loop({ policy })`. Home expansion, deny-wins, short-circuit combinator, argv-based safe allowlists. `require('bare-agent/policy')` |
|
|
75
76
|
| **Browsing** | Web navigation, clicking, typing, reading via `barebrowse` (17 tools). Two modes: library tools (inline snapshots, pass to Loop) or CLI session (disk-based snapshots, token-efficient for multi-step flows). Optional `assess` tool (privacy scan) when `wearehere` is installed |
|
|
76
77
|
| **Mobile** | Android + iOS device control via `baremobile`. Same two modes: library tools (`createMobileTools` — action tools auto-return snapshots) or CLI session (`baremobile` CLI — disk-based snapshots) |
|
|
77
78
|
| **Shell** | Cross-platform `shell_read`, `shell_grep`, `shell_run` (argv, no shell), `shell_exec` (raw shell). Pure Node — no `grep`/`rg`/`findstr` dependency. Injection-proof `shell_run` for policy-gated use |
|
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
ValidationError,
|
|
19
19
|
CircuitOpenError,
|
|
20
20
|
MaxRoundsError,
|
|
21
|
+
MaxCostError,
|
|
21
22
|
} = require('./src/errors');
|
|
22
23
|
|
|
23
24
|
module.exports = {
|
|
@@ -38,4 +39,5 @@ module.exports = {
|
|
|
38
39
|
ValidationError,
|
|
39
40
|
CircuitOpenError,
|
|
40
41
|
MaxRoundsError,
|
|
42
|
+
MaxCostError,
|
|
41
43
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"src/",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"./stores": "./src/stores.js",
|
|
25
25
|
"./transports": "./src/transports.js",
|
|
26
26
|
"./tools": "./src/tools.js",
|
|
27
|
-
"./mcp": "./src/mcp.js"
|
|
27
|
+
"./mcp": "./src/mcp.js",
|
|
28
|
+
"./policy": "./src/policy.js"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=18"
|
package/src/checkpoint.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { TimeoutError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
|
|
3
7
|
class Checkpoint {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {Array<string>} [options.tools] - Tool names that require approval (exact match).
|
|
11
|
+
* @param {Function} [options.shouldAsk] - Custom predicate `(toolName, args) => bool` — overrides tools list if set.
|
|
12
|
+
* @param {Function} options.send - Async `(question, context) => void` to deliver the question.
|
|
13
|
+
* @param {Function} options.waitForReply - Async `(context) => string` that resolves with the user's reply.
|
|
14
|
+
* @param {number} [options.timeout=300000] - Ms to wait before auto-denying. 0 disables.
|
|
15
|
+
*/
|
|
4
16
|
constructor(options = {}) {
|
|
5
17
|
this.tools = new Set(options.tools || []);
|
|
6
18
|
this.send = options.send || null;
|
|
7
19
|
this.waitForReply = options.waitForReply || null;
|
|
8
|
-
this.shouldAskFn = options.shouldAsk || null;
|
|
20
|
+
this.shouldAskFn = options.shouldAsk || null;
|
|
21
|
+
this.timeout = options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
shouldAsk(toolName, args) {
|
|
@@ -14,19 +27,40 @@ class Checkpoint {
|
|
|
14
27
|
}
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
|
-
* Send a question and wait for a reply.
|
|
30
|
+
* Send a question and wait for a reply. Rejects with TimeoutError if `timeout` ms elapse
|
|
31
|
+
* without a reply — the Loop catches this, auto-denies the tool call, and routes the
|
|
32
|
+
* error through loop:error + onError. No silent hangs.
|
|
18
33
|
* @param {string} question - The approval question to send.
|
|
19
34
|
* @param {object} [context={}] - Context passed to send and waitForReply.
|
|
20
35
|
* @returns {Promise<string|null>} The user's reply, or null.
|
|
21
36
|
* @throws {Error} `[Checkpoint] send and waitForReply callbacks required` — when callbacks are missing.
|
|
37
|
+
* @throws {TimeoutError} When no reply arrives within `timeout` ms.
|
|
22
38
|
*/
|
|
23
39
|
async ask(question, context = {}) {
|
|
24
40
|
if (!this.send || !this.waitForReply) {
|
|
25
41
|
throw new Error('[Checkpoint] send and waitForReply callbacks required');
|
|
26
42
|
}
|
|
27
43
|
await this.send(question, context);
|
|
28
|
-
const
|
|
29
|
-
|
|
44
|
+
const waitPromise = Promise.resolve(this.waitForReply(context));
|
|
45
|
+
if (!this.timeout || this.timeout <= 0) {
|
|
46
|
+
const reply = await waitPromise;
|
|
47
|
+
return reply ?? null;
|
|
48
|
+
}
|
|
49
|
+
let timer;
|
|
50
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
51
|
+
timer = setTimeout(
|
|
52
|
+
() => reject(new TimeoutError(`[Checkpoint] no reply within ${this.timeout}ms — auto-denied`, {
|
|
53
|
+
context: { tool: context?.tool, timeout: this.timeout },
|
|
54
|
+
})),
|
|
55
|
+
this.timeout,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const reply = await Promise.race([waitPromise, timeoutPromise]);
|
|
60
|
+
return reply ?? null;
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
30
64
|
}
|
|
31
65
|
}
|
|
32
66
|
|
package/src/errors.js
CHANGED
|
@@ -49,6 +49,12 @@ class MaxRoundsError extends BareAgentError {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
class MaxCostError extends BareAgentError {
|
|
53
|
+
constructor(message, opts = {}) {
|
|
54
|
+
super(message || 'Loop exceeded maximum cost cap', { code: 'MAX_COST', retryable: false, ...opts });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
module.exports = {
|
|
53
59
|
BareAgentError,
|
|
54
60
|
ProviderError,
|
|
@@ -57,4 +63,5 @@ module.exports = {
|
|
|
57
63
|
ValidationError,
|
|
58
64
|
CircuitOpenError,
|
|
59
65
|
MaxRoundsError,
|
|
66
|
+
MaxCostError,
|
|
60
67
|
};
|
package/src/loop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
|
-
const { ToolError, MaxRoundsError } = require('./errors');
|
|
4
|
+
const { ToolError, MaxRoundsError, MaxCostError } = require('./errors');
|
|
5
5
|
|
|
6
6
|
// Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
|
|
7
7
|
// Last updated: 2026-03-18. Source: public provider pricing pages.
|
|
@@ -62,11 +62,50 @@ class Loop {
|
|
|
62
62
|
}
|
|
63
63
|
this.policy = options.policy || null;
|
|
64
64
|
this.audit = options.audit || null;
|
|
65
|
+
this.maxCost = typeof options.maxCost === 'number' && options.maxCost > 0 ? options.maxCost : null;
|
|
65
66
|
this._stopped = false;
|
|
66
67
|
this._history = []; // for chat() stateful mode
|
|
67
68
|
this._auditInFlight = new Set();
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// Unified error emitter — every silent-ish failure path routes through here so
|
|
72
|
+
// operators see audit writes, callback throws, checkpoint timeouts, stream listener
|
|
73
|
+
// errors in one place: loop:error stream event + onError callback.
|
|
74
|
+
_reportError(source, err, extra = {}) {
|
|
75
|
+
const message = err?.message || String(err);
|
|
76
|
+
this._safeEmit({ type: 'loop:error', data: { source, error: message, ...extra } });
|
|
77
|
+
if (this.onError) {
|
|
78
|
+
try {
|
|
79
|
+
this.onError(err, { source, ...extra });
|
|
80
|
+
} catch (cbErr) {
|
|
81
|
+
console.warn(`[Loop] onError callback threw: ${cbErr.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Swallow-proof stream emit: a throwing listener must not corrupt Loop state.
|
|
87
|
+
_safeEmit(event) {
|
|
88
|
+
if (!this.stream) return;
|
|
89
|
+
try {
|
|
90
|
+
this.stream.emit(event);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`[Loop] stream listener threw on ${event.type}: ${err.message}`);
|
|
93
|
+
if (this.onError && event.type !== 'loop:error') {
|
|
94
|
+
try { this.onError(err, { source: 'stream', eventType: event.type }); } catch { /* swallow */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Fire a user callback without letting its throw kill the loop.
|
|
100
|
+
_safeCall(name, fn, ...args) {
|
|
101
|
+
if (!fn) return;
|
|
102
|
+
try {
|
|
103
|
+
fn(...args);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
this._reportError(`callback:${name}`, err);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
70
109
|
// Append one JSONL record. Returns nothing (fire-and-forget for callers)
|
|
71
110
|
// but tracks the in-flight promise so `flush()` and the end of `run()` can await it.
|
|
72
111
|
_writeAudit(record) {
|
|
@@ -75,11 +114,11 @@ class Loop {
|
|
|
75
114
|
try {
|
|
76
115
|
line = JSON.stringify(record) + '\n';
|
|
77
116
|
} catch (err) {
|
|
78
|
-
|
|
117
|
+
this._reportError('audit:serialize', err, { tool: record?.tool });
|
|
79
118
|
return;
|
|
80
119
|
}
|
|
81
120
|
const p = fs.promises.appendFile(this.audit, line)
|
|
82
|
-
.catch(err =>
|
|
121
|
+
.catch(err => this._reportError('audit:write', err, { tool: record?.tool }))
|
|
83
122
|
.finally(() => this._auditInFlight.delete(p));
|
|
84
123
|
this._auditInFlight.add(p);
|
|
85
124
|
}
|
|
@@ -104,6 +143,7 @@ class Loop {
|
|
|
104
143
|
async run(messages, tools = [], options = {}) {
|
|
105
144
|
this._stopped = false;
|
|
106
145
|
const system = options.system || this.system;
|
|
146
|
+
const ctx = options.ctx || null; // per-run opaque blob forwarded to policy
|
|
107
147
|
const msgs = system
|
|
108
148
|
? [{ role: 'system', content: system }, ...messages]
|
|
109
149
|
: [...messages];
|
|
@@ -125,7 +165,7 @@ class Loop {
|
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
167
|
|
|
128
|
-
this.
|
|
168
|
+
this._safeEmit({ type: 'loop:start', data: { messageCount: msgs.length } });
|
|
129
169
|
|
|
130
170
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
131
171
|
let totalCost = 0;
|
|
@@ -138,8 +178,7 @@ class Loop {
|
|
|
138
178
|
const generate = () => this.provider.generate(msgs, tools, options);
|
|
139
179
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
140
180
|
} catch (err) {
|
|
141
|
-
this.
|
|
142
|
-
this.onError?.(err);
|
|
181
|
+
this._reportError('provider', err, { round });
|
|
143
182
|
await this.flush();
|
|
144
183
|
if (this.throwOnError) throw err;
|
|
145
184
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
|
|
@@ -150,12 +189,22 @@ class Loop {
|
|
|
150
189
|
const roundCost = estimateCost(model, lastUsage);
|
|
151
190
|
if (roundCost !== null) totalCost += roundCost;
|
|
152
191
|
|
|
192
|
+
// Cost cap — fail fast before the next round costs more money.
|
|
193
|
+
if (this.maxCost !== null && totalCost > this.maxCost) {
|
|
194
|
+
const msg = `[Loop] cost ${totalCost.toFixed(4)} exceeded cap ${this.maxCost.toFixed(4)} after round ${round + 1}`;
|
|
195
|
+
const err = new MaxCostError(msg, { context: { cost: totalCost, maxCost: this.maxCost, round } });
|
|
196
|
+
this._reportError('cost-cap', err, { cost: totalCost, maxCost: this.maxCost });
|
|
197
|
+
await this.flush();
|
|
198
|
+
if (this.throwOnError) throw err;
|
|
199
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: msg };
|
|
200
|
+
}
|
|
201
|
+
|
|
153
202
|
// No tool calls — LLM gave a final text response
|
|
154
203
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
155
|
-
this.
|
|
156
|
-
this.onText
|
|
204
|
+
this._safeEmit({ type: 'loop:text', data: { text: result.text } });
|
|
205
|
+
this._safeCall('onText', this.onText, result.text);
|
|
157
206
|
await this.flush();
|
|
158
|
-
this.
|
|
207
|
+
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
159
208
|
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null };
|
|
160
209
|
}
|
|
161
210
|
|
|
@@ -177,34 +226,44 @@ class Loop {
|
|
|
177
226
|
if (!tool) {
|
|
178
227
|
const errMsg = `[Loop] Unknown tool: ${tc.name}`;
|
|
179
228
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
180
|
-
this.
|
|
229
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
181
230
|
continue;
|
|
182
231
|
}
|
|
183
232
|
|
|
184
233
|
// Checkpoint — ask for approval before executing
|
|
185
234
|
if (this.checkpoint?.shouldAsk(tc.name, tc.arguments)) {
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
235
|
+
this._safeEmit({ type: 'checkpoint:ask', data: { tool: tc.name, args: tc.arguments } });
|
|
236
|
+
let reply;
|
|
237
|
+
try {
|
|
238
|
+
reply = await this.checkpoint.ask(
|
|
239
|
+
`Approve ${tc.name}(${JSON.stringify(tc.arguments)})?`,
|
|
240
|
+
{ tool: tc.name, args: tc.arguments }
|
|
241
|
+
);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Checkpoint errors (e.g. timeout, transport failure) auto-deny and
|
|
244
|
+
// get reported via loop:error + onError. The loop never hangs silently.
|
|
245
|
+
this._reportError('checkpoint', err, { tool: tc.name });
|
|
246
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: `[Loop] Checkpoint failed: ${err.message}. Action auto-denied.` });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
this._safeEmit({ type: 'checkpoint:reply', data: { reply } });
|
|
192
250
|
if (!reply || reply.toLowerCase() === 'no' || reply.toLowerCase() === 'n') {
|
|
193
251
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: 'User denied this action.' });
|
|
194
252
|
continue;
|
|
195
253
|
}
|
|
196
254
|
}
|
|
197
255
|
|
|
198
|
-
this.
|
|
199
|
-
this.onToolCall
|
|
256
|
+
this._safeEmit({ type: 'loop:tool_call', data: { tool: tc.name, args: tc.arguments } });
|
|
257
|
+
this._safeCall('onToolCall', this.onToolCall, tc.name, tc.arguments);
|
|
200
258
|
|
|
201
259
|
// Policy check — runs before execute. Fail-safe: only verdict === true allows;
|
|
202
260
|
// anything else (false, string, undefined, object, throw) denies. A string verdict
|
|
203
|
-
// is used verbatim as the deny reason.
|
|
261
|
+
// is used verbatim as the deny reason. `ctx` (opaque blob passed via
|
|
262
|
+
// loop.run(msgs, tools, { ctx })) is forwarded as the third arg for per-caller gating.
|
|
204
263
|
if (this.policy) {
|
|
205
264
|
let verdict;
|
|
206
265
|
try {
|
|
207
|
-
verdict = await this.policy(tc.name, tc.arguments);
|
|
266
|
+
verdict = await this.policy(tc.name, tc.arguments, ctx);
|
|
208
267
|
} catch (err) {
|
|
209
268
|
verdict = `[Loop] policy error: ${err.message}`;
|
|
210
269
|
}
|
|
@@ -213,7 +272,7 @@ class Loop {
|
|
|
213
272
|
? verdict
|
|
214
273
|
: `[Loop] Tool "${tc.name}" denied by policy`;
|
|
215
274
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
|
|
216
|
-
this.
|
|
275
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
|
|
217
276
|
this._writeAudit({
|
|
218
277
|
ts: new Date().toISOString(),
|
|
219
278
|
tool: tc.name,
|
|
@@ -231,7 +290,7 @@ class Loop {
|
|
|
231
290
|
const toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
232
291
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
233
292
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
234
|
-
this.
|
|
293
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
235
294
|
this._writeAudit({
|
|
236
295
|
ts: new Date().toISOString(),
|
|
237
296
|
tool: tc.name,
|
|
@@ -244,7 +303,7 @@ class Loop {
|
|
|
244
303
|
const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
|
|
245
304
|
const errMsg = `[Loop] Tool error: ${toolErr.message}`;
|
|
246
305
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
247
|
-
this.
|
|
306
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
248
307
|
this._writeAudit({
|
|
249
308
|
ts: new Date().toISOString(),
|
|
250
309
|
tool: tc.name,
|
|
@@ -260,7 +319,7 @@ class Loop {
|
|
|
260
319
|
// maxRounds exceeded
|
|
261
320
|
const warning = `[Loop] ended after ${this.maxRounds} rounds without final response`;
|
|
262
321
|
await this.flush();
|
|
263
|
-
this.
|
|
322
|
+
this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
|
|
264
323
|
if (this.throwOnError) throw new MaxRoundsError(warning);
|
|
265
324
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
|
|
266
325
|
}
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Policy helpers — composable predicates for `new Loop({ policy })`.
|
|
5
|
+
*
|
|
6
|
+
* Each helper returns an async function `(toolName, args, ctx) => true | string`
|
|
7
|
+
* matching bareagent's policy contract. `true` allows; anything else denies.
|
|
8
|
+
* A string is fed verbatim to the LLM as the deny reason.
|
|
9
|
+
*
|
|
10
|
+
* Compose multiple helpers with `combinePolicies(a, b, c)` — first non-`true`
|
|
11
|
+
* verdict wins, short-circuit semantics.
|
|
12
|
+
*
|
|
13
|
+
* Zero deps. Pure Node. Cross-platform.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
function expandHome(p) {
|
|
19
|
+
if (!p || typeof p !== 'string') return p;
|
|
20
|
+
if (p === '~') return process.env.HOME || process.env.USERPROFILE || '';
|
|
21
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
22
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
23
|
+
return path.join(home, p.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalize(p) {
|
|
29
|
+
try {
|
|
30
|
+
return path.resolve(expandHome(p));
|
|
31
|
+
} catch {
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Allow/deny file-system paths used by tools like shell_read, shell_grep.
|
|
38
|
+
*
|
|
39
|
+
* Deny wins over allow. Paths containing `..` after normalization are denied
|
|
40
|
+
* unconditionally (prevents traversal bypassing the allow list).
|
|
41
|
+
*
|
|
42
|
+
* @param {object} options
|
|
43
|
+
* @param {string[]} [options.allow] - Path prefixes users may access. `~` expands.
|
|
44
|
+
* @param {string[]} [options.deny] - Path prefixes hard-denied. `~` expands.
|
|
45
|
+
* @param {string[]} [options.toolNames] - Only check these tool names. If omitted, checks any tool with an `args.path` string.
|
|
46
|
+
* @param {string} [options.argKey='path'] - Name of the args field to inspect.
|
|
47
|
+
* @returns {Function} policy predicate `(toolName, args) => true | string`
|
|
48
|
+
*/
|
|
49
|
+
function pathAllowlist({ allow = [], deny = [], toolNames, argKey = 'path' } = {}) {
|
|
50
|
+
const allowNorm = allow.map(normalize);
|
|
51
|
+
const denyNorm = deny.map(normalize);
|
|
52
|
+
const gatedTools = toolNames ? new Set(toolNames) : null;
|
|
53
|
+
|
|
54
|
+
return async function pathPolicy(toolName, args) {
|
|
55
|
+
if (gatedTools && !gatedTools.has(toolName)) return true;
|
|
56
|
+
const raw = args?.[argKey];
|
|
57
|
+
if (typeof raw !== 'string') return true; // nothing to check
|
|
58
|
+
const target = normalize(raw);
|
|
59
|
+
|
|
60
|
+
for (const d of denyNorm) {
|
|
61
|
+
if (target === d || target.startsWith(d + path.sep) || target.startsWith(d + '/')) {
|
|
62
|
+
return `Path denied: ${raw} is under a denied root (${d}).`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (allowNorm.length === 0) return true;
|
|
66
|
+
for (const a of allowNorm) {
|
|
67
|
+
if (target === a || target.startsWith(a + path.sep) || target.startsWith(a + '/')) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return `Path denied: ${raw} is not under any allowed root.`;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Allow/deny commands by their base name.
|
|
77
|
+
*
|
|
78
|
+
* For `shell_run` (argv-array): inspects `args.argv[0]`. Safe — no shell in path.
|
|
79
|
+
* For `shell_exec` (raw shell): inspects `args.command.split(/\s+/)[0]` BUT this
|
|
80
|
+
* is defeatable by shell metacharacters. Prefer gating `shell_run` with this helper
|
|
81
|
+
* and denying `shell_exec` entirely, or handling shell_exec with a custom policy
|
|
82
|
+
* that parses the command string carefully.
|
|
83
|
+
*
|
|
84
|
+
* Deny wins over allow.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} options
|
|
87
|
+
* @param {string[]} [options.allow] - Base command names allowed.
|
|
88
|
+
* @param {string[]} [options.deny] - Base command names denied.
|
|
89
|
+
* @param {string} [options.toolName='shell_run'] - Which tool this helper gates.
|
|
90
|
+
* @returns {Function} policy predicate `(toolName, args) => true | string`
|
|
91
|
+
*/
|
|
92
|
+
function commandAllowlist({ allow = [], deny = [], toolName = 'shell_run' } = {}) {
|
|
93
|
+
const allowSet = new Set(allow);
|
|
94
|
+
const denySet = new Set(deny);
|
|
95
|
+
|
|
96
|
+
return async function commandPolicy(name, args) {
|
|
97
|
+
if (name !== toolName) return true;
|
|
98
|
+
let base;
|
|
99
|
+
if (name === 'shell_run') {
|
|
100
|
+
if (!Array.isArray(args?.argv) || typeof args.argv[0] !== 'string') return true;
|
|
101
|
+
base = args.argv[0];
|
|
102
|
+
} else {
|
|
103
|
+
if (typeof args?.command !== 'string') return true;
|
|
104
|
+
base = args.command.trim().split(/\s+/)[0];
|
|
105
|
+
}
|
|
106
|
+
if (denySet.has(base)) return `Command denied: ${base} is on the denylist.`;
|
|
107
|
+
if (allowSet.size > 0 && !allowSet.has(base)) {
|
|
108
|
+
return `Command denied: ${base} is not on the allowlist.`;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compose multiple policy predicates into one. First non-true verdict wins.
|
|
116
|
+
* Short-circuits on first deny — later predicates are not called.
|
|
117
|
+
*
|
|
118
|
+
* @param {...Function} policies - Any number of policy predicates.
|
|
119
|
+
* @returns {Function} combined policy predicate `(toolName, args, ctx) => true | string`
|
|
120
|
+
*/
|
|
121
|
+
function combinePolicies(...policies) {
|
|
122
|
+
const list = policies.filter(p => typeof p === 'function');
|
|
123
|
+
return async function combined(toolName, args, ctx) {
|
|
124
|
+
for (const p of list) {
|
|
125
|
+
const verdict = await p(toolName, args, ctx);
|
|
126
|
+
if (verdict !== true) return verdict;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { pathAllowlist, commandAllowlist, combinePolicies };
|