@dieulc/workflow 0.9.2 → 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 +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- 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-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: [
|
|
@@ -373,9 +412,28 @@ export interface ReviewSubmitDeps {
|
|
|
373
412
|
submit: (
|
|
374
413
|
payload: ReviewSubmitPayload,
|
|
375
414
|
) => Promise<{ ok: boolean; path?: string; error?: string }>;
|
|
415
|
+
/** Chunked submission is optional so older callers/tests keep working. */
|
|
416
|
+
beginPlan?: (totalChunks?: number) => {
|
|
417
|
+
ok: boolean;
|
|
418
|
+
error?: string;
|
|
419
|
+
maxChunkChars?: number;
|
|
420
|
+
};
|
|
421
|
+
appendPlanChunk?: (index: number, content: string) => {
|
|
422
|
+
ok: boolean;
|
|
423
|
+
error?: string;
|
|
424
|
+
received?: number;
|
|
425
|
+
nextIndex?: number;
|
|
426
|
+
totalChars?: number;
|
|
427
|
+
};
|
|
428
|
+
takeAssembledPlan?: () => string | undefined;
|
|
429
|
+
resetPlan?: () => void;
|
|
430
|
+
/** Max characters accepted per chunk (default 6000). */
|
|
431
|
+
maxChunkChars?: number;
|
|
376
432
|
log: (message: string, level?: "info" | "warning" | "error") => void;
|
|
377
433
|
}
|
|
378
434
|
|
|
435
|
+
const DEFAULT_MAX_CHUNK_CHARS = 6_000;
|
|
436
|
+
|
|
379
437
|
const VERDICT_SCHEMA = Type.Union([
|
|
380
438
|
Type.Literal("approve"),
|
|
381
439
|
Type.Literal("revise"),
|
|
@@ -413,6 +471,196 @@ const FINDING_SCHEMA = Type.Object({
|
|
|
413
471
|
]),
|
|
414
472
|
});
|
|
415
473
|
|
|
474
|
+
/**
|
|
475
|
+
* `review_plan_begin` — start a chunked rewrite submission.
|
|
476
|
+
*
|
|
477
|
+
* Long plan rewrites can outlive a gateway's stream duration. This resets the
|
|
478
|
+
* assembler; the reviewer then sends ordered chunks and finalises through
|
|
479
|
+
* `review_submit_plan` (with no `planMarkdown`). The single-shot path remains.
|
|
480
|
+
*/
|
|
481
|
+
export function createReviewPlanBeginTool(deps: ReviewSubmitDeps): {
|
|
482
|
+
name: string;
|
|
483
|
+
label: string;
|
|
484
|
+
description: string;
|
|
485
|
+
parameters: TSchema;
|
|
486
|
+
execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
|
|
487
|
+
} {
|
|
488
|
+
return {
|
|
489
|
+
name: "review_plan_begin",
|
|
490
|
+
label: "Begin Chunked Plan",
|
|
491
|
+
description:
|
|
492
|
+
"Start submitting the revised plan in ordered chunks. Use this when the plan is too long for one response, then call review_plan_chunk for each part and review_submit_plan (without planMarkdown) to finalise.",
|
|
493
|
+
parameters: Type.Object({
|
|
494
|
+
totalChunks: Type.Optional(
|
|
495
|
+
Type.Number({
|
|
496
|
+
description:
|
|
497
|
+
"How many chunks you intend to send. Optional; helps the harness report progress.",
|
|
498
|
+
}),
|
|
499
|
+
),
|
|
500
|
+
}),
|
|
501
|
+
async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
|
|
502
|
+
const [, rawParams] = args as unknown as [
|
|
503
|
+
string,
|
|
504
|
+
{ totalChunks?: unknown },
|
|
505
|
+
];
|
|
506
|
+
if (!deps.beginPlan) {
|
|
507
|
+
return {
|
|
508
|
+
content: [
|
|
509
|
+
{
|
|
510
|
+
type: "text",
|
|
511
|
+
text: "review_plan_begin: chunked submission is unavailable in this round; use review_submit_plan with the complete planMarkdown instead.",
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
isError: true,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const totalChunks =
|
|
518
|
+
typeof rawParams?.totalChunks === "number" &&
|
|
519
|
+
Number.isInteger(rawParams.totalChunks) &&
|
|
520
|
+
rawParams.totalChunks > 0
|
|
521
|
+
? rawParams.totalChunks
|
|
522
|
+
: undefined;
|
|
523
|
+
const result = deps.beginPlan(totalChunks);
|
|
524
|
+
if (!result.ok) {
|
|
525
|
+
return {
|
|
526
|
+
content: [
|
|
527
|
+
{
|
|
528
|
+
type: "text",
|
|
529
|
+
text: `review_plan_begin: ${result.error ?? "could not start chunked submission"}`,
|
|
530
|
+
},
|
|
531
|
+
],
|
|
532
|
+
isError: true,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const maxChunkChars =
|
|
536
|
+
result.maxChunkChars ?? deps.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS;
|
|
537
|
+
return {
|
|
538
|
+
content: [
|
|
539
|
+
{
|
|
540
|
+
type: "text",
|
|
541
|
+
text: [
|
|
542
|
+
"review_plan_begin: ready.",
|
|
543
|
+
`Send the plan in order with review_plan_chunk (max ${maxChunkChars} characters per chunk).`,
|
|
544
|
+
"After the last chunk, call review_plan_finalize (or review_submit_plan without planMarkdown).",
|
|
545
|
+
].join("\n"),
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
details: { totalChunks, maxChunkChars },
|
|
549
|
+
};
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** `review_plan_chunk` — append one ordered part of the rewrite. */
|
|
555
|
+
export function createReviewPlanChunkTool(deps: ReviewSubmitDeps): {
|
|
556
|
+
name: string;
|
|
557
|
+
label: string;
|
|
558
|
+
description: string;
|
|
559
|
+
parameters: TSchema;
|
|
560
|
+
execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
|
|
561
|
+
} {
|
|
562
|
+
return {
|
|
563
|
+
name: "review_plan_chunk",
|
|
564
|
+
label: "Append Plan Chunk",
|
|
565
|
+
description:
|
|
566
|
+
"Append one ordered chunk of the revised plan. Start with review_plan_begin, send index 1, 2, … without skipping or repeating, then finalise with review_submit_plan (no planMarkdown).",
|
|
567
|
+
parameters: Type.Object({
|
|
568
|
+
index: Type.Number({
|
|
569
|
+
description: "1-based chunk index. Must be the next expected index.",
|
|
570
|
+
}),
|
|
571
|
+
content: Type.String({
|
|
572
|
+
description:
|
|
573
|
+
"The chunk's markdown. Do not repeat earlier chunks or add a Review changes section.",
|
|
574
|
+
}),
|
|
575
|
+
}),
|
|
576
|
+
async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
|
|
577
|
+
const [, rawParams] = args as unknown as [
|
|
578
|
+
string,
|
|
579
|
+
{ index?: unknown; content?: unknown },
|
|
580
|
+
];
|
|
581
|
+
const index =
|
|
582
|
+
typeof rawParams?.index === "number" ? rawParams.index : NaN;
|
|
583
|
+
if (!Number.isInteger(index) || index < 1) {
|
|
584
|
+
return {
|
|
585
|
+
content: [
|
|
586
|
+
{
|
|
587
|
+
type: "text",
|
|
588
|
+
text: "review_plan_chunk: index must be a positive integer.",
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
isError: true,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
const content =
|
|
595
|
+
typeof rawParams?.content === "string" ? rawParams.content : "";
|
|
596
|
+
if (!content) {
|
|
597
|
+
return {
|
|
598
|
+
content: [
|
|
599
|
+
{
|
|
600
|
+
type: "text",
|
|
601
|
+
text: "review_plan_chunk: content is empty.",
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
isError: true,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
const maxChunkChars = deps.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS;
|
|
608
|
+
if (content.length > maxChunkChars) {
|
|
609
|
+
return {
|
|
610
|
+
content: [
|
|
611
|
+
{
|
|
612
|
+
type: "text",
|
|
613
|
+
text: `review_plan_chunk: chunk ${index} is ${content.length} characters; the limit is ${maxChunkChars}. Split it and resend.`,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
isError: true,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (!deps.appendPlanChunk) {
|
|
620
|
+
return {
|
|
621
|
+
content: [
|
|
622
|
+
{
|
|
623
|
+
type: "text",
|
|
624
|
+
text: "review_plan_chunk: chunked submission is unavailable in this round; use review_submit_plan with the complete planMarkdown instead.",
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
isError: true,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const result = deps.appendPlanChunk(index, content);
|
|
631
|
+
if (!result.ok) {
|
|
632
|
+
return {
|
|
633
|
+
content: [
|
|
634
|
+
{
|
|
635
|
+
type: "text",
|
|
636
|
+
text: `review_plan_chunk: ${result.error ?? "chunk rejected"}`,
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
isError: true,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
const nextIndex = result.nextIndex ?? index + 1;
|
|
643
|
+
return {
|
|
644
|
+
content: [
|
|
645
|
+
{
|
|
646
|
+
type: "text",
|
|
647
|
+
text: [
|
|
648
|
+
`review_plan_chunk: accepted chunk ${index}.`,
|
|
649
|
+
`received ${result.received ?? index} chunk(s), ${result.totalChars ?? content.length} characters so far.`,
|
|
650
|
+
`Next expected index: ${nextIndex}. When done, call review_submit_plan without planMarkdown.`,
|
|
651
|
+
].join("\n"),
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
details: {
|
|
655
|
+
index,
|
|
656
|
+
nextIndex,
|
|
657
|
+
totalChars: result.totalChars ?? content.length,
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
416
664
|
/**
|
|
417
665
|
* `review_submit_plan` — the ONLY write path for Review Mode.
|
|
418
666
|
*
|
|
@@ -438,12 +686,14 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
|
|
|
438
686
|
name: "review_submit_plan",
|
|
439
687
|
label: "Submit Reviewed Plan",
|
|
440
688
|
description:
|
|
441
|
-
"Submit the FINAL revised plan. This is the only way your work reaches the user, and the only file you can write. Pass the complete plan markdown (not a diff or a summary) plus your findings and verdict. The plan body must keep a markdown heading and stay close to the original length; the harness rejects stubs. A `## Review changes` section is appended automatically from your findings, so do not write it yourself. You may call this more than once — the last successful submission wins.",
|
|
689
|
+
"Submit the FINAL revised plan. This is the only way your work reaches the user, and the only file you can write. Pass the complete plan markdown (not a diff or a summary) plus your findings and verdict. After review_plan_begin/review_plan_chunk, omit planMarkdown and the harness uses the assembled chunks. The plan body must keep a markdown heading and stay close to the original length; the harness rejects stubs. A `## Review changes` section is appended automatically from your findings, so do not write it yourself. You may call this more than once — the last successful submission wins.",
|
|
442
690
|
parameters: Type.Object({
|
|
443
|
-
planMarkdown: Type.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
691
|
+
planMarkdown: Type.Optional(
|
|
692
|
+
Type.String({
|
|
693
|
+
description:
|
|
694
|
+
"The complete revised plan in markdown, preserving the original structure and heading style. Omit it after review_plan_begin/review_plan_chunk. Do NOT include a 'Review changes' section.",
|
|
695
|
+
}),
|
|
696
|
+
),
|
|
447
697
|
findings: Type.Array(FINDING_SCHEMA, {
|
|
448
698
|
description:
|
|
449
699
|
"Every issue you found. Use an empty array if the plan is sound and you are submitting it unchanged.",
|
|
@@ -466,10 +716,24 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
|
|
|
466
716
|
},
|
|
467
717
|
];
|
|
468
718
|
|
|
469
|
-
const
|
|
719
|
+
const explicitPlan =
|
|
470
720
|
typeof rawParams?.planMarkdown === "string"
|
|
471
721
|
? rawParams.planMarkdown
|
|
472
722
|
: "";
|
|
723
|
+
const planBody = explicitPlan.trim()
|
|
724
|
+
? explicitPlan
|
|
725
|
+
: deps.takeAssembledPlan?.() ?? "";
|
|
726
|
+
if (!planBody.trim()) {
|
|
727
|
+
return {
|
|
728
|
+
content: [
|
|
729
|
+
{
|
|
730
|
+
type: "text",
|
|
731
|
+
text: "review_submit_plan: no plan was provided. Pass planMarkdown, or use review_plan_begin + review_plan_chunk first.",
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
isError: true,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
473
737
|
const verdict: ReviewVerdict =
|
|
474
738
|
rawParams?.verdict === "approve" ||
|
|
475
739
|
rawParams?.verdict === "revise" ||
|
|
@@ -563,6 +827,11 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
|
|
|
563
827
|
};
|
|
564
828
|
}
|
|
565
829
|
|
|
830
|
+
try {
|
|
831
|
+
deps.resetPlan?.();
|
|
832
|
+
} catch {
|
|
833
|
+
/* the written plan is authoritative; reset is best-effort */
|
|
834
|
+
}
|
|
566
835
|
return {
|
|
567
836
|
content: [
|
|
568
837
|
{
|
|
@@ -590,6 +859,70 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
|
|
|
590
859
|
};
|
|
591
860
|
}
|
|
592
861
|
|
|
862
|
+
// ── Step 17: review_plan_finalize ────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* `review_plan_finalize` — finalise a chunked rewrite.
|
|
866
|
+
*
|
|
867
|
+
* The chunks were accepted by `review_plan_chunk`; this is the step that turns
|
|
868
|
+
* them into the plan file. It delegates to the SAME `review_submit_plan`
|
|
869
|
+
* funnel, so `writePlan` stays the only writer and `notifySubmitted` fires once.
|
|
870
|
+
*/
|
|
871
|
+
export function createReviewPlanFinalizeTool(deps: ReviewSubmitDeps): {
|
|
872
|
+
name: string;
|
|
873
|
+
label: string;
|
|
874
|
+
description: string;
|
|
875
|
+
parameters: TSchema;
|
|
876
|
+
execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
|
|
877
|
+
} {
|
|
878
|
+
const submit = createReviewSubmitPlanTool(deps);
|
|
879
|
+
return {
|
|
880
|
+
name: "review_plan_finalize",
|
|
881
|
+
label: "Finalize Chunked Plan",
|
|
882
|
+
description:
|
|
883
|
+
"Finalise the plan you sent with review_plan_begin + review_plan_chunk. The assembled chunks become the plan body; the findings and verdict are recorded exactly as review_submit_plan would. Refuses an incomplete assembly — send the missing chunks first.",
|
|
884
|
+
parameters: Type.Object({
|
|
885
|
+
findings: Type.Array(FINDING_SCHEMA, {
|
|
886
|
+
description:
|
|
887
|
+
"Every issue you found. Use an empty array if the plan is sound and you are submitting it unchanged.",
|
|
888
|
+
}),
|
|
889
|
+
verdict: VERDICT_SCHEMA,
|
|
890
|
+
summary: Type.String({
|
|
891
|
+
description: "One sentence describing what you changed.",
|
|
892
|
+
}),
|
|
893
|
+
}),
|
|
894
|
+
async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
|
|
895
|
+
const [toolCallId, rawParams, ...rest] = args as [
|
|
896
|
+
string,
|
|
897
|
+
Record<string, unknown>,
|
|
898
|
+
...unknown[],
|
|
899
|
+
];
|
|
900
|
+
let assembled: string | undefined;
|
|
901
|
+
try {
|
|
902
|
+
assembled = deps.takeAssembledPlan?.();
|
|
903
|
+
} catch {
|
|
904
|
+
assembled = undefined;
|
|
905
|
+
}
|
|
906
|
+
if (!assembled || !assembled.trim()) {
|
|
907
|
+
return {
|
|
908
|
+
content: [
|
|
909
|
+
{
|
|
910
|
+
type: "text",
|
|
911
|
+
text: "review_plan_finalize: no complete plan to finalise. Call review_plan_begin, send every chunk with review_plan_chunk (watch the expected next index), then finalise.",
|
|
912
|
+
},
|
|
913
|
+
],
|
|
914
|
+
isError: true,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
return submit.execute(
|
|
918
|
+
toolCallId,
|
|
919
|
+
{ ...(rawParams ?? {}) },
|
|
920
|
+
...rest,
|
|
921
|
+
);
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
593
926
|
// ── Step 15: review_pass_done ────────────────────────────────────────
|
|
594
927
|
|
|
595
928
|
/**
|
|
@@ -721,6 +1054,128 @@ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
|
|
|
721
1054
|
};
|
|
722
1055
|
}
|
|
723
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
|
+
|
|
724
1179
|
/**
|
|
725
1180
|
* Build every tool the reviewer may use.
|
|
726
1181
|
*/
|
|
@@ -738,7 +1193,17 @@ export function createReviewTools(
|
|
|
738
1193
|
unavailable.push("review_bash (createBashToolDefinition unavailable)");
|
|
739
1194
|
}
|
|
740
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
|
+
|
|
741
1203
|
tools.push(createReviewExploreTool(deps.explore));
|
|
1204
|
+
tools.push(createReviewPlanBeginTool(deps.submit));
|
|
1205
|
+
tools.push(createReviewPlanChunkTool(deps.submit));
|
|
1206
|
+
tools.push(createReviewPlanFinalizeTool(deps.submit));
|
|
742
1207
|
tools.push(createReviewSubmitPlanTool(deps.submit));
|
|
743
1208
|
tools.push(createReviewPassDoneTool(deps.passDone));
|
|
744
1209
|
|