pi-crew 0.6.0 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +225 -0
- package/README.md +70 -31
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/contracts.ts +2 -1
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/utils/safe-paths.ts
CHANGED
|
@@ -11,37 +11,314 @@ export function assertSafePathId(kind: string, value: string): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function resolveContainedPath(baseDir: string, targetPath: string): string {
|
|
14
|
+
if (targetPath.includes('\0')) {
|
|
15
|
+
throw new Error(`Security: path contains null byte`);
|
|
16
|
+
}
|
|
14
17
|
const base = path.resolve(baseDir);
|
|
15
18
|
const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
|
|
16
|
-
|
|
19
|
+
// On Windows, paths are case-insensitive and short-name (8.3) aliases may
|
|
20
|
+
// differ from long-name forms (e.g. C:\Users\RUNNER~1 vs C:\Users\runneradmin).
|
|
21
|
+
// We normalize both paths to their canonical form by resolving through
|
|
22
|
+
// realpathSync, walking up ancestors for non-existent paths.
|
|
23
|
+
const baseNorm = process.platform === "win32" ? resolveWindowsCanonical(base) : base;
|
|
24
|
+
const resolvedNorm = process.platform === "win32" ? resolveWindowsCanonical(resolved) : resolved;
|
|
25
|
+
const relative = process.platform === "win32"
|
|
26
|
+
? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase())
|
|
27
|
+
: path.relative(base, resolved);
|
|
17
28
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
18
29
|
return resolved;
|
|
19
30
|
}
|
|
20
31
|
|
|
32
|
+
/**
|
|
33
|
+
* On Windows, resolve a path to its canonical (long-name) form.
|
|
34
|
+
* Walks up ancestors until finding one that exists, then joins back down.
|
|
35
|
+
* This handles paths where intermediate directories don't exist yet but
|
|
36
|
+
* their ancestors do (and may use short-name aliases).
|
|
37
|
+
*/
|
|
38
|
+
function resolveWindowsCanonical(p: string): string {
|
|
39
|
+
try {
|
|
40
|
+
// Use regular realpathSync (not .native) to preserve the input path form.
|
|
41
|
+
// On Windows CI, .native always returns long-name (runneradmin) while
|
|
42
|
+
// non-native preserves short-name (RUNNER~1). Using non-native ensures
|
|
43
|
+
// the returned form matches what os.tmpdir() and mkdtempSync produce.
|
|
44
|
+
let real = fs.realpathSync(p);
|
|
45
|
+
// Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
|
|
46
|
+
if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
|
|
47
|
+
return real;
|
|
48
|
+
} catch {
|
|
49
|
+
// Fallback: try realpathSync (non-native) which may succeed where .native fails
|
|
50
|
+
try {
|
|
51
|
+
const real = fs.realpathSync(p);
|
|
52
|
+
return real;
|
|
53
|
+
} catch { /* proceed to ancestor walk */ }
|
|
54
|
+
// Walk up to find the deepest existing ancestor
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
let current = p;
|
|
57
|
+
while (current !== path.dirname(current)) {
|
|
58
|
+
try {
|
|
59
|
+
// Use non-native to preserve input path form
|
|
60
|
+
let real = fs.realpathSync(current);
|
|
61
|
+
// Guard against NTFS internal paths
|
|
62
|
+
if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
|
|
63
|
+
// Found existing ancestor — join with remaining parts in reverse order
|
|
64
|
+
// (parts were pushed bottom-up, so iterate from last to first)
|
|
65
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
66
|
+
real = path.join(real, parts[i]);
|
|
67
|
+
}
|
|
68
|
+
return real;
|
|
69
|
+
} catch { /* keep walking */ }
|
|
70
|
+
parts.push(path.basename(current));
|
|
71
|
+
current = path.dirname(current);
|
|
72
|
+
}
|
|
73
|
+
// Couldn't resolve any ancestor — return original
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a target path to its real (symlink-resolved) absolute path while
|
|
80
|
+
* guaranteeing the result stays inside `baseDir`.
|
|
81
|
+
*
|
|
82
|
+
* ## Security model — asymmetric ancestor handling
|
|
83
|
+
*
|
|
84
|
+
* `baseDir` and `targetPath` are validated with different policies because
|
|
85
|
+
* they play different roles:
|
|
86
|
+
*
|
|
87
|
+
* - **baseDir** (the container): all ancestors MUST exist and MUST NOT be
|
|
88
|
+
* symlinks. We refuse to operate if any component is missing or symlinked,
|
|
89
|
+
* because a symlinked container could point the caller outside the
|
|
90
|
+
* intended trust boundary (e.g. `/var/run -> /run` resolving into an
|
|
91
|
+
* attacker-controlled directory).
|
|
92
|
+
*
|
|
93
|
+
* - **targetPath** (the contained file): the FINAL component may be
|
|
94
|
+
* non-existent (for write operations creating a new file) and EXISTING
|
|
95
|
+
* ancestors of the target may also be missing. We DO require that any
|
|
96
|
+
* ancestor that DOES exist must not be a symlink — an attacker who can
|
|
97
|
+
* create a directory in the container must not be able to redirect the
|
|
98
|
+
* file being created.
|
|
99
|
+
*
|
|
100
|
+
* This asymmetry is intentional: callers that need to create a new file
|
|
101
|
+
* pass a non-existent targetPath. Callers that operate on an existing file
|
|
102
|
+
* get full symlink protection. Callers MUST NOT pass a symlinked
|
|
103
|
+
* intermediate component; if you need to, use `resolveContainedPath`
|
|
104
|
+
* instead (which only checks the resolved path, not the chain).
|
|
105
|
+
*
|
|
106
|
+
* Throws on:
|
|
107
|
+
* - null byte in targetPath
|
|
108
|
+
* - targetPath resolves outside baseDir
|
|
109
|
+
* - any existing ancestor (base or target) is a symlink
|
|
110
|
+
* - baseDir itself does not exist
|
|
111
|
+
*
|
|
112
|
+
* Returns the resolved real path on success, or the resolved (but not
|
|
113
|
+
* realpathed) path when the target does not exist yet.
|
|
114
|
+
*
|
|
115
|
+
* NOTE: There is a race condition window between validation and use where an
|
|
116
|
+
* attacker could create a directory component after validation but before the
|
|
117
|
+
* file is created. Callers MUST create parent directories atomically
|
|
118
|
+
* (e.g., mkdirSync with { recursive: true }) and use O_CREAT | O_NOFOLLOW | O_EXCL
|
|
119
|
+
* for atomic file creation, as atomicWriteFile does. This ensures the entire
|
|
120
|
+
* operation is atomic and prevents TOCTOU attacks.
|
|
121
|
+
*/
|
|
21
122
|
export function resolveRealContainedPath(baseDir: string, targetPath: string): string {
|
|
123
|
+
if (targetPath.includes('\0')) {
|
|
124
|
+
throw new Error(`Security: path contains null byte`);
|
|
125
|
+
}
|
|
22
126
|
const resolved = resolveContainedPath(baseDir, targetPath);
|
|
127
|
+
|
|
128
|
+
// Open baseDir with O_NOFOLLOW to atomically validate no symlinks in the path.
|
|
129
|
+
// O_NOFOLLOW makes the open fail with ELOOP if any path component is a symlink.
|
|
130
|
+
let baseFd: number | undefined;
|
|
131
|
+
try {
|
|
132
|
+
baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const errCode = (error as NodeJS.ErrnoException).code;
|
|
135
|
+
if (errCode === "ENOENT") {
|
|
136
|
+
// baseDir doesn't exist yet — create it and retry
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
139
|
+
baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
140
|
+
} catch (retryError) {
|
|
141
|
+
throw new Error(`Cannot open base directory ${baseDir}: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
|
|
142
|
+
}
|
|
143
|
+
} else if (errCode === "ELOOP") {
|
|
144
|
+
// On macOS, system directories like /var → /private/var contain symlinks.
|
|
145
|
+
// If baseDir is under such a path, resolve through realpath and retry.
|
|
146
|
+
if (process.platform === "darwin") {
|
|
147
|
+
try {
|
|
148
|
+
const realBaseDir = fs.realpathSync(baseDir);
|
|
149
|
+
if (realBaseDir !== baseDir) {
|
|
150
|
+
baseFd = fs.openSync(realBaseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
151
|
+
baseDir = realBaseDir; // update for later use
|
|
152
|
+
// Fall through to fstatSync below
|
|
153
|
+
}
|
|
154
|
+
} catch { /* throw original */ }
|
|
155
|
+
}
|
|
156
|
+
if (baseFd === undefined) throw new Error("Refusing to resolve: baseDir path contains a symlink: " + baseDir);
|
|
157
|
+
} else {
|
|
158
|
+
throw new Error(`Cannot open base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
23
161
|
let realBase: string;
|
|
24
|
-
let realTarget: string;
|
|
25
162
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
163
|
+
const stat = fs.fstatSync(baseFd);
|
|
164
|
+
if (!stat.isDirectory()) throw new Error(`baseDir ${baseDir} is not a directory`);
|
|
165
|
+
// Use regular realpathSync (not .native) to preserve input path form.
|
|
166
|
+
// On Windows CI, .native always returns long-name form (runneradmin)
|
|
167
|
+
// while non-native preserves short-name (RUNNER~1). Using non-native
|
|
168
|
+
// ensures the result matches what the caller passed in.
|
|
169
|
+
realBase = fs.realpathSync(baseDir);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// baseDir MUST exist and be resolvable for the containment guarantee to hold.
|
|
172
|
+
// Callers creating new directories must create baseDir atomically (e.g.,
|
|
173
|
+
// mkdirSync with { recursive: true }) BEFORE calling this function, and use
|
|
174
|
+
// O_NOFOLLOW|O_CREAT|O_EXCL for the actual file creation to ensure atomicity.
|
|
175
|
+
// The safe-paths validation and the file creation are two separate operations
|
|
176
|
+
// with a gap between them — callers must close this gap with atomic primitives.
|
|
177
|
+
throw new Error(`Cannot resolve real path of base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
178
|
+
} finally {
|
|
179
|
+
fs.closeSync(baseFd);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Walk the ancestor chain of the resolved target path, using O_NOFOLLOW
|
|
183
|
+
// on each ancestor to atomically validate none are symlinks.
|
|
184
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW;
|
|
185
|
+
const O_RDONLY = fs.constants.O_RDONLY;
|
|
186
|
+
const resolvedParts = resolved.split(path.sep);
|
|
187
|
+
let resolvedAccumulated = "";
|
|
188
|
+
if (resolvedParts[0] === "") resolvedAccumulated = "/"; // Unix root
|
|
189
|
+
for (let i = 1; i < resolvedParts.length; i++) {
|
|
190
|
+
if (resolvedParts[i] === "") continue;
|
|
191
|
+
resolvedAccumulated = path.join(resolvedAccumulated, resolvedParts[i]);
|
|
192
|
+
try {
|
|
193
|
+
const fd = fs.openSync(resolvedAccumulated, O_RDONLY | O_NOFOLLOW);
|
|
194
|
+
fs.closeSync(fd);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code === "ELOOP") {
|
|
197
|
+
// On macOS, /var → /private/var, /tmp → /private/tmp, /etc → /private/etc
|
|
198
|
+
// are system symlinks managed by the OS. Allow them.
|
|
199
|
+
if (process.platform === "darwin") {
|
|
200
|
+
const resolvedSymlink = resolvedAccumulated;
|
|
201
|
+
const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"];
|
|
202
|
+
if (knownDarwinSymlinks.includes(resolvedSymlink)) continue;
|
|
203
|
+
}
|
|
204
|
+
throw new Error("Refusing to resolve: target path ancestor is a symlink: " + resolvedAccumulated);
|
|
205
|
+
}
|
|
206
|
+
// EPERM on Windows when opening a directory — skip validation
|
|
207
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") continue;
|
|
208
|
+
// ENOENT means component doesn't exist — that's OK. Only existing symlinks
|
|
209
|
+
// are a security risk (symlinks to attacker-controlled targets). Non-existent
|
|
210
|
+
// paths can be created by the caller and don't pose a symlink risk.
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
212
|
+
// For the final component (target itself), ENOENT is expected for non-existent targets.
|
|
213
|
+
if (i === resolvedParts.length - 1) continue;
|
|
214
|
+
// For non-final components (parent directories), ENOENT is also acceptable —
|
|
215
|
+
// the caller will create them before the write operation if needed.
|
|
216
|
+
// We only need to ensure no existing path component is a symlink.
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Open the target with O_NOFOLLOW to catch any symlinks.
|
|
222
|
+
// ENOENT is acceptable for write operations — the file may not exist yet.
|
|
223
|
+
let targetFd: number;
|
|
224
|
+
try {
|
|
225
|
+
targetFd = fs.openSync(resolved, O_RDONLY | O_NOFOLLOW);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Refusing to resolve: target path is a symlink: " + resolved);
|
|
228
|
+
// EPERM on Windows when opening a directory — treat as non-existent
|
|
229
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") return resolved;
|
|
230
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
231
|
+
// Target doesn't exist yet — that's OK for write operations.
|
|
232
|
+
// All ancestors have been validated above (no symlinks).
|
|
233
|
+
// The caller will create the file with atomic primitives.
|
|
234
|
+
return resolved;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Cannot open ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
237
|
}
|
|
238
|
+
|
|
239
|
+
let realTarget: string;
|
|
30
240
|
try {
|
|
31
|
-
|
|
241
|
+
// Use regular realpathSync (not .native) to preserve input path form.
|
|
242
|
+
realTarget = fs.realpathSync(resolved);
|
|
32
243
|
} catch (targetError) {
|
|
33
244
|
if ((targetError as NodeJS.ErrnoException).code === "ENOENT") {
|
|
34
|
-
|
|
245
|
+
// Target doesn't exist yet — this is OK for write operations.
|
|
246
|
+
// Return the resolved path so the caller can create the file.
|
|
247
|
+
// We already validated all ancestors are not symlinks above.
|
|
248
|
+
return resolved;
|
|
35
249
|
}
|
|
36
250
|
throw new Error(`Cannot resolve real path of ${resolved}: ${targetError instanceof Error ? targetError.message : String(targetError)}`);
|
|
251
|
+
} finally {
|
|
252
|
+
fs.closeSync(targetFd);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Re-validate the ancestor chain of the resolved path to catch any TOCTOU
|
|
256
|
+
// races that occurred between the initial O_NOFOLLOW validation and the
|
|
257
|
+
// realpathSync call. An attacker could have replaced a validated ancestor
|
|
258
|
+
// with a symlink during this window.
|
|
259
|
+
//
|
|
260
|
+
// Skip the final path component (realTarget itself) — we just successfully
|
|
261
|
+
// realpathSync'd it, so it exists. Re-validating it can spuriously fail on
|
|
262
|
+
// Windows where the resolved path uses short-name (8.3) form that
|
|
263
|
+
// openSync cannot reopen, or where the realpathSync result differs in
|
|
264
|
+
// case/separator form from the original.
|
|
265
|
+
//
|
|
266
|
+
// Walk via path.dirname which is portable across all platforms and
|
|
267
|
+
// correctly handles extended-length (\\?\), UNC (\\server\share), and
|
|
268
|
+
// short-name paths on Windows without manual parsing.
|
|
269
|
+
let ancestor = path.dirname(realTarget);
|
|
270
|
+
while (ancestor && ancestor !== path.dirname(ancestor)) {
|
|
271
|
+
try {
|
|
272
|
+
const fd = fs.openSync(ancestor, O_RDONLY | O_NOFOLLOW);
|
|
273
|
+
fs.closeSync(fd);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const errCode = (error as NodeJS.ErrnoException).code;
|
|
276
|
+
if (errCode === "ELOOP") throw new Error("Refusing to resolve: TOCTOU race detected, path became a symlink: " + ancestor);
|
|
277
|
+
// Windows: EPERM can occur when opening system directories (e.g. C:\)
|
|
278
|
+
// or NTFS internal paths ($Extend/$Deleted). Skip and continue walking.
|
|
279
|
+
if (process.platform === "win32" && errCode === "EPERM") {
|
|
280
|
+
if (ancestor.includes("$Extend") || ancestor.includes("$Deleted")) {
|
|
281
|
+
// NTFS internal path — stop walking, we've reached the filesystem root
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
// System directory — continue walking up
|
|
285
|
+
ancestor = path.dirname(ancestor);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (errCode !== "ENOENT") throw error;
|
|
289
|
+
// ENOENT on an ancestor of realTarget after realpathSync is concerning
|
|
290
|
+
// — the path existed when we validated it but now doesn't. This could
|
|
291
|
+
// indicate a race or attack. For safety, treat this as an error.
|
|
292
|
+
throw new Error(`Cannot validate resolved path: ${ancestor} disappeared after realpathSync: ${error instanceof Error ? error.message : String(error)}`);
|
|
293
|
+
}
|
|
294
|
+
ancestor = path.dirname(ancestor);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Verify the resolved real path is still within baseDir.
|
|
298
|
+
// On Windows, realpathSync.native may return different short/long-name forms
|
|
299
|
+
// for the same physical directory depending on how the path was opened.
|
|
300
|
+
// Use resolveWindowsCanonical (same as resolveContainedPath) to normalize
|
|
301
|
+
// both paths consistently before comparison.
|
|
302
|
+
if (process.platform === "win32") {
|
|
303
|
+
const normBase = resolveWindowsCanonical(realBase).replace(/\\/g, "/").toLowerCase();
|
|
304
|
+
const normTarget = resolveWindowsCanonical(realTarget).replace(/\\/g, "/").toLowerCase();
|
|
305
|
+
if (!normTarget.startsWith(normBase + "/") && normBase !== normTarget) {
|
|
306
|
+
throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
const relative = path.relative(realBase, realTarget);
|
|
310
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
37
311
|
}
|
|
38
|
-
const relative = path.relative(realBase, realTarget);
|
|
39
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
40
312
|
return realTarget;
|
|
41
313
|
}
|
|
42
314
|
|
|
43
315
|
export function resolveContainedRelativePath(baseDir: string, relativePath: string, kind = "path"): string {
|
|
44
|
-
|
|
316
|
+
if (relativePath.includes('\0')) {
|
|
317
|
+
throw new Error(`Security: path contains null byte: ${kind}`);
|
|
318
|
+
}
|
|
319
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
320
|
+
// Detect Windows absolute paths (C:\, \\server\share) that path.isAbsolute may miss after normalization
|
|
321
|
+
if (/^[A-Za-z]:/.test(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
|
|
45
322
|
if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
|
|
46
323
|
return resolveContainedPath(baseDir, path.resolve(baseDir, normalized));
|
|
47
324
|
}
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { TeamRunManifest } from "../state/types.ts";
|
|
|
5
5
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
6
6
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
7
7
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
8
|
+
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
9
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
10
|
|
|
9
11
|
export interface WorktreeCleanupResult {
|
|
10
12
|
removed: string[];
|
|
@@ -14,15 +16,33 @@ export interface WorktreeCleanupResult {
|
|
|
14
16
|
committedBranches: string[];
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
// SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
|
|
20
|
+
// Git operations do not need PI_CREW_* execution-control vars.
|
|
21
|
+
const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "C", LC_ALL: "C" };
|
|
22
|
+
|
|
23
|
+
function sanitizeBranchPart(value: string): string {
|
|
24
|
+
return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sanitizeFilename(value: string): string {
|
|
28
|
+
// Strip control chars and newlines for safe artifact filenames
|
|
29
|
+
return value.slice(0, 200).replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ");
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
function git(cwd: string, args: string[]): string {
|
|
18
|
-
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true }).trim();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw new Error(`git ${args.join(" ")} failed: ${message}`);
|
|
38
|
+
}
|
|
19
39
|
}
|
|
20
40
|
|
|
21
|
-
function isDirty(worktreePath: string): boolean {
|
|
41
|
+
function isDirty(worktreePath: string): boolean | "error" {
|
|
22
42
|
try {
|
|
23
43
|
return git(worktreePath, ["status", "--porcelain"]).trim().length > 0;
|
|
24
44
|
} catch {
|
|
25
|
-
return
|
|
45
|
+
return "error";
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
|
|
@@ -36,61 +56,112 @@ function captureDiff(worktreePath: string): string {
|
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?: boolean; signal?: AbortSignal } = {}): WorktreeCleanupResult {
|
|
39
|
-
const
|
|
59
|
+
const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
|
|
60
|
+
const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
|
|
40
61
|
const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [], committedBranches: [] };
|
|
41
62
|
if (!fs.existsSync(worktreeRoot)) return result;
|
|
42
63
|
|
|
43
64
|
// M3 fix: use withFileTypes to avoid race between readdirSync and statSync.
|
|
65
|
+
// Rely on Dirent.isDirectory() instead of a separate statSync to eliminate TOCTOU window.
|
|
44
66
|
const withFileTypes = fs.readdirSync(worktreeRoot, { withFileTypes: true });
|
|
45
67
|
for (const entry of withFileTypes) {
|
|
46
68
|
if (options.signal?.aborted) break;
|
|
47
69
|
if (!entry.isDirectory()) continue;
|
|
70
|
+
// Issue 1 fix: check signal before each git operation to respect abort faster
|
|
71
|
+
if (options.signal?.aborted) break;
|
|
48
72
|
const worktreePath = path.join(worktreeRoot, entry.name);
|
|
49
|
-
|
|
50
|
-
const stat = fs.statSync(worktreePath);
|
|
51
|
-
if (!stat.isDirectory()) continue;
|
|
52
|
-
} catch {
|
|
53
|
-
// Entry deleted between readdir and stat — skip safely.
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
73
|
+
if (options.signal?.aborted) break;
|
|
56
74
|
const dirty = isDirty(worktreePath);
|
|
57
|
-
const branchName = `pi-crew/${manifest.runId}/${entry.name}`;
|
|
75
|
+
const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
|
|
76
|
+
const safeBranchName = sanitizeBranchPart(entry.name);
|
|
58
77
|
if (dirty) {
|
|
78
|
+
// Issue 1 fix: check signal before git operations
|
|
79
|
+
if (options.signal?.aborted) break;
|
|
59
80
|
// Commit changes to a branch instead of just preserving the worktree
|
|
60
81
|
try {
|
|
61
|
-
|
|
82
|
+
// Issue 2 fix: verify status before and after add to ensure atomicity
|
|
83
|
+
const statusBefore = git(worktreePath, ["status", "--porcelain"]);
|
|
84
|
+
execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
85
|
+
// Verify no unexpected changes were added (only staged the original dirty files)
|
|
86
|
+
const statusAfterAdd = git(worktreePath, ["status", "--porcelain"]);
|
|
87
|
+
if (statusAfterAdd !== statusBefore) {
|
|
88
|
+
// Something changed between our status check and add - abort to avoid including unexpected changes
|
|
89
|
+
throw new Error("worktree state changed unexpectedly during add; refusing to commit");
|
|
90
|
+
}
|
|
91
|
+
// Issue 1 fix: check signal before commit
|
|
92
|
+
if (options.signal?.aborted) break;
|
|
62
93
|
let safeDesc = entry.name.slice(0, 200);
|
|
63
94
|
// SECURITY: Strip any newlines that could be injected via a malicious worktree name
|
|
64
95
|
// to prevent newline injection in git commit messages
|
|
65
96
|
if (safeDesc.includes("\n")) {
|
|
66
|
-
safeDesc = safeDesc.replace(/[\
|
|
97
|
+
safeDesc = safeDesc.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, " ");
|
|
67
98
|
}
|
|
68
|
-
execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env:
|
|
99
|
+
execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
100
|
+
// Issue 1 fix: check signal before branch creation
|
|
101
|
+
if (options.signal?.aborted) break;
|
|
69
102
|
// Create branch in the main repo pointing to this worktree's HEAD
|
|
103
|
+
let branchError: Error | null = null;
|
|
70
104
|
try {
|
|
71
|
-
execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env:
|
|
72
|
-
} catch {
|
|
105
|
+
execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
branchError = err instanceof Error ? err : new Error(String(err));
|
|
73
108
|
// Branch already exists — use timestamp suffix
|
|
74
109
|
const tsBranch = `${branchName}-${Date.now()}`;
|
|
75
|
-
|
|
110
|
+
try {
|
|
111
|
+
execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
112
|
+
} catch (err2) {
|
|
113
|
+
// Both branch attempts failed — accumulate error for outer catch
|
|
114
|
+
const err2_msg = err2 instanceof Error ? err2.message : String(err2);
|
|
115
|
+
throw new Error(`branch creation failed (${branchName} already exists): ${branchError.message}; fallback branch also failed: ${err2_msg}`);
|
|
116
|
+
}
|
|
76
117
|
}
|
|
77
118
|
result.committedBranches.push(branchName);
|
|
78
|
-
//
|
|
119
|
+
// Issue 1 fix: check signal before worktree remove
|
|
120
|
+
if (options.signal?.aborted) break;
|
|
121
|
+
// Remove the worktree (branch persists).
|
|
122
|
+
// NOTE: If git worktree remove fails here after the commit succeeded,
|
|
123
|
+
// the worktree directory remains on disk orphaned from the branch.
|
|
124
|
+
// The committed changes are safe in the branch and recoverable via:
|
|
125
|
+
// git branch -D <branchName> (to clean up the branch)
|
|
126
|
+
// rm -rf <worktreePath> (to clean up the orphaned directory)
|
|
79
127
|
const removeArgs = ["worktree", "remove", "--force", worktreePath];
|
|
80
|
-
git
|
|
81
|
-
|
|
128
|
+
// Capture git status before removal since worktree won't exist after successful remove
|
|
129
|
+
const gitStatusBeforeRemove = git(worktreePath, ["status", "--porcelain"]);
|
|
130
|
+
try {
|
|
131
|
+
git(manifest.cwd, removeArgs);
|
|
132
|
+
result.removed.push(worktreePath);
|
|
133
|
+
} catch (removeError) {
|
|
134
|
+
// Commit succeeded but worktree remove failed — directory is orphaned
|
|
135
|
+
// Issue 1 fix: use fs.rmSync as fallback to clean up orphaned directory
|
|
136
|
+
try {
|
|
137
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
138
|
+
result.removed.push(worktreePath);
|
|
139
|
+
} catch {
|
|
140
|
+
result.preserved.push({ path: worktreePath, reason: `commit succeeded but worktree remove failed: ${removeError instanceof Error ? removeError.message : String(removeError)}; fs.rmSync fallback also failed` });
|
|
141
|
+
}
|
|
142
|
+
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
143
|
+
kind: "metadata",
|
|
144
|
+
relativePath: `metadata/worktree-branch-${safeBranchName}.json`,
|
|
145
|
+
content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}`, gitStatusAtCommit: gitStatusBeforeRemove }, null, 2),
|
|
146
|
+
producer: "worktree-cleanup",
|
|
147
|
+
});
|
|
148
|
+
result.artifactPaths.push(artifact.path);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
82
151
|
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
83
152
|
kind: "metadata",
|
|
84
|
-
relativePath: `metadata/worktree-branch-${
|
|
85
|
-
content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}
|
|
153
|
+
relativePath: `metadata/worktree-branch-${safeBranchName}.json`,
|
|
154
|
+
content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}`, gitStatusAtCommit: gitStatusBeforeRemove }, null, 2),
|
|
86
155
|
producer: "worktree-cleanup",
|
|
87
156
|
});
|
|
88
157
|
result.artifactPaths.push(artifact.path);
|
|
89
158
|
} catch (error) {
|
|
90
159
|
// Fallback to preserving dirty worktree
|
|
160
|
+
// FIX: entry is a DirEnt object, must use entry.name
|
|
161
|
+
const safeFallbackName = sanitizeFilename(entry.name);
|
|
91
162
|
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
92
163
|
kind: "diff",
|
|
93
|
-
relativePath: `cleanup/${
|
|
164
|
+
relativePath: `cleanup/${safeFallbackName}.diff`,
|
|
94
165
|
content: captureDiff(worktreePath),
|
|
95
166
|
producer: "worktree-cleanup",
|
|
96
167
|
});
|
|
@@ -99,6 +170,10 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
|
|
|
99
170
|
}
|
|
100
171
|
continue;
|
|
101
172
|
}
|
|
173
|
+
// Issue 3 fix: sanitize entry.name before using in git commands
|
|
174
|
+
const safeEntryName = sanitizeBranchPart(entry.name);
|
|
175
|
+
// Issue 1 fix: check signal before non-dirty worktree remove
|
|
176
|
+
if (options.signal?.aborted) break;
|
|
102
177
|
const args = ["worktree", "remove"];
|
|
103
178
|
if (options.force) args.push("--force");
|
|
104
179
|
args.push(worktreePath);
|
|
@@ -112,9 +187,25 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
|
|
|
112
187
|
}
|
|
113
188
|
|
|
114
189
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
190
|
+
// Issue 2 fix: run git worktree prune to clean up any orphaned worktree references
|
|
191
|
+
// after processing all worktrees. This helps clean up directories left orphaned
|
|
192
|
+
// when git worktree remove failed after a successful commit.
|
|
193
|
+
git(manifest.cwd, ["worktree", "prune"]);
|
|
194
|
+
} catch (error) {
|
|
117
195
|
// Non-critical cleanup.
|
|
196
|
+
logInternalError("cleanup.worktreePrune", error instanceof Error ? error : new Error(String(error)));
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
// Issue 1 fix: Use rmdirSync which fails atomically on non-empty directories,
|
|
200
|
+
// avoiding TOCTOU race between readdirSync check and rmSync removal.
|
|
201
|
+
// Ignore ENOENT (already removed) and ENOTEMPTY (became non-empty between check and remove).
|
|
202
|
+
fs.rmdirSync(worktreeRoot);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Non-critical cleanup. Ignore ENOENT and ENOTEMPTY.
|
|
205
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
206
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY") {
|
|
207
|
+
// Optionally log unexpected errors in debug mode
|
|
208
|
+
}
|
|
118
209
|
}
|
|
119
210
|
return result;
|
|
120
211
|
}
|