@czottmann/pi-automode 1.12.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 +15 -0
- package/README.md +5 -3
- 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/config.ts +181 -3
- package/extensions/auto-mode/constants.ts +4 -1
- package/extensions/auto-mode/extension.ts +143 -36
- package/extensions/auto-mode/hard-deny.ts +103 -148
- 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
|
@@ -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,
|
|
@@ -15,6 +23,7 @@ import {
|
|
|
15
23
|
DEFAULT_PROTECTED_PATHS,
|
|
16
24
|
DEFAULT_SOFT_DENY,
|
|
17
25
|
PI_GLOBAL_SETTINGS,
|
|
26
|
+
PI_LEGACY_GLOBAL_SETTINGS,
|
|
18
27
|
PI_PROJECT_LOCAL_SETTINGS,
|
|
19
28
|
PI_PROJECT_SHARED_SETTINGS,
|
|
20
29
|
} from "./constants.ts";
|
|
@@ -35,6 +44,169 @@ import type {
|
|
|
35
44
|
} from "./types.ts";
|
|
36
45
|
import { hasOwn, stringArray } from "./utils.ts";
|
|
37
46
|
|
|
47
|
+
export type GlobalConfigPreparation = {
|
|
48
|
+
status: "current" | "migrated" | "conflict" | "failed";
|
|
49
|
+
activePath: string;
|
|
50
|
+
diagnostic?: string;
|
|
51
|
+
notification?: string;
|
|
52
|
+
writeBlockedReason?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type PrepareGlobalConfigOptions = {
|
|
56
|
+
currentPath?: string;
|
|
57
|
+
legacyPath?: string;
|
|
58
|
+
moveFile?: (source: string, destination: string) => void;
|
|
59
|
+
unlinkFile?: (path: string) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function sameFileIdentity(firstPath: string, secondPath: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const first = lstatSync(firstPath);
|
|
65
|
+
const second = lstatSync(secondPath);
|
|
66
|
+
return first.dev === second.dev && first.ino === second.ino;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cleanupPublishedDestination(
|
|
73
|
+
destination: string,
|
|
74
|
+
unlinkFile: (path: string) => void,
|
|
75
|
+
originalError: unknown,
|
|
76
|
+
): AggregateError | undefined {
|
|
77
|
+
try {
|
|
78
|
+
unlinkFile(destination);
|
|
79
|
+
return undefined;
|
|
80
|
+
} catch (cleanupError) {
|
|
81
|
+
return new AggregateError(
|
|
82
|
+
[originalError, cleanupError],
|
|
83
|
+
`Could not clean up interrupted Auto Mode config migration at ${destination}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function moveFileWithoutOverwrite(
|
|
89
|
+
source: string,
|
|
90
|
+
destination: string,
|
|
91
|
+
unlinkFile: (path: string) => void,
|
|
92
|
+
): void {
|
|
93
|
+
linkSync(source, destination);
|
|
94
|
+
try {
|
|
95
|
+
unlinkFile(source);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw cleanupPublishedDestination(destination, unlinkFile, error) ?? error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function globalConfigFailure(
|
|
102
|
+
currentPath: string,
|
|
103
|
+
legacyPath: string,
|
|
104
|
+
error: unknown,
|
|
105
|
+
writeBlockedReason?: string,
|
|
106
|
+
): GlobalConfigPreparation {
|
|
107
|
+
const message =
|
|
108
|
+
`Could not move Auto Mode config from ${legacyPath} to ${currentPath}: ${
|
|
109
|
+
error instanceof Error ? error.message : String(error)
|
|
110
|
+
}. Using the legacy config for this session${
|
|
111
|
+
writeBlockedReason ? "; global config writes are disabled" : ""
|
|
112
|
+
}.`;
|
|
113
|
+
return {
|
|
114
|
+
status: "failed",
|
|
115
|
+
activePath: legacyPath,
|
|
116
|
+
diagnostic: message,
|
|
117
|
+
notification: message,
|
|
118
|
+
writeBlockedReason,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function globalConfigConflict(
|
|
123
|
+
currentPath: string,
|
|
124
|
+
legacyPath: string,
|
|
125
|
+
): GlobalConfigPreparation {
|
|
126
|
+
const message =
|
|
127
|
+
`Auto Mode config conflict: using ${currentPath}; legacy config ${legacyPath} is ignored and was not changed.`;
|
|
128
|
+
return {
|
|
129
|
+
status: "conflict",
|
|
130
|
+
activePath: currentPath,
|
|
131
|
+
diagnostic: message,
|
|
132
|
+
notification: message,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Select one global config path for this runtime and migrate a legacy file when possible. */
|
|
137
|
+
export function prepareGlobalConfig(
|
|
138
|
+
options: PrepareGlobalConfigOptions = {},
|
|
139
|
+
): GlobalConfigPreparation {
|
|
140
|
+
const currentPath = options.currentPath ?? PI_GLOBAL_SETTINGS[0];
|
|
141
|
+
const legacyPath = options.legacyPath ?? PI_LEGACY_GLOBAL_SETTINGS;
|
|
142
|
+
const currentExists = existsSync(currentPath);
|
|
143
|
+
const legacyExists = existsSync(legacyPath);
|
|
144
|
+
const unlinkFile = options.unlinkFile ?? unlinkSync;
|
|
145
|
+
|
|
146
|
+
if (currentExists && legacyExists) {
|
|
147
|
+
if (!sameFileIdentity(currentPath, legacyPath)) {
|
|
148
|
+
return globalConfigConflict(currentPath, legacyPath);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
unlinkFile(legacyPath);
|
|
152
|
+
return {
|
|
153
|
+
status: "migrated",
|
|
154
|
+
activePath: currentPath,
|
|
155
|
+
notification:
|
|
156
|
+
`Completed interrupted Auto Mode config migration from ${legacyPath} to ${currentPath}.`,
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const cleanupError = cleanupPublishedDestination(
|
|
160
|
+
currentPath,
|
|
161
|
+
unlinkFile,
|
|
162
|
+
error,
|
|
163
|
+
);
|
|
164
|
+
return globalConfigFailure(
|
|
165
|
+
currentPath,
|
|
166
|
+
legacyPath,
|
|
167
|
+
cleanupError ?? error,
|
|
168
|
+
cleanupError?.message,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (currentExists || !legacyExists) {
|
|
173
|
+
return { status: "current", activePath: currentPath };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(dirname(currentPath), { recursive: true });
|
|
178
|
+
const moveFile = options.moveFile ??
|
|
179
|
+
((source: string, destination: string) =>
|
|
180
|
+
moveFileWithoutOverwrite(source, destination, unlinkFile));
|
|
181
|
+
moveFile(legacyPath, currentPath);
|
|
182
|
+
return {
|
|
183
|
+
status: "migrated",
|
|
184
|
+
activePath: currentPath,
|
|
185
|
+
notification: `Moved Auto Mode config from ${legacyPath} to ${currentPath}.`,
|
|
186
|
+
};
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (existsSync(currentPath)) {
|
|
189
|
+
if (!sameFileIdentity(currentPath, legacyPath)) {
|
|
190
|
+
return globalConfigConflict(currentPath, legacyPath);
|
|
191
|
+
}
|
|
192
|
+
const cleanupError = cleanupPublishedDestination(
|
|
193
|
+
currentPath,
|
|
194
|
+
unlinkFile,
|
|
195
|
+
error,
|
|
196
|
+
);
|
|
197
|
+
if (cleanupError) {
|
|
198
|
+
return globalConfigFailure(
|
|
199
|
+
currentPath,
|
|
200
|
+
legacyPath,
|
|
201
|
+
cleanupError,
|
|
202
|
+
cleanupError.message,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return globalConfigFailure(currentPath, legacyPath, error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
38
210
|
function readSettingsFile(path: string): LoadedSettingsFile | undefined {
|
|
39
211
|
if (!existsSync(path)) return undefined;
|
|
40
212
|
try {
|
|
@@ -611,6 +783,7 @@ function ignoredSharedAllowDiagnostics(
|
|
|
611
783
|
export function loadEffectiveConfigWithDiagnostics(
|
|
612
784
|
cwd: string,
|
|
613
785
|
projectTrusted = false,
|
|
786
|
+
globalSettingsPath = PI_GLOBAL_SETTINGS[0],
|
|
614
787
|
): ConfigLoadResult {
|
|
615
788
|
const inlineSettings: SettingsFile[] = [];
|
|
616
789
|
const diagnostics: string[] = [];
|
|
@@ -632,7 +805,7 @@ export function loadEffectiveConfigWithDiagnostics(
|
|
|
632
805
|
}
|
|
633
806
|
}
|
|
634
807
|
|
|
635
|
-
const globalFiles =
|
|
808
|
+
const globalFiles = [readSettingsFile(globalSettingsPath)];
|
|
636
809
|
const projectLocalPaths = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
|
|
637
810
|
resolve(cwd, file)
|
|
638
811
|
);
|
|
@@ -682,8 +855,13 @@ export function loadEffectiveConfigWithDiagnostics(
|
|
|
682
855
|
export function loadEffectiveConfig(
|
|
683
856
|
cwd: string,
|
|
684
857
|
projectTrusted = false,
|
|
858
|
+
globalSettingsPath = PI_GLOBAL_SETTINGS[0],
|
|
685
859
|
): EffectiveConfig {
|
|
686
|
-
return loadEffectiveConfigWithDiagnostics(
|
|
860
|
+
return loadEffectiveConfigWithDiagnostics(
|
|
861
|
+
cwd,
|
|
862
|
+
projectTrusted,
|
|
863
|
+
globalSettingsPath,
|
|
864
|
+
).config;
|
|
687
865
|
}
|
|
688
866
|
|
|
689
867
|
function readWritableSettingsFile(path: string): SettingsFile {
|
|
@@ -164,7 +164,10 @@ Valid decision/tier combinations:
|
|
|
164
164
|
- block: hard_deny, soft_deny, or none
|
|
165
165
|
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
166
|
|
|
167
|
-
export const PI_GLOBAL_SETTINGS = [
|
|
167
|
+
export const PI_GLOBAL_SETTINGS = [
|
|
168
|
+
resolve(HOME, ".pi/agent/extensions/pi-automode/config.json"),
|
|
169
|
+
];
|
|
170
|
+
export const PI_LEGACY_GLOBAL_SETTINGS = resolve(HOME, ".pi/agent/automode.json");
|
|
168
171
|
export const PI_PROJECT_LOCAL_SETTINGS = [".pi/automode.local.json"];
|
|
169
172
|
export const PI_PROJECT_SHARED_SETTINGS = [".pi/automode.json"];
|
|
170
173
|
|
|
@@ -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
|
|