@preapexis/pi-kit 1.1.1 → 1.1.3
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.
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
type EventContext = Parameters<Parameters<ExtensionAPI["on"]>[1]>[1];
|
|
5
|
+
|
|
6
|
+
type ToolDecision =
|
|
7
|
+
| {
|
|
8
|
+
block: true;
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
| undefined;
|
|
12
|
+
|
|
13
|
+
type InputRecord = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = "workspace-guard";
|
|
16
|
+
|
|
17
|
+
export default function (pi: ExtensionAPI): void {
|
|
18
|
+
let workspaceRoot: string | null = null;
|
|
19
|
+
|
|
20
|
+
function normalize(filePath: string): string {
|
|
21
|
+
return path.resolve(filePath);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isInsideWorkspace(targetPath: string): boolean {
|
|
25
|
+
if (!workspaceRoot) return true;
|
|
26
|
+
|
|
27
|
+
const root = normalize(workspaceRoot);
|
|
28
|
+
const target = normalize(targetPath);
|
|
29
|
+
|
|
30
|
+
return target === root || target.startsWith(root + path.sep);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function inputRecord(input: unknown): InputRecord {
|
|
34
|
+
if (input && typeof input === "object") {
|
|
35
|
+
return input as InputRecord;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getPathValue(input: InputRecord): string | undefined {
|
|
42
|
+
const keys = [
|
|
43
|
+
"path",
|
|
44
|
+
"filePath",
|
|
45
|
+
"filepath",
|
|
46
|
+
"dir",
|
|
47
|
+
"directory",
|
|
48
|
+
"cwd",
|
|
49
|
+
"root"
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const key of keys) {
|
|
53
|
+
const value = input[key];
|
|
54
|
+
|
|
55
|
+
if (typeof value === "string" && value.trim()) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveFromWorkspaceOrCwd(ctx: EventContext, value: string): string {
|
|
64
|
+
if (path.isAbsolute(value)) {
|
|
65
|
+
return normalize(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return normalize(path.join(ctx.cwd, value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function commandLooksOutside(command: string): boolean {
|
|
72
|
+
const patterns = [
|
|
73
|
+
/\bcd\s+\.\./i,
|
|
74
|
+
/\bcd\s+["']?\//i,
|
|
75
|
+
/\bcd\s+["']?[a-zA-Z]:\\/i,
|
|
76
|
+
/\bpushd\s+\.\./i,
|
|
77
|
+
/\bpushd\s+["']?\//i,
|
|
78
|
+
/\bpushd\s+["']?[a-zA-Z]:\\/i,
|
|
79
|
+
/\bgit\s+-C\s+\.\./i,
|
|
80
|
+
/\bnpm\s+--prefix\s+\.\./i,
|
|
81
|
+
/\bpnpm\s+-C\s+\.\./i,
|
|
82
|
+
/\byarn\s+--cwd\s+\.\./i,
|
|
83
|
+
/\bSet-Location\s+\.\./i,
|
|
84
|
+
/\bsl\s+\.\./i
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
return patterns.some((pattern) => pattern.test(command));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function confirmOutsideAccess(
|
|
91
|
+
ctx: EventContext,
|
|
92
|
+
action: string,
|
|
93
|
+
targetPath: string
|
|
94
|
+
): Promise<ToolDecision> {
|
|
95
|
+
if (!workspaceRoot) return undefined;
|
|
96
|
+
|
|
97
|
+
if (isInsideWorkspace(targetPath)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const message = [
|
|
102
|
+
`Workspace guard blocked outside-${action} access.`,
|
|
103
|
+
"",
|
|
104
|
+
`Workspace root: ${workspaceRoot}`,
|
|
105
|
+
`Requested path: ${targetPath}`,
|
|
106
|
+
"",
|
|
107
|
+
"Allow this one time?"
|
|
108
|
+
].join("\n");
|
|
109
|
+
|
|
110
|
+
if (!ctx.hasUI) {
|
|
111
|
+
return {
|
|
112
|
+
block: true,
|
|
113
|
+
reason: `Outside workspace ${action} blocked: ${targetPath}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ok = await ctx.ui.confirm("Outside workspace access", message);
|
|
118
|
+
|
|
119
|
+
if (!ok) {
|
|
120
|
+
return {
|
|
121
|
+
block: true,
|
|
122
|
+
reason: `Outside workspace ${action} cancelled by user.`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
130
|
+
workspaceRoot = normalize(ctx.cwd);
|
|
131
|
+
|
|
132
|
+
if (ctx.hasUI) {
|
|
133
|
+
ctx.ui.setStatus(STATUS_KEY, "workspace: locked");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.on("before_agent_start", async (event) => {
|
|
138
|
+
return {
|
|
139
|
+
systemPrompt:
|
|
140
|
+
event.systemPrompt +
|
|
141
|
+
`
|
|
142
|
+
|
|
143
|
+
Workspace boundary rules:
|
|
144
|
+
- Treat the current working directory as the workspace root.
|
|
145
|
+
- Do not read, search, edit, write, delete, or inspect files outside the current workspace unless the user explicitly asks.
|
|
146
|
+
- Before using any path outside the workspace, ask the user for permission.
|
|
147
|
+
- Prefer relative paths inside the current repository.
|
|
148
|
+
- Do not run commands that cd, pushd, Set-Location, or otherwise move outside the workspace unless explicitly requested.
|
|
149
|
+
- If outside-workspace context seems useful, ask first instead of checking it silently.
|
|
150
|
+
`
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
155
|
+
if (!workspaceRoot) {
|
|
156
|
+
workspaceRoot = normalize(ctx.cwd);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const input = inputRecord(event.input);
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
event.toolName === "read" ||
|
|
163
|
+
event.toolName === "write" ||
|
|
164
|
+
event.toolName === "edit" ||
|
|
165
|
+
event.toolName === "ls" ||
|
|
166
|
+
event.toolName === "grep" ||
|
|
167
|
+
event.toolName === "find"
|
|
168
|
+
) {
|
|
169
|
+
const rawPath = getPathValue(input);
|
|
170
|
+
|
|
171
|
+
if (rawPath) {
|
|
172
|
+
const targetPath = resolveFromWorkspaceOrCwd(ctx, rawPath);
|
|
173
|
+
|
|
174
|
+
return await confirmOutsideAccess(ctx, event.toolName, targetPath);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (event.toolName === "bash") {
|
|
179
|
+
const command = String(input.command ?? "");
|
|
180
|
+
|
|
181
|
+
const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
|
|
182
|
+
|
|
183
|
+
if (rawCwd) {
|
|
184
|
+
const targetCwd = resolveFromWorkspaceOrCwd(ctx, rawCwd);
|
|
185
|
+
const decision = await confirmOutsideAccess(ctx, "command", targetCwd);
|
|
186
|
+
|
|
187
|
+
if (decision) return decision;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (commandLooksOutside(command)) {
|
|
191
|
+
if (!ctx.hasUI) {
|
|
192
|
+
return {
|
|
193
|
+
block: true,
|
|
194
|
+
reason: "Command that may leave the workspace was blocked."
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const ok = await ctx.ui.confirm(
|
|
199
|
+
"Command may leave workspace",
|
|
200
|
+
[
|
|
201
|
+
"This command appears to move outside the current workspace.",
|
|
202
|
+
"",
|
|
203
|
+
`Workspace root: ${workspaceRoot}`,
|
|
204
|
+
"",
|
|
205
|
+
command,
|
|
206
|
+
"",
|
|
207
|
+
"Allow this one time?"
|
|
208
|
+
].join("\n")
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (!ok) {
|
|
212
|
+
return {
|
|
213
|
+
block: true,
|
|
214
|
+
reason: "Outside-workspace command cancelled by user."
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return undefined;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pi.registerCommand("workspace-root", {
|
|
224
|
+
description: "Show the current locked workspace root",
|
|
225
|
+
handler: async (_args, ctx) => {
|
|
226
|
+
if (!ctx.hasUI) return;
|
|
227
|
+
|
|
228
|
+
ctx.ui.notify(
|
|
229
|
+
`Workspace root:\n\n${workspaceRoot ?? normalize(ctx.cwd)}`,
|
|
230
|
+
"info"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
package/package.json
CHANGED
package/scripts/git-release.mjs
CHANGED
package/themes/latte-review.json
CHANGED
package/themes/safe-dark.json
CHANGED