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 +1 -0
- package/bareagent.context.md +9 -6
- 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 +12 -2
- package/src/recurse.js +38 -6
- package/tools/shell.d.ts +29 -6
- package/tools/shell.js +64 -4
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
|
});
|
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.
|
|
@@ -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
|
/**
|
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
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 };
|