pi-crew 0.6.0 → 0.6.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +13 -11
  3. package/package.json +1 -1
  4. package/src/agents/agent-config.ts +2 -1
  5. package/src/benchmark/feedback-loop.ts +4 -2
  6. package/src/extension/cross-extension-rpc.ts +48 -0
  7. package/src/extension/registration/commands.ts +2 -1
  8. package/src/extension/registration/subagent-tools.ts +2 -0
  9. package/src/extension/registration/team-tool.ts +2 -0
  10. package/src/extension/registration/viewers.ts +1 -0
  11. package/src/extension/run-export.ts +16 -1
  12. package/src/extension/run-import.ts +16 -0
  13. package/src/extension/team-tool/anchor.ts +5 -1
  14. package/src/extension/team-tool/api.ts +9 -4
  15. package/src/extension/team-tool/config-patch.ts +15 -1
  16. package/src/extension/team-tool.ts +2 -1
  17. package/src/hooks/registry.ts +9 -1
  18. package/src/hooks/types.ts +3 -3
  19. package/src/i18n.ts +15 -2
  20. package/src/observability/exporters/otlp-exporter.ts +73 -0
  21. package/src/runtime/adaptive-plan.ts +24 -0
  22. package/src/runtime/agent-control.ts +6 -3
  23. package/src/runtime/async-runner.ts +58 -3
  24. package/src/runtime/background-runner.ts +1 -1
  25. package/src/runtime/chain-runner.ts +58 -0
  26. package/src/runtime/child-pi.ts +1 -1
  27. package/src/runtime/crew-agent-records.ts +4 -3
  28. package/src/runtime/cross-extension-rpc.ts +34 -8
  29. package/src/runtime/diagnostic-export.ts +3 -4
  30. package/src/runtime/dynamic-script-runner.ts +7 -7
  31. package/src/runtime/foreground-watchdog.ts +2 -2
  32. package/src/runtime/live-agent-manager.ts +6 -3
  33. package/src/runtime/live-irc.ts +4 -2
  34. package/src/runtime/parallel-utils.ts +2 -1
  35. package/src/runtime/post-checks.ts +10 -3
  36. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  37. package/src/runtime/sandbox.ts +26 -20
  38. package/src/runtime/semaphore.ts +2 -1
  39. package/src/runtime/settings-store.ts +14 -2
  40. package/src/runtime/skill-effectiveness.ts +4 -2
  41. package/src/runtime/skill-instructions.ts +4 -1
  42. package/src/runtime/subagent-manager.ts +20 -2
  43. package/src/runtime/subprocess-tool-registry.ts +2 -2
  44. package/src/runtime/task-packet.ts +13 -1
  45. package/src/runtime/task-runner.ts +9 -0
  46. package/src/runtime/usage-tracker.ts +4 -2
  47. package/src/runtime/verification-gates.ts +36 -9
  48. package/src/state/contracts.ts +2 -1
  49. package/src/state/event-log.ts +16 -5
  50. package/src/state/hook-instinct-bridge.ts +2 -1
  51. package/src/state/locks.ts +9 -2
  52. package/src/state/state-store.ts +4 -2
  53. package/src/state/task-claims.ts +9 -2
  54. package/src/tools/safe-bash.ts +69 -20
  55. package/src/types/new-api-types.ts +10 -5
  56. package/src/ui/keybinding-map.ts +2 -1
  57. package/src/ui/run-action-dispatcher.ts +2 -1
  58. package/src/ui/status-colors.ts +2 -1
  59. package/src/ui/syntax-highlight.ts +2 -1
  60. package/src/ui/tool-render.ts +13 -3
  61. package/src/utils/fs-watch.ts +4 -2
  62. package/src/utils/gh-protocol.ts +2 -1
  63. package/src/utils/safe-paths.ts +6 -0
  64. package/src/worktree/cleanup.ts +8 -5
  65. package/src/worktree/worktree-manager.ts +1 -1
@@ -69,7 +69,8 @@ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentR
69
69
  return updated;
70
70
  }
71
71
 
72
- export function applyLongRunningCheck(
72
+ /** @internal */
73
+ function applyLongRunningCheck(
73
74
  manifest: TeamRunManifest,
74
75
  agent: CrewAgentRecord,
75
76
  config: CrewControlConfig,
@@ -105,7 +106,8 @@ export function applyLongRunningCheck(
105
106
  return updated;
106
107
  }
107
108
 
108
- export function trackConsecutiveToolFailure(
109
+ /** @internal */
110
+ function trackConsecutiveToolFailure(
109
111
  manifest: TeamRunManifest,
110
112
  agent: CrewAgentRecord,
111
113
  toolName: string,
@@ -140,7 +142,8 @@ export function trackConsecutiveToolFailure(
140
142
  return updated;
141
143
  }
142
144
 
143
- export function resetConsecutiveToolFailures(
145
+ /** @internal */
146
+ function resetConsecutiveToolFailures(
144
147
  manifest: TeamRunManifest,
145
148
  agent: CrewAgentRecord,
146
149
  ): void {
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { appendEvent } from "../state/event-log.ts";
8
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
8
9
  import type { TeamRunManifest } from "../state/types.ts";
9
10
 
10
11
 
@@ -131,8 +132,62 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
131
132
  const logPath = path.join(manifest.stateRoot, "background.log");
132
133
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
133
134
 
134
- // NOTE: Do NOT set PI_CREW_PARENT_PID for the background runner.
135
- const { PI_CREW_PARENT_PID: _, ...envWithoutParentPid } = process.env;
135
+ // SECURITY FIX: Use sanitizeEnvSecrets with same allow-list as child-pi.ts
136
+ // to prevent leaking all env vars (including secrets) to detached background runner.
137
+ // Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else.
138
+ const filteredEnv = sanitizeEnvSecrets(process.env, {
139
+ allowList: [
140
+ // Model provider API keys (same as child-pi.ts)
141
+ "MINIMAX_API_KEY",
142
+ "MINIMAX_GROUP_ID",
143
+ "OPENAI_API_KEY",
144
+ "OPENAI_ORG_ID",
145
+ "ANTHROPIC_API_KEY",
146
+ "GOOGLE_API_KEY",
147
+ "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
148
+ "AZURE_OPENAI_API_KEY",
149
+ "AZURE_OPENAI_ENDPOINT",
150
+ "AWS_ACCESS_KEY_ID",
151
+ "AWS_SECRET_ACCESS_KEY",
152
+ "AWS_REGION",
153
+ "ZEU_API_KEY",
154
+ "ZERODEV_API_KEY",
155
+ // Essential non-secret vars
156
+ "PATH",
157
+ "HOME",
158
+ "USER",
159
+ "SHELL",
160
+ "TERM",
161
+ "LANG",
162
+ "LC_ALL",
163
+ "LC_COLLATE",
164
+ "LC_CTYPE",
165
+ "LC_MESSAGES",
166
+ "LC_MONETARY",
167
+ "LC_NUMERIC",
168
+ "LC_TIME",
169
+ "XDG_CONFIG_HOME",
170
+ "XDG_DATA_HOME",
171
+ "XDG_CACHE_HOME",
172
+ "XDG_RUNTIME_DIR",
173
+ "NVM_BIN",
174
+ "NVM_DIR",
175
+ "NVM_INC",
176
+ "NODE_PATH",
177
+ "NODE_DISABLE_COLORS",
178
+ "NODE_EXTRA_CA_CERTS",
179
+ "NPM_CONFIG_REGISTRY",
180
+ "NPM_CONFIG_USERCONFIG",
181
+ "NPM_CONFIG_GLOBALCONFIG",
182
+ "PI_*",
183
+ "PI_CREW_*",
184
+ "PI_TEAMS_*",
185
+ ],
186
+ });
187
+ // Block execution control vars from leaking
188
+ delete filteredEnv.PI_CREW_PARENT_PID;
189
+ delete filteredEnv.PI_CREW_EXECUTE_WORKERS;
190
+ delete filteredEnv.PI_TEAMS_EXECUTE_WORKERS;
136
191
 
137
192
  const loader = resolveTypeScriptLoader();
138
193
  if (!loader) {
@@ -159,7 +214,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
159
214
  detached: true,
160
215
  setsid: true,
161
216
  stdio: ["ignore", "pipe", "pipe"],
162
- env: envWithoutParentPid,
217
+ env: filteredEnv,
163
218
  windowsHide: true,
164
219
  } as unknown as Parameters<typeof spawn>[2];
165
220
  const child = spawn(process.execPath, command.args, spawnOpts);
@@ -138,7 +138,7 @@ async function main(): Promise<void> {
138
138
  try {
139
139
  const logPath = path.join(_cwd, ".crew/state/runs", _runId, "background.log");
140
140
  logFd = fs.openSync(logPath, "a");
141
- const origWrite = (prefix: string) => (data: any, ...args: any[]) => {
141
+ const origWrite = (_prefix: string) => (data: unknown, ...args: unknown[]) => {
142
142
  const msg = [data, ...args].map(String).join(" ") + "\n";
143
143
  fs.writeSync(logFd!, msg);
144
144
  };
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
14
+ import { parseChainDSL } from "./chain-parser.ts";
15
+ import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
14
16
 
15
17
  /**
16
18
  * Single step in a chain.
@@ -123,10 +125,25 @@ export class ChainRunner {
123
125
  * parseChain('"Research AI trends" -> "Analyze findings"')
124
126
  * parseChain("@step1 --model claude-opus-3 -> @step2")
125
127
  *
128
+ * Also supports DSL syntax from chain-parser for advanced constructs:
129
+ * parseChain("step1 -> parallel(step2, step3) -> step4")
130
+ * parseChain("step1:3 -> step2 --with-context -> step3")
131
+ *
126
132
  * @param chainString - The chain string to parse
127
133
  * @returns Parsed chain specification
128
134
  */
129
135
  parseChain(chainString: string): ChainSpec {
136
+ // Try DSL parser first for advanced syntax (parallel groups, loop counts, flags)
137
+ // Falls back to the simple split parser if DSL parsing fails
138
+ if (this.hasDSLConstructs(chainString)) {
139
+ try {
140
+ const dslSteps = parseChainDSL(chainString);
141
+ return this.dslToChainSpec(dslSteps, chainString);
142
+ } catch {
143
+ // DSL parse failed; fall through to simple parser
144
+ }
145
+ }
146
+
130
147
  const stepStrings = chainString.split("->").map(s => s.trim());
131
148
 
132
149
  const steps: ChainStep[] = stepStrings.map((step, index) => {
@@ -337,6 +354,47 @@ export class ChainRunner {
337
354
  return parsed;
338
355
  }
339
356
 
357
+ /**
358
+ * Detect if chainString uses DSL constructs that require chain-parser.
359
+ * DSL features: parallel(...), :loopCount, --with-context flag
360
+ */
361
+ private hasDSLConstructs(chainString: string): boolean {
362
+ return /\bparallel\s*\(/.test(chainString) ||
363
+ /\w+:\d+\b/.test(chainString) ||
364
+ /--with-context/.test(chainString);
365
+ }
366
+
367
+ /**
368
+ * Convert DSL AST steps (from chain-parser) to ChainSpec.
369
+ */
370
+ private dslToChainSpec(dslSteps: DSLChainStep[], chainString: string): ChainSpec {
371
+ const steps: ChainStep[] = dslSteps.map((dslStep, index) => {
372
+ // For parallel groups, use a synthetic step name
373
+ if (dslStep.parallel) {
374
+ return {
375
+ name: dslStep.name,
376
+ context: {
377
+ parallel: dslStep.parallel.map(p => ({ name: p.name, loopCount: p.loopCount, withContext: p.withContext, args: p.args })),
378
+ },
379
+ loopCount: dslStep.loopCount,
380
+ };
381
+ }
382
+ const step: ChainStep = { name: dslStep.name };
383
+ if (dslStep.loopCount) step.context = { ...step.context, loopCount: dslStep.loopCount };
384
+ if (dslStep.withContext) step.context = { ...step.context, withContext: true };
385
+ if (dslStep.args && dslStep.args.length > 0) step.context = { ...step.context, args: dslStep.args };
386
+ return step;
387
+ });
388
+
389
+ // Extract global overrides using existing logic
390
+ const globalModel = this.extractGlobalFlag(chainString, "global-model");
391
+ const globalSkill = this.extractGlobalFlag(chainString, "global-skill");
392
+ const globalThinking = this.extractGlobalFlag(chainString, "global-thinking") as "fast" | "standard" | "deep" | undefined;
393
+ const continueOnError = this.extractGlobalFlag(chainString, "continue-on-error") === "true";
394
+
395
+ return { steps, globalModel, globalSkill, globalThinking, continueOnError };
396
+ }
397
+
340
398
  /**
341
399
  * Sanitize identifier to prevent injection.
342
400
  */
@@ -424,7 +424,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
424
424
  return { exitCode: 1, stdout: "", stderr: "Mock mode requires PI_CREW_ALLOW_MOCK=1" };
425
425
  }
426
426
  // SECURITY: Log mock mode activation prominently for audit trail
427
- console.warn(`Mock mode active: ${mock} NOT running real agents!`);
427
+ logInternalError("child-pi.mock", new Error(`Mock mode active: ${mock}`), "NOT running real agents");
428
428
  if (mock === "success") {
429
429
  const stdout = `[MOCK] Success for ${input.agent.name}\n`;
430
430
  observeStdoutChunk(input, stdout);
@@ -249,8 +249,8 @@ export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record:
249
249
  atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS);
250
250
  }
251
251
 
252
- /** Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
253
- export function flushPendingAgentWrites(): void {
252
+ /** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
253
+ function flushPendingAgentWrites(): void {
254
254
  flushPendingAtomicWrites();
255
255
  }
256
256
 
@@ -353,7 +353,8 @@ export interface CrewAgentEventCursorOptions {
353
353
  limit?: number;
354
354
  }
355
355
 
356
- export function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] {
356
+ /** @internal Convenience wrapper around readCrewAgentEventsCursor. */
357
+ function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] {
357
358
  return readCrewAgentEventsCursor(manifest, taskId).events;
358
359
  }
359
360
 
@@ -29,15 +29,19 @@ function handleRpc<P extends { requestId: string }>(
29
29
  ): () => void {
30
30
  return events.on(channel, async (raw: unknown) => {
31
31
  const params = raw as P;
32
+ // SECURITY: Validate requestId format to prevent channel injection.
33
+ if (!/^[a-zA-Z0-9_-]+$/.test(params.requestId)) {
34
+ throw new Error("Security: invalid requestId format");
35
+ }
32
36
  try {
33
37
  const data = await fn(params);
34
38
  const reply: { success: true; data?: unknown } = { success: true };
35
39
  if (data !== undefined) reply.data = data;
36
40
  events.emit(`${channel}:reply:${params.requestId}`, reply);
37
- } catch (err: any) {
41
+ } catch (err: unknown) {
38
42
  events.emit(`${channel}:reply:${params.requestId}`, {
39
43
  success: false,
40
- error: err?.message ?? String(err),
44
+ error: err instanceof Error ? err.message : String(err),
41
45
  });
42
46
  }
43
47
  });
@@ -50,21 +54,43 @@ export function registerCrewRpcHandlers(deps: RpcDeps): RpcHandle {
50
54
  return { version: PROTOCOL_VERSION };
51
55
  });
52
56
 
53
- const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: Record<string, unknown> }>(
57
+ // SECURITY TRUST BOUNDARY: crew:rpc:spawn and crew:rpc:stop are privileged
58
+ // operations that create or terminate child processes. Any subscriber on
59
+ // the shared event bus can emit these events. In a multi-extension
60
+ // environment, this means a malicious extension could spawn/stop agents.
61
+ // Mitigation: validate that the caller is the pi-crew extension by checking
62
+ // the request includes a known extension identifier. Log all invocations
63
+ // for audit. A full fix requires event-bus-level origin signing.
64
+ const CREW_RPC_SOURCE = "pi-crew";
65
+
66
+ function validateRpcSource(params: { requestId: string; source?: string }): boolean {
67
+ if (!params.source || params.source !== CREW_RPC_SOURCE) {
68
+ console.warn(
69
+ `[pi-crew SECURITY] RPC invocation from unexpected source: ${params.source ?? "(none)"}. ` +
70
+ `Expected '${CREW_RPC_SOURCE}'. Request may be from an untrusted extension.`,
71
+ );
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: Record<string, unknown>; source?: string }>(
54
78
  events,
55
79
  "crew:rpc:spawn",
56
- ({ type, prompt, options }) => {
80
+ (params) => {
81
+ if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC spawn requires source='pi-crew'");
57
82
  const ctx = getCtx();
58
83
  if (!ctx) throw new Error("No active session");
59
- return { id: spawn(type, prompt, options ?? {}) };
84
+ return { id: spawn(params.type, params.prompt, params.options ?? {}) };
60
85
  },
61
86
  );
62
87
 
63
- const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
88
+ const unsubStop = handleRpc<{ requestId: string; agentId: string; source?: string }>(
64
89
  events,
65
90
  "crew:rpc:stop",
66
- ({ agentId }) => {
67
- if (!abort(agentId)) throw new Error("Agent not found");
91
+ (params) => {
92
+ if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC stop requires source='pi-crew'");
93
+ if (!abort(params.agentId)) throw new Error("Agent not found");
68
94
  },
69
95
  );
70
96
 
@@ -9,9 +9,9 @@ import { loadRunManifestById } from "../state/state-store.ts";
9
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
10
10
  import { summarizeHeartbeats, type HeartbeatSummary } from "../ui/heartbeat-aggregator.ts";
11
11
  import type { RunUiSnapshot } from "../ui/snapshot-types.ts";
12
- import { redactSecrets } from "../utils/redaction.ts";
12
+ import { redactSecrets, isSecretKey } from "../utils/redaction.ts";
13
13
  import { buildRecoveryLedger, type RecoveryLedgerEntry } from "./recovery-recipes.ts";
14
- export { redactSecrets } from "../utils/redaction.ts";
14
+ export { redactSecrets, isSecretKey } from "../utils/redaction.ts";
15
15
 
16
16
  export interface DiagnosticReport {
17
17
  schemaVersion?: number;
@@ -37,13 +37,12 @@ export interface DiagnosticReport {
37
37
  recoveryLedger: RecoveryLedgerEntry[];
38
38
  }
39
39
 
40
- const SECRET_KEY_PATTERN = /(token|key|password|secret|credential|auth)/i;
41
40
  const ENV_DEBUG_ALLOWLIST = /^(PI_CREW_|PI_TEAMS_|PI_.*HOME|NODE_ENV|NODE_VERSION|OS|PROCESSOR|TERM|LANG|HOME|USERPROFILE|APPDATA|PLATFORM|ARCH|WIN32|DOCKER|CI|VERBOSE|DEBUG|NO_COLOR|FORCE_COLOR|NPM_CONFIG|npm_)/i;
42
41
 
43
42
  function envRedacted(): Record<string, string> {
44
43
  const output: Record<string, string> = {};
45
44
  for (const [key, value] of Object.entries(process.env)) {
46
- if (SECRET_KEY_PATTERN.test(key)) output[key] = "***";
45
+ if (isSecretKey(key)) output[key] = "***";
47
46
  else if (typeof value === "string" && ENV_DEBUG_ALLOWLIST.test(key)) output[key] = value;
48
47
  // All other env vars are omitted to prevent leaking sensitive paths or system topology.
49
48
  }
@@ -484,11 +484,11 @@ export function createScriptRunner(options?: DynamicScriptOptions): DynamicScrip
484
484
  /**
485
485
  * @internal TEST ONLY — do not use in production code.
486
486
  * Exposes DynamicScriptRunner.executeUnchecked for unit testing.
487
+ * Returns undefined in non-test environments to prevent production use.
487
488
  */
488
- export function __test_executeUnchecked(
489
- runner: DynamicScriptRunner,
490
- code: string,
491
- timeout?: number,
492
- ): ScriptExecutionResult {
493
- return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
494
- }
489
+ export const __test_executeUnchecked: ((runner: DynamicScriptRunner, code: string, timeout?: number) => ScriptExecutionResult) | undefined =
490
+ process.env.NODE_ENV === "test"
491
+ ? (runner: DynamicScriptRunner, code: string, timeout?: number): ScriptExecutionResult => {
492
+ return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
493
+ }
494
+ : undefined;
@@ -41,8 +41,8 @@ export function stopWatchdog(runId: string): void {
41
41
  }
42
42
  }
43
43
 
44
- /** Stop all active watchdogs. Called on session shutdown. */
45
- export function stopAllWatchdogs(): void {
44
+ /** @internal Stop all active watchdogs. Called on session shutdown. */
45
+ function stopAllWatchdogs(): void {
46
46
  for (const [runId, timer] of activeWatchdogs) {
47
47
  clearTimeout(timer);
48
48
  }
@@ -81,7 +81,8 @@ export function listLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[
81
81
  /**
82
82
  * List only active agents (running/queued/waiting) for a specific workspace.
83
83
  */
84
- export function listActiveLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[] {
84
+ /** @internal */
85
+ function listActiveLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[] {
85
86
  return listActiveLiveAgents().filter((a) => a.workspaceId === workspaceId);
86
87
  }
87
88
 
@@ -150,7 +151,8 @@ function safeDisposeLiveSession(handle: LiveAgentHandle): void {
150
151
  }
151
152
  }
152
153
 
153
- export function removeLiveAgentHandle(agentId: string): LiveAgentHandle | undefined {
154
+ /** @internal */
155
+ function removeLiveAgentHandle(agentId: string): LiveAgentHandle | undefined {
154
156
  const handle = liveAgents.get(agentId);
155
157
  if (!handle) return undefined;
156
158
  liveAgents.delete(agentId);
@@ -406,7 +408,8 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
406
408
  }
407
409
 
408
410
  /** Phase 7: Get pending IRC messages for an agent (and clear them). */
409
- export function drainIrcMessages(agentIdOrTaskId: string): IrcMessage[] {
411
+ /** @internal */
412
+ function drainIrcMessages(agentIdOrTaskId: string): IrcMessage[] {
410
413
  const handle = getLiveAgent(agentIdOrTaskId);
411
414
  if (!handle) return [];
412
415
  const messages = [...handle.pendingMessages];
@@ -51,7 +51,8 @@ export function renderIrcPeerRoster(selfId: string, peers: Array<{ agentId: stri
51
51
  /**
52
52
  * Build the IRC system prompt section for a live-session worker.
53
53
  */
54
- export function buildIrcSystemSection(selfId: string, peers: Array<{ agentId: string; status: string }>): string {
54
+ /** @internal */
55
+ function buildIrcSystemSection(selfId: string, peers: Array<{ agentId: string; status: string }>): string {
55
56
  const roster = renderIrcPeerRoster(selfId, peers);
56
57
  return [
57
58
  "## Inter-Agent Communication",
@@ -66,7 +67,8 @@ export function buildIrcSystemSection(selfId: string, peers: Array<{ agentId: st
66
67
  * Route an IRC message to the appropriate agent(s).
67
68
  * Returns the list of agent IDs that received the message.
68
69
  */
69
- export function routeIrcMessage(
70
+ /** @internal */
71
+ function routeIrcMessage(
70
72
  message: IrcSendMessage,
71
73
  selfId: string,
72
74
  routing: {
@@ -63,7 +63,8 @@ export async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item:
63
63
  * On abort: returns partial results (may contain undefined entries).
64
64
  * On error: throws immediately (fail-fast) and cancels remaining work.
65
65
  */
66
- export async function mapConcurrentWithSignal<T, R>(
66
+ /** @internal */
67
+ async function mapConcurrentWithSignal<T, R>(
67
68
  items: T[],
68
69
  limit: number,
69
70
  fn: (item: T, i: number, signal: AbortSignal) => Promise<R>,
@@ -5,6 +5,7 @@
5
5
  * Distilled from pi-autoresearch's post-check / backpressure pattern.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
+ import * as path from "node:path";
8
9
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
9
10
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
10
11
 
@@ -56,9 +57,8 @@ function resolveScriptPath(config: PostCheckConfig): string | undefined {
56
57
  * If no script path is available (neither config nor env var), the check
57
58
  * passes by default with a note.
58
59
  *
59
- * **Security note:** The script path is user-configurable (config or env var)
60
- * and executed with minimal environment (PATH, HOME, USER, LANG). Only use with trusted script
61
- * paths. No path containment validation is performed.
60
+ * **Security note:** The script path is validated to stay within `cwd`.
61
+ * Scripts that escape the working directory are rejected.
62
62
  *
63
63
  * @param config - Post-check configuration (script path and timeout)
64
64
  * @param cwd - Working directory for script execution
@@ -77,6 +77,13 @@ export async function runPostCheck(config: PostCheckConfig, cwd: string): Promis
77
77
  };
78
78
  }
79
79
 
80
+ // M1: Validate that the script path is contained within cwd to prevent arbitrary file execution
81
+ const resolved = path.resolve(cwd, scriptPath);
82
+ const resolvedCwd = path.resolve(cwd);
83
+ if (!resolved.startsWith(resolvedCwd + path.sep) && resolved !== resolvedCwd) {
84
+ throw new Error(`Security: PI_CREW_POST_CHECK_SCRIPT escapes cwd: ${scriptPath}`);
85
+ }
86
+
80
87
  const startTime = Date.now();
81
88
 
82
89
  return new Promise<PostCheckResult>((resolve) => {
@@ -208,7 +208,7 @@ export function runDriftDetection(ctx: DriftContext, maxPasses = 2): DriftReport
208
208
  newFindings++;
209
209
  }
210
210
  } catch (error) {
211
- logInternalError("drift-detectors", error, `detector=${detector.name} runId=${ctx.manifest?.runId}`);
211
+ logInternalError("run-drift", error, `detector=${detector.name} runId=${ctx.manifest?.runId}`);
212
212
  }
213
213
  }
214
214
 
@@ -21,28 +21,32 @@ const FORBIDDEN_PATTERNS = [
21
21
  // Global escape vectors
22
22
  /\bglobalThis\b/, // globalThis reference
23
23
  /\bglobal\b/, // global reference (Node.js)
24
+ /\bconstructor\b/, // Block constructor chain escape: [].constructor.constructor("return process")()
24
25
  ] as const;
25
26
 
27
+ Object.freeze(FORBIDDEN_PATTERNS);
28
+
26
29
  /**
27
- * Whitelist of allowed identifiers for strict mode.
28
- * Only these identifiers can be used in sandboxed code.
30
+ * SECURITY (HIGH #3 fix): Normalize source code before forbidden-pattern checks
31
+ * to prevent unicode-escape bypasses.
32
+ *
33
+ * Attackers can write `import\u0028"fs"\u0029` which compiles as
34
+ * `import("fs")` but does not match the regex `/import\s*\(/`.
35
+ *
36
+ * This function:
37
+ * 1. Strips null bytes (used to split keywords across boundaries)
38
+ * 2. Decodes \uXXXX escape sequences so regexes see the actual characters
29
39
  */
30
- const ALLOWED_IDENTIFIERS = new Set([
31
- // Built-in constructors
32
- "Array", "Boolean", "Date", "Error", "Function", "JSON", "Map", "Number", "Object", "Promise", "RegExp", "Set", "String", "Symbol",
33
- // Static methods
34
- "ArrayBuffer", "Uint8Array", "parseInt", "parseFloat", "isNaN", "isFinite",
35
- // URI encoding
36
- "encodeURI", "decodeURI", "encodeURIComponent", "decodeURIComponent",
37
- // Math (read-only)
38
- "Math",
39
- // Console (safe methods only)
40
- "console",
41
- // Process (limited)
42
- "process",
43
- ]);
44
-
45
- Object.freeze(FORBIDDEN_PATTERNS);
40
+ export function normalizeCodeForValidation(code: string): string {
41
+ // Strip null bytes
42
+ let normalized = code.replace(/\0/g, "");
43
+ // Decode common unicode escapes: \u0028 → (
44
+ normalized = normalized.replace(
45
+ /\\u([0-9a-fA-F]{4})/g,
46
+ (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)),
47
+ );
48
+ return normalized;
49
+ }
46
50
 
47
51
  export interface SandboxOptions {
48
52
  timeout?: number;
@@ -204,15 +208,17 @@ export class WorkflowSandbox {
204
208
  * ensure compilation is safe.
205
209
  */
206
210
  private validateScript(code: string): void {
211
+ // SECURITY (HIGH #3 fix): Normalize unicode escapes before pattern matching
212
+ const normalized = normalizeCodeForValidation(code);
207
213
  // Check for ESM/module patterns
208
214
  for (const pattern of FORBIDDEN_PATTERNS) {
209
- if (pattern.test(code)) {
215
+ if (pattern.test(normalized)) {
210
216
  throw new Error(`Forbidden pattern detected: ${pattern.source}`);
211
217
  }
212
218
  }
213
219
 
214
220
  // Check for import.meta specifically (C4)
215
- if (/import\.meta/.test(code)) {
221
+ if (/import\.meta/.test(normalized)) {
216
222
  throw new Error("import.meta is not allowed in sandboxed code");
217
223
  }
218
224
 
@@ -88,7 +88,8 @@ export interface ParallelResult<R> {
88
88
  *
89
89
  * Adapted from oh-my-pi's `mapWithConcurrencyLimit`.
90
90
  */
91
- export async function mapWithFailFast<T, R>(
91
+ /** @internal */
92
+ async function mapWithFailFast<T, R>(
92
93
  items: T[],
93
94
  concurrency: number,
94
95
  fn: (item: T, index: number, signal: AbortSignal) => Promise<R>,
@@ -20,6 +20,18 @@ const MAX_TURNS_CEILING = 10_000;
20
20
  const GRACE_TURNS_CEILING = 1_000;
21
21
  const VALID_JOIN_MODES = new Set<JoinMode>(["async", "group", "smart"]);
22
22
 
23
+ /**
24
+ * M2: Validate that a scheduled job object has required fields before passing to scheduler.
25
+ * Prevents opaque unknown[] from reaching CrewScheduler.add() without validation.
26
+ */
27
+ function validateScheduledJob(job: unknown): boolean {
28
+ if (!job || typeof job !== "object") return false;
29
+ const obj = job as Record<string, unknown>;
30
+ return typeof obj.id === "string" && obj.id.length > 0
31
+ && typeof obj.scheduleType === "string"
32
+ && typeof obj.enabled === "boolean";
33
+ }
34
+
23
35
  function sanitizeSettings(raw: unknown): CrewSettings {
24
36
  if (!raw || typeof raw !== "object") return {};
25
37
  const r = raw as Record<string, unknown>;
@@ -57,9 +69,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
57
69
  if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
58
70
  out.notifierIntervalMs = r.notifierIntervalMs;
59
71
  }
60
- // Pass through scheduledJobs as opaque array (validated by crewScheduler.add)
72
+ // Pass through scheduledJobs after basic validation
61
73
  if (Array.isArray(r.scheduledJobs)) {
62
- out.scheduledJobs = r.scheduledJobs;
74
+ out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
63
75
  }
64
76
  return out;
65
77
  }
@@ -374,7 +374,8 @@ export function getWeightedSkillsForRole(
374
374
  * Filter skills by confidence threshold.
375
375
  * Skills below threshold are marked as "suggest" only.
376
376
  */
377
- export function filterSkillsByConfidence(
377
+ /** @internal */
378
+ function filterSkillsByConfidence(
378
379
  skillIds: string[],
379
380
  runId: string,
380
381
  threshold: keyof typeof CONFIDENCE_THRESHOLDS = "MODERATE",
@@ -431,7 +432,8 @@ export function registerSkillEffectivenessHooks(): void {
431
432
  /**
432
433
  * Generate a skill effectiveness report for a run.
433
434
  */
434
- export function generateSkillEffectivenessReport(
435
+ /** @internal */
436
+ function generateSkillEffectivenessReport(
435
437
  runId: string,
436
438
  skillIds: string[],
437
439
  ): string {
@@ -244,7 +244,10 @@ export function renderSkillInstructions(input: RenderSkillInstructionsInput & {
244
244
  const confidenceNote = weighted ? ` [Confidence: ${(weighted.confidence * 100).toFixed(0)}% — ${weighted.threshold}]` : "";
245
245
 
246
246
  const header = [`## ${safeName}`, description ? `Description: ${description}${confidenceNote}` : undefined, `Source: ${source}`].filter(Boolean).join("\n");
247
- const section = `${header}\n\n${compactSkillContent(loaded.content)}`;
247
+ const rawContent = compactSkillContent(loaded.content);
248
+ // Wrap skill content with provenance markers to help LLMs distinguish skill instructions
249
+ const wrappedContent = `<!-- skill: ${safeName} -->\n${rawContent}\n<!-- end-skill: ${safeName} -->`;
250
+ const section = `${header}\n\n${wrappedContent}`;
248
251
  if (!pushSection(section)) omittedCount += 1;
249
252
  }
250
253
  if (omittedCount > 0) {
@@ -88,10 +88,28 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
88
88
  }
89
89
  }
90
90
 
91
+ const ALLOWED_RECORD_FIELDS = new Set([
92
+ "agentId", "agentName", "subagentType", "status", "spawnedAt",
93
+ "completedAt", "model", "runId", "cwd", "taskId", "taskId",
94
+ ]);
95
+
96
+ function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
97
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
98
+ const obj = raw as Record<string, unknown>;
99
+ if (typeof obj.agentId !== "string" || !obj.agentId) return undefined;
100
+ const clean: Record<string, unknown> = { agentId: obj.agentId };
101
+ for (const key of Object.keys(obj)) {
102
+ if (ALLOWED_RECORD_FIELDS.has(key) && (typeof obj[key] === "string" || typeof obj[key] === "number" || typeof obj[key] === "boolean")) {
103
+ clean[key] = obj[key];
104
+ }
105
+ }
106
+ return clean as unknown as SubagentRecord;
107
+ }
108
+
91
109
  export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRecord | undefined {
92
110
  try {
93
- const parsed = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
94
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as SubagentRecord : undefined;
111
+ const raw = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
112
+ return sanitizePersistedRecord(raw);
95
113
  } catch {
96
114
  return undefined;
97
115
  }