@diegopetrucci/pi-extensions 0.1.74 → 0.1.76

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.
Files changed (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +50 -12
  54. package/extensions/fast/index.ts +513 -317
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -1,14 +1,19 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
 
4
- import type { AgentSession, ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
4
+ import type {
5
+ AgentSession,
6
+ ExtensionAPI,
7
+ ExtensionCommandContext,
8
+ ExtensionFactory,
9
+ } from "@earendil-works/pi-coding-agent";
5
10
  import {
6
- DefaultResourceLoader,
7
- SessionManager,
8
- SettingsManager,
9
- createAgentSession,
10
- getAgentDir,
11
- getMarkdownTheme,
11
+ DefaultResourceLoader,
12
+ SessionManager,
13
+ SettingsManager,
14
+ createAgentSession,
15
+ getAgentDir,
16
+ getMarkdownTheme,
12
17
  } from "@earendil-works/pi-coding-agent";
13
18
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
14
19
 
@@ -37,834 +42,1177 @@ type AuditStatus = "running" | "done" | "error" | "aborted";
37
42
  type AuditMode = "execute" | "plan-only";
38
43
 
39
44
  type ToolCall = {
40
- id: string;
41
- name: string;
42
- args: unknown;
43
- startedAt: number;
44
- endedAt?: number;
45
- isError?: boolean;
45
+ id: string;
46
+ name: string;
47
+ args: unknown;
48
+ startedAt: number;
49
+ endedAt?: number;
50
+ isError?: boolean;
46
51
  };
47
52
 
48
53
  type AuditDetails = {
49
- status: AuditStatus;
50
- mode: AuditMode;
51
- cwd: string;
52
- focus?: string;
53
- turns: number;
54
- toolCalls: ToolCall[];
55
- startedAt: number;
56
- endedAt?: number;
57
- error?: string;
58
- initialGitStatus?: string;
59
- finalGitStatus?: string;
60
- reportLength?: number;
61
- toolCallCount?: number;
54
+ status: AuditStatus;
55
+ mode: AuditMode;
56
+ cwd: string;
57
+ focus?: string;
58
+ turns: number;
59
+ toolCalls: ToolCall[];
60
+ startedAt: number;
61
+ endedAt?: number;
62
+ error?: string;
63
+ initialGitStatus?: string;
64
+ finalGitStatus?: string;
65
+ reportLength?: number;
66
+ toolCallCount?: number;
62
67
  };
63
68
 
64
69
  type ParsedArgs = {
65
- help: boolean;
66
- yes: boolean;
67
- planOnly: boolean;
68
- focus?: string;
69
- error?: string;
70
+ help: boolean;
71
+ yes: boolean;
72
+ planOnly: boolean;
73
+ focus?: string;
74
+ error?: string;
70
75
  };
71
76
 
72
77
  type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
73
78
 
74
- function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
75
- const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
76
- return modelRuntime ? { modelRuntime } : {};
79
+ function getModelRuntimeOption(ctx: {
80
+ modelRegistry?: unknown;
81
+ }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
82
+ const modelRuntime = (
83
+ ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
84
+ )?.runtime;
85
+ return modelRuntime ? { modelRuntime } : {};
77
86
  }
78
87
 
79
88
  function parseArgs(args: string): ParsedArgs {
80
- const parts = args.trim().split(/\s+/).filter(Boolean);
81
- const focusParts: string[] = [];
82
- const parsed: ParsedArgs = { help: false, yes: false, planOnly: false };
83
-
84
- for (const part of parts) {
85
- if (part === "--help" || part === "-h" || part === "help") {
86
- parsed.help = true;
87
- continue;
88
- }
89
- if (part === "--yes" || part === "-y") {
90
- parsed.yes = true;
91
- continue;
92
- }
93
- if (part === "--plan-only" || part === "--dry-run") {
94
- parsed.planOnly = true;
95
- continue;
96
- }
97
- if (part.startsWith("-")) {
98
- return { ...parsed, error: `Unknown /agent-workflow-audit option: ${part}\n${USAGE}` };
99
- }
100
- focusParts.push(part);
101
- }
102
-
103
- const focus = focusParts.join(" ").trim();
104
- if (focus) parsed.focus = focus;
105
- return parsed;
89
+ const parts = args.trim().split(/\s+/).filter(Boolean);
90
+ const focusParts: string[] = [];
91
+ const parsed: ParsedArgs = { help: false, yes: false, planOnly: false };
92
+
93
+ for (const part of parts) {
94
+ if (part === "--help" || part === "-h" || part === "help") {
95
+ parsed.help = true;
96
+ continue;
97
+ }
98
+ if (part === "--yes" || part === "-y") {
99
+ parsed.yes = true;
100
+ continue;
101
+ }
102
+ if (part === "--plan-only" || part === "--dry-run") {
103
+ parsed.planOnly = true;
104
+ continue;
105
+ }
106
+ if (part.startsWith("-")) {
107
+ return { ...parsed, error: `Unknown /agent-workflow-audit option: ${part}\n${USAGE}` };
108
+ }
109
+ focusParts.push(part);
110
+ }
111
+
112
+ const focus = focusParts.join(" ").trim();
113
+ if (focus) parsed.focus = focus;
114
+ return parsed;
106
115
  }
107
116
 
108
117
  function notifyCommand(
109
- ctx: ExtensionCommandContext,
110
- message: string,
111
- type: "info" | "warning" | "error" = "info",
118
+ ctx: ExtensionCommandContext,
119
+ message: string,
120
+ type: "info" | "warning" | "error" = "info",
112
121
  ): void {
113
- if (ctx.hasUI) {
114
- ctx.ui.notify(message, type);
115
- return;
116
- }
122
+ if (ctx.hasUI) {
123
+ ctx.ui.notify(message, type);
124
+ return;
125
+ }
117
126
 
118
- const writer = type === "error" ? console.error : console.log;
119
- writer(message);
127
+ const writer = type === "error" ? console.error : console.log;
128
+ writer(message);
120
129
  }
121
130
 
122
131
  function asRecord(value: unknown): Record<string, unknown> | undefined {
123
- return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
132
+ return value && typeof value === "object" && !Array.isArray(value)
133
+ ? (value as Record<string, unknown>)
134
+ : undefined;
124
135
  }
125
136
 
126
137
  function shorten(text: string, max: number): string {
127
- const oneLine = text.replace(/\s+/g, " ").trim();
128
- if (oneLine.length <= max) return oneLine;
129
- return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
138
+ const oneLine = text.replace(/\s+/g, " ").trim();
139
+ if (oneLine.length <= max) return oneLine;
140
+ return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
130
141
  }
131
142
 
132
143
  function formatDuration(durationMs: number): string {
133
- if (durationMs < 1000) return `${durationMs}ms`;
134
- if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)}s`;
135
- return `${(durationMs / 60_000).toFixed(1)}m`;
144
+ if (durationMs < 1000) return `${durationMs}ms`;
145
+ if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)}s`;
146
+ return `${(durationMs / 60_000).toFixed(1)}m`;
136
147
  }
137
148
 
138
149
  function messageContentToText(content: unknown): string {
139
- if (typeof content === "string") return content;
140
- if (!Array.isArray(content)) return "";
141
- const parts: string[] = [];
142
- for (const part of content) {
143
- if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
144
- const text = (part as { text?: unknown }).text;
145
- if (typeof text === "string") parts.push(text);
146
- }
147
- }
148
- return parts.join("\n").trim();
150
+ if (typeof content === "string") return content;
151
+ if (!Array.isArray(content)) return "";
152
+ const parts: string[] = [];
153
+ for (const part of content) {
154
+ if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
155
+ const text = (part as { text?: unknown }).text;
156
+ if (typeof text === "string") parts.push(text);
157
+ }
158
+ }
159
+ return parts.join("\n").trim();
149
160
  }
150
161
 
151
162
  type AuditAssistantOutcome =
152
- | { ok: true; answer: string; stopReason?: string }
153
- | { ok: false; reason: string; stopReason?: string; errorMessage?: string };
163
+ | { ok: true; answer: string; stopReason?: string }
164
+ | { ok: false; reason: string; stopReason?: string; errorMessage?: string };
154
165
 
155
166
  function inspectFinalAssistant(messages: unknown[]): AuditAssistantOutcome {
156
- for (let i = messages.length - 1; i >= 0; i--) {
157
- const message = messages[i] as { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown };
158
- if (message?.role !== "assistant") continue;
159
- const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
160
- const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
161
- const answer = messageContentToText(message.content);
162
- if (stopReason === "error") {
163
- return { ok: false, reason: `Agent Workflow Audit subagent error: ${errorMessage || "provider/model error"}`, stopReason, errorMessage };
164
- }
165
- if (stopReason === "aborted") {
166
- return { ok: false, reason: "Agent Workflow Audit subagent aborted before producing a usable report", stopReason };
167
- }
168
- if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
169
- return {
170
- ok: false,
171
- reason: stopReason
172
- ? `Agent Workflow Audit subagent produced no final assistant text (stopReason: ${stopReason})`
173
- : "Agent Workflow Audit subagent produced no final assistant text",
174
- ...(stopReason ? { stopReason } : {}),
175
- ...(errorMessage ? { errorMessage } : {}),
176
- };
177
- }
178
- return { ok: false, reason: "Agent Workflow Audit subagent produced no assistant message" };
179
- }
180
-
181
- function extractLastAssistantText(messages: unknown[]): string {
182
- const outcome = inspectFinalAssistant(messages);
183
- return outcome.ok ? outcome.answer : "";
167
+ for (let i = messages.length - 1; i >= 0; i--) {
168
+ const message = messages[i] as {
169
+ role?: string;
170
+ content?: unknown;
171
+ stopReason?: unknown;
172
+ errorMessage?: unknown;
173
+ };
174
+ if (message?.role !== "assistant") continue;
175
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
176
+ const errorMessage =
177
+ typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
178
+ const answer = messageContentToText(message.content);
179
+ if (stopReason === "error") {
180
+ return {
181
+ ok: false,
182
+ reason: `Agent Workflow Audit subagent error: ${errorMessage || "provider/model error"}`,
183
+ stopReason,
184
+ errorMessage,
185
+ };
186
+ }
187
+ if (stopReason === "aborted") {
188
+ return {
189
+ ok: false,
190
+ reason: "Agent Workflow Audit subagent aborted before producing a usable report",
191
+ stopReason,
192
+ };
193
+ }
194
+ if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
195
+ return {
196
+ ok: false,
197
+ reason: stopReason
198
+ ? `Agent Workflow Audit subagent produced no final assistant text (stopReason: ${stopReason})`
199
+ : "Agent Workflow Audit subagent produced no final assistant text",
200
+ ...(stopReason ? { stopReason } : {}),
201
+ ...(errorMessage ? { errorMessage } : {}),
202
+ };
203
+ }
204
+ return { ok: false, reason: "Agent Workflow Audit subagent produced no assistant message" };
184
205
  }
185
206
 
186
207
  function isAbortLikeError(error: unknown): boolean {
187
- if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError") return true;
188
- const message = error instanceof Error ? error.message : String(error);
189
- return /aborted|cancelled|canceled/i.test(message);
208
+ if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError")
209
+ return true;
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ return /aborted|cancelled|canceled/i.test(message);
190
212
  }
191
213
 
192
214
  function renderCollapsedReport(report: string, lineLimit = COLLAPSED_REPORT_LINES): string {
193
- const lines = report.trim().split("\n");
194
- if (lines.length <= lineLimit) return lines.join("\n");
195
- return [...lines.slice(0, lineLimit), `… (${lines.length - lineLimit} more lines; expand to view)`].join("\n");
215
+ const lines = report.trim().split("\n");
216
+ if (lines.length <= lineLimit) return lines.join("\n");
217
+ return [
218
+ ...lines.slice(0, lineLimit),
219
+ `… (${lines.length - lineLimit} more lines; expand to view)`,
220
+ ].join("\n");
196
221
  }
197
222
 
198
223
  function summarizeGitStatus(status: string | undefined): { lineCount: number; dirtyCount: number } {
199
- const lines = status?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
200
- const dirty = lines.filter((line) => !line.startsWith("##"));
201
- return { lineCount: lines.length, dirtyCount: dirty.length };
224
+ const lines =
225
+ status
226
+ ?.split("\n")
227
+ .map((line) => line.trim())
228
+ .filter(Boolean) ?? [];
229
+ const dirty = lines.filter((line) => !line.startsWith("##"));
230
+ return { lineCount: lines.length, dirtyCount: dirty.length };
202
231
  }
203
232
 
204
233
  function appendRunBoundary(report: string, details: AuditDetails): string {
205
- const initial = summarizeGitStatus(details.initialGitStatus);
206
- const final = summarizeGitStatus(details.finalGitStatus);
207
- const statusChanged = (details.initialGitStatus ?? "") !== (details.finalGitStatus ?? "");
208
- const duration = details.endedAt ? formatDuration(details.endedAt - details.startedAt) : "unknown duration";
209
- const lines = [
210
- "---",
211
- "## Audit run boundary",
212
- `- Ran in an isolated in-memory Agent Workflow Audit subagent; intermediate tool transcript, raw command output, errors, retries, and search path were not added to the main session.`,
213
- `- Child run: ${details.turns} turn(s), ${details.toolCalls.length} tool call(s), ${duration}.`,
214
- ];
215
-
216
- if (details.initialGitStatus !== undefined || details.finalGitStatus !== undefined) {
217
- lines.push(
218
- `- Git status check: ${statusChanged ? "changed" : "unchanged"} (${initial.dirtyCount} dirty item(s) before, ${final.dirtyCount} dirty item(s) after).`,
219
- );
220
- }
221
-
222
- return `${report.trim()}\n\n${lines.join("\n")}`.trim();
234
+ const initial = summarizeGitStatus(details.initialGitStatus);
235
+ const final = summarizeGitStatus(details.finalGitStatus);
236
+ const statusChanged = (details.initialGitStatus ?? "") !== (details.finalGitStatus ?? "");
237
+ const duration = details.endedAt
238
+ ? formatDuration(details.endedAt - details.startedAt)
239
+ : "unknown duration";
240
+ const lines = [
241
+ "---",
242
+ "## Audit run boundary",
243
+ `- Ran in an isolated in-memory Agent Workflow Audit subagent; intermediate tool transcript, raw command output, errors, retries, and search path were not added to the main session.`,
244
+ `- Child run: ${details.turns} turn(s), ${details.toolCalls.length} tool call(s), ${duration}.`,
245
+ ];
246
+
247
+ if (details.initialGitStatus !== undefined || details.finalGitStatus !== undefined) {
248
+ lines.push(
249
+ `- Git status check: ${statusChanged ? "changed" : "unchanged"} (${initial.dirtyCount} dirty item(s) before, ${final.dirtyCount} dirty item(s) after).`,
250
+ );
251
+ }
252
+
253
+ return `${report.trim()}\n\n${lines.join("\n")}`.trim();
223
254
  }
