@matyah00/openpi 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,17 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@sinclair/typebox";
3
- import { Text } from "@mariozechner/pi-tui";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { spawn } from "child_process";
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs";
5
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "fs";
6
+ import { tmpdir } from "os";
6
7
  import { join, resolve } from "path";
8
+ import { parse as parseYaml } from "yaml";
7
9
  import { bundledAgentsDir } from "./lib/packagePaths.ts";
10
+ import { arrayField, parseMarkdownFrontmatter, stringField } from "./lib/markdown.ts";
11
+ import { writeAuditLog } from "./lib/auditLogger.ts";
8
12
 
9
- type ChainStep = { agent: string; prompt: string };
10
- type ChainDef = { name: string; description: string; steps: ChainStep[] };
13
+ type ChainStep = { agent: string; prompt: string; timeout?: number };
14
+ type ChainDef = { name: string; description: string; steps: ChainStep[]; continueOnError?: boolean };
11
15
  type AgentDef = { name: string; description: string; tools: string; systemPrompt: string };
12
16
  type StepState = { agent: string; status: "pending" | "running" | "done" | "error"; elapsed: number; lastWork: string };
13
17
 
@@ -16,57 +20,53 @@ function displayName(name: string): string {
16
20
  }
17
21
 
18
22
  function parseChainYaml(raw: string): ChainDef[] {
19
- const chains: ChainDef[] = [];
20
- let current: ChainDef | null = null;
21
- let currentStep: ChainStep | null = null;
22
-
23
- for (const line of raw.split("\n")) {
24
- const chainMatch = line.match(/^(\S[^:]*):$/);
25
- if (chainMatch) {
26
- if (current && currentStep) current.steps.push(currentStep);
27
- current = { name: chainMatch[1].trim(), description: "", steps: [] };
28
- currentStep = null;
29
- chains.push(current);
30
- continue;
31
- }
32
- const descMatch = line.match(/^\s+description:\s+(.+)$/);
33
- if (descMatch && current && !currentStep) {
34
- current.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
35
- continue;
36
- }
37
- const agentMatch = line.match(/^\s+-\s+agent:\s+(.+)$/);
38
- if (agentMatch && current) {
39
- if (currentStep) current.steps.push(currentStep);
40
- currentStep = { agent: agentMatch[1].trim(), prompt: "" };
41
- continue;
42
- }
43
- const promptMatch = line.match(/^\s+prompt:\s+(.+)$/);
44
- if (promptMatch && currentStep) {
45
- currentStep.prompt = promptMatch[1].trim().replace(/^["']|["']$/g, "").replace(/\\n/g, "\n");
46
- }
47
- }
48
- if (current && currentStep) current.steps.push(currentStep);
49
- return chains;
23
+ const parsed = parseYaml(raw);
24
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
25
+
26
+ return Object.entries(parsed as Record<string, unknown>).flatMap(([name, value]) => {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
28
+ const chain = value as { description?: unknown; steps?: unknown; continueOnError?: unknown };
29
+ if (!Array.isArray(chain.steps)) return [];
30
+ const steps = chain.steps.flatMap((step): ChainStep[] => {
31
+ if (!step || typeof step !== "object" || Array.isArray(step)) return [];
32
+ const item = step as { agent?: unknown; prompt?: unknown; timeout?: unknown };
33
+ if (typeof item.agent !== "string" || typeof item.prompt !== "string") return [];
34
+ return [{
35
+ agent: item.agent,
36
+ prompt: item.prompt,
37
+ timeout: typeof item.timeout === "number" ? item.timeout : undefined,
38
+ }];
39
+ });
40
+ if (!steps.length) return [];
41
+ return [{
42
+ name,
43
+ description: typeof chain.description === "string" ? chain.description : "",
44
+ steps,
45
+ continueOnError: typeof chain.continueOnError === "boolean" ? chain.continueOnError : undefined,
46
+ }];
47
+ });
50
48
  }
51
49
 
52
50
  function parseAgentFile(filePath: string): AgentDef | null {
53
51
  const raw = readFileSync(filePath, "utf-8");
54
- const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
55
- if (!match) return null;
56
- const frontmatter: Record<string, string> = {};
57
- for (const line of match[1].split("\n")) {
58
- const idx = line.indexOf(":");
59
- if (idx > 0) frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
60
- }
61
- if (!frontmatter.name) return null;
52
+ const { frontmatter, body } = parseMarkdownFrontmatter(raw);
53
+ const name = stringField(frontmatter.name);
54
+ if (!name) return null;
62
55
  return {
63
- name: frontmatter.name,
64
- description: frontmatter.description || "",
65
- tools: frontmatter.tools || "read,grep,find,ls",
66
- systemPrompt: match[2].trim(),
56
+ name,
57
+ description: stringField(frontmatter.description),
58
+ tools: arrayField(frontmatter.tools).join(",") || "read,grep,find,ls",
59
+ systemPrompt: body,
67
60
  };
68
61
  }
69
62
 
63
+ function writeSystemPromptFile(agentName: string, systemPrompt: string): { dir: string; filePath: string } {
64
+ const dir = mkdtempSync(join(tmpdir(), "openpi-chain-"));
65
+ const filePath = join(dir, `system-${agentName.replace(/[^\w.-]+/g, "_")}.md`);
66
+ writeFileSync(filePath, systemPrompt, { encoding: "utf-8", mode: 0o600 });
67
+ return { dir, filePath };
68
+ }
69
+
70
70
  function scanAgentDirs(cwd: string): Map<string, AgentDef> {
71
71
  const dirs = [join(cwd, ".pi", "agents"), join(cwd, "agents"), bundledAgentsDir];
72
72
  const agents = new Map<string, AgentDef>();
@@ -121,17 +121,24 @@ export default function (pi: ExtensionAPI) {
121
121
  }));
122
122
  }
