@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.
@@ -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 args: string[] = ["--no-extensions", "--mode", "json", "-p", "--no-session"];
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: task.cwd ?? defaultCwd,
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 = "";