224
255
 
225
- async function gitStatusShort(pi: ExtensionAPI, cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
226
- const result = await pi.exec("git", ["status", "--short", "--branch"], {
227
- cwd,
228
- signal,
229
- timeout: GIT_STATUS_TIMEOUT_MS,
230
- });
231
- if (result.killed || result.code !== 0) return undefined;
232
- return result.stdout.trim();
256
+ async function gitStatusShort(
257
+ pi: ExtensionAPI,
258
+ cwd: string,
259
+ signal: AbortSignal | undefined,
260
+ ): Promise<string | undefined> {
261
+ const result = await pi.exec("git", ["status", "--short", "--branch"], {
262
+ cwd,
263
+ signal,
264
+ timeout: GIT_STATUS_TIMEOUT_MS,
265
+ });
266
+ if (result.killed || result.code !== 0) return undefined;
267
+ return result.stdout.trim();
233
268
  }
234
269
 
235
270
  function resolveToolPath(cwd: string, rawPath: unknown): string {
236
- const input = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
237
- const normalized = input.startsWith("@") ? input.slice(1) : input;
238
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
271
+ const input = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
272
+ const normalized = input.startsWith("@") ? input.slice(1) : input;
273
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
239
274
  }
240
275
 
241
276
  function isInside(parent: string, child: string): boolean {
242
- const parentResolved = path.resolve(parent);
243
- const childResolved = path.resolve(child);
244
- return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
277
+ const parentResolved = path.resolve(parent);
278
+ const childResolved = path.resolve(child);
279
+ return (
280
+ childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
281
+ );
245
282
  }
246
283
 
247
- async function assertToolPathInsideCwd(cwd: string, rawPath: unknown, toolName: string): Promise<string | undefined> {
248
- if (rawPath !== undefined && typeof rawPath !== "string") return `${toolName} path must be a string.`;
249
- const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
250
- const resolved = resolveToolPath(cwd, rawPath);
251
- const realPath = await fs.realpath(resolved).catch(() => resolved);
252
- if (!isInside(root, realPath)) return `${toolName} is limited to the audited checkout: ${realPath}`;
253
- return undefined;
284
+ async function assertToolPathInsideCwd(
285
+ cwd: string,
286
+ rawPath: unknown,
287
+ toolName: string,
288
+ ): Promise<string | undefined> {
289
+ if (rawPath !== undefined && typeof rawPath !== "string")
290
+ return `${toolName} path must be a string.`;
291
+ const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
292
+ const resolved = resolveToolPath(cwd, rawPath);
293
+ const realPath = await fs.realpath(resolved).catch(() => resolved);
294
+ if (!isInside(root, realPath))
295
+ return `${toolName} is limited to the audited checkout: ${realPath}`;
296
+ return undefined;
254
297
  }
255
298
 
256
299
  function getUnsafePatternReason(value: unknown, label: string): string | undefined {
257
- if (value === undefined) return undefined;
258
- if (typeof value !== "string") return `${label} must be a string.`;
259
- if (path.isAbsolute(value)) return `${label} must be relative to the audited checkout.`;
260
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value)) return `${label} must not traverse outside the audited checkout.`;
261
- return undefined;
300
+ if (value === undefined) return undefined;
301
+ if (typeof value !== "string") return `${label} must be a string.`;
302
+ if (path.isAbsolute(value)) return `${label} must be relative to the audited checkout.`;
303
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value))
304
+ return `${label} must not traverse outside the audited checkout.`;
305
+ return undefined;
262
306
  }
263
307
 
264
308
  type TokenizedSegment = { tokens: string[]; reason?: string };
265
309
 
266
310
  const SAFE_DIRECT_COMMANDS = new Set([
267
- "bun",
268
- "cargo",
269
- "dotnet",
270
- "gh",
271
- "git",
272
- "go",
273
- "gradle",
274
- "gradlew",
275
- "just",
276
- "make",
277
- "mvn",
278
- "npm",
279
- "pnpm",
280
- "pwd",
281
- "pytest",
282
- "ruff",
283
- "swift",
284
- "task",
285
- "tox",
286
- "yarn",
311
+ "bun",
312
+ "cargo",
313
+ "dotnet",
314
+ "gh",
315
+ "git",
316
+ "go",
317
+ "gradle",
318
+ "gradlew",
319
+ "just",
320
+ "make",
321
+ "mvn",
322
+ "npm",
323
+ "pnpm",
324
+ "pwd",
325
+ "pytest",
326
+ "ruff",
327
+ "swift",
328
+ "task",
329
+ "tox",
330
+ "yarn",
287
331
  ]);
288
332
 
289
333
  const READ_ONLY_GIT_SUBCOMMANDS = new Set([
290
- "blame",
291
- "branch",
292
- "cat-file",
293
- "describe",
294
- "diff",
295
- "for-each-ref",
296
- "grep",
297
- "log",
298
- "ls-files",
299
- "ls-tree",
300
- "merge-base",
301
- "name-rev",
302
- "remote",
303
- "rev-parse",
304
- "shortlog",
305
- "show",
306
- "show-ref",
307
- "status",
308
- "whatchanged",
334
+ "blame",
335
+ "branch",
336
+ "cat-file",
337
+ "describe",
338
+ "diff",
339
+ "for-each-ref",
340
+ "grep",
341
+ "log",
342
+ "ls-files",
343
+ "ls-tree",
344
+ "merge-base",
345
+ "name-rev",
346
+ "remote",
347
+ "rev-parse",
348
+ "shortlog",
349
+ "show",
350
+ "show-ref",
351
+ "status",
352
+ "whatchanged",
309
353
  ]);
310
354
 
311
355
  const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
312
356
  const SAFE_GIT_BRANCH_FLAGS = new Set([
313
- "-a",
314
- "--all",
315
- "-r",
316
- "--remotes",
317
- "-v",
318
- "-vv",
319
- "--show-current",
320
- "--list",
321
- "--contains",
322
- "--merged",
323
- "--no-merged",
357
+ "-a",
358
+ "--all",
359
+ "-r",
360
+ "--remotes",
361
+ "-v",
362
+ "-vv",
363
+ "--show-current",
364
+ "--list",
365
+ "--contains",
366
+ "--merged",
367
+ "--no-merged",
368
+ ]);
369
+ const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set([
370
+ "--repo",
371
+ "-R",
372
+ "--hostname",
373
+ "--jq",
374
+ "-q",
375
+ "--template",
324
376
  ]);
325
- const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set(["--repo", "-R", "--hostname", "--jq", "-q", "--template"]);
326
377
  const GH_API_FIELD_FLAGS = new Set(["-f", "-F", "--field", "--raw-field", "--input"]);
327
378
  const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
328
379
  const READ_ONLY_GH_SUBCOMMANDS: Record<string, Set<string> | undefined> = {
329
- api: undefined,
330
- auth: new Set(["status"]),
331
- issue: new Set(["view", "list", "status"]),
332
- label: new Set(["list"]),
333
- milestone: new Set(["list"]),
334
- pr: new Set(["view", "list", "diff", "status", "checks"]),
335
- release: new Set(["view", "list"]),
336
- repo: new Set(["view", "list"]),
337
- run: new Set(["view", "list"]),
338
- search: undefined,
339
- status: undefined,
340
- workflow: new Set(["view", "list"]),
380
+ api: undefined,
381
+ auth: new Set(["status"]),
382
+ issue: new Set(["view", "list", "status"]),
383
+ label: new Set(["list"]),
384
+ milestone: new Set(["list"]),
385
+ pr: new Set(["view", "list", "diff", "status", "checks"]),
386
+ release: new Set(["view", "list"]),
387
+ repo: new Set(["view", "list"]),
388
+ run: new Set(["view", "list"]),
389
+ search: undefined,
390
+ status: undefined,
391
+ workflow: new Set(["view", "list"]),
341
392
  };
342
393
 
343
394
  function splitShellSegments(command: string): string[] | { reason: string } {
344
- if (/[`<>|;\n\r]/.test(command) || /\$\(/.test(command)) {
345
- return { reason: "Agent Workflow Audit bash blocks shell substitution, redirection, pipes, semicolons, and multi-line commands." };
346
- }
347
- if (/(^|[^&])&(?!&)|&&&/.test(command)) {
348
- return { reason: "Agent Workflow Audit bash allows only simple commands optionally joined by &&." };
349
- }
350
- return command.split(/&&/).map((segment) => segment.trim()).filter(Boolean);
395
+ if (/[`<>|;\n\r]/.test(command) || /\$\(/.test(command)) {
396
+ return {
397
+ reason:
398
+ "Agent Workflow Audit bash blocks shell substitution, redirection, pipes, semicolons, and multi-line commands.",
399
+ };
400
+ }
401
+ if (/(^|[^&])&(?!&)|&&&/.test(command)) {
402
+ return {
403
+ reason: "Agent Workflow Audit bash allows only simple commands optionally joined by &&.",
404
+ };
405
+ }
406
+ return command
407
+ .split(/&&/)
408
+ .map((segment) => segment.trim())
409
+ .filter(Boolean);
351
410
  }
352
411
 
353
412
  function tokenizeSegment(segment: string): TokenizedSegment {
354
- const tokens: string[] = [];
355
- let token = "";
356
- let tokenStarted = false;
357
- let inSingleQuote = false;
358
- let inDoubleQuote = false;
359
-
360
- const pushToken = () => {
361
- if (!tokenStarted) return;
362
- tokens.push(token);
363
- token = "";
364
- tokenStarted = false;
365
- };
366
-
367
- for (let index = 0; index < segment.length; index += 1) {
368
- const char = segment[index];
369
- if (inSingleQuote) {
370
- if (char === "'") {
371
- inSingleQuote = false;
372
- continue;
373
- }
374
- token += char;
375
- tokenStarted = true;
376
- continue;
377
- }
378
- if (inDoubleQuote) {
379
- if (char === '"') {
380
- inDoubleQuote = false;
381
- continue;
382
- }
383
- if (char === "`" || char === "$") {
384
- return { tokens, reason: "Agent Workflow Audit bash blocks shell expansion inside quoted arguments." };
385
- }
386
- token += char;
387
- tokenStarted = true;
388
- continue;
389
- }
390
- if (/\s/.test(char)) {
391
- pushToken();
392
- continue;
393
- }
394
- if (char === "'") {
395
- inSingleQuote = true;
396
- tokenStarted = true;
397
- continue;
398
- }
399
- if (char === '"') {
400
- inDoubleQuote = true;
401
- tokenStarted = true;
402
- continue;
403
- }
404
- if (char === "\\") return { tokens, reason: "Agent Workflow Audit bash blocks shell escape sequences." };
405
- if (char === "$") return { tokens, reason: "Agent Workflow Audit bash blocks shell variable expansion." };
406
- if (/[{}[\]*?]/.test(char)) return { tokens, reason: "Agent Workflow Audit bash blocks shell glob and brace metacharacters." };
407
- token += char;
408
- tokenStarted = true;
409
- }
410
-
411
- if (inSingleQuote || inDoubleQuote) return { tokens, reason: "Agent Workflow Audit bash blocks unterminated shell quotes." };
412
- pushToken();
413
- return { tokens };
413
+ const tokens: string[] = [];
414
+ let token = "";
415
+ let tokenStarted = false;
416
+ let inSingleQuote = false;
417
+ let inDoubleQuote = false;
418
+
419
+ const pushToken = () => {
420
+ if (!tokenStarted) return;
421
+ tokens.push(token);
422
+ token = "";
423
+ tokenStarted = false;
424
+ };
425
+
426
+ for (let index = 0; index < segment.length; index += 1) {
427
+ const char = segment[index];
428
+ if (inSingleQuote) {
429
+ if (char === "'") {
430
+ inSingleQuote = false;
431
+ continue;
432
+ }
433
+ token += char;
434
+ tokenStarted = true;
435
+ continue;
436
+ }
437
+ if (inDoubleQuote) {
438
+ if (char === '"') {
439
+ inDoubleQuote = false;
440
+ continue;
441
+ }
442
+ if (char === "`" || char === "$") {
443
+ return {
444
+ tokens,
445
+ reason: "Agent Workflow Audit bash blocks shell expansion inside quoted arguments.",
446
+ };
447
+ }
448
+ token += char;
449
+ tokenStarted = true;
450
+ continue;
451
+ }
452
+ if (/\s/.test(char)) {
453
+ pushToken();
454
+ continue;
455
+ }
456
+ if (char === "'") {
457
+ inSingleQuote = true;
458
+ tokenStarted = true;
459
+ continue;
460
+ }
461
+ if (char === '"') {
462
+ inDoubleQuote = true;
463
+ tokenStarted = true;
464
+ continue;
465
+ }
466
+ if (char === "\\")
467
+ return { tokens, reason: "Agent Workflow Audit bash blocks shell escape sequences." };
468
+ if (char === "$")
469
+ return { tokens, reason: "Agent Workflow Audit bash blocks shell variable expansion." };
470
+ if (/[{}[\]*?]/.test(char))
471
+ return {
472
+ tokens,
473
+ reason: "Agent Workflow Audit bash blocks shell glob and brace metacharacters.",
474
+ };
475
+ token += char;
476
+ tokenStarted = true;
477
+ }
478
+
479
+ if (inSingleQuote || inDoubleQuote)
480
+ return { tokens, reason: "Agent Workflow Audit bash blocks unterminated shell quotes." };
481
+ pushToken();
482
+ return { tokens };
414
483
  }
415
484
 
416
485
  function getUnsafeShellFlagReason(token: string): string | undefined {
417
- const lower = token.toLowerCase();
418
- if (
419
- lower === "-c" || lower.startsWith("-c") ||
420
- lower === "-p" || lower.startsWith("-p") ||
421
- lower === "-f" || lower.startsWith("-f") ||
422
- lower === "--dir" || lower.startsWith("--dir=") ||
423
- lower === "--directory" || lower.startsWith("--directory=") ||
424
- lower === "--working-directory" || lower.startsWith("--working-directory=") ||
425
- lower === "--project-dir" || lower.startsWith("--project-dir=") ||
426
- lower === "--build-file" || lower.startsWith("--build-file=") ||
427
- lower === "--file" || lower.startsWith("--file=") ||
428
- lower === "--prefix" || lower.startsWith("--prefix=") ||
429
- lower === "--cwd" || lower.startsWith("--cwd=")
430
- ) {
431
- return "Agent Workflow Audit bash blocks path-changing flags to avoid symlink/path escapes.";
432
- }
433
- return undefined;
486
+ const lower = token.toLowerCase();
487
+ if (
488
+ lower === "-c" ||
489
+ lower.startsWith("-c") ||
490
+ lower === "-p" ||
491
+ lower.startsWith("-p") ||
492
+ lower === "-f" ||
493
+ lower.startsWith("-f") ||
494
+ lower === "--dir" ||
495
+ lower.startsWith("--dir=") ||
496
+ lower === "--directory" ||
497
+ lower.startsWith("--directory=") ||
498
+ lower === "--working-directory" ||
499
+ lower.startsWith("--working-directory=") ||
500
+ lower === "--project-dir" ||
501
+ lower.startsWith("--project-dir=") ||
502
+ lower === "--build-file" ||
503
+ lower.startsWith("--build-file=") ||
504
+ lower === "--file" ||
505
+ lower.startsWith("--file=") ||
506
+ lower === "--prefix" ||
507
+ lower.startsWith("--prefix=") ||
508
+ lower === "--cwd" ||
509
+ lower.startsWith("--cwd=")
510
+ ) {
511
+ return "Agent Workflow Audit bash blocks path-changing flags to avoid symlink/path escapes.";
512
+ }
513
+ return undefined;
434
514
  }
435
515
 
436
516
  function getUnsafeShellTokenReason(token: string): string | undefined {
437
- const values = [token];
438
- const equalsIndex = token.indexOf("=");
439
- if (equalsIndex >= 0 && equalsIndex < token.length - 1) values.push(token.slice(equalsIndex + 1));
440
-
441
- for (const value of values) {
442
- const normalized = value.startsWith("@") ? value.slice(1) : value;
443
- if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
444
- return "Agent Workflow Audit bash blocks home-directory paths.";
445
- }
446
- if (path.isAbsolute(normalized)) return "Agent Workflow Audit bash blocks absolute paths; stay inside the audited checkout.";
447
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
448
- return "Agent Workflow Audit bash blocks path traversal outside the audited checkout.";
449
- }
450
- }
451
- return undefined;
517
+ const values = [token];
518
+ const equalsIndex = token.indexOf("=");
519
+ if (equalsIndex >= 0 && equalsIndex < token.length - 1) values.push(token.slice(equalsIndex + 1));
520
+
521
+ for (const value of values) {
522
+ const normalized = value.startsWith("@") ? value.slice(1) : value;
523
+ if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
524
+ return "Agent Workflow Audit bash blocks home-directory paths.";
525
+ }
526
+ if (path.isAbsolute(normalized))
527
+ return "Agent Workflow Audit bash blocks absolute paths; stay inside the audited checkout.";
528
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
529
+ return "Agent Workflow Audit bash blocks path traversal outside the audited checkout.";
530
+ }
531
+ }
532
+ return undefined;
452
533
  }
