pi-crew 0.3.4 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -38,6 +38,46 @@ function textOf(result: Awaited<ReturnType<typeof handleTeamTool>>): string {
38
38
  return result.content?.map((item) => item.type === "text" ? item.text : "").join("\n") ?? "";
39
39
  }
40
40
 
41
+ // SECURITY: Strictly enumerate allowed operations per RPC channel.
42
+ // Only read-only operations are permitted via RPC to prevent malicious extensions
43
+ // from mutating run state without user consent.
44
+ const RPC_ALLOWED_OPERATIONS = new Set([
45
+ // Read-only manifest/plan ops
46
+ "metrics-snapshot", "inventory", "read-manifest",
47
+ "list-tasks", "read-task", "read-events",
48
+ // Read-only agent ops
49
+ "list-agents", "read-agent-status", "read-agent-events",
50
+ "read-agent-transcript", "read-agent-output", "agent-dashboard",
51
+ // Mailbox read ops
52
+ "read-mailbox", "read-delivery", "read-heartbeat",
53
+ // No mutating ops (approve-plan, cancel-plan, steer-agent, stop-agent, etc.)
54
+ // — these require explicit intent confirmation and are NOT allowed via RPC.
55
+ ]);
56
+
57
+ function isAllowedRpcOperation(operation: string): boolean {
58
+ return RPC_ALLOWED_OPERATIONS.has(operation);
59
+ }
60
+
61
+ function isAllowedRpcRunParams(params: TeamToolParamsValue): { ok: boolean; error?: string } {
62
+ // SECURITY: Require explicit intent for any RPC-initiated run creation.
63
+ // This prevents malicious extensions from spawning child Pi processes silently.
64
+ const cfg = params.config as Record<string, unknown> | undefined;
65
+ const intent = cfg?.intent as string | undefined;
66
+ if (!intent || typeof intent !== "string" || intent.trim().length === 0) {
67
+ return { ok: false, error: "RPC run requires config.intent (a non-empty intent string)" };
68
+ }
69
+ // SECURITY: Validate cwd is within the project directory if provided.
70
+ if (params.cwd && typeof params.cwd === "string") {
71
+ try {
72
+ const { resolveContainedPath } = require("../utils/safe-paths.ts");
73
+ resolveContainedPath(params.cwd, ".");
74
+ } catch {
75
+ return { ok: false, error: "RPC run config.cwd must be within the project directory" };
76
+ }
77
+ }
78
+ return { ok: true };
79
+ }
80
+
41
81
  function on(events: EventBusLike, channel: string, handler: (raw: unknown) => void): () => void {
42
82
  const unsub = events.on(channel, handler);
43
83
  return typeof unsub === "function" ? unsub : () => {};
@@ -65,6 +105,11 @@ export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () =
65
105
  } else {
66
106
  params = { action: "run" };
67
107
  }
108
+ const permission = isAllowedRpcRunParams(params);
109
+ if (!permission.ok) {
110
+ reply(events, "pi-crew:rpc:run", id, { success: false, error: permission.error ?? "permission denied" });
111
+ return;
112
+ }
68
113
  const result = await handleTeamTool(params, ctx);
69
114
  reply(events, "pi-crew:rpc:run", id, result.isError ? { success: false, error: textOf(result) } : { success: true, data: result.details });
70
115
  } catch (error) {
@@ -87,18 +132,29 @@ export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () =
87
132
  const request = parseLiveControlRealtimeMessage(raw);
88
133
  if (request) publishLiveControlRealtime(request);
89
134
  }),
