@erichll/pi-auto-review 0.1.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/LICENSE +21 -0
- package/README.md +221 -0
- package/config/config.example.json +12 -0
- package/package.json +61 -0
- package/src/broker/broker.ts +215 -0
- package/src/broker/circuit-breaker.ts +61 -0
- package/src/broker/grants.ts +97 -0
- package/src/broker/index.ts +29 -0
- package/src/broker/overrides.ts +120 -0
- package/src/broker/service.ts +50 -0
- package/src/broker/types.ts +118 -0
- package/src/config.json +12 -0
- package/src/index.ts +941 -0
- package/src/integrations/landstrip.ts +83 -0
- package/src/policy.ts +713 -0
package/src/policy.ts
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
export type RiskLevel = "low" | "medium" | "high" | "critical";
|
|
2
|
+
|
|
3
|
+
export type ModelDecision = {
|
|
4
|
+
outcome: "allow" | "deny" | "defer";
|
|
5
|
+
risk_level: RiskLevel;
|
|
6
|
+
user_authorization: "unknown" | "low" | "medium" | "high";
|
|
7
|
+
rationale: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PermissionDetailsLike = {
|
|
11
|
+
surface?: string | null;
|
|
12
|
+
toolName?: string;
|
|
13
|
+
command?: string;
|
|
14
|
+
path?: string;
|
|
15
|
+
target?: string;
|
|
16
|
+
toolInputPreview?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type TranscriptConfig = {
|
|
20
|
+
maxUserTranscriptTokens: number;
|
|
21
|
+
maxToolTranscriptTokens: number;
|
|
22
|
+
maxRelevantResultTokens?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type TranscriptResult = {
|
|
26
|
+
text: string;
|
|
27
|
+
userCharacters: number;
|
|
28
|
+
toolCharacters: number;
|
|
29
|
+
relevantResultCharacters: number;
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Evidence = {
|
|
34
|
+
index: number;
|
|
35
|
+
kind: "user" | "tool";
|
|
36
|
+
text: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type RelevantBoundaryRequest = {
|
|
40
|
+
id?: string;
|
|
41
|
+
source?: string;
|
|
42
|
+
surface?: string;
|
|
43
|
+
operation?: string;
|
|
44
|
+
command?: string;
|
|
45
|
+
path?: string;
|
|
46
|
+
resolvedPath?: string;
|
|
47
|
+
destination?: string;
|
|
48
|
+
toolCallId?: string;
|
|
49
|
+
toolName?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const MAX_EVIDENCE_ITEM_CHARACTERS = 4_000;
|
|
53
|
+
|
|
54
|
+
function exactKeys(
|
|
55
|
+
record: Record<string, unknown>,
|
|
56
|
+
expected: readonly string[],
|
|
57
|
+
): boolean {
|
|
58
|
+
const actual = Object.keys(record).sort();
|
|
59
|
+
const wanted = [...expected].sort();
|
|
60
|
+
return (
|
|
61
|
+
actual.length === wanted.length &&
|
|
62
|
+
actual.every((key, index) => key === wanted[index])
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseDecision(text: string): ModelDecision {
|
|
67
|
+
let value: unknown;
|
|
68
|
+
try {
|
|
69
|
+
value = JSON.parse(text.trim());
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("reviewer returned non-JSON output");
|
|
72
|
+
}
|
|
73
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
74
|
+
throw new Error("reviewer returned a non-object");
|
|
75
|
+
}
|
|
76
|
+
const record = value as Record<string, unknown>;
|
|
77
|
+
if (
|
|
78
|
+
!exactKeys(record, [
|
|
79
|
+
"outcome",
|
|
80
|
+
"risk_level",
|
|
81
|
+
"user_authorization",
|
|
82
|
+
"rationale",
|
|
83
|
+
])
|
|
84
|
+
) {
|
|
85
|
+
throw new Error("reviewer returned unexpected fields");
|
|
86
|
+
}
|
|
87
|
+
if (!["allow", "deny", "defer"].includes(String(record.outcome))) {
|
|
88
|
+
throw new Error("reviewer returned an invalid outcome");
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
!["low", "medium", "high", "critical"].includes(
|
|
92
|
+
String(record.risk_level),
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
throw new Error("reviewer returned an invalid risk level");
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
!["unknown", "low", "medium", "high"].includes(
|
|
99
|
+
String(record.user_authorization),
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
throw new Error("reviewer returned invalid user authorization");
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
typeof record.rationale !== "string" ||
|
|
106
|
+
!record.rationale.trim() ||
|
|
107
|
+
record.rationale.length > 600
|
|
108
|
+
) {
|
|
109
|
+
throw new Error("reviewer returned an invalid rationale");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const decision = {
|
|
113
|
+
outcome: record.outcome,
|
|
114
|
+
risk_level: record.risk_level,
|
|
115
|
+
user_authorization: record.user_authorization,
|
|
116
|
+
rationale: record.rationale.trim(),
|
|
117
|
+
} as ModelDecision;
|
|
118
|
+
if (
|
|
119
|
+
decision.outcome === "allow" &&
|
|
120
|
+
decision.risk_level === "critical"
|
|
121
|
+
) {
|
|
122
|
+
throw new Error("reviewer attempted a critical-risk allow");
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
decision.outcome === "allow" &&
|
|
126
|
+
decision.risk_level === "high" &&
|
|
127
|
+
!["medium", "high"].includes(decision.user_authorization)
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"reviewer attempted an unauthorized high-risk allow",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
decision.outcome === "defer" &&
|
|
135
|
+
!["medium", "high"].includes(decision.risk_level)
|
|
136
|
+
) {
|
|
137
|
+
throw new Error("reviewer returned an inconsistent defer");
|
|
138
|
+
}
|
|
139
|
+
return decision;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function surfaceOf(details: PermissionDetailsLike): string {
|
|
143
|
+
if (typeof details.surface === "string" && details.surface) {
|
|
144
|
+
return details.surface;
|
|
145
|
+
}
|
|
146
|
+
if (details.path) return "path";
|
|
147
|
+
if (details.command) return "bash";
|
|
148
|
+
return details.toolName || "unknown";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function effectiveCommand(
|
|
152
|
+
details: PermissionDetailsLike,
|
|
153
|
+
): string | undefined {
|
|
154
|
+
if (details.command?.trim()) return details.command;
|
|
155
|
+
if (
|
|
156
|
+
surfaceOf(details) !== "bash_escalated" ||
|
|
157
|
+
!details.toolInputPreview?.trim()
|
|
158
|
+
) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const preview = details.toolInputPreview.trim().replace(/^input\s+/, "");
|
|
162
|
+
try {
|
|
163
|
+
const value = JSON.parse(preview) as unknown;
|
|
164
|
+
if (
|
|
165
|
+
value &&
|
|
166
|
+
typeof value === "object" &&
|
|
167
|
+
!Array.isArray(value) &&
|
|
168
|
+
typeof (value as Record<string, unknown>).command === "string"
|
|
169
|
+
) {
|
|
170
|
+
return String((value as Record<string, unknown>).command);
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// A bounded/truncated preview is deliberately not guessed.
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type HardDeny = {
|
|
179
|
+
rule: string;
|
|
180
|
+
reason: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Keep this list narrow: these checks are terminal and cannot be overridden by
|
|
185
|
+
* the model or the user prompt. Ambiguous or merely high-risk actions belong in
|
|
186
|
+
* the detailed reviewer, which can defer to the human.
|
|
187
|
+
*/
|
|
188
|
+
export function deterministicHardDeny(
|
|
189
|
+
details: PermissionDetailsLike,
|
|
190
|
+
): HardDeny | undefined {
|
|
191
|
+
const command = effectiveCommand(details)?.trim();
|
|
192
|
+
if (!command) return undefined;
|
|
193
|
+
|
|
194
|
+
for (const segment of command.split(/&&|\|\||;|\n/)) {
|
|
195
|
+
const isRm = /(?:^|\s)(?:\/[^\s/]+)*\/?rm(?:\s|$)/i.test(segment);
|
|
196
|
+
const recursive =
|
|
197
|
+
/(?:^|\s)--recursive(?:\s|$)/i.test(segment) ||
|
|
198
|
+
/(?:^|\s)-[A-Za-z]*r[A-Za-z]*(?:\s|$)/i.test(segment);
|
|
199
|
+
const forced =
|
|
200
|
+
/(?:^|\s)--force(?:\s|$)/i.test(segment) ||
|
|
201
|
+
/(?:^|\s)-[A-Za-z]*f[A-Za-z]*(?:\s|$)/i.test(segment);
|
|
202
|
+
const rootOrHomeTarget =
|
|
203
|
+
/(?:^|\s)["']?(?:\/(?:\*)?|~(?:\/(?:\*)?)?|\$HOME(?:\/(?:\*)?)?|\$\{HOME\}(?:\/(?:\*)?)?)["']?(?=\s|$)/i.test(
|
|
204
|
+
segment,
|
|
205
|
+
);
|
|
206
|
+
if (isRm && recursive && forced && rootOrHomeTarget) {
|
|
207
|
+
return {
|
|
208
|
+
rule: "destructive-root-or-home-delete",
|
|
209
|
+
reason:
|
|
210
|
+
"recursive forced deletion of root or the home directory is forbidden",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
/\bcurl\b[^;\n]*(?:--insecure\b|-[A-Za-z]*k[A-Za-z]*(?:\s|$))/i.test(
|
|
217
|
+
command,
|
|
218
|
+
) ||
|
|
219
|
+
/\bwget\b[^;\n]*--no-check-certificate\b/i.test(command) ||
|
|
220
|
+
/\bgit\s+config\b[^;\n]*http\.sslverify\s+false\b/i.test(command) ||
|
|
221
|
+
/\bnpm\s+config\s+set\s+strict-ssl\s+false\b/i.test(command) ||
|
|
222
|
+
/\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0\b/i.test(command)
|
|
223
|
+
) {
|
|
224
|
+
return {
|
|
225
|
+
rule: "transport-security-weakening",
|
|
226
|
+
reason: "disabling TLS or certificate verification is forbidden",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const credentialSource =
|
|
231
|
+
/(?:\/|~\/|\$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;
|
|
232
|
+
const networkUpload =
|
|
233
|
+
/\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;
|
|
234
|
+
const credentialPipe =
|
|
235
|
+
/(?: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;
|
|
236
|
+
if (
|
|
237
|
+
(credentialSource.test(command) && networkUpload.test(command)) ||
|
|
238
|
+
credentialPipe.test(command)
|
|
239
|
+
) {
|
|
240
|
+
return {
|
|
241
|
+
rule: "credential-exfiltration",
|
|
242
|
+
reason:
|
|
243
|
+
"sending credentials or secret configuration to a network sink is forbidden",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const authorizationPath =
|
|
248
|
+
String.raw`(?:authorized_keys|/etc/sudoers|/etc/sudoers\.d/)`;
|
|
249
|
+
const redirectedAuthorizationWrite = new RegExp(
|
|
250
|
+
String.raw`\b(?:printf|echo|cat)\b[^;\n]*(?:>>?|\|\s*tee\b)[^;\n]*${authorizationPath}`,
|
|
251
|
+
"i",
|
|
252
|
+
);
|
|
253
|
+
const directAuthorizationWrite = new RegExp(
|
|
254
|
+
String.raw`\b(?:tee|cp|mv|install)\b[^;\n]*${authorizationPath}`,
|
|
255
|
+
"i",
|
|
256
|
+
);
|
|
257
|
+
if (
|
|
258
|
+
redirectedAuthorizationWrite.test(command) ||
|
|
259
|
+
directAuthorizationWrite.test(command)
|
|
260
|
+
) {
|
|
261
|
+
return {
|
|
262
|
+
rule: "access-persistence",
|
|
263
|
+
reason: "adding SSH or sudo authorization persistence is forbidden",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function boundedString(value: unknown): string {
|
|
271
|
+
let rendered: string;
|
|
272
|
+
if (typeof value === "string") rendered = value;
|
|
273
|
+
else {
|
|
274
|
+
try {
|
|
275
|
+
rendered = JSON.stringify(value);
|
|
276
|
+
} catch {
|
|
277
|
+
rendered = "[unserializable]";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (rendered.length <= MAX_EVIDENCE_ITEM_CHARACTERS) return rendered;
|
|
281
|
+
const half = Math.floor((MAX_EVIDENCE_ITEM_CHARACTERS - 32) / 2);
|
|
282
|
+
return `${rendered.slice(0, half)}\n…[middle truncated]…\n${rendered.slice(-half)}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function userText(message: Record<string, unknown>): string {
|
|
286
|
+
if (message.role !== "user") return "";
|
|
287
|
+
if (typeof message.content === "string") return message.content.trim();
|
|
288
|
+
if (!Array.isArray(message.content)) return "";
|
|
289
|
+
return message.content
|
|
290
|
+
.filter(
|
|
291
|
+
(part): part is Record<string, unknown> =>
|
|
292
|
+
Boolean(part) && typeof part === "object" && !Array.isArray(part),
|
|
293
|
+
)
|
|
294
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
295
|
+
.map((part) => String(part.text))
|
|
296
|
+
.join("\n")
|
|
297
|
+
.trim();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function toolTexts(message: Record<string, unknown>): string[] {
|
|
301
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
return message.content.flatMap((part) => {
|
|
305
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) return [];
|
|
306
|
+
const record = part as Record<string, unknown>;
|
|
307
|
+
if (record.type !== "toolCall" || typeof record.name !== "string") {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
return [`${record.name} ${boundedString(record.arguments ?? {})}`];
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function extractEvidence(entries: readonly unknown[]): Evidence[] {
|
|
315
|
+
const evidence: Evidence[] = [];
|
|
316
|
+
entries.forEach((entry) => {
|
|
317
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
|
|
318
|
+
const message = (entry as Record<string, unknown>).message;
|
|
319
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const record = message as Record<string, unknown>;
|
|
323
|
+
const user = userText(record);
|
|
324
|
+
if (user) {
|
|
325
|
+
evidence.push({
|
|
326
|
+
index: evidence.length,
|
|
327
|
+
kind: "user",
|
|
328
|
+
text: boundedString(user),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
for (const tool of toolTexts(record)) {
|
|
332
|
+
evidence.push({ index: evidence.length, kind: "tool", text: tool });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
return evidence;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
type ToolCallRecord = {
|
|
339
|
+
id?: string;
|
|
340
|
+
name: string;
|
|
341
|
+
arguments: unknown;
|
|
342
|
+
rendered: string;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function messageRecord(entry: unknown): Record<string, unknown> | undefined {
|
|
346
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
|
|
347
|
+
const message = (entry as Record<string, unknown>).message;
|
|
348
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return;
|
|
349
|
+
return message as Record<string, unknown>;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function redactSensitiveResult(value: string): string {
|
|
353
|
+
return value
|
|
354
|
+
.replace(
|
|
355
|
+
/-----BEGIN [^-]*(?:PRIVATE KEY|OPENSSH PRIVATE KEY)-----[\s\S]*?-----END [^-]*(?:PRIVATE KEY|OPENSSH PRIVATE KEY)-----/gi,
|
|
356
|
+
"[REDACTED PRIVATE KEY]",
|
|
357
|
+
)
|
|
358
|
+
.replace(
|
|
359
|
+
/\b(authorization\s*:\s*(?:bearer|basic)|bearer)\s+[A-Za-z0-9._~+/=-]+/gi,
|
|
360
|
+
"$1 [REDACTED]",
|
|
361
|
+
)
|
|
362
|
+
.replace(
|
|
363
|
+
/(["'])([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY))\1\s*:\s*(["'])[^"'\r\n]*\3/g,
|
|
364
|
+
'$1$2$1:$3[REDACTED]$3',
|
|
365
|
+
)
|
|
366
|
+
.replace(
|
|
367
|
+
/\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY))\s*[:=]\s*([^\s]+)/g,
|
|
368
|
+
"$1=[REDACTED]",
|
|
369
|
+
)
|
|
370
|
+
.replace(/\b(?:ghp|github_pat|sk)-[A-Za-z0-9_-]{12,}\b/g, "[REDACTED TOKEN]");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function escapeEvidenceMarkup(value: string): string {
|
|
374
|
+
return value
|
|
375
|
+
.replace(/&/g, "&")
|
|
376
|
+
.replace(/</g, "<")
|
|
377
|
+
.replace(/>/g, ">");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resultText(message: Record<string, unknown>): string {
|
|
381
|
+
if (!Array.isArray(message.content)) return "";
|
|
382
|
+
const text = message.content
|
|
383
|
+
.flatMap((part) => {
|
|
384
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) return [];
|
|
385
|
+
const record = part as Record<string, unknown>;
|
|
386
|
+
return record.type === "text" && typeof record.text === "string"
|
|
387
|
+
? [record.text]
|
|
388
|
+
: [];
|
|
389
|
+
})
|
|
390
|
+
.join("\n")
|
|
391
|
+
.trim();
|
|
392
|
+
return boundedString(redactSensitiveResult(text));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function commandArgument(call: ToolCallRecord): string {
|
|
396
|
+
if (
|
|
397
|
+
call.arguments &&
|
|
398
|
+
typeof call.arguments === "object" &&
|
|
399
|
+
!Array.isArray(call.arguments) &&
|
|
400
|
+
typeof (call.arguments as Record<string, unknown>).command === "string"
|
|
401
|
+
) {
|
|
402
|
+
return String((call.arguments as Record<string, unknown>).command);
|
|
403
|
+
}
|
|
404
|
+
return "";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
type ProviderBranchQuery = {
|
|
408
|
+
provider: "github" | "gitlab";
|
|
409
|
+
branch: string;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
function normalizedBranch(value: string): string | undefined {
|
|
413
|
+
let branch = value.replace(/^refs\/heads\//, "");
|
|
414
|
+
try {
|
|
415
|
+
branch = decodeURIComponent(branch);
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
return branch && !/[\s;&|`$<>]/.test(branch) ? branch : undefined;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function explicitPushBranch(command: string): string | undefined {
|
|
423
|
+
if (/[\n;&|`<>]|\$\(/.test(command)) return;
|
|
424
|
+
const words = command
|
|
425
|
+
.trim()
|
|
426
|
+
.split(/\s+/)
|
|
427
|
+
.map((word) => word.replace(/^(["'])(.*)\1$/, "$2"));
|
|
428
|
+
const git = words.findIndex((word) => /(?:^|\/)git$/.test(word));
|
|
429
|
+
if (git < 0 || words[git + 1] !== "push") return;
|
|
430
|
+
const positional = words
|
|
431
|
+
.slice(git + 2)
|
|
432
|
+
.filter((word) => word && !word.startsWith("-"));
|
|
433
|
+
// A single explicit refspec keeps the provider evidence bound to the whole
|
|
434
|
+
// push. Multi-ref pushes deliberately receive only the generic Git context.
|
|
435
|
+
if (positional.length !== 2) return;
|
|
436
|
+
const refspec = positional[positional.length - 1];
|
|
437
|
+
const destination = refspec.includes(":")
|
|
438
|
+
? refspec.slice(refspec.lastIndexOf(":") + 1)
|
|
439
|
+
: refspec;
|
|
440
|
+
return normalizedBranch(destination);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function providerBranchQuery(command: string): ProviderBranchQuery | undefined {
|
|
444
|
+
if (/[\n;&|`<>]|\$\(/.test(command)) return;
|
|
445
|
+
const github = command
|
|
446
|
+
.trim()
|
|
447
|
+
.match(
|
|
448
|
+
/^gh\s+api\s+["']?\/?repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/branches\/([^\/\s"']+)(?:\/protection)?["']?$/,
|
|
449
|
+
);
|
|
450
|
+
if (github) {
|
|
451
|
+
const branch = normalizedBranch(github[1]);
|
|
452
|
+
return branch ? { provider: "github", branch } : undefined;
|
|
453
|
+
}
|
|
454
|
+
const gitlab = command
|
|
455
|
+
.trim()
|
|
456
|
+
.match(
|
|
457
|
+
/^glab\s+api\s+["']?\/?projects\/[A-Za-z0-9_.%/-]+\/protected_branches\/([^\/\s"']+)["']?$/,
|
|
458
|
+
);
|
|
459
|
+
if (gitlab) {
|
|
460
|
+
const branch = normalizedBranch(gitlab[1]);
|
|
461
|
+
return branch ? { provider: "gitlab", branch } : undefined;
|
|
462
|
+
}
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function relevanceReason(
|
|
467
|
+
call: ToolCallRecord,
|
|
468
|
+
request: RelevantBoundaryRequest,
|
|
469
|
+
):
|
|
470
|
+
| "same-tool"
|
|
471
|
+
| "delete-precheck"
|
|
472
|
+
| "git-push-context"
|
|
473
|
+
| "provider-branch-protection"
|
|
474
|
+
| undefined {
|
|
475
|
+
if (request.toolCallId && call.id === request.toolCallId) return "same-tool";
|
|
476
|
+
const callText = call.rendered.toLowerCase();
|
|
477
|
+
const needles = [
|
|
478
|
+
request.path,
|
|
479
|
+
request.resolvedPath,
|
|
480
|
+
request.destination,
|
|
481
|
+
request.command,
|
|
482
|
+
]
|
|
483
|
+
.filter((value): value is string => Boolean(value && value.length >= 3))
|
|
484
|
+
.map((value) => value.toLowerCase());
|
|
485
|
+
const namesMatch =
|
|
486
|
+
Boolean(request.toolName) &&
|
|
487
|
+
(call.name === request.toolName ||
|
|
488
|
+
call.name.endsWith(`/${request.toolName}`) ||
|
|
489
|
+
request.toolName?.endsWith(`/${call.name}`));
|
|
490
|
+
const currentCommand = request.command || "";
|
|
491
|
+
const priorCommand = commandArgument(call);
|
|
492
|
+
const pushedBranch = explicitPushBranch(currentCommand);
|
|
493
|
+
const providerQuery = providerBranchQuery(priorCommand);
|
|
494
|
+
const destructive =
|
|
495
|
+
/\b(?:rm|rmdir|unlink|trash|delete)\b/i.test(currentCommand);
|
|
496
|
+
const readOnlyCheck =
|
|
497
|
+
/^\s*(?:stat|ls|find|test|readlink|realpath)\b/i.test(priorCommand);
|
|
498
|
+
const targets = [request.path, request.resolvedPath]
|
|
499
|
+
.filter((value): value is string => Boolean(value))
|
|
500
|
+
.map((value) => value.toLowerCase());
|
|
501
|
+
if (
|
|
502
|
+
destructive &&
|
|
503
|
+
readOnlyCheck &&
|
|
504
|
+
targets.some((target) => priorCommand.toLowerCase().includes(target))
|
|
505
|
+
) {
|
|
506
|
+
return "delete-precheck";
|
|
507
|
+
}
|
|
508
|
+
if (
|
|
509
|
+
/\bgit\b[\s\S]*\bpush\b/i.test(currentCommand) &&
|
|
510
|
+
/^\s*git\s+(?:remote|branch|status|rev-parse|config\s+--get\s+remote)/i.test(
|
|
511
|
+
priorCommand,
|
|
512
|
+
)
|
|
513
|
+
) {
|
|
514
|
+
return "git-push-context";
|
|
515
|
+
}
|
|
516
|
+
if (
|
|
517
|
+
pushedBranch &&
|
|
518
|
+
providerQuery &&
|
|
519
|
+
providerQuery.branch === pushedBranch
|
|
520
|
+
) {
|
|
521
|
+
return "provider-branch-protection";
|
|
522
|
+
}
|
|
523
|
+
if (
|
|
524
|
+
(namesMatch || needles.length > 0) &&
|
|
525
|
+
needles.some((value) => callText.includes(value))
|
|
526
|
+
) {
|
|
527
|
+
return "same-tool";
|
|
528
|
+
}
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function relevantResultEvidence(
|
|
533
|
+
entries: readonly unknown[],
|
|
534
|
+
request: RelevantBoundaryRequest,
|
|
535
|
+
): Array<{ index: number; text: string }> {
|
|
536
|
+
const calls = new Map<string, ToolCallRecord>();
|
|
537
|
+
const results: Array<{ index: number; text: string }> = [];
|
|
538
|
+
entries.forEach((entry, index) => {
|
|
539
|
+
const message = messageRecord(entry);
|
|
540
|
+
if (!message) return;
|
|
541
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
542
|
+
for (const part of message.content) {
|
|
543
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
|
|
544
|
+
const record = part as Record<string, unknown>;
|
|
545
|
+
if (record.type !== "toolCall" || typeof record.name !== "string") continue;
|
|
546
|
+
const id =
|
|
547
|
+
typeof record.id === "string"
|
|
548
|
+
? record.id
|
|
549
|
+
: typeof record.toolCallId === "string"
|
|
550
|
+
? record.toolCallId
|
|
551
|
+
: undefined;
|
|
552
|
+
if (!id) continue;
|
|
553
|
+
calls.set(id, {
|
|
554
|
+
id,
|
|
555
|
+
name: record.name,
|
|
556
|
+
arguments: record.arguments,
|
|
557
|
+
rendered: `${record.name} ${boundedString(record.arguments ?? {})}`,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (
|
|
563
|
+
message.role !== "toolResult" ||
|
|
564
|
+
typeof message.toolCallId !== "string"
|
|
565
|
+
) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const call = calls.get(message.toolCallId);
|
|
569
|
+
if (!call) return;
|
|
570
|
+
const reason = relevanceReason(call, request);
|
|
571
|
+
const text = resultText(message);
|
|
572
|
+
if (reason && text) {
|
|
573
|
+
results.push({
|
|
574
|
+
index,
|
|
575
|
+
text: `<tool-result reason="${reason}" tool="${escapeEvidenceMarkup(call.name)}">\n${escapeEvidenceMarkup(text)}\n</tool-result>`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
return results;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function sandboxTrapEvidence(
|
|
583
|
+
request: RelevantBoundaryRequest,
|
|
584
|
+
): string | undefined {
|
|
585
|
+
if (request.source !== "landstrip") return;
|
|
586
|
+
return `<sandbox-trap>
|
|
587
|
+
${boundedString({
|
|
588
|
+
surface: request.surface,
|
|
589
|
+
operation: request.operation,
|
|
590
|
+
path: request.path,
|
|
591
|
+
resolvedPath: request.resolvedPath,
|
|
592
|
+
destination: request.destination,
|
|
593
|
+
process: request.toolName,
|
|
594
|
+
})}
|
|
595
|
+
</sandbox-trap>`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function selectEvidence(
|
|
599
|
+
evidence: Evidence[],
|
|
600
|
+
kind: Evidence["kind"],
|
|
601
|
+
budgetCharacters: number,
|
|
602
|
+
): { selected: Evidence[]; truncated: boolean } {
|
|
603
|
+
const candidates = evidence.filter((item) => item.kind === kind);
|
|
604
|
+
if (candidates.length === 0 || budgetCharacters <= 0) {
|
|
605
|
+
return { selected: [], truncated: candidates.length > 0 };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const selected = new Map<number, Evidence>();
|
|
609
|
+
let remaining = budgetCharacters;
|
|
610
|
+
const add = (item: Evidence, limit = remaining): void => {
|
|
611
|
+
if (selected.has(item.index) || remaining <= 0) return;
|
|
612
|
+
const text = item.text.slice(0, Math.min(remaining, limit));
|
|
613
|
+
if (!text) return;
|
|
614
|
+
selected.set(item.index, { ...item, text });
|
|
615
|
+
remaining -= text.length;
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// Keep the original user intent as an anchor, then fill from newest to oldest.
|
|
619
|
+
if (kind === "user") {
|
|
620
|
+
const firstBudget =
|
|
621
|
+
candidates.length > 1
|
|
622
|
+
? Math.max(1, Math.floor(budgetCharacters / 2))
|
|
623
|
+
: budgetCharacters;
|
|
624
|
+
add(candidates[0], firstBudget);
|
|
625
|
+
if (candidates.length > 1) add(candidates[candidates.length - 1]);
|
|
626
|
+
}
|
|
627
|
+
for (let index = candidates.length - 1; index >= 0; index--) {
|
|
628
|
+
add(candidates[index]);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
selected: [...selected.values()],
|
|
632
|
+
truncated:
|
|
633
|
+
selected.size < candidates.length ||
|
|
634
|
+
[...selected.values()].some(
|
|
635
|
+
(item) =>
|
|
636
|
+
item.text.length <
|
|
637
|
+
(candidates.find((candidate) => candidate.index === item.index)?.text
|
|
638
|
+
.length || 0),
|
|
639
|
+
),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function buildClassifierTranscript(
|
|
644
|
+
entries: readonly unknown[],
|
|
645
|
+
config: TranscriptConfig,
|
|
646
|
+
request: RelevantBoundaryRequest = {},
|
|
647
|
+
): TranscriptResult {
|
|
648
|
+
const evidence = extractEvidence(entries);
|
|
649
|
+
const users = selectEvidence(
|
|
650
|
+
evidence,
|
|
651
|
+
"user",
|
|
652
|
+
config.maxUserTranscriptTokens * 4,
|
|
653
|
+
);
|
|
654
|
+
const tools = selectEvidence(
|
|
655
|
+
evidence,
|
|
656
|
+
"tool",
|
|
657
|
+
config.maxToolTranscriptTokens * 4,
|
|
658
|
+
);
|
|
659
|
+
const selected = [...users.selected, ...tools.selected].sort(
|
|
660
|
+
(left, right) => left.index - right.index,
|
|
661
|
+
);
|
|
662
|
+
const baseRendered = selected
|
|
663
|
+
.map(
|
|
664
|
+
(item) =>
|
|
665
|
+
`<${item.kind}>\n${escapeEvidenceMarkup(item.text)}\n</${item.kind}>`,
|
|
666
|
+
)
|
|
667
|
+
.join("\n\n");
|
|
668
|
+
const relevantBudget =
|
|
669
|
+
(config.maxRelevantResultTokens ?? config.maxToolTranscriptTokens) * 4;
|
|
670
|
+
const relevantCandidates = [
|
|
671
|
+
...(sandboxTrapEvidence(request)
|
|
672
|
+
? [{ index: Number.MAX_SAFE_INTEGER - 1, text: sandboxTrapEvidence(request)! }]
|
|
673
|
+
: []),
|
|
674
|
+
...relevantResultEvidence(entries, request),
|
|
675
|
+
];
|
|
676
|
+
let relevantRemaining = relevantBudget;
|
|
677
|
+
const relevantSelected: string[] = [];
|
|
678
|
+
for (let index = relevantCandidates.length - 1; index >= 0; index--) {
|
|
679
|
+
if (relevantRemaining <= 0) break;
|
|
680
|
+
const text = relevantCandidates[index].text.slice(0, relevantRemaining);
|
|
681
|
+
if (text) {
|
|
682
|
+
relevantSelected.unshift(text);
|
|
683
|
+
relevantRemaining -= text.length;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const rendered = [baseRendered, ...relevantSelected]
|
|
687
|
+
.filter(Boolean)
|
|
688
|
+
.join("\n\n");
|
|
689
|
+
const relevantTruncated =
|
|
690
|
+
relevantSelected.length < relevantCandidates.length ||
|
|
691
|
+
relevantSelected.reduce((total, value) => total + value.length, 0) <
|
|
692
|
+
relevantCandidates.reduce((total, value) => total + value.text.length, 0);
|
|
693
|
+
const truncated = users.truncated || tools.truncated || relevantTruncated;
|
|
694
|
+
const text = truncated
|
|
695
|
+
? `[Some transcript evidence was omitted or truncated.]\n\n${rendered}`
|
|
696
|
+
: rendered;
|
|
697
|
+
return {
|
|
698
|
+
text: text || "(no eligible transcript evidence)",
|
|
699
|
+
userCharacters: users.selected.reduce(
|
|
700
|
+
(total, item) => total + item.text.length,
|
|
701
|
+
0,
|
|
702
|
+
),
|
|
703
|
+
toolCharacters: tools.selected.reduce(
|
|
704
|
+
(total, item) => total + item.text.length,
|
|
705
|
+
0,
|
|
706
|
+
),
|
|
707
|
+
relevantResultCharacters: relevantSelected.reduce(
|
|
708
|
+
(total, item) => total + item.length,
|
|
709
|
+
0,
|
|
710
|
+
),
|
|
711
|
+
truncated,
|
|
712
|
+
};
|
|
713
|
+
}
|