453
534
 
454
535
  function getCommandName(token: string): string {
455
- return path.basename(token).toLowerCase();
536
+ return path.basename(token).toLowerCase();
456
537
  }
457
538
 
458
539
  function getBlockedCdReason(_tokens: string[]): string | undefined {
459
- return "Agent Workflow Audit blocks shell cd to keep bash execution pinned to the audited checkout root. Report documented subdirectory cd steps as manual or workflow friction.";
540
+ return "Agent Workflow Audit blocks shell cd to keep bash execution pinned to the audited checkout root. Report documented subdirectory cd steps as manual or workflow friction.";
460
541
  }
461
542
 
462
543
  const PACKAGE_VALUE_FLAGS = new Set(["--filter", "--workspace", "-w", "--prefix", "-c", "--cwd"]);
463
544
 
464
545
  function skipPackageFlagValue(lowerTokens: string[], index: number): number {
465
- const token = lowerTokens[index];
466
- if (PACKAGE_VALUE_FLAGS.has(token)) return index + 2;
467
- if (token.startsWith("--filter=") || token.startsWith("--workspace=") || token.startsWith("--prefix=") || token.startsWith("--cwd=")) return index + 1;
468
- return token.startsWith("-") ? index + 1 : index;
546
+ const token = lowerTokens[index];
547
+ if (PACKAGE_VALUE_FLAGS.has(token)) return index + 2;
548
+ if (
549
+ token.startsWith("--filter=") ||
550
+ token.startsWith("--workspace=") ||
551
+ token.startsWith("--prefix=") ||
552
+ token.startsWith("--cwd=")
553
+ )
554
+ return index + 1;
555
+ return token.startsWith("-") ? index + 1 : index;
469
556
  }
470
557
 
471
558
  function getPackageFirstCommand(lowerTokens: string[]): { command?: string; index: number } {
472
- let index = 1;
473
- while (index < lowerTokens.length) {
474
- const next = skipPackageFlagValue(lowerTokens, index);
475
- if (next !== index) {
476
- index = next;
477
- continue;
478
- }
479
- return { command: lowerTokens[index], index };
480
- }
481
- return { index };
559
+ let index = 1;
560
+ while (index < lowerTokens.length) {
561
+ const next = skipPackageFlagValue(lowerTokens, index);
562
+ if (next !== index) {
563
+ index = next;
564
+ continue;
565
+ }
566
+ return { command: lowerTokens[index], index };
567
+ }
568
+ return { index };
482
569
  }
483
570
 
484
- function getPackageRunScript(lowerTokens: string[], runIndex: number, commandName: string): string | undefined {
485
- if (runIndex < 0) return commandName === "yarn" ? getPackageFirstCommand(lowerTokens).command : undefined;
486
- for (let index = runIndex + 1; index < lowerTokens.length;) {
487
- const token = lowerTokens[index];
488
- if (token === "--") return lowerTokens[index + 1];
489
- const next = skipPackageFlagValue(lowerTokens, index);
490
- if (next !== index) {
491
- index = next;
492
- continue;
493
- }
494
- return token;
495
- }
496
- return undefined;
571
+ function getPackageRunScript(
572
+ lowerTokens: string[],
573
+ runIndex: number,
574
+ commandName: string,
575
+ ): string | undefined {
576
+ if (runIndex < 0)
577
+ return commandName === "yarn" ? getPackageFirstCommand(lowerTokens).command : undefined;
578
+ for (let index = runIndex + 1; index < lowerTokens.length;) {
579
+ const token = lowerTokens[index];
580
+ if (token === "--") return lowerTokens[index + 1];
581
+ const next = skipPackageFlagValue(lowerTokens, index);
582
+ if (next !== index) {
583
+ index = next;
584
+ continue;
585
+ }
586
+ return token;
587
+ }
588
+ return undefined;
497
589
  }
498
590
 
499
591
  function getBlockedPackageManagerReason(commandName: string, tokens: string[]): string | undefined {
500
- const lowerTokens = tokens.map((token) => token.toLowerCase());
501
- if (lowerTokens.some((token) => token === "--prefix" || token.startsWith("--prefix=") || token === "--cwd" || token.startsWith("--cwd=") || token === "-c" || token === "--workspace" || token.startsWith("--workspace=") || token === "-w")) {
502
- return "Agent Workflow Audit blocks package-manager path/workspace flags to avoid symlink/path escapes.";
503
- }
504
- if (lowerTokens.some((token) => ["publish", "unpublish", "version", "deprecate", "dist-tag", "owner", "access", "token", "login", "logout", "adduser", "team", "profile", "org", "hook", "config"].includes(token))) {
505
- return "Agent Workflow Audit blocks package registry/account/config mutation commands.";
506
- }
507
- if (lowerTokens.some((token) => ["exec", "x", "dlx", "create"].includes(token))) {
508
- return "Agent Workflow Audit blocks package-manager arbitrary execution commands such as exec, dlx, x, and create.";
509
- }
510
- const { command: firstCommand, index: firstCommandIndex } = getPackageFirstCommand(lowerTokens);
511
- const allowedCommands = new Set(["install", "ci", "test", "run", "start", "build", "lint", "check"]);
512
- if (firstCommand && !allowedCommands.has(firstCommand)) {
513
- return `Agent Workflow Audit blocks ${commandName} ${firstCommand}; only install/ci/test/run/start/build/lint/check are allowed.`;
514
- }
515
- if (firstCommand === "install" || firstCommand === "ci") {
516
- if (lowerTokens.some((token) => token === "-f" || token === "--force" || token.startsWith("--force="))) return "Agent Workflow Audit blocks forced package installs.";
517
- if (lowerTokens.some((token) => token === "-g" || token === "--global" || token.startsWith("--global=") || token === "--location=global" || token === "--location")) return "Agent Workflow Audit blocks global package installs.";
518
- }
519
- if (firstCommand === "install") {
520
- for (let index = firstCommandIndex + 1; index < lowerTokens.length;) {
521
- const next = skipPackageFlagValue(lowerTokens, index);
522
- if (next !== index) {
523
- index = next;
524
- continue;
525
- }
526
- if (!lowerTokens[index].startsWith("-")) return "Agent Workflow Audit blocks package install operands; run documented dependency install commands without adding packages.";
527
- index += 1;
528
- }
529
- }
530
- const runnerIndex = lowerTokens.findIndex((token) => ["node", "python", "python3", "ruby", "php"].includes(token));
531
- if (runnerIndex >= 0) {
532
- const runnerReason = getBlockedInterpreterReason(lowerTokens[runnerIndex], lowerTokens.slice(runnerIndex));
533
- if (runnerReason) return runnerReason;
534
- }
535
- const runIndex = lowerTokens.indexOf("run");
536
- const script = getPackageRunScript(lowerTokens, runIndex, commandName);
537
- if (script && /(^|[:_-])(deploy|release|publish|clean|reset|fix|format)(?:$|[:_-])/.test(script)) {
538
- return "Agent Workflow Audit blocks deploy/release/publish/clean/reset/fix/format package scripts.";
539
- }
540
- if (lowerTokens.some((token) => token === "--fix" || token.startsWith("--fix="))) return "Agent Workflow Audit blocks package-script fix flags.";
541
- return undefined;
592
+ const lowerTokens = tokens.map((token) => token.toLowerCase());
593
+ if (
594
+ lowerTokens.some(
595
+ (token) =>
596
+ token === "--prefix" ||
597
+ token.startsWith("--prefix=") ||
598
+ token === "--cwd" ||
599
+ token.startsWith("--cwd=") ||
600
+ token === "-c" ||
601
+ token === "--workspace" ||
602
+ token.startsWith("--workspace=") ||
603
+ token === "-w",
604
+ )
605
+ ) {
606
+ return "Agent Workflow Audit blocks package-manager path/workspace flags to avoid symlink/path escapes.";
607
+ }
608
+ if (
609
+ lowerTokens.some((token) =>
610
+ [
611
+ "publish",
612
+ "unpublish",
613
+ "version",
614
+ "deprecate",
615
+ "dist-tag",
616
+ "owner",
617
+ "access",
618
+ "token",
619
+ "login",
620
+ "logout",
621
+ "adduser",
622
+ "team",
623
+ "profile",
624
+ "org",
625
+ "hook",
626
+ "config",
627
+ ].includes(token),
628
+ )
629
+ ) {
630
+ return "Agent Workflow Audit blocks package registry/account/config mutation commands.";
631
+ }
632
+ if (lowerTokens.some((token) => ["exec", "x", "dlx", "create"].includes(token))) {
633
+ return "Agent Workflow Audit blocks package-manager arbitrary execution commands such as exec, dlx, x, and create.";
634
+ }
635
+ const { command: firstCommand, index: firstCommandIndex } = getPackageFirstCommand(lowerTokens);
636
+ const allowedCommands = new Set([
637
+ "install",
638
+ "ci",
639
+ "test",
640
+ "run",
641
+ "start",
642
+ "build",
643
+ "lint",
644
+ "check",
645
+ ]);
646
+ if (firstCommand && !allowedCommands.has(firstCommand)) {
647
+ return `Agent Workflow Audit blocks ${commandName} ${firstCommand}; only install/ci/test/run/start/build/lint/check are allowed.`;
648
+ }
649
+ if (firstCommand === "install" || firstCommand === "ci") {
650
+ if (
651
+ lowerTokens.some(
652
+ (token) => token === "-f" || token === "--force" || token.startsWith("--force="),
653
+ )
654
+ )
655
+ return "Agent Workflow Audit blocks forced package installs.";
656
+ if (
657
+ lowerTokens.some(
658
+ (token) =>
659
+ token === "-g" ||
660
+ token === "--global" ||
661
+ token.startsWith("--global=") ||
662
+ token === "--location=global" ||
663
+ token === "--location",
664
+ )
665
+ )
666
+ return "Agent Workflow Audit blocks global package installs.";
667
+ }
668
+ if (firstCommand === "install") {
669
+ for (let index = firstCommandIndex + 1; index < lowerTokens.length;) {
670
+ const next = skipPackageFlagValue(lowerTokens, index);
671
+ if (next !== index) {
672
+ index = next;
673
+ continue;
674
+ }
675
+ if (!lowerTokens[index].startsWith("-"))
676
+ return "Agent Workflow Audit blocks package install operands; run documented dependency install commands without adding packages.";
677
+ index += 1;
678
+ }
679
+ }
680
+ const runnerIndex = lowerTokens.findIndex((token) =>
681
+ ["node", "python", "python3", "ruby", "php"].includes(token),
682
+ );
683
+ if (runnerIndex >= 0) {
684
+ const runnerReason = getBlockedInterpreterReason(
685
+ lowerTokens[runnerIndex],
686
+ lowerTokens.slice(runnerIndex),
687
+ );
688
+ if (runnerReason) return runnerReason;
689
+ }
690
+ const runIndex = lowerTokens.indexOf("run");
691
+ const script = getPackageRunScript(lowerTokens, runIndex, commandName);
692
+ if (
693
+ script &&
694
+ /(^|[:_-])(deploy|release|publish|clean|reset|fix|format)(?:$|[:_-])/.test(script)
695
+ ) {
696
+ return "Agent Workflow Audit blocks deploy/release/publish/clean/reset/fix/format package scripts.";
697
+ }
698
+ if (lowerTokens.some((token) => token === "--fix" || token.startsWith("--fix=")))
699
+ return "Agent Workflow Audit blocks package-script fix flags.";
700
+ return undefined;
542
701
  }
543
702
 
544
703
  function getBlockedTaskRunnerReason(commandName: string, tokens: string[]): string | undefined {
545
- const lowerTokens = tokens.map((token) => token.toLowerCase());
546
- if (lowerTokens.some((token) => token === "-c" || token === "--directory" || token.startsWith("--directory="))) {
547
- return `Agent Workflow Audit blocks ${commandName} directory-changing flags to avoid symlink/path escapes.`;
548
- }
549
- const targets = lowerTokens.slice(1).filter((token) => !token.startsWith("-"));
550
- if (targets.some((target) => /(^|[:_-])(deploy|release|publish|install|clean|reset|fix|format)(?:$|[:_-])/.test(target))) {
551
- return `Agent Workflow Audit blocks ${commandName} deploy/release/publish/install/clean/reset/fix/format targets.`;
552
- }
553
- return undefined;
704
+ const lowerTokens = tokens.map((token) => token.toLowerCase());
705
+ if (
706
+ lowerTokens.some(
707
+ (token) => token === "-c" || token === "--directory" || token.startsWith("--directory="),
708
+ )
709
+ ) {
710
+ return `Agent Workflow Audit blocks ${commandName} directory-changing flags to avoid symlink/path escapes.`;
711
+ }
712
+ const targets = lowerTokens.slice(1).filter((token) => !token.startsWith("-"));
713
+ if (
714
+ targets.some((target) =>
715
+ /(^|[:_-])(deploy|release|publish|install|clean|reset|fix|format)(?:$|[:_-])/.test(target),
716
+ )
717
+ ) {
718
+ return `Agent Workflow Audit blocks ${commandName} deploy/release/publish/install/clean/reset/fix/format targets.`;
719
+ }
720
+ return undefined;
554
721
  }
555
722
 
556
- function getGitSubcommand(tokens: string[]): { subcommand?: string; index: number; reason?: string } {
557
- let index = 1;
558
- while (index < tokens.length) {
559
- const token = tokens[index];
560
- if (!token.startsWith("-")) break;
561
- if (token === "-C" || token.startsWith("-C")) {
562
- return { index, reason: "Agent Workflow Audit blocks git -C to keep git inspection pinned to the audited checkout root." };
563
- }
564
- if (token === "--git-dir" || token.startsWith("--git-dir=") || token === "--work-tree" || token.startsWith("--work-tree=")) {
565
- return { index, reason: "Agent Workflow Audit blocks git options that can target repositories outside the audited checkout." };
566
- }
567
- if (token === "-c" || token.startsWith("-c")) {
568
- return { index, reason: "Agent Workflow Audit blocks git -c because aliases/config can change command behavior." };
569
- }
570
- if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
571
- index += 1;
572
- continue;
573
- }
574
- return { index, reason: `Agent Workflow Audit blocks git global option ${token}; use a direct read-only git command.` };
575
- }
576
- return { subcommand: tokens[index]?.toLowerCase(), index };
723
+ function getGitSubcommand(tokens: string[]): {
724
+ subcommand?: string;
725
+ index: number;
726
+ reason?: string;
727
+ } {
728
+ let index = 1;
729
+ while (index < tokens.length) {
730
+ const token = tokens[index];
731
+ if (!token.startsWith("-")) break;
732
+ if (token === "-C" || token.startsWith("-C")) {
733
+ return {
734
+ index,
735
+ reason:
736
+ "Agent Workflow Audit blocks git -C to keep git inspection pinned to the audited checkout root.",
737
+ };
738
+ }
739
+ if (
740
+ token === "--git-dir" ||
741
+ token.startsWith("--git-dir=") ||
742
+ token === "--work-tree" ||
743
+ token.startsWith("--work-tree=")
744
+ ) {
745
+ return {
746
+ index,
747
+ reason:
748
+ "Agent Workflow Audit blocks git options that can target repositories outside the audited checkout.",
749
+ };
750
+ }
751
+ if (token === "-c" || token.startsWith("-c")) {
752
+ return {
753
+ index,
754
+ reason:
755
+ "Agent Workflow Audit blocks git -c because aliases/config can change command behavior.",
756
+ };
757
+ }
758
+ if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
759
+ index += 1;
760
+ continue;
761
+ }
762
+ return {
763
+ index,
764
+ reason: `Agent Workflow Audit blocks git global option ${token}; use a direct read-only git command.`,
765
+ };
766
+ }
767
+ return { subcommand: tokens[index]?.toLowerCase(), index };
577
768
  }