90
- on(events, "pi-crew:rpc:live-control", async (raw) => {
91
- const id = requestId(raw);
92
- try {
93
- const ctx = getCtx();
94
- if (!ctx) throw new Error("No active pi-crew session context.");
95
- const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
96
- const result = await handleTeamTool({ action: "api", runId: typeof obj.runId === "string" ? obj.runId : undefined, config: { operation: typeof obj.operation === "string" ? obj.operation : "steer-agent", agentId: obj.agentId, message: obj.message, prompt: obj.prompt } }, ctx);
97
- reply(events, "pi-crew:rpc:live-control", id, result.isError ? { success: false, error: textOf(result) } : { success: true, data: { text: textOf(result), details: result.details } });
98
- } catch (error) {
99
- reply(events, "pi-crew:rpc:live-control", id, { success: false, error: error instanceof Error ? error.message : String(error) });
100
- }
101
- }),
135
+ on(events, "pi-crew:rpc:live-control", async (raw) => {
136
+ const id = requestId(raw);
137
+ try {
138
+ const ctx = getCtx();
139
+ if (!ctx) throw new Error("No active pi-crew session context.");
140
+ const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
141
+ const rawOp = typeof obj.operation === "string" ? obj.operation : "steer-agent";
142
+ // SECURITY: Reject any operation not in the explicit allowlist.
143
+ // Mutating ops (approve-plan, cancel-plan, steer-agent, stop-agent, etc.)
144
+ // require user consent and are blocked here.
145
+ if (!isAllowedRpcOperation(rawOp)) {
146
+ reply(events, "pi-crew:rpc:live-control", id, {
147
+ success: false,
148
+ error: `RPC operation '${rawOp}' is not allowed. Allowed: ${[...RPC_ALLOWED_OPERATIONS].join(", ")}`,
149
+ });
150
+ return;
151
+ }
152
+ const result = await handleTeamTool({ action: "api", runId: typeof obj.runId === "string" ? obj.runId : undefined, config: { operation: rawOp, agentId: obj.agentId, message: obj.message, prompt: obj.prompt } }, ctx);
153
+ reply(events, "pi-crew:rpc:live-control", id, result.isError ? { success: false, error: textOf(result) } : { success: true, data: { text: textOf(result), details: result.details } });
154
+ } catch (error) {
155
+ reply(events, "pi-crew:rpc:live-control", id, { success: false, error: error instanceof Error ? error.message : String(error) });
156
+ }
157
+ }),
102
158
  ];
103
159
  return { unsubscribe: () => unsubs.forEach((unsub) => unsub()) };
104
160
  }
@@ -107,10 +107,17 @@ function parseSteps(value: unknown): { steps?: WorkflowStep[]; error?: string }
107
107
  if (id.error) return { error: id.error };
108
108
  const role = requireString(obj.role, `config.steps[${i}].role`);
109
109
  if (role.error) return { error: role.error };
