bare-agent 0.47.0 → 0.48.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.
@@ -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.43.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.16.0` optional peer for governance) | Apache 2.0
4
+ > v0.43.0 | Node.js >= 18 | zero required deps (`bareguard >=0.19.0 <1.0.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/archive/usage-guide.md)
7
7
 
@@ -509,7 +509,18 @@ The blocks now ride the transcript on `Message.providerBlocks` (`{provider, mode
509
509
 
510
510
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
511
511
 
512
- **`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
512
+ **`actionTranslator` for bash/fs primitive activation — DEFAULT since bareguard >=0.19.0 (the "C2" fix).** Bareguard's `bashCheck` / `fsCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'edit'`. Through 0.15, `wireGate`'s default action shape was `{type: toolName, args, _ctx}` — this matched `tools.denylist` / `tools.allowlist` but never activated `fs`/`bash`, so every adopter had to hand-write the translator below. **As of bareagent's current peerDependency floor (bareguard >=0.19.0), the DEFAULT translator does this mapping for you** — no custom `actionTranslator` needed for the six `createShellTools` primitives:
513
+
514
+ - `shell_read` / `shell_grep` → `{type:'read', tool, path: resolveToolPath(args.path), args, _ctx}`
515
+ - `shell_write` → `{type:'write', tool, path: resolveToolPath(args.path), args, _ctx}`
516
+ - `shell_edit` → `{type:'edit', tool, path: resolveToolPath(args.path), args, _ctx}`
517
+ - `shell_run` → `{type:'bash', tool, cmd: args.argv.join(' '), args, _ctx}`
518
+ - `shell_exec` → `{type:'bash', tool, cmd: args.command, args, _ctx}`
519
+ - every other tool → `{type: toolName, tool: toolName, args, _ctx}`
520
+
521
+ `tool` carries the literal bareagent tool name on **every** branch — bareguard 0.19's action identity for `tools.allowlist`/`tools.denylist` is `action.tool ?? action.type`, so a tool-name allowlist (`tools.allowlist: ['shell_read']`) keeps matching even though `type` is now `'read'`/`'write'`/`'bash'`, not the tool name. **`filterTools` deliberately does NOT use this translator** — it probes tool IDENTITY ONLY (`{type: name, tool: name}`). No real `args` exist at discovery time, so probing the translated shape would mean `path: undefined`; measured on a real 0.19 Gate, a path-less `read`/`write`/`edit` action SKIPS the fs scope check entirely (not a deny), which would silently OFFER a shell file tool with NO scope configured at all — and bareguard has flagged that behavior may tighten to a deny in a future release, which would instead silently HIDE a properly-scoped tool. Neither reading is safe to build `filterTools` on, so it only reflects `tools.allowlist`/`tools.denylist`; scope enforcement stays entirely at `policy`, on the real call, where it denies loudly. `onLlmResult` still always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `resolveToolPath` canonicalizes `~`/relative paths before the gate sees them, exactly as the manual translator below used to; a malformed or missing path is handled so the translator **never crashes and never silently allows** — see `src/bareguard-adapter.js`'s `safeToolPath` for the exact contract: ANY missing or unresolvable path — absent `path` key, non-string, empty, or an unresolvable `~` — normalizes to `''` (bareguard denies via `fs.invalidPath` regardless of scope config). A real call is never silently allowed.
522
+
523
+ **Requires bareguard >=0.19.0.** 0.15 and earlier ignore `action.tool` entirely, so this exact shape combined with a tool-name allowlist would deny every shell file action — that's WHY the peerDependency floor moved, not a runtime version check. Override with `wireGate(gate, { actionTranslator })` if you need a different shape or are pinned to an older bareguard:
513
524
 
514
525
  ```javascript
515
526
  const { policy, onToolResult } = wireGate(gate, {
@@ -524,9 +535,9 @@ const { policy, onToolResult } = wireGate(gate, {
524
535
  });
525
536
  ```
526
537
 
527
- `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).
538
+ Verified live against a real 0.19 Gate: `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) — with zero custom translator. `defaultActionTranslator` is exported for composition.
528
539
 
529
- > **Canonicalize the path before the gate sees it.** bareguard's `fs` primitive checks the path you hand it WITHOUT canonicalizing it — a `~`-prefixed or relative `args.path` reads `fs.readScope`/`fs.writeScope` differently than the absolute path the tool actually opens. Call `resolveToolPath` (exported from `bare-agent/tools` alongside `createShellTools`) on `args.path` in the translator — either put the result in an explicit `path` field (as the snippet above's sibling example, `examples/with-bareguard.mjs`, does) or, if relying on bareguard reading `args.path` directly, replace `args.path` with the canonicalized value before returning. Same string in, same string opened.
540
+ > **fs.readScope/writeScope now deny by default when unset (bareguard 0.19).** Before, an unconfigured `fs` primitive that never activated was silently a no-op; now that the default translator activates it, a Gate with NO `fs.readScope` configured denies every `shell_read`/`shell_grep` (`fs.readScope.unset`), and likewise `fs.writeScope.unset` for `shell_write`/`shell_edit`. If you want the shell file tools usable at all, set both scopes explicitly — see `examples/with-bareguard.mjs`. Note this denial is a `policy`-time fact only: `filterTools` (identity-only, see above) still OFFERS the shell file tools to the model even with no scope configured — the deny happens loudly on the real call, not by hiding the tool at discovery time.
530
541
 
