bare-agent 0.21.1 → 0.22.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 CHANGED
@@ -154,6 +154,7 @@ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate, {
154
154
  actionTranslator: (toolName, args, ctx) => {
155
155
  if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx };
156
156
  if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx };
157
+ if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope
157
158
  return defaultActionTranslator(toolName, args, ctx);
158
159
  },
159
160
  });
@@ -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.21.1 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4
+ > v0.22.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | 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
 
@@ -62,7 +62,7 @@ Eight entry points:
62
62
  | Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
63
63
  | Control Android/iOS devices | createMobileTools + Loop |
64
64
  | Control mobile (token-efficient, disk-based) | `baremobile` CLI session — snapshots to `.baremobile/*.yml` |
65
- | Read files, list directories, run shell commands, grep | createShellTools + Loop({ policy }) |
65
+ | Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/run/exec) + Loop({ policy }) — gate `shell_write` via `fs.writeScope` with an actionTranslator |
66
66
  | Auto-discover MCP servers from IDE configs | createMCPBridge |
67
67
  | Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
68
68
  | Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
@@ -384,15 +384,16 @@ Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot cons
384
384
  ```javascript
385
385
  const { policy, onToolResult } = wireGate(gate, {
386
386
  actionTranslator: (toolName, args, ctx) => {
387
- if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx }; // bareguard 0.4.1+ reads args.command
388
- if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
389
- if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
387
+ if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx }; // bareguard 0.4.1+ reads args.command
388
+ if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
389
+ if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
390
+ if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope (reads args.path)
390
391
  return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
391
392
  },
392
393
  });
393
394
  ```
394
395
 
395
- `onLlmResult` always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `defaultActionTranslator` is exported for composition.
396
+ `onLlmResult` always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `defaultActionTranslator` is exported for composition. **A tool is NOT auto-gated by the fs/bash primitives without this translator** — e.g. `shell_write` runs the write but `fs.writeScope` only enforces once `shell_write` → `{type:'write', path}`; the default `{type:'shell_write'}` matches `tools.allow/denylist` only. Verified live: with the translator, `gate.check` ALLOWs `shell_run ["ls","/tmp"]` and DENYs `shell_read /etc/passwd` (`[deny: fs.readScope]`), and an out-of-scope `shell_write` is denied **before** `execute` (nothing touches disk).
396
397
 
397
398
  **Bounding tool rounds — use `limits.maxToolRounds` (bareguard 0.4.2+), not doubled `maxTurns`.** `limits.maxTurns` ticks on every `gate.record` (LLM + tool), so an "N LLM-tool round" cap is `maxTurns: N*2`. `limits.maxToolRounds: N` ticks only on non-`llm` records and gives the natural semantic — pairs cleanly with our split `onLlmResult` / `onToolResult` (the LLM side writes `{type:'llm'}` records which the counter skips). Halt severity, same shape as `maxTurns`, rebuilt from audit on cold-start.
398
399
 
