pi-crew 0.9.18 → 0.9.20
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 +92 -0
- package/dist/build-meta.json +11 -6
- package/dist/index.mjs +75 -3
- package/dist/index.mjs.map +3 -3
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +149 -131
- package/src/extension/rpc-hmac.ts +236 -0
- package/src/extension/team-tool/run.ts +98 -2
- package/src/runtime/child-pi.ts +62 -81
- package/src/runtime/cross-extension-rpc.ts +22 -3
- package/src/schema/team-tool-schema.ts +21 -0
- package/src/tools/safe-bash-extension.ts +2 -2
- package/src/tools/safe-bash.ts +100 -0
- package/src/utils/env-filter.ts +49 -0
- package/workflows/plan-execute.workflow.md +30 -0
- package/docs/A +0 -358
- package/docs/M-A +0 -357
- package/docs/Y +0 -357
- package/docs/a +0 -357
- package/docs/aA +0 -358
- package/docs//303/242mmaAAA/303/242 +0 -357
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
|
|
2
2
|
import { loadConfig } from "../../config/config.ts";
|
|
3
3
|
import { PipelineRunner, type PipelineWorkflow } from "../../runtime/pipeline-runner.ts";
|
|
4
|
+
import { sanitizeTaskText } from "../../runtime/task-packet.ts";
|
|
4
5
|
// Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
|
|
5
6
|
import type { executeTeamRun as ExecuteTeamRunFn } from "../../runtime/team-runner.ts";
|
|
6
7
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
@@ -17,7 +18,7 @@ import { assertCleanLeader, findGitRoot } from "../../worktree/worktree-manager.
|
|
|
17
18
|
const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
|
|
18
19
|
|
|
19
20
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
20
|
-
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
21
|
+
import { resolveContainedPath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
21
22
|
|
|
22
23
|
let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
|
|
23
24
|
async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
|
|
@@ -127,6 +128,76 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
|
|
|
127
128
|
timer.unref();
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Max analysis size in bytes — matches the `maxLength: 100_000` schema cap on
|
|
133
|
+
* the inline `analysis` param, so the file channel can't smuggle in a larger
|
|
134
|
+
* payload than the inline channel allows (prompt-size blowup guard).
|
|
135
|
+
*/
|
|
136
|
+
const MAX_ANALYSIS_BYTES = 100_000;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the analysis channel (round-X Y1): inline `analysis` or `analysisPath` file.
|
|
140
|
+
*
|
|
141
|
+
* Called BEFORE `createRunManifest` so validation failures fail-fast and no orphan
|
|
142
|
+
* run state is left on disk. Sanitizes the parsed content with the same
|
|
143
|
+
* {@link sanitizeTaskText} machinery used in `buildTaskPacket` (SEC-007), since the
|
|
144
|
+
* resulting text is injected into worker prompts via standard sharedReads.
|
|
145
|
+
*
|
|
146
|
+
* Mutual exclusivity mirrors the `budgetTotal`/`budgetUnlimited` pattern in
|
|
147
|
+
* `goal.ts` — a cold-review #2 blocking fix pattern.
|
|
148
|
+
*/
|
|
149
|
+
function resolveAnalysisText(
|
|
150
|
+
params: TeamToolParamsValue,
|
|
151
|
+
cwd: string,
|
|
152
|
+
): { text?: string; error?: string; source: "inline" | "path" | "none" } {
|
|
153
|
+
const hasInline = typeof params.analysis === "string" && params.analysis.length > 0;
|
|
154
|
+
const hasPath = typeof params.analysisPath === "string" && params.analysisPath.length > 0;
|
|
155
|
+
|
|
156
|
+
if (hasInline && hasPath) {
|
|
157
|
+
return {
|
|
158
|
+
error: "`analysis` and `analysisPath` are mutually exclusive. Set exactly one.",
|
|
159
|
+
source: "none",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (!hasInline && !hasPath) return { source: "none" };
|
|
163
|
+
|
|
164
|
+
if (hasPath) {
|
|
165
|
+
let resolved: string;
|
|
166
|
+
try {
|
|
167
|
+
resolved = resolveContainedPath(cwd, params.analysisPath as string);
|
|
168
|
+
} catch {
|
|
169
|
+
return {
|
|
170
|
+
error: `analysisPath must be within project directory: ${params.analysisPath}`,
|
|
171
|
+
source: "none",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (!fs.existsSync(resolved)) {
|
|
175
|
+
return {
|
|
176
|
+
error: `Analysis file not found: ${resolved}`,
|
|
177
|
+
source: "none",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// Size cap BEFORE reading: mirror the inline schema cap (maxLength 100_000)
|
|
181
|
+
// so a large file can't blow up worker prompts via the sharedReads channel.
|
|
182
|
+
const { size } = fs.statSync(resolved);
|
|
183
|
+
if (size > MAX_ANALYSIS_BYTES) {
|
|
184
|
+
return {
|
|
185
|
+
error: `Analysis file too large: ${size} bytes (max ${MAX_ANALYSIS_BYTES}). Trim the analysis or pass a summary inline.`,
|
|
186
|
+
source: "none",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const raw = fs.readFileSync(resolved, "utf-8");
|
|
190
|
+
const sanitized = sanitizeTaskText(raw);
|
|
191
|
+
if (!sanitized) return { source: "none" };
|
|
192
|
+
return { text: sanitized, source: "path" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// hasInline
|
|
196
|
+
const sanitized = sanitizeTaskText(params.analysis as string);
|
|
197
|
+
if (!sanitized) return { source: "none" };
|
|
198
|
+
return { text: sanitized, source: "inline" };
|
|
199
|
+
}
|
|
200
|
+
|
|
130
201
|
export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
131
202
|
// CHAIN DISPATCH: runs before goal validation since a chain has no top-level
|
|
132
203
|
// goal. The injected handleRun reference breaks the run.ts ↔ chain-dispatch.ts
|
|
@@ -234,11 +305,18 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
234
305
|
role: params.role ?? "agent",
|
|
235
306
|
task: "{goal}",
|
|
236
307
|
model: params.model,
|
|
308
|
+
reads: params.analysis || params.analysisPath ? ["analysis.md"] : undefined,
|
|
237
309
|
},
|
|
238
310
|
],
|
|
239
311
|
}
|
|
240
312
|
: workflows.find((item) => item.name === workflowName);
|
|
241
313
|
if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
|
|
314
|
+
|
|
315
|
+
// ANALYSIS CHANNEL (round-X Y1): resolve analysis text from inline or file BEFORE
|
|
316
|
+
// createRunManifest so validation errors fail-fast (no orphan run state).
|
|
317
|
+
const analysisParam = resolveAnalysisText(params, resolvedCtx.cwd);
|
|
318
|
+
if (analysisParam.error) return result(analysisParam.error, { action: "run", status: "error" }, true);
|
|
319
|
+
|
|
242
320
|
// LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { expandParallelResearchWorkflow }` above (issue #28, RFC 17). At call time the module body has fully evaluated, so the dynamic import returns a live binding. Multi-line form breaks scripts/check-lazy-imports.mjs (which does `lines[lineNum - 2]`), so keep destructuring + await import on one line.
|
|
243
321
|
const { expandParallelResearchWorkflow: expandParallelResearch } = await import("../../runtime/parallel-research.ts");
|
|
244
322
|
const workflow = directAgent ? baseWorkflow : expandParallelResearch(baseWorkflow, resolvedCtx.cwd);
|
|
@@ -276,6 +354,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
276
354
|
if (!directAgent && workflow.source === "builtin" && isGoalWrapEnabled(resolvedCtx.cwd, workflow.name)) {
|
|
277
355
|
const decision = shouldGoalWrap(resolvedCtx.cwd, workflow);
|
|
278
356
|
if (decision.enabled) {
|
|
357
|
+
if (analysisParam.text) {
|
|
358
|
+
console.warn(
|
|
359
|
+
`[team-tool.run] analysis param is ignored by goal-wrapped run (workflow=${workflow.name}). The analysis artifact will not be written.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
279
362
|
return await startGoalWrappedRun(params, ctx, workflow, goal);
|
|
280
363
|
}
|
|
281
364
|
// goal-wrap disabled for this workflow — fall through silently to normal
|
|
@@ -364,10 +447,23 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
364
447
|
content: `${goal}\n`,
|
|
365
448
|
producer: "team-tool",
|
|
366
449
|
});
|
|
450
|
+
// ANALYSIS CHANNEL (round-X Y1): if analysis was provided, persist as a shared artifact
|
|
451
|
+
// so workflow steps declaring reads: analysis.md receive it via the standard
|
|
452
|
+
// dependency-context injection (collectDependencyOutputContext → renderDependencyOutputContext).
|
|
453
|
+
const analysisArtifacts = analysisParam.text
|
|
454
|
+
? [
|
|
455
|
+
writeArtifact(paths.artifactsRoot, {
|
|
456
|
+
kind: "prompt",
|
|
457
|
+
relativePath: "shared/analysis.md",
|
|
458
|
+
content: `${analysisParam.text}\n`,
|
|
459
|
+
producer: "team-tool",
|
|
460
|
+
}),
|
|
461
|
+
]
|
|
462
|
+
: [];
|
|
367
463
|
const updatedManifest = {
|
|
368
464
|
...manifest,
|
|
369
465
|
...(skillOverride !== undefined ? { skillOverride } : {}),
|
|
370
|
-
artifacts: [goalArtifact],
|
|
466
|
+
artifacts: [goalArtifact, ...analysisArtifacts],
|
|
371
467
|
summary: "Run manifest created; worker execution is not implemented yet.",
|
|
372
468
|
};
|
|
373
469
|
atomicWriteJson(paths.manifestPath, updatedManifest);
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
|
7
7
|
import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
|
|
8
8
|
import type { WorkerExitStatus } from "../state/types.ts";
|
|
9
9
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
10
|
-
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
10
|
+
import { buildScopedAllowList, sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
11
11
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
12
12
|
import { redactJsonLine, redactSecretString } from "../utils/redaction.ts";
|
|
13
13
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
@@ -254,7 +254,53 @@ export interface ChildPiRunResult {
|
|
|
254
254
|
intermediateFindings?: string;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
|
|
257
|
+
// Base allowlist of non-provider env vars always passed to child workers.
|
|
258
|
+
// Provider API keys are injected dynamically via buildScopedAllowList() only
|
|
259
|
+
// when a model is assigned to the task (per-task key scoping).
|
|
260
|
+
const BASE_ALLOWLIST: string[] = [
|
|
261
|
+
"PATH",
|
|
262
|
+
"HOME",
|
|
263
|
+
"USER",
|
|
264
|
+
"SHELL",
|
|
265
|
+
"TERM",
|
|
266
|
+
"LANG",
|
|
267
|
+
"LC_ALL",
|
|
268
|
+
"LC_COLLATE",
|
|
269
|
+
"LC_CTYPE",
|
|
270
|
+
"LC_MESSAGES",
|
|
271
|
+
"LC_MONETARY",
|
|
272
|
+
"LC_NUMERIC",
|
|
273
|
+
"LC_TIME",
|
|
274
|
+
"XDG_CONFIG_HOME",
|
|
275
|
+
"XDG_DATA_HOME",
|
|
276
|
+
"XDG_CACHE_HOME",
|
|
277
|
+
"XDG_RUNTIME_DIR",
|
|
278
|
+
// Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
|
|
279
|
+
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
280
|
+
"NVM_BIN",
|
|
281
|
+
"NVM_DIR",
|
|
282
|
+
"NVM_INC",
|
|
283
|
+
"NODE_DISABLE_COLORS",
|
|
284
|
+
"NODE_EXTRA_CA_CERTS",
|
|
285
|
+
"NPM_CONFIG_REGISTRY",
|
|
286
|
+
"NPM_CONFIG_USERCONFIG",
|
|
287
|
+
"NPM_CONFIG_GLOBALCONFIG",
|
|
288
|
+
"PI_CREW_DEPTH",
|
|
289
|
+
"PI_CREW_MAX_DEPTH",
|
|
290
|
+
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
291
|
+
"PI_CREW_INHERIT_SKILLS",
|
|
292
|
+
"PI_CREW_KIND",
|
|
293
|
+
"PI_CREW_PARENT_PID",
|
|
294
|
+
"PI_TEAMS_DEPTH",
|
|
295
|
+
"PI_TEAMS_MAX_DEPTH",
|
|
296
|
+
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
297
|
+
"PI_TEAMS_INHERIT_SKILLS",
|
|
298
|
+
"PI_TEAMS_PI_BIN",
|
|
299
|
+
"PI_TEAMS_MOCK_CHILD_PI",
|
|
300
|
+
"PI_CREW_ALLOW_MOCK",
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
|
|
258
304
|
// SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
|
|
259
305
|
// If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
|
|
260
306
|
// could cause the child process to operate in an attacker-controlled directory,
|
|
@@ -284,81 +330,12 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
|
|
|
284
330
|
// IMPORTANT: preserve model provider API keys — they are needed by the child Pi to call the LLM.
|
|
285
331
|
// Also preserve essential non-secret vars (PATH, HOME, USER, etc.) so the child process can function.
|
|
286
332
|
// Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
* authenticate with whichever provider the model routes to. Reducing keys per-child
|
|
294
|
-
* would break multi-provider functionality. Mitigations:
|
|
295
|
-
* - sanitizeEnvSecrets strips all env vars NOT on this list.
|
|
296
|
-
* - Do NOT add wildcards ("*_API_KEY") — only explicit, intended provider keys.
|
|
297
|
-
* - Consider per-task key scoping if the architecture allows it in the future.
|
|
298
|
-
*
|
|
299
|
-
* MAINTENANCE REQUIREMENT: When new secret env vars are added to the Pi ecosystem,
|
|
300
|
-
* they MUST be explicitly added to this allowlist to be passed to child processes.
|
|
301
|
-
* A CI check should fail if a secret-like env var (matching patterns like *_API_KEY,
|
|
302
|
-
* *_TOKEN, *_SECRET) is detected in the codebase but not present in this list.
|
|
303
|
-
*/
|
|
304
|
-
// NOTE: Model provider API keys are NOT needed here — child Pi uses the same
|
|
305
|
-
// config file as parent Pi. Passing keys via env is a security risk.
|
|
306
|
-
"PATH",
|
|
307
|
-
"HOME",
|
|
308
|
-
"USER",
|
|
309
|
-
"SHELL",
|
|
310
|
-
"TERM",
|
|
311
|
-
"LANG",
|
|
312
|
-
// FIX: Replaced broad wildcards (LC_*, XDG_*, NVM_*, NODE_*, npm_*) with
|
|
313
|
-
// specific names. Previously NPM_TOKEN, NODE_ENV=production, NVM_RC_VERSION
|
|
314
|
-
// all leaked through wildcards.
|
|
315
|
-
"LC_ALL",
|
|
316
|
-
"LC_COLLATE",
|
|
317
|
-
"LC_CTYPE",
|
|
318
|
-
"LC_MESSAGES",
|
|
319
|
-
"LC_MONETARY",
|
|
320
|
-
"LC_NUMERIC",
|
|
321
|
-
"LC_TIME",
|
|
322
|
-
"XDG_CONFIG_HOME",
|
|
323
|
-
"XDG_DATA_HOME",
|
|
324
|
-
"XDG_CACHE_HOME",
|
|
325
|
-
"XDG_RUNTIME_DIR",
|
|
326
|
-
// Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
|
|
327
|
-
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
328
|
-
"NVM_BIN",
|
|
329
|
-
"NVM_DIR",
|
|
330
|
-
"NVM_INC",
|
|
331
|
-
// NODE_PATH is intentionally omitted from the allowlist.
|
|
332
|
-
// NODE_PATH can reveal user environment information (e.g., NVM paths under $HOME)
|
|
333
|
-
// and the validation at lines 286-298 only filters to standard system prefixes.
|
|
334
|
-
// Removing it entirely is cleaner than best-effort filtering.
|
|
335
|
-
"NODE_DISABLE_COLORS",
|
|
336
|
-
"NODE_EXTRA_CA_CERTS",
|
|
337
|
-
"NPM_CONFIG_REGISTRY",
|
|
338
|
-
"NPM_CONFIG_USERCONFIG",
|
|
339
|
-
"NPM_CONFIG_GLOBALCONFIG",
|
|
340
|
-
// FIX: Replace PI_CREW_*/PI_TEAMS_* wildcards with explicit list of
|
|
341
|
-
// safe vars. Wildcards are fragile — any new secret var would leak.
|
|
342
|
-
// Only non-secret execution-control vars that children legitimately need.
|
|
343
|
-
"PI_CREW_DEPTH",
|
|
344
|
-
"PI_CREW_MAX_DEPTH",
|
|
345
|
-
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
346
|
-
"PI_CREW_INHERIT_SKILLS",
|
|
347
|
-
// PI_CREW_KIND marks this process as a crew sub-agent (vs the user's main session).
|
|
348
|
-
// doctor --zombies matches it to safely list orphaned sub-agents only.
|
|
349
|
-
"PI_CREW_KIND",
|
|
350
|
-
// PI_CREW_PARENT_PID is needed by child-pi's parent-guard (uses
|
|
351
|
-
// process.kill(pid, 0) liveness check). The PID is not a secret.
|
|
352
|
-
"PI_CREW_PARENT_PID",
|
|
353
|
-
"PI_TEAMS_DEPTH",
|
|
354
|
-
"PI_TEAMS_MAX_DEPTH",
|
|
355
|
-
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
356
|
-
"PI_TEAMS_INHERIT_SKILLS",
|
|
357
|
-
"PI_TEAMS_PI_BIN",
|
|
358
|
-
"PI_TEAMS_MOCK_CHILD_PI",
|
|
359
|
-
"PI_CREW_ALLOW_MOCK",
|
|
360
|
-
],
|
|
361
|
-
});
|
|
333
|
+
//
|
|
334
|
+
// PER-TASK KEY SCOPING: when a model is provided, only the env keys for that
|
|
335
|
+
// provider are injected (via buildScopedAllowList). When no model is given,
|
|
336
|
+
// only BASE_ALLOWLIST system vars pass through — no provider keys leak.
|
|
337
|
+
const allowList = model ? buildScopedAllowList(BASE_ALLOWLIST, [model]) : BASE_ALLOWLIST;
|
|
338
|
+
const filteredEnv = sanitizeEnvSecrets(env, { allowList });
|
|
362
339
|
// FIX: Removed delete workarounds — with explicit allowlist, these vars
|
|
363
340
|
// are no longer auto-leaked. The wildcard approach was fragile.
|
|
364
341
|
|
|
@@ -900,10 +877,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
900
877
|
const child = spawn(
|
|
901
878
|
spawnSpec.command,
|
|
902
879
|
spawnSpec.args,
|
|
903
|
-
buildChildPiSpawnOptions(
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
880
|
+
buildChildPiSpawnOptions(
|
|
881
|
+
input.cwd,
|
|
882
|
+
{
|
|
883
|
+
...process.env,
|
|
884
|
+
...built.env,
|
|
885
|
+
},
|
|
886
|
+
input.model,
|
|
887
|
+
),
|
|
907
888
|
);
|
|
908
889
|
if (child.pid) {
|
|
909
890
|
activeChildProcesses.set(child.pid, child);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
+
import { isHmacEnabled, extractSignaturePayload, verifyRpcSignature } from "../extension/rpc-hmac.ts";
|
|
2
3
|
|
|
3
4
|
export interface EventBus {
|
|
4
5
|
on(event: string, handler: (data: unknown) => void): () => void;
|
|
@@ -47,6 +48,25 @@ function handleRpc<P extends { requestId: string }>(
|
|
|
47
48
|
if (!/^[a-zA-Z0-9_-]+$/.test(params.requestId)) {
|
|
48
49
|
throw new Error("Security: invalid requestId format");
|
|
49
50
|
}
|
|
51
|
+
// SECURITY: HMAC signature verification (when enabled).
|
|
52
|
+
if (isHmacEnabled()) {
|
|
53
|
+
const sigPayload = extractSignaturePayload(params);
|
|
54
|
+
if (!sigPayload) {
|
|
55
|
+
throw new Error("[pi-crew HMAC] Missing HMAC signature in RPC request.");
|
|
56
|
+
}
|
|
57
|
+
// Strip HMAC fields before verification (HMAC was signed over original params)
|
|
58
|
+
const originalParams = { ...params };
|
|
59
|
+
delete (originalParams as Record<string, unknown>).hmacVersion;
|
|
60
|
+
delete (originalParams as Record<string, unknown>).hmacOrigin;
|
|
61
|
+
delete (originalParams as Record<string, unknown>).hmacTimestamp;
|
|
62
|
+
delete (originalParams as Record<string, unknown>).hmacChannel;
|
|
63
|
+
delete (originalParams as Record<string, unknown>).hmacNonce;
|
|
64
|
+
delete (originalParams as Record<string, unknown>).hmacSignature;
|
|
65
|
+
const verification = verifyRpcSignature(sigPayload, originalParams);
|
|
66
|
+
if (!verification.valid) {
|
|
67
|
+
throw new Error(`[pi-crew HMAC] ${verification.reason}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
50
70
|
try {
|
|
51
71
|
const data = await fn(params);
|
|
52
72
|
const reply: { success: true; data?: unknown } = { success: true };
|
|
@@ -72,9 +92,8 @@ export function registerCrewRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
72
92
|
// operations that create or terminate child processes. Any subscriber on
|
|
73
93
|
// the shared event bus can emit these events. In a multi-extension
|
|
74
94
|
// environment, this means a malicious extension could spawn/stop agents.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// still requires event-bus-level origin signing.
|
|
95
|
+
// Mitigations: HMAC origin signing (see rpc-hmac.ts) + per-process token
|
|
96
|
+
// (H-2) + legacy `source` identifier.
|
|
78
97
|
const CREW_RPC_SOURCE = "pi-crew";
|
|
79
98
|
const EXPECTED_TOKEN = getCrewRpcToken();
|
|
80
99
|
|
|
@@ -264,6 +264,19 @@ export const TeamToolParams = Type.Object({
|
|
|
264
264
|
// "description-only schema" strict-provider check.
|
|
265
265
|
Type.Any(),
|
|
266
266
|
),
|
|
267
|
+
analysis: Type.Optional(
|
|
268
|
+
Type.String({
|
|
269
|
+
maxLength: 100_000,
|
|
270
|
+
description:
|
|
271
|
+
"Inline analysis/context notes from the calling session. Persisted to artifacts/{runId}/shared/analysis.md (audit trail) and auto-injected into any workflow step declaring reads: analysis.md (e.g. builtin 'plan-execute'). Mutually exclusive with analysisPath. Ignored by goal-wrapped runs and chain dispatch in v1.",
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
analysisPath: Type.Optional(
|
|
275
|
+
Type.String({
|
|
276
|
+
description:
|
|
277
|
+
"Path to an existing markdown analysis file (resolved within cwd). Copied into shared/analysis.md. Mutually exclusive with analysis.",
|
|
278
|
+
}),
|
|
279
|
+
),
|
|
267
280
|
focus: Type.Optional(
|
|
268
281
|
Type.String({
|
|
269
282
|
description:
|
|
@@ -385,4 +398,12 @@ export interface TeamToolParamsValue {
|
|
|
385
398
|
tokenBudget?: number;
|
|
386
399
|
/** Typed workflow arguments for .dwf.ts scripts, accessible via ctx.args<T>() (round-14 P1-5). */
|
|
387
400
|
args?: unknown;
|
|
401
|
+
/** Inline analysis/context notes from the calling session. Persisted to
|
|
402
|
+
* artifacts/{runId}/shared/analysis.md (audit trail) and auto-injected into
|
|
403
|
+
* any workflow step declaring reads: analysis.md (e.g. builtin 'plan-execute').
|
|
404
|
+
* Mutually exclusive with `analysisPath`. */
|
|
405
|
+
analysis?: string;
|
|
406
|
+
/** Path to an existing analysis file resolved within `cwd`. Copied into
|
|
407
|
+
* shared/analysis.md. Mutually exclusive with `analysis`. */
|
|
408
|
+
analysisPath?: string;
|
|
388
409
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { createBashTool } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type } from "@sinclair/typebox";
|
|
17
|
-
import {
|
|
17
|
+
import { checkCommand } from "./safe-bash.ts";
|
|
18
18
|
|
|
19
19
|
export default function safeBashExtension(pi: ExtensionAPI): void {
|
|
20
20
|
const cwd = process.cwd();
|
|
@@ -35,7 +35,7 @@ export default function safeBashExtension(pi: ExtensionAPI): void {
|
|
|
35
35
|
),
|
|
36
36
|
}),
|
|
37
37
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
38
|
-
const danger =
|
|
38
|
+
const danger = checkCommand(params.command);
|
|
39
39
|
if (danger) {
|
|
40
40
|
return {
|
|
41
41
|
details: {},
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -403,3 +403,103 @@ export const SAFE_BASH_PRESETS = {
|
|
|
403
403
|
allowPatterns: [],
|
|
404
404
|
},
|
|
405
405
|
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* === Whitelist Mode (opt-in, additive) ===
|
|
409
|
+
*
|
|
410
|
+
* A deny-by-default mode that checks the first token of a command against an
|
|
411
|
+
* explicit allowlist of read-only utilities. Enabled via
|
|
412
|
+
* `PI_CREW_SAFE_BASH_MODE=whitelist`. When NOT enabled, the legacy blacklist
|
|
413
|
+
* `isDangerous()` path is used unchanged.
|
|
414
|
+
*/
|
|
415
|
+
|
|
416
|
+
/** Commands permitted under whitelist mode (read-only utilities only). */
|
|
417
|
+
const WHITELISTED_COMMANDS = new Set<string>([
|
|
418
|
+
"ls",
|
|
419
|
+
"cat",
|
|
420
|
+
"head",
|
|
421
|
+
"tail",
|
|
422
|
+
"wc",
|
|
423
|
+
"grep",
|
|
424
|
+
"find",
|
|
425
|
+
"echo",
|
|
426
|
+
"pwd",
|
|
427
|
+
"date",
|
|
428
|
+
"whoami",
|
|
429
|
+
"uname",
|
|
430
|
+
"df",
|
|
431
|
+
"du",
|
|
432
|
+
"file",
|
|
433
|
+
"stat",
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Shell operators/metacharacters that could chain or substitute a command past
|
|
438
|
+
* the first token. Their presence anywhere in the raw command causes the
|
|
439
|
+
* whitelist check to reject, so e.g. `ls; rm file` cannot smuggle `rm` through
|
|
440
|
+
* under a permitted first token.
|
|
441
|
+
*/
|
|
442
|
+
const SHELL_METACHARACTER_RE = /[|;&`()<>]|\$\(/;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Simple shell tokenizer: extract the first token (command name) of a command,
|
|
446
|
+
* respecting single and double quotes. Leading whitespace is skipped. Quote
|
|
447
|
+
* characters are not included in the returned token (`"ls"` → `ls`). An
|
|
448
|
+
* UNMATCHED quote is treated as malformed input and returns an empty string,
|
|
449
|
+
* causing `isAllowedWhitelist()` to reject the command (unmatched quotes can
|
|
450
|
+
* indicate malformed injection attempts).
|
|
451
|
+
*/
|
|
452
|
+
function shellFirstToken(command: string): string {
|
|
453
|
+
let i = 0;
|
|
454
|
+
const len = command.length;
|
|
455
|
+
while (i < len && /\s/.test(command[i])) i++;
|
|
456
|
+
let token = "";
|
|
457
|
+
while (i < len) {
|
|
458
|
+
const ch = command[i];
|
|
459
|
+
if (/\s/.test(ch)) break;
|
|
460
|
+
if (ch === "'" || ch === '"') {
|
|
461
|
+
const quote = ch;
|
|
462
|
+
i++;
|
|
463
|
+
while (i < len && command[i] !== quote) {
|
|
464
|
+
token += command[i];
|
|
465
|
+
i++;
|
|
466
|
+
}
|
|
467
|
+
// Unmatched quote → malformed input, reject by returning empty string.
|
|
468
|
+
if (i >= len) return "";
|
|
469
|
+
i++; // skip closing quote
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
token += ch;
|
|
473
|
+
i++;
|
|
474
|
+
}
|
|
475
|
+
return token;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Whitelist check (deny-by-default). Returns true only when the command's first
|
|
480
|
+
* token is in the allowlist AND no shell operators/metacharacters are present.
|
|
481
|
+
* Quoted first tokens (e.g. `"ls" -la`) are resolved before checking.
|
|
482
|
+
*/
|
|
483
|
+
export function isAllowedWhitelist(command: string): boolean {
|
|
484
|
+
if (command.trim() === "") return false;
|
|
485
|
+
if (SHELL_METACHARACTER_RE.test(command)) return false;
|
|
486
|
+
const firstToken = shellFirstToken(command);
|
|
487
|
+
if (firstToken === "") return false;
|
|
488
|
+
return WHITELISTED_COMMANDS.has(firstToken);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Current safe-bash mode, controlled by the `PI_CREW_SAFE_BASH_MODE` env var. */
|
|
492
|
+
export function getSafeBashMode(): "blacklist" | "whitelist" {
|
|
493
|
+
return process.env.PI_CREW_SAFE_BASH_MODE === "whitelist" ? "whitelist" : "blacklist";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Unified command check that dispatches on the active mode.
|
|
498
|
+
* @returns Error message if the command should be blocked, null if allowed.
|
|
499
|
+
*/
|
|
500
|
+
export function checkCommand(command: string, options: SafeBashOptions = {}): string | null {
|
|
501
|
+
if (getSafeBashMode() === "whitelist") {
|
|
502
|
+
return isAllowedWhitelist(command) ? null : "Command blocked by safe_bash whitelist: command not in allowlist";
|
|
503
|
+
}
|
|
504
|
+
return isDangerous(command, options);
|
|
505
|
+
}
|
package/src/utils/env-filter.ts
CHANGED
|
@@ -21,10 +21,59 @@ const KNOWN_PROVIDER_KEYS = new Set([
|
|
|
21
21
|
"ZERODEV_API_KEY",
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
|
+
// Map from provider prefix (lowercase) to env var names.
|
|
25
|
+
// Used by providerEnvKeys() to scope API keys per task.
|
|
26
|
+
const PROVIDER_ENV_KEY_MAP: Record<string, string[]> = {
|
|
27
|
+
minimax: ["MINIMAX_API_KEY", "MINIMAX_GROUP_ID"],
|
|
28
|
+
openai: ["OPENAI_API_KEY", "OPENAI_ORG_ID"],
|
|
29
|
+
anthropic: ["ANTHROPIC_API_KEY"],
|
|
30
|
+
google: ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY"],
|
|
31
|
+
gemini: ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY"],
|
|
32
|
+
azure: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
|
|
33
|
+
"azure-openai": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
|
|
34
|
+
aws: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
|
35
|
+
bedrock: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
|
36
|
+
zai: ["ZEU_API_KEY"],
|
|
37
|
+
zerodev: ["ZERODEV_API_KEY"],
|
|
38
|
+
};
|
|
39
|
+
|
|
24
40
|
function isKnownProviderKey(key: string): boolean {
|
|
25
41
|
return KNOWN_PROVIDER_KEYS.has(key);
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Extract provider prefix from a model ID string and return the corresponding
|
|
46
|
+
* env var names. Returns empty array for unknown/custom providers or invalid input.
|
|
47
|
+
*
|
|
48
|
+
* @param modelId - Model in "provider/modelId" format (e.g. "openai/gpt-4o")
|
|
49
|
+
* @returns Array of env var names needed for this provider
|
|
50
|
+
*/
|
|
51
|
+
export function providerEnvKeys(modelId: string | undefined): string[] {
|
|
52
|
+
if (!modelId) return [];
|
|
53
|
+
const separatorIndex = modelId.indexOf("/");
|
|
54
|
+
if (separatorIndex <= 0) return [];
|
|
55
|
+
const provider = modelId.substring(0, separatorIndex).toLowerCase();
|
|
56
|
+
return PROVIDER_ENV_KEY_MAP[provider] ?? [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a scoped allowlist that includes system vars + only the provider keys
|
|
61
|
+
* relevant to the assigned model (and its fallback chain).
|
|
62
|
+
*
|
|
63
|
+
* @param baseAllowList - Non-provider system vars to always include
|
|
64
|
+
* @param models - Model IDs to include provider keys for (primary + fallbacks)
|
|
65
|
+
* @returns Combined allowlist with scoped provider keys
|
|
66
|
+
*/
|
|
67
|
+
export function buildScopedAllowList(baseAllowList: string[], models: (string | undefined)[]): string[] {
|
|
68
|
+
const providerKeys = new Set<string>();
|
|
69
|
+
for (const model of models) {
|
|
70
|
+
for (const key of providerEnvKeys(model)) {
|
|
71
|
+
providerKeys.add(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...baseAllowList, ...providerKeys];
|
|
75
|
+
}
|
|
76
|
+
|
|
28
77
|
export interface SanitizeEnvOptions {
|
|
29
78
|
/** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
|
|
30
79
|
allowList?: string[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-execute
|
|
3
|
+
description: Plan and execute a goal already analyzed by the calling session (no explore step)
|
|
4
|
+
topology: sequential
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## plan
|
|
8
|
+
role: planner
|
|
9
|
+
output: plan.md
|
|
10
|
+
reads: analysis.md
|
|
11
|
+
|
|
12
|
+
Create a concise implementation plan for: {goal}
|
|
13
|
+
|
|
14
|
+
The calling session has already analyzed the problem. Its analysis is provided
|
|
15
|
+
as shared context (analysis.md). Build directly on that analysis; only re-verify
|
|
16
|
+
file paths and facts you need. Do not redo full discovery.
|
|
17
|
+
|
|
18
|
+
## execute
|
|
19
|
+
role: executor
|
|
20
|
+
dependsOn: plan
|
|
21
|
+
|
|
22
|
+
Implement the plan for: {goal}
|
|
23
|
+
|
|
24
|
+
## verify
|
|
25
|
+
role: verifier
|
|
26
|
+
dependsOn: execute
|
|
27
|
+
verify: true
|
|
28
|
+
|
|
29
|
+
Verify completion for: {goal}
|
|
30
|
+
Run tests ONCE (cache to .crew/cache/), read changed files from executor context. Cross-reference test output with the changes. Do NOT re-run tests. Give PASS or FAIL with specific test evidence.
|