@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/README.md +113 -15
- package/commit-policy.ts +82 -10
- package/edit-policy.ts +121 -45
- package/folder-policy.ts +2165 -0
- package/index.ts +2493 -270
- package/package.json +9 -2
- package/read-policy-redact.ts +359 -0
- package/read-policy-tools.ts +312 -0
- package/read-policy.ts +672 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +152 -52
- package/review-tools.ts +228 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1066 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +166 -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,
|
|
@@ -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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
987
|
+
ops = ensureTranscript().ingest(event);
|
|
932
988
|
} catch {
|
|
933
|
-
/*
|
|
989
|
+
/* a malformed event must not break the review */
|
|
934
990
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
failure.
|
|
950
|
-
)
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
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
|
-
|
|
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
|
|
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));
|