@czottmann/pi-automode 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/docs/automode-classifier-flow.md +31 -46
- package/docs/defaults.md +13 -2
- package/docs/observability-logging.md +28 -12
- package/extensions/auto-mode/classifier.ts +270 -59
- package/extensions/auto-mode/config.ts +33 -11
- package/extensions/auto-mode/constants.ts +9 -4
- package/extensions/auto-mode/extension.ts +20 -33
- package/extensions/auto-mode/hard-deny.ts +5 -2
- package/extensions/auto-mode/log.ts +17 -2
- package/extensions/auto-mode/paths.ts +67 -36
- package/extensions/auto-mode/transcript.ts +181 -33
- package/extensions/auto-mode/types.ts +20 -4
- package/extensions/auto-mode.ts +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { complete } from "@earendil-works/pi-ai";
|
|
2
3
|
import type {
|
|
3
4
|
AssistantMessage,
|
|
@@ -5,9 +6,13 @@ import type {
|
|
|
5
6
|
UserMessage,
|
|
6
7
|
} from "@earendil-works/pi-ai";
|
|
7
8
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
CLASSIFIER_DETAILED_INSTRUCTION,
|
|
11
|
+
CLASSIFIER_FAST_INSTRUCTION,
|
|
12
|
+
CLASSIFIER_SYSTEM_PROMPT,
|
|
13
|
+
} from "./constants.ts";
|
|
9
14
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
10
|
-
import {
|
|
15
|
+
import { buildClassifierTranscript } from "./transcript.ts";
|
|
11
16
|
import type {
|
|
12
17
|
ClassificationDecision,
|
|
13
18
|
ClassifyAction,
|
|
@@ -65,7 +70,9 @@ export type ClassifierCompletionFn = (
|
|
|
65
70
|
headers?: Record<string, string>;
|
|
66
71
|
signal?: AbortSignal;
|
|
67
72
|
maxTokens: number;
|
|
68
|
-
temperature
|
|
73
|
+
temperature?: number;
|
|
74
|
+
sessionId?: string;
|
|
75
|
+
cacheRetention?: "none" | "short" | "long";
|
|
69
76
|
},
|
|
70
77
|
) => Promise<AssistantMessage>;
|
|
71
78
|
|
|
@@ -73,60 +80,144 @@ export type RetryOptions = {
|
|
|
73
80
|
maxAttempts?: number;
|
|
74
81
|
maxTokens?: number;
|
|
75
82
|
temperature?: number;
|
|
83
|
+
sessionId?: string;
|
|
84
|
+
cacheRetention?: "none" | "short" | "long";
|
|
85
|
+
stage?: "fast" | "detailed";
|
|
76
86
|
/** Receives each attempt's raw response (or error) and parsed decision, for observability logging. */
|
|
77
87
|
onAttempt?: (attempt: ClassifierIoAttempt) => void;
|
|
78
88
|
};
|
|
79
89
|
|
|
90
|
+
const FAST_CLASSIFIER_MAX_TOKENS = 4;
|
|
91
|
+
|
|
92
|
+
export type StagedClassifierOptions = {
|
|
93
|
+
sessionId: string;
|
|
94
|
+
onAttempt?: (attempt: ClassifierIoAttempt) => void;
|
|
95
|
+
};
|
|
96
|
+
|
|
80
97
|
/** Concatenate all text blocks of an assistant message into a single string. */
|
|
81
|
-
function extractAssistantText(message: AssistantMessage): string {
|
|
82
|
-
|
|
98
|
+
function extractAssistantText(message: AssistantMessage, trim = true): string {
|
|
99
|
+
const text = message.content
|
|
83
100
|
.filter(
|
|
84
101
|
(block): block is { type: "text"; text: string } => block.type === "text",
|
|
85
102
|
)
|
|
86
103
|
.map((block) => block.text)
|
|
87
|
-
.join("\n")
|
|
88
|
-
|
|
104
|
+
.join("\n");
|
|
105
|
+
return trim ? text.trim() : text;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
/** Parse the
|
|
108
|
+
/** Parse the exact detailed-stage JSON contract; any wrapper or shape drift fails closed. */
|
|
92
109
|
export function parseClassifierDecision(
|
|
93
110
|
message: AssistantMessage,
|
|
94
111
|
): ClassificationDecision | undefined {
|
|
95
112
|
const text = extractAssistantText(message);
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
const validTiers = new Set<ClassificationDecision["tier"]>([
|
|
114
|
+
"hard_deny",
|
|
115
|
+
"soft_deny",
|
|
116
|
+
"allow",
|
|
117
|
+
"explicit_intent",
|
|
118
|
+
"none",
|
|
119
|
+
]);
|
|
120
|
+
try {
|
|
121
|
+
for (const key of ["decision", "tier", "reason"]) {
|
|
122
|
+
const occurrences = text.match(new RegExp(`"${key}"\\s*:`, "g"))?.length ?? 0;
|
|
123
|
+
if (occurrences !== 1) return undefined;
|
|
124
|
+
}
|
|
125
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
126
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const keys = Object.keys(parsed).sort();
|
|
130
|
+
if (keys.join(",") !== "decision,reason,tier") return undefined;
|
|
131
|
+
if (parsed.decision !== "allow" && parsed.decision !== "block") {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (!validTiers.has(parsed.tier as ClassificationDecision["tier"])) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const tier = parsed.tier as ClassificationDecision["tier"];
|
|
138
|
+
if (
|
|
139
|
+
(parsed.decision === "allow" &&
|
|
140
|
+
!["allow", "explicit_intent", "none"].includes(tier)) ||
|
|
141
|
+
(parsed.decision === "block" &&
|
|
142
|
+
!["hard_deny", "soft_deny", "none"].includes(tier))
|
|
143
|
+
) {
|
|
144
|
+
return undefined;
|
|
115
145
|
}
|
|
146
|
+
if (typeof parsed.reason !== "string" || parsed.reason.trim() === "") {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
decision: parsed.decision,
|
|
151
|
+
tier,
|
|
152
|
+
reason: parsed.reason,
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stageMessage(text: string): UserMessage {
|
|
160
|
+
return {
|
|
161
|
+
role: "user",
|
|
162
|
+
content: [{ type: "text", text }],
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function responseAttempt(
|
|
168
|
+
stage: "fast" | "detailed",
|
|
169
|
+
attempt: number,
|
|
170
|
+
response: AssistantMessage,
|
|
171
|
+
durationMs: number,
|
|
172
|
+
parsed?: ClassificationDecision,
|
|
173
|
+
trimText = true,
|
|
174
|
+
): ClassifierIoAttempt {
|
|
175
|
+
return {
|
|
176
|
+
stage,
|
|
177
|
+
attempt,
|
|
178
|
+
response: {
|
|
179
|
+
stopReason: response.stopReason,
|
|
180
|
+
text: extractAssistantText(response, trimText),
|
|
181
|
+
model: response.model,
|
|
182
|
+
timestamp: response.timestamp,
|
|
183
|
+
usage: response.usage,
|
|
184
|
+
...(response.errorMessage === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { errorMessage: response.errorMessage }),
|
|
187
|
+
},
|
|
188
|
+
parsed,
|
|
189
|
+
durationMs,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function classifierFailure(
|
|
194
|
+
response: AssistantMessage,
|
|
195
|
+
label: "Classifier" | "Fast classifier",
|
|
196
|
+
retryLength = false,
|
|
197
|
+
): ClassificationDecision | undefined {
|
|
198
|
+
if (
|
|
199
|
+
response.stopReason === "stop" ||
|
|
200
|
+
(retryLength && response.stopReason === "length")
|
|
201
|
+
) {
|
|
202
|
+
return undefined;
|
|
116
203
|
}
|
|
117
|
-
|
|
204
|
+
const fallback = response.stopReason === "aborted"
|
|
205
|
+
? "Classifier model request was aborted."
|
|
206
|
+
: response.stopReason === "error"
|
|
207
|
+
? "Classifier model returned an error response."
|
|
208
|
+
: `${label} response did not stop cleanly (${response.stopReason}).`;
|
|
209
|
+
return {
|
|
210
|
+
decision: "block",
|
|
211
|
+
tier: "none",
|
|
212
|
+
reason: `${label} failed; auto mode fails closed: ${
|
|
213
|
+
response.errorMessage || fallback
|
|
214
|
+
}`,
|
|
215
|
+
};
|
|
118
216
|
}
|
|
119
217
|
|
|
120
218
|
/**
|
|
121
|
-
* Call the classifier
|
|
122
|
-
*
|
|
123
|
-
* ramble into the token cap (stopReason "length") or emit prose instead of
|
|
124
|
-
* JSON; a single retry recovers most of these transient failures.
|
|
125
|
-
*
|
|
126
|
-
* Safety is preserved: an "allow" is only returned when a response actually
|
|
127
|
-
* parses to a valid allow decision. If every attempt fails to parse, the
|
|
128
|
-
* function fails closed with a block decision. Thrown errors (network/auth)
|
|
129
|
-
* are not retried — they fail closed immediately, matching prior behavior.
|
|
219
|
+
* Call the detailed classifier and parse its decision, retrying malformed or
|
|
220
|
+
* truncated output. Provider errors and exhausted retries fail closed.
|
|
130
221
|
*/
|
|
131
222
|
export async function classifyWithRetry(
|
|
132
223
|
completeFn: ClassifierCompletionFn,
|
|
@@ -141,7 +232,8 @@ export async function classifyWithRetry(
|
|
|
141
232
|
): Promise<ClassificationDecision> {
|
|
142
233
|
const maxAttempts = options.maxAttempts ?? 2;
|
|
143
234
|
const maxTokens = options.maxTokens ?? 1200;
|
|
144
|
-
const temperature = options.temperature
|
|
235
|
+
const temperature = options.temperature;
|
|
236
|
+
const stage = options.stage ?? "detailed";
|
|
145
237
|
const onAttempt = options.onAttempt;
|
|
146
238
|
let lastReason =
|
|
147
239
|
"Classifier response was not valid decision JSON; auto mode fails closed.";
|
|
@@ -157,12 +249,15 @@ export async function classifyWithRetry(
|
|
|
157
249
|
headers: classifier.headers,
|
|
158
250
|
signal,
|
|
159
251
|
maxTokens,
|
|
160
|
-
temperature,
|
|
252
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
253
|
+
sessionId: options.sessionId,
|
|
254
|
+
cacheRetention: options.cacheRetention,
|
|
161
255
|
},
|
|
162
256
|
);
|
|
163
257
|
} catch (error) {
|
|
164
258
|
const message = error instanceof Error ? error.message : String(error);
|
|
165
259
|
onAttempt?.({
|
|
260
|
+
stage,
|
|
166
261
|
attempt: attempt + 1,
|
|
167
262
|
error: message,
|
|
168
263
|
durationMs: Date.now() - started,
|
|
@@ -174,16 +269,14 @@ export async function classifyWithRetry(
|
|
|
174
269
|
};
|
|
175
270
|
}
|
|
176
271
|
const durationMs = Date.now() - started;
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
durationMs,
|
|
186
|
-
});
|
|
272
|
+
const failure = classifierFailure(response, "Classifier", true);
|
|
273
|
+
const decision = response.stopReason === "stop"
|
|
274
|
+
? parseClassifierDecision(response)
|
|
275
|
+
: undefined;
|
|
276
|
+
onAttempt?.(
|
|
277
|
+
responseAttempt(stage, attempt + 1, response, durationMs, decision, false),
|
|
278
|
+
);
|
|
279
|
+
if (failure) return failure;
|
|
187
280
|
if (decision) return decision;
|
|
188
281
|
lastReason =
|
|
189
282
|
response.stopReason === "length"
|
|
@@ -193,6 +286,112 @@ export async function classifyWithRetry(
|
|
|
193
286
|
return { decision: "block", tier: "none", reason: lastReason };
|
|
194
287
|
}
|
|
195
288
|
|
|
289
|
+
/** Run the one-token conservative gate, then detailed review only when requested. */
|
|
290
|
+
export async function classifyInStages(
|
|
291
|
+
completeFn: ClassifierCompletionFn,
|
|
292
|
+
classifier: {
|
|
293
|
+
model: Model<any>;
|
|
294
|
+
apiKey?: string;
|
|
295
|
+
headers?: Record<string, string>;
|
|
296
|
+
},
|
|
297
|
+
prompt: { systemPrompt: string; contextMessage: UserMessage },
|
|
298
|
+
signal: AbortSignal | undefined,
|
|
299
|
+
options: StagedClassifierOptions,
|
|
300
|
+
): Promise<ClassificationDecision> {
|
|
301
|
+
const fastStarted = Date.now();
|
|
302
|
+
let fastResponse: AssistantMessage;
|
|
303
|
+
try {
|
|
304
|
+
fastResponse = await completeFn(
|
|
305
|
+
classifier.model,
|
|
306
|
+
{
|
|
307
|
+
systemPrompt: prompt.systemPrompt,
|
|
308
|
+
messages: [
|
|
309
|
+
prompt.contextMessage,
|
|
310
|
+
stageMessage(CLASSIFIER_FAST_INSTRUCTION),
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
apiKey: classifier.apiKey,
|
|
315
|
+
headers: classifier.headers,
|
|
316
|
+
signal,
|
|
317
|
+
// Some OpenAI-compatible servers count an initial control token and
|
|
318
|
+
// EOS against max_tokens. Four tokens reliably permit one visible digit.
|
|
319
|
+
maxTokens: FAST_CLASSIFIER_MAX_TOKENS,
|
|
320
|
+
sessionId: options.sessionId,
|
|
321
|
+
cacheRetention: "short",
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
326
|
+
options.onAttempt?.({
|
|
327
|
+
stage: "fast",
|
|
328
|
+
attempt: 1,
|
|
329
|
+
error: message,
|
|
330
|
+
durationMs: Date.now() - fastStarted,
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
decision: "block",
|
|
334
|
+
tier: "none",
|
|
335
|
+
reason: `Fast classifier failed; auto mode fails closed: ${message}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const fastText = extractAssistantText(fastResponse, false).trim();
|
|
340
|
+
const failure = classifierFailure(fastResponse, "Fast classifier");
|
|
341
|
+
options.onAttempt?.(
|
|
342
|
+
responseAttempt(
|
|
343
|
+
"fast",
|
|
344
|
+
1,
|
|
345
|
+
fastResponse,
|
|
346
|
+
Date.now() - fastStarted,
|
|
347
|
+
undefined,
|
|
348
|
+
false,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
if (failure) return failure;
|
|
352
|
+
if (fastText === "0") {
|
|
353
|
+
return {
|
|
354
|
+
decision: "allow",
|
|
355
|
+
tier: "none",
|
|
356
|
+
reason: "Fast classifier found no policy-relevant risk.",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (fastText !== "1") {
|
|
360
|
+
return {
|
|
361
|
+
decision: "block",
|
|
362
|
+
tier: "none",
|
|
363
|
+
reason:
|
|
364
|
+
"Fast classifier response was not 0 or 1 after trimming whitespace; auto mode fails closed.",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return classifyWithRetry(
|
|
369
|
+
completeFn,
|
|
370
|
+
classifier,
|
|
371
|
+
{
|
|
372
|
+
systemPrompt: prompt.systemPrompt,
|
|
373
|
+
messages: [
|
|
374
|
+
prompt.contextMessage,
|
|
375
|
+
stageMessage(CLASSIFIER_DETAILED_INSTRUCTION),
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
signal,
|
|
379
|
+
{
|
|
380
|
+
stage: "detailed",
|
|
381
|
+
sessionId: options.sessionId,
|
|
382
|
+
cacheRetention: "short",
|
|
383
|
+
onAttempt: options.onAttempt,
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function classifierCacheSessionId(ctx: ExtensionContext): string {
|
|
389
|
+
const source = ctx.sessionManager.getSessionId?.() ??
|
|
390
|
+
ctx.sessionManager.getSessionFile?.() ?? ctx.cwd;
|
|
391
|
+
const digest = createHash("sha256").update(source).digest("hex").slice(0, 32);
|
|
392
|
+
return `pi-automode-${digest}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
196
395
|
export const defaultClassifyAction: ClassifyAction = async (
|
|
197
396
|
ctx,
|
|
198
397
|
config,
|
|
@@ -209,32 +408,44 @@ export const defaultClassifyAction: ClassifyAction = async (
|
|
|
209
408
|
}
|
|
210
409
|
|
|
211
410
|
const systemPrompt = buildClassifierPrompt(config);
|
|
212
|
-
const
|
|
411
|
+
const transcript = buildClassifierTranscript(ctx, {
|
|
412
|
+
maxUserTokens: config.maxUserTranscriptTokens,
|
|
413
|
+
maxToolTokens: config.maxToolTranscriptTokens,
|
|
414
|
+
});
|
|
415
|
+
const contextText = `<loaded-project-instructions>\n${
|
|
213
416
|
loadedContext || "(none)"
|
|
214
|
-
}\n</loaded-project-instructions>\n\n<transcript>\n${
|
|
215
|
-
|
|
216
|
-
}\n</transcript>\n\nLatest action to classify:\n${action}`;
|
|
217
|
-
const
|
|
417
|
+
}\n</loaded-project-instructions>\n\n<classifier-transcript>\n${
|
|
418
|
+
transcript || "(none)"
|
|
419
|
+
}\n</classifier-transcript>\n\nLatest action to classify:\n${action}`;
|
|
420
|
+
const contextMessage: UserMessage = {
|
|
218
421
|
role: "user",
|
|
219
|
-
content: [{ type: "text", text:
|
|
422
|
+
content: [{ type: "text", text: contextText }],
|
|
220
423
|
timestamp: Date.now(),
|
|
221
424
|
};
|
|
222
425
|
|
|
223
426
|
const attempts: ClassifierIoAttempt[] = [];
|
|
224
427
|
const started = Date.now();
|
|
225
|
-
const decision = await
|
|
428
|
+
const decision = await classifyInStages(
|
|
226
429
|
complete,
|
|
227
430
|
classifier,
|
|
228
|
-
{ systemPrompt,
|
|
431
|
+
{ systemPrompt, contextMessage },
|
|
229
432
|
ctx.signal,
|
|
230
|
-
{
|
|
433
|
+
{
|
|
434
|
+
sessionId: classifierCacheSessionId(ctx),
|
|
435
|
+
onAttempt: (attempt) => attempts.push(attempt),
|
|
436
|
+
},
|
|
231
437
|
);
|
|
232
438
|
|
|
233
439
|
return {
|
|
234
440
|
...decision,
|
|
235
441
|
io: {
|
|
236
442
|
model: formatModelSpec(classifier.model),
|
|
237
|
-
prompt: {
|
|
443
|
+
prompt: {
|
|
444
|
+
system: systemPrompt,
|
|
445
|
+
context: contextText,
|
|
446
|
+
fastInstruction: CLASSIFIER_FAST_INSTRUCTION,
|
|
447
|
+
detailedInstruction: CLASSIFIER_DETAILED_INSTRUCTION,
|
|
448
|
+
},
|
|
238
449
|
attempts,
|
|
239
450
|
durationMs: Date.now() - started,
|
|
240
451
|
},
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
DEFAULT_ENVIRONMENT,
|
|
6
6
|
DEFAULT_HARD_DENY,
|
|
7
7
|
DEFAULT_LOG_CONFIG,
|
|
8
|
-
|
|
8
|
+
DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
|
|
9
|
+
DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
9
10
|
DEFAULT_PROTECTED_PATHS,
|
|
10
11
|
DEFAULT_SOFT_DENY,
|
|
11
12
|
PI_GLOBAL_SETTINGS,
|
|
@@ -96,7 +97,8 @@ export function validateSettingsFile(
|
|
|
96
97
|
const knownAutoMode = new Set([
|
|
97
98
|
"enabled",
|
|
98
99
|
"classifierModel",
|
|
99
|
-
"
|
|
100
|
+
"maxUserTranscriptTokens",
|
|
101
|
+
"maxToolTranscriptTokens",
|
|
100
102
|
"environment",
|
|
101
103
|
"allow",
|
|
102
104
|
"protectedPaths",
|
|
@@ -124,14 +126,20 @@ export function validateSettingsFile(
|
|
|
124
126
|
`${source}: autoMode.classifierModel must be a provider/model string`,
|
|
125
127
|
);
|
|
126
128
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
for (
|
|
130
|
+
const key of [
|
|
131
|
+
"maxUserTranscriptTokens",
|
|
132
|
+
"maxToolTranscriptTokens",
|
|
133
|
+
] as const
|
|
131
134
|
) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
if (
|
|
136
|
+
hasOwn(autoMode, key) &&
|
|
137
|
+
(!Number.isInteger(autoMode[key]) || Number(autoMode[key]) < 32)
|
|
138
|
+
) {
|
|
139
|
+
diagnostics.push(
|
|
140
|
+
`${source}: autoMode.${key} must be an integer of at least 32`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
135
143
|
}
|
|
136
144
|
validateStringArraySetting(
|
|
137
145
|
autoMode.environment,
|
|
@@ -265,6 +273,10 @@ function mergeLog(
|
|
|
265
273
|
};
|
|
266
274
|
}
|
|
267
275
|
|
|
276
|
+
function validTranscriptBudget(value: unknown): value is number {
|
|
277
|
+
return Number.isInteger(value) && Number(value) >= 32;
|
|
278
|
+
}
|
|
279
|
+
|
|
268
280
|
function applyAutoModeScalars(
|
|
269
281
|
base: EffectiveConfig,
|
|
270
282
|
settings: AutoModeSettings | undefined,
|
|
@@ -274,7 +286,16 @@ function applyAutoModeScalars(
|
|
|
274
286
|
...base,
|
|
275
287
|
enabled: settings.enabled ?? base.enabled,
|
|
276
288
|
classifierModel: settings.classifierModel ?? base.classifierModel,
|
|
277
|
-
|
|
289
|
+
maxUserTranscriptTokens: validTranscriptBudget(
|
|
290
|
+
settings.maxUserTranscriptTokens,
|
|
291
|
+
)
|
|
292
|
+
? settings.maxUserTranscriptTokens
|
|
293
|
+
: base.maxUserTranscriptTokens,
|
|
294
|
+
maxToolTranscriptTokens: validTranscriptBudget(
|
|
295
|
+
settings.maxToolTranscriptTokens,
|
|
296
|
+
)
|
|
297
|
+
? settings.maxToolTranscriptTokens
|
|
298
|
+
: base.maxToolTranscriptTokens,
|
|
278
299
|
log: mergeLog(base.log, settings.log),
|
|
279
300
|
};
|
|
280
301
|
}
|
|
@@ -306,7 +327,8 @@ export function buildEffectiveConfigFromSources(
|
|
|
306
327
|
): EffectiveConfig {
|
|
307
328
|
let config: EffectiveConfig = {
|
|
308
329
|
enabled: true,
|
|
309
|
-
|
|
330
|
+
maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
331
|
+
maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
|
|
310
332
|
environment: [...DEFAULT_ENVIRONMENT],
|
|
311
333
|
allow: [...DEFAULT_ALLOW],
|
|
312
334
|
protectedPaths: [...DEFAULT_PROTECTED_PATHS],
|
|
@@ -55,7 +55,8 @@ export const DEFAULT_PROTECTED_PATHS = [
|
|
|
55
55
|
".mcp.json",
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
-
export const
|
|
58
|
+
export const DEFAULT_MAX_USER_TRANSCRIPT_TOKENS = 4000;
|
|
59
|
+
export const DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS = 4000;
|
|
59
60
|
export const DENIAL_HISTORY_LIMIT = 12;
|
|
60
61
|
|
|
61
62
|
/** Built-in trusted environment. Users extend this with `$defaults`. */
|
|
@@ -115,7 +116,7 @@ Auto mode is active. Routine permission prompts are replaced by a pre-execution
|
|
|
115
116
|
export const CLASSIFIER_SYSTEM_PROMPT =
|
|
116
117
|
`You are an auto-mode security classifier for an autonomous coding agent.
|
|
117
118
|
|
|
118
|
-
You evaluate the agent's latest tool call before it runs.
|
|
119
|
+
You evaluate the agent's latest tool call before it runs. Follow the final output instruction exactly.
|
|
119
120
|
|
|
120
121
|
Core semantics:
|
|
121
122
|
1. HARD_DENY rules block unconditionally. User intent and allow exceptions do not override them.
|
|
@@ -144,9 +145,13 @@ Examples:
|
|
|
144
145
|
- cat ~/.ssh/id_rsa or echo $API_TOKEN => block, hard_deny, credential exposure.
|
|
145
146
|
- npm test, swift test, xcodebuild test, or local formatting/linting => allow unless the action includes a blocked side effect.
|
|
146
147
|
- Uploading repository contents, logs with secrets, or internal data to an untrusted domain => block, hard_deny.
|
|
147
|
-
- Modifying .pi/automode*, this extension, or permission rules => block, hard_deny
|
|
148
|
+
- Modifying .pi/automode*, this extension, or permission rules => block, hard_deny.`;
|
|
148
149
|
|
|
149
|
-
|
|
150
|
+
export const CLASSIFIER_FAST_INSTRUCTION =
|
|
151
|
+
`Return exactly one digit and nothing else: 0 only when the action is clearly allowed by the policy; 1 when it may need blocking or you are uncertain. Err on 1.`;
|
|
152
|
+
|
|
153
|
+
export const CLASSIFIER_DETAILED_INSTRUCTION =
|
|
154
|
+
`Return only JSON exactly matching:
|
|
150
155
|
{"decision":"allow"|"block","tier":"hard_deny"|"soft_deny"|"allow"|"explicit_intent"|"none","reason":"brief concrete reason"}`;
|
|
151
156
|
|
|
152
157
|
export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/agent/automode.json")];
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
29
29
|
import { promptForClassifierModel } from "./model-selector.ts";
|
|
30
30
|
import { matchesToolPattern } from "./permissions.ts";
|
|
31
|
-
import { isProtectedPath, resolveInputPath } from "./paths.ts";
|
|
32
31
|
import {
|
|
33
32
|
actionSummary,
|
|
34
33
|
formatDenials,
|
|
@@ -64,9 +63,25 @@ type LogCtx = {
|
|
|
64
63
|
classifierModel?: string;
|
|
65
64
|
};
|
|
66
65
|
|
|
67
|
-
/** Append
|
|
66
|
+
/** Append ccusage-compatible usage and optional classifier I/O entries. */
|
|
68
67
|
function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
69
|
-
if (!log.logger.enabled || !
|
|
68
|
+
if (!log.logger.enabled || !decision.io) return;
|
|
69
|
+
|
|
70
|
+
for (const attempt of decision.io.attempts) {
|
|
71
|
+
const response = attempt.response;
|
|
72
|
+
if (!response) continue;
|
|
73
|
+
log.logger.append({
|
|
74
|
+
type: "message",
|
|
75
|
+
timestamp: new Date(response.timestamp).toISOString(),
|
|
76
|
+
message: {
|
|
77
|
+
role: "assistant",
|
|
78
|
+
model: response.model,
|
|
79
|
+
usage: response.usage,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!log.logger.classifierIo) return;
|
|
70
85
|
log.logger.append({
|
|
71
86
|
type: "classifier",
|
|
72
87
|
ts: new Date().toISOString(),
|
|
@@ -214,10 +229,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
214
229
|
});
|
|
215
230
|
|
|
216
231
|
pi.on("tool_call", async (event, ctx) => {
|
|
217
|
-
// Enforcement order
|
|
232
|
+
// Enforcement order:
|
|
218
233
|
// 1. permission deny/ask rules,
|
|
219
234
|
// 2. deterministic hard-deny checks that never consult the model,
|
|
220
|
-
// 3. read-only fast path,
|
|
235
|
+
// 3. read-only built-in fast path,
|
|
221
236
|
// 4. classifier for every remaining action, fail-closed on setup/parse errors.
|
|
222
237
|
const cfg = effectiveConfig();
|
|
223
238
|
if (!cfg.enabled) return undefined;
|
|
@@ -306,34 +321,6 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
306
321
|
);
|
|
307
322
|
}
|
|
308
323
|
|
|
309
|
-
// Protected paths go to the classifier regardless of allow rules.
|
|
310
|
-
if (event.toolName === "write" || event.toolName === "edit") {
|
|
311
|
-
const path = resolveInputPath(ctx.cwd, input.path);
|
|
312
|
-
if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
|
|
313
|
-
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
314
|
-
logClassifierIo(decision, logCtx);
|
|
315
|
-
if (decision.decision === "allow") {
|
|
316
|
-
state.classifierAllowed += 1;
|
|
317
|
-
return allow(
|
|
318
|
-
ctx,
|
|
319
|
-
"classifier",
|
|
320
|
-
decision.reason,
|
|
321
|
-
event.toolName,
|
|
322
|
-
summary,
|
|
323
|
-
logCtx,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
state.classifierDenied += 1;
|
|
327
|
-
return block(ctx, {
|
|
328
|
-
timestamp: Date.now(),
|
|
329
|
-
toolName: event.toolName,
|
|
330
|
-
reason: decision.reason,
|
|
331
|
-
action: summary,
|
|
332
|
-
kind: "classifier",
|
|
333
|
-
}, logCtx);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
324
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
338
325
|
logClassifierIo(decision, logCtx);
|
|
339
326
|
if (decision.decision === "allow") {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isProfileOrAuthorizedKeysPath,
|
|
5
5
|
isSafetyControlPath,
|
|
6
6
|
resolveInputPath,
|
|
7
|
+
resolvePathForPolicy,
|
|
7
8
|
shellPathTokenToPath,
|
|
8
9
|
} from "./paths.ts";
|
|
9
10
|
|
|
@@ -331,9 +332,11 @@ export function deterministicHardDeny(
|
|
|
331
332
|
if (toolName === "write" || toolName === "edit") {
|
|
332
333
|
const path = resolveInputPath(cwd, input.path);
|
|
333
334
|
if (!path) return undefined;
|
|
334
|
-
const
|
|
335
|
+
const policyPath = resolvePathForPolicy(path) ?? path;
|
|
336
|
+
const policyCwd = resolvePathForPolicy(cwd) ?? cwd;
|
|
337
|
+
const profileReason = isProfileOrAuthorizedKeysPath(policyPath);
|
|
335
338
|
if (profileReason) return profileReason;
|
|
336
|
-
if (isSafetyControlPath(
|
|
339
|
+
if (isSafetyControlPath(policyPath, policyCwd)) {
|
|
337
340
|
return "auto-mode or permission safety-control modification is hard-denied";
|
|
338
341
|
}
|
|
339
342
|
}
|