@basein/runner 0.3.19 → 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 +6 -1
- package/dist/control/recorder-main.js +15 -3
- package/dist/control/server.d.ts +64 -0
- package/dist/control/server.js +643 -36
- package/dist/record/recorder.d.ts +37 -2
- package/dist/record/recorder.js +3 -0
- package/dist/record/remote-recorder.d.ts +9 -1
- package/dist/record/remote-recorder.js +30 -0
- package/dist/record/switchable-recorder.js +2 -0
- 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 +23 -11
- package/dist/replay/derive.d.ts +10 -0
- package/dist/replay/derive.js +3 -0
- package/dist/replay/reads-only.d.ts +29 -0
- package/dist/replay/reads-only.js +261 -0
- package/docs/calculatedReplay.md +13 -7
- package/docs/calculatedReplayGuide.md +12 -5
- package/package.json +1 -1
- package/production.md +16 -2
|
@@ -197,8 +197,13 @@ export declare function isRecorderHealthReporter(r: Recorder): r is Recorder & R
|
|
|
197
197
|
* callers must stop recording it. What the caller does *instead* of recording is
|
|
198
198
|
* v2's subject (docs/calculatedReplay.md); stopping is mandatory either way.
|
|
199
199
|
*/
|
|
200
|
-
/**
|
|
201
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Which of a segment's two keys scored against the live step (R-OUT-3).
|
|
202
|
+
* `decision`: no score at all — one of the calculated scenarios that can follow
|
|
203
|
+
* a plan's decision, handed out for the agent's next call (the service's
|
|
204
|
+
* offer1.md O3).
|
|
205
|
+
*/
|
|
206
|
+
export type MatchKey = "intent" | "firstStep" | "decision";
|
|
202
207
|
/** The cheap model's answer about one live step and one segment (R-VERIFY-1). */
|
|
203
208
|
export interface MatchVerdict {
|
|
204
209
|
model: string;
|
|
@@ -406,6 +411,36 @@ export interface IntentMatcher {
|
|
|
406
411
|
matchIntent(req: IntentMatchRequest, opts?: IntentMatchOptions): Promise<IntentMatchAnswer | null>;
|
|
407
412
|
}
|
|
408
413
|
export declare function isIntentMatcher(r: Recorder): r is Recorder & IntentMatcher;
|
|
414
|
+
/** What a turn sends when a plan of it has stopped at its decision (the service's offer1.md O3). */
|
|
415
|
+
export interface NextScenariosRequest {
|
|
416
|
+
/** The turn's identity, as a probe sends it (R-HIT-6). */
|
|
417
|
+
turnId?: string;
|
|
418
|
+
/** Always true from this runner (R-CALL-28). */
|
|
419
|
+
supportsCalls?: boolean;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* One calculated scenario that can follow a plan's decision: a hand-out in the
|
|
423
|
+
* shape of a match, with its own ticket, `key: "decision"`, and the tool of its
|
|
424
|
+
* first step. The service leaves withheld ones out.
|
|
425
|
+
*/
|
|
426
|
+
export interface NextHandOut extends RunMatch {
|
|
427
|
+
/** The tool its first served step calls; null when the service could not say. */
|
|
428
|
+
firstTool?: string | null;
|
|
429
|
+
}
|
|
430
|
+
/** The service's answer: the scenarios, and where the plan's decision is. */
|
|
431
|
+
export interface NextScenariosAnswer {
|
|
432
|
+
next: NextHandOut[];
|
|
433
|
+
decisionStep: number | null;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Optional capability: the calculated scenarios that can follow a decision
|
|
437
|
+
* (offer1.md O3) need a service. `null` is "none" — an older service (404), a
|
|
438
|
+
* cool-down, or any failure: the agent then does the work itself, as before.
|
|
439
|
+
*/
|
|
440
|
+
export interface NextScenarioSource {
|
|
441
|
+
nextScenarios(planId: string, req: NextScenariosRequest, opts?: IntentMatchOptions): Promise<NextScenariosAnswer | null>;
|
|
442
|
+
}
|
|
443
|
+
export declare function isNextScenarioSource(r: Recorder): r is Recorder & NextScenarioSource;
|
|
409
444
|
/**
|
|
410
445
|
* What a matched turn actually cost, reported once per match
|
|
411
446
|
* (docs/calculatedReplay.md §11).
|
package/dist/record/recorder.js
CHANGED
|
@@ -20,6 +20,9 @@ export function isServiceProber(r) {
|
|
|
20
20
|
export function isIntentMatcher(r) {
|
|
21
21
|
return typeof r.matchIntent === "function";
|
|
22
22
|
}
|
|
23
|
+
export function isNextScenarioSource(r) {
|
|
24
|
+
return typeof r.nextScenarios === "function";
|
|
25
|
+
}
|
|
23
26
|
export function isToolListReporter(r) {
|
|
24
27
|
return typeof r.reportTools === "function";
|
|
25
28
|
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
*/
|
|
51
51
|
import { type AuthSession } from "../auth/client.js";
|
|
52
52
|
import { type ServiceHealth } from "../util/service-health.js";
|
|
53
|
-
import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
|
|
53
|
+
import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, NextScenariosAnswer, NextScenariosRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
|
|
54
54
|
/**
|
|
55
55
|
* Caps for the free text this recorder sends, in UTF-8 bytes. Tool payloads
|
|
56
56
|
* arrive capped at 64 KiB by their producers; text never passed through one,
|
|
@@ -206,6 +206,14 @@ export declare class RemoteRecorder implements Recorder, RecorderHealthReporter
|
|
|
206
206
|
* waiting, so an abandoned probe does not hold a socket for minutes.
|
|
207
207
|
*/
|
|
208
208
|
matchIntent(req: IntentMatchRequest, opts?: IntentMatchOptions): Promise<IntentMatchAnswer | null>;
|
|
209
|
+
/**
|
|
210
|
+
* The calculated scenarios that can follow a plan's decision (the service's
|
|
211
|
+
* offer1.md O3), each handed out with a ticket of its own. Asked once per
|
|
212
|
+
* decision hand-over, while the agent decides. An older service (404), a
|
|
213
|
+
* cool-down or any failure answers null: the agent then does the work
|
|
214
|
+
* itself, exactly as before.
|
|
215
|
+
*/
|
|
216
|
+
nextScenarios(planId: string, req: NextScenariosRequest, opts?: IntentMatchOptions): Promise<NextScenariosAnswer | null>;
|
|
209
217
|
/**
|
|
210
218
|
* The similar-prompt match for a run's create call (null = fresh run). Like
|
|
211
219
|
* {@link runCreated}, answers null at once while a failure is on record —
|
|
@@ -713,6 +713,36 @@ export class RemoteRecorder {
|
|
|
713
713
|
return null;
|
|
714
714
|
}
|
|
715
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* The calculated scenarios that can follow a plan's decision (the service's
|
|
718
|
+
* offer1.md O3), each handed out with a ticket of its own. Asked once per
|
|
719
|
+
* decision hand-over, while the agent decides. An older service (404), a
|
|
720
|
+
* cool-down or any failure answers null: the agent then does the work
|
|
721
|
+
* itself, exactly as before.
|
|
722
|
+
*/
|
|
723
|
+
async nextScenarios(planId, req, opts = {}) {
|
|
724
|
+
try {
|
|
725
|
+
if (!this.mayHotWait())
|
|
726
|
+
return null;
|
|
727
|
+
const answer = await this.request("POST", `/scenarios/${encodeURIComponent(planId)}/next`, JSON.stringify(req), { signal: opts.signal, timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS });
|
|
728
|
+
const b = answer && typeof answer === "object" ? answer : {};
|
|
729
|
+
const next = Array.isArray(b.next)
|
|
730
|
+
? b.next.filter((n) => !!n && typeof n === "object" && typeof n.scenarioId === "string")
|
|
731
|
+
: [];
|
|
732
|
+
return { next, decisionStep: typeof b.decisionStep === "number" ? b.decisionStep : null };
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
if (!opts.signal?.aborted) {
|
|
736
|
+
logEvery("recorder.next_scenarios_failed", 30_000, "replay.next_failed", {
|
|
737
|
+
error: errText(err),
|
|
738
|
+
why: err instanceof ServiceError && err.status === 404
|
|
739
|
+
? "the service has no next scenarios — an older service, or not this account's plan"
|
|
740
|
+
: "no next scenarios this time — the agent does the work after the decision",
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
716
746
|
/**
|
|
717
747
|
* The similar-prompt match for a run's create call (null = fresh run). Like
|
|
718
748
|
* {@link runCreated}, answers null at once while a failure is on record —
|
|
@@ -49,6 +49,8 @@ const NEUTRAL = {
|
|
|
49
49
|
settledMatch: () => Promise.resolve(null),
|
|
50
50
|
runCreated: () => Promise.resolve({ created: true }),
|
|
51
51
|
matchIntent: () => Promise.resolve(null),
|
|
52
|
+
// Signed out: no scenario follows a decision (offer1.md O3); the agent goes on.
|
|
53
|
+
nextScenarios: () => Promise.resolve(null),
|
|
52
54
|
reportExecution: () => undefined,
|
|
53
55
|
backfillContext: () => undefined,
|
|
54
56
|
probeIfFailing: () => undefined,
|
|
@@ -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
|
/**
|