bare-agent 0.21.0 → 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
@@ -94,7 +94,7 @@ Every piece works alone — take what you need, ignore the rest. Two axes: **Act
94
94
 
95
95
  ### Recurse — break a hard task into a tree *(the RLM primitive)*
96
96
 
97
- `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
97
+ `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier). The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
98
98
 
99
99
  Over a corpus, context reaches a worker as a **handle routed by question shape** (`opts.retrieval`):
100
100
 
@@ -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.0 | 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.
@@ -658,7 +661,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the
658
661
 
659
662
  **Synthesis (`opts.synthesize`):** a **function** (deterministic code-reduce over child `results` — use for arithmetic/aggregation; LLM arithmetic over partials carried ~10–15% error), or `'concat'` (lossless no-LLM join), or `'merge'` (isolated Loop-driven subjective merge). Default = the parent model's own closing-turn synthesis. **`opts.contract`** = a definition-of-done the verifier grades against (instead of the loose task); **`opts.evaluate`** overrides the verifier. Exported helpers for the per-query face: `buildScanTool`, `buildSearchTool`, `buildExactTool`, `litectxCorpus`.
660
663
 
661
- **Worker persona (`opts.persona`, v0.21.0):** a string PREPENDED to every Family-A worker's system prompt — give workers a stance (`persona: 'You are a senior security engineer; be specific and cite the exact file:line'`). It **augments**, never replaces, the built-in decomposition policy + depth-scrub (those drive the spawn mechanics), and **carries down the whole tree** (a child of a "senior security engineer" is still one). It is deliberately **not** applied to the isolated verifier (that isolation is what defeats self-grading sycophancy) nor the deterministic scan judge. Absent ⇒ the worker prompt is unchanged from pre-0.21.
664
+ **Worker persona (`opts.persona`, v0.21.0):** a string PREPENDED to every Family-A worker's system prompt — give workers a stance (`persona: 'You are a senior security engineer; be specific and cite the exact file:line'`). It **augments**, never replaces, the built-in decomposition policy + depth-scrub (those drive the spawn mechanics), and **carries down the whole tree** (a child of a "senior security engineer" is still one). It is deliberately **not** applied to the isolated verifier (that isolation is what defeats self-grading sycophancy) nor the deterministic scan judge. Absent ⇒ the worker prompt is unchanged from pre-0.21. **Security:** treat `persona` like a system prompt — it is prepended ahead of the decomposition policy and can override it for every worker, so pass caller-trusted text only, never untrusted/end-user input.
662
665
 
