@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.
- package/LICENSE +2 -0
- package/README.md +17 -12
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +1631 -1009
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +3 -1
- package/extensions/annotate-git-diff/clipboard.ts +109 -100
- package/extensions/annotate-git-diff/git.ts +846 -768
- package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
- package/extensions/annotate-git-diff/index.ts +712 -671
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-git-diff/prompt.ts +45 -45
- package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
- package/extensions/annotate-git-diff/review-server.ts +337 -311
- package/extensions/annotate-git-diff/types.ts +132 -132
- package/extensions/annotate-git-diff/ui.ts +27 -27
- package/extensions/annotate-git-diff/watch.ts +74 -74
- package/extensions/annotate-git-diff/web/app.js +1947 -1884
- package/extensions/annotate-git-diff/web/index.html +5 -6
- package/extensions/annotate-last-message/README.md +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
- package/extensions/annotate-last-message/index.ts +210 -206
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/prompt.ts +87 -66
- package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
- package/extensions/annotate-last-message/session.ts +94 -89
- package/extensions/annotate-last-message/theme.ts +199 -193
- package/extensions/annotate-last-message/types.ts +23 -23
- package/extensions/annotate-last-message/ui.ts +27 -25
- package/extensions/annotate-last-message/web/app.js +4 -7
- package/extensions/annotate-last-message/web/index.html +3 -3
- package/extensions/annotate-last-message/web/md-renderer.js +1 -1
- package/extensions/brrr/README.md +1 -1
- package/extensions/brrr/index.ts +213 -191
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/README.md +5 -79
- package/extensions/claude-fast/index.ts +31 -289
- package/extensions/claude-fast/package.json +2 -3
- package/extensions/code-reviewer/README.md +6 -6
- package/extensions/code-reviewer/index.ts +1825 -1373
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +50 -52
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +313 -305
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +1863 -1737
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +37 -41
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +11 -43
- package/extensions/dynamic-context-pruning/index.ts +2131 -2105
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +52 -14
- package/extensions/fast/index.ts +513 -317
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -1
- package/extensions/git-footer/index.ts +458 -411
- package/extensions/git-footer/package.json +2 -3
- package/extensions/gnosis/index.ts +180 -130
- package/extensions/gnosis/package.json +1 -1
- package/extensions/inline-bash/index.ts +87 -103
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +1877 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +987 -1012
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/notify/index.ts +222 -230
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/README.md +5 -73
- package/extensions/openai-fast/index.ts +32 -272
- package/extensions/openai-fast/package.json +2 -3
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +1740 -1627
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +785 -612
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/permission-gate/powershell-safety.ts +840 -793
- package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
- package/extensions/project-mcp-json/README.md +199 -0
- package/extensions/project-mcp-json/config.ts +564 -0
- package/extensions/project-mcp-json/index.ts +119 -0
- package/extensions/project-mcp-json/package.json +38 -0
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +376 -291
- package/extensions/quiet-tools/package.json +2 -2
- package/extensions/review/README.md +2 -2
- package/extensions/review/index.ts +1745 -1872
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +280 -280
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +6 -4
- package/extensions/triage-comments/index.ts +2620 -1888
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +9 -6
- package/assets/social-preview.png +0 -0
- package/extensions/claude-fast/claude-fast.example.json +0 -4
- 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 {
|
|
7
|
+
import type {
|
|
8
|
+
AgentSession,
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
ExtensionFactory,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
13
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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: {
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
ctx: ExtensionCommandContext,
|
|
123
|
+
message: string,
|
|
124
|
+
type: "info" | "warning" | "error" = "info",
|
|
112
125
|
): void {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
if (ctx.hasUI) {
|
|
127
|
+
ctx.ui.notify(message, type);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
117
130
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
751
|
+
return path.basename(token).toLowerCase();
|
|
456
752
|
}
|
|
457
753
|
|
|
458
754
|
function getBlockedCdReason(_tokens: string[]): string | undefined {
|
|
459
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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(
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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[]): {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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: {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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: {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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: {
|
|
917
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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(
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1545
|
+
pi: ExtensionAPI,
|
|
1546
|
+
ctx: ExtensionCommandContext,
|
|
1547
|
+
options: { focus?: string; planOnly: boolean; initialGitStatus?: string },
|
|
977
1548
|
): Promise<{ report: string; details: AuditDetails }> {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
}
|