bare-agent 0.10.3 → 0.11.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 +15 -0
- package/bin/cli.js +30 -2
- package/package.json +1 -1
- package/src/loop.js +7 -1
- package/src/mcp-bridge.js +21 -0
- package/src/provider-anthropic.js +4 -1
- package/src/provider-ollama.js +3 -1
- package/src/provider-openai.js +13 -1
- package/src/store-jsonfile.js +17 -0
- package/tools/shell.js +28 -0
package/README.md
CHANGED
|
@@ -169,6 +169,21 @@ Halts also fire `loop:error` on the stream (`source: 'halt'`) and the `onError`
|
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
172
|
+
## Examples
|
|
173
|
+
|
|
174
|
+
Runnable scripts in [`examples/`](examples/) — each is self-contained and the file's top docstring documents flags and required env vars.
|
|
175
|
+
|
|
176
|
+
| File | What it shows |
|
|
177
|
+
|---|---|
|
|
178
|
+
| [`with-bareguard.mjs`](examples/with-bareguard.mjs) | End-to-end Loop + bareguard wiring: budget cap, fs scope, bash allowlist, audit log, humanChannel. The canonical governed-loop reference. |
|
|
179
|
+
| [`mcp-bridge-poc.js`](examples/mcp-bridge-poc.js) | Auto-discover MCP servers from your IDE configs and expose them as bareagent tools. First run writes `.mcp-bridge.json` (edit to deny tools). |
|
|
180
|
+
| [`mcp-bridge-concurrent.js`](examples/mcp-bridge-concurrent.js) | Soak test: fan out concurrent `barebrowse_browse` calls against real domains (Amazon, Wikipedia, GitHub, a dead host) and verify resilience. |
|
|
181
|
+
| [`orchestrator/`](examples/orchestrator/) | Multi-agent dispatch via `spawn`. Three configs, one system prompt — no orchestrator class, no role types. Roles are JSON files. |
|
|
182
|
+
| [`wake.sh`](examples/wake.sh) + [`wake.md`](examples/wake.md) | Reference cron + jq script for firing deferred actions. The runtime half of `createDeferTool` — bareagent emits, `wake.sh` fires. |
|
|
183
|
+
| [`replay-job.js`](examples/replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
172
187
|
## Cross-language usage
|
|
173
188
|
|
|
174
189
|
Not using Node.js? Spawn bare-agent as a subprocess from any language. Ready-made wrappers in [`contrib/`](contrib/README.md) for Python, Go, Rust, Ruby, and Java — copy one file, no package registry needed.
|
package/bin/cli.js
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
* "provider": "openai" | "anthropic" | "ollama",
|
|
24
24
|
* "model": "gpt-4o-mini" (etc),
|
|
25
25
|
* "tools": ["shell_read", "shell_grep", "spawn", "defer", ...],
|
|
26
|
-
* "gate": { ...bareguard config; humanChannel headless-defaults to deny }
|
|
26
|
+
* "gate": { ...bareguard config; humanChannel headless-defaults to deny },
|
|
27
|
+
* "ungoverned": false // omit/false ⇒ a config with no `gate` is refused;
|
|
28
|
+
* // set true to explicitly run without governance (not recommended)
|
|
27
29
|
* }
|
|
28
30
|
*/
|
|
29
31
|
|
|
@@ -81,7 +83,16 @@ async function runConfigMode(cfgPath) {
|
|
|
81
83
|
let humanChannel = cfg.gate.humanChannel;
|
|
82
84
|
if (typeof humanChannel === 'string') {
|
|
83
85
|
// Allow `humanChannel: "./my-channel.js"` — load from a file relative to config.
|
|
84
|
-
|
|
86
|
+
// Confine the resolved path to the config directory: a JSON config (data)
|
|
87
|
+
// must not be able to require() arbitrary code elsewhere on disk (e.g.
|
|
88
|
+
// "../../evil.js"), which would execute outside the gate.
|
|
89
|
+
const cfgDir = path.resolve(path.dirname(cfgPath));
|
|
90
|
+
const fnPath = path.resolve(cfgDir, humanChannel);
|
|
91
|
+
if (fnPath !== cfgDir && !fnPath.startsWith(cfgDir + path.sep)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`gate.humanChannel must resolve inside the config directory (${cfgDir}); refusing to load ${fnPath}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
85
96
|
humanChannel = require(fnPath);
|
|
86
97
|
}
|
|
87
98
|
if (typeof humanChannel !== 'function') {
|
|
@@ -106,6 +117,23 @@ async function runConfigMode(cfgPath) {
|
|
|
106
117
|
process.stderr.write(`[cli] failed to wire bareguard: ${err.message}. Refusing to run ungoverned (cfg.gate set).\n`);
|
|
107
118
|
process.exit(1);
|
|
108
119
|
}
|
|
120
|
+
} else if (cfg.ungoverned === true) {
|
|
121
|
+
// Explicit opt-out. A config-driven / spawned agent runs with no policy,
|
|
122
|
+
// budget, depth, or rate limits — every configured tool executes unchecked.
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
'[cli] WARNING: running UNGOVERNED (cfg.ungoverned=true) — no policy/budget/depth/rate limits. ' +
|
|
125
|
+
'All configured tools run unchecked.\n',
|
|
126
|
+
);
|
|
127
|
+
} else {
|
|
128
|
+
// Fail-closed: a config with no `gate` is rejected rather than silently run
|
|
129
|
+
// ungoverned. This is the path the LLM-callable `spawn` tool drives — without
|
|
130
|
+
// it, a gate-less child config bypasses all governance (and recursive spawn is
|
|
131
|
+
// unbounded, since maxDepth is only enforced by a wired Gate).
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
'[cli] refusing to run: config has no `gate` block. A config-driven / spawned agent must be governed.\n' +
|
|
134
|
+
' Add a bareguard `gate` config, or set `"ungoverned": true` to explicitly opt out (not recommended).\n',
|
|
135
|
+
);
|
|
136
|
+
process.exit(1);
|
|
109
137
|
}
|
|
110
138
|
|
|
111
139
|
// Read ONE input record from stdin (JSON or raw string). Treat blank stdin
|
package/package.json
CHANGED
package/src/loop.js
CHANGED
|
@@ -279,7 +279,13 @@ class Loop {
|
|
|
279
279
|
continue;
|
|
280
280
|
}
|
|
281
281
|
this._safeEmit({ type: 'checkpoint:reply', data: { reply } });
|
|
282
|
-
|
|
282
|
+
// Fail-closed: approve ONLY on an explicit affirmative. Any other reply —
|
|
283
|
+
// an unrecognized string ("denied", "wait"), empty, or a non-string — denies.
|
|
284
|
+
// A human approval gate must never approve on ambiguous input, and reading
|
|
285
|
+
// .toLowerCase() off a non-string here used to throw out of run().
|
|
286
|
+
const approved = typeof reply === 'string'
|
|
287
|
+
&& ['yes', 'y', 'approve', 'approved'].includes(reply.trim().toLowerCase());
|
|
288
|
+
if (!approved) {
|
|
283
289
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: 'User denied this action.' });
|
|
284
290
|
continue;
|
|
285
291
|
}
|
package/src/mcp-bridge.js
CHANGED
|
@@ -431,6 +431,13 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
431
431
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
432
432
|
* @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
|
|
433
433
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
434
|
+
* @param {(name: string, def: object) => boolean | Promise<boolean>} [opts.confirmServer]
|
|
435
|
+
* Vet each discovered server BEFORE its `command` is spawned. Connecting to an
|
|
436
|
+
* MCP server runs its command, and discovery reads configs from the cwd (a
|
|
437
|
+
* `.mcp.json` in an untrusted repo) as well as the user's home/IDE configs.
|
|
438
|
+
* Return false to skip a server (its command is never executed). A throw is
|
|
439
|
+
* treated as a deny (fail-closed). Default: every discovered server is trusted
|
|
440
|
+
* (unchanged behavior) — pass this to gate command execution.
|
|
434
441
|
* @returns {Promise<{tools: Array, metaTools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
435
442
|
*/
|
|
436
443
|
async function createMCPBridge(opts = {}) {
|
|
@@ -444,6 +451,15 @@ async function createMCPBridge(opts = {}) {
|
|
|
444
451
|
const bridgePath = opts.bridgePath || DEFAULT_BRIDGE_PATH();
|
|
445
452
|
const timeout = opts.timeout || 15000;
|
|
446
453
|
|
|
454
|
+
// Vet a server before spawning its command. Fail-closed: an undefined hook
|
|
455
|
+
// trusts all (unchanged behavior); a throw denies.
|
|
456
|
+
const confirmServer = typeof opts.confirmServer === 'function' ? opts.confirmServer : null;
|
|
457
|
+
const vetServer = async (name, def) => {
|
|
458
|
+
if (!confirmServer) return true;
|
|
459
|
+
try { return (await confirmServer(name, def)) === true; }
|
|
460
|
+
catch { return false; }
|
|
461
|
+
};
|
|
462
|
+
|
|
447
463
|
let config = readBridgeConfig(bridgePath);
|
|
448
464
|
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
449
465
|
|
|
@@ -469,6 +485,9 @@ async function createMCPBridge(opts = {}) {
|
|
|
469
485
|
|
|
470
486
|
await Promise.all(toDiscover.map(async ([name, def]) => {
|
|
471
487
|
try {
|
|
488
|
+
// Denied by confirmServer: skip silently — this is the caller's own
|
|
489
|
+
// decision, not a connection failure, so it must not land in `errors`.
|
|
490
|
+
if (!(await vetServer(name, def))) return;
|
|
472
491
|
const result = await connectAndListTools(name, def, timeout);
|
|
473
492
|
freshTools.set(name, result.mcpTools);
|
|
474
493
|
connectResults.set(name, result.client);
|
|
@@ -525,6 +544,8 @@ async function createMCPBridge(opts = {}) {
|
|
|
525
544
|
.map(([t]) => t);
|
|
526
545
|
|
|
527
546
|
try {
|
|
547
|
+
// Denied by confirmServer: skip silently (the caller's decision, not a failure).
|
|
548
|
+
if (!(await vetServer(name, serverConf))) return;
|
|
528
549
|
const { mcpTools, client } = await connectAndListTools(name, serverConf, timeout);
|
|
529
550
|
|
|
530
551
|
// Only wrap tools that are allowed in config
|
|
@@ -8,12 +8,15 @@ class AnthropicProvider {
|
|
|
8
8
|
* @param {object} options
|
|
9
9
|
* @param {string} options.apiKey - Anthropic API key (required).
|
|
10
10
|
* @param {string} [options.model='claude-haiku-4-5-20251001'] - Model ID.
|
|
11
|
+
* @param {boolean} [options.exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
|
|
11
12
|
* @throws {Error} `[AnthropicProvider] requires apiKey` — when apiKey is missing.
|
|
12
13
|
*/
|
|
13
14
|
constructor(options = {}) {
|
|
14
15
|
if (!options.apiKey) throw new Error('[AnthropicProvider] requires apiKey');
|
|
15
16
|
this.apiKey = options.apiKey.trim();
|
|
16
17
|
this.model = options.model || 'claude-haiku-4-5-20251001';
|
|
18
|
+
// See OpenAIProvider: attach full upstream body to err.body only on opt-in.
|
|
19
|
+
this.exposeErrorBody = options.exposeErrorBody === true;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -126,7 +129,7 @@ class AnthropicProvider {
|
|
|
126
129
|
if (res.statusCode >= 400) {
|
|
127
130
|
return reject(new ProviderError(
|
|
128
131
|
`[AnthropicProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`,
|
|
129
|
-
{ status: res.statusCode, body: parsed }
|
|
132
|
+
{ status: res.statusCode, body: this.exposeErrorBody ? parsed : undefined }
|
|
130
133
|
));
|
|
131
134
|
}
|
|
132
135
|
resolve(parsed);
|
package/src/provider-ollama.js
CHANGED
|
@@ -7,6 +7,8 @@ class OllamaProvider {
|
|
|
7
7
|
constructor(options = {}) {
|
|
8
8
|
this.model = options.model || 'llama3.2';
|
|
9
9
|
this.url = options.url || 'http://localhost:11434';
|
|
10
|
+
// See OpenAIProvider: attach full upstream body to err.body only on opt-in.
|
|
11
|
+
this.exposeErrorBody = options.exposeErrorBody === true;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -70,7 +72,7 @@ class OllamaProvider {
|
|
|
70
72
|
if (res.statusCode >= 400) {
|
|
71
73
|
return reject(new ProviderError(
|
|
72
74
|
`[OllamaProvider] ${parsed.error || `HTTP ${res.statusCode}`}`,
|
|
73
|
-
{ status: res.statusCode, body: parsed }
|
|
75
|
+
{ status: res.statusCode, body: this.exposeErrorBody ? parsed : undefined }
|
|
74
76
|
));
|
|
75
77
|
}
|
|
76
78
|
resolve(parsed);
|
package/src/provider-openai.js
CHANGED
|
@@ -5,10 +5,22 @@ const http = require('http');
|
|
|
5
5
|
const { ProviderError } = require('./errors');
|
|
6
6
|
|
|
7
7
|
class OpenAIProvider {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {string} [options.apiKey]
|
|
11
|
+
* @param {string} [options.model='gpt-4o-mini']
|
|
12
|
+
* @param {string} [options.baseUrl='https://api.openai.com/v1']
|
|
13
|
+
* @param {boolean} [options.exposeErrorBody=false] - Attach the full upstream
|
|
14
|
+
* response to `err.body` on HTTP errors. Off by default so an unexpected
|
|
15
|
+
* field in an error payload can't leak through logs that dump the error
|
|
16
|
+
* object; `err.message` still carries the API's error message. Turn on for
|
|
17
|
+
* debugging only.
|
|
18
|
+
*/
|
|
8
19
|
constructor(options = {}) {
|
|
9
20
|
this.apiKey = options.apiKey?.trim();
|
|
10
21
|
this.model = options.model || 'gpt-4o-mini';
|
|
11
22
|
this.baseUrl = options.baseUrl || 'https://api.openai.com/v1';
|
|
23
|
+
this.exposeErrorBody = options.exposeErrorBody === true;
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
/**
|
|
@@ -73,7 +85,7 @@ class OpenAIProvider {
|
|
|
73
85
|
if (res.statusCode >= 400) {
|
|
74
86
|
return reject(new ProviderError(
|
|
75
87
|
`[OpenAIProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`,
|
|
76
|
-
{ status: res.statusCode, body: parsed }
|
|
88
|
+
{ status: res.statusCode, body: this.exposeErrorBody ? parsed : undefined }
|
|
77
89
|
));
|
|
78
90
|
}
|
|
79
91
|
resolve(parsed);
|
package/src/store-jsonfile.js
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
|
|
4
4
|
|
|
5
|
+
// Past this many entries, the O(n) scan + whole-file rewrite per write becomes a
|
|
6
|
+
// real latency/availability concern. Warn once (per instance) and point at SQLiteStore.
|
|
7
|
+
const LARGE_STORE_THRESHOLD = 10000;
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* JSON file memory store. Zero deps, case-insensitive substring search.
|
|
7
11
|
*
|
|
12
|
+
* SCALING: intended for small memory sets. `search()` is an O(n) substring scan
|
|
13
|
+
* (no index), and every `store()`/`delete()` re-serializes and rewrites the entire
|
|
14
|
+
* file synchronously. Fine for hundreds–low-thousands of entries; for larger or
|
|
15
|
+
* write-heavy memory use SQLiteStore (FTS5 index, parameterized, incremental writes).
|
|
16
|
+
*
|
|
8
17
|
* Interface (implements Memory store contract):
|
|
9
18
|
* store(content, metadata) → id
|
|
10
19
|
* search(query, options) → [{ id, content, metadata, score }]
|
|
@@ -26,6 +35,7 @@ class JsonFileStore {
|
|
|
26
35
|
this._nextId = this._data.length
|
|
27
36
|
? this._data.reduce((max, d) => Math.max(max, d.id), 0) + 1
|
|
28
37
|
: 1;
|
|
38
|
+
this._warnedLarge = false;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
_save() {
|
|
@@ -36,6 +46,13 @@ class JsonFileStore {
|
|
|
36
46
|
const id = this._nextId++;
|
|
37
47
|
this._data.push({ id, content, metadata, createdAt: new Date().toISOString() });
|
|
38
48
|
this._save();
|
|
49
|
+
if (!this._warnedLarge && this._data.length > LARGE_STORE_THRESHOLD) {
|
|
50
|
+
this._warnedLarge = true;
|
|
51
|
+
console.warn(
|
|
52
|
+
`[JsonFileStore] ${this._data.length} entries — search is O(n) and every write rewrites ` +
|
|
53
|
+
`the whole file. Switch to SQLiteStore for memory this size.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
39
56
|
return id;
|
|
40
57
|
}
|
|
41
58
|
|
package/tools/shell.js
CHANGED
|
@@ -91,9 +91,37 @@ async function* walk(dir, recursive) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Conservative ReDoS guard. Rejects the classic catastrophic-backtracking shape:
|
|
95
|
+
// a quantifier (* + {n,}) applied to a group whose body itself contains an
|
|
96
|
+
// unbounded quantifier — e.g. (a+)+, (a*)*, (.+)* . JS RegExp has no execution
|
|
97
|
+
// timeout, and grep runs the pattern against attacker-influenceable file content
|
|
98
|
+
// on the main thread, so one such pattern blocks the whole event loop. Errs toward
|
|
99
|
+
// rejection; the agent simply rephrases. (Single-level nesting only — does not
|
|
100
|
+
// detect deeply nested groups or overlapping alternation like (a|a)*.)
|
|
101
|
+
const UNBOUNDED_QUANT = /[*+]|\{\d+,\}/;
|
|
102
|
+
function looksCatastrophic(pattern) {
|
|
103
|
+
// A quantifier binds to the atom immediately before it — no whitespace between
|
|
104
|
+
// `)` and the quantifier in a real regex.
|
|
105
|
+
const groupQuant = /\(([^()]*)\)(?:[*+]|\{\d+,\})/g;
|
|
106
|
+
let m;
|
|
107
|
+
while ((m = groupQuant.exec(pattern)) !== null) {
|
|
108
|
+
// Drop escaped literals (\+ \* \{ …) so a group like (\+)+ — one-or-more
|
|
109
|
+
// literal plus signs, which is linear — isn't mistaken for a nested quantifier.
|
|
110
|
+
const body = m[1].replace(/\\./g, '');
|
|
111
|
+
if (UNBOUNDED_QUANT.test(body)) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
async function grepPath({ pattern, path: rawPath, recursive = true, maxMatches, flags = 'i' }) {
|
|
95
117
|
const resolved = path.resolve(expandHome(rawPath));
|
|
96
118
|
const cap = maxMatches || DEFAULT_GREP_MAX_MATCHES;
|
|
119
|
+
if (looksCatastrophic(pattern)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`shell_grep: pattern rejected — nested unbounded quantifier (e.g. "(a+)+") risks catastrophic ` +
|
|
122
|
+
`backtracking that would block the process. Simplify the regex.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
97
125
|
let re;
|
|
98
126
|
try {
|
|
99
127
|
re = new RegExp(pattern, flags);
|