578
769
 
579
770
  function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
580
- const args = tokens.slice(subcommandIndex + 1);
581
- if (args.length === 0) return true;
582
- if (args.some((arg) => /^-(?:d|D|m|M|c|C|f)$/.test(arg) || /^--(?:delete|move|copy|force|set-upstream-to|unset-upstream)$/.test(arg))) {
583
- return false;
584
- }
585
- const hasReadOnlyMode = args.some((arg) => SAFE_GIT_BRANCH_FLAGS.has(arg) || arg.startsWith("--contains=") || arg.startsWith("--merged=") || arg.startsWith("--no-merged="));
586
- return hasReadOnlyMode && args.every((arg) => SAFE_GIT_BRANCH_FLAGS.has(arg) || arg.startsWith("--contains=") || arg.startsWith("--merged=") || arg.startsWith("--no-merged=") || !arg.startsWith("-"));
771
+ const args = tokens.slice(subcommandIndex + 1);
772
+ if (args.length === 0) return true;
773
+ if (
774
+ args.some(
775
+ (arg) =>
776
+ /^-(?:d|D|m|M|c|C|f)$/.test(arg) ||
777
+ /^--(?:delete|move|copy|force|set-upstream-to|unset-upstream)$/.test(arg),
778
+ )
779
+ ) {
780
+ return false;
781
+ }
782
+ const hasReadOnlyMode = args.some(
783
+ (arg) =>
784
+ SAFE_GIT_BRANCH_FLAGS.has(arg) ||
785
+ arg.startsWith("--contains=") ||
786
+ arg.startsWith("--merged=") ||
787
+ arg.startsWith("--no-merged="),
788
+ );
789
+ return (
790
+ hasReadOnlyMode &&
791
+ args.every(
792
+ (arg) =>
793
+ SAFE_GIT_BRANCH_FLAGS.has(arg) ||
794
+ arg.startsWith("--contains=") ||
795
+ arg.startsWith("--merged=") ||
796
+ arg.startsWith("--no-merged=") ||
797
+ !arg.startsWith("-"),
798
+ )
799
+ );
587
800
  }
588
801
 
589
802
  function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
590
- const args = tokens.slice(subcommandIndex + 1);
591
- if (args.length === 0) return true;
592
- if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
593
- const action = args.find((arg) => !arg.startsWith("-"));
594
- return action === "show" || action === "get-url";
803
+ const args = tokens.slice(subcommandIndex + 1);
804
+ if (args.length === 0) return true;
805
+ if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
806
+ const action = args.find((arg) => !arg.startsWith("-"));
807
+ return action === "show" || action === "get-url";
595
808
  }
596
809
 
597
810
  function getUnsafeGitFlagReason(tokens: string[]): string | undefined {
598
- for (const token of tokens.slice(1)) {
599
- const lower = token.toLowerCase();
600
- if (lower === "--output" || lower.startsWith("--output=")) return "Agent Workflow Audit blocks git output-writing flags.";
601
- if (lower === "-o" || lower.startsWith("-o")) return "Agent Workflow Audit blocks git output-order/pager helper flags that can write or execute helpers.";
602
- if (lower === "--open-files-in-pager" || lower.startsWith("--open-files-in-pager=")) {
603
- return "Agent Workflow Audit blocks git pager-opening flags because pagers can execute local utilities.";
604
- }
605
- if (lower === "--ext-diff" || lower.startsWith("--ext-diff=") || lower === "--textconv" || lower.startsWith("--textconv=")) {
606
- return "Agent Workflow Audit blocks git external diff/text conversion flags because they can execute helpers.";
607
- }
608
- if (lower === "--no-index" || lower.startsWith("--no-index=")) return "Agent Workflow Audit blocks git --no-index because it can inspect arbitrary filesystem paths.";
609
- if (lower === "--exec-path" || lower.startsWith("--exec-path=")) return "Agent Workflow Audit blocks git exec-path overrides.";
610
- }
611
- return undefined;
811
+ for (const token of tokens.slice(1)) {
812
+ const lower = token.toLowerCase();
813
+ if (lower === "--output" || lower.startsWith("--output="))
814
+ return "Agent Workflow Audit blocks git output-writing flags.";
815
+ if (lower === "-o" || lower.startsWith("-o"))
816
+ return "Agent Workflow Audit blocks git output-order/pager helper flags that can write or execute helpers.";
817
+ if (lower === "--open-files-in-pager" || lower.startsWith("--open-files-in-pager=")) {
818
+ return "Agent Workflow Audit blocks git pager-opening flags because pagers can execute local utilities.";
819
+ }
820
+ if (
821
+ lower === "--ext-diff" ||
822
+ lower.startsWith("--ext-diff=") ||
823
+ lower === "--textconv" ||
824
+ lower.startsWith("--textconv=")
825
+ ) {
826
+ return "Agent Workflow Audit blocks git external diff/text conversion flags because they can execute helpers.";
827
+ }
828
+ if (lower === "--no-index" || lower.startsWith("--no-index="))
829
+ return "Agent Workflow Audit blocks git --no-index because it can inspect arbitrary filesystem paths.";
830
+ if (lower === "--exec-path" || lower.startsWith("--exec-path="))
831
+ return "Agent Workflow Audit blocks git exec-path overrides.";
832
+ }
833
+ return undefined;
612
834
  }
613
835
 
614
836
  function getBlockedGitReason(tokens: string[]): string | undefined {
615
- const unsafeFlagReason = getUnsafeGitFlagReason(tokens);
616
- if (unsafeFlagReason) return unsafeFlagReason;
617
- const parsed = getGitSubcommand(tokens);
618
- if (parsed.reason) return parsed.reason;
619
- if (!parsed.subcommand) return undefined;
620
- if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
621
- return `Agent Workflow Audit blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
622
- }
623
- if (parsed.subcommand === "branch" && !isSafeGitBranchCommand(tokens, parsed.index)) {
624
- return "Agent Workflow Audit allows git branch only for read-only listing/show-current/contains/merged queries.";
625
- }
626
- if (parsed.subcommand === "remote" && !isSafeGitRemoteCommand(tokens, parsed.index)) {
627
- return "Agent Workflow Audit allows git remote only for read-only list/show/get-url queries.";
628
- }
629
- return undefined;
837
+ const unsafeFlagReason = getUnsafeGitFlagReason(tokens);
838
+ if (unsafeFlagReason) return unsafeFlagReason;
839
+ const parsed = getGitSubcommand(tokens);
840
+ if (parsed.reason) return parsed.reason;
841
+ if (!parsed.subcommand) return undefined;
842
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
843
+ return `Agent Workflow Audit blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
844
+ }
845
+ if (parsed.subcommand === "branch" && !isSafeGitBranchCommand(tokens, parsed.index)) {
846
+ return "Agent Workflow Audit allows git branch only for read-only listing/show-current/contains/merged queries.";
847
+ }
848
+ if (parsed.subcommand === "remote" && !isSafeGitRemoteCommand(tokens, parsed.index)) {
849
+ return "Agent Workflow Audit allows git remote only for read-only list/show/get-url queries.";
850
+ }
851
+ return undefined;
630
852
  }
631
853
 
632
854
  function getGhCommand(tokens: string[]): { command?: string; subcommand?: string; index: number } {
633
- let index = 1;
634
- while (index < tokens.length) {
635
- const token = tokens[index];
636
- if (!token.startsWith("-")) break;
637
- if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
638
- index += 2;
639
- continue;
640
- }
641
- if (["--repo=", "--hostname=", "--jq=", "--template="].some((prefix) => token.startsWith(prefix))) {
642
- index += 1;
643
- continue;
644
- }
645
- index += 1;
646
- }
647
- return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase(), index };
855
+ let index = 1;
856
+ while (index < tokens.length) {
857
+ const token = tokens[index];
858
+ if (!token.startsWith("-")) break;
859
+ if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
860
+ index += 2;
861
+ continue;
862
+ }
863
+ if (
864
+ ["--repo=", "--hostname=", "--jq=", "--template="].some((prefix) => token.startsWith(prefix))
865
+ ) {
866
+ index += 1;
867
+ continue;
868
+ }
869
+ index += 1;
870
+ }
871
+ return {
872
+ command: tokens[index]?.toLowerCase(),
873
+ subcommand: tokens[index + 1]?.toLowerCase(),
874
+ index,
875
+ };
648
876
  }
649
877
 
650
878
  function getBlockedGhApiReason(tokens: string[]): string | undefined {
651
- const parsed = getGhCommand(tokens);
652
- if (parsed.command !== "api") return undefined;
653
- const endpoint = tokens[parsed.index + 1]?.toLowerCase();
654
- if (endpoint === "graphql") return "Agent Workflow Audit blocks gh api graphql because it uses POST/body fields.";
655
- for (let index = parsed.index + 1; index < tokens.length; index += 1) {
656
- const token = tokens[index];
657
- const lower = token.toLowerCase();
658
- if (GH_API_FIELD_FLAGS.has(token) || lower.startsWith("--field=") || lower.startsWith("--raw-field=") || lower.startsWith("--input=") || /^-[fF]/.test(token)) {
659
- return "Agent Workflow Audit allows read-only gh api calls only; request fields and input files are blocked.";
660
- }
661
- let method: string | undefined;
662
- if (lower === "-x" || lower === "--method") method = tokens[index + 1];
663
- else if (lower.startsWith("-x") && token.length > 2) method = token.slice(2).replace(/^=/, "");
664
- else if (lower.startsWith("--method=")) method = token.slice("--method=".length);
665
- if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
666
- return "Agent Workflow Audit allows read-only gh api calls only; mutating methods are blocked.";
667
- }
668
- }
669
- return undefined;
879
+ const parsed = getGhCommand(tokens);
880
+ if (parsed.command !== "api") return undefined;
881
+ const endpoint = tokens[parsed.index + 1]?.toLowerCase();
882
+ if (endpoint === "graphql")
883
+ return "Agent Workflow Audit blocks gh api graphql because it uses POST/body fields.";
884
+ for (let index = parsed.index + 1; index < tokens.length; index += 1) {
885
+ const token = tokens[index];
886
+ const lower = token.toLowerCase();
887
+ if (
888
+ GH_API_FIELD_FLAGS.has(token) ||
889
+ lower.startsWith("--field=") ||
890
+ lower.startsWith("--raw-field=") ||
891
+ lower.startsWith("--input=") ||
892
+ /^-[fF]/.test(token)
893
+ ) {
894
+ return "Agent Workflow Audit allows read-only gh api calls only; request fields and input files are blocked.";
895
+ }
896
+ let method: string | undefined;
897
+ if (lower === "-x" || lower === "--method") method = tokens[index + 1];
898
+ else if (lower.startsWith("-x") && token.length > 2) method = token.slice(2).replace(/^=/, "");
899
+ else if (lower.startsWith("--method=")) method = token.slice("--method=".length);
900
+ if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
901
+ return "Agent Workflow Audit allows read-only gh api calls only; mutating methods are blocked.";
902
+ }
903
+ }
904
+ return undefined;
670
905
  }
