@basein/runner 0.3.20 → 0.3.22

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.
Files changed (51) hide show
  1. package/README.md +6 -5
  2. package/dist/bin/bir-hooks.d.ts +17 -19
  3. package/dist/bin/bir-hooks.js +17 -19
  4. package/dist/bin/bir-scenario.js +1 -1
  5. package/dist/bin/bir.js +1 -21
  6. package/dist/bin/investigate.d.ts +0 -9
  7. package/dist/bin/investigate.js +15 -43
  8. package/dist/bin/scenario-edit.js +13 -44
  9. package/dist/control/recorder-main.d.ts +0 -15
  10. package/dist/control/recorder-main.js +22 -42
  11. package/dist/control/server.d.ts +24 -137
  12. package/dist/control/server.js +107 -1001
  13. package/dist/record/recorder.d.ts +11 -142
  14. package/dist/record/recorder.js +0 -3
  15. package/dist/record/remote-recorder.d.ts +1 -9
  16. package/dist/record/remote-recorder.js +3 -49
  17. package/dist/record/switchable-recorder.js +0 -2
  18. package/dist/replay/builtins.d.ts +45 -9
  19. package/dist/replay/builtins.js +131 -59
  20. package/dist/replay/bundle.d.ts +2 -2
  21. package/dist/replay/bundle.js +3 -3
  22. package/dist/replay/controller.d.ts +34 -125
  23. package/dist/replay/controller.js +171 -528
  24. package/dist/replay/derive.d.ts +0 -10
  25. package/dist/replay/derive.js +0 -3
  26. package/dist/replay/handover.d.ts +2 -37
  27. package/dist/replay/handover.js +38 -106
  28. package/dist/replay/logic.d.ts +0 -11
  29. package/dist/replay/logic.js +0 -24
  30. package/dist/replay/plan.d.ts +43 -163
  31. package/dist/replay/plan.js +144 -556
  32. package/dist/replay/safety.d.ts +4 -5
  33. package/dist/replay/safety.js +7 -7
  34. package/dist/replay/source-run.d.ts +15 -24
  35. package/dist/replay/source-run.js +25 -58
  36. package/dist/replay/targets.d.ts +11 -21
  37. package/dist/replay/targets.js +30 -69
  38. package/dist/replay/types.d.ts +9 -96
  39. package/dist/replay/types.js +3 -16
  40. package/dist/util/service-headers.d.ts +1 -2
  41. package/dist/util/service-headers.js +1 -2
  42. package/docs/calculatedReplay.md +30 -81
  43. package/docs/calculatedReplayGuide.md +17 -34
  44. package/package.json +1 -1
  45. package/production.md +7 -36
  46. package/dist/replay/condition-words.d.ts +0 -28
  47. package/dist/replay/condition-words.js +0 -59
  48. package/dist/replay/flatten.d.ts +0 -161
  49. package/dist/replay/flatten.js +0 -222
  50. package/dist/replay/reads-only.d.ts +0 -29
  51. package/dist/replay/reads-only.js +0 -261
@@ -130,20 +130,18 @@ export interface Recorder {
130
130
  toolInput: string;
131
131
  context?: string;
132
132
  /**
133
- * The thinking text this call was chosen in, when there was no written
134
- * text (segmented.md R-INTENT-1). It travels to the service only to be
135
- * embedded and is never stored as text (R-INTENT-2), which is why it is
136
- * carried apart from `context` rather than folded into it.
133
+ * The thinking text this call was chosen in. The service keeps it for
134
+ * the recording page, for people to read and never for a model, which
135
+ * is why it is carried apart from `context` rather than folded into it.
137
136
  */
138
137
  intent?: {
139
138
  text: string;
140
139
  source: "thinking";
141
140
  };
142
141
  /**
143
- * Set when a plan executed this step, to the plan's scenario id
144
- * (R-INTENT-7), or made it before the agent redid the task (`byPlan`,
145
- * AC2). The service stores such a step without a vector, so the
146
- * system's own replays never count as recurrences of the work.
142
+ * Set when a plan executed this step, to the plan's scenario id, or made
143
+ * it before the agent redid the task (`byPlan`, AC2): how the service
144
+ * tells the system's own steps from the agent's work.
147
145
  */
148
146
  metadata?: StepMetadata;
149
147
  }): string;