110
+ // SECURITY: Sanitize task field to prevent markdown injection in workflow markdown.
111
+ // Strip characters that could create headings, code blocks, or links.
112
+ // Defense-in-depth — the task field is stored in YAML frontmatter, not executed.
113
+ const rawTask = obj.task;
114
+ const task = typeof rawTask === "string"
115
+ ? rawTask.replace(/^#{1,6}\s+/gm, "").replace(/```/g, "\`\`").replace(/\n{3,}/g, "\n\n").slice(0, 4000)
116
+ : "{goal}";
110
117
  steps.push({
111
118
  id: sanitizeName(id.value!),
112
119
  role: sanitizeName(role.value!),
113
- task: typeof obj.task === "string" ? obj.task : "{goal}",
120
+ task,
114
121
  dependsOn: parseStringArray(obj.dependsOn),
115
122
  parallelGroup: typeof obj.parallelGroup === "string" ? obj.parallelGroup.trim() : undefined,
116
123
  output: obj.output === false ? false : typeof obj.output === "string" ? obj.output.trim() : undefined,
@@ -468,12 +468,13 @@ export function registerPiTeams(pi: ExtensionAPI): void {
468
468
  };
469
469
  while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
470
470
  };
471
- registry.hasRunning = (runId: string) => {
471
+ registry.hasRunning = async (runId: string) => {
472
472
  const manifest = manifestCacheForRegistry.get(runId);
473
473
  if (!manifest) return false;
474
- // LAZY: state-store only needed in hasRunning; avoid at startup.
475
- const { loadRunManifestById } = require("../state/state-store.ts");
476
- const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
474
+ // LAZY: state-store only needed in hasRunning; avoid at startup.
475
+ // Use dynamic import to avoid CJS/ESM mixed module issues.
476
+ const { loadRunManifestById: loadRunForHasRunning } = await import("../state/state-store.ts");
477
+ const loaded = loadRunForHasRunning(currentCtx?.cwd ?? process.cwd(), runId);
477
478
  if (!loaded) return false;
478
479
  return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
479
480
  };
@@ -16,6 +16,10 @@ export interface ImportedRunBundleInfo {
16
16
 
17
17
  function importRoot(cwd: string, scope: "project" | "user"): string {
18
18
  const base = scope === "project" ? projectCrewRoot(cwd) : userCrewRoot();
19
+ // SECURITY NOTE: `DEFAULT_PATHS.state.importsSubdir` is a constant (not user-controlled).
20
+ // If this constant ever becomes user-influenced, this function could become a path
21
+ // traversal risk. Always keep `importsSubdir` as a hardcoded constant. Do NOT accept
22
+ // `importsSubdir` as a parameter or from config.
19
23
  return path.join(base, DEFAULT_PATHS.state.importsSubdir);
20
24
  }
21
25
 
@@ -5,6 +5,11 @@ import { runEventBus } from "../ui/run-event-bus.ts";
5
5
 
6
6
  const registry = new Map<HookName, HookDefinition[]>();
7
7
 
8
+ // SECURITY: Hooks are currently global (registered once, applied to all workspaces).
9
+ // For multi-workspace environments, consider filtering hooks by workspace scope:
10
+ // const workspaceHooks = getHooks(name).filter(h => !h.workspaceId || h.workspaceId === ctx.workspaceId);
11
+ // This prevents globally-registered hooks from operating on runs they weren't designed for.
12
+
8
13
  export function registerHook(definition: HookDefinition): void {
9
14
  const hooks = registry.get(definition.name) ?? [];
10
15
  hooks.push(definition);
@@ -22,15 +27,22 @@ export function getHooks(name: HookName): HookDefinition[] {
22
27
  export async function executeHook(name: HookName, ctx: HookContext): Promise<HookExecutionReport> {
23
28
  const hooks = getHooks(name);
24
29
  if (hooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
30
+ // SECURITY: If ctx contains a workspaceId, filter hooks to only those scoped to
31
+ // this workspace. This prevents globally-registered hooks from operating on runs
32
+ // they weren't designed for.
33
+ const scopedHooks = ctx.workspaceId
34
+ ? hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId)
35
+ : hooks;
36
+ if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
25
37
  const start = Date.now();
26
38
  const diagnostics: string[] = [];
27
39
  let capturedModifications: Record<string, unknown> | undefined;
28
- for (const hook of hooks) {
29
- try {
30
- const result: HookResult = await hook.handler(ctx);
31
- if (hook.mode === "blocking" && result.outcome === "block") {
32
- return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
33
- }
40
+ for (const hook of scopedHooks) {
41
+ try {
42
+ const result: HookResult = await hook.handler(ctx);
43
+ if (hook.mode === "blocking" && result.outcome === "block") {
44
+ return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
45
+ }
34
46
  if (result.outcome === "modify" && result.data) {
35
47
  Object.assign(ctx, result.data);
36
48
  capturedModifications = { ...result.data };
@@ -30,6 +30,9 @@ export interface HookDefinition {
30
30
  name: HookName;
31
31
  mode: HookMode;
32
32
  handler: (ctx: HookContext) => HookResult | Promise<HookResult>;
33
+ // SECURITY: Optional workspace scoping. When set, the hook only executes for
34
+ // runs in the specified workspace. When absent, the hook applies to all runs.
35
+ workspaceId?: string;
33
36
  }
34
37
 
35
38
  export interface HookExecutionReport {
@@ -79,6 +79,10 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
79
79
  const filePath = persistedSubagentPath(cwd, record.id);
80
80
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
81
  fs.writeFileSync(filePath, `${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`, "utf-8");
82
+ // SECURITY: Restrict permissions to owner-only (rw-------).
83
+ // On multi-user systems, other users must not read task prompts,
84
+ // agent descriptions, and run IDs from subagent record files.
85
+ fs.chmodSync(filePath, 0o600);
82
86
  } catch (error) {
83
87
  logInternalError("subagent-manager.save", error, `id=${record.id}`);
84
88
  }
package/src/ui/mascot.ts CHANGED
@@ -122,6 +122,7 @@ export class AnimatedMascot {
122
122
  private gridVersion = 0;
123
123
  private cachedWidth = 0;
124
124
  private cachedVersion = -1;
125
+ private cachedFrame = -1;
125
126
  private cachedLines: string[] = [];
126
127
 
127
128
  constructor(themeLike: unknown, onDone: () => void, options: AnimatedMascotOptions = {}) {
@@ -398,7 +399,7 @@ export class AnimatedMascot {
398
399
  }
399
400
 
400
401
  render(width: number): string[] {
401
- if (width === this.cachedWidth && this.cachedVersion === this.gridVersion && this.cachedLines.length) {
402
+ if (width === this.cachedWidth && this.cachedVersion === this.gridVersion && this.cachedFrame === this.frame && this.cachedLines.length) {
402
403
  return this.cachedLines;
403
404
  }
404
405
  const safeWidth = Math.max(20, width);
@@ -424,6 +425,7 @@ export class AnimatedMascot {
424
425
  result.push(`${this.theme.fg("border", "╰")}${horizontal}${this.theme.fg("border", "╯")}`);
425
426
  this.cachedWidth = safeWidth;
426
427
  this.cachedVersion = this.gridVersion;
428
+ this.cachedFrame = this.frame;
427
429
  this.cachedLines = result;
428
430
  return result;
429
431
  }
@@ -77,18 +77,36 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
77
77
  * @param hookPath - The hook script path to validate
78
78
  * @returns true if the path is allowed, false otherwise
79
79
  */
80
- function isAllowedSetupHook(hookPath: string): boolean {
81
- if (!hookPath || hookPath.trim().length === 0) return false;
82
- if (!path.isAbsolute(hookPath)) {
83
- // Use path.posix.normalize for consistent forward-slash handling on all platforms.
84
- const normalized = path.posix.normalize(hookPath);
85
- return normalized === ".hooks" || normalized.startsWith(".hooks/");
86
- }
87
- // Normalize to forward slashes for consistent cross-platform comparison.
88
- const normalizedHookPath = hookPath.replace(/\\/g, "/");
89
- const homeHooksNormalized = (process.env.HOME ?? "").replace(/\\/g, "/") + "/.pi/hooks";
90
- return normalizedHookPath === homeHooksNormalized || normalizedHookPath.startsWith(homeHooksNormalized + "/");
80
+ function isAllowedSetupHook(hookPath: string): boolean {
81
+ if (!hookPath || hookPath.trim().length === 0) return false;
82
+ if (!path.isAbsolute(hookPath)) {
83
+ // Use path.posix.normalize for consistent forward-slash handling on all platforms.
84
+ const normalized = path.posix.normalize(hookPath);
85
+ return normalized === ".hooks" || normalized.startsWith(".hooks/");
86
+ }
87
+ // Normalize to forward slashes for consistent cross-platform comparison.
88
+ const normalizedHookPath = hookPath.replace(/\\/g, "/");
89
+ const homeHooksNormalized = (process.env.HOME ?? "").replace(/\\/g, "/") + "/.pi/hooks";
90
+ return normalizedHookPath === homeHooksNormalized || normalizedHookPath.startsWith(homeHooksNormalized + "/");
91
+ }
92
+
93
+ /**
94
+ * SECURITY: Verify a hook script path remains within the allowed directory after
95
+ * real-path resolution. This prevents symlink-based escape where repoRoot is a
96
+ * symlink and the hook path would resolve outside the repository.
97
+ * @param repoRoot - The repository root (resolved to real path)
98
+ * @param hookPath - The resolved absolute hook path
99
+ * @returns true if the hook is safely contained within repoRoot
100
+ */
101
+ function isHookPathContainedInRepoRoot(repoRoot: string, hookPath: string): boolean {
102
+ try {
103
+ const realRepoRoot = fs.realpathSync(repoRoot);
104
+ const realHookPath = fs.realpathSync(path.dirname(hookPath));
105
+ return realHookPath.startsWith(realRepoRoot + path.sep) || realHookPath === realRepoRoot;
106
+ } catch {
107
+ return false;
91
108
  }
109
+ }
92
110
 
93
111
  function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot: string, worktreePath: string, branch: string): string[] {
94
112
  const cfg = loadConfig(manifest.cwd).config.worktree;
@@ -99,6 +117,12 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
99
117
  return [];
100
118
  }
101
119
  const hookPath = path.isAbsolute(rawHookPath) ? rawHookPath : path.resolve(repoRoot, rawHookPath);
120
+ // SECURITY: Verify the resolved hook path is contained within the real repoRoot.
121
+ // This prevents symlink-based escape where repoRoot is a symlink.
122
+ if (!path.isAbsolute(rawHookPath) && !isHookPathContainedInRepoRoot(repoRoot, hookPath)) {
123
+ logInternalError("worktree.setupHook.contained", new Error("hook path escapes repoRoot after realpath resolution: " + hookPath), `repoRoot=${repoRoot}`);
124
+ return [];
125
+ }
102
126
  if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) {
103
127
  logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
104
128
  return [];