@mediadatafusion/pi-workflow-suite 0.0.8 → 0.0.10
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 +13 -0
- package/CONTRIBUTING.md +14 -4
- package/README.md +31 -124
- package/SECURITY.md +6 -2
- package/SUPPORT.md +3 -5
- package/VERSION +1 -1
- package/config/prompts/mission-final-validation.md +3 -2
- package/config/prompts/validate-approved-plan.md +4 -3
- package/extensions/subagent/index.ts +69 -3
- package/extensions/subagent/repolock-guard.ts +111 -0
- package/extensions/subagent/runner.ts +51 -3
- package/extensions/workflow-modes.ts +95 -49
- package/extensions/workflow-parsers.ts +2 -1
- package/extensions/workflow-state.ts +2 -1
- package/extensions/workflow-tool-guard.ts +68 -29
- package/extensions/workflow-validation-classifier.ts +5 -2
- package/package.json +8 -4
- package/scripts/install-to-live.sh +2 -1
- package/scripts/prepare-package-readme.mjs +33 -7
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
|
|
6
|
+
|
|
7
|
+
function safeRealpath(path: string): string {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(path);
|
|
10
|
+
} catch {
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveCandidatePath(pathValue: string, cwd: string): string {
|
|
16
|
+
const expanded = pathValue === "~" || pathValue.startsWith("~/") ? resolve(process.env.HOME || cwd, pathValue.slice(2)) : pathValue;
|
|
17
|
+
const resolved = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded || ".");
|
|
18
|
+
if (existsSync(resolved)) return safeRealpath(resolved);
|
|
19
|
+
const existingParent = safeRealpath(resolve(resolved, ".."));
|
|
20
|
+
return resolve(existingParent, resolved.split(/[\\/]/).pop() || "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pathInsideRoot(candidate: string, root: string): boolean {
|
|
24
|
+
return candidate === root || candidate.startsWith(`${root}/`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function protectedRepoPath(candidate: string, root: string): boolean {
|
|
28
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
29
|
+
return rel === ".pi" || rel.startsWith(".pi/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function piRuntimeInstructionPath(candidate: string): boolean {
|
|
33
|
+
const root = safeRealpath(getAgentDir());
|
|
34
|
+
if (!pathInsideRoot(candidate, root)) return false;
|
|
35
|
+
const rel = candidate === root ? "" : candidate.slice(root.length + 1);
|
|
36
|
+
return rel === "skills" || rel.startsWith("skills/")
|
|
37
|
+
|| rel === "agents" || rel.startsWith("agents/")
|
|
38
|
+
|| rel === "config/prompts" || rel.startsWith("config/prompts/")
|
|
39
|
+
|| rel === "prompts" || rel.startsWith("prompts/")
|
|
40
|
+
|| rel === "themes" || rel.startsWith("themes/");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
|
|
44
|
+
if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED !== "1") return undefined;
|
|
45
|
+
const root = safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT || cwd);
|
|
46
|
+
const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
|
|
47
|
+
if (!pathInsideRoot(candidate, root)) {
|
|
48
|
+
if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && piRuntimeInstructionPath(candidate)) return undefined;
|
|
49
|
+
return `Repo Lock blocked sub-agent path outside current repository: ${candidate} (repo root: ${root})`;
|
|
50
|
+
}
|
|
51
|
+
if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return `Repo Lock blocked sub-agent ${tool} for protected project control path: ${candidate}`;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripHereDocBodies(command: string): string {
|
|
56
|
+
const lines = command.split("\n");
|
|
57
|
+
const kept: string[] = [];
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const line = lines[i];
|
|
60
|
+
kept.push(line);
|
|
61
|
+
const match = line.match(/<<[-]?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?/);
|
|
62
|
+
if (!match) continue;
|
|
63
|
+
const marker = match[1];
|
|
64
|
+
i++;
|
|
65
|
+
while (i < lines.length && lines[i].trim() !== marker) i++;
|
|
66
|
+
}
|
|
67
|
+
return kept.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stripUriTokens(command: string): string {
|
|
71
|
+
return command.replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s'"`;&|)]*/g, " ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function bashPathCandidates(command: string): string[] {
|
|
75
|
+
const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
|
|
76
|
+
if (!trimmed) return [];
|
|
77
|
+
return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function repoLockBashBlock(command: string, cwd: string): string | undefined {
|
|
81
|
+
if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED !== "1") return undefined;
|
|
82
|
+
const root = safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT || cwd);
|
|
83
|
+
const candidates = bashPathCandidates(command);
|
|
84
|
+
for (const raw of candidates) {
|
|
85
|
+
if (raw === "." || raw === "./" || raw === "/") continue;
|
|
86
|
+
const cleaned = raw.replace(/[),]+$/, "");
|
|
87
|
+
if (!cleaned || cleaned.startsWith("./node_modules/.bin")) continue;
|
|
88
|
+
const candidate = resolveCandidatePath(cleaned, cwd);
|
|
89
|
+
if (!pathInsideRoot(candidate, root)) return `Repo Lock blocked sub-agent bash path outside current repository: ${cleaned} -> ${candidate} (repo root: ${root})`;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default function repoLockSubagentGuard(pi: ExtensionAPI): void {
|
|
95
|
+
pi.on("tool_call", (event, ctx) => {
|
|
96
|
+
if (PATH_SCOPED_TOOLS.has(event.toolName)) {
|
|
97
|
+
const reason = repoLockPathBlock((event.input as { path?: unknown; file_path?: unknown }).path ?? (event.input as { file_path?: unknown }).file_path, ctx.cwd, event.toolName);
|
|
98
|
+
if (reason) return { block: true, reason };
|
|
99
|
+
}
|
|
100
|
+
if (event.toolName === "bash") {
|
|
101
|
+
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
102
|
+
const reason = repoLockBashBlock(command, ctx.cwd);
|
|
103
|
+
if (reason) return { block: true, reason };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("user_bash", (event, ctx) => {
|
|
108
|
+
const reason = repoLockBashBlock(event.command, ctx.cwd);
|
|
109
|
+
if (reason) return { result: { output: reason, exitCode: 1, cancelled: false, truncated: false } };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* sub-agent policy before the main planner/executor/reviewer/validator turn.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
8
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import type { Message } from "@earendil-works/pi-ai";
|
|
13
13
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { loadWorkflowSettings } from "../workflow-model-router.js";
|
|
14
15
|
import { type AgentConfig, type AgentScope, type AgentSource, discoverAgents } from "./agents.js";
|
|
15
16
|
|
|
16
17
|
export interface WorkflowSubagentTask {
|
|
@@ -60,6 +61,37 @@ export interface WorkflowSubagentRunOptions {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const MAX_CONCURRENCY = 4;
|
|
64
|
+
const REPOLOCK_GUARD_EXTENSION = path.join(path.dirname(new URL(import.meta.url).pathname), "repolock-guard.ts");
|
|
65
|
+
|
|
66
|
+
function safeRealpath(candidate: string): string {
|
|
67
|
+
try {
|
|
68
|
+
return fs.realpathSync(candidate);
|
|
69
|
+
} catch {
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pathInsideRoot(candidate: string, root: string): boolean {
|
|
75
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveSubagentCwd(candidate: string | undefined, defaultCwd: string): string {
|
|
79
|
+
return safeRealpath(path.resolve(defaultCwd, candidate || "."));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function repoRootForCwd(cwd: string): string {
|
|
83
|
+
try {
|
|
84
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
85
|
+
return safeRealpath(root || cwd);
|
|
86
|
+
} catch {
|
|
87
|
+
return safeRealpath(cwd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function repoLockRootForSubagent(defaultCwd: string): string | undefined {
|
|
92
|
+
if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED === "1" && process.env.PI_WORKFLOW_REPO_LOCK_ROOT) return safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT);
|
|
93
|
+
return loadWorkflowSettings(defaultCwd).safety.repoLockEnabled === true ? repoRootForCwd(defaultCwd) : undefined;
|
|
94
|
+
}
|
|
63
95
|
|
|
64
96
|
function finalOutput(messages: Message[]): string {
|
|
65
97
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -132,7 +164,22 @@ async function runSingleWorkflowSubagent(
|
|
|
132
164
|
};
|
|
133
165
|
}
|
|
134
166
|
|
|
135
|
-
const
|
|
167
|
+
const lockRoot = repoLockRootForSubagent(defaultCwd);
|
|
168
|
+
const effectiveCwd = resolveSubagentCwd(task.cwd, defaultCwd);
|
|
169
|
+
if (lockRoot && !pathInsideRoot(effectiveCwd, lockRoot)) {
|
|
170
|
+
return {
|
|
171
|
+
agent: task.agent,
|
|
172
|
+
agentSource: agent.source,
|
|
173
|
+
agentTools: agent.tools,
|
|
174
|
+
task: task.task,
|
|
175
|
+
exitCode: 1,
|
|
176
|
+
output: "",
|
|
177
|
+
stderr: `Repo Lock blocked sub-agent cwd outside current repository: ${effectiveCwd} (repo root: ${lockRoot})`,
|
|
178
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const args: string[] = ["--no-extensions", "--extension", REPOLOCK_GUARD_EXTENSION, "--mode", "json", "-p", "--no-session"];
|
|
136
183
|
if (agent.model) args.push("--model", agent.model);
|
|
137
184
|
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
138
185
|
|
|
@@ -162,13 +209,14 @@ async function runSingleWorkflowSubagent(
|
|
|
162
209
|
const exitCode = await new Promise<number>((resolve) => {
|
|
163
210
|
const invocation = getPiInvocation(args);
|
|
164
211
|
const proc = spawn(invocation.command, invocation.args, {
|
|
165
|
-
cwd:
|
|
212
|
+
cwd: effectiveCwd,
|
|
166
213
|
shell: false,
|
|
167
214
|
stdio: ["ignore", "pipe", "pipe"],
|
|
168
215
|
env: {
|
|
169
216
|
...process.env,
|
|
170
217
|
PI_SUBAGENT_WORKER: "1",
|
|
171
218
|
PI_SUBAGENT_NAME: agent.name,
|
|
219
|
+
...(lockRoot ? { PI_WORKFLOW_REPO_LOCK_ENABLED: "1", PI_WORKFLOW_REPO_LOCK_ROOT: lockRoot } : {}),
|
|
172
220
|
},
|
|
173
221
|
});
|
|
174
222
|
let buffer = "";
|