@czottmann/pi-automode 1.11.0 → 1.12.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/CHANGELOG.md +24 -0
- package/README.md +78 -112
- package/docs/GLOSSARY.md +18 -14
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +44 -0
- package/docs/adr/INDEX.md +5 -0
- package/docs/automode-classifier-flow.md +198 -104
- package/docs/configuration.md +155 -0
- package/docs/defaults.md +55 -14
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +61 -26
- package/examples/automode.local.json +5 -0
- package/extensions/auto-mode/classifier.ts +172 -18
- package/extensions/auto-mode/config.ts +127 -30
- package/extensions/auto-mode/constants.ts +3 -0
- package/extensions/auto-mode/extension.ts +273 -31
- package/extensions/auto-mode/log.ts +60 -5
- package/extensions/auto-mode/paths.ts +92 -8
- package/extensions/auto-mode/permissions.ts +203 -13
- package/extensions/auto-mode/state.ts +1 -0
- package/extensions/auto-mode/types.ts +11 -0
- package/extensions/auto-mode/utils.ts +9 -1
- package/package.json +9 -2
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
import type {
|
|
2
5
|
ExtensionAPI,
|
|
3
6
|
ExtensionCommandContext,
|
|
4
7
|
ExtensionContext,
|
|
5
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import { Type } from "typebox";
|
|
6
11
|
import {
|
|
7
12
|
classifierReasoningForConfig,
|
|
8
13
|
defaultClassifyAction,
|
|
14
|
+
serializeClassifierAction,
|
|
9
15
|
} from "./classifier.ts";
|
|
10
16
|
import {
|
|
11
17
|
AUTO_MODE_GUIDANCE,
|
|
@@ -18,7 +24,6 @@ import {
|
|
|
18
24
|
READ_ONLY_TOOLS,
|
|
19
25
|
} from "./constants.ts";
|
|
20
26
|
import {
|
|
21
|
-
loadEffectiveConfig,
|
|
22
27
|
loadEffectiveConfigWithDiagnostics,
|
|
23
28
|
writeGlobalClassifierModel,
|
|
24
29
|
} from "./config.ts";
|
|
@@ -31,14 +36,17 @@ import {
|
|
|
31
36
|
} from "./log.ts";
|
|
32
37
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
33
38
|
import { promptForClassifierModel } from "./model-selector.ts";
|
|
34
|
-
import { matchesDeniedPath, matchesToolPattern } from "./permissions.ts";
|
|
35
39
|
import {
|
|
36
|
-
|
|
40
|
+
matchesDeniedPath,
|
|
41
|
+
matchesToolPattern,
|
|
42
|
+
recursiveSearchMayReachDeniedPath,
|
|
43
|
+
} from "./permissions.ts";
|
|
44
|
+
import {
|
|
37
45
|
extractInputPath,
|
|
38
46
|
isInside,
|
|
39
47
|
isProtectedPath,
|
|
40
|
-
resolveInputPath,
|
|
41
48
|
resolvePathForPolicy,
|
|
49
|
+
resolveToolInputPath,
|
|
42
50
|
} from "./paths.ts";
|
|
43
51
|
import {
|
|
44
52
|
actionSummary,
|
|
@@ -61,13 +69,45 @@ import type {
|
|
|
61
69
|
} from "./types.ts";
|
|
62
70
|
import { safeJson } from "./utils.ts";
|
|
63
71
|
|
|
72
|
+
const INSPECT_TOOL = "automode_inspect";
|
|
73
|
+
const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
|
|
74
|
+
type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
|
|
75
|
+
|
|
76
|
+
function canonicalPath(path: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(path);
|
|
79
|
+
} catch {
|
|
80
|
+
return resolve(path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const EXTENSION_PATH = canonicalPath(fileURLToPath(import.meta.url));
|
|
85
|
+
const EXTENSION_ENTRY_PATH = canonicalPath(
|
|
86
|
+
resolve(dirname(EXTENSION_PATH), "../auto-mode.ts"),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
export function modelVisibleConfigDiagnostics(
|
|
90
|
+
diagnostics: string[],
|
|
91
|
+
): string[] {
|
|
92
|
+
return diagnostics.map((diagnostic) =>
|
|
93
|
+
diagnostic.replace(
|
|
94
|
+
/invalid JSON \([\s\S]*\)$/,
|
|
95
|
+
"invalid JSON (parser details omitted from model-visible output)",
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
64
100
|
export type PiAutomodeOptions = {
|
|
65
101
|
/** Override config loading in tests. Runtime code uses Pi-owned disk settings. */
|
|
66
|
-
loadConfig?: (cwd: string) => EffectiveConfig;
|
|
102
|
+
loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
|
|
67
103
|
/** Override classifier calls in tests so unit tests never need a real LLM/API key. */
|
|
68
104
|
classifyAction?: ClassifyAction;
|
|
69
105
|
/** Override classifier-model persistence in tests. Runtime code writes ~/.pi/agent/automode.json. */
|
|
70
106
|
saveClassifierModel?: (classifierModel: string) => void;
|
|
107
|
+
/** Override the application-owned observability log root in tests. */
|
|
108
|
+
logRoot?: string;
|
|
109
|
+
/** Override the observability log clock in tests. */
|
|
110
|
+
now?: () => Date;
|
|
71
111
|
};
|
|
72
112
|
|
|
73
113
|
type LogCtx = {
|
|
@@ -118,17 +158,18 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
|
118
158
|
/** Create a Pi extension instance. Default export uses production dependencies. */
|
|
119
159
|
export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
120
160
|
const loadConfigWithDiagnostics = options.loadConfig
|
|
121
|
-
? (cwd: string): ConfigLoadResult => ({
|
|
122
|
-
config: options.loadConfig
|
|
161
|
+
? (cwd: string, projectTrusted: boolean): ConfigLoadResult => ({
|
|
162
|
+
config: options.loadConfig!(cwd, projectTrusted),
|
|
123
163
|
diagnostics: [],
|
|
124
164
|
})
|
|
125
165
|
: loadEffectiveConfigWithDiagnostics;
|
|
126
166
|
const classify = options.classifyAction ?? defaultClassifyAction;
|
|
127
167
|
const saveClassifierModel = options.saveClassifierModel ??
|
|
128
168
|
writeGlobalClassifierModel;
|
|
169
|
+
const now = options.now ?? (() => new Date());
|
|
129
170
|
|
|
130
171
|
return function piAutomode(pi: ExtensionAPI) {
|
|
131
|
-
let loadResult = loadConfigWithDiagnostics(process.cwd());
|
|
172
|
+
let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
|
|
132
173
|
let config: EffectiveConfig = loadResult.config;
|
|
133
174
|
let configDiagnostics: string[] = loadResult.diagnostics;
|
|
134
175
|
let state: AutoModeState = {
|
|
@@ -147,6 +188,13 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
147
188
|
};
|
|
148
189
|
}
|
|
149
190
|
|
|
191
|
+
function ownsInspectionTool(): boolean {
|
|
192
|
+
const tool = pi.getAllTools().find(({ name }) => name === INSPECT_TOOL);
|
|
193
|
+
if (!tool) return false;
|
|
194
|
+
const sourcePath = canonicalPath(tool.sourceInfo.path);
|
|
195
|
+
return sourcePath === EXTENSION_PATH || sourcePath === EXTENSION_ENTRY_PATH;
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
function persist(): void {
|
|
151
199
|
pi.appendEntry("pi-automode-state", state);
|
|
152
200
|
}
|
|
@@ -163,6 +211,77 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
163
211
|
);
|
|
164
212
|
}
|
|
165
213
|
|
|
214
|
+
function inspectAutomode(
|
|
215
|
+
action: InspectionAction,
|
|
216
|
+
ctx: ExtensionContext,
|
|
217
|
+
): unknown {
|
|
218
|
+
const cfg = effectiveConfig();
|
|
219
|
+
if (action === "status") {
|
|
220
|
+
const status = [
|
|
221
|
+
`enabled: ${cfg.enabled ? "yes" : "no"}`,
|
|
222
|
+
`classifier: ${cfg.classifierModel ?? "current session model"}`,
|
|
223
|
+
`classifier reasoning: ${cfg.classifierReasoningLevel ?? "server default"}`,
|
|
224
|
+
`checked actions: ${state.checkedActions}`,
|
|
225
|
+
`blocked actions: ${state.blockedActions}`,
|
|
226
|
+
`classifier allowed: ${state.classifierAllowed}`,
|
|
227
|
+
`classifier denied: ${state.classifierDenied}`,
|
|
228
|
+
`permissions.deny rules: ${cfg.permissionDeny.length}`,
|
|
229
|
+
`permissions.ask rules: ${cfg.permissionAsk.length}`,
|
|
230
|
+
`environment entries: ${cfg.environment.length}`,
|
|
231
|
+
`allow entries: ${cfg.allow.length}`,
|
|
232
|
+
`soft_deny entries: ${cfg.softDeny.length}`,
|
|
233
|
+
`hard_deny entries: ${cfg.hardDeny.length}`,
|
|
234
|
+
`last decision: ${state.lastDecision ?? "none"}`,
|
|
235
|
+
"last reason: omitted from model-visible inspection",
|
|
236
|
+
].join("\n");
|
|
237
|
+
return {
|
|
238
|
+
status,
|
|
239
|
+
state: {
|
|
240
|
+
enabledOverride: state.enabledOverride,
|
|
241
|
+
lastDecision: state.lastDecision,
|
|
242
|
+
checkedActions: state.checkedActions,
|
|
243
|
+
blockedActions: state.blockedActions,
|
|
244
|
+
classifierAllowed: state.classifierAllowed,
|
|
245
|
+
classifierDenied: state.classifierDenied,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (action === "config") {
|
|
250
|
+
return {
|
|
251
|
+
config: cfg,
|
|
252
|
+
logFile: resolveLogPath(
|
|
253
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
254
|
+
ctx.sessionManager.getSessionDir?.() ?? "",
|
|
255
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
256
|
+
ctx.cwd,
|
|
257
|
+
options.logRoot,
|
|
258
|
+
now(),
|
|
259
|
+
),
|
|
260
|
+
diagnostics: modelVisibleConfigDiagnostics(configDiagnostics),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (action === "defaults") {
|
|
264
|
+
return {
|
|
265
|
+
environment: DEFAULT_ENVIRONMENT,
|
|
266
|
+
allow: DEFAULT_ALLOW,
|
|
267
|
+
protectedPaths: DEFAULT_PROTECTED_PATHS,
|
|
268
|
+
soft_deny: DEFAULT_SOFT_DENY,
|
|
269
|
+
hard_deny: DEFAULT_HARD_DENY,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const denials = state.recentDenials.slice().reverse().map((denial) => ({
|
|
273
|
+
timestamp: denial.timestamp,
|
|
274
|
+
kind: denial.kind,
|
|
275
|
+
toolName: denial.toolName,
|
|
276
|
+
}));
|
|
277
|
+
return {
|
|
278
|
+
summary: denials.length === 0
|
|
279
|
+
? "No recent auto-mode denials."
|
|
280
|
+
: `${denials.length} recent auto-mode denial(s). Reasons and action payloads are omitted.`,
|
|
281
|
+
denials,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
166
285
|
function block(
|
|
167
286
|
ctx: ExtensionContext,
|
|
168
287
|
denial: DenialRecord,
|
|
@@ -231,7 +350,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
231
350
|
}
|
|
232
351
|
|
|
233
352
|
pi.on("session_start", (_event, ctx) => {
|
|
234
|
-
loadResult = loadConfigWithDiagnostics(
|
|
353
|
+
loadResult = loadConfigWithDiagnostics(
|
|
354
|
+
ctx.cwd,
|
|
355
|
+
ctx.isProjectTrusted(),
|
|
356
|
+
);
|
|
235
357
|
config = loadResult.config;
|
|
236
358
|
configDiagnostics = loadResult.diagnostics;
|
|
237
359
|
state = restoreState(ctx);
|
|
@@ -251,22 +373,30 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
251
373
|
// Enforcement order:
|
|
252
374
|
// 1. permission deny/ask rules,
|
|
253
375
|
// 2. deterministic hard-deny checks that never consult the model,
|
|
254
|
-
// 3. read-only
|
|
255
|
-
// 4.
|
|
376
|
+
// 3. extension-owned read-only inspection tool,
|
|
377
|
+
// 4. deterministic path denials,
|
|
378
|
+
// 5. accepted ask rules force classifier review and skip all allow tiers,
|
|
379
|
+
// 6. inside-CWD, permissions.allow, and read-only allow tiers,
|
|
380
|
+
// 7. classifier for every remaining action, fail-closed on setup/parse errors.
|
|
256
381
|
const cfg = effectiveConfig();
|
|
257
382
|
if (!cfg.enabled) return undefined;
|
|
258
383
|
if (ctx.signal?.aborted) return { block: true, reason: "Cancelled" };
|
|
259
384
|
|
|
385
|
+
const isOwnedInspection = event.toolName === INSPECT_TOOL &&
|
|
386
|
+
ownsInspectionTool();
|
|
260
387
|
const input = event.input as Record<string, unknown>;
|
|
261
388
|
const summary = actionSummary(event.toolName, input);
|
|
262
|
-
state.checkedActions += 1;
|
|
389
|
+
if (!isOwnedInspection) state.checkedActions += 1;
|
|
263
390
|
const logCtx: LogCtx = {
|
|
264
391
|
logger: createLogger({
|
|
265
392
|
enabled: cfg.log.enabled,
|
|
266
393
|
classifierIo: cfg.log.classifierIo,
|
|
267
394
|
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
268
|
-
sessionDir: ctx.sessionManager.getSessionDir?.() ??
|
|
395
|
+
sessionDir: ctx.sessionManager.getSessionDir?.() ?? "",
|
|
396
|
+
sessionCwd: ctx.cwd,
|
|
269
397
|
sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
398
|
+
logRoot: options.logRoot,
|
|
399
|
+
now: now(),
|
|
270
400
|
}),
|
|
271
401
|
decisionId: newDecisionId(),
|
|
272
402
|
classifierModel: cfg.classifierModel,
|
|
@@ -275,6 +405,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
275
405
|
|
|
276
406
|
for (const pattern of cfg.permissionDeny) {
|
|
277
407
|
if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
|
|
408
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
278
409
|
return block(ctx, {
|
|
279
410
|
timestamp: Date.now(),
|
|
280
411
|
toolName: event.toolName,
|
|
@@ -285,11 +416,13 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
285
416
|
}
|
|
286
417
|
}
|
|
287
418
|
|
|
419
|
+
let askRequiresClassifier = false;
|
|
288
420
|
for (const pattern of cfg.permissionAsk) {
|
|
289
421
|
if (!matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
|
|
290
422
|
continue;
|
|
291
423
|
}
|
|
292
424
|
if (!ctx.hasUI) {
|
|
425
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
293
426
|
return block(ctx, {
|
|
294
427
|
timestamp: Date.now(),
|
|
295
428
|
toolName: event.toolName,
|
|
@@ -305,6 +438,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
305
438
|
{ signal: ctx.signal },
|
|
306
439
|
);
|
|
307
440
|
if (!allowed) {
|
|
441
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
308
442
|
return block(ctx, {
|
|
309
443
|
timestamp: Date.now(),
|
|
310
444
|
toolName: event.toolName,
|
|
@@ -313,6 +447,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
313
447
|
kind: "permissions.ask",
|
|
314
448
|
}, logCtx);
|
|
315
449
|
}
|
|
450
|
+
askRequiresClassifier = true;
|
|
316
451
|
}
|
|
317
452
|
|
|
318
453
|
const deterministicReason = deterministicHardDeny(
|
|
@@ -321,6 +456,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
321
456
|
ctx.cwd,
|
|
322
457
|
);
|
|
323
458
|
if (deterministicReason) {
|
|
459
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
324
460
|
return block(ctx, {
|
|
325
461
|
timestamp: Date.now(),
|
|
326
462
|
toolName: event.toolName,
|
|
@@ -330,6 +466,9 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
330
466
|
}, logCtx);
|
|
331
467
|
}
|
|
332
468
|
|
|
469
|
+
if (isOwnedInspection && !askRequiresClassifier) return undefined;
|
|
470
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
471
|
+
|
|
333
472
|
// Deterministic path gate for file tools.
|
|
334
473
|
//
|
|
335
474
|
// `deniedPaths` always applies: a matching path is hard-denied before any
|
|
@@ -343,15 +482,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
343
482
|
// The gate is skipped entirely when both features are off, so the
|
|
344
483
|
// default configuration costs no extra filesystem calls.
|
|
345
484
|
let readOnlyFastPath =
|
|
346
|
-
!
|
|
485
|
+
!askRequiresClassifier &&
|
|
486
|
+
!cfg.classifyReadOnlyTools &&
|
|
487
|
+
READ_ONLY_TOOLS.has(event.toolName);
|
|
347
488
|
if (
|
|
348
489
|
(cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
|
|
349
490
|
PATH_BEARING_TOOLS.has(event.toolName)
|
|
350
491
|
) {
|
|
351
492
|
const inputPath = extractInputPath(event.toolName, input);
|
|
352
493
|
if (inputPath !== undefined) {
|
|
353
|
-
const
|
|
354
|
-
|
|
494
|
+
const resolved =
|
|
495
|
+
resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
|
|
496
|
+
inputPath;
|
|
355
497
|
const policyPath = resolvePathForPolicy(resolved) ?? resolved;
|
|
356
498
|
const denied =
|
|
357
499
|
cfg.deniedPaths.length > 0 &&
|
|
@@ -366,19 +508,42 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
366
508
|
kind: "deterministic-path-deny",
|
|
367
509
|
}, logCtx);
|
|
368
510
|
}
|
|
511
|
+
let recursiveSearch =
|
|
512
|
+
event.toolName === "grep" || event.toolName === "find";
|
|
513
|
+
if (recursiveSearch) {
|
|
514
|
+
try {
|
|
515
|
+
recursiveSearch = statSync(policyPath).isDirectory();
|
|
516
|
+
} catch {
|
|
517
|
+
// A missing search root will fail in the tool. Treat it as a
|
|
518
|
+
// directory here so a denied scope cannot fail open in a race.
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const deniedSearchScope =
|
|
522
|
+
recursiveSearch &&
|
|
523
|
+
cfg.deniedPaths.length > 0 &&
|
|
524
|
+
(recursiveSearchMayReachDeniedPath(resolved, cfg.deniedPaths) ||
|
|
525
|
+
recursiveSearchMayReachDeniedPath(
|
|
526
|
+
policyPath,
|
|
527
|
+
cfg.deniedPaths,
|
|
528
|
+
));
|
|
529
|
+
if (deniedSearchScope) {
|
|
530
|
+
return block(ctx, {
|
|
531
|
+
timestamp: Date.now(),
|
|
532
|
+
toolName: event.toolName,
|
|
533
|
+
reason: `Search scope can contain a path denied by policy: ${policyPath}`,
|
|
534
|
+
action: summary,
|
|
535
|
+
kind: "deterministic-path-deny",
|
|
536
|
+
}, logCtx);
|
|
537
|
+
}
|
|
369
538
|
if (cfg.allowInsideWorkingDirectory) {
|
|
370
539
|
const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
|
|
371
540
|
if (isInside(policyPath, policyCwd)) {
|
|
372
|
-
// Protected in-tree writes
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
// .husky/*, or .gitignore.
|
|
376
|
-
if (
|
|
541
|
+
// Protected in-tree writes and accepted ask rules must still
|
|
542
|
+
// reach the classifier. They cannot use the inside-CWD tier.
|
|
543
|
+
const protectedWrite =
|
|
377
544
|
(event.toolName === "write" || event.toolName === "edit") &&
|
|
378
|
-
isProtectedPath(policyPath, policyCwd, cfg.protectedPaths)
|
|
379
|
-
) {
|
|
380
|
-
readOnlyFastPath = false;
|
|
381
|
-
} else {
|
|
545
|
+
isProtectedPath(policyPath, policyCwd, cfg.protectedPaths);
|
|
546
|
+
if (!askRequiresClassifier && !protectedWrite) {
|
|
382
547
|
return allow(
|
|
383
548
|
ctx,
|
|
384
549
|
"inside-working-directory",
|
|
@@ -389,13 +554,54 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
389
554
|
);
|
|
390
555
|
}
|
|
391
556
|
}
|
|
392
|
-
// Outside the working directory
|
|
393
|
-
//
|
|
557
|
+
// Outside the working directory, protected writes, and accepted
|
|
558
|
+
// ask rules must not use the read-only fast path.
|
|
394
559
|
readOnlyFastPath = false;
|
|
395
560
|
}
|
|
396
561
|
}
|
|
397
562
|
}
|
|
398
563
|
|
|
564
|
+
// Deterministic allow tier. It runs after every deterministic denial.
|
|
565
|
+
// Accepted ask rules skip this tier and always reach the classifier.
|
|
566
|
+
if (!askRequiresClassifier) {
|
|
567
|
+
for (const pattern of cfg.permissionAllow) {
|
|
568
|
+
if (
|
|
569
|
+
!matchesToolPattern(
|
|
570
|
+
pattern,
|
|
571
|
+
event.toolName,
|
|
572
|
+
input,
|
|
573
|
+
ctx.cwd,
|
|
574
|
+
"no-match",
|
|
575
|
+
)
|
|
576
|
+
) {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
// A protected-path write/edit is never covered by permissions.allow;
|
|
580
|
+
// it stays on the classifier path (same rule as the inside-CWD tier).
|
|
581
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
582
|
+
const inputPath = extractInputPath(event.toolName, input);
|
|
583
|
+
const resolved = inputPath === undefined
|
|
584
|
+
? undefined
|
|
585
|
+
: resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
|
|
586
|
+
inputPath;
|
|
587
|
+
if (
|
|
588
|
+
resolved !== undefined &&
|
|
589
|
+
isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
|
|
590
|
+
) {
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return allow(
|
|
595
|
+
ctx,
|
|
596
|
+
"permissions.allow",
|
|
597
|
+
`Allowed by permissions.allow: ${pattern.raw}`,
|
|
598
|
+
event.toolName,
|
|
599
|
+
summary,
|
|
600
|
+
logCtx,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
399
605
|
if (readOnlyFastPath) {
|
|
400
606
|
return allow(
|
|
401
607
|
ctx,
|
|
@@ -407,7 +613,12 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
407
613
|
);
|
|
408
614
|
}
|
|
409
615
|
|
|
410
|
-
const decision = await classify(
|
|
616
|
+
const decision = await classify(
|
|
617
|
+
ctx,
|
|
618
|
+
cfg,
|
|
619
|
+
serializeClassifierAction(event.toolName, input),
|
|
620
|
+
loadedContext,
|
|
621
|
+
);
|
|
411
622
|
logClassifierIo(decision, logCtx);
|
|
412
623
|
if (decision.decision === "allow") {
|
|
413
624
|
state.classifierAllowed += 1;
|
|
@@ -431,6 +642,28 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
431
642
|
}, logCtx);
|
|
432
643
|
});
|
|
433
644
|
|
|
645
|
+
pi.registerTool({
|
|
646
|
+
name: INSPECT_TOOL,
|
|
647
|
+
label: "Inspect Auto Mode",
|
|
648
|
+
description:
|
|
649
|
+
"Inspect the active pi-automode status, effective config, built-in defaults, or recent denial metadata. This tool is read-only and cannot enable, disable, reload, reset, or reconfigure auto mode. Its output is sent to the current model; denial reasons and action payloads are omitted.",
|
|
650
|
+
promptSnippet:
|
|
651
|
+
"Inspect active pi-automode state and diagnostic information without changing it",
|
|
652
|
+
parameters: Type.Object({
|
|
653
|
+
action: StringEnum(INSPECTION_ACTIONS, {
|
|
654
|
+
description: "The read-only auto-mode view to return",
|
|
655
|
+
}),
|
|
656
|
+
}),
|
|
657
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
658
|
+
if (signal?.aborted) throw new Error("Auto-mode inspection cancelled");
|
|
659
|
+
const result = inspectAutomode(params.action, ctx);
|
|
660
|
+
return {
|
|
661
|
+
content: [{ type: "text", text: safeJson(result, 16000) }],
|
|
662
|
+
details: result,
|
|
663
|
+
};
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
|
|
434
667
|
async function handleAutomodeCommand(
|
|
435
668
|
args: string,
|
|
436
669
|
ctx: ExtensionCommandContext,
|
|
@@ -460,7 +693,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
460
693
|
return;
|
|
461
694
|
}
|
|
462
695
|
if (command === "reload") {
|
|
463
|
-
loadResult = loadConfigWithDiagnostics(
|
|
696
|
+
loadResult = loadConfigWithDiagnostics(
|
|
697
|
+
ctx.cwd,
|
|
698
|
+
ctx.isProjectTrusted(),
|
|
699
|
+
);
|
|
464
700
|
config = loadResult.config;
|
|
465
701
|
configDiagnostics = loadResult.diagnostics;
|
|
466
702
|
persist();
|
|
@@ -504,8 +740,11 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
504
740
|
if (command === "config") {
|
|
505
741
|
const logFile = resolveLogPath(
|
|
506
742
|
ctx.sessionManager.getSessionFile?.(),
|
|
507
|
-
ctx.sessionManager.getSessionDir?.() ??
|
|
743
|
+
ctx.sessionManager.getSessionDir?.() ?? "",
|
|
508
744
|
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
745
|
+
ctx.cwd,
|
|
746
|
+
options.logRoot,
|
|
747
|
+
now(),
|
|
509
748
|
);
|
|
510
749
|
ctx.ui.notify(
|
|
511
750
|
safeJson(
|
|
@@ -561,7 +800,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
561
800
|
);
|
|
562
801
|
return;
|
|
563
802
|
}
|
|
564
|
-
loadResult = loadConfigWithDiagnostics(
|
|
803
|
+
loadResult = loadConfigWithDiagnostics(
|
|
804
|
+
ctx.cwd,
|
|
805
|
+
ctx.isProjectTrusted(),
|
|
806
|
+
);
|
|
565
807
|
config = loadResult.config;
|
|
566
808
|
configDiagnostics = loadResult.diagnostics;
|
|
567
809
|
persist();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
5
|
import type {
|
|
5
6
|
ClassifierIo,
|
|
6
7
|
ClassifierIoAttempt,
|
|
@@ -65,9 +66,32 @@ export type LoggerOptions = {
|
|
|
65
66
|
classifierIo: boolean;
|
|
66
67
|
sessionFile?: string;
|
|
67
68
|
sessionDir: string;
|
|
69
|
+
/** Effective cwd for an in-memory session. */
|
|
70
|
+
sessionCwd?: string;
|
|
68
71
|
sessionId: string;
|
|
72
|
+
/** Test/embedder override. Runtime uses ~/.pi/agent/extensions/pi-automode/logs. */
|
|
73
|
+
logRoot?: string;
|
|
74
|
+
/** Test clock used for the UTC date partition. */
|
|
75
|
+
now?: Date;
|
|
69
76
|
};
|
|
70
77
|
|
|
78
|
+
export const DEFAULT_AUTOMODE_LOG_ROOT = join(
|
|
79
|
+
homedir(),
|
|
80
|
+
".pi/agent/extensions/pi-automode/logs",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const VALID_SESSION_ID =
|
|
84
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
85
|
+
|
|
86
|
+
function safeLogSessionId(sessionId: string): string {
|
|
87
|
+
if (VALID_SESSION_ID.test(sessionId)) return sessionId;
|
|
88
|
+
const digest = createHash("sha256")
|
|
89
|
+
.update(sessionId)
|
|
90
|
+
.digest("hex")
|
|
91
|
+
.slice(0, 16);
|
|
92
|
+
return `invalid-${digest}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
/** Short id linking a classifier entry to its decision entry in the same file. */
|
|
72
96
|
export function newDecisionId(): string {
|
|
73
97
|
return randomBytes(4).toString("hex");
|
|
@@ -76,19 +100,43 @@ export function newDecisionId(): string {
|
|
|
76
100
|
/**
|
|
77
101
|
* Derive the log file path from the current session: the session file's
|
|
78
102
|
* directory with `-pi-automode` inserted before the extension. Falls back to
|
|
79
|
-
*
|
|
103
|
+
* an absolute session directory when one is available. In-memory sessions use
|
|
104
|
+
* an application-owned, project- and date-partitioned directory instead of a
|
|
105
|
+
* relative path resolved against the launching process cwd.
|
|
80
106
|
*/
|
|
81
107
|
export function resolveLogPath(
|
|
82
108
|
sessionFile: string | undefined,
|
|
83
109
|
sessionDir: string,
|
|
84
110
|
sessionId: string,
|
|
111
|
+
sessionCwd = process.cwd(),
|
|
112
|
+
logRoot = DEFAULT_AUTOMODE_LOG_ROOT,
|
|
113
|
+
now = new Date(),
|
|
85
114
|
): string {
|
|
86
115
|
if (sessionFile) {
|
|
87
116
|
const ext = extname(sessionFile);
|
|
88
117
|
const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
|
|
89
118
|
return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
|
|
90
119
|
}
|
|
91
|
-
|
|
120
|
+
|
|
121
|
+
const logFile = `${safeLogSessionId(sessionId)}-pi-automode.jsonl`;
|
|
122
|
+
if (isAbsolute(sessionDir)) {
|
|
123
|
+
return join(sessionDir, logFile);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resolvedCwd = resolve(sessionCwd);
|
|
127
|
+
const projectDir = `--${
|
|
128
|
+
resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")
|
|
129
|
+
}--`;
|
|
130
|
+
const dateDir = now.toISOString().slice(0, 10);
|
|
131
|
+
const resolvedLogRoot = isAbsolute(logRoot)
|
|
132
|
+
? logRoot
|
|
133
|
+
: DEFAULT_AUTOMODE_LOG_ROOT;
|
|
134
|
+
return join(
|
|
135
|
+
resolvedLogRoot,
|
|
136
|
+
projectDir,
|
|
137
|
+
dateDir,
|
|
138
|
+
logFile,
|
|
139
|
+
);
|
|
92
140
|
}
|
|
93
141
|
|
|
94
142
|
/** Append one JSON object as a line. Failures are swallowed: logging must
|
|
@@ -105,7 +153,14 @@ function appendJsonl(path: string, entry: unknown): void {
|
|
|
105
153
|
/** Build a logger bound to one session's log path. No-ops when disabled. */
|
|
106
154
|
export function createLogger(opts: LoggerOptions): Logger {
|
|
107
155
|
const { enabled, classifierIo } = opts;
|
|
108
|
-
const path = resolveLogPath(
|
|
156
|
+
const path = resolveLogPath(
|
|
157
|
+
opts.sessionFile,
|
|
158
|
+
opts.sessionDir,
|
|
159
|
+
opts.sessionId,
|
|
160
|
+
opts.sessionCwd,
|
|
161
|
+
opts.logRoot,
|
|
162
|
+
opts.now,
|
|
163
|
+
);
|
|
109
164
|
return {
|
|
110
165
|
enabled,
|
|
111
166
|
classifierIo,
|