bare-agent 0.5.0 → 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 +5 -3
- 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 +158 -19
- package/src/mcp-bridge.js +12 -12
- package/src/policy.js +132 -0
- package/src/tools.js +2 -1
- package/tools/shell.js +286 -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 |
|
|
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,10 +71,12 @@ 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 |
|
|
79
|
+
| **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Zero deps |
|
|
78
80
|
|
|
79
81
|
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely.
|
|
80
82
|
|
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,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { ToolError, MaxRoundsError, MaxCostError } = require('./errors');
|
|
4
5
|
|
|
5
6
|
// Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
|
|
6
7
|
// Last updated: 2026-03-18. Source: public provider pricing pages.
|
|
@@ -39,6 +40,8 @@ class Loop {
|
|
|
39
40
|
* @param {object} [options.retry] - Retry instance for backoff on failures.
|
|
40
41
|
* @param {object} [options.stream] - Stream instance for event emission.
|
|
41
42
|
* @param {object} [options.store] - Store instance for validate() health check.
|
|
43
|
+
* @param {Function} [options.policy] - Async (toolName, args) => true|false|string. Deny returns the string (or a generic message) to the LLM as tool result.
|
|
44
|
+
* @param {string} [options.audit] - File path for JSONL audit log. Each tool call appends one line: {ts, tool, args, decision, result|reason|error, durationMs}.
|
|
42
45
|
* @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
|
|
43
46
|
*/
|
|
44
47
|
constructor(options = {}) {
|
|
@@ -54,8 +57,77 @@ class Loop {
|
|
|
54
57
|
this.onError = options.onError || null;
|
|
55
58
|
this.throwOnError = options.throwOnError !== undefined ? options.throwOnError : true;
|
|
56
59
|
this.store = options.store || null;
|
|
60
|
+
if (options.policy != null && typeof options.policy !== 'function') {
|
|
61
|
+
throw new Error('[Loop] options.policy must be a function (toolName, args) => true | false | string');
|
|
62
|
+
}
|
|
63
|
+
this.policy = options.policy || null;
|
|
64
|
+
this.audit = options.audit || null;
|
|
65
|
+
this.maxCost = typeof options.maxCost === 'number' && options.maxCost > 0 ? options.maxCost : null;
|
|
57
66
|
this._stopped = false;
|
|
58
67
|
this._history = []; // for chat() stateful mode
|
|
68
|
+
this._auditInFlight = new Set();
|
|
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
|
+
|
|
109
|
+
// Append one JSONL record. Returns nothing (fire-and-forget for callers)
|
|
110
|
+
// but tracks the in-flight promise so `flush()` and the end of `run()` can await it.
|
|
111
|
+
_writeAudit(record) {
|
|
112
|
+
if (!this.audit) return;
|
|
113
|
+
let line;
|
|
114
|
+
try {
|
|
115
|
+
line = JSON.stringify(record) + '\n';
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this._reportError('audit:serialize', err, { tool: record?.tool });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const p = fs.promises.appendFile(this.audit, line)
|
|
121
|
+
.catch(err => this._reportError('audit:write', err, { tool: record?.tool }))
|
|
122
|
+
.finally(() => this._auditInFlight.delete(p));
|
|
123
|
+
this._auditInFlight.add(p);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Await any in-flight audit writes. Safe to call multiple times; resolves immediately
|
|
127
|
+
// when no writes are pending. Called automatically at the end of each `run()`.
|
|
128
|
+
async flush() {
|
|
129
|
+
if (this._auditInFlight.size === 0) return;
|
|
130
|
+
await Promise.all([...this._auditInFlight]);
|
|
59
131
|
}
|
|
60
132
|
|
|
61
133
|
/**
|
|
@@ -71,6 +143,7 @@ class Loop {
|
|
|
71
143
|
async run(messages, tools = [], options = {}) {
|
|
72
144
|
this._stopped = false;
|
|
73
145
|
const system = options.system || this.system;
|
|
146
|
+
const ctx = options.ctx || null; // per-run opaque blob forwarded to policy
|
|
74
147
|
const msgs = system
|
|
75
148
|
? [{ role: 'system', content: system }, ...messages]
|
|
76
149
|
: [...messages];
|
|
@@ -92,7 +165,7 @@ class Loop {
|
|
|
92
165
|
}
|
|
93
166
|
}
|
|
94
167
|
|
|
95
|
-
this.
|
|
168
|
+
this._safeEmit({ type: 'loop:start', data: { messageCount: msgs.length } });
|
|
96
169
|
|
|
97
170
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
98
171
|
let totalCost = 0;
|
|
@@ -105,8 +178,8 @@ class Loop {
|
|
|
105
178
|
const generate = () => this.provider.generate(msgs, tools, options);
|
|
106
179
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
107
180
|
} catch (err) {
|
|
108
|
-
this.
|
|
109
|
-
this.
|
|
181
|
+
this._reportError('provider', err, { round });
|
|
182
|
+
await this.flush();
|
|
110
183
|
if (this.throwOnError) throw err;
|
|
111
184
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
|
|
112
185
|
}
|
|
@@ -116,11 +189,22 @@ class Loop {
|
|
|
116
189
|
const roundCost = estimateCost(model, lastUsage);
|
|
117
190
|
if (roundCost !== null) totalCost += roundCost;
|
|
118
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
|
+
|
|
119
202
|
// No tool calls — LLM gave a final text response
|
|
120
203
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
121
|
-
this.
|
|
122
|
-
this.onText
|
|
123
|
-
this.
|
|
204
|
+
this._safeEmit({ type: 'loop:text', data: { text: result.text } });
|
|
205
|
+
this._safeCall('onText', this.onText, result.text);
|
|
206
|
+
await this.flush();
|
|
207
|
+
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
124
208
|
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null };
|
|
125
209
|
}
|
|
126
210
|
|
|
@@ -142,45 +226,100 @@ class Loop {
|
|
|
142
226
|
if (!tool) {
|
|
143
227
|
const errMsg = `[Loop] Unknown tool: ${tc.name}`;
|
|
144
228
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
145
|
-
this.
|
|
229
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
146
230
|
continue;
|
|
147
231
|
}
|
|
148
232
|
|
|
149
233
|
// Checkpoint — ask for approval before executing
|
|
150
234
|
if (this.checkpoint?.shouldAsk(tc.name, tc.arguments)) {
|
|
151
|
-
this.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 } });
|
|
157
250
|
if (!reply || reply.toLowerCase() === 'no' || reply.toLowerCase() === 'n') {
|
|
158
251
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: 'User denied this action.' });
|
|
159
252
|
continue;
|
|
160
253
|
}
|
|
161
254
|
}
|
|
162
255
|
|
|
163
|
-
this.
|
|
164
|
-
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);
|
|
258
|
+
|
|
259
|
+
// Policy check — runs before execute. Fail-safe: only verdict === true allows;
|
|
260
|
+
// anything else (false, string, undefined, object, throw) denies. A string verdict
|
|
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.
|
|
263
|
+
if (this.policy) {
|
|
264
|
+
let verdict;
|
|
265
|
+
try {
|
|
266
|
+
verdict = await this.policy(tc.name, tc.arguments, ctx);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
verdict = `[Loop] policy error: ${err.message}`;
|
|
269
|
+
}
|
|
270
|
+
if (verdict !== true) {
|
|
271
|
+
const reason = typeof verdict === 'string'
|
|
272
|
+
? verdict
|
|
273
|
+
: `[Loop] Tool "${tc.name}" denied by policy`;
|
|
274
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
|
|
275
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
|
|
276
|
+
this._writeAudit({
|
|
277
|
+
ts: new Date().toISOString(),
|
|
278
|
+
tool: tc.name,
|
|
279
|
+
args: tc.arguments,
|
|
280
|
+
decision: 'deny',
|
|
281
|
+
reason,
|
|
282
|
+
});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
165
286
|
|
|
287
|
+
const startedAt = Date.now();
|
|
166
288
|
try {
|
|
167
289
|
const execute = () => tool.execute(tc.arguments);
|
|
168
290
|
const toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
169
291
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
170
292
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
171
|
-
this.
|
|
293
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
294
|
+
this._writeAudit({
|
|
295
|
+
ts: new Date().toISOString(),
|
|
296
|
+
tool: tc.name,
|
|
297
|
+
args: tc.arguments,
|
|
298
|
+
decision: 'allow',
|
|
299
|
+
result: content,
|
|
300
|
+
durationMs: Date.now() - startedAt,
|
|
301
|
+
});
|
|
172
302
|
} catch (err) {
|
|
173
303
|
const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
|
|
174
304
|
const errMsg = `[Loop] Tool error: ${toolErr.message}`;
|
|
175
305
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
176
|
-
this.
|
|
306
|
+
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
307
|
+
this._writeAudit({
|
|
308
|
+
ts: new Date().toISOString(),
|
|
309
|
+
tool: tc.name,
|
|
310
|
+
args: tc.arguments,
|
|
311
|
+
decision: 'allow',
|
|
312
|
+
error: toolErr.message,
|
|
313
|
+
durationMs: Date.now() - startedAt,
|
|
314
|
+
});
|
|
177
315
|
}
|
|
178
316
|
}
|
|
179
317
|
}
|
|
180
318
|
|
|
181
319
|
// maxRounds exceeded
|
|
182
320
|
const warning = `[Loop] ended after ${this.maxRounds} rounds without final response`;
|
|
183
|
-
this.
|
|
321
|
+
await this.flush();
|
|
322
|
+
this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
|
|
184
323
|
if (this.throwOnError) throw new MaxRoundsError(warning);
|
|
185
324
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
|
|
186
325
|
}
|
package/src/mcp-bridge.js
CHANGED
|
@@ -194,21 +194,15 @@ function unwrapContent(content) {
|
|
|
194
194
|
|
|
195
195
|
// --- Tool wrapping ---
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
// Runtime arg-dependent policy has moved to Loop-level (new Loop({ policy })).
|
|
198
|
+
// mcp-bridge retains only the static .mcp-bridge.json allow/deny filter below —
|
|
199
|
+
// that decides which tools are exposed to the Loop in the first place.
|
|
200
|
+
function wrapTools(serverName, mcpTools, rpc) {
|
|
198
201
|
return mcpTools.map(t => ({
|
|
199
202
|
name: `${serverName}_${t.name}`,
|
|
200
203
|
description: t.description || '',
|
|
201
204
|
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
202
205
|
execute: async (args) => {
|
|
203
|
-
if (policy) {
|
|
204
|
-
const verdict = await policy(serverName, t.name, args);
|
|
205
|
-
if (verdict === false || typeof verdict === 'string') {
|
|
206
|
-
const reason = typeof verdict === 'string'
|
|
207
|
-
? verdict
|
|
208
|
-
: `[GOVERNANCE] Tool "${serverName}_${t.name}" is not permitted by policy. Do not retry this tool.`;
|
|
209
|
-
throw new ToolError(reason, { context: { server: serverName, tool: t.name } });
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
206
|
const result = await rpc('tools/call', { name: t.name, arguments: args });
|
|
213
207
|
if (result.isError) {
|
|
214
208
|
throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
|
|
@@ -327,11 +321,17 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
327
321
|
* @param {string[]} [opts.configPaths] - IDE config paths for discovery.
|
|
328
322
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
329
323
|
* @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
|
|
330
|
-
* @param {Function} [opts.policy] - Async function(serverName, toolName, args) for runtime arg-dependent checks.
|
|
331
324
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
332
325
|
* @returns {Promise<{tools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
333
326
|
*/
|
|
334
327
|
async function createMCPBridge(opts = {}) {
|
|
328
|
+
if ('policy' in opts) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
'[MCP Bridge] The `policy` option was removed in v0.6.0. Runtime arg-dependent policy is now Loop-level: ' +
|
|
331
|
+
'pass `policy` to `new Loop({ policy })` instead — it gates MCP tools identically to native tools. ' +
|
|
332
|
+
'The static allow/deny filter in .mcp-bridge.json is unchanged.'
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
335
|
const bridgePath = opts.bridgePath || DEFAULT_BRIDGE_PATH();
|
|
336
336
|
const timeout = opts.timeout || 15000;
|
|
337
337
|
|
|
@@ -411,7 +411,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
411
411
|
|
|
412
412
|
// Only wrap tools that are allowed in config
|
|
413
413
|
const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
|
|
414
|
-
const wrapped = wrapTools(name, allowed, client.rpc
|
|
414
|
+
const wrapped = wrapTools(name, allowed, client.rpc);
|
|
415
415
|
|
|
416
416
|
tools.push(...wrapped);
|
|
417
417
|
children.push(client.child);
|
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 };
|
package/src/tools.js
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const { createBrowsingTools } = require('../tools/browse');
|
|
4
4
|
const { createMobileTools } = require('../tools/mobile');
|
|
5
|
+
const { createShellTools } = require('../tools/shell');
|
|
5
6
|
|
|
6
|
-
module.exports = { createBrowsingTools, createMobileTools };
|
|
7
|
+
module.exports = { createBrowsingTools, createMobileTools, createShellTools };
|
package/tools/shell.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure-Node shell tools — cross-platform (linux, macOS, Windows), no external binaries.
|
|
5
|
+
*
|
|
6
|
+
* Three primitives:
|
|
7
|
+
* shell_read — read a file or list a directory
|
|
8
|
+
* shell_grep — regex search across files (JS regex, no grep/rg/findstr)
|
|
9
|
+
* shell_exec — run a shell command with timeout + max buffer
|
|
10
|
+
*
|
|
11
|
+
* All three run through Loop's policy hook when wired via `new Loop({ policy })`.
|
|
12
|
+
* Library ships zero baked-in allowlist — gating is the agent author's responsibility.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs/promises');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { exec, execFile } = require('node:child_process');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_READ_MAX_BYTES = 256 * 1024; // 256 KB
|
|
20
|
+
const DEFAULT_GREP_MAX_MATCHES = 200;
|
|
21
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
|
22
|
+
const DEFAULT_EXEC_MAX_BUFFER = 1024 * 1024; // 1 MB
|
|
23
|
+
|
|
24
|
+
function expandHome(p) {
|
|
25
|
+
if (!p) return p;
|
|
26
|
+
if (p.startsWith('~/') || p === '~') {
|
|
27
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
28
|
+
return path.join(home, p.slice(1));
|
|
29
|
+
}
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readEntry(rawPath, maxBytes) {
|
|
34
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
35
|
+
const stat = await fs.stat(resolved);
|
|
36
|
+
if (stat.isDirectory()) {
|
|
37
|
+
const entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
38
|
+
const lines = entries.map(e => {
|
|
39
|
+
const kind = e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : 'file';
|
|
40
|
+
return `${kind}\t${e.name}`;
|
|
41
|
+
});
|
|
42
|
+
return `dir ${resolved}\n${lines.join('\n')}`;
|
|
43
|
+
}
|
|
44
|
+
const cap = maxBytes || DEFAULT_READ_MAX_BYTES;
|
|
45
|
+
if (stat.size > cap) {
|
|
46
|
+
const fh = await fs.open(resolved, 'r');
|
|
47
|
+
try {
|
|
48
|
+
const buf = Buffer.alloc(cap);
|
|
49
|
+
await fh.read(buf, 0, cap, 0);
|
|
50
|
+
return buf.toString('utf8') + `\n\n[truncated: ${stat.size - cap} more bytes not shown]`;
|
|
51
|
+
} finally {
|
|
52
|
+
await fh.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return fs.readFile(resolved, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Probe the first 1KB for NUL bytes to skip binary files in grep walks.
|
|
59
|
+
async function isProbablyText(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
const fh = await fs.open(filePath, 'r');
|
|
62
|
+
try {
|
|
63
|
+
const buf = Buffer.alloc(1024);
|
|
64
|
+
const { bytesRead } = await fh.read(buf, 0, 1024, 0);
|
|
65
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
66
|
+
if (buf[i] === 0) return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
} finally {
|
|
70
|
+
await fh.close();
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function* walk(dir, recursive) {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const full = path.join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
if (recursive) yield* walk(full, true);
|
|
88
|
+
} else if (entry.isFile()) {
|
|
89
|
+
yield full;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function grepPath({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i' }) {
|
|
95
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
96
|
+
const cap = maxMatches || DEFAULT_GREP_MAX_MATCHES;
|
|
97
|
+
let re;
|
|
98
|
+
try {
|
|
99
|
+
re = new RegExp(pattern, flags);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new Error(`shell_grep: invalid regex — ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const hits = [];
|
|
105
|
+
const stat = await fs.stat(resolved).catch(() => null);
|
|
106
|
+
if (!stat) throw new Error(`shell_grep: path not found — ${rawPath}`);
|
|
107
|
+
|
|
108
|
+
const files = [];
|
|
109
|
+
if (stat.isFile()) {
|
|
110
|
+
files.push(resolved);
|
|
111
|
+
} else if (stat.isDirectory()) {
|
|
112
|
+
for await (const f of walk(resolved, recursive)) files.push(f);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
if (hits.length >= cap) break;
|
|
117
|
+
if (!(await isProbablyText(file))) continue;
|
|
118
|
+
let content;
|
|
119
|
+
try {
|
|
120
|
+
content = await fs.readFile(file, 'utf8');
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const lines = content.split(/\r?\n/);
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (hits.length >= cap) break;
|
|
127
|
+
if (re.test(lines[i])) {
|
|
128
|
+
hits.push({ file, line: i + 1, text: lines[i].slice(0, 500) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const truncated = hits.length >= cap;
|
|
134
|
+
return { hits, truncated, fileCount: files.length };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function runArgv({ argv, cwd, timeout, maxBuffer, env }) {
|
|
138
|
+
if (!Array.isArray(argv) || argv.length === 0 || typeof argv[0] !== 'string') {
|
|
139
|
+
return Promise.reject(new Error('shell_run: argv must be a non-empty array of strings, starting with the command'));
|
|
140
|
+
}
|
|
141
|
+
const [file, ...args] = argv;
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
execFile(
|
|
144
|
+
file,
|
|
145
|
+
args,
|
|
146
|
+
{
|
|
147
|
+
cwd: cwd ? expandHome(cwd) : undefined,
|
|
148
|
+
timeout: timeout || DEFAULT_EXEC_TIMEOUT_MS,
|
|
149
|
+
maxBuffer: maxBuffer || DEFAULT_EXEC_MAX_BUFFER,
|
|
150
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
shell: false,
|
|
153
|
+
},
|
|
154
|
+
(err, stdout, stderr) => {
|
|
155
|
+
if (err) {
|
|
156
|
+
if (err.killed) {
|
|
157
|
+
resolve({ stdout: stdout || '', stderr: stderr || '', code: null, timedOut: true });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (err.code === 'ENOENT') {
|
|
161
|
+
resolve({ stdout: '', stderr: `shell_run: command not found: ${file}`, code: null, timedOut: false });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
resolve({
|
|
165
|
+
stdout: stdout || '',
|
|
166
|
+
stderr: stderr || '',
|
|
167
|
+
code: typeof err.code === 'number' ? err.code : null,
|
|
168
|
+
timedOut: false,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
resolve({ stdout: stdout || '', stderr: stderr || '', code: 0, timedOut: false });
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function execCommand({ command, cwd, timeout, maxBuffer, env }) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
exec(
|
|
181
|
+
command,
|
|
182
|
+
{
|
|
183
|
+
cwd: cwd ? expandHome(cwd) : undefined,
|
|
184
|
+
timeout: timeout || DEFAULT_EXEC_TIMEOUT_MS,
|
|
185
|
+
maxBuffer: maxBuffer || DEFAULT_EXEC_MAX_BUFFER,
|
|
186
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
},
|
|
189
|
+
(err, stdout, stderr) => {
|
|
190
|
+
if (err) {
|
|
191
|
+
if (err.killed) {
|
|
192
|
+
resolve({ stdout: stdout || '', stderr: stderr || '', code: null, timedOut: true });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
resolve({
|
|
196
|
+
stdout: stdout || '',
|
|
197
|
+
stderr: stderr || '',
|
|
198
|
+
code: typeof err.code === 'number' ? err.code : null,
|
|
199
|
+
timedOut: false,
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
resolve({ stdout: stdout || '', stderr: stderr || '', code: 0, timedOut: false });
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create the three shell tools. No options — configuration is per-call via tool args,
|
|
211
|
+
* gating is the caller's responsibility via `new Loop({ policy })`.
|
|
212
|
+
*
|
|
213
|
+
* @returns {{tools: Array}}
|
|
214
|
+
*/
|
|
215
|
+
function createShellTools() {
|
|
216
|
+
const tools = [
|
|
217
|
+
{
|
|
218
|
+
name: 'shell_read',
|
|
219
|
+
description: 'Read a file or list a directory. Returns file contents (truncated at 256KB) or a tab-separated directory listing.',
|
|
220
|
+
parameters: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
path: { type: 'string', description: 'File or directory path. ~ expands to home.' },
|
|
224
|
+
maxBytes: { type: 'integer', description: 'Optional cap for file reads (default 262144).' },
|
|
225
|
+
},
|
|
226
|
+
required: ['path'],
|
|
227
|
+
},
|
|
228
|
+
execute: async ({ path: p, maxBytes }) => readEntry(p, maxBytes),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'shell_grep',
|
|
232
|
+
description: 'Search for a JavaScript regex pattern across files. Skips binary files. Returns matching lines with file paths and line numbers.',
|
|
233
|
+
parameters: {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
pattern: { type: 'string', description: 'JavaScript regex (without surrounding slashes).' },
|
|
237
|
+
path: { type: 'string', description: 'File or directory to search. ~ expands to home.' },
|
|
238
|
+
recursive: { type: 'boolean', description: 'Recurse into subdirectories (default true).' },
|
|
239
|
+
maxMatches: { type: 'integer', description: 'Stop after this many hits (default 200).' },
|
|
240
|
+
flags: { type: 'string', description: 'Regex flags, e.g. "i" or "gim" (default "i").' },
|
|
241
|
+
},
|
|
242
|
+
required: ['pattern', 'path'],
|
|
243
|
+
},
|
|
244
|
+
execute: async (args) => grepPath(args),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'shell_run',
|
|
248
|
+
description: 'Run a command with an argv array (no shell, no interpolation) and return {stdout, stderr, code, timedOut}. Use this when a policy allowlist needs to match on argv[0] — no shell metacharacter injection is possible. Default timeout 30s, max output 1MB.',
|
|
249
|
+
parameters: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
argv: {
|
|
253
|
+
type: 'array',
|
|
254
|
+
items: { type: 'string' },
|
|
255
|
+
description: 'Non-empty array of strings: argv[0] is the command, argv[1..] are its arguments. Spawned via child_process.execFile (shell: false).',
|
|
256
|
+
},
|
|
257
|
+
cwd: { type: 'string', description: 'Working directory. ~ expands to home.' },
|
|
258
|
+
timeout: { type: 'integer', description: 'Kill after this many ms (default 30000).' },
|
|
259
|
+
maxBuffer: { type: 'integer', description: 'Max stdout/stderr bytes (default 1048576).' },
|
|
260
|
+
env: { type: 'object', description: 'Additional env vars merged over process.env.' },
|
|
261
|
+
},
|
|
262
|
+
required: ['argv'],
|
|
263
|
+
},
|
|
264
|
+
execute: async (args) => runArgv(args),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'shell_exec',
|
|
268
|
+
description: 'Run a raw shell command string via /bin/sh -c (or cmd.exe) and return {stdout, stderr, code, timedOut}. SECURITY: shell metacharacters (;, &&, |, `, $(), etc.) are interpreted — a naive base-command allowlist like `command.split(/\\s+/)[0]` is bypassable via "ls;rm -rf". Prefer shell_run for policy-gated use cases. Default timeout 30s, max output 1MB.',
|
|
269
|
+
parameters: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
command: { type: 'string', description: 'Raw shell command string. Goes through the system shell.' },
|
|
273
|
+
cwd: { type: 'string', description: 'Working directory. ~ expands to home.' },
|
|
274
|
+
timeout: { type: 'integer', description: 'Kill after this many ms (default 30000).' },
|
|
275
|
+
maxBuffer: { type: 'integer', description: 'Max stdout/stderr bytes (default 1048576).' },
|
|
276
|
+
env: { type: 'object', description: 'Additional env vars merged over process.env.' },
|
|
277
|
+
},
|
|
278
|
+
required: ['command'],
|
|
279
|
+
},
|
|
280
|
+
execute: async (args) => execCommand(args),
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
return { tools };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { createShellTools };
|