671
906
 
672
907
  function getBlockedGhReason(tokens: string[]): string | undefined {
673
- const apiReason = getBlockedGhApiReason(tokens);
674
- if (apiReason) return apiReason;
675
- const parsed = getGhCommand(tokens);
676
- const command = parsed.command;
677
- if (!command) return undefined;
678
- if (!(command in READ_ONLY_GH_SUBCOMMANDS)) {
679
- return `Agent Workflow Audit blocks gh ${command}; only known read-only gh commands are allowed.`;
680
- }
681
- const allowedSubcommands = READ_ONLY_GH_SUBCOMMANDS[command];
682
- if (allowedSubcommands && (!parsed.subcommand || !allowedSubcommands.has(parsed.subcommand))) {
683
- return `Agent Workflow Audit blocks gh ${command}${parsed.subcommand ? ` ${parsed.subcommand}` : ""}; only known read-only gh subcommands are allowed.`;
684
- }
685
- if (command === "auth" && tokens.some((token) => {
686
- const lower = token.toLowerCase();
687
- return lower === "-t" || lower === "--show-token" || lower.startsWith("--show-token=");
688
- })) {
689
- return "Agent Workflow Audit blocks gh auth status --show-token because it reveals credentials.";
690
- }
691
- return undefined;
908
+ const apiReason = getBlockedGhApiReason(tokens);
909
+ if (apiReason) return apiReason;
910
+ const parsed = getGhCommand(tokens);
911
+ const command = parsed.command;
912
+ if (!command) return undefined;
913
+ if (!(command in READ_ONLY_GH_SUBCOMMANDS)) {
914
+ return `Agent Workflow Audit blocks gh ${command}; only known read-only gh commands are allowed.`;
915
+ }
916
+ const allowedSubcommands = READ_ONLY_GH_SUBCOMMANDS[command];
917
+ if (allowedSubcommands && (!parsed.subcommand || !allowedSubcommands.has(parsed.subcommand))) {
918
+ return `Agent Workflow Audit blocks gh ${command}${parsed.subcommand ? ` ${parsed.subcommand}` : ""}; only known read-only gh subcommands are allowed.`;
919
+ }
920
+ if (
921
+ command === "auth" &&
922
+ tokens.some((token) => {
923
+ const lower = token.toLowerCase();
924
+ return lower === "-t" || lower === "--show-token" || lower.startsWith("--show-token=");
925
+ })
926
+ ) {
927
+ return "Agent Workflow Audit blocks gh auth status --show-token because it reveals credentials.";
928
+ }
929
+ return undefined;
692
930
  }
693
931
 
694
932
  function hasInlineFlag(tokens: string[], flags: string[]): boolean {
695
- return tokens.some((token) => {
696
- const lower = token.toLowerCase();
697
- return flags.some((flag) => lower === flag || lower.startsWith(`${flag}=`) || (flag.length === 2 && lower.startsWith(flag) && lower !== flag.slice(0, 1)));
698
- });
933
+ return tokens.some((token) => {
934
+ const lower = token.toLowerCase();
935
+ return flags.some(
936
+ (flag) =>
937
+ lower === flag ||
938
+ lower.startsWith(`${flag}=`) ||
939
+ (flag.length === 2 && lower.startsWith(flag) && lower !== flag.slice(0, 1)),
940
+ );
941
+ });
699
942
  }
700
943
 
701
944
  function getBlockedInterpreterReason(commandName: string, tokens: string[]): string | undefined {
702
- const lowerTokens = tokens.map((token) => token.toLowerCase());
703
- if (["node", "deno", "bun"].includes(commandName) && hasInlineFlag(lowerTokens, ["-e", "--eval", "eval", "-p", "--print", "-r", "--require", "--import", "--preload", "--loader", "--experimental-loader"])) {
704
- return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
705
- }
706
- if (["python", "python3"].includes(commandName) && hasInlineFlag(lowerTokens, ["-c"])) {
707
- return "Agent Workflow Audit blocks inline Python execution; run documented project scripts instead.";
708
- }
709
- if (["ruby", "php"].includes(commandName) && hasInlineFlag(lowerTokens, ["-e", "-r"])) {
710
- return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
711
- }
712
- return undefined;
945
+ const lowerTokens = tokens.map((token) => token.toLowerCase());
946
+ if (
947
+ ["node", "deno", "bun"].includes(commandName) &&
948
+ hasInlineFlag(lowerTokens, [
949
+ "-e",
950
+ "--eval",
951
+ "eval",
952
+ "-p",
953
+ "--print",
954
+ "-r",
955
+ "--require",
956
+ "--import",
957
+ "--preload",
958
+ "--loader",
959
+ "--experimental-loader",
960
+ ])
961
+ ) {
962
+ return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
963
+ }
964
+ if (["python", "python3"].includes(commandName) && hasInlineFlag(lowerTokens, ["-c"])) {
965
+ return "Agent Workflow Audit blocks inline Python execution; run documented project scripts instead.";
966
+ }
967
+ if (["ruby", "php"].includes(commandName) && hasInlineFlag(lowerTokens, ["-e", "-r"])) {
968
+ return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
969
+ }
970
+ return undefined;
713
971
  }
714
972
 
715
973
  function getBlockedLocalToolReason(commandName: string, tokens: string[]): string | undefined {
716
- const lowerTokens = tokens.map((token) => token.toLowerCase());
717
- const firstCommand = lowerTokens.slice(1).find((token) => !token.startsWith("-"));
718
- if (commandName === "go") {
719
- if (!firstCommand || !["test", "build", "vet", "list", "version"].includes(firstCommand)) return `Agent Workflow Audit blocks go ${firstCommand ?? ""}; only test/build/vet/list/version are allowed.`;
720
- }
721
- if (commandName === "cargo") {
722
- if (!firstCommand || !["test", "build", "check", "clippy", "doc", "metadata", "tree", "version"].includes(firstCommand)) return `Agent Workflow Audit blocks cargo ${firstCommand ?? ""}; only test/build/check/clippy/doc/metadata/tree/version are allowed.`;
723
- }
724
- if (commandName === "ruff") {
725
- if (lowerTokens.some((token) => token === "--fix" || token.startsWith("--fix=") || token === "--fix-only" || token.startsWith("--fix-only=")) || firstCommand === "format") return "Agent Workflow Audit blocks ruff source-mutating fix/format commands.";
726
- }
727
- if (commandName === "dotnet") {
728
- if (firstCommand && ["add", "remove", "format", "tool", "new", "nuget"].includes(firstCommand)) return `Agent Workflow Audit blocks dotnet ${firstCommand} mutation commands.`;
729
- }
730
- if (commandName === "swift") {
731
- if (firstCommand && !["test", "build", "--version", "-version"].includes(firstCommand)) return `Agent Workflow Audit blocks swift ${firstCommand}; only test/build/version are allowed.`;
732
- }
733
- return undefined;
974
+ const lowerTokens = tokens.map((token) => token.toLowerCase());
975
+ const firstCommand = lowerTokens.slice(1).find((token) => !token.startsWith("-"));
976
+ if (commandName === "go") {
977
+ if (!firstCommand || !["test", "build", "vet", "list", "version"].includes(firstCommand))
978
+ return `Agent Workflow Audit blocks go ${firstCommand ?? ""}; only test/build/vet/list/version are allowed.`;
979
+ }
980
+ if (commandName === "cargo") {
981
+ if (
982
+ !firstCommand ||
983
+ !["test", "build", "check", "clippy", "doc", "metadata", "tree", "version"].includes(
984
+ firstCommand,
985
+ )
986
+ )
987
+ return `Agent Workflow Audit blocks cargo ${firstCommand ?? ""}; only test/build/check/clippy/doc/metadata/tree/version are allowed.`;
988
+ }
989
+ if (commandName === "ruff") {
990
+ if (
991
+ lowerTokens.some(
992
+ (token) =>
993
+ token === "--fix" ||
994
+ token.startsWith("--fix=") ||
995
+ token === "--fix-only" ||
996
+ token.startsWith("--fix-only="),
997
+ ) ||
998
+ firstCommand === "format"
999
+ )
1000
+ return "Agent Workflow Audit blocks ruff source-mutating fix/format commands.";
1001
+ }
1002
+ if (commandName === "dotnet") {
1003
+ if (firstCommand && ["add", "remove", "format", "tool", "new", "nuget"].includes(firstCommand))
1004
+ return `Agent Workflow Audit blocks dotnet ${firstCommand} mutation commands.`;
1005
+ }
1006
+ if (commandName === "swift") {
1007
+ if (firstCommand && !["test", "build", "--version", "-version"].includes(firstCommand))
1008
+ return `Agent Workflow Audit blocks swift ${firstCommand}; only test/build/version are allowed.`;
1009
+ }
1010
+ return undefined;
734
1011
  }
735
1012
 
736
1013
  function getBlockedExecutableReason(tokens: string[]): string | undefined {
737
- const executable = tokens[0];
738
- if (!executable) return "Agent Workflow Audit bash requires a command.";
739
- if (executable.startsWith("/")) return "Agent Workflow Audit bash blocks absolute-path executables.";
740
- if (/[\\/]/.test(executable)) return "Agent Workflow Audit bash blocks path-qualified executables to avoid symlink/path escapes; use direct project/package commands.";
741
-
742
- const commandName = getCommandName(executable);
743
- if (commandName === "cd") return getBlockedCdReason(tokens);
744
- if (executable.startsWith("./")) return "Agent Workflow Audit blocks relative executables to avoid symlink/path escapes; use package-manager scripts or report the documented command as manual.";
745
- if (!SAFE_DIRECT_COMMANDS.has(commandName)) {
746
- return `Agent Workflow Audit bash blocks direct ${commandName || "shell"} commands; use read/grep/find/ls for inspection or documented project commands.`;
747
- }
748
- const interpreterReason = getBlockedInterpreterReason(commandName, tokens);
749
- if (interpreterReason) return interpreterReason;
750
- const localToolReason = getBlockedLocalToolReason(commandName, tokens);
751
- if (localToolReason) return localToolReason;
752
- if (["npm", "pnpm", "yarn", "bun"].includes(commandName)) return getBlockedPackageManagerReason(commandName, tokens);
753
- if (["make", "just", "task"].includes(commandName)) return getBlockedTaskRunnerReason(commandName, tokens);
754
- if (commandName === "cargo" && tokens.slice(1).some((token) => /^(publish|login|owner|yank|install|release)(?:$|[:_-])/i.test(token))) return "Agent Workflow Audit blocks cargo registry/user mutation commands.";
755
- if (commandName === "mvn") {
756
- const goals = tokens.slice(1).filter((token) => !token.startsWith("-")).map((token) => token.toLowerCase());
757
- const allowedGoals = new Set(["test", "verify", "package", "compile", "validate", "dependency:tree", "help:effective-pom", "--version"]);
758
- if (goals.some((goal) => !allowedGoals.has(goal))) return "Agent Workflow Audit allows mvn only for test/verify/package/compile/validate/dependency:tree/help:effective-pom.";
759
- }
760
- if (["gradle", "gradlew"].includes(commandName)) {
761
- const lowerTokens = tokens.map((token) => token.toLowerCase());
762
- if (lowerTokens.some((token) => token === "-p" || token === "--project-dir" || token.startsWith("--project-dir="))) return "Agent Workflow Audit blocks Gradle project-dir flags to avoid symlink/path escapes.";
763
- const tasks = lowerTokens.slice(1).filter((token) => !token.startsWith("-"));
764
- const allowedTasks = new Set(["test", "build", "check", "assemble", "tasks", "projects", "properties", "dependencies"]);
765
- if (tasks.some((task) => !allowedTasks.has(task))) return "Agent Workflow Audit allows Gradle only for test/build/check/assemble/tasks/projects/properties/dependencies.";
766
- }
767
- if (commandName === "dotnet" && tokens.slice(1).some((token, index, items) => token.toLowerCase() === "nuget" && ["push", "delete"].includes(items[index + 1]?.toLowerCase()))) return "Agent Workflow Audit blocks dotnet nuget mutation commands.";
768
- if (commandName === "git") return getBlockedGitReason(tokens);
769
- if (commandName === "gh") return getBlockedGhReason(tokens);
770
- return undefined;
1014
+ const executable = tokens[0];
1015
+ if (!executable) return "Agent Workflow Audit bash requires a command.";
1016
+ if (executable.startsWith("/"))
1017
+ return "Agent Workflow Audit bash blocks absolute-path executables.";
1018
+ if (/[\\/]/.test(executable))
1019
+ return "Agent Workflow Audit bash blocks path-qualified executables to avoid symlink/path escapes; use direct project/package commands.";
1020
+
1021
+ const commandName = getCommandName(executable);
1022
+ if (commandName === "cd") return getBlockedCdReason(tokens);
1023
+ if (!SAFE_DIRECT_COMMANDS.has(commandName)) {
1024
+ return `Agent Workflow Audit bash blocks direct ${commandName || "shell"} commands; use read/grep/find/ls for inspection or documented project commands.`;
1025
+ }
1026
+ const interpreterReason = getBlockedInterpreterReason(commandName, tokens);
1027
+ if (interpreterReason) return interpreterReason;
1028
+ const localToolReason = getBlockedLocalToolReason(commandName, tokens);
1029
+ if (localToolReason) return localToolReason;
1030
+ if (["npm", "pnpm", "yarn", "bun"].includes(commandName))
1031
+ return getBlockedPackageManagerReason(commandName, tokens);
1032
+ if (["make", "just", "task"].includes(commandName))
1033
+ return getBlockedTaskRunnerReason(commandName, tokens);
1034
+ if (
1035
+ commandName === "cargo" &&
1036
+ tokens
1037
+ .slice(1)
1038
+ .some((token) => /^(publish|login|owner|yank|install|release)(?:$|[:_-])/i.test(token))
1039
+ )
1040
+ return "Agent Workflow Audit blocks cargo registry/user mutation commands.";
1041
+ if (commandName === "mvn") {
1042
+ const goals = tokens
1043
+ .slice(1)
1044
+ .filter((token) => !token.startsWith("-"))
1045
+ .map((token) => token.toLowerCase());
1046
+ const allowedGoals = new Set([
1047
+ "test",
1048
+ "verify",
1049
+ "package",
1050
+ "compile",
1051
+ "validate",
1052
+ "dependency:tree",
1053
+ "help:effective-pom",
1054
+ "--version",
1055
+ ]);
1056
+ if (goals.some((goal) => !allowedGoals.has(goal)))
1057
+ return "Agent Workflow Audit allows mvn only for test/verify/package/compile/validate/dependency:tree/help:effective-pom.";
1058
+ }
1059
+ if (["gradle", "gradlew"].includes(commandName)) {
1060
+ const lowerTokens = tokens.map((token) => token.toLowerCase());
1061
+ if (
1062
+ lowerTokens.some(
1063
+ (token) =>
1064
+ token === "-p" || token === "--project-dir" || token.startsWith("--project-dir="),
1065
+ )
1066
+ )
1067
+ return "Agent Workflow Audit blocks Gradle project-dir flags to avoid symlink/path escapes.";
1068
+ const tasks = lowerTokens.slice(1).filter((token) => !token.startsWith("-"));
1069
+ const allowedTasks = new Set([
1070
+ "test",
1071
+ "build",
1072
+ "check",
1073
+ "assemble",
1074
+ "tasks",
1075
+ "projects",
1076
+ "properties",
1077
+ "dependencies",
1078
+ ]);
1079
+ if (tasks.some((task) => !allowedTasks.has(task)))
1080
+ return "Agent Workflow Audit allows Gradle only for test/build/check/assemble/tasks/projects/properties/dependencies.";
1081
+ }
1082
+ if (
1083
+ commandName === "dotnet" &&
1084
+ tokens
1085
+ .slice(1)
1086
+ .some(
1087
+ (token, index, items) =>
1088
+ token.toLowerCase() === "nuget" &&
1089
+ ["push", "delete"].includes(items[index + 1]?.toLowerCase()),
1090
+ )
1091
+ )
1092
+ return "Agent Workflow Audit blocks dotnet nuget mutation commands.";
1093
+ if (commandName === "git") return getBlockedGitReason(tokens);
1094
+ if (commandName === "gh") return getBlockedGhReason(tokens);
1095
+ return undefined;
771
1096
  }
