pi-agent-supervisor 1.0.0 → 1.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/package.json +11 -3
- package/patterns/container.txt +9 -0
- package/patterns/credentials.txt +14 -0
- package/patterns/crypto.txt +10 -0
- package/patterns/destructive.txt +15 -0
- package/patterns/evasion.txt +10 -0
- package/patterns/hardware.txt +8 -0
- package/patterns/injection.txt +7 -0
- package/patterns/network.txt +12 -0
- package/patterns/persistence.txt +14 -0
- package/patterns/supplychain.txt +7 -0
- package/src/__tests__/supervisor.test.ts +882 -0
- package/src/config.ts +69 -0
- package/src/helpers.ts +44 -0
- package/src/index.ts +22 -460
- package/src/intercepts.ts +70 -0
- package/src/state.ts +16 -0
- package/src/tools/supervisor.ts +65 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface SupervisorConfig {
|
|
5
|
+
blockedPatterns: string[];
|
|
6
|
+
protectedFiles: string[];
|
|
7
|
+
protectedPatterns: string[];
|
|
8
|
+
rateLimitPerMinute: number;
|
|
9
|
+
rateLimitHardBlock: number;
|
|
10
|
+
maxConsecutiveErrors: number;
|
|
11
|
+
enableAuditLog: boolean;
|
|
12
|
+
auditLogPath: string;
|
|
13
|
+
contextWarnThreshold: number;
|
|
14
|
+
contextCriticalThreshold: number;
|
|
15
|
+
blockAtCriticalContext: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_CONFIG: SupervisorConfig = {
|
|
19
|
+
blockedPatterns: [],
|
|
20
|
+
protectedFiles: [".env", ".env.local", ".env.production", "credentials.json", "serviceAccountKey.json", ".claude/settings.local.json", ".git/config"],
|
|
21
|
+
protectedPatterns: ["*.pem", "*.key", "id_rsa*", "*secret*", "*credential*"],
|
|
22
|
+
rateLimitPerMinute: 50, rateLimitHardBlock: 80, maxConsecutiveErrors: 3,
|
|
23
|
+
enableAuditLog: true, auditLogPath: ".supervisor/audit.log",
|
|
24
|
+
contextWarnThreshold: 70, contextCriticalThreshold: 90, blockAtCriticalContext: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function loadBlockedPatterns(extensionDir: string): string[] {
|
|
28
|
+
const patternsDir = path.join(extensionDir, "patterns");
|
|
29
|
+
if (!fs.existsSync(patternsDir)) return [];
|
|
30
|
+
const patterns: string[] = [];
|
|
31
|
+
try {
|
|
32
|
+
for (const file of fs.readdirSync(patternsDir).filter(f => f.endsWith(".txt"))) {
|
|
33
|
+
const content = fs.readFileSync(path.join(patternsDir, file), "utf-8");
|
|
34
|
+
for (const line of content.split("\n")) {
|
|
35
|
+
const t = line.trim();
|
|
36
|
+
if (t && !t.startsWith("#")) patterns.push(t);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch { /* ignore */ }
|
|
40
|
+
return patterns;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadConfig(cwd: string, extensionDir?: string): SupervisorConfig {
|
|
44
|
+
const configPath = path.join(cwd, ".supervisorrc.yml");
|
|
45
|
+
const config = { ...DEFAULT_CONFIG };
|
|
46
|
+
if (extensionDir) config.blockedPatterns = loadBlockedPatterns(extensionDir);
|
|
47
|
+
if (!fs.existsSync(configPath)) return config;
|
|
48
|
+
try {
|
|
49
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
50
|
+
const result: Record<string, unknown> = {};
|
|
51
|
+
for (const line of content.split("\n")) {
|
|
52
|
+
const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
|
|
53
|
+
if (m) { let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); result[m[1]] = v; }
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
blockedPatterns: result["blockedPatterns"] ? (result["blockedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean) : config.blockedPatterns,
|
|
57
|
+
protectedFiles: result["protectedFiles"] ? (result["protectedFiles"] as string).split(",").map(s => s.trim()).filter(Boolean) : DEFAULT_CONFIG.protectedFiles,
|
|
58
|
+
protectedPatterns: result["protectedPatterns"] ? (result["protectedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean) : DEFAULT_CONFIG.protectedPatterns,
|
|
59
|
+
rateLimitPerMinute: parseInt(result["rateLimitPerMinute"] as string) || DEFAULT_CONFIG.rateLimitPerMinute,
|
|
60
|
+
rateLimitHardBlock: parseInt(result["rateLimitHardBlock"] as string) || DEFAULT_CONFIG.rateLimitHardBlock,
|
|
61
|
+
maxConsecutiveErrors: parseInt(result["maxConsecutiveErrors"] as string) || DEFAULT_CONFIG.maxConsecutiveErrors,
|
|
62
|
+
enableAuditLog: result["enableAuditLog"] !== "false",
|
|
63
|
+
auditLogPath: (result["auditLogPath"] as string) || DEFAULT_CONFIG.auditLogPath,
|
|
64
|
+
contextWarnThreshold: parseInt(result["contextWarnThreshold"] as string) || DEFAULT_CONFIG.contextWarnThreshold,
|
|
65
|
+
contextCriticalThreshold: parseInt(result["contextCriticalThreshold"] as string) || DEFAULT_CONFIG.contextCriticalThreshold,
|
|
66
|
+
blockAtCriticalContext: result["blockAtCriticalContext"] === "true",
|
|
67
|
+
};
|
|
68
|
+
} catch { return config; }
|
|
69
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { SupervisorConfig } from "./config";
|
|
4
|
+
import { state } from "./state";
|
|
5
|
+
|
|
6
|
+
export function getAuditLogPath(baseDir: string, config: SupervisorConfig): string {
|
|
7
|
+
return path.join(baseDir, config.auditLogPath);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function appendToAuditLog(baseDir: string, config: SupervisorConfig, entry: string): void {
|
|
11
|
+
if (!config.enableAuditLog) return;
|
|
12
|
+
const logPath = getAuditLogPath(baseDir, config);
|
|
13
|
+
const dir = path.dirname(logPath);
|
|
14
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${entry}\n`, { encoding: "utf-8" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getCurrentRate(config: SupervisorConfig): number {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
state.toolCalls = state.toolCalls.filter(c => c.timestamp > now - 60000);
|
|
21
|
+
return state.toolCalls.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function matchBlockedCommand(cmd: string, config: SupervisorConfig): string | null {
|
|
25
|
+
for (const pattern of config.blockedPatterns) {
|
|
26
|
+
try { if (new RegExp(pattern, "i").test(cmd)) return pattern; } catch { /* skip */ }
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isProtectedFile(filePath: string, config: SupervisorConfig): boolean {
|
|
32
|
+
const basename = path.basename(filePath);
|
|
33
|
+
if (config.protectedFiles.some(f => basename === f || filePath.endsWith(f))) return true;
|
|
34
|
+
for (const pattern of config.protectedPatterns) {
|
|
35
|
+
const regexStr = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*");
|
|
36
|
+
try { if (new RegExp(`^${regexStr}$`, "i").test(basename)) return true; } catch { /* skip */ }
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function detectFileWrite(cmd: string): string | null {
|
|
42
|
+
const m = cmd.match(/>>?\s*(\S+)/);
|
|
43
|
+
return m ? m[1] : null;
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,477 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-agent-supervisor — Runtime Safety Net
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* sensitive files, enforces rate limits, tracks context budget, records
|
|
6
|
-
* sessions to an append-only log, and escalates on consecutive errors.
|
|
7
|
-
*
|
|
8
|
-
* Tools:
|
|
9
|
-
* supervisor_status() → show session stats (rate, errors, context)
|
|
10
|
-
* supervisor_log(tail) → read session log (read-only)
|
|
11
|
-
* supervisor_override(reason) → request human override for blocked operation
|
|
12
|
-
*
|
|
4
|
+
* Tools: supervisor_status, supervisor_log, supervisor_override
|
|
13
5
|
* Config: .supervisorrc.yml
|
|
6
|
+
* Patterns: patterns/*.txt
|
|
14
7
|
*/
|
|
15
|
-
|
|
16
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { Type } from "typebox";
|
|
18
|
-
import * as fs from "node:fs";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
9
|
import * as path from "node:path";
|
|
20
10
|
import * as os from "node:os";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
blockedPatterns: string[];
|
|
27
|
-
/** Protected file paths (write blocked) */
|
|
28
|
-
protectedFiles: string[];
|
|
29
|
-
/** Protected file patterns (glob-like, write blocked) */
|
|
30
|
-
protectedPatterns: string[];
|
|
31
|
-
/** Max tool calls per minute before warning */
|
|
32
|
-
rateLimitPerMinute: number;
|
|
33
|
-
/** Max tool calls per minute before blocking */
|
|
34
|
-
rateLimitHardBlock: number;
|
|
35
|
-
/** Max consecutive errors before escalation */
|
|
36
|
-
maxConsecutiveErrors: number;
|
|
37
|
-
/** Whether to record session to audit log */
|
|
38
|
-
enableAuditLog: boolean;
|
|
39
|
-
/** Audit log path */
|
|
40
|
-
auditLogPath: string;
|
|
41
|
-
/** Context budget warning threshold (percentage) */
|
|
42
|
-
contextWarnThreshold: number;
|
|
43
|
-
/** Context budget critical threshold (percentage) */
|
|
44
|
-
contextCriticalThreshold: number;
|
|
45
|
-
/** Whether to block at critical context */
|
|
46
|
-
blockAtCriticalContext: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const DEFAULT_CONFIG: SupervisorConfig = {
|
|
50
|
-
blockedPatterns: [
|
|
51
|
-
"rm\\s+-rf\\s+/",
|
|
52
|
-
"rm\\s+-rf\\s+~",
|
|
53
|
-
"rm\\s+-rf\\s+\\*",
|
|
54
|
-
"git\\s+push\\s+.*--force",
|
|
55
|
-
"git\\s+push\\s+.*-f\\b",
|
|
56
|
-
"sudo\\s+",
|
|
57
|
-
"chmod\\s+777",
|
|
58
|
-
">\\s*/dev/sd[a-z]",
|
|
59
|
-
"dd\\s+if=",
|
|
60
|
-
"mkfs\\.",
|
|
61
|
-
":(){ :|:& };:", // fork bomb
|
|
62
|
-
">\\s*\\.env",
|
|
63
|
-
">\\s*\\.git",
|
|
64
|
-
],
|
|
65
|
-
protectedFiles: [
|
|
66
|
-
".env",
|
|
67
|
-
".env.local",
|
|
68
|
-
".env.production",
|
|
69
|
-
"credentials.json",
|
|
70
|
-
"serviceAccountKey.json",
|
|
71
|
-
".claude/settings.local.json",
|
|
72
|
-
".git/config",
|
|
73
|
-
],
|
|
74
|
-
protectedPatterns: [
|
|
75
|
-
"*.pem",
|
|
76
|
-
"*.key",
|
|
77
|
-
"id_rsa*",
|
|
78
|
-
"*secret*",
|
|
79
|
-
"*credential*",
|
|
80
|
-
],
|
|
81
|
-
rateLimitPerMinute: 50,
|
|
82
|
-
rateLimitHardBlock: 80,
|
|
83
|
-
maxConsecutiveErrors: 3,
|
|
84
|
-
enableAuditLog: true,
|
|
85
|
-
auditLogPath: ".supervisor/audit.log",
|
|
86
|
-
contextWarnThreshold: 70,
|
|
87
|
-
contextCriticalThreshold: 90,
|
|
88
|
-
blockAtCriticalContext: false,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
// ── Config ──
|
|
92
|
-
|
|
93
|
-
function loadConfig(cwd: string): SupervisorConfig {
|
|
94
|
-
const configPath = path.join(cwd, ".supervisorrc.yml");
|
|
95
|
-
if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
96
|
-
try {
|
|
97
|
-
const content = fs.readFileSync(configPath, "utf-8");
|
|
98
|
-
const result: Record<string, unknown> = {};
|
|
99
|
-
for (const line of content.split("\n")) {
|
|
100
|
-
const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
|
|
101
|
-
if (m) result[m[1]] = m[2].trim();
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
blockedPatterns: result["blockedPatterns"]
|
|
105
|
-
? (result["blockedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean)
|
|
106
|
-
: DEFAULT_CONFIG.blockedPatterns,
|
|
107
|
-
protectedFiles: result["protectedFiles"]
|
|
108
|
-
? (result["protectedFiles"] as string).split(",").map(s => s.trim()).filter(Boolean)
|
|
109
|
-
: DEFAULT_CONFIG.protectedFiles,
|
|
110
|
-
protectedPatterns: result["protectedPatterns"]
|
|
111
|
-
? (result["protectedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean)
|
|
112
|
-
: DEFAULT_CONFIG.protectedPatterns,
|
|
113
|
-
rateLimitPerMinute: parseInt(result["rateLimitPerMinute"] as string) || DEFAULT_CONFIG.rateLimitPerMinute,
|
|
114
|
-
rateLimitHardBlock: parseInt(result["rateLimitHardBlock"] as string) || DEFAULT_CONFIG.rateLimitHardBlock,
|
|
115
|
-
maxConsecutiveErrors: parseInt(result["maxConsecutiveErrors"] as string) || DEFAULT_CONFIG.maxConsecutiveErrors,
|
|
116
|
-
enableAuditLog: result["enableAuditLog"] !== "false",
|
|
117
|
-
auditLogPath: (result["auditLogPath"] as string) || DEFAULT_CONFIG.auditLogPath,
|
|
118
|
-
contextWarnThreshold: parseInt(result["contextWarnThreshold"] as string) || DEFAULT_CONFIG.contextWarnThreshold,
|
|
119
|
-
contextCriticalThreshold: parseInt(result["contextCriticalThreshold"] as string) || DEFAULT_CONFIG.contextCriticalThreshold,
|
|
120
|
-
blockAtCriticalContext: result["blockAtCriticalContext"] === "true",
|
|
121
|
-
};
|
|
122
|
-
} catch {
|
|
123
|
-
return { ...DEFAULT_CONFIG };
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ── Session state ──
|
|
128
|
-
|
|
129
|
-
interface SessionState {
|
|
130
|
-
toolCalls: Array<{ tool: string; timestamp: number }>;
|
|
131
|
-
errorCount: number;
|
|
132
|
-
consecutiveErrors: number;
|
|
133
|
-
blockedCount: number;
|
|
134
|
-
lastEscalation: number;
|
|
135
|
-
contextBudget: { used: number; total: number } | null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
let state: SessionState = {
|
|
139
|
-
toolCalls: [],
|
|
140
|
-
errorCount: 0,
|
|
141
|
-
consecutiveErrors: 0,
|
|
142
|
-
blockedCount: 0,
|
|
143
|
-
lastEscalation: 0,
|
|
144
|
-
contextBudget: null,
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// ── Audit log ──
|
|
148
|
-
|
|
149
|
-
function getAuditLogPath(baseDir: string, config: SupervisorConfig): string {
|
|
150
|
-
return path.join(baseDir, config.auditLogPath);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function appendToAuditLog(baseDir: string, config: SupervisorConfig, entry: string): void {
|
|
154
|
-
if (!config.enableAuditLog) return;
|
|
155
|
-
const logPath = getAuditLogPath(baseDir, config);
|
|
156
|
-
const dir = path.dirname(logPath);
|
|
157
|
-
if (!fs.existsSync(dir)) {
|
|
158
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
159
|
-
}
|
|
160
|
-
// Append-only — use O_APPEND flag
|
|
161
|
-
const timestamp = new Date().toISOString();
|
|
162
|
-
const line = `[${timestamp}] ${entry}\n`;
|
|
163
|
-
fs.appendFileSync(logPath, line, { encoding: "utf-8" });
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ── Rate limiting ──
|
|
167
|
-
|
|
168
|
-
function getCurrentRate(config: SupervisorConfig): number {
|
|
169
|
-
const now = Date.now();
|
|
170
|
-
const oneMinuteAgo = now - 60000;
|
|
171
|
-
// Keep only calls from the last minute
|
|
172
|
-
state.toolCalls = state.toolCalls.filter(c => c.timestamp > oneMinuteAgo);
|
|
173
|
-
return state.toolCalls.length;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── Command pattern matching ──
|
|
177
|
-
|
|
178
|
-
function matchBlockedCommand(cmd: string, config: SupervisorConfig): string | null {
|
|
179
|
-
for (const pattern of config.blockedPatterns) {
|
|
180
|
-
try {
|
|
181
|
-
if (new RegExp(pattern, "i").test(cmd)) {
|
|
182
|
-
return pattern;
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
// Invalid regex, skip
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ── File protection ──
|
|
192
|
-
|
|
193
|
-
function isProtectedFile(filePath: string, config: SupervisorConfig): boolean {
|
|
194
|
-
const basename = path.basename(filePath);
|
|
195
|
-
|
|
196
|
-
// Exact file match
|
|
197
|
-
if (config.protectedFiles.some(f => filePath.includes(f) || basename === f)) {
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Glob pattern match
|
|
202
|
-
for (const pattern of config.protectedPatterns) {
|
|
203
|
-
// Simple glob: convert * to regex
|
|
204
|
-
const regexStr = pattern
|
|
205
|
-
.replace(/\./g, "\\.")
|
|
206
|
-
.replace(/\*/g, ".*");
|
|
207
|
-
try {
|
|
208
|
-
if (new RegExp(`^${regexStr}$`, "i").test(basename)) {
|
|
209
|
-
return true;
|
|
210
|
-
}
|
|
211
|
-
} catch {
|
|
212
|
-
// Invalid regex, skip
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function detectFileWrite(cmd: string): string | null {
|
|
220
|
-
// Detect patterns like: > file, write to file, edit file, cat > file
|
|
221
|
-
const redirectMatch = cmd.match(/>\s*(\S+)/);
|
|
222
|
-
if (redirectMatch) return redirectMatch[1];
|
|
223
|
-
|
|
224
|
-
const writeMatch = cmd.match(/(?:write|edit)\s+["']?([^\s"']+)["']?/i);
|
|
225
|
-
if (writeMatch) return writeMatch[1];
|
|
226
|
-
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ── Extension ──
|
|
11
|
+
import { loadConfig } from "./config";
|
|
12
|
+
import { appendToAuditLog } from "./helpers";
|
|
13
|
+
import { resetState } from "./state";
|
|
14
|
+
import { interceptToolCall, trackToolError, trackToolSuccess } from "./intercepts";
|
|
15
|
+
import { statusTool, logTool, overrideTool } from "./tools/supervisor";
|
|
231
16
|
|
|
232
17
|
export default function (pi: ExtensionAPI) {
|
|
233
|
-
|
|
234
|
-
// Runtime monitoring — intercept ALL tool calls
|
|
235
|
-
// ═══════════════════════════════════════
|
|
236
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
237
|
-
const config = loadConfig(ctx.cwd);
|
|
238
|
-
const now = Date.now();
|
|
239
|
-
|
|
240
|
-
// Track call for rate limiting
|
|
241
|
-
state.toolCalls.push({ tool: event.toolName, timestamp: now });
|
|
242
|
-
const rate = getCurrentRate(config);
|
|
243
|
-
|
|
244
|
-
// Log the call
|
|
245
|
-
appendToAuditLog(ctx.cwd, config, `CALL ${event.toolName} (rate: ${rate}/min)`);
|
|
246
|
-
|
|
247
|
-
// ── Rate limiting ──
|
|
248
|
-
if (rate > config.rateLimitHardBlock) {
|
|
249
|
-
const msg = `⛔ Rate limit exceeded (${rate}/${config.rateLimitHardBlock} calls/min). Paused.`;
|
|
250
|
-
ctx.ui.notify(msg, "error");
|
|
251
|
-
appendToAuditLog(ctx.cwd, config, `BLOCK rate-limit: ${rate}/min`);
|
|
252
|
-
state.blockedCount++;
|
|
253
|
-
return { block: true, reason: msg };
|
|
254
|
-
}
|
|
255
|
-
if (rate > config.rateLimitPerMinute) {
|
|
256
|
-
ctx.ui.notify(
|
|
257
|
-
`⚠️ High tool call rate (${rate}/${config.rateLimitPerMinute} calls/min). Slow down.`,
|
|
258
|
-
"warning",
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ── Dangerous command blocking (bash only) ──
|
|
263
|
-
if (event.toolName === "bash" && typeof event.input.command === "string") {
|
|
264
|
-
const cmd = event.input.command;
|
|
265
|
-
|
|
266
|
-
// Check blocked patterns
|
|
267
|
-
const blockedPattern = matchBlockedCommand(cmd, config);
|
|
268
|
-
if (blockedPattern) {
|
|
269
|
-
const msg = `⛔ Dangerous command blocked (pattern: "${blockedPattern}")`;
|
|
270
|
-
ctx.ui.notify(msg, "error");
|
|
271
|
-
appendToAuditLog(ctx.cwd, config, `BLOCK dangerous-cmd: ${cmd.substring(0, 100)}`);
|
|
272
|
-
state.blockedCount++;
|
|
273
|
-
return { block: true, reason: msg };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Check file protection
|
|
277
|
-
const writtenFile = detectFileWrite(cmd);
|
|
278
|
-
if (writtenFile && isProtectedFile(writtenFile, config)) {
|
|
279
|
-
const msg = `⛔ Write to protected file blocked: ${writtenFile}`;
|
|
280
|
-
ctx.ui.notify(msg, "error");
|
|
281
|
-
appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${writtenFile}`);
|
|
282
|
-
state.blockedCount++;
|
|
283
|
-
return { block: true, reason: msg };
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ── File operation protection (write/edit tools) ──
|
|
288
|
-
if ((event.toolName === "write" || event.toolName === "edit") && event.input.path) {
|
|
289
|
-
const filePath = event.input.path as string;
|
|
290
|
-
if (isProtectedFile(filePath, config)) {
|
|
291
|
-
const msg = `⛔ Write to protected file blocked: ${filePath}`;
|
|
292
|
-
ctx.ui.notify(msg, "error");
|
|
293
|
-
appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${filePath}`);
|
|
294
|
-
state.blockedCount++;
|
|
295
|
-
return { block: true, reason: msg };
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// ── Error tracking ──
|
|
301
|
-
pi.on("tool_error", async (event, ctx) => {
|
|
302
|
-
const config = loadConfig(ctx.cwd);
|
|
303
|
-
state.errorCount++;
|
|
304
|
-
state.consecutiveErrors++;
|
|
305
|
-
|
|
306
|
-
appendToAuditLog(ctx.cwd, config,
|
|
307
|
-
`ERROR ${event.toolName}: ${String(event.error).substring(0, 200)} (consecutive: ${state.consecutiveErrors})`,
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
if (state.consecutiveErrors >= config.maxConsecutiveErrors) {
|
|
311
|
-
const msg = `🚨 ${state.consecutiveErrors} consecutive errors — escalation triggered. Pausing for human review.`;
|
|
312
|
-
ctx.ui.notify(msg, "error");
|
|
313
|
-
appendToAuditLog(ctx.cwd, config, `ESCALATE consecutive-errors: ${state.consecutiveErrors}`);
|
|
314
|
-
state.lastEscalation = Date.now();
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
// ── Track successes to reset error counter ──
|
|
319
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
320
|
-
state.consecutiveErrors = 0; // Reset on success
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// ═══════════════════════════════════════
|
|
324
|
-
// Tool: supervisor_status
|
|
325
|
-
// ═══════════════════════════════════════
|
|
326
|
-
pi.registerTool({
|
|
327
|
-
name: "supervisor_status",
|
|
328
|
-
label: "Supervisor Status",
|
|
329
|
-
description: "Show supervisor session stats — rate, errors, blocked calls, context budget.",
|
|
330
|
-
parameters: Type.Object({}),
|
|
331
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
332
|
-
const config = loadConfig(ctx.cwd);
|
|
333
|
-
const rate = getCurrentRate(config);
|
|
334
|
-
|
|
335
|
-
const lines: string[] = [];
|
|
336
|
-
lines.push("🛡 Supervisor Status");
|
|
337
|
-
lines.push("");
|
|
338
|
-
lines.push(`📊 Tool calls: ${state.toolCalls.length} total (${rate}/min current)`);
|
|
339
|
-
lines.push(` Rate limit: ${config.rateLimitPerMinute}/min warn, ${config.rateLimitHardBlock}/min block`);
|
|
340
|
-
lines.push(`❌ Errors: ${state.errorCount} total, ${state.consecutiveErrors} consecutive`);
|
|
341
|
-
lines.push(` Escalation at: ${config.maxConsecutiveErrors} consecutive errors`);
|
|
342
|
-
lines.push(`🚫 Blocked: ${state.blockedCount} calls blocked`);
|
|
343
|
-
lines.push(`📝 Audit log: ${config.enableAuditLog ? config.auditLogPath : "disabled"}`);
|
|
344
|
-
lines.push(`🔒 Protected files: ${config.protectedFiles.length} exact, ${config.protectedPatterns.length} patterns`);
|
|
345
|
-
|
|
346
|
-
if (state.lastEscalation > 0) {
|
|
347
|
-
const escTime = new Date(state.lastEscalation).toISOString();
|
|
348
|
-
lines.push(`🚨 Last escalation: ${escTime}`);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return {
|
|
352
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
353
|
-
details: {
|
|
354
|
-
rate,
|
|
355
|
-
errors: state.errorCount,
|
|
356
|
-
consecutiveErrors: state.consecutiveErrors,
|
|
357
|
-
blocked: state.blockedCount,
|
|
358
|
-
lastEscalation: state.lastEscalation,
|
|
359
|
-
},
|
|
360
|
-
};
|
|
361
|
-
},
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
// ═══════════════════════════════════════
|
|
365
|
-
// Tool: supervisor_log
|
|
366
|
-
// ═══════════════════════════════════════
|
|
367
|
-
pi.registerTool({
|
|
368
|
-
name: "supervisor_log",
|
|
369
|
-
label: "View Audit Log",
|
|
370
|
-
description: "Read the supervisor audit log (read-only, last N lines).",
|
|
371
|
-
parameters: Type.Object({
|
|
372
|
-
tail: Type.Optional(Type.Number({ description: "Number of recent lines to show (default: 50)" })),
|
|
373
|
-
}),
|
|
374
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
375
|
-
const config = loadConfig(ctx.cwd);
|
|
376
|
-
const logPath = getAuditLogPath(ctx.cwd, config);
|
|
377
|
-
const tail = params.tail || 50;
|
|
378
|
-
|
|
379
|
-
if (!fs.existsSync(logPath)) {
|
|
380
|
-
return {
|
|
381
|
-
content: [{ type: "text", text: "No audit log found. Session hasn't been recorded yet." }],
|
|
382
|
-
details: {},
|
|
383
|
-
};
|
|
384
|
-
}
|
|
18
|
+
const extensionDir = path.dirname(__filename || path.join(__dirname, ".."));
|
|
385
19
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
20
|
+
pi.on("tool_call", (event, ctx) => interceptToolCall(event, ctx, extensionDir));
|
|
21
|
+
pi.on("tool_error", (event, ctx) => trackToolError(event, ctx, extensionDir));
|
|
22
|
+
pi.on("tool_result", () => trackToolSuccess());
|
|
389
23
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (line.includes("ESCALATE")) return `🚨 ${line}`;
|
|
394
|
-
if (line.includes("ERROR")) return `❌ ${line}`;
|
|
395
|
-
return ` ${line}`;
|
|
396
|
-
});
|
|
24
|
+
pi.registerTool(statusTool);
|
|
25
|
+
pi.registerTool(logTool);
|
|
26
|
+
pi.registerTool(overrideTool);
|
|
397
27
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
text: [
|
|
402
|
-
`📝 Audit Log (last ${recent.length} of ${lines.length} entries)`,
|
|
403
|
-
` Path: ${logPath}`,
|
|
404
|
-
"",
|
|
405
|
-
...formatted,
|
|
406
|
-
].join("\n"),
|
|
407
|
-
}],
|
|
408
|
-
details: { totalLines: lines.length, shown: recent.length, path: logPath },
|
|
409
|
-
};
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
// ═══════════════════════════════════════
|
|
414
|
-
// Tool: supervisor_override
|
|
415
|
-
// ═══════════════════════════════════════
|
|
416
|
-
pi.registerTool({
|
|
417
|
-
name: "supervisor_override",
|
|
418
|
-
label: "Request Override",
|
|
419
|
-
description: "Request human override for a blocked operation. Requires explicit confirmation.",
|
|
420
|
-
parameters: Type.Object({
|
|
421
|
-
reason: Type.String({ description: "Why the blocked operation should be allowed" }),
|
|
422
|
-
command: Type.Optional(Type.String({ description: "The specific command that was blocked (if applicable)" })),
|
|
423
|
-
}),
|
|
424
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
425
|
-
const config = loadConfig(ctx.cwd);
|
|
426
|
-
|
|
427
|
-
const cmdInfo = params.command ? `\n\nBlocked command: ${params.command}` : "";
|
|
428
|
-
const msg = `Override requested${cmdInfo}\n\nReason: ${params.reason}\n\nAllow this operation?`;
|
|
429
|
-
|
|
430
|
-
const allowed = await ctx.ui.confirm("Supervisor Override", msg);
|
|
431
|
-
|
|
432
|
-
if (allowed) {
|
|
433
|
-
appendToAuditLog(ctx.cwd, config,
|
|
434
|
-
`OVERRIDE allowed: ${params.reason}${params.command ? ` (cmd: ${params.command.substring(0, 80)})` : ""}`,
|
|
435
|
-
);
|
|
436
|
-
return {
|
|
437
|
-
content: [{ type: "text", text: "✅ Override granted. Proceed with the operation." }],
|
|
438
|
-
details: { override: true, reason: params.reason },
|
|
439
|
-
};
|
|
440
|
-
} else {
|
|
441
|
-
appendToAuditLog(ctx.cwd, config,
|
|
442
|
-
`OVERRIDE denied: ${params.reason}`,
|
|
443
|
-
);
|
|
444
|
-
return {
|
|
445
|
-
content: [{ type: "text", text: "❌ Override denied. Operation remains blocked." }],
|
|
446
|
-
isError: true,
|
|
447
|
-
details: { override: false, reason: params.reason },
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
},
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// ═══════════════════════════════════════
|
|
454
|
-
// Session lifecycle
|
|
455
|
-
// ═══════════════════════════════════════
|
|
456
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
457
|
-
const config = loadConfig(ctx.cwd);
|
|
28
|
+
pi.on("session_start", (_event, ctx) => {
|
|
29
|
+
const config = loadConfig(ctx.cwd, extensionDir);
|
|
30
|
+
resetState();
|
|
458
31
|
appendToAuditLog(ctx.cwd, config, `SESSION_START host=${os.hostname()} cwd=${ctx.cwd}`);
|
|
459
|
-
|
|
460
|
-
// Reset state for new session
|
|
461
|
-
state = {
|
|
462
|
-
toolCalls: [],
|
|
463
|
-
errorCount: 0,
|
|
464
|
-
consecutiveErrors: 0,
|
|
465
|
-
blockedCount: 0,
|
|
466
|
-
lastEscalation: 0,
|
|
467
|
-
contextBudget: null,
|
|
468
|
-
};
|
|
469
32
|
});
|
|
470
33
|
|
|
471
|
-
pi.on("session_shutdown",
|
|
472
|
-
const config = loadConfig(ctx.cwd);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
);
|
|
34
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
35
|
+
const config = loadConfig(ctx.cwd, extensionDir);
|
|
36
|
+
const { state } = require("./state");
|
|
37
|
+
appendToAuditLog(ctx.cwd, config, `SESSION_END calls=${state.toolCalls.length} errors=${state.errorCount} blocked=${state.blockedCount}`);
|
|
476
38
|
});
|
|
477
39
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig } from "./config";
|
|
3
|
+
import { getCurrentRate, matchBlockedCommand, isProtectedFile, detectFileWrite, appendToAuditLog } from "./helpers";
|
|
4
|
+
import { state } from "./state";
|
|
5
|
+
|
|
6
|
+
export async function interceptToolCall(event: any, ctx: ExtensionContext, extensionDir: string) {
|
|
7
|
+
const config = loadConfig(ctx.cwd, extensionDir);
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
state.toolCalls.push({ tool: event.toolName, timestamp: now });
|
|
10
|
+
const rate = getCurrentRate(config);
|
|
11
|
+
appendToAuditLog(ctx.cwd, config, `CALL ${event.toolName} (rate: ${rate}/min)`);
|
|
12
|
+
|
|
13
|
+
if (rate > config.rateLimitHardBlock) {
|
|
14
|
+
const msg = `⛔ Rate limit exceeded (${rate}/${config.rateLimitHardBlock}/min).`;
|
|
15
|
+
ctx.ui.notify(msg, "error");
|
|
16
|
+
appendToAuditLog(ctx.cwd, config, `BLOCK rate-limit: ${rate}/min`);
|
|
17
|
+
state.blockedCount++;
|
|
18
|
+
return { block: true, reason: msg };
|
|
19
|
+
}
|
|
20
|
+
if (rate > config.rateLimitPerMinute) {
|
|
21
|
+
ctx.ui.notify(`⚠️ High rate (${rate}/${config.rateLimitPerMinute}/min).`, "warning");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (event.toolName === "bash" && typeof event.input.command === "string") {
|
|
25
|
+
const cmd = event.input.command;
|
|
26
|
+
const blocked = matchBlockedCommand(cmd, config);
|
|
27
|
+
if (blocked) {
|
|
28
|
+
const msg = `⛔ Dangerous command blocked (pattern: "${blocked}")`;
|
|
29
|
+
ctx.ui.notify(msg, "error");
|
|
30
|
+
appendToAuditLog(ctx.cwd, config, `BLOCK dangerous-cmd: ${cmd.substring(0, 100)}`);
|
|
31
|
+
state.blockedCount++;
|
|
32
|
+
return { block: true, reason: msg };
|
|
33
|
+
}
|
|
34
|
+
const written = detectFileWrite(cmd);
|
|
35
|
+
if (written && isProtectedFile(written, config)) {
|
|
36
|
+
const msg = `⛔ Write to protected file blocked: ${written}`;
|
|
37
|
+
ctx.ui.notify(msg, "error");
|
|
38
|
+
appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${written}`);
|
|
39
|
+
state.blockedCount++;
|
|
40
|
+
return { block: true, reason: msg };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ((event.toolName === "write" || event.toolName === "edit") && event.input.path) {
|
|
45
|
+
if (isProtectedFile(event.input.path as string, config)) {
|
|
46
|
+
const msg = `⛔ Write to protected file blocked: ${event.input.path}`;
|
|
47
|
+
ctx.ui.notify(msg, "error");
|
|
48
|
+
appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${event.input.path}`);
|
|
49
|
+
state.blockedCount++;
|
|
50
|
+
return { block: true, reason: msg };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function trackToolError(event: any, ctx: ExtensionContext, extensionDir: string) {
|
|
56
|
+
const config = loadConfig(ctx.cwd, extensionDir);
|
|
57
|
+
state.errorCount++;
|
|
58
|
+
state.consecutiveErrors++;
|
|
59
|
+
appendToAuditLog(ctx.cwd, config, `ERROR ${event.toolName}: ${String(event.error).substring(0, 200)} (consecutive: ${state.consecutiveErrors})`);
|
|
60
|
+
if (state.consecutiveErrors >= config.maxConsecutiveErrors) {
|
|
61
|
+
const msg = `🚨 ${state.consecutiveErrors} consecutive errors — escalation triggered.`;
|
|
62
|
+
ctx.ui.notify(msg, "error");
|
|
63
|
+
appendToAuditLog(ctx.cwd, config, `ESCALATE consecutive-errors: ${state.consecutiveErrors}`);
|
|
64
|
+
state.lastEscalation = Date.now();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function trackToolSuccess() {
|
|
69
|
+
state.consecutiveErrors = 0;
|
|
70
|
+
}
|