663
666
  ```javascript
664
667
  const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs', ctx, {
@@ -668,6 +671,41 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
668
671
 
669
672
  **What a delegated child inherits (important — the setpoint is the TOP node's job):** when a worker delegates with `spawn_child`, the child runs a **fresh `recurse`** that inherits `tools`, `synthesize`, `maxDepth`, and `persona` — but the parent's **`contract`/`evaluate` are stripped** (and the forced `count`/`mode` + the corpus `retrieval` knobs). A slice is not graded against the *whole*-task definition-of-done (that verdict is the top node's, and a slice satisfying the whole DoD is the wrong question); only the top `recurse` verifies the synthesized result. The non-overridable `critical → force-verify` safety floor still fires per node (it keys on the task text, not the contract). So: set `contract`/`evaluate` once at the top; they do not — and should not — re-run per intermediate node.
670
673
 
674
+ ## Wiring with Evaluator + refine (output-side verification)
675
+
676
+ `Evaluator` is the output-side judge (the mirror of `Planner`): it grades a result against a goal and returns a tri-state `Verdict`. `refine` is the bounded generate → evaluate → regenerate loop. Both compose *around* a Loop — neither lives inside `loop.js`.
677
+
678
+ ```javascript
679
+ const { Evaluator, refine } = require('bare-agent');
680
+
681
+ const evaluator = new Evaluator({ provider }); // provider REQUIRED for rubric/agentic; predicate needs none
682
+
683
+ // Three criteria types — pass EXACTLY ONE:
684
+ const v1 = await evaluator.evaluate(goal, result, { predicate: (r) => r.includes('DONE') }); // deterministic, 0 tokens
685
+ const v2 = await evaluator.evaluate(goal, result, { rubric: 'Cites a source for every claim.' }); // isolated adversarial LLM grader
686
+ const v3 = await evaluator.evaluate(goal, url, { agentic: 'Open the page, click Submit, check the console for errors.' }); // tool-running critic that EXERCISES the artifact
687
+
688
+ // Verdict: { status: 'satisfied' | 'needs_revision' | 'failed', pass, score, critique, suggestions }
689
+ // pass = (status === 'satisfied'); needs_revision is retryable; failed is terminal (stop spending).
690
+ if (!v2.pass) console.log(v2.critique, v2.suggestions);
691
+ ```
692
+
693
+ Key invariants:
694
+ - The **rubric path runs an isolated adversarial grader** — a separate context window with a harsh, independent prompt, never the generator's transcript. That isolation (not a feedback knob) is what defeats the self-evaluation trap; the grader treats the RESULT as untrusted DATA (judge prompt-injection defence).
695
+ - **`agentic`** (the third type) spins up a fresh Loop with scoped tools (set on the Evaluator, or per-call `opts.tools`) that **exercises** the live artifact — clicks, reads console/network — rather than reading text. Each critic round forwards to `onLlmResult`; a governance `HaltError` re-throws clean.
696
+ - **`contract`** (a definition of done) is graded against instead of the loose goal: `evaluate(goal, result, { rubric, contract })`. Judge tokens forward to the gate via `onLlmResult` (`kind:'evaluate'`) so verification spend is visible to the budget.
697
+
698
+ **`refine`** drives a caller-supplied `attempt`/`evaluate` until a satisfied verdict, a terminal `failed`, or `maxIterations` (the real bound is bareguard maxTurns/budget). It threads the latest `critique` into the next attempt (fresh-feedback, not anchoring on a failed answer) and a shared `contract` to both sides.
699
+
700
+ ```javascript
701
+ const { result, verdict, iterations, history } = await refine({
702
+ attempt: ({ critique, contract }) => generate(prompt, { critique, contract }), // critique = null on the first pass
703
+ evaluate: (result, { contract }) => evaluator.evaluate(goal, result, { rubric, contract }),
704
+ contract: 'No TODOs; every public fn has a JSDoc; tests pass.',
705
+ maxIterations: 3, // hard cap; the REAL bound is the gate
706
+ });
707
+ ```
708
+
671
709
  ## Provider options
672
710
 
673
711
  ```javascript
