@czottmann/pi-automode 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -9
- package/docs/automode-classifier-flow.md +43 -46
- package/docs/defaults.md +13 -2
- package/docs/observability-logging.md +109 -0
- package/extensions/auto-mode/classifier.ts +305 -59
- package/extensions/auto-mode/config.ts +72 -11
- package/extensions/auto-mode/constants.ts +15 -4
- package/extensions/auto-mode/extension.ts +140 -43
- package/extensions/auto-mode/hard-deny.ts +5 -2
- package/extensions/auto-mode/log.ts +115 -0
- package/extensions/auto-mode/paths.ts +67 -36
- package/extensions/auto-mode/transcript.ts +181 -33
- package/extensions/auto-mode/types.ts +51 -3
- package/extensions/auto-mode.ts +2 -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,12 +6,18 @@ 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
|
-
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
CLASSIFIER_DETAILED_INSTRUCTION,
|
|
11
|
+
CLASSIFIER_FAST_INSTRUCTION,
|
|
12
|
+
CLASSIFIER_SYSTEM_PROMPT,
|
|
13
|
+
} from "./constants.ts";
|
|
14
|
+
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
15
|
+
import { buildClassifierTranscript } from "./transcript.ts";
|
|
11
16
|
import type {
|
|
12
17
|
ClassificationDecision,
|
|
13
18
|
ClassifyAction,
|
|
19
|
+
ClassifierIoAttempt,
|
|
20
|
+
ClassifyResult,
|
|
14
21
|
EffectiveConfig,
|
|
15
22
|
} from "./types.ts";
|
|
16
23
|
|
|
@@ -63,7 +70,9 @@ export type ClassifierCompletionFn = (
|
|
|
63
70
|
headers?: Record<string, string>;
|
|
64
71
|
signal?: AbortSignal;
|
|
65
72
|
maxTokens: number;
|
|
66
|
-
temperature
|
|
73
|
+
temperature?: number;
|
|
74
|
+
sessionId?: string;
|
|
75
|
+
cacheRetention?: "none" | "short" | "long";
|
|
67
76
|
},
|
|
68
77
|
) => Promise<AssistantMessage>;
|
|
69
78
|
|
|
@@ -71,53 +80,144 @@ export type RetryOptions = {
|
|
|
71
80
|
maxAttempts?: number;
|
|
72
81
|
maxTokens?: number;
|
|
73
82
|
temperature?: number;
|
|
83
|
+
sessionId?: string;
|
|
84
|
+
cacheRetention?: "none" | "short" | "long";
|
|
85
|
+
stage?: "fast" | "detailed";
|
|
86
|
+
/** Receives each attempt's raw response (or error) and parsed decision, for observability logging. */
|
|
87
|
+
onAttempt?: (attempt: ClassifierIoAttempt) => void;
|
|
74
88
|
};
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
const FAST_CLASSIFIER_MAX_TOKENS = 4;
|
|
91
|
+
|
|
92
|
+
export type StagedClassifierOptions = {
|
|
93
|
+
sessionId: string;
|
|
94
|
+
onAttempt?: (attempt: ClassifierIoAttempt) => void;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Concatenate all text blocks of an assistant message into a single string. */
|
|
98
|
+
function extractAssistantText(message: AssistantMessage, trim = true): string {
|
|
80
99
|
const text = message.content
|
|
81
100
|
.filter(
|
|
82
101
|
(block): block is { type: "text"; text: string } => block.type === "text",
|
|
83
102
|
)
|
|
84
103
|
.map((block) => block.text)
|
|
85
|
-
.join("\n")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
.join("\n");
|
|
105
|
+
return trim ? text.trim() : text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse the exact detailed-stage JSON contract; any wrapper or shape drift fails closed. */
|
|
109
|
+
export function parseClassifierDecision(
|
|
110
|
+
message: AssistantMessage,
|
|
111
|
+
): ClassificationDecision | undefined {
|
|
112
|
+
const text = extractAssistantText(message);
|
|
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;
|
|
106
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;
|
|
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;
|
|
107
156
|
}
|
|
108
|
-
|
|
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;
|
|
203
|
+
}
|
|
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
|
+
};
|
|
109
216
|
}
|
|
110
217
|
|
|
111
218
|
/**
|
|
112
|
-
* Call the classifier
|
|
113
|
-
*
|
|
114
|
-
* ramble into the token cap (stopReason "length") or emit prose instead of
|
|
115
|
-
* JSON; a single retry recovers most of these transient failures.
|
|
116
|
-
*
|
|
117
|
-
* Safety is preserved: an "allow" is only returned when a response actually
|
|
118
|
-
* parses to a valid allow decision. If every attempt fails to parse, the
|
|
119
|
-
* function fails closed with a block decision. Thrown errors (network/auth)
|
|
120
|
-
* 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.
|
|
121
221
|
*/
|
|
122
222
|
export async function classifyWithRetry(
|
|
123
223
|
completeFn: ClassifierCompletionFn,
|
|
@@ -132,10 +232,13 @@ export async function classifyWithRetry(
|
|
|
132
232
|
): Promise<ClassificationDecision> {
|
|
133
233
|
const maxAttempts = options.maxAttempts ?? 2;
|
|
134
234
|
const maxTokens = options.maxTokens ?? 1200;
|
|
135
|
-
const temperature = options.temperature
|
|
235
|
+
const temperature = options.temperature;
|
|
236
|
+
const stage = options.stage ?? "detailed";
|
|
237
|
+
const onAttempt = options.onAttempt;
|
|
136
238
|
let lastReason =
|
|
137
239
|
"Classifier response was not valid decision JSON; auto mode fails closed.";
|
|
138
240
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
241
|
+
const started = Date.now();
|
|
139
242
|
let response: AssistantMessage;
|
|
140
243
|
try {
|
|
141
244
|
response = await completeFn(
|
|
@@ -146,19 +249,34 @@ export async function classifyWithRetry(
|
|
|
146
249
|
headers: classifier.headers,
|
|
147
250
|
signal,
|
|
148
251
|
maxTokens,
|
|
149
|
-
temperature,
|
|
252
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
253
|
+
sessionId: options.sessionId,
|
|
254
|
+
cacheRetention: options.cacheRetention,
|
|
150
255
|
},
|
|
151
256
|
);
|
|
152
257
|
} catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
259
|
+
onAttempt?.({
|
|
260
|
+
stage,
|
|
261
|
+
attempt: attempt + 1,
|
|
262
|
+
error: message,
|
|
263
|
+
durationMs: Date.now() - started,
|
|
264
|
+
});
|
|
153
265
|
return {
|
|
154
266
|
decision: "block",
|
|
155
267
|
tier: "none",
|
|
156
|
-
reason: `Classifier failed; auto mode fails closed: ${
|
|
157
|
-
error instanceof Error ? error.message : String(error)
|
|
158
|
-
}`,
|
|
268
|
+
reason: `Classifier failed; auto mode fails closed: ${message}`,
|
|
159
269
|
};
|
|
160
270
|
}
|
|
161
|
-
const
|
|
271
|
+
const durationMs = Date.now() - started;
|
|
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;
|
|
162
280
|
if (decision) return decision;
|
|
163
281
|
lastReason =
|
|
164
282
|
response.stopReason === "length"
|
|
@@ -168,12 +286,118 @@ export async function classifyWithRetry(
|
|
|
168
286
|
return { decision: "block", tier: "none", reason: lastReason };
|
|
169
287
|
}
|
|
170
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);
|
|
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 exactly 0 or 1; 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
|
+
|
|
171
395
|
export const defaultClassifyAction: ClassifyAction = async (
|
|
172
396
|
ctx,
|
|
173
397
|
config,
|
|
174
398
|
action,
|
|
175
399
|
loadedContext,
|
|
176
|
-
): Promise<
|
|
400
|
+
): Promise<ClassifyResult> => {
|
|
177
401
|
const classifier = await resolveClassifier(ctx, config);
|
|
178
402
|
if (!classifier) {
|
|
179
403
|
return {
|
|
@@ -183,25 +407,47 @@ export const defaultClassifyAction: ClassifyAction = async (
|
|
|
183
407
|
};
|
|
184
408
|
}
|
|
185
409
|
|
|
186
|
-
const
|
|
410
|
+
const systemPrompt = buildClassifierPrompt(config);
|
|
411
|
+
const transcript = buildClassifierTranscript(ctx, {
|
|
412
|
+
maxUserTokens: config.maxUserTranscriptTokens,
|
|
413
|
+
maxToolTokens: config.maxToolTranscriptTokens,
|
|
414
|
+
});
|
|
415
|
+
const contextText = `<loaded-project-instructions>\n${
|
|
416
|
+
loadedContext || "(none)"
|
|
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 = {
|
|
187
421
|
role: "user",
|
|
188
|
-
content: [
|
|
189
|
-
{
|
|
190
|
-
type: "text",
|
|
191
|
-
text: `<loaded-project-instructions>\n${
|
|
192
|
-
loadedContext || "(none)"
|
|
193
|
-
}\n</loaded-project-instructions>\n\n<transcript>\n${
|
|
194
|
-
buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
|
|
195
|
-
}\n</transcript>\n\nLatest action to classify:\n${action}`,
|
|
196
|
-
},
|
|
197
|
-
],
|
|
422
|
+
content: [{ type: "text", text: contextText }],
|
|
198
423
|
timestamp: Date.now(),
|
|
199
424
|
};
|
|
200
425
|
|
|
201
|
-
|
|
426
|
+
const attempts: ClassifierIoAttempt[] = [];
|
|
427
|
+
const started = Date.now();
|
|
428
|
+
const decision = await classifyInStages(
|
|
202
429
|
complete,
|
|
203
430
|
classifier,
|
|
204
|
-
{ systemPrompt
|
|
431
|
+
{ systemPrompt, contextMessage },
|
|
205
432
|
ctx.signal,
|
|
433
|
+
{
|
|
434
|
+
sessionId: classifierCacheSessionId(ctx),
|
|
435
|
+
onAttempt: (attempt) => attempts.push(attempt),
|
|
436
|
+
},
|
|
206
437
|
);
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
...decision,
|
|
441
|
+
io: {
|
|
442
|
+
model: formatModelSpec(classifier.model),
|
|
443
|
+
prompt: {
|
|
444
|
+
system: systemPrompt,
|
|
445
|
+
context: contextText,
|
|
446
|
+
fastInstruction: CLASSIFIER_FAST_INSTRUCTION,
|
|
447
|
+
detailedInstruction: CLASSIFIER_DETAILED_INSTRUCTION,
|
|
448
|
+
},
|
|
449
|
+
attempts,
|
|
450
|
+
durationMs: Date.now() - started,
|
|
451
|
+
},
|
|
452
|
+
};
|
|
207
453
|
};
|
|
@@ -4,7 +4,9 @@ import {
|
|
|
4
4
|
DEFAULT_ALLOW,
|
|
5
5
|
DEFAULT_ENVIRONMENT,
|
|
6
6
|
DEFAULT_HARD_DENY,
|
|
7
|
-
|
|
7
|
+
DEFAULT_LOG_CONFIG,
|
|
8
|
+
DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
|
|
9
|
+
DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
8
10
|
DEFAULT_PROTECTED_PATHS,
|
|
9
11
|
DEFAULT_SOFT_DENY,
|
|
10
12
|
PI_GLOBAL_SETTINGS,
|
|
@@ -17,6 +19,7 @@ import type {
|
|
|
17
19
|
ConfigLoadResult,
|
|
18
20
|
EffectiveConfig,
|
|
19
21
|
LoadedSettingsFile,
|
|
22
|
+
LogConfig,
|
|
20
23
|
SettingsFile,
|
|
21
24
|
SettingsSources,
|
|
22
25
|
ToolPattern,
|
|
@@ -94,7 +97,8 @@ export function validateSettingsFile(
|
|
|
94
97
|
const knownAutoMode = new Set([
|
|
95
98
|
"enabled",
|
|
96
99
|
"classifierModel",
|
|
97
|
-
"
|
|
100
|
+
"maxUserTranscriptTokens",
|
|
101
|
+
"maxToolTranscriptTokens",
|
|
98
102
|
"environment",
|
|
99
103
|
"allow",
|
|
100
104
|
"protectedPaths",
|
|
@@ -102,6 +106,7 @@ export function validateSettingsFile(
|
|
|
102
106
|
"softDeny",
|
|
103
107
|
"hard_deny",
|
|
104
108
|
"hardDeny",
|
|
109
|
+
"log",
|
|
105
110
|
]);
|
|
106
111
|
for (const key of Object.keys(autoMode)) {
|
|
107
112
|
if (!knownAutoMode.has(key)) {
|
|
@@ -121,14 +126,20 @@ export function validateSettingsFile(
|
|
|
121
126
|
`${source}: autoMode.classifierModel must be a provider/model string`,
|
|
122
127
|
);
|
|
123
128
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
for (
|
|
130
|
+
const key of [
|
|
131
|
+
"maxUserTranscriptTokens",
|
|
132
|
+
"maxToolTranscriptTokens",
|
|
133
|
+
] as const
|
|
128
134
|
) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
143
|
}
|
|
133
144
|
validateStringArraySetting(
|
|
134
145
|
autoMode.environment,
|
|
@@ -160,6 +171,9 @@ export function validateSettingsFile(
|
|
|
160
171
|
"autoMode.hard_deny",
|
|
161
172
|
diagnostics,
|
|
162
173
|
);
|
|
174
|
+
if (hasOwn(autoMode, "log")) {
|
|
175
|
+
validateLogSetting(autoMode.log, source, diagnostics);
|
|
176
|
+
}
|
|
163
177
|
}
|
|
164
178
|
}
|
|
165
179
|
|
|
@@ -228,6 +242,41 @@ function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
|
|
|
228
242
|
return [...new Set([...base, ...accumulator.entries])];
|
|
229
243
|
}
|
|
230
244
|
|
|
245
|
+
function validateLogSetting(
|
|
246
|
+
value: unknown,
|
|
247
|
+
source: string,
|
|
248
|
+
diagnostics: string[],
|
|
249
|
+
): void {
|
|
250
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
251
|
+
diagnostics.push(`${source}: autoMode.log must be an object`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const log = value as Record<string, unknown>;
|
|
255
|
+
if (hasOwn(log, "enabled") && typeof log.enabled !== "boolean") {
|
|
256
|
+
diagnostics.push(`${source}: autoMode.log.enabled must be a boolean`);
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
hasOwn(log, "classifierIo") && typeof log.classifierIo !== "boolean"
|
|
260
|
+
) {
|
|
261
|
+
diagnostics.push(`${source}: autoMode.log.classifierIo must be a boolean`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function mergeLog(
|
|
266
|
+
base: LogConfig,
|
|
267
|
+
patch: Partial<LogConfig> | undefined,
|
|
268
|
+
): LogConfig {
|
|
269
|
+
if (!patch) return base;
|
|
270
|
+
return {
|
|
271
|
+
enabled: patch.enabled ?? base.enabled,
|
|
272
|
+
classifierIo: patch.classifierIo ?? base.classifierIo,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validTranscriptBudget(value: unknown): value is number {
|
|
277
|
+
return Number.isInteger(value) && Number(value) >= 32;
|
|
278
|
+
}
|
|
279
|
+
|
|
231
280
|
function applyAutoModeScalars(
|
|
232
281
|
base: EffectiveConfig,
|
|
233
282
|
settings: AutoModeSettings | undefined,
|
|
@@ -237,7 +286,17 @@ function applyAutoModeScalars(
|
|
|
237
286
|
...base,
|
|
238
287
|
enabled: settings.enabled ?? base.enabled,
|
|
239
288
|
classifierModel: settings.classifierModel ?? base.classifierModel,
|
|
240
|
-
|
|
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,
|
|
299
|
+
log: mergeLog(base.log, settings.log),
|
|
241
300
|
};
|
|
242
301
|
}
|
|
243
302
|
|
|
@@ -268,7 +327,8 @@ export function buildEffectiveConfigFromSources(
|
|
|
268
327
|
): EffectiveConfig {
|
|
269
328
|
let config: EffectiveConfig = {
|
|
270
329
|
enabled: true,
|
|
271
|
-
|
|
330
|
+
maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
331
|
+
maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
|
|
272
332
|
environment: [...DEFAULT_ENVIRONMENT],
|
|
273
333
|
allow: [...DEFAULT_ALLOW],
|
|
274
334
|
protectedPaths: [...DEFAULT_PROTECTED_PATHS],
|
|
@@ -276,6 +336,7 @@ export function buildEffectiveConfigFromSources(
|
|
|
276
336
|
hardDeny: [...DEFAULT_HARD_DENY],
|
|
277
337
|
permissionDeny: [],
|
|
278
338
|
permissionAsk: [],
|
|
339
|
+
log: { ...DEFAULT_LOG_CONFIG },
|
|
279
340
|
};
|
|
280
341
|
|
|
281
342
|
const globalSettings = sources.globalSettings ?? [];
|
|
@@ -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")];
|
|
@@ -166,3 +171,9 @@ export const PROFILE_FILES = new Set([
|
|
|
166
171
|
]);
|
|
167
172
|
|
|
168
173
|
export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
174
|
+
|
|
175
|
+
/** Default observability log config: off, classifier I/O off. */
|
|
176
|
+
export const DEFAULT_LOG_CONFIG = {
|
|
177
|
+
enabled: false,
|
|
178
|
+
classifierIo: false,
|
|
179
|
+
};
|