@@ -197,28 +195,6 @@ export declare function isRecorderHealthReporter(r: Recorder): r is Recorder & R
197
195
  * callers must stop recording it. What the caller does *instead* of recording is
198
196
  * v2's subject (docs/calculatedReplay.md); stopping is mandatory either way.
199
197
  */
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";
207
- /** The cheap model's answer about one live step and one segment (R-VERIFY-1). */
208
- export interface MatchVerdict {
209
- model: string;
210
- verdict: "same" | "different" | "skipped";
211
- reason?: string;
212
- }
213
- /** Where a handed-out segment sits in its recording (R-OUT-8). */
214
- export interface MatchSegment {
215
- runId: string;
216
- stepFrom: number;
217
- stepTo: number;
218
- /** `tool_selected` rows of the recording before `stepFrom` (R-OUT-6). */
219
- firstPosition: number;
220
- hitCount?: number;
221
- }
222
198
  export interface RunMatch {
223
199
  runId: string;
224
200
  scenarioId: string | null;
@@ -233,14 +209,6 @@ export interface RunMatch {
233
209
  fallback?: {
234
210
  maxStepFailures: number;
235
211
  };
236
- /**
237
- * What kind of row matched (segmented.md R-OUT-7). Absent from an older
238
- * service, and then read as `scenario`.
239
- */
240
- kind?: "scenario" | "segment";
241
- segment?: MatchSegment;
242
- key?: MatchKey;
243
- verified?: MatchVerdict;
244
212
  /** The frozen name, beside the runtime `intent` the plan runs on (R-INTENT-12). */
245
213
  intentName?: string;
246
214
  /**
@@ -286,7 +254,7 @@ export declare function isMatchAware(r: Recorder): r is Recorder & MatchAware;
286
254
  export interface RunCreation {
287
255
  created: boolean;
288
256
  /**
289
- * Set when this was a hit on an existing fragment (R-FALL-7, R-HIT-7) —
257
+ * Set when this was a hit on an existing fragment (R-FALL-7) —
290
258
  * with `created` too when the service recorded it all the same, as that
291
259
  * fragment's newest run (its newestRun.md N2).
292
260
  */
@@ -319,45 +287,11 @@ export interface ServiceProber {
319
287
  export declare function isServiceProber(r: Recorder): r is Recorder & ServiceProber;
320
288
  /** What a ReAct iteration sends to find a scenario by its intent (fallbk.md §Runner 4). */
321
289
  export interface IntentMatchRequest {
322
- /**
323
- * The step's intent (segmented.md R-INTENT-1). May be empty: a call made with
324
- * no reasoning still probes, and the service builds a line from the tool name
325
- * and arguments instead (R-HIT-4).
326
- */
290
+ /** What the agent wrote, or was thinking, as it chose the call. Never empty. */
327
291
  text: string;
328
292
  currentToolName?: string;
329
293
  /** Scenario ids already armed this turn. */
330
294
  exclude?: string[];
331
- /** Which rung the text came from (R-INTENT-3). */
332
- source?: IntentSourceName;
333
- /** The same value as `currentToolName`; what the new server reads (R-HIT-6). */
334
- toolName?: string;
335
- /** Redacted and capped arguments of the call about to be made (R-HIT-4). */
336
- toolInput?: string;
337
- /** The turn's identity, fixed for its whole life (R-HIT-6). */
338
- turnId?: string;
339
- /** `run.ordering.next` — where this call sits in the turn. */
340
- stepPosition?: number;
341
- /** Recordings this turn must not count hits against (R-HIT-7). */
342
- excludeRuns?: string[];
343
- /**
344
- * Per plan of this turn: its recording, and how far it got in its own chain
345
- * — the step it handed over at (a decision included), or its last step when
346
- * it ran to its end (R-OUT-6). One entry per plan; the service maps each onto
347
- * the recording and keeps the largest (its segmentsbreakdown.md D18).
348
- */
349
- ranThrough?: Array<{
350
- runId: string;
351
- position: number;
352
- scenarioId?: string;
353
- }>;
354
- /**
355
- * Present only while segment arming is on (R-OUT-9, R-OUT-10): the
356
- * recorder's default from 0.3.19, absent with `BIR_SEGMENT_ARM=0`.
357
- */
358
- acceptSegments?: boolean;
359
- /** Always true from this runner (R-CALL-28). */
360
- supportsCalls?: boolean;
361
295
  /**
362
296
  * The host the turn runs in, as its runs' `framework` says it (see
363
297
  * {@link StartRunOptions.framework}). Left out for Claude Code, which is
@@ -365,39 +299,11 @@ export interface IntentMatchRequest {
365
299
  */
366
300
  framework?: string;
367
301
  }
368
- /** Which text a step's intent came from. */
369
- export type IntentSourceName = "text" | "thinking" | "tool";
370
- /** One recorded step a live step matched (R-HIT-6). */
371
- export interface StepHit {
372
- runId: string;
373
- stepIndex: number;
374
- similarity: number;
375
- hitCount?: number;
376
- }
377
- /** What a segment *would* have armed, when no ticket was issued (R-OUT-9). */
378
- export interface SegmentWouldArm {
379
- scenarioId: string;
380
- runId: string;
381
- key?: MatchKey;
382
- similarity: number;
383
- band?: string;
384
- verified?: MatchVerdict;
385
- tool?: string;
386
- firstTool?: string;
387
- stepFrom?: number;
388
- stepTo?: number;
389
- hitCount?: number;
390
- }
391
- /**
392
- * The whole answer to a probe, not just its match. The counting fields are what
393
- * make the observe-only period readable (R-OUT-10), so they must survive the
394
- * trip rather than being discarded with the rest of the body.
395
- */
302
+ /** The answer to a probe. */
396
303
  export interface IntentMatchAnswer {
397
304
  matched: RunMatch | null;
398
- stepHit?: StepHit | null;
399
- bestStepSimilarity?: number;
400
- segmentWouldArm?: SegmentWouldArm;
305
+ /** The best score the service saw, matched or not — for the log, to tune by. */
306
+ bestSimilarity?: number | null;
401
307
  }
402
308
  /** How long a probe's caller will wait; the request is abandoned past it. */
403
309
  export interface IntentMatchOptions {
@@ -411,36 +317,6 @@ export interface IntentMatcher {
411
317
  matchIntent(req: IntentMatchRequest, opts?: IntentMatchOptions): Promise<IntentMatchAnswer | null>;
412
318
  }
413
319
  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;
444
320
  /**
445
321
  * What a matched turn actually cost, reported once per match
446
322
  * (docs/calculatedReplay.md §11).
@@ -498,13 +374,6 @@ export interface ExecutionReport {
498
374
  * which is what every report said before this field existed.
499
375
  */
500
376
  baselineEligible?: boolean;
501
- /**
502
- * Segments of this plan's own recording that ran later in the same turn
503
- * (segmented.md R-MONEY-4). The service freezes this row's baseline at what
504
- * is left after their share, so one recording's steps are never counted in
505
- * two baselines of one turn. The segments' own rows are untouched.
506
- */
507
- sharedWith?: string[];
508
377
  /**
509
378
  * Which gate of the ladder declined, on a `not_steered` report.
510
379
  *
@@ -20,9 +20,6 @@ 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
- }
26
23
  export function isToolListReporter(r) {
27
24
  return typeof r.reportTools === "function";
28
25
  }
@@ -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, NextScenariosAnswer, NextScenariosRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
53
+ import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, 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,14 +206,6 @@ 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>;
217
209
  /**
218
210
  * The similar-prompt match for a run's create call (null = fresh run). Like
219
211
  * {@link runCreated}, answers null at once while a failure is on record —
@@ -604,9 +604,6 @@ export class RemoteRecorder {
604
604
  // A repeat (the service's newestRun.md). Sent only after a match
605
605
  // that offered it, so a service that would not know it never sees it.
606
606
  ...(options?.repeatOf ? { repeatOf: options.repeatOf } : {}),
607
- // A chain with calls is inlined for this runner rather than stopped in
608
- // front of its first call (segmented.md R-CALL-28).
609
- supportsCalls: true,
610
607
  }),
611
608
  // The id is client-generated, so the service can tell a repeat. A
612
609
  // hook waiting on this answer is let go before the retry: a match
@@ -616,9 +613,7 @@ export class RemoteRecorder {
616
613
  const b = body && typeof body === "object"
617
614
  ? body
618
615
  : {};
619
- // `id: null` is the service saying it kept a run of its own. Which run
620
- // that was matters: a turn must not count step hits against the
621
- // recording it is itself a repeat of (R-HIT-7).
616
+ // `id: null` is the service saying it kept a run of its own.
622
617
  if (entry.released && b.id === null) {
623
618
  logLine("recorder.matched_after_retry", {
624
619
  run: id,
@@ -679,23 +674,15 @@ export class RemoteRecorder {
679
674
  const body = {
680
675
  ...req,
681
676
  text: capText(req.text ?? "", TEXT_CAPS.intent, redactText),
682
- ...(typeof req.toolInput === "string"
683
- ? { toolInput: capJsonText(req.toolInput, DEFAULT_MAX_PAYLOAD_BYTES, redactJsonText) }
684
- : {}),
685
677
  };
686
678
  const answer = await this.request("POST", "/scenarios/match-intent", JSON.stringify(body), {
687
679
  signal: opts.signal,
688
680
  timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS,
689
681
  });
690
- // The whole answer, not just `matched`: the counting fields are what make
691
- // the observe-only period readable (segmented.md R-OUT-10), and an older
692
- // server simply sends none of them (R-COMPAT-2).
693
682
  const b = answer && typeof answer === "object" ? answer : {};
694
683
  return {
695
684
  matched: b.matched ?? null,
696
- stepHit: b.stepHit ?? null,
697
- bestStepSimilarity: b.bestStepSimilarity,
698
- segmentWouldArm: b.segmentWouldArm,
685
+ ...(typeof b.bestSimilarity === "number" ? { bestSimilarity: b.bestSimilarity } : {}),
699
686
  };
700
687
  }
701
688
  catch (err) {
@@ -713,36 +700,6 @@ export class RemoteRecorder {
713
700
  return null;
714
701
  }
715
702
  }
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
- }
746
703
  /**
747
704
  * The similar-prompt match for a run's create call (null = fresh run). Like
748
705
  * {@link runCreated}, answers null at once while a failure is on record —
@@ -805,8 +762,7 @@ export class RemoteRecorder {
805
762
  const intentSource = data.intent?.source;
806
763
  this.enqueueRun(runId, "step", sizeOf(toolInput, context, intentText), () =>
807
764
  // `intent` and `metadata` are omitted, not nulled, when absent, so an
808
- // older service sees the body it has always seen (segmented.md
809
- // R-COMPAT-1).
765
+ // older service sees the body it has always seen.
810
766
  this.post(`/recordings/runs/${runId}/steps`, {
811
767
  id,
812
768
  stepIndex,
@@ -1084,7 +1040,6 @@ export class RemoteRecorder {
1084
1040
  fallbackStepIndex: report.fallbackStepIndex,
1085
1041
  fallbackKind: report.fallbackKind,
1086
1042
  baselineEligible: report.baselineEligible,
1087
- sharedWith: report.sharedWith,
1088
1043
  // Why nothing ran, when nothing ran. Additive: an older service drops
1089
1044
  // the field and books the cost exactly as it always did.
1090
1045
  declined: report.declined,
@@ -1114,7 +1069,6 @@ export class RemoteRecorder {
1114
1069
  steps: report.steps?.length,
1115
1070
  stepsFailed: failed || undefined,
1116
1071
  baselineEligible: report.baselineEligible === false ? false : undefined,
1117
- sharedWith: report.sharedWith?.length,
1118
1072
  declined: report.declined,
1119
1073
  // `bir investigate` prints it beside the outcome: "redone by run_…".
1120
1074
  redoneBy: report.redoneBy?.runId,
@@ -49,8 +49,6 @@ 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),
54
52
  reportExecution: () => undefined,
55
53
  backfillContext: () => undefined,
56
54
  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, against the *session's own project directory*,
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
- * - **Path jail.** Every path is resolved against, and confined to, the
21
- * session's project directory ({@link jailed}), symlinks included. The
22
- * daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
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 leaves the project, `Bash` is not allowed, or the
76
- * input is not even the right shape. Maps to `not_run`: nothing happened, the
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 — the jail every path resolves against.
94
- * The caller (controller.ts) refuses to execute at all without one; the
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