@erichll/pi-auto-review 0.3.5 → 0.5.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/src/policy.ts CHANGED
@@ -47,16 +47,105 @@ export type TranscriptConfig = {
47
47
 
48
48
  export type TranscriptResult = {
49
49
  text: string;
50
+ surfaceProfile: EvidenceSurfaceProfile;
51
+ reviewerEvidence: {
52
+ userMessages: ReviewerEvidenceItem[];
53
+ toolCalls: ReviewerEvidenceItem[];
54
+ relevantResults: ReviewerEvidenceItem[];
55
+ };
56
+ budgetRemovals: ReviewerBudgetRemoval[];
50
57
  userCharacters: number;
51
58
  toolCharacters: number;
52
59
  relevantResultCharacters: number;
53
60
  truncated: boolean;
61
+ selectedCandidates: EvidenceCandidateMetadata[];
62
+ failureCode?:
63
+ | "critical_evidence_overflow"
64
+ | "required_profile_overflow"
65
+ | "required_user_constraint_overflow"
66
+ | "reviewer_input_budget_exceeded";
67
+ userAuthorizationCeiling: "unknown" | "low" | "medium" | "high";
68
+ userConstraint: "none" | "narrowed" | "revoked";
69
+ compactionState: "none" | "summary-present" | "authorization-unavailable";
70
+ };
71
+
72
+ export type ReviewerBudgetRemoval = {
73
+ reason:
74
+ | "secondary-reasons"
75
+ | "older-structured-tool"
76
+ | "optional-result";
77
+ count: number;
78
+ };
79
+
80
+ export type ReviewerEvidenceItem = {
81
+ id: string;
82
+ reason: EvidenceSelectionReason;
83
+ secondaryReasons: EvidenceSelectionReason[];
84
+ toolCallId?: string;
85
+ content: string;
54
86
  };
55
87
 
56
88
  type Evidence = {
89
+ id: string;
57
90
  index: number;
91
+ entryIndex: number;
58
92
  kind: "user" | "tool";
59
93
  text: string;
94
+ toolCallId?: string;
95
+ reason: EvidenceSelectionReason;
96
+ secondaryReasons: EvidenceSelectionReason[];
97
+ sensitivity: EvidenceSensitivity;
98
+ originalCharacters: number;
99
+ preTruncated: boolean;
100
+ representationCharacters?: number;
101
+ };
102
+
103
+ export type EvidenceSensitivity =
104
+ | "user-intent"
105
+ | "tool-input"
106
+ | "tool-output"
107
+ | "boundary"
108
+ | "credential"
109
+ | "permission-control"
110
+ | "authorization-persistence"
111
+ | "transport-weakening"
112
+ | "audit-control"
113
+ | "security-control";
114
+
115
+ export type EvidenceSelectionReason =
116
+ | "latest-user"
117
+ | "exact-request-reference"
118
+ | "active-narrowing-constraint"
119
+ | "trusted-retry-user-message"
120
+ | "exact-tool-call"
121
+ | "structured-request-match"
122
+ | "security-combination"
123
+ | "same-tool"
124
+ | "delete-precheck"
125
+ | "git-push-context"
126
+ | "provider-branch-protection"
127
+ | "sandbox-trap";
128
+
129
+ export type EvidenceSurfaceProfile =
130
+ | "network"
131
+ | "delete"
132
+ | "git-push"
133
+ | "forwarded"
134
+ | "generic";
135
+
136
+ export type EvidenceCandidateMetadata = {
137
+ id: string;
138
+ entryIndex: number;
139
+ kind: "user" | "tool-call" | "tool-result" | "sandbox-trap";
140
+ toolCallId?: string;
141
+ reason: EvidenceSelectionReason;
142
+ secondaryReasons: EvidenceSelectionReason[];
143
+ surfaceProfile: EvidenceSurfaceProfile;
144
+ originalCharacters: number;
145
+ selectedCharacters: number;
146
+ estimatedTokens: number;
147
+ truncated: boolean;
148
+ sensitivity: EvidenceSensitivity;
60
149
  };
61
150
 
62
151
  export type RelevantBoundaryRequest = {
@@ -64,16 +153,40 @@ export type RelevantBoundaryRequest = {
64
153
  source?: string;
65
154
  surface?: string;
66
155
  operation?: string;
156
+ cwd?: string;
67
157
  command?: string;
68
158
  path?: string;
69
159
  resolvedPath?: string;
70
160
  destination?: string;
71
161
  toolCallId?: string;
72
162
  toolName?: string;
163
+ toolInputPreview?: string;
164
+ agentName?: string;
165
+ requesterSessionId?: string;
166
+ accessIntent?: {
167
+ surface: string;
168
+ matchValues: readonly string[];
169
+ boundaryValue?: string;
170
+ };
171
+ trustedRetryOriginalRequestId?: string;
73
172
  };
74
173
 
75
174
  const MAX_EVIDENCE_ITEM_CHARACTERS = 4_000;
76
175
 
176
+ /** Stable compact JSON for reviewer-only representations. */
177
+ export function canonicalReviewerJson(value: unknown): string {
178
+ return JSON.stringify(value, (_key, current: unknown) => {
179
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
180
+ return current;
181
+ }
182
+ return Object.fromEntries(
183
+ Object.keys(current as Record<string, unknown>)
184
+ .sort()
185
+ .map((key) => [key, (current as Record<string, unknown>)[key]]),
186
+ );
187
+ }) ?? "null";
188
+ }
189
+
77
190
  function exactKeys(
78
191
  record: Record<string, unknown>,
79
192
  expected: readonly string[],
@@ -298,15 +411,139 @@ export function deterministicHardDeny(
298
411
  };
299
412
  }
300
413
 
301
- const credentialSource =
302
- /(?:\/|~\/|\$HOME\/|\$\{HOME\}\/)(?:\.ssh\/(?:id_[A-Za-z0-9_-]+|authorized_keys)|\.aws\/credentials|\.kube\/config|\.docker\/config\.json|\.npmrc|\.netrc|\.pi\/agent\/auth\.json)|(?:^|[\/@\s])\.env(?:\s|$)/i;
303
- const networkUpload =
304
- /\b(?:curl\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T)|wget\b[^;\n]*(?:--post-file|--post-data)|(?:nc|ncat|socat)\b)/i;
305
- const credentialPipe =
306
- /(?:cat|sed|awk|base64|openssl)\b[^|;\n]*(?:\.ssh|\.aws|\.kube|\.docker|\.npmrc|\.netrc|\.env|auth\.json)[^|;\n]*\|[^;\n]*\b(?:curl|wget|nc|ncat|socat)\b/i;
414
+ // Secret path text is only evidence when it occurs as the operand of an
415
+ // operation that reads a file. In particular, a slash-delimited `.env` in
416
+ // a URL or literal request payload is not itself a credential source.
417
+ const envFileName =
418
+ String.raw`\.env(?:\.(?!example\b|sample\b)[A-Za-z0-9_-]*)*`;
419
+ const envFile =
420
+ envFileName + String.raw`(?=[\s"'/@<>=|;&)\`]|$)`;
421
+ const envPath =
422
+ String.raw`(?:[^\s"'@<>=|;&()]+\/)*` + envFile;
423
+ const credentialDirectoryPath =
424
+ String.raw`(?:~\/|\$HOME\/|\$\{HOME\}\/|\/|\.\.?\/)?` +
425
+ String.raw`(?:[^\s"'@<>=|;&()\/]+\/)*` +
426
+ String.raw`(?:\.ssh\/(?:id_[A-Za-z0-9_-]+|authorized_keys)|\.aws\/credentials|\.kube\/config|\.docker\/config\.json|\.npmrc|\.netrc|\.pi\/agent\/auth\.json)`;
427
+ const credentialPath =
428
+ String.raw`(?:` +
429
+ credentialDirectoryPath +
430
+ String.raw`|` + envPath + String.raw`)`;
431
+ const directCredentialUpload = new RegExp(
432
+ String.raw`\b(?:curl|wget)\b[^;\n]*(?:` +
433
+ String.raw`(?:--data(?:-binary|-urlencode)?|-d|--form|-F)(?:=|\s+)[^;\n\s]*@\s*["']?` + credentialPath +
434
+ String.raw`|(?:--upload-file|--post-file)(?:=|\s+)["']?` + credentialPath +
435
+ String.raw`|-T(?:\s*|=)["']?` + credentialPath +
436
+ String.raw`)["']?`,
437
+ "i",
438
+ );
439
+ const redirectedCredentialUpload = new RegExp(
440
+ String.raw`\b(?:` +
441
+ String.raw`(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*` +
442
+ String.raw`|(?:nc|ncat|socat)\b[^;\n]*` +
443
+ String.raw`)<\s*["']?` +
444
+ credentialPath,
445
+ "i",
446
+ );
447
+ // The pipe matcher covers any content-emitting reader that names a secret
448
+ // file (a bare filename such as `head .env`, `dd if=.env`, or a path like
449
+ // ~/.aws/credentials) and feeds a network sink. The producer set is
450
+ // enumerated rather than derived from directCredentialUpload because a bare
451
+ // filename argument carries no path/@/redirect evidence; keeping the list
452
+ // broad is fail-closed and cheap, since a match also requires a secret-file
453
+ // token before the pipe and a network sink after it. This is best-effort
454
+ // hardening, not a completeness guarantee: readers not on the list still
455
+ // fall through to the contextual matchers below or the dynamic reviewer.
456
+ // The leading lookbehind keeps the producers from
457
+ // matching mid-word (star, code) so the terms stay real tool tokens.
458
+ const credentialPipe = new RegExp(
459
+ String.raw`(?<![A-Za-z0-9_])(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|sort|cut|strings|rev|uniq|fold|od|xxd|hexdump|base32|uuencode|gzip|bzip2|xz|zstd|tar|zip)\b[^|;\n]*(?:\.ssh|\.aws|\.kube|\.docker|\.npmrc|\.netrc|` +
460
+ String.raw`(?:^|[\s"'=/@])` +
461
+ envFile +
462
+ String.raw`|auth\.json)[^|;\n]*\|[^;\n]*\b(?:curl|wget|nc|ncat|socat)\b`,
463
+ "i",
464
+ );
465
+ const credentialReader =
466
+ String.raw`(?<![A-Za-z0-9_])(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|sort|cut|strings|rev|uniq|fold|od|xxd|hexdump|base32|uuencode|gzip|bzip2|xz|zstd|tar|zip)\b[^|;\n]*` +
467
+ credentialPath;
468
+ const credentialShellExpansion =
469
+ String.raw`(?:\$\([^)]*` +
470
+ credentialReader +
471
+ String.raw`[^)]*\)|<\([^)]*` +
472
+ credentialReader +
473
+ String.raw`[^)]*\)|\`[^\`\n]*` +
474
+ credentialReader +
475
+ String.raw`[^\`\n]*\`)`;
476
+ const credentialExpansionUpload = new RegExp(
477
+ String.raw`\b(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*` +
478
+ credentialShellExpansion,
479
+ "i",
480
+ );
481
+ const redirectedCredentialExpansion = new RegExp(
482
+ String.raw`\b(?:nc|ncat|socat)\b[^;\n]*(?:<<<|<)\s*` +
483
+ credentialShellExpansion,
484
+ "i",
485
+ );
486
+ const stagedCredentialVariableRead = new RegExp(
487
+ String.raw`\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*` +
488
+ credentialShellExpansion,
489
+ "gi",
490
+ );
491
+ let stagedCredentialVariableUpload = false;
492
+ for (const match of command.matchAll(stagedCredentialVariableRead)) {
493
+ const variable = match[1];
494
+ const suffix = command.slice((match.index ?? 0) + match[0].length);
495
+ const escapedVariable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
496
+ const variableUpload = new RegExp(
497
+ String.raw`(?:\b(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*|\b(?:nc|ncat|socat)\b[^;\n]*<<<\s*["']?)` +
498
+ String.raw`(?:\$\{` +
499
+ escapedVariable +
500
+ String.raw`\}|\$` +
501
+ escapedVariable +
502
+ String.raw`(?![A-Za-z0-9_]))`,
503
+ "i",
504
+ );
505
+ if (variableUpload.test(suffix)) {
506
+ stagedCredentialVariableUpload = true;
507
+ break;
508
+ }
509
+ }
510
+ const credentialSubstitution = new RegExp(
511
+ String.raw`\b(?:nc|ncat|socat)\b[^;\n]*\$\([^)]*` +
512
+ credentialReader +
513
+ String.raw`[^)]*\)`,
514
+ "i",
515
+ );
516
+ const stagedCredentialRead = new RegExp(
517
+ credentialReader + String.raw`[^|;\n]*>\s*["']?([^\s"';&|]+)["']?`,
518
+ "gi",
519
+ );
520
+ let stagedCredentialUpload = false;
521
+ for (const match of command.matchAll(stagedCredentialRead)) {
522
+ const target = match[1];
523
+ const suffix = command.slice((match.index ?? 0) + match[0].length);
524
+ const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
525
+ const targetUpload = new RegExp(
526
+ String.raw`\b(?:curl|wget)\b[^;\n]*(?:` +
527
+ String.raw`(?:--data(?:-binary|-urlencode)?|-d|--form|-F)(?:=|\s+)[^;\n\s]*@\s*["']?` + escapedTarget +
528
+ String.raw`|(?:--upload-file|--post-file)(?:=|\s+)["']?` + escapedTarget +
529
+ String.raw`|-T(?:\s*|=)["']?` + escapedTarget +
530
+ String.raw`)(?=[\s"']|$)`,
531
+ "i",
532
+ );
533
+ if (targetUpload.test(suffix)) {
534
+ stagedCredentialUpload = true;
535
+ break;
536
+ }
537
+ }
307
538
  if (
308
- (credentialSource.test(command) && networkUpload.test(command)) ||
309
- credentialPipe.test(command)
539
+ directCredentialUpload.test(command) ||
540
+ redirectedCredentialUpload.test(command) ||
541
+ credentialPipe.test(command) ||
542
+ credentialExpansionUpload.test(command) ||
543
+ redirectedCredentialExpansion.test(command) ||
544
+ credentialSubstitution.test(command) ||
545
+ stagedCredentialVariableUpload ||
546
+ stagedCredentialUpload
310
547
  ) {
311
548
  return {
312
549
  rule: "credential-exfiltration",
@@ -368,7 +605,9 @@ function userText(message: Record<string, unknown>): string {
368
605
  .trim();
369
606
  }
370
607
 
371
- function toolTexts(message: Record<string, unknown>): string[] {
608
+ function toolTexts(
609
+ message: Record<string, unknown>,
610
+ ): Array<{ id?: string; name: string; text: string }> {
372
611
  if (message.role !== "assistant" || !Array.isArray(message.content)) {
373
612
  return [];
374
613
  }
@@ -378,13 +617,23 @@ function toolTexts(message: Record<string, unknown>): string[] {
378
617
  if (record.type !== "toolCall" || typeof record.name !== "string") {
379
618
  return [];
380
619
  }
381
- return [`${record.name} ${boundedString(record.arguments ?? {})}`];
620
+ const id =
621
+ typeof record.id === "string"
622
+ ? record.id
623
+ : typeof record.toolCallId === "string"
624
+ ? record.toolCallId
625
+ : undefined;
626
+ return [{
627
+ id,
628
+ name: record.name,
629
+ text: `${record.name} ${boundedString(record.arguments ?? {})}`,
630
+ }];
382
631
  });
383
632
  }
384
633
 
385
634
  function extractEvidence(entries: readonly unknown[]): Evidence[] {
386
635
  const evidence: Evidence[] = [];
387
- entries.forEach((entry) => {
636
+ entries.forEach((entry, entryIndex) => {
388
637
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
389
638
  const message = (entry as Record<string, unknown>).message;
390
639
  if (!message || typeof message !== "object" || Array.isArray(message)) {
@@ -394,13 +643,34 @@ function extractEvidence(entries: readonly unknown[]): Evidence[] {
394
643
  const user = userText(record);
395
644
  if (user) {
396
645
  evidence.push({
646
+ id: `${stableEntryId(entry, entryIndex)}:user`,
397
647
  index: evidence.length,
648
+ entryIndex,
398
649
  kind: "user",
399
650
  text: boundedString(user),
651
+ reason: "latest-user",
652
+ secondaryReasons: [],
653
+ sensitivity: "user-intent",
654
+ originalCharacters: user.length,
655
+ preTruncated: boundedString(user).length < user.length,
400
656
  });
401
657
  }
402
658
  for (const tool of toolTexts(record)) {
403
- evidence.push({ index: evidence.length, kind: "tool", text: tool });
659
+ evidence.push({
660
+ id: tool.id
661
+ ? `tool-call:${tool.id}`
662
+ : `${stableEntryId(entry, entryIndex)}:tool-${evidence.length}`,
663
+ index: evidence.length,
664
+ entryIndex,
665
+ kind: "tool",
666
+ text: tool.text,
667
+ toolCallId: tool.id,
668
+ reason: "structured-request-match",
669
+ secondaryReasons: [],
670
+ sensitivity: "tool-input",
671
+ originalCharacters: tool.text.length,
672
+ preTruncated: false,
673
+ });
404
674
  }
405
675
  });
406
676
  return evidence;
@@ -411,6 +681,8 @@ type ToolCallRecord = {
411
681
  name: string;
412
682
  arguments: unknown;
413
683
  rendered: string;
684
+ entryIndex: number;
685
+ candidateId: string;
414
686
  };
415
687
 
416
688
  function messageRecord(entry: unknown): Record<string, unknown> | undefined {
@@ -420,6 +692,14 @@ function messageRecord(entry: unknown): Record<string, unknown> | undefined {
420
692
  return message as Record<string, unknown>;
421
693
  }
422
694
 
695
+ function stableEntryId(entry: unknown, index: number): string {
696
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
697
+ const id = (entry as Record<string, unknown>).id;
698
+ if (typeof id === "string" && id.trim()) return `entry:${id}`;
699
+ }
700
+ return `entry-index:${index}`;
701
+ }
702
+
423
703
  function redactSensitiveResult(value: string): string {
424
704
  return value
425
705
  .replace(
@@ -544,20 +824,6 @@ function relevanceReason(
544
824
  | "provider-branch-protection"
545
825
  | undefined {
546
826
  if (request.toolCallId && call.id === request.toolCallId) return "same-tool";
547
- const callText = call.rendered.toLowerCase();
548
- const needles = [
549
- request.path,
550
- request.resolvedPath,
551
- request.destination,
552
- request.command,
553
- ]
554
- .filter((value): value is string => Boolean(value && value.length >= 3))
555
- .map((value) => value.toLowerCase());
556
- const namesMatch =
557
- Boolean(request.toolName) &&
558
- (call.name === request.toolName ||
559
- call.name.endsWith(`/${request.toolName}`) ||
560
- request.toolName?.endsWith(`/${call.name}`));
561
827
  const currentCommand = request.command || "";
562
828
  const priorCommand = commandArgument(call);
563
829
  const pushedBranch = explicitPushBranch(currentCommand);
@@ -566,13 +832,19 @@ function relevanceReason(
566
832
  /\b(?:rm|rmdir|unlink|trash|delete)\b/i.test(currentCommand);
567
833
  const readOnlyCheck =
568
834
  /^\s*(?:stat|ls|find|test|readlink|realpath)\b/i.test(priorCommand);
569
- const targets = [request.path, request.resolvedPath]
570
- .filter((value): value is string => Boolean(value))
571
- .map((value) => value.toLowerCase());
835
+ const priorWords = /[\n;&|`<>]|\$\(/.test(priorCommand)
836
+ ? []
837
+ : priorCommand
838
+ .trim()
839
+ .split(/\s+/)
840
+ .map((word) => word.replace(/^(["'])(.*)\1$/, "$2"));
841
+ const targets = [request.path, request.resolvedPath].filter(
842
+ (value): value is string => Boolean(value),
843
+ );
572
844
  if (
573
845
  destructive &&
574
846
  readOnlyCheck &&
575
- targets.some((target) => priorCommand.toLowerCase().includes(target))
847
+ targets.some((target) => priorWords.includes(target))
576
848
  ) {
577
849
  return "delete-precheck";
578
850
  }
@@ -591,21 +863,40 @@ function relevanceReason(
591
863
  ) {
592
864
  return "provider-branch-protection";
593
865
  }
594
- if (
595
- (namesMatch || needles.length > 0) &&
596
- needles.some((value) => callText.includes(value))
597
- ) {
598
- return "same-tool";
599
- }
600
866
  return undefined;
601
867
  }
602
868
 
603
869
  function relevantResultEvidence(
604
870
  entries: readonly unknown[],
605
871
  request: RelevantBoundaryRequest,
606
- ): Array<{ index: number; text: string }> {
872
+ currentTurnStart: number,
873
+ ): Array<{
874
+ index: number;
875
+ reason: Exclude<
876
+ EvidenceSelectionReason,
877
+ | "latest-user"
878
+ | "exact-request-reference"
879
+ | "active-narrowing-constraint"
880
+ | "trusted-retry-user-message"
881
+ | "exact-tool-call"
882
+ | "structured-request-match"
883
+ | "security-combination"
884
+ | "sandbox-trap"
885
+ >;
886
+ call: ToolCallRecord;
887
+ resultId: string;
888
+ resultText: string;
889
+ renderedResult: string;
890
+ }> {
607
891
  const calls = new Map<string, ToolCallRecord>();
608
- const results: Array<{ index: number; text: string }> = [];
892
+ const results: Array<{
893
+ index: number;
894
+ reason: "same-tool" | "delete-precheck" | "git-push-context" | "provider-branch-protection";
895
+ call: ToolCallRecord;
896
+ resultId: string;
897
+ resultText: string;
898
+ renderedResult: string;
899
+ }> = [];
609
900
  entries.forEach((entry, index) => {
610
901
  const message = messageRecord(entry);
611
902
  if (!message) return;
@@ -626,6 +917,8 @@ function relevantResultEvidence(
626
917
  name: record.name,
627
918
  arguments: record.arguments,
628
919
  rendered: `${record.name} ${boundedString(record.arguments ?? {})}`,
920
+ entryIndex: index,
921
+ candidateId: `tool-call:${id}`,
629
922
  });
630
923
  }
631
924
  return;
@@ -640,58 +933,728 @@ function relevantResultEvidence(
640
933
  if (!call) return;
641
934
  const reason = relevanceReason(call, request);
642
935
  const text = resultText(message);
643
- if (reason && text) {
936
+ const crossTurnAllowed = reason === "same-tool";
937
+ if (reason && text && (crossTurnAllowed || call.entryIndex >= currentTurnStart)) {
644
938
  results.push({
645
939
  index,
646
- text: `<tool-result reason="${reason}" tool="${escapeEvidenceMarkup(call.name)}">\n${escapeEvidenceMarkup(text)}\n</tool-result>`,
940
+ reason,
941
+ call,
942
+ resultId: `${stableEntryId(entry, index)}:tool-result:${message.toolCallId}`,
943
+ resultText: text,
944
+ renderedResult: `<tool-result id="${escapeEvidenceMarkup(message.toolCallId)}" reason="${reason}" tool="${escapeEvidenceMarkup(call.name)}">\n${escapeEvidenceMarkup(text)}\n</tool-result>`,
647
945
  });
648
946
  }
649
947
  });
650
948
  return results;
651
949
  }
652
950
 
653
- function sandboxTrapEvidence(
951
+ function sandboxTrapSupplement(
654
952
  request: RelevantBoundaryRequest,
655
953
  ): string | undefined {
656
954
  if (request.source !== "sandbox-runtime") return;
955
+ const supplement = {
956
+ ...(request.toolName ? { process: request.toolName } : {}),
957
+ };
958
+ if (Object.keys(supplement).length === 0) return;
959
+ return canonicalReviewerJson(supplement);
960
+ }
961
+
962
+ function sandboxTrapEvidence(
963
+ request: RelevantBoundaryRequest,
964
+ ): string | undefined {
965
+ const supplement = sandboxTrapSupplement(request);
966
+ if (!supplement) return;
657
967
  return `<sandbox-trap>
658
- ${boundedString({
659
- surface: request.surface,
660
- operation: request.operation,
968
+ ${supplement}
969
+ </sandbox-trap>`;
970
+ }
971
+
972
+ function currentTurnStart(entries: readonly unknown[]): number {
973
+ let start = 0;
974
+ entries.forEach((entry, index) => {
975
+ if (messageRecord(entry)?.role === "user") start = index;
976
+ });
977
+ return start;
978
+ }
979
+
980
+ function surfaceProfile(
981
+ request: RelevantBoundaryRequest,
982
+ ): EvidenceSurfaceProfile {
983
+ if (request.agentName || request.requesterSessionId) return "forwarded";
984
+ if (request.surface === "network" || request.destination) return "network";
985
+ if (/\bgit\b[\s\S]*\bpush\b/i.test(request.command ?? "")) return "git-push";
986
+ if (/\b(?:rm|rmdir|unlink|trash|delete)\b/i.test(request.command ?? "")) {
987
+ return "delete";
988
+ }
989
+ return "generic";
990
+ }
991
+
992
+ function collectToolCalls(entries: readonly unknown[]): ToolCallRecord[] {
993
+ const calls: ToolCallRecord[] = [];
994
+ entries.forEach((entry, entryIndex) => {
995
+ const message = messageRecord(entry);
996
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return;
997
+ for (const part of message.content) {
998
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
999
+ const value = part as Record<string, unknown>;
1000
+ if (value.type !== "toolCall" || typeof value.name !== "string") continue;
1001
+ const id =
1002
+ typeof value.id === "string"
1003
+ ? value.id
1004
+ : typeof value.toolCallId === "string"
1005
+ ? value.toolCallId
1006
+ : undefined;
1007
+ calls.push({
1008
+ id,
1009
+ name: value.name,
1010
+ arguments: value.arguments,
1011
+ rendered: `${value.name} ${boundedString(value.arguments ?? {})}`,
1012
+ entryIndex,
1013
+ candidateId: id
1014
+ ? `tool-call:${id}`
1015
+ : `${stableEntryId(entry, entryIndex)}:tool-${calls.length}`,
1016
+ });
1017
+ }
1018
+ });
1019
+ return calls;
1020
+ }
1021
+
1022
+ function exactStructuredMatch(
1023
+ call: ToolCallRecord,
1024
+ request: RelevantBoundaryRequest,
1025
+ ): boolean {
1026
+ if (!call.arguments || typeof call.arguments !== "object" || Array.isArray(call.arguments)) {
1027
+ return false;
1028
+ }
1029
+ const args = call.arguments as Record<string, unknown>;
1030
+ const pairs: Array<[unknown, unknown]> = [
1031
+ [args.command, request.command],
1032
+ [args.path, request.path],
1033
+ [args.resolvedPath, request.resolvedPath],
1034
+ [args.destination, request.destination],
1035
+ [args.agentName, request.agentName],
1036
+ [args.requesterSessionId, request.requesterSessionId],
1037
+ [args.target, request.destination ?? request.path],
1038
+ [args.value, request.destination ?? request.path ?? request.command],
1039
+ ];
1040
+ if (pairs.some(
1041
+ ([actual, expected]) =>
1042
+ typeof actual === "string" &&
1043
+ typeof expected === "string" &&
1044
+ actual === expected,
1045
+ )) return true;
1046
+ const forwarding =
1047
+ args.forwarding && typeof args.forwarding === "object" && !Array.isArray(args.forwarding)
1048
+ ? args.forwarding as Record<string, unknown>
1049
+ : undefined;
1050
+ if (
1051
+ forwarding &&
1052
+ ((typeof forwarding.requesterAgentName === "string" &&
1053
+ forwarding.requesterAgentName === request.agentName) ||
1054
+ (typeof forwarding.requesterSessionId === "string" &&
1055
+ forwarding.requesterSessionId === request.requesterSessionId))
1056
+ ) return true;
1057
+ const intent =
1058
+ args.accessIntent && typeof args.accessIntent === "object" && !Array.isArray(args.accessIntent)
1059
+ ? args.accessIntent as Record<string, unknown>
1060
+ : undefined;
1061
+ return Boolean(
1062
+ intent &&
1063
+ request.accessIntent &&
1064
+ intent.surface === request.accessIntent.surface &&
1065
+ Array.isArray(intent.matchValues) &&
1066
+ intent.matchValues.length === request.accessIntent.matchValues.length &&
1067
+ intent.matchValues.every(
1068
+ (value, index) => value === request.accessIntent!.matchValues[index],
1069
+ ) &&
1070
+ intent.boundaryValue === request.accessIntent.boundaryValue,
1071
+ );
1072
+ }
1073
+
1074
+ function sameReviewerField(actual: unknown, expected: unknown): boolean {
1075
+ return expected !== undefined &&
1076
+ canonicalReviewerJson(actual) === canonicalReviewerJson(expected);
1077
+ }
1078
+
1079
+ function toolArgumentCoveredByRequest(
1080
+ key: string,
1081
+ actual: unknown,
1082
+ request: RelevantBoundaryRequest,
1083
+ ): boolean {
1084
+ if (key === "target") {
1085
+ return sameReviewerField(actual, request.destination ?? request.path);
1086
+ }
1087
+ if (key === "value") {
1088
+ return [request.command, request.path, request.destination].some(
1089
+ (expected) => sameReviewerField(actual, expected),
1090
+ );
1091
+ }
1092
+ if (key === "forwarding") {
1093
+ if (!request.agentName && !request.requesterSessionId) return false;
1094
+ return sameReviewerField(actual, {
1095
+ requesterAgentName: request.agentName,
1096
+ requesterSessionId: request.requesterSessionId,
1097
+ });
1098
+ }
1099
+ const expected = ({
1100
+ command: request.command,
661
1101
  path: request.path,
662
1102
  resolvedPath: request.resolvedPath,
663
1103
  destination: request.destination,
664
- process: request.toolName,
665
- })}
666
- </sandbox-trap>`;
1104
+ cwd: request.cwd,
1105
+ agentName: request.agentName,
1106
+ requesterSessionId: request.requesterSessionId,
1107
+ accessIntent: request.accessIntent,
1108
+ } as Record<string, unknown>)[key];
1109
+ return sameReviewerField(actual, expected);
1110
+ }
1111
+
1112
+ function exactToolCallReviewerRepresentation(
1113
+ call: ToolCallRecord,
1114
+ request: RelevantBoundaryRequest,
1115
+ reason: EvidenceSelectionReason,
1116
+ ): string | undefined {
1117
+ if (
1118
+ !call.id ||
1119
+ call.id !== request.toolCallId ||
1120
+ !call.arguments ||
1121
+ typeof call.arguments !== "object" ||
1122
+ Array.isArray(call.arguments)
1123
+ ) {
1124
+ return;
1125
+ }
1126
+ const args = call.arguments as Record<string, unknown>;
1127
+ const serializedArgs = canonicalReviewerJson(args);
1128
+ const previewCoversAll =
1129
+ request.toolInputPreview === serializedArgs ||
1130
+ request.toolInputPreview === `input ${serializedArgs}`;
1131
+ const supplement = previewCoversAll
1132
+ ? {}
1133
+ : Object.fromEntries(
1134
+ Object.entries(args).filter(
1135
+ ([key, actual]) =>
1136
+ !toolArgumentCoveredByRequest(key, actual, request),
1137
+ ),
1138
+ );
1139
+ return canonicalReviewerJson({
1140
+ id: call.id,
1141
+ name: call.name,
1142
+ reason,
1143
+ ...(Object.keys(supplement).length > 0 ? { supplement } : {}),
1144
+ });
1145
+ }
1146
+
1147
+ function securityEvidenceCategory(
1148
+ call: ToolCallRecord,
1149
+ ):
1150
+ | "credential"
1151
+ | "permission"
1152
+ | "authorization"
1153
+ | "transport"
1154
+ | "audit"
1155
+ | "security-control"
1156
+ | undefined {
1157
+ const command = commandArgument(call);
1158
+ if (!command) return;
1159
+ if (
1160
+ /\b(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|xxd|tar|zip)\b[\s\S]*(?:\.env|\.ssh|\.aws\/credentials|\.kube\/config|\.npmrc|auth\.json)/i.test(
1161
+ command,
1162
+ )
1163
+ ) return "credential";
1164
+ if (/\b(?:chmod|chown|chgrp|setfacl)\b/i.test(command)) return "permission";
1165
+ if (/(?:authorized_keys|\/etc\/sudoers|access[_-]?grant)/i.test(command)) {
1166
+ return "authorization";
1167
+ }
1168
+ if (
1169
+ /(?:--insecure|--no-check-certificate|sslverify\s+false|strict-ssl\s+false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0|https?_proxy)/i.test(
1170
+ command,
1171
+ )
1172
+ ) return "transport";
1173
+ if (/\b(?:disable|truncate|delete|rm)\b[\s\S]*\b(?:audit|log)/i.test(command)) {
1174
+ return "audit";
1175
+ }
1176
+ if (/(?:pi-auto-review|pi-sandbox|permission-system|permissions\.json|sandbox\.json)/i.test(command)) {
1177
+ return "security-control";
1178
+ }
1179
+ return undefined;
1180
+ }
1181
+
1182
+ function selectRequestAwareTools(
1183
+ entries: readonly unknown[],
1184
+ evidence: Evidence[],
1185
+ request: RelevantBoundaryRequest,
1186
+ relevant: ReturnType<typeof relevantResultEvidence>,
1187
+ turnStart: number,
1188
+ ): { selected: Evidence[]; criticalEvidenceOverflow: boolean } {
1189
+ const calls = collectToolCalls(entries);
1190
+ const reasons = new Map<string, EvidenceSelectionReason[]>();
1191
+ const addReason = (call: ToolCallRecord, reason: EvidenceSelectionReason) => {
1192
+ const current = reasons.get(call.candidateId) ?? [];
1193
+ if (!current.includes(reason)) current.push(reason);
1194
+ reasons.set(call.candidateId, current);
1195
+ };
1196
+
1197
+ const exact = request.toolCallId
1198
+ ? calls.find((call) => call.id === request.toolCallId)
1199
+ : undefined;
1200
+ if (exact) addReason(exact, "exact-tool-call");
1201
+ for (const unit of relevant) addReason(unit.call, unit.reason);
1202
+ for (const call of calls) {
1203
+ if (call.entryIndex < turnStart || call === exact) continue;
1204
+ if (exactStructuredMatch(call, request)) {
1205
+ addReason(call, "structured-request-match");
1206
+ }
1207
+ }
1208
+ const profile = surfaceProfile(request);
1209
+ const securityCalls = calls.filter(
1210
+ (call) =>
1211
+ call.entryIndex >= turnStart &&
1212
+ securityEvidenceCategory(call) !== undefined &&
1213
+ ["network", "git-push", "forwarded"].includes(profile),
1214
+ );
1215
+ for (const call of securityCalls.slice(-4)) {
1216
+ addReason(call, "security-combination");
1217
+ }
1218
+
1219
+ const priority: EvidenceSelectionReason[] = [
1220
+ "exact-tool-call",
1221
+ "same-tool",
1222
+ "delete-precheck",
1223
+ "git-push-context",
1224
+ "provider-branch-protection",
1225
+ "security-combination",
1226
+ "structured-request-match",
1227
+ ];
1228
+ const selected = evidence
1229
+ .filter((item) => item.kind === "tool" && reasons.has(item.id))
1230
+ .map((item) => {
1231
+ const matched = reasons.get(item.id)!;
1232
+ const ordered = [...matched].sort(
1233
+ (left, right) => priority.indexOf(left) - priority.indexOf(right),
1234
+ );
1235
+ const call = calls.find((candidate) => candidate.candidateId === item.id)!;
1236
+ const linkage = exactToolCallReviewerRepresentation(
1237
+ call,
1238
+ request,
1239
+ ordered[0],
1240
+ );
1241
+ return {
1242
+ ...item,
1243
+ ...(linkage
1244
+ ? { text: linkage, representationCharacters: linkage.length }
1245
+ : {}),
1246
+ reason: ordered[0],
1247
+ secondaryReasons: ordered.slice(1),
1248
+ sensitivity:
1249
+ ordered.includes("security-combination")
1250
+ ? ({
1251
+ credential: "credential",
1252
+ permission: "permission-control",
1253
+ authorization: "authorization-persistence",
1254
+ transport: "transport-weakening",
1255
+ audit: "audit-control",
1256
+ "security-control": "security-control",
1257
+ } as const)[
1258
+ securityEvidenceCategory(
1259
+ calls.find((call) => call.candidateId === item.id)!,
1260
+ )!
1261
+ ]
1262
+ : item.sensitivity,
1263
+ };
1264
+ });
1265
+ return {
1266
+ selected,
1267
+ criticalEvidenceOverflow: securityCalls.length > 4,
1268
+ };
1269
+ }
1270
+
1271
+ function capRelevantUnits(
1272
+ units: ReturnType<typeof relevantResultEvidence>,
1273
+ profile: EvidenceSurfaceProfile,
1274
+ ): ReturnType<typeof relevantResultEvidence> {
1275
+ const newest = [...units].sort((left, right) => right.index - left.index);
1276
+ const selected: typeof units = [];
1277
+ const take = (
1278
+ reason: (typeof units)[number]["reason"],
1279
+ limit: number,
1280
+ predicate: (unit: (typeof units)[number]) => boolean = () => true,
1281
+ ) => {
1282
+ for (const unit of newest) {
1283
+ if (
1284
+ selected.length >= units.length ||
1285
+ selected.includes(unit) ||
1286
+ unit.reason !== reason ||
1287
+ !predicate(unit) ||
1288
+ selected.filter(
1289
+ (candidate) => candidate.reason === reason && predicate(candidate),
1290
+ ).length >= limit
1291
+ ) {
1292
+ continue;
1293
+ }
1294
+ selected.push(unit);
1295
+ }
1296
+ };
1297
+ take("same-tool", 1);
1298
+ if (profile === "delete") take("delete-precheck", 2);
1299
+ if (profile === "git-push") {
1300
+ take("git-push-context", 1, (unit) =>
1301
+ /^\s*git\s+(?:remote|config\s+--get\s+remote)/i.test(
1302
+ commandArgument(unit.call),
1303
+ ),
1304
+ );
1305
+ take("git-push-context", 1, (unit) =>
1306
+ /^\s*git\s+(?:branch|status|rev-parse)/i.test(
1307
+ commandArgument(unit.call),
1308
+ ),
1309
+ );
1310
+ take("provider-branch-protection", 1);
1311
+ }
1312
+ if (profile === "generic") {
1313
+ for (const unit of newest) {
1314
+ if (!selected.includes(unit) && selected.length < 4) selected.push(unit);
1315
+ }
1316
+ }
1317
+ return selected.sort((left, right) => left.index - right.index);
1318
+ }
1319
+
1320
+ function selectedMetadata(
1321
+ item: Evidence,
1322
+ original: Evidence,
1323
+ profile: EvidenceSurfaceProfile,
1324
+ ): EvidenceCandidateMetadata {
1325
+ return {
1326
+ id: item.id,
1327
+ entryIndex: item.entryIndex,
1328
+ kind: item.kind === "user" ? "user" : "tool-call",
1329
+ ...(item.toolCallId ? { toolCallId: item.toolCallId } : {}),
1330
+ reason: item.reason,
1331
+ secondaryReasons: [...item.secondaryReasons],
1332
+ surfaceProfile: profile,
1333
+ originalCharacters: original.originalCharacters,
1334
+ selectedCharacters: item.text.length,
1335
+ estimatedTokens: Buffer.byteLength(item.text, "utf8"),
1336
+ truncated:
1337
+ original.preTruncated ||
1338
+ item.text.length <
1339
+ (item.representationCharacters ?? original.originalCharacters),
1340
+ sensitivity: item.sensitivity,
1341
+ };
1342
+ }
1343
+
1344
+ type UserSelection = {
1345
+ selected: Evidence[];
1346
+ truncated: boolean;
1347
+ authorizationCeiling: TranscriptResult["userAuthorizationCeiling"];
1348
+ constraint: TranscriptResult["userConstraint"];
1349
+ compactionState: TranscriptResult["compactionState"];
1350
+ constraintOverflow: boolean;
1351
+ };
1352
+
1353
+ function utf8Prefix(value: string, maxBytes: number): string {
1354
+ if (maxBytes <= 0) return "";
1355
+ let bytes = 0;
1356
+ let result = "";
1357
+ for (const character of value) {
1358
+ const size = Buffer.byteLength(character, "utf8");
1359
+ if (bytes + size > maxBytes) break;
1360
+ result += character;
1361
+ bytes += size;
1362
+ }
1363
+ return result;
1364
+ }
1365
+
1366
+ function utf8Suffix(value: string, maxBytes: number): string {
1367
+ if (maxBytes <= 0) return "";
1368
+ let bytes = 0;
1369
+ const characters = [...value];
1370
+ let result = "";
1371
+ for (let index = characters.length - 1; index >= 0; index--) {
1372
+ const character = characters[index];
1373
+ const size = Buffer.byteLength(character, "utf8");
1374
+ if (bytes + size > maxBytes) break;
1375
+ result = character + result;
1376
+ bytes += size;
1377
+ }
1378
+ return result;
1379
+ }
1380
+
1381
+ function headTail(value: string, limit: number): string {
1382
+ if (Buffer.byteLength(value, "utf8") <= limit) return value;
1383
+ const marker = "\n…[middle truncated]…\n";
1384
+ const markerBytes = Buffer.byteLength(marker, "utf8");
1385
+ if (limit <= markerBytes) return utf8Prefix(marker, limit);
1386
+ const available = limit - markerBytes;
1387
+ const head = Math.ceil(available / 2);
1388
+ return `${utf8Prefix(value, head)}${marker}${utf8Suffix(value, available - head)}`;
1389
+ }
1390
+
1391
+ function summaryEntryIndices(entries: readonly unknown[]): number[] {
1392
+ const indices: number[] = [];
1393
+ entries.forEach((entry, index) => {
1394
+ const role = messageRecord(entry)?.role;
1395
+ if (role === "compactionSummary" || role === "branchSummary") {
1396
+ indices.push(index);
1397
+ }
1398
+ });
1399
+ return indices;
1400
+ }
1401
+
1402
+ function containsExactIdentifier(text: string, value: string): boolean {
1403
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1404
+ return new RegExp(`(^|[^A-Za-z0-9_-])${escaped}(?=$|[^A-Za-z0-9_-])`).test(
1405
+ text,
1406
+ );
1407
+ }
1408
+
1409
+ function exactUserReference(
1410
+ text: string,
1411
+ request: RelevantBoundaryRequest,
1412
+ ): boolean {
1413
+ return [
1414
+ request.id,
1415
+ request.toolCallId,
1416
+ request.requesterSessionId,
1417
+ request.agentName,
1418
+ ].some((value) => {
1419
+ if (typeof value !== "string" || value.length === 0) return false;
1420
+ return containsExactIdentifier(text, value);
1421
+ });
1422
+ }
1423
+
1424
+ function constraintEffect(text: string): "narrowed" | "revoked" | undefined {
1425
+ if (
1426
+ /(?:\b(?:do not|don't|never|must not|forbid|forbidden|revoke|cancel)\b|不要|不得|禁止|撤销|取消|不能|别)/i.test(
1427
+ text,
1428
+ )
1429
+ ) return "revoked";
1430
+ if (/(?:\b(?:only|except|staging only|not main)\b|只允许|仅允许|仅限|只能)/i.test(text)) {
1431
+ return "narrowed";
1432
+ }
1433
+ return undefined;
1434
+ }
1435
+
1436
+ function vagueContinuation(text: string): boolean {
1437
+ return /^\s*(?:continue|proceed|go ahead|do it|yes|ok(?:ay)?|继续|照做|可以|好的?|行)[.!。!\s]*$/i.test(
1438
+ text,
1439
+ );
1440
+ }
1441
+
1442
+ function constraintMatchesProfile(
1443
+ text: string,
1444
+ profile: EvidenceSurfaceProfile,
1445
+ ): boolean {
1446
+ const patterns: Record<EvidenceSurfaceProfile, RegExp> = {
1447
+ network: /(?:\b(?:network|connect|domain|upload|download|http)\b|网络|联网|域名|上传|下载)/i,
1448
+ delete: /(?:\b(?:delete|remove|unlink|rmdir|\brm\b)\b|删除|移除)/i,
1449
+ "git-push": /(?:\b(?:git\s+push|push|branch|main)\b|推送|分支|主分支)/i,
1450
+ forwarded: /(?:\b(?:agent|subagent|forward|worker)\b|代理|子代理|转发)/i,
1451
+ generic: /(?:\b(?:execute|run|write|file|path|permission|deploy)\b|执行|运行|写入|文件|路径|权限|部署)/i,
1452
+ };
1453
+ return patterns[profile].test(text);
1454
+ }
1455
+
1456
+ function constraintConflictsOrIsUncertain(
1457
+ text: string,
1458
+ effect: "narrowed" | "revoked",
1459
+ request: RelevantBoundaryRequest,
1460
+ ): boolean {
1461
+ const representation = [
1462
+ request.command,
1463
+ request.path,
1464
+ request.resolvedPath,
1465
+ request.destination,
1466
+ request.agentName,
1467
+ ]
1468
+ .filter((value): value is string => Boolean(value))
1469
+ .join(" ")
1470
+ .toLowerCase();
1471
+ if (/\bmain\b|主分支/i.test(text)) {
1472
+ return effect === "revoked"
1473
+ ? /\bmain\b/.test(representation)
1474
+ : /\bmain\b/.test(representation);
1475
+ }
1476
+ if (/\bstaging\b|预发布|测试环境/i.test(text)) {
1477
+ const targetsStaging = /\bstaging\b/.test(representation);
1478
+ return effect === "revoked" ? targetsStaging : !targetsStaging;
1479
+ }
1480
+ return true;
1481
+ }
1482
+
1483
+ function selectUserEvidence(
1484
+ entries: readonly unknown[],
1485
+ evidence: Evidence[],
1486
+ request: RelevantBoundaryRequest,
1487
+ profile: EvidenceSurfaceProfile,
1488
+ budgetTokens: number,
1489
+ ): UserSelection {
1490
+ const users = evidence.filter((item) => item.kind === "user");
1491
+ const summaries = summaryEntryIndices(entries);
1492
+ const lastSummary = summaries.at(-1) ?? -1;
1493
+ const rawAfterSummary = users.filter((item) => item.entryIndex > lastSummary);
1494
+ const eligibleUsers = lastSummary >= 0 ? rawAfterSummary : users;
1495
+ const latest = eligibleUsers.at(-1);
1496
+ const compactionState: TranscriptResult["compactionState"] =
1497
+ summaries.length === 0
1498
+ ? "none"
1499
+ : latest
1500
+ ? "summary-present"
1501
+ : "authorization-unavailable";
1502
+ if (!latest || budgetTokens <= 0) {
1503
+ return {
1504
+ selected: [],
1505
+ truncated: users.length > 0,
1506
+ authorizationCeiling: request.trustedRetryOriginalRequestId
1507
+ ? "high"
1508
+ : "unknown",
1509
+ constraint: "none",
1510
+ compactionState,
1511
+ constraintOverflow: false,
1512
+ };
1513
+ }
1514
+
1515
+ let remaining = budgetTokens;
1516
+ const latestText = headTail(latest.text, remaining);
1517
+ const latestTrustedRetry = Boolean(
1518
+ request.trustedRetryOriginalRequestId &&
1519
+ latest.text.includes("I approved one reviewer retry") &&
1520
+ containsExactIdentifier(
1521
+ latest.text,
1522
+ request.trustedRetryOriginalRequestId,
1523
+ ),
1524
+ );
1525
+ const latestExactReference = exactUserReference(latest.text, request);
1526
+ const latestEffect = constraintEffect(latest.text);
1527
+ const latestActiveConstraint = Boolean(
1528
+ latestEffect && constraintMatchesProfile(latest.text, profile),
1529
+ );
1530
+ const latestConstraintConflict = Boolean(
1531
+ latestEffect &&
1532
+ latestActiveConstraint &&
1533
+ constraintConflictsOrIsUncertain(latest.text, latestEffect, request),
1534
+ );
1535
+ const selected: Evidence[] = [{
1536
+ ...latest,
1537
+ text: latestText,
1538
+ reason: "latest-user",
1539
+ secondaryReasons: [
1540
+ ...(latestTrustedRetry ? ["trusted-retry-user-message" as const] : []),
1541
+ ...(latestExactReference ? ["exact-request-reference" as const] : []),
1542
+ ...(latestActiveConstraint ? ["active-narrowing-constraint" as const] : []),
1543
+ ],
1544
+ }];
1545
+ remaining -= Buffer.byteLength(latestText, "utf8");
1546
+ let constraint: TranscriptResult["userConstraint"] =
1547
+ latestConstraintConflict ? latestEffect! : "none";
1548
+
1549
+ const older = eligibleUsers.slice(0, -1).reverse();
1550
+ for (const candidate of older) {
1551
+ if (remaining <= 0) break;
1552
+ const trustedRetry =
1553
+ request.trustedRetryOriginalRequestId &&
1554
+ candidate.text.includes("I approved one reviewer retry") &&
1555
+ containsExactIdentifier(
1556
+ candidate.text,
1557
+ request.trustedRetryOriginalRequestId,
1558
+ );
1559
+ const exactReference = exactUserReference(candidate.text, request);
1560
+ const effect = constraintEffect(candidate.text);
1561
+ const activeConstraint = effect && constraintMatchesProfile(candidate.text, profile);
1562
+ if (!trustedRetry && !exactReference && !activeConstraint) continue;
1563
+ const reason: EvidenceSelectionReason = trustedRetry
1564
+ ? "trusted-retry-user-message"
1565
+ : exactReference
1566
+ ? "exact-request-reference"
1567
+ : "active-narrowing-constraint";
1568
+ const text = headTail(candidate.text, remaining);
1569
+ if (!text) continue;
1570
+ selected.push({
1571
+ ...candidate,
1572
+ text,
1573
+ reason,
1574
+ secondaryReasons: [
1575
+ ...(trustedRetry && exactReference ? ["exact-request-reference" as const] : []),
1576
+ ...(activeConstraint && reason !== "active-narrowing-constraint"
1577
+ ? ["active-narrowing-constraint" as const]
1578
+ : []),
1579
+ ],
1580
+ });
1581
+ remaining -= Buffer.byteLength(text, "utf8");
1582
+ if (
1583
+ activeConstraint &&
1584
+ constraintConflictsOrIsUncertain(candidate.text, effect, request)
1585
+ ) {
1586
+ constraint = effect === "revoked" ? "revoked" : constraint === "none" ? "narrowed" : constraint;
1587
+ }
1588
+ }
1589
+ selected.sort((left, right) => left.entryIndex - right.entryIndex);
1590
+ const selectedIds = new Set(selected.map((item) => item.id));
1591
+ const constraintOverflow = older.some((candidate) => {
1592
+ const effect = constraintEffect(candidate.text);
1593
+ if (!effect || !constraintMatchesProfile(candidate.text, profile)) return false;
1594
+ const chosen = selected.find((item) => item.id === candidate.id);
1595
+ return (
1596
+ !selectedIds.has(candidate.id) ||
1597
+ !chosen ||
1598
+ chosen.text.length < candidate.originalCharacters ||
1599
+ candidate.preTruncated
1600
+ );
1601
+ });
1602
+ const latestTruncated = latestText.length < latest.originalCharacters || latest.preTruncated;
1603
+ const hasExactAuthorizationLink = selected.some(
1604
+ (item) =>
1605
+ item.reason === "exact-request-reference" ||
1606
+ item.reason === "trusted-retry-user-message" ||
1607
+ item.secondaryReasons.includes("exact-request-reference") ||
1608
+ item.secondaryReasons.includes("trusted-retry-user-message"),
1609
+ );
1610
+ const authorizationCeiling: TranscriptResult["userAuthorizationCeiling"] =
1611
+ request.trustedRetryOriginalRequestId && constraint !== "revoked"
1612
+ ? "high"
1613
+ : compactionState === "authorization-unavailable" || constraint === "revoked"
1614
+ ? "unknown"
1615
+ : latestTruncated
1616
+ ? "medium"
1617
+ : vagueContinuation(latest.text) && !hasExactAuthorizationLink
1618
+ ? "unknown"
1619
+ : "high";
1620
+ return {
1621
+ selected,
1622
+ truncated:
1623
+ latestTruncated ||
1624
+ selected.some((item) => item.text.length < item.originalCharacters),
1625
+ authorizationCeiling,
1626
+ constraint,
1627
+ compactionState,
1628
+ constraintOverflow,
1629
+ };
667
1630
  }
668
1631
 
669
1632
  function selectEvidence(
670
1633
  evidence: Evidence[],
671
1634
  kind: Evidence["kind"],
672
- budgetCharacters: number,
1635
+ budgetTokens: number,
673
1636
  ): { selected: Evidence[]; truncated: boolean } {
674
1637
  const candidates = evidence.filter((item) => item.kind === kind);
675
- if (candidates.length === 0 || budgetCharacters <= 0) {
1638
+ if (candidates.length === 0 || budgetTokens <= 0) {
676
1639
  return { selected: [], truncated: candidates.length > 0 };
677
1640
  }
678
1641
 
679
1642
  const selected = new Map<number, Evidence>();
680
- let remaining = budgetCharacters;
1643
+ let remaining = budgetTokens;
681
1644
  const add = (item: Evidence, limit = remaining): void => {
682
1645
  if (selected.has(item.index) || remaining <= 0) return;
683
- const text = item.text.slice(0, Math.min(remaining, limit));
1646
+ const text = utf8Prefix(item.text, Math.min(remaining, limit));
684
1647
  if (!text) return;
685
1648
  selected.set(item.index, { ...item, text });
686
- remaining -= text.length;
1649
+ remaining -= Buffer.byteLength(text, "utf8");
687
1650
  };
688
1651
 
689
1652
  // Keep the original user intent as an anchor, then fill from newest to oldest.
690
1653
  if (kind === "user") {
691
1654
  const firstBudget =
692
1655
  candidates.length > 1
693
- ? Math.max(1, Math.floor(budgetCharacters / 2))
694
- : budgetCharacters;
1656
+ ? Math.max(1, Math.floor(budgetTokens / 2))
1657
+ : budgetTokens;
695
1658
  add(candidates[0], firstBudget);
696
1659
  if (candidates.length > 1) add(candidates[candidates.length - 1]);
697
1660
  }
@@ -717,68 +1680,229 @@ export function buildClassifierTranscript(
717
1680
  request: RelevantBoundaryRequest = {},
718
1681
  ): TranscriptResult {
719
1682
  const evidence = extractEvidence(entries);
720
- const users = selectEvidence(
1683
+ const profile = surfaceProfile(request);
1684
+ const turnStart = currentTurnStart(entries);
1685
+ const relevantUnits = capRelevantUnits(
1686
+ relevantResultEvidence(entries, request, turnStart),
1687
+ profile,
1688
+ );
1689
+ const users = selectUserEvidence(
1690
+ entries,
721
1691
  evidence,
722
- "user",
723
- config.maxUserTranscriptTokens * 4,
1692
+ request,
1693
+ profile,
1694
+ config.maxUserTranscriptTokens,
724
1695
  );
725
- const tools = selectEvidence(
1696
+ const requestAwareTools = selectRequestAwareTools(
1697
+ entries,
726
1698
  evidence,
727
- "tool",
728
- config.maxToolTranscriptTokens * 4,
1699
+ request,
1700
+ relevantUnits,
1701
+ turnStart,
729
1702
  );
730
- const selected = [...users.selected, ...tools.selected].sort(
731
- (left, right) => left.index - right.index,
1703
+ const tools = selectEvidence(
1704
+ requestAwareTools.selected,
1705
+ "tool",
1706
+ config.maxToolTranscriptTokens,
732
1707
  );
733
- const baseRendered = selected
734
- .map(
735
- (item) =>
736
- `<${item.kind}>\n${escapeEvidenceMarkup(item.text)}\n</${item.kind}>`,
737
- )
738
- .join("\n\n");
739
1708
  const relevantBudget =
740
- (config.maxRelevantResultTokens ?? config.maxToolTranscriptTokens) * 4;
1709
+ config.maxRelevantResultTokens ?? config.maxToolTranscriptTokens;
1710
+ const sandboxSupplement = sandboxTrapSupplement(request);
1711
+ const sandboxEvidence = sandboxTrapEvidence(request);
741
1712
  const relevantCandidates = [
742
- ...(sandboxTrapEvidence(request)
743
- ? [{ index: Number.MAX_SAFE_INTEGER - 1, text: sandboxTrapEvidence(request)! }]
1713
+ ...(sandboxEvidence && sandboxSupplement
1714
+ ? [{
1715
+ index: Number.MAX_SAFE_INTEGER - 1,
1716
+ id: `sandbox-trap:${request.id ?? "request"}`,
1717
+ reason: "sandbox-trap" as const,
1718
+ toolCallId: undefined,
1719
+ text: sandboxEvidence,
1720
+ content: sandboxSupplement,
1721
+ rawCharacters: sandboxEvidence.length,
1722
+ sensitivity: "boundary" as const,
1723
+ }]
744
1724
  : []),
745
- ...relevantResultEvidence(entries, request),
1725
+ ...relevantUnits
1726
+ .filter((unit) =>
1727
+ tools.selected.some((tool) => tool.id === unit.call.candidateId),
1728
+ )
1729
+ .map((unit) => ({
1730
+ index: unit.index,
1731
+ id: unit.resultId,
1732
+ reason: unit.reason,
1733
+ toolCallId: unit.call.id,
1734
+ text: unit.renderedResult,
1735
+ content: unit.resultText,
1736
+ rawCharacters: unit.renderedResult.length,
1737
+ sensitivity: "tool-output" as const,
1738
+ })),
746
1739
  ];
747
1740
  let relevantRemaining = relevantBudget;
748
- const relevantSelected: string[] = [];
749
- for (let index = relevantCandidates.length - 1; index >= 0; index--) {
1741
+ let requiredProfileOverflow = false;
1742
+ const relevantSelected: Array<
1743
+ (typeof relevantCandidates)[number] & { selectedText: string }
1744
+ > = [];
1745
+ const relevantSelectionOrder = [
1746
+ ...relevantCandidates.filter((candidate) => candidate.reason === "sandbox-trap"),
1747
+ ...relevantCandidates
1748
+ .filter((candidate) => candidate.reason !== "sandbox-trap")
1749
+ .sort((left, right) => right.index - left.index),
1750
+ ];
1751
+ for (const candidate of relevantSelectionOrder) {
750
1752
  if (relevantRemaining <= 0) break;
751
- const text = relevantCandidates[index].text.slice(0, relevantRemaining);
1753
+ if (
1754
+ candidate.reason === "sandbox-trap" &&
1755
+ Buffer.byteLength(candidate.text, "utf8") > relevantRemaining
1756
+ ) {
1757
+ requiredProfileOverflow = true;
1758
+ break;
1759
+ }
1760
+ const text = utf8Prefix(candidate.text, relevantRemaining);
752
1761
  if (text) {
753
- relevantSelected.unshift(text);
754
- relevantRemaining -= text.length;
1762
+ relevantSelected.push({ ...candidate, selectedText: text });
1763
+ relevantRemaining -= Buffer.byteLength(text, "utf8");
755
1764
  }
756
1765
  }
757
- const rendered = [baseRendered, ...relevantSelected]
1766
+ relevantSelected.sort((left, right) => left.index - right.index);
1767
+ const pairedToolCallIds = new Set(
1768
+ relevantSelected
1769
+ .map((candidate) => candidate.toolCallId)
1770
+ .filter((id): id is string => Boolean(id)),
1771
+ );
1772
+ const resultReasons = new Set<EvidenceSelectionReason>([
1773
+ "same-tool",
1774
+ "delete-precheck",
1775
+ "git-push-context",
1776
+ "provider-branch-protection",
1777
+ ]);
1778
+ const finalTools = tools.selected.filter((tool) => {
1779
+ if (!resultReasons.has(tool.reason)) return true;
1780
+ if (tool.toolCallId && pairedToolCallIds.has(tool.toolCallId)) return true;
1781
+ return tool.secondaryReasons.some((reason) => !resultReasons.has(reason));
1782
+ });
1783
+ const selected = [...users.selected, ...finalTools].sort(
1784
+ (left, right) => left.index - right.index,
1785
+ );
1786
+ const baseRendered = selected
1787
+ .map((item) => {
1788
+ const attributes = item.kind === "tool"
1789
+ ? ` id="${escapeEvidenceMarkup(item.toolCallId ?? item.id)}" reason="${item.reason}"`
1790
+ : ` reason="${item.reason}"`;
1791
+ return `<${item.kind}${attributes}>\n${escapeEvidenceMarkup(item.text)}\n</${item.kind}>`;
1792
+ })
1793
+ .join("\n\n");
1794
+ const authorizationNotice =
1795
+ users.compactionState === "authorization-unavailable"
1796
+ ? "[Original user authorization is unavailable after compaction. Agent-generated summaries are not authorization.]"
1797
+ : users.compactionState === "summary-present"
1798
+ ? "[A compaction/branch summary exists but is not user authorization.]"
1799
+ : "";
1800
+ const rendered = [
1801
+ authorizationNotice,
1802
+ baseRendered,
1803
+ ...relevantSelected.map((candidate) => candidate.selectedText),
1804
+ ]
758
1805
  .filter(Boolean)
759
1806
  .join("\n\n");
760
1807
  const relevantTruncated =
761
1808
  relevantSelected.length < relevantCandidates.length ||
762
- relevantSelected.reduce((total, value) => total + value.length, 0) <
1809
+ relevantSelected.reduce(
1810
+ (total, value) => total + value.selectedText.length,
1811
+ 0,
1812
+ ) <
763
1813
  relevantCandidates.reduce((total, value) => total + value.text.length, 0);
764
- const truncated = users.truncated || tools.truncated || relevantTruncated;
1814
+ const truncated =
1815
+ users.truncated ||
1816
+ tools.truncated ||
1817
+ finalTools.length < tools.selected.length ||
1818
+ relevantTruncated;
765
1819
  const text = truncated
766
1820
  ? `[Some transcript evidence was omitted or truncated.]\n\n${rendered}`
767
1821
  : rendered;
768
1822
  return {
769
1823
  text: text || "(no eligible transcript evidence)",
1824
+ surfaceProfile: profile,
1825
+ reviewerEvidence: {
1826
+ userMessages: users.selected.map((item) => ({
1827
+ id: item.id,
1828
+ reason: item.reason,
1829
+ secondaryReasons: [...item.secondaryReasons],
1830
+ content: item.text,
1831
+ })),
1832
+ toolCalls: finalTools.map((item) => ({
1833
+ id: item.id,
1834
+ reason: item.reason,
1835
+ secondaryReasons: [...item.secondaryReasons],
1836
+ ...(item.toolCallId ? { toolCallId: item.toolCallId } : {}),
1837
+ content: item.text,
1838
+ })),
1839
+ relevantResults: relevantSelected.map((item) => ({
1840
+ id: item.id,
1841
+ reason: item.reason,
1842
+ secondaryReasons: [],
1843
+ ...(item.toolCallId ? { toolCallId: item.toolCallId } : {}),
1844
+ content: item.selectedText === item.text
1845
+ ? item.content
1846
+ : item.selectedText,
1847
+ })),
1848
+ },
1849
+ budgetRemovals: [],
770
1850
  userCharacters: users.selected.reduce(
771
1851
  (total, item) => total + item.text.length,
772
1852
  0,
773
1853
  ),
774
- toolCharacters: tools.selected.reduce(
1854
+ toolCharacters: finalTools.reduce(
775
1855
  (total, item) => total + item.text.length,
776
1856
  0,
777
1857
  ),
778
1858
  relevantResultCharacters: relevantSelected.reduce(
779
- (total, item) => total + item.length,
1859
+ (total, item) =>
1860
+ total +
1861
+ (item.selectedText === item.text
1862
+ ? item.content.length
1863
+ : item.selectedText.length),
780
1864
  0,
781
1865
  ),
782
1866
  truncated,
1867
+ selectedCandidates: [
1868
+ ...users.selected.map((item) =>
1869
+ selectedMetadata(
1870
+ item,
1871
+ evidence.find((candidate) => candidate.id === item.id)!,
1872
+ profile,
1873
+ ),
1874
+ ),
1875
+ ...finalTools.map((item) =>
1876
+ selectedMetadata(
1877
+ item,
1878
+ evidence.find((candidate) => candidate.id === item.id)!,
1879
+ profile,
1880
+ ),
1881
+ ),
1882
+ ...relevantSelected.map((item) => ({
1883
+ id: item.id,
1884
+ entryIndex: item.index,
1885
+ kind: item.sensitivity === "boundary" ? "sandbox-trap" as const : "tool-result" as const,
1886
+ ...(item.toolCallId ? { toolCallId: item.toolCallId } : {}),
1887
+ reason: item.reason,
1888
+ secondaryReasons: [],
1889
+ surfaceProfile: profile,
1890
+ originalCharacters: item.rawCharacters,
1891
+ selectedCharacters: item.selectedText.length,
1892
+ estimatedTokens: Buffer.byteLength(item.selectedText, "utf8"),
1893
+ truncated: item.selectedText.length < item.rawCharacters,
1894
+ sensitivity: item.sensitivity,
1895
+ })),
1896
+ ].sort((left, right) => left.entryIndex - right.entryIndex),
1897
+ ...(requestAwareTools.criticalEvidenceOverflow
1898
+ ? { failureCode: "critical_evidence_overflow" as const }
1899
+ : requiredProfileOverflow
1900
+ ? { failureCode: "required_profile_overflow" as const }
1901
+ : users.constraintOverflow
1902
+ ? { failureCode: "required_user_constraint_overflow" as const }
1903
+ : {}),
1904
+ userAuthorizationCeiling: users.authorizationCeiling,
1905
+ userConstraint: users.constraint,
1906
+ compactionState: users.compactionState,
783
1907
  };
784
1908
  }