@matyah00/openpi 0.1.5 → 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.
@@ -8,9 +8,10 @@ import { join, resolve } from "path";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { bundledAgentsDir } from "./lib/packagePaths.ts";
10
10
  import { arrayField, parseMarkdownFrontmatter, stringField } from "./lib/markdown.ts";
11
+ import { writeAuditLog } from "./lib/auditLogger.ts";
11
12
 
12
- type ChainStep = { agent: string; prompt: string };
13
- 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 };
14
15
  type AgentDef = { name: string; description: string; tools: string; systemPrompt: string };
15
16
  type StepState = { agent: string; status: "pending" | "running" | "done" | "error"; elapsed: number; lastWork: string };
16
17
 
@@ -24,19 +25,24 @@ function parseChainYaml(raw: string): ChainDef[] {
24
25
 
25
26
  return Object.entries(parsed as Record<string, unknown>).flatMap(([name, value]) => {
26
27
  if (!value || typeof value !== "object" || Array.isArray(value)) return [];
27
- const chain = value as { description?: unknown; steps?: unknown };
28
+ const chain = value as { description?: unknown; steps?: unknown; continueOnError?: unknown };
28
29
  if (!Array.isArray(chain.steps)) return [];
29
30
  const steps = chain.steps.flatMap((step): ChainStep[] => {
30
31
  if (!step || typeof step !== "object" || Array.isArray(step)) return [];
31
- const item = step as { agent?: unknown; prompt?: unknown };
32
+ const item = step as { agent?: unknown; prompt?: unknown; timeout?: unknown };
32
33
  if (typeof item.agent !== "string" || typeof item.prompt !== "string") return [];
33
- return [{ agent: item.agent, prompt: item.prompt }];
34
+ return [{
35
+ agent: item.agent,
36
+ prompt: item.prompt,
37
+ timeout: typeof item.timeout === "number" ? item.timeout : undefined,
38
+ }];
34
39
  });
35
40
  if (!steps.length) return [];
36
41
  return [{
37
42
  name,
38
43
  description: typeof chain.description === "string" ? chain.description : "",
39
44
  steps,
45
+ continueOnError: typeof chain.continueOnError === "boolean" ? chain.continueOnError : undefined,
40
46
  }];
41
47
  });
42
48
  }
@@ -115,7 +121,13 @@ export default function (pi: ExtensionAPI) {
115
121
  }));
116
122
  }
117
123
 
118
- 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 }> {
119
131
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
120
132
  const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-");
121
133
  const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`);
@@ -137,9 +149,21 @@ export default function (pi: ExtensionAPI) {
137
149
  const start = Date.now();
138
150
  const chunks: string[] = [];
139
151
 
152
+ const timeoutMs = timeoutSecs ? timeoutSecs * 1000 : 120000;
153
+
140
154
  return new Promise((resolveDone) => {
141
155
  const cleanupPrompt = () => rmSync(systemPrompt.dir, { recursive: true, force: true });
142
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
+
143
167
  const timer = setInterval(() => {
144
168
  state.elapsed = Date.now() - start;
145
169
  updateWidget();
@@ -166,13 +190,17 @@ export default function (pi: ExtensionAPI) {
166
190
  proc.stderr!.setEncoding("utf-8");
167
191
  proc.stderr!.on("data", () => {});
168
192
  proc.on("close", (code) => {
193
+ clearTimeout(killTimer);
169
194
  clearInterval(timer);
170
195
  cleanupPrompt();
171
196
  state.elapsed = Date.now() - start;
172
197
  if (code === 0) agentSessions.set(agentKey, agentSessionFile);
173
- 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 });
174
201
  });
175
202
  proc.on("error", (error) => {
203
+ clearTimeout(killTimer);
176
204
  clearInterval(timer);
177
205
  cleanupPrompt();
178
206
  resolveDone({ output: `Error spawning agent: ${error.message}`, exitCode: 1, elapsed: Date.now() - start });
@@ -188,28 +216,89 @@ export default function (pi: ExtensionAPI) {
188
216
 
189
217
  let input = task;
190
218
  const original = task;
219
+ const outputs: string[] = [];
220
+
191
221
  for (let i = 0; i < activeChain.steps.length; i++) {
192
222
  const step = activeChain.steps[i];
193
223
  stepStates[i].status = "running";
194
224
  updateWidget();
195
225
 
196
- 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
+
197
236
  const agentDef = allAgents.get(step.agent.toLowerCase());
198
237
  if (!agentDef) {
199
238
  stepStates[i].status = "error";
200
- 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;
201
251
  }
202
252
 
203
- 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
+
204
265
  if (result.exitCode !== 0) {
205
266
  stepStates[i].status = "error";
206
- 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;
207
284
  }
208
285
  stepStates[i].status = "done";
209
286
  input = result.output;
287
+ outputs.push(result.output);
210
288
  updateWidget();
211
289
  }
212
- 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;
213
302
  }
214
303
 
215
304
  pi.registerTool({
@@ -8,6 +8,7 @@ import { join, resolve } from "path";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { bundledAgentsDir } from "./lib/packagePaths.ts";
10
10
  import { arrayField, parseMarkdownFrontmatter, stringField } from "./lib/markdown.ts";
11
+ import { writeAuditLog } from "./lib/auditLogger.ts";
11
12
 
12
13
  type AgentDef = {
13
14
  name: string;
@@ -238,6 +239,16 @@ export default function (pi: ExtensionAPI) {
238
239
  const { agent, task } = params as { agent: string; task: string };
239
240
  onUpdate?.({ content: [{ type: "text", text: `Dispatching to ${agent}...` }], details: { agent, task, status: "running" } });
240
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
+ });
241
252
  const text = result.output.length > 8000 ? `${result.output.slice(0, 8000)}\n\n... [truncated]` : result.output;
242
253
  return {
243
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
  }
@@ -0,0 +1,29 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export type AuditLogEntry = {
5
+ timestamp: string;
6
+ type: "workflow" | "team_agent" | "chain_step" | "chain_run";
7
+ name: string;
8
+ task: string;
9
+ input: string;
10
+ output: string;
11
+ exitCode: number;
12
+ elapsedMs: number;
13
+ metadata?: Record<string, any>;
14
+ };
15
+
16
+ export function writeAuditLog(cwd: string, entry: Omit<AuditLogEntry, "timestamp">) {
17
+ try {
18
+ const logDir = path.join(cwd, ".pi", "logs");
19
+ fs.mkdirSync(logDir, { recursive: true });
20
+ const logFile = path.join(logDir, "openpi-audit.jsonl");
21
+ const line = JSON.stringify({
22
+ timestamp: new Date().toISOString(),
23
+ ...entry
24
+ });
25
+ fs.appendFileSync(logFile, `${line}\n`, "utf-8");
26
+ } catch {
27
+ // Silent fail to ensure logger never disrupts runtime in case of file locks
28
+ }
29
+ }