@j6e/pi-auto-mode 0.1.1
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 +13 -0
- package/package.json +46 -0
- package/src/classifier.ts +264 -0
- package/src/config-command.ts +204 -0
- package/src/config.ts +218 -0
- package/src/decision.ts +65 -0
- package/src/deny-continue.ts +66 -0
- package/src/effective-config.ts +17 -0
- package/src/index.ts +73 -0
- package/src/mode.ts +130 -0
- package/src/session-context.ts +29 -0
- package/src/tiers.ts +91 -0
- package/src/types.ts +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# pi-auto-mode
|
|
2
|
+
A Claude auto mode clone for [pi](pi.dev)
|
|
3
|
+
|
|
4
|
+
## Classifier output contract
|
|
5
|
+
|
|
6
|
+
The classifier decision path is strict: a valid classifier response must call the `auto_mode_classifier` tool. The tool arguments must include:
|
|
7
|
+
|
|
8
|
+
- `decision`: `allow` or `block`
|
|
9
|
+
- `reason`: string
|
|
10
|
+
- `confidence`: `high`, `medium`, or `low`
|
|
11
|
+
- `category`: `user_intent`, `security`, `data_loss`, `scope_creep`, `infrastructure`, `credential_access`, or `other`
|
|
12
|
+
|
|
13
|
+
Plain text JSON, fenced JSON, and reasoning/thinking-block JSON are not accepted as classifier decisions. Malformed or missing tool calls fail closed.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@j6e/pi-auto-mode",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A Claude auto mode clone for pi",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Joan G. Esquerdo",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/j6e/pi-auto-mode.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/j6e/pi-auto-mode/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/j6e/pi-auto-mode#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"files": [
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-ai": "*",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"typebox": "*"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@earendil-works/pi-ai": "^0.79.3",
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^0.79.3",
|
|
37
|
+
"typebox": "^1.2.10",
|
|
38
|
+
"typescript": "^5.5.0",
|
|
39
|
+
"vitest": "^2.0.0"
|
|
40
|
+
},
|
|
41
|
+
"pi": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./src/index.ts"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { complete, StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { AssistantMessage, Context, Message, Tool, ToolCall } from "@earendil-works/pi-ai";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import type { ResolvedConfig, ClassifierDecision } from "./types";
|
|
7
|
+
|
|
8
|
+
const CLASSIFIER_TOOL_NAME = "auto_mode_classifier";
|
|
9
|
+
|
|
10
|
+
const classifierToolSchema = Type.Object({
|
|
11
|
+
decision: StringEnum(["allow", "block"] as const, {
|
|
12
|
+
description: "Whether to allow or block the pending tool call",
|
|
13
|
+
}),
|
|
14
|
+
reason: Type.String({ description: "Concise explanation for the decision" }),
|
|
15
|
+
confidence: StringEnum(["high", "medium", "low"] as const, {
|
|
16
|
+
description: "Confidence level in the decision",
|
|
17
|
+
}),
|
|
18
|
+
category: StringEnum(
|
|
19
|
+
[
|
|
20
|
+
"user_intent",
|
|
21
|
+
"security",
|
|
22
|
+
"data_loss",
|
|
23
|
+
"scope_creep",
|
|
24
|
+
"infrastructure",
|
|
25
|
+
"credential_access",
|
|
26
|
+
"other",
|
|
27
|
+
] as const,
|
|
28
|
+
{ description: "Category of the decision" },
|
|
29
|
+
),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const classifierTool: Tool = {
|
|
33
|
+
name: CLASSIFIER_TOOL_NAME,
|
|
34
|
+
description:
|
|
35
|
+
"Classify whether the pending tool call should be allowed or blocked. Provide a concise reason.",
|
|
36
|
+
parameters: classifierToolSchema,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function buildPrompt(config: ResolvedConfig): string {
|
|
40
|
+
if (config.classifier.prompt) {
|
|
41
|
+
return config.classifier.prompt;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
"You are a security classifier for a coding agent. Your job is to evaluate a pending tool call and decide whether it should be allowed or blocked.",
|
|
46
|
+
"",
|
|
47
|
+
"## Environment",
|
|
48
|
+
...config.classifier.environment,
|
|
49
|
+
"",
|
|
50
|
+
"## Hard Deny Rules (block unless explicitly justified)",
|
|
51
|
+
...config.classifier.hardDeny,
|
|
52
|
+
"",
|
|
53
|
+
"## Soft Deny Rules (be cautious)",
|
|
54
|
+
...config.classifier.softDeny,
|
|
55
|
+
"",
|
|
56
|
+
"## Allow Rules (exceptions that are safe)",
|
|
57
|
+
...config.classifier.allow,
|
|
58
|
+
"",
|
|
59
|
+
"## Decision Rules",
|
|
60
|
+
"1. Evaluate real-world impact, not surface text.",
|
|
61
|
+
"2. Be conservative on user intent.",
|
|
62
|
+
"3. Apply block rules first, then allow exceptions.",
|
|
63
|
+
"4. If uncertain, block with low confidence.",
|
|
64
|
+
"",
|
|
65
|
+
"## IMPORTANT",
|
|
66
|
+
`You MUST call the \`${CLASSIFIER_TOOL_NAME}\` tool to submit your decision.`,
|
|
67
|
+
"Do not write a text response — use the tool.",
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatPendingToolCall(toolName: string, input: Record<string, unknown>): string {
|
|
72
|
+
return [
|
|
73
|
+
"## Pending Tool Call",
|
|
74
|
+
`Tool: ${toolName}`,
|
|
75
|
+
"Arguments:",
|
|
76
|
+
JSON.stringify(input, null, 2),
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stripTranscript(messages: Message[]): Message[] {
|
|
81
|
+
return messages.filter((m) => m.role === "user");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const VALID_DECISIONS = ["allow", "block"] as const;
|
|
85
|
+
const VALID_CONFIDENCES = ["high", "medium", "low"] as const;
|
|
86
|
+
const VALID_CATEGORIES = [
|
|
87
|
+
"user_intent",
|
|
88
|
+
"security",
|
|
89
|
+
"data_loss",
|
|
90
|
+
"scope_creep",
|
|
91
|
+
"infrastructure",
|
|
92
|
+
"credential_access",
|
|
93
|
+
"other",
|
|
94
|
+
] as const;
|
|
95
|
+
|
|
96
|
+
function isValidDecision(obj: unknown): obj is ClassifierDecision {
|
|
97
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
98
|
+
const d = obj as Record<string, unknown>;
|
|
99
|
+
return (
|
|
100
|
+
typeof d.decision === "string" && VALID_DECISIONS.includes(d.decision as any) &&
|
|
101
|
+
typeof d.reason === "string" &&
|
|
102
|
+
typeof d.confidence === "string" && VALID_CONFIDENCES.includes(d.confidence as any) &&
|
|
103
|
+
typeof d.category === "string" && VALID_CATEGORIES.includes(d.category as any)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractArgsFromToolCall(toolCall: { arguments: unknown } | ToolCall): ClassifierDecision | null {
|
|
108
|
+
let args = toolCall.arguments;
|
|
109
|
+
// Some providers return arguments as a JSON string
|
|
110
|
+
if (typeof args === "string") {
|
|
111
|
+
try {
|
|
112
|
+
args = JSON.parse(args);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return isValidDecision(args) ? args : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseClassifierToolCall(message: AssistantMessage): ClassifierDecision | null {
|
|
121
|
+
const toolCall = message.content.find(
|
|
122
|
+
(c): c is ToolCall =>
|
|
123
|
+
c.type === "toolCall" && "name" in c && c.name === CLASSIFIER_TOOL_NAME,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (!toolCall) return null;
|
|
127
|
+
return extractArgsFromToolCall(toolCall);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ClassifierDeps {
|
|
131
|
+
complete: typeof complete;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveModel(
|
|
135
|
+
config: ResolvedConfig,
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
): Model<any> | undefined {
|
|
138
|
+
if (config.classifier.model) {
|
|
139
|
+
const parts = config.classifier.model.split("/");
|
|
140
|
+
if (parts.length >= 2) {
|
|
141
|
+
const provider = parts[0];
|
|
142
|
+
const modelId = parts.slice(1).join("/");
|
|
143
|
+
const found = ctx.modelRegistry.find(provider, modelId);
|
|
144
|
+
if (found) return found;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return ctx.model;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function classify(
|
|
151
|
+
config: ResolvedConfig,
|
|
152
|
+
toolName: string,
|
|
153
|
+
toolInput: Record<string, unknown>,
|
|
154
|
+
transcript: Message[],
|
|
155
|
+
ctx: ExtensionContext,
|
|
156
|
+
currentMode: string,
|
|
157
|
+
deps: ClassifierDeps = { complete },
|
|
158
|
+
): Promise<ClassifierDecision> {
|
|
159
|
+
const model = resolveModel(config, ctx);
|
|
160
|
+
if (!model) {
|
|
161
|
+
return {
|
|
162
|
+
decision: "block",
|
|
163
|
+
reason: "Classifier failed: no model available",
|
|
164
|
+
confidence: "low",
|
|
165
|
+
category: "other",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
170
|
+
if (!auth.ok) {
|
|
171
|
+
return {
|
|
172
|
+
decision: "block",
|
|
173
|
+
reason: `Classifier failed: ${auth.error || "no API key available"}`,
|
|
174
|
+
confidence: "low",
|
|
175
|
+
category: "other",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const systemPrompt = buildPrompt(config);
|
|
180
|
+
const userMessages = stripTranscript(transcript);
|
|
181
|
+
const pendingTool = formatPendingToolCall(toolName, toolInput);
|
|
182
|
+
|
|
183
|
+
const context: Context = {
|
|
184
|
+
systemPrompt,
|
|
185
|
+
messages: [
|
|
186
|
+
...userMessages,
|
|
187
|
+
{ role: "user", content: pendingTool, timestamp: Date.now() },
|
|
188
|
+
{
|
|
189
|
+
role: "user",
|
|
190
|
+
content: `Call the \`${CLASSIFIER_TOOL_NAME}\` tool now with your decision.`,
|
|
191
|
+
timestamp: Date.now(),
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
tools: [classifierTool],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (ctx.hasUI) {
|
|
198
|
+
ctx.ui.setStatus("auto-mode", "auto-mode: classifying...");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
const timeout = setTimeout(() => controller.abort(), config.classifier.timeoutMs);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const streamOptions: Parameters<typeof deps.complete>[2] = {
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
apiKey: auth.apiKey,
|
|
208
|
+
headers: auth.headers,
|
|
209
|
+
};
|
|
210
|
+
// Let users choose the provider-compatible tool-choice strength instead of
|
|
211
|
+
// retrying based on provider-specific error text.
|
|
212
|
+
if (config.classifier.toolMode === "required") {
|
|
213
|
+
streamOptions.toolChoice = "required";
|
|
214
|
+
} else if (config.classifier.toolMode !== "auto") {
|
|
215
|
+
streamOptions.toolChoice = { type: "function", function: { name: CLASSIFIER_TOOL_NAME } };
|
|
216
|
+
}
|
|
217
|
+
// Disable reasoning when the model declares a provider-specific way to do so.
|
|
218
|
+
// If thinkingLevelMap.off is a string, it maps to a provider value (e.g. "none").
|
|
219
|
+
// If it's null or undefined, reasoning cannot be disabled via this mechanism.
|
|
220
|
+
if (model.thinkingLevelMap?.off != null) {
|
|
221
|
+
streamOptions.reasoningEffort = model.thinkingLevelMap.off;
|
|
222
|
+
}
|
|
223
|
+
const response = await deps.complete(model, context, streamOptions);
|
|
224
|
+
|
|
225
|
+
clearTimeout(timeout);
|
|
226
|
+
|
|
227
|
+
if (response.stopReason === "error") {
|
|
228
|
+
throw new Error(response.errorMessage || "Classifier model returned an error");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const parsed = parseClassifierToolCall(response);
|
|
232
|
+
if (parsed) return parsed;
|
|
233
|
+
|
|
234
|
+
// Build a preview of whatever the model returned for debugging
|
|
235
|
+
const preview = response.content
|
|
236
|
+
.map((c) => {
|
|
237
|
+
if (c.type === "text") return c.text;
|
|
238
|
+
if (c.type === "toolCall") return `[tool:${c.name}] ${JSON.stringify(c.arguments)}`;
|
|
239
|
+
if (c.type === "thinking") return `[thinking] ${c.thinking.slice(0, 100)}`;
|
|
240
|
+
return "";
|
|
241
|
+
})
|
|
242
|
+
.join(" ")
|
|
243
|
+
.slice(0, 300);
|
|
244
|
+
|
|
245
|
+
// If the model returned absolutely nothing, default to block (safe fallback)
|
|
246
|
+
if (!preview.trim()) {
|
|
247
|
+
return {
|
|
248
|
+
decision: "block",
|
|
249
|
+
reason: "Classifier returned empty response (model did not produce output)",
|
|
250
|
+
confidence: "low",
|
|
251
|
+
category: "other",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
throw new Error(`Classifier returned malformed decision (preview: ${preview})`);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
clearTimeout(timeout);
|
|
258
|
+
throw new Error(`Classifier failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
259
|
+
} finally {
|
|
260
|
+
if (ctx.hasUI) {
|
|
261
|
+
ctx.ui.setStatus("auto-mode", `auto-mode: ${currentMode}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { EffectiveConfigResult } from "./effective-config";
|
|
5
|
+
import type { AutoModeSettings, PermissionMode, ResolvedConfig } from "./types";
|
|
6
|
+
import { isValidMode } from "./mode";
|
|
7
|
+
|
|
8
|
+
const LIST_KEYS = new Set([
|
|
9
|
+
"classifier.environment",
|
|
10
|
+
"classifier.hardDeny",
|
|
11
|
+
"classifier.softDeny",
|
|
12
|
+
"classifier.allow",
|
|
13
|
+
"protectedPaths",
|
|
14
|
+
"tools.alwaysAllow",
|
|
15
|
+
"tools.allowInProject",
|
|
16
|
+
"tools.alwaysEvaluate",
|
|
17
|
+
"tools.alwaysBlock",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const SCALAR_KEYS = new Set([
|
|
21
|
+
"defaultMode",
|
|
22
|
+
"classifier.model",
|
|
23
|
+
"classifier.prompt",
|
|
24
|
+
"classifier.timeoutMs",
|
|
25
|
+
"classifier.toolMode",
|
|
26
|
+
"denyAndContinue.maxConsecutiveDenials",
|
|
27
|
+
"denyAndContinue.maxTotalDenials",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function settingsPath(cwd: string): string {
|
|
31
|
+
return path.join(cwd, ".pi", "settings.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readSettings(cwd: string): Record<string, unknown> {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(settingsPath(cwd), "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeSettings(cwd: string, settings: Record<string, unknown>) {
|
|
43
|
+
const file = settingsPath(cwd);
|
|
44
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
45
|
+
fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensureAutoMode(settings: Record<string, unknown>): any {
|
|
49
|
+
if (!settings.autoMode || typeof settings.autoMode !== "object") settings.autoMode = {};
|
|
50
|
+
return settings.autoMode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getPath(obj: any, key: string): unknown {
|
|
54
|
+
return key.split(".").reduce((cur, part) => cur?.[part], obj);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setPath(obj: any, key: string, value: unknown) {
|
|
58
|
+
const parts = key.split(".");
|
|
59
|
+
let cur = obj;
|
|
60
|
+
for (const part of parts.slice(0, -1)) {
|
|
61
|
+
if (!cur[part] || typeof cur[part] !== "object") cur[part] = {};
|
|
62
|
+
cur = cur[part];
|
|
63
|
+
}
|
|
64
|
+
cur[parts[parts.length - 1]!] = value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deletePath(obj: any, key: string) {
|
|
68
|
+
const parts = key.split(".");
|
|
69
|
+
let cur = obj;
|
|
70
|
+
for (const part of parts.slice(0, -1)) {
|
|
71
|
+
if (!cur?.[part] || typeof cur[part] !== "object") return;
|
|
72
|
+
cur = cur[part];
|
|
73
|
+
}
|
|
74
|
+
delete cur[parts[parts.length - 1]!];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseValue(key: string, raw: string): unknown {
|
|
78
|
+
if (raw === "null") return null;
|
|
79
|
+
if (key === "defaultMode") {
|
|
80
|
+
if (!isValidMode(raw)) throw new Error("defaultMode must be off, auto, or dontAsk");
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
if (key === "classifier.toolMode") {
|
|
84
|
+
if (!["force", "required", "auto"].includes(raw)) throw new Error("classifier.toolMode must be force, required, or auto");
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
if (key === "classifier.timeoutMs" || key.startsWith("denyAndContinue.")) {
|
|
88
|
+
const n = Number(raw);
|
|
89
|
+
if (!Number.isFinite(n)) throw new Error(`${key} must be a number`);
|
|
90
|
+
return n;
|
|
91
|
+
}
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function format(config: ResolvedConfig): string {
|
|
96
|
+
return JSON.stringify(config, null, 2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatStatusConfig(config: ResolvedConfig): string {
|
|
100
|
+
return JSON.stringify(
|
|
101
|
+
{
|
|
102
|
+
...config,
|
|
103
|
+
classifier: {
|
|
104
|
+
...config.classifier,
|
|
105
|
+
prompt: config.classifier.prompt ?? "<built-in>",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
null,
|
|
109
|
+
2,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function notifyUntrustedWrite(ctx: ExtensionContext) {
|
|
114
|
+
ctx.ui.notify(
|
|
115
|
+
"Project is not trusted, so project-local auto-mode settings are not currently loaded. Review .pi/settings.json before trusting the project.",
|
|
116
|
+
"warning",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface AutoModeCommandDeps {
|
|
121
|
+
resolveEffectiveConfig(ctx: ExtensionContext): EffectiveConfigResult;
|
|
122
|
+
getMode(): PermissionMode;
|
|
123
|
+
setMode(mode: PermissionMode, ctx: ExtensionContext): void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function handleAutoModeCommand(args: string, ctx: ExtensionContext, deps: AutoModeCommandDeps): Promise<void> {
|
|
127
|
+
const tokens = args.trim().match(/(?:[^\s"]+|"[^"]*")+/g)?.map((t) => t.replace(/^"|"$/g, "")) ?? [];
|
|
128
|
+
const [first, second, third, ...rest] = tokens;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (!first || first === "cycle") return;
|
|
132
|
+
if (first === "status") {
|
|
133
|
+
const { config } = deps.resolveEffectiveConfig(ctx);
|
|
134
|
+
ctx.ui.notify(`Mode: ${deps.getMode()}\n\nEffective config:\n${formatStatusConfig(config)}`, "info");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (first === "set") {
|
|
138
|
+
if (!isValidMode(second)) throw new Error("usage: /auto-mode set <off|auto|dontAsk>");
|
|
139
|
+
deps.setMode(second, ctx);
|
|
140
|
+
ctx.ui.notify(`auto-mode: ${second}`, "info");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (first !== "config") throw new Error("usage: /auto-mode [status|set|config]");
|
|
144
|
+
|
|
145
|
+
if (!second) {
|
|
146
|
+
const { config } = deps.resolveEffectiveConfig(ctx);
|
|
147
|
+
ctx.ui.notify(format(config), "info");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (second === "edit") {
|
|
151
|
+
ctx.ui.notify(`Edit project config at ${settingsPath(ctx.cwd)}`, "info");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const key = third;
|
|
156
|
+
if (!key) throw new Error("usage: /auto-mode config <get|set|add|remove|reset> <key> [value]");
|
|
157
|
+
|
|
158
|
+
if (second === "get") {
|
|
159
|
+
const { config } = deps.resolveEffectiveConfig(ctx);
|
|
160
|
+
ctx.ui.notify(JSON.stringify(getPath(config, key), null, 2), "info");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const settings = readSettings(ctx.cwd);
|
|
165
|
+
const autoMode = ensureAutoMode(settings) as AutoModeSettings;
|
|
166
|
+
|
|
167
|
+
if (second === "reset") {
|
|
168
|
+
deletePath(autoMode, key);
|
|
169
|
+
writeSettings(ctx.cwd, settings);
|
|
170
|
+
const { includesProject } = deps.resolveEffectiveConfig(ctx);
|
|
171
|
+
ctx.ui.notify(`Reset ${key}`, "info");
|
|
172
|
+
if (!includesProject) notifyUntrustedWrite(ctx);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rawValue = rest.join(" ");
|
|
177
|
+
if (!rawValue) throw new Error(`usage: /auto-mode config ${second} ${key} <value>`);
|
|
178
|
+
|
|
179
|
+
if (second === "set") {
|
|
180
|
+
if (!SCALAR_KEYS.has(key) && !LIST_KEYS.has(key)) throw new Error(`Unknown config key: ${key}`);
|
|
181
|
+
const value = LIST_KEYS.has(key) ? rawValue.split(",").map((s) => s.trim()).filter(Boolean) : parseValue(key, rawValue);
|
|
182
|
+
setPath(autoMode, key, value);
|
|
183
|
+
} else if (second === "add" || second === "remove") {
|
|
184
|
+
if (!LIST_KEYS.has(key)) throw new Error(`${key} is not a list key`);
|
|
185
|
+
const current = getPath(autoMode, key);
|
|
186
|
+
const list = Array.isArray(current) ? [...current] : [];
|
|
187
|
+
if (second === "add" && !list.includes(rawValue)) list.push(rawValue);
|
|
188
|
+
if (second === "remove") {
|
|
189
|
+
const idx = list.indexOf(rawValue);
|
|
190
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
191
|
+
}
|
|
192
|
+
setPath(autoMode, key, list);
|
|
193
|
+
} else {
|
|
194
|
+
throw new Error("usage: /auto-mode config <get|set|add|remove|reset|edit>");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
writeSettings(ctx.cwd, settings);
|
|
198
|
+
const { includesProject } = deps.resolveEffectiveConfig(ctx);
|
|
199
|
+
ctx.ui.notify(`Updated ${key}`, "info");
|
|
200
|
+
if (!includesProject) notifyUntrustedWrite(ctx);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { AutoModeSettings, ResolvedConfig, PermissionMode, ClassifierToolMode } from "./types";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MODE: PermissionMode = "off";
|
|
7
|
+
const DEFAULT_MAX_CONSECUTIVE = 3;
|
|
8
|
+
const DEFAULT_MAX_TOTAL = 20;
|
|
9
|
+
const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
|
|
10
|
+
const DEFAULT_CLASSIFIER_TOOL_MODE: ClassifierToolMode = "force";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PROTECTED_PATHS = [
|
|
13
|
+
".git/",
|
|
14
|
+
".env",
|
|
15
|
+
".env.",
|
|
16
|
+
".pi/",
|
|
17
|
+
"node_modules/",
|
|
18
|
+
"~/.bashrc",
|
|
19
|
+
"~/.zshrc",
|
|
20
|
+
"~/.ssh/",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TOOLS = {
|
|
24
|
+
alwaysAllow: ["read", "grep", "find", "ls"],
|
|
25
|
+
allowInProject: ["write", "edit"],
|
|
26
|
+
alwaysEvaluate: [],
|
|
27
|
+
alwaysBlock: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const BUILTIN_DEFAULTS = {
|
|
31
|
+
environment: [
|
|
32
|
+
"This is a standard software development environment.",
|
|
33
|
+
"The user is a software engineer working on a codebase.",
|
|
34
|
+
],
|
|
35
|
+
hardDeny: [
|
|
36
|
+
"Never delete .git directories or modify git internals.",
|
|
37
|
+
"Never modify .env files, .env.* files, or SSH keys in ~/.ssh/.",
|
|
38
|
+
"Never modify shell configuration files like ~/.bashrc or ~/.zshrc.",
|
|
39
|
+
"Never run rm -rf on system directories or node_modules/ without explicit user intent.",
|
|
40
|
+
],
|
|
41
|
+
softDeny: [
|
|
42
|
+
"Be cautious with external API calls that may transmit sensitive data.",
|
|
43
|
+
"Be cautious with commands that modify infrastructure or deploy to production.",
|
|
44
|
+
],
|
|
45
|
+
allow: [
|
|
46
|
+
"Reading files and searching code is always allowed.",
|
|
47
|
+
"Writing and editing files within the current project directory is allowed.",
|
|
48
|
+
"Running standard build, test, and lint commands is allowed.",
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function expandDefaults(arr: string[] | undefined, builtin: string[]): string[] {
|
|
53
|
+
if (!Array.isArray(arr)) return [...builtin];
|
|
54
|
+
const result: string[] = [];
|
|
55
|
+
for (const item of arr) {
|
|
56
|
+
if (item === "$defaults") {
|
|
57
|
+
result.push(...builtin);
|
|
58
|
+
} else {
|
|
59
|
+
result.push(item);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isValidMode(mode: unknown): mode is PermissionMode {
|
|
66
|
+
return mode === "off" || mode === "auto" || mode === "dontAsk";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sanitizeString(value: unknown): string | null {
|
|
70
|
+
if (typeof value === "string") return value;
|
|
71
|
+
if (value === null) return null;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sanitizeNumber(value: unknown, fallback: number): number {
|
|
76
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
77
|
+
return fallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isValidClassifierToolMode(value: unknown): value is ClassifierToolMode {
|
|
81
|
+
return value === "force" || value === "required" || value === "auto";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveConfig(
|
|
85
|
+
globalSettings?: AutoModeSettings,
|
|
86
|
+
projectSettings?: AutoModeSettings,
|
|
87
|
+
): ResolvedConfig {
|
|
88
|
+
const merged: ResolvedConfig = {
|
|
89
|
+
defaultMode: DEFAULT_MODE,
|
|
90
|
+
classifier: {
|
|
91
|
+
model: null,
|
|
92
|
+
prompt: null,
|
|
93
|
+
environment: ["$defaults"],
|
|
94
|
+
hardDeny: ["$defaults"],
|
|
95
|
+
softDeny: ["$defaults"],
|
|
96
|
+
allow: ["$defaults"],
|
|
97
|
+
timeoutMs: DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
|
98
|
+
toolMode: DEFAULT_CLASSIFIER_TOOL_MODE,
|
|
99
|
+
},
|
|
100
|
+
denyAndContinue: {
|
|
101
|
+
maxConsecutiveDenials: DEFAULT_MAX_CONSECUTIVE,
|
|
102
|
+
maxTotalDenials: DEFAULT_MAX_TOTAL,
|
|
103
|
+
},
|
|
104
|
+
tools: { ...DEFAULT_TOOLS },
|
|
105
|
+
protectedPaths: [...DEFAULT_PROTECTED_PATHS],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function mergeLayer(settings: AutoModeSettings | undefined) {
|
|
109
|
+
if (!settings) return;
|
|
110
|
+
if (isValidMode(settings.defaultMode)) {
|
|
111
|
+
merged.defaultMode = settings.defaultMode;
|
|
112
|
+
}
|
|
113
|
+
if (settings.classifier) {
|
|
114
|
+
merged.classifier.model = sanitizeString(settings.classifier.model) ?? merged.classifier.model;
|
|
115
|
+
merged.classifier.prompt = sanitizeString(settings.classifier.prompt) ?? merged.classifier.prompt;
|
|
116
|
+
if (Array.isArray(settings.classifier.environment)) {
|
|
117
|
+
merged.classifier.environment = settings.classifier.environment;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(settings.classifier.hardDeny)) {
|
|
120
|
+
merged.classifier.hardDeny = settings.classifier.hardDeny;
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(settings.classifier.softDeny)) {
|
|
123
|
+
merged.classifier.softDeny = settings.classifier.softDeny;
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(settings.classifier.allow)) {
|
|
126
|
+
merged.classifier.allow = settings.classifier.allow;
|
|
127
|
+
}
|
|
128
|
+
if (typeof settings.classifier.timeoutMs === "number" && Number.isFinite(settings.classifier.timeoutMs)) {
|
|
129
|
+
merged.classifier.timeoutMs = settings.classifier.timeoutMs;
|
|
130
|
+
}
|
|
131
|
+
if (isValidClassifierToolMode(settings.classifier.toolMode)) {
|
|
132
|
+
merged.classifier.toolMode = settings.classifier.toolMode;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (settings.denyAndContinue) {
|
|
136
|
+
merged.denyAndContinue.maxConsecutiveDenials = sanitizeNumber(
|
|
137
|
+
settings.denyAndContinue.maxConsecutiveDenials,
|
|
138
|
+
DEFAULT_MAX_CONSECUTIVE,
|
|
139
|
+
);
|
|
140
|
+
merged.denyAndContinue.maxTotalDenials = sanitizeNumber(
|
|
141
|
+
settings.denyAndContinue.maxTotalDenials,
|
|
142
|
+
DEFAULT_MAX_TOTAL,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (settings.tools) {
|
|
146
|
+
if (Array.isArray(settings.tools.alwaysAllow)) merged.tools.alwaysAllow = settings.tools.alwaysAllow;
|
|
147
|
+
if (Array.isArray(settings.tools.allowInProject)) merged.tools.allowInProject = settings.tools.allowInProject;
|
|
148
|
+
if (Array.isArray(settings.tools.alwaysEvaluate)) merged.tools.alwaysEvaluate = settings.tools.alwaysEvaluate;
|
|
149
|
+
if (Array.isArray(settings.tools.alwaysBlock)) merged.tools.alwaysBlock = settings.tools.alwaysBlock;
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(settings.protectedPaths)) {
|
|
152
|
+
merged.protectedPaths = settings.protectedPaths;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
mergeLayer(globalSettings);
|
|
157
|
+
mergeLayer(projectSettings);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
defaultMode: merged.defaultMode,
|
|
161
|
+
classifier: {
|
|
162
|
+
model: merged.classifier.model,
|
|
163
|
+
prompt: merged.classifier.prompt,
|
|
164
|
+
environment: expandDefaults(merged.classifier.environment, BUILTIN_DEFAULTS.environment),
|
|
165
|
+
hardDeny: expandDefaults(merged.classifier.hardDeny, BUILTIN_DEFAULTS.hardDeny),
|
|
166
|
+
softDeny: expandDefaults(merged.classifier.softDeny, BUILTIN_DEFAULTS.softDeny),
|
|
167
|
+
allow: expandDefaults(merged.classifier.allow, BUILTIN_DEFAULTS.allow),
|
|
168
|
+
timeoutMs: merged.classifier.timeoutMs,
|
|
169
|
+
toolMode: merged.classifier.toolMode,
|
|
170
|
+
},
|
|
171
|
+
denyAndContinue: {
|
|
172
|
+
maxConsecutiveDenials: merged.denyAndContinue.maxConsecutiveDenials,
|
|
173
|
+
maxTotalDenials: merged.denyAndContinue.maxTotalDenials,
|
|
174
|
+
},
|
|
175
|
+
tools: {
|
|
176
|
+
alwaysAllow: merged.tools.alwaysAllow,
|
|
177
|
+
allowInProject: merged.tools.allowInProject,
|
|
178
|
+
alwaysEvaluate: merged.tools.alwaysEvaluate,
|
|
179
|
+
alwaysBlock: merged.tools.alwaysBlock,
|
|
180
|
+
},
|
|
181
|
+
protectedPaths: merged.protectedPaths,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface LoadConfigOptions {
|
|
186
|
+
includeProject: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function loadConfig(cwd: string, homeDir = os.homedir(), options: LoadConfigOptions): ResolvedConfig {
|
|
190
|
+
let globalSettings: AutoModeSettings | undefined;
|
|
191
|
+
let projectSettings: AutoModeSettings | undefined;
|
|
192
|
+
|
|
193
|
+
const globalSettingsPath = path.join(homeDir, ".pi", "agent", "settings.json");
|
|
194
|
+
try {
|
|
195
|
+
const raw = fs.readFileSync(globalSettingsPath, "utf-8");
|
|
196
|
+
const parsed = JSON.parse(raw);
|
|
197
|
+
if (parsed && typeof parsed === "object" && "autoMode" in parsed) {
|
|
198
|
+
globalSettings = parsed.autoMode as AutoModeSettings;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// file doesn't exist or is malformed — proceed without global settings
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (options.includeProject) {
|
|
205
|
+
const projectSettingsPath = path.join(cwd, ".pi", "settings.json");
|
|
206
|
+
try {
|
|
207
|
+
const raw = fs.readFileSync(projectSettingsPath, "utf-8");
|
|
208
|
+
const parsed = JSON.parse(raw);
|
|
209
|
+
if (parsed && typeof parsed === "object" && "autoMode" in parsed) {
|
|
210
|
+
projectSettings = parsed.autoMode as AutoModeSettings;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// file doesn't exist or is malformed — proceed without project settings
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return resolveConfig(globalSettings, projectSettings);
|
|
218
|
+
}
|
package/src/decision.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { PermissionMode, ResolvedConfig } from "./types";
|
|
4
|
+
import { evaluateTier } from "./tiers";
|
|
5
|
+
import { classify } from "./classifier";
|
|
6
|
+
|
|
7
|
+
export interface DecisionAllow {
|
|
8
|
+
allow: true;
|
|
9
|
+
}
|
|
10
|
+
export interface DecisionBlock {
|
|
11
|
+
block: true;
|
|
12
|
+
reason: string;
|
|
13
|
+
}
|
|
14
|
+
export type DecisionResult = DecisionAllow | DecisionBlock;
|
|
15
|
+
|
|
16
|
+
export async function makeDecision(
|
|
17
|
+
mode: PermissionMode,
|
|
18
|
+
toolName: string,
|
|
19
|
+
input: Record<string, unknown>,
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
config: ResolvedConfig,
|
|
22
|
+
transcript: Message[],
|
|
23
|
+
currentMode: string,
|
|
24
|
+
): Promise<DecisionResult> {
|
|
25
|
+
const tier = evaluateTier(toolName, input, ctx.cwd, config);
|
|
26
|
+
|
|
27
|
+
// Protected paths are blocked unconditionally in all modes
|
|
28
|
+
if (tier.kind === "block") {
|
|
29
|
+
return { block: true, reason: tier.reason };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (mode === "off") {
|
|
33
|
+
return { allow: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (tier.kind === "allow") {
|
|
37
|
+
return { allow: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// tier.kind === "evaluate"
|
|
41
|
+
if (mode === "dontAsk") {
|
|
42
|
+
return { block: true, reason: "Blocked: tool not in auto-allow tier (dontAsk mode)" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// auto mode: call the real classifier
|
|
46
|
+
try {
|
|
47
|
+
const classifierDecision = await classify(config, toolName, input, transcript, ctx, currentMode);
|
|
48
|
+
if (classifierDecision.decision === "allow") {
|
|
49
|
+
return { allow: true };
|
|
50
|
+
}
|
|
51
|
+
return { block: true, reason: classifierDecision.reason };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
if (ctx.hasUI) {
|
|
55
|
+
const ok = await ctx.ui.confirm(
|
|
56
|
+
"Classifier failed",
|
|
57
|
+
`${message}\n\nAllow this action?`,
|
|
58
|
+
);
|
|
59
|
+
if (ok) {
|
|
60
|
+
return { allow: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { block: true, reason: message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export function createDenyContinueManager() {
|
|
4
|
+
let consecutiveDenials = 0;
|
|
5
|
+
let totalDenials = 0;
|
|
6
|
+
const blockedToolCalls = new Map<string, string>();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
buildDenialMessage(reason: string): string {
|
|
10
|
+
return [
|
|
11
|
+
"This action was blocked by the permission gate.",
|
|
12
|
+
"",
|
|
13
|
+
`Reason: ${reason}`,
|
|
14
|
+
"",
|
|
15
|
+
"Please try a different, safer approach.",
|
|
16
|
+
].join("\n");
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
recordBlock(toolCallId: string, reason: string) {
|
|
20
|
+
consecutiveDenials++;
|
|
21
|
+
totalDenials++;
|
|
22
|
+
blockedToolCalls.set(toolCallId, reason);
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
recordAllow() {
|
|
26
|
+
consecutiveDenials = 0;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
isBlocked(toolCallId: string): boolean {
|
|
30
|
+
return blockedToolCalls.has(toolCallId);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
getReason(toolCallId: string): string | undefined {
|
|
34
|
+
return blockedToolCalls.get(toolCallId);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
consumeBlocked(toolCallId: string): string | undefined {
|
|
38
|
+
const reason = blockedToolCalls.get(toolCallId);
|
|
39
|
+
blockedToolCalls.delete(toolCallId);
|
|
40
|
+
return reason;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
getConsecutiveDenials(): number {
|
|
44
|
+
return consecutiveDenials;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
getTotalDenials(): number {
|
|
48
|
+
return totalDenials;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
isThresholdBreached(config: ResolvedConfig): boolean {
|
|
52
|
+
return (
|
|
53
|
+
consecutiveDenials >= config.denyAndContinue.maxConsecutiveDenials ||
|
|
54
|
+
totalDenials >= config.denyAndContinue.maxTotalDenials
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
reset() {
|
|
59
|
+
consecutiveDenials = 0;
|
|
60
|
+
totalDenials = 0;
|
|
61
|
+
blockedToolCalls.clear();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type DenyContinueManager = ReturnType<typeof createDenyContinueManager>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import { loadConfig } from "./config";
|
|
4
|
+
import type { ResolvedConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface EffectiveConfigResult {
|
|
7
|
+
config: ResolvedConfig;
|
|
8
|
+
includesProject: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveEffectiveConfig(ctx: ExtensionContext, homeDir = os.homedir()): EffectiveConfigResult {
|
|
12
|
+
const includesProject = ctx.isProjectTrusted();
|
|
13
|
+
return {
|
|
14
|
+
config: loadConfig(ctx.cwd, homeDir, { includeProject: includesProject }),
|
|
15
|
+
includesProject,
|
|
16
|
+
};
|
|
17
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import { resolveEffectiveConfig } from "./effective-config";
|
|
4
|
+
import { createModeManager } from "./mode";
|
|
5
|
+
import { makeDecision } from "./decision";
|
|
6
|
+
import { createDenyContinueManager } from "./deny-continue";
|
|
7
|
+
import { getClassifierTranscript } from "./session-context";
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
const modeManager = createModeManager(pi, resolveEffectiveConfig);
|
|
11
|
+
const denyManager = createDenyContinueManager();
|
|
12
|
+
modeManager.setup();
|
|
13
|
+
|
|
14
|
+
pi.on("session_start", async (_event, _ctx) => {
|
|
15
|
+
denyManager.reset();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
19
|
+
const { config } = resolveEffectiveConfig(ctx);
|
|
20
|
+
const transcript: Message[] = getClassifierTranscript(ctx);
|
|
21
|
+
|
|
22
|
+
const decision = await makeDecision(
|
|
23
|
+
modeManager.getMode(),
|
|
24
|
+
event.toolName,
|
|
25
|
+
event.input,
|
|
26
|
+
ctx,
|
|
27
|
+
config,
|
|
28
|
+
transcript,
|
|
29
|
+
modeManager.getMode(),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if ("allow" in decision) {
|
|
33
|
+
denyManager.recordAllow();
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Block
|
|
38
|
+
denyManager.recordBlock(event.toolCallId, decision.reason);
|
|
39
|
+
|
|
40
|
+
if (ctx.hasUI) {
|
|
41
|
+
ctx.ui.notify(`Blocked: ${decision.reason}`, "warning");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (denyManager.isThresholdBreached(config)) {
|
|
45
|
+
if (ctx.hasUI) {
|
|
46
|
+
const ok = await ctx.ui.confirm(
|
|
47
|
+
"Auto-mode threshold reached",
|
|
48
|
+
`The agent has been blocked ${denyManager.getConsecutiveDenials()} consecutive times. Allow this action?`,
|
|
49
|
+
);
|
|
50
|
+
if (ok) {
|
|
51
|
+
denyManager.recordAllow();
|
|
52
|
+
denyManager.consumeBlocked(event.toolCallId);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
ctx.shutdown();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { block: true, reason: decision.reason };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
64
|
+
const reason = denyManager.consumeBlocked(event.toolCallId);
|
|
65
|
+
if (reason) {
|
|
66
|
+
const message = denyManager.buildDenialMessage(reason);
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text", text: message }],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
});
|
|
73
|
+
}
|
package/src/mode.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { EffectiveConfigResult } from "./effective-config";
|
|
3
|
+
import type { PermissionMode } from "./types";
|
|
4
|
+
import { handleAutoModeCommand } from "./config-command";
|
|
5
|
+
import { getActiveAutoModeStateEntries } from "./session-context";
|
|
6
|
+
|
|
7
|
+
const MODES: PermissionMode[] = ["off", "auto", "dontAsk"];
|
|
8
|
+
|
|
9
|
+
export function isValidMode(mode: unknown): mode is PermissionMode {
|
|
10
|
+
return mode === "off" || mode === "auto" || mode === "dontAsk";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveInitialMode(
|
|
14
|
+
flagValue: string | boolean | undefined,
|
|
15
|
+
sessionEntries: Array<{ customType?: string; data?: unknown }>,
|
|
16
|
+
settingsDefault: PermissionMode,
|
|
17
|
+
): PermissionMode {
|
|
18
|
+
// 1. CLI flag
|
|
19
|
+
if (typeof flagValue === "string" && isValidMode(flagValue)) {
|
|
20
|
+
return flagValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. Session state (most recent)
|
|
24
|
+
let sessionMode: PermissionMode | undefined;
|
|
25
|
+
for (const entry of sessionEntries) {
|
|
26
|
+
if (entry.customType === "auto-mode-state") {
|
|
27
|
+
const data = entry.data as Record<string, unknown> | undefined;
|
|
28
|
+
if (data && isValidMode(data.mode)) {
|
|
29
|
+
sessionMode = data.mode;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (sessionMode) return sessionMode;
|
|
34
|
+
|
|
35
|
+
// 3. Settings default
|
|
36
|
+
if (isValidMode(settingsDefault)) return settingsDefault;
|
|
37
|
+
|
|
38
|
+
// 4. Off
|
|
39
|
+
return "off";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cycleMode(current: PermissionMode): PermissionMode {
|
|
43
|
+
const idx = MODES.indexOf(current);
|
|
44
|
+
return MODES[(idx + 1) % MODES.length];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ModeManager {
|
|
48
|
+
getMode(): PermissionMode;
|
|
49
|
+
setMode(mode: PermissionMode, ctx: ExtensionContext): void;
|
|
50
|
+
cycleMode(ctx: ExtensionContext): void;
|
|
51
|
+
setup(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createModeManager(
|
|
55
|
+
pi: ExtensionAPI,
|
|
56
|
+
resolveEffectiveConfig: (ctx: ExtensionContext) => EffectiveConfigResult,
|
|
57
|
+
): ModeManager {
|
|
58
|
+
let currentMode: PermissionMode = "off";
|
|
59
|
+
|
|
60
|
+
function updateStatus(ctx: ExtensionContext) {
|
|
61
|
+
ctx.ui.setStatus("auto-mode", `auto-mode: ${currentMode}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function persistAndUpdate(mode: PermissionMode, ctx: ExtensionContext) {
|
|
65
|
+
currentMode = mode;
|
|
66
|
+
pi.appendEntry("auto-mode-state", { mode });
|
|
67
|
+
updateStatus(ctx);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
getMode() {
|
|
72
|
+
return currentMode;
|
|
73
|
+
},
|
|
74
|
+
setMode(mode, ctx) {
|
|
75
|
+
persistAndUpdate(mode, ctx);
|
|
76
|
+
},
|
|
77
|
+
cycleMode(ctx) {
|
|
78
|
+
persistAndUpdate(cycleMode(currentMode), ctx);
|
|
79
|
+
},
|
|
80
|
+
setup() {
|
|
81
|
+
pi.registerFlag("auto-mode", {
|
|
82
|
+
description: "Set auto mode (off, auto, dontAsk)",
|
|
83
|
+
type: "string",
|
|
84
|
+
default: undefined,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.registerCommand("auto-mode", {
|
|
88
|
+
description: "Configure auto-mode, or cycle states with no arguments",
|
|
89
|
+
handler: async (args, ctx) => {
|
|
90
|
+
if (!String(args ?? "").trim()) {
|
|
91
|
+
persistAndUpdate(cycleMode(currentMode), ctx);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await handleAutoModeCommand(String(args), ctx, {
|
|
95
|
+
resolveEffectiveConfig,
|
|
96
|
+
getMode: () => currentMode,
|
|
97
|
+
setMode: (mode, commandCtx) => persistAndUpdate(mode, commandCtx),
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.registerShortcut("ctrl+shift+a", {
|
|
103
|
+
description: "Toggle auto-mode",
|
|
104
|
+
handler: async (ctx) => {
|
|
105
|
+
persistAndUpdate(cycleMode(currentMode), ctx);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
110
|
+
const flagValue = pi.getFlag("auto-mode");
|
|
111
|
+
|
|
112
|
+
// In non-interactive mode without explicit flag, force off
|
|
113
|
+
if (!ctx.hasUI && !flagValue) {
|
|
114
|
+
currentMode = "off";
|
|
115
|
+
updateStatus(ctx);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const effectiveConfig = resolveEffectiveConfig(ctx).config;
|
|
120
|
+
const mode = resolveInitialMode(
|
|
121
|
+
flagValue,
|
|
122
|
+
getActiveAutoModeStateEntries(ctx),
|
|
123
|
+
effectiveConfig.defaultMode,
|
|
124
|
+
);
|
|
125
|
+
currentMode = mode;
|
|
126
|
+
updateStatus(ctx);
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { buildSessionContext, type CustomEntry, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
function isUserMessage(message: unknown): message is Message {
|
|
5
|
+
return (
|
|
6
|
+
typeof message === "object" &&
|
|
7
|
+
message !== null &&
|
|
8
|
+
(message as { role?: unknown }).role === "user"
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AutoModeStateEntry {
|
|
13
|
+
customType: "auto-mode-state";
|
|
14
|
+
data?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getClassifierTranscript(ctx: ExtensionContext): Message[] {
|
|
18
|
+
return buildSessionContext(ctx.sessionManager.getBranch()).messages.filter(isUserMessage);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getActiveAutoModeStateEntries(ctx: ExtensionContext): AutoModeStateEntry[] {
|
|
22
|
+
return ctx.sessionManager
|
|
23
|
+
.getBranch()
|
|
24
|
+
.filter(
|
|
25
|
+
(entry): entry is CustomEntry & { customType: "auto-mode-state" } =>
|
|
26
|
+
entry.type === "custom" && entry.customType === "auto-mode-state",
|
|
27
|
+
)
|
|
28
|
+
.map((entry) => ({ customType: entry.customType, data: entry.data }));
|
|
29
|
+
}
|
package/src/tiers.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { ResolvedConfig } from "./types";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG: Pick<ResolvedConfig, "tools" | "protectedPaths"> = {
|
|
5
|
+
protectedPaths: [".git/", ".env", ".env.", ".pi/", "node_modules/", "~/.bashrc", "~/.zshrc", "~/.ssh/"],
|
|
6
|
+
tools: {
|
|
7
|
+
alwaysAllow: ["read", "grep", "find", "ls"],
|
|
8
|
+
allowInProject: ["write", "edit"],
|
|
9
|
+
alwaysEvaluate: [],
|
|
10
|
+
alwaysBlock: [],
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface TierResultAllow {
|
|
15
|
+
kind: "allow";
|
|
16
|
+
}
|
|
17
|
+
export interface TierResultBlock {
|
|
18
|
+
kind: "block";
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
export interface TierResultEvaluate {
|
|
22
|
+
kind: "evaluate";
|
|
23
|
+
}
|
|
24
|
+
export type TierResult = TierResultAllow | TierResultBlock | TierResultEvaluate;
|
|
25
|
+
|
|
26
|
+
export function isProtectedPath(filePath: string, protectedPatterns = DEFAULT_CONFIG.protectedPaths): boolean {
|
|
27
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
28
|
+
for (const pattern of protectedPatterns) {
|
|
29
|
+
if (pattern.endsWith("/")) {
|
|
30
|
+
if (normalized.includes(pattern)) return true;
|
|
31
|
+
} else if (pattern.startsWith(".env.")) {
|
|
32
|
+
if (normalized.includes(pattern)) return true;
|
|
33
|
+
} else if (pattern === ".env") {
|
|
34
|
+
const segments = normalized.split("/");
|
|
35
|
+
const filename = segments[segments.length - 1];
|
|
36
|
+
if (filename === ".env" || filename.startsWith(".env.")) return true;
|
|
37
|
+
} else {
|
|
38
|
+
if (normalized.includes(pattern)) return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getPathFromInput(toolName: string, input: Record<string, unknown>): string | undefined {
|
|
45
|
+
if (typeof input.path === "string") return input.path;
|
|
46
|
+
if (toolName === "bash" && typeof input.command === "string") {
|
|
47
|
+
return input.command;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isWithinCwd(filePath: string, cwd: string): boolean {
|
|
53
|
+
const resolved = path.resolve(cwd, filePath);
|
|
54
|
+
const resolvedCwd = path.resolve(cwd);
|
|
55
|
+
const relative = path.relative(resolvedCwd, resolved);
|
|
56
|
+
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function evaluateTier(
|
|
60
|
+
toolName: string,
|
|
61
|
+
input: Record<string, unknown>,
|
|
62
|
+
cwd: string,
|
|
63
|
+
config: Pick<ResolvedConfig, "tools" | "protectedPaths"> = DEFAULT_CONFIG,
|
|
64
|
+
): TierResult {
|
|
65
|
+
const effective = {
|
|
66
|
+
protectedPaths: config.protectedPaths ?? DEFAULT_CONFIG.protectedPaths,
|
|
67
|
+
tools: { ...DEFAULT_CONFIG.tools, ...(config.tools ?? {}) },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (effective.tools.alwaysBlock.includes(toolName)) {
|
|
71
|
+
return { kind: "block", reason: `Blocked: tool "${toolName}" is always blocked` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const targetPath = getPathFromInput(toolName, input);
|
|
75
|
+
|
|
76
|
+
if (targetPath && isProtectedPath(targetPath, effective.protectedPaths)) {
|
|
77
|
+
return { kind: "block", reason: `Blocked: path "${targetPath}" is protected` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (effective.tools.alwaysAllow.includes(toolName)) {
|
|
81
|
+
return { kind: "allow" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!effective.tools.alwaysEvaluate.includes(toolName) && effective.tools.allowInProject.includes(toolName) && targetPath) {
|
|
85
|
+
if (isWithinCwd(targetPath, cwd)) {
|
|
86
|
+
return { kind: "allow" };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { kind: "evaluate" };
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type PermissionMode = "off" | "auto" | "dontAsk";
|
|
2
|
+
|
|
3
|
+
export interface ClassifierDecision {
|
|
4
|
+
decision: "allow" | "block";
|
|
5
|
+
reason: string;
|
|
6
|
+
confidence: "high" | "medium" | "low";
|
|
7
|
+
category:
|
|
8
|
+
| "user_intent"
|
|
9
|
+
| "security"
|
|
10
|
+
| "data_loss"
|
|
11
|
+
| "scope_creep"
|
|
12
|
+
| "infrastructure"
|
|
13
|
+
| "credential_access"
|
|
14
|
+
| "other";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DenyAndContinueConfig {
|
|
18
|
+
maxConsecutiveDenials: number;
|
|
19
|
+
maxTotalDenials: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ClassifierToolMode = "force" | "required" | "auto";
|
|
23
|
+
|
|
24
|
+
export interface ClassifierConfig {
|
|
25
|
+
model: string | null;
|
|
26
|
+
prompt: string | null;
|
|
27
|
+
environment: string[];
|
|
28
|
+
hardDeny: string[];
|
|
29
|
+
softDeny: string[];
|
|
30
|
+
allow: string[];
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
toolMode?: ClassifierToolMode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToolTierConfig {
|
|
36
|
+
alwaysAllow: string[];
|
|
37
|
+
allowInProject: string[];
|
|
38
|
+
alwaysEvaluate: string[];
|
|
39
|
+
alwaysBlock: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AutoModeSettings {
|
|
43
|
+
defaultMode: PermissionMode;
|
|
44
|
+
classifier: ClassifierConfig;
|
|
45
|
+
denyAndContinue: DenyAndContinueConfig;
|
|
46
|
+
tools?: ToolTierConfig;
|
|
47
|
+
protectedPaths?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolvedConfig {
|
|
51
|
+
defaultMode: PermissionMode;
|
|
52
|
+
classifier: Required<ClassifierConfig>;
|
|
53
|
+
denyAndContinue: Required<DenyAndContinueConfig>;
|
|
54
|
+
tools: Required<ToolTierConfig>;
|
|
55
|
+
protectedPaths: string[];
|
|
56
|
+
}
|