@dieulc/workflow 0.10.0 → 0.11.0
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 +36 -14
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +1958 -258
- package/package.json +9 -2
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +142 -52
- package/review-tools.ts +169 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
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,
|
|
@@ -392,6 +394,31 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
392
394
|
| { message: string; retries: number; at: number }
|
|
393
395
|
| undefined;
|
|
394
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Agent runs stopped by Review Mode's own abort (pass complete, pause,
|
|
399
|
+
* teardown). The SDK's setup path can report such a stop as
|
|
400
|
+
* `stopReason: "error"` with an abort-shaped message, so the subscriber
|
|
401
|
+
* pairs this expectation with `classifyAssistantStop` before suppressing it.
|
|
402
|
+
* Bounded by two anchors: reset before every pass and on every settle.
|
|
403
|
+
*/
|
|
404
|
+
let expectedAbortEnds = 0;
|
|
405
|
+
|
|
406
|
+
/** Record that the next child run end was requested by this runtime. */
|
|
407
|
+
const noteExpectedAbort = (): void => {
|
|
408
|
+
expectedAbortEnds += 1;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Consume one pending self-abort expectation; false when none is pending. */
|
|
412
|
+
const consumeExpectedAbort = (): boolean => {
|
|
413
|
+
if (expectedAbortEnds <= 0) return false;
|
|
414
|
+
expectedAbortEnds -= 1;
|
|
415
|
+
return true;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const resetExpectedAborts = (): void => {
|
|
419
|
+
expectedAbortEnds = 0;
|
|
420
|
+
};
|
|
421
|
+
|
|
395
422
|
/** Ordered plan chunks for the current pass; reset on submit/abort/switch. */
|
|
396
423
|
let planAssembler:
|
|
397
424
|
| { chunks: string[]; nextIndex: number; totalChunks?: number }
|
|
@@ -470,6 +497,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
470
497
|
/** Drop the captured error when a new pass starts. */
|
|
471
498
|
const clearProviderError = (): void => {
|
|
472
499
|
lastProviderError = undefined;
|
|
500
|
+
// A pending self-abort expectation belongs to the run that already ended.
|
|
501
|
+
resetExpectedAborts();
|
|
473
502
|
};
|
|
474
503
|
|
|
475
504
|
const config = (): ReviewModeConfig => {
|
|
@@ -604,6 +633,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
604
633
|
/** Abort the reviewer's in-flight run. Best-effort; never throws. */
|
|
605
634
|
async function abortChild(): Promise<void> {
|
|
606
635
|
try {
|
|
636
|
+
noteExpectedAbort();
|
|
607
637
|
await session?.abort();
|
|
608
638
|
} catch {
|
|
609
639
|
/* cancellation is best-effort */
|
|
@@ -712,6 +742,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
712
742
|
async function disposeSession(options: { keepRoundController?: boolean } = {}): Promise<void> {
|
|
713
743
|
try {
|
|
714
744
|
if (options.keepRoundController !== true) abortController?.abort();
|
|
745
|
+
noteExpectedAbort();
|
|
715
746
|
await session?.abort();
|
|
716
747
|
} catch {
|
|
717
748
|
/* ignore */
|
|
@@ -920,44 +951,68 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
920
951
|
ensureTranscript();
|
|
921
952
|
const child = created.session;
|
|
922
953
|
session.subscribe((event) => {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
954
|
+
// D4: a round ends at `review_pass_done`/settle, but the child may
|
|
955
|
+
// still stream a closing tail. Once the round is inactive its events
|
|
956
|
+
// must never reach the main transcript (that tail used to appear
|
|
957
|
+
// while Build/Plan had already started).
|
|
958
|
+
if (!roundActive) return;
|
|
959
|
+
const type = readEventType(event);
|
|
960
|
+
// Classify BEFORE ingest/capture: a stop the runtime requested itself
|
|
961
|
+
// must never reach the transcript or the provider-error slot. The
|
|
962
|
+
// SDK can label that stop `error` with an abort-shaped message, so
|
|
963
|
+
// the expectation and the classification are both required.
|
|
964
|
+
const stop =
|
|
965
|
+
type === "agent_end"
|
|
966
|
+
? classifyAssistantStop(
|
|
967
|
+
(event as { messages?: unknown } | undefined)?.messages,
|
|
968
|
+
)
|
|
969
|
+
: undefined;
|
|
970
|
+
// Consume on EVERY agent_end so a real error after a silent abort
|
|
971
|
+
// cannot leave a stale expectation behind.
|
|
972
|
+
const expectedAbort = type === "agent_end" && consumeExpectedAbort();
|
|
973
|
+
const selfAbort = expectedAbort && stop?.kind === "aborted";
|
|
974
|
+
if (!selfAbort) {
|
|
975
|
+
let ops: ReviewTranscriptOp[] = [];
|
|
930
976
|
try {
|
|
931
|
-
|
|
977
|
+
ops = ensureTranscript().ingest(event);
|
|
932
978
|
} catch {
|
|
933
|
-
/*
|
|
979
|
+
/* a malformed event must not break the review */
|
|
934
980
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
failure.
|
|
950
|
-
)
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
981
|
+
if (ops.length > 0) {
|
|
982
|
+
try {
|
|
983
|
+
deps.onTranscriptOps?.(ops);
|
|
984
|
+
} catch {
|
|
985
|
+
/* streaming to the main transcript is display-only: never fatal */
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
// Capture provider failures as they happen, so a pass that ends
|
|
989
|
+
// without a submission can be diagnosed with the reviewer's real
|
|
990
|
+
// API error instead of a generic "no plan" message (see runRound).
|
|
991
|
+
// `aborted` is deliberately not captured: it is our own cancel.
|
|
992
|
+
try {
|
|
993
|
+
const failure = readProviderErrorEvent(event);
|
|
994
|
+
if (failure.kind === "retry") {
|
|
995
|
+
noteProviderError(failure.message, failure.retries);
|
|
996
|
+
} else if (failure.kind === "exhaust") {
|
|
997
|
+
noteProviderError(
|
|
998
|
+
failure.message ||
|
|
999
|
+
lastProviderError?.message ||
|
|
1000
|
+
"provider error (no detail)",
|
|
1001
|
+
failure.retries || lastProviderError?.retries || 0,
|
|
1002
|
+
);
|
|
1003
|
+
} else if (failure.kind === "stop") {
|
|
1004
|
+
noteProviderError(
|
|
1005
|
+
failure.message,
|
|
1006
|
+
lastProviderError?.retries ?? 0,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
} catch {
|
|
1010
|
+
/* error capture is diagnostics only: never break the review */
|
|
956
1011
|
}
|
|
957
|
-
} catch {
|
|
958
|
-
/* error capture is diagnostics only: never break the review */
|
|
959
1012
|
}
|
|
960
1013
|
if (type === "agent_settled") {
|
|
1014
|
+
// The abort (if any) has landed; no expectation may outlive it.
|
|
1015
|
+
resetExpectedAborts();
|
|
961
1016
|
noteSettled();
|
|
962
1017
|
} else if (type === "agent_end") {
|
|
963
1018
|
// Fallback for a runtime without `agent_settled`: a retry or a
|
|
@@ -1248,6 +1303,21 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1248
1303
|
return child.prompt(pass1Prompt);
|
|
1249
1304
|
});
|
|
1250
1305
|
|
|
1306
|
+
// A round aborted by ESC / the host signal must take the abort path even
|
|
1307
|
+
// when the in-flight send rejected first: the rejection is a symptom of
|
|
1308
|
+
// the abort, not a provider/send failure to recover from.
|
|
1309
|
+
if (p1.stop === "aborted" || rc.signal.aborted) {
|
|
1310
|
+
await abortChild();
|
|
1311
|
+
if (state) state.phase = "aborted";
|
|
1312
|
+
return {
|
|
1313
|
+
ok: false,
|
|
1314
|
+
fallback: true,
|
|
1315
|
+
planText: p1.submitted?.planText ?? originalPlan,
|
|
1316
|
+
planHash: hash,
|
|
1317
|
+
state: state as ReviewState,
|
|
1318
|
+
reason: "aborted",
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1251
1321
|
if (p1.stop === "error") {
|
|
1252
1322
|
await abortChild();
|
|
1253
1323
|
const sendMessage = errorText(p1.error);
|
|
@@ -1279,18 +1349,6 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1279
1349
|
}
|
|
1280
1350
|
return fail(lastFailure);
|
|
1281
1351
|
}
|
|
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
1352
|
if (!p1.submitted) {
|
|
1295
1353
|
// The run is over (or out of time) and nothing reached the plan file.
|
|
1296
1354
|
await abortChild();
|
|
@@ -1352,6 +1410,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1352
1410
|
cfg.passes > 1 &&
|
|
1353
1411
|
reviewNeedsVerification(state?.findings ?? []);
|
|
1354
1412
|
|
|
1413
|
+
// Pass 1's protocol submission is final; stop a tail that is still
|
|
1414
|
+
// streaming before deciding whether pass 2 runs. This is what keeps the
|
|
1415
|
+
// reviewer quiescent when the handoff opens.
|
|
1416
|
+
if (!wantVerify && session?.isStreaming()) {
|
|
1417
|
+
await abortChild();
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1355
1420
|
if (wantVerify) {
|
|
1356
1421
|
setPhase("verifying");
|
|
1357
1422
|
await settleInFlight(deadline, rc);
|
|
@@ -1369,16 +1434,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1369
1434
|
});
|
|
1370
1435
|
// Name the provider when the verification pass died on one; pass 2
|
|
1371
1436
|
// stays best-effort either way — pass 1's plan is never lost to it.
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1437
|
+
// A round abort (or an abort-shaped stop) is not provider health.
|
|
1438
|
+
const p2Send = p2.error === undefined ? "" : errorText(p2.error);
|
|
1439
|
+
const p2Aborted =
|
|
1440
|
+
rc.signal.aborted ||
|
|
1441
|
+
isAbortStopText(p2Send) ||
|
|
1442
|
+
isAbortStopText(lastProviderError?.message);
|
|
1443
|
+
const p2Provider =
|
|
1444
|
+
!p2Aborted && lastProviderError
|
|
1445
|
+
? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
|
|
1446
|
+
: undefined;
|
|
1375
1447
|
if (p2.error !== undefined) {
|
|
1376
1448
|
// Verification is an enhancement: never lose pass 1's plan over it.
|
|
1449
|
+
let failureText: string;
|
|
1450
|
+
if (p2Aborted) {
|
|
1451
|
+
failureText = "verification pass aborted";
|
|
1452
|
+
} else if (p2Provider) {
|
|
1453
|
+
failureText = `verification pass failed (${p2Provider}; send error: ${p2Send})`;
|
|
1454
|
+
} else {
|
|
1455
|
+
failureText = `verification pass failed (${p2Send})`;
|
|
1456
|
+
}
|
|
1377
1457
|
deps.log(
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
: `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
|
|
1381
|
-
"warning",
|
|
1458
|
+
`${failureText} — keeping pass 1's plan.`,
|
|
1459
|
+
p2Aborted ? "info" : "warning",
|
|
1382
1460
|
);
|
|
1383
1461
|
} else if (p2.submitted) {
|
|
1384
1462
|
finalPlan = p2.submitted.planText;
|
|
@@ -1427,8 +1505,19 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1427
1505
|
} catch {
|
|
1428
1506
|
/* listener cleanup is best-effort */
|
|
1429
1507
|
}
|
|
1430
|
-
|
|
1508
|
+
// D4: no terminal path may return while the reviewer child is still
|
|
1509
|
+
// streaming. `review_pass_done` ends the protocol, not the SDK run; the
|
|
1510
|
+
// remaining tail could still call review tools or write the plan while
|
|
1511
|
+
// the parent has moved to Build/Plan. The submission is already captured.
|
|
1512
|
+
// Mark the round inactive FIRST: the abort's own `agent_end` is teardown
|
|
1513
|
+
// noise, not a run result, and must never reach the transcript.
|
|
1431
1514
|
roundActive = false;
|
|
1515
|
+
try {
|
|
1516
|
+
if (session?.isStreaming()) await session.abort();
|
|
1517
|
+
} catch {
|
|
1518
|
+
/* cancellation is best-effort */
|
|
1519
|
+
}
|
|
1520
|
+
abortController = undefined;
|
|
1432
1521
|
}
|
|
1433
1522
|
}
|
|
1434
1523
|
|
|
@@ -1448,6 +1537,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1448
1537
|
async abortRound() {
|
|
1449
1538
|
try {
|
|
1450
1539
|
abortController?.abort();
|
|
1540
|
+
noteExpectedAbort();
|
|
1451
1541
|
await session?.abort();
|
|
1452
1542
|
} catch {
|
|
1453
1543
|
/* 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,
|
|
@@ -145,6 +155,35 @@ export function createReviewBashTool(
|
|
|
145
155
|
};
|
|
146
156
|
}
|
|
147
157
|
|
|
158
|
+
const guard = reviewerReadGuard(deps.cwd);
|
|
159
|
+
const readPaths = analyzeShellPaths(command, deps.cwd).reads;
|
|
160
|
+
const readEval = evaluateFolderTargets(readPaths, {
|
|
161
|
+
config: guard.config,
|
|
162
|
+
grants: [],
|
|
163
|
+
cwd: deps.cwd,
|
|
164
|
+
lockedRoots: [guard.root],
|
|
165
|
+
action: "read",
|
|
166
|
+
});
|
|
167
|
+
if (readEval.sensitive.length > 0 || readEval.outside.length > 0) {
|
|
168
|
+
const target =
|
|
169
|
+
readEval.sensitive[0]?.path ??
|
|
170
|
+
readEval.outside[0]?.path ??
|
|
171
|
+
"the requested path";
|
|
172
|
+
deps.log(
|
|
173
|
+
`Review Mode refused a read outside the repository/sensitive guard: ${target}`,
|
|
174
|
+
"warning",
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: "text",
|
|
180
|
+
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.`,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
isError: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
148
187
|
try {
|
|
149
188
|
const result = (await inner.execute(
|
|
150
189
|
toolCallId,
|
|
@@ -153,7 +192,7 @@ export function createReviewBashTool(
|
|
|
153
192
|
onUpdate,
|
|
154
193
|
ctx,
|
|
155
194
|
)) as ReviewToolTextResult;
|
|
156
|
-
return result;
|
|
195
|
+
return redactReviewResult(result, "bash", rawParams, deps.cwd, guard.config);
|
|
157
196
|
} catch (e: unknown) {
|
|
158
197
|
return {
|
|
159
198
|
content: [
|
|
@@ -1015,6 +1054,128 @@ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
|
|
|
1015
1054
|
};
|
|
1016
1055
|
}
|
|
1017
1056
|
|
|
1057
|
+
interface ReviewerReadGuard {
|
|
1058
|
+
root: string;
|
|
1059
|
+
config: FolderPolicyConfig;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function reviewerReadGuard(cwd: string): ReviewerReadGuard {
|
|
1063
|
+
return {
|
|
1064
|
+
root: detectRepoRoot(cwd) ?? cwd,
|
|
1065
|
+
config: {
|
|
1066
|
+
...DEFAULT_FOLDER_POLICY_CONFIG,
|
|
1067
|
+
mode: "cwd",
|
|
1068
|
+
outside: "block",
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function reviewerRefusal(target: FolderTarget): ReviewToolTextResult {
|
|
1074
|
+
return {
|
|
1075
|
+
content: [
|
|
1076
|
+
{
|
|
1077
|
+
type: "text",
|
|
1078
|
+
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.`,
|
|
1079
|
+
},
|
|
1080
|
+
],
|
|
1081
|
+
isError: true,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function guardReviewerTarget(
|
|
1086
|
+
tool: string,
|
|
1087
|
+
input: Record<string, unknown>,
|
|
1088
|
+
guard: ReviewerReadGuard,
|
|
1089
|
+
cwd: string,
|
|
1090
|
+
): ReviewToolTextResult | undefined {
|
|
1091
|
+
const raw = input.path;
|
|
1092
|
+
const value =
|
|
1093
|
+
typeof raw === "string" && raw.trim()
|
|
1094
|
+
? raw
|
|
1095
|
+
: tool === "read"
|
|
1096
|
+
? ""
|
|
1097
|
+
: cwd;
|
|
1098
|
+
if (!value) {
|
|
1099
|
+
return {
|
|
1100
|
+
content: [{ type: "text", text: `review ${tool}: missing path` }],
|
|
1101
|
+
isError: true,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
const target: FolderTarget = {
|
|
1105
|
+
path: value,
|
|
1106
|
+
kind: tool === "read" ? "file" : "dir",
|
|
1107
|
+
};
|
|
1108
|
+
const hit = matchSensitivePath(value, guard.config, cwd);
|
|
1109
|
+
const evaluation = evaluateFolderTargets([target], {
|
|
1110
|
+
config: guard.config,
|
|
1111
|
+
grants: [],
|
|
1112
|
+
cwd,
|
|
1113
|
+
lockedRoots: [guard.root],
|
|
1114
|
+
action: "read",
|
|
1115
|
+
});
|
|
1116
|
+
if (hit.sensitive || evaluation.sensitive.length > 0)
|
|
1117
|
+
return reviewerRefusal(target);
|
|
1118
|
+
if (evaluation.outside.length > 0) return reviewerRefusal(target);
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function redactReviewResult(
|
|
1123
|
+
result: ReviewToolTextResult,
|
|
1124
|
+
tool: string,
|
|
1125
|
+
input: Record<string, unknown>,
|
|
1126
|
+
cwd: string,
|
|
1127
|
+
config: FolderPolicyConfig,
|
|
1128
|
+
): ReviewToolTextResult {
|
|
1129
|
+
let redacted = false;
|
|
1130
|
+
const content = (result.content ?? []).map((block) => {
|
|
1131
|
+
if (block?.type !== "text") return block;
|
|
1132
|
+
const next = redactSensitiveText(block.text, tool, input, cwd, config);
|
|
1133
|
+
if (next.redacted) redacted = true;
|
|
1134
|
+
return next.redacted ? { ...block, text: next.text } : block;
|
|
1135
|
+
});
|
|
1136
|
+
if (!redacted) return result;
|
|
1137
|
+
return { ...result, content };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function createGuardedBuiltinTools(
|
|
1141
|
+
sdk: PiCodingAgentSdk | undefined,
|
|
1142
|
+
cwd: string,
|
|
1143
|
+
): { tools: unknown[]; unavailable: string[] } {
|
|
1144
|
+
const tools: unknown[] = [];
|
|
1145
|
+
const unavailable: string[] = [];
|
|
1146
|
+
const guard = reviewerReadGuard(cwd);
|
|
1147
|
+
const builders: Array<[string, string, unknown]> = [
|
|
1148
|
+
["read", "read", (sdk as any)?.createReadToolDefinition],
|
|
1149
|
+
["grep", "grep", (sdk as any)?.createGrepToolDefinition],
|
|
1150
|
+
["find", "find", (sdk as any)?.createFindToolDefinition],
|
|
1151
|
+
["ls", "ls", (sdk as any)?.createLsToolDefinition],
|
|
1152
|
+
];
|
|
1153
|
+
for (const [name, tool, builder] of builders) {
|
|
1154
|
+
if (typeof builder !== "function") {
|
|
1155
|
+
unavailable.push(`${name} (guarded builder unavailable)`);
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const definition = builder(cwd) as {
|
|
1159
|
+
name: string;
|
|
1160
|
+
execute: (...args: unknown[]) => Promise<unknown>;
|
|
1161
|
+
};
|
|
1162
|
+
tools.push({
|
|
1163
|
+
...definition,
|
|
1164
|
+
name,
|
|
1165
|
+
async execute(...args: unknown[]) {
|
|
1166
|
+
const input = (args[1] ?? {}) as Record<string, unknown>;
|
|
1167
|
+
const refusal = guardReviewerTarget(tool, input, guard, cwd);
|
|
1168
|
+
if (refusal) return refusal;
|
|
1169
|
+
const result = (await definition.execute(
|
|
1170
|
+
...args,
|
|
1171
|
+
)) as ReviewToolTextResult;
|
|
1172
|
+
return redactReviewResult(result, tool, input, cwd, guard.config);
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
return { tools, unavailable };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1018
1179
|
/**
|
|
1019
1180
|
* Build every tool the reviewer may use.
|
|
1020
1181
|
*/
|
|
@@ -1032,6 +1193,13 @@ export function createReviewTools(
|
|
|
1032
1193
|
unavailable.push("review_bash (createBashToolDefinition unavailable)");
|
|
1033
1194
|
}
|
|
1034
1195
|
|
|
1196
|
+
// The reviewer session has NO ambient extensions, so its built-in
|
|
1197
|
+
// read/grep/find/ls are otherwise a complete bypass of the read gate.
|
|
1198
|
+
// Custom tools with those names override the built-ins.
|
|
1199
|
+
const guarded = createGuardedBuiltinTools(sdk, deps.cwd);
|
|
1200
|
+
tools.push(...guarded.tools);
|
|
1201
|
+
unavailable.push(...guarded.unavailable);
|
|
1202
|
+
|
|
1035
1203
|
tools.push(createReviewExploreTool(deps.explore));
|
|
1036
1204
|
tools.push(createReviewPlanBeginTool(deps.submit));
|
|
1037
1205
|
tools.push(createReviewPlanChunkTool(deps.submit));
|