@@ -629,6 +630,8 @@ if (out.incomplete) {
629
630
  console.log(out.receipts.spawned.length); // RC-10 audit tree: parent→child lineage, per-node tokens/verdict
630
631
  ```
631
632
 
633
+ > **Audit-safe by construction (since Unreleased).** You pass `provider` on `ctx`, and a wired gate records the per-run ctx VERBATIM as `action._ctx`. `recurse()` **strips the live provider** (and thus its `apiKey`) from the ctx at every governance boundary before it reaches `gate.record`/`gate.check`, so the key never lands in the audit JSONL — only the provider *name* does (identity, not secret). The provider still reaches the worker (it runs); only the audited copy is cleaned. (bareguard's own secret-redaction is opt-in and value/pattern-based, so do not rely on it to catch a key you put on `ctx` — but DO scrub any *other* secret-bearing field you thread on `ctx` yourself, or configure `gate` `secrets`.)
634
+
632
635
  **Control families (how the tree is shaped):**
633
636
 
634
637
  - **Family A — model-driven (default).** The worker is handed an in-process `spawn_child` tool and *decides* whether to split. `assessComplexity` is a **hint, not a gate** (only `simple → single-shot`, and the non-overridable `critical → force adversarial verify` safety floor). Nothing extra to set.
@@ -63,16 +63,23 @@ async function main() {
63
63
  } catch {
64
64
  console.log('\n[litectx] not installed — the swap is one line:');
65
65
  console.log(" import { LiteCtx, liteCtxAsStore } from 'litectx';");
66
- console.log(' const lc = new LiteCtx({ dbPath: \'./agent.db\' }); await lc.ready();');
66
+ console.log(' const lc = new LiteCtx({ root: \'./agent-ctx\' }); await lc.ready();');
67
67
  console.log(' const memory = new Memory({ store: liteCtxAsStore(lc) }); // ← only this line changes');
68
68
  console.log('\n Install it (`npm install litectx`) to run the litectx half of this example.');
69
69
  return;
70
70
  }
71
71
 
72
- const lc = new LiteCtx({ dbPath: join(tmpdir(), `litectx-as-store-${process.pid}.db`) });
72
+ // litectx >= 0.21 takes a `root` DIRECTORY (it manages its own files under it), not a `dbPath` file —
73
+ // passing `{ dbPath }` throws on construction. Use a fresh temp dir per run.
74
+ const root = mkdtempSync(join(tmpdir(), `litectx-as-store-${process.pid}-`));
75
+ const lc = new LiteCtx({ root });
73
76
  if (typeof lc.ready === 'function') await lc.ready();
74
- await hostWorkflow(new Memory({ store: liteCtxAsStore(lc) }), 'litectx (ranked, graph-aware)');
75
- if (typeof lc.close === 'function') lc.close();
77
+ try {
78
+ await hostWorkflow(new Memory({ store: liteCtxAsStore(lc) }), 'litectx (ranked, graph-aware)');
79
+ } finally {
80
+ if (typeof lc.close === 'function') lc.close();
81
+ rmSync(root, { recursive: true, force: true });
82
+ }
76
83
  }
77
84
 
78
85
  main().catch((err) => { console.error(err); process.exit(1); });
@@ -6,9 +6,12 @@
6
6
  // Run: OPENAI_API_KEY=... node examples/with-bareguard.mjs
7
7
  //
8
8
  // What this demonstrates:
9
- // - Single-gate governance: every tool call traverses gate.check; every
10
- // result reaches gate.record (via wrapTools).
11
- // - Budget halt: if accumulated cost exceeds maxCostUsd, gate halts the loop.
9
+ // - Single-gate governance: every tool call traverses gate.check (policy); every
10
+ // result reaches gate.record (via onToolResult + onLlmResult — wrapTools is deprecated).
11
+ // - Primitive enforcement: a shell→primitive actionTranslator makes bash.allow + fs.readScope
12
+ // actually fire (the default translator leaves them dead — relayfact F7/BA-3).
13
+ // - Budget halt: if accumulated cost exceeds maxCostUsd, gate halts the loop (a HaltError,
14
+ // caught by the Loop as a clean exit — distinct from a per-action deny; see humanChannel below).
12
15
  // - Audit log: one JSONL line per gated event at ./bareagent-audit.jsonl.
13
16
  // - humanChannel: required by bareguard. Here we auto-deny asks; in real use
14
17
  // wire it to a chat platform, terminal prompt, etc.
@@ -29,16 +32,40 @@ const gate = new Gate({
29
32
  audit: { path: './bareagent-audit.jsonl' },
30
33
  // Required by bareguard: any ask/halt event flows through here.
31
34
  // Auto-deny is the safest default for headless use; in real apps, wire to
32
- // a Telegram/Slack/terminal prompt and return { decision: 'allow' | 'deny' }.
35
+ // a Telegram/Slack/terminal prompt and return a decision.
36
+ // • { decision: 'deny' } → denies THIS ONE action only; the loop keeps running and the
37
+ // model may try something else. deny does NOT stop the loop (relayfact F11/BA-6) — under a
38
+ // retry wrapper like `refine` a denied-but-not-stopped loop can keep spending.
39
+ // • { decision: 'terminate' } → the clean-halt path: surfaces as a HaltError the Loop catches
40
+ // and exits on. Use this (or a budget/turn cap) when you mean "stop", not "skip this action".
33
41
  humanChannel: async (event) => {
34
- console.warn(`[humanChannel] ${event.kind}: ${event.rule} — auto-denying`);
42
+ console.warn(`[humanChannel] ${event.kind}: ${event.rule} — auto-denying (this action only)`);
35
43
  return { decision: 'deny' };
36
44
  },
37
45
  });
38
46
  await gate.init();
39
47
 
40
- // 2. Wire the gate into Loop's policy slot and wrap tools so gate.record fires.
41
- const { policy, wrapTools } = wireGate(gate);
48
+ // 2. Wire the gate. The DEFAULT translator emits `{ type: <toolName> }` which matches bareguard's
49
+ // `tools.allowlist`/`tools.denylist` (they read `action.type`) but does NOT activate the `bash`/`fs`/`net`
50
+ // primitives: those fire only on `action.type ∈ {bash, read, write, edit}` and read `action.cmd`/`action.path`.
51
+ // So to make the `bash.allow` + `fs.readScope` config above actually enforce, we MUST translate the shell
52
+ // tools into those primitive shapes — otherwise the caps are silently dead (relayfact F7/BA-3).
53
+ const actionTranslator = (toolName, args, ctx) => {
54
+ switch (toolName) {
55
+ // shell_run is argv (no shell); bareguard's bash.allow matches `cmd.startsWith(prefix)`, so join argv[0..].
56
+ case 'shell_run': return { type: 'bash', cmd: (args?.argv || []).join(' '), args, _ctx: ctx ?? null };
57
+ case 'shell_exec': return { type: 'bash', cmd: args?.command, args, _ctx: ctx ?? null };
58
+ // shell_read / shell_grep are reads — gate them through fs.readScope.
59
+ case 'shell_read':
60
+ case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
61
+ // shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
62
+ case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
63
+ default: return { type: toolName, args, _ctx: ctx ?? null };
64
+ }
65
+ };
66
+ // onToolResult + onLlmResult are the current wiring (wrapTools is deprecated — it loses _ctx and never sees
67
+ // LLM cost, so the budget cap can't cover token-only rounds). policy gates pre-call; the result hooks record.
68
+ const { policy, onToolResult, onLlmResult } = wireGate(gate, { actionTranslator });
42
69
 
43
70
  // 3. Standard bareagent setup.
44
71
  const provider = new OpenAI({
@@ -50,16 +77,19 @@ const { tools } = createShellTools();
50
77
  const loop = new Loop({
51
78
  provider,
52
79
  policy,
80
+ onToolResult, // every tool result → gate.record (with _ctx in scope)
81
+ onLlmResult, // every LLM round → gate.record so budget.maxCostUsd covers token-only spend
53
82
  onError: (err, meta) => console.error(`[onError ${meta.source}]`, err.message),
54
83
  });
55
84
 
56
- // 4. Run.
85
+ // 4. Run. Pass the tools as-is — gating is via policy/onToolResult, not by wrapping execute().
57
86
  const result = await loop.run(
58
87
  [{ role: 'user', content: 'List the contents of /tmp using shell_run with argv ["ls", "/tmp"].' }],
59
- wrapTools(tools),
88
+ tools,
60
89
  );
61
90
 
62
91
  console.log('---');
63
92
  console.log('text:', result.text);
64
- console.log('cost:', result.cost?.toFixed(6) ?? 'n/a');
93
+ // Loop returns the meter under result.metrics (result.cost was removed); costUsd is null when unpriced.
94
+ console.log('cost:', result.metrics?.costUsd != null ? result.metrics.costUsd.toFixed(6) : 'n/a (unpriced)');
65
95
  console.log('audit log → ./bareagent-audit.jsonl');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/recurse.d.ts CHANGED
@@ -23,7 +23,12 @@ export type RecurseCtx = {
23
23
  */
24
24
  depth?: number | undefined;
25
25
  /**
26
- * - Optional event stream forwarded to each worker Loop (receipts substrate).
26
+ * - Optional event stream forwarded to each worker Loop (receipts substrate). This
27
+ * is the observability channel for worker activity (relayfact F15/BA-5): recurse intentionally does NOT take
28
+ * `onToolCall`/`onText` Loop callbacks — instead every worker Loop emits `loop:tool_call` / `loop:tool_result`
29
+ * (and `loop:text`/`loop:done`) to THIS stream (loop.js), so a consumer observes worker tool calls by reading
30
+ * the stream, not via per-call callbacks. The full audit trail is stream + the RC-10 receipts tree + (if a
31
+ * gate is wired) the bareguard audit.
27
32
  */
28
33
  stream?: object;
29
34
  /**
@@ -252,7 +257,12 @@ export type Slice = {
252
257
  * and worker tokens are all real spend (BA1: never invisible).
253
258
  * @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
254
259
  * threaded into `policy`. Callers normally omit it (defaults to 0).
255
- * @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate).
260
+ * @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate). This
261
+ * is the observability channel for worker activity (relayfact F15/BA-5): recurse intentionally does NOT take
262
+ * `onToolCall`/`onText` Loop callbacks — instead every worker Loop emits `loop:tool_call` / `loop:tool_result`
263
+ * (and `loop:text`/`loop:done`) to THIS stream (loop.js), so a consumer observes worker tool calls by reading
264
+ * the stream, not via per-call callbacks. The full audit trail is stream + the RC-10 receipts tree + (if a
265
+ * gate is wired) the bareguard audit.
256
266
  * @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
257
267
  * retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
258
268
  * the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
package/src/recurse.js CHANGED
@@ -111,6 +111,30 @@ function forChild(opts) {
111
111
  };
112
112
  }
113
113
 
114
+ /**
115
+ * The ctx handed to a worker `Loop.run({ ctx })` or to a direct `ctx.policy(...)` checkpoint — i.e. the ctx
116
+ * that a wired gate records VERBATIM into the audit as `action._ctx` (see `defaultActionTranslator` in
117
+ * src/bareguard-adapter.js). It STRIPS the live `provider` instance, because that object carries the API key
118
+ * (`provider.apiKey`) and bareguard serializes `_ctx` to disk — so an un-stripped ctx writes the raw
119
+ * `sk-…` key into the plaintext audit log (F16/BA-1, confirmed by relayfact probe-03).
120
+ *
121
+ * Only the AUDITED copy is cleaned: the provider still rides in the recurse-internal ctx that is threaded into
122
+ * each child `recurse()` self-call (children need `ctx.provider` to run), and the worker Loop already receives
123
+ * the provider as a constructor option — `Loop.run` never reads `ctx.provider`. The provider's IDENTITY is not
124
+ * lost from the audit either: the meter records the provider NAME on the `{type:'llm'}` action's args.
125
+ *
126
+ * NB: this strips the provider only — the leak that was grounded. A caller that threads its OWN secret-bearing
127
+ * fields onto ctx is backstopped by bareguard-side redaction (BG-1), the defense-in-depth pair to this fix.
128
+ * @param {RecurseCtx} ctx
129
+ * @param {object} [overrides] - extra fields to set on the audited copy (e.g. `{ depth }`).
130
+ * @returns {object}
131
+ */
132
+ function auditSafeCtx(ctx, overrides = {}) {
133
+ const safe = { ...(ctx || {}) };
134
+ delete (/** @type {any} */ (safe)).provider;
135
+ return { ...safe, ...overrides };
136
+ }
137
+
114
138
  /**
115
139
  * @typedef {object} RecurseCtx
116
140
  * The per-run runtime blob — the wiring, threaded down the whole recursion tree (and forwarded to the worker
@@ -122,7 +146,12 @@ function forChild(opts) {
122
146
  * and worker tokens are all real spend (BA1: never invisible).
123
147
  * @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
124
148
  * threaded into `policy`. Callers normally omit it (defaults to 0).
125
- * @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate).
149
+ * @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate). This
150
+ * is the observability channel for worker activity (relayfact F15/BA-5): recurse intentionally does NOT take
151
+ * `onToolCall`/`onText` Loop callbacks — instead every worker Loop emits `loop:tool_call` / `loop:tool_result`
152
+ * (and `loop:text`/`loop:done`) to THIS stream (loop.js), so a consumer observes worker tool calls by reading
153
+ * the stream, not via per-call callbacks. The full audit trail is stream + the RC-10 receipts tree + (if a
154
+ * gate is wired) the bareguard audit.
126
155
  * @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
127
156
  * retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
128
157
  * the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
@@ -363,7 +392,7 @@ async function recurse(task, ctx = {}, opts = {}) {
363
392
  provider,
364
393
  window: opts.window,
365
394
  passes: opts.passes,
366
- ctx: { ...ctx, depth },
395
+ ctx: auditSafeCtx(ctx, { depth }), // scan's Loop run ctx reaches the gate — strip provider (F16/BA-1)
367
396
  onLlmResult: ctx.onLlmResult,
368
397
  policy: ctx.policy,
369
398
  }));
@@ -394,7 +423,10 @@ async function recurse(task, ctx = {}, opts = {}) {
394
423
  const out = await loop.run(
395
424
  [{ role: 'user', content: task }],
396
425
  tools,
397
- { ctx: { ...ctx, depth } },
426
+ // auditSafeCtx: the run ctx reaches the gate as `_ctx`; strip the key-bearing provider (F16/BA-1). The
427
+ // worker Loop already has `provider` as a constructor option, so stripping it from the run ctx is invisible
428
+ // to the worker and only cleans the audited copy.
429
+ { ctx: auditSafeCtx(ctx, { depth }) },
398
430
  );
399
431
 
400
432
  node.tokens = out.metrics ? out.metrics.tokens : null;
@@ -524,7 +556,7 @@ async function recurseScan(task, ctx, opts, state) {
524
556
  provider,
525
557
  window: opts.window,
526
558
  passes: opts.passes,
527
- ctx: { ...ctx, depth: state.depth },
559
+ ctx: auditSafeCtx(ctx, { depth: state.depth }), // scan's Loop run ctx reaches the gate — strip provider (F16/BA-1)
528
560
  onLlmResult: ctx.onLlmResult,
529
561
  policy: ctx.policy,
530
562
  });
@@ -615,7 +647,7 @@ async function recursePartition(task, ctx, opts, state) {
615
647
  // spends (bounds the burst to zero); a plain deny is advisory (allowlist-safe), same contract as fanout.
616
648
  if (typeof ctx.policy === 'function') {
617
649
  try {
618
- await ctx.policy('recurse_partition', { width, size, depth }, { ...ctx, depth });
650
+ await ctx.policy('recurse_partition', { width, size, depth }, auditSafeCtx(ctx, { depth }));
619
651
  } catch (err) {
620
652
  if (err instanceof HaltError) throw err;
621
653
  }
@@ -731,7 +763,7 @@ async function recurseFanout(task, ctx, opts, state) {
731
763
  // descriptor — the load-bearing budget signal is the HaltError, on bareguard's existing contract.
732
764
  if (typeof ctx.policy === 'function') {
733
765
  try {
734
- await ctx.policy('recurse_fanout', { count: steps.length, depth }, { ...ctx, depth });
766
+ await ctx.policy('recurse_fanout', { count: steps.length, depth }, auditSafeCtx(ctx, { depth }));
735
767
  } catch (err) {
736
768
  if (err instanceof HaltError) throw err;
737
769
  // non-halt policy error/deny → advisory; proceed (per-worker policy still gates each child below)
package/tools/shell.d.ts CHANGED
@@ -1,4 +1,13 @@
1
- export type GrepArgs = {
1
+ declare namespace _exports {
2
+ export { GrepArgs, RunArgvArgs, ExecCommandArgs, ToolDef };
3
+ }
4
+ declare namespace _exports {
5
+ export { createShellTools };
6
+ export { _grepCore };
7
+ export { writeFile as _writeFile };
8
+ }
9
+ export = _exports;
10
+ type GrepArgs = {
2
11
  pattern: string;
3
12
  path: string;
4
13
  recursive?: boolean | undefined;
@@ -11,28 +20,28 @@ export type GrepArgs = {
11
20
  */
12
21
  timeout?: number | undefined;
13
22
  };
14
- export type RunArgvArgs = {
23
+ type RunArgvArgs = {
15
24
  argv: string[];
16
25
  cwd?: string | undefined;
17
26
  timeout?: number | undefined;
18
27
  maxBuffer?: number | undefined;
19
28
  env?: Record<string, string> | undefined;
20
29
  };
21
- export type ExecCommandArgs = {
30
+ type ExecCommandArgs = {
22
31
  command: string;
23
32
  cwd?: string | undefined;
24
33
  timeout?: number | undefined;
25
34
  maxBuffer?: number | undefined;
26
35
  env?: Record<string, string> | undefined;
27
36
  };
28
- export type ToolDef = import("../types").ToolDef;
37
+ type ToolDef = import("../types").ToolDef;
29
38
  /**
30
39
  * Create the three shell tools. No options — configuration is per-call via tool args,
31
40
  * gating is the caller's responsibility via `new Loop({ policy })`.
32
41
  *
33
42
  * @returns {{tools: ToolDef[]}}
34
43
  */
35
- export function createShellTools(): {
44
+ declare function createShellTools(): {
36
45
  tools: ToolDef[];
37
46
  };
38
47
  /**
@@ -54,7 +63,7 @@ export function createShellTools(): {
54
63
  * guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
55
64
  * @param {GrepArgs} args
56
65
  */
57
- export function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags }: GrepArgs): Promise<{
66
+ declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags }: GrepArgs): Promise<{
58
67
  hits: {
59
68
  file: string;
60
69
  line: number;
@@ -63,3 +72,17 @@ export function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags
63
72
  truncated: boolean;
64
73
  fileCount: number;
65
74
  }>;
75
+ /**
76
+ * Write text to a file (the BA-2 first-class write primitive — a coding agent must edit files, and routing
77
+ * writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies).
78
+ * Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's
79
+ * fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc).
80
+ * @param {{path: string, content?: string, append?: boolean, maxBytes?: number}} args
81
+ * @returns {Promise<string>}
82
+ */
83
+ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
84
+ path: string;
85
+ content?: string;
86
+ append?: boolean;
87
+ maxBytes?: number;
88
+ }): Promise<string>;
package/tools/shell.js CHANGED
@@ -3,13 +3,30 @@
3
3
  /**
4
4
  * Pure-Node shell tools — cross-platform (linux, macOS, Windows), no external binaries.
5
5
  *
6
- * Three primitives:
6
+ * Primitives:
7
7
  * shell_read — read a file or list a directory
8
8
  * shell_grep — regex search across files (JS regex, no grep/rg/findstr)
9
- * shell_exec run a shell command with timeout + max buffer
9
+ * shell_write write/overwrite (or append to) a file, creating parent dirs (no shell)
10
+ * shell_run — run a command via an argv array (no shell, allowlist-friendly on argv[0])
11
+ * shell_exec — run a raw shell command with timeout + max buffer
10
12
  *
11
- * All three run through Loop's policy hook when wired via `new Loop({ policy })`.
13
+ * All run through Loop's policy hook when wired via `new Loop({ policy })`.
12
14
  * Library ships zero baked-in allowlist — gating is the agent author's responsibility.
15
+ *
16
+ * GATING WITH bareguard's fs/bash PRIMITIVES: these tools carry tool-named actions by default
17
+ * (`{ type:'shell_write' }`), which match `tools.allowlist`/`tools.denylist` but do NOT activate the
18
+ * `fs`/`bash` primitives — those need `action.type ∈ {read,write,edit,bash}` with `action.path`/`action.cmd`.
19
+ * To gate `shell_write` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it touches
20
+ * disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
21
+ * mapping (`shell_write` → `{ type:'write', path }`, `shell_read`/`shell_grep` → `{ type:'read', path }`,
22
+ * `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). A write tool alone is NOT auto-gated — validated by
23
+ * poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
24
+ *
25
+ * CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
26
+ * `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
27
+ * not caught — a `shell_write` through such a link can escape the scope. If untrusted input can create
28
+ * symlinks under your scope, canonicalize (`fs.realpath`) before the gate, or keep the scope on a root with
29
+ * no attacker-writable symlinks. This is bareguard's documented lexical-match contract, not specific to this tool.
13
30
  */
14
31
 
15
32
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -20,6 +37,7 @@ const { exec, execFile } = require('node:child_process');
20
37
  const { Worker } = require('node:worker_threads');
21
38
 
22
39
  const DEFAULT_READ_MAX_BYTES = 256 * 1024; // 256 KB
40
+ const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB — a sanity ceiling on a single write (LLM-authored)
23
41
  const DEFAULT_GREP_MAX_MATCHES = 200;
24
42
  const DEFAULT_GREP_TIMEOUT_MS = 5_000; // hard ceiling on a single grep — bounds ReDoS
25
43
  const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
@@ -67,6 +85,31 @@ async function readEntry(rawPath, maxBytes) {
67
85
  return fs.readFile(resolved, 'utf8');
68
86
  }
69
87
 
88
+ /**
89
+ * Write text to a file (the BA-2 first-class write primitive — a coding agent must edit files, and routing
90
+ * writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies).
91
+ * Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's
92
+ * fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc).
93
+ * @param {{path: string, content?: string, append?: boolean, maxBytes?: number}} args
94
+ * @returns {Promise<string>}
95
+ */
96
+ async function writeFile({ path: rawPath, content = '', append = false, maxBytes }) {
97
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
98
+ throw new Error('shell_write requires a non-empty "path" string');
99
+ }
100
+ const text = content == null ? '' : String(content);
101
+ const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
102
+ const bytes = Buffer.byteLength(text, 'utf8');
103
+ if (bytes > cap) {
104
+ throw new Error(`shell_write content is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
105
+ }
106
+ const resolved = path.resolve(expandHome(rawPath));
107
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
108
+ if (append) await fs.appendFile(resolved, text, 'utf8');
109
+ else await fs.writeFile(resolved, text, 'utf8');
110
+ return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
111
+ }
112
+
70
113
  // Probe the first 1KB for NUL bytes to skip binary files in grep walks.
