@czottmann/pi-automode 1.0.0 → 1.2.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/README.md +15 -2
- package/extensions/auto-mode/classifier.ts +152 -0
- package/extensions/auto-mode/config.ts +399 -0
- package/extensions/auto-mode/constants.ts +168 -0
- package/extensions/auto-mode/extension.ts +402 -0
- package/extensions/auto-mode/hard-deny.ts +348 -0
- package/extensions/auto-mode/model-selector.ts +113 -0
- package/extensions/auto-mode/model.ts +13 -0
- package/extensions/auto-mode/paths.ts +134 -0
- package/extensions/auto-mode/permissions.ts +90 -0
- package/extensions/auto-mode/state.ts +103 -0
- package/extensions/auto-mode/transcript.ts +88 -0
- package/extensions/auto-mode/types.ts +95 -0
- package/extensions/auto-mode/utils.ts +46 -0
- package/extensions/auto-mode.ts +14 -1812
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ Example:
|
|
|
66
66
|
"Trusted internal domains: *.corp.example.com"
|
|
67
67
|
],
|
|
68
68
|
"allow": ["$defaults"],
|
|
69
|
+
"protectedPaths": ["$defaults"],
|
|
69
70
|
"soft_deny": ["$defaults"],
|
|
70
71
|
"hard_deny": [
|
|
71
72
|
"$defaults",
|
|
@@ -106,6 +107,16 @@ Example:
|
|
|
106
107
|
|
|
107
108
|
These are exceptions to `soft_deny`, not to `hard_deny`.
|
|
108
109
|
|
|
110
|
+
#### `protectedPaths`
|
|
111
|
+
|
|
112
|
+
`$defaults` expands to paths where writes are never auto-approved — they always go to the classifier, regardless of `allow` rules. This matches Claude Code's protected-paths behavior.
|
|
113
|
+
|
|
114
|
+
Protected directories: `.git`, `.config/git`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.pi`.
|
|
115
|
+
|
|
116
|
+
Protected files: `.gitconfig`, `.gitmodules`, `.gitignore`, `.gitattributes`, shell profiles (`.bashrc`, `.zshrc`, `.profile`, etc.), `.envrc`, package manager configs (`.npmrc`, `.yarnrc`, `.yarnrc.yml`, `.pnp.cjs`, `bunfig.toml`, etc.), Bazel configs (`.bazelrc`, `.bazelversion`, `.bazeliskrc`), hook configs (`.pre-commit-config.yaml`, `lefthook.yml`), Gradle/Maven wrappers, `.devcontainer.json`, `.ripgreprc`, `pyrightconfig.json`, `.mcp.json`.
|
|
117
|
+
|
|
118
|
+
Read-only tools (`read`, `grep`, `find`, `ls`) bypass this check — reads to protected paths are always allowed. Only `write` and `edit` are affected.
|
|
119
|
+
|
|
109
120
|
#### `soft_deny`
|
|
110
121
|
|
|
111
122
|
`$defaults` expands to soft blocks for:
|
|
@@ -167,7 +178,7 @@ If you omit `$defaults`, you replace the built-ins for that section:
|
|
|
167
178
|
}
|
|
168
179
|
```
|
|
169
180
|
|
|
170
|
-
That means: use only that one `allow` entry. The built-in `allow` entries are not used. Replacing `allow` does not replace `soft_deny`, `hard_deny`, or `environment`.
|
|
181
|
+
That means: use only that one `allow` entry. The built-in `allow` entries are not used. Replacing `allow` does not replace `soft_deny`, `hard_deny`, `protectedPaths`, or `environment`.
|
|
171
182
|
|
|
172
183
|
`$defaults` is not used in `permissions.deny` or `permissions.ask`. Those lists contain only explicit Pi tool patterns.
|
|
173
184
|
|
|
@@ -190,6 +201,8 @@ The extension blocks these before any allow or classifier decision:
|
|
|
190
201
|
|
|
191
202
|
Read-only Pi tools (`read`, `grep`, `find`, `ls`) are allowed after those checks.
|
|
192
203
|
|
|
204
|
+
Writes to [protected paths](#protectedpaths) (shell profiles, tool configs, `.git`, `.vscode`, `.pi`, etc.) always go to the classifier, even if an `allow` rule matches. The classifier decides whether to permit the write.
|
|
205
|
+
|
|
193
206
|
Everything else goes to the classifier. If the classifier is missing, fails, or returns invalid JSON, the action is blocked.
|
|
194
207
|
|
|
195
208
|
## Examples
|
|
@@ -204,7 +217,7 @@ npm test
|
|
|
204
217
|
npm pack --dry-run
|
|
205
218
|
```
|
|
206
219
|
|
|
207
|
-
The tests cover the risky parts: scoped permission matching, config-source precedence, `$defaults` behavior, config diagnostics, deterministic hard-deny checks, shell parsing for risky bash fragments, classifier JSON parsing, hook-level blocking/allowing, and
|
|
220
|
+
The tests cover the risky parts: scoped permission matching, config-source precedence, `$defaults` behavior, config diagnostics, deterministic hard-deny checks, shell parsing for risky bash fragments, classifier JSON parsing, hook-level blocking/allowing, classifier mocking, and protected-path enforcement.
|
|
208
221
|
|
|
209
222
|
## Known limits
|
|
210
223
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { complete } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
Model,
|
|
5
|
+
UserMessage,
|
|
6
|
+
} from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { CLASSIFIER_SYSTEM_PROMPT } from "./constants.ts";
|
|
9
|
+
import { parseModelSpec } from "./model.ts";
|
|
10
|
+
import { buildTranscript } from "./transcript.ts";
|
|
11
|
+
import type {
|
|
12
|
+
ClassificationDecision,
|
|
13
|
+
ClassifyAction,
|
|
14
|
+
EffectiveConfig,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export function buildClassifierPrompt(config: EffectiveConfig): string {
|
|
18
|
+
return CLASSIFIER_SYSTEM_PROMPT.replace(
|
|
19
|
+
"<ENVIRONMENT>",
|
|
20
|
+
config.environment.map((line) => `- ${line}`).join("\n"),
|
|
21
|
+
)
|
|
22
|
+
.replace(
|
|
23
|
+
"<ALLOW_RULES>",
|
|
24
|
+
config.allow.map((line) => `- ${line}`).join("\n"),
|
|
25
|
+
)
|
|
26
|
+
.replace(
|
|
27
|
+
"<SOFT_DENY_RULES>",
|
|
28
|
+
config.softDeny.map((line) => `- ${line}`).join("\n"),
|
|
29
|
+
)
|
|
30
|
+
.replace(
|
|
31
|
+
"<HARD_DENY_RULES>",
|
|
32
|
+
config.hardDeny.map((line) => `- ${line}`).join("\n"),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resolveClassifier(
|
|
37
|
+
ctx: ExtensionContext,
|
|
38
|
+
config: EffectiveConfig,
|
|
39
|
+
): Promise<
|
|
40
|
+
| { model: Model<any>; apiKey?: string; headers?: Record<string, string> }
|
|
41
|
+
| undefined
|
|
42
|
+
> {
|
|
43
|
+
const configured = config.classifierModel;
|
|
44
|
+
const model = configured
|
|
45
|
+
? (() => {
|
|
46
|
+
const parsed = parseModelSpec(configured);
|
|
47
|
+
return parsed
|
|
48
|
+
? ctx.modelRegistry.find(parsed.provider, parsed.id)
|
|
49
|
+
: undefined;
|
|
50
|
+
})()
|
|
51
|
+
: ctx.model;
|
|
52
|
+
if (!model) return undefined;
|
|
53
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
54
|
+
if (!auth.ok) return undefined;
|
|
55
|
+
return { model, apiKey: auth.apiKey, headers: auth.headers };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
|
|
59
|
+
export function parseClassifierDecision(
|
|
60
|
+
message: AssistantMessage,
|
|
61
|
+
): ClassificationDecision | undefined {
|
|
62
|
+
const text = message.content
|
|
63
|
+
.filter(
|
|
64
|
+
(block): block is { type: "text"; text: string } => block.type === "text",
|
|
65
|
+
)
|
|
66
|
+
.map((block) => block.text)
|
|
67
|
+
.join("\n")
|
|
68
|
+
.trim();
|
|
69
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
|
|
70
|
+
const candidates = [fenced, text, text.match(/\{[\s\S]*\}/)?.[0]].filter(
|
|
71
|
+
Boolean,
|
|
72
|
+
) as string[];
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(candidate) as Partial<ClassificationDecision>;
|
|
76
|
+
if (
|
|
77
|
+
(parsed.decision === "allow" || parsed.decision === "block") &&
|
|
78
|
+
typeof parsed.reason === "string"
|
|
79
|
+
) {
|
|
80
|
+
return {
|
|
81
|
+
decision: parsed.decision,
|
|
82
|
+
tier: parsed.tier ?? "none",
|
|
83
|
+
reason: parsed.reason,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Try next candidate.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const defaultClassifyAction: ClassifyAction = async (
|
|
94
|
+
ctx,
|
|
95
|
+
config,
|
|
96
|
+
action,
|
|
97
|
+
loadedContext,
|
|
98
|
+
): Promise<ClassificationDecision> => {
|
|
99
|
+
const classifier = await resolveClassifier(ctx, config);
|
|
100
|
+
if (!classifier) {
|
|
101
|
+
return {
|
|
102
|
+
decision: "block",
|
|
103
|
+
tier: "none",
|
|
104
|
+
reason: "No classifier model/API key available; auto mode fails closed.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const userMessage: UserMessage = {
|
|
109
|
+
role: "user",
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: `<loaded-project-instructions>\n${
|
|
114
|
+
loadedContext || "(none)"
|
|
115
|
+
}\n</loaded-project-instructions>\n\n<transcript>\n${
|
|
116
|
+
buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
|
|
117
|
+
}\n</transcript>\n\nLatest action to classify:\n${action}`,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const response = await complete(
|
|
125
|
+
classifier.model,
|
|
126
|
+
{ systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
|
|
127
|
+
{
|
|
128
|
+
apiKey: classifier.apiKey,
|
|
129
|
+
headers: classifier.headers,
|
|
130
|
+
signal: ctx.signal,
|
|
131
|
+
maxTokens: 700,
|
|
132
|
+
temperature: 0,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
return (
|
|
136
|
+
parseClassifierDecision(response) ?? {
|
|
137
|
+
decision: "block",
|
|
138
|
+
tier: "none",
|
|
139
|
+
reason:
|
|
140
|
+
"Classifier response was not valid decision JSON; auto mode fails closed.",
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
decision: "block",
|
|
146
|
+
tier: "none",
|
|
147
|
+
reason: `Classifier failed; auto mode fails closed: ${
|
|
148
|
+
error instanceof Error ? error.message : String(error)
|
|
149
|
+
}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ALLOW,
|
|
5
|
+
DEFAULT_ENVIRONMENT,
|
|
6
|
+
DEFAULT_HARD_DENY,
|
|
7
|
+
DEFAULT_MAX_TRANSCRIPT_LINES,
|
|
8
|
+
DEFAULT_PROTECTED_PATHS,
|
|
9
|
+
DEFAULT_SOFT_DENY,
|
|
10
|
+
PI_GLOBAL_SETTINGS,
|
|
11
|
+
PI_PROJECT_LOCAL_SETTINGS,
|
|
12
|
+
PI_PROJECT_SHARED_SETTINGS,
|
|
13
|
+
} from "./constants.ts";
|
|
14
|
+
import { parseToolPattern } from "./permissions.ts";
|
|
15
|
+
import type {
|
|
16
|
+
AutoModeSettings,
|
|
17
|
+
ConfigLoadResult,
|
|
18
|
+
EffectiveConfig,
|
|
19
|
+
LoadedSettingsFile,
|
|
20
|
+
SettingsFile,
|
|
21
|
+
SettingsSources,
|
|
22
|
+
ToolPattern,
|
|
23
|
+
} from "./types.ts";
|
|
24
|
+
import { hasOwn, stringArray } from "./utils.ts";
|
|
25
|
+
|
|
26
|
+
function readSettingsFile(path: string): LoadedSettingsFile | undefined {
|
|
27
|
+
if (!existsSync(path)) return undefined;
|
|
28
|
+
try {
|
|
29
|
+
const settings = JSON.parse(readFileSync(path, "utf8")) as SettingsFile;
|
|
30
|
+
return {
|
|
31
|
+
path,
|
|
32
|
+
settings,
|
|
33
|
+
diagnostics: validateSettingsFile(settings, path),
|
|
34
|
+
};
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
path,
|
|
38
|
+
diagnostics: [
|
|
39
|
+
`${path}: invalid JSON (${
|
|
40
|
+
error instanceof Error ? error.message : String(error)
|
|
41
|
+
})`,
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateStringArraySetting(
|
|
48
|
+
value: unknown,
|
|
49
|
+
source: string,
|
|
50
|
+
key: string,
|
|
51
|
+
diagnostics: string[],
|
|
52
|
+
): void {
|
|
53
|
+
if (value === undefined) return;
|
|
54
|
+
if (!Array.isArray(value)) {
|
|
55
|
+
diagnostics.push(`${source}: ${key} must be an array of strings`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const [index, entry] of value.entries()) {
|
|
59
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
60
|
+
diagnostics.push(
|
|
61
|
+
`${source}: ${key}[${index}] must be a non-empty string`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (value.length > 0 && !value.includes("$defaults")) {
|
|
66
|
+
diagnostics.push(
|
|
67
|
+
`${source}: ${key} omits "$defaults" and replaces the built-in ${key} rules`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Validate config shape and emit human-readable diagnostics for `/automode config`. */
|
|
73
|
+
export function validateSettingsFile(
|
|
74
|
+
settings: SettingsFile,
|
|
75
|
+
source: string,
|
|
76
|
+
): string[] {
|
|
77
|
+
const diagnostics: string[] = [];
|
|
78
|
+
const root = settings as Record<string, unknown>;
|
|
79
|
+
for (const key of Object.keys(root)) {
|
|
80
|
+
if (key !== "autoMode" && key !== "permissions") {
|
|
81
|
+
diagnostics.push(`${source}: unknown top-level key ${key}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (settings.autoMode !== undefined) {
|
|
86
|
+
if (
|
|
87
|
+
!settings.autoMode ||
|
|
88
|
+
typeof settings.autoMode !== "object" ||
|
|
89
|
+
Array.isArray(settings.autoMode)
|
|
90
|
+
) {
|
|
91
|
+
diagnostics.push(`${source}: autoMode must be an object`);
|
|
92
|
+
} else {
|
|
93
|
+
const autoMode = settings.autoMode as Record<string, unknown>;
|
|
94
|
+
const knownAutoMode = new Set([
|
|
95
|
+
"enabled",
|
|
96
|
+
"classifierModel",
|
|
97
|
+
"maxTranscriptLines",
|
|
98
|
+
"environment",
|
|
99
|
+
"allow",
|
|
100
|
+
"protectedPaths",
|
|
101
|
+
"soft_deny",
|
|
102
|
+
"softDeny",
|
|
103
|
+
"hard_deny",
|
|
104
|
+
"hardDeny",
|
|
105
|
+
]);
|
|
106
|
+
for (const key of Object.keys(autoMode)) {
|
|
107
|
+
if (!knownAutoMode.has(key)) {
|
|
108
|
+
diagnostics.push(`${source}: unknown autoMode key ${key}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
hasOwn(autoMode, "enabled") && typeof autoMode.enabled !== "boolean"
|
|
113
|
+
) {
|
|
114
|
+
diagnostics.push(`${source}: autoMode.enabled must be a boolean`);
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
hasOwn(autoMode, "classifierModel") &&
|
|
118
|
+
typeof autoMode.classifierModel !== "string"
|
|
119
|
+
) {
|
|
120
|
+
diagnostics.push(
|
|
121
|
+
`${source}: autoMode.classifierModel must be a provider/model string`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
hasOwn(autoMode, "maxTranscriptLines") &&
|
|
126
|
+
(!Number.isInteger(autoMode.maxTranscriptLines) ||
|
|
127
|
+
Number(autoMode.maxTranscriptLines) <= 0)
|
|
128
|
+
) {
|
|
129
|
+
diagnostics.push(
|
|
130
|
+
`${source}: autoMode.maxTranscriptLines must be a positive integer`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
validateStringArraySetting(
|
|
134
|
+
autoMode.environment,
|
|
135
|
+
source,
|
|
136
|
+
"autoMode.environment",
|
|
137
|
+
diagnostics,
|
|
138
|
+
);
|
|
139
|
+
validateStringArraySetting(
|
|
140
|
+
autoMode.allow,
|
|
141
|
+
source,
|
|
142
|
+
"autoMode.allow",
|
|
143
|
+
diagnostics,
|
|
144
|
+
);
|
|
145
|
+
validateStringArraySetting(
|
|
146
|
+
autoMode.protectedPaths,
|
|
147
|
+
source,
|
|
148
|
+
"autoMode.protectedPaths",
|
|
149
|
+
diagnostics,
|
|
150
|
+
);
|
|
151
|
+
validateStringArraySetting(
|
|
152
|
+
autoMode.soft_deny ?? autoMode.softDeny,
|
|
153
|
+
source,
|
|
154
|
+
"autoMode.soft_deny",
|
|
155
|
+
diagnostics,
|
|
156
|
+
);
|
|
157
|
+
validateStringArraySetting(
|
|
158
|
+
autoMode.hard_deny ?? autoMode.hardDeny,
|
|
159
|
+
source,
|
|
160
|
+
"autoMode.hard_deny",
|
|
161
|
+
diagnostics,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (settings.permissions !== undefined) {
|
|
167
|
+
if (
|
|
168
|
+
!settings.permissions ||
|
|
169
|
+
typeof settings.permissions !== "object" ||
|
|
170
|
+
Array.isArray(settings.permissions)
|
|
171
|
+
) {
|
|
172
|
+
diagnostics.push(`${source}: permissions must be an object`);
|
|
173
|
+
} else {
|
|
174
|
+
const permissions = settings.permissions as Record<string, unknown>;
|
|
175
|
+
for (const key of Object.keys(permissions)) {
|
|
176
|
+
if (key !== "deny" && key !== "ask") {
|
|
177
|
+
diagnostics.push(`${source}: unknown permissions key ${key}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const key of ["deny", "ask"] as const) {
|
|
181
|
+
const value = permissions[key];
|
|
182
|
+
if (value === undefined) continue;
|
|
183
|
+
if (!Array.isArray(value)) {
|
|
184
|
+
diagnostics.push(
|
|
185
|
+
`${source}: permissions.${key} must be an array of tool patterns`,
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
for (const [index, entry] of value.entries()) {
|
|
190
|
+
if (typeof entry !== "string" || !parseToolPattern(entry)) {
|
|
191
|
+
diagnostics.push(
|
|
192
|
+
`${source}: permissions.${key}[${index}] must be a tool pattern string`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return diagnostics;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type RuleAccumulator = {
|
|
204
|
+
defaults: string[];
|
|
205
|
+
includeDefaults: boolean;
|
|
206
|
+
seen: boolean;
|
|
207
|
+
entries: string[];
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
function createRuleAccumulator(defaults: string[]): RuleAccumulator {
|
|
211
|
+
return { defaults, includeDefaults: true, seen: false, entries: [] };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function applyRuleSetting(accumulator: RuleAccumulator, value: unknown): void {
|
|
215
|
+
const entries = stringArray(value);
|
|
216
|
+
if (!entries) return;
|
|
217
|
+
accumulator.seen = true;
|
|
218
|
+
accumulator.includeDefaults = entries.includes("$defaults");
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (entry !== "$defaults") accumulator.entries.push(entry);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
|
|
225
|
+
const base = accumulator.includeDefaults || !accumulator.seen
|
|
226
|
+
? accumulator.defaults
|
|
227
|
+
: [];
|
|
228
|
+
return [...new Set([...base, ...accumulator.entries])];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function applyAutoModeScalars(
|
|
232
|
+
base: EffectiveConfig,
|
|
233
|
+
settings: AutoModeSettings | undefined,
|
|
234
|
+
): EffectiveConfig {
|
|
235
|
+
if (!settings) return base;
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
enabled: settings.enabled ?? base.enabled,
|
|
239
|
+
classifierModel: settings.classifierModel ?? base.classifierModel,
|
|
240
|
+
maxTranscriptLines: settings.maxTranscriptLines ?? base.maxTranscriptLines,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function appendPermissionPatterns(
|
|
245
|
+
target: ToolPattern[],
|
|
246
|
+
settings: SettingsFile | undefined,
|
|
247
|
+
key: "deny" | "ask",
|
|
248
|
+
): void {
|
|
249
|
+
const values = stringArray(settings?.permissions?.[key]);
|
|
250
|
+
if (!values) return;
|
|
251
|
+
for (const value of values) {
|
|
252
|
+
const pattern = parseToolPattern(value);
|
|
253
|
+
if (pattern) target.push(pattern);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Merge settings with Claude Code-style precedence using Pi-owned config files.
|
|
259
|
+
*
|
|
260
|
+
* Important details:
|
|
261
|
+
* - shared project `.pi/automode.json` contributes `permissions.*` but not `autoMode`,
|
|
262
|
+
* so a checked-in repo cannot weaken classifier rules;
|
|
263
|
+
* - global, project-local, and inline `autoMode` settings combine additively across scopes;
|
|
264
|
+
* - omitting `$defaults` in any scope for a rule list means "replace built-ins" for that list.
|
|
265
|
+
*/
|
|
266
|
+
export function buildEffectiveConfigFromSources(
|
|
267
|
+
sources: SettingsSources = {},
|
|
268
|
+
): EffectiveConfig {
|
|
269
|
+
let config: EffectiveConfig = {
|
|
270
|
+
enabled: true,
|
|
271
|
+
maxTranscriptLines: DEFAULT_MAX_TRANSCRIPT_LINES,
|
|
272
|
+
environment: [...DEFAULT_ENVIRONMENT],
|
|
273
|
+
allow: [...DEFAULT_ALLOW],
|
|
274
|
+
protectedPaths: [...DEFAULT_PROTECTED_PATHS],
|
|
275
|
+
softDeny: [...DEFAULT_SOFT_DENY],
|
|
276
|
+
hardDeny: [...DEFAULT_HARD_DENY],
|
|
277
|
+
permissionDeny: [],
|
|
278
|
+
permissionAsk: [],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const globalSettings = sources.globalSettings ?? [];
|
|
282
|
+
const projectLocalSettings = sources.projectLocalSettings ?? [];
|
|
283
|
+
const projectSharedSettings = sources.projectSharedSettings ?? [];
|
|
284
|
+
const inlineSettings = sources.inlineSettings ?? [];
|
|
285
|
+
|
|
286
|
+
const configurableSettings = [
|
|
287
|
+
...globalSettings,
|
|
288
|
+
...projectLocalSettings,
|
|
289
|
+
...inlineSettings,
|
|
290
|
+
];
|
|
291
|
+
const environment = createRuleAccumulator(DEFAULT_ENVIRONMENT);
|
|
292
|
+
const allow = createRuleAccumulator(DEFAULT_ALLOW);
|
|
293
|
+
const protectedPaths = createRuleAccumulator(DEFAULT_PROTECTED_PATHS);
|
|
294
|
+
const softDeny = createRuleAccumulator(DEFAULT_SOFT_DENY);
|
|
295
|
+
const hardDeny = createRuleAccumulator(DEFAULT_HARD_DENY);
|
|
296
|
+
|
|
297
|
+
for (const settings of configurableSettings) {
|
|
298
|
+
config = applyAutoModeScalars(config, settings.autoMode);
|
|
299
|
+
applyRuleSetting(environment, settings.autoMode?.environment);
|
|
300
|
+
applyRuleSetting(allow, settings.autoMode?.allow);
|
|
301
|
+
applyRuleSetting(protectedPaths, settings.autoMode?.protectedPaths);
|
|
302
|
+
applyRuleSetting(
|
|
303
|
+
softDeny,
|
|
304
|
+
settings.autoMode?.soft_deny ?? settings.autoMode?.softDeny,
|
|
305
|
+
);
|
|
306
|
+
applyRuleSetting(
|
|
307
|
+
hardDeny,
|
|
308
|
+
settings.autoMode?.hard_deny ?? settings.autoMode?.hardDeny,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
config = {
|
|
313
|
+
...config,
|
|
314
|
+
environment: finalizeRuleSetting(environment),
|
|
315
|
+
allow: finalizeRuleSetting(allow),
|
|
316
|
+
protectedPaths: finalizeRuleSetting(protectedPaths),
|
|
317
|
+
softDeny: finalizeRuleSetting(softDeny),
|
|
318
|
+
hardDeny: finalizeRuleSetting(hardDeny),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
for (
|
|
322
|
+
const settings of [
|
|
323
|
+
...globalSettings,
|
|
324
|
+
...projectSharedSettings,
|
|
325
|
+
...projectLocalSettings,
|
|
326
|
+
...inlineSettings,
|
|
327
|
+
]
|
|
328
|
+
) {
|
|
329
|
+
appendPermissionPatterns(config.permissionDeny, settings, "deny");
|
|
330
|
+
appendPermissionPatterns(config.permissionAsk, settings, "ask");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return config;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function loadedSettingsToSettings(
|
|
337
|
+
files: Array<LoadedSettingsFile | undefined>,
|
|
338
|
+
): SettingsFile[] {
|
|
339
|
+
return files.flatMap((file) => (file?.settings ? [file.settings] : []));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function loadedSettingsDiagnostics(
|
|
343
|
+
files: Array<LoadedSettingsFile | undefined>,
|
|
344
|
+
): string[] {
|
|
345
|
+
return files.flatMap((file) => file?.diagnostics ?? []);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Load config from disk and environment variables, including diagnostics for `/automode config`. */
|
|
349
|
+
export function loadEffectiveConfigWithDiagnostics(
|
|
350
|
+
cwd: string,
|
|
351
|
+
): ConfigLoadResult {
|
|
352
|
+
const inlineSettings: SettingsFile[] = [];
|
|
353
|
+
const diagnostics: string[] = [];
|
|
354
|
+
if (process.env.PI_AUTOMODE_SETTINGS_JSON) {
|
|
355
|
+
try {
|
|
356
|
+
const parsed = JSON.parse(
|
|
357
|
+
process.env.PI_AUTOMODE_SETTINGS_JSON,
|
|
358
|
+
) as SettingsFile;
|
|
359
|
+
inlineSettings.push(parsed);
|
|
360
|
+
diagnostics.push(
|
|
361
|
+
...validateSettingsFile(parsed, "PI_AUTOMODE_SETTINGS_JSON"),
|
|
362
|
+
);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
diagnostics.push(
|
|
365
|
+
`PI_AUTOMODE_SETTINGS_JSON: invalid JSON (${
|
|
366
|
+
error instanceof Error ? error.message : String(error)
|
|
367
|
+
})`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const globalFiles = PI_GLOBAL_SETTINGS.map(readSettingsFile);
|
|
373
|
+
const projectLocalFiles = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
|
|
374
|
+
readSettingsFile(resolve(cwd, file))
|
|
375
|
+
);
|
|
376
|
+
const projectSharedFiles = PI_PROJECT_SHARED_SETTINGS.map((file) =>
|
|
377
|
+
readSettingsFile(resolve(cwd, file))
|
|
378
|
+
);
|
|
379
|
+
const fileDiagnostics = loadedSettingsDiagnostics([
|
|
380
|
+
...globalFiles,
|
|
381
|
+
...projectLocalFiles,
|
|
382
|
+
...projectSharedFiles,
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
config: buildEffectiveConfigFromSources({
|
|
387
|
+
globalSettings: loadedSettingsToSettings(globalFiles),
|
|
388
|
+
projectLocalSettings: loadedSettingsToSettings(projectLocalFiles),
|
|
389
|
+
projectSharedSettings: loadedSettingsToSettings(projectSharedFiles),
|
|
390
|
+
inlineSettings,
|
|
391
|
+
}),
|
|
392
|
+
diagnostics: [...fileDiagnostics, ...diagnostics],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Load config from disk and environment variables. Exported for tests and diagnostics. */
|
|
397
|
+
export function loadEffectiveConfig(cwd: string): EffectiveConfig {
|
|
398
|
+
return loadEffectiveConfigWithDiagnostics(cwd).config;
|
|
399
|
+
}
|