@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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -26,6 +26,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
26
26
|
import { type ExtensionAPI, getAgentDir, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { Type } from "typebox";
|
|
28
28
|
import { loadWorkflowSettings } from "../workflow-model-router.js";
|
|
29
|
+
import { trackSubagentPid, untrackSubagentPid } from "./runner.js";
|
|
29
30
|
import { type AgentConfig, type AgentScope, type AgentSource, discoverAgents } from "./agents.js";
|
|
30
31
|
|
|
31
32
|
const requireFromExtension = createRequire(import.meta.url);
|
|
@@ -106,8 +107,8 @@ class SafeContainer {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
const MAX_PARALLEL_TASKS =
|
|
110
|
-
const
|
|
110
|
+
const MAX_PARALLEL_TASKS = 16;
|
|
111
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
111
112
|
const COLLAPSED_ITEM_COUNT = 10;
|
|
112
113
|
const REPOLOCK_GUARD_EXTENSION = path.join(path.dirname(new URL(import.meta.url).pathname), "repolock-guard.ts");
|
|
113
114
|
|
|
@@ -359,6 +360,7 @@ async function runSingleAgent(
|
|
|
359
360
|
agentName: string,
|
|
360
361
|
task: string,
|
|
361
362
|
cwd: string | undefined,
|
|
363
|
+
workflowPhase: string | undefined,
|
|
362
364
|
step: number | undefined,
|
|
363
365
|
signal: AbortSignal | undefined,
|
|
364
366
|
limits: { timeoutMinutes?: number; staleMinutes?: number } | undefined,
|
|
@@ -449,9 +451,11 @@ async function runSingleAgent(
|
|
|
449
451
|
...process.env,
|
|
450
452
|
PI_SUBAGENT_WORKER: "1",
|
|
451
453
|
PI_SUBAGENT_NAME: agent.name,
|
|
454
|
+
...(workflowPhase ? { PI_WORKFLOW_SUBAGENT_PHASE: workflowPhase } : {}),
|
|
452
455
|
...(lockRoot ? { PI_WORKFLOW_REPO_LOCK_ENABLED: "1", PI_WORKFLOW_REPO_LOCK_ROOT: lockRoot } : {}),
|
|
453
456
|
},
|
|
454
457
|
});
|
|
458
|
+
if (proc.pid) trackSubagentPid(proc.pid);
|
|
455
459
|
let buffer = "";
|
|
456
460
|
let lastOutputAt = Date.now();
|
|
457
461
|
let settled = false;
|
|
@@ -460,9 +464,9 @@ async function runSingleAgent(
|
|
|
460
464
|
timeoutReason = reason;
|
|
461
465
|
wasAborted = true;
|
|
462
466
|
currentResult.errorMessage = reason;
|
|
463
|
-
proc.kill("SIGTERM");
|
|
467
|
+
try { process.kill(-proc.pid!, "SIGTERM"); } catch { proc.kill("SIGTERM"); }
|
|
464
468
|
setTimeout(() => {
|
|
465
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
469
|
+
if (!proc.killed) { try { process.kill(-proc.pid!, "SIGKILL"); } catch { proc.kill("SIGKILL"); } }
|
|
466
470
|
}, 5000);
|
|
467
471
|
};
|
|
468
472
|
const timeoutTimer = setTimeout(() => stopProcess(`Sub-agent timed out after ${Math.round(timeoutMs / 60000)} minute(s).`), timeoutMs);
|
|
@@ -520,11 +524,15 @@ async function runSingleAgent(
|
|
|
520
524
|
currentResult.stderr += data.toString();
|
|
521
525
|
});
|
|
522
526
|
|
|
523
|
-
proc.on("close", (code) => {
|
|
527
|
+
proc.on("close", (code) => { if (proc.pid) untrackSubagentPid(proc.pid);
|
|
524
528
|
settled = true;
|
|
525
529
|
clearTimeout(timeoutTimer);
|
|
526
530
|
clearInterval(staleTimer);
|
|
527
531
|
if (buffer.trim()) processLine(buffer);
|
|
532
|
+
// Kill process group to clean up background child processes
|
|
533
|
+
// (dev servers, static servers, tools — any program the sub-agent started).
|
|
534
|
+
// process.kill(-pid) signals the entire process group; works on all Unix.
|
|
535
|
+
try { if (proc.pid) process.kill(-proc.pid, "SIGTERM"); } catch { /* group empty */ }
|
|
528
536
|
resolve(code ?? 0);
|
|
529
537
|
});
|
|
530
538
|
|
|
@@ -598,7 +606,8 @@ const SubagentParams = Type.Object({
|
|
|
598
606
|
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
|
|
599
607
|
),
|
|
600
608
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
601
|
-
})
|
|
609
|
+
concurrency: Type.Optional(Type.Number({ description: "Max concurrent sub-agents for parallel mode. Default: 8.", minimum: 1, maximum: 16 })),
|
|
610
|
+
failFast: Type.Optional(Type.Boolean({ description: "Stop remaining tasks on first failure. Default: false.", default: false })),});
|
|
602
611
|
|
|
603
612
|
export default function (pi: ExtensionAPI) {
|
|
604
613
|
pi.registerTool({
|
|
@@ -713,10 +722,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
713
722
|
if (params.chain && params.chain.length > 0) {
|
|
714
723
|
const results: SingleResult[] = [];
|
|
715
724
|
let previousOutput = "";
|
|
725
|
+
const chainOutputs: Record<string, string> = {};
|
|
716
726
|
|
|
717
727
|
for (let i = 0; i < params.chain.length; i++) {
|
|
718
728
|
const step = params.chain[i];
|
|
719
|
-
|
|
729
|
+
let taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
730
|
+
// Replace {outputs.name} with named outputs from prior steps
|
|
731
|
+
taskWithContext = taskWithContext.replace(/\{outputs\.([^}]+)\}/g, (_match, name: string) => chainOutputs[name.trim()] ?? `{outputs.${name}}`);
|
|
720
732
|
|
|
721
733
|
// Create update callback that includes all previous results
|
|
722
734
|
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
@@ -739,6 +751,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
739
751
|
step.agent,
|
|
740
752
|
taskWithContext,
|
|
741
753
|
step.cwd,
|
|
754
|
+
params.workflowPhase,
|
|
742
755
|
i + 1,
|
|
743
756
|
signal,
|
|
744
757
|
subagentLimits,
|
|
@@ -747,22 +760,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
747
760
|
);
|
|
748
761
|
results.push(result);
|
|
749
762
|
|
|
763
|
+
// ── Chain mode resiliency (#2): continue on individual failure ──
|
|
750
764
|
const isError =
|
|
751
765
|
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
766
|
+
const stepOutput = isError
|
|
767
|
+
? result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(step failed)"
|
|
768
|
+
: getFinalOutput(result.messages);
|
|
769
|
+
previousOutput = stepOutput;
|
|
770
|
+
// Store named output for downstream {outputs.name} references
|
|
771
|
+
const stepAs = (step as Record<string, unknown>).as;
|
|
772
|
+
if (typeof stepAs === "string" && stepAs.trim()) {
|
|
773
|
+
chainOutputs[stepAs.trim()] = stepOutput;
|
|
760
774
|
}
|
|
761
|
-
previousOutput = getFinalOutput(result.messages);
|
|
762
775
|
}
|
|
776
|
+
// Report all results — successes and failures
|
|
777
|
+
const failedSteps = results.filter((r) => r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted");
|
|
778
|
+
const successCount = results.length - failedSteps.length;
|
|
779
|
+
const summaryText = successCount === results.length
|
|
780
|
+
? getFinalOutput(results[results.length - 1].messages) || "(no output)"
|
|
781
|
+
: `${successCount}/${results.length} steps succeeded. Failed: ${failedSteps.map((r, i) => `step ${i + 1} (${r.agent}): ${r.errorMessage || r.stderr || "(no output)"}`).join("; ")}`;
|
|
763
782
|
return {
|
|
764
|
-
content: [{ type: "text", text:
|
|
783
|
+
content: [{ type: "text", text: summaryText }],
|
|
765
784
|
details: makeDetails("chain")(results),
|
|
785
|
+
isError: failedSteps.length > 0 ? true : undefined,
|
|
766
786
|
};
|
|
767
787
|
}
|
|
768
788
|
|
|
@@ -807,13 +827,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
807
827
|
}
|
|
808
828
|
};
|
|
809
829
|
|
|
810
|
-
const
|
|
830
|
+
const concurrency = typeof params.concurrency === "number" && params.concurrency >= 1 ? params.concurrency : DEFAULT_CONCURRENCY;
|
|
831
|
+
const results = await mapWithConcurrencyLimit(params.tasks, concurrency, async (t, index) => {
|
|
811
832
|
const result = await runSingleAgent(
|
|
812
833
|
ctx.cwd,
|
|
813
834
|
agents,
|
|
814
835
|
t.agent,
|
|
815
836
|
t.task,
|
|
816
837
|
t.cwd,
|
|
838
|
+
params.workflowPhase,
|
|
817
839
|
undefined,
|
|
818
840
|
signal,
|
|
819
841
|
subagentLimits,
|
|
@@ -855,6 +877,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
855
877
|
params.agent,
|
|
856
878
|
params.task,
|
|
857
879
|
params.cwd,
|
|
880
|
+
params.workflowPhase,
|
|
858
881
|
undefined,
|
|
859
882
|
signal,
|
|
860
883
|
subagentLimits,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
-
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { isAbsolute, resolve, join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { loadWorkflowSettings } from "../workflow-model-router.js";
|
|
4
6
|
|
|
5
7
|
const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
|
|
6
8
|
|
|
@@ -40,18 +42,151 @@ function piRuntimeInstructionPath(candidate: string): boolean {
|
|
|
40
42
|
|| rel === "themes" || rel.startsWith("themes/");
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
function packageInstructionPath(candidate: string): boolean {
|
|
46
|
+
const root = safeRealpath(join(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
47
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
48
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
49
|
+
return rel === "skills" || rel.startsWith("skills/")
|
|
50
|
+
|| rel === "agents" || rel.startsWith("agents/")
|
|
51
|
+
|| rel === "config/prompts" || rel.startsWith("config/prompts/")
|
|
52
|
+
|| rel === "prompts" || rel.startsWith("prompts/")
|
|
53
|
+
|| rel === "themes" || rel.startsWith("themes/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function piClipboardImageTempFile(candidate: string): boolean {
|
|
57
|
+
const base = candidate.split(/[\\/]/).pop() ?? "";
|
|
58
|
+
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);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function piCodingAgentPackageRoot(): string | undefined {
|
|
62
|
+
try {
|
|
63
|
+
const resolver = (import.meta as ImportMeta & { resolve?: (specifier: string) => string }).resolve;
|
|
64
|
+
if (!resolver) return undefined;
|
|
65
|
+
const entry = fileURLToPath(resolver("@earendil-works/pi-coding-agent"));
|
|
66
|
+
return safeRealpath(resolve(dirname(entry), ".."));
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function piCodingAgentDocsPath(candidate: string): boolean {
|
|
73
|
+
const root = piCodingAgentPackageRoot();
|
|
74
|
+
if (!root || !pathInsideRoot(candidate, root)) return false;
|
|
75
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
76
|
+
return rel === "docs" || rel.startsWith("docs/");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function userInstalledSkillPath(candidate: string): boolean {
|
|
80
|
+
const home = process.env.HOME;
|
|
81
|
+
if (!home) return false;
|
|
82
|
+
const root = safeRealpath(join(home, ".agents", "skills"));
|
|
83
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
84
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
85
|
+
const skillName = rel.split(/[\\/]/)[0];
|
|
86
|
+
return Boolean(skillName && skillName !== "." && skillName !== ".." && !skillName.startsWith("."));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function workflowStateReadPath(candidate: string): boolean {
|
|
90
|
+
const root = safeRealpath(getAgentDir());
|
|
91
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
92
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
93
|
+
return rel === "workflows/active.json"
|
|
94
|
+
|| rel === "workflows/plans/latest.json"
|
|
95
|
+
|| rel === "workflows/missions/latest.json";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BLOCKED_EXECUTE_BASH: RegExp[] = [
|
|
99
|
+
/\brm\s+-[^\n;|&]*r[^\n;|&]*f\b/i,
|
|
100
|
+
/\bsudo\b/i,
|
|
101
|
+
/\bchmod\s+-R\b/i,
|
|
102
|
+
/\bchown\s+-R\b/i,
|
|
103
|
+
/\bgit\s+reset\b/i,
|
|
104
|
+
/\bgit\s+clean\b/i,
|
|
105
|
+
/\bgit\s+push\b/i,
|
|
106
|
+
/\bgit\s+checkout\b/i,
|
|
107
|
+
/\bgit\s+switch\b/i,
|
|
108
|
+
/\bnpm\s+install\b/i,
|
|
109
|
+
/\bpnpm\s+add\b/i,
|
|
110
|
+
/\byarn\s+add\b/i,
|
|
111
|
+
/\bpip\s+install\b/i,
|
|
112
|
+
/\bpip3?\s+install\b/i,
|
|
113
|
+
/\bbundle\s+install\b/i,
|
|
114
|
+
/\bgem\s+install\b/i,
|
|
115
|
+
/\bcargo\s+install\b/i,
|
|
116
|
+
/\bgo\s+(?:get|install)\b/i,
|
|
117
|
+
/\bdeno\s+(?:install|add|cache)\b/i,
|
|
118
|
+
/\bcomposer\s+(?:install|require|update)\b/i,
|
|
119
|
+
/\bmix\s+(?:deps\.get|deps\.compile)\b/i,
|
|
120
|
+
/\bbrew\s+install\b/i,
|
|
121
|
+
/\bapt\s+(?:install|get\s+install)\b/i,
|
|
122
|
+
/\byum\s+install\b/i,
|
|
123
|
+
/\bdnf\s+install\b/i,
|
|
124
|
+
/\bapk\s+add\b/i,
|
|
125
|
+
/\bnuget\s+install\b/i,
|
|
126
|
+
/\bdotnet\s+(?:add\s+package|tool\s+install|restore)\b/i,
|
|
127
|
+
/\bcabal\s+(?:install|update)\b/i,
|
|
128
|
+
/\bstack\s+(?:install|update)\b/i,
|
|
129
|
+
/\bconan\s+install\b/i,
|
|
130
|
+
/\bvcpkg\s+install\b/i,
|
|
131
|
+
/\bcoursier\s+(?:install|fetch)\b/i,
|
|
132
|
+
/\bcurl\b[^\n]*\|\s*sh\b/i,
|
|
133
|
+
/\bwget\b[^\n]*\|\s*sh\b/i,
|
|
134
|
+
/\bvercel\s+deploy\b/i,
|
|
135
|
+
/\bdeploy\b/i,
|
|
136
|
+
/\bsupabase\s+db\s+push\b/i,
|
|
137
|
+
/\bsupabase\s+migration\s+up\b/i,
|
|
138
|
+
/\bmigration\b[^\n]*(run|up|execute)/i,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
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;
|
|
142
|
+
|
|
143
|
+
function isBlockedExecuteCommand(command: string): boolean {
|
|
144
|
+
return BLOCKED_EXECUTE_BASH.some((pattern) => pattern.test(command));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isPackageInstallCommand(command: string): boolean {
|
|
148
|
+
return PACKAGE_INSTALL_RE.test(command);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function commandBlocked(command: string, cwd?: string): boolean {
|
|
152
|
+
const settings = loadWorkflowSettings(cwd);
|
|
153
|
+
if (settings.safety.blockDestructiveCommands === false) return false;
|
|
154
|
+
if (isPackageInstallCommand(command) && settings.safety.allowPackageInstallInExecution !== false) return false;
|
|
155
|
+
return isBlockedExecuteCommand(command);
|
|
156
|
+
}
|
|
157
|
+
|
|
43
158
|
function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
|
|
44
159
|
if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED !== "1") return undefined;
|
|
45
160
|
const root = safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT || cwd);
|
|
46
161
|
const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
|
|
47
162
|
if (!pathInsideRoot(candidate, root)) {
|
|
48
|
-
if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && piRuntimeInstructionPath(candidate)) return undefined;
|
|
163
|
+
if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate) || piCodingAgentDocsPath(candidate) || userInstalledSkillPath(candidate) || workflowStateReadPath(candidate) || piClipboardImageTempFile(candidate))) return undefined;
|
|
164
|
+
if (candidate.startsWith("/private/tmp/") || candidate.startsWith("/tmp/") || candidate.startsWith("/var/tmp/")) return undefined;
|
|
49
165
|
return `Repo Lock blocked sub-agent path outside current repository: ${candidate} (repo root: ${root})`;
|
|
50
166
|
}
|
|
51
167
|
if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return `Repo Lock blocked sub-agent ${tool} for protected project control path: ${candidate}`;
|
|
52
168
|
return undefined;
|
|
53
169
|
}
|
|
54
170
|
|
|
171
|
+
function stripQuotedSlashes(command: string): string {
|
|
172
|
+
return command
|
|
173
|
+
.replace(/'([^']*)'/g, (_full, content: string) => {
|
|
174
|
+
// If content starts with /regex/ or /regex/flags (awk/sed address pattern), mask slashes
|
|
175
|
+
if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return "'" + content.replace(/\//g, " ") + "'";
|
|
176
|
+
// If content starts with /, it's a quoted absolute path — preserve
|
|
177
|
+
if (content.startsWith('/')) return _full;
|
|
178
|
+
// Content contains / but is not a path — mask (sed expression, prose, etc.)
|
|
179
|
+
if (content.includes('/')) return "'" + content.replace(/\//g, " ") + "'";
|
|
180
|
+
return _full;
|
|
181
|
+
})
|
|
182
|
+
.replace(/"([^"]*)"/g, (_full, content: string) => {
|
|
183
|
+
if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return '"' + content.replace(/\//g, " ") + '"';
|
|
184
|
+
if (content.startsWith('/')) return _full;
|
|
185
|
+
if (content.includes('/')) return '"' + content.replace(/\//g, " ") + '"';
|
|
186
|
+
return _full;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
55
190
|
function stripHereDocBodies(command: string): string {
|
|
56
191
|
const lines = command.split("\n");
|
|
57
192
|
const kept: string[] = [];
|
|
@@ -72,21 +207,104 @@ function stripUriTokens(command: string): string {
|
|
|
72
207
|
}
|
|
73
208
|
|
|
74
209
|
function bashPathCandidates(command: string): string[] {
|
|
75
|
-
const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
|
|
210
|
+
const trimmed = stripUriTokens(stripHereDocBodies(stripQuotedSlashes(command))).trim();
|
|
76
211
|
if (!trimmed) return [];
|
|
77
212
|
return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
|
|
78
213
|
}
|
|
79
214
|
|
|
215
|
+
function hasShellControlOperator(command: string): boolean {
|
|
216
|
+
let quote: "'" | '"' | undefined;
|
|
217
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
218
|
+
const char = command[i];
|
|
219
|
+
if (char === "\\") { i += 1; continue; }
|
|
220
|
+
if (quote) {
|
|
221
|
+
if (char === quote) quote = undefined;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
225
|
+
if (char === ";" || char === "|" || char === "&" || char === "<" || char === ">" || char === "\n") return true;
|
|
226
|
+
}
|
|
227
|
+
return quote !== undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function shellWords(command: string): string[] | undefined {
|
|
231
|
+
const words: string[] = [];
|
|
232
|
+
let current = "";
|
|
233
|
+
let quote: "'" | '"' | undefined;
|
|
234
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
235
|
+
const char = command[i];
|
|
236
|
+
if (char === "\\") {
|
|
237
|
+
i += 1;
|
|
238
|
+
current += command[i] ?? "";
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (quote) {
|
|
242
|
+
if (char === quote) quote = undefined;
|
|
243
|
+
else current += char;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
247
|
+
if (/\s/.test(char)) {
|
|
248
|
+
if (current) { words.push(current); current = ""; }
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
current += char;
|
|
252
|
+
}
|
|
253
|
+
if (quote) return undefined;
|
|
254
|
+
if (current) words.push(current);
|
|
255
|
+
return words;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function simpleCpSourceOperands(command: string): Set<string> | undefined {
|
|
259
|
+
if (hasShellControlOperator(command)) return undefined;
|
|
260
|
+
const words = shellWords(command);
|
|
261
|
+
if (!words || words.length < 3) return undefined;
|
|
262
|
+
const commandName = words[0].split(/[\\/]/).pop();
|
|
263
|
+
if (commandName !== "cp") return undefined;
|
|
264
|
+
const operands: string[] = [];
|
|
265
|
+
let endOfOptions = false;
|
|
266
|
+
for (const word of words.slice(1)) {
|
|
267
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
268
|
+
if (!endOfOptions && word.startsWith("-")) continue;
|
|
269
|
+
operands.push(word);
|
|
270
|
+
}
|
|
271
|
+
if (operands.length < 2) return undefined;
|
|
272
|
+
return new Set(operands.slice(0, -1));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function simpleReadOnlyBashAllowed(command: string): boolean {
|
|
276
|
+
if (hasShellControlOperator(command)) return false;
|
|
277
|
+
const words = shellWords(command);
|
|
278
|
+
if (!words?.length) return false;
|
|
279
|
+
const commandName = words[0].split(/[\\/]/).pop();
|
|
280
|
+
if (commandName === "cat" || commandName === "ls" || commandName === "grep" || commandName === "rg") return true;
|
|
281
|
+
if (commandName !== "find") return false;
|
|
282
|
+
return !words.some((word) => word === "-delete" || word === "-exec" || word === "-execdir" || word === "-ok" || word === "-okdir" || word === "-fprint" || word === "-fprintf");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function piCodingAgentDocsBashReadAllowed(command: string): boolean {
|
|
286
|
+
return simpleReadOnlyBashAllowed(command);
|
|
287
|
+
}
|
|
288
|
+
|
|
80
289
|
function repoLockBashBlock(command: string, cwd: string): string | undefined {
|
|
81
290
|
if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED !== "1") return undefined;
|
|
82
291
|
const root = safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT || cwd);
|
|
83
292
|
const candidates = bashPathCandidates(command);
|
|
293
|
+
const cpSourceOperands = simpleCpSourceOperands(command);
|
|
84
294
|
for (const raw of candidates) {
|
|
85
295
|
if (raw === "." || raw === "./" || raw === "/") continue;
|
|
86
296
|
const cleaned = raw.replace(/[),]+$/, "");
|
|
87
297
|
if (!cleaned || cleaned.startsWith("./node_modules/.bin")) continue;
|
|
298
|
+
if (cleaned.startsWith("/dev/")) continue;
|
|
299
|
+
if (cleaned.startsWith("/tmp/") || cleaned.startsWith("/private/tmp/") || cleaned.startsWith("/var/tmp/")) continue;
|
|
88
300
|
const candidate = resolveCandidatePath(cleaned, cwd);
|
|
89
|
-
if (!pathInsideRoot(candidate, root))
|
|
301
|
+
if (!pathInsideRoot(candidate, root)) {
|
|
302
|
+
if (piCodingAgentDocsPath(candidate) && piCodingAgentDocsBashReadAllowed(command)) continue;
|
|
303
|
+
if (userInstalledSkillPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
|
|
304
|
+
if (workflowStateReadPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
|
|
305
|
+
if (piClipboardImageTempFile(candidate) && cpSourceOperands?.has(cleaned)) continue;
|
|
306
|
+
return `Repo Lock blocked sub-agent bash path outside current repository: ${cleaned} -> ${candidate} (repo root: ${root})`;
|
|
307
|
+
}
|
|
90
308
|
}
|
|
91
309
|
return undefined;
|
|
92
310
|
}
|
|
@@ -101,11 +319,13 @@ export default function repoLockSubagentGuard(pi: ExtensionAPI): void {
|
|
|
101
319
|
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
102
320
|
const reason = repoLockBashBlock(command, ctx.cwd);
|
|
103
321
|
if (reason) return { block: true, reason };
|
|
322
|
+
if (commandBlocked(command, ctx.cwd)) return { block: true, reason: "Destructive or out-of-scope command blocked" };
|
|
104
323
|
}
|
|
105
324
|
});
|
|
106
325
|
|
|
107
326
|
pi.on("user_bash", (event, ctx) => {
|
|
108
327
|
const reason = repoLockBashBlock(event.command, ctx.cwd);
|
|
109
328
|
if (reason) return { result: { output: reason, exitCode: 1, cancelled: false, truncated: false } };
|
|
329
|
+
if (commandBlocked(event.command, ctx.cwd)) return { result: { output: "Destructive command blocked", exitCode: 1, cancelled: false, truncated: false } };
|
|
110
330
|
});
|
|
111
331
|
}
|