71
114
  /** @param {string} filePath */
72
115
  async function isProbablyText(filePath) {
@@ -373,6 +416,23 @@ function createShellTools() {
373
416
  },
374
417
  execute: async (/** @type {GrepArgs} */ args) => grepPath(args),
375
418
  },
419
+ {
420
+ name: 'shell_write',
421
+ description: 'Write text to a file (overwriting it), creating parent directories as needed. No shell — so an ' +
422
+ 'fs.writeScope policy can gate it by path (translate to {type:"write"}). Use append:true to add to the end ' +
423
+ 'instead of overwriting. Returns a "wrote N bytes to <path>" summary. Max 5MB per write by default.',
424
+ parameters: {
425
+ type: 'object',
426
+ properties: {
427
+ path: { type: 'string', description: 'Target file path. ~ expands to home. Parent dirs are created.' },
428
+ content: { type: 'string', description: 'The full text to write (UTF-8).' },
429
+ append: { type: 'boolean', description: 'Append to the file instead of overwriting it (default false).' },
430
+ maxBytes: { type: 'integer', description: 'Reject a write larger than this many bytes (default 5242880).' },
431
+ },
432
+ required: ['path', 'content'],
433
+ },
434
+ execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) => writeFile(args),
435
+ },
376
436
  {
377
437
  name: 'shell_run',
378
438
  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.',
@@ -413,4 +473,4 @@ function createShellTools() {
413
473
  return { tools };
414
474
  }
415
475
 
416
- module.exports = { createShellTools, _grepCore };
476
+ module.exports = { createShellTools, _grepCore, _writeFile: writeFile };