bare-agent 0.4.3 → 0.6.2
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 -1
- package/package.json +3 -2
- package/src/loop.js +80 -0
- package/src/mcp-bridge.js +450 -0
- package/src/mcp.js +5 -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` + `audit` gate every tool call (native, MCP, browsing, mobile, user-defined) through one hook, JSONL audit to disk |
|
|
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` |
|
|
@@ -74,6 +74,8 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
74
74
|
| **Errors** | Typed hierarchy — `ProviderError`, `ToolError`, `TimeoutError`, `MaxRoundsError`, `CircuitOpenError` |
|
|
75
75
|
| **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
76
|
| **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
|
+
| **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 |
|
|
78
|
+
| **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 |
|
|
77
79
|
|
|
78
80
|
**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.
|
|
79
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"src/",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"./providers": "./src/providers.js",
|
|
24
24
|
"./stores": "./src/stores.js",
|
|
25
25
|
"./transports": "./src/transports.js",
|
|
26
|
-
"./tools": "./src/tools.js"
|
|
26
|
+
"./tools": "./src/tools.js",
|
|
27
|
+
"./mcp": "./src/mcp.js"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=18"
|
package/src/loop.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
3
4
|
const { ToolError, MaxRoundsError } = require('./errors');
|
|
4
5
|
|
|
5
6
|
// Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
|
|
@@ -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,38 @@ 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;
|
|
57
65
|
this._stopped = false;
|
|
58
66
|
this._history = []; // for chat() stateful mode
|
|
67
|
+
this._auditInFlight = new Set();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Append one JSONL record. Returns nothing (fire-and-forget for callers)
|
|
71
|
+
// but tracks the in-flight promise so `flush()` and the end of `run()` can await it.
|
|
72
|
+
_writeAudit(record) {
|
|
73
|
+
if (!this.audit) return;
|
|
74
|
+
let line;
|
|
75
|
+
try {
|
|
76
|
+
line = JSON.stringify(record) + '\n';
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn(`[Loop] audit serialize failed: ${err.message}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const p = fs.promises.appendFile(this.audit, line)
|
|
82
|
+
.catch(err => console.warn(`[Loop] audit write failed: ${err.message}`))
|
|
83
|
+
.finally(() => this._auditInFlight.delete(p));
|
|
84
|
+
this._auditInFlight.add(p);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Await any in-flight audit writes. Safe to call multiple times; resolves immediately
|
|
88
|
+
// when no writes are pending. Called automatically at the end of each `run()`.
|
|
89
|
+
async flush() {
|
|
90
|
+
if (this._auditInFlight.size === 0) return;
|
|
91
|
+
await Promise.all([...this._auditInFlight]);
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
/**
|
|
@@ -107,6 +140,7 @@ class Loop {
|
|
|
107
140
|
} catch (err) {
|
|
108
141
|
this.stream?.emit({ type: 'loop:error', data: { error: err.message, round } });
|
|
109
142
|
this.onError?.(err);
|
|
143
|
+
await this.flush();
|
|
110
144
|
if (this.throwOnError) throw err;
|
|
111
145
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
|
|
112
146
|
}
|
|
@@ -120,6 +154,7 @@ class Loop {
|
|
|
120
154
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
121
155
|
this.stream?.emit({ type: 'loop:text', data: { text: result.text } });
|
|
122
156
|
this.onText?.(result.text);
|
|
157
|
+
await this.flush();
|
|
123
158
|
this.stream?.emit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
124
159
|
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null };
|
|
125
160
|
}
|
|
@@ -163,23 +198,68 @@ class Loop {
|
|
|
163
198
|
this.stream?.emit({ type: 'loop:tool_call', data: { tool: tc.name, args: tc.arguments } });
|
|
164
199
|
this.onToolCall?.(tc.name, tc.arguments);
|
|
165
200
|
|
|
201
|
+
// Policy check — runs before execute. Fail-safe: only verdict === true allows;
|
|
202
|
+
// anything else (false, string, undefined, object, throw) denies. A string verdict
|
|
203
|
+
// is used verbatim as the deny reason.
|
|
204
|
+
if (this.policy) {
|
|
205
|
+
let verdict;
|
|
206
|
+
try {
|
|
207
|
+
verdict = await this.policy(tc.name, tc.arguments);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
verdict = `[Loop] policy error: ${err.message}`;
|
|
210
|
+
}
|
|
211
|
+
if (verdict !== true) {
|
|
212
|
+
const reason = typeof verdict === 'string'
|
|
213
|
+
? verdict
|
|
214
|
+
: `[Loop] Tool "${tc.name}" denied by policy`;
|
|
215
|
+
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 } });
|
|
217
|
+
this._writeAudit({
|
|
218
|
+
ts: new Date().toISOString(),
|
|
219
|
+
tool: tc.name,
|
|
220
|
+
args: tc.arguments,
|
|
221
|
+
decision: 'deny',
|
|
222
|
+
reason,
|
|
223
|
+
});
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const startedAt = Date.now();
|
|
166
229
|
try {
|
|
167
230
|
const execute = () => tool.execute(tc.arguments);
|
|
168
231
|
const toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
169
232
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
170
233
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
171
234
|
this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
235
|
+
this._writeAudit({
|
|
236
|
+
ts: new Date().toISOString(),
|
|
237
|
+
tool: tc.name,
|
|
238
|
+
args: tc.arguments,
|
|
239
|
+
decision: 'allow',
|
|
240
|
+
result: content,
|
|
241
|
+
durationMs: Date.now() - startedAt,
|
|
242
|
+
});
|
|
172
243
|
} catch (err) {
|
|
173
244
|
const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
|
|
174
245
|
const errMsg = `[Loop] Tool error: ${toolErr.message}`;
|
|
175
246
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
176
247
|
this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
248
|
+
this._writeAudit({
|
|
249
|
+
ts: new Date().toISOString(),
|
|
250
|
+
tool: tc.name,
|
|
251
|
+
args: tc.arguments,
|
|
252
|
+
decision: 'allow',
|
|
253
|
+
error: toolErr.message,
|
|
254
|
+
durationMs: Date.now() - startedAt,
|
|
255
|
+
});
|
|
177
256
|
}
|
|
178
257
|
}
|
|
179
258
|
}
|
|
180
259
|
|
|
181
260
|
// maxRounds exceeded
|
|
182
261
|
const warning = `[Loop] ended after ${this.maxRounds} rounds without final response`;
|
|
262
|
+
await this.flush();
|
|
183
263
|
this.stream?.emit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
|
|
184
264
|
if (this.throwOnError) throw new MaxRoundsError(warning);
|
|
185
265
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
|
|
5
|
+
const { join } = require('node:path');
|
|
6
|
+
const { homedir } = require('node:os');
|
|
7
|
+
const { ToolError } = require('./errors');
|
|
8
|
+
|
|
9
|
+
// --- Config discovery (from IDE configs) ---
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CONFIG_PATHS = [
|
|
12
|
+
() => join(process.cwd(), '.mcp.json'), // project
|
|
13
|
+
() => join(homedir(), '.mcp.json'), // home
|
|
14
|
+
() => join(homedir(), '.claude', 'mcp_servers.json'), // Claude Code
|
|
15
|
+
() => join(homedir(), '.config', 'Claude', 'claude_desktop_config.json'), // Claude Desktop
|
|
16
|
+
() => join(homedir(), '.cursor', 'mcp.json'), // Cursor
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function discoverServers(configPaths) {
|
|
20
|
+
const paths = configPaths || DEFAULT_CONFIG_PATHS.map(fn => fn());
|
|
21
|
+
const servers = new Map();
|
|
22
|
+
|
|
23
|
+
for (const p of paths) {
|
|
24
|
+
let raw;
|
|
25
|
+
try { raw = readFileSync(p, 'utf8'); } catch { continue; }
|
|
26
|
+
let parsed;
|
|
27
|
+
try { parsed = JSON.parse(raw); } catch { continue; }
|
|
28
|
+
|
|
29
|
+
const entries = parsed.mcpServers || {};
|
|
30
|
+
for (const [name, def] of Object.entries(entries)) {
|
|
31
|
+
if (!servers.has(name)) servers.set(name, def);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return servers;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Bridge config file (.mcp-bridge.json) ---
|
|
39
|
+
|
|
40
|
+
const DEFAULT_BRIDGE_PATH = () => join(process.cwd(), '.mcp-bridge.json');
|
|
41
|
+
const DEFAULT_TTL = '24h';
|
|
42
|
+
|
|
43
|
+
function parseTTL(ttl) {
|
|
44
|
+
const match = (ttl || DEFAULT_TTL).match(/^(\d+)(s|m|h|d)$/);
|
|
45
|
+
if (!match) return 24 * 60 * 60 * 1000;
|
|
46
|
+
const n = parseInt(match[1]);
|
|
47
|
+
const unit = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]];
|
|
48
|
+
return n * unit;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readBridgeConfig(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeBridgeConfig(filePath, config) {
|
|
60
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isExpired(config) {
|
|
64
|
+
if (!config || !config.discovered) return true;
|
|
65
|
+
const ttlMs = parseTTL(config.ttl);
|
|
66
|
+
return Date.now() - new Date(config.discovered).getTime() > ttlMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge fresh discovery into existing config.
|
|
71
|
+
* - New servers: added with all tools "allow"
|
|
72
|
+
* - Removed servers: removed from config
|
|
73
|
+
* - New tools on existing server: added as "allow"
|
|
74
|
+
* - Removed tools on existing server: removed from config
|
|
75
|
+
* - Existing tools: user's allow/deny preserved
|
|
76
|
+
*/
|
|
77
|
+
function mergeBridgeConfig(existing, discovered, freshTools) {
|
|
78
|
+
const merged = {
|
|
79
|
+
discovered: new Date().toISOString(),
|
|
80
|
+
ttl: existing?.ttl || DEFAULT_TTL,
|
|
81
|
+
servers: {},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const [name, def] of discovered) {
|
|
85
|
+
const serverTools = freshTools.get(name) || [];
|
|
86
|
+
const existingServer = existing?.servers?.[name];
|
|
87
|
+
const existingTools = existingServer?.tools || {};
|
|
88
|
+
|
|
89
|
+
const tools = {};
|
|
90
|
+
for (const t of serverTools) {
|
|
91
|
+
tools[t.name] = existingTools[t.name] || 'allow';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
merged.servers[name] = {
|
|
95
|
+
command: def.command,
|
|
96
|
+
args: def.args || [],
|
|
97
|
+
...(def.env && { env: def.env }),
|
|
98
|
+
...(def.cwd && { cwd: def.cwd }),
|
|
99
|
+
tools,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return merged;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Env resolution ---
|
|
107
|
+
|
|
108
|
+
function resolveEnv(env) {
|
|
109
|
+
if (!env) return {};
|
|
110
|
+
const resolved = {};
|
|
111
|
+
for (const [k, v] of Object.entries(env)) {
|
|
112
|
+
resolved[k] = typeof v === 'string'
|
|
113
|
+
? v.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || '')
|
|
114
|
+
: v;
|
|
115
|
+
}
|
|
116
|
+
return resolved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- JSON-RPC stdio client ---
|
|
120
|
+
|
|
121
|
+
function createRpcClient(name, def) {
|
|
122
|
+
const { command, args = [], env, cwd } = def;
|
|
123
|
+
const mergedEnv = { ...process.env, ...resolveEnv(env) };
|
|
124
|
+
|
|
125
|
+
const child = spawn(command, args, {
|
|
126
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
127
|
+
env: mergedEnv,
|
|
128
|
+
...(cwd && { cwd }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const pending = new Map();
|
|
132
|
+
let nextId = 1;
|
|
133
|
+
let buffer = '';
|
|
134
|
+
|
|
135
|
+
child.stdout.setEncoding('utf8');
|
|
136
|
+
child.stdout.on('data', (chunk) => {
|
|
137
|
+
buffer += chunk;
|
|
138
|
+
let idx;
|
|
139
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
140
|
+
const line = buffer.slice(0, idx).trim();
|
|
141
|
+
buffer = buffer.slice(idx + 1);
|
|
142
|
+
if (!line) continue;
|
|
143
|
+
let msg;
|
|
144
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
145
|
+
if (!msg.id) continue;
|
|
146
|
+
const p = pending.get(msg.id);
|
|
147
|
+
if (!p) continue;
|
|
148
|
+
pending.delete(msg.id);
|
|
149
|
+
if (msg.error) {
|
|
150
|
+
p.reject(new ToolError(`MCP server "${name}": ${msg.error.message}`, {
|
|
151
|
+
context: { code: msg.error.code },
|
|
152
|
+
}));
|
|
153
|
+
} else {
|
|
154
|
+
p.resolve(msg.result);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
let stderrBuf = '';
|
|
160
|
+
child.stderr?.setEncoding('utf8');
|
|
161
|
+
child.stderr?.on('data', (chunk) => { stderrBuf += chunk; });
|
|
162
|
+
|
|
163
|
+
child.on('close', (code) => {
|
|
164
|
+
for (const [id, { reject }] of pending) {
|
|
165
|
+
reject(new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
|
|
166
|
+
}
|
|
167
|
+
pending.clear();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function rpc(method, params = {}) {
|
|
171
|
+
const id = nextId++;
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
pending.set(id, { resolve, reject });
|
|
174
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
175
|
+
child.stdin.write(msg);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function notify(method, params = {}) {
|
|
180
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
|
|
181
|
+
child.stdin.write(msg);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { rpc, notify, child, get stderr() { return stderrBuf; } };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Content unwrapping ---
|
|
188
|
+
|
|
189
|
+
function unwrapContent(content) {
|
|
190
|
+
if (!Array.isArray(content) || content.length === 0) return '';
|
|
191
|
+
if (content.length === 1 && content[0].type === 'text') return content[0].text;
|
|
192
|
+
return content;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Tool wrapping ---
|
|
196
|
+
|
|
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) {
|
|
201
|
+
return mcpTools.map(t => ({
|
|
202
|
+
name: `${serverName}_${t.name}`,
|
|
203
|
+
description: t.description || '',
|
|
204
|
+
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
205
|
+
execute: async (args) => {
|
|
206
|
+
const result = await rpc('tools/call', { name: t.name, arguments: args });
|
|
207
|
+
if (result.isError) {
|
|
208
|
+
throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
|
|
209
|
+
context: { server: serverName, tool: t.name },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return unwrapContent(result.content);
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Server lifecycle ---
|
|
218
|
+
|
|
219
|
+
async function killServer(child) {
|
|
220
|
+
if (child.exitCode !== null) return;
|
|
221
|
+
|
|
222
|
+
child.stdin?.destroy();
|
|
223
|
+
child.stdout?.destroy();
|
|
224
|
+
child.stderr?.destroy();
|
|
225
|
+
|
|
226
|
+
await new Promise(resolve => {
|
|
227
|
+
const onClose = () => resolve();
|
|
228
|
+
child.once('close', onClose);
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
child.removeListener('close', onClose);
|
|
231
|
+
resolve();
|
|
232
|
+
}, 700);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (child.exitCode === null) {
|
|
236
|
+
child.kill('SIGTERM');
|
|
237
|
+
await new Promise(resolve => {
|
|
238
|
+
const onClose = () => resolve();
|
|
239
|
+
child.once('close', onClose);
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
child.removeListener('close', onClose);
|
|
242
|
+
resolve();
|
|
243
|
+
}, 700);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (child.exitCode === null) {
|
|
248
|
+
child.kill('SIGKILL');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
child.unref();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- Connect + list tools from a server ---
|
|
255
|
+
|
|
256
|
+
async function connectAndListTools(name, def, timeout = 15000) {
|
|
257
|
+
const client = createRpcClient(name, def);
|
|
258
|
+
|
|
259
|
+
const init = client.rpc('initialize', {
|
|
260
|
+
protocolVersion: '2024-11-05',
|
|
261
|
+
capabilities: {},
|
|
262
|
+
clientInfo: { name: 'bare-agent', version: '0.5.0' },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const timer = new Promise((_, reject) =>
|
|
266
|
+
setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await Promise.race([init, timer]);
|
|
270
|
+
client.notify('notifications/initialized');
|
|
271
|
+
|
|
272
|
+
const { tools: mcpTools } = await client.rpc('tools/list');
|
|
273
|
+
|
|
274
|
+
return { mcpTools, client };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- System context for LLM ---
|
|
278
|
+
|
|
279
|
+
function buildSystemContext(servers, tools, denied) {
|
|
280
|
+
const lines = [];
|
|
281
|
+
lines.push(`MCP Bridge: ${tools.length} tools available from ${servers.length} server(s): ${servers.join(', ')}.`);
|
|
282
|
+
|
|
283
|
+
const byServer = {};
|
|
284
|
+
for (const t of tools) {
|
|
285
|
+
const parts = t.name.split('_');
|
|
286
|
+
const server = parts[0];
|
|
287
|
+
(byServer[server] = byServer[server] || []).push(t.name.replace(`${server}_`, ''));
|
|
288
|
+
}
|
|
289
|
+
for (const [server, toolNames] of Object.entries(byServer)) {
|
|
290
|
+
lines.push(` ${server}: ${toolNames.join(', ')}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (denied.length > 0) {
|
|
294
|
+
lines.push(`Restricted tools (${denied.length}, not available to you):`);
|
|
295
|
+
for (const d of denied) {
|
|
296
|
+
lines.push(` - ${d.server}_${d.tool}: ${d.description.slice(0, 80)} [denied]`);
|
|
297
|
+
}
|
|
298
|
+
lines.push('If you need a restricted tool, explain what you need and why to the user.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const governance = denied.length > 0 ? 'filtered' : 'open (all tools exposed)';
|
|
302
|
+
lines.push(`Governance: ${governance}.`);
|
|
303
|
+
|
|
304
|
+
if (denied.length === 0) {
|
|
305
|
+
lines.push('To restrict tools, edit .mcp-bridge.json and set tools to "deny".');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return lines.join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- Main entry point ---
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Create an MCP bridge. On first run, discovers MCP servers from IDE configs,
|
|
315
|
+
* connects, lists tools, and writes .mcp-bridge.json with all tools set to "allow".
|
|
316
|
+
* On subsequent runs, reads .mcp-bridge.json and respects allow/deny per tool.
|
|
317
|
+
* Re-discovers when TTL expires (default: 24h).
|
|
318
|
+
*
|
|
319
|
+
* @param {object} [opts]
|
|
320
|
+
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
321
|
+
* @param {string[]} [opts.configPaths] - IDE config paths for discovery.
|
|
322
|
+
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
323
|
+
* @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
|
|
324
|
+
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
325
|
+
* @returns {Promise<{tools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
326
|
+
*/
|
|
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
|
+
const bridgePath = opts.bridgePath || DEFAULT_BRIDGE_PATH();
|
|
336
|
+
const timeout = opts.timeout || 15000;
|
|
337
|
+
|
|
338
|
+
let config = readBridgeConfig(bridgePath);
|
|
339
|
+
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
340
|
+
|
|
341
|
+
if (needsRefresh) {
|
|
342
|
+
// Discover from IDE configs
|
|
343
|
+
const discovered = discoverServers(opts.configPaths);
|
|
344
|
+
if (discovered.size === 0 && !config) {
|
|
345
|
+
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Connect to all discovered servers and list their tools
|
|
349
|
+
const freshTools = new Map();
|
|
350
|
+
const connectResults = new Map();
|
|
351
|
+
const errors = [];
|
|
352
|
+
|
|
353
|
+
const toDiscover = opts.servers
|
|
354
|
+
? [...discovered.entries()].filter(([n]) => opts.servers.includes(n))
|
|
355
|
+
: [...discovered.entries()];
|
|
356
|
+
|
|
357
|
+
await Promise.all(toDiscover.map(async ([name, def]) => {
|
|
358
|
+
try {
|
|
359
|
+
const result = await connectAndListTools(name, def, timeout);
|
|
360
|
+
freshTools.set(name, result.mcpTools);
|
|
361
|
+
connectResults.set(name, result.client);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
errors.push({ server: name, error: err.message });
|
|
364
|
+
}
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
if (errors.length > 0) {
|
|
368
|
+
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Merge with existing config (preserves user's allow/deny)
|
|
372
|
+
config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
|
|
373
|
+
|
|
374
|
+
// Write the config file
|
|
375
|
+
writeBridgeConfig(bridgePath, config);
|
|
376
|
+
console.log(`[MCP Bridge] Wrote ${bridgePath}`);
|
|
377
|
+
|
|
378
|
+
// Close the discovery connections — we'll reconnect below using the config
|
|
379
|
+
for (const client of connectResults.values()) {
|
|
380
|
+
await killServer(client.child);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Filter to requested servers
|
|
385
|
+
const serverNames = opts.servers
|
|
386
|
+
? opts.servers.filter(n => config.servers[n])
|
|
387
|
+
: Object.keys(config.servers);
|
|
388
|
+
|
|
389
|
+
if (serverNames.length === 0) {
|
|
390
|
+
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Connect to servers and wrap only allowed tools
|
|
394
|
+
const tools = [];
|
|
395
|
+
const children = [];
|
|
396
|
+
const connected = [];
|
|
397
|
+
const denied = [];
|
|
398
|
+
const errors = [];
|
|
399
|
+
|
|
400
|
+
await Promise.all(serverNames.map(async (name) => {
|
|
401
|
+
const serverConf = config.servers[name];
|
|
402
|
+
const allowedToolNames = Object.entries(serverConf.tools)
|
|
403
|
+
.filter(([, perm]) => perm === 'allow')
|
|
404
|
+
.map(([t]) => t);
|
|
405
|
+
const deniedToolNames = Object.entries(serverConf.tools)
|
|
406
|
+
.filter(([, perm]) => perm !== 'allow')
|
|
407
|
+
.map(([t]) => t);
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const { mcpTools, client } = await connectAndListTools(name, serverConf, timeout);
|
|
411
|
+
|
|
412
|
+
// Only wrap tools that are allowed in config
|
|
413
|
+
const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
|
|
414
|
+
const wrapped = wrapTools(name, allowed, client.rpc);
|
|
415
|
+
|
|
416
|
+
tools.push(...wrapped);
|
|
417
|
+
children.push(client.child);
|
|
418
|
+
connected.push(name);
|
|
419
|
+
|
|
420
|
+
// Track denied tools with descriptions from the server
|
|
421
|
+
for (const t of mcpTools) {
|
|
422
|
+
if (deniedToolNames.includes(t.name)) {
|
|
423
|
+
denied.push({ server: name, tool: t.name, description: t.description || '' });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
errors.push({ server: name, error: err.message });
|
|
428
|
+
}
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
if (errors.length > 0) {
|
|
432
|
+
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const systemContext = buildSystemContext(connected, tools, denied);
|
|
436
|
+
if (connected.length > 0) console.log(systemContext);
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
tools,
|
|
440
|
+
servers: connected,
|
|
441
|
+
denied,
|
|
442
|
+
systemContext,
|
|
443
|
+
errors,
|
|
444
|
+
close: async () => {
|
|
445
|
+
await Promise.all(children.map(killServer));
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
module.exports = { createMCPBridge, discoverServers };
|
package/src/mcp.js
ADDED
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 };
|