@basein/runner 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -413,6 +413,8 @@ what to do when they register, so there is one switch and no second copy of it:
413
413
  |---|---|
414
414
  | `BIR_REPLAY` | `0` turns replay off and any other value on, over the project's stored switch (`bir replay on` / `off`; a first plain `bir install` stores off). Nothing below matters while it is off. |
415
415
  | `BIR_REPLAY_ALLOW_SERVERS` | Server keys bir may call unattended: **direct** execution, and a steered plan's recovery. Unset means the list `bir replay allow` stored for the project, else every wrapped server; narrowing it is the recommendation. |
416
+ | `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay — in the session's own project directory, and confined to it. Default `Read,Glob,Grep` (read-only). Add `Edit,Write,Bash` only deliberately: a runner-side step never reaches Claude Code's permission prompts. `none` turns it off, and a built-in step hands the turn back as before. |
417
+ | `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. |
416
418
  | `BIR_MIN_STEER_SIMILARITY` | Minimum match similarity to replay (default `0.92`, above the service's own `0.9` detection threshold). |
417
419
  | `BIR_DERIVE_API_KEY` | **Optional.** Working out what a new request is about — which fleet, which file, which date — is done for you by the service on its own key, as long as you are signed in. Set this (an Anthropic key) only to keep that reading on your machine, on your key. Signed out *and* unset, a scenario whose values change between requests is declined rather than replayed on stale ones. |
418
420
  | `ANTHROPIC_API_KEY` | Not used by the background recorder, which has Claude Code's environment and would bill your own key — unless `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand in a terminal uses that terminal's key. |
@@ -61,6 +61,18 @@
61
61
  * mode; every other tool call is asked as usual.
62
62
  * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
63
63
  * execution. Unset means every wrapped server
64
+ * BIR_BUILTIN_TOOLS comma-separated built-ins the runner may execute
65
+ * itself during a replay, in the session's own
66
+ * project directory and confined to it
67
+ * (replay/builtins.ts). Default Read,Glob,Grep —
68
+ * the read-only set. Add Edit,Write,Bash only
69
+ * deliberately: a runner-side step never reaches
70
+ * the host's permission prompts. `none` turns
71
+ * built-ins off, and then a built-in step hands
72
+ * the turn back, as before
73
+ * BIR_BASH_ALLOW command prefixes a runner-side Bash step may run
74
+ * (`*` for all). Unset, Bash never executes on the
75
+ * runner even when BIR_BUILTIN_TOOLS lists it
64
76
  * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
65
77
  * BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
66
78
  * on — is done by the service for a signed-in
@@ -61,6 +61,18 @@
61
61
  * mode; every other tool call is asked as usual.
62
62
  * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
63
63
  * execution. Unset means every wrapped server
64
+ * BIR_BUILTIN_TOOLS comma-separated built-ins the runner may execute
65
+ * itself during a replay, in the session's own
66
+ * project directory and confined to it
67
+ * (replay/builtins.ts). Default Read,Glob,Grep —
68
+ * the read-only set. Add Edit,Write,Bash only
69
+ * deliberately: a runner-side step never reaches
70
+ * the host's permission prompts. `none` turns
71
+ * built-ins off, and then a built-in step hands
72
+ * the turn back, as before
73
+ * BIR_BASH_ALLOW command prefixes a runner-side Bash step may run
74
+ * (`*` for all). Unset, Bash never executes on the
75
+ * runner even when BIR_BUILTIN_TOOLS lists it
64
76
  * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
65
77
  * BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
66
78
  * on — is done by the service for a signed-in
@@ -26,6 +26,7 @@ import { DISCOVERY_CHECK_MS, HEARTBEAT_MS, pidAlive, readDiscovery, removeDiscov
26
26
  import { DAEMON_CWD_ENV, DAEMON_ENV, DAEMON_LOG_ENV, DERIVE_KEY_ENV, DaemonLog, USE_API_KEY_ENV, clearPortProblem, compareVersions, probeHealth, projectEndpoint, rediscover, validPort, writePortProblem, } from "./daemon.js";
27
27
  import { configDir, normalizePath, supersededLogPath } from "./paths.js";
28
28
  import { RecorderKeeper } from "./recorder-keeper.js";
29
+ import { parseBashAllow, parseBuiltinTools } from "../replay/builtins.js";
29
30
  import { parseAllowList } from "../replay/coverage.js";
30
31
  import { sweepDeliveries } from "../replay/bash-channel.js";
31
32
  import { exemptLoopbackFromProxy, isWrapped, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
@@ -177,10 +178,18 @@ function buildReplayOptions(keeper, policy, key) {
177
178
  ? parseAllowList(policy.allowServers.join(","))
178
179
  : undefined;
179
180
  const minSimilarity = Number.isFinite(envMin) ? envMin : (policy?.minSimilarity ?? 0.92);
181
+ // Which built-ins the runner may execute itself (replay/builtins.ts):
182
+ // read-only by default, `BIR_BUILTIN_TOOLS` to change, `BIR_BASH_ALLOW`
183
+ // before a `Bash` step may run at all. Environment-only knobs, like
184
+ // `BIR_REPLAY_RECORDED_OUTPUTS`.
185
+ const builtinTools = parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
186
+ const bashAllow = parseBashAllow(process.env.BIR_BASH_ALLOW);
180
187
  const opts = {
181
188
  enabled,
182
189
  minSimilarity,
183
190
  allowServers,
191
+ builtinTools,
192
+ bashAllow,
184
193
  budgets: {
185
194
  // What a prompt may wait for its match (K5). Past it the prompt goes on
186
195
  // without one; a service slower than this must not cost every prompt.
@@ -237,6 +246,10 @@ function buildReplayOptions(keeper, policy, key) {
237
246
  logLine("replay.enabled", {
238
247
  minSimilarity: opts.minSimilarity,
239
248
  allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
249
+ // Which built-ins may run on the runner itself, and whether Bash may at
250
+ // all — loud for the same reason as the rest of this line.
251
+ builtins: builtinTools.size > 0 ? [...builtinTools].join(",") : "(none)",
252
+ bashAllow: builtinTools.has("Bash") ? (bashAllow ? bashAllow.join(",") : "(unset — Bash never runs here)") : undefined,
240
253
  // Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
241
254
  // startup because the third state is a replay that silently never runs.
242
255
  derive: key.source ??
@@ -1546,6 +1546,10 @@ export class ControlServer {
1546
1546
  return undefined;
1547
1547
  }
1548
1548
  state.sessionId = session.sessionId;
1549
+ // The project directory an allow-listed built-in step executes in, and
1550
+ // the jail it may not leave (replay/builtins.ts). The session's own cwd
1551
+ // from the hook payloads, never the daemon's.
1552
+ state.sessionCwd = session.cwd ?? this.opts.cwd;
1549
1553
  this.declineWithoutDeliveryTool(state);
1550
1554
  // A steer plan with a shell or file-write step can only be followed if the
1551
1555
  // directive shows its computed arguments (the pin guard holds those steps
@@ -1643,6 +1647,10 @@ export class ControlServer {
1643
1647
  return undefined;
1644
1648
  }
1645
1649
  state.sessionId = session.sessionId;
1650
+ // The project directory an allow-listed built-in step executes in, and
1651
+ // the jail it may not leave (replay/builtins.ts). The session's own cwd
1652
+ // from the hook payloads, never the daemon's.
1653
+ state.sessionCwd = session.cwd ?? this.opts.cwd;
1646
1654
  run.replay = state;
1647
1655
  run.replays.push(state);
1648
1656
  return undefined;
@@ -3014,6 +3022,10 @@ export class ControlServer {
3014
3022
  return undefined;
3015
3023
  }
3016
3024
  state.sessionId = session.sessionId;
3025
+ // The project directory an allow-listed built-in step executes in, and
3026
+ // the jail it may not leave (replay/builtins.ts). The session's own cwd
3027
+ // from the hook payloads, never the daemon's.
3028
+ state.sessionCwd = session.cwd ?? this.opts.cwd;
3017
3029
  this.declineWithoutDeliveryTool(state);
3018
3030
  if (!state.plan) {
3019
3031
  // A declined intent hit reports nothing: the turn's cost is not a
@@ -0,0 +1,120 @@
1
+ /**
2
+ * builtins — running a scenario's built-in steps (`Read`, `Glob`, `Grep`,
3
+ * `Edit`, `Write`, `Bash`) on the runner itself (docs/calculatedReplay.md §5).
4
+ *
5
+ * A built-in lives inside the host session, not behind any MCP server, so there
6
+ * is no proxy to relay it to: historically every built-in step threw `not_run`
7
+ * and the plan handed the task back to the model. This module is the other
8
+ * option: execute the step here, against the *session's own project directory*,
9
+ * so a chain that starts with a `Read` no longer costs a hand-over.
10
+ *
11
+ * THE RULES, because this bypasses the host's permission prompts entirely:
12
+ *
13
+ * - **Default deny beyond read-only.** Which built-ins may run at all is
14
+ * `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}), and its default is the
15
+ * read-only set (`Read,Glob,Grep`). `Edit`, `Write` and `Bash` are an
16
+ * explicit, per-environment opt-in — a recorded `rm` must never run
17
+ * because a prompt looked similar.
18
+ * - **Path jail.** Every path is resolved against, and confined to, the
19
+ * session's project directory ({@link jailed}), symlinks included. The
20
+ * daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
21
+ * project (§5.1 of the change plan).
22
+ * - **Bash runs nothing unless `BIR_BASH_ALLOW` says so.** A command must
23
+ * match one of its prefixes ({@link bashAllowed}); unset means `Bash`
24
+ * never executes here, even when allow-listed.
25
+ * - **Same failure contract as an MCP step.** A step that *cannot* run here
26
+ * throws {@link BuiltinNotRunnable} (→ `not_run`: the session can still do
27
+ * it, or a recorded output may stand in under
28
+ * `BIR_REPLAY_RECORDED_OUTPUTS=1`). One that ran out of time throws
29
+ * {@link BuiltinTimeout} (→ `uncertain`). Any other throw is a step that
30
+ * ran and failed (→ `failed`), never silently threaded as a success.
31
+ *
32
+ * Outputs mimic the **hook view** of each tool — Claude Code's `tool_response`
33
+ * as `PostToolUse` reports it — because that is exactly what the recorder
34
+ * stored for built-in steps (control/server.ts, `onToolPost`) and therefore
35
+ * what each step's `toolOutputLogic` was authored against. The shapes are a
36
+ * best effort and may drift from a future host's, the same caveat the
37
+ * hook-view threading fallback already carries ("shapes may differ").
38
+ */
39
+ /**
40
+ * The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
41
+ * unset: the read-only set. Reading a file the session itself would read
42
+ * without a prompt is the risk floor; anything that writes or runs is not.
43
+ */
44
+ export declare const DEFAULT_BUILTIN_TOOLS: readonly string[];
45
+ /** The built-ins {@link runBuiltin} actually implements. */
46
+ export declare const EXECUTABLE_BUILTINS: ReadonlySet<string>;
47
+ /**
48
+ * Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
49
+ * it is the whole list — `none` (or an empty value) turns built-ins off
50
+ * entirely. Unlike `BIR_REPLAY_ALLOW_SERVERS`, an explicit empty list is
51
+ * honoured: there is a non-empty default to fall back from, so emptiness here
52
+ * is a choice, not an accident.
53
+ */
54
+ export declare function parseBuiltinTools(value: string | undefined): ReadonlySet<string>;
55
+ /**
56
+ * Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
57
+ * everything. Undefined (unset or blank) means `Bash` never executes on the
58
+ * runner — the flag is the opt-in, exactly like the tool list itself.
59
+ */
60
+ export declare function parseBashAllow(value: string | undefined): readonly string[] | undefined;
61
+ /**
62
+ * Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
63
+ * mid-word must end the command's word too: `git` allows `git status` and
64
+ * `git`, never `gitk` — an operator who wrote `git` meant git, and the
65
+ * trimming in {@link parseBashAllow} must not widen that.
66
+ */
67
+ export declare function bashAllowed(command: string, allow: readonly string[] | undefined): boolean;
68
+ /**
69
+ * The step cannot run here — policy, not breakage: the tool is not
70
+ * implemented, its path leaves the project, `Bash` is not allowed, or the
71
+ * input is not even the right shape. Maps to `not_run`: nothing happened, the
72
+ * session may still do it, a recorded output may stand in.
73
+ */
74
+ export declare class BuiltinNotRunnable extends Error {
75
+ constructor(message: string);
76
+ }
77
+ /**
78
+ * The step started and was cut off by its clock. For `Bash` the command may
79
+ * have done part of its work; for a walk there is simply no result. Either
80
+ * way nothing may be asserted, which is `uncertain` — the same word an MCP
81
+ * step's timeout gets.
82
+ */
83
+ export declare class BuiltinTimeout extends Error {
84
+ constructor(message: string);
85
+ }
86
+ export interface BuiltinContext {
87
+ /**
88
+ * The session's project directory — the jail every path resolves against.
89
+ * The caller (controller.ts) refuses to execute at all without one; the
90
+ * daemon's own cwd is never a substitute.
91
+ */
92
+ cwd: string;
93
+ /** The step's clock, the same budget an MCP dispatch would get. */
94
+ timeoutMs?: number;
95
+ /** Parsed `BIR_BASH_ALLOW` ({@link parseBashAllow}). */
96
+ bashAllow?: readonly string[];
97
+ }
98
+ /**
99
+ * Execute one built-in step and return its hook-shaped result (an object; the
100
+ * caller serializes). Throws per the failure contract above.
101
+ */
102
+ export declare function runBuiltin(toolName: string, input: Record<string, unknown>, ctx: BuiltinContext): Promise<unknown>;
103
+ /**
104
+ * Which `bash` a runner-side step uses. On Windows the host's own Bash tool
105
+ * is Git Bash, which is rarely on the daemon's PATH — so the standard
106
+ * Git-for-Windows locations are preferred over PATH, where `bash.exe` may
107
+ * even be the WSL stub (a different filesystem view entirely). Resolved once.
108
+ */
109
+ export declare function bashExecutable(): string;
110
+ /**
111
+ * A glob pattern as a RegExp over `/`-separated relative paths: `**` crosses
112
+ * directories, `*` and `?` stay inside one, `{a,b}` alternates, `[…]` passes
113
+ * through as a character class. Matched against the whole relative path, as
114
+ * the host's Glob does — a bare `*.ts` is the top level only (Grep's filter
115
+ * widens a bare pattern itself, ripgrep-style). Enough for the patterns
116
+ * Claude Code writes; a pattern beyond it simply matches nothing it should
117
+ * not.
118
+ */
119
+ export declare function globToRegExp(pattern: string): RegExp;
120
+ //# sourceMappingURL=builtins.d.ts.map
@@ -0,0 +1,641 @@
1
+ /**
2
+ * builtins — running a scenario's built-in steps (`Read`, `Glob`, `Grep`,
3
+ * `Edit`, `Write`, `Bash`) on the runner itself (docs/calculatedReplay.md §5).
4
+ *
5
+ * A built-in lives inside the host session, not behind any MCP server, so there
6
+ * is no proxy to relay it to: historically every built-in step threw `not_run`
7
+ * and the plan handed the task back to the model. This module is the other
8
+ * option: execute the step here, against the *session's own project directory*,
9
+ * so a chain that starts with a `Read` no longer costs a hand-over.
10
+ *
11
+ * THE RULES, because this bypasses the host's permission prompts entirely:
12
+ *
13
+ * - **Default deny beyond read-only.** Which built-ins may run at all is
14
+ * `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}), and its default is the
15
+ * read-only set (`Read,Glob,Grep`). `Edit`, `Write` and `Bash` are an
16
+ * explicit, per-environment opt-in — a recorded `rm` must never run
17
+ * because a prompt looked similar.
18
+ * - **Path jail.** Every path is resolved against, and confined to, the
19
+ * session's project directory ({@link jailed}), symlinks included. The
20
+ * daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
21
+ * project (§5.1 of the change plan).
22
+ * - **Bash runs nothing unless `BIR_BASH_ALLOW` says so.** A command must
23
+ * match one of its prefixes ({@link bashAllowed}); unset means `Bash`
24
+ * never executes here, even when allow-listed.
25
+ * - **Same failure contract as an MCP step.** A step that *cannot* run here
26
+ * throws {@link BuiltinNotRunnable} (→ `not_run`: the session can still do
27
+ * it, or a recorded output may stand in under
28
+ * `BIR_REPLAY_RECORDED_OUTPUTS=1`). One that ran out of time throws
29
+ * {@link BuiltinTimeout} (→ `uncertain`). Any other throw is a step that
30
+ * ran and failed (→ `failed`), never silently threaded as a success.
31
+ *
32
+ * Outputs mimic the **hook view** of each tool — Claude Code's `tool_response`
33
+ * as `PostToolUse` reports it — because that is exactly what the recorder
34
+ * stored for built-in steps (control/server.ts, `onToolPost`) and therefore
35
+ * what each step's `toolOutputLogic` was authored against. The shapes are a
36
+ * best effort and may drift from a future host's, the same caveat the
37
+ * hook-view threading fallback already carries ("shapes may differ").
38
+ */
39
+ import { execFile } from "node:child_process";
40
+ import { existsSync } from "node:fs";
41
+ import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
42
+ import { dirname, join, resolve, sep } from "node:path";
43
+ // ── configuration ────────────────────────────────────────────────────────────
44
+ /**
45
+ * The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
46
+ * unset: the read-only set. Reading a file the session itself would read
47
+ * without a prompt is the risk floor; anything that writes or runs is not.
48
+ */
49
+ export const DEFAULT_BUILTIN_TOOLS = ["Read", "Glob", "Grep"];
50
+ /** The built-ins {@link runBuiltin} actually implements. */
51
+ export const EXECUTABLE_BUILTINS = new Set([
52
+ "Read",
53
+ "Glob",
54
+ "Grep",
55
+ "Edit",
56
+ "Write",
57
+ "Bash",
58
+ ]);
59
+ /** `read` → `Read`: the vocabulary is small enough to forgive casing. */
60
+ const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.toLowerCase(), name]));
61
+ /**
62
+ * Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
63
+ * it is the whole list — `none` (or an empty value) turns built-ins off
64
+ * entirely. Unlike `BIR_REPLAY_ALLOW_SERVERS`, an explicit empty list is
65
+ * honoured: there is a non-empty default to fall back from, so emptiness here
66
+ * is a choice, not an accident.
67
+ */
68
+ export function parseBuiltinTools(value) {
69
+ if (value === undefined)
70
+ return new Set(DEFAULT_BUILTIN_TOOLS);
71
+ const names = value
72
+ .split(",")
73
+ .map((s) => s.trim())
74
+ .filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()))
75
+ .map((s) => CANONICAL_BUILTIN.get(s.toLowerCase()) ?? s);
76
+ return new Set(names);
77
+ }
78
+ /**
79
+ * Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
80
+ * everything. Undefined (unset or blank) means `Bash` never executes on the
81
+ * runner — the flag is the opt-in, exactly like the tool list itself.
82
+ */
83
+ export function parseBashAllow(value) {
84
+ const prefixes = (value ?? "")
85
+ .split(",")
86
+ .map((s) => s.trim())
87
+ .filter(Boolean);
88
+ return prefixes.length > 0 ? prefixes : undefined;
89
+ }
90
+ /**
91
+ * Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
92
+ * mid-word must end the command's word too: `git` allows `git status` and
93
+ * `git`, never `gitk` — an operator who wrote `git` meant git, and the
94
+ * trimming in {@link parseBashAllow} must not widen that.
95
+ */
96
+ export function bashAllowed(command, allow) {
97
+ if (!allow)
98
+ return false;
99
+ const trimmed = command.trimStart();
100
+ const word = /[A-Za-z0-9_-]/;
101
+ return allow.some((prefix) => {
102
+ if (prefix === "*")
103
+ return true;
104
+ if (!trimmed.startsWith(prefix))
105
+ return false;
106
+ const next = trimmed.charAt(prefix.length);
107
+ return next === "" || !word.test(next) || !word.test(prefix.charAt(prefix.length - 1));
108
+ });
109
+ }
110
+ // ── the failure contract ─────────────────────────────────────────────────────
111
+ /**
112
+ * The step cannot run here — policy, not breakage: the tool is not
113
+ * implemented, its path leaves the project, `Bash` is not allowed, or the
114
+ * input is not even the right shape. Maps to `not_run`: nothing happened, the
115
+ * session may still do it, a recorded output may stand in.
116
+ */
117
+ export class BuiltinNotRunnable extends Error {
118
+ constructor(message) {
119
+ super(message);
120
+ this.name = "BuiltinNotRunnable";
121
+ }
122
+ }
123
+ /**
124
+ * The step started and was cut off by its clock. For `Bash` the command may
125
+ * have done part of its work; for a walk there is simply no result. Either
126
+ * way nothing may be asserted, which is `uncertain` — the same word an MCP
127
+ * step's timeout gets.
128
+ */
129
+ export class BuiltinTimeout extends Error {
130
+ constructor(message) {
131
+ super(message);
132
+ this.name = "BuiltinTimeout";
133
+ }
134
+ }
135
+ // ── size caps ────────────────────────────────────────────────────────────────
136
+ // The results flow through the same `serializeCapped(redact(...))` as every
137
+ // recorded payload, so these caps are about the daemon's memory and time, not
138
+ // about what reaches the service.
139
+ /** The most of one file a `Read`/`Edit` loads. */
140
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
141
+ /** `Read` line clamp, matching the host's own rendering limit. */
142
+ const MAX_LINE_CHARS = 2_000;
143
+ /** `Read` default line count, matching the host's. */
144
+ const DEFAULT_READ_LINES = 2_000;
145
+ /** Bash stdout/stderr cap. */
146
+ const MAX_BASH_BUFFER = 8 * 1024 * 1024;
147
+ /** The most files a Glob answers, oldest dropped first (mtime order). */
148
+ const MAX_GLOB_RESULTS = 100;
149
+ /** The most directory entries a walk visits before it reports `truncated`. */
150
+ const MAX_WALK_ENTRIES = 50_000;
151
+ /** The most files a Grep opens, and the most of each it reads. */
152
+ const MAX_GREP_FILES = 5_000;
153
+ const MAX_GREP_FILE_BYTES = 4 * 1024 * 1024;
154
+ /** The most matching lines a Grep's `content` mode returns. */
155
+ const DEFAULT_GREP_HEAD = 250;
156
+ // ── the executor ─────────────────────────────────────────────────────────────
157
+ /**
158
+ * Execute one built-in step and return its hook-shaped result (an object; the
159
+ * caller serializes). Throws per the failure contract above.
160
+ */
161
+ export async function runBuiltin(toolName, input, ctx) {
162
+ const deadline = ctx.timeoutMs !== undefined ? Date.now() + ctx.timeoutMs : undefined;
163
+ switch (toolName) {
164
+ case "Read":
165
+ return runRead(input, ctx);
166
+ case "Glob":
167
+ return runGlob(input, ctx, deadline);
168
+ case "Grep":
169
+ return runGrep(input, ctx, deadline);
170
+ case "Edit":
171
+ return runEdit(input, ctx);
172
+ case "Write":
173
+ return runWrite(input, ctx);
174
+ case "Bash":
175
+ return runBash(input, ctx);
176
+ default:
177
+ // Allow-listed but not implemented (`TodoWrite`, a tool this version
178
+ // does not know): the session runs it, or its recording stands in.
179
+ throw new BuiltinNotRunnable(`no runner-side implementation for ${toolName}`);
180
+ }
181
+ }
182
+ // ── the path jail ────────────────────────────────────────────────────────────
183
+ /** Case-blind on Windows, where `C:\Repo` and `c:\repo` are one directory. */
184
+ function samePath(a, b) {
185
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
186
+ }
187
+ function underRoot(root, abs) {
188
+ const prefix = root.endsWith(sep) ? root : root + sep;
189
+ return (samePath(abs, root) ||
190
+ (process.platform === "win32"
191
+ ? abs.toLowerCase().startsWith(prefix.toLowerCase())
192
+ : abs.startsWith(prefix)));
193
+ }
194
+ /**
195
+ * Resolve `p` against the session root and refuse anything outside it — first
196
+ * textually, then through `realpath` so a symlink inside the project cannot
197
+ * point the step at a file beyond it. The nearest existing ancestor is what
198
+ * gets resolved, so a `Write` to a new file is judged by its real directory.
199
+ */
200
+ async function jailed(root, p, what = "file_path") {
201
+ if (typeof p !== "string" || p.trim() === "") {
202
+ throw new BuiltinNotRunnable(`${what} is required`);
203
+ }
204
+ const r = resolve(root);
205
+ const abs = resolve(r, p);
206
+ const outside = () => {
207
+ throw new BuiltinNotRunnable(`${p} is outside the session's project directory, so bir does not touch it unattended`);
208
+ };
209
+ if (!underRoot(r, abs))
210
+ outside();
211
+ // The textual check passed; now the physical one. Walk up to what exists,
212
+ // realpath it, and require *that* to sit under the root's own realpath.
213
+ let probe = abs;
214
+ let real;
215
+ for (;;) {
216
+ try {
217
+ real = await realpath(probe);
218
+ break;
219
+ }
220
+ catch {
221
+ const parent = dirname(probe);
222
+ if (samePath(parent, probe))
223
+ break; // hit the filesystem root: nothing exists
224
+ probe = parent;
225
+ }
226
+ }
227
+ if (real !== undefined) {
228
+ let realRoot;
229
+ try {
230
+ realRoot = await realpath(r);
231
+ }
232
+ catch {
233
+ throw new BuiltinNotRunnable(`the session's project directory does not exist: ${root}`);
234
+ }
235
+ if (!underRoot(realRoot, real))
236
+ outside();
237
+ }
238
+ return abs;
239
+ }
240
+ // ── Read ─────────────────────────────────────────────────────────────────────
241
+ async function runRead(input, ctx) {
242
+ const filePath = await jailed(ctx.cwd, input.file_path);
243
+ const size = (await stat(filePath)).size;
244
+ if (size > MAX_FILE_BYTES) {
245
+ throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner reads`);
246
+ }
247
+ const text = await readFile(filePath, "utf8");
248
+ const all = text.split(/\r?\n/);
249
+ // `offset` is 1-based, as the host's Read documents it; 0 reads as 1.
250
+ const offset = Math.max(1, numberOr(input.offset, 1));
251
+ const limit = Math.max(1, numberOr(input.limit, DEFAULT_READ_LINES));
252
+ const lines = all
253
+ .slice(offset - 1, offset - 1 + limit)
254
+ .map((l) => (l.length > MAX_LINE_CHARS ? l.slice(0, MAX_LINE_CHARS) : l));
255
+ return {
256
+ type: "text",
257
+ file: {
258
+ filePath,
259
+ content: lines.join("\n"),
260
+ numLines: lines.length,
261
+ startLine: offset,
262
+ totalLines: all.length,
263
+ },
264
+ };
265
+ }
266
+ // ── Glob ─────────────────────────────────────────────────────────────────────
267
+ async function runGlob(input, ctx, deadline) {
268
+ const pattern = input.pattern;
269
+ if (typeof pattern !== "string" || pattern.trim() === "") {
270
+ throw new BuiltinNotRunnable("pattern is required");
271
+ }
272
+ const root = await jailed(ctx.cwd, typeof input.path === "string" ? input.path : ".", "path");
273
+ const startedAt = Date.now();
274
+ const re = globToRegExp(pattern);
275
+ const found = [];
276
+ const truncated = await walk(root, deadline, (rel, abs, mtime) => {
277
+ if (re.test(rel))
278
+ found.push({ path: abs, mtime });
279
+ return true;
280
+ });
281
+ found.sort((a, b) => b.mtime - a.mtime);
282
+ const filenames = found.slice(0, MAX_GLOB_RESULTS).map((f) => f.path);
283
+ return {
284
+ filenames,
285
+ durationMs: Date.now() - startedAt,
286
+ numFiles: filenames.length,
287
+ truncated: truncated || found.length > MAX_GLOB_RESULTS,
288
+ };
289
+ }
290
+ // ── Grep ─────────────────────────────────────────────────────────────────────
291
+ /** Grep inputs this executor honours; anything else is the session's job. */
292
+ const GREP_KNOWN_KEYS = new Set([
293
+ "pattern",
294
+ "path",
295
+ "glob",
296
+ "output_mode",
297
+ "-i",
298
+ "-n",
299
+ "head_limit",
300
+ ]);
301
+ async function runGrep(input, ctx, deadline) {
302
+ const pattern = input.pattern;
303
+ if (typeof pattern !== "string" || pattern === "") {
304
+ throw new BuiltinNotRunnable("pattern is required");
305
+ }
306
+ // Context lines, multiline mode, type filters, offsets: a stand-in that
307
+ // silently ignored them would thread the *wrong* output. Decline instead —
308
+ // the step still runs in the session, or its recording stands in.
309
+ const exotic = Object.keys(input).filter((k) => !GREP_KNOWN_KEYS.has(k) && input[k] !== undefined && input[k] !== null);
310
+ if (exotic.length > 0) {
311
+ throw new BuiltinNotRunnable(`this runner does not support Grep's ${exotic.join(", ")}`);
312
+ }
313
+ let re;
314
+ try {
315
+ re = new RegExp(pattern, input["-i"] === true ? "i" : "");
316
+ }
317
+ catch (err) {
318
+ throw new BuiltinNotRunnable(`the pattern is not a regular expression this runner can run: ${err.message}`);
319
+ }
320
+ const target = await jailed(ctx.cwd, typeof input.path === "string" ? input.path : ".", "path");
321
+ // ripgrep's `-g '*.ts'` matches the basename at any depth; only a pattern
322
+ // with a `/` in it speaks about the path.
323
+ const globRe = typeof input.glob === "string" && input.glob
324
+ ? globToRegExp(input.glob.includes("/") ? input.glob : `**/${input.glob}`)
325
+ : undefined;
326
+ const mode = typeof input.output_mode === "string" ? input.output_mode : "files_with_matches";
327
+ const headLimit = Math.max(1, numberOr(input.head_limit, DEFAULT_GREP_HEAD));
328
+ // One file, or a walk — the host's Grep takes both.
329
+ const files = [];
330
+ const st = await stat(target).catch(() => undefined);
331
+ let truncated = false;
332
+ if (st?.isFile()) {
333
+ files.push({ path: target, mtime: st.mtimeMs });
334
+ }
335
+ else {
336
+ truncated = await walk(target, deadline, (rel, abs, mtime) => {
337
+ if (!globRe || globRe.test(rel))
338
+ files.push({ path: abs, mtime });
339
+ return files.length < MAX_GREP_FILES;
340
+ });
341
+ }
342
+ const mtimes = new Map(files.map((f) => [f.path, f.mtime]));
343
+ const matches = [];
344
+ for (const f of files) {
345
+ if (deadline !== undefined && Date.now() > deadline) {
346
+ throw new BuiltinTimeout(`Grep ran out of time after ${files.length} candidate files`);
347
+ }
348
+ const lines = await matchFile(f.path, re, mode === "files_with_matches");
349
+ if (lines)
350
+ matches.push({ path: f.path, lines });
351
+ }
352
+ matches.sort((a, b) => (mtimes.get(b.path) ?? 0) - (mtimes.get(a.path) ?? 0));
353
+ if (mode === "count") {
354
+ const shown = matches.slice(0, headLimit);
355
+ return {
356
+ mode: "count",
357
+ content: shown.map((m) => `${m.path}:${m.lines.length}`).join("\n"),
358
+ numFiles: matches.length,
359
+ numMatches: matches.reduce((n, m) => n + m.lines.length, 0),
360
+ truncated: truncated || matches.length > shown.length,
361
+ };
362
+ }
363
+ if (mode === "content") {
364
+ const numbered = input["-n"] !== false;
365
+ const out = [];
366
+ for (const m of matches) {
367
+ for (const line of m.lines) {
368
+ out.push(numbered ? `${m.path}:${line.n}:${line.text}` : `${m.path}:${line.text}`);
369
+ if (out.length >= headLimit)
370
+ break;
371
+ }
372
+ if (out.length >= headLimit)
373
+ break;
374
+ }
375
+ return { mode: "content", content: out.join("\n"), numLines: out.length, truncated };
376
+ }
377
+ const filenames = matches.slice(0, headLimit).map((m) => m.path);
378
+ return {
379
+ mode: "files_with_matches",
380
+ filenames,
381
+ numFiles: filenames.length,
382
+ truncated: truncated || matches.length > filenames.length,
383
+ };
384
+ }
385
+ /** The matching lines of one file, or undefined when none (or not text). */
386
+ async function matchFile(path, re, firstOnly) {
387
+ let buf;
388
+ try {
389
+ const st = await stat(path);
390
+ if (!st.isFile() || st.size > MAX_GREP_FILE_BYTES)
391
+ return undefined;
392
+ buf = await readFile(path);
393
+ }
394
+ catch {
395
+ return undefined; // unreadable is not a match
396
+ }
397
+ // A NUL near the start is the classic binary sniff, same as grep's own.
398
+ if (buf.subarray(0, 4096).includes(0))
399
+ return undefined;
400
+ const lines = buf.toString("utf8").split(/\r?\n/);
401
+ const out = [];
402
+ for (let i = 0; i < lines.length; i += 1) {
403
+ if (re.test(lines[i])) {
404
+ out.push({ n: i + 1, text: lines[i].slice(0, MAX_LINE_CHARS) });
405
+ if (firstOnly)
406
+ return out;
407
+ }
408
+ }
409
+ return out.length > 0 ? out : undefined;
410
+ }
411
+ // ── Edit / Write ─────────────────────────────────────────────────────────────
412
+ async function runEdit(input, ctx) {
413
+ const filePath = await jailed(ctx.cwd, input.file_path);
414
+ const oldString = input.old_string;
415
+ const newString = input.new_string;
416
+ if (typeof oldString !== "string" || oldString === "") {
417
+ throw new BuiltinNotRunnable("old_string is required");
418
+ }
419
+ if (typeof newString !== "string")
420
+ throw new BuiltinNotRunnable("new_string is required");
421
+ const replaceAll = input.replace_all === true;
422
+ const size = (await stat(filePath)).size;
423
+ if (size > MAX_FILE_BYTES) {
424
+ throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner edits`);
425
+ }
426
+ const current = await readFile(filePath, "utf8");
427
+ const occurrences = current.split(oldString).length - 1;
428
+ if (occurrences === 0)
429
+ throw new Error(`old_string was not found in ${filePath}`);
430
+ if (!replaceAll && occurrences > 1) {
431
+ throw new Error(`old_string appears ${occurrences} times in ${filePath}; it must be unique`);
432
+ }
433
+ const next = replaceAll
434
+ ? current.split(oldString).join(newString)
435
+ : current.replace(oldString, newString);
436
+ await writeFile(filePath, next, "utf8");
437
+ return {
438
+ filePath,
439
+ oldString,
440
+ newString,
441
+ originalFile: current,
442
+ structuredPatch: [],
443
+ userModified: false,
444
+ replaceAll,
445
+ };
446
+ }
447
+ async function runWrite(input, ctx) {
448
+ const filePath = await jailed(ctx.cwd, input.file_path);
449
+ const content = input.content;
450
+ if (typeof content !== "string")
451
+ throw new BuiltinNotRunnable("content is required");
452
+ const existed = await stat(filePath).then((s) => s.isFile(), () => false);
453
+ // No mkdir: the host's Write does not invent directories, so neither does
454
+ // the stand-in — a missing parent is a divergence, not a wish.
455
+ await writeFile(filePath, content, "utf8");
456
+ return {
457
+ type: existed ? "update" : "create",
458
+ filePath,
459
+ content,
460
+ structuredPatch: [],
461
+ };
462
+ }
463
+ // ── Bash ─────────────────────────────────────────────────────────────────────
464
+ /**
465
+ * Which `bash` a runner-side step uses. On Windows the host's own Bash tool
466
+ * is Git Bash, which is rarely on the daemon's PATH — so the standard
467
+ * Git-for-Windows locations are preferred over PATH, where `bash.exe` may
468
+ * even be the WSL stub (a different filesystem view entirely). Resolved once.
469
+ */
470
+ export function bashExecutable() {
471
+ if (process.platform !== "win32")
472
+ return "bash";
473
+ for (const candidate of [
474
+ join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
475
+ join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
476
+ ]) {
477
+ if (existsSync(candidate))
478
+ return candidate;
479
+ }
480
+ return "bash.exe";
481
+ }
482
+ async function runBash(input, ctx) {
483
+ const command = input.command;
484
+ if (typeof command !== "string" || command.trim() === "") {
485
+ throw new BuiltinNotRunnable("command is required");
486
+ }
487
+ if (!ctx.bashAllow) {
488
+ throw new BuiltinNotRunnable("BIR_BASH_ALLOW is not set, so bir runs no Bash command unattended");
489
+ }
490
+ if (!bashAllowed(command, ctx.bashAllow)) {
491
+ throw new BuiltinNotRunnable("the command does not match BIR_BASH_ALLOW, so bir does not run it unattended");
492
+ }
493
+ const timeoutMs = ctx.timeoutMs ?? 120_000;
494
+ return new Promise((resolvePromise, rejectPromise) => {
495
+ execFile(bashExecutable(), ["-lc", command], {
496
+ cwd: resolve(ctx.cwd),
497
+ timeout: timeoutMs,
498
+ maxBuffer: MAX_BASH_BUFFER,
499
+ windowsHide: true,
500
+ }, (err, stdout, stderr) => {
501
+ if (err) {
502
+ const e = err;
503
+ if (e.code === "ENOENT") {
504
+ // No bash on this machine (a Windows runner without Git Bash):
505
+ // the step is the session's, exactly as before.
506
+ rejectPromise(new BuiltinNotRunnable("no `bash` on the runner's PATH"));
507
+ return;
508
+ }
509
+ if (e.killed || e.signal === "SIGTERM") {
510
+ rejectPromise(new BuiltinTimeout(`the command was stopped after ${timeoutMs}ms and may have done part of its work`));
511
+ return;
512
+ }
513
+ const code = typeof e.code === "number" ? e.code : 1;
514
+ const detail = String(stderr || stdout || "").trim().slice(0, 500);
515
+ rejectPromise(new Error(`the command exited with code ${code}${detail ? `: ${detail}` : ""}`));
516
+ return;
517
+ }
518
+ resolvePromise({
519
+ stdout: String(stdout ?? ""),
520
+ stderr: String(stderr ?? ""),
521
+ interrupted: false,
522
+ isImage: false,
523
+ });
524
+ });
525
+ });
526
+ }
527
+ // ── shared walking and matching ─────────────────────────────────────────────
528
+ /** Directories no scenario's step means to scan. */
529
+ const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
530
+ /**
531
+ * Walk `root`, calling `visit(relativePath, absolutePath, mtimeMs)` for every
532
+ * regular file. Never follows directory symlinks — the jail was checked at
533
+ * the root, and a link out of it must not re-open the door. Returns whether
534
+ * the walk was cut short (entry cap, deadline, or `visit` saying stop).
535
+ */
536
+ async function walk(root, deadline, visit) {
537
+ let seen = 0;
538
+ const queue = [root];
539
+ while (queue.length > 0) {
540
+ if (deadline !== undefined && Date.now() > deadline) {
541
+ throw new BuiltinTimeout(`the walk ran out of time after ${seen} entries`);
542
+ }
543
+ const dir = queue.shift();
544
+ let entries;
545
+ try {
546
+ entries = await readdir(dir, { withFileTypes: true });
547
+ }
548
+ catch {
549
+ continue; // unreadable directory: skip, as the host's own tools do
550
+ }
551
+ for (const entry of entries) {
552
+ seen += 1;
553
+ if (seen > MAX_WALK_ENTRIES)
554
+ return true;
555
+ const abs = resolve(dir, entry.name);
556
+ if (entry.isDirectory()) {
557
+ if (!SKIP_DIRS.has(entry.name))
558
+ queue.push(abs);
559
+ continue;
560
+ }
561
+ if (!entry.isFile())
562
+ continue; // symlinks and specials stay unfollowed
563
+ let mtime = 0;
564
+ try {
565
+ mtime = (await stat(abs)).mtimeMs;
566
+ }
567
+ catch {
568
+ continue;
569
+ }
570
+ const rel = abs.slice(resolve(root).length + 1).split(sep).join("/");
571
+ if (!visit(rel, abs, mtime))
572
+ return true;
573
+ }
574
+ }
575
+ return false;
576
+ }
577
+ /**
578
+ * A glob pattern as a RegExp over `/`-separated relative paths: `**` crosses
579
+ * directories, `*` and `?` stay inside one, `{a,b}` alternates, `[…]` passes
580
+ * through as a character class. Matched against the whole relative path, as
581
+ * the host's Glob does — a bare `*.ts` is the top level only (Grep's filter
582
+ * widens a bare pattern itself, ripgrep-style). Enough for the patterns
583
+ * Claude Code writes; a pattern beyond it simply matches nothing it should
584
+ * not.
585
+ */
586
+ export function globToRegExp(pattern) {
587
+ let p = pattern.replace(/\\/g, "/");
588
+ if (p.startsWith("./"))
589
+ p = p.slice(2);
590
+ let out = "";
591
+ for (let i = 0; i < p.length; i += 1) {
592
+ const c = p[i];
593
+ if (c === "*") {
594
+ if (p[i + 1] === "*") {
595
+ // `**/` may match nothing at all; a trailing `**` matches the rest.
596
+ out += p[i + 2] === "/" ? "(?:[^/]+/)*" : ".*";
597
+ i += p[i + 2] === "/" ? 2 : 1;
598
+ }
599
+ else {
600
+ out += "[^/]*";
601
+ }
602
+ }
603
+ else if (c === "?") {
604
+ out += "[^/]";
605
+ }
606
+ else if (c === "{") {
607
+ const end = p.indexOf("}", i);
608
+ if (end === -1) {
609
+ out += "\\{";
610
+ }
611
+ else {
612
+ out += `(?:${p
613
+ .slice(i + 1, end)
614
+ .split(",")
615
+ .map((s) => s.replace(/[.+^$()|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"))
616
+ .join("|")})`;
617
+ i = end;
618
+ }
619
+ }
620
+ else if (c === "[") {
621
+ const end = p.indexOf("]", i + (p[i + 1] === "!" || p[i + 1] === "^" ? 2 : 1) + 1);
622
+ if (end === -1) {
623
+ out += "\\[";
624
+ }
625
+ else {
626
+ const body = p.slice(i + 1, end).replace(/^!/, "^");
627
+ out += `[${body}]`;
628
+ i = end;
629
+ }
630
+ }
631
+ else {
632
+ out += /[.+^$()|\\]/.test(c) ? `\\${c}` : c;
633
+ }
634
+ }
635
+ return new RegExp(`^${out}$`);
636
+ }
637
+ function numberOr(value, fallback) {
638
+ const n = typeof value === "string" ? Number(value) : value;
639
+ return typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : fallback;
640
+ }
641
+ //# sourceMappingURL=builtins.js.map
@@ -196,6 +196,23 @@ export interface ReplayOptions {
196
196
  segmentArm?: boolean;
197
197
  /** `BIR_REPLAY_ALLOW_SERVERS`; undefined means every wrapped server. */
198
198
  allowServers?: ReadonlySet<string>;
199
+ /**
200
+ * `BIR_BUILTIN_TOOLS` — the built-ins the runner may execute itself, from
201
+ * the session's own project directory (replay/builtins.ts), or stand in
202
+ * for with a recorded output under `BIR_REPLAY_RECORDED_OUTPUTS=1`.
203
+ * Omitted, the environment decides; the default is the read-only set
204
+ * (`Read,Glob,Grep`). `Edit`, `Write` and `Bash` run only when listed
205
+ * explicitly — and `Bash` additionally only for commands matching
206
+ * {@link ReplayOptions.bashAllow}.
207
+ */
208
+ builtinTools?: ReadonlySet<string>;
209
+ /**
210
+ * `BIR_BASH_ALLOW` — command prefixes a runner-side `Bash` step may run
211
+ * (`*` for all). Undefined means `Bash` never executes here, even when
212
+ * allow-listed: runner-side execution bypasses the host's permission
213
+ * prompts, so the shell needs its own explicit opt-in.
214
+ */
215
+ bashAllow?: readonly string[];
199
216
  budgets?: Partial<ReplayBudgets>;
200
217
  /**
201
218
  * The service: the lazy source-run fetch, and the parameter derivation this
@@ -331,6 +348,14 @@ export interface ReplayState {
331
348
  * replay`, which has no session and takes the newest proxy.
332
349
  */
333
350
  sessionId?: string;
351
+ /**
352
+ * That session's project directory, set by the control server from the
353
+ * hook payloads — never the daemon's own cwd, which is `~/.baseinstrunner`.
354
+ * It is where an allow-listed built-in step executes and the jail it may
355
+ * not leave (replay/builtins.ts). Absent — `bir replay`, an older server —
356
+ * no built-in executes: the step is `not_run`, exactly as before.
357
+ */
358
+ sessionCwd?: string;
334
359
  /**
335
360
  * Which kind of row was handed out (segmented.md R-OUT-7). A `segment` is a
336
361
  * named sub-task of a recording rather than a whole task, and it is judged
@@ -447,6 +472,10 @@ export declare class ReplayController {
447
472
  private readonly opts;
448
473
  /** See {@link ReplayOptions.recordedFallback}. */
449
474
  private readonly recordedFallback;
475
+ /** See {@link ReplayOptions.builtinTools}. */
476
+ private readonly builtinTools;
477
+ /** See {@link ReplayOptions.bashAllow}. */
478
+ private readonly bashAllow?;
450
479
  /** Calls waiting for a plan's pins to change (see `awaitPins`). */
451
480
  private readonly pinWaiters;
452
481
  /**
@@ -762,6 +791,19 @@ export declare class ReplayController {
762
791
  * asked for by id and which the allowlist does not gate.
763
792
  */
764
793
  private executeStep;
794
+ /**
795
+ * Run one allow-listed built-in step on the runner, inside `cwd`.
796
+ *
797
+ * The result mimics the hook's view of the tool, because that is what was
798
+ * recorded for built-in steps and what their `toolOutputLogic` was authored
799
+ * against (control/server.ts, `onToolPost`) — and it is serialized and
800
+ * redacted through the exact pipeline every recorded payload takes, so
801
+ * `Bash` output cannot carry a secret the recording would have stripped.
802
+ *
803
+ * Failures keep {@link ExecuteStep}'s contract: policy (`not_run`), a step
804
+ * cut off by its clock (`uncertain`), a step that ran and broke (`failed`).
805
+ */
806
+ private executeBuiltin;
765
807
  /** Where recorded outputs come from, when they may stand in at all. */
766
808
  private recordedSource;
767
809
  /**
@@ -797,6 +839,11 @@ export declare class ReplayController {
797
839
  * wrapped server the allowlist excludes is still proxied, still reported, and
798
840
  * still recorded in the proxy's serialization. Threading such a step from the
799
841
  * hook's differently-shaped view would derive nothing at all.
842
+ *
843
+ * The built-in allow-list is deliberately NOT passed here for the same
844
+ * reason in the other direction: a *steered* built-in is never proxied,
845
+ * whatever `BIR_BUILTIN_TOOLS` says, so its output can only ever arrive
846
+ * from the hook.
800
847
  */
801
848
  private reachFor;
802
849
  /**
@@ -18,6 +18,7 @@ import { serializeCapped } from "../record/truncate.js";
18
18
  import { logDetail, logLine, errText } from "../util/log.js";
19
19
  import { serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
20
20
  import { bashCommandFor } from "./bash-channel.js";
21
+ import { BuiltinNotRunnable, BuiltinTimeout, parseBashAllow, parseBuiltinTools, runBuiltin, } from "./builtins.js";
21
22
  import { BASH_CHANNEL_MAX, MAX_REPLAY_REASON, truncate } from "./bundle.js";
22
23
  import { coverageOf, modeFor, reachOf } from "./coverage.js";
23
24
  import { deriveParameters, } from "./derive.js";
@@ -185,6 +186,10 @@ export class ReplayController {
185
186
  opts;
186
187
  /** See {@link ReplayOptions.recordedFallback}. */
187
188
  recordedFallback;
189
+ /** See {@link ReplayOptions.builtinTools}. */
190
+ builtinTools;
191
+ /** See {@link ReplayOptions.bashAllow}. */
192
+ bashAllow;
188
193
  /** Calls waiting for a plan's pins to change (see `awaitPins`). */
189
194
  pinWaiters = new WeakMap();
190
195
  /**
@@ -245,6 +250,8 @@ export class ReplayController {
245
250
  this.intentMatch = { ...DEFAULT_INTENT_MATCH, ...(opts.intentMatch ?? {}) };
246
251
  this.recordedFallback =
247
252
  opts.recordedFallback ?? process.env.BIR_REPLAY_RECORDED_OUTPUTS === "1";
253
+ this.builtinTools = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
254
+ this.bashAllow = opts.bashAllow ?? parseBashAllow(process.env.BIR_BASH_ALLOW);
248
255
  }
249
256
  /**
250
257
  * Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
@@ -423,8 +430,11 @@ export class ReplayController {
423
430
  : "known_bad_first_step");
424
431
  }
425
432
  const planned = stopAt === undefined ? entries : entries.slice(0, stopAt);
426
- // Gate 6 — tool coverage, over the steps that will actually run.
427
- const mode = modeFor(planned.map((e) => e.step), wrapped, this.opts.allowServers);
433
+ // Gate 6 — tool coverage, over the steps that will actually run. A
434
+ // built-in on `BIR_BUILTIN_TOOLS` counts as reachable: the runner
435
+ // executes it itself (replay/builtins.ts), so a chain that starts with a
436
+ // `Read` no longer forces steer mode.
437
+ const mode = modeFor(planned.map((e) => e.step), wrapped, this.opts.allowServers, this.builtinTools);
428
438
  if (mode === "none")
429
439
  return decline("no step is executable", "coverage");
430
440
  // Tool results feed derivation as context, and they are whatever the agent
@@ -598,7 +608,7 @@ export class ReplayController {
598
608
  unusable: flat.stopReason?.reason,
599
609
  // Which steps the service told this plan to stop in front of, and why.
600
610
  stops: stopLines(entries),
601
- coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers).join(","),
611
+ coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers, this.builtinTools).join(","),
602
612
  tools: entries.map((e) => e.step.toolName ?? "").join(","),
603
613
  });
604
614
  return state;
@@ -1565,7 +1575,7 @@ export class ReplayController {
1565
1575
  }
1566
1576
  if (flat.entries.length === 0)
1567
1577
  return { ok: false, why: "the chain has no runnable step" };
1568
- const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers);
1578
+ const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers, this.builtinTools);
1569
1579
  if (mode === "none")
1570
1580
  return { ok: false, why: "no step is executable" };
1571
1581
  const state = {
@@ -1636,7 +1646,7 @@ export class ReplayController {
1636
1646
  scenario: scenario.id,
1637
1647
  mode,
1638
1648
  steps: scenario.steps.length,
1639
- coverage: coverageOf(scenario.steps, wrapped, this.opts.allowServers).join(","),
1649
+ coverage: coverageOf(scenario.steps, wrapped, this.opts.allowServers, this.builtinTools).join(","),
1640
1650
  why: "bir replay — no match, an operator asked for it",
1641
1651
  });
1642
1652
  // An operator's run keeps its own contract (R-CALL-37): out of time, the
@@ -2066,6 +2076,16 @@ export class ReplayController {
2066
2076
  const name = step.toolName ?? "";
2067
2077
  const mcp = parseQualifiedName(name);
2068
2078
  if (!mcp) {
2079
+ // An allow-listed built-in runs *here*, against the session's own
2080
+ // project directory (replay/builtins.ts). Everything else about it —
2081
+ // no session cwd (`bir replay`, an older server), off the list, no
2082
+ // runner-side implementation, a path that leaves the project, `Bash`
2083
+ // without `BIR_BASH_ALLOW` — stays the old contract: `not_run`, so
2084
+ // the session can do the step itself or its recorded output can
2085
+ // stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
2086
+ if (this.builtinTools.has(name) && state.sessionCwd) {
2087
+ return this.executeBuiltin(name, input, state.sessionCwd, opts.deadline);
2088
+ }
2069
2089
  throw new StepRunError(`${name || "this step"} is a built-in tool, which only runs in the session`, "not_run");
2070
2090
  }
2071
2091
  // `BIR_REPLAY_ALLOW_SERVERS` is about exactly this: a call bir makes by
@@ -2098,6 +2118,38 @@ export class ReplayController {
2098
2118
  return serializeCapped(redact(result));
2099
2119
  };
2100
2120
  }
2121
+ /**
2122
+ * Run one allow-listed built-in step on the runner, inside `cwd`.
2123
+ *
2124
+ * The result mimics the hook's view of the tool, because that is what was
2125
+ * recorded for built-in steps and what their `toolOutputLogic` was authored
2126
+ * against (control/server.ts, `onToolPost`) — and it is serialized and
2127
+ * redacted through the exact pipeline every recorded payload takes, so
2128
+ * `Bash` output cannot carry a secret the recording would have stripped.
2129
+ *
2130
+ * Failures keep {@link ExecuteStep}'s contract: policy (`not_run`), a step
2131
+ * cut off by its clock (`uncertain`), a step that ran and broke (`failed`).
2132
+ */
2133
+ async executeBuiltin(name, input, cwd, deadline) {
2134
+ const left = deadline !== undefined ? deadline - Date.now() : Number.POSITIVE_INFINITY;
2135
+ const timeoutMs = Math.max(MIN_STEP_MS, Math.min(this.budgets.stepMs, left));
2136
+ // The audit line precedes the action, always (§13.2, mitigation 4).
2137
+ logDetail("replay.builtin", { tool: name, cwd });
2138
+ let result;
2139
+ try {
2140
+ result = await runBuiltin(name, input, { cwd, timeoutMs, bashAllow: this.bashAllow });
2141
+ }
2142
+ catch (err) {
2143
+ if (err instanceof BuiltinNotRunnable)
2144
+ throw new StepRunError(err.message, "not_run");
2145
+ if (err instanceof BuiltinTimeout)
2146
+ throw new StepRunError(err.message, "uncertain");
2147
+ // It ran and broke — a missing file, a non-unique `old_string`, a
2148
+ // non-zero exit. The step failed here, now; never threaded as success.
2149
+ throw new StepRunError(errText(err), "failed");
2150
+ }
2151
+ return serializeCapped(redact(result));
2152
+ }
2101
2153
  /** Where recorded outputs come from, when they may stand in at all. */
2102
2154
  recordedSource(state, force = false) {
2103
2155
  if (!force && !this.recordedFallback)
@@ -2230,6 +2282,11 @@ export class ReplayController {
2230
2282
  * wrapped server the allowlist excludes is still proxied, still reported, and
2231
2283
  * still recorded in the proxy's serialization. Threading such a step from the
2232
2284
  * hook's differently-shaped view would derive nothing at all.
2285
+ *
2286
+ * The built-in allow-list is deliberately NOT passed here for the same
2287
+ * reason in the other direction: a *steered* built-in is never proxied,
2288
+ * whatever `BIR_BUILTIN_TOOLS` says, so its output can only ever arrive
2289
+ * from the hook.
2233
2290
  */
2234
2291
  reachFor(state, step) {
2235
2292
  return reachOf(step.toolName ?? "", state.wrapped);
@@ -8,9 +8,17 @@
8
8
  * only happen inside the live session.
9
9
  *
10
10
  * `live` covers three genuinely different cases and treats them identically,
11
- * correctly: a built-in (`Bash`, `Read`, `Edit`, …), an MCP server the user chose
12
- * not to wrap, and `claude-in-chrome` — which is `scope: "dynamic"`, appears in
13
- * no config file, and therefore can never be wrapped at all.
11
+ * correctly: a built-in (`Bash`, `Read`, `Edit`, …) outside the built-in
12
+ * allow-list, an MCP server the user chose not to wrap, and `claude-in-chrome`
13
+ * — which is `scope: "dynamic"`, appears in no config file, and therefore can
14
+ * never be wrapped at all.
15
+ *
16
+ * A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, default `Read,Glob,
17
+ * Grep`) is `direct`: the runner executes it itself (replay/builtins.ts), or —
18
+ * under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves its recorded output. The
19
+ * callers that ask a *different* question, "will a proxy report this call"
20
+ * (`ReplayController.reachFor`), deliberately pass no allow-list: a built-in
21
+ * is never proxied, whoever executes it.
14
22
  */
15
23
  import type { SerializedScenarioStep } from "./types.js";
16
24
  /** Where a step's tool can actually be executed. */
@@ -19,18 +27,23 @@ export type StepReach = "direct" | "live";
19
27
  export type ReplayMode = "direct" | "steer" | "none";
20
28
  /**
21
29
  * `direct` when the tool is `mcp__<server>__<tool>` for a server this control
22
- * plane has a proxy for; `live` otherwise.
30
+ * plane has a proxy for, or a built-in on `builtins`; `live` otherwise.
23
31
  *
24
32
  * `allow` is the `BIR_REPLAY_ALLOW_SERVERS` allowlist (§13.2, mitigation 2): a
25
33
  * wrapped server outside it is deliberately demoted to `live`, so it can still be
26
34
  * steered inside the session — where the user's own permission rules apply — but
27
35
  * is never called unattended by us.
36
+ *
37
+ * `builtins` is the `BIR_BUILTIN_TOOLS` allow-list (replay/builtins.ts): the
38
+ * built-ins the runner may execute itself, or stand in for with a recorded
39
+ * output. Omitted means none — the old contract — which is what every caller
40
+ * that is not the mode decision wants.
28
41
  */
29
- export declare function reachOf(toolName: string, wrapped: ReadonlySet<string>, allow?: ReadonlySet<string>): StepReach;
42
+ export declare function reachOf(toolName: string, wrapped: ReadonlySet<string>, allow?: ReadonlySet<string>, builtins?: ReadonlySet<string>): StepReach;
30
43
  /** The mode for a whole scenario: `direct` only when *every* step is reachable. */
31
- export declare function modeFor(steps: readonly SerializedScenarioStep[], wrapped: ReadonlySet<string>, allow?: ReadonlySet<string>): ReplayMode;
44
+ export declare function modeFor(steps: readonly SerializedScenarioStep[], wrapped: ReadonlySet<string>, allow?: ReadonlySet<string>, builtins?: ReadonlySet<string>): ReplayMode;
32
45
  /** Per-step reach, in step order — what `plan.armed` logs and `bir doctor` shows. */
33
- export declare function coverageOf(steps: readonly SerializedScenarioStep[], wrapped: ReadonlySet<string>, allow?: ReadonlySet<string>): StepReach[];
46
+ export declare function coverageOf(steps: readonly SerializedScenarioStep[], wrapped: ReadonlySet<string>, allow?: ReadonlySet<string>, builtins?: ReadonlySet<string>): StepReach[];
34
47
  /**
35
48
  * Parse `BIR_REPLAY_ALLOW_SERVERS`. Unset (or empty) means *every wrapped
36
49
  * server*, which is why the return is `undefined` rather than an empty set — an
@@ -8,37 +8,54 @@
8
8
  * only happen inside the live session.
9
9
  *
10
10
  * `live` covers three genuinely different cases and treats them identically,
11
- * correctly: a built-in (`Bash`, `Read`, `Edit`, …), an MCP server the user chose
12
- * not to wrap, and `claude-in-chrome` — which is `scope: "dynamic"`, appears in
13
- * no config file, and therefore can never be wrapped at all.
11
+ * correctly: a built-in (`Bash`, `Read`, `Edit`, …) outside the built-in
12
+ * allow-list, an MCP server the user chose not to wrap, and `claude-in-chrome`
13
+ * — which is `scope: "dynamic"`, appears in no config file, and therefore can
14
+ * never be wrapped at all.
15
+ *
16
+ * A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, default `Read,Glob,
17
+ * Grep`) is `direct`: the runner executes it itself (replay/builtins.ts), or —
18
+ * under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves its recorded output. The
19
+ * callers that ask a *different* question, "will a proxy report this call"
20
+ * (`ReplayController.reachFor`), deliberately pass no allow-list: a built-in
21
+ * is never proxied, whoever executes it.
14
22
  */
15
23
  import { parseQualifiedName } from "../control/correlation.js";
16
24
  /**
17
25
  * `direct` when the tool is `mcp__<server>__<tool>` for a server this control
18
- * plane has a proxy for; `live` otherwise.
26
+ * plane has a proxy for, or a built-in on `builtins`; `live` otherwise.
19
27
  *
20
28
  * `allow` is the `BIR_REPLAY_ALLOW_SERVERS` allowlist (§13.2, mitigation 2): a
21
29
  * wrapped server outside it is deliberately demoted to `live`, so it can still be
22
30
  * steered inside the session — where the user's own permission rules apply — but
23
31
  * is never called unattended by us.
32
+ *
33
+ * `builtins` is the `BIR_BUILTIN_TOOLS` allow-list (replay/builtins.ts): the
34
+ * built-ins the runner may execute itself, or stand in for with a recorded
35
+ * output. Omitted means none — the old contract — which is what every caller
36
+ * that is not the mode decision wants.
24
37
  */
25
- export function reachOf(toolName, wrapped, allow) {
38
+ export function reachOf(toolName, wrapped, allow, builtins) {
26
39
  const mcp = parseQualifiedName(toolName);
27
- if (!mcp || !wrapped.has(mcp.serverName))
40
+ if (!mcp)
41
+ return builtins?.has(toolName) ? "direct" : "live";
42
+ if (!wrapped.has(mcp.serverName))
28
43
  return "live";
29
44
  if (allow && !allow.has(mcp.serverName))
30
45
  return "live";
31
46
  return "direct";
32
47
  }
33
48
  /** The mode for a whole scenario: `direct` only when *every* step is reachable. */
34
- export function modeFor(steps, wrapped, allow) {
49
+ export function modeFor(steps, wrapped, allow, builtins) {
35
50
  if (steps.length === 0)
36
51
  return "none";
37
- return steps.every((s) => reachOf(s.toolName ?? "", wrapped, allow) === "direct") ? "direct" : "steer";
52
+ return steps.every((s) => reachOf(s.toolName ?? "", wrapped, allow, builtins) === "direct")
53
+ ? "direct"
54
+ : "steer";
38
55
  }
39
56
  /** Per-step reach, in step order — what `plan.armed` logs and `bir doctor` shows. */
40
- export function coverageOf(steps, wrapped, allow) {
41
- return steps.map((s) => reachOf(s.toolName ?? "", wrapped, allow));
57
+ export function coverageOf(steps, wrapped, allow, builtins) {
58
+ return steps.map((s) => reachOf(s.toolName ?? "", wrapped, allow, builtins));
42
59
  }
43
60
  /**
44
61
  * Parse `BIR_REPLAY_ALLOW_SERVERS`. Unset (or empty) means *every wrapped
@@ -45,7 +45,7 @@ this design has a ladder whose cheapest rung is free — and the more of a stack
45
45
  |---|---|---|
46
46
  | Runs MCP tools itself | No (`replay.md` §12: *"rrepeat never executes tools — not even MCP"*) | **Yes**, on the connection it already owns |
47
47
  | Divergence fallback | A second Agent SDK session — the dominant cost | Direct `tools/call` through the proxies — **$0** |
48
- | Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | Same — only by steering the live session |
48
+ | Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | Read-only ones (`Read`, `Glob`, `Grep`) run on the runner itself, jailed to the project (`BIR_BUILTIN_TOOLS`); `Edit`/`Write`/`Bash` opt-in, else steered |
49
49
  | Needs `@anthropic-ai/*` at runtime | Yes | No (§16.3) |
50
50
 
51
51
  ---
@@ -243,15 +243,24 @@ export function modeFor(steps: SerializedScenarioStep[], wrapped: ReadonlySet<st
243
243
 
244
244
  | Every step | Mode | Where the tools run | Model spend |
245
245
  |---|---|---|---|
246
- | a wrapped MCP tool | **`direct`** (§6) | The proxies, on connections already open | one Haiku derivation + one turn that reads the results |
246
+ | a wrapped MCP tool, or a built-in on `BIR_BUILTIN_TOOLS` | **`direct`** (§6) | The proxies, on connections already open; allow-listed built-ins on the runner itself, jailed to the session's project directory (replay/builtins.ts) | one Haiku derivation + one turn that reads the results |
247
247
  | anything else, or mixed | **`steer`** (§7) | The live session, inputs pinned | one Haiku derivation + the live turn |
248
248
  | — | `none` | nowhere | declines at gate 5 |
249
249
 
250
250
  `live` covers three genuinely different cases and treats them identically, correctly: a built-in
251
- (`Bash`, `Read`, `Edit`, `Grep`, `Task`, `WebFetch`), an MCP server the user chose not to wrap, and
251
+ off the `BIR_BUILTIN_TOOLS` allow-list (by default everything but `Read`, `Glob`, `Grep` — so
252
+ `Bash`, `Edit`, `Write`, `Task`, `WebFetch`), an MCP server the user chose not to wrap, and
252
253
  `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config file, and therefore can never
253
254
  be wrapped (v1 design §1). None of them can be reached except through the session.
254
255
 
256
+ A built-in **on** the allow-list runs on the runner, in the session's own project directory and
257
+ confined to it — a path that resolves outside the project, symlinks included, is refused and the
258
+ step is `not_run`, the clean hand-over. `Bash` needs a second opt-in on top (`BIR_BASH_ALLOW`,
259
+ command prefixes), because a runner-side step never reaches the host's permission prompts. The
260
+ results mimic the hook's view of each tool, which is what the recorder stored for built-in steps
261
+ and what their `toolOutputLogic` was authored against; they pass through the same
262
+ `serializeCapped(redact(…))` as every recorded payload.
263
+
255
264
  A **mixed** scenario in `steer` mode still gets direct execution for its wrapped steps once it
256
265
  diverges (§8), so coverage is not all-or-nothing at the divergence boundary — only at the arm-time
257
266
  mode choice.
@@ -352,7 +352,9 @@ so there is exactly one switch and it cannot get out of step with itself.
352
352
  | `BIR_REPLAY_PLAN_BUDGET_MS` | `100000` | Whole-plan ceiling for direct execution; `BIR_REPLAY_BUDGET_MS`, where set, overrides it. Past about 110 000 a long run may be moved to the background by Claude Code. Derivation plus plan is held under 270 s |
353
353
  | `BIR_STEP_TIMEOUT_MS` | `60000` | Per-step ceiling for one direct `tools/call`, clipped to what the plan has left |
354
354
  | `BIR_LOGIC_TIMEOUT_MS` | `1000` | How long one scenario logic body may run before it is stopped. At most 10 000 |
355
- | `BIR_REPLAY_RECORDED_OUTPUTS` | *(unset)* | `1` lets a step that could not be sent (a built-in, no proxy, a server off the allowlist) stand on its recorded output instead of stopping the plan. Never for a timeout or an upstream error |
355
+ | `BIR_REPLAY_RECORDED_OUTPUTS` | *(unset)* | `1` lets a step that could not be sent (a built-in off `BIR_BUILTIN_TOOLS`, no proxy, a server off the allowlist) stand on its recorded output instead of stopping the plan. Never for a timeout or an upstream error |
356
+ | `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's own project directory and confined to it. A chain whose only non-MCP steps are on this list arms `direct`. Add `Edit,Write,Bash` only deliberately — a runner-side step never reaches the host's permission prompts. `none` restores the old behaviour: every built-in hands the turn back |
357
+ | `BIR_BASH_ALLOW` | *(unset)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it |
356
358
  | `BIR_INTENT_ARM` | *(unset)* | `1` lets a match found mid-turn arm a plan in the calling tool's place, which means waiting for it (`BIR_INTENT_ARM_BUDGET_MS`, 500); `0` never waits; unset follows `BIR_SEGMENT_ARM` |
357
359
  | `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
358
360
 
@@ -553,7 +555,7 @@ first prompt that states the task plainly.
553
555
  | `replay.vetoed … the model's first call was …, not run_scenario` | A direct plan, and the model reached for another tool first — usually because it has no `run_scenario` | As for `no_delivery_tool`. Nothing ran; the model did the task |
554
556
  | `replay.pin_declined` on most steered turns | The model's calls to the expected tool are not the step — another command, another file — so nothing is pinned; if it then moves on, `replay.pin_abandoned` retires the plan | Harmless for those calls, which ran as the model wrote them. Persistent declines on one step usually mean its arguments cannot be shown in the directive (they depend on an earlier step's output, or the command runs to several lines), and the model writes its own |
555
557
  | `why="no step is executable"` | None of the scenario's tools is a wrapped MCP server and none is a built-in reachable in this session | Wrap the servers the scenario uses (`bir install --server …`) and recalculate |
556
- | `plan.armed mode=steer` where you expected `direct` | At least one step is a built-in, an unwrapped MCP server, or `claude-in-chrome` | `bir status` shows what is wrapped. `claude-in-chrome` is `scope: "dynamic"` and can never be wrapped |
558
+ | `plan.armed mode=steer` where you expected `direct` | At least one step is a built-in off `BIR_BUILTIN_TOOLS` (default `Read,Glob,Grep` — so usually `Edit`, `Write` or `Bash`), an unwrapped MCP server, or `claude-in-chrome` | `bir status` shows what is wrapped; the `plan.armed` line's `coverage=` names the `live` step. Widen `BIR_BUILTIN_TOOLS` (and `BIR_BASH_ALLOW`) only deliberately. `claude-in-chrome` is `scope: "dynamic"` and can never be wrapped |
557
559
  | Model ignores the directive and diverges every turn | Steering is advisory — the model chooses; `bir` only pins arguments | Expected occasionally. Persistent divergence usually means the scenario's tools do not fit the live task; check `intent` |
558
560
  | A `replay.thread` line with no `emitted=` (under `BIR_VERBOSE=1`) | `toolOutputLogic` derived nothing — usually the output shape changed since recording | Dry-replay (§3.1). If the dry run emits and the live run does not, the tool's output shape has drifted; recalculate |
559
561
  | `replay.thread_fallback` on a wrapped step | The proxy did not report within the grace window | Check that proxy is alive (`bir doctor`). Repeats mean a slow or dying upstream |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/production.md CHANGED
@@ -992,6 +992,8 @@ Defaults were checked in `src/`.
992
992
  | `BIR_STEP_TIMEOUT_MS` | recorder | 60000 | One direct `tools/call`. |
993
993
  | `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
994
994
  | `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
995
+ | `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's project directory and confined to it. Add `Edit,Write,Bash` deliberately — runner-side steps bypass the host's permission prompts. `none` turns it off. |
996
+ | `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. |
995
997
  | `BIR_DERIVE_RECENT_RESULTS` | recorder | 5 | How many of this prompt's tool results derivation may read (`0`: none). Never for plans with a command sink. |
996
998
  | `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
997
999
  | `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |