pi-crew 0.3.5 → 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 +1 -1
- package/src/extension/cross-extension-rpc.ts +68 -12
- package/src/extension/management.ts +8 -1
- package/src/extension/register.ts +5 -4
- package/src/extension/run-import.ts +4 -0
- package/src/hooks/registry.ts +18 -6
- package/src/hooks/types.ts +3 -0
- package/src/runtime/subagent-manager.ts +4 -0
- package/src/worktree/worktree-manager.ts +35 -11
package/package.json
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
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
|
|
package/src/hooks/registry.ts
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 };
|
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 [];
|