123
123
 
124
- function runAgent(agentDef: AgentDef, task: string, stepIndex: number, ctx: any): Promise<{ output: string; exitCode: number; elapsed: number }> {
124
+ function runAgent(
125
+ agentDef: AgentDef,
126
+ task: string,
127
+ stepIndex: number,
128
+ timeoutSecs: number | undefined,
129
+ ctx: any,
130
+ ): Promise<{ output: string; exitCode: number; elapsed: number }> {
125
131
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
126
132
  const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-");
127
133
  const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`);
134
+ const systemPrompt = writeSystemPromptFile(agentKey, agentDef.systemPrompt);
128
135
  const args = [
129
136
  "--mode", "json",
130
137
  "-p",
131
138
  "--no-extensions",
132
139
  "--tools", agentDef.tools,
133
140
  "--thinking", "off",
134
- "--append-system-prompt", agentDef.systemPrompt,
141
+ "--append-system-prompt", systemPrompt.filePath,
135
142
  "--session", agentSessionFile,
136
143
  ];
137
144
  if (model) args.splice(4, 0, "--model", model);
@@ -142,8 +149,21 @@ export default function (pi: ExtensionAPI) {
142
149
  const start = Date.now();
143
150
  const chunks: string[] = [];
144
151
 
152
+ const timeoutMs = timeoutSecs ? timeoutSecs * 1000 : 120000;
153
+
145
154
  return new Promise((resolveDone) => {
155
+ const cleanupPrompt = () => rmSync(systemPrompt.dir, { recursive: true, force: true });
146
156
  const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
157
+
158
+ let killed = false;
159
+ const killTimer = setTimeout(() => {
160
+ killed = true;
161
+ proc.kill("SIGTERM");
162
+ setTimeout(() => {
163
+ if (proc.exitCode === null) proc.kill("SIGKILL");
164
+ }, 2000);
165
+ }, timeoutMs);
166
+
147
167
  const timer = setInterval(() => {
148
168
  state.elapsed = Date.now() - start;
149
169
  updateWidget();
@@ -170,13 +190,19 @@ export default function (pi: ExtensionAPI) {
170
190
  proc.stderr!.setEncoding("utf-8");
171
191
  proc.stderr!.on("data", () => {});
172
192
  proc.on("close", (code) => {
193
+ clearTimeout(killTimer);
173
194
  clearInterval(timer);
195
+ cleanupPrompt();
174
196
  state.elapsed = Date.now() - start;
175
197
  if (code === 0) agentSessions.set(agentKey, agentSessionFile);
176
- resolveDone({ output: chunks.join(""), exitCode: code ?? 1, elapsed: state.elapsed });
198
+ const exitCode = killed ? 124 : (code ?? 1);
199
+ const finalOutput = killed ? `${chunks.join("")}\n\n[Agent timeout after ${timeoutSecs}s]` : chunks.join("");
200
+ resolveDone({ output: finalOutput, exitCode, elapsed: state.elapsed });
177
201
  });
178
202
  proc.on("error", (error) => {
203
+ clearTimeout(killTimer);
179
204
  clearInterval(timer);
205
+ cleanupPrompt();
180
206
  resolveDone({ output: `Error spawning agent: ${error.message}`, exitCode: 1, elapsed: Date.now() - start });
181
207
  });
182
208
  });
@@ -190,28 +216,89 @@ export default function (pi: ExtensionAPI) {
190
216
 
191
217
  let input = task;
192
218
  const original = task;
219
+ const outputs: string[] = [];
220
+
193
221
  for (let i = 0; i < activeChain.steps.length; i++) {
194
222
  const step = activeChain.steps[i];
195
223
  stepStates[i].status = "running";
196
224
  updateWidget();
197
225
 
198
- const prompt = step.prompt.replace(/\$INPUT/g, input).replace(/\$ORIGINAL/g, original);
226
+ let prompt = step.prompt.replace(/\$INPUT/g, input).replace(/\$ORIGINAL/g, original);
227
+ // Replace $STEP_N references
228
+ prompt = prompt.replace(/\$STEP_(\d+)/g, (match, numStr) => {
229
+ const num = parseInt(numStr, 10);
230
+ if (num > 0 && num <= outputs.length) {
231
+ return outputs[num - 1];
232
+ }
233
+ return match;
234
+ });
235
+
199
236
  const agentDef = allAgents.get(step.agent.toLowerCase());
200
237
  if (!agentDef) {
201
238
  stepStates[i].status = "error";
202
- return { output: `Agent "${step.agent}" not found`, success: false, elapsed: Date.now() - start };
239
+ const errResult = { output: `Agent "${step.agent}" not found`, success: false, elapsed: Date.now() - start };
240
+ writeAuditLog(ctx.cwd, {
241
+ type: "chain_run",
242
+ name: activeChain.name,
243
+ task,
244
+ input: task,
245
+ output: errResult.output,
246
+ exitCode: 1,
247
+ elapsedMs: errResult.elapsed,
248
+ metadata: { stepsCount: activeChain.steps.length, success: false, error: "agent_not_found" }
249
+ });
250
+ return errResult;
203
251
  }
204
252
 
205
- const result = await runAgent(agentDef, prompt, i, ctx);
253
+ const result = await runAgent(agentDef, prompt, i, step.timeout, ctx);
254
+ writeAuditLog(ctx.cwd, {
255
+ type: "chain_step",
256
+ name: `${activeChain.name}:${step.agent}`,
257
+ task: prompt,
258
+ input: input,
259
+ output: result.output,
260
+ exitCode: result.exitCode,
261
+ elapsedMs: result.elapsed,
262
+ metadata: { stepIndex: i, chainName: activeChain.name, agent: step.agent }
263
+ });
264
+
206
265
  if (result.exitCode !== 0) {
207
266
  stepStates[i].status = "error";
208
- return { output: result.output, success: false, elapsed: Date.now() - start };
267
+ if (activeChain.continueOnError) {
268
+ outputs.push("");
269
+ updateWidget();
270
+ continue;
271
+ }
272
+ const errResult = { output: result.output, success: false, elapsed: Date.now() - start };
273
+ writeAuditLog(ctx.cwd, {
274
+ type: "chain_run",
275
+ name: activeChain.name,
276
+ task,
277
+ input: task,
278
+ output: errResult.output,
279
+ exitCode: 1,
280
+ elapsedMs: errResult.elapsed,
281
+ metadata: { stepsCount: activeChain.steps.length, success: false, error: "step_failed", failedStep: step.agent }
282
+ });
283
+ return errResult;
209
284
  }
210
285
  stepStates[i].status = "done";
211
286
  input = result.output;
287
+ outputs.push(result.output);
212
288
  updateWidget();
213
289
  }
214
- return { output: input, success: true, elapsed: Date.now() - start };
290
+ const finalResult = { output: input, success: true, elapsed: Date.now() - start };
291
+ writeAuditLog(ctx.cwd, {
292
+ type: "chain_run",
293
+ name: activeChain.name,
294
+ task,
295
+ input: task,
296
+ output: finalResult.output,
297
+ exitCode: 0,
298
+ elapsedMs: finalResult.elapsed,
299
+ metadata: { stepsCount: activeChain.steps.length, success: true }
300
+ });
301
+ return finalResult;
215
302
  }
216
303
 
217
304
  pi.registerTool({
@@ -1,10 +1,14 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@sinclair/typebox";
3
- import { Text } from "@mariozechner/pi-tui";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { spawn } from "child_process";
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs";
5
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "fs";
6
+ import { tmpdir } from "os";
6
7
  import { join, resolve } from "path";
8
+ import { parse as parseYaml } from "yaml";
7
9
  import { bundledAgentsDir } from "./lib/packagePaths.ts";
10
+ import { arrayField, parseMarkdownFrontmatter, stringField } from "./lib/markdown.ts";
11
+ import { writeAuditLog } from "./lib/auditLogger.ts";
8
12
 
9
13
  type AgentDef = {
10
14
  name: string;
@@ -28,42 +32,39 @@ function displayName(name: string): string {
28
32
  }
29
33
 
30
34
  function parseTeamsYaml(raw: string): Record<string, string[]> {
35
+ const parsed = parseYaml(raw);
36
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
37
+
31
38
  const teams: Record<string, string[]> = {};
32
- let current: string | null = null;
33
- for (const line of raw.split("\n")) {
34
- const teamMatch = line.match(/^(\S[^:]*):$/);
35
- if (teamMatch) {
36
- current = teamMatch[1].trim();
37
- teams[current] = [];
38
- continue;
39
- }
40
- const itemMatch = line.match(/^\s+-\s+(.+)$/);
41
- if (itemMatch && current) teams[current].push(itemMatch[1].trim());
39
+ for (const [name, members] of Object.entries(parsed as Record<string, unknown>)) {
40
+ if (!Array.isArray(members)) continue;
41
+ teams[name] = members.filter((member): member is string => typeof member === "string");
42
42
  }
43
43
  return teams;
44
44
  }
45
45
 
46
46
  function parseAgentFile(filePath: string): AgentDef | null {
47
47
  const raw = readFileSync(filePath, "utf-8");
48
- const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
49
- if (!match) return null;
50
-
51
- const frontmatter: Record<string, string> = {};
52
- for (const line of match[1].split("\n")) {
53
- const idx = line.indexOf(":");
54
- if (idx > 0) frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
55
- }
56
- if (!frontmatter.name) return null;
48
+ const { frontmatter, body } = parseMarkdownFrontmatter(raw);
49
+ const name = stringField(frontmatter.name);
50
+ if (!name) return null;
57
51
 
58
52
  return {
59
- name: frontmatter.name,
60
- description: frontmatter.description || "",
61
- tools: frontmatter.tools || "read,grep,find,ls",
62
- systemPrompt: match[2].trim(),
53
+ name,
54
+ description: stringField(frontmatter.description),
55
+ tools: arrayField(frontmatter.tools).join(",") || "read,grep,find,ls",
56
+ systemPrompt: body,
63
57
  file: filePath,
64
58
  };
65
59
  }
66
60
 
61
+ function writeSystemPromptFile(agentKey: string, systemPrompt: string): { dir: string; filePath: string } {
62
+ const dir = mkdtempSync(join(tmpdir(), "openpi-team-"));
63
+ const filePath = join(dir, `system-${agentKey.replace(/[^\w.-]+/g, "_")}.md`);
64
+ writeFileSync(filePath, systemPrompt, { encoding: "utf-8", mode: 0o600 });
65
+ return { dir, filePath };
66
+ }
67
+
67
68
  function scanAgentDirs(cwd: string): AgentDef[] {
68
69
  const dirs = [join(cwd, ".pi", "agents"), join(cwd, "agents"), bundledAgentsDir];
69
70
  const agents: AgentDef[] = [];
@@ -161,13 +162,14 @@ export default function (pi: ExtensionAPI) {
161
162
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
162
163
  const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
163
164
  const agentSessionFile = join(sessionDir, `${agentKey}.json`);
165
+ const systemPrompt = writeSystemPromptFile(agentKey, state.def.systemPrompt);
164
166
  const args = [
165
167
  "--mode", "json",
166
168
  "-p",
167
169
  "--no-extensions",
168
170
  "--tools", state.def.tools,
169
171
  "--thinking", "off",
170
- "--append-system-prompt", state.def.systemPrompt,
172
+ "--append-system-prompt", systemPrompt.filePath,
171
173
  "--session", agentSessionFile,
172
174
  ];
173
175
  if (model) args.splice(4, 0, "--model", model);
@@ -178,6 +180,7 @@ export default function (pi: ExtensionAPI) {
178
180
  const chunks: string[] = [];
179
181
 
180
182
  return new Promise((resolveDone) => {
183
+ const cleanupPrompt = () => rmSync(systemPrompt.dir, { recursive: true, force: true });
181
184
  const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
182
185
  const timer = setInterval(() => {
183
186
  state.elapsed = Date.now() - start;
@@ -206,6 +209,7 @@ export default function (pi: ExtensionAPI) {
206
209
  proc.stderr!.on("data", () => {});
207
210
  proc.on("close", (code) => {
208
211
  clearInterval(timer);
212
+ cleanupPrompt();
209
213
  state.elapsed = Date.now() - start;
210
214
  state.status = code === 0 ? "done" : "error";
211
215
  if (code === 0) state.sessionFile = agentSessionFile;
@@ -214,6 +218,7 @@ export default function (pi: ExtensionAPI) {
214
218
  });
215
219
  proc.on("error", (error) => {
216
220
  clearInterval(timer);
221
+ cleanupPrompt();
217
222
  state.status = "error";
218
223
  state.lastWork = error.message;
219
224
  updateWidget();
@@ -234,6 +239,16 @@ export default function (pi: ExtensionAPI) {
234
239
  const { agent, task } = params as { agent: string; task: string };
235
240
  onUpdate?.({ content: [{ type: "text", text: `Dispatching to ${agent}...` }], details: { agent, task, status: "running" } });
236
241
  const result = await dispatchAgent(agent, task, ctx);
242
+ writeAuditLog(ctx.cwd, {
243
+ type: "team_agent",
244
+ name: agent,
245
+ task: task,
246
+ input: task,
247
+ output: result.output,
248
+ exitCode: result.exitCode,
249
+ elapsedMs: result.elapsed,
250
+ metadata: { teamName: activeTeamName }
251
+ });
237
252
  const text = result.output.length > 8000 ? `${result.output.slice(0, 8000)}\n\n... [truncated]` : result.output;
238
253
  return {
239
254
  content: [{ type: "text", text: `[${agent}] ${result.exitCode === 0 ? "done" : "error"} in ${Math.round(result.elapsed / 1000)}s\n\n${text}` }],
@@ -31,6 +31,19 @@ const SECRET_PATTERNS = [
31
31
  ["sendgrid-key", /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g],
32
32
  ["private-key", /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g],
33
33
  ["jwt-like-token", /eyJ[A-Za-z0-9_-]{40,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
34
+ // New patterns
35
+ ["supabase-key", /sbp_[a-f0-9]{40}/g],
36
+ ["twilio-sid", /AC[a-f0-9]{32}/g],
37
+ ["twilio-auth", /SK[a-f0-9]{32}/g],
38
+ ["mailgun-key", /key-[a-zA-Z0-9]{32}/g],
39
+ ["slack-webhook", /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[a-zA-Z0-9]+/g],
40
+ ["digitalocean-token", /dop_v1_[a-f0-9]{64}/g],
41
+ ["databricks-token", /dapi[a-f0-9]{32}/g],
42
+ ["openai-key", /sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}/g],
43
+ ["anthropic-key", /sk-ant-[a-zA-Z0-9_-]{90,}/g],
44
+ ["azure-connection-string", /DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[^;]+/g],
45
+ ["vercel-token", /vercel_[A-Za-z0-9_-]{24,}/g],
46
+ ["hashicorp-vault-token", /hvs\.[A-Za-z0-9_-]{24,}/g],
34
47
  ] as const;
35
48
 
36
49
  const GHOST_PATTERNS = [
@@ -38,6 +51,30 @@ const GHOST_PATTERNS = [
38
51
  ["exit-bypass", /sys\.exit\s*\(\s*0\s*\)|os\._exit\s*\(\s*0\s*\)|process\.exit\s*\(\s*0\s*\)/g],
39
52
  ["pytest-report-patch", /TestReport\.from_item_and_call|pytest_runtest_makereport|monkeypatch.*TestReport/g],
40
53
  ["assertion-monkeypatch", /expect\s*=\s*jest\.fn|assert\s*=\s*lambda|monkeypatch.*assert/g],
54
+ // New patterns
55
+ ["empty-test-body", /it\s*\(\s*['"][^'"]*['"]\s*,\s*\(\s*\)\s*=>\s*\{\s*\}\s*\)|def\s+test_\w+\s*\([^)]*\)\s*:\s*\n\s*pass\b/g],
56
+ ["catch-all-swallow", /catch\s*\([^)]*\)\s*\{\s*\}|except\s*:\s*\n\s*pass\b/g],
57
+ ["self-comparison", /expect\s*\(\s*(\w+)\s*\)\s*\.toBe\s*\(\s*\1\s*\)|assert\s+(\w+)\s*==\s*\2\b/g],
58
+ ["disabled-tests", /\bxit\s*\(|\bxdescribe\s*\(|\bxtest\s*\(|@pytest\.mark\.skip(?!\(reason)|\.skip\s*\(\s*\)/g],
59
+ ["console-only-validation", /(?:it|test)\s*\([^)]*\)\s*(?:=>|{)[\s\S]{0,200}console\.\w+[\s\S]{0,50}(?:\}|\))\s*(?:;|\n)(?![\s\S]{0,50}(?:expect|assert|should))/g],
60
+ ["unreachable-assertion", /\breturn\b[\s\S]{0,20}\n\s*(?:expect|assert)\b/g],
61
+ ["mock-everything", /jest\.mock\s*\(\s*['"]\.\.?\//g],
62
+ ["timeout-zero", /setTimeout\s*\(\s*(?:done|resolve|callback)\s*,\s*0\s*\)/g],
63
+ ] as const;
64
+
65
+ const SAST_PATTERNS = [
66
+ ["eval-usage", /\beval\s*\(/g],
67
+ ["function-constructor", /new\s+Function\s*\(/g],
68
+ ["child-process-exec", /child_process.*\.exec\s*\(|exec\s*\(\s*`/g],
69
+ ["sql-concat", /['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[\s\S]{0,80}\$\{|['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[\s\S]{0,80}\+\s*\w/g],
70
+ ["innerhtml-assign", /\.innerHTML\s*=\s*(?!['"`]<)/g],
71
+ ["dangerous-react", /dangerouslySetInnerHTML/g],
72
+ ["path-traversal", /\.\.\/|\.\.\\|req\.\w+\.\w+.*(?:readFile|writeFile|createReadStream|path\.join|path\.resolve)/g],
73
+ ["prototype-pollution", /\[['"`]__proto__['"`]\]|\[['"`]constructor['"`]\]|\[['"`]prototype['"`]\]/g],
74
+ ["ssrf-pattern", /fetch\s*\(\s*(?:req\.|request\.|params\.|query\.)|axios\.\w+\s*\(\s*(?:req\.|request\.|params\.|query\.)/g],
75
+ ["hardcoded-secret-assign", /(?:password|secret|apiKey|api_key|token|auth)\s*[:=]\s*['"][^'"]{8,}['"]/g],
76
+ ["unvalidated-redirect", /res\.redirect\s*\(\s*req\.|response\.redirect\s*\(\s*request\./g],
77
+ ["cors-wildcard", /(?:Access-Control-Allow-Origin|origin)\s*[:=]\s*['"]\*['"]/g],
41
78
  ] as const;
42
79
 
43
80
  function walkFiles(root: string, maxFiles: number): string[] {
@@ -103,17 +140,64 @@ function detectFrameworks(root: string): string[] {
103
140
  if (deps.react) found.add("React");
104
141
  if (deps.vite) found.add("Vite");
105
142
  if (deps["@sveltejs/kit"]) found.add("SvelteKit");
143
+ if (deps.svelte) found.add("Svelte");
106
144
  if (deps.vue) found.add("Vue");
107
145
  if (deps.express) found.add("Express");
108
146
  if (deps.vitest) found.add("Vitest");
109
147
  if (deps.jest) found.add("Jest");
110
148
  if (deps.typescript) found.add("TypeScript");
149
+ // New framework detections
150
+ if (deps.astro) found.add("Astro");
151
+ if (deps["@remix-run/node"] || deps["@remix-run/react"]) found.add("Remix");
152
+ if (deps.nuxt) found.add("Nuxt");
153
+ if (deps["@angular/core"]) found.add("Angular");
154
+ if (deps["@nestjs/core"]) found.add("NestJS");
155
+ if (deps.fastify) found.add("Fastify");
156
+ if (deps.hono) found.add("Hono");
157
+ if (deps.elysia) found.add("Elysia");
158
+ if (deps.prisma || deps["@prisma/client"]) found.add("Prisma");
159
+ if (deps["drizzle-orm"]) found.add("Drizzle");
160
+ if (deps["@trpc/server"]) found.add("tRPC");
161
+ if (deps.graphql || deps["@apollo/server"]) found.add("GraphQL");
162
+ if (deps.tailwindcss) found.add("Tailwind CSS");
163
+ if (deps.playwright || deps["@playwright/test"]) found.add("Playwright");
164
+ if (deps.cypress) found.add("Cypress");
165
+ if (deps.storybook || deps["@storybook/react"]) found.add("Storybook");
166
+ if (deps.turborepo || deps.turbo) found.add("Turborepo");
167
+ if (deps.electron) found.add("Electron");
168
+ if (deps["react-native"]) found.add("React Native");
169
+ if (deps.expo) found.add("Expo");
111
170
  }
112
171
  }
113
- if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "requirements.txt"))) found.add("Python");
172
+ if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "requirements.txt"))) {
173
+ found.add("Python");
174
+ const pyReqs = readSmallText(path.join(root, "requirements.txt"));
175
+ const pyProject = readSmallText(path.join(root, "pyproject.toml"));
176
+ const pyContent = (pyReqs || "") + (pyProject || "");
177
+ if (pyContent.includes("fastapi") || pyContent.includes("FastAPI")) found.add("FastAPI");
178
+ if (pyContent.includes("django") || pyContent.includes("Django")) found.add("Django");
179
+ if (pyContent.includes("flask") || pyContent.includes("Flask")) found.add("Flask");
180
+ if (pyContent.includes("pytest")) found.add("pytest");
181
+ }
114
182
  if (fs.existsSync(path.join(root, "Cargo.toml"))) found.add("Rust");
115
183
  if (fs.existsSync(path.join(root, "go.mod"))) found.add("Go");
116
184
  if (fs.existsSync(path.join(root, "Dockerfile"))) found.add("Docker");
185
+ if (fs.existsSync(path.join(root, "Gemfile"))) {
186
+ found.add("Ruby");
187
+ const gemfile = readSmallText(path.join(root, "Gemfile"));
188
+ if (gemfile?.includes("rails")) found.add("Rails");
189
+ }
190
+ if (fs.existsSync(path.join(root, "mix.exs"))) {
191
+ found.add("Elixir");
192
+ const mix = readSmallText(path.join(root, "mix.exs"));
193
+ if (mix?.includes("phoenix")) found.add("Phoenix");
194
+ }
195
+ if (fs.existsSync(path.join(root, "pom.xml")) || fs.existsSync(path.join(root, "build.gradle"))) {
196
+ found.add("Java");
197
+ const pom = readSmallText(path.join(root, "pom.xml")) || "";
198
+ const gradle = readSmallText(path.join(root, "build.gradle")) || "";
199
+ if (pom.includes("spring-boot") || gradle.includes("spring-boot")) found.add("Spring Boot");
200
+ }
117
201
  return Array.from(found);
118
202
  }
119
203
 
@@ -131,7 +215,7 @@ function dependencyInventory(root: string): string {
131
215
  if (loose.length) sections.push(`Loose pins: ${loose.slice(0, 20).map(([name, version]) => `${name}@${version}`).join(", ")}${loose.length > 20 ? `, +${loose.length - 20} more` : ""}`);
132
216
  }
133
217
  }
134
- for (const file of ["requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "Gemfile"]) {
218
+ for (const file of ["requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "Gemfile", "mix.exs", "pom.xml", "build.gradle"]) {
135
219
  if (fs.existsSync(path.join(root, file))) sections.push(`Manifest: ${file}`);
136
220
  }
137
221
  return sections.join("\n") || "No common dependency manifests found.";
@@ -179,7 +263,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
179
263
  pi.registerTool({
180
264
  name: "secret_scan",
181
265
  label: "Secret Scan",
182
- description: "Read-only high-signal secret scan with redacted findings.",
266
+ description: "Read-only high-signal secret scan with redacted findings. Detects 22 secret patterns including cloud provider keys, API tokens, and private keys.",
183
267
  promptSnippet: "Use secret_scan before shipping, importing external files, or touching credentials.",
184
268
  parameters: Type.Object({
185
269
  maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 5000." })),
@@ -202,7 +286,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
202
286
  const envGitignored = !fs.existsSync(path.join(ctx.cwd, ".env")) || (readSmallText(path.join(ctx.cwd, ".gitignore")) || "").includes(".env");
203
287
  const verdict = findings.length ? "BLOCKED" : "CLEAN";
204
288
  return {
205
- content: [{ type: "text", text: [`secret_scan verdict: ${verdict}`, `.env gitignored: ${envGitignored ? "yes" : "no"}`, "", ...(findings.length ? findings : ["No high-signal secrets found."])].join("\n") }],
289
+ content: [{ type: "text", text: [`secret_scan verdict: ${verdict}`, `patterns: ${SECRET_PATTERNS.length}`, `files scanned: ${files.length}`, `.env gitignored: ${envGitignored ? "yes" : "no"}`, "", ...(findings.length ? findings : ["No high-signal secrets found."])].join("\n") }],
206
290
  details: { findings },
207
291
  };
208
292
  },
@@ -214,7 +298,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
214
298
  pi.registerTool({
215
299
  name: "ghost_test_scan",
216
300
  label: "Ghost Test Scan",
217
- description: "Static scan for test-suite reward hacking patterns such as always-true equality, exit bypasses, and framework patching.",
301
+ description: "Static scan for test-suite reward hacking patterns: always-true equality, exit bypasses, empty test bodies, disabled tests, self-comparisons, and framework patching.",
218
302
  promptSnippet: "Use ghost_test_scan when validating test integrity before trusting green tests.",
219
303
  parameters: Type.Object({
220
304
  maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 3000." })),
@@ -235,7 +319,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
235
319
  }
236
320
  }
237
321
  return {
238
- content: [{ type: "text", text: [`ghost_test_scan verdict: ${findings.length ? "SUSPICIOUS" : "CLEAN"}`, `files scanned: ${files.length}`, "", ...(findings.length ? findings : ["No static reward-hack patterns found."])].join("\n") }],
322
+ content: [{ type: "text", text: [`ghost_test_scan verdict: ${findings.length ? "SUSPICIOUS" : "CLEAN"}`, `patterns: ${GHOST_PATTERNS.length}`, `files scanned: ${files.length}`, "", ...(findings.length ? findings : ["No static reward-hack patterns found."])].join("\n") }],
239
323
  details: { findings },
240
324
  };
241
325
  },
@@ -257,4 +341,39 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
257
341
  return new Text(theme.fg("toolTitle", theme.bold("dependency_inventory")), 0, 0);
258
342
  },
259
343
  });
344
+
345
+ pi.registerTool({
346
+ name: "sast_scan",
347
+ label: "SAST Scan",
348
+ description: "Static application security testing: detects eval(), SQL injection, prototype pollution, SSRF, XSS, unvalidated redirects, CORS wildcards, and hardcoded secrets in code.",
349
+ promptSnippet: "Use sast_scan to detect dangerous code patterns before shipping or reviewing external contributions.",
350
+ parameters: Type.Object({
351
+ maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 5000." })),
352
+ }),
353
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
354
+ const maxFiles = Math.max(100, Math.min(Number(params.maxFiles ?? 5000), 20000));
355
+ const codeExts = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|php)$/i;
356
+ const files = walkFiles(ctx.cwd, maxFiles).filter((file) => codeExts.test(file));
357
+ const findings: string[] = [];
358
+ for (const file of files) {
359
+ const content = readSmallText(file);
360
+ if (!content) continue;
361
+ for (const [rule, pattern] of SAST_PATTERNS) {
362
+ pattern.lastIndex = 0;
363
+ let match: RegExpExecArray | null;
364
+ while ((match = pattern.exec(content))) {
365
+ findings.push(`${relative(ctx.cwd, file)}:${lineOf(content, match.index)} ${rule}`);
366
+ }
367
+ }
368
+ }
369
+ const severity = findings.some((f) => /eval-usage|sql-concat|child-process-exec|prototype-pollution/.test(f)) ? "HIGH" : findings.length ? "MEDIUM" : "CLEAN";
370
+ return {
371
+ content: [{ type: "text", text: [`sast_scan verdict: ${severity}`, `patterns: ${SAST_PATTERNS.length}`, `files scanned: ${files.length}`, "", ...(findings.length ? findings : ["No dangerous code patterns found."])].join("\n") }],
372
+ details: { findings, severity },
373
+ };
374
+ },
375
+ renderCall(_args, theme) {
376
+ return new Text(theme.fg("toolTitle", theme.bold("sast_scan")), 0, 0);
377
+ },
378
+ });
260
379
  }
@@ -12,8 +12,8 @@
12
12
  * Usage: pi -e extensions/damage-control-continue.ts
13
13
  */
14
14
 
15
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
- import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
17
17
  import { parse as yamlParse } from "yaml";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";