@ibartel74/pi-automode-ext 1.0.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 +81 -0
- package/LICENSE.md +22 -0
- package/README.md +262 -0
- package/docs/GLOSSARY.md +41 -0
- 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 +449 -0
- package/docs/configuration.md +226 -0
- package/docs/defaults.md +178 -0
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +160 -0
- package/examples/automode.local.json +45 -0
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +940 -0
- package/extensions/auto-mode/config.ts +948 -0
- package/extensions/auto-mode/constants.ts +232 -0
- package/extensions/auto-mode/extension.ts +1118 -0
- package/extensions/auto-mode/hard-deny.ts +429 -0
- package/extensions/auto-mode/jev.ts +338 -0
- package/extensions/auto-mode/log.ts +173 -0
- package/extensions/auto-mode/model-selector.ts +113 -0
- package/extensions/auto-mode/model.ts +13 -0
- package/extensions/auto-mode/paths.ts +303 -0
- package/extensions/auto-mode/permissions.ts +667 -0
- package/extensions/auto-mode/state.ts +106 -0
- package/extensions/auto-mode/transcript.ts +236 -0
- package/extensions/auto-mode/types.ts +210 -0
- package/extensions/auto-mode/utils.ts +54 -0
- package/extensions/auto-mode.ts +27 -0
- package/package.json +61 -0
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionCommandContext,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import {
|
|
12
|
+
classifierReasoningForConfig,
|
|
13
|
+
defaultClassifyAction,
|
|
14
|
+
resolveJevApiKey,
|
|
15
|
+
serializeClassifierAction,
|
|
16
|
+
} from "./classifier.ts";
|
|
17
|
+
import { analyzeBash, type BashAnalysis } from "./bash.ts";
|
|
18
|
+
import {
|
|
19
|
+
AUTO_MODE_GUIDANCE,
|
|
20
|
+
DEFAULT_ALLOW,
|
|
21
|
+
DEFAULT_ENVIRONMENT,
|
|
22
|
+
DEFAULT_HARD_DENY,
|
|
23
|
+
DEFAULT_PROTECTED_PATHS,
|
|
24
|
+
DEFAULT_SOFT_DENY,
|
|
25
|
+
PATH_BEARING_TOOLS,
|
|
26
|
+
PI_GLOBAL_SETTINGS,
|
|
27
|
+
READ_ONLY_TOOLS,
|
|
28
|
+
} from "./constants.ts";
|
|
29
|
+
import {
|
|
30
|
+
type GlobalConfigPreparation,
|
|
31
|
+
loadEffectiveConfigWithDiagnostics,
|
|
32
|
+
prepareGlobalConfig,
|
|
33
|
+
persistAllowRule,
|
|
34
|
+
writeGlobalClassifierModel,
|
|
35
|
+
} from "./config.ts";
|
|
36
|
+
import { deterministicHardDeny } from "./hard-deny.ts";
|
|
37
|
+
import { isJevClassifierModel, JEV_API_KEY_ENV, JEV_TYPESAFE_API_KEY_ENV } from "./jev.ts";
|
|
38
|
+
import {
|
|
39
|
+
createLogger,
|
|
40
|
+
newDecisionId,
|
|
41
|
+
resolveLogPath,
|
|
42
|
+
type Logger,
|
|
43
|
+
} from "./log.ts";
|
|
44
|
+
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
45
|
+
import { promptForClassifierModel } from "./model-selector.ts";
|
|
46
|
+
import {
|
|
47
|
+
allowRuleForAction,
|
|
48
|
+
matchesAllowedToolPatterns,
|
|
49
|
+
normalizeEditedAllowRule,
|
|
50
|
+
matchesDeniedPath,
|
|
51
|
+
matchesToolPattern,
|
|
52
|
+
matchingBashCommandText,
|
|
53
|
+
recursiveSearchMayReachDeniedPath,
|
|
54
|
+
} from "./permissions.ts";
|
|
55
|
+
import {
|
|
56
|
+
extractInputPath,
|
|
57
|
+
isInside,
|
|
58
|
+
isProtectedPath,
|
|
59
|
+
resolvePathForPolicy,
|
|
60
|
+
resolveToolInputPath,
|
|
61
|
+
} from "./paths.ts";
|
|
62
|
+
import {
|
|
63
|
+
actionSummary,
|
|
64
|
+
formatDenials,
|
|
65
|
+
pushDenial,
|
|
66
|
+
restoreState,
|
|
67
|
+
statusLine,
|
|
68
|
+
statusText,
|
|
69
|
+
} from "./state.ts";
|
|
70
|
+
import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
|
|
71
|
+
import type {
|
|
72
|
+
AutoModeState,
|
|
73
|
+
ClassifierReasoningLog,
|
|
74
|
+
ClassifyAction,
|
|
75
|
+
ClassifyResult,
|
|
76
|
+
ConfigLoadResult,
|
|
77
|
+
DecisionKind,
|
|
78
|
+
DenialRecord,
|
|
79
|
+
EffectiveConfig,
|
|
80
|
+
} from "./types.ts";
|
|
81
|
+
import { safeJson, truncateMiddle } from "./utils.ts";
|
|
82
|
+
|
|
83
|
+
const INSPECT_TOOL = "automode_inspect";
|
|
84
|
+
const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
|
|
85
|
+
type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
|
|
86
|
+
|
|
87
|
+
const CONFIRM_ALLOW_ONCE = "Allow once";
|
|
88
|
+
const CONFIRM_CUSTOM_PROJECT = "Custom allow rule (this project)…";
|
|
89
|
+
const CONFIRM_CUSTOM_GLOBAL = "Custom allow rule (global)…";
|
|
90
|
+
const CONFIRM_BLOCK = "Block";
|
|
91
|
+
|
|
92
|
+
function matchedCommandSummary(command: string | undefined): string | undefined {
|
|
93
|
+
return command ? truncateMiddle(command, 500) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function canonicalPath(path: string): string {
|
|
97
|
+
try {
|
|
98
|
+
return realpathSync(path);
|
|
99
|
+
} catch {
|
|
100
|
+
return resolve(path);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const EXTENSION_PATH = canonicalPath(fileURLToPath(import.meta.url));
|
|
105
|
+
const EXTENSION_ENTRY_PATH = canonicalPath(
|
|
106
|
+
resolve(dirname(EXTENSION_PATH), "../auto-mode.ts"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
export function modelVisibleConfigDiagnostics(
|
|
110
|
+
diagnostics: string[],
|
|
111
|
+
): string[] {
|
|
112
|
+
return diagnostics.map((diagnostic) =>
|
|
113
|
+
diagnostic.replace(
|
|
114
|
+
/invalid JSON \([\s\S]*\)$/,
|
|
115
|
+
"invalid JSON (parser details omitted from model-visible output)",
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function projectIsTrusted(
|
|
121
|
+
ctx: { isProjectTrusted?: () => boolean },
|
|
122
|
+
): boolean {
|
|
123
|
+
return typeof ctx.isProjectTrusted === "function"
|
|
124
|
+
? ctx.isProjectTrusted()
|
|
125
|
+
: false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type PiAutomodeOptions = {
|
|
129
|
+
/** Override config loading in tests. Runtime code uses Pi-owned disk settings. */
|
|
130
|
+
loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
|
|
131
|
+
/** Override classifier calls in tests so unit tests never need a real LLM/API key. */
|
|
132
|
+
classifyAction?: ClassifyAction;
|
|
133
|
+
/** Override classifier-model persistence in tests. Runtime code writes the active global config. */
|
|
134
|
+
saveClassifierModel?: (classifierModel: string) => void;
|
|
135
|
+
/** Override allow-rule persistence in tests. Runtime code writes the target config file. */
|
|
136
|
+
saveAllowRule?: (
|
|
137
|
+
rule: string,
|
|
138
|
+
scope: "global" | "project",
|
|
139
|
+
cwd: string,
|
|
140
|
+
) => { path: string; added: boolean };
|
|
141
|
+
/** Override global config migration and path selection in tests. */
|
|
142
|
+
prepareGlobalConfig?: () => GlobalConfigPreparation;
|
|
143
|
+
/** Override the application-owned observability log root in tests. */
|
|
144
|
+
logRoot?: string;
|
|
145
|
+
/** Override the observability log clock in tests. */
|
|
146
|
+
now?: () => Date;
|
|
147
|
+
/** Override Bash analysis in tests. Runtime code uses unbash. */
|
|
148
|
+
analyzeBash?: typeof analyzeBash;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
type LogCtx = {
|
|
152
|
+
logger: Logger;
|
|
153
|
+
decisionId: string;
|
|
154
|
+
classifierModel?: string;
|
|
155
|
+
reasoning: ClassifierReasoningLog;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Append ccusage-compatible usage and optional classifier I/O entries. */
|
|
159
|
+
function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
160
|
+
if (decision.reasoning) log.reasoning = decision.reasoning;
|
|
161
|
+
if (decision.io) log.reasoning = decision.io.reasoning;
|
|
162
|
+
if (!log.logger.enabled || !decision.io) return;
|
|
163
|
+
|
|
164
|
+
for (const attempt of decision.io.attempts) {
|
|
165
|
+
const response = attempt.response;
|
|
166
|
+
if (!response) continue;
|
|
167
|
+
log.logger.append({
|
|
168
|
+
type: "message",
|
|
169
|
+
timestamp: new Date(response.timestamp).toISOString(),
|
|
170
|
+
message: {
|
|
171
|
+
role: "assistant",
|
|
172
|
+
model: response.model,
|
|
173
|
+
usage: response.usage,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!log.logger.classifierIo) return;
|
|
179
|
+
log.logger.append({
|
|
180
|
+
type: "classifier",
|
|
181
|
+
ts: new Date().toISOString(),
|
|
182
|
+
decisionId: log.decisionId,
|
|
183
|
+
model: decision.io.model,
|
|
184
|
+
reasoning: decision.io.reasoning,
|
|
185
|
+
prompt: decision.io.prompt,
|
|
186
|
+
attempts: decision.io.attempts,
|
|
187
|
+
durationMs: decision.io.durationMs,
|
|
188
|
+
parsed: {
|
|
189
|
+
decision: decision.decision,
|
|
190
|
+
tier: decision.tier,
|
|
191
|
+
reason: decision.reason,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Create a Pi extension instance. Default export uses production dependencies. */
|
|
197
|
+
export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
198
|
+
const classify = options.classifyAction ?? defaultClassifyAction;
|
|
199
|
+
const now = options.now ?? (() => new Date());
|
|
200
|
+
|
|
201
|
+
return function piAutomode(pi: ExtensionAPI) {
|
|
202
|
+
const globalConfig = options.prepareGlobalConfig?.() ??
|
|
203
|
+
(options.loadConfig
|
|
204
|
+
? { status: "current" as const, activePath: PI_GLOBAL_SETTINGS[0] }
|
|
205
|
+
: prepareGlobalConfig());
|
|
206
|
+
const loadConfigWithDiagnostics = (
|
|
207
|
+
cwd: string,
|
|
208
|
+
projectTrusted: boolean,
|
|
209
|
+
): ConfigLoadResult => {
|
|
210
|
+
const result = options.loadConfig
|
|
211
|
+
? {
|
|
212
|
+
config: options.loadConfig(cwd, projectTrusted),
|
|
213
|
+
diagnostics: [],
|
|
214
|
+
}
|
|
215
|
+
: loadEffectiveConfigWithDiagnostics(
|
|
216
|
+
cwd,
|
|
217
|
+
projectTrusted,
|
|
218
|
+
globalConfig.activePath,
|
|
219
|
+
);
|
|
220
|
+
return globalConfig.diagnostic
|
|
221
|
+
? {
|
|
222
|
+
...result,
|
|
223
|
+
diagnostics: [...result.diagnostics, globalConfig.diagnostic],
|
|
224
|
+
}
|
|
225
|
+
: result;
|
|
226
|
+
};
|
|
227
|
+
const persistClassifierModel = options.saveClassifierModel ??
|
|
228
|
+
((classifierModel: string) =>
|
|
229
|
+
writeGlobalClassifierModel(classifierModel, globalConfig.activePath));
|
|
230
|
+
const saveClassifierModel = globalConfig.writeBlockedReason
|
|
231
|
+
? (_classifierModel: string) => {
|
|
232
|
+
throw new Error(globalConfig.writeBlockedReason);
|
|
233
|
+
}
|
|
234
|
+
: persistClassifierModel;
|
|
235
|
+
let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
|
|
236
|
+
let config: EffectiveConfig = loadResult.config;
|
|
237
|
+
let configDiagnostics: string[] = loadResult.diagnostics;
|
|
238
|
+
let state: AutoModeState = {
|
|
239
|
+
checkedActions: 0,
|
|
240
|
+
blockedActions: 0,
|
|
241
|
+
classifierAllowed: 0,
|
|
242
|
+
classifierDenied: 0,
|
|
243
|
+
userConfirmed: 0,
|
|
244
|
+
recentDenials: [],
|
|
245
|
+
};
|
|
246
|
+
let loadedContext = "";
|
|
247
|
+
let globalConfigNoticeShown = false;
|
|
248
|
+
|
|
249
|
+
function effectiveConfig(): EffectiveConfig {
|
|
250
|
+
return {
|
|
251
|
+
...config,
|
|
252
|
+
enabled: state.enabledOverride ?? config.enabled,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function ownsInspectionTool(): boolean {
|
|
257
|
+
const tool = pi.getAllTools().find(({ name }) => name === INSPECT_TOOL);
|
|
258
|
+
if (!tool) return false;
|
|
259
|
+
const sourcePath = canonicalPath(tool.sourceInfo.path);
|
|
260
|
+
return sourcePath === EXTENSION_PATH || sourcePath === EXTENSION_ENTRY_PATH;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function persist(): void {
|
|
264
|
+
pi.appendEntry("pi-automode-state", state);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function updateUi(ctx: ExtensionContext): void {
|
|
268
|
+
if (!ctx.hasUI) return;
|
|
269
|
+
const cfg = effectiveConfig();
|
|
270
|
+
const text = statusLine(cfg, state);
|
|
271
|
+
ctx.ui.setStatus(
|
|
272
|
+
"pi-automode",
|
|
273
|
+
cfg.enabled
|
|
274
|
+
? ctx.ui.theme.fg("accent", text)
|
|
275
|
+
: ctx.ui.theme.fg("dim", text),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function inspectAutomode(
|
|
280
|
+
action: InspectionAction,
|
|
281
|
+
ctx: ExtensionContext,
|
|
282
|
+
): unknown {
|
|
283
|
+
const cfg = effectiveConfig();
|
|
284
|
+
if (action === "status") {
|
|
285
|
+
const status = [
|
|
286
|
+
`enabled: ${cfg.enabled ? "yes" : "no"}`,
|
|
287
|
+
`classifier: ${cfg.classifierModel ?? "current session model"}`,
|
|
288
|
+
`classifier reasoning: ${cfg.classifierReasoningLevel ?? "server default"}`,
|
|
289
|
+
`checked actions: ${state.checkedActions}`,
|
|
290
|
+
`blocked actions: ${state.blockedActions}`,
|
|
291
|
+
`classifier allowed: ${state.classifierAllowed}`,
|
|
292
|
+
`classifier denied: ${state.classifierDenied}`,
|
|
293
|
+
`permissions.deny rules: ${cfg.permissionDeny.length}`,
|
|
294
|
+
`permissions.ask rules: ${cfg.permissionAsk.length}`,
|
|
295
|
+
`permissions.allow rules: ${cfg.permissionAllow.length}`,
|
|
296
|
+
`environment entries: ${cfg.environment.length}`,
|
|
297
|
+
`allow entries: ${cfg.allow.length}`,
|
|
298
|
+
`soft_deny entries: ${cfg.softDeny.length}`,
|
|
299
|
+
`hard_deny entries: ${cfg.hardDeny.length}`,
|
|
300
|
+
`last decision: ${state.lastDecision ?? "none"}`,
|
|
301
|
+
"last reason: omitted from model-visible inspection",
|
|
302
|
+
].join("\n");
|
|
303
|
+
return {
|
|
304
|
+
status,
|
|
305
|
+
state: {
|
|
306
|
+
enabledOverride: state.enabledOverride,
|
|
307
|
+
lastDecision: state.lastDecision,
|
|
308
|
+
checkedActions: state.checkedActions,
|
|
309
|
+
blockedActions: state.blockedActions,
|
|
310
|
+
classifierAllowed: state.classifierAllowed,
|
|
311
|
+
classifierDenied: state.classifierDenied,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (action === "config") {
|
|
316
|
+
return {
|
|
317
|
+
config: cfg,
|
|
318
|
+
logFile: resolveLogPath(
|
|
319
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
320
|
+
ctx.sessionManager.getSessionDir?.() ?? "",
|
|
321
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
322
|
+
ctx.cwd,
|
|
323
|
+
options.logRoot,
|
|
324
|
+
now(),
|
|
325
|
+
),
|
|
326
|
+
diagnostics: modelVisibleConfigDiagnostics(configDiagnostics),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (action === "defaults") {
|
|
330
|
+
return {
|
|
331
|
+
environment: DEFAULT_ENVIRONMENT,
|
|
332
|
+
allow: DEFAULT_ALLOW,
|
|
333
|
+
protectedPaths: DEFAULT_PROTECTED_PATHS,
|
|
334
|
+
soft_deny: DEFAULT_SOFT_DENY,
|
|
335
|
+
hard_deny: DEFAULT_HARD_DENY,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const denials = state.recentDenials.slice().reverse().map((denial) => ({
|
|
339
|
+
timestamp: denial.timestamp,
|
|
340
|
+
kind: denial.kind,
|
|
341
|
+
toolName: denial.toolName,
|
|
342
|
+
}));
|
|
343
|
+
return {
|
|
344
|
+
summary: denials.length === 0
|
|
345
|
+
? "No recent auto-mode denials."
|
|
346
|
+
: `${denials.length} recent auto-mode denial(s). Reasons and action payloads are omitted.`,
|
|
347
|
+
denials,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function block(
|
|
352
|
+
ctx: ExtensionContext,
|
|
353
|
+
denial: DenialRecord,
|
|
354
|
+
logCtx: LogCtx,
|
|
355
|
+
): { block: true; reason: string } {
|
|
356
|
+
state.blockedActions += 1;
|
|
357
|
+
state.lastDecision = "block";
|
|
358
|
+
state.lastReason = denial.reason;
|
|
359
|
+
pushDenial(state, denial);
|
|
360
|
+
persist();
|
|
361
|
+
updateUi(ctx);
|
|
362
|
+
if (logCtx.logger.enabled) {
|
|
363
|
+
logCtx.logger.append({
|
|
364
|
+
type: "decision",
|
|
365
|
+
ts: new Date().toISOString(),
|
|
366
|
+
decisionId: logCtx.decisionId,
|
|
367
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
368
|
+
cwd: ctx.cwd,
|
|
369
|
+
tool: denial.toolName,
|
|
370
|
+
summary: denial.action,
|
|
371
|
+
kind: denial.kind,
|
|
372
|
+
outcome: "block",
|
|
373
|
+
reason: denial.reason,
|
|
374
|
+
classifierModel: logCtx.classifierModel,
|
|
375
|
+
reasoning: logCtx.reasoning,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (ctx.hasUI) {
|
|
379
|
+
ctx.ui.notify(
|
|
380
|
+
`Auto mode blocked ${denial.toolName}: ${denial.reason}`,
|
|
381
|
+
"warning",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return { block: true, reason: `[pi-automode] ${denial.reason}` };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function allow(
|
|
388
|
+
ctx: ExtensionContext,
|
|
389
|
+
kind: DecisionKind,
|
|
390
|
+
reason: string,
|
|
391
|
+
toolName: string,
|
|
392
|
+
summary: string,
|
|
393
|
+
logCtx: LogCtx,
|
|
394
|
+
): undefined {
|
|
395
|
+
state.lastDecision = "allow";
|
|
396
|
+
state.lastReason = reason;
|
|
397
|
+
persist();
|
|
398
|
+
updateUi(ctx);
|
|
399
|
+
if (logCtx.logger.enabled) {
|
|
400
|
+
logCtx.logger.append({
|
|
401
|
+
type: "decision",
|
|
402
|
+
ts: new Date().toISOString(),
|
|
403
|
+
decisionId: logCtx.decisionId,
|
|
404
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
405
|
+
cwd: ctx.cwd,
|
|
406
|
+
tool: toolName,
|
|
407
|
+
summary,
|
|
408
|
+
kind,
|
|
409
|
+
outcome: "allow",
|
|
410
|
+
reason,
|
|
411
|
+
classifierModel: logCtx.classifierModel,
|
|
412
|
+
reasoning: logCtx.reasoning,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
pi.on("session_start", (_event, ctx) => {
|
|
419
|
+
loadResult = loadConfigWithDiagnostics(
|
|
420
|
+
ctx.cwd,
|
|
421
|
+
projectIsTrusted(ctx),
|
|
422
|
+
);
|
|
423
|
+
config = loadResult.config;
|
|
424
|
+
configDiagnostics = loadResult.diagnostics;
|
|
425
|
+
state = restoreState(ctx);
|
|
426
|
+
if (
|
|
427
|
+
ctx.hasUI &&
|
|
428
|
+
globalConfig.notification &&
|
|
429
|
+
!globalConfigNoticeShown
|
|
430
|
+
) {
|
|
431
|
+
ctx.ui.notify(
|
|
432
|
+
globalConfig.notification,
|
|
433
|
+
globalConfig.status === "migrated" ? "info" : "warning",
|
|
434
|
+
);
|
|
435
|
+
globalConfigNoticeShown = true;
|
|
436
|
+
}
|
|
437
|
+
updateUi(ctx);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
pi.on("before_agent_start", (event) => {
|
|
441
|
+
const cfg = effectiveConfig();
|
|
442
|
+
if (!cfg.enabled) return undefined;
|
|
443
|
+
loadedContext = loadedContextFromSystemPromptOptions(
|
|
444
|
+
event.systemPromptOptions,
|
|
445
|
+
);
|
|
446
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${AUTO_MODE_GUIDANCE}` };
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
450
|
+
// Enforcement order:
|
|
451
|
+
// 1. permission deny/ask rules,
|
|
452
|
+
// 2. deterministic hard-deny checks that never consult the model,
|
|
453
|
+
// 3. extension-owned read-only inspection tool,
|
|
454
|
+
// 4. deterministic path denials,
|
|
455
|
+
// 5. accepted ask rules force classifier review and skip all allow tiers,
|
|
456
|
+
// 6. inside-CWD, permissions.allow, and read-only allow tiers,
|
|
457
|
+
// 7. classifier for every remaining action, fail-closed on setup/parse errors.
|
|
458
|
+
const cfg = effectiveConfig();
|
|
459
|
+
if (!cfg.enabled) return undefined;
|
|
460
|
+
if (ctx.signal?.aborted) return { block: true, reason: "Cancelled" };
|
|
461
|
+
|
|
462
|
+
const isOwnedInspection = event.toolName === INSPECT_TOOL &&
|
|
463
|
+
ownsInspectionTool();
|
|
464
|
+
const input = event.input as Record<string, unknown>;
|
|
465
|
+
let bashAnalysis: BashAnalysis | undefined;
|
|
466
|
+
if (event.toolName === "bash") {
|
|
467
|
+
const source = typeof input.command === "string" ? input.command : "";
|
|
468
|
+
try {
|
|
469
|
+
bashAnalysis = (options.analyzeBash ?? analyzeBash)(source);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
bashAnalysis = {
|
|
472
|
+
source,
|
|
473
|
+
commands: [],
|
|
474
|
+
redirects: [],
|
|
475
|
+
redirectTargets: [],
|
|
476
|
+
structure: [],
|
|
477
|
+
allowStructureSafe: false,
|
|
478
|
+
errors: [{
|
|
479
|
+
message: `Bash analysis failed: ${
|
|
480
|
+
error instanceof Error ? error.message : String(error)
|
|
481
|
+
}`,
|
|
482
|
+
}],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const summary = actionSummary(event.toolName, input);
|
|
487
|
+
if (!isOwnedInspection) state.checkedActions += 1;
|
|
488
|
+
const logCtx: LogCtx = {
|
|
489
|
+
logger: createLogger({
|
|
490
|
+
enabled: cfg.log.enabled,
|
|
491
|
+
classifierIo: cfg.log.classifierIo,
|
|
492
|
+
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
493
|
+
sessionDir: ctx.sessionManager.getSessionDir?.() ?? "",
|
|
494
|
+
sessionCwd: ctx.cwd,
|
|
495
|
+
sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
496
|
+
logRoot: options.logRoot,
|
|
497
|
+
now: now(),
|
|
498
|
+
}),
|
|
499
|
+
decisionId: newDecisionId(),
|
|
500
|
+
classifierModel: cfg.classifierModel,
|
|
501
|
+
reasoning: classifierReasoningForConfig(cfg.classifierReasoningLevel),
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
for (const pattern of cfg.permissionDeny) {
|
|
505
|
+
if (
|
|
506
|
+
matchesToolPattern(
|
|
507
|
+
pattern,
|
|
508
|
+
event.toolName,
|
|
509
|
+
input,
|
|
510
|
+
ctx.cwd,
|
|
511
|
+
"match",
|
|
512
|
+
bashAnalysis,
|
|
513
|
+
)
|
|
514
|
+
) {
|
|
515
|
+
const matchedCommand = matchedCommandSummary(
|
|
516
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
517
|
+
);
|
|
518
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
519
|
+
return block(ctx, {
|
|
520
|
+
timestamp: Date.now(),
|
|
521
|
+
toolName: event.toolName,
|
|
522
|
+
reason: `Blocked by permissions.deny: ${pattern.raw}${
|
|
523
|
+
matchedCommand ? `; matched command: ${matchedCommand}` : ""
|
|
524
|
+
}`,
|
|
525
|
+
action: summary,
|
|
526
|
+
kind: "permissions.deny",
|
|
527
|
+
}, logCtx);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let askRequiresClassifier = false;
|
|
532
|
+
for (const pattern of cfg.permissionAsk) {
|
|
533
|
+
if (
|
|
534
|
+
!matchesToolPattern(
|
|
535
|
+
pattern,
|
|
536
|
+
event.toolName,
|
|
537
|
+
input,
|
|
538
|
+
ctx.cwd,
|
|
539
|
+
"match",
|
|
540
|
+
bashAnalysis,
|
|
541
|
+
)
|
|
542
|
+
) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (!ctx.hasUI) {
|
|
546
|
+
const matchedCommand = matchedCommandSummary(
|
|
547
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
548
|
+
);
|
|
549
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
550
|
+
return block(ctx, {
|
|
551
|
+
timestamp: Date.now(),
|
|
552
|
+
toolName: event.toolName,
|
|
553
|
+
reason:
|
|
554
|
+
`Matched permissions.ask (${pattern.raw})${
|
|
555
|
+
matchedCommand ? ` for command: ${matchedCommand}` : ""
|
|
556
|
+
} but no UI is available`,
|
|
557
|
+
action: summary,
|
|
558
|
+
kind: "permissions.ask",
|
|
559
|
+
}, logCtx);
|
|
560
|
+
}
|
|
561
|
+
const allowed = await ctx.ui.confirm(
|
|
562
|
+
"Auto mode permission ask",
|
|
563
|
+
`Rule: ${pattern.raw}\n\nAction:\n${summary}\n\nAllow this action to continue to auto-mode classification?`,
|
|
564
|
+
{ signal: ctx.signal },
|
|
565
|
+
);
|
|
566
|
+
if (!allowed) {
|
|
567
|
+
const matchedCommand = matchedCommandSummary(
|
|
568
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
569
|
+
);
|
|
570
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
571
|
+
return block(ctx, {
|
|
572
|
+
timestamp: Date.now(),
|
|
573
|
+
toolName: event.toolName,
|
|
574
|
+
reason: `Declined permissions.ask: ${pattern.raw}${
|
|
575
|
+
matchedCommand ? `; matched command: ${matchedCommand}` : ""
|
|
576
|
+
}`,
|
|
577
|
+
action: summary,
|
|
578
|
+
kind: "permissions.ask",
|
|
579
|
+
}, logCtx);
|
|
580
|
+
}
|
|
581
|
+
askRequiresClassifier = true;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const deterministicReason = deterministicHardDeny(
|
|
585
|
+
event.toolName,
|
|
586
|
+
input,
|
|
587
|
+
ctx.cwd,
|
|
588
|
+
bashAnalysis,
|
|
589
|
+
);
|
|
590
|
+
if (deterministicReason) {
|
|
591
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
592
|
+
return block(ctx, {
|
|
593
|
+
timestamp: Date.now(),
|
|
594
|
+
toolName: event.toolName,
|
|
595
|
+
reason: deterministicReason,
|
|
596
|
+
action: summary,
|
|
597
|
+
kind: "deterministic-hard-deny",
|
|
598
|
+
}, logCtx);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (isOwnedInspection && !askRequiresClassifier) return undefined;
|
|
602
|
+
if (isOwnedInspection) state.checkedActions += 1;
|
|
603
|
+
|
|
604
|
+
// Deterministic path gate for file tools.
|
|
605
|
+
//
|
|
606
|
+
// `deniedPaths` always applies: a matching path is hard-denied before any
|
|
607
|
+
// classifier or fast path, so secrets and system dirs never reach the
|
|
608
|
+
// model. `allowInsideWorkingDirectory` adds a deterministic silent-allow
|
|
609
|
+
// tier for file tools whose resolved path is inside the working
|
|
610
|
+
// directory, and routes outside-CWD file access to the classifier
|
|
611
|
+
// (bypassing the read-only fast path so reads outside the tree are
|
|
612
|
+
// reviewed too).
|
|
613
|
+
//
|
|
614
|
+
// The gate is skipped entirely when both features are off, so the
|
|
615
|
+
// default configuration costs no extra filesystem calls.
|
|
616
|
+
let readOnlyFastPath =
|
|
617
|
+
!askRequiresClassifier &&
|
|
618
|
+
!cfg.classifyReadOnlyTools &&
|
|
619
|
+
READ_ONLY_TOOLS.has(event.toolName);
|
|
620
|
+
if (
|
|
621
|
+
(cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
|
|
622
|
+
PATH_BEARING_TOOLS.has(event.toolName)
|
|
623
|
+
) {
|
|
624
|
+
const inputPath = extractInputPath(event.toolName, input);
|
|
625
|
+
if (inputPath !== undefined) {
|
|
626
|
+
const resolved =
|
|
627
|
+
resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
|
|
628
|
+
inputPath;
|
|
629
|
+
const policyPath = resolvePathForPolicy(resolved) ?? resolved;
|
|
630
|
+
const denied =
|
|
631
|
+
cfg.deniedPaths.length > 0 &&
|
|
632
|
+
(matchesDeniedPath(resolved, cfg.deniedPaths) ||
|
|
633
|
+
matchesDeniedPath(policyPath, cfg.deniedPaths));
|
|
634
|
+
if (denied) {
|
|
635
|
+
return block(ctx, {
|
|
636
|
+
timestamp: Date.now(),
|
|
637
|
+
toolName: event.toolName,
|
|
638
|
+
reason: `Path denied by policy: ${policyPath}`,
|
|
639
|
+
action: summary,
|
|
640
|
+
kind: "deterministic-path-deny",
|
|
641
|
+
}, logCtx);
|
|
642
|
+
}
|
|
643
|
+
let recursiveSearch =
|
|
644
|
+
event.toolName === "grep" || event.toolName === "find";
|
|
645
|
+
if (recursiveSearch) {
|
|
646
|
+
try {
|
|
647
|
+
recursiveSearch = statSync(policyPath).isDirectory();
|
|
648
|
+
} catch {
|
|
649
|
+
// A missing search root will fail in the tool. Treat it as a
|
|
650
|
+
// directory here so a denied scope cannot fail open in a race.
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const deniedSearchScope =
|
|
654
|
+
recursiveSearch &&
|
|
655
|
+
cfg.deniedPaths.length > 0 &&
|
|
656
|
+
(recursiveSearchMayReachDeniedPath(resolved, cfg.deniedPaths) ||
|
|
657
|
+
recursiveSearchMayReachDeniedPath(
|
|
658
|
+
policyPath,
|
|
659
|
+
cfg.deniedPaths,
|
|
660
|
+
));
|
|
661
|
+
if (deniedSearchScope) {
|
|
662
|
+
return block(ctx, {
|
|
663
|
+
timestamp: Date.now(),
|
|
664
|
+
toolName: event.toolName,
|
|
665
|
+
reason: `Search scope can contain a path denied by policy: ${policyPath}`,
|
|
666
|
+
action: summary,
|
|
667
|
+
kind: "deterministic-path-deny",
|
|
668
|
+
}, logCtx);
|
|
669
|
+
}
|
|
670
|
+
if (cfg.allowInsideWorkingDirectory) {
|
|
671
|
+
const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
|
|
672
|
+
if (isInside(policyPath, policyCwd)) {
|
|
673
|
+
// Protected in-tree writes and accepted ask rules must still
|
|
674
|
+
// reach the classifier. They cannot use the inside-CWD tier.
|
|
675
|
+
const protectedWrite =
|
|
676
|
+
(event.toolName === "write" || event.toolName === "edit") &&
|
|
677
|
+
isProtectedPath(policyPath, policyCwd, cfg.protectedPaths);
|
|
678
|
+
if (!askRequiresClassifier && !protectedWrite) {
|
|
679
|
+
return allow(
|
|
680
|
+
ctx,
|
|
681
|
+
"inside-working-directory",
|
|
682
|
+
`Path inside working directory: ${policyPath}`,
|
|
683
|
+
event.toolName,
|
|
684
|
+
summary,
|
|
685
|
+
logCtx,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Outside the working directory, protected writes, and accepted
|
|
690
|
+
// ask rules must not use the read-only fast path.
|
|
691
|
+
readOnlyFastPath = false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Deterministic allow tier. It runs after every deterministic denial.
|
|
697
|
+
// Accepted ask rules skip this tier and always reach the classifier.
|
|
698
|
+
if (!askRequiresClassifier) {
|
|
699
|
+
if (
|
|
700
|
+
matchesAllowedToolPatterns(
|
|
701
|
+
cfg.permissionAllow,
|
|
702
|
+
event.toolName,
|
|
703
|
+
input,
|
|
704
|
+
ctx.cwd,
|
|
705
|
+
bashAnalysis,
|
|
706
|
+
)
|
|
707
|
+
) {
|
|
708
|
+
// A protected-path write/edit is never covered by permissions.allow;
|
|
709
|
+
// it stays on the classifier path (same rule as the inside-CWD tier).
|
|
710
|
+
let protectedWrite = false;
|
|
711
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
712
|
+
const inputPath = extractInputPath(event.toolName, input);
|
|
713
|
+
const resolved = inputPath === undefined
|
|
714
|
+
? undefined
|
|
715
|
+
: resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
|
|
716
|
+
inputPath;
|
|
717
|
+
if (
|
|
718
|
+
resolved !== undefined &&
|
|
719
|
+
isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
|
|
720
|
+
) {
|
|
721
|
+
protectedWrite = true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (!protectedWrite) {
|
|
725
|
+
return allow(
|
|
726
|
+
ctx,
|
|
727
|
+
"permissions.allow",
|
|
728
|
+
"Allowed by permissions.allow",
|
|
729
|
+
event.toolName,
|
|
730
|
+
summary,
|
|
731
|
+
logCtx,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (readOnlyFastPath) {
|
|
738
|
+
return allow(
|
|
739
|
+
ctx,
|
|
740
|
+
"read-only",
|
|
741
|
+
`Read-only built-in tool: ${event.toolName}`,
|
|
742
|
+
event.toolName,
|
|
743
|
+
summary,
|
|
744
|
+
logCtx,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const decision = await classify(
|
|
749
|
+
ctx,
|
|
750
|
+
cfg,
|
|
751
|
+
serializeClassifierAction(event.toolName, input),
|
|
752
|
+
loadedContext,
|
|
753
|
+
);
|
|
754
|
+
logClassifierIo(decision, logCtx);
|
|
755
|
+
if (decision.decision === "allow") {
|
|
756
|
+
state.classifierAllowed += 1;
|
|
757
|
+
return allow(
|
|
758
|
+
ctx,
|
|
759
|
+
"classifier",
|
|
760
|
+
decision.reason,
|
|
761
|
+
event.toolName,
|
|
762
|
+
summary,
|
|
763
|
+
logCtx,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Claude Code-style interactive override: when enabled and a UI is
|
|
768
|
+
// available, a classifier block (any tier, including fail-closed errors)
|
|
769
|
+
// asks the user instead of blocking outright. Approval is a live human
|
|
770
|
+
// decision, never transcript content, so prompt-injection defenses in
|
|
771
|
+
// the classifier prompt still hold. The "always allow" choices persist
|
|
772
|
+
// a permissions.allow rule — exact-match by default, or a user-edited
|
|
773
|
+
// pattern via the custom-rule choices — which by design skips classifier
|
|
774
|
+
// review for future matching actions. Without a UI the block stands.
|
|
775
|
+
if (cfg.interactiveConfirm && ctx.hasUI) {
|
|
776
|
+
const exactRule = allowRuleForAction(event.toolName, input);
|
|
777
|
+
const allowGlobal = exactRule ? `Always allow (global): ${exactRule}` : undefined;
|
|
778
|
+
const allowProject = exactRule ? `Always allow (this project): ${exactRule}` : undefined;
|
|
779
|
+
const normalizedToolName = event.toolName.trim().replace(/^@/, "").toLowerCase();
|
|
780
|
+
const supportsPatterns = normalizedToolName === "bash" || PATH_BEARING_TOOLS.has(normalizedToolName);
|
|
781
|
+
const choiceOptions = [
|
|
782
|
+
CONFIRM_ALLOW_ONCE,
|
|
783
|
+
...(allowGlobal ? [allowGlobal] : []),
|
|
784
|
+
...(allowProject ? [allowProject] : []),
|
|
785
|
+
...(supportsPatterns ? [CONFIRM_CUSTOM_PROJECT, CONFIRM_CUSTOM_GLOBAL] : []),
|
|
786
|
+
CONFIRM_BLOCK,
|
|
787
|
+
];
|
|
788
|
+
let rule: string | undefined;
|
|
789
|
+
let scope: "global" | "project" | undefined;
|
|
790
|
+
let confirmed = false;
|
|
791
|
+
while (!confirmed) {
|
|
792
|
+
const choice = await ctx.ui.select(
|
|
793
|
+
`Auto mode blocked this action (${decision.tier})\n\nReason: ${decision.reason}\n\nAction:\n${summary}`,
|
|
794
|
+
choiceOptions,
|
|
795
|
+
{ signal: ctx.signal },
|
|
796
|
+
);
|
|
797
|
+
if (choice === CONFIRM_ALLOW_ONCE) {
|
|
798
|
+
confirmed = true;
|
|
799
|
+
} else if (choice === allowGlobal && exactRule) {
|
|
800
|
+
confirmed = true;
|
|
801
|
+
rule = exactRule;
|
|
802
|
+
scope = "global";
|
|
803
|
+
} else if (choice === allowProject && exactRule) {
|
|
804
|
+
confirmed = true;
|
|
805
|
+
rule = exactRule;
|
|
806
|
+
scope = "project";
|
|
807
|
+
} else if (choice === CONFIRM_CUSTOM_PROJECT || choice === CONFIRM_CUSTOM_GLOBAL) {
|
|
808
|
+
// The exact rule is the prefill; the user owns any widening, e.g.
|
|
809
|
+
// adding a `*` to `bash(npm test*)`. Wildcards are the user's
|
|
810
|
+
// explicit scope choice, never inferred.
|
|
811
|
+
const edited = await ctx.ui.input(
|
|
812
|
+
"Auto mode allow rule",
|
|
813
|
+
exactRule ?? `${normalizedToolName}(…)`,
|
|
814
|
+
{ signal: ctx.signal },
|
|
815
|
+
);
|
|
816
|
+
const normalized = normalizeEditedAllowRule(event.toolName, edited);
|
|
817
|
+
if (normalized) {
|
|
818
|
+
confirmed = true;
|
|
819
|
+
rule = normalized;
|
|
820
|
+
scope = choice === CONFIRM_CUSTOM_PROJECT ? "project" : "global";
|
|
821
|
+
} else if (edited !== undefined && edited.trim() !== "") {
|
|
822
|
+
ctx.ui.notify(
|
|
823
|
+
"Invalid allow rule; expected a scoped pattern for this tool, e.g. bash(npm test*)",
|
|
824
|
+
"warning",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
// Cancelled or empty input falls through and re-prompts the choice
|
|
828
|
+
// dialog, so an accidental escape cannot silently block.
|
|
829
|
+
} else {
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (confirmed) {
|
|
834
|
+
if (scope !== undefined && rule) {
|
|
835
|
+
try {
|
|
836
|
+
const saved = (options.saveAllowRule ?? persistAllowRule)(
|
|
837
|
+
rule,
|
|
838
|
+
scope,
|
|
839
|
+
ctx.cwd,
|
|
840
|
+
);
|
|
841
|
+
loadResult = loadConfigWithDiagnostics(
|
|
842
|
+
ctx.cwd,
|
|
843
|
+
projectIsTrusted(ctx),
|
|
844
|
+
);
|
|
845
|
+
config = loadResult.config;
|
|
846
|
+
configDiagnostics = loadResult.diagnostics;
|
|
847
|
+
ctx.ui.notify(
|
|
848
|
+
`pi-automode allow rule saved to ${saved.path}: ${rule}${
|
|
849
|
+
saved.added ? "" : " (already present)"
|
|
850
|
+
}`,
|
|
851
|
+
"info",
|
|
852
|
+
);
|
|
853
|
+
if (scope === "project" && !projectIsTrusted(ctx)) {
|
|
854
|
+
// The file is written on explicit user choice, but untrusted
|
|
855
|
+
// project config is never read, so the rule activates only
|
|
856
|
+
// after the project becomes trusted.
|
|
857
|
+
ctx.ui.notify(
|
|
858
|
+
"Project-local allow rules apply once Pi trusts this project",
|
|
859
|
+
"warning",
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
} catch (error) {
|
|
863
|
+
// A failed save must not revoke the user's explicit approval;
|
|
864
|
+
// the action is allowed once and the error is reported.
|
|
865
|
+
ctx.ui.notify(
|
|
866
|
+
`Failed to save pi-automode allow rule: ${
|
|
867
|
+
error instanceof Error ? error.message : String(error)
|
|
868
|
+
}`,
|
|
869
|
+
"error",
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
state.userConfirmed += 1;
|
|
874
|
+
return allow(
|
|
875
|
+
ctx,
|
|
876
|
+
"user-confirmed",
|
|
877
|
+
`User confirmed action blocked by classifier (${decision.tier}): ${decision.reason}${
|
|
878
|
+
scope !== undefined && rule
|
|
879
|
+
? `; saved permissions.allow rule for future actions: ${rule}`
|
|
880
|
+
: ""
|
|
881
|
+
}`,
|
|
882
|
+
event.toolName,
|
|
883
|
+
summary,
|
|
884
|
+
logCtx,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
state.classifierDenied += 1;
|
|
888
|
+
return block(ctx, {
|
|
889
|
+
timestamp: Date.now(),
|
|
890
|
+
toolName: event.toolName,
|
|
891
|
+
reason: `Declined interactive confirmation (${decision.tier}): ${decision.reason}`,
|
|
892
|
+
action: summary,
|
|
893
|
+
kind: "classifier",
|
|
894
|
+
}, logCtx);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
state.classifierDenied += 1;
|
|
898
|
+
return block(ctx, {
|
|
899
|
+
timestamp: Date.now(),
|
|
900
|
+
toolName: event.toolName,
|
|
901
|
+
reason: decision.reason,
|
|
902
|
+
action: summary,
|
|
903
|
+
kind: "classifier",
|
|
904
|
+
}, logCtx);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
pi.registerTool({
|
|
908
|
+
name: INSPECT_TOOL,
|
|
909
|
+
label: "Inspect Auto Mode",
|
|
910
|
+
description:
|
|
911
|
+
"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.",
|
|
912
|
+
promptSnippet:
|
|
913
|
+
"Inspect active pi-automode state and diagnostic information without changing it",
|
|
914
|
+
parameters: Type.Object({
|
|
915
|
+
action: StringEnum(INSPECTION_ACTIONS, {
|
|
916
|
+
description: "The read-only auto-mode view to return",
|
|
917
|
+
}),
|
|
918
|
+
}),
|
|
919
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
920
|
+
if (signal?.aborted) throw new Error("Auto-mode inspection cancelled");
|
|
921
|
+
const result = inspectAutomode(params.action, ctx);
|
|
922
|
+
return {
|
|
923
|
+
content: [{ type: "text", text: safeJson(result, 16000) }],
|
|
924
|
+
details: result,
|
|
925
|
+
};
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
async function handleAutomodeCommand(
|
|
930
|
+
args: string,
|
|
931
|
+
ctx: ExtensionCommandContext,
|
|
932
|
+
): Promise<void> {
|
|
933
|
+
const [command = "status", ...rest] = args
|
|
934
|
+
.trim()
|
|
935
|
+
.split(/\s+/)
|
|
936
|
+
.filter(Boolean);
|
|
937
|
+
const remainder = rest.join(" ").trim();
|
|
938
|
+
|
|
939
|
+
if (command === "status") {
|
|
940
|
+
ctx.ui.notify(statusText(effectiveConfig(), state), "info");
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (command === "on") {
|
|
944
|
+
state.enabledOverride = true;
|
|
945
|
+
persist();
|
|
946
|
+
updateUi(ctx);
|
|
947
|
+
ctx.ui.notify("pi-automode enabled for this session", "info");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (command === "off") {
|
|
951
|
+
state.enabledOverride = false;
|
|
952
|
+
persist();
|
|
953
|
+
updateUi(ctx);
|
|
954
|
+
ctx.ui.notify("pi-automode disabled for this session", "warning");
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (command === "reload") {
|
|
958
|
+
loadResult = loadConfigWithDiagnostics(
|
|
959
|
+
ctx.cwd,
|
|
960
|
+
projectIsTrusted(ctx),
|
|
961
|
+
);
|
|
962
|
+
config = loadResult.config;
|
|
963
|
+
configDiagnostics = loadResult.diagnostics;
|
|
964
|
+
persist();
|
|
965
|
+
updateUi(ctx);
|
|
966
|
+
ctx.ui.notify(
|
|
967
|
+
"pi-automode config reloaded",
|
|
968
|
+
configDiagnostics.length > 0 ? "warning" : "info",
|
|
969
|
+
);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (command === "reset") {
|
|
973
|
+
state = {
|
|
974
|
+
checkedActions: 0,
|
|
975
|
+
blockedActions: 0,
|
|
976
|
+
classifierAllowed: 0,
|
|
977
|
+
classifierDenied: 0,
|
|
978
|
+
userConfirmed: 0,
|
|
979
|
+
recentDenials: [],
|
|
980
|
+
enabledOverride: state.enabledOverride,
|
|
981
|
+
};
|
|
982
|
+
persist();
|
|
983
|
+
updateUi(ctx);
|
|
984
|
+
ctx.ui.notify("pi-automode counters reset", "info");
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (command === "defaults") {
|
|
988
|
+
ctx.ui.notify(
|
|
989
|
+
safeJson(
|
|
990
|
+
{
|
|
991
|
+
environment: DEFAULT_ENVIRONMENT,
|
|
992
|
+
allow: DEFAULT_ALLOW,
|
|
993
|
+
protectedPaths: DEFAULT_PROTECTED_PATHS,
|
|
994
|
+
soft_deny: DEFAULT_SOFT_DENY,
|
|
995
|
+
hard_deny: DEFAULT_HARD_DENY,
|
|
996
|
+
},
|
|
997
|
+
12000,
|
|
998
|
+
),
|
|
999
|
+
"info",
|
|
1000
|
+
);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (command === "config") {
|
|
1004
|
+
const logFile = resolveLogPath(
|
|
1005
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
1006
|
+
ctx.sessionManager.getSessionDir?.() ?? "",
|
|
1007
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
1008
|
+
ctx.cwd,
|
|
1009
|
+
options.logRoot,
|
|
1010
|
+
now(),
|
|
1011
|
+
);
|
|
1012
|
+
ctx.ui.notify(
|
|
1013
|
+
safeJson(
|
|
1014
|
+
{
|
|
1015
|
+
config: effectiveConfig(),
|
|
1016
|
+
logFile,
|
|
1017
|
+
diagnostics: configDiagnostics,
|
|
1018
|
+
},
|
|
1019
|
+
16000,
|
|
1020
|
+
),
|
|
1021
|
+
configDiagnostics.length > 0 ? "warning" : "info",
|
|
1022
|
+
);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (command === "denials") {
|
|
1026
|
+
ctx.ui.notify(
|
|
1027
|
+
formatDenials(state),
|
|
1028
|
+
state.recentDenials.length > 0 ? "warning" : "info",
|
|
1029
|
+
);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (command === "model") {
|
|
1033
|
+
const selected = remainder || await promptForClassifierModel(
|
|
1034
|
+
ctx,
|
|
1035
|
+
effectiveConfig().classifierModel,
|
|
1036
|
+
);
|
|
1037
|
+
if (!selected) {
|
|
1038
|
+
ctx.ui.notify("Classifier model unchanged", "info");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
let modelSpec: string;
|
|
1042
|
+
const jev = isJevClassifierModel(selected);
|
|
1043
|
+
if (jev) {
|
|
1044
|
+
// Jev bypasses Pi's model registry on both transports.
|
|
1045
|
+
if (!(await resolveJevApiKey(ctx, jev.transport))) {
|
|
1046
|
+
ctx.ui.notify(
|
|
1047
|
+
jev.transport === "typesafe"
|
|
1048
|
+
? `${JEV_TYPESAFE_API_KEY_ENV} is not set and no typesafe provider key is registered`
|
|
1049
|
+
: `${JEV_API_KEY_ENV} is not set and no openrouter provider key is registered`,
|
|
1050
|
+
"error",
|
|
1051
|
+
);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
modelSpec = selected;
|
|
1055
|
+
} else {
|
|
1056
|
+
const parsed = parseModelSpec(selected);
|
|
1057
|
+
const model = parsed
|
|
1058
|
+
? ctx.modelRegistry.find(parsed.provider, parsed.id)
|
|
1059
|
+
: undefined;
|
|
1060
|
+
if (!model) {
|
|
1061
|
+
ctx.ui.notify(`Model not found: ${selected}`, "error");
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
1065
|
+
if (!auth.ok) {
|
|
1066
|
+
ctx.ui.notify(auth.error, "error");
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
modelSpec = formatModelSpec(model);
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
saveClassifierModel(modelSpec);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
ctx.ui.notify(
|
|
1075
|
+
`Failed to save classifier model: ${
|
|
1076
|
+
error instanceof Error ? error.message : String(error)
|
|
1077
|
+
}`,
|
|
1078
|
+
"error",
|
|
1079
|
+
);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
loadResult = loadConfigWithDiagnostics(
|
|
1083
|
+
ctx.cwd,
|
|
1084
|
+
projectIsTrusted(ctx),
|
|
1085
|
+
);
|
|
1086
|
+
config = loadResult.config;
|
|
1087
|
+
configDiagnostics = loadResult.diagnostics;
|
|
1088
|
+
persist();
|
|
1089
|
+
updateUi(ctx);
|
|
1090
|
+
const active = effectiveConfig().classifierModel ??
|
|
1091
|
+
"current session model";
|
|
1092
|
+
ctx.ui.notify(
|
|
1093
|
+
active === modelSpec
|
|
1094
|
+
? `pi-automode classifier saved globally: ${modelSpec}`
|
|
1095
|
+
: `pi-automode classifier saved globally: ${modelSpec}; current config uses ${active}`,
|
|
1096
|
+
"info",
|
|
1097
|
+
);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
ctx.ui.notify(
|
|
1102
|
+
"Usage: /automode [status|on|off|reload|reset|defaults|config|denials|model [provider/id]]",
|
|
1103
|
+
"error",
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
pi.registerCommand("automode", {
|
|
1108
|
+
description:
|
|
1109
|
+
"Control pi-automode: status, on, off, reload, reset, defaults, config, denials, model",
|
|
1110
|
+
handler: handleAutomodeCommand,
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
pi.registerCommand("auto-mode", {
|
|
1114
|
+
description: "Alias for /automode",
|
|
1115
|
+
handler: handleAutomodeCommand,
|
|
1116
|
+
});
|
|
1117
|
+
};
|
|
1118
|
+
}
|