@@ -677,6 +715,9 @@ new OpenAI({ apiKey, model: 'gpt-4o-mini', baseUrl: 'https://api.openai.com/v1'
677
715
  // Anthropic
678
716
  new Anthropic({ apiKey, model: 'claude-haiku-4-5-20251001' })
679
717
 
718
+ // Gemini (native generateContent — needed for prompt-cache token tiers; the OpenAI-compat endpoint drops them)
719
+ new Gemini({ apiKey, model: 'gemini-2.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' })
720
+
680
721
  // Ollama (local, no key needed)
681
722
  new Ollama({ model: 'llama3.2', url: 'http://localhost:11434' })
682
723
 
@@ -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.0",
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
  /**
@@ -54,6 +59,9 @@ export type RecurseOptions = {
54
59
  * (preserved by `forChild` — a durable worker stance, unlike the top-only `contract`/`evaluate`). Deliberately
55
60
  * NOT applied to the isolated verifier (would defeat the anti-sycophancy isolation, A1) nor the deterministic
56
61
  * scan judge. Absent/blank ⇒ the worker prompt is byte-identical to pre-0.21 (backward-compatible).
62
+ * **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
63
+ * untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
64
+ * and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
57
65
  */
58
66
  persona?: string | undefined;
59
67
  /**
@@ -249,7 +257,12 @@ export type Slice = {
249
257
  * and worker tokens are all real spend (BA1: never invisible).
250
258
  * @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
251
259
  * threaded into `policy`. Callers normally omit it (defaults to 0).
252
- * @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.
253
266
  * @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
254
267
  * retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
255
268
  * the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
@@ -267,6 +280,9 @@ export type Slice = {
267
280
  * (preserved by `forChild` — a durable worker stance, unlike the top-only `contract`/`evaluate`). Deliberately
268
281
  * NOT applied to the isolated verifier (would defeat the anti-sycophancy isolation, A1) nor the deterministic
269
282
  * scan judge. Absent/blank ⇒ the worker prompt is byte-identical to pre-0.21 (backward-compatible).
283
+ * **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
284
+ * untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
285
+ * and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
270
286
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
271
287
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
272
288
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
package/src/recurse.js CHANGED
@@ -78,7 +78,8 @@ function partitionInto(arr, n) {
78
78
  * @returns {string}
79
79
  */
80
80
  function workerPersonaPrefix(persona) {
81
- return typeof persona === 'string' && persona.trim() ? persona.trim() + '\n\n' : '';
81
+ const p = typeof persona === 'string' ? persona.trim() : '';
82
+ return p ? p + '\n\n' : '';
82
83
  }
83
84
 
84
85
  /**
@@ -110,6 +111,30 @@ function forChild(opts) {
110
111
  };
111
112
  }
112
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
+
113
138
  /**
114
139
  * @typedef {object} RecurseCtx
115
140
  * The per-run runtime blob — the wiring, threaded down the whole recursion tree (and forwarded to the worker
@@ -121,7 +146,12 @@ function forChild(opts) {
121
146
  * and worker tokens are all real spend (BA1: never invisible).
122
147
  * @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
123
148
  * threaded into `policy`. Callers normally omit it (defaults to 0).
124
- * @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.
125
155
  * @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
126
156
  * retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
127
157
  * the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
@@ -140,6 +170,9 @@ function forChild(opts) {
140
170
  * (preserved by `forChild` — a durable worker stance, unlike the top-only `contract`/`evaluate`). Deliberately
141
171
  * NOT applied to the isolated verifier (would defeat the anti-sycophancy isolation, A1) nor the deterministic
142
172
  * scan judge. Absent/blank ⇒ the worker prompt is byte-identical to pre-0.21 (backward-compatible).
173
+ * **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
174
+ * untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
175
+ * and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
143
176
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
144
177
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
145
178
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
@@ -359,7 +392,7 @@ async function recurse(task, ctx = {}, opts = {}) {
359
392
  provider,
360
393
  window: opts.window,
361
394
  passes: opts.passes,
362
- ctx: { ...ctx, depth },
395
+ ctx: auditSafeCtx(ctx, { depth }), // scan's Loop run ctx reaches the gate — strip provider (F16/BA-1)
363
396
  onLlmResult: ctx.onLlmResult,
364
397
  policy: ctx.policy,
365
398
  }));
@@ -390,7 +423,10 @@ async function recurse(task, ctx = {}, opts = {}) {
390
423
  const out = await loop.run(
391
424
  [{ role: 'user', content: task }],
392
425
  tools,
393
- { 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 }) },
394
430
  );
395
431
 
396
432
  node.tokens = out.metrics ? out.metrics.tokens : null;
@@ -520,7 +556,7 @@ async function recurseScan(task, ctx, opts, state) {
520
556
  provider,
521
557
  window: opts.window,
522
558
  passes: opts.passes,
523
- 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)
524
560
  onLlmResult: ctx.onLlmResult,
525
561
  policy: ctx.policy,
526
562
  });
@@ -611,7 +647,7 @@ async function recursePartition(task, ctx, opts, state) {
611
647
  // spends (bounds the burst to zero); a plain deny is advisory (allowlist-safe), same contract as fanout.
612
648
  if (typeof ctx.policy === 'function') {
613
649
  try {
614
- await ctx.policy('recurse_partition', { width, size, depth }, { ...ctx, depth });
650
+ await ctx.policy('recurse_partition', { width, size, depth }, auditSafeCtx(ctx, { depth }));
615
651
  } catch (err) {
616
652
  if (err instanceof HaltError) throw err;
617
653
  }
@@ -727,7 +763,7 @@ async function recurseFanout(task, ctx, opts, state) {
727
763
  // descriptor — the load-bearing budget signal is the HaltError, on bareguard's existing contract.
728
764
  if (typeof ctx.policy === 'function') {
729
765
  try {
730
- await ctx.policy('recurse_fanout', { count: steps.length, depth }, { ...ctx, depth });
766
+ await ctx.policy('recurse_fanout', { count: steps.length, depth }, auditSafeCtx(ctx, { depth }));
731
767
  } catch (err) {
732
768
  if (err instanceof HaltError) throw err;
733
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 };