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 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, JSONL audit to disk |
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.6.2",
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; // custom predicate override
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 reply = await this.waitForReply(context);
29
- return reply ?? null;
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
- console.warn(`[Loop] audit serialize failed: ${err.message}`);
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 => console.warn(`[Loop] audit write failed: ${err.message}`))
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.stream?.emit({ type: 'loop:start', data: { messageCount: msgs.length } });
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.stream?.emit({ type: 'loop:error', data: { error: err.message, round } });
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.stream?.emit({ type: 'loop:text', data: { text: result.text } });
156
- this.onText?.(result.text);
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.stream?.emit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
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.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
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.stream?.emit({ type: 'checkpoint:ask', data: { tool: tc.name, args: tc.arguments } });
187
- const reply = await this.checkpoint.ask(
188
- `Approve ${tc.name}(${JSON.stringify(tc.arguments)})?`,
189
- { tool: tc.name, args: tc.arguments }
190
- );
191
- this.stream?.emit({ type: 'checkpoint:reply', data: { reply } });
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.stream?.emit({ type: 'loop:tool_call', data: { tool: tc.name, args: tc.arguments } });
199
- this.onToolCall?.(tc.name, tc.arguments);
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.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
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.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
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.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
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.stream?.emit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
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 };