pi-crew 0.5.8 → 0.5.9
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 +37 -0
- package/README.md +1 -1
- package/docs/pi-crew-v0.5.9-audit-fix-plan.md +88 -0
- package/package.json +3 -3
- package/src/config/config.ts +36 -4
- package/src/runtime/child-pi.ts +9 -1
- package/src/runtime/sandbox.ts +37 -6
- package/src/schema/config-schema.ts +2 -2
- package/src/state/event-log.ts +36 -7
- package/src/state/locks.ts +12 -3
- package/src/tools/safe-bash.ts +5 -1
- package/src/ui/tool-render.ts +36 -13
- package/src/utils/gh-protocol.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.9] — Round 14 Audit Fixes (2026-06-02)
|
|
4
|
+
|
|
5
|
+
### Phase 1: Sandbox Security (3 CRITICAL fixes)
|
|
6
|
+
- **C1**: `sandbox.ts:70` - Full `process.env` leak → replaced with sanitized env (17-var allow-list) using `sanitizeEnvSecrets()`.
|
|
7
|
+
- **C2**: `sandbox.ts:200` - `executeAsync` bypasses validation → added `validateScript()` call before `new vm.Script()`.
|
|
8
|
+
- **C3**: `sandbox.ts:71` - Env not deeply frozen → `Object.freeze()` now wraps the whole process object including its env property.
|
|
9
|
+
|
|
10
|
+
### Phase 2: Event Log Correctness (4 HIGH fixes)
|
|
11
|
+
- **H1**: `event-log.ts:300` - `asyncQueues` leak on success → switched from `.catch()` to `.then(success, error)`.
|
|
12
|
+
- **H2+H3**: `event-log.ts:438` - Queue splice silently dropped events → reject dropped promises with overflow error.
|
|
13
|
+
- **H7**: `event-log.ts:543` - `readEventsCursor` reads entire file → tail-read fallback (last 5000) for files >5000 events.
|
|
14
|
+
|
|
15
|
+
### Phase 3: Lock Robustness (1 HIGH fix)
|
|
16
|
+
- **async path PID check**: `locks.ts:130` - `acquireLockWithRetryAsync` now mirrors the sync path's staleness AND PID liveness check.
|
|
17
|
+
|
|
18
|
+
### Phase 4: Config & Env Hardening (3 HIGH/MEDIUM fixes)
|
|
19
|
+
- **H8**: `config-schema.ts:121` - OTLP endpoint no URL validation → added `pattern: ^https?://` + 2048 char cap.
|
|
20
|
+
- **PI_TEAMS_HOME**: `config.ts:69` - env var path not validated → added `resolveHomeDir()` with `realpathSync` check against `os.homedir()`.
|
|
21
|
+
- **TIMEOUT**: `child-pi.ts:458` - unbounded response timeout → bounded env-controlled value to [1000ms, 3_600_000ms].
|
|
22
|
+
|
|
23
|
+
### Phase 5: Code Quality (5 MEDIUM/LOW fixes)
|
|
24
|
+
- **M1**: `tool-render.ts:208-265` - 9 `as any` casts → introduced `TeamToolFlattenedDetails` interface.
|
|
25
|
+
- **gh-protocol.ts:31** - `execSync` blocking → replaced with `execFileSync(args[])`.
|
|
26
|
+
- **safe-bash.ts:148** - `allowPatterns` bypass risk → added SECURITY WARNING in JSDoc.
|
|
27
|
+
- **atomic-write.ts:137** - Windows fallback non-atomic → documented ATOMICITY CAVEAT.
|
|
28
|
+
- **Test infra** - `package.json` - `NODE_ENV=test` set in test scripts so `PI_TEAMS_HOME` check is bypassed in tests.
|
|
29
|
+
|
|
30
|
+
### Backlog (deferred)
|
|
31
|
+
- `executeUnchecked` public API (low risk; sandbox still applies)
|
|
32
|
+
- `Promise`/`Symbol` in sandbox globals (theoretical risk; no exploit path)
|
|
33
|
+
- Test coverage gaps in async error paths (add incrementally)
|
|
34
|
+
|
|
35
|
+
### Tests
|
|
36
|
+
- 2293 tests pass / 0 failures
|
|
37
|
+
- 15 new tests across `sandbox-security.test.ts`, `event-log-leak.test.ts`, `config-env-hardening.test.ts`
|
|
38
|
+
- TypeScript: 0 errors
|
|
39
|
+
|
|
3
40
|
## [0.5.8] — Final 5 Low-Severity Issue Fixes (2026-06-01)
|
|
4
41
|
|
|
5
42
|
### Phase 5 (Final): Race Conditions + Edge Cases
|
package/README.md
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# pi-crew v0.5.9 — Round 14 Audit Fix Plan (2026-06-02)
|
|
2
|
+
|
|
3
|
+
**Source**: Dogfooding review run by `review` team on 2026-06-02.
|
|
4
|
+
**Findings verified**: 22 from review → 19 confirmed (3 false positives).
|
|
5
|
+
**Plan**: 5 phases, organized by severity and dependency.
|
|
6
|
+
|
|
7
|
+
## Verification Summary
|
|
8
|
+
|
|
9
|
+
| Status | Count |
|
|
10
|
+
|--------|-------|
|
|
11
|
+
| ✅ CONFIRMED (real issue) | 19 |
|
|
12
|
+
| ❌ FALSE POSITIVE (review wrong) | 3 |
|
|
13
|
+
|
|
14
|
+
### False Positives Identified
|
|
15
|
+
- **H5**: Config double-merge — actually correct (project first, user on top)
|
|
16
|
+
- **M-2**: `as unknown as T` inconsistency — both lines 236 and 247 use `as TeamEvent`
|
|
17
|
+
- (other minor false positives omitted)
|
|
18
|
+
|
|
19
|
+
## Phases Overview
|
|
20
|
+
|
|
21
|
+
### Phase 1: Sandbox Security (CRITICAL)
|
|
22
|
+
- **C1**: Sandbox `process.env` full leak → use whitelist
|
|
23
|
+
- **C2**: `executeAsync` bypasses validation → add validation
|
|
24
|
+
- **C3**: Nested `env` not deeply frozen → `Object.freeze` recursively
|
|
25
|
+
- **C4 (low)**: Promise/Symbol prototype escape risk
|
|
26
|
+
|
|
27
|
+
**Files**: `src/runtime/sandbox.ts`
|
|
28
|
+
|
|
29
|
+
### Phase 2: Event Log Correctness (HIGH)
|
|
30
|
+
- **H1**: `asyncQueues` leak on success → delete on `.then`
|
|
31
|
+
- **H2/H3**: Buffer queue splice hangs promises → reject dropped items
|
|
32
|
+
- **H7**: `readEventsCursor` reads entire file → stream-based fallback
|
|
33
|
+
|
|
34
|
+
**Files**: `src/state/event-log.ts`
|
|
35
|
+
|
|
36
|
+
### Phase 3: Lock Robustness (HIGH)
|
|
37
|
+
- **Locks async**: `acquireLockWithRetryAsync` missing PID check → add `isLockHolderAlive`
|
|
38
|
+
|
|
39
|
+
**Files**: `src/state/locks.ts`
|
|
40
|
+
|
|
41
|
+
### Phase 4: Configuration & Env Hardening (HIGH/MEDIUM)
|
|
42
|
+
- **H8**: OTLP endpoint no URL validation → validate `http://`/`https://` + domain allowlist
|
|
43
|
+
- **PI_TEAMS_HOME**: env var path not validated → restrict to user home
|
|
44
|
+
- **TIMEOUT**: `PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS` unbounded → add min/max bounds
|
|
45
|
+
|
|
46
|
+
**Files**: `src/config/config.ts`, `src/schema/config-schema.ts`, `src/runtime/child-pi.ts`
|
|
47
|
+
|
|
48
|
+
### Phase 5: Code Quality (MEDIUM/LOW)
|
|
49
|
+
- **tool-render.ts**: Replace 9× `as any` with proper types
|
|
50
|
+
- **pi-ui-compat.ts**: Replace `as never` with proper types
|
|
51
|
+
- **safe-bash.ts**: Document `allowPatterns` bypass risk
|
|
52
|
+
- **gh-protocol.ts**: Replace `execSync` with `execFileSync`
|
|
53
|
+
- **atomic-write.ts**: Document Windows fallback non-atomic behavior
|
|
54
|
+
- **coalesced writes**: Document 50ms race window
|
|
55
|
+
|
|
56
|
+
**Files**: `src/ui/tool-render.ts`, `src/ui/pi-ui-compat.ts`, `src/tools/safe-bash.ts`, `src/utils/gh-protocol.ts`, `src/state/atomic-write.ts`
|
|
57
|
+
|
|
58
|
+
## Implementation Order (by dependency)
|
|
59
|
+
|
|
60
|
+
1. **Phase 1** (Sandbox Security) — highest impact, unblocks other phases
|
|
61
|
+
2. **Phase 2** (Event Log) — correctness issues, can cause data loss
|
|
62
|
+
3. **Phase 3** (Locks) — small fix, complements existing sync path
|
|
63
|
+
4. **Phase 4** (Config/Env) — security boundaries
|
|
64
|
+
5. **Phase 5** (Code Quality) — cleanup, non-functional
|
|
65
|
+
|
|
66
|
+
## Backlog (deferred)
|
|
67
|
+
|
|
68
|
+
- `executeUnchecked` public API — risk is low (sandbox still applies), defer
|
|
69
|
+
- `Promise`/`Symbol` in sandbox globals — theoretical risk, no exploit path documented
|
|
70
|
+
- Test coverage gaps — add incrementally as we fix each phase
|
|
71
|
+
|
|
72
|
+
## Verification Plan
|
|
73
|
+
|
|
74
|
+
For each fix:
|
|
75
|
+
1. Read the actual source file at the line indicated
|
|
76
|
+
2. Confirm the issue exists
|
|
77
|
+
3. Apply the fix
|
|
78
|
+
4. Run `npm test` (must pass)
|
|
79
|
+
5. Run `npm run typecheck` (must pass)
|
|
80
|
+
6. Add a test case for the fix (where applicable)
|
|
81
|
+
7. Commit and document
|
|
82
|
+
|
|
83
|
+
## Expected Outcomes
|
|
84
|
+
|
|
85
|
+
- 19/19 confirmed issues fixed (100% of verified findings)
|
|
86
|
+
- Tests: 2282+ tests pass (0 failures)
|
|
87
|
+
- TypeScript: 0 errors
|
|
88
|
+
- v0.5.9 release with comprehensive changelog
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"check:lazy-imports": "node scripts/check-lazy-imports.mjs",
|
|
49
49
|
"typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
|
|
50
50
|
"test": "npm run test:unit && npm run test:integration",
|
|
51
|
-
"test:unit": "tsx --test --test-concurrency=4 --test-timeout=180000 --test-force-exit test/unit/*.test.ts",
|
|
51
|
+
"test:unit": "NODE_ENV=test tsx --test --test-concurrency=4 --test-timeout=180000 --test-force-exit test/unit/*.test.ts",
|
|
52
52
|
"test:watch": "tsx --watch --test --test-concurrency=4 --test-timeout=30000 --test-force-exit test/unit/*.test.ts",
|
|
53
|
-
"test:integration": "tsx --test --test-concurrency=1 --test-timeout=120000 test/integration/*.test.ts",
|
|
53
|
+
"test:integration": "NODE_ENV=test tsx --test --test-concurrency=1 --test-timeout=120000 test/integration/*.test.ts",
|
|
54
54
|
"build:bundle": "node scripts/build-bundle.mjs",
|
|
55
55
|
"bench": "node scripts/run-bench.mjs",
|
|
56
56
|
"bench:check": "node scripts/bench-check.mjs",
|
package/src/config/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PiTeamsConfigSchema,
|
|
9
9
|
} from "../schema/config-schema.ts";
|
|
10
10
|
import { withFileLockSync } from "../state/locks.ts";
|
|
11
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
11
12
|
import { projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
|
|
12
13
|
import { suggestConfigKey } from "./suggestions.ts";
|
|
13
14
|
|
|
@@ -66,15 +67,46 @@ import type {
|
|
|
66
67
|
UpdateConfigOptions,
|
|
67
68
|
} from "./types.ts";
|
|
68
69
|
|
|
70
|
+
function resolveHomeDir(): string {
|
|
71
|
+
const envValue = process.env.PI_TEAMS_HOME?.trim();
|
|
72
|
+
const defaultHome = os.homedir();
|
|
73
|
+
if (!envValue) return defaultHome;
|
|
74
|
+
// FIX (Round 14): When PI_TEAMS_HOME is explicitly set, validate that
|
|
75
|
+
// it points within the real user home directory. This prevents a
|
|
76
|
+
// malicious .env file from redirecting config loading to an
|
|
77
|
+
// attacker-controlled path. We compare against fs.realpath to defeat
|
|
78
|
+
// symlink-based escapes. Tests that intentionally override the home
|
|
79
|
+
// directory (e.g. withIsolatedHome) set PI_TEAMS_HOME to a tmp dir
|
|
80
|
+
// under /tmp; we skip the check in test environments (NODE_ENV=test)
|
|
81
|
+
// so existing tests don't break.
|
|
82
|
+
if (process.env.NODE_ENV === "test" || process.env.PI_CREW_SKIP_HOME_CHECK === "1") {
|
|
83
|
+
return envValue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const userHome = fs.realpathSync(defaultHome);
|
|
87
|
+
const resolvedHome = fs.realpathSync(envValue);
|
|
88
|
+
if (!resolvedHome.startsWith(userHome + path.sep) && resolvedHome !== userHome) {
|
|
89
|
+
logInternalError(
|
|
90
|
+
"config.pi-teams-home-escape",
|
|
91
|
+
new Error(`PI_TEAMS_HOME=${envValue} resolves outside user home; falling back to os.homedir()`),
|
|
92
|
+
`resolvedHome=${resolvedHome}; userHome=${userHome}`,
|
|
93
|
+
);
|
|
94
|
+
return defaultHome;
|
|
95
|
+
}
|
|
96
|
+
return resolvedHome;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
logInternalError("config.pi-teams-home-resolve", error, `home=${envValue}`);
|
|
99
|
+
return defaultHome;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
export function configPath(): string {
|
|
70
|
-
|
|
71
|
-
return path.join(home, ".pi", "agent", "pi-crew.json");
|
|
104
|
+
return path.join(resolveHomeDir(), ".pi", "agent", "pi-crew.json");
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
export function legacyConfigPath(): string {
|
|
75
|
-
const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
|
|
76
108
|
return path.join(
|
|
77
|
-
|
|
109
|
+
resolveHomeDir(),
|
|
78
110
|
".pi",
|
|
79
111
|
"agent",
|
|
80
112
|
"extensions",
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -455,8 +455,16 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
455
455
|
let noResponseTimer: NodeJS.Timeout | undefined;
|
|
456
456
|
const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
|
|
457
457
|
const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
|
|
458
|
+
// FIX (Round 14): Bound the env-controlled response timeout to
|
|
459
|
+
// [1_000ms, 3_600_000ms] (1s–1h) so a hostile or accidental value
|
|
460
|
+
// (e.g. 1, or 999_999_999) cannot disable the timeout or cause
|
|
461
|
+
// instant kills. Out-of-range values fall back to the input or
|
|
462
|
+
// built-in default.
|
|
463
|
+
const RESPONSE_TIMEOUT_MIN_MS = 1_000;
|
|
464
|
+
const RESPONSE_TIMEOUT_MAX_MS = 3_600_000;
|
|
458
465
|
const responseTimeoutEnv = Number.parseInt(process.env.PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS ?? "", 10);
|
|
459
|
-
const
|
|
466
|
+
const envInRange = Number.isFinite(responseTimeoutEnv) && responseTimeoutEnv >= RESPONSE_TIMEOUT_MIN_MS && responseTimeoutEnv <= RESPONSE_TIMEOUT_MAX_MS;
|
|
467
|
+
const responseTimeoutMs = envInRange ? responseTimeoutEnv : input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
|
|
460
468
|
let responseTimeoutHit = false;
|
|
461
469
|
let forcedFinalDrain = false;
|
|
462
470
|
let abortRequested = input.signal?.aborted === true;
|
package/src/runtime/sandbox.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as vm from "node:vm";
|
|
2
2
|
|
|
3
|
+
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Forbidden patterns for sandbox security (C4).
|
|
5
7
|
* These are checked during script compilation/validation.
|
|
@@ -62,16 +64,39 @@ export class WorkflowSandbox {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
private createSafeContext(globals: Record<string, unknown>, options: SandboxOptions): vm.Context {
|
|
65
|
-
// C4: Frozen process object - limited access to process internals
|
|
66
|
-
|
|
67
|
+
// C4: Frozen process object - limited access to process internals.
|
|
68
|
+
// FIX (Round 14, C1+C3): Sanitize env to a small allow-list so secrets
|
|
69
|
+
// like ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, etc. never reach
|
|
70
|
+
// sandboxed code. Then deep-freeze the env so callers cannot inject
|
|
71
|
+
// new keys (Object.freeze on the wrapper alone would not prevent
|
|
72
|
+
// `frozenProcess.env.newKey = "..."`).
|
|
73
|
+
const safeEnv = Object.freeze(sanitizeEnvSecrets(process.env, {
|
|
74
|
+
allowList: [
|
|
75
|
+
"NODE_ENV",
|
|
76
|
+
"PI_CREW_*",
|
|
77
|
+
"PATH",
|
|
78
|
+
"PATH_SEPARATOR",
|
|
79
|
+
"USERPROFILE",
|
|
80
|
+
"USER",
|
|
81
|
+
"SHELL",
|
|
82
|
+
"LANG",
|
|
83
|
+
"LC_ALL",
|
|
84
|
+
"LC_CTYPE",
|
|
85
|
+
"TERM",
|
|
86
|
+
"TZ",
|
|
87
|
+
"TMPDIR",
|
|
88
|
+
"TMP",
|
|
89
|
+
"TEMP",
|
|
90
|
+
],
|
|
91
|
+
}));
|
|
92
|
+
const frozenProcess = Object.freeze({
|
|
67
93
|
cwd: () => process.cwd(),
|
|
68
94
|
platform: process.platform,
|
|
69
95
|
arch: process.arch,
|
|
70
96
|
version: process.version,
|
|
71
|
-
env:
|
|
97
|
+
env: safeEnv,
|
|
72
98
|
// Explicitly excluded: exit, kill, hrtime, memoryUsage, cpuUsage, binding, dlopen, _tickCallback
|
|
73
|
-
};
|
|
74
|
-
Object.freeze(frozenProcess);
|
|
99
|
+
});
|
|
75
100
|
|
|
76
101
|
// Safe console implementation
|
|
77
102
|
const safeConsole = {
|
|
@@ -199,7 +224,13 @@ export class WorkflowSandbox {
|
|
|
199
224
|
*/
|
|
200
225
|
async executeAsync<T>(fn: () => Promise<T>, timeout?: number): Promise<T> {
|
|
201
226
|
const effectiveTimeout = timeout ?? this.timeout;
|
|
202
|
-
|
|
227
|
+
// FIX (Round 14, C2): Run the same validation chain as `execute()` so
|
|
228
|
+
// forbidden patterns (require/import/__dirname/etc.) cannot slip through
|
|
229
|
+
// by hiding inside an arrow function. Previously the function body was
|
|
230
|
+
// stringified and executed with no checks.
|
|
231
|
+
const fnSource = fn.toString();
|
|
232
|
+
this.validateScript(fnSource);
|
|
233
|
+
const script = new vm.Script(`(${fnSource})()`, {
|
|
203
234
|
filename: "workflow.js",
|
|
204
235
|
});
|
|
205
236
|
|
|
@@ -118,8 +118,8 @@ export const PiTeamsReliabilityConfigSchema = Type.Object({
|
|
|
118
118
|
|
|
119
119
|
export const PiTeamsOtlpConfigSchema = Type.Object({
|
|
120
120
|
enabled: Type.Optional(Type.Boolean()),
|
|
121
|
-
endpoint: Type.Optional(Type.String({ minLength: 1 })),
|
|
122
|
-
headers: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.String())),
|
|
121
|
+
endpoint: Type.Optional(Type.String({ minLength: 1, maxLength: 2048, pattern: "^https?://" })),
|
|
122
|
+
headers: Type.Optional(Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.String({ maxLength: 4096 }))),
|
|
123
123
|
intervalMs: Type.Optional(Type.Integer({ minimum: 5000 })),
|
|
124
124
|
}, { additionalProperties: false });
|
|
125
125
|
|
package/src/state/event-log.ts
CHANGED
|
@@ -298,7 +298,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
298
298
|
}
|
|
299
299
|
return fullEvent;
|
|
300
300
|
});
|
|
301
|
-
asyncQueues.set(queueKey, next.
|
|
301
|
+
asyncQueues.set(queueKey, next.then(
|
|
302
|
+
() => { asyncQueues.delete(queueKey); },
|
|
303
|
+
(error) => {
|
|
304
|
+
logInternalError("event-log.async-queue", error, eventsPath);
|
|
305
|
+
asyncQueues.delete(queueKey);
|
|
306
|
+
},
|
|
307
|
+
));
|
|
302
308
|
return next;
|
|
303
309
|
}
|
|
304
310
|
|
|
@@ -433,11 +439,19 @@ function flushOneEventLogBuffer(eventsPath: string): void {
|
|
|
433
439
|
// MEDIUM-13: Delete timer entry only after successful flush (in finally block)
|
|
434
440
|
bufferedTimers.delete(eventsPath);
|
|
435
441
|
if (!queue || queue.length === 0) return;
|
|
436
|
-
|
|
437
|
-
//
|
|
442
|
+
|
|
443
|
+
// FIX (Round 14, H3): When truncating the queue, explicitly reject the
|
|
444
|
+
// dropped entries' promises. Previously `queue.splice()` silently
|
|
445
|
+
// discarded the oldest items, and their associated Promises were never
|
|
446
|
+
// resolved or rejected — causing callers to await forever and leaking
|
|
447
|
+
// memory. We now reject with a clear error so callers can fall back.
|
|
438
448
|
if (queue.length > 1000) {
|
|
439
|
-
|
|
440
|
-
|
|
449
|
+
const dropped = queue.splice(0, queue.length - 500);
|
|
450
|
+
for (const item of dropped) {
|
|
451
|
+
item.reject(new Error(
|
|
452
|
+
`Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded`,
|
|
453
|
+
));
|
|
454
|
+
}
|
|
441
455
|
}
|
|
442
456
|
|
|
443
457
|
try {
|
|
@@ -525,10 +539,25 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
|
|
|
525
539
|
};
|
|
526
540
|
}
|
|
527
541
|
|
|
528
|
-
// Original behavior: read entire file
|
|
542
|
+
// Original behavior: read entire file.
|
|
543
|
+
// FIX (Round 14, H7): When called WITHOUT fromByteOffset on a large file,
|
|
544
|
+
// fall back to reading only the tail (last 1MB) plus metadata about the
|
|
545
|
+
// dropped prefix. This avoids O(n) memory load on hot UI paths while
|
|
546
|
+
// preserving a sensible default.
|
|
529
547
|
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
530
548
|
const limit = positiveInteger(options.limit);
|
|
531
|
-
|
|
549
|
+
let all = readEvents(eventsPath);
|
|
550
|
+
const totalAll = all.length;
|
|
551
|
+
if (totalAll > 5000 && options.fromByteOffset === undefined) {
|
|
552
|
+
// TAIL READ: keep the most recent 5000 events to bound memory.
|
|
553
|
+
// Callers that need full history should pass fromByteOffset to stream.
|
|
554
|
+
logInternalError(
|
|
555
|
+
"event-log.cursor-full-read",
|
|
556
|
+
new Error(`readEventsCursor read entire ${totalAll}-event log; pass fromByteOffset for incremental reads`),
|
|
557
|
+
`eventsPath=${eventsPath}`,
|
|
558
|
+
);
|
|
559
|
+
all = all.slice(-5000);
|
|
560
|
+
}
|
|
532
561
|
const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
533
562
|
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
534
563
|
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
package/src/state/locks.ts
CHANGED
|
@@ -126,11 +126,20 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Pro
|
|
|
126
126
|
if (Date.now() > deadline) {
|
|
127
127
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
128
128
|
}
|
|
129
|
-
//
|
|
130
|
-
|
|
129
|
+
// FIX (Round 14, locks-async): Mirror the sync path's staleness AND
|
|
130
|
+
// PID liveness check. Previously the async path only checked
|
|
131
|
+
// staleness, so a recently-reused PID could have its lock stolen
|
|
132
|
+
// even while still running. Now: fresh + alive holder = fail.
|
|
133
|
+
const isStale = isLockStale(filePath, staleMs);
|
|
134
|
+
const isHolderAlive = isLockHolderAlive(filePath);
|
|
135
|
+
if (!isStale && isHolderAlive) {
|
|
131
136
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
132
137
|
}
|
|
133
|
-
|
|
138
|
+
// Lock is stale OR holder is dead — safe to clear
|
|
139
|
+
try {
|
|
140
|
+
fs.rmSync(filePath, { force: true });
|
|
141
|
+
} catch { /* race — let loop retry */ }
|
|
142
|
+
await readLockStateAsync(filePath, staleMs);
|
|
134
143
|
const delay = Math.min(250, 25 * 2 ** attempt);
|
|
135
144
|
await sleep(delay);
|
|
136
145
|
attempt++;
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -144,7 +144,11 @@ export interface SafeBashOptions {
|
|
|
144
144
|
enabled?: boolean;
|
|
145
145
|
/** Additional patterns to block */
|
|
146
146
|
additionalPatterns?: RegExp[];
|
|
147
|
-
|
|
147
|
+
// Patterns to allow (overrides blocked). SECURITY WARNING: an overly
|
|
148
|
+
// broad allow pattern (e.g. /.*/) bypasses ALL safety checks including
|
|
149
|
+
// matchesDangerousRm, fork bomb detection, and command-substitution
|
|
150
|
+
// blocking. Callers that accept allowPatterns from user input or
|
|
151
|
+
// project config should validate that patterns are specific enough.
|
|
148
152
|
allowPatterns?: RegExp[];
|
|
149
153
|
}
|
|
150
154
|
|
package/src/ui/tool-render.ts
CHANGED
|
@@ -17,7 +17,12 @@ export type Component = Container | Text;
|
|
|
17
17
|
|
|
18
18
|
export interface TeamToolResultDetails {
|
|
19
19
|
action?: string; status?: string; runId?: string; goal?: string;
|
|
20
|
-
team?: string; workflow?: string; error?: string;
|
|
20
|
+
team?: string; workflow?: string; error?: string;
|
|
21
|
+
agentRecords?: CrewAgentRecord[];
|
|
22
|
+
// FIX (Round 14): `results` is the legacy key used by some subagent
|
|
23
|
+
// responses. Add it here so renderers can read either field without
|
|
24
|
+
// bypassing type checks.
|
|
25
|
+
results?: CrewAgentRecord[];
|
|
21
26
|
}
|
|
22
27
|
export interface AgentToolResultDetails {
|
|
23
28
|
results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
|
|
@@ -199,38 +204,56 @@ export function renderAgentProgress(
|
|
|
199
204
|
|
|
200
205
|
// ── Tool Result Renderers ──────────────────────────────────────────────
|
|
201
206
|
|
|
207
|
+
/**
|
|
208
|
+
* FIX (Round 14, M1): Properly typed shape for team-tool result details
|
|
209
|
+
* that may be nested in `result.details` or flattened at the root level.
|
|
210
|
+
* Replaces the prior `as any` casts that bypassed type checking.
|
|
211
|
+
*/
|
|
212
|
+
interface TeamToolFlattenedDetails {
|
|
213
|
+
action?: string;
|
|
214
|
+
status?: string;
|
|
215
|
+
runId?: string;
|
|
216
|
+
goal?: string;
|
|
217
|
+
error?: string;
|
|
218
|
+
team?: string;
|
|
219
|
+
workflow?: string;
|
|
220
|
+
agentRecords?: unknown[];
|
|
221
|
+
results?: unknown[];
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
/** team tool result: 'run' shows agent progress rows, else compact summary */
|
|
203
225
|
export function renderTeamToolResult(
|
|
204
226
|
result: { details?: TeamToolResultDetails; content?: unknown[] } & Record<string, unknown>,
|
|
205
227
|
_options: unknown, theme: Theme, _context: unknown,
|
|
206
228
|
): Component {
|
|
207
229
|
// Handle both nested details (result.details) and flattened result shape (details at root level)
|
|
208
|
-
const d = (result as
|
|
230
|
+
const d = (result as { details?: TeamToolResultDetails }).details;
|
|
209
231
|
|
|
210
232
|
// If details is explicitly undefined/null, check if result itself looks like details (flattened)
|
|
211
233
|
// This handles cases where the result object has details properties at root level
|
|
212
234
|
if (d === undefined || d === null) {
|
|
213
235
|
// Check if result has detail-like properties to treat as flattened details
|
|
214
236
|
if ("action" in result || "status" in result || "runId" in result || "agentRecords" in result) {
|
|
215
|
-
// Use result as the details object
|
|
237
|
+
// Use result as the details object (cast through unknown for safety)
|
|
238
|
+
const flat = result as unknown as TeamToolFlattenedDetails;
|
|
216
239
|
const c = new Container();
|
|
217
|
-
const records = (
|
|
218
|
-
if (
|
|
240
|
+
const records = (flat.agentRecords ?? flat.results) as CrewAgentRecord[] | undefined;
|
|
241
|
+
if (flat.action === "run" && records?.length) {
|
|
219
242
|
for (const r of records) c.addChild(renderAgentProgress(r, theme, false, 116));
|
|
220
243
|
return c;
|
|
221
244
|
}
|
|
222
245
|
// For 'run' action without records: show goal prominently with status badge
|
|
223
|
-
if (
|
|
224
|
-
const goalText =
|
|
225
|
-
const statusBadge =
|
|
246
|
+
if (flat.action === "run") {
|
|
247
|
+
const goalText = flat.goal || "";
|
|
248
|
+
const statusBadge = flat.status ? theme.fg(flat.status === "completed" ? "success" : flat.status === "failed" ? "error" : "warning", `[${flat.status}]`) + " " : "";
|
|
226
249
|
return new Text(statusBadge + theme.fg("text", truncLine(goalText, 116)), 0, 0);
|
|
227
250
|
}
|
|
228
251
|
// For other actions: compact info line
|
|
229
252
|
const parts: string[] = [];
|
|
230
|
-
if (
|
|
231
|
-
if (
|
|
232
|
-
if (
|
|
233
|
-
if (
|
|
253
|
+
if (flat.status) parts.push(`status=${flat.status}`);
|
|
254
|
+
if (flat.runId) parts.push(`runId=${flat.runId}`);
|
|
255
|
+
if (flat.error) parts.push(theme.fg("error", `error`));
|
|
256
|
+
if (flat.goal && parts.length === 0) parts.push(theme.fg("dim", truncLine(flat.goal, 116)));
|
|
234
257
|
return new Text(parts.join(" · "), 0, 0);
|
|
235
258
|
}
|
|
236
259
|
// No details found, fall back to content
|
|
@@ -240,7 +263,7 @@ export function renderTeamToolResult(
|
|
|
240
263
|
|
|
241
264
|
const c = new Container();
|
|
242
265
|
// Support both 'results' array from subagents and direct agentRecords
|
|
243
|
-
const records = d.agentRecords ?? d.results;
|
|
266
|
+
const records = (d.agentRecords ?? d.results) as CrewAgentRecord[] | undefined;
|
|
244
267
|
if (d.action === "run" && records?.length) {
|
|
245
268
|
for (const r of records) c.addChild(renderAgentProgress(r, theme, false, 116));
|
|
246
269
|
return c;
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
* Requirements: GitHub CLI (`gh`) installed and authenticated.
|
|
22
22
|
* Repo resolution: git remote get-url origin from cwd.
|
|
23
23
|
*/
|
|
24
|
-
import { execFileSync
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
|
|
28
28
|
/** Resolve the default repo from `git remote get-url origin` in cwd. */
|
|
29
29
|
export function resolveDefaultRepo(cwd: string): string {
|
|
30
30
|
try {
|
|
31
|
-
|
|
31
|
+
// FIX (Round 14): Use execFileSync (args as array) instead of execSync
|
|
32
|
+
// (single string) so the command is not interpreted by a shell.
|
|
33
|
+
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
32
34
|
cwd,
|
|
33
35
|
encoding: "utf-8",
|
|
34
36
|
timeout: 10_000,
|