@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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