531
542
  **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.
532
543
 
@@ -18,16 +18,30 @@
18
18
 
19
19
  import { Gate } from 'bareguard';
20
20
  import { createRequire } from 'node:module';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
21
24
  const require = createRequire(import.meta.url);
22
25
  const { Loop, wireGate } = require('bare-agent');
23
26
  const { OpenAI } = require('bare-agent/providers');
24
- const { createShellTools, resolveToolPath } = require('bare-agent/tools');
27
+ const { createShellTools } = require('bare-agent/tools');
25
28
 
26
29
  // 1. Build the gate. Every primitive is optional with sensible defaults.
30
+ // fs.readScope/fs.writeScope are both set: on bareguard >=0.19 (bareagent's peerDependency
31
+ // floor), a shell file action denies by default when its scope is unset (fs.readScope.unset /
32
+ // fs.writeScope.unset) — leaving writeScope out here would silently deny every shell_write /
33
+ // shell_edit call, not leave the primitive dead like on older bareguard.
34
+ // readScope is deliberately WIDER than writeScope here — read is fine anywhere under /tmp,
35
+ // but writes are narrowed to one demo subdir, to show the two scopes are independent (a
36
+ // folder listed only in readScope is NOT writable, and vice versa). The demo dir is created
37
+ // below since fs.writeScope doesn't need the path to pre-exist, but the example's own
38
+ // shell_write call does.
39
+ const writeDir = path.join(os.tmpdir(), 'bare-agent-demo');
40
+ fs.mkdirSync(writeDir, { recursive: true });
27
41
  const gate = new Gate({
28
42
  budget: { maxCostUsd: 0.10 }, // hard USD cap
29
43
  limits: { maxTurns: 20 }, // safety net on think/act cycles
30
- fs: { readScope: ['/tmp', '~/'] }, // shell_read / shell_grep allowed roots
44
+ fs: { readScope: ['/tmp'], writeScope: [writeDir] }, // read: anywhere under /tmp; write: one narrower demo dir
31
45
  bash: { allow: ['ls', 'cat', 'echo', 'pwd'] }, // argv[0] allowlist for shell_run
32
46
  audit: { path: './bareagent-audit.jsonl' },
33
47
  // Required by bareguard: any ask/halt event flows through here.
@@ -45,36 +59,16 @@ const gate = new Gate({
45
59
  });
46
60
  await gate.init();
47
61
 
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
- //
54
- // `path` below is `resolveToolPath(args.path)`, not the raw arg: bareguard's fs primitive checks the
55
- // path you hand it WITHOUT canonicalizing it, so a `~`-prefixed or relative path would be judged
56
- // against `fs.readScope`/`fs.writeScope` in a different form than the one the tool actually opens —
57
- // gate string and opened string must be the SAME string. `resolveToolPath` is the one canonicalizer
58
- // (expand `~` via os.homedir(), then path.resolve) every shell file tool already applies internally
59
- // before opening; calling it here too means the gate sees exactly what gets opened.
60
- const actionTranslator = (toolName, args, ctx) => {
61
- switch (toolName) {
62
- // shell_run is argv (no shell); bareguard's bash.allow matches `cmd.startsWith(prefix)`, so join argv[0..].
63
- case 'shell_run': return { type: 'bash', cmd: (args?.argv || []).join(' '), args, _ctx: ctx ?? null };
64
- case 'shell_exec': return { type: 'bash', cmd: args?.command, args, _ctx: ctx ?? null };
65
- // shell_read / shell_grep are reads — gate them through fs.readScope, on the canonicalized path.
66
- case 'shell_read':
67
- case 'shell_grep': return { type: 'read', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
68
- // shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
69
- case 'shell_write': return { type: 'write', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
70
- // shell_edit is an anchored edit — bareguard gates {type:'edit'} by the SAME fs.writeScope as write.
71
- case 'shell_edit': return { type: 'edit', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
72
- default: return { type: toolName, args, _ctx: ctx ?? null };
73
- }
74
- };
62
+ // 2. Wire the gate. As of bareguard >=0.19 (bareagent's peerDependency floor), the DEFAULT
63
+ // actionTranslator already maps the six `createShellTools` primitives into bareguard's `fs`/`bash`
64
+ // shapes (shell_read/shell_grep → {type:'read',...}, shell_write → {type:'write',...}, shell_edit →
65
+ // {type:'edit',...}, shell_run/shell_exec → {type:'bash',...}), canonicalizing the path with
66
+ // `resolveToolPath` itself — so the `bash.allow` + `fs.readScope`/`fs.writeScope` config above just
67
+ // works with no custom actionTranslator (this used to be a required hand-written override — the "C2"
68
+ // gap, relayfact F7/BA-3 — see src/bareguard-adapter.js for the exact shape and bareguard-version note).
75
69
  // onToolResult + onLlmResult are the current wiring (wrapTools is deprecated — it loses _ctx and never sees
76
70
  // LLM cost, so the budget cap can't cover token-only rounds). policy gates pre-call; the result hooks record.
77
- const { policy, onToolResult, onLlmResult } = wireGate(gate, { actionTranslator });
71
+ const { policy, onToolResult, onLlmResult } = wireGate(gate);
78
72
 
79
73
  // 3. Standard bareagent setup.
80
74
  const provider = new OpenAI({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -83,7 +83,7 @@
83
83
  "cron-parser": "^4.9.0"
84
84
  },
85
85
  "peerDependencies": {
86
- "bareguard": ">=0.9.0 <1.0.0",
86
+ "bareguard": ">=0.19.0 <1.0.0",
87
87
  "better-sqlite3": ">=9.0.0"
88
88
  },
89
89
  "peerDependenciesMeta": {
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "devDependencies": {
109
109
  "@types/node": "^22.19.19",
110
- "bareguard": ">=0.9.0 <1.0.0",
110
+ "bareguard": ">=0.19.0 <1.0.0",
111
111
  "litectx": "^0.26.0",
112
112
  "typescript": "^5.7.0"
113
113
  }
@@ -89,7 +89,15 @@ export type Annotation = {
89
89
  * - `onToolResult` — callback for `new Loop({ onToolResult })`. Forwards every
90
90
  * tool.execute result to gate.record with ctx in scope.
91
91
  * - `filterTools` — async (tools) => filtered. Drops tools denied by gate.allows
92
- * so the LLM never sees them. No audit, no record. Bulk-only:
92
+ * so the LLM never sees them. No audit, no record. Judges tool
93
+ * IDENTITY ONLY (`{type: name, tool: name}`) — never the
94
+ * `actionTranslator`'s real per-type shape (an `fs`/`bash` primitive
95
+ * needs a real path/cmd that doesn't exist yet at discovery time; a
96
+ * path-less probe's meaning is version-fragile — see the function doc
97
+ * below). So filterTools reflects ONLY `tools.allowlist`/`tools.denylist`;
98
+ * per-call scope (`fs.readScope`/`writeScope`, `bash.allow`) is enforced
99
+ * by `policy` on every real call — an unset scope denies loudly there,
100
+ * never silently hidden or silently offered at filter time. Bulk-only:
93
101
  * when MCP tools are exposed via `mcp_discover`+`mcp_invoke`
94
102
  * meta-tools, filterTools cannot drop the inner names (they
95
103
  * are not in the tool list). Gate those via bareguard's
@@ -111,13 +119,17 @@ export type Annotation = {
111
119
  * "[deny: <rule>] <toolName> denied" when bareguard omits a reason. Halt bypasses
112
120
  * this (HaltError doesn't reach the LLM).
113
121
  * @param {Function} [options.actionTranslator] - (toolName, args, ctx) => action.
114
- * Builds the action object passed to `gate.check` and `gate.record`. Default:
115
- * `{ type: toolName, args, _ctx: ctx }`. Override when bareguard's primitives
116
- * need a specific shape — e.g. `bashCheck` requires `{type:'bash', cmd:...}`,
117
- * `fsCheck` requires `{type:'read'|'write'|'edit', path:...}`. The default shape
118
- * matches `tools.denylist` / `tools.allowlist` (which read `action.type`) but
119
- * does NOT activate `bash`/`fs`/`net` primitives — those need their own
120
- * `action.type` value. Adopters using those primitives must translate.
122
+ * Builds the action object passed to `gate.check` and `gate.record`. Default (requires
123
+ * bareguard >=0.19.0): the six `createShellTools` primitives map to bareguard's `fs`/`bash`
124
+ * shapes — `shell_read`/`shell_grep` → `{type:'read', tool, path: resolveToolPath(args.path), args, _ctx}`,
125
+ * `shell_write` → `{type:'write', ...}`, `shell_edit` → `{type:'edit', ...}`, `shell_run` →
126
+ * `{type:'bash', tool, cmd: args.argv.join(' '), args, _ctx}`, `shell_exec` → `{type:'bash', tool,
127
+ * cmd: args.command, args, _ctx}`; every other tool → `{type: toolName, tool: toolName, args, _ctx}`.
128
+ * `tool` always carries the literal bareagent tool name — bareguard's action identity for
129
+ * `tools.allowlist`/`tools.denylist` is `action.tool ?? action.type`, so a tool-name allowlist
130
+ * (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
131
+ * activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
132
+ * when unset — see CHANGELOG) — override when a different shape is needed.
121
133
  * @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
122
134
  * @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
123
135
  * @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
@@ -156,9 +168,22 @@ export function wireGate(gate: Gate, options?: {
156
168
  * @param {Ctx} ctx
157
169
  */
158
170
  export function defaultActionTranslator(toolName: string, args: any, ctx: Ctx): {
171
+ tool: string;
172
+ args: any;
173
+ _ctx: any;
174
+ type: string;
175
+ path: string;
176
+ } | {
177
+ tool: string;
178
+ args: any;
179
+ _ctx: any;
159
180
  type: string;
181
+ cmd: any;
182
+ } | {
183
+ tool: string;
160
184
  args: any;
161
185
  _ctx: any;
186
+ type: string;
162
187
  };
163
188
  /**
164
189
  * @typedef {object} AnnotationLimits
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { HaltError } = require('./errors');
4
+ const { resolveToolPath } = require('../tools/shell');
4
5
 
5
6
  /** @typedef {import('../types').Ctx} Ctx */
6
7
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -56,7 +57,15 @@ let warnedWrap = false;
56
57
  * - `onToolResult` — callback for `new Loop({ onToolResult })`. Forwards every
57
58
  * tool.execute result to gate.record with ctx in scope.
58
59
  * - `filterTools` — async (tools) => filtered. Drops tools denied by gate.allows
59
- * so the LLM never sees them. No audit, no record. Bulk-only:
60
+ * so the LLM never sees them. No audit, no record. Judges tool
61
+ * IDENTITY ONLY (`{type: name, tool: name}`) — never the
62
+ * `actionTranslator`'s real per-type shape (an `fs`/`bash` primitive
63
+ * needs a real path/cmd that doesn't exist yet at discovery time; a
64
+ * path-less probe's meaning is version-fragile — see the function doc
65
+ * below). So filterTools reflects ONLY `tools.allowlist`/`tools.denylist`;
66
+ * per-call scope (`fs.readScope`/`writeScope`, `bash.allow`) is enforced
67
+ * by `policy` on every real call — an unset scope denies loudly there,
68
+ * never silently hidden or silently offered at filter time. Bulk-only:
60
69
  * when MCP tools are exposed via `mcp_discover`+`mcp_invoke`
61
70
  * meta-tools, filterTools cannot drop the inner names (they
62
71
  * are not in the tool list). Gate those via bareguard's
@@ -78,13 +87,17 @@ let warnedWrap = false;
78
87
  * "[deny: <rule>] <toolName> denied" when bareguard omits a reason. Halt bypasses
79
88
  * this (HaltError doesn't reach the LLM).
80
89
  * @param {Function} [options.actionTranslator] - (toolName, args, ctx) => action.
81
- * Builds the action object passed to `gate.check` and `gate.record`. Default:
82
- * `{ type: toolName, args, _ctx: ctx }`. Override when bareguard's primitives
83
- * need a specific shape — e.g. `bashCheck` requires `{type:'bash', cmd:...}`,
84
- * `fsCheck` requires `{type:'read'|'write'|'edit', path:...}`. The default shape
85
- * matches `tools.denylist` / `tools.allowlist` (which read `action.type`) but
86
- * does NOT activate `bash`/`fs`/`net` primitives — those need their own
87
- * `action.type` value. Adopters using those primitives must translate.
90
+ * Builds the action object passed to `gate.check` and `gate.record`. Default (requires
91
+ * bareguard >=0.19.0): the six `createShellTools` primitives map to bareguard's `fs`/`bash`
92
+ * shapes — `shell_read`/`shell_grep` → `{type:'read', tool, path: resolveToolPath(args.path), args, _ctx}`,
93
+ * `shell_write` → `{type:'write', ...}`, `shell_edit` → `{type:'edit', ...}`, `shell_run` →
94
+ * `{type:'bash', tool, cmd: args.argv.join(' '), args, _ctx}`, `shell_exec` → `{type:'bash', tool,
95
+ * cmd: args.command, args, _ctx}`; every other tool → `{type: toolName, tool: toolName, args, _ctx}`.
96
+ * `tool` always carries the literal bareagent tool name — bareguard's action identity for
97
+ * `tools.allowlist`/`tools.denylist` is `action.tool ?? action.type`, so a tool-name allowlist
98
+ * (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
99
+ * activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
100
+ * when unset — see CHANGELOG) — override when a different shape is needed.
88
101
  * @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
89
102
  * @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
90
103
  * @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
@@ -214,10 +227,33 @@ function wireGate(gate, options = {}) {
214
227
  // Bind to the Gate so `this` stays correct (bareguard's allows reads
215
228
  // this._initialized) — extracting the method unbound would crash.
216
229
  const allows = gate.allows.bind(gate);
230
+ // DELIBERATELY NOT `translate(t.name, undefined, undefined)`. No real `args` exist
231
+ // yet at discovery time, so a path/cmd-bearing actionTranslator (e.g. the default,
232
+ // which maps shell_read/write/edit to `fs` primitive shapes) would probe with
233
+ // `path:undefined` — and what that means is a bareguard-VERSION-DEPENDENT fact, not
234
+ // a stable contract: measured on bareguard 0.19.0, a path-less `read`/`write`/`edit`
235
+ // action SKIPS the fs scope check entirely ("not a file action", not "scope unset"),
236
+ // so `allows()` returns true even with NO fs.readScope/writeScope configured at all —
237
+ // silently OFFERING a tool that every real call to it would then be denied by `policy`
238
+ // (fs.readScope.unset/fs.writeScope.unset). bareguard has flagged this as likely to
239
+ // tighten in a future release (a path-less file action denying instead of skipping),
240
+ // which would flip the failure mode to silently HIDING the tool even when scope IS
241
+ // configured. Neither reading is something this adapter should guess at, and there is
242
+ // no bareguard API to ask "is fs.readScope configured" directly — so filterTools probes
243
+ // IDENTITY ONLY (`{type: name, tool: name}`, never the translated per-type shape). That
244
+ // only reflects `tools.allowlist`/`tools.denylist` (bareguard's identity for both is
245
+ // `action.tool ?? action.type` — see `defaultActionTranslator`'s header comment), which
246
+ // is exactly the class of rule filterTools can safely pre-evaluate with no real args.
247
+ // Scope enforcement (`fs.readScope`/`writeScope`, `bash.allow`) stays where it has real
248
+ // data to judge: `policy`, on every actual call, denying loudly when scope is unset —
249
+ // never silently hidden at discovery time. See test/integration-bareguard-real.test.js
250
+ // for the live-0.19 verification of both allowlist/denylist filtering and the
251
+ // no-fs-config "still offered, denied on the real call" behavior.
252
+ //
217
253
  // Parallel: gate.allows is config-driven and pure, so concurrent calls are
218
254
  // safe. Matters for large MCP catalogs (50+ tools) where sequential awaits
219
255
  // were noticeable overhead on every startup.
220
- const verdicts = await Promise.all(tools.map(t => allows(t.name)));
256
+ const verdicts = await Promise.all(tools.map(t => allows({ type: t.name, tool: t.name })));
221
257
  return tools.filter((_, i) => verdicts[i]);
222
258
  };
223
259
 
@@ -284,18 +320,65 @@ function defaultFormatDeny(decision, toolName) {
284
320
  return decision.reason ? `${tag} ${decision.reason}` : `${tag} ${toolName} denied`;
285
321
  }
286
322
 
287
- // Canonical action shape: tool name as type, args nested, ctx tagged. Matches
288
- // bareguard's `tools.denylist`/`tools.allowlist` (which read `action.type`) but
289
- // does NOT activate `bash`/`fs`/`net` primitives — those require `action.type`
290
- // to be `bash`/`read`/`write`/etc. and read fields like `action.cmd` /
291
- // `action.path` at the top level. Override via `wireGate(gate, { actionTranslator })`.
323
+ // Requires bareguard >=0.19.0 (bareagent's own peerDependency floor). Maps the six
324
+ // `createShellTools` primitives (tools/shell.js) to bareguard's `fs`/`bash` action
325
+ // shapes, so `fs.readScope`/`fs.writeScope`/`bash.allow` activate WITHOUT a caller
326
+ // writing their own translator (the "C2" fix — this used to be a documented gap
327
+ // every adopter had to close by hand, see examples/with-bareguard.mjs). Every other
328
+ // tool still gets the pre-0.19 tool-named shape. `tool` is set on every branch —
329
+ // bareguard 0.19's action identity for `tools.allowlist`/`tools.denylist` is
330
+ // `action.tool ?? action.type` (tool exclusive when present), so a tool-name
331
+ // allowlist keeps matching even though `type` is now `'read'`/`'write'`/`'bash'`.
332
+ // Override via `wireGate(gate, { actionTranslator })` when a different shape is needed
333
+ // (e.g. pre-0.19 bareguard, which ignores `action.tool` entirely and would deny every
334
+ // shell file action under this shape — hence the peerDependency floor, not a runtime check).
292
335
  /**
293
336
  * @param {string} toolName
294
337
  * @param {any} args
295
338
  * @param {Ctx} ctx
296
339
  */
297
340
  function defaultActionTranslator(toolName, args, ctx) {
298
- return { type: toolName, args, _ctx: ctx ?? null };
341
+ const tagged = { tool: toolName, args, _ctx: ctx ?? null };
342
+ if (toolName === 'shell_read' || toolName === 'shell_grep') {
343
+ return { type: 'read', path: safeToolPath(args), ...tagged };
344
+ }
345
+ if (toolName === 'shell_write') {
346
+ return { type: 'write', path: safeToolPath(args), ...tagged };
347
+ }
348
+ if (toolName === 'shell_edit') {
349
+ return { type: 'edit', path: safeToolPath(args), ...tagged };
350
+ }
351
+ if (toolName === 'shell_run') {
352
+ return { type: 'bash', cmd: Array.isArray(args?.argv) ? args.argv.join(' ') : undefined, ...tagged };
353
+ }
354
+ if (toolName === 'shell_exec') {
355
+ return { type: 'bash', cmd: args?.command, ...tagged };
356
+ }
357
+ return { type: toolName, ...tagged };
358
+ }
359
+
360
+ // Canonicalize a shell file tool's `path` arg for bareguard's fs primitive, without
361
+ // ever letting a bad or missing path crash the translator or launder into a silent
362
+ // allow. `filterTools` no longer probes this translator at all (it judges tool
363
+ // IDENTITY ONLY — see filterTools above), so there is no remaining "discovery-time,
364
+ // no real args yet" case to special-case here: every call into this function is a
365
+ // REAL per-call translation, and `args` missing entirely, missing the `path` key,
366
+ // or carrying a non-string/empty/unresolvable-`~` path are ALL malformed-or-absent
367
+ // paths that must deny, never skip-then-allow. `resolveToolPath` itself throws on
368
+ // anything that isn't a non-empty string (missing `args`/`args.path` reads as
369
+ // `undefined`, which fails that same check) — so a single try/catch covers every
370
+ // case uniformly, always normalizing to `''`, which `fs.invalidPath` reliably denies
371
+ // on a real bareguard 0.19 Gate whether or not `fs.*Scope` is configured. (Previously
372
+ // this returned `undefined` — not `''` — for a missing `path` key, which hit
373
+ // bareguard's fs primitive's "not a file action, skip scope entirely" branch and
374
+ // silently ALLOWED a real call with no path at all; closed here.)
375
+ /** @param {any} args */
376
+ function safeToolPath(args) {
377
+ try {
378
+ return resolveToolPath(args?.path);
379
+ } catch {
380
+ return '';
381
+ }
299
382
  }
300
383
 
301
384
  // ── judge → gate.annotate mapping (BA-20) ─────────────────────────────────────
package/src/loop.d.ts CHANGED
@@ -167,7 +167,7 @@ export class Loop {
167
167
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
168
168
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
169
169
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
170
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
170
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
171
171
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
172
172
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
173
173
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -208,6 +208,8 @@ export class Loop {
208
208
  malformedToolCall?: {
209
209
  name: string | undefined;
210
210
  error: string;
211
+ rawArguments?: string;
212
+ rawTruncated?: true;
211
213
  };
212
214
  temperatureDropped?: boolean;
213
215
  }>;
@@ -238,7 +240,7 @@ export class Loop {
238
240
  * @param {string} text - User message.
239
241
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
240
242
  * @param {Record<string, any>} [options={}] - Per-run overrides.
241
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
243
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
242
244
  */
243
245
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
244
246
  text: string;
@@ -253,6 +255,8 @@ export class Loop {
253
255
  malformedToolCall?: {
254
256
  name: string | undefined;
255
257
  error: string;
258
+ rawArguments?: string;
259
+ rawTruncated?: true;
256
260
  };
257
261
  temperatureDropped?: boolean;
258
262
  }>;
package/src/loop.js CHANGED
@@ -455,7 +455,7 @@ class Loop {
455
455
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
456
456
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
457
457
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
458
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
458
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
459
459
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
460
460
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
461
461
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -1328,7 +1328,7 @@ class Loop {
1328
1328
  * @param {string} text - User message.
1329
1329
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1330
1330
  * @param {Record<string, any>} [options={}] - Per-run overrides.
1331
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
1331
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
1332
1332
  */
1333
1333
  async chat(text, tools = [], options = {}) {
1334
1334
  this._history.push({ role: 'user', content: text });
@@ -13,6 +13,13 @@ export type OllamaOptions = {
13
13
  * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
14
14
  */
15
15
  deadlineMs?: number | undefined;
16
+ /**
17
+ * - BA-27 follow-up: when a tool call's string-shaped
18
+ * `function.arguments` JSON fails to parse, also attach the raw string (verbatim, capped at 500
19
+ * chars, `rawTruncated:true` when longer) to `malformedToolCall.rawArguments`. Off by default,
20
+ * independent of `exposeErrorBody`. See `src/provider-toolcalls.js`.
21
+ */
22
+ exposeMalformedArgs?: boolean | undefined;
16
23
  };
17
24
  /** @typedef {import('../types').Message} Message */
18
25
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -24,6 +31,10 @@ export type OllamaOptions = {
24
31
  * @property {boolean} [exposeErrorBody=false]
25
32
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
26
33
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
34
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's string-shaped
35
+ * `function.arguments` JSON fails to parse, also attach the raw string (verbatim, capped at 500
36
+ * chars, `rawTruncated:true` when longer) to `malformedToolCall.rawArguments`. Off by default,
37
+ * independent of `exposeErrorBody`. See `src/provider-toolcalls.js`.
27
38
  */
28
39
  export class OllamaProvider {
29
40
  /**
@@ -39,6 +50,7 @@ export class OllamaProvider {
39
50
  exposeErrorBody: boolean;
40
51
  timeoutMs: number | undefined;
41
52
  deadlineMs: number | undefined;
53
+ exposeMalformedArgs: boolean;
42
54
  /**
43
55
  * Generate a response from a local Ollama instance.
44
56
  * @param {Message[]} messages - Conversation messages.
@@ -22,6 +22,10 @@ const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
22
22
  * @property {boolean} [exposeErrorBody=false]
23
23
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
24
24
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
25
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's string-shaped
26
+ * `function.arguments` JSON fails to parse, also attach the raw string (verbatim, capped at 500
27
+ * chars, `rawTruncated:true` when longer) to `malformedToolCall.rawArguments`. Off by default,
28
+ * independent of `exposeErrorBody`. See `src/provider-toolcalls.js`.
25
29
  */
26
30
 
27
31
  class OllamaProvider {
@@ -41,6 +45,8 @@ class OllamaProvider {
41
45
  this.timeoutMs = options.timeoutMs;
42
46
  // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
43
47
  this.deadlineMs = options.deadlineMs;
48
+ // BA-27 follow-up: expose the raw malformed tool-call arguments string. Off by default.
49
+ this.exposeMalformedArgs = options.exposeMalformedArgs === true;
44
50
  }
45
51
 
46
52
  /**
@@ -100,7 +106,7 @@ class OllamaProvider {
100
106
  arguments: typeof tc.function.arguments === 'string'
101
107
  ? JSON.parse(tc.function.arguments)
102
108
  : tc.function.arguments,
103
- }));
109
+ }), { exposeMalformedArgs: this.exposeMalformedArgs });
104
110
 
105
111
  return {
106
112
  text: msg.content || '',
@@ -38,6 +38,14 @@ export type OpenAIOptions = {
38
38
  * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
39
39
  */
40
40
  legacyMaxTokens?: boolean | undefined;
41
+ /**
42
+ * - BA-27 follow-up: when a tool call's `function.arguments`
43
+ * JSON fails to parse, also attach the raw string (verbatim, capped at 500 chars, `rawTruncated:true`
44
+ * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
45
+ * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
46
+ * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
47
+ */
48
+ exposeMalformedArgs?: boolean | undefined;
41
49
  };
42
50
  /**
43
51
  * @typedef {object} OpenAIOptions
@@ -64,6 +72,11 @@ export type OpenAIOptions = {
64
72
  * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
65
73
  * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
66
74
  * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
75
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's `function.arguments`
76
+ * JSON fails to parse, also attach the raw string (verbatim, capped at 500 chars, `rawTruncated:true`
77
+ * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
78
+ * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
79
+ * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
67
80
  */
68
81
  export class OpenAIProvider {
69
82
  /**
@@ -81,6 +94,7 @@ export class OpenAIProvider {
81
94
  timeoutMs: number | undefined;
82
95
  deadlineMs: number | undefined;
83
96
  legacyMaxTokens: boolean;
97
+ exposeMalformedArgs: boolean;
84
98
  /**
85
99
  * Generate a response from the OpenAI API.
86
100
  * @param {Message[]} messages - Conversation messages.
@@ -72,6 +72,11 @@ function isLoopbackHost(hostname) {
72
72
  * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
73
73
  * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
74
74
  * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
75
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's `function.arguments`
76
+ * JSON fails to parse, also attach the raw string (verbatim, capped at 500 chars, `rawTruncated:true`
77
+ * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
78
+ * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
79
+ * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
75
80
  */
76
81
 
77
82
  class OpenAIProvider {
@@ -93,6 +98,8 @@ class OpenAIProvider {
93
98
  this.deadlineMs = options.deadlineMs;
94
99
  // BA-24 (fwdloop): use the legacy `max_tokens` key. Default false ⇒ `max_completion_tokens` (GPT-5-safe).
95
100
  this.legacyMaxTokens = options.legacyMaxTokens === true;
101
+ // BA-27 follow-up: expose the raw malformed tool-call arguments string. Off by default.
102
+ this.exposeMalformedArgs = options.exposeMalformedArgs === true;
96
103
  }
97
104
 
98
105
  /**
@@ -161,7 +168,7 @@ class OpenAIProvider {
161
168
  id: tc.id,
162
169
  name: tc.function.name,
163
170
  arguments: JSON.parse(tc.function.arguments),
164
- }));
171
+ }), { exposeMalformedArgs: this.exposeMalformedArgs });
165
172
 
166
173
  return {
167
174
  text: msg.content || '',
@@ -1,5 +1,4 @@
1
1
  export type ToolCall = import("../types").ToolCall;
2
- /** @typedef {import('../types').ToolCall} ToolCall */
3
2
  /**
4
3
  * BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
5
4
  * malformed arguments.
@@ -16,14 +15,27 @@ export type ToolCall = import("../types").ToolCall;
16
15
  * All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
17
16
  * running half a decomposed intent, so one bad call voids the whole round's calls.
18
17
  *
18
+ * `opts.exposeMalformedArgs` (default off, mirrors `exposeErrorBody`): when on, the marker also
19
+ * carries `rawArguments` — the raw `function.arguments` VERBATIM, capped at 500 chars (`rawTruncated:
20
+ * true` added only when it was longer). Non-string raw arguments (e.g. Ollama's well-formed object
21
+ * case reaching this catch some other way) are never exposed — only a string is a model-generated
22
+ * blob worth surfacing for debugging; an object was already structurally valid before whatever else
23
+ * threw. Off by default for the same reason as `exposeErrorBody`: an arbitrary model-generated string
24
+ * landing in a log/audit row unbounded is its own leak surface.
25
+ *
19
26
  * @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
20
27
  * @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
21
- * @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string } }}
28
+ * @param {{ exposeMalformedArgs?: boolean }} [opts]
29
+ * @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true } }}
22
30
  */
23
- export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall): {
31
+ export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall, opts?: {
32
+ exposeMalformedArgs?: boolean;
33
+ }): {
24
34
  toolCalls: ToolCall[];
25
35
  malformedToolCall?: {
26
36
  name: string | undefined;
27
37
  error: string;
38
+ rawArguments?: string;
39
+ rawTruncated?: true;
28
40
  };
29
41
  };
@@ -2,6 +2,11 @@
2
2
 
3
3
  /** @typedef {import('../types').ToolCall} ToolCall */
4
4
 
5
+ // Raw-arguments exposure cap. Matches the sibling opt-in error-body exposure pattern
6
+ // (`exposeErrorBody`) elsewhere in the providers: off by default, and even when on, bounded —
7
+ // an arbitrarily long model-generated string reaching a log/receipt unbounded is its own risk.
8
+ const RAW_ARGUMENTS_MAX_CHARS = 500;
9
+
5
10
  /**
6
11
  * BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
7
12
  * malformed arguments.
@@ -18,11 +23,20 @@
18
23
  * All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
19
24
  * running half a decomposed intent, so one bad call voids the whole round's calls.
20
25
  *
26
+ * `opts.exposeMalformedArgs` (default off, mirrors `exposeErrorBody`): when on, the marker also
27
+ * carries `rawArguments` — the raw `function.arguments` VERBATIM, capped at 500 chars (`rawTruncated:
28
+ * true` added only when it was longer). Non-string raw arguments (e.g. Ollama's well-formed object
29
+ * case reaching this catch some other way) are never exposed — only a string is a model-generated
30
+ * blob worth surfacing for debugging; an object was already structurally valid before whatever else
31
+ * threw. Off by default for the same reason as `exposeErrorBody`: an arbitrary model-generated string
32
+ * landing in a log/audit row unbounded is its own leak surface.
33
+ *
21
34
  * @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
22
35
  * @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
23
- * @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string } }}
36
+ * @param {{ exposeMalformedArgs?: boolean }} [opts]
37
+ * @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true } }}
24
38
  */
25
- function parseToolCalls(rawToolCalls, mapOne) {
39
+ function parseToolCalls(rawToolCalls, mapOne, opts = {}) {
26
40
  const raw = rawToolCalls || [];
27
41
  /** @type {ToolCall[]} */
28
42
  const toolCalls = [];
@@ -30,13 +44,19 @@ function parseToolCalls(rawToolCalls, mapOne) {
30
44
  try {
31
45
  toolCalls.push(mapOne(tc));
32
46
  } catch (e) {
33
- return {
34
- toolCalls: [],
35
- malformedToolCall: {
36
- name: tc && tc.function ? tc.function.name : undefined,
37
- error: e instanceof Error ? e.message : String(e),
38
- },
47
+ /** @type {{ name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true }} */
48
+ const malformedToolCall = {
49
+ name: tc && tc.function ? tc.function.name : undefined,
50
+ error: e instanceof Error ? e.message : String(e),
39
51
  };
52
+ if (opts.exposeMalformedArgs) {
53
+ const rawArgs = tc && tc.function ? tc.function.arguments : undefined;
54
+ if (typeof rawArgs === 'string') {
55
+ malformedToolCall.rawArguments = rawArgs.slice(0, RAW_ARGUMENTS_MAX_CHARS);
56
+ if (rawArgs.length > RAW_ARGUMENTS_MAX_CHARS) malformedToolCall.rawTruncated = true;
57
+ }
58
+ }
59
+ return { toolCalls: [], malformedToolCall };
40
60
  }
41
61
  }
42
62
  return { toolCalls };
package/types/index.d.ts CHANGED
@@ -142,8 +142,13 @@ export interface GenerateResult {
142
142
  * tool call" and retries; `usage`/`model` still flow so the round is metered. The JSON is NEVER
143
143
  * repaired. Absent on a clean round. OpenAI-compatible + Ollama string-arguments only; Anthropic
144
144
  * arrives pre-parsed and cannot hit this.
145
+ *
146
+ * `rawArguments`/`rawTruncated` (BA-27 follow-up) are present only when the provider was
147
+ * constructed with `exposeMalformedArgs: true` (default off, mirrors `exposeErrorBody`) AND the
148
+ * raw `function.arguments` was a string: the raw value verbatim, capped at 500 chars, with
149
+ * `rawTruncated: true` added only when it was longer.
145
150
  */
146
- malformedToolCall?: { name: string | undefined; error: string };
151
+ malformedToolCall?: { name: string | undefined; error: string; rawArguments?: string; rawTruncated?: true };
147
152
  /**
148
153
  * BA-7 — provider-native content blocks the normalized `{text, toolCalls}` shape cannot express
149
154
  * (Anthropic `thinking` / `redacted_thinking`), captured opaquely so the Loop can put them on the