@czottmann/pi-automode 1.12.0 → 1.14.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 +27 -0
- package/README.md +6 -4
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +2 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +1 -0
- package/docs/automode-classifier-flow.md +4 -4
- package/docs/configuration.md +20 -4
- package/docs/observability-logging.md +1 -1
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +55 -4
- package/extensions/auto-mode/config.ts +188 -6
- package/extensions/auto-mode/constants.ts +6 -1
- package/extensions/auto-mode/extension.ts +143 -36
- package/extensions/auto-mode/hard-deny.ts +225 -159
- package/extensions/auto-mode/paths.ts +32 -6
- package/extensions/auto-mode/permissions.ts +344 -22
- package/extensions/auto-mode.ts +1 -0
- package/package.json +4 -1
|
@@ -155,6 +155,55 @@ export type ClassifierCompletionPlan = {
|
|
|
155
155
|
reasoningLevel?: Exclude<EffectiveClassifierReasoningLevel, "off">;
|
|
156
156
|
};
|
|
157
157
|
|
|
158
|
+
async function completeClassifierAttempt(
|
|
159
|
+
completeFn: ClassifierCompletionFn,
|
|
160
|
+
model: Model<any>,
|
|
161
|
+
prompt: Parameters<ClassifierCompletionFn>[1],
|
|
162
|
+
parentSignal: AbortSignal | undefined,
|
|
163
|
+
options: Omit<Parameters<ClassifierCompletionFn>[2], "signal">,
|
|
164
|
+
): Promise<AssistantMessage> {
|
|
165
|
+
if (options.timeoutMs === undefined) {
|
|
166
|
+
return completeFn(model, prompt, {
|
|
167
|
+
...options,
|
|
168
|
+
...(parentSignal === undefined ? {} : { signal: parentSignal }),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const onParentAbort = () => controller.abort(parentSignal?.reason);
|
|
174
|
+
if (parentSignal?.aborted) onParentAbort();
|
|
175
|
+
else parentSignal?.addEventListener("abort", onParentAbort, { once: true });
|
|
176
|
+
|
|
177
|
+
let onAbort: (() => void) | undefined;
|
|
178
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
179
|
+
onAbort = () => {
|
|
180
|
+
const reason = controller.signal.reason;
|
|
181
|
+
reject(reason instanceof Error ? reason : new Error("Classifier request aborted."));
|
|
182
|
+
};
|
|
183
|
+
if (controller.signal.aborted) onAbort();
|
|
184
|
+
else controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
});
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
controller.abort(
|
|
188
|
+
new Error(`Classifier request timed out after ${options.timeoutMs} ms.`),
|
|
189
|
+
);
|
|
190
|
+
}, options.timeoutMs);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
return await Promise.race([
|
|
194
|
+
completeFn(model, prompt, {
|
|
195
|
+
...options,
|
|
196
|
+
signal: controller.signal,
|
|
197
|
+
}),
|
|
198
|
+
aborted,
|
|
199
|
+
]);
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
if (onAbort) controller.signal.removeEventListener("abort", onAbort);
|
|
203
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
158
207
|
/**
|
|
159
208
|
* Run normalized Pi AI completion through the provider in Pi's runtime registry.
|
|
160
209
|
* This temporary bridge is only valid until Pi exposes
|
|
@@ -435,14 +484,15 @@ export async function classifyWithRetry(
|
|
|
435
484
|
const started = Date.now();
|
|
436
485
|
let response: AssistantMessage;
|
|
437
486
|
try {
|
|
438
|
-
response = await
|
|
487
|
+
response = await completeClassifierAttempt(
|
|
488
|
+
completeFn,
|
|
439
489
|
classifier.model,
|
|
440
490
|
prompt,
|
|
491
|
+
signal,
|
|
441
492
|
{
|
|
442
493
|
apiKey: classifier.apiKey,
|
|
443
494
|
headers: classifier.headers,
|
|
444
495
|
env: classifier.env,
|
|
445
|
-
signal,
|
|
446
496
|
maxTokens,
|
|
447
497
|
...(temperature === undefined ? {} : { temperature }),
|
|
448
498
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
@@ -505,7 +555,8 @@ export async function classifyInStages(
|
|
|
505
555
|
const fastStarted = Date.now();
|
|
506
556
|
let fastResponse: AssistantMessage;
|
|
507
557
|
try {
|
|
508
|
-
fastResponse = await
|
|
558
|
+
fastResponse = await completeClassifierAttempt(
|
|
559
|
+
completeFn,
|
|
509
560
|
classifier.model,
|
|
510
561
|
{
|
|
511
562
|
systemPrompt: prompt.systemPrompt,
|
|
@@ -515,11 +566,11 @@ export async function classifyInStages(
|
|
|
515
566
|
stageMessage(CLASSIFIER_FAST_INSTRUCTION),
|
|
516
567
|
],
|
|
517
568
|
},
|
|
569
|
+
signal,
|
|
518
570
|
{
|
|
519
571
|
apiKey: classifier.apiKey,
|
|
520
572
|
headers: classifier.headers,
|
|
521
573
|
env: classifier.env,
|
|
522
|
-
signal,
|
|
523
574
|
// Reasoning and OpenAI-compatible models may consume hidden reasoning,
|
|
524
575
|
// control, and EOS tokens before emitting the required visible digit.
|
|
525
576
|
maxTokens: options.fastClassifierMaxTokens ??
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
linkSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import { dirname, resolve } from "node:path";
|
|
3
11
|
import {
|
|
4
12
|
DEFAULT_ALLOW,
|
|
@@ -14,7 +22,9 @@ import {
|
|
|
14
22
|
DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
15
23
|
DEFAULT_PROTECTED_PATHS,
|
|
16
24
|
DEFAULT_SOFT_DENY,
|
|
25
|
+
MAX_CLASSIFIER_TIMEOUT_MS,
|
|
17
26
|
PI_GLOBAL_SETTINGS,
|
|
27
|
+
PI_LEGACY_GLOBAL_SETTINGS,
|
|
18
28
|
PI_PROJECT_LOCAL_SETTINGS,
|
|
19
29
|
PI_PROJECT_SHARED_SETTINGS,
|
|
20
30
|
} from "./constants.ts";
|
|
@@ -35,6 +45,169 @@ import type {
|
|
|
35
45
|
} from "./types.ts";
|
|
36
46
|
import { hasOwn, stringArray } from "./utils.ts";
|
|
37
47
|
|
|
48
|
+
export type GlobalConfigPreparation = {
|
|
49
|
+
status: "current" | "migrated" | "conflict" | "failed";
|
|
50
|
+
activePath: string;
|
|
51
|
+
diagnostic?: string;
|
|
52
|
+
notification?: string;
|
|
53
|
+
writeBlockedReason?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type PrepareGlobalConfigOptions = {
|
|
57
|
+
currentPath?: string;
|
|
58
|
+
legacyPath?: string;
|
|
59
|
+
moveFile?: (source: string, destination: string) => void;
|
|
60
|
+
unlinkFile?: (path: string) => void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function sameFileIdentity(firstPath: string, secondPath: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
const first = lstatSync(firstPath);
|
|
66
|
+
const second = lstatSync(secondPath);
|
|
67
|
+
return first.dev === second.dev && first.ino === second.ino;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cleanupPublishedDestination(
|
|
74
|
+
destination: string,
|
|
75
|
+
unlinkFile: (path: string) => void,
|
|
76
|
+
originalError: unknown,
|
|
77
|
+
): AggregateError | undefined {
|
|
78
|
+
try {
|
|
79
|
+
unlinkFile(destination);
|
|
80
|
+
return undefined;
|
|
81
|
+
} catch (cleanupError) {
|
|
82
|
+
return new AggregateError(
|
|
83
|
+
[originalError, cleanupError],
|
|
84
|
+
`Could not clean up interrupted Auto Mode config migration at ${destination}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function moveFileWithoutOverwrite(
|
|
90
|
+
source: string,
|
|
91
|
+
destination: string,
|
|
92
|
+
unlinkFile: (path: string) => void,
|
|
93
|
+
): void {
|
|
94
|
+
linkSync(source, destination);
|
|
95
|
+
try {
|
|
96
|
+
unlinkFile(source);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw cleanupPublishedDestination(destination, unlinkFile, error) ?? error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function globalConfigFailure(
|
|
103
|
+
currentPath: string,
|
|
104
|
+
legacyPath: string,
|
|
105
|
+
error: unknown,
|
|
106
|
+
writeBlockedReason?: string,
|
|
107
|
+
): GlobalConfigPreparation {
|
|
108
|
+
const message =
|
|
109
|
+
`Could not move Auto Mode config from ${legacyPath} to ${currentPath}: ${
|
|
110
|
+
error instanceof Error ? error.message : String(error)
|
|
111
|
+
}. Using the legacy config for this session${
|
|
112
|
+
writeBlockedReason ? "; global config writes are disabled" : ""
|
|
113
|
+
}.`;
|
|
114
|
+
return {
|
|
115
|
+
status: "failed",
|
|
116
|
+
activePath: legacyPath,
|
|
117
|
+
diagnostic: message,
|
|
118
|
+
notification: message,
|
|
119
|
+
writeBlockedReason,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function globalConfigConflict(
|
|
124
|
+
currentPath: string,
|
|
125
|
+
legacyPath: string,
|
|
126
|
+
): GlobalConfigPreparation {
|
|
127
|
+
const message =
|
|
128
|
+
`Auto Mode config conflict: using ${currentPath}; legacy config ${legacyPath} is ignored and was not changed.`;
|
|
129
|
+
return {
|
|
130
|
+
status: "conflict",
|
|
131
|
+
activePath: currentPath,
|
|
132
|
+
diagnostic: message,
|
|
133
|
+
notification: message,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Select one global config path for this runtime and migrate a legacy file when possible. */
|
|
138
|
+
export function prepareGlobalConfig(
|
|
139
|
+
options: PrepareGlobalConfigOptions = {},
|
|
140
|
+
): GlobalConfigPreparation {
|
|
141
|
+
const currentPath = options.currentPath ?? PI_GLOBAL_SETTINGS[0];
|
|
142
|
+
const legacyPath = options.legacyPath ?? PI_LEGACY_GLOBAL_SETTINGS;
|
|
143
|
+
const currentExists = existsSync(currentPath);
|
|
144
|
+
const legacyExists = existsSync(legacyPath);
|
|
145
|
+
const unlinkFile = options.unlinkFile ?? unlinkSync;
|
|
146
|
+
|
|
147
|
+
if (currentExists && legacyExists) {
|
|
148
|
+
if (!sameFileIdentity(currentPath, legacyPath)) {
|
|
149
|
+
return globalConfigConflict(currentPath, legacyPath);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
unlinkFile(legacyPath);
|
|
153
|
+
return {
|
|
154
|
+
status: "migrated",
|
|
155
|
+
activePath: currentPath,
|
|
156
|
+
notification:
|
|
157
|
+
`Completed interrupted Auto Mode config migration from ${legacyPath} to ${currentPath}.`,
|
|
158
|
+
};
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const cleanupError = cleanupPublishedDestination(
|
|
161
|
+
currentPath,
|
|
162
|
+
unlinkFile,
|
|
163
|
+
error,
|
|
164
|
+
);
|
|
165
|
+
return globalConfigFailure(
|
|
166
|
+
currentPath,
|
|
167
|
+
legacyPath,
|
|
168
|
+
cleanupError ?? error,
|
|
169
|
+
cleanupError?.message,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (currentExists || !legacyExists) {
|
|
174
|
+
return { status: "current", activePath: currentPath };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(dirname(currentPath), { recursive: true });
|
|
179
|
+
const moveFile = options.moveFile ??
|
|
180
|
+
((source: string, destination: string) =>
|
|
181
|
+
moveFileWithoutOverwrite(source, destination, unlinkFile));
|
|
182
|
+
moveFile(legacyPath, currentPath);
|
|
183
|
+
return {
|
|
184
|
+
status: "migrated",
|
|
185
|
+
activePath: currentPath,
|
|
186
|
+
notification: `Moved Auto Mode config from ${legacyPath} to ${currentPath}.`,
|
|
187
|
+
};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (existsSync(currentPath)) {
|
|
190
|
+
if (!sameFileIdentity(currentPath, legacyPath)) {
|
|
191
|
+
return globalConfigConflict(currentPath, legacyPath);
|
|
192
|
+
}
|
|
193
|
+
const cleanupError = cleanupPublishedDestination(
|
|
194
|
+
currentPath,
|
|
195
|
+
unlinkFile,
|
|
196
|
+
error,
|
|
197
|
+
);
|
|
198
|
+
if (cleanupError) {
|
|
199
|
+
return globalConfigFailure(
|
|
200
|
+
currentPath,
|
|
201
|
+
legacyPath,
|
|
202
|
+
cleanupError,
|
|
203
|
+
cleanupError.message,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return globalConfigFailure(currentPath, legacyPath, error);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
38
211
|
function readSettingsFile(path: string): LoadedSettingsFile | undefined {
|
|
39
212
|
if (!existsSync(path)) return undefined;
|
|
40
213
|
try {
|
|
@@ -152,10 +325,11 @@ export function validateSettingsFile(
|
|
|
152
325
|
if (
|
|
153
326
|
hasOwn(autoMode, "classifierTimeoutMs") &&
|
|
154
327
|
(!Number.isInteger(autoMode.classifierTimeoutMs) ||
|
|
155
|
-
(autoMode.classifierTimeoutMs as number) < 1000
|
|
328
|
+
(autoMode.classifierTimeoutMs as number) < 1000 ||
|
|
329
|
+
(autoMode.classifierTimeoutMs as number) > MAX_CLASSIFIER_TIMEOUT_MS)
|
|
156
330
|
) {
|
|
157
331
|
diagnostics.push(
|
|
158
|
-
`${source}: autoMode.classifierTimeoutMs must be an integer
|
|
332
|
+
`${source}: autoMode.classifierTimeoutMs must be an integer from 1000 through ${MAX_CLASSIFIER_TIMEOUT_MS}`,
|
|
159
333
|
);
|
|
160
334
|
}
|
|
161
335
|
if (
|
|
@@ -419,7 +593,9 @@ function validFastClassifierBudget(value: unknown): value is number {
|
|
|
419
593
|
}
|
|
420
594
|
|
|
421
595
|
function validClassifierTimeout(value: unknown): value is number {
|
|
422
|
-
return Number.isInteger(value) &&
|
|
596
|
+
return Number.isInteger(value) &&
|
|
597
|
+
Number(value) >= 1000 &&
|
|
598
|
+
Number(value) <= MAX_CLASSIFIER_TIMEOUT_MS;
|
|
423
599
|
}
|
|
424
600
|
|
|
425
601
|
function applyAutoModeScalars(
|
|
@@ -611,6 +787,7 @@ function ignoredSharedAllowDiagnostics(
|
|
|
611
787
|
export function loadEffectiveConfigWithDiagnostics(
|
|
612
788
|
cwd: string,
|
|
613
789
|
projectTrusted = false,
|
|
790
|
+
globalSettingsPath = PI_GLOBAL_SETTINGS[0],
|
|
614
791
|
): ConfigLoadResult {
|
|
615
792
|
const inlineSettings: SettingsFile[] = [];
|
|
616
793
|
const diagnostics: string[] = [];
|
|
@@ -632,7 +809,7 @@ export function loadEffectiveConfigWithDiagnostics(
|
|
|
632
809
|
}
|
|
633
810
|
}
|
|
634
811
|
|
|
635
|
-
const globalFiles =
|
|
812
|
+
const globalFiles = [readSettingsFile(globalSettingsPath)];
|
|
636
813
|
const projectLocalPaths = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
|
|
637
814
|
resolve(cwd, file)
|
|
638
815
|
);
|
|
@@ -682,8 +859,13 @@ export function loadEffectiveConfigWithDiagnostics(
|
|
|
682
859
|
export function loadEffectiveConfig(
|
|
683
860
|
cwd: string,
|
|
684
861
|
projectTrusted = false,
|
|
862
|
+
globalSettingsPath = PI_GLOBAL_SETTINGS[0],
|
|
685
863
|
): EffectiveConfig {
|
|
686
|
-
return loadEffectiveConfigWithDiagnostics(
|
|
864
|
+
return loadEffectiveConfigWithDiagnostics(
|
|
865
|
+
cwd,
|
|
866
|
+
projectTrusted,
|
|
867
|
+
globalSettingsPath,
|
|
868
|
+
).config;
|
|
687
869
|
}
|
|
688
870
|
|
|
689
871
|
function readWritableSettingsFile(path: string): SettingsFile {
|
|
@@ -61,6 +61,8 @@ export const DENIAL_HISTORY_LIMIT = 12;
|
|
|
61
61
|
|
|
62
62
|
/** Per-request timeout for classifier completions (fast and detailed stages). */
|
|
63
63
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 20_000;
|
|
64
|
+
/** Largest timeout that Node can represent without reducing it to 1 ms. */
|
|
65
|
+
export const MAX_CLASSIFIER_TIMEOUT_MS = 2_147_483_647;
|
|
64
66
|
|
|
65
67
|
/** Built-in trusted environment. Users extend this with `$defaults`. */
|
|
66
68
|
export const DEFAULT_ENVIRONMENT = [
|
|
@@ -164,7 +166,10 @@ Valid decision/tier combinations:
|
|
|
164
166
|
- block: hard_deny, soft_deny, or none
|
|
165
167
|
If an allow exception or explicit user intent overrides a soft-deny rule, return allow with tier allow or explicit_intent, never soft_deny.`;
|
|
166
168
|
|
|
167
|
-
export const PI_GLOBAL_SETTINGS = [
|
|
169
|
+
export const PI_GLOBAL_SETTINGS = [
|
|
170
|
+
resolve(HOME, ".pi/agent/extensions/pi-automode/config.json"),
|
|
171
|
+
];
|
|
172
|
+
export const PI_LEGACY_GLOBAL_SETTINGS = resolve(HOME, ".pi/agent/automode.json");
|
|
168
173
|
export const PI_PROJECT_LOCAL_SETTINGS = [".pi/automode.local.json"];
|
|
169
174
|
export const PI_PROJECT_SHARED_SETTINGS = [".pi/automode.json"];
|
|
170
175
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
defaultClassifyAction,
|
|
14
14
|
serializeClassifierAction,
|
|
15
15
|
} from "./classifier.ts";
|
|
16
|
+
import { analyzeBash, type BashAnalysis } from "./bash.ts";
|
|
16
17
|
import {
|
|
17
18
|
AUTO_MODE_GUIDANCE,
|
|
18
19
|
DEFAULT_ALLOW,
|
|
@@ -21,10 +22,13 @@ import {
|
|
|
21
22
|
DEFAULT_PROTECTED_PATHS,
|
|
22
23
|
DEFAULT_SOFT_DENY,
|
|
23
24
|
PATH_BEARING_TOOLS,
|
|
25
|
+
PI_GLOBAL_SETTINGS,
|
|
24
26
|
READ_ONLY_TOOLS,
|
|
25
27
|
} from "./constants.ts";
|
|
26
28
|
import {
|
|
29
|
+
type GlobalConfigPreparation,
|
|
27
30
|
loadEffectiveConfigWithDiagnostics,
|
|
31
|
+
prepareGlobalConfig,
|
|
28
32
|
writeGlobalClassifierModel,
|
|
29
33
|
} from "./config.ts";
|
|
30
34
|
import { deterministicHardDeny } from "./hard-deny.ts";
|
|
@@ -37,8 +41,10 @@ import {
|
|
|
37
41
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
38
42
|
import { promptForClassifierModel } from "./model-selector.ts";
|
|
39
43
|
import {
|
|
44
|
+
matchesAllowedToolPatterns,
|
|
40
45
|
matchesDeniedPath,
|
|
41
46
|
matchesToolPattern,
|
|
47
|
+
matchingBashCommandText,
|
|
42
48
|
recursiveSearchMayReachDeniedPath,
|
|
43
49
|
} from "./permissions.ts";
|
|
44
50
|
import {
|
|
@@ -67,12 +73,16 @@ import type {
|
|
|
67
73
|
DenialRecord,
|
|
68
74
|
EffectiveConfig,
|
|
69
75
|
} from "./types.ts";
|
|
70
|
-
import { safeJson } from "./utils.ts";
|
|
76
|
+
import { safeJson, truncateMiddle } from "./utils.ts";
|
|
71
77
|
|
|
72
78
|
const INSPECT_TOOL = "automode_inspect";
|
|
73
79
|
const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
|
|
74
80
|
type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
|
|
75
81
|
|
|
82
|
+
function matchedCommandSummary(command: string | undefined): string | undefined {
|
|
83
|
+
return command ? truncateMiddle(command, 500) : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
function canonicalPath(path: string): string {
|
|
77
87
|
try {
|
|
78
88
|
return realpathSync(path);
|
|
@@ -102,12 +112,16 @@ export type PiAutomodeOptions = {
|
|
|
102
112
|
loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
|
|
103
113
|
/** Override classifier calls in tests so unit tests never need a real LLM/API key. */
|
|
104
114
|
classifyAction?: ClassifyAction;
|
|
105
|
-
/** Override classifier-model persistence in tests. Runtime code writes
|
|
115
|
+
/** Override classifier-model persistence in tests. Runtime code writes the active global config. */
|
|
106
116
|
saveClassifierModel?: (classifierModel: string) => void;
|
|
117
|
+
/** Override global config migration and path selection in tests. */
|
|
118
|
+
prepareGlobalConfig?: () => GlobalConfigPreparation;
|
|
107
119
|
/** Override the application-owned observability log root in tests. */
|
|
108
120
|
logRoot?: string;
|
|
109
121
|
/** Override the observability log clock in tests. */
|
|
110
122
|
now?: () => Date;
|
|
123
|
+
/** Override Bash analysis in tests. Runtime code uses unbash. */
|
|
124
|
+
analyzeBash?: typeof analyzeBash;
|
|
111
125
|
};
|
|
112
126
|
|
|
113
127
|
type LogCtx = {
|
|
@@ -157,18 +171,43 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
|
157
171
|
|
|
158
172
|
/** Create a Pi extension instance. Default export uses production dependencies. */
|
|
159
173
|
export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
160
|
-
const loadConfigWithDiagnostics = options.loadConfig
|
|
161
|
-
? (cwd: string, projectTrusted: boolean): ConfigLoadResult => ({
|
|
162
|
-
config: options.loadConfig!(cwd, projectTrusted),
|
|
163
|
-
diagnostics: [],
|
|
164
|
-
})
|
|
165
|
-
: loadEffectiveConfigWithDiagnostics;
|
|
166
174
|
const classify = options.classifyAction ?? defaultClassifyAction;
|
|
167
|
-
const saveClassifierModel = options.saveClassifierModel ??
|
|
168
|
-
writeGlobalClassifierModel;
|
|
169
175
|
const now = options.now ?? (() => new Date());
|
|
170
176
|
|
|
171
177
|
return function piAutomode(pi: ExtensionAPI) {
|
|
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;
|
|
172
211
|
let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
|
|
173
212
|
let config: EffectiveConfig = loadResult.config;
|
|
174
213
|
let configDiagnostics: string[] = loadResult.diagnostics;
|
|
@@ -180,6 +219,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
180
219
|
recentDenials: [],
|
|
181
220
|
};
|
|
182
221
|
let loadedContext = "";
|
|
222
|
+
let globalConfigNoticeShown = false;
|
|
183
223
|
|
|
184
224
|
function effectiveConfig(): EffectiveConfig {
|
|
185
225
|
return {
|
|
@@ -227,6 +267,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
227
267
|
`classifier denied: ${state.classifierDenied}`,
|
|
228
268
|
`permissions.deny rules: ${cfg.permissionDeny.length}`,
|
|
229
269
|
`permissions.ask rules: ${cfg.permissionAsk.length}`,
|
|
270
|
+
`permissions.allow rules: ${cfg.permissionAllow.length}`,
|
|
230
271
|
`environment entries: ${cfg.environment.length}`,
|
|
231
272
|
`allow entries: ${cfg.allow.length}`,
|
|
232
273
|
`soft_deny entries: ${cfg.softDeny.length}`,
|
|
@@ -357,6 +398,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
357
398
|
config = loadResult.config;
|
|
358
399
|
configDiagnostics = loadResult.diagnostics;
|
|
359
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
|
+
}
|
|
360
412
|
updateUi(ctx);
|
|
361
413
|
});
|
|
362
414
|
|
|
@@ -385,6 +437,27 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
385
437
|
const isOwnedInspection = event.toolName === INSPECT_TOOL &&
|
|
386
438
|
ownsInspectionTool();
|
|
387
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
|
+
}
|
|
388
461
|
const summary = actionSummary(event.toolName, input);
|
|
389
462
|
if (!isOwnedInspection) state.checkedActions += 1;
|
|
390
463
|
const logCtx: LogCtx = {
|
|
@@ -404,12 +477,26 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
404
477
|
};
|
|
405
478
|
|
|
406
479
|
for (const pattern of cfg.permissionDeny) {
|
|
407
|
-
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
|
+
);
|
|
408
493
|
if (isOwnedInspection) state.checkedActions += 1;
|
|
409
494
|
return block(ctx, {
|
|
410
495
|
timestamp: Date.now(),
|
|
411
496
|
toolName: event.toolName,
|
|
412
|
-
reason: `Blocked by permissions.deny: ${pattern.raw}
|
|
497
|
+
reason: `Blocked by permissions.deny: ${pattern.raw}${
|
|
498
|
+
matchedCommand ? `; matched command: ${matchedCommand}` : ""
|
|
499
|
+
}`,
|
|
413
500
|
action: summary,
|
|
414
501
|
kind: "permissions.deny",
|
|
415
502
|
}, logCtx);
|
|
@@ -418,16 +505,30 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
418
505
|
|
|
419
506
|
let askRequiresClassifier = false;
|
|
420
507
|
for (const pattern of cfg.permissionAsk) {
|
|
421
|
-
if (
|
|
508
|
+
if (
|
|
509
|
+
!matchesToolPattern(
|
|
510
|
+
pattern,
|
|
511
|
+
event.toolName,
|
|
512
|
+
input,
|
|
513
|
+
ctx.cwd,
|
|
514
|
+
"match",
|
|
515
|
+
bashAnalysis,
|
|
516
|
+
)
|
|
517
|
+
) {
|
|
422
518
|
continue;
|
|
423
519
|
}
|
|
424
520
|
if (!ctx.hasUI) {
|
|
521
|
+
const matchedCommand = matchedCommandSummary(
|
|
522
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
523
|
+
);
|
|
425
524
|
if (isOwnedInspection) state.checkedActions += 1;
|
|
426
525
|
return block(ctx, {
|
|
427
526
|
timestamp: Date.now(),
|
|
428
527
|
toolName: event.toolName,
|
|
429
528
|
reason:
|
|
430
|
-
`Matched permissions.ask (${pattern.raw})
|
|
529
|
+
`Matched permissions.ask (${pattern.raw})${
|
|
530
|
+
matchedCommand ? ` for command: ${matchedCommand}` : ""
|
|
531
|
+
} but no UI is available`,
|
|
431
532
|
action: summary,
|
|
432
533
|
kind: "permissions.ask",
|
|
433
534
|
}, logCtx);
|
|
@@ -438,11 +539,16 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
438
539
|
{ signal: ctx.signal },
|
|
439
540
|
);
|
|
440
541
|
if (!allowed) {
|
|
542
|
+
const matchedCommand = matchedCommandSummary(
|
|
543
|
+
matchingBashCommandText(pattern, bashAnalysis),
|
|
544
|
+
);
|
|
441
545
|
if (isOwnedInspection) state.checkedActions += 1;
|
|
442
546
|
return block(ctx, {
|
|
443
547
|
timestamp: Date.now(),
|
|
444
548
|
toolName: event.toolName,
|
|
445
|
-
reason: `Declined permissions.ask: ${pattern.raw}
|
|
549
|
+
reason: `Declined permissions.ask: ${pattern.raw}${
|
|
550
|
+
matchedCommand ? `; matched command: ${matchedCommand}` : ""
|
|
551
|
+
}`,
|
|
446
552
|
action: summary,
|
|
447
553
|
kind: "permissions.ask",
|
|
448
554
|
}, logCtx);
|
|
@@ -454,6 +560,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
454
560
|
event.toolName,
|
|
455
561
|
input,
|
|
456
562
|
ctx.cwd,
|
|
563
|
+
bashAnalysis,
|
|
457
564
|
);
|
|
458
565
|
if (deterministicReason) {
|
|
459
566
|
if (isOwnedInspection) state.checkedActions += 1;
|
|
@@ -564,20 +671,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
564
671
|
// Deterministic allow tier. It runs after every deterministic denial.
|
|
565
672
|
// Accepted ask rules skip this tier and always reach the classifier.
|
|
566
673
|
if (!askRequiresClassifier) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
) {
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
674
|
+
if (
|
|
675
|
+
matchesAllowedToolPatterns(
|
|
676
|
+
cfg.permissionAllow,
|
|
677
|
+
event.toolName,
|
|
678
|
+
input,
|
|
679
|
+
ctx.cwd,
|
|
680
|
+
bashAnalysis,
|
|
681
|
+
)
|
|
682
|
+
) {
|
|
579
683
|
// A protected-path write/edit is never covered by permissions.allow;
|
|
580
684
|
// it stays on the classifier path (same rule as the inside-CWD tier).
|
|
685
|
+
let protectedWrite = false;
|
|
581
686
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
582
687
|
const inputPath = extractInputPath(event.toolName, input);
|
|
583
688
|
const resolved = inputPath === undefined
|
|
@@ -588,17 +693,19 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
588
693
|
resolved !== undefined &&
|
|
589
694
|
isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
|
|
590
695
|
) {
|
|
591
|
-
|
|
696
|
+
protectedWrite = true;
|
|
592
697
|
}
|
|
593
698
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
}
|
|
602
709
|
}
|
|
603
710
|
}
|
|
604
711
|
|