bare-agent 0.16.0 → 0.16.1
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 +7 -5
- package/bareagent.context.md +15 -14
- package/package.json +1 -1
- package/src/circuit-breaker.d.ts +7 -3
- package/src/circuit-breaker.js +6 -3
- package/src/loop.js +4 -2
- package/src/mcp-bridge.d.ts +18 -8
- package/src/mcp-bridge.js +39 -16
- package/src/provider-anthropic.js +1 -0
- package/src/provider-ollama.js +1 -0
- package/src/provider-openai.js +1 -0
- package/tools/defer.js +8 -5
- package/tools/grep-worker.d.ts +1 -0
- package/tools/grep-worker.js +20 -0
- package/tools/shell.d.ts +34 -0
- package/tools/shell.js +62 -9
- package/types/index.d.ts +2 -0
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
66
66
|
|
|
67
67
|
| Component | What it does |
|
|
68
68
|
|---|---|
|
|
69
|
-
| **Loop** | Think → act → observe → repeat. Calls any LLM,
|
|
69
|
+
| **Loop** | Think → act → observe → repeat. Calls any LLM, runs your tools, loops until done, returns estimated USD cost per run. Three opt-in seams hook external libraries in without touching your code: **`policy`** (governance — wire bareguard for one gated chokepoint over every tool call), **`assemble`** (context engineering — recall/compress/trim the window per round; the seam [litectx](https://npmjs.com/package/litectx) plugs into, transcript untouched), and **`trim`** (destructively bound the transcript for unbounded runs, harvesting turns before eviction). Each is a single chokepoint, fail-open, off by default. `onError` + `loop:error` surface every silent failure |
|
|
70
70
|
| **Planner** | Break a goal into a step DAG via LLM. Built-in caching (`cacheTTL`) |
|
|
71
71
|
| **assessComplexity** | Pure-code pre-planner (no LLM): rates a goal `simple`/`medium`/`complex`/`critical` from its text via keyword scoring + a critical safety override. `needsPlanning` gates whether to spend a Planner pass; `critical` flags security/production/compliance work for extra scrutiny. Free, instant, debuggable via `signals` |
|
|
72
72
|
| **runPlan** | Execute steps in parallel waves. Dependency-aware, failure propagation, per-step retry |
|
|
@@ -79,13 +79,13 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
79
79
|
| **Scheduler** | Cron (`0 9 * * 1-5`) or relative (`2h`, `30m`). Persisted jobs survive restarts |
|
|
80
80
|
| **Stream** | Structured event emitter. Pipe as JSONL, subscribe in-process, or custom transport |
|
|
81
81
|
| **Errors** | Typed hierarchy — `ProviderError`, `ToolError`, `TimeoutError`, `CircuitOpenError`, `ValidationError`. Halt decisions (turn cap, budget cap, content rules) come from bareguard, not Loop |
|
|
82
|
-
| **bareguard adapter** | `wireGate(gate)`
|
|
82
|
+
| **bareguard adapter** | `wireGate(gate)` → `{ policy, onLlmResult, onToolResult, filterTools, formatDeny }`: one-line wiring to bareguard's `Gate`. Routes every LLM + tool result through the gate so budget caps cover token-heavy workloads, drops denied tools before the LLM ever sees them, and turns halts into a clean exit. `require('bare-agent/bareguard')` |
|
|
83
83
|
| **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 |
|
|
84
84
|
| **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) |
|
|
85
85
|
| **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 |
|
|
86
|
-
| **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor,
|
|
87
|
-
| **Spawn** | Fork a child bareagent
|
|
88
|
-
| **Defer** |
|
|
86
|
+
| **MCP Bridge** | Auto-discover MCP servers from your $HOME/IDE configs (Claude Code, Cursor, …) and expose them as bareagent tools — bulk (`tools`) or token-thrifty meta-tools (`mcp_discover` + `mcp_invoke`) for large catalogs. Same `Loop({ policy })` hook governs MCP and native tools alike. The project-cwd `.mcp.json` is **opt-in** (untrusted-repo safety); vet every server spawn with `confirmServer`; every RPC is time-bounded. Zero deps |
|
|
87
|
+
| **Spawn** | Fork a child bareagent as a specialist agent — LLM-callable (blocks until exit) or a library handle (`wait`, `onLine`, `kill`). The whole family stitches into one audit log + budget; `bareguard ^0.2.0` adds per-family rate + depth caps. `timeoutMs` caps wall-clock, opt-in `idleTimeoutMs` kills a child gone silent (slow-but-working children survive) |
|
|
88
|
+
| **Defer** | Queue a `{action, when}` record for a separate waker (cron / `examples/wake.sh`) to fire later. Governed twice — once when emitted, again when it fires. `bareguard ^0.2.0` adds a family-wide rate cap |
|
|
89
89
|
|
|
90
90
|
**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. The OpenAI provider warns if it would send your key over plaintext `http://` to a non-loopback host (use `https`, or drop `apiKey` for keyless local endpoints).
|
|
91
91
|
|
|
@@ -95,6 +95,8 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
95
95
|
|
|
96
96
|
**Deps:** 1 required (`bareguard ^0.2.0` for governance — single-gate policy + audit + budget + per-family rate caps). Optional: `cron-parser` (cron expressions), `better-sqlite3` (SQLite store), `barebrowse` (web browsing), `baremobile` (Android + iOS device control), `wearehere` (privacy assessment via barebrowse).
|
|
97
97
|
|
|
98
|
+
This table is the map, not the manual — per-component wiring and API detail live in the [Integration Guide](bareagent.context.md) and [Usage Guide](docs/02-features/usage-guide.md).
|
|
99
|
+
|
|
98
100
|
---
|
|
99
101
|
|
|
100
102
|
## Recipes
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.16.
|
|
4
|
+
> v0.16.1 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -251,16 +251,17 @@ invoked tool name lives in `args.name`. To deny specific MCP tools when
|
|
|
251
251
|
using metaTools, use `tools.denyArgPatterns: { mcp_invoke: [/"name":"linear_admin_/] }`
|
|
252
252
|
or `content.denyPatterns` over the serialized action.
|
|
253
253
|
|
|
254
|
-
**Vetting server commands (v0.11.0
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
`
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
still
|
|
254
|
+
**Vetting server commands (v0.11.0; cwd default tightened v0.16.1).** Connecting
|
|
255
|
+
to a server runs its `command`. **Default discovery now scans only your
|
|
256
|
+
$HOME/IDE configs — NOT the project-cwd `./.mcp.json`** (v0.16.1): a checked-in
|
|
257
|
+
config in an untrusted repo would otherwise auto-spawn arbitrary commands. To
|
|
258
|
+
include the project config, pass `createMCPBridge({ includeProjectConfig: true })`,
|
|
259
|
+
or a `confirmServer` hook (which implies it, since the hook vets every command).
|
|
260
|
+
Explicitly-passed `configPaths` are honored verbatim. Pass `confirmServer(name,
|
|
261
|
+
def) => boolean` to approve each server **before its command is spawned** (return
|
|
262
|
+
`false` to skip it; a throw fails closed). When no `confirmServer` is set, the
|
|
263
|
+
bridge still trusts all *discovered* servers and prints a one-time warning naming
|
|
264
|
+
every command it is about to spawn — `confirmServer` is how you actually *gate* it.
|
|
264
265
|
|
|
265
266
|
**RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
|
|
266
267
|
server that never answers can't hang the bridge or the loop. `opts.timeout`
|
|
@@ -554,13 +555,13 @@ new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-
|
|
|
554
555
|
new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
|
|
555
556
|
```
|
|
556
557
|
|
|
557
|
-
All return `{ text, toolCalls, usage: { inputTokens, outputTokens } }`. CLIPipe always returns `toolCalls: []` and zero usage (CLI tools don't report tokens)
|
|
558
|
+
All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. CLIPipe always returns `toolCalls: []` and zero usage (CLI tools don't report tokens), and omits `model`.
|
|
558
559
|
|
|
559
560
|
**Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
|
|
560
561
|
|
|
561
562
|
**Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
|
|
562
563
|
|
|
563
|
-
**Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. Pricing covers OpenAI and Anthropic models; unknown models use a default average. To adjust rates, edit `COST_PER_1K` at the top of `src/loop.js`.
|
|
564
|
+
**Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. Pricing covers OpenAI and Anthropic models; unknown models use a default average. To adjust rates, edit `COST_PER_1K` at the top of `src/loop.js`. The model is resolved as `result.model || provider.model` (v0.16.1+) — providers now echo the model in their `generate()` result, so cost accounting holds even when `provider.model` is absent or varies per response, e.g. behind `FallbackProvider` or `CircuitBreaker.wrapProvider` (the wrapper also preserves `model`/`name` passthrough props). Wire `onLlmResult` (via `wireGate`) and a `budget.maxCostUsd` cap then halts on token-heavy workloads too.
|
|
564
565
|
|
|
565
566
|
## Store options
|
|
566
567
|
|
|
@@ -642,7 +643,7 @@ All error classes extend `Error` — `instanceof Error` always works. The `retry
|
|
|
642
643
|
## Key contracts
|
|
643
644
|
|
|
644
645
|
- Loop builds messages in OpenAI format internally. Each provider normalizes to its native format.
|
|
645
|
-
- `provider.generate(messages, tools, options)` must return `{ text, toolCalls, usage }
|
|
646
|
+
- `provider.generate(messages, tools, options)` must return `{ text, toolCalls, usage }` (and may include `model` for accurate cost accounting).
|
|
646
647
|
- Store must implement `store(content, metadata) → id`, `search(query, options) → [{id, content, metadata, score}]`, `get(id)`, `delete(id)`.
|
|
647
648
|
- Components are independent: Memory doesn't know Loop, Scheduler doesn't know Planner. You compose them.
|
|
648
649
|
|
package/package.json
CHANGED
package/src/circuit-breaker.d.ts
CHANGED
|
@@ -57,14 +57,18 @@ export class CircuitBreaker {
|
|
|
57
57
|
*/
|
|
58
58
|
reset(key?: string): void;
|
|
59
59
|
/**
|
|
60
|
-
* Wrap a provider so generate() goes through the circuit breaker.
|
|
61
|
-
*
|
|
60
|
+
* Wrap a provider so generate() goes through the circuit breaker. Passthrough props (e.g.
|
|
61
|
+
* `model`, `name`) are preserved so Loop cost accounting — which reads `provider.model` —
|
|
62
|
+
* keeps working through the wrapper.
|
|
63
|
+
* @param {{ generate: (...args: any[]) => Promise<any>, [k: string]: any }} provider - Provider with generate().
|
|
62
64
|
* @param {string} [key] - Circuit key.
|
|
63
|
-
* @returns {{ generate: (...args: any[]) => Promise<any
|
|
65
|
+
* @returns {{ generate: (...args: any[]) => Promise<any>, [k: string]: any }} Wrapped provider.
|
|
64
66
|
*/
|
|
65
67
|
wrapProvider(provider: {
|
|
66
68
|
generate: (...args: any[]) => Promise<any>;
|
|
69
|
+
[k: string]: any;
|
|
67
70
|
}, key?: string): {
|
|
68
71
|
generate: (...args: any[]) => Promise<any>;
|
|
72
|
+
[k: string]: any;
|
|
69
73
|
};
|
|
70
74
|
}
|
package/src/circuit-breaker.js
CHANGED
|
@@ -112,13 +112,16 @@ class CircuitBreaker {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* Wrap a provider so generate() goes through the circuit breaker.
|
|
116
|
-
*
|
|
115
|
+
* Wrap a provider so generate() goes through the circuit breaker. Passthrough props (e.g.
|
|
116
|
+
* `model`, `name`) are preserved so Loop cost accounting — which reads `provider.model` —
|
|
117
|
+
* keeps working through the wrapper.
|
|
118
|
+
* @param {{ generate: (...args: any[]) => Promise<any>, [k: string]: any }} provider - Provider with generate().
|
|
117
119
|
* @param {string} [key] - Circuit key.
|
|
118
|
-
* @returns {{ generate: (...args: any[]) => Promise<any
|
|
120
|
+
* @returns {{ generate: (...args: any[]) => Promise<any>, [k: string]: any }} Wrapped provider.
|
|
119
121
|
*/
|
|
120
122
|
wrapProvider(provider, key) {
|
|
121
123
|
return {
|
|
124
|
+
...provider,
|
|
122
125
|
/** @param {...any} args */
|
|
123
126
|
generate: (...args) => this.call(() => provider.generate(...args), key),
|
|
124
127
|
};
|
package/src/loop.js
CHANGED
|
@@ -331,7 +331,7 @@ class Loop {
|
|
|
331
331
|
const startedAt = Date.now();
|
|
332
332
|
const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
|
|
333
333
|
const usage = (result && result.usage) || null;
|
|
334
|
-
const model = loop.provider.model || null;
|
|
334
|
+
const model = (result && result.model) || loop.provider.model || null;
|
|
335
335
|
const cost = estimateCost(model, usage);
|
|
336
336
|
if (cost !== null) totalCost += cost;
|
|
337
337
|
loop._safeEmit({ type: 'loop:summarize', data: { usage, costUsd: cost, durationMs: Date.now() - startedAt } });
|
|
@@ -421,7 +421,9 @@ class Loop {
|
|
|
421
421
|
}
|
|
422
422
|
|
|
423
423
|
lastUsage = result.usage || lastUsage;
|
|
424
|
-
|
|
424
|
+
// Prefer the model the response reports (robust when provider.model is absent or varies per
|
|
425
|
+
// response — e.g. FallbackProvider, or a CircuitBreaker-wrapped provider that drops .model).
|
|
426
|
+
const model = result.model || this.provider.model || null;
|
|
425
427
|
const roundCost = estimateCost(model, lastUsage);
|
|
426
428
|
if (roundCost !== null) totalCost += roundCost;
|
|
427
429
|
|
package/src/mcp-bridge.d.ts
CHANGED
|
@@ -74,7 +74,11 @@ export type RpcClient = {
|
|
|
74
74
|
*
|
|
75
75
|
* @param {object} [opts]
|
|
76
76
|
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
77
|
-
* @param {string[]} [opts.configPaths] -
|
|
77
|
+
* @param {string[]} [opts.configPaths] - Explicit config paths for discovery. When given, honored
|
|
78
|
+
* verbatim. When omitted, only the trusted $HOME/IDE defaults are scanned (NOT `./.mcp.json`).
|
|
79
|
+
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
80
|
+
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
81
|
+
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
78
82
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
79
83
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
80
84
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
@@ -82,16 +86,17 @@ export type RpcClient = {
|
|
|
82
86
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
83
87
|
* @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer]
|
|
84
88
|
* Vet each discovered server BEFORE its `command` is spawned. Connecting to an
|
|
85
|
-
* MCP server runs its command
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
89
|
+
* MCP server runs its command. Return false to skip a server (its command is
|
|
90
|
+
* never executed). A throw is treated as a deny (fail-closed). Default: every
|
|
91
|
+
* discovered server is trusted — pass this to gate command execution. Presence
|
|
92
|
+
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
93
|
+
* since each command is then vetted regardless of source.
|
|
90
94
|
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>}
|
|
91
95
|
*/
|
|
92
96
|
export function createMCPBridge(opts?: {
|
|
93
97
|
bridgePath?: string | undefined;
|
|
94
98
|
configPaths?: string[] | undefined;
|
|
99
|
+
includeProjectConfig?: boolean | undefined;
|
|
95
100
|
servers?: string[] | undefined;
|
|
96
101
|
timeout?: number | undefined;
|
|
97
102
|
callTimeout?: number | undefined;
|
|
@@ -110,10 +115,15 @@ export function createMCPBridge(opts?: {
|
|
|
110
115
|
close: Function;
|
|
111
116
|
}>;
|
|
112
117
|
/**
|
|
113
|
-
* @param {string[]} [configPaths]
|
|
118
|
+
* @param {string[]} [configPaths] - Explicit config paths. When given, honored verbatim (the
|
|
119
|
+
* caller owns the choice). When omitted, the trusted $HOME/IDE defaults are scanned.
|
|
120
|
+
* @param {{ includeProjectConfig?: boolean }} [opts] - When no explicit `configPaths` are given,
|
|
121
|
+
* set `includeProjectConfig: true` to also scan `./.mcp.json`. Default false — see PROJECT_CONFIG_PATH.
|
|
114
122
|
* @returns {Map<string, ServerDef>}
|
|
115
123
|
*/
|
|
116
|
-
export function discoverServers(configPaths?: string[]
|
|
124
|
+
export function discoverServers(configPaths?: string[], { includeProjectConfig }?: {
|
|
125
|
+
includeProjectConfig?: boolean;
|
|
126
|
+
}): Map<string, ServerDef>;
|
|
117
127
|
/**
|
|
118
128
|
* Build the LLM-callable meta-tool surface from a fully-connected bridge.
|
|
119
129
|
* Shares the underlying tool array and RPC clients with the bulk surface —
|
package/src/mcp-bridge.js
CHANGED
|
@@ -62,8 +62,12 @@ const { ToolError } = require('./errors');
|
|
|
62
62
|
|
|
63
63
|
// --- Config discovery (from IDE configs) ---
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// The project-cwd `.mcp.json` is the untrusted-repo vector: discovering it auto-spawns its
|
|
66
|
+
// `command`, so cloning a hostile repo and running an agent inside it would be arbitrary code
|
|
67
|
+
// execution. It is therefore NOT in the trusted defaults — these are user/IDE-authored configs
|
|
68
|
+
// under $HOME, which the user owns. The project config is opt-in (see `includeProjectConfig`).
|
|
69
|
+
const PROJECT_CONFIG_PATH = () => join(process.cwd(), '.mcp.json');
|
|
70
|
+
const TRUSTED_CONFIG_PATHS = [
|
|
67
71
|
() => join(homedir(), '.mcp.json'), // home
|
|
68
72
|
() => join(homedir(), '.claude', 'mcp_servers.json'), // Claude Code
|
|
69
73
|
() => join(homedir(), '.config', 'Claude', 'claude_desktop_config.json'), // Claude Desktop
|
|
@@ -71,11 +75,22 @@ const DEFAULT_CONFIG_PATHS = [
|
|
|
71
75
|
];
|
|
72
76
|
|
|
73
77
|
/**
|
|
74
|
-
* @param {string[]} [configPaths]
|
|
78
|
+
* @param {string[]} [configPaths] - Explicit config paths. When given, honored verbatim (the
|
|
79
|
+
* caller owns the choice). When omitted, the trusted $HOME/IDE defaults are scanned.
|
|
80
|
+
* @param {{ includeProjectConfig?: boolean }} [opts] - When no explicit `configPaths` are given,
|
|
81
|
+
* set `includeProjectConfig: true` to also scan `./.mcp.json`. Default false — see PROJECT_CONFIG_PATH.
|
|
75
82
|
* @returns {Map<string, ServerDef>}
|
|
76
83
|
*/
|
|
77
|
-
function discoverServers(configPaths) {
|
|
78
|
-
|
|
84
|
+
function discoverServers(configPaths, { includeProjectConfig = false } = {}) {
|
|
85
|
+
let paths;
|
|
86
|
+
if (configPaths) {
|
|
87
|
+
paths = configPaths;
|
|
88
|
+
} else {
|
|
89
|
+
paths = TRUSTED_CONFIG_PATHS.map(fn => fn());
|
|
90
|
+
// Project config kept at highest precedence (front) when explicitly opted in — preserves the
|
|
91
|
+
// historical "project overrides home" ordering for callers that want it.
|
|
92
|
+
if (includeProjectConfig) paths.unshift(PROJECT_CONFIG_PATH());
|
|
93
|
+
}
|
|
79
94
|
/** @type {Map<string, ServerDef>} */
|
|
80
95
|
const servers = new Map();
|
|
81
96
|
|
|
@@ -594,7 +609,11 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
594
609
|
*
|
|
595
610
|
* @param {object} [opts]
|
|
596
611
|
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
597
|
-
* @param {string[]} [opts.configPaths] -
|
|
612
|
+
* @param {string[]} [opts.configPaths] - Explicit config paths for discovery. When given, honored
|
|
613
|
+
* verbatim. When omitted, only the trusted $HOME/IDE defaults are scanned (NOT `./.mcp.json`).
|
|
614
|
+
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
615
|
+
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
616
|
+
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
598
617
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
599
618
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
600
619
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
@@ -602,11 +621,11 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
602
621
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
603
622
|
* @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer]
|
|
604
623
|
* Vet each discovered server BEFORE its `command` is spawned. Connecting to an
|
|
605
|
-
* MCP server runs its command
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
624
|
+
* MCP server runs its command. Return false to skip a server (its command is
|
|
625
|
+
* never executed). A throw is treated as a deny (fail-closed). Default: every
|
|
626
|
+
* discovered server is trusted — pass this to gate command execution. Presence
|
|
627
|
+
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
628
|
+
* since each command is then vetted regardless of source.
|
|
610
629
|
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>}
|
|
611
630
|
*/
|
|
612
631
|
async function createMCPBridge(opts = {}) {
|
|
@@ -632,9 +651,10 @@ async function createMCPBridge(opts = {}) {
|
|
|
632
651
|
catch { return false; }
|
|
633
652
|
};
|
|
634
653
|
|
|
635
|
-
// Connecting to a server EXECUTES its `command
|
|
636
|
-
//
|
|
637
|
-
//
|
|
654
|
+
// Connecting to a server EXECUTES its `command`. The project-cwd `.mcp.json` is excluded from
|
|
655
|
+
// default discovery (see TRUSTED_CONFIG_PATHS / includeProjectConfig), so the untrusted-repo
|
|
656
|
+
// path is closed by default; this warning covers the residual case where home/IDE configs (or an
|
|
657
|
+
// explicit opt-in) contribute commands and no confirmServer hook is present to vet them.
|
|
638
658
|
// Warn ONCE per call, BEFORE the first spawn — and the first spawn is the
|
|
639
659
|
// discovery phase on a cold/refresh run, not the main-connect phase below.
|
|
640
660
|
let warnedUnvetted = false;
|
|
@@ -654,8 +674,11 @@ async function createMCPBridge(opts = {}) {
|
|
|
654
674
|
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
655
675
|
|
|
656
676
|
if (needsRefresh) {
|
|
657
|
-
// Discover from IDE configs
|
|
658
|
-
|
|
677
|
+
// Discover from IDE configs. The project-cwd `.mcp.json` is excluded by default (untrusted-repo
|
|
678
|
+
// RCE vector); it is scanned only on explicit opt-in, or when a `confirmServer` hook is present
|
|
679
|
+
// (which vets every command before it spawns, so cwd discovery is safe under it).
|
|
680
|
+
const includeProjectConfig = opts.includeProjectConfig === true || !!confirmServer;
|
|
681
|
+
const discovered = discoverServers(opts.configPaths, { includeProjectConfig });
|
|
659
682
|
|
|
660
683
|
if (discovered.size === 0 && !config) {
|
|
661
684
|
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
package/src/provider-ollama.js
CHANGED
package/src/provider-openai.js
CHANGED
package/tools/defer.js
CHANGED
|
@@ -133,16 +133,19 @@ async function readQueue(queuePath) {
|
|
|
133
133
|
const path = resolveQueuePath(queuePath);
|
|
134
134
|
try {
|
|
135
135
|
const text = await fsp.readFile(path, 'utf8');
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
// Fold append-only status lines by id (latest wins). A Map — not a plain object — so an
|
|
137
|
+
// attacker-influenced id from a tampered queue file (e.g. "__proto__", "constructor") is just
|
|
138
|
+
// an ordinary key and cannot reach the prototype-setter path. Also require a string id.
|
|
139
|
+
/** @type {Map<string, Record<string, any>>} */
|
|
140
|
+
const records = new Map();
|
|
138
141
|
for (const line of text.split('\n')) {
|
|
139
142
|
if (!line.trim()) continue;
|
|
140
143
|
let r;
|
|
141
144
|
try { r = JSON.parse(line); } catch { continue; }
|
|
142
|
-
if (!r.id) continue;
|
|
143
|
-
records
|
|
145
|
+
if (typeof r.id !== 'string' || !r.id) continue;
|
|
146
|
+
records.set(r.id, { ...records.get(r.id), ...r });
|
|
144
147
|
}
|
|
145
|
-
return
|
|
148
|
+
return [...records.values()];
|
|
146
149
|
} catch (/** @type {any} */ err) {
|
|
147
150
|
if (err.code === 'ENOENT') return [];
|
|
148
151
|
throw err;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Worker thread for shell_grep's matching phase. Runs the (potentially expensive) regex search
|
|
5
|
+
* off the main thread so the parent can enforce a hard timeout via `worker.terminate()` — JS
|
|
6
|
+
* regex backtracking is uninterruptible on its own thread, so isolation is the only sound bound
|
|
7
|
+
* against catastrophic patterns that slip past the static guard. See tools/shell.js `grepPath`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { workerData, parentPort } = require('node:worker_threads');
|
|
11
|
+
const { _grepCore } = require('./shell.js');
|
|
12
|
+
|
|
13
|
+
// parentPort is non-null inside a worker, but the type is nullable for the main-thread case.
|
|
14
|
+
if (!parentPort) throw new Error('grep-worker.js must be run as a worker thread');
|
|
15
|
+
const port = parentPort;
|
|
16
|
+
|
|
17
|
+
_grepCore(workerData).then(
|
|
18
|
+
(result) => port.postMessage({ ok: true, result }),
|
|
19
|
+
(err) => port.postMessage({ ok: false, error: err && err.message ? err.message : String(err) }),
|
|
20
|
+
);
|
package/tools/shell.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export type GrepArgs = {
|
|
|
4
4
|
recursive?: boolean | undefined;
|
|
5
5
|
maxMatches?: number | undefined;
|
|
6
6
|
flags?: string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* - Hard wall-clock ceiling in ms (default 5000). The match runs in a
|
|
9
|
+
* worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
|
|
10
|
+
* past `looksCatastrophic` can no longer hang the host event loop.
|
|
11
|
+
*/
|
|
12
|
+
timeout?: number | undefined;
|
|
7
13
|
};
|
|
8
14
|
export type RunArgvArgs = {
|
|
9
15
|
argv: string[];
|
|
@@ -29,3 +35,31 @@ export type ToolDef = import("../types").ToolDef;
|
|
|
29
35
|
export function createShellTools(): {
|
|
30
36
|
tools: ToolDef[];
|
|
31
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} GrepArgs
|
|
40
|
+
* @property {string} pattern
|
|
41
|
+
* @property {string} path
|
|
42
|
+
* @property {boolean} [recursive]
|
|
43
|
+
* @property {number} [maxMatches]
|
|
44
|
+
* @property {string} [flags]
|
|
45
|
+
* @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a
|
|
46
|
+
* worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
|
|
47
|
+
* past `looksCatastrophic` can no longer hang the host event loop.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* The actual search: walk, skip binaries, regex-test each line. Runs in a worker thread (see
|
|
51
|
+
* grep-worker.js) so a runaway regex is killable via `worker.terminate()`. JS RegExp has no
|
|
52
|
+
* execution timeout and backtracking is uninterruptible on its own thread — isolation is the
|
|
53
|
+
* only sound bound (the static `looksCatastrophic` guard is a best-effort fast-reject, not a
|
|
54
|
+
* guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
|
|
55
|
+
* @param {GrepArgs} args
|
|
56
|
+
*/
|
|
57
|
+
export function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags }: GrepArgs): Promise<{
|
|
58
|
+
hits: {
|
|
59
|
+
file: string;
|
|
60
|
+
line: number;
|
|
61
|
+
text: string;
|
|
62
|
+
}[];
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
fileCount: number;
|
|
65
|
+
}>;
|
package/tools/shell.js
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
const fs = require('node:fs/promises');
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const { exec, execFile } = require('node:child_process');
|
|
20
|
+
const { Worker } = require('node:worker_threads');
|
|
20
21
|
|
|
21
22
|
const DEFAULT_READ_MAX_BYTES = 256 * 1024; // 256 KB
|
|
22
23
|
const DEFAULT_GREP_MAX_MATCHES = 200;
|
|
24
|
+
const DEFAULT_GREP_TIMEOUT_MS = 5_000; // hard ceiling on a single grep — bounds ReDoS
|
|
23
25
|
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
|
24
26
|
const DEFAULT_EXEC_MAX_BUFFER = 1024 * 1024; // 1 MB
|
|
25
27
|
|
|
@@ -137,18 +139,22 @@ function looksCatastrophic(pattern) {
|
|
|
137
139
|
* @property {boolean} [recursive]
|
|
138
140
|
* @property {number} [maxMatches]
|
|
139
141
|
* @property {string} [flags]
|
|
142
|
+
* @property {number} [timeout] - Hard wall-clock ceiling in ms (default 5000). The match runs in a
|
|
143
|
+
* worker thread; on overrun the worker is terminated and the call rejects, so a pattern that slips
|
|
144
|
+
* past `looksCatastrophic` can no longer hang the host event loop.
|
|
140
145
|
*/
|
|
141
146
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The actual search: walk, skip binaries, regex-test each line. Runs in a worker thread (see
|
|
149
|
+
* grep-worker.js) so a runaway regex is killable via `worker.terminate()`. JS RegExp has no
|
|
150
|
+
* execution timeout and backtracking is uninterruptible on its own thread — isolation is the
|
|
151
|
+
* only sound bound (the static `looksCatastrophic` guard is a best-effort fast-reject, not a
|
|
152
|
+
* guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
|
|
153
|
+
* @param {GrepArgs} args
|
|
154
|
+
*/
|
|
155
|
+
async function _grepCore({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i' }) {
|
|
144
156
|
const resolved = path.resolve(expandHome(rawPath));
|
|
145
157
|
const cap = maxMatches || DEFAULT_GREP_MAX_MATCHES;
|
|
146
|
-
if (looksCatastrophic(pattern)) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
`shell_grep: pattern rejected — nested unbounded quantifier (e.g. "(a+)+") risks catastrophic ` +
|
|
149
|
-
`backtracking that would block the process. Simplify the regex.`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
158
|
let re;
|
|
153
159
|
try {
|
|
154
160
|
re = new RegExp(pattern, flags);
|
|
@@ -190,6 +196,53 @@ async function grepPath({ pattern, path: rawPath, recursive = true, maxMatches,
|
|
|
190
196
|
return { hits, truncated, fileCount: files.length };
|
|
191
197
|
}
|
|
192
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Public grep entry. Fast-rejects obviously catastrophic patterns without paying for a worker,
|
|
201
|
+
* then runs the search in a worker thread bounded by a hard timeout — so even a pattern that
|
|
202
|
+
* defeats the static guard degrades to a bounded rejection instead of an event-loop hang.
|
|
203
|
+
* @param {GrepArgs} args
|
|
204
|
+
*/
|
|
205
|
+
function grepPath(args) {
|
|
206
|
+
const { pattern, flags = 'i', timeout } = args;
|
|
207
|
+
if (looksCatastrophic(pattern)) {
|
|
208
|
+
return Promise.reject(new Error(
|
|
209
|
+
`shell_grep: pattern rejected — nested unbounded quantifier (e.g. "(a+)+") risks catastrophic ` +
|
|
210
|
+
`backtracking that would block the process. Simplify the regex.`,
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
// Cheap up-front validation so a syntactically invalid regex fails clearly without a worker spin-up.
|
|
214
|
+
try {
|
|
215
|
+
new RegExp(pattern, flags);
|
|
216
|
+
} catch (/** @type {any} */ err) {
|
|
217
|
+
return Promise.reject(new Error(`shell_grep: invalid regex — ${err.message}`));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const budgetMs = timeout && timeout > 0 ? timeout : DEFAULT_GREP_TIMEOUT_MS;
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const worker = new Worker(path.join(__dirname, 'grep-worker.js'), { workerData: args });
|
|
223
|
+
let settled = false;
|
|
224
|
+
const done = (fn, val) => {
|
|
225
|
+
if (settled) return;
|
|
226
|
+
settled = true;
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
worker.terminate();
|
|
229
|
+
fn(val);
|
|
230
|
+
};
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
done(reject, new Error(
|
|
233
|
+
`shell_grep: pattern exceeded ${budgetMs}ms time budget — likely catastrophic backtracking. ` +
|
|
234
|
+
`Simplify the regex.`,
|
|
235
|
+
));
|
|
236
|
+
}, budgetMs);
|
|
237
|
+
timer.unref?.();
|
|
238
|
+
worker.once('message', (msg) => {
|
|
239
|
+
if (msg && msg.ok) done(resolve, msg.result);
|
|
240
|
+
else done(reject, new Error((msg && msg.error) || 'shell_grep: worker failed'));
|
|
241
|
+
});
|
|
242
|
+
worker.once('error', (err) => done(reject, err));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
193
246
|
/**
|
|
194
247
|
* @typedef {object} RunArgvArgs
|
|
195
248
|
* @property {string[]} argv
|
|
@@ -360,4 +413,4 @@ function createShellTools() {
|
|
|
360
413
|
return { tools };
|
|
361
414
|
}
|
|
362
415
|
|
|
363
|
-
module.exports = { createShellTools };
|
|
416
|
+
module.exports = { createShellTools, _grepCore };
|
package/types/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface GenerateResult {
|
|
|
22
22
|
text: string;
|
|
23
23
|
toolCalls: ToolCall[];
|
|
24
24
|
usage: Usage;
|
|
25
|
+
/** Model id the response was produced by; preferred over Provider.model for cost accounting. */
|
|
26
|
+
model?: string | null;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** A conversation message in OpenAI chat format. */
|