@dieulc/workflow 0.10.0 → 0.11.1

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/review-runtime.ts CHANGED
@@ -27,9 +27,11 @@ import * as path from "node:path";
27
27
  import {
28
28
  DEFAULT_REVIEW_MODE_CONFIG,
29
29
  buildReviewerAttempts,
30
+ classifyAssistantStop,
30
31
  createReviewRoundGate,
31
32
  createReviewSession,
32
33
  createReviewState,
34
+ isAbortStopText,
33
35
  planHash,
34
36
  readProviderErrorEvent,
35
37
  reviewNeedsVerification,
@@ -73,6 +75,15 @@ const SETTLE_IN_FLIGHT_FALLBACK_MS = 60_000;
73
75
  export interface ReviewRuntimeDeps {
74
76
  cwd: string;
75
77
  agentDir: string;
78
+ /**
79
+ * The parent's effective folder policy (sensitive extras, `excludePaths`)
80
+ * and read grants, handed to the reviewer's guarded tools. Optional so
81
+ * existing tests and embedders keep working.
82
+ */
83
+ folderPolicy?: () => {
84
+ config: import("./folder-policy.ts").FolderPolicyConfig;
85
+ grants: readonly string[];
86
+ };
76
87
  config: () => ReviewModeConfig | undefined;
77
88
  /** Resolve the reviewer role's model (falls back to the planner when unset). */
78
89
  reviewerModel: () =>
@@ -392,6 +403,31 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
392
403
  | { message: string; retries: number; at: number }
393
404
  | undefined;
394
405
 
406
+ /**
407
+ * Agent runs stopped by Review Mode's own abort (pass complete, pause,
408
+ * teardown). The SDK's setup path can report such a stop as
409
+ * `stopReason: "error"` with an abort-shaped message, so the subscriber
410
+ * pairs this expectation with `classifyAssistantStop` before suppressing it.
411
+ * Bounded by two anchors: reset before every pass and on every settle.
412
+ */
413
+ let expectedAbortEnds = 0;
414
+
415
+ /** Record that the next child run end was requested by this runtime. */
416
+ const noteExpectedAbort = (): void => {
417
+ expectedAbortEnds += 1;
418
+ };
419
+
420
+ /** Consume one pending self-abort expectation; false when none is pending. */
421
+ const consumeExpectedAbort = (): boolean => {
422
+ if (expectedAbortEnds <= 0) return false;
423
+ expectedAbortEnds -= 1;
424
+ return true;
425
+ };
426
+
427
+ const resetExpectedAborts = (): void => {
428
+ expectedAbortEnds = 0;
429
+ };
430
+
395
431
  /** Ordered plan chunks for the current pass; reset on submit/abort/switch. */
396
432
  let planAssembler:
397
433
  | { chunks: string[]; nextIndex: number; totalChunks?: number }
@@ -470,6 +506,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
470
506
  /** Drop the captured error when a new pass starts. */
471
507
  const clearProviderError = (): void => {
472
508
  lastProviderError = undefined;
509
+ // A pending self-abort expectation belongs to the run that already ended.
510
+ resetExpectedAborts();
473
511
  };
474
512
 
475
513
  const config = (): ReviewModeConfig => {
@@ -604,6 +642,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
604
642
  /** Abort the reviewer's in-flight run. Best-effort; never throws. */
605
643
  async function abortChild(): Promise<void> {
606
644
  try {
645
+ noteExpectedAbort();
607
646
  await session?.abort();
608
647
  } catch {
609
648
  /* cancellation is best-effort */
@@ -712,6 +751,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
712
751
  async function disposeSession(options: { keepRoundController?: boolean } = {}): Promise<void> {
713
752
  try {
714
753
  if (options.keepRoundController !== true) abortController?.abort();
754
+ noteExpectedAbort();
715
755
  await session?.abort();
716
756
  } catch {
717
757
  /* ignore */
@@ -791,6 +831,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
791
831
  const built = createReviewTools(sdk, {
792
832
  cwd: deps.cwd,
793
833
  log: deps.log,
834
+ folderPolicy: deps.folderPolicy,
794
835
  explore: {
795
836
  // Step 8: Planner recon rides along in the prompt — when it is
796
837
  // present the reviewer verifies instead of re-exploring.
@@ -920,44 +961,68 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
920
961
  ensureTranscript();
921
962
  const child = created.session;
922
963
  session.subscribe((event) => {
923
- let ops: ReviewTranscriptOp[] = [];
924
- try {
925
- ops = ensureTranscript().ingest(event);
926
- } catch {
927
- /* a malformed event must not break the review */
928
- }
929
- if (ops.length > 0) {
964
+ // D4: a round ends at `review_pass_done`/settle, but the child may
965
+ // still stream a closing tail. Once the round is inactive its events
966
+ // must never reach the main transcript (that tail used to appear
967
+ // while Build/Plan had already started).
968
+ if (!roundActive) return;
969
+ const type = readEventType(event);
970
+ // Classify BEFORE ingest/capture: a stop the runtime requested itself
971
+ // must never reach the transcript or the provider-error slot. The
972
+ // SDK can label that stop `error` with an abort-shaped message, so
973
+ // the expectation and the classification are both required.
974
+ const stop =
975
+ type === "agent_end"
976
+ ? classifyAssistantStop(
977
+ (event as { messages?: unknown } | undefined)?.messages,
978
+ )
979
+ : undefined;
980
+ // Consume on EVERY agent_end so a real error after a silent abort
981
+ // cannot leave a stale expectation behind.
982
+ const expectedAbort = type === "agent_end" && consumeExpectedAbort();
983
+ const selfAbort = expectedAbort && stop?.kind === "aborted";
984
+ if (!selfAbort) {
985
+ let ops: ReviewTranscriptOp[] = [];
930
986
  try {
931
- deps.onTranscriptOps?.(ops);
987
+ ops = ensureTranscript().ingest(event);
932
988
  } catch {
933
- /* streaming to the main transcript is display-only: never fatal */
989
+ /* a malformed event must not break the review */
934
990
  }
935
- }
936
- const type = readEventType(event);
937
- // Capture provider failures as they happen, so a pass that ends
938
- // without a submission can be diagnosed with the reviewer's real
939
- // API error instead of a generic "no plan" message (see runRound).
940
- try {
941
- const failure = readProviderErrorEvent(event);
942
- if (failure.kind === "retry") {
943
- noteProviderError(failure.message, failure.retries);
944
- } else if (failure.kind === "exhaust") {
945
- noteProviderError(
946
- failure.message ||
947
- lastProviderError?.message ||
948
- "provider error (no detail)",
949
- failure.retries || lastProviderError?.retries || 0,
950
- );
951
- } else if (failure.kind === "stop") {
952
- noteProviderError(
953
- failure.message,
954
- lastProviderError?.retries ?? 0,
955
- );
991
+ if (ops.length > 0) {
992
+ try {
993
+ deps.onTranscriptOps?.(ops);
994
+ } catch {
995
+ /* streaming to the main transcript is display-only: never fatal */
996
+ }
997
+ }
998
+ // Capture provider failures as they happen, so a pass that ends
999
+ // without a submission can be diagnosed with the reviewer's real
1000
+ // API error instead of a generic "no plan" message (see runRound).
1001
+ // `aborted` is deliberately not captured: it is our own cancel.
1002
+ try {
1003
+ const failure = readProviderErrorEvent(event);
1004
+ if (failure.kind === "retry") {
1005
+ noteProviderError(failure.message, failure.retries);
1006
+ } else if (failure.kind === "exhaust") {
1007
+ noteProviderError(
1008
+ failure.message ||
1009
+ lastProviderError?.message ||
1010
+ "provider error (no detail)",
1011
+ failure.retries || lastProviderError?.retries || 0,
1012
+ );
1013
+ } else if (failure.kind === "stop") {
1014
+ noteProviderError(
1015
+ failure.message,
1016
+ lastProviderError?.retries ?? 0,
1017
+ );
1018
+ }
1019
+ } catch {
1020
+ /* error capture is diagnostics only: never break the review */
956
1021
  }
957
- } catch {
958
- /* error capture is diagnostics only: never break the review */
959
1022
  }
960
1023
  if (type === "agent_settled") {
1024
+ // The abort (if any) has landed; no expectation may outlive it.
1025
+ resetExpectedAborts();
961
1026
  noteSettled();
962
1027
  } else if (type === "agent_end") {
963
1028
  // Fallback for a runtime without `agent_settled`: a retry or a
@@ -1248,6 +1313,21 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1248
1313
  return child.prompt(pass1Prompt);
1249
1314
  });
1250
1315
 
1316
+ // A round aborted by ESC / the host signal must take the abort path even
1317
+ // when the in-flight send rejected first: the rejection is a symptom of
1318
+ // the abort, not a provider/send failure to recover from.
1319
+ if (p1.stop === "aborted" || rc.signal.aborted) {
1320
+ await abortChild();
1321
+ if (state) state.phase = "aborted";
1322
+ return {
1323
+ ok: false,
1324
+ fallback: true,
1325
+ planText: p1.submitted?.planText ?? originalPlan,
1326
+ planHash: hash,
1327
+ state: state as ReviewState,
1328
+ reason: "aborted",
1329
+ };
1330
+ }
1251
1331
  if (p1.stop === "error") {
1252
1332
  await abortChild();
1253
1333
  const sendMessage = errorText(p1.error);
@@ -1279,18 +1359,6 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1279
1359
  }
1280
1360
  return fail(lastFailure);
1281
1361
  }
1282
- if (p1.stop === "aborted" || rc.signal.aborted) {
1283
- await abortChild();
1284
- if (state) state.phase = "aborted";
1285
- return {
1286
- ok: false,
1287
- fallback: true,
1288
- planText: p1.submitted?.planText ?? originalPlan,
1289
- planHash: hash,
1290
- state: state as ReviewState,
1291
- reason: "aborted",
1292
- };
1293
- }
1294
1362
  if (!p1.submitted) {
1295
1363
  // The run is over (or out of time) and nothing reached the plan file.
1296
1364
  await abortChild();
@@ -1352,6 +1420,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1352
1420
  cfg.passes > 1 &&
1353
1421
  reviewNeedsVerification(state?.findings ?? []);
1354
1422
 
1423
+ // Pass 1's protocol submission is final; stop a tail that is still
1424
+ // streaming before deciding whether pass 2 runs. This is what keeps the
1425
+ // reviewer quiescent when the handoff opens.
1426
+ if (!wantVerify && session?.isStreaming()) {
1427
+ await abortChild();
1428
+ }
1429
+
1355
1430
  if (wantVerify) {
1356
1431
  setPhase("verifying");
1357
1432
  await settleInFlight(deadline, rc);
@@ -1369,16 +1444,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1369
1444
  });
1370
1445
  // Name the provider when the verification pass died on one; pass 2
1371
1446
  // stays best-effort either way — pass 1's plan is never lost to it.
1372
- const p2Provider = lastProviderError
1373
- ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
1374
- : undefined;
1447
+ // A round abort (or an abort-shaped stop) is not provider health.
1448
+ const p2Send = p2.error === undefined ? "" : errorText(p2.error);
1449
+ const p2Aborted =
1450
+ rc.signal.aborted ||
1451
+ isAbortStopText(p2Send) ||
1452
+ isAbortStopText(lastProviderError?.message);
1453
+ const p2Provider =
1454
+ !p2Aborted && lastProviderError
1455
+ ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
1456
+ : undefined;
1375
1457
  if (p2.error !== undefined) {
1376
1458
  // Verification is an enhancement: never lose pass 1's plan over it.
1459
+ let failureText: string;
1460
+ if (p2Aborted) {
1461
+ failureText = "verification pass aborted";
1462
+ } else if (p2Provider) {
1463
+ failureText = `verification pass failed (${p2Provider}; send error: ${p2Send})`;
1464
+ } else {
1465
+ failureText = `verification pass failed (${p2Send})`;
1466
+ }
1377
1467
  deps.log(
1378
- p2Provider
1379
- ? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
1380
- : `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
1381
- "warning",
1468
+ `${failureText} — keeping pass 1's plan.`,
1469
+ p2Aborted ? "info" : "warning",
1382
1470
  );
1383
1471
  } else if (p2.submitted) {
1384
1472
  finalPlan = p2.submitted.planText;
@@ -1427,8 +1515,19 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1427
1515
  } catch {
1428
1516
  /* listener cleanup is best-effort */
1429
1517
  }
1430
- abortController = undefined;
1518
+ // D4: no terminal path may return while the reviewer child is still
1519
+ // streaming. `review_pass_done` ends the protocol, not the SDK run; the
1520
+ // remaining tail could still call review tools or write the plan while
1521
+ // the parent has moved to Build/Plan. The submission is already captured.
1522
+ // Mark the round inactive FIRST: the abort's own `agent_end` is teardown
1523
+ // noise, not a run result, and must never reach the transcript.
1431
1524
  roundActive = false;
1525
+ try {
1526
+ if (session?.isStreaming()) await session.abort();
1527
+ } catch {
1528
+ /* cancellation is best-effort */
1529
+ }
1530
+ abortController = undefined;
1432
1531
  }
1433
1532
  }
1434
1533
 
@@ -1448,6 +1547,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1448
1547
  async abortRound() {
1449
1548
  try {
1450
1549
  abortController?.abort();
1550
+ noteExpectedAbort();
1451
1551
  await session?.abort();
1452
1552
  } catch {
1453
1553
  /* cancellation is best-effort */
package/review-tools.ts CHANGED
@@ -29,6 +29,16 @@ import {
29
29
  type ReviewFinding,
30
30
  } from "./utils.ts";
31
31
  import { explainCommandRefusal } from "./permissions.ts";
32
+ import { analyzeShellPaths } from "./shell-paths.ts";
33
+ import {
34
+ DEFAULT_FOLDER_POLICY_CONFIG,
35
+ detectRepoRoot,
36
+ evaluateFolderTargets,
37
+ matchSensitivePath,
38
+ type FolderPolicyConfig,
39
+ type FolderTarget,
40
+ } from "./folder-policy.ts";
41
+ import { redactSensitiveText } from "./read-policy-redact.ts";
32
42
  import type {
33
43
  PiCodingAgentSdk,
34
44
  ReviewPassResult,
@@ -48,6 +58,11 @@ export interface ReviewBashDeps {
48
58
  cwd: string;
49
59
  /** Surface refusals and anomalies to the parent session. */
50
60
  log: (message: string, level?: "info" | "warning" | "error") => void;
61
+ /** The parent's folder policy + read grants (see ReviewToolDeps). */
62
+ folderPolicy?: () => {
63
+ config: FolderPolicyConfig;
64
+ grants: readonly string[];
65
+ };
51
66
  }
52
67
 
53
68
  /** Injectable shell-tool builder so this module is testable without the SDK. */
@@ -145,6 +160,41 @@ export function createReviewBashTool(
145
160
  };
146
161
  }
147
162
 
163
+ const guard = reviewerReadGuard(deps.cwd, deps.folderPolicy?.());
164
+ const readPaths = analyzeShellPaths(command, deps.cwd).reads;
165
+ const readEval = evaluateFolderTargets(readPaths, {
166
+ config: guard.config,
167
+ grants: guard.grants,
168
+ cwd: deps.cwd,
169
+ lockedRoots: [guard.root],
170
+ explicitGrants: guard.grants,
171
+ action: "read",
172
+ });
173
+ if (
174
+ readEval.sensitive.length > 0 ||
175
+ readEval.excluded.length > 0 ||
176
+ readEval.outside.length > 0
177
+ ) {
178
+ const target =
179
+ readEval.sensitive[0]?.path ??
180
+ readEval.excluded[0]?.path ??
181
+ readEval.outside[0]?.path ??
182
+ "the requested path";
183
+ deps.log(
184
+ `Review Mode refused a read outside the repository/sensitive guard: ${target}`,
185
+ "warning",
186
+ );
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: `review_bash: REFUSED — \`${target}\` is outside the reviewer's permitted repository or is a sensitive file. Work only inside the repository; do not work around this.`,
192
+ },
193
+ ],
194
+ isError: true,
195
+ };
196
+ }
197
+
148
198
  try {
149
199
  const result = (await inner.execute(
150
200
  toolCallId,
@@ -153,7 +203,14 @@ export function createReviewBashTool(
153
203
  onUpdate,
154
204
  ctx,
155
205
  )) as ReviewToolTextResult;
156
- return result;
206
+ return redactReviewResult(
207
+ result,
208
+ "bash",
209
+ rawParams,
210
+ deps.cwd,
211
+ guard.config,
212
+ { base: [guard.root] },
213
+ );
157
214
  } catch (e: unknown) {
158
215
  return {
159
216
  content: [
@@ -341,6 +398,15 @@ export interface ReviewToolDeps {
341
398
  explore: ReviewExploreDeps;
342
399
  submit: ReviewSubmitDeps;
343
400
  passDone: ReviewPassDoneDeps;
401
+ /**
402
+ * The parent's effective folder policy (sensitive extras, `excludePaths`)
403
+ * and read grants. The reviewer is a separate session with no ambient
404
+ * extensions, so without this it would ignore the user's deny layer.
405
+ */
406
+ folderPolicy?: () => {
407
+ config: FolderPolicyConfig;
408
+ grants: readonly string[];
409
+ };
344
410
  }
345
411
 
346
412
  // ── Steps 9-11: review_submit_plan ───────────────────────────────────
@@ -460,6 +526,10 @@ export function createReviewPlanBeginTool(deps: ReviewSubmitDeps): {
460
526
  ),
461
527
  }),
462
528
  async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
529
+ // SAFETY: Pi calls every tool `execute(toolCallId, params, …)`,
530
+ // so index 1 is the model-supplied params object. This tool's
531
+ // schema declares `totalChunks`, which is narrowed below before
532
+ // use — nothing else is read off the value.
463
533
  const [, rawParams] = args as unknown as [
464
534
  string,
465
535
  { totalChunks?: unknown },
@@ -535,6 +605,9 @@ export function createReviewPlanChunkTool(deps: ReviewSubmitDeps): {
535
605
  }),
536
606
  }),
537
607
  async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
608
+ // SAFETY: Pi calls every tool `execute(toolCallId, params, …)`,
609
+ // so index 1 is the model-supplied params object. `index` and
610
+ // `content` are narrowed below before use.
538
611
  const [, rawParams] = args as unknown as [
539
612
  string,
540
613
  { index?: unknown; content?: unknown },
@@ -1015,6 +1088,149 @@ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
1015
1088
  };
1016
1089
  }
1017
1090
 
1091
+ interface ReviewerReadGuard {
1092
+ root: string;
1093
+ config: FolderPolicyConfig;
1094
+ /** Explicit grants that can re-open an excluded subtree. */
1095
+ grants: readonly string[];
1096
+ }
1097
+
1098
+ function reviewerReadGuard(
1099
+ cwd: string,
1100
+ parent?: { config: FolderPolicyConfig; grants: readonly string[] },
1101
+ ): ReviewerReadGuard {
1102
+ return {
1103
+ root: detectRepoRoot(cwd) ?? cwd,
1104
+ config: {
1105
+ ...(parent?.config ?? DEFAULT_FOLDER_POLICY_CONFIG),
1106
+ mode: "cwd",
1107
+ outside: "block",
1108
+ },
1109
+ grants: parent?.grants ?? [],
1110
+ };
1111
+ }
1112
+
1113
+ function reviewerRefusal(target: FolderTarget): ReviewToolTextResult {
1114
+ return {
1115
+ content: [
1116
+ {
1117
+ type: "text",
1118
+ text: `review read: REFUSED — \`${target.path}\` is outside the reviewer's repository or is a sensitive file. Work inside the repository; sensitive files stay blocked.`,
1119
+ },
1120
+ ],
1121
+ isError: true,
1122
+ };
1123
+ }
1124
+
1125
+ function guardReviewerTarget(
1126
+ tool: string,
1127
+ input: Record<string, unknown>,
1128
+ guard: ReviewerReadGuard,
1129
+ cwd: string,
1130
+ ): ReviewToolTextResult | undefined {
1131
+ const raw = input.path;
1132
+ const value =
1133
+ typeof raw === "string" && raw.trim()
1134
+ ? raw
1135
+ : tool === "read"
1136
+ ? ""
1137
+ : cwd;
1138
+ if (!value) {
1139
+ return {
1140
+ content: [{ type: "text", text: `review ${tool}: missing path` }],
1141
+ isError: true,
1142
+ };
1143
+ }
1144
+ const target: FolderTarget = {
1145
+ path: value,
1146
+ kind: tool === "read" ? "file" : "dir",
1147
+ };
1148
+ const hit = matchSensitivePath(value, guard.config, cwd);
1149
+ const evaluation = evaluateFolderTargets([target], {
1150
+ config: guard.config,
1151
+ grants: guard.grants,
1152
+ cwd,
1153
+ lockedRoots: [guard.root],
1154
+ explicitGrants: guard.grants,
1155
+ action: "read",
1156
+ });
1157
+ if (hit.sensitive || evaluation.sensitive.length > 0)
1158
+ return reviewerRefusal(target);
1159
+ // The excluded folder itself, or a path inside it that no approved grant
1160
+ // re-opens, is refused to the reviewer too.
1161
+ if (evaluation.excluded.length > 0) return reviewerRefusal(target);
1162
+ if (evaluation.outside.length > 0) return reviewerRefusal(target);
1163
+ return undefined;
1164
+ }
1165
+
1166
+ function redactReviewResult(
1167
+ result: ReviewToolTextResult,
1168
+ tool: string,
1169
+ input: Record<string, unknown>,
1170
+ cwd: string,
1171
+ config: FolderPolicyConfig,
1172
+ roots?: { base?: readonly string[]; grants?: readonly string[] },
1173
+ ): ReviewToolTextResult {
1174
+ let redacted = false;
1175
+ const content = (result.content ?? []).map((block) => {
1176
+ if (block?.type !== "text") return block;
1177
+ const next = redactSensitiveText(
1178
+ block.text,
1179
+ tool,
1180
+ input,
1181
+ cwd,
1182
+ config,
1183
+ roots,
1184
+ );
1185
+ if (next.redacted) redacted = true;
1186
+ return next.redacted ? { ...block, text: next.text } : block;
1187
+ });
1188
+ if (!redacted) return result;
1189
+ return { ...result, content };
1190
+ }
1191
+
1192
+ function createGuardedBuiltinTools(
1193
+ sdk: PiCodingAgentSdk | undefined,
1194
+ cwd: string,
1195
+ parent?: { config: FolderPolicyConfig; grants: readonly string[] },
1196
+ ): { tools: unknown[]; unavailable: string[] } {
1197
+ const tools: unknown[] = [];
1198
+ const unavailable: string[] = [];
1199
+ const guard = reviewerReadGuard(cwd, parent);
1200
+ const builders: Array<[string, string, unknown]> = [
1201
+ ["read", "read", (sdk as any)?.createReadToolDefinition],
1202
+ ["grep", "grep", (sdk as any)?.createGrepToolDefinition],
1203
+ ["find", "find", (sdk as any)?.createFindToolDefinition],
1204
+ ["ls", "ls", (sdk as any)?.createLsToolDefinition],
1205
+ ];
1206
+ for (const [name, tool, builder] of builders) {
1207
+ if (typeof builder !== "function") {
1208
+ unavailable.push(`${name} (guarded builder unavailable)`);
1209
+ continue;
1210
+ }
1211
+ const definition = builder(cwd) as {
1212
+ name: string;
1213
+ execute: (...args: unknown[]) => Promise<unknown>;
1214
+ };
1215
+ tools.push({
1216
+ ...definition,
1217
+ name,
1218
+ async execute(...args: unknown[]) {
1219
+ const input = (args[1] ?? {}) as Record<string, unknown>;
1220
+ const refusal = guardReviewerTarget(tool, input, guard, cwd);
1221
+ if (refusal) return refusal;
1222
+ const result = (await definition.execute(
1223
+ ...args,
1224
+ )) as ReviewToolTextResult;
1225
+ return redactReviewResult(result, tool, input, cwd, guard.config, {
1226
+ base: [guard.root],
1227
+ });
1228
+ },
1229
+ });
1230
+ }
1231
+ return { tools, unavailable };
1232
+ }
1233
+
1018
1234
  /**
1019
1235
  * Build every tool the reviewer may use.
1020
1236
  */
@@ -1032,6 +1248,17 @@ export function createReviewTools(
1032
1248
  unavailable.push("review_bash (createBashToolDefinition unavailable)");
1033
1249
  }
1034
1250
 
1251
+ // The reviewer session has NO ambient extensions, so its built-in
1252
+ // read/grep/find/ls are otherwise a complete bypass of the read gate.
1253
+ // Custom tools with those names override the built-ins.
1254
+ const guarded = createGuardedBuiltinTools(
1255
+ sdk,
1256
+ deps.cwd,
1257
+ deps.folderPolicy?.(),
1258
+ );
1259
+ tools.push(...guarded.tools);
1260
+ unavailable.push(...guarded.unavailable);
1261
+
1035
1262
  tools.push(createReviewExploreTool(deps.explore));
1036
1263
  tools.push(createReviewPlanBeginTool(deps.submit));
1037
1264
  tools.push(createReviewPlanChunkTool(deps.submit));