@basein/runner 0.3.20 → 0.3.21
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 +6 -5
- package/dist/bin/bir-hooks.d.ts +10 -3
- package/dist/bin/bir-hooks.js +10 -3
- package/dist/bin/investigate.js +1 -1
- package/dist/control/recorder-main.js +15 -3
- package/dist/control/server.js +6 -6
- package/dist/replay/builtins.d.ts +45 -9
- package/dist/replay/builtins.js +131 -59
- package/dist/replay/controller.d.ts +15 -2
- package/dist/replay/controller.js +20 -10
- package/docs/calculatedReplay.md +13 -7
- package/docs/calculatedReplayGuide.md +8 -4
- package/package.json +1 -1
- package/production.md +2 -1
package/README.md
CHANGED
|
@@ -360,13 +360,13 @@ same command, the same file, the same kind of arguments); any other call runs as
|
|
|
360
360
|
written and is asked about as usual. A *directly executed* step never reaches the
|
|
361
361
|
permission system at all — it is dispatched to a `bir-proxy` and called on a
|
|
362
362
|
connection that is already open, or, for Claude Code's own `Read`, `Glob`,
|
|
363
|
-
`Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself
|
|
364
|
-
folder — and `run_scenario`, the call that starts the
|
|
363
|
+
`Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself from the project
|
|
364
|
+
folder, on any file the agent could reach — and `run_scenario`, the call that starts the
|
|
365
365
|
run, is answered `allow` while the recorder has announced the plan. In plan mode
|
|
366
366
|
nothing is announced and nothing is approved. Set `BIR_REPLAY_ALLOW_SERVERS` to
|
|
367
367
|
the servers you are content to have called unattended (it also covers a steered
|
|
368
|
-
plan's recovery), `BIR_BUILTIN_TOOLS` and `BIR_BASH_ALLOW` to
|
|
369
|
-
commands, and read [the guide's §5.1](docs/calculatedReplayGuide.md)
|
|
368
|
+
plan's recovery), `BIR_BUILTIN_TOOLS`, `BIR_PATH_ALLOW` and `BIR_BASH_ALLOW` to
|
|
369
|
+
the built-ins, folders and commands, and read [the guide's §5.1](docs/calculatedReplayGuide.md)
|
|
370
370
|
before enabling it anywhere that matters.
|
|
371
371
|
|
|
372
372
|
What it buys: on a scenario whose every step is a wrapped MCP tool or one of
|
|
@@ -489,8 +489,9 @@ what to do when they register, so there is one switch and no second copy of it:
|
|
|
489
489
|
|---|---|
|
|
490
490
|
| `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. |
|
|
491
491
|
| `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. |
|
|
492
|
-
| `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay —
|
|
492
|
+
| `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay — from the session's own project directory; which folders the file tools may touch is `BIR_PATH_ALLOW`. Default all six: `Read,Glob,Grep,Edit,Write,Bash` (up to 0.3.7 only the read-only `Read,Glob,Grep`). A runner-side step never reaches Claude Code's permission prompts, so leave off the list any tool you want the model to call itself. `none` turns it off, and built-in steps are steered in the session. |
|
|
493
493
|
| `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run. Unset means any command (`*`); a list such as `git,npm test` narrows it; `none` allows no command, and `Bash` steps are then steered in the session. A plan's Bash steps share a working directory, as Claude Code's do (variables and functions do not persist, also as in Claude Code). |
|
|
494
|
+
| `BIR_PATH_ALLOW` | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch. Unset means any folder (`*`), as the agent's own tools: a plan that reads the agent's memory file under `~/.claude` runs (from 0.3.21; up to 0.3.20 only the project directory). `project` keeps them inside the session's project directory; add folders after a comma, `~` for your home folder: `project,~/.claude`. `none` allows no folder, and file steps are then steered in the session. |
|
|
494
495
|
| `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. |
|
|
495
496
|
| `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. |
|
|
496
497
|
| `BIR_DERIVE_MODEL` | Derivation model, when you set a key of your own (default `claude-haiku-4-5-20251001`). |
|
package/dist/bin/bir-hooks.d.ts
CHANGED
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
* BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
|
|
63
63
|
* execution. Unset means every wrapped server
|
|
64
64
|
* BIR_BUILTIN_TOOLS comma-separated built-ins the runner may execute
|
|
65
|
-
* itself during a replay,
|
|
66
|
-
* project directory
|
|
67
|
-
*
|
|
65
|
+
* itself during a replay, from the session's own
|
|
66
|
+
* project directory (replay/builtins.ts; which
|
|
67
|
+
* folders: BIR_PATH_ALLOW). Default all six:
|
|
68
68
|
* Read,Glob,Grep,Edit,Write,Bash. A runner-side
|
|
69
69
|
* step never reaches the host's permission
|
|
70
70
|
* prompts; Read,Glob,Grep is the read-only set
|
|
@@ -73,6 +73,13 @@
|
|
|
73
73
|
* BIR_BASH_ALLOW command prefixes a runner-side Bash step may run
|
|
74
74
|
* (`*` for all). Unset means `*`; `none` means no
|
|
75
75
|
* command, and Bash steps are steered
|
|
76
|
+
* BIR_PATH_ALLOW folders a runner-side Read, Glob, Grep, Edit or
|
|
77
|
+
* Write may touch (`*` for any). Unset means `*`:
|
|
78
|
+
* any path, as the agent's own tools (up to 0.3.20
|
|
79
|
+
* the project directory alone). `project` is the
|
|
80
|
+
* session's project directory, `~` the home
|
|
81
|
+
* folder: `project,~/.claude`. `none` means no
|
|
82
|
+
* folder, and the file steps are steered
|
|
76
83
|
* BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
|
|
77
84
|
* on — is done by the service for a signed-in
|
|
78
85
|
* runner. Set this (an Anthropic key) only to keep
|
package/dist/bin/bir-hooks.js
CHANGED
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
* BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
|
|
63
63
|
* execution. Unset means every wrapped server
|
|
64
64
|
* BIR_BUILTIN_TOOLS comma-separated built-ins the runner may execute
|
|
65
|
-
* itself during a replay,
|
|
66
|
-
* project directory
|
|
67
|
-
*
|
|
65
|
+
* itself during a replay, from the session's own
|
|
66
|
+
* project directory (replay/builtins.ts; which
|
|
67
|
+
* folders: BIR_PATH_ALLOW). Default all six:
|
|
68
68
|
* Read,Glob,Grep,Edit,Write,Bash. A runner-side
|
|
69
69
|
* step never reaches the host's permission
|
|
70
70
|
* prompts; Read,Glob,Grep is the read-only set
|
|
@@ -73,6 +73,13 @@
|
|
|
73
73
|
* BIR_BASH_ALLOW command prefixes a runner-side Bash step may run
|
|
74
74
|
* (`*` for all). Unset means `*`; `none` means no
|
|
75
75
|
* command, and Bash steps are steered
|
|
76
|
+
* BIR_PATH_ALLOW folders a runner-side Read, Glob, Grep, Edit or
|
|
77
|
+
* Write may touch (`*` for any). Unset means `*`:
|
|
78
|
+
* any path, as the agent's own tools (up to 0.3.20
|
|
79
|
+
* the project directory alone). `project` is the
|
|
80
|
+
* session's project directory, `~` the home
|
|
81
|
+
* folder: `project,~/.claude`. `none` means no
|
|
82
|
+
* folder, and the file steps are steered
|
|
76
83
|
* BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
|
|
77
84
|
* on — is done by the service for a signed-in
|
|
78
85
|
* runner. Set this (an Anthropic key) only to keep
|
package/dist/bin/investigate.js
CHANGED
|
@@ -413,7 +413,7 @@ export function localFindings(turn) {
|
|
|
413
413
|
const elsewhere = names.filter((n) => !EXECUTABLE_BUILTINS.has(n));
|
|
414
414
|
const fixes = [];
|
|
415
415
|
if (offList.length > 0) {
|
|
416
|
-
fixes.push(`The runner runs ${offList.join(", ")} itself when ${offList.length === 1 ? "it is" : "they are"} on \`BIR_BUILTIN_TOOLS\` (unset means all six, from runner 0.3.8) and, for Bash, \`BIR_BASH_ALLOW\` is not \`none
|
|
416
|
+
fixes.push(`The runner runs ${offList.join(", ")} itself when ${offList.length === 1 ? "it is" : "they are"} on \`BIR_BUILTIN_TOOLS\` (unset means all six, from runner 0.3.8) and, for Bash, \`BIR_BASH_ALLOW\` is not \`none\` (for the file tools, \`BIR_PATH_ALLOW\`): fix those where the recorder starts, then \`bir up --restart\`.`);
|
|
417
417
|
}
|
|
418
418
|
if (elsewhere.length > 0) {
|
|
419
419
|
fixes.push(`Put the data ${elsewhere.join(", ")} ${elsewhere.length === 1 ? "reaches" : "reach"} behind an MCP server, wrap it (\`bir install --server <name>\`), and record the prompt again so its steps are MCP calls.`);
|
|
@@ -26,7 +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
|
+
import { FILE_BUILTINS, parseBashAllow, parseBuiltinTools, parsePathAllow } from "../replay/builtins.js";
|
|
30
30
|
import { parseAllowList } from "../replay/coverage.js";
|
|
31
31
|
import { sweepDeliveries } from "../replay/bash-channel.js";
|
|
32
32
|
import { exemptLoopbackFromProxy, isWrapped, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
|
|
@@ -197,15 +197,18 @@ function buildReplayOptions(keeper, policy, key) {
|
|
|
197
197
|
: undefined;
|
|
198
198
|
// Which built-ins the runner may execute itself (replay/builtins.ts):
|
|
199
199
|
// all six by default, `BIR_BUILTIN_TOOLS` to narrow them, `BIR_BASH_ALLOW`
|
|
200
|
-
// to narrow the commands a `Bash` step may run
|
|
201
|
-
//
|
|
200
|
+
// to narrow the commands a `Bash` step may run, `BIR_PATH_ALLOW` the
|
|
201
|
+
// folders a file step may touch (unset: any, as the agent's own tools).
|
|
202
|
+
// Environment-only knobs, like `BIR_REPLAY_RECORDED_OUTPUTS`.
|
|
202
203
|
const builtinTools = parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
|
|
203
204
|
const bashAllow = parseBashAllow(process.env.BIR_BASH_ALLOW);
|
|
205
|
+
const pathAllow = parsePathAllow(process.env.BIR_PATH_ALLOW);
|
|
204
206
|
const opts = {
|
|
205
207
|
enabled,
|
|
206
208
|
allowServers,
|
|
207
209
|
builtinTools,
|
|
208
210
|
bashAllow,
|
|
211
|
+
pathAllow,
|
|
209
212
|
budgets: {
|
|
210
213
|
// What a prompt may wait for its match (K5). Past it the prompt goes on
|
|
211
214
|
// without one; a service slower than this must not cost every prompt.
|
|
@@ -263,6 +266,15 @@ function buildReplayOptions(keeper, policy, key) {
|
|
|
263
266
|
// all — loud for the same reason as the rest of this line.
|
|
264
267
|
builtins: builtinTools.size > 0 ? [...builtinTools].join(",") : "(none)",
|
|
265
268
|
bashAllow: builtinTools.has("Bash") ? (bashAllow.length > 0 ? bashAllow.join(",") : "(none — Bash steps are steered)") : undefined,
|
|
269
|
+
// Where the file tools may reach: anywhere, as the agent's own, unless
|
|
270
|
+
// BIR_PATH_ALLOW names folders (up to 0.3.20 the project alone).
|
|
271
|
+
pathAllow: [...builtinTools].some((name) => FILE_BUILTINS.has(name))
|
|
272
|
+
? pathAllow.includes("*")
|
|
273
|
+
? "(any folder)"
|
|
274
|
+
: pathAllow.length > 0
|
|
275
|
+
? pathAllow.join(",")
|
|
276
|
+
: "(none — file steps are steered)"
|
|
277
|
+
: undefined,
|
|
266
278
|
// Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
|
|
267
279
|
// startup because the third state is a replay that silently never runs.
|
|
268
280
|
derive: key.source ??
|
package/dist/control/server.js
CHANGED
|
@@ -2212,8 +2212,8 @@ export class ControlServer {
|
|
|
2212
2212
|
}
|
|
2213
2213
|
state.sessionId = session.sessionId;
|
|
2214
2214
|
// The project directory an allow-listed built-in step executes in, and
|
|
2215
|
-
//
|
|
2216
|
-
// from the hook payloads, never the daemon's.
|
|
2215
|
+
// what its relative paths resolve against (replay/builtins.ts). The
|
|
2216
|
+
// session's own cwd from the hook payloads, never the daemon's.
|
|
2217
2217
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
2218
2218
|
// The service offered to record this turn as the recording's newest run:
|
|
2219
2219
|
// should the agent redo the task after the plan, the plan's own calls go
|
|
@@ -2372,8 +2372,8 @@ export class ControlServer {
|
|
|
2372
2372
|
}
|
|
2373
2373
|
state.sessionId = session.sessionId;
|
|
2374
2374
|
// The project directory an allow-listed built-in step executes in, and
|
|
2375
|
-
//
|
|
2376
|
-
// from the hook payloads, never the daemon's.
|
|
2375
|
+
// what its relative paths resolve against (replay/builtins.ts). The
|
|
2376
|
+
// session's own cwd from the hook payloads, never the daemon's.
|
|
2377
2377
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
2378
2378
|
run.replay = state;
|
|
2379
2379
|
run.replays.push(state);
|
|
@@ -4715,8 +4715,8 @@ export class ControlServer {
|
|
|
4715
4715
|
}
|
|
4716
4716
|
state.sessionId = session.sessionId;
|
|
4717
4717
|
// The project directory an allow-listed built-in step executes in, and
|
|
4718
|
-
//
|
|
4719
|
-
// from the hook payloads, never the daemon's.
|
|
4718
|
+
// what its relative paths resolve against (replay/builtins.ts). The
|
|
4719
|
+
// session's own cwd from the hook payloads, never the daemon's.
|
|
4720
4720
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
4721
4721
|
// The request this plan serves, for a note at its decision (SB-18): the
|
|
4722
4722
|
// turn's prompt. `arm` was given this step's intent instead.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* A built-in lives inside the host session, not behind any MCP server, so there
|
|
6
6
|
* is no proxy to relay it to: historically every built-in step threw `not_run`
|
|
7
7
|
* and the plan handed the task back to the model. This module is the other
|
|
8
|
-
* option: execute the step here,
|
|
8
|
+
* option: execute the step here, from the *session's own project directory*,
|
|
9
9
|
* so a chain that starts with a `Read` no longer costs a hand-over.
|
|
10
10
|
*
|
|
11
11
|
* THE RULES, because this bypasses the host's permission prompts entirely:
|
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
* already has — its pinned call is answered `allow` too. What running it
|
|
18
18
|
* here changes is that no model sees the call before it runs, so an
|
|
19
19
|
* operator who wants one in the loop takes the tool off the list.
|
|
20
|
-
* - **
|
|
21
|
-
* session's project directory
|
|
22
|
-
* daemon's own cwd is never used: it is
|
|
23
|
-
* project (§5.1 of the change plan).
|
|
20
|
+
* - **Any path the agent could reach, unless `BIR_PATH_ALLOW` narrows it.**
|
|
21
|
+
* A relative path resolves against the session's project directory, as
|
|
22
|
+
* the host's tools resolve it; the daemon's own cwd is never used: it is
|
|
23
|
+
* `~/.baseinstrunner`, not the project (§5.1 of the change plan). An
|
|
24
|
+
* absolute path is taken as it is, inside the project or not: the agent's
|
|
25
|
+
* own tools reach any folder its session allows — the recorded call was
|
|
26
|
+
* one of them — and a runner-side `Bash` reaches any folder already. Up
|
|
27
|
+
* to 0.3.20 every path was confined to the project directory;
|
|
28
|
+
* `BIR_PATH_ALLOW=project` brings that back, and a list of folders
|
|
29
|
+
* ({@link parsePathAllow}) confines the file tools to those, symlinks
|
|
30
|
+
* included ({@link allowedPath}).
|
|
24
31
|
* - **Bash runs any command unless `BIR_BASH_ALLOW` narrows it.** Set, a
|
|
25
32
|
* command must match one of its prefixes ({@link bashAllowed}); `none`
|
|
26
33
|
* allows no command, and then `Bash` counts as off the list, so its steps
|
|
@@ -48,6 +55,12 @@ export declare const EXECUTABLE_BUILTINS: ReadonlySet<string>;
|
|
|
48
55
|
* set (`Read,Glob,Grep`).
|
|
49
56
|
*/
|
|
50
57
|
export declare const DEFAULT_BUILTIN_TOOLS: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* The built-ins that reach files by a path, the ones `BIR_PATH_ALLOW`
|
|
60
|
+
* governs. `Bash` is not among them: what a command touches is
|
|
61
|
+
* `BIR_BASH_ALLOW`'s business.
|
|
62
|
+
*/
|
|
63
|
+
export declare const FILE_BUILTINS: ReadonlySet<string>;
|
|
51
64
|
/**
|
|
52
65
|
* Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
|
|
53
66
|
* it is the whole list — `none` (or an empty value) turns built-ins off
|
|
@@ -63,6 +76,19 @@ export declare function parseBuiltinTools(value: string | undefined): ReadonlySe
|
|
|
63
76
|
* value) allows no command.
|
|
64
77
|
*/
|
|
65
78
|
export declare function parseBashAllow(value: string | undefined): readonly string[];
|
|
79
|
+
/**
|
|
80
|
+
* Parse `BIR_PATH_ALLOW`: the folders a runner-side `Read`, `Glob`, `Grep`,
|
|
81
|
+
* `Edit` or `Write` may touch, comma-separated, `*` for anywhere. Unset means
|
|
82
|
+
* anywhere, as the agent's own tools reach any folder its session allows (up
|
|
83
|
+
* to 0.3.20 it was the project directory alone). In a list, `project` is the
|
|
84
|
+
* session's project directory, `~` the home folder (`~/.claude` a folder in
|
|
85
|
+
* it), and any other relative folder resolves against the project directory.
|
|
86
|
+
* Set, it is the whole list, as `BIR_BASH_ALLOW` is: `none` (or an empty
|
|
87
|
+
* value) allows no folder, and the file tools then count as off
|
|
88
|
+
* `BIR_BUILTIN_TOOLS`, so their steps are steered in the session
|
|
89
|
+
* (controller.ts).
|
|
90
|
+
*/
|
|
91
|
+
export declare function parsePathAllow(value: string | undefined): readonly string[];
|
|
66
92
|
/**
|
|
67
93
|
* Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
|
|
68
94
|
* mid-word must end the command's word too: `git` allows `git status` and
|
|
@@ -72,8 +98,9 @@ export declare function parseBashAllow(value: string | undefined): readonly stri
|
|
|
72
98
|
export declare function bashAllowed(command: string, allow: readonly string[] | undefined): boolean;
|
|
73
99
|
/**
|
|
74
100
|
* The step cannot run here — policy, not breakage: the tool is not
|
|
75
|
-
* implemented, its path
|
|
76
|
-
*
|
|
101
|
+
* implemented, its path is outside the folders `BIR_PATH_ALLOW` names, the
|
|
102
|
+
* command is not one `BIR_BASH_ALLOW` allows, or the input is not even the
|
|
103
|
+
* right shape. Maps to `not_run`: nothing happened, the
|
|
77
104
|
* session may still do it, a recorded output may stand in.
|
|
78
105
|
*/
|
|
79
106
|
export declare class BuiltinNotRunnable extends Error {
|
|
@@ -90,8 +117,9 @@ export declare class BuiltinTimeout extends Error {
|
|
|
90
117
|
}
|
|
91
118
|
export interface BuiltinContext {
|
|
92
119
|
/**
|
|
93
|
-
* The session's project directory —
|
|
94
|
-
*
|
|
120
|
+
* The session's project directory — what a relative path resolves
|
|
121
|
+
* against, and the folder `project` names in {@link pathAllow}. The
|
|
122
|
+
* caller (controller.ts) refuses to execute at all without one; the
|
|
95
123
|
* daemon's own cwd is never a substitute.
|
|
96
124
|
*/
|
|
97
125
|
cwd: string;
|
|
@@ -99,6 +127,14 @@ export interface BuiltinContext {
|
|
|
99
127
|
timeoutMs?: number;
|
|
100
128
|
/** Parsed `BIR_BASH_ALLOW` ({@link parseBashAllow}). Absent or empty, no command runs. */
|
|
101
129
|
bashAllow?: readonly string[];
|
|
130
|
+
/**
|
|
131
|
+
* Parsed `BIR_PATH_ALLOW` ({@link parsePathAllow}): the folders a file
|
|
132
|
+
* step may touch, `*` for any. Absent means the project directory alone —
|
|
133
|
+
* a caller that says nothing gets the narrow answer, as with
|
|
134
|
+
* {@link bashAllow}; the controller always passes the environment's, which
|
|
135
|
+
* is `*` when the variable is unset. Empty, no file step runs.
|
|
136
|
+
*/
|
|
137
|
+
pathAllow?: readonly string[];
|
|
102
138
|
/**
|
|
103
139
|
* Where this plan's *previous* `Bash` step left its shell — the host's own
|
|
104
140
|
* semantics, which a recorded chain was written against: the working
|
package/dist/replay/builtins.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* A built-in lives inside the host session, not behind any MCP server, so there
|
|
6
6
|
* is no proxy to relay it to: historically every built-in step threw `not_run`
|
|
7
7
|
* and the plan handed the task back to the model. This module is the other
|
|
8
|
-
* option: execute the step here,
|
|
8
|
+
* option: execute the step here, from the *session's own project directory*,
|
|
9
9
|
* so a chain that starts with a `Read` no longer costs a hand-over.
|
|
10
10
|
*
|
|
11
11
|
* THE RULES, because this bypasses the host's permission prompts entirely:
|
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
* already has — its pinned call is answered `allow` too. What running it
|
|
18
18
|
* here changes is that no model sees the call before it runs, so an
|
|
19
19
|
* operator who wants one in the loop takes the tool off the list.
|
|
20
|
-
* - **
|
|
21
|
-
* session's project directory
|
|
22
|
-
* daemon's own cwd is never used: it is
|
|
23
|
-
* project (§5.1 of the change plan).
|
|
20
|
+
* - **Any path the agent could reach, unless `BIR_PATH_ALLOW` narrows it.**
|
|
21
|
+
* A relative path resolves against the session's project directory, as
|
|
22
|
+
* the host's tools resolve it; the daemon's own cwd is never used: it is
|
|
23
|
+
* `~/.baseinstrunner`, not the project (§5.1 of the change plan). An
|
|
24
|
+
* absolute path is taken as it is, inside the project or not: the agent's
|
|
25
|
+
* own tools reach any folder its session allows — the recorded call was
|
|
26
|
+
* one of them — and a runner-side `Bash` reaches any folder already. Up
|
|
27
|
+
* to 0.3.20 every path was confined to the project directory;
|
|
28
|
+
* `BIR_PATH_ALLOW=project` brings that back, and a list of folders
|
|
29
|
+
* ({@link parsePathAllow}) confines the file tools to those, symlinks
|
|
30
|
+
* included ({@link allowedPath}).
|
|
24
31
|
* - **Bash runs any command unless `BIR_BASH_ALLOW` narrows it.** Set, a
|
|
25
32
|
* command must match one of its prefixes ({@link bashAllowed}); `none`
|
|
26
33
|
* allows no command, and then `Bash` counts as off the list, so its steps
|
|
@@ -43,7 +50,8 @@ import { execFile } from "node:child_process";
|
|
|
43
50
|
import { randomBytes } from "node:crypto";
|
|
44
51
|
import { existsSync } from "node:fs";
|
|
45
52
|
import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
46
|
-
import {
|
|
53
|
+
import { homedir } from "node:os";
|
|
54
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
47
55
|
// ── configuration ────────────────────────────────────────────────────────────
|
|
48
56
|
/** The built-ins {@link runBuiltin} actually implements. */
|
|
49
57
|
export const EXECUTABLE_BUILTINS = new Set([
|
|
@@ -61,8 +69,24 @@ export const EXECUTABLE_BUILTINS = new Set([
|
|
|
61
69
|
* set (`Read,Glob,Grep`).
|
|
62
70
|
*/
|
|
63
71
|
export const DEFAULT_BUILTIN_TOOLS = [...EXECUTABLE_BUILTINS];
|
|
72
|
+
/**
|
|
73
|
+
* The built-ins that reach files by a path, the ones `BIR_PATH_ALLOW`
|
|
74
|
+
* governs. `Bash` is not among them: what a command touches is
|
|
75
|
+
* `BIR_BASH_ALLOW`'s business.
|
|
76
|
+
*/
|
|
77
|
+
export const FILE_BUILTINS = new Set(["Read", "Glob", "Grep", "Edit", "Write"]);
|
|
64
78
|
/** `read` → `Read`: the vocabulary is small enough to forgive casing. */
|
|
65
79
|
const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.toLowerCase(), name]));
|
|
80
|
+
/**
|
|
81
|
+
* A switch's list once it is set: comma-separated, trimmed, empty entries
|
|
82
|
+
* dropped, and `none` / `off` / `0` meaning nothing at all.
|
|
83
|
+
*/
|
|
84
|
+
function parseList(value) {
|
|
85
|
+
return value
|
|
86
|
+
.split(",")
|
|
87
|
+
.map((s) => s.trim())
|
|
88
|
+
.filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()));
|
|
89
|
+
}
|
|
66
90
|
/**
|
|
67
91
|
* Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
|
|
68
92
|
* it is the whole list — `none` (or an empty value) turns built-ins off
|
|
@@ -73,12 +97,7 @@ const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.t
|
|
|
73
97
|
export function parseBuiltinTools(value) {
|
|
74
98
|
if (value === undefined)
|
|
75
99
|
return new Set(DEFAULT_BUILTIN_TOOLS);
|
|
76
|
-
|
|
77
|
-
.split(",")
|
|
78
|
-
.map((s) => s.trim())
|
|
79
|
-
.filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()))
|
|
80
|
-
.map((s) => CANONICAL_BUILTIN.get(s.toLowerCase()) ?? s);
|
|
81
|
-
return new Set(names);
|
|
100
|
+
return new Set(parseList(value).map((s) => CANONICAL_BUILTIN.get(s.toLowerCase()) ?? s));
|
|
82
101
|
}
|
|
83
102
|
/**
|
|
84
103
|
* Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
|
|
@@ -89,10 +108,24 @@ export function parseBuiltinTools(value) {
|
|
|
89
108
|
export function parseBashAllow(value) {
|
|
90
109
|
if (value === undefined)
|
|
91
110
|
return ["*"];
|
|
92
|
-
return value
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
return parseList(value);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parse `BIR_PATH_ALLOW`: the folders a runner-side `Read`, `Glob`, `Grep`,
|
|
115
|
+
* `Edit` or `Write` may touch, comma-separated, `*` for anywhere. Unset means
|
|
116
|
+
* anywhere, as the agent's own tools reach any folder its session allows (up
|
|
117
|
+
* to 0.3.20 it was the project directory alone). In a list, `project` is the
|
|
118
|
+
* session's project directory, `~` the home folder (`~/.claude` a folder in
|
|
119
|
+
* it), and any other relative folder resolves against the project directory.
|
|
120
|
+
* Set, it is the whole list, as `BIR_BASH_ALLOW` is: `none` (or an empty
|
|
121
|
+
* value) allows no folder, and the file tools then count as off
|
|
122
|
+
* `BIR_BUILTIN_TOOLS`, so their steps are steered in the session
|
|
123
|
+
* (controller.ts).
|
|
124
|
+
*/
|
|
125
|
+
export function parsePathAllow(value) {
|
|
126
|
+
if (value === undefined)
|
|
127
|
+
return ["*"];
|
|
128
|
+
return parseList(value);
|
|
96
129
|
}
|
|
97
130
|
/**
|
|
98
131
|
* Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
|
|
@@ -117,8 +150,9 @@ export function bashAllowed(command, allow) {
|
|
|
117
150
|
// ── the failure contract ─────────────────────────────────────────────────────
|
|
118
151
|
/**
|
|
119
152
|
* The step cannot run here — policy, not breakage: the tool is not
|
|
120
|
-
* implemented, its path
|
|
121
|
-
*
|
|
153
|
+
* implemented, its path is outside the folders `BIR_PATH_ALLOW` names, the
|
|
154
|
+
* command is not one `BIR_BASH_ALLOW` allows, or the input is not even the
|
|
155
|
+
* right shape. Maps to `not_run`: nothing happened, the
|
|
122
156
|
* session may still do it, a recorded output may stand in.
|
|
123
157
|
*/
|
|
124
158
|
export class BuiltinNotRunnable extends Error {
|
|
@@ -186,7 +220,9 @@ export async function runBuiltin(toolName, input, ctx) {
|
|
|
186
220
|
throw new BuiltinNotRunnable(`no runner-side implementation for ${toolName}`);
|
|
187
221
|
}
|
|
188
222
|
}
|
|
189
|
-
// ──
|
|
223
|
+
// ── which paths a file step may touch ────────────────────────────────────────
|
|
224
|
+
/** The word in `BIR_PATH_ALLOW` that stands for the session's project directory. */
|
|
225
|
+
const PROJECT_WORD = "project";
|
|
190
226
|
/** Case-blind on Windows, where `C:\Repo` and `c:\repo` are one directory. */
|
|
191
227
|
function samePath(a, b) {
|
|
192
228
|
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
@@ -199,54 +235,87 @@ function underRoot(root, abs) {
|
|
|
199
235
|
: abs.startsWith(prefix)));
|
|
200
236
|
}
|
|
201
237
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* gets resolved, so a `Write` to a new file is judged by its real directory.
|
|
238
|
+
* The folders the context's `BIR_PATH_ALLOW` list names, as absolute paths,
|
|
239
|
+
* or `anywhere` for `*`. `project` is the session's project directory, `~`
|
|
240
|
+
* the home folder, and any other relative entry resolves against the project.
|
|
206
241
|
*/
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
242
|
+
function allowedFolders(ctx) {
|
|
243
|
+
const allow = ctx.pathAllow ?? [PROJECT_WORD];
|
|
244
|
+
if (allow.includes("*"))
|
|
245
|
+
return "anywhere";
|
|
246
|
+
const project = resolve(ctx.cwd);
|
|
247
|
+
return allow.map((entry) => {
|
|
248
|
+
if (entry.toLowerCase() === PROJECT_WORD)
|
|
249
|
+
return project;
|
|
250
|
+
if (entry === "~")
|
|
251
|
+
return homedir();
|
|
252
|
+
if (entry.startsWith("~/") || entry.startsWith("~\\"))
|
|
253
|
+
return resolve(homedir(), entry.slice(2));
|
|
254
|
+
return resolve(project, entry);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** Why a path was refused, naming the switch that refused it. */
|
|
258
|
+
function outsideMessage(p, allow) {
|
|
259
|
+
return allow.length === 1 && allow[0].toLowerCase() === PROJECT_WORD
|
|
260
|
+
? `${p} is outside the session's project directory, and BIR_PATH_ALLOW=project keeps bir inside it`
|
|
261
|
+
: `${p} is outside the folders BIR_PATH_ALLOW names (${allow.join(", ")}), so bir does not touch it unattended`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* `abs` as it is on disk: its longest existing ancestor replaced by that
|
|
265
|
+
* ancestor's realpath (symlinks resolved, and on macOS `/var` read as
|
|
266
|
+
* `/private/var`), the rest kept as written. A path none of which exists
|
|
267
|
+
* comes back as it is.
|
|
268
|
+
*/
|
|
269
|
+
async function physical(abs) {
|
|
220
270
|
let probe = abs;
|
|
221
|
-
|
|
271
|
+
const rest = [];
|
|
222
272
|
for (;;) {
|
|
223
273
|
try {
|
|
224
|
-
real = await realpath(probe);
|
|
225
|
-
|
|
274
|
+
const real = await realpath(probe);
|
|
275
|
+
return rest.length > 0 ? join(real, ...rest.reverse()) : real;
|
|
226
276
|
}
|
|
227
277
|
catch {
|
|
228
278
|
const parent = dirname(probe);
|
|
229
279
|
if (samePath(parent, probe))
|
|
230
|
-
|
|
280
|
+
return abs; // the filesystem root: nothing exists
|
|
281
|
+
rest.push(basename(probe));
|
|
231
282
|
probe = parent;
|
|
232
283
|
}
|
|
233
284
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Resolve `p` as the host does — against the session's project directory
|
|
288
|
+
* when it is relative — and allow it anywhere, as the agent's own tools
|
|
289
|
+
* would, unless `BIR_PATH_ALLOW` names folders. Then anything outside them is
|
|
290
|
+
* refused: first as written, then physically ({@link physical}, both sides),
|
|
291
|
+
* so a symlink inside an allowed folder cannot point the step at a file
|
|
292
|
+
* beyond it, and a `Write` to a new file is judged by its real folder.
|
|
293
|
+
*/
|
|
294
|
+
async function allowedPath(ctx, p, what = "file_path") {
|
|
295
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
296
|
+
throw new BuiltinNotRunnable(`${what} is required`);
|
|
244
297
|
}
|
|
298
|
+
const abs = resolve(ctx.cwd, p);
|
|
299
|
+
const folders = allowedFolders(ctx);
|
|
300
|
+
if (folders === "anywhere")
|
|
301
|
+
return abs;
|
|
302
|
+
if (folders.length === 0) {
|
|
303
|
+
throw new BuiltinNotRunnable("BIR_PATH_ALLOW allows no folder, so bir touches no file unattended");
|
|
304
|
+
}
|
|
305
|
+
const outside = () => {
|
|
306
|
+
throw new BuiltinNotRunnable(outsideMessage(p, ctx.pathAllow ?? [PROJECT_WORD]));
|
|
307
|
+
};
|
|
308
|
+
if (!folders.some((folder) => underRoot(folder, abs)))
|
|
309
|
+
outside();
|
|
310
|
+
const real = await physical(abs);
|
|
311
|
+
const realFolders = await Promise.all(folders.map(physical));
|
|
312
|
+
if (!realFolders.some((folder) => underRoot(folder, real)))
|
|
313
|
+
outside();
|
|
245
314
|
return abs;
|
|
246
315
|
}
|
|
247
316
|
// ── Read ─────────────────────────────────────────────────────────────────────
|
|
248
317
|
async function runRead(input, ctx) {
|
|
249
|
-
const filePath = await
|
|
318
|
+
const filePath = await allowedPath(ctx, input.file_path);
|
|
250
319
|
const size = (await stat(filePath)).size;
|
|
251
320
|
if (size > MAX_FILE_BYTES) {
|
|
252
321
|
throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner reads`);
|
|
@@ -276,7 +345,7 @@ async function runGlob(input, ctx, deadline) {
|
|
|
276
345
|
if (typeof pattern !== "string" || pattern.trim() === "") {
|
|
277
346
|
throw new BuiltinNotRunnable("pattern is required");
|
|
278
347
|
}
|
|
279
|
-
const root = await
|
|
348
|
+
const root = await allowedPath(ctx, typeof input.path === "string" ? input.path : ".", "path");
|
|
280
349
|
const startedAt = Date.now();
|
|
281
350
|
const re = globToRegExp(pattern);
|
|
282
351
|
const found = [];
|
|
@@ -324,7 +393,7 @@ async function runGrep(input, ctx, deadline) {
|
|
|
324
393
|
catch (err) {
|
|
325
394
|
throw new BuiltinNotRunnable(`the pattern is not a regular expression this runner can run: ${err.message}`);
|
|
326
395
|
}
|
|
327
|
-
const target = await
|
|
396
|
+
const target = await allowedPath(ctx, typeof input.path === "string" ? input.path : ".", "path");
|
|
328
397
|
// ripgrep's `-g '*.ts'` matches the basename at any depth; only a pattern
|
|
329
398
|
// with a `/` in it speaks about the path.
|
|
330
399
|
const globRe = typeof input.glob === "string" && input.glob
|
|
@@ -417,7 +486,7 @@ async function matchFile(path, re, firstOnly) {
|
|
|
417
486
|
}
|
|
418
487
|
// ── Edit / Write ─────────────────────────────────────────────────────────────
|
|
419
488
|
async function runEdit(input, ctx) {
|
|
420
|
-
const filePath = await
|
|
489
|
+
const filePath = await allowedPath(ctx, input.file_path);
|
|
421
490
|
const oldString = input.old_string;
|
|
422
491
|
const newString = input.new_string;
|
|
423
492
|
if (typeof oldString !== "string" || oldString === "") {
|
|
@@ -452,7 +521,7 @@ async function runEdit(input, ctx) {
|
|
|
452
521
|
};
|
|
453
522
|
}
|
|
454
523
|
async function runWrite(input, ctx) {
|
|
455
|
-
const filePath = await
|
|
524
|
+
const filePath = await allowedPath(ctx, input.file_path);
|
|
456
525
|
const content = input.content;
|
|
457
526
|
if (typeof content !== "string")
|
|
458
527
|
throw new BuiltinNotRunnable("content is required");
|
|
@@ -579,9 +648,10 @@ function takePwdMarker(stderr, mark) {
|
|
|
579
648
|
const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
580
649
|
/**
|
|
581
650
|
* Walk `root`, calling `visit(relativePath, absolutePath, mtimeMs)` for every
|
|
582
|
-
* regular file. Never follows directory symlinks — the
|
|
583
|
-
* the root, and a link out of it must not
|
|
584
|
-
* the
|
|
651
|
+
* regular file. Never follows directory symlinks — the folder was checked at
|
|
652
|
+
* the root, and under a `BIR_PATH_ALLOW` list a link out of it must not
|
|
653
|
+
* re-open the door (nor may a link loop the walk). Returns whether the walk
|
|
654
|
+
* was cut short (entry cap, deadline, or `visit` saying stop).
|
|
585
655
|
*/
|
|
586
656
|
async function walk(root, deadline, visit) {
|
|
587
657
|
let seen = 0;
|
|
@@ -617,7 +687,9 @@ async function walk(root, deadline, visit) {
|
|
|
617
687
|
catch {
|
|
618
688
|
continue;
|
|
619
689
|
}
|
|
620
|
-
|
|
690
|
+
// `relative`, not a slice: a walk may now start at a drive's or the
|
|
691
|
+
// disk's root (`C:\`, `/`), which already ends in a separator.
|
|
692
|
+
const rel = relative(root, abs).split(sep).join("/");
|
|
621
693
|
if (!visit(rel, abs, mtime))
|
|
622
694
|
return true;
|
|
623
695
|
}
|
|
@@ -266,6 +266,16 @@ export interface ReplayOptions {
|
|
|
266
266
|
* rather than armed `direct` and stopped at the first one.
|
|
267
267
|
*/
|
|
268
268
|
bashAllow?: readonly string[];
|
|
269
|
+
/**
|
|
270
|
+
* `BIR_PATH_ALLOW` — the folders a runner-side `Read`, `Glob`, `Grep`,
|
|
271
|
+
* `Edit` or `Write` may touch (`*` for any; `project` is the session's
|
|
272
|
+
* project directory). Omitted, the environment decides; the default is
|
|
273
|
+
* `*`, any path the agent's own tools could reach (up to 0.3.20 the
|
|
274
|
+
* project directory alone). Empty allows no folder, and then the file tools
|
|
275
|
+
* count as off {@link ReplayOptions.builtinTools}, as `Bash` does under an
|
|
276
|
+
* empty {@link ReplayOptions.bashAllow}.
|
|
277
|
+
*/
|
|
278
|
+
pathAllow?: readonly string[];
|
|
269
279
|
budgets?: Partial<ReplayBudgets>;
|
|
270
280
|
/**
|
|
271
281
|
* The service: the lazy source-run fetch, and the parameter derivation this
|
|
@@ -418,8 +428,9 @@ export interface ReplayState {
|
|
|
418
428
|
/**
|
|
419
429
|
* That session's project directory, set by the control server from the
|
|
420
430
|
* hook payloads — never the daemon's own cwd, which is `~/.baseinstrunner`.
|
|
421
|
-
* It is where an allow-listed built-in step executes and
|
|
422
|
-
*
|
|
431
|
+
* It is where an allow-listed built-in step executes and what its relative
|
|
432
|
+
* paths resolve against (replay/builtins.ts; `BIR_PATH_ALLOW=project`
|
|
433
|
+
* confines the file tools to it). Absent — `bir replay`, an older server —
|
|
423
434
|
* no built-in executes: the step is `not_run`, exactly as before.
|
|
424
435
|
*/
|
|
425
436
|
sessionCwd?: string;
|
|
@@ -689,6 +700,8 @@ export declare class ReplayController {
|
|
|
689
700
|
private readonly builtinTools;
|
|
690
701
|
/** See {@link ReplayOptions.bashAllow}. */
|
|
691
702
|
private readonly bashAllow;
|
|
703
|
+
/** See {@link ReplayOptions.pathAllow}. */
|
|
704
|
+
private readonly pathAllow;
|
|
692
705
|
/** Calls waiting for a plan's pins to change (see `awaitPins`). */
|
|
693
706
|
private readonly pinWaiters;
|
|
694
707
|
/**
|
|
@@ -18,7 +18,7 @@ import { capText, DEFAULT_MAX_PAYLOAD_BYTES, REPORT_PAYLOAD_MAX_BYTES, serialize
|
|
|
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
|
+
import { BuiltinNotRunnable, BuiltinTimeout, FILE_BUILTINS, parseBashAllow, parseBuiltinTools, parsePathAllow, runBuiltin, } from "./builtins.js";
|
|
22
22
|
import { BASH_CHANNEL_MAX, MAX_REPLAY_REASON, testWords, truncate } from "./bundle.js";
|
|
23
23
|
import { coverageOf, modeFor, reachOf } from "./coverage.js";
|
|
24
24
|
import { deriveParameters, localDay, } from "./derive.js";
|
|
@@ -216,6 +216,8 @@ export class ReplayController {
|
|
|
216
216
|
builtinTools;
|
|
217
217
|
/** See {@link ReplayOptions.bashAllow}. */
|
|
218
218
|
bashAllow;
|
|
219
|
+
/** See {@link ReplayOptions.pathAllow}. */
|
|
220
|
+
pathAllow;
|
|
219
221
|
/** Calls waiting for a plan's pins to change (see `awaitPins`). */
|
|
220
222
|
pinWaiters = new WeakMap();
|
|
221
223
|
/**
|
|
@@ -277,13 +279,17 @@ export class ReplayController {
|
|
|
277
279
|
this.recordedFallback =
|
|
278
280
|
opts.recordedFallback ?? process.env.BIR_REPLAY_RECORDED_OUTPUTS === "1";
|
|
279
281
|
this.bashAllow = opts.bashAllow ?? parseBashAllow(process.env.BIR_BASH_ALLOW);
|
|
282
|
+
this.pathAllow = opts.pathAllow ?? parsePathAllow(process.env.BIR_PATH_ALLOW);
|
|
280
283
|
const builtins = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
|
|
281
|
-
// A `Bash` that may run no command
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
// A `Bash` that may run no command, or a file tool that may touch no
|
|
285
|
+
// folder, is not reachable here. Left on the list, a chain of such steps
|
|
286
|
+
// would arm `direct` and stop at its first.
|
|
287
|
+
const reachable = (name) => name === "Bash"
|
|
288
|
+
? this.bashAllow.length > 0
|
|
289
|
+
: !FILE_BUILTINS.has(name) || this.pathAllow.length > 0;
|
|
290
|
+
this.builtinTools = [...builtins].every(reachable)
|
|
291
|
+
? builtins
|
|
292
|
+
: new Set([...builtins].filter(reachable));
|
|
287
293
|
}
|
|
288
294
|
/**
|
|
289
295
|
* Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
|
|
@@ -2873,11 +2879,12 @@ export class ReplayController {
|
|
|
2873
2879
|
}
|
|
2874
2880
|
const mcp = parseQualifiedName(name);
|
|
2875
2881
|
if (!mcp) {
|
|
2876
|
-
// An allow-listed built-in runs *here*,
|
|
2882
|
+
// An allow-listed built-in runs *here*, from the session's own
|
|
2877
2883
|
// project directory (replay/builtins.ts). Everything else about it —
|
|
2878
2884
|
// no session cwd (`bir replay`, an older server), off the list, no
|
|
2879
|
-
// runner-side implementation, a path
|
|
2880
|
-
// command `BIR_BASH_ALLOW` does not
|
|
2885
|
+
// runner-side implementation, a path outside the folders
|
|
2886
|
+
// `BIR_PATH_ALLOW` names, a `Bash` command `BIR_BASH_ALLOW` does not
|
|
2887
|
+
// allow — stays the old contract:
|
|
2881
2888
|
// `not_run`, so the session can do the step itself or its recorded
|
|
2882
2889
|
// output can stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
|
|
2883
2890
|
if (this.builtinTools.has(name) && state.sessionCwd) {
|
|
@@ -2939,6 +2946,9 @@ export class ReplayController {
|
|
|
2939
2946
|
cwd,
|
|
2940
2947
|
timeoutMs,
|
|
2941
2948
|
bashAllow: this.bashAllow,
|
|
2949
|
+
// Any folder unless `BIR_PATH_ALLOW` names some, as the agent's own
|
|
2950
|
+
// tools would reach it (up to 0.3.20, the project directory alone).
|
|
2951
|
+
pathAllow: this.pathAllow,
|
|
2942
2952
|
// The plan's Bash steps share a working directory, as the host's do.
|
|
2943
2953
|
bashCwd: state.bashCwd,
|
|
2944
2954
|
onBashCwd: (pwd) => {
|
package/docs/calculatedReplay.md
CHANGED
|
@@ -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 | `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash` run on the runner itself,
|
|
48
|
+
| Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash` run on the runner itself, on any path the agent could reach unless `BIR_PATH_ALLOW` names folders (`BIR_BUILTIN_TOOLS`, `BIR_BASH_ALLOW`, `BIR_PATH_ALLOW`); any other built-in is steered. In a host that is not Claude Code a tool with one of these names is the application's own, and the runner runs none of them (§7.4) |
|
|
49
49
|
| Needs `@anthropic-ai/*` at runtime | Yes | No (§16.3) |
|
|
50
50
|
|
|
51
51
|
---
|
|
@@ -265,7 +265,7 @@ export function modeFor(steps: SerializedScenarioStep[], wrapped: ReadonlySet<st
|
|
|
265
265
|
|
|
266
266
|
| Every step | Mode | Where the tools run | Model spend |
|
|
267
267
|
|---|---|---|---|
|
|
268
|
-
| 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,
|
|
268
|
+
| 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, from the session's project directory (replay/builtins.ts) | one Haiku derivation + one turn that reads the results |
|
|
269
269
|
| anything else, or mixed | **`steer`** (§7) | The live session, inputs pinned | one Haiku derivation + the live turn |
|
|
270
270
|
| — | `none` | nowhere | declines at gate 5 |
|
|
271
271
|
|
|
@@ -275,11 +275,17 @@ so `Task`, `WebFetch`; up to 0.3.7 also `Bash`, `Edit`, `Write`), an MCP server
|
|
|
275
275
|
`claude-in-chrome` — which is `scope: "dynamic"`, appears in no config file, and therefore can never
|
|
276
276
|
be wrapped (v1 design §1). None of them can be reached except through the session.
|
|
277
277
|
|
|
278
|
-
A built-in **on** the allow-list runs on the runner,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
A built-in **on** the allow-list runs on the runner, from the session's own project directory: a
|
|
279
|
+
relative path resolves against it, as the host's tools resolve it, and an absolute path is taken
|
|
280
|
+
as it is, inside the project or not — the agent's own tools reach any folder its session allows,
|
|
281
|
+
and the recorded call was one of them (up to 0.3.20 every path was confined to the project, and a
|
|
282
|
+
plan that read the agent's memory file under `~/.claude` handed the whole task back). The file
|
|
283
|
+
tools have a switch of their own (`BIR_PATH_ALLOW`, folders; unset means any, `project` brings
|
|
284
|
+
back the confinement, `project,~/.claude` adds a folder, `none` keeps them in the session); under
|
|
285
|
+
a list, a path outside it, symlinks included, is refused and the step is `not_run`, the clean
|
|
286
|
+
hand-over. `Bash` has its switch too (`BIR_BASH_ALLOW`, command prefixes; unset means any command,
|
|
287
|
+
`none` keeps `Bash` in the session), because a runner-side step never reaches the host's
|
|
288
|
+
permission prompts. The
|
|
283
289
|
results mimic the hook's view of each tool, which is what the recorder stored for built-in steps
|
|
284
290
|
and what their `toolOutputLogic` was authored against; they pass through the same
|
|
285
291
|
`serializeCapped(redact(…))` as every recorded payload. Bash keeps the host's own semantics a
|
|
@@ -241,8 +241,10 @@ When replay arms:
|
|
|
241
241
|
§7) — and never in plan mode.
|
|
242
242
|
- **Direct steps are not visible to the permission system at all.** They are dispatched straight to a
|
|
243
243
|
`bir-proxy`, which calls the upstream on a connection it already holds — or, for Claude Code's own
|
|
244
|
-
`Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself
|
|
245
|
-
folder, any
|
|
244
|
+
`Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself from the project
|
|
245
|
+
folder, on any file the agent could reach (from 0.3.21; up to 0.3.20 only files inside the
|
|
246
|
+
project) and any `Bash` command included (`BIR_BUILTIN_TOOLS`, `BIR_PATH_ALLOW`,
|
|
247
|
+
`BIR_BASH_ALLOW`; up to 0.3.7 only the
|
|
246
248
|
first three). Claude Code never sees them.
|
|
247
249
|
The `run_scenario` call that starts them is answered `allow` too, while the runner has announced a
|
|
248
250
|
direct plan; your own deny or ask rules for it still apply.
|
|
@@ -253,7 +255,8 @@ That is what makes replay fast, and it is the whole risk. Two things follow:
|
|
|
253
255
|
2. Run `bir replay allow <servers>` (§5.3). An allowlist of the servers you are comfortable having
|
|
254
256
|
called unattended is cheap insurance, and it is the difference between "replay reads a dashboard"
|
|
255
257
|
and "replay can do anything any wrapped server can do". The same goes for built-ins:
|
|
256
|
-
`BIR_BUILTIN_TOOLS=Read,Glob,Grep` keeps writes and commands with the model,
|
|
258
|
+
`BIR_BUILTIN_TOOLS=Read,Glob,Grep` keeps writes and commands with the model,
|
|
259
|
+
`BIR_PATH_ALLOW=project` keeps the file tools inside the project folder, and
|
|
257
260
|
`BIR_BASH_ALLOW=git,npm test` (for example) limits the commands the recorder runs itself.
|
|
258
261
|
|
|
259
262
|
Nothing else is approved on your behalf. Outside a matched scenario's own steps, a wrapped MCP call
|
|
@@ -368,8 +371,9 @@ so there is exactly one switch and it cannot get out of step with itself.
|
|
|
368
371
|
| `BIR_STEP_TIMEOUT_MS` | `60000` | Per-step ceiling for one direct `tools/call`, clipped to what the plan has left |
|
|
369
372
|
| `BIR_LOGIC_TIMEOUT_MS` | `1000` | How long one scenario logic body may run before it is stopped. At most 10 000 |
|
|
370
373
|
| `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 |
|
|
371
|
-
| `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay,
|
|
374
|
+
| `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay, from the session's own project directory (which folders the file tools may touch: `BIR_PATH_ALLOW`). A chain whose only non-MCP steps are on this list arms `direct`. A runner-side step never reaches the host's permission prompts, so leave off any tool you want the model to call itself; `Read,Glob,Grep` is the read-only set (the default up to 0.3.7). `none`: every built-in step is steered in the session. Claude Code sessions only: in a LangChain application a tool with one of these names is the application's own, and the runner runs none (§6.5) |
|
|
372
375
|
| `BIR_BASH_ALLOW` | *(unset: any command)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset means any command; `none` means no command, and `Bash` steps are steered in the session. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's. A call with `run_in_background`, or with its own `timeout` longer than the step's clock (`BIR_STEP_TIMEOUT_MS`), is never started on the runner: the plan hands over in front of it |
|
|
376
|
+
| `BIR_PATH_ALLOW` | *(unset: any folder, from 0.3.21)* | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch (`*` for all). Unset means any path, as the agent's own tools reach it — a plan that reads the agent's memory file under `~/.claude` runs. `project` is the session's project folder alone (the rule up to 0.3.20); add folders after a comma, `~` for the home folder: `project,~/.claude`. Under a list, a path outside it, symlinks included, is refused and the plan hands over in front of it. `none` means no folder, and the file steps are steered in the session. `Bash` is `BIR_BASH_ALLOW`'s |
|
|
373
377
|
| `BIR_SEGMENT_ARM` | *(unset: on, from 0.3.19)* | A segment the service hands out runs mid-task: a recurring sub-task, or a half of a task after a plan's decision. `0` is observe-only: what would have armed is logged (`replay.segment_would_arm`) and nothing is replaced. Up to 0.3.18 observe-only was the default. A recorder started before an update keeps its old default until `bir up --restart` |
|
|
374
378
|
| `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, and then nothing starts mid-task, segments included: what would have armed is logged; unset follows `BIR_SEGMENT_ARM`, so from 0.3.19 it is on unless `BIR_SEGMENT_ARM=0` |
|
|
375
379
|
| `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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
|
@@ -1071,8 +1071,9 @@ Defaults were checked in `src/`.
|
|
|
1071
1071
|
| `BIR_STEP_TIMEOUT_MS` | recorder | 60000 | One direct `tools/call`. |
|
|
1072
1072
|
| `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
|
|
1073
1073
|
| `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
|
|
1074
|
-
| `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay,
|
|
1074
|
+
| `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay, from the session's project directory; which folders the file tools may touch is `BIR_PATH_ALLOW` (up to 0.3.7 the default was `Read,Glob,Grep`). Runner-side steps bypass the host's permission prompts; leave a tool off to have the model call it. `none` turns it off. Not in a session of another host (§3.2): the runner runs no step itself there, whatever a tool is named. |
|
|
1075
1075
|
| `BIR_BASH_ALLOW` | recorder | unset (any command) | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset means any command; `none` means no command, and `Bash` steps are steered. A plan's Bash steps share a working directory, as the host's do; variables do not persist, also as the host's. |
|
|
1076
|
+
| `BIR_PATH_ALLOW` | recorder | unset (any folder) | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch (`*` for all). Unset means any path, as the agent's own tools (from 0.3.21; up to 0.3.20 the project directory alone). `project` is the session's project directory, `~` the home folder: `project,~/.claude`. Under a list, a path outside it, symlinks included, is `not_run` (hand-over). `none` means no folder, and file steps are steered. |
|
|
1076
1077
|
| `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, and never for a plan started at the prompt: a prompt just submitted has no results yet, and an earlier prompt's are not its own. So in a session of another host, where a plan starts at the prompt only, none are read. |
|
|
1077
1078
|
| `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
|
|
1078
1079
|
| `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |
|