@czottmann/pi-automode 1.11.0 → 1.13.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 +39 -0
- package/README.md +82 -114
- package/docs/GLOSSARY.md +18 -14
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +46 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +6 -0
- package/docs/automode-classifier-flow.md +201 -107
- package/docs/configuration.md +171 -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/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +172 -18
- package/extensions/auto-mode/config.ts +307 -32
- package/extensions/auto-mode/constants.ts +7 -1
- package/extensions/auto-mode/extension.ts +393 -44
- package/extensions/auto-mode/hard-deny.ts +103 -148
- package/extensions/auto-mode/log.ts +60 -5
- package/extensions/auto-mode/paths.ts +124 -14
- package/extensions/auto-mode/permissions.ts +542 -30
- 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/extensions/auto-mode.ts +1 -0
- package/package.json +12 -2
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -1,12 +1,19 @@
|
|
|
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";
|
|
16
|
+
import { analyzeBash, type BashAnalysis } from "./bash.ts";
|
|
10
17
|
import {
|
|
11
18
|
AUTO_MODE_GUIDANCE,
|
|
12
19
|
DEFAULT_ALLOW,
|
|
@@ -15,11 +22,13 @@ import {
|
|
|
15
22
|
DEFAULT_PROTECTED_PATHS,
|
|
16
23
|
DEFAULT_SOFT_DENY,
|
|
17
24
|
PATH_BEARING_TOOLS,
|
|
25
|
+
PI_GLOBAL_SETTINGS,
|
|
18
26
|
READ_ONLY_TOOLS,
|
|
19
27
|
} from "./constants.ts";
|
|
20
28
|
import {
|
|
21
|
-
|
|
29
|
+
type GlobalConfigPreparation,
|
|
22
30
|
loadEffectiveConfigWithDiagnostics,
|
|
31
|
+
prepareGlobalConfig,
|
|
23
32
|
writeGlobalClassifierModel,
|
|
24
33
|
} from "./config.ts";
|
|
25
34
|
import { deterministicHardDeny } from "./hard-deny.ts";
|
|
@@ -31,14 +40,19 @@ import {
|
|
|
31
40
|
} from "./log.ts";
|
|
32
41
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
33
42
|
import { promptForClassifierModel } from "./model-selector.ts";
|
|
34
|
-
import { matchesDeniedPath, matchesToolPattern } from "./permissions.ts";
|
|
35
43
|
import {
|
|
36
|
-
|
|
44
|
+
matchesAllowedToolPatterns,
|
|
45
|
+
matchesDeniedPath,
|
|
46
|
+
matchesToolPattern,
|
|
47
|
+
matchingBashCommandText,
|
|
48
|
+
recursiveSearchMayReachDeniedPath,
|
|
49
|
+
} from "./permissions.ts";
|
|
50
|
+
import {
|
|
37
51
|
extractInputPath,
|
|
38
52
|
isInside,
|
|
39
53
|
isProtectedPath,
|
|
40
|
-
resolveInputPath,
|
|
41
54
|
resolvePathForPolicy,
|
|
55
|
+
resolveToolInputPath,
|
|
42
56
|
} from "./paths.ts";
|
|
43
57
|
import {
|
|
44
58
|
actionSummary,
|
|
@@ -59,15 +73,55 @@ import type {
|
|
|
59
73
|
DenialRecord,
|
|
60
74
|
EffectiveConfig,
|
|
61
75
|
} from "./types.ts";
|
|
62
|
-
import { safeJson } from "./utils.ts";
|
|
76
|
+
import { safeJson, truncateMiddle } from "./utils.ts";
|
|
77
|
+
|
|
78
|
+
const INSPECT_TOOL = "automode_inspect";
|
|
79
|
+
const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
|
|
80
|
+
type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
|
|
81
|
+
|
|
82
|
+
function matchedCommandSummary(command: string | undefined): string | undefined {
|
|
83
|
+
return command ? truncateMiddle(command, 500) : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function canonicalPath(path: string): string {
|
|
87
|
+
try {
|
|
88
|
+
return realpathSync(path);
|
|
89
|
+
} catch {
|
|
90
|
+
return resolve(path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const EXTENSION_PATH = canonicalPath(fileURLToPath(import.meta.url));
|
|
95
|
+
const EXTENSION_ENTRY_PATH = canonicalPath(
|
|
96
|
+
resolve(dirname(EXTENSION_PATH), "../auto-mode.ts"),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
export function modelVisibleConfigDiagnostics(
|
|
100
|
+
diagnostics: string[],
|
|
101
|
+
): string[] {
|
|
102
|
+
return diagnostics.map((diagnostic) =>
|
|
103
|
+
diagnostic.replace(
|
|
104
|
+
/invalid JSON \([\s\S]*\)$/,
|
|
105
|
+
"invalid JSON (parser details omitted from model-visible output)",
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
63
109
|
|
|
64
110
|
export type PiAutomodeOptions = {
|
|
65
111
|
/** Override config loading in tests. Runtime code uses Pi-owned disk settings. */
|
|
66
|
-
loadConfig?: (cwd: string) => EffectiveConfig;
|
|
112
|
+
loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
|
|
67
113
|
/** Override classifier calls in tests so unit tests never need a real LLM/API key. */
|
|
68
114
|
classifyAction?: ClassifyAction;
|
|
69
|
-
/** Override classifier-model persistence in tests. Runtime code writes
|
|
115
|
+
/** Override classifier-model persistence in tests. Runtime code writes the active global config. */
|
|
70
116
|
saveClassifierModel?: (classifierModel: string) => void;
|
|
117
|
+
/** Override global config migration and path selection in tests. */
|
|
118
|
+
prepareGlobalConfig?: () => GlobalConfigPreparation;
|
|
119
|
+
/** Override the application-owned observability log root in tests. */
|
|
120
|
+
logRoot?: string;
|
|
121
|
+
/** Override the observability log clock in tests. */
|
|
122
|
+
now?: () => Date;
|
|
123
|
+
/** Override Bash analysis in tests. Runtime code uses unbash. */
|
|
124
|
+
analyzeBash?: typeof analyzeBash;
|
|
71
125
|
};
|
|
72
126
|
|
|
73
127
|
type LogCtx = {
|
|
@@ -117,18 +171,44 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
|
117
171
|
|
|
118
172
|
/** Create a Pi extension instance. Default export uses production dependencies. */
|
|
119
173
|
export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
120
|
-
const loadConfigWithDiagnostics = options.loadConfig
|
|
121
|
-
? (cwd: string): ConfigLoadResult => ({
|
|
122
|
-
config: options.loadConfig?.(cwd) ?? loadEffectiveConfig(cwd),
|
|
123
|
-
diagnostics: [],
|
|
124
|
-
})
|
|
125
|
-
: loadEffectiveConfigWithDiagnostics;
|
|
126
174
|
const classify = options.classifyAction ?? defaultClassifyAction;
|
|
127
|
-
const
|
|
128
|
-
writeGlobalClassifierModel;
|
|
175
|
+
const now = options.now ?? (() => new Date());
|
|
129
176
|
|
|
130
177
|
return function piAutomode(pi: ExtensionAPI) {
|
|
131
|
-
|
|
178
|
+
const globalConfig = options.prepareGlobalConfig?.() ??
|
|
179
|
+
(options.loadConfig
|
|
180
|
+
? { status: "current" as const, activePath: PI_GLOBAL_SETTINGS[0] }
|
|
181
|
+
: prepareGlobalConfig());
|
|
182
|
+
const loadConfigWithDiagnostics = (
|
|
183
|
+
cwd: string,
|
|
184
|
+
projectTrusted: boolean,
|
|
185
|
+
): ConfigLoadResult => {
|
|
186
|
+
const result = options.loadConfig
|
|
187
|
+
? {
|
|
188
|
+
config: options.loadConfig(cwd, projectTrusted),
|
|
189
|
+
diagnostics: [],
|
|
190
|
+
}
|
|
191
|
+
: loadEffectiveConfigWithDiagnostics(
|
|
192
|
+
cwd,
|
|
193
|
+
projectTrusted,
|
|
194
|
+
globalConfig.activePath,
|
|
195
|
+
);
|
|
196
|
+
return globalConfig.diagnostic
|
|
197
|
+
? {
|
|
198
|
+
...result,
|
|
199
|
+
diagnostics: [...result.diagnostics, globalConfig.diagnostic],
|
|
200
|
+
}
|
|
201
|
+
: result;
|
|
202
|
+
};
|
|
203
|
+
const persistClassifierModel = options.saveClassifierModel ??
|
|
204
|
+
((classifierModel: string) =>
|
|
205
|
+
writeGlobalClassifierModel(classifierModel, globalConfig.activePath));
|
|
206
|
+
const saveClassifierModel = globalConfig.writeBlockedReason
|
|
207
|
+
? (_classifierModel: string) => {
|
|
208
|
+
throw new Error(globalConfig.writeBlockedReason);
|
|
209
|
+
}
|
|
210
|
+
: persistClassifierModel;
|
|
211
|
+
let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
|
|
132
212
|
let config: EffectiveConfig = loadResult.config;
|
|
133
213
|
let configDiagnostics: string[] = loadResult.diagnostics;
|
|
134
214
|
let state: AutoModeState = {
|
|
@@ -139,6 +219,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
139
219
|
recentDenials: [],
|
|
140
220
|
};
|
|
141
221
|
let loadedContext = "";
|
|
222
|
+
let globalConfigNoticeShown = false;
|
|
142
223
|
|
|
143
224
|
function effectiveConfig(): EffectiveConfig {
|
|
144
225
|
return {
|
|
@@ -147,6 +228,13 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
147
228
|
};
|
|
148
229
|
}
|
|
149
230
|
|
|
231
|
+
function ownsInspectionTool(): boolean {
|
|
232
|
+
const tool = pi.getAllTools().find(({ name }) => name === INSPECT_TOOL);
|
|
233
|
+
if (!tool) return false;
|
|
234
|
+
const sourcePath = canonicalPath(tool.sourceInfo.path);
|
|
235
|
+
return sourcePath === EXTENSION_PATH || sourcePath === EXTENSION_ENTRY_PATH;
|
|
236
|
+
}
|
|
237
|
+
|
|
150
238
|
function persist(): void {
|
|
151
239
|
pi.appendEntry("pi-automode-state", state);
|
|
152
240
|
}
|
|
@@ -163,6 +251,78 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
163
251
|
);
|
|
164
252
|
}
|
|
165
253
|
|
|
254
|
+
function inspectAutomode(
|
|
255
|
+
action: InspectionAction,
|
|
256
|
+
ctx: ExtensionContext,
|
|
257
|
+
): unknown {
|
|
258
|
+
const cfg = effectiveConfig();
|
|
259
|
+
if (action === "status") {
|
|
260
|
+
const status = [
|
|
261
|
+
`enabled: ${cfg.enabled ? "yes" : "no"}`,
|
|
262
|
+
`classifier: ${cfg.classifierModel ?? "current session model"}`,
|
|
263
|
+
`classifier reasoning: ${cfg.classifierReasoningLevel ?? "server default"}`,
|
|
264
|
+
`checked actions: ${state.checkedActions}`,
|
|
265
|
+
`blocked actions: ${state.blockedActions}`,
|
|
266
|
+
`classifier allowed: ${state.classifierAllowed}`,
|
|
267
|
+
`classifier denied: ${state.classifierDenied}`,
|
|
268
|
+
`permissions.deny rules: ${cfg.permissionDeny.length}`,
|
|
269
|
+
`permissions.ask rules: ${cfg.permissionAsk.length}`,
|
|
270
|
+
`permissions.allow rules: ${cfg.permissionAllow.length}`,
|
|
271
|
+
`environment entries: ${cfg.environment.length}`,
|
|
272
|
+
`allow entries: ${cfg.allow.length}`,
|
|
273
|
+
`soft_deny entries: ${cfg.softDeny.length}`,
|
|
274
|
+
`hard_deny entries: ${cfg.hardDeny.length}`,
|
|
275
|
+
`last decision: ${state.lastDecision ?? "none"}`,
|
|
276
|
+
"last reason: omitted from model-visible inspection",
|
|
277
|
+
].join("\n");
|
|
278
|
+
return {
|
|
279
|
+
status,
|
|
280
|
+
state: {
|
|
281
|
+
enabledOverride: state.enabledOverride,
|
|
282
|
+
lastDecision: state.lastDecision,
|
|
283
|
+
checkedActions: state.checkedActions,
|
|
284
|
+
blockedActions: state.blockedActions,
|
|
285
|
+
classifierAllowed: state.classifierAllowed,
|
|
286
|
+
classifierDenied: state.classifierDenied,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (action === "config") {
|
|
291
|
+
return {
|
|
292
|
+
config: cfg,
|
|
293
|
+
logFile: resolveLogPath(
|
|
294
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
295
|
+
ctx.sessionManager.getSessionDir?.() ?? "",
|
|
296
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
297
|
+
ctx.cwd,
|
|
298
|
+
options.logRoot,
|
|
299
|
+
now(),
|
|
300
|
+
),
|
|
301
|
+
diagnostics: modelVisibleConfigDiagnostics(configDiagnostics),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (action === "defaults") {
|
|
305
|
+
return {
|
|
306
|
+
environment: DEFAULT_ENVIRONMENT,
|
|
307
|
+
allow: DEFAULT_ALLOW,
|
|
308
|
+
protectedPaths: DEFAULT_PROTECTED_PATHS,
|
|
309
|
+
soft_deny: DEFAULT_SOFT_DENY,
|
|
310
|
+
hard_deny: DEFAULT_HARD_DENY,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const denials = state.recentDenials.slice().reverse().map((denial) => ({
|
|
314
|
+
timestamp: denial.timestamp,
|
|
315
|
+
kind: denial.kind,
|
|
316
|
+
toolName: denial.toolName,
|
|
317
|
+
}));
|
|
318
|
+
return {
|
|
319
|
+
summary: denials.length === 0
|
|
320
|
+
? "No recent auto-mode denials."
|
|
321
|
+
: `${denials.length} recent auto-mode denial(s). Reasons and action payloads are omitted.`,
|
|
322
|
+
denials,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
166
326
|
function block(
|
|
167
327
|
ctx: ExtensionContext,
|
|
168
328
|
denial: DenialRecord,
|
|
@@ -231,10 +391,24 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
231
391
|
}
|
|
232
392
|
|
|
233
393
|
pi.on("session_start", (_event, ctx) => {
|
|
234
|
-
loadResult = loadConfigWithDiagnostics(
|
|
394
|
+
loadResult = loadConfigWithDiagnostics(
|
|
395
|
+
ctx.cwd,
|
|
396
|
+
ctx.isProjectTrusted(),
|
|
397
|
+
);
|
|
235
398
|
config = loadResult.config;
|
|
236
399
|
configDiagnostics = loadResult.diagnostics;
|
|
237
400
|
state = restoreState(ctx);
|
|
401
|
+
if (
|
|
402
|
+
ctx.hasUI &&
|
|
403
|
+
globalConfig.notification &&
|
|
404
|
+
!globalConfigNoticeShown
|
|
405
|
+
) {
|
|
406
|
+
ctx.ui.notify(
|
|
407
|
+
globalConfig.notification,
|
|
408
|
+
globalConfig.status === "migrated" ? "info" : "warning",
|
|
409
|
+
);
|
|
410
|
+
globalConfigNoticeShown = true;
|
|
411
|
+
}
|
|
238
412
|
updateUi(ctx);
|
|
239
413
|
});
|
|
240
414
|
|
|
@@ -251,22 +425,51 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
251
425
|
// Enforcement order:
|
|
252
426
|
// 1. permission deny/ask rules,
|
|
253
427
|
// 2. deterministic hard-deny checks that never consult the model,
|
|
254
|
-
// 3. read-only
|
|
255
|
-
// 4.
|
|
428
|
+
// 3. extension-owned read-only inspection tool,
|
|
429
|
+
// 4. deterministic path denials,
|
|
430
|
+
// 5. accepted ask rules force classifier review and skip all allow tiers,
|
|
431
|
+
// 6. inside-CWD, permissions.allow, and read-only allow tiers,
|
|
432
|
+
// 7. classifier for every remaining action, fail-closed on setup/parse errors.
|
|
256
433
|
const cfg = effectiveConfig();
|
|
257
434
|
if (!cfg.enabled) return undefined;
|
|
258
435
|
if (ctx.signal?.aborted) return { block: true, reason: "Cancelled" };
|
|
259
436
|
|
|
437
|
+
const isOwnedInspection = event.toolName === INSPECT_TOOL &&
|
|
438
|
+
ownsInspectionTool();
|
|
260
439
|
const input = event.input as Record<string, unknown>;
|
|
440
|
+
let bashAnalysis: BashAnalysis | undefined;
|
|
441
|
+
if (event.toolName === "bash") {
|
|
442
|
+
const source = typeof input.command === "string" ? input.command : "";
|
|
443
|
+
try {
|
|
444
|
+
bashAnalysis = (options.analyzeBash ?? analyzeBash)(source);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
bashAnalysis = {
|
|
447
|
+
source,
|
|
448
|
+
commands: [],
|
|
449
|
+
redirects: [],
|
|
450
|
+
redirectTargets: [],
|
|
451
|
+
structure: [],
|
|
452
|
+
allowStructureSafe: false,
|
|
453
|
+
errors: [{
|
|
454
|
+
message: `Bash analysis failed: ${
|
|
455
|
+
error instanceof Error ? error.message : String(error)
|
|
456
|
+
}`,
|
|
457
|
+
}],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
261
461
|
const summary = actionSummary(event.toolName, input);
|
|
262
|
-
state.checkedActions += 1;
|
|
462
|
+
if (!isOwnedInspection) state.checkedActions += 1;
|
|
263
463
|
const logCtx: LogCtx = {
|
|
264
464
|
logger: createLogger({
|
|
265
465
|
enabled: cfg.log.enabled,
|
|
266
466
|
classifierIo: cfg.log.classifierIo,
|
|
267
467
|
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
268
|
-
sessionDir: ctx.sessionManager.getSessionDir?.() ??
|
|
468
|
+
sessionDir: ctx.sessionManager.getSessionDir?.() ?? "",
|
|
469
|
+
sessionCwd: ctx.cwd,
|
|
269
470
|
sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
471
|
+
logRoot: options.logRoot,
|
|
472
|
+
now: now(),
|
|
270
473
|
}),
|
|
271
474
|
decisionId: newDecisionId(),
|
|
272
475
|
classifierModel: cfg.classifierModel,
|
|
@@ -274,27 +477,58 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
274
477
|
};
|
|
275
478
|
|
|
276
479
|
for (const pattern of cfg.permissionDeny) {
|
|
277
|
-
if (
|
|
480
|
+
if (
|
|
481
|
+
matchesToolPattern(
|
|
482
|
+
pattern,
|
|
483
|
+
event.toolName,
|
|
484
|
+
input,
|
|
485
|
+
ctx.cwd,
|
|
486
|
+
"match",
|
|
487
|
+
bashAnalysis,
|
|
488
|
+
)
|
|
489
|
+
) {
|
|
490
|
+
const matchedCommand = matchedCommandSummary(
|
|
491
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
492
|
+
);
|
|
493
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
278
494
|
return block(ctx, {
|
|
279
495
|
timestamp: Date.now(),
|
|
280
496
|
toolName: event.toolName,
|
|
281
|
-
reason: `Blocked by permissions.deny: ${pattern.raw}
|
|
497
|
+
reason: `Blocked by permissions.deny: ${pattern.raw}${
|
|
498
|
+
matchedCommand ? `; matched command: ${matchedCommand}` : ""
|
|
499
|
+
}`,
|
|
282
500
|
action: summary,
|
|
283
501
|
kind: "permissions.deny",
|
|
284
502
|
}, logCtx);
|
|
285
503
|
}
|
|
286
504
|
}
|
|
287
505
|
|
|
506
|
+
let askRequiresClassifier = false;
|
|
288
507
|
for (const pattern of cfg.permissionAsk) {
|
|
289
|
-
if (
|
|
508
|
+
if (
|
|
509
|
+
!matchesToolPattern(
|
|
510
|
+
pattern,
|
|
511
|
+
event.toolName,
|
|
512
|
+
input,
|
|
513
|
+
ctx.cwd,
|
|
514
|
+
"match",
|
|
515
|
+
bashAnalysis,
|
|
516
|
+
)
|
|
517
|
+
) {
|
|
290
518
|
continue;
|
|
291
519
|
}
|
|
292
520
|
if (!ctx.hasUI) {
|
|
521
|
+
const matchedCommand = matchedCommandSummary(
|
|
522
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
523
|
+
);
|
|
524
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
293
525
|
return block(ctx, {
|
|
294
526
|
timestamp: Date.now(),
|
|
295
527
|
toolName: event.toolName,
|
|
296
528
|
reason:
|
|
297
|
-
`Matched permissions.ask (${pattern.raw})
|
|
529
|
+
`Matched permissions.ask (${pattern.raw})${
|
|
530
|
+
matchedCommand ? ` for command: ${matchedCommand}` : ""
|
|
531
|
+
} but no UI is available`,
|
|
298
532
|
action: summary,
|
|
299
533
|
kind: "permissions.ask",
|
|
300
534
|
}, logCtx);
|
|
@@ -305,22 +539,31 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
305
539
|
{ signal: ctx.signal },
|
|
306
540
|
);
|
|
307
541
|
if (!allowed) {
|
|
542
|
+
const matchedCommand = matchedCommandSummary(
|
|
543
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
544
|
+
);
|
|
545
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
308
546
|
return block(ctx, {
|
|
309
547
|
timestamp: Date.now(),
|
|
310
548
|
toolName: event.toolName,
|
|
311
|
-
reason: `Declined permissions.ask: ${pattern.raw}
|
|
549
|
+
reason: `Declined permissions.ask: ${pattern.raw}${
|
|
550
|
+
matchedCommand ? `; matched command: ${matchedCommand}` : ""
|
|
551
|
+
}`,
|
|
312
552
|
action: summary,
|
|
313
553
|
kind: "permissions.ask",
|
|
314
554
|
}, logCtx);
|
|
315
555
|
}
|
|
556
|
+
askRequiresClassifier = true;
|
|
316
557
|
}
|
|
317
558
|
|
|
318
559
|
const deterministicReason = deterministicHardDeny(
|
|
319
560
|
event.toolName,
|
|
320
561
|
input,
|
|
321
562
|
ctx.cwd,
|
|
563
|
+
bashAnalysis,
|
|
322
564
|
);
|
|
323
565
|
if (deterministicReason) {
|
|
566
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
324
567
|
return block(ctx, {
|
|
325
568
|
timestamp: Date.now(),
|
|
326
569
|
toolName: event.toolName,
|
|
@@ -330,6 +573,9 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
330
573
|
}, logCtx);
|
|
331
574
|
}
|
|
332
575
|
|
|
576
|
+
if (isOwnedInspection && !askRequiresClassifier) return undefined;
|
|
577
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
578
|
+
|
|
333
579
|
// Deterministic path gate for file tools.
|
|
334
580
|
//
|
|
335
581
|
// `deniedPaths` always applies: a matching path is hard-denied before any
|
|
@@ -343,15 +589,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
343
589
|
// The gate is skipped entirely when both features are off, so the
|
|
344
590
|
// default configuration costs no extra filesystem calls.
|
|
345
591
|
let readOnlyFastPath =
|
|
346
|
-
!
|
|
592
|
+
!askRequiresClassifier &&
|
|
593
|
+
!cfg.classifyReadOnlyTools &&
|
|
594
|
+
READ_ONLY_TOOLS.has(event.toolName);
|
|
347
595
|
if (
|
|
348
596
|
(cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
|
|
349
597
|
PATH_BEARING_TOOLS.has(event.toolName)
|
|
350
598
|
) {
|
|
351
599
|
const inputPath = extractInputPath(event.toolName, input);
|
|
352
600
|
if (inputPath !== undefined) {
|
|
353
|
-
const
|
|
354
|
-
|
|
601
|
+
const resolved =
|
|
602
|
+
resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
|
|
603
|
+
inputPath;
|
|
355
604
|
const policyPath = resolvePathForPolicy(resolved) ?? resolved;
|
|
356
605
|
const denied =
|
|
357
606
|
cfg.deniedPaths.length > 0 &&
|
|
@@ -366,19 +615,42 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
366
615
|
kind: "deterministic-path-deny",
|
|
367
616
|
}, logCtx);
|
|
368
617
|
}
|
|
618
|
+
let recursiveSearch =
|
|
619
|
+
event.toolName === "grep" || event.toolName === "find";
|
|
620
|
+
if (recursiveSearch) {
|
|
621
|
+
try {
|
|
622
|
+
recursiveSearch = statSync(policyPath).isDirectory();
|
|
623
|
+
} catch {
|
|
624
|
+
// A missing search root will fail in the tool. Treat it as a
|
|
625
|
+
// directory here so a denied scope cannot fail open in a race.
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const deniedSearchScope =
|
|
629
|
+
recursiveSearch &&
|
|
630
|
+
cfg.deniedPaths.length > 0 &&
|
|
631
|
+
(recursiveSearchMayReachDeniedPath(resolved, cfg.deniedPaths) ||
|
|
632
|
+
recursiveSearchMayReachDeniedPath(
|
|
633
|
+
policyPath,
|
|
634
|
+
cfg.deniedPaths,
|
|
635
|
+
));
|
|
636
|
+
if (deniedSearchScope) {
|
|
637
|
+
return block(ctx, {
|
|
638
|
+
timestamp: Date.now(),
|
|
639
|
+
toolName: event.toolName,
|
|
640
|
+
reason: `Search scope can contain a path denied by policy: ${policyPath}`,
|
|
641
|
+
action: summary,
|
|
642
|
+
kind: "deterministic-path-deny",
|
|
643
|
+
}, logCtx);
|
|
644
|
+
}
|
|
369
645
|
if (cfg.allowInsideWorkingDirectory) {
|
|
370
646
|
const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
|
|
371
647
|
if (isInside(policyPath, policyCwd)) {
|
|
372
|
-
// Protected in-tree writes
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
// .husky/*, or .gitignore.
|
|
376
|
-
if (
|
|
648
|
+
// Protected in-tree writes and accepted ask rules must still
|
|
649
|
+
// reach the classifier. They cannot use the inside-CWD tier.
|
|
650
|
+
const protectedWrite =
|
|
377
651
|
(event.toolName === "write" || event.toolName === "edit") &&
|
|
378
|
-
isProtectedPath(policyPath, policyCwd, cfg.protectedPaths)
|
|
379
|
-
) {
|
|
380
|
-
readOnlyFastPath = false;
|
|
381
|
-
} else {
|
|
652
|
+
isProtectedPath(policyPath, policyCwd, cfg.protectedPaths);
|
|
653
|
+
if (!askRequiresClassifier && !protectedWrite) {
|
|
382
654
|
return allow(
|
|
383
655
|
ctx,
|
|
384
656
|
"inside-working-directory",
|
|
@@ -389,13 +661,54 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
389
661
|
);
|
|
390
662
|
}
|
|
391
663
|
}
|
|
392
|
-
// Outside the working directory
|
|
393
|
-
//
|
|
664
|
+
// Outside the working directory, protected writes, and accepted
|
|
665
|
+
// ask rules must not use the read-only fast path.
|
|
394
666
|
readOnlyFastPath = false;
|
|
395
667
|
}
|
|
396
668
|
}
|
|
397
669
|
}
|
|
398
670
|
|
|
671
|
+
// Deterministic allow tier. It runs after every deterministic denial.
|
|
672
|
+
// Accepted ask rules skip this tier and always reach the classifier.
|
|
673
|
+
if (!askRequiresClassifier) {
|
|
674
|
+
if (
|
|
675
|
+
matchesAllowedToolPatterns(
|
|
676
|
+
cfg.permissionAllow,
|
|
677
|
+
event.toolName,
|
|
678
|
+
input,
|
|
679
|
+
ctx.cwd,
|
|
680
|
+
bashAnalysis,
|
|
681
|
+
)
|
|
682
|
+
) {
|
|
683
|
+
// A protected-path write/edit is never covered by permissions.allow;
|
|
684
|
+
// it stays on the classifier path (same rule as the inside-CWD tier).
|
|
685
|
+
let protectedWrite = false;
|
|
686
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
687
|
+
const inputPath = extractInputPath(event.toolName, input);
|
|
688
|
+
const resolved = inputPath === undefined
|
|
689
|
+
? undefined
|
|
690
|
+
: resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
|
|
691
|
+
inputPath;
|
|
692
|
+
if (
|
|
693
|
+
resolved !== undefined &&
|
|
694
|
+
isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
|
|
695
|
+
) {
|
|
696
|
+
protectedWrite = true;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (!protectedWrite) {
|
|
700
|
+
return allow(
|
|
701
|
+
ctx,
|
|
702
|
+
"permissions.allow",
|
|
703
|
+
"Allowed by permissions.allow",
|
|
704
|
+
event.toolName,
|
|
705
|
+
summary,
|
|
706
|
+
logCtx,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
399
712
|
if (readOnlyFastPath) {
|
|
400
713
|
return allow(
|
|
401
714
|
ctx,
|
|
@@ -407,7 +720,12 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
407
720
|
);
|
|
408
721
|
}
|
|
409
722
|
|
|
410
|
-
const decision = await classify(
|
|
723
|
+
const decision = await classify(
|
|
724
|
+
ctx,
|
|
725
|
+
cfg,
|
|
726
|
+
serializeClassifierAction(event.toolName, input),
|
|
727
|
+
loadedContext,
|
|
728
|
+
);
|
|
411
729
|
logClassifierIo(decision, logCtx);
|
|
412
730
|
if (decision.decision === "allow") {
|
|
413
731
|
state.classifierAllowed += 1;
|
|
@@ -431,6 +749,28 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
431
749
|
}, logCtx);
|
|
432
750
|
});
|
|
433
751
|
|
|
752
|
+
pi.registerTool({
|
|
753
|
+
name: INSPECT_TOOL,
|
|
754
|
+
label: "Inspect Auto Mode",
|
|
755
|
+
description:
|
|
756
|
+
"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.",
|
|
757
|
+
promptSnippet:
|
|
758
|
+
"Inspect active pi-automode state and diagnostic information without changing it",
|
|
759
|
+
parameters: Type.Object({
|
|
760
|
+
action: StringEnum(INSPECTION_ACTIONS, {
|
|
761
|
+
description: "The read-only auto-mode view to return",
|
|
762
|
+
}),
|
|
763
|
+
}),
|
|
764
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
765
|
+
if (signal?.aborted) throw new Error("Auto-mode inspection cancelled");
|
|
766
|
+
const result = inspectAutomode(params.action, ctx);
|
|
767
|
+
return {
|
|
768
|
+
content: [{ type: "text", text: safeJson(result, 16000) }],
|
|
769
|
+
details: result,
|
|
770
|
+
};
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
|
|
434
774
|
async function handleAutomodeCommand(
|
|
435
775
|
args: string,
|
|
436
776
|
ctx: ExtensionCommandContext,
|
|
@@ -460,7 +800,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
460
800
|
return;
|
|
461
801
|
}
|
|
462
802
|
if (command === "reload") {
|
|
463
|
-
loadResult = loadConfigWithDiagnostics(
|
|
803
|
+
loadResult = loadConfigWithDiagnostics(
|
|
804
|
+
ctx.cwd,
|
|
805
|
+
ctx.isProjectTrusted(),
|
|
806
|
+
);
|
|
464
807
|
config = loadResult.config;
|
|
465
808
|
configDiagnostics = loadResult.diagnostics;
|
|
466
809
|
persist();
|
|
@@ -504,8 +847,11 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
504
847
|
if (command === "config") {
|
|
505
848
|
const logFile = resolveLogPath(
|
|
506
849
|
ctx.sessionManager.getSessionFile?.(),
|
|
507
|
-
ctx.sessionManager.getSessionDir?.() ??
|
|
850
|
+
ctx.sessionManager.getSessionDir?.() ?? "",
|
|
508
851
|
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
852
|
+
ctx.cwd,
|
|
853
|
+
options.logRoot,
|
|
854
|
+
now(),
|
|
509
855
|
);
|
|
510
856
|
ctx.ui.notify(
|
|
511
857
|
safeJson(
|
|
@@ -561,7 +907,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
561
907
|
);
|
|
562
908
|
return;
|
|
563
909
|
}
|
|
564
|
-
loadResult = loadConfigWithDiagnostics(
|
|
910
|
+
loadResult = loadConfigWithDiagnostics(
|
|
911
|
+
ctx.cwd,
|
|
912
|
+
ctx.isProjectTrusted(),
|
|
913
|
+
);
|
|
565
914
|
config = loadResult.config;
|
|
566
915
|
configDiagnostics = loadResult.diagnostics;
|
|
567
916
|
persist();
|