@mediadatafusion/pi-workflow-suite 0.0.10 → 0.0.12
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/CHANGELOG.md +67 -0
- package/README.md +146 -20
- package/VERSION +1 -1
- package/agents/codebase-research.md +7 -5
- package/agents/general-worker.md +9 -7
- package/agents/implementation-planning.md +5 -3
- package/agents/quality-validation.md +9 -8
- package/agents/workflow-orchestrator.md +9 -7
- package/config/prompts/execute-approved-plan.md +12 -2
- package/config/prompts/mission-final-validation.md +38 -5
- package/config/prompts/mission-plan.md +17 -1
- package/config/prompts/mission-repair.md +16 -2
- package/config/prompts/mission-review-prompt.md +55 -0
- package/config/prompts/mission-run.md +18 -5
- package/config/prompts/validate-approved-plan.md +57 -3
- package/config/prompts/workflow-plan-prompt.md +11 -1
- package/config/prompts/workflow-repair.md +18 -2
- package/config/prompts/workflow-reviewer-prompt.md +60 -0
- package/config/prompts/workflow-summary.md +1 -4
- package/config/workflow-settings.example.json +13 -11
- package/docs/assets/mediadatafusion-logo.png +0 -0
- package/docs/assets/pi-workflow-suite-demo.gif +0 -0
- package/docs/assets/pi-workflow-suite-demo.mp4 +0 -0
- package/docs/assets/pi-workflow-suite-header.png +0 -0
- package/docs/assets/pi-workflow-suite-video-thumb.png +0 -0
- package/docs/assets/readme-link-commands.svg +10 -0
- package/docs/assets/readme-link-install.svg +10 -0
- package/docs/assets/readme-link-quick-start.svg +10 -0
- package/docs/assets/readme-link-settings.svg +10 -0
- package/docs/assets/screenshots/.gitkeep +1 -0
- package/docs/assets/screenshots/00-mission-home.png +0 -0
- package/docs/assets/screenshots/01-startup-Logo.png +0 -0
- package/docs/assets/screenshots/02-theme-settings.png +0 -0
- package/docs/assets/screenshots/03-GlobalSafetySettings.png +0 -0
- package/docs/assets/screenshots/04-SharedSubAgentsSettings.png +0 -0
- package/docs/assets/screenshots/05-mission-mode.png +0 -0
- package/docs/assets/screenshots/06-diagram-mermaid.png +0 -0
- package/extensions/subagent/index.ts +41 -18
- package/extensions/subagent/repolock-guard.ts +224 -4
- package/extensions/subagent/runner.ts +136 -12
- package/extensions/workflow-model-router.ts +152 -55
- package/extensions/workflow-modes.ts +4784 -1087
- package/extensions/workflow-settings-capabilities.ts +10 -0
- package/extensions/workflow-state.ts +139 -15
- package/extensions/workflow-subagent-policy.ts +13 -1
- package/extensions/workflow-summary.ts +8 -19
- package/extensions/workflow-tool-guard.ts +420 -39
- package/extensions/workflow-validation-classifier.ts +46 -4
- package/extensions/workflow-web-tools.ts +361 -1
- package/package.json +10 -5
- package/scripts/audit-live.sh +1 -1
- package/scripts/build-package-export.mjs +8 -13
- package/scripts/check-clean-release-tree.sh +3 -2
- package/scripts/check-package-media.mjs +78 -0
- package/scripts/install-to-live.sh +2 -0
- package/scripts/package-media-config.mjs +28 -0
- package/scripts/prepare-package-readme.mjs +19 -18
- package/scripts/quarantine-live-junk.sh +1 -1
- package/scripts/verify-live.sh +9 -1
- package/skills/implementation-planning/SKILL.md +1 -1
- package/skills/safe-execution/SKILL.md +1 -1
- package/skills/validation-review/SKILL.md +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, resolve, join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { loadWorkflowSettings } from "./workflow-model-router.js";
|
|
6
|
-
import type
|
|
7
|
+
import { loadMissionState, type WorkflowState } from "./workflow-state.js";
|
|
7
8
|
|
|
8
9
|
export const PLAN_TOOLS = ["read", "grep", "find", "ls"];
|
|
9
10
|
export const WORKFLOW_PROGRESS_TOOL = "workflow_progress";
|
|
@@ -22,9 +23,10 @@ export const EXECUTION_RESULT_TOOLS = [WORKFLOW_EXECUTION_RESULT_TOOL, MISSION_M
|
|
|
22
23
|
export const VALIDATION_RESULT_TOOLS = [WORKFLOW_VALIDATION_RESULT_TOOL];
|
|
23
24
|
export const REPAIR_RESULT_TOOLS = [WORKFLOW_REPAIR_RESULT_TOOL];
|
|
24
25
|
export const STANDARD_RESULT_TOOLS = [STANDARD_HANDOFF_RESULT_TOOL];
|
|
25
|
-
export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash",
|
|
26
|
-
export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
|
|
27
|
-
export const
|
|
26
|
+
export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash", WORKFLOW_DIAGRAM_TOOL];
|
|
27
|
+
export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, WORKFLOW_PROGRESS_TOOL, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
|
|
28
|
+
export const REVIEW_TOOLS = ["read", "grep", "find", "ls", WORKFLOW_DIAGRAM_TOOL, ...REVIEW_RESULT_TOOLS];
|
|
29
|
+
export const VALIDATOR_TOOLS = ["read", "grep", "find", "ls", "bash", WORKFLOW_DIAGRAM_TOOL, ...VALIDATION_RESULT_TOOLS];
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
|
|
@@ -80,17 +82,90 @@ function piRuntimeInstructionPath(candidate: string): boolean {
|
|
|
80
82
|
|| rel === "themes" || rel.startsWith("themes/");
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
function packageInstructionPath(candidate: string): boolean {
|
|
86
|
+
const root = safeRealpath(join(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
87
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
88
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
89
|
+
return rel === "skills" || rel.startsWith("skills/")
|
|
90
|
+
|| rel === "agents" || rel.startsWith("agents/")
|
|
91
|
+
|| rel === "config/prompts" || rel.startsWith("config/prompts/")
|
|
92
|
+
|| rel === "prompts" || rel.startsWith("prompts/")
|
|
93
|
+
|| rel === "themes" || rel.startsWith("themes/");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function piClipboardImageTempFile(candidate: string): boolean {
|
|
97
|
+
const base = candidate.split(/[\\/]/).pop() ?? "";
|
|
98
|
+
return /^pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|jpeg|gif|webp|bmp|tiff|heic)$/i.test(base);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function piCodingAgentPackageRoot(): string | undefined {
|
|
102
|
+
try {
|
|
103
|
+
const resolver = (import.meta as ImportMeta & { resolve?: (specifier: string) => string }).resolve;
|
|
104
|
+
if (!resolver) return undefined;
|
|
105
|
+
const entry = fileURLToPath(resolver("@earendil-works/pi-coding-agent"));
|
|
106
|
+
return safeRealpath(resolve(dirname(entry), ".."));
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function piCodingAgentDocsPath(candidate: string): boolean {
|
|
113
|
+
const root = piCodingAgentPackageRoot();
|
|
114
|
+
if (!root || !pathInsideRoot(candidate, root)) return false;
|
|
115
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
116
|
+
return rel === "docs" || rel.startsWith("docs/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function userInstalledSkillPath(candidate: string): boolean {
|
|
120
|
+
const home = process.env.HOME;
|
|
121
|
+
if (!home) return false;
|
|
122
|
+
const root = safeRealpath(join(home, ".agents", "skills"));
|
|
123
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
124
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
125
|
+
const skillName = rel.split(/[\\/]/)[0];
|
|
126
|
+
return Boolean(skillName && skillName !== "." && skillName !== ".." && !skillName.startsWith("."));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function workflowStateReadPath(candidate: string): boolean {
|
|
130
|
+
const root = safeRealpath(getAgentDir());
|
|
131
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
132
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
133
|
+
return rel === "workflows/active.json"
|
|
134
|
+
|| rel === "workflows/plans/latest.json"
|
|
135
|
+
|| rel === "workflows/missions/latest.json";
|
|
136
|
+
}
|
|
137
|
+
|
|
83
138
|
function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
|
|
84
139
|
const root = repoLockRoot(cwd);
|
|
85
140
|
const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
|
|
86
141
|
if (!pathInsideRoot(candidate, root)) {
|
|
87
|
-
if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && piRuntimeInstructionPath(candidate)) return undefined;
|
|
88
|
-
|
|
142
|
+
if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate) || piCodingAgentDocsPath(candidate) || userInstalledSkillPath(candidate) || workflowStateReadPath(candidate) || piClipboardImageTempFile(candidate))) return undefined;
|
|
143
|
+
if (candidate.startsWith("/private/tmp/") || candidate.startsWith("/tmp/") || candidate.startsWith("/var/tmp/")) return undefined;
|
|
144
|
+
return "Path outside repository";
|
|
89
145
|
}
|
|
90
|
-
if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return
|
|
146
|
+
if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return "Protected path — use settings to disable Repo Lock";
|
|
91
147
|
return undefined;
|
|
92
148
|
}
|
|
93
149
|
|
|
150
|
+
function stripQuotedSlashes(command: string): string {
|
|
151
|
+
return command
|
|
152
|
+
.replace(/'([^']*)'/g, (_full, content: string) => {
|
|
153
|
+
// If content starts with /regex/ or /regex/flags (awk/sed address pattern), mask slashes
|
|
154
|
+
if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return "'" + content.replace(/\//g, " ") + "'";
|
|
155
|
+
// If content starts with /, it's a quoted absolute path — preserve
|
|
156
|
+
if (content.startsWith('/')) return _full;
|
|
157
|
+
// Content contains / but is not a path — mask (sed expression, prose, etc.)
|
|
158
|
+
if (content.includes('/')) return "'" + content.replace(/\//g, " ") + "'";
|
|
159
|
+
return _full;
|
|
160
|
+
})
|
|
161
|
+
.replace(/"([^"]*)"/g, (_full, content: string) => {
|
|
162
|
+
if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return '"' + content.replace(/\//g, " ") + '"';
|
|
163
|
+
if (content.startsWith('/')) return _full;
|
|
164
|
+
if (content.includes('/')) return '"' + content.replace(/\//g, " ") + '"';
|
|
165
|
+
return _full;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
94
169
|
function stripHereDocBodies(command: string): string {
|
|
95
170
|
const lines = command.split("\n");
|
|
96
171
|
const kept: string[] = [];
|
|
@@ -111,20 +186,103 @@ function stripUriTokens(command: string): string {
|
|
|
111
186
|
}
|
|
112
187
|
|
|
113
188
|
function bashPathCandidates(command: string): string[] {
|
|
114
|
-
const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
|
|
189
|
+
const trimmed = stripUriTokens(stripHereDocBodies(stripQuotedSlashes(command))).trim();
|
|
115
190
|
if (!trimmed) return [];
|
|
116
191
|
return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
|
|
117
192
|
}
|
|
118
193
|
|
|
194
|
+
function hasShellControlOperator(command: string): boolean {
|
|
195
|
+
let quote: "'" | '"' | undefined;
|
|
196
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
197
|
+
const char = command[i];
|
|
198
|
+
if (char === "\\") { i += 1; continue; }
|
|
199
|
+
if (quote) {
|
|
200
|
+
if (char === quote) quote = undefined;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
204
|
+
if (char === ";" || char === "|" || char === "&" || char === "<" || char === ">" || char === "\n") return true;
|
|
205
|
+
}
|
|
206
|
+
return quote !== undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function shellWords(command: string): string[] | undefined {
|
|
210
|
+
const words: string[] = [];
|
|
211
|
+
let current = "";
|
|
212
|
+
let quote: "'" | '"' | undefined;
|
|
213
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
214
|
+
const char = command[i];
|
|
215
|
+
if (char === "\\") {
|
|
216
|
+
i += 1;
|
|
217
|
+
current += command[i] ?? "";
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (quote) {
|
|
221
|
+
if (char === quote) quote = undefined;
|
|
222
|
+
else current += char;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
226
|
+
if (/\s/.test(char)) {
|
|
227
|
+
if (current) { words.push(current); current = ""; }
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
current += char;
|
|
231
|
+
}
|
|
232
|
+
if (quote) return undefined;
|
|
233
|
+
if (current) words.push(current);
|
|
234
|
+
return words;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function simpleCpSourceOperands(command: string): Set<string> | undefined {
|
|
238
|
+
if (hasShellControlOperator(command)) return undefined;
|
|
239
|
+
const words = shellWords(command);
|
|
240
|
+
if (!words || words.length < 3) return undefined;
|
|
241
|
+
const commandName = words[0].split(/[\\/]/).pop();
|
|
242
|
+
if (commandName !== "cp") return undefined;
|
|
243
|
+
const operands: string[] = [];
|
|
244
|
+
let endOfOptions = false;
|
|
245
|
+
for (const word of words.slice(1)) {
|
|
246
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
247
|
+
if (!endOfOptions && word.startsWith("-")) continue;
|
|
248
|
+
operands.push(word);
|
|
249
|
+
}
|
|
250
|
+
if (operands.length < 2) return undefined;
|
|
251
|
+
return new Set(operands.slice(0, -1));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function simpleReadOnlyBashAllowed(command: string): boolean {
|
|
255
|
+
if (hasShellControlOperator(command)) return false;
|
|
256
|
+
const words = shellWords(command);
|
|
257
|
+
if (!words?.length) return false;
|
|
258
|
+
const commandName = words[0].split(/[\\/]/).pop();
|
|
259
|
+
if (commandName === "cat" || commandName === "ls" || commandName === "grep" || commandName === "rg") return true;
|
|
260
|
+
if (commandName !== "find") return false;
|
|
261
|
+
return !words.some((word) => word === "-delete" || word === "-exec" || word === "-execdir" || word === "-ok" || word === "-okdir" || word === "-fprint" || word === "-fprintf");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function piCodingAgentDocsBashReadAllowed(command: string): boolean {
|
|
265
|
+
return simpleReadOnlyBashAllowed(command);
|
|
266
|
+
}
|
|
267
|
+
|
|
119
268
|
function repoLockBashBlock(command: string, cwd: string): string | undefined {
|
|
120
269
|
const root = repoLockRoot(cwd);
|
|
121
270
|
const pathCandidates = bashPathCandidates(command);
|
|
271
|
+
const cpSourceOperands = simpleCpSourceOperands(command);
|
|
122
272
|
for (const raw of pathCandidates) {
|
|
123
273
|
if (raw === "." || raw === "./" || raw === "/") continue;
|
|
124
274
|
const cleaned = raw.replace(/[),]+$/, "");
|
|
125
275
|
if (!cleaned || cleaned.startsWith("./node_modules/.bin")) continue;
|
|
276
|
+
if (cleaned.startsWith("/dev/")) continue;
|
|
277
|
+
if (cleaned.startsWith("/tmp/") || cleaned.startsWith("/private/tmp/") || cleaned.startsWith("/var/tmp/")) continue;
|
|
126
278
|
const candidate = resolveCandidatePath(cleaned, cwd);
|
|
127
|
-
if (!pathInsideRoot(candidate, root))
|
|
279
|
+
if (!pathInsideRoot(candidate, root)) {
|
|
280
|
+
if (piCodingAgentDocsPath(candidate) && piCodingAgentDocsBashReadAllowed(command)) continue;
|
|
281
|
+
if (userInstalledSkillPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
|
|
282
|
+
if (workflowStateReadPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
|
|
283
|
+
if (piClipboardImageTempFile(candidate) && cpSourceOperands?.has(cleaned)) continue;
|
|
284
|
+
return "Path outside repository";
|
|
285
|
+
}
|
|
128
286
|
}
|
|
129
287
|
return undefined;
|
|
130
288
|
}
|
|
@@ -147,6 +305,26 @@ const BLOCKED_EXECUTE_BASH: RegExp[] = [
|
|
|
147
305
|
/\bpnpm\s+add\b/i,
|
|
148
306
|
/\byarn\s+add\b/i,
|
|
149
307
|
/\bpip\s+install\b/i,
|
|
308
|
+
/\bpip3?\s+install\b/i,
|
|
309
|
+
/\bbundle\s+install\b/i,
|
|
310
|
+
/\bgem\s+install\b/i,
|
|
311
|
+
/\bcargo\s+install\b/i,
|
|
312
|
+
/\bgo\s+(?:get|install)\b/i,
|
|
313
|
+
/\bdeno\s+(?:install|add|cache)\b/i,
|
|
314
|
+
/\bcomposer\s+(?:install|require|update)\b/i,
|
|
315
|
+
/\bmix\s+(?:deps\.get|deps\.compile)\b/i,
|
|
316
|
+
/\bbrew\s+install\b/i,
|
|
317
|
+
/\bapt\s+(?:install|get\s+install)\b/i,
|
|
318
|
+
/\byum\s+install\b/i,
|
|
319
|
+
/\bdnf\s+install\b/i,
|
|
320
|
+
/\bapk\s+add\b/i,
|
|
321
|
+
/\bnuget\s+install\b/i,
|
|
322
|
+
/\bdotnet\s+(?:add\s+package|tool\s+install|restore)\b/i,
|
|
323
|
+
/\bcabal\s+(?:install|update)\b/i,
|
|
324
|
+
/\bstack\s+(?:install|update)\b/i,
|
|
325
|
+
/\bconan\s+install\b/i,
|
|
326
|
+
/\bvcpkg\s+install\b/i,
|
|
327
|
+
/\bcoursier\s+(?:install|fetch)\b/i,
|
|
150
328
|
/\bcurl\b[^\n]*\|\s*sh\b/i,
|
|
151
329
|
/\bwget\b[^\n]*\|\s*sh\b/i,
|
|
152
330
|
/\bvercel\s+deploy\b/i,
|
|
@@ -164,8 +342,36 @@ function isPlanMode(mode: WorkflowState["mode"]): boolean {
|
|
|
164
342
|
return mode === "awaiting_plan_input" || mode === "awaiting_clarification" || mode === "planning" || mode === "plan_draft" || mode === "plan_approved";
|
|
165
343
|
}
|
|
166
344
|
|
|
345
|
+
function isReviewMode(mode: WorkflowState["mode"]): boolean {
|
|
346
|
+
return mode === "reviewing" || mode === "reviewed";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function missionReviewActive(state: WorkflowState): boolean {
|
|
350
|
+
if (state.mode !== "mission_plan_ready") return false;
|
|
351
|
+
const mission = state.activeMissionId ? loadMissionState(state.activeMissionId) : loadMissionState();
|
|
352
|
+
return mission?.currentStep === "reviewer" && mission.reviewRepairInProgress !== true && mission.lastReviewRepairStatus !== "running";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function missionReviewAlreadyAccepted(state: WorkflowState): boolean {
|
|
356
|
+
if (state.mode !== "mission_plan_ready" || state.lastWorkflowHandoff?.type !== WORKFLOW_REVIEW_RESULT_TOOL) return false;
|
|
357
|
+
const mission = state.activeMissionId ? loadMissionState(state.activeMissionId) : loadMissionState();
|
|
358
|
+
return mission?.currentStep !== "reviewer" && (mission?.reviewerVerdict === "PASS" || mission?.reviewerVerdict === "NOTES");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function initialPlanHandoffAlreadyAccepted(state: WorkflowState): boolean {
|
|
362
|
+
return state.mode === "plan_draft"
|
|
363
|
+
&& state.reviewHandoffSuppression?.kind === "plan_typed_initial_to_approval"
|
|
364
|
+
&& state.lastWorkflowHandoff?.type === WORKFLOW_PLAN_RESULT_TOOL;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function reviewPhaseSubagentCall(input: unknown): boolean {
|
|
368
|
+
if (!input || typeof input !== "object") return false;
|
|
369
|
+
const workflowPhase = String((input as { workflowPhase?: unknown }).workflowPhase ?? "").toLowerCase();
|
|
370
|
+
return workflowPhase === "review";
|
|
371
|
+
}
|
|
372
|
+
|
|
167
373
|
function isValidatorMode(mode: WorkflowState["mode"]): boolean {
|
|
168
|
-
return mode === "
|
|
374
|
+
return mode === "validating" || mode === "revalidating" || mode === "mission_validating" || mode === "mission_revalidating" || mode === "mission_final_validating";
|
|
169
375
|
}
|
|
170
376
|
|
|
171
377
|
function isValidationResultMode(mode: WorkflowState["mode"]): boolean {
|
|
@@ -180,9 +386,17 @@ function isSubagentWorker(): boolean {
|
|
|
180
386
|
return process.env.PI_SUBAGENT_WORKER === "1";
|
|
181
387
|
}
|
|
182
388
|
|
|
389
|
+
const PACKAGE_INSTALL_RE = /\b(?:npm\s+install|pnpm\s+add|yarn\s+add|pip3?\s+install|bundle\s+install|gem\s+install|cargo\s+install|go\s+(?:get|install)|deno\s+(?:install|add|cache)|composer\s+(?:install|require|update)|mix\s+deps\.(?:get|compile)|brew\s+install|apt(?:-get)?\s+install|yum\s+install|dnf\s+install|apk\s+add|nuget\s+install|dotnet\s+(?:add\s+package|tool\s+install|restore)|cabal\s+(?:install|update)|stack\s+(?:install|update)|conan\s+install|vcpkg\s+install|coursier\s+(?:install|fetch))\b/i;
|
|
390
|
+
|
|
391
|
+
function isPackageInstallCommand(command: string): boolean {
|
|
392
|
+
return PACKAGE_INSTALL_RE.test(command);
|
|
393
|
+
}
|
|
394
|
+
|
|
183
395
|
function commandBlocked(command: string, cwd?: string): boolean {
|
|
184
396
|
const settings = loadWorkflowSettings(cwd);
|
|
185
|
-
|
|
397
|
+
if (settings.safety.blockDestructiveCommands === false) return false;
|
|
398
|
+
if (isPackageInstallCommand(command) && settings.safety.allowPackageInstallInExecution !== false) return false;
|
|
399
|
+
return isBlockedExecuteCommand(command);
|
|
186
400
|
}
|
|
187
401
|
|
|
188
402
|
function standardTodoMode(settings: ReturnType<typeof loadWorkflowSettings>): "off" | "manual" | "auto" | "required" {
|
|
@@ -202,17 +416,77 @@ function standardTaskLooksSubstantive(task: string | undefined): boolean {
|
|
|
202
416
|
return text.length >= 8 || text.split(/\s+/).filter(Boolean).length >= 2;
|
|
203
417
|
}
|
|
204
418
|
|
|
205
|
-
function
|
|
419
|
+
function stripTimeoutPrefix(command: string): string {
|
|
420
|
+
return command.replace(/^timeout\s+\d+[smhd]?\s+/, "").trim() || command;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const DESTRUCTIVE_WORD_RE = /\b(?:install|add|update|upgrade|publish|deploy|push|checkout|switch|commit|merge|rebase|stash|tag|apply|am|restore|sed\s+-i|perl\s+-pi|chmod|chown|curl\s.*\|\s*(?:sh|bash)|wget\s.*\|\s*(?:sh|bash))\b/i;
|
|
424
|
+
|
|
425
|
+
const SAFE_READ_ONLY_COMMANDS_RE = /^(?:git\s+(?:status|log|diff|show|branch|rev-parse|ls-files|describe|remote|tag|shortlog|count-objects|blame|name-rev)\b|cat\b|head\b|tail\b|less\b|more\b|wc\b|file\b|stat\b|which\b|where\b|command\s+-v\b|type\b|echo\b|printf\b|printenv\b|env\b|uname\b|date\b|id\b|whoami\b|hostname\b|pwd\b|ls\b|du\b|df\b|diff\b|comm\b|sort\b|uniq\b|cut\b|tr\b|awk\b|jq\b|yq\b|xq\b|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|test|lint|typecheck|type-check|check[\s:]?\w*|dev|start|preview|serve|watch|format|analyze|compile|ci|validate|verify|coverage|bench|benchmark|bundle|pack|dist|static|docs|doc|stylelint|e2e|integration|unit)\b|(?:npm|pnpm|yarn|bun)\s+(?:exec|info|ls|list|query|outdated|why|view|pack\s+--dry-run)\b|npx\s+(?:serve|http-server|lite-server|tsc|vite|eslint|prettier|vitest|jest|mocha|cypress|playwright|webpack|rollup|parcel|turbo|nx|ts-node|tsx|esbuild|swc|babel|stylelint|biome|rome|knip|typedoc|compodoc|angular-cli|react-scripts|next|nuxt|remix|astro|svelte-kit)\b|pnpm\s+(?:exec|dlx)\s+\w+\b|bun\s+(?:test|check|build|run)\b|deno\s+(?:check|test|build|lint|task|info|doc|compile|fmt|eval|cache)\b|cargo\s+(?:build|test|check|clippy|doc|bench|run|metadata|locate-project|tree|version)\b|(?:rustc|rustup)\s+(?:--version|--print|which)\b|go\s+(?:build|test|vet|run|doc|list|mod\s+(?:verify|tidy|graph|download|why))\b|python3?\s+(?:--version|-V|-c\b|-m\s+(?:pytest|unittest|mypy|pylint|flake8|black|isort|ruff|json\.tool|compileall|bandit|pyright|http\.server|html\.parser|html))\b|pip3?\s+(?:list|show|check|debug|index\s+versions)\b|tsc\b|node\s+(?:--version|-v|--check|-c|-e|--eval)\b|make\s+(?:build|test|check|lint|all|verify|docs|format|static|analyze)\b|cmake\s+(?:--build|--version)\b|(?:dotnet|msbuild)\s+(?:build|test|restore|check|format|lint|pack)\b|(?:gradle|\.\/gradlew|gradlew\.bat)\s+(?:build|test|check|compile|lint|dependencies|projects|tasks)\b|mvn\s+(?:compile|test|verify|checkstyle|pmd|versions:display|dependency:tree|dependency:list)\b|(?:swift|swiftc)\s+(?:build|test|package\s+(?:describe|dump-package))\b|(?:bundle|gem)\s+(?:exec|list|check|info|query)\b|rake\s+(?:test|spec|lint|check|notes|stats|about)\b|php\s+(?:--version|-v|-l)\b|(?:php\s+)?artisan\s+(?:--version|route:list|config:show|env)\b|composer\s+(?:validate|check|show|outdated|info|diagnose)\b|mix\s+(?:test|compile|lint|format|docs)\b|bazel\s+(?:build|test|query|cquery|info|version)\b|buck\s+(?:build|test|query|audit)\b|curl\s+(?:-[^\s]*[sSfIv][^\s]*\s+)+(?:https?:\/\/|localhost|\$)|kill\s+\$!\b|kill\s+-0\s+\$\w+\b|wait\s+\$!\b|wait\s+\$\w+\b|sleep\s+[0-9.]+[smhd]?\b|ps\s+(?:aux?|-[a-z]*[eE][a-z]*|-[a-z]*[pP][a-z]*)\b|pgrep\s+-\w+\s+\w+|true\b|false\b|\.\s*\/node_modules\/\.bin\/\S+\b)/i;
|
|
426
|
+
|
|
427
|
+
export function standardSafeReadOnlyBash(command: string): boolean {
|
|
206
428
|
const trimmed = command.trim();
|
|
207
429
|
if (!trimmed || isBlockedExecuteCommand(trimmed)) return false;
|
|
208
|
-
|
|
430
|
+
const cmd = stripTimeoutPrefix(trimmed);
|
|
431
|
+
return SAFE_READ_ONLY_COMMANDS_RE.test(cmd);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function stripSafePreamble(command: string): string {
|
|
435
|
+
return command.replace(/^(?:set\s+[-+][euxo]+(?:\s+[^\n]*)?|export\s+\w+=["']?[^\n"']*["']?|\w+=\S+)\s*\n+/gm, "").trim() || command;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function stripBenignValidationRedirections(command: string): string {
|
|
439
|
+
return command
|
|
440
|
+
.replace(/\s+\d?>&\d\b/g, "")
|
|
441
|
+
.replace(/\s+\d?>\s*(?:\/tmp|\/private\/tmp|\/dev\/null)\/?\S*/g, "")
|
|
442
|
+
.replace(/\s+\d?>>\s*(?:\/tmp|\/private\/tmp|\/dev\/null)\/?\S*/g, "")
|
|
443
|
+
.replace(/\s+&\s*$/g, "")
|
|
444
|
+
.trim();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function validationWriteVectorBlocked(command: string): boolean {
|
|
448
|
+
if (/<<[-~]?\s*['"]?\w+/i.test(command)) return true;
|
|
449
|
+
if (/(?:\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bcat\s*>)/i.test(command)) return true;
|
|
450
|
+
if (/(?:^|\s)(?:>|>>)\s*(?!\/tmp\/|\/private\/tmp\/|\/dev\/null\b|&\d\b)\S+/i.test(command)) return true;
|
|
451
|
+
if (/\b(?:python3?|node|perl|ruby|bash|sh)\s+(?:\/tmp|\/private\/tmp)\/\S+/i.test(command)) return true;
|
|
452
|
+
if (/\bpython3?\s+-c\b[\s\S]*(?:open\s*\([^)]*,\s*['"][wa+]|write\s*\()/i.test(command)) return true;
|
|
453
|
+
if (/\bnode\s+(?:-e|--eval)\b[\s\S]*(?:writeFile|appendFile|rmSync|mkdirSync|renameSync|copyFileSync)/i.test(command)) return true;
|
|
454
|
+
if (/\bperl\s+-e\b[\s\S]*(?:open\s*\([^)]*[>]|print\s+\w+\s+)/i.test(command)) return true;
|
|
455
|
+
if (/\bruby\s+-e\b[\s\S]*(?:File\.(?:write|open|delete|rename)|IO\.write)/i.test(command)) return true;
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function validatorSafeReadOnlySegment(segment: string): boolean {
|
|
460
|
+
const cleaned = stripBenignValidationRedirections(stripTimeoutPrefix(segment.trim()));
|
|
461
|
+
return Boolean(cleaned) && standardSafeReadOnlyBash(cleaned);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function validatorSafeReadOnlyPipeline(command: string): boolean {
|
|
465
|
+
const parts = command.split("|").map((part) => part.trim()).filter(Boolean);
|
|
466
|
+
return parts.length > 1 && parts.every(validatorSafeReadOnlySegment);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function validatorSafeDevServerComposite(command: string): boolean {
|
|
470
|
+
const lines = command.split(/\n+/).map((line) => line.trim()).filter(Boolean);
|
|
471
|
+
if (lines.length < 2) return false;
|
|
472
|
+
const first = lines[0];
|
|
473
|
+
const serverStart = /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|preview|serve)\b|npx\s+(?:vite|next|serve|http-server|lite-server)\b|python3?\s+-m\s+http\.server\b)/i.test(first);
|
|
474
|
+
const safeOutput = /\s(?:>|>>)\s*(?:\/tmp\/|\/private\/tmp\/|\/dev\/null\b)/.test(first) || /(?:^|\s)&\s*$/.test(first);
|
|
475
|
+
if (!serverStart || !safeOutput || !/(?:^|\s)&\s*$/.test(first)) return false;
|
|
476
|
+
return lines.slice(1).every((line) => line.split("&&").map((part) => part.trim()).filter(Boolean).every(validatorSafeReadOnlySegment));
|
|
209
477
|
}
|
|
210
478
|
|
|
211
479
|
function validatorSafeEvidenceBash(command: string): boolean {
|
|
212
480
|
const trimmed = command.trim();
|
|
213
|
-
if (!trimmed
|
|
214
|
-
|
|
215
|
-
|
|
481
|
+
if (!trimmed) return false;
|
|
482
|
+
const cmd = stripSafePreamble(stripTimeoutPrefix(trimmed));
|
|
483
|
+
if (isBlockedExecuteCommand(cmd)) return false;
|
|
484
|
+
if (DESTRUCTIVE_WORD_RE.test(cmd)) return false;
|
|
485
|
+
if (validationWriteVectorBlocked(cmd)) return false;
|
|
486
|
+
if (standardSafeReadOnlyBash(stripBenignValidationRedirections(cmd))) return true;
|
|
487
|
+
if (validatorSafeReadOnlyPipeline(cmd)) return true;
|
|
488
|
+
if (validatorSafeDevServerComposite(cmd)) return true;
|
|
489
|
+
return false;
|
|
216
490
|
}
|
|
217
491
|
|
|
218
492
|
function standardTodoTitleLooksGeneric(title: string): boolean {
|
|
@@ -238,6 +512,43 @@ function standardRequiredTodoMissing(state: WorkflowState, settings: ReturnType<
|
|
|
238
512
|
&& standardTaskLooksSubstantive(task);
|
|
239
513
|
}
|
|
240
514
|
|
|
515
|
+
function planProgressRelevantWorkTool(tool: string, input: unknown): boolean {
|
|
516
|
+
if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls" || tool === "edit" || tool === "write" || tool === "subagent") return true;
|
|
517
|
+
if (tool !== "bash") return false;
|
|
518
|
+
const command = String((input as { command?: unknown } | undefined)?.command ?? "");
|
|
519
|
+
return Boolean(command.trim()) && !standardSafeReadOnlyBash(command);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function currentPlanProgressStepNumber(state: WorkflowState): number | undefined {
|
|
523
|
+
const steps = state.planProgress?.steps ?? [];
|
|
524
|
+
if (!steps.length) return undefined;
|
|
525
|
+
if (steps.every((step) => step.status === "completed" || step.status === "skipped")) return undefined;
|
|
526
|
+
const activeIndex = steps.findIndex((step) => step.status === "active");
|
|
527
|
+
const fallbackIndex = Math.max(0, Math.min(steps.length - 1, Math.floor(state.planProgress?.currentStepIndex ?? 0)));
|
|
528
|
+
return (activeIndex >= 0 ? activeIndex : fallbackIndex) + 1;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function planProgressToolRequiredBlock(state: WorkflowState, tool: string, input: unknown): string | undefined {
|
|
532
|
+
if (!planProgressRelevantWorkTool(tool, input)) return undefined;
|
|
533
|
+
const stalePlanExecutionWait =
|
|
534
|
+
(state.mode === "reviewed" || state.mode === "plan_approved" || state.mode === "validated")
|
|
535
|
+
&& Boolean(state.approvedPlan)
|
|
536
|
+
&& Boolean(state.planProgress?.steps?.length)
|
|
537
|
+
&& state.planProgress?.lifecycleStatus !== "completed";
|
|
538
|
+
if (stalePlanExecutionWait) {
|
|
539
|
+
const stepNumber = currentPlanProgressStepNumber(state);
|
|
540
|
+
if (!stepNumber) return `Plan ${tool} is blocked while the approved Plan is in ${state.mode} with lifecycleStatus=${state.planProgress?.lifecycleStatus ?? "unknown"}. Use the appropriate Plan command to arm a fresh workflow phase before running work tools.`;
|
|
541
|
+
return `Plan execution ${tool} is blocked until the workflow re-enters execution and workflow_progress({ step: ${stepNumber}, status: "active" }) is called for the current approved Plan step. Run /plan continue to arm a fresh executor turn.`;
|
|
542
|
+
}
|
|
543
|
+
const stepNumber = currentPlanProgressStepNumber(state);
|
|
544
|
+
if (!stepNumber) return undefined;
|
|
545
|
+
if (state.mode !== "executing" && state.mode !== "repairing") return undefined;
|
|
546
|
+
// Only the explicit workflow_progress tool call can satisfy this gate.
|
|
547
|
+
// Display/fallback active steps are widget state, not execution proof.
|
|
548
|
+
if (state.planProgressLastToolStatus === "active" && state.planProgressLastToolStep === stepNumber) return undefined;
|
|
549
|
+
return `Plan execution ${tool} is blocked until workflow_progress({ step: ${stepNumber}, status: "active" }) is called for the current approved Plan step.`;
|
|
550
|
+
}
|
|
551
|
+
|
|
241
552
|
export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowState): void {
|
|
242
553
|
pi.on("tool_call", async (event, ctx) => {
|
|
243
554
|
const state = getState();
|
|
@@ -263,51 +574,111 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
|
|
|
263
574
|
}
|
|
264
575
|
|
|
265
576
|
if (isSubagentWorker()) {
|
|
577
|
+
const workerPhase = String(process.env.PI_WORKFLOW_SUBAGENT_PHASE ?? "").toLowerCase();
|
|
578
|
+
const workerReadOnlyPhase = workerPhase === "validation" || workerPhase === "review";
|
|
579
|
+
if (workerReadOnlyPhase && (tool === "edit" || tool === "write")) {
|
|
580
|
+
return { block: true, reason: `${tool} blocked — ${workerPhase} sub-agent workers are read-only` };
|
|
581
|
+
}
|
|
266
582
|
if (tool === "bash") {
|
|
267
583
|
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
268
|
-
if (
|
|
584
|
+
if (workerReadOnlyPhase && !validatorSafeEvidenceBash(command)) return { block: true, reason: `Bash blocked — unsafe command in ${workerPhase} sub-agent worker` };
|
|
585
|
+
if (commandBlocked(command, ctx.cwd)) return { block: true, reason: "Destructive command blocked" };
|
|
269
586
|
}
|
|
270
587
|
return;
|
|
271
588
|
}
|
|
272
589
|
|
|
273
|
-
if (tool === STANDARD_HANDOFF_RESULT_TOOL && state.mode !== "standard") return { block: true, reason: "Standard handoff
|
|
590
|
+
if (tool === STANDARD_HANDOFF_RESULT_TOOL && state.mode !== "standard") return { block: true, reason: "Standard handoff unavailable outside Standard Mode" };
|
|
591
|
+
|
|
592
|
+
if (tool === WORKFLOW_PLAN_RESULT_TOOL && state.mode !== "planning" && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: `${tool} unavailable outside planning phase` };
|
|
593
|
+
if (tool === MISSION_PLAN_RESULT_TOOL && state.mode !== "mission_planning") return { block: true, reason: "mission_plan_result is only for mission planning. Use mission_milestone_result to submit milestone execution checkpoints." };
|
|
594
|
+
const staleAcceptedPlanReviewResult = tool === WORKFLOW_REVIEW_RESULT_TOOL
|
|
595
|
+
&& state.mode !== "reviewing"
|
|
596
|
+
&& !missionReviewActive(state)
|
|
597
|
+
&& (
|
|
598
|
+
state.reviewHandoffSuppression?.kind === "plan_typed_review_to_execution"
|
|
599
|
+
|| (state.lastWorkflowHandoff?.type === WORKFLOW_REVIEW_RESULT_TOOL && (state.mode === "reviewed" || state.reviewRepairInProgress === true || state.lastReviewRepairStatus === "running"))
|
|
600
|
+
);
|
|
601
|
+
const staleAcceptedMissionReviewResult = tool === WORKFLOW_REVIEW_RESULT_TOOL && missionReviewAlreadyAccepted(state);
|
|
602
|
+
if (tool === WORKFLOW_REVIEW_RESULT_TOOL && !staleAcceptedPlanReviewResult && !staleAcceptedMissionReviewResult && state.mode !== "reviewing" && !missionReviewActive(state)) return { block: true, reason: "Review handoff unavailable outside review phase" };
|
|
603
|
+
if (tool === WORKFLOW_EXECUTION_RESULT_TOOL && state.mode !== "executing") return { block: true, reason: "Execution handoff unavailable outside execution phase" };
|
|
604
|
+
if (tool === MISSION_MILESTONE_RESULT_TOOL && state.mode !== "mission_running") return { block: true, reason: "Milestone handoff unavailable outside Mission execution" };
|
|
605
|
+
if (tool === WORKFLOW_VALIDATION_RESULT_TOOL && !isValidationResultMode(state.mode)) return { block: true, reason: "Validation handoff unavailable outside validation phase" };
|
|
606
|
+
if (tool === WORKFLOW_REPAIR_RESULT_TOOL && state.mode !== "repairing" && state.mode !== "mission_repairing") return { block: true, reason: "Repair handoff unavailable outside repair phase" };
|
|
607
|
+
|
|
608
|
+
if (tool === WORKFLOW_PROGRESS_TOOL && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: "Progress tracking unavailable outside Plan execution" };
|
|
609
|
+
|
|
610
|
+
if (initialPlanHandoffAlreadyAccepted(state) && planProgressRelevantWorkTool(tool, event.input)) {
|
|
611
|
+
return { block: true, reason: "Plan already accepted; execution must start from the queued executor or approval path." };
|
|
612
|
+
}
|
|
274
613
|
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (tool ===
|
|
279
|
-
|
|
280
|
-
|
|
614
|
+
if (tool === "subagent" && state.reviewHandoffSuppression?.kind === "plan_typed_review_to_execution" && reviewPhaseSubagentCall(event.input)) {
|
|
615
|
+
return { block: true, reason: "Plan review already accepted; stale Review-phase sub-agent call ignored. Execution has started." };
|
|
616
|
+
}
|
|
617
|
+
if (tool === "subagent" && state.mode === "reviewed" && state.lastWorkflowHandoff?.type === WORKFLOW_REVIEW_RESULT_TOOL) {
|
|
618
|
+
return { block: true, reason: "Plan review already accepted; stale review sub-agent call ignored. Execution remains queued." };
|
|
619
|
+
}
|
|
620
|
+
if (tool === "subagent" && state.reviewRepairInProgress === true && state.lastWorkflowHandoff?.type === WORKFLOW_REVIEW_RESULT_TOOL) {
|
|
621
|
+
return { block: true, reason: "Plan review handoff is already accepted; do not launch more review sub-agents. Workflow Suite will continue from the accepted review state." };
|
|
622
|
+
}
|
|
623
|
+
if (tool === "subagent" && missionReviewAlreadyAccepted(state)) {
|
|
624
|
+
return { block: true, reason: "Mission review already accepted; stale review sub-agent call ignored. Mission approval remains queued." };
|
|
625
|
+
}
|
|
626
|
+
if (tool === "subagent" && state.mode === "mission_plan_ready" && !missionReviewActive(state)) {
|
|
627
|
+
return { block: true, reason: "Mission review is not active; start the reviewer with /mission review or the Mission review menu before launching review sub-agents." };
|
|
628
|
+
}
|
|
281
629
|
|
|
282
630
|
if (tool === "standard_todo") {
|
|
283
|
-
if (state.mode !== "standard") return { block: true, reason: "
|
|
284
|
-
if (state.standardClarificationPending || state.standardClarificationStage === "drafting" || state.standardClarificationStage === "awaiting_answer") return { block: true, reason: "Standard Mode To Do is blocked until the pending Standard clarification is answered." };
|
|
631
|
+
if (state.mode !== "standard") return { block: true, reason: "To Do unavailable outside Standard Mode" };
|
|
285
632
|
}
|
|
286
633
|
|
|
287
634
|
if (state.mode === "standard" && tool !== "standard_todo" && standardRequiredTodoMissing(state, settings)) {
|
|
288
|
-
if (tool === "edit" || tool === "write"
|
|
635
|
+
if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} blocked — To Do required` };
|
|
636
|
+
if (tool === "bash") {
|
|
637
|
+
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
638
|
+
if (!standardSafeReadOnlyBash(command)) return { block: true, reason: "Bash blocked — To Do required" };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (state.mode === "standard" && state.standardClarificationPending) {
|
|
643
|
+
if (tool === "edit" || tool === "write")
|
|
644
|
+
return { block: true, reason: `${tool} blocked — Standard Mode clarification is pending. Answer the clarification questions first.` };
|
|
289
645
|
if (tool === "bash") {
|
|
290
646
|
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
291
|
-
if (!standardSafeReadOnlyBash(command))
|
|
647
|
+
if (!standardSafeReadOnlyBash(command))
|
|
648
|
+
return { block: true, reason: "Bash blocked — Standard Mode clarification is pending. Answer the clarification questions first." };
|
|
292
649
|
}
|
|
293
650
|
}
|
|
294
651
|
|
|
652
|
+
const planProgressBlock = planProgressToolRequiredBlock(state, tool, event.input);
|
|
653
|
+
if (planProgressBlock) return { block: true, reason: planProgressBlock };
|
|
654
|
+
|
|
295
655
|
if (isPlanMode(state.mode)) {
|
|
296
|
-
if (
|
|
297
|
-
if (tool === "
|
|
656
|
+
if (state.mode === "plan_approved" && state.approvedPlan) return;
|
|
657
|
+
if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in Plan mode` };
|
|
658
|
+
if (tool === "bash" && settings.safety.disableBashInPlanMode !== false) return { block: true, reason: "Bash not available in Plan mode" };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (isReviewMode(state.mode)) {
|
|
662
|
+
if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in review mode` };
|
|
663
|
+
if (tool === "bash") return { block: true, reason: "Bash not available in review mode" };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (state.mode === "mission_plan_ready") {
|
|
667
|
+
if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available during mission plan review` };
|
|
668
|
+
if (tool === "bash") return { block: true, reason: "Bash not available during mission plan review" };
|
|
298
669
|
}
|
|
299
670
|
|
|
300
671
|
if (isValidatorMode(state.mode)) {
|
|
301
|
-
if (tool === "edit" || tool === "write") return { block: true, reason:
|
|
302
|
-
if (tool === "bash") {
|
|
672
|
+
if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in validation mode` };
|
|
673
|
+
if (tool === "bash" && settings.safety.disableBashInValidatorMode !== false) {
|
|
303
674
|
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
304
|
-
if (!validatorSafeEvidenceBash(command)) return { block: true, reason:
|
|
675
|
+
if (!validatorSafeEvidenceBash(command)) return { block: true, reason: "Bash blocked — unsafe command in validation mode" };
|
|
305
676
|
}
|
|
306
677
|
}
|
|
307
678
|
|
|
308
|
-
if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode)) && tool === "bash") {
|
|
679
|
+
if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode) || isReviewMode(state.mode)) && tool === "bash") {
|
|
309
680
|
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
310
|
-
if (commandBlocked(command, ctx.cwd)) return { block: true, reason:
|
|
681
|
+
if (commandBlocked(command, ctx.cwd)) return { block: true, reason: "Destructive or out-of-scope command blocked" };
|
|
311
682
|
}
|
|
312
683
|
});
|
|
313
684
|
|
|
@@ -316,7 +687,11 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
|
|
|
316
687
|
const settings = loadWorkflowSettings(ctx.cwd);
|
|
317
688
|
|
|
318
689
|
if (isSubagentWorker()) {
|
|
319
|
-
|
|
690
|
+
const workerPhase = String(process.env.PI_WORKFLOW_SUBAGENT_PHASE ?? "").toLowerCase();
|
|
691
|
+
if ((workerPhase === "validation" || workerPhase === "review") && !validatorSafeEvidenceBash(event.command)) {
|
|
692
|
+
return { result: { output: `Workflow ${workerPhase} sub-agent blocks unsafe user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
|
|
693
|
+
}
|
|
694
|
+
if (commandBlocked(event.command, ctx.cwd)) return { result: { output: "Destructive command blocked", exitCode: 1, cancelled: false, truncated: false } };
|
|
320
695
|
return;
|
|
321
696
|
}
|
|
322
697
|
|
|
@@ -328,11 +703,17 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
|
|
|
328
703
|
if (isPlanMode(state.mode) && settings.safety.disableBashInPlanMode !== false) {
|
|
329
704
|
return { result: { output: `Workflow ${state.mode} blocks user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
|
|
330
705
|
}
|
|
706
|
+
if (isReviewMode(state.mode)) {
|
|
707
|
+
return { result: { output: `Workflow ${state.mode} blocks user bash during read-only review: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
|
|
708
|
+
}
|
|
709
|
+
if (state.mode === "mission_plan_ready") {
|
|
710
|
+
return { result: { output: `Workflow ${state.mode} blocks user bash during mission plan review: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
|
|
711
|
+
}
|
|
331
712
|
if (isValidatorMode(state.mode) && !validatorSafeEvidenceBash(event.command)) {
|
|
332
713
|
return { result: { output: `Workflow ${state.mode} blocks unsafe user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
|
|
333
714
|
}
|
|
334
|
-
if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode)) && commandBlocked(event.command, ctx.cwd)) {
|
|
335
|
-
return { result: { output:
|
|
715
|
+
if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode) || isReviewMode(state.mode)) && commandBlocked(event.command, ctx.cwd)) {
|
|
716
|
+
return { result: { output: "Destructive command blocked", exitCode: 1, cancelled: false, truncated: false } };
|
|
336
717
|
}
|
|
337
718
|
});
|
|
338
719
|
}
|