772
1097
 
773
1098
  function getBlockedBashReason(command: string, options: { planOnly: boolean }): string | undefined {
774
- const trimmed = command.trim();
775
- if (!trimmed) return "Agent Workflow Audit bash requires a non-empty command.";
776
- if (options.planOnly) return "Plan-only Agent Workflow Audit mode blocks bash/project command execution.";
777
-
778
- const segments = splitShellSegments(trimmed);
779
- if (!Array.isArray(segments)) return segments.reason;
780
- for (const segment of segments) {
781
- const tokenized = tokenizeSegment(segment);
782
- if (tokenized.reason) return tokenized.reason;
783
- const tokens = tokenized.tokens;
784
- if (tokens.length === 0) return "Agent Workflow Audit bash requires non-empty command segments.";
785
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) return "Agent Workflow Audit bash blocks inline environment assignments.";
786
- for (const token of tokens) {
787
- const flagReason = getUnsafeShellFlagReason(token);
788
- if (flagReason) return flagReason;
789
- const pathReason = getUnsafeShellTokenReason(token);
790
- if (pathReason) return pathReason;
791
- }
792
- const executableReason = getBlockedExecutableReason(tokens);
793
- if (executableReason) return executableReason;
794
- }
795
-
796
- return undefined;
1099
+ const trimmed = command.trim();
1100
+ if (!trimmed) return "Agent Workflow Audit bash requires a non-empty command.";
1101
+ if (options.planOnly)
1102
+ return "Plan-only Agent Workflow Audit mode blocks bash/project command execution.";
1103
+
1104
+ const segments = splitShellSegments(trimmed);
1105
+ if (!Array.isArray(segments)) return segments.reason;
1106
+ for (const segment of segments) {
1107
+ const tokenized = tokenizeSegment(segment);
1108
+ if (tokenized.reason) return tokenized.reason;
1109
+ const tokens = tokenized.tokens;
1110
+ if (tokens.length === 0)
1111
+ return "Agent Workflow Audit bash requires non-empty command segments.";
1112
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
1113
+ return "Agent Workflow Audit bash blocks inline environment assignments.";
1114
+ for (const token of tokens) {
1115
+ const flagReason = getUnsafeShellFlagReason(token);
1116
+ if (flagReason) return flagReason;
1117
+ const pathReason = getUnsafeShellTokenReason(token);
1118
+ if (pathReason) return pathReason;
1119
+ }
1120
+ const executableReason = getBlockedExecutableReason(tokens);
1121
+ if (executableReason) return executableReason;
1122
+ }
1123
+
1124
+ return undefined;
797
1125
  }
798
1126
 
