bare-agent 0.10.4 → 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/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
- const fnPath = path.resolve(path.dirname(cfgPath), humanChannel);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "src/",
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
- if (!reply || reply.toLowerCase() === 'no' || reply.toLowerCase() === 'n') {
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);
@@ -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);
@@ -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);
@@ -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);