pi-agent-toolkit 0.1.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/dist/dotfiles/AGENTS.md +197 -0
- package/dist/dotfiles/APPEND_SYSTEM.md +78 -0
- package/dist/dotfiles/agent-modes.json +12 -0
- package/dist/dotfiles/agent-skills/exa-search/.env.example +4 -0
- package/dist/dotfiles/agent-skills/exa-search/SKILL.md +234 -0
- package/dist/dotfiles/agent-skills/exa-search/scripts/exa-api.cjs +197 -0
- package/dist/dotfiles/auth.json.template +5 -0
- package/dist/dotfiles/damage-control-rules.yaml +318 -0
- package/dist/dotfiles/extensions/btw.ts +1031 -0
- package/dist/dotfiles/extensions/commit-approval.ts +590 -0
- package/dist/dotfiles/extensions/context.ts +578 -0
- package/dist/dotfiles/extensions/control.ts +1748 -0
- package/dist/dotfiles/extensions/damage-control/index.ts +543 -0
- package/dist/dotfiles/extensions/damage-control/node_modules/.package-lock.json +22 -0
- package/dist/dotfiles/extensions/damage-control/package-lock.json +28 -0
- package/dist/dotfiles/extensions/damage-control/package.json +7 -0
- package/dist/dotfiles/extensions/dirty-repo-guard.ts +56 -0
- package/dist/dotfiles/extensions/exa-enforce.ts +51 -0
- package/dist/dotfiles/extensions/exa-search-tool.ts +384 -0
- package/dist/dotfiles/extensions/execute-command/index.ts +82 -0
- package/dist/dotfiles/extensions/files.ts +1112 -0
- package/dist/dotfiles/extensions/loop.ts +446 -0
- package/dist/dotfiles/extensions/pr-approval.ts +730 -0
- package/dist/dotfiles/extensions/qna-interactive.ts +532 -0
- package/dist/dotfiles/extensions/question-mode.ts +242 -0
- package/dist/dotfiles/extensions/require-session-name-on-exit.ts +141 -0
- package/dist/dotfiles/extensions/review.ts +2091 -0
- package/dist/dotfiles/extensions/session-breakdown.ts +1629 -0
- package/dist/dotfiles/extensions/term-notify.ts +150 -0
- package/dist/dotfiles/extensions/tilldone.ts +527 -0
- package/dist/dotfiles/extensions/todos.ts +2082 -0
- package/dist/dotfiles/extensions/tools.ts +146 -0
- package/dist/dotfiles/extensions/uv.ts +123 -0
- package/dist/dotfiles/global-skills/brainstorm/SKILL.md +10 -0
- package/dist/dotfiles/global-skills/cli-detector/SKILL.md +192 -0
- package/dist/dotfiles/global-skills/gh-issue-creator/SKILL.md +173 -0
- package/dist/dotfiles/global-skills/google-chat-cards-v2/SKILL.md +237 -0
- package/dist/dotfiles/global-skills/google-chat-cards-v2/references/bridge_tap_implementation.md +466 -0
- package/dist/dotfiles/global-skills/technical-docs/SKILL.md +204 -0
- package/dist/dotfiles/global-skills/technical-docs/references/diagrams.md +168 -0
- package/dist/dotfiles/global-skills/technical-docs/references/examples.md +449 -0
- package/dist/dotfiles/global-skills/technical-docs/scripts/validate_docs.py +352 -0
- package/dist/dotfiles/global-skills/whats-new/SKILL.md +159 -0
- package/dist/dotfiles/intercepted-commands/pip +7 -0
- package/dist/dotfiles/intercepted-commands/pip3 +7 -0
- package/dist/dotfiles/intercepted-commands/poetry +10 -0
- package/dist/dotfiles/intercepted-commands/python +104 -0
- package/dist/dotfiles/intercepted-commands/python3 +104 -0
- package/dist/dotfiles/mcp.json.template +32 -0
- package/dist/dotfiles/models.json +27 -0
- package/dist/dotfiles/settings.json +25 -0
- package/dist/index.js +1344 -0
- package/package.json +34 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Damage Control Extension
|
|
3
|
+
*
|
|
4
|
+
* Real-time safety auditing that intercepts dangerous bash patterns and
|
|
5
|
+
* enforces path-based access controls. Rules are loaded from YAML config.
|
|
6
|
+
*
|
|
7
|
+
* Config locations (merged, project-local extends global):
|
|
8
|
+
* ~/.pi/agent/damage-control-rules.yaml (global)
|
|
9
|
+
* .pi/damage-control-rules.yaml (project-local)
|
|
10
|
+
*
|
|
11
|
+
* Commands:
|
|
12
|
+
* /dc - Show loaded rule counts and last block/ask events
|
|
13
|
+
* /dc rules - Show all loaded rules in detail
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { resolve, basename } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { parse as parseYaml } from "yaml";
|
|
22
|
+
|
|
23
|
+
// -- Types ------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface BashPattern {
|
|
26
|
+
pattern: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
ask?: boolean;
|
|
29
|
+
allow?: boolean;
|
|
30
|
+
_compiled?: RegExp;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DamageControlRules {
|
|
34
|
+
bashToolPatterns: BashPattern[];
|
|
35
|
+
zeroAccessPaths: string[];
|
|
36
|
+
askAccessPaths: string[];
|
|
37
|
+
readOnlyPaths: string[];
|
|
38
|
+
noDeletePaths: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface BlockEvent {
|
|
42
|
+
timestamp: number;
|
|
43
|
+
type: "bash" | "path";
|
|
44
|
+
detail: string;
|
|
45
|
+
action: "blocked" | "asked" | "allowed";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// -- Helpers ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const HOME = homedir();
|
|
51
|
+
|
|
52
|
+
function expandHome(p: string): string {
|
|
53
|
+
if (p.startsWith("~/")) return resolve(HOME, p.slice(2));
|
|
54
|
+
if (p === "~") return HOME;
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Simple glob matcher for path rules. Supports:
|
|
60
|
+
* * - matches any sequence (non-slash for basename, any for full)
|
|
61
|
+
* ~/ - home directory expansion
|
|
62
|
+
* trailing / - directory prefix match
|
|
63
|
+
*/
|
|
64
|
+
function pathMatches(filePath: string, pattern: string): boolean {
|
|
65
|
+
const expanded = expandHome(pattern);
|
|
66
|
+
|
|
67
|
+
// Directory rule: match the directory itself or anything beneath it
|
|
68
|
+
if (expanded.endsWith("/")) {
|
|
69
|
+
const dir = expanded.replace(/\/+$/, "");
|
|
70
|
+
const norm = filePath.startsWith("/") ? filePath : resolve(filePath);
|
|
71
|
+
return (
|
|
72
|
+
norm === dir ||
|
|
73
|
+
norm === `${dir}/` ||
|
|
74
|
+
norm.startsWith(`${dir}/`) ||
|
|
75
|
+
norm.includes(`/${dir}/`) ||
|
|
76
|
+
norm.endsWith(`/${dir}`)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Glob pattern (contains *)
|
|
81
|
+
if (expanded.includes("*")) {
|
|
82
|
+
const regexStr = expanded
|
|
83
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
84
|
+
.replace(/\*/g, ".*");
|
|
85
|
+
const regex = new RegExp(`(^|/)${regexStr}$`);
|
|
86
|
+
const norm = filePath.startsWith("/") ? filePath : resolve(filePath);
|
|
87
|
+
// Match against full path and also just the basename
|
|
88
|
+
return regex.test(norm) || regex.test(basename(filePath));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Exact match against basename or full path
|
|
92
|
+
const norm = filePath.startsWith("/") ? filePath : resolve(filePath);
|
|
93
|
+
const name = basename(filePath);
|
|
94
|
+
return name === expanded || norm === expanded || norm.endsWith("/" + expanded);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadRulesFile(path: string): Partial<DamageControlRules> | null {
|
|
98
|
+
if (!existsSync(path)) return null;
|
|
99
|
+
try {
|
|
100
|
+
const content = readFileSync(path, "utf-8");
|
|
101
|
+
return parseYaml(content) as Partial<DamageControlRules>;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function mergeRules(...sources: (Partial<DamageControlRules> | null)[]): DamageControlRules {
|
|
108
|
+
const merged: DamageControlRules = {
|
|
109
|
+
bashToolPatterns: [],
|
|
110
|
+
zeroAccessPaths: [],
|
|
111
|
+
askAccessPaths: [],
|
|
112
|
+
readOnlyPaths: [],
|
|
113
|
+
noDeletePaths: [],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const src of sources) {
|
|
117
|
+
if (!src) continue;
|
|
118
|
+
if (src.bashToolPatterns) merged.bashToolPatterns.push(...src.bashToolPatterns);
|
|
119
|
+
if (src.zeroAccessPaths) merged.zeroAccessPaths.push(...src.zeroAccessPaths);
|
|
120
|
+
if (src.askAccessPaths) merged.askAccessPaths.push(...src.askAccessPaths);
|
|
121
|
+
if (src.readOnlyPaths) merged.readOnlyPaths.push(...src.readOnlyPaths);
|
|
122
|
+
if (src.noDeletePaths) merged.noDeletePaths.push(...src.noDeletePaths);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Deduplicate paths
|
|
126
|
+
merged.zeroAccessPaths = [...new Set(merged.zeroAccessPaths)];
|
|
127
|
+
merged.askAccessPaths = [...new Set(merged.askAccessPaths)];
|
|
128
|
+
merged.readOnlyPaths = [...new Set(merged.readOnlyPaths)];
|
|
129
|
+
merged.noDeletePaths = [...new Set(merged.noDeletePaths)];
|
|
130
|
+
|
|
131
|
+
// Compile regexes
|
|
132
|
+
for (const bp of merged.bashToolPatterns) {
|
|
133
|
+
try {
|
|
134
|
+
bp._compiled = new RegExp(bp.pattern);
|
|
135
|
+
} catch {
|
|
136
|
+
// Skip invalid patterns
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return merged;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -- Extension --------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export default function (pi: ExtensionAPI) {
|
|
146
|
+
let rules: DamageControlRules = mergeRules();
|
|
147
|
+
const recentEvents: BlockEvent[] = [];
|
|
148
|
+
const MAX_RECENT = 20;
|
|
149
|
+
|
|
150
|
+
function recordEvent(evt: BlockEvent) {
|
|
151
|
+
recentEvents.unshift(evt);
|
|
152
|
+
if (recentEvents.length > MAX_RECENT) recentEvents.pop();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function loadAllRules(cwd: string) {
|
|
156
|
+
const globalPath = resolve(HOME, ".pi/agent/damage-control-rules.yaml");
|
|
157
|
+
const projectPath = resolve(cwd, ".pi/damage-control-rules.yaml");
|
|
158
|
+
|
|
159
|
+
const globalRules = loadRulesFile(globalPath);
|
|
160
|
+
const projectRules = loadRulesFile(projectPath);
|
|
161
|
+
|
|
162
|
+
rules = mergeRules(globalRules, projectRules);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -- Bash pattern checking ------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function checkBash(command: string): { match: BashPattern; isAsk: boolean } | null {
|
|
168
|
+
// Pass 1: allow rules -- if any match, the command is explicitly permitted.
|
|
169
|
+
// Allow always wins over block/ask regardless of rule order in YAML.
|
|
170
|
+
for (const bp of rules.bashToolPatterns) {
|
|
171
|
+
if (!bp._compiled || !bp.allow) continue;
|
|
172
|
+
if (bp._compiled.test(command)) return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Pass 2: block/ask rules
|
|
176
|
+
let askMatch: BashPattern | null = null;
|
|
177
|
+
|
|
178
|
+
for (const bp of rules.bashToolPatterns) {
|
|
179
|
+
if (!bp._compiled || bp.allow) continue;
|
|
180
|
+
if (!bp._compiled.test(command)) continue;
|
|
181
|
+
|
|
182
|
+
// Hard-block wins immediately -- no need to check further
|
|
183
|
+
if (!bp.ask) return { match: bp, isAsk: false };
|
|
184
|
+
|
|
185
|
+
// Remember the first ask match as fallback
|
|
186
|
+
if (!askMatch) askMatch = bp;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (askMatch) return { match: askMatch, isAsk: true };
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function extractCommandPathCandidates(command: string): string[] {
|
|
194
|
+
const candidates = new Set<string>();
|
|
195
|
+
const tokens = command.match(/"[^"]*"|'[^']*'|`[^`]*`|\S+/g) ?? [];
|
|
196
|
+
|
|
197
|
+
for (const token of tokens) {
|
|
198
|
+
const stripped = token
|
|
199
|
+
.trim()
|
|
200
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
201
|
+
.replace(/[;,]+$/g, "");
|
|
202
|
+
|
|
203
|
+
if (!stripped || stripped === "-" || stripped.startsWith("-")) continue;
|
|
204
|
+
|
|
205
|
+
candidates.add(stripped);
|
|
206
|
+
|
|
207
|
+
const equalsIndex = stripped.indexOf("=");
|
|
208
|
+
if (equalsIndex > 0 && equalsIndex < stripped.length - 1) {
|
|
209
|
+
candidates.add(stripped.slice(equalsIndex + 1));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return [...candidates];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getBashPathAccess(command: string): { path: string; access: PathAccess } | null {
|
|
217
|
+
for (const candidate of extractCommandPathCandidates(command)) {
|
|
218
|
+
const access = checkPathAccess(candidate);
|
|
219
|
+
if (access === "zero") return { path: candidate, access };
|
|
220
|
+
if (shouldAskPathAccess(candidate)) return { path: candidate, access: "ask" };
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// -- Path access checking -------------------------------------------------
|
|
226
|
+
|
|
227
|
+
type PathAccess = "zero" | "ask" | "readOnly" | "noDelete" | "allowed";
|
|
228
|
+
|
|
229
|
+
function isNodeModulesPath(filePath: string): boolean {
|
|
230
|
+
return pathMatches(filePath, "node_modules/");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function shouldSkipReadConfirmation(filePath: string): boolean {
|
|
234
|
+
return isNodeModulesPath(filePath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shouldAskPathAccess(filePath: string): boolean {
|
|
238
|
+
return rules.askAccessPaths.some((p) => pathMatches(filePath, p));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function checkPathAccess(filePath: string): PathAccess {
|
|
242
|
+
if (rules.zeroAccessPaths.some((p) => pathMatches(filePath, p))) return "zero";
|
|
243
|
+
if (rules.readOnlyPaths.some((p) => pathMatches(filePath, p))) return "readOnly";
|
|
244
|
+
if (rules.noDeletePaths.some((p) => pathMatches(filePath, p))) return "noDelete";
|
|
245
|
+
return "allowed";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function confirmPathAccess(kind: "read" | "bash", target: string, preview: string, ctx: ExtensionContext): Promise<boolean> {
|
|
249
|
+
recordEvent({
|
|
250
|
+
timestamp: Date.now(),
|
|
251
|
+
type: "path",
|
|
252
|
+
detail: `ask ${kind}: ${target}`,
|
|
253
|
+
action: "asked",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!ctx.hasUI) return false;
|
|
257
|
+
|
|
258
|
+
const noun = kind === "read" ? "read from" : "access via bash";
|
|
259
|
+
const choice = await ctx.ui.select(
|
|
260
|
+
`[Damage Control] ${target} requires confirmation before the agent can ${noun} it.\n\n ${preview}\n\nAllow this access?`,
|
|
261
|
+
["Yes, allow once", "No, block it"],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (choice === "Yes, allow once") {
|
|
265
|
+
recordEvent({
|
|
266
|
+
timestamp: Date.now(),
|
|
267
|
+
type: "path",
|
|
268
|
+
detail: `ask ${kind}: ${target}`,
|
|
269
|
+
action: "allowed",
|
|
270
|
+
});
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Determine if the tool operation is a "delete" (rm via bash is handled separately)
|
|
278
|
+
function isDeleteOperation(toolName: string, input: Record<string, unknown>): boolean {
|
|
279
|
+
// write with empty content to a noDelete path could be destructive
|
|
280
|
+
// but the main delete vector is bash rm, which is caught by bash patterns
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// -- Load rules on session start ------------------------------------------
|
|
285
|
+
|
|
286
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
287
|
+
loadAllRules(ctx.cwd);
|
|
288
|
+
const total =
|
|
289
|
+
rules.bashToolPatterns.length +
|
|
290
|
+
rules.zeroAccessPaths.length +
|
|
291
|
+
rules.askAccessPaths.length +
|
|
292
|
+
rules.readOnlyPaths.length +
|
|
293
|
+
rules.noDeletePaths.length;
|
|
294
|
+
if (total > 0) {
|
|
295
|
+
ctx.ui.setStatus("damage-control", ctx.ui.theme.fg("success", "DC"));
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// -- Intercept tool calls -------------------------------------------------
|
|
300
|
+
|
|
301
|
+
pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | undefined> => {
|
|
302
|
+
// --- Bash commands ---
|
|
303
|
+
if (isToolCallEventType("bash", event)) {
|
|
304
|
+
const cmd = event.input.command;
|
|
305
|
+
const pathAccess = getBashPathAccess(cmd);
|
|
306
|
+
if (pathAccess?.access === "zero") {
|
|
307
|
+
recordEvent({
|
|
308
|
+
timestamp: Date.now(),
|
|
309
|
+
type: "path",
|
|
310
|
+
detail: `zero-access bash target: ${pathAccess.path}`,
|
|
311
|
+
action: "blocked",
|
|
312
|
+
});
|
|
313
|
+
ctx.ui.notify(`[DC] Blocked bash access: ${pathAccess.path} (zero-access)`, "error");
|
|
314
|
+
return {
|
|
315
|
+
block: true,
|
|
316
|
+
reason: `Damage Control: bash command targets zero-access path "${pathAccess.path}". Access is not permitted.`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (pathAccess?.access === "ask") {
|
|
321
|
+
const allowed = await confirmPathAccess("bash", pathAccess.path, cmd, ctx);
|
|
322
|
+
if (!allowed) {
|
|
323
|
+
recordEvent({
|
|
324
|
+
timestamp: Date.now(),
|
|
325
|
+
type: "path",
|
|
326
|
+
detail: `ask bash: ${pathAccess.path}`,
|
|
327
|
+
action: "blocked",
|
|
328
|
+
});
|
|
329
|
+
ctx.ui.notify(`[DC] Blocked bash access: ${pathAccess.path} (confirmation required)`, "error");
|
|
330
|
+
return {
|
|
331
|
+
block: true,
|
|
332
|
+
reason: `Damage Control: bash command targets protected path "${pathAccess.path}" and was not approved.`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const result = checkBash(cmd);
|
|
338
|
+
|
|
339
|
+
if (!result) return undefined;
|
|
340
|
+
|
|
341
|
+
const { match, isAsk } = result;
|
|
342
|
+
|
|
343
|
+
if (isAsk && ctx.hasUI) {
|
|
344
|
+
recordEvent({
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
type: "bash",
|
|
347
|
+
detail: match.reason,
|
|
348
|
+
action: "asked",
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const choice = await ctx.ui.select(
|
|
352
|
+
`[Damage Control] ${match.reason}\n\n ${cmd}\n\nAllow this command?`,
|
|
353
|
+
["Yes, proceed", "No, block it"],
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (choice === "Yes, proceed") {
|
|
357
|
+
recordEvent({
|
|
358
|
+
timestamp: Date.now(),
|
|
359
|
+
type: "bash",
|
|
360
|
+
detail: match.reason,
|
|
361
|
+
action: "allowed",
|
|
362
|
+
});
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
recordEvent({
|
|
368
|
+
timestamp: Date.now(),
|
|
369
|
+
type: "bash",
|
|
370
|
+
detail: match.reason,
|
|
371
|
+
action: "blocked",
|
|
372
|
+
});
|
|
373
|
+
ctx.ui.notify(`[DC] Blocked: ${match.reason}`, "error");
|
|
374
|
+
return {
|
|
375
|
+
block: true,
|
|
376
|
+
reason: `Damage Control: ${match.reason}. Command blocked by safety rules.`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --- Read tool: check protected paths ---
|
|
381
|
+
if (isToolCallEventType("read", event)) {
|
|
382
|
+
const filePath = event.input.path;
|
|
383
|
+
const access = checkPathAccess(filePath);
|
|
384
|
+
|
|
385
|
+
if (access === "zero") {
|
|
386
|
+
recordEvent({
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
type: "path",
|
|
389
|
+
detail: `zero-access read: ${filePath}`,
|
|
390
|
+
action: "blocked",
|
|
391
|
+
});
|
|
392
|
+
ctx.ui.notify(`[DC] Blocked read: ${filePath} (zero-access)`, "error");
|
|
393
|
+
return {
|
|
394
|
+
block: true,
|
|
395
|
+
reason: `Damage Control: "${filePath}" is a zero-access path. Reading is not permitted.`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!shouldSkipReadConfirmation(filePath) && shouldAskPathAccess(filePath)) {
|
|
400
|
+
const allowed = await confirmPathAccess("read", filePath, filePath, ctx);
|
|
401
|
+
if (!allowed) {
|
|
402
|
+
recordEvent({
|
|
403
|
+
timestamp: Date.now(),
|
|
404
|
+
type: "path",
|
|
405
|
+
detail: `ask read: ${filePath}`,
|
|
406
|
+
action: "blocked",
|
|
407
|
+
});
|
|
408
|
+
ctx.ui.notify(`[DC] Blocked read: ${filePath} (confirmation required)`, "error");
|
|
409
|
+
return {
|
|
410
|
+
block: true,
|
|
411
|
+
reason: `Damage Control: "${filePath}" requires explicit approval before it can be read.`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// --- Write tool: check zero-access and read-only paths ---
|
|
418
|
+
if (event.toolName === "write") {
|
|
419
|
+
const filePath = (event.input as Record<string, unknown>).path as string;
|
|
420
|
+
const access = checkPathAccess(filePath);
|
|
421
|
+
|
|
422
|
+
if (access === "zero") {
|
|
423
|
+
recordEvent({
|
|
424
|
+
timestamp: Date.now(),
|
|
425
|
+
type: "path",
|
|
426
|
+
detail: `zero-access write: ${filePath}`,
|
|
427
|
+
action: "blocked",
|
|
428
|
+
});
|
|
429
|
+
ctx.ui.notify(`[DC] Blocked write: ${filePath} (zero-access)`, "error");
|
|
430
|
+
return {
|
|
431
|
+
block: true,
|
|
432
|
+
reason: `Damage Control: "${filePath}" is a zero-access path. Writing is not permitted.`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (access === "readOnly") {
|
|
437
|
+
recordEvent({
|
|
438
|
+
timestamp: Date.now(),
|
|
439
|
+
type: "path",
|
|
440
|
+
detail: `read-only write: ${filePath}`,
|
|
441
|
+
action: "blocked",
|
|
442
|
+
});
|
|
443
|
+
ctx.ui.notify(`[DC] Blocked write: ${filePath} (read-only)`, "error");
|
|
444
|
+
return {
|
|
445
|
+
block: true,
|
|
446
|
+
reason: `Damage Control: "${filePath}" is read-only. Writing is not permitted.`,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// --- Edit tool: check zero-access and read-only paths ---
|
|
452
|
+
if (isToolCallEventType("edit", event)) {
|
|
453
|
+
const filePath = event.input.path;
|
|
454
|
+
const access = checkPathAccess(filePath);
|
|
455
|
+
|
|
456
|
+
if (access === "zero") {
|
|
457
|
+
recordEvent({
|
|
458
|
+
timestamp: Date.now(),
|
|
459
|
+
type: "path",
|
|
460
|
+
detail: `zero-access edit: ${filePath}`,
|
|
461
|
+
action: "blocked",
|
|
462
|
+
});
|
|
463
|
+
ctx.ui.notify(`[DC] Blocked edit: ${filePath} (zero-access)`, "error");
|
|
464
|
+
return {
|
|
465
|
+
block: true,
|
|
466
|
+
reason: `Damage Control: "${filePath}" is a zero-access path. Editing is not permitted.`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (access === "readOnly") {
|
|
471
|
+
recordEvent({
|
|
472
|
+
timestamp: Date.now(),
|
|
473
|
+
type: "path",
|
|
474
|
+
detail: `read-only edit: ${filePath}`,
|
|
475
|
+
action: "blocked",
|
|
476
|
+
});
|
|
477
|
+
ctx.ui.notify(`[DC] Blocked edit: ${filePath} (read-only)`, "error");
|
|
478
|
+
return {
|
|
479
|
+
block: true,
|
|
480
|
+
reason: `Damage Control: "${filePath}" is read-only. Editing is not permitted.`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return undefined;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// -- /dc command ----------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
pi.registerCommand("dc", {
|
|
491
|
+
description: "Show damage control status and rules",
|
|
492
|
+
handler: async (args, ctx) => {
|
|
493
|
+
// Reload rules in case the YAML was edited
|
|
494
|
+
loadAllRules(ctx.cwd);
|
|
495
|
+
|
|
496
|
+
if (args?.trim() === "rules") {
|
|
497
|
+
const lines: string[] = [];
|
|
498
|
+
lines.push("--- Bash patterns ---");
|
|
499
|
+
for (const bp of rules.bashToolPatterns) {
|
|
500
|
+
const tag = bp.allow ? " [allow]" : bp.ask ? " [ask]" : " [block]";
|
|
501
|
+
lines.push(` ${bp.pattern}${tag} -- ${bp.reason}`);
|
|
502
|
+
}
|
|
503
|
+
lines.push("");
|
|
504
|
+
lines.push("--- Zero-access paths ---");
|
|
505
|
+
for (const p of rules.zeroAccessPaths) lines.push(` ${p}`);
|
|
506
|
+
lines.push("");
|
|
507
|
+
lines.push("--- Ask-before-access paths ---");
|
|
508
|
+
for (const p of rules.askAccessPaths) lines.push(` ${p}`);
|
|
509
|
+
lines.push("");
|
|
510
|
+
lines.push("--- Read-only paths ---");
|
|
511
|
+
for (const p of rules.readOnlyPaths) lines.push(` ${p}`);
|
|
512
|
+
lines.push("");
|
|
513
|
+
lines.push("--- No-delete paths ---");
|
|
514
|
+
for (const p of rules.noDeletePaths) lines.push(` ${p}`);
|
|
515
|
+
|
|
516
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const lines: string[] = [];
|
|
521
|
+
const allowCount = rules.bashToolPatterns.filter((r) => r.allow).length;
|
|
522
|
+
const askCount = rules.bashToolPatterns.filter((r) => !r.allow && r.ask).length;
|
|
523
|
+
const blockCount = rules.bashToolPatterns.filter((r) => !r.allow && !r.ask).length;
|
|
524
|
+
lines.push(`Bash patterns: ${rules.bashToolPatterns.length} (${allowCount} allow, ${askCount} ask, ${blockCount} block)`);
|
|
525
|
+
lines.push(`Zero-access paths: ${rules.zeroAccessPaths.length}`);
|
|
526
|
+
lines.push(`Ask-access paths: ${rules.askAccessPaths.length}`);
|
|
527
|
+
lines.push(`Read-only paths: ${rules.readOnlyPaths.length}`);
|
|
528
|
+
lines.push(`No-delete paths: ${rules.noDeletePaths.length}`);
|
|
529
|
+
|
|
530
|
+
if (recentEvents.length > 0) {
|
|
531
|
+
lines.push("");
|
|
532
|
+
lines.push("Recent events:");
|
|
533
|
+
for (const evt of recentEvents.slice(0, 10)) {
|
|
534
|
+
const time = new Date(evt.timestamp).toLocaleTimeString();
|
|
535
|
+
const icon = evt.action === "blocked" ? "[x]" : evt.action === "asked" ? "[?]" : "[>]";
|
|
536
|
+
lines.push(` ${time} ${icon} ${evt.detail}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "damage-control",
|
|
3
|
+
"lockfileVersion": 3,
|
|
4
|
+
"requires": true,
|
|
5
|
+
"packages": {
|
|
6
|
+
"node_modules/yaml": {
|
|
7
|
+
"version": "2.8.2",
|
|
8
|
+
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
9
|
+
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"bin": {
|
|
12
|
+
"yaml": "bin.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">= 14.6"
|
|
16
|
+
},
|
|
17
|
+
"funding": {
|
|
18
|
+
"url": "https://github.com/sponsors/eemeli"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "damage-control",
|
|
3
|
+
"lockfileVersion": 3,
|
|
4
|
+
"requires": true,
|
|
5
|
+
"packages": {
|
|
6
|
+
"": {
|
|
7
|
+
"name": "damage-control",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"yaml": "^2.7.0"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"node_modules/yaml": {
|
|
13
|
+
"version": "2.8.2",
|
|
14
|
+
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
15
|
+
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"bin": {
|
|
18
|
+
"yaml": "bin.mjs"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">= 14.6"
|
|
22
|
+
},
|
|
23
|
+
"funding": {
|
|
24
|
+
"url": "https://github.com/sponsors/eemeli"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dirty Repo Guard Extension
|
|
3
|
+
*
|
|
4
|
+
* Prevents session changes when there are uncommitted git changes.
|
|
5
|
+
* Useful to ensure work is committed before switching context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
async function checkDirtyRepo(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
ctx: ExtensionContext,
|
|
13
|
+
action: string,
|
|
14
|
+
): Promise<{ cancel: boolean } | undefined> {
|
|
15
|
+
// Check for uncommitted changes
|
|
16
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
17
|
+
|
|
18
|
+
if (code !== 0) {
|
|
19
|
+
// Not a git repo, allow the action
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hasChanges = stdout.trim().length > 0;
|
|
24
|
+
if (!hasChanges) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!ctx.hasUI) {
|
|
29
|
+
// In non-interactive mode, block by default
|
|
30
|
+
return { cancel: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Count changed files
|
|
34
|
+
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
|
35
|
+
|
|
36
|
+
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
|
37
|
+
"Yes, proceed anyway",
|
|
38
|
+
"No, let me commit first",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
if (choice !== "Yes, proceed anyway") {
|
|
42
|
+
ctx.ui.notify("Commit your changes first", "warning");
|
|
43
|
+
return { cancel: true };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function (pi: ExtensionAPI) {
|
|
48
|
+
pi.on("session_before_switch", async (event, ctx) => {
|
|
49
|
+
const action = event.reason === "new" ? "new session" : "switch session";
|
|
50
|
+
return checkDirtyRepo(pi, ctx, action);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.on("session_before_fork", async (_event, ctx) => {
|
|
54
|
+
return checkDirtyRepo(pi, ctx, "fork");
|
|
55
|
+
});
|
|
56
|
+
}
|