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 +2 -1
- package/bareagent.context.md +48 -7
- package/examples/litectx-as-store.mjs +11 -4
- package/examples/with-bareguard.mjs +40 -10
- package/package.json +1 -1
- package/src/recurse.d.ts +18 -2
- package/src/recurse.js +43 -7
- package/tools/shell.d.ts +29 -6
- package/tools/shell.js +64 -4
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
|
});
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.
|
|
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')
|
|
388
|
-
if (toolName === 'shell_run')
|
|
389
|
-
if (toolName === 'shell_read')
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
// -
|
|
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
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
88
|
+
tools,
|
|
60
89
|
);
|
|
61
90
|
|
|
62
91
|
console.log('---');
|
|
63
92
|
console.log('text:', result.text);
|
|
64
|
-
|
|
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
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 };
|