799
- function createAuditRuntimeGuardExtension(options: { cwd: string; maxTurns: number; planOnly: boolean }): ExtensionFactory {
800
- return (pi) => {
801
- let currentTurn = 0;
802
-
803
- pi.on("turn_start", async (event) => {
804
- currentTurn = event.turnIndex;
805
- });
806
-
807
- pi.on("tool_call", async (event) => {
808
- if (!["read", "grep", "find", "ls", "bash"].includes(event.toolName)) {
809
- return { block: true, reason: `agent-workflow-audit exposes only read, grep, find, ls, and guarded bash; ${event.toolName} is not allowed.` };
810
- }
811
-
812
- if (currentTurn >= options.maxTurns - 1) {
813
- return {
814
- block: true,
815
- reason: `Tool use is disabled on final agent-workflow-audit turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
816
- };
817
- }
818
-
819
- if (event.toolName === "read") {
820
- const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, "read");
821
- if (reason) return { block: true, reason };
822
- }
823
-
824
- if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
825
- const input = asRecord(event.input) ?? {};
826
- const reason = await assertToolPathInsideCwd(options.cwd, input.path, event.toolName);
827
- if (reason) return { block: true, reason };
828
- if (event.toolName === "grep") {
829
- const globReason = getUnsafePatternReason(input.glob, "grep glob");
830
- if (globReason) return { block: true, reason: globReason };
831
- }
832
- if (event.toolName === "find") {
833
- const patternReason = getUnsafePatternReason(input.pattern, "find pattern");
834
- if (patternReason) return { block: true, reason: patternReason };
835
- }
836
- }
837
-
838
- if (event.toolName === "bash") {
839
- const input = event.input as { command?: unknown; timeout?: unknown };
840
- if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
841
- else input.timeout = Math.min(MAX_BASH_TIMEOUT_SECONDS, Math.max(1, Math.floor(input.timeout)));
842
- const command = typeof input.command === "string" ? input.command : "";
843
- const reason = getBlockedBashReason(command, { planOnly: options.planOnly });
844
- if (reason) return { block: true, reason };
845
- }
846
-
847
- return undefined;
848
- });
849
-
850
- pi.on("tool_result", async (event) => ({
851
- content: [
852
- ...(event.content ?? []),
853
- {
854
- type: "text",
855
- text: `\n\n[agent-workflow-audit turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
856
- },
857
- ],
858
- }));
859
- };
1127
+ function createAuditRuntimeGuardExtension(options: {
1128
+ cwd: string;
1129
+ maxTurns: number;
1130
+ planOnly: boolean;
1131
+ }): ExtensionFactory {
1132
+ return (pi) => {
1133
+ let currentTurn = 0;
1134
+
1135
+ pi.on("turn_start", async (event) => {
1136
+ currentTurn = event.turnIndex;
1137
+ });
1138
+
1139
+ pi.on("tool_call", async (event) => {
1140
+ if (!["read", "grep", "find", "ls", "bash"].includes(event.toolName)) {
1141
+ return {
1142
+ block: true,
1143
+ reason: `agent-workflow-audit exposes only read, grep, find, ls, and guarded bash; ${event.toolName} is not allowed.`,
1144
+ };
1145
+ }
1146
+
1147
+ if (currentTurn >= options.maxTurns - 1) {
1148
+ return {
1149
+ block: true,
1150
+ reason: `Tool use is disabled on final agent-workflow-audit turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
1151
+ };
1152
+ }
1153
+
1154
+ if (event.toolName === "read") {
1155
+ const reason = await assertToolPathInsideCwd(
1156
+ options.cwd,
1157
+ (event.input as { path?: unknown }).path,
1158
+ "read",
1159
+ );
1160
+ if (reason) return { block: true, reason };
1161
+ }
1162
+
1163
+ if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
1164
+ const input = asRecord(event.input) ?? {};
1165
+ const reason = await assertToolPathInsideCwd(options.cwd, input.path, event.toolName);
1166
+ if (reason) return { block: true, reason };
1167
+ if (event.toolName === "grep") {
1168
+ const globReason = getUnsafePatternReason(input.glob, "grep glob");
1169
+ if (globReason) return { block: true, reason: globReason };
1170
+ }
1171
+ if (event.toolName === "find") {
1172
+ const patternReason = getUnsafePatternReason(input.pattern, "find pattern");
1173
+ if (patternReason) return { block: true, reason: patternReason };
1174
+ }
1175
+ }
1176
+
1177
+ if (event.toolName === "bash") {
1178
+ const input = event.input as { command?: unknown; timeout?: unknown };
1179
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
1180
+ else
1181
+ input.timeout = Math.min(
1182
+ MAX_BASH_TIMEOUT_SECONDS,
1183
+ Math.max(1, Math.floor(input.timeout)),
1184
+ );
1185
+ const command = typeof input.command === "string" ? input.command : "";
1186
+ const reason = getBlockedBashReason(command, { planOnly: options.planOnly });
1187
+ if (reason) return { block: true, reason };
1188
+ }
1189
+
1190
+ return undefined;
1191
+ });
1192
+
1193
+ pi.on("tool_result", async (event) => ({
1194
+ content: [
1195
+ ...(event.content ?? []),
1196
+ {
1197
+ type: "text",
1198
+ text: `\n\n[agent-workflow-audit turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1199
+ },
1200
+ ],
1201
+ }));
1202
+ };
860
1203
  }
861
1204
 
862
- function buildSystemPrompt(options: { cwd: string; maxTurns: number; maxRunSeconds: number; planOnly: boolean }): string {
863
- const executionMode = options.planOnly
864
- ? "Plan-only mode is active. Do not run project commands with bash. Read instructions and manifests, infer the likely workflow, and report what would be tried plus remaining uncertainty."
865
- : "Execution mode is active. Try documented setup, build, lint, test, run, and other obvious project commands when they are safe and relevant. A runtime guard blocks deploy/publish/VCS-mutating/destructive commands; if a command is blocked, report it as a workflow/safety finding instead of trying to bypass it.";
1205
+ function buildSystemPrompt(options: {
1206
+ cwd: string;
1207
+ maxTurns: number;
1208
+ maxRunSeconds: number;
1209
+ planOnly: boolean;
1210
+ }): string {
1211
+ const executionMode = options.planOnly
1212
+ ? "Plan-only mode is active. Do not run project commands with bash. Read instructions and manifests, infer the likely workflow, and report what would be tried plus remaining uncertainty."
1213
+ : "Execution mode is active. Try documented setup, build, lint, test, run, and other obvious project commands when they are safe and relevant. A runtime guard blocks deploy/publish/VCS-mutating/destructive commands; if a command is blocked, report it as a workflow/safety finding instead of trying to bypass it.";
866
1214
 
867
- return `You are Agent Workflow Audit, an isolated subagent running inside The Last Harness. Your job is to stress-test how efficiently an agent can operate in the current repository and return a concise final audit report to the parent session.
1215
+ return `You are Agent Workflow Audit, an isolated subagent running inside The Last Harness. Your job is to stress-test how efficiently an agent can operate in the current repository and return a concise final audit report to the parent session.
868
1216
 
869
1217
  Working directory: ${options.cwd}
870
1218
  Turn budget: ${options.maxTurns} turns total, including your final answer.
@@ -913,8 +1261,13 @@ Unresolved prerequisites, missing context, or checks that were unsafe/blocked/no
913
1261
  1-5 concise next actions. If product code or tests need fixing, say that separately from instruction improvements.`;
914
1262
  }
915
1263
 
916
- function buildUserPrompt(options: { cwd: string; focus?: string; initialGitStatus?: string; planOnly: boolean }): string {
917
- return `Task: audit the current repository for agent workflow efficiency.
1264
+ function buildUserPrompt(options: {
1265
+ cwd: string;
1266
+ focus?: string;
1267
+ initialGitStatus?: string;
1268
+ planOnly: boolean;
1269
+ }): string {
1270
+ return `Task: audit the current repository for agent workflow efficiency.
918
1271
 
919
1272
  Local checkout: ${options.cwd}
920
1273
  Mode: ${options.planOnly ? "plan-only (do not execute project commands)" : "execute safe documented/inferred workflow commands"}
@@ -926,341 +1279,377 @@ Read the repo instructions/manifests, try or plan the workflow according to the
926
1279
  }
927
1280
 
928
1281
  function formatToolCall(call: ToolCall): string {
929
- const args = asRecord(call.args) ?? {};
930
- if (call.name === "read") {
931
- const readPath = typeof args.path === "string" ? args.path : "";
932
- const offset = typeof args.offset === "number" ? args.offset : undefined;
933
- const limit = typeof args.limit === "number" ? args.limit : undefined;
934
- const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
935
- return `read ${readPath}${range}`.trim();
936
- }
937
- if (call.name === "grep") {
938
- return `grep ${shorten(String(args.pattern ?? ""), 60)} ${String(args.path ?? ".")}`.trim();
939
- }
940
- if (call.name === "find") {
941
- return `find ${shorten(String(args.pattern ?? ""), 60)} ${String(args.path ?? ".")}`.trim();
942
- }
943
- if (call.name === "ls") return `ls ${String(args.path ?? ".")}`.trim();
944
- if (call.name === "bash") return `bash ${shorten(String(args.command ?? ""), 140)}`.trim();
945
- return call.name;
1282
+ const args = asRecord(call.args) ?? {};
1283
+ if (call.name === "read") {
1284
+ const readPath = typeof args.path === "string" ? args.path : "";
1285
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
1286
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
1287
+ const range =
1288
+ offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1289
+ return `read ${readPath}${range}`.trim();
1290
+ }
1291
+ if (call.name === "grep") {
1292
+ return `grep ${shorten(String(args.pattern ?? ""), 60)} ${String(args.path ?? ".")}`.trim();
1293
+ }
1294
+ if (call.name === "find") {
1295
+ return `find ${shorten(String(args.pattern ?? ""), 60)} ${String(args.path ?? ".")}`.trim();
1296
+ }
1297
+ if (call.name === "ls") return `ls ${String(args.path ?? ".")}`.trim();
1298
+ if (call.name === "bash") return `bash ${shorten(String(args.command ?? ""), 140)}`.trim();
1299
+ return call.name;
946
1300
  }
947
1301
 
948
- function updateAuditUi(ctx: ExtensionCommandContext, details: AuditDetails | undefined, preview?: string): void {
949
- if (!ctx.hasUI) return;
950
- if (!details || details.status !== "running") {
951
- ctx.ui.setStatus(STATUS_ID, undefined);
952
- ctx.ui.setWidget(WIDGET_ID, undefined);
953
- return;
954
- }
955
-
956
- const theme = ctx.ui.theme;
957
- const elapsed = formatDuration(Date.now() - details.startedAt);
958
- ctx.ui.setStatus(
959
- STATUS_ID,
960
- `${theme.fg("accent", "🧭 workflow audit")} ${theme.fg("dim", `${details.mode} · ${details.turns} turns · ${details.toolCalls.length} tools · ${elapsed}`)}`,
961
- );
962
-
963
- const lastTool = details.toolCalls[details.toolCalls.length - 1];
964
- const lines = [
965
- "🧭 Agent Workflow Audit",
966
- `${details.mode} · ${details.turns} turn(s) · ${details.toolCalls.length} tool call(s) · ${elapsed}`,
967
- ];
968
- if (lastTool) lines.push(`last tool: ${formatToolCall(lastTool)}`);
969
- if (preview?.trim()) lines.push(`preview: ${shorten(preview, 120)}`);
970
- ctx.ui.setWidget(WIDGET_ID, lines, { placement: "belowEditor" });
1302
+ function updateAuditUi(
1303
+ ctx: ExtensionCommandContext,
1304
+ details: AuditDetails | undefined,
1305
+ preview?: string,
1306
+ ): void {
1307
+ if (!ctx.hasUI) return;
1308
+ if (!details || details.status !== "running") {
1309
+ ctx.ui.setStatus(STATUS_ID, undefined);
1310
+ ctx.ui.setWidget(WIDGET_ID, undefined);
1311
+ return;
1312
+ }
1313
+
1314
+ const theme = ctx.ui.theme;
1315
+ const elapsed = formatDuration(Date.now() - details.startedAt);
1316
+ ctx.ui.setStatus(
1317
+ STATUS_ID,
1318
+ `${theme.fg("accent", "🧭 workflow audit")} ${theme.fg("dim", `${details.mode} · ${details.turns} turns · ${details.toolCalls.length} tools · ${elapsed}`)}`,
1319
+ );
1320
+
1321
+ const lastTool = details.toolCalls[details.toolCalls.length - 1];
1322
+ const lines = [
1323
+ "🧭 Agent Workflow Audit",
1324
+ `${details.mode} · ${details.turns} turn(s) · ${details.toolCalls.length} tool call(s) · ${elapsed}`,
1325
+ ];
1326
+ if (lastTool) lines.push(`last tool: ${formatToolCall(lastTool)}`);
1327
+ if (preview?.trim()) lines.push(`preview: ${shorten(preview, 120)}`);
1328
+ ctx.ui.setWidget(WIDGET_ID, lines, { placement: "belowEditor" });
971
1329
  }
972
1330
 
973
1331
  async function runAudit(
974
- pi: ExtensionAPI,
975
- ctx: ExtensionCommandContext,
976
- options: { focus?: string; planOnly: boolean; initialGitStatus?: string },
1332
+ pi: ExtensionAPI,
1333
+ ctx: ExtensionCommandContext,
1334
+ options: { focus?: string; planOnly: boolean; initialGitStatus?: string },
977
1335
  ): Promise<{ report: string; details: AuditDetails }> {
978
- if (!ctx.model) throw new Error("/agent-workflow-audit needs an active model, but ctx.model is unavailable.");
979
-
980
- const cwd = path.resolve(ctx.cwd);
981
- const mode: AuditMode = options.planOnly ? "plan-only" : "execute";
982
- const details: AuditDetails = {
983
- status: "running",
984
- mode,
985
- cwd,
986
- focus: options.focus,
987
- turns: 0,
988
- toolCalls: [],
989
- startedAt: Date.now(),
990
- initialGitStatus: options.initialGitStatus,
991
- };
992
-
993
- let lastContent = "(auditing agent workflow...)";
994
- let session: AgentSession | undefined;
995
- let unsubscribe: (() => void) | undefined;
996
- let runTimeout: NodeJS.Timeout | undefined;
997
- let abortListenerAdded = false;
998
- let aborted = Boolean(ctx.signal?.aborted);
999
-
1000
- const emit = () => updateAuditUi(ctx, details, lastContent);
1001
- const abort = () => {
1002
- aborted = true;
1003
- details.status = "aborted";
1004
- details.endedAt = Date.now();
1005
- lastContent = "Aborted";
1006
- emit();
1007
- void session?.abort();
1008
- };
1009
-
1010
- if (ctx.signal?.aborted) abort();
1011
- if (ctx.signal && !ctx.signal.aborted) {
1012
- ctx.signal.addEventListener("abort", abort);
1013
- abortListenerAdded = true;
1014
- }
1015
-
1016
- try {
1017
- emit();
1018
- const isolatedSettingsManager = SettingsManager.inMemory({});
1019
- const resourceLoader = new DefaultResourceLoader({
1020
- cwd,
1021
- agentDir: getAgentDir(),
1022
- settingsManager: isolatedSettingsManager,
1023
- noExtensions: true,
1024
- noSkills: true,
1025
- noPromptTemplates: true,
1026
- noThemes: true,
1027
- noContextFiles: true,
1028
- extensionFactories: [createAuditRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS, planOnly: options.planOnly })],
1029
- systemPromptOverride: () =>
1030
- buildSystemPrompt({
1031
- cwd,
1032
- maxTurns: MAX_TURNS,
1033
- maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
1034
- planOnly: options.planOnly,
1035
- }),
1036
- appendSystemPromptOverride: () => [],
1037
- skillsOverride: () => ({ skills: [], diagnostics: [] }),
1038
- promptsOverride: () => ({ prompts: [], diagnostics: [] }),
1039
- themesOverride: () => ({ themes: [], diagnostics: [] }),
1040
- agentsFilesOverride: () => ({ agentsFiles: [] }),
1041
- });
1042
-
1043
- await resourceLoader.reload();
1044
-
1045
- const tools = options.planOnly ? ["read", "grep", "find", "ls"] : ["read", "grep", "find", "ls", "bash"];
1046
- const created = await createAgentSession({
1047
- cwd,
1048
- ...getModelRuntimeOption(ctx),
1049
- resourceLoader,
1050
- settingsManager: isolatedSettingsManager,
1051
- sessionManager: SessionManager.inMemory(cwd),
1052
- model: ctx.model,
1053
- thinkingLevel: pi.getThinkingLevel(),
1054
- tools,
1055
- });
1056
-
1057
- session = created.session;
1058
- unsubscribe = session.subscribe((event) => {
1059
- switch (event.type) {
1060
- case "message_update":
1061
- if (event.assistantMessageEvent?.type === "text_delta") {
1062
- lastContent += event.assistantMessageEvent.delta ?? "";
1063
- emit();
1064
- }
1065
- break;
1066
- case "turn_end":
1067
- details.turns += 1;
1068
- emit();
1069
- break;
1070
- case "tool_execution_start":
1071
- details.toolCalls.push({
1072
- id: event.toolCallId,
1073
- name: event.toolName,
1074
- args: event.args,
1075
- startedAt: Date.now(),
1076
- });
1077
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1078
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1079
- }
1080
- emit();
1081
- break;
1082
- case "tool_execution_end": {
1083
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1084
- if (call) {
1085
- call.endedAt = Date.now();
1086
- call.isError = event.isError;
1087
- }
1088
- emit();
1089
- break;
1090
- }
1091
- }
1092
- });
1093
-
1094
- if (!aborted) {
1095
- const promptPromise = session.prompt(
1096
- buildUserPrompt({
1097
- cwd,
1098
- focus: options.focus,
1099
- initialGitStatus: options.initialGitStatus,
1100
- planOnly: options.planOnly,
1101
- }),
1102
- { expandPromptTemplates: false },
1103
- );
1104
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1105
- runTimeout = setTimeout(() => {
1106
- abort();
1107
- reject(new Error(`/agent-workflow-audit timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1108
- }, MAX_RUN_MS);
1109
- });
1110
- await Promise.race([promptPromise, timeoutPromise]);
1111
- }
1112
-
1113
- const outcome = session ? inspectFinalAssistant(session.state.messages) : { ok: false as const, reason: "Agent Workflow Audit subagent produced no assistant message" };
1114
- if (outcome.ok) {
1115
- lastContent = outcome.answer;
1116
- details.status = "done";
1117
- } else if (aborted) {
1118
- lastContent = "Aborted";
1119
- details.status = "aborted";
1120
- } else {
1121
- throw new Error(outcome.reason);
1122
- }
1123
- details.endedAt = Date.now();
1124
- details.reportLength = lastContent.length;
1125
- emit();
1126
- return { report: lastContent, details };
1127
- } catch (error) {
1128
- const wasAbort = aborted || isAbortLikeError(error);
1129
- const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
1130
- details.status = wasAbort ? "aborted" : "error";
1131
- details.error = wasAbort ? undefined : message;
1132
- details.endedAt = Date.now();
1133
- lastContent = message;
1134
- details.reportLength = lastContent.length;
1135
- emit();
1136
- return { report: `## Agent Workflow Audit failed\n\n${message}`, details };
1137
- } finally {
1138
- if (runTimeout) clearTimeout(runTimeout);
1139
- if (ctx.signal && abortListenerAdded) ctx.signal.removeEventListener("abort", abort);
1140
- unsubscribe?.();
1141
- session?.dispose();
1142
- }
1336
+ if (!ctx.model)
1337
+ throw new Error("/agent-workflow-audit needs an active model, but ctx.model is unavailable.");
1338
+
1339
+ const cwd = path.resolve(ctx.cwd);
1340
+ const mode: AuditMode = options.planOnly ? "plan-only" : "execute";
1341
+ const details: AuditDetails = {
1342
+ status: "running",
1343
+ mode,
1344
+ cwd,
1345
+ focus: options.focus,
1346
+ turns: 0,
1347
+ toolCalls: [],
1348
+ startedAt: Date.now(),
1349
+ initialGitStatus: options.initialGitStatus,
1350
+ };
1351
+
1352
+ let lastContent = "(auditing agent workflow...)";
1353
+ let session: AgentSession | undefined;
1354
+ let unsubscribe: (() => void) | undefined;
1355
+ let runTimeout: NodeJS.Timeout | undefined;
1356
+ let abortListenerAdded = false;
1357
+ let aborted = Boolean(ctx.signal?.aborted);
1358
+
1359
+ const emit = () => updateAuditUi(ctx, details, lastContent);
1360
+ const abort = () => {
1361
+ aborted = true;
1362
+ details.status = "aborted";
1363
+ details.endedAt = Date.now();
1364
+ lastContent = "Aborted";
1365
+ emit();
1366
+ void session?.abort();
1367
+ };
1368
+
1369
+ if (ctx.signal?.aborted) abort();
1370
+ if (ctx.signal && !ctx.signal.aborted) {
1371
+ ctx.signal.addEventListener("abort", abort);
1372
+ abortListenerAdded = true;
1373
+ }
1374
+
1375
+ try {
1376
+ emit();
1377
+ const isolatedSettingsManager = SettingsManager.inMemory({});
1378
+ const resourceLoader = new DefaultResourceLoader({
1379
+ cwd,
1380
+ agentDir: getAgentDir(),
1381
+ settingsManager: isolatedSettingsManager,
1382
+ noExtensions: true,
1383
+ noSkills: true,
1384
+ noPromptTemplates: true,
1385
+ noThemes: true,
1386
+ noContextFiles: true,
1387
+ extensionFactories: [
1388
+ createAuditRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS, planOnly: options.planOnly }),
1389
+ ],
1390
+ systemPromptOverride: () =>
1391
+ buildSystemPrompt({
1392
+ cwd,
1393
+ maxTurns: MAX_TURNS,
1394
+ maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
1395
+ planOnly: options.planOnly,
1396
+ }),
1397
+ appendSystemPromptOverride: () => [],
1398
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
1399
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
1400
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
1401
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
1402
+ });
1403
+
1404
+ await resourceLoader.reload();
1405
+
1406
+ const tools = options.planOnly
1407
+ ? ["read", "grep", "find", "ls"]
1408
+ : ["read", "grep", "find", "ls", "bash"];
1409
+ const created = await createAgentSession({
1410
+ cwd,
1411
+ ...getModelRuntimeOption(ctx),
1412
+ resourceLoader,
1413
+ settingsManager: isolatedSettingsManager,
1414
+ sessionManager: SessionManager.inMemory(cwd),
1415
+ model: ctx.model,
1416
+ thinkingLevel: pi.getThinkingLevel(),
1417
+ tools,
1418
+ });
1419
+
1420
+ session = created.session;
1421
+ unsubscribe = session.subscribe((event) => {
1422
+ switch (event.type) {
1423
+ case "message_update":
1424
+ if (event.assistantMessageEvent?.type === "text_delta") {
1425
+ lastContent += event.assistantMessageEvent.delta ?? "";
1426
+ emit();
1427
+ }
1428
+ break;
1429
+ case "turn_end":
1430
+ details.turns += 1;
1431
+ emit();
1432
+ break;
1433
+ case "tool_execution_start":
1434
+ details.toolCalls.push({
1435
+ id: event.toolCallId,
1436
+ name: event.toolName,
1437
+ args: event.args,
1438
+ startedAt: Date.now(),
1439
+ });
1440
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1441
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1442
+ }
1443
+ emit();
1444
+ break;
1445
+ case "tool_execution_end": {
1446
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1447
+ if (call) {
1448
+ call.endedAt = Date.now();
1449
+ call.isError = event.isError;
1450
+ }
1451
+ emit();
1452
+ break;
1453
+ }
1454
+ }
1455
+ });
1456
+
1457
+ if (!aborted) {
1458
+ const promptPromise = session.prompt(
1459
+ buildUserPrompt({
1460
+ cwd,
1461
+ focus: options.focus,
1462
+ initialGitStatus: options.initialGitStatus,
1463
+ planOnly: options.planOnly,
1464
+ }),
1465
+ { expandPromptTemplates: false },
1466
+ );
1467
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1468
+ runTimeout = setTimeout(() => {
1469
+ abort();
1470
+ reject(
1471
+ new Error(
1472
+ `/agent-workflow-audit timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
1473
+ ),
1474
+ );
1475
+ }, MAX_RUN_MS);
1476
+ });
1477
+ await Promise.race([promptPromise, timeoutPromise]);
1478
+ }
1479
+
1480
+ const outcome = session
1481
+ ? inspectFinalAssistant(session.state.messages)
1482
+ : {
1483
+ ok: false as const,
1484
+ reason: "Agent Workflow Audit subagent produced no assistant message",
1485
+ };
1486
+ if (outcome.ok) {
1487
+ lastContent = outcome.answer;
1488
+ details.status = "done";
1489
+ } else if (aborted) {
1490
+ lastContent = "Aborted";
1491
+ details.status = "aborted";
1492
+ } else {
1493
+ throw new Error(outcome.reason);
1494
+ }
1495
+ details.endedAt = Date.now();
1496
+ details.reportLength = lastContent.length;
1497
+ emit();
1498
+ return { report: lastContent, details };
1499
+ } catch (error) {
1500
+ const wasAbort = aborted || isAbortLikeError(error);
1501
+ const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
1502
+ details.status = wasAbort ? "aborted" : "error";
1503
+ details.error = wasAbort ? undefined : message;
1504
+ details.endedAt = Date.now();
1505
+ lastContent = message;
1506
+ details.reportLength = lastContent.length;
1507
+ emit();
1508
+ return { report: `## Agent Workflow Audit failed\n\n${message}`, details };
1509
+ } finally {
1510
+ if (runTimeout) clearTimeout(runTimeout);
1511
+ if (ctx.signal && abortListenerAdded) ctx.signal.removeEventListener("abort", abort);
1512
+ unsubscribe?.();
1513
+ session?.dispose();
1514
+ }
1143
1515
  }
1144
1516
 
1145
1517
  export const __test__ = {
1146
- appendRunBoundary,
1147
- assertToolPathInsideCwd,
1148
- buildSystemPrompt,
1149
- buildUserPrompt,
1150
- createAuditRuntimeGuardExtension,
1151
- inspectFinalAssistant,
1152
- formatToolCall,
1153
- getBlockedBashReason,
1154
- parseArgs,
1155
- renderCollapsedReport,
1518
+ appendRunBoundary,
1519
+ assertToolPathInsideCwd,
1520
+ buildSystemPrompt,
1521
+ buildUserPrompt,
1522
+ createAuditRuntimeGuardExtension,
1523
+ inspectFinalAssistant,
1524
+ formatToolCall,
1525
+ getBlockedBashReason,
1526
+ parseArgs,
1527
+ renderCollapsedReport,
1156
1528
  };
1157
1529
 
1158
1530
  export default function agentWorkflowAuditExtension(pi: ExtensionAPI) {
1159
- pi.registerMessageRenderer(CUSTOM_TYPE, (message, options, theme) => {
1160
- const { expanded } = options;
1161
- const rendererOptions = options as { outputPad?: number };
1162
- const outputPad = rendererOptions.outputPad ?? 0;
1163
- const details = message.details as AuditDetails | undefined;
1164
- const report = messageContentToText(message.content) || "(no report)";
1165
- const status = details?.status ?? "done";
1166
- const icon =
1167
- status === "done"
1168
- ? theme.fg("success", "✓")
1169
- : status === "error"
1170
- ? theme.fg("error", "✗")
1171
- : status === "aborted"
1172
- ? theme.fg("warning", "◼")
1173
- : theme.fg("warning", "⏳");
1174
- const duration = details?.endedAt ? formatDuration(details.endedAt - details.startedAt) : undefined;
1175
- const toolCount = details?.toolCallCount ?? details?.toolCalls.length ?? 0;
1176
- const meta = details
1177
- ? `${details.mode} · ${details.turns} turns · ${toolCount} tools${duration ? ` · ${duration}` : ""}`
1178
- : "final report";
1179
- const header = `${icon} ${theme.fg("toolTitle", theme.bold("agent-workflow-audit "))}${theme.fg("dim", meta)}`;
1180
-
1181
- if (!expanded) {
1182
- return new Text(`${header}\n\n${theme.fg("toolOutput", renderCollapsedReport(report))}`, outputPad, 0);
1183
- }
1184
-
1185
- const container = new Container();
1186
- container.addChild(new Text(header, outputPad, 0));
1187
- if (details?.cwd) container.addChild(new Text(theme.fg("dim", `cwd: ${details.cwd}`), outputPad, 0));
1188
- if (details?.focus) container.addChild(new Text(theme.fg("dim", `focus: ${details.focus}`), outputPad, 0));
1189
- container.addChild(new Spacer(1));
1190
- container.addChild(new Markdown(report, outputPad, 0, getMarkdownTheme()));
1191
- return container;
1192
- });
1193
-
1194
- pi.registerCommand("agent-workflow-audit", {
1195
- description: "Run an isolated repo workflow audit and return only the final report to this session",
1196
- getArgumentCompletions: (prefix) => {
1197
- const commands = ["--yes", "--plan-only", "--help"];
1198
- const normalized = prefix.trim().toLowerCase();
1199
- const matches = commands.filter((command) => command.startsWith(normalized));
1200
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1201
- },
1202
- handler: async (args, ctx) => {
1203
- const parsed = parseArgs(args);
1204
- if (parsed.help) {
1205
- notifyCommand(ctx, USAGE, "info");
1206
- return;
1207
- }
1208
- if (parsed.error) {
1209
- notifyCommand(ctx, parsed.error, "error");
1210
- return;
1211
- }
1212
- if (!ctx.model) {
1213
- notifyCommand(ctx, "/agent-workflow-audit needs an active model before it can run the isolated audit.", "error");
1214
- return;
1215
- }
1216
- if (!parsed.planOnly && !parsed.yes) {
1217
- if (!ctx.hasUI) {
1218
- notifyCommand(ctx, `${USAGE}\n\nNon-interactive execution mode requires --yes or --plan-only.`, "warning");
1219
- return;
1220
- }
1221
- const confirmed = await ctx.ui.confirm(
1222
- "Run Agent Workflow Audit?",
1223
- "An isolated subagent will read repo instructions and may execute documented setup/build/lint/test/run commands in the current checkout. Only the final audit report will be added to this session; intermediate command output, errors, retries, and tool transcript stay in the child session. Project commands may still create dependencies or build artifacts. Continue?",
1224
- );
1225
- if (!confirmed) return;
1226
- }
1227
-
1228
- await ctx.waitForIdle();
1229
- const initialGitStatus = await gitStatusShort(pi, ctx.cwd, ctx.signal).catch(() => undefined);
1230
- let report = "";
1231
- let details: AuditDetails | undefined;
1232
- try {
1233
- const result = await runAudit(pi, ctx, {
1234
- focus: parsed.focus,
1235
- planOnly: parsed.planOnly,
1236
- initialGitStatus,
1237
- });
1238
- report = result.report;
1239
- details = result.details;
1240
- } finally {
1241
- updateAuditUi(ctx, undefined);
1242
- }
1243
-
1244
- if (!details) return;
1245
- details.finalGitStatus = await gitStatusShort(pi, ctx.cwd, ctx.signal).catch(() => undefined);
1246
- report = appendRunBoundary(report, details);
1247
- details.reportLength = report.length;
1248
- const parentDetails: AuditDetails = {
1249
- ...details,
1250
- toolCallCount: details.toolCalls.length,
1251
- toolCalls: [],
1252
- initialGitStatus: undefined,
1253
- finalGitStatus: undefined,
1254
- };
1255
-
1256
- pi.sendMessage({
1257
- customType: CUSTOM_TYPE,
1258
- content: report,
1259
- display: true,
1260
- details: parentDetails,
1261
- });
1262
-
1263
- if (!ctx.hasUI) console.log(report);
1264
- },
1265
- });
1531
+ pi.registerMessageRenderer(CUSTOM_TYPE, (message, options, theme) => {
1532
+ const { expanded } = options;
1533
+ const rendererOptions = options as { outputPad?: number };
1534
+ const outputPad = rendererOptions.outputPad ?? 0;
1535
+ const details = message.details as AuditDetails | undefined;
1536
+ const report = messageContentToText(message.content) || "(no report)";
1537
+ const status = details?.status ?? "done";
1538
+ const icon =
1539
+ status === "done"
1540
+ ? theme.fg("success", "✓")
1541
+ : status === "error"
1542
+ ? theme.fg("error", "✗")
1543
+ : status === "aborted"
1544
+ ? theme.fg("warning", "◼")
1545
+ : theme.fg("warning", "⏳");
1546
+ const duration = details?.endedAt
1547
+ ? formatDuration(details.endedAt - details.startedAt)
1548
+ : undefined;
1549
+ const toolCount = details?.toolCallCount ?? details?.toolCalls.length ?? 0;
1550
+ const meta = details
1551
+ ? `${details.mode} · ${details.turns} turns · ${toolCount} tools${duration ? ` · ${duration}` : ""}`
1552
+ : "final report";
1553
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold("agent-workflow-audit "))}${theme.fg("dim", meta)}`;
1554
+
1555
+ if (!expanded) {
1556
+ return new Text(
1557
+ `${header}\n\n${theme.fg("toolOutput", renderCollapsedReport(report))}`,
1558
+ outputPad,
1559
+ 0,
1560
+ );
1561
+ }
1562
+
1563
+ const container = new Container();
1564
+ container.addChild(new Text(header, outputPad, 0));
1565
+ if (details?.cwd)
1566
+ container.addChild(new Text(theme.fg("dim", `cwd: ${details.cwd}`), outputPad, 0));
1567
+ if (details?.focus)
1568
+ container.addChild(new Text(theme.fg("dim", `focus: ${details.focus}`), outputPad, 0));
1569
+ container.addChild(new Spacer(1));
1570
+ container.addChild(new Markdown(report, outputPad, 0, getMarkdownTheme()));
1571
+ return container;
1572
+ });
1573
+
1574
+ pi.registerCommand("agent-workflow-audit", {
1575
+ description:
1576
+ "Run an isolated repo workflow audit and return only the final report to this session",
1577
+ getArgumentCompletions: (prefix) => {
1578
+ const commands = ["--yes", "--plan-only", "--help"];
1579
+ const normalized = prefix.trim().toLowerCase();
1580
+ const matches = commands.filter((command) => command.startsWith(normalized));
1581
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1582
+ },
1583
+ handler: async (args, ctx) => {
1584
+ const parsed = parseArgs(args);
1585
+ if (parsed.help) {
1586
+ notifyCommand(ctx, USAGE, "info");
1587
+ return;
1588
+ }
1589
+ if (parsed.error) {
1590
+ notifyCommand(ctx, parsed.error, "error");
1591
+ return;
1592
+ }
1593
+ if (!ctx.model) {
1594
+ notifyCommand(
1595
+ ctx,
1596
+ "/agent-workflow-audit needs an active model before it can run the isolated audit.",
1597
+ "error",
1598
+ );
1599
+ return;
1600
+ }
1601
+ if (!parsed.planOnly && !parsed.yes) {
1602
+ if (!ctx.hasUI) {
1603
+ notifyCommand(
1604
+ ctx,
1605
+ `${USAGE}\n\nNon-interactive execution mode requires --yes or --plan-only.`,
1606
+ "warning",
1607
+ );
1608
+ return;
1609
+ }
1610
+ const confirmed = await ctx.ui.confirm(
1611
+ "Run Agent Workflow Audit?",
1612
+ "An isolated subagent will read repo instructions and may execute documented setup/build/lint/test/run commands in the current checkout. Only the final audit report will be added to this session; intermediate command output, errors, retries, and tool transcript stay in the child session. Project commands may still create dependencies or build artifacts. Continue?",
1613
+ );
1614
+ if (!confirmed) return;
1615
+ }
1616
+
1617
+ await ctx.waitForIdle();
1618
+ const initialGitStatus = await gitStatusShort(pi, ctx.cwd, ctx.signal).catch(() => undefined);
1619
+ let report = "";
1620
+ let details: AuditDetails | undefined;
1621
+ try {
1622
+ const result = await runAudit(pi, ctx, {
1623
+ focus: parsed.focus,
1624
+ planOnly: parsed.planOnly,
1625
+ initialGitStatus,
1626
+ });
1627
+ report = result.report;
1628
+ details = result.details;
1629
+ } finally {
1630
+ updateAuditUi(ctx, undefined);
1631
+ }
1632
+
1633
+ if (!details) return;
1634
+ details.finalGitStatus = await gitStatusShort(pi, ctx.cwd, ctx.signal).catch(() => undefined);
1635
+ report = appendRunBoundary(report, details);
1636
+ details.reportLength = report.length;
1637
+ const parentDetails: AuditDetails = {
1638
+ ...details,
1639
+ toolCallCount: details.toolCalls.length,
1640
+ toolCalls: [],
1641
+ initialGitStatus: undefined,
1642
+ finalGitStatus: undefined,
1643
+ };
1644
+
1645
+ pi.sendMessage({
1646
+ customType: CUSTOM_TYPE,
1647
+ content: report,
1648
+ display: true,
1649
+ details: parentDetails,
1650
+ });
1651
+
1652
+ if (!ctx.hasUI) console.log(report);
1653
+ },
1654
+ });
1266
1655
  }