@pugi/cli 0.1.0-beta.49 → 0.1.0-beta.50
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/dist/core/consensus/diff-capture.js +39 -3
- package/dist/core/engine/budgets.js +1 -1
- package/dist/core/path-security.js +284 -2
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/runtime/cli.js +24 -1
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/bash.js +203 -4
- package/dist/tui/repl-splash-mascot.js +19 -7
- package/package.json +2 -2
|
@@ -128,7 +128,18 @@ function captureFromPr(cwd, pr) {
|
|
|
128
128
|
// Fetch the PR head into a private ref so we have local objects to
|
|
129
129
|
// diff against. `pull/<num>/head` is GitHub's special refspec exposed
|
|
130
130
|
// to anyone with read access on the repo.
|
|
131
|
-
|
|
131
|
+
//
|
|
132
|
+
// The leading `+` (force-update) is REQUIRED: if a prior invocation
|
|
133
|
+
// died before reaching the `finally` cleanup (SIGKILL, host crash,
|
|
134
|
+
// operator Ctrl-C inside a `try` block in a wrapping caller, hung
|
|
135
|
+
// gh CLI), the tempRef survives on disk. Without `+`, the next
|
|
136
|
+
// `fetch <pr>/head:<tempRef>` aborts with
|
|
137
|
+
// `! [rejected] pull/N/head -> refs/pugi/consensus-pr-N (non-fast-forward)`
|
|
138
|
+
// and the entire consensus run fails for an operator who did nothing
|
|
139
|
+
// wrong. `+` semantics: replace the ref unconditionally — exactly
|
|
140
|
+
// the recovery behavior we want for a sandboxed `refs/pugi/...` slot
|
|
141
|
+
// that no human ever reads.
|
|
142
|
+
safeExec(cwd, 'git', ['fetch', 'origin', `+pull/${pr}/head:${tempRef}`]);
|
|
132
143
|
try {
|
|
133
144
|
// Resolve the base ref to diff against. Prefer the PR's declared
|
|
134
145
|
// base; fall back to `origin/main`. We compute the merge-base so a
|
|
@@ -163,8 +174,10 @@ function captureFromPr(cwd, pr) {
|
|
|
163
174
|
safeExec(cwd, 'git', ['update-ref', '-d', tempRef]);
|
|
164
175
|
}
|
|
165
176
|
catch {
|
|
166
|
-
// Swallow: a leftover ref under refs/pugi/ is harmless. The
|
|
167
|
-
// run
|
|
177
|
+
// Swallow: a leftover ref under refs/pugi/ is harmless. The
|
|
178
|
+
// next run force-overwrites it via `fetch +pull/<n>/head:<ref>`
|
|
179
|
+
// (note the leading `+` in the refspec above), so a stale tempRef
|
|
180
|
+
// never blocks a future invocation even if cleanup never runs.
|
|
168
181
|
}
|
|
169
182
|
}
|
|
170
183
|
}
|
|
@@ -229,6 +242,29 @@ function captureFromRange(cwd, baseRef, commit) {
|
|
|
229
242
|
const fullCommit = safeExec(cwd, 'git', ['rev-parse', commit]).trim();
|
|
230
243
|
if (!fullCommit)
|
|
231
244
|
throw new Error(`Unknown commit ref: ${commit}`);
|
|
245
|
+
// Task #63 (2026-05-27) — refresh the base ref via `git fetch` BEFORE
|
|
246
|
+
// resolving. Previously a stale local `main` (last fetched days ago)
|
|
247
|
+
// would silently produce a diff containing every commit that landed
|
|
248
|
+
// upstream after the fetch, swamping the model's review с unrelated
|
|
249
|
+
// changes. The fetch is opportunistic: failures (offline, auth) are
|
|
250
|
+
// swallowed so the existing rev-parse path stays the safety net и
|
|
251
|
+
// returns the clear "Unknown base ref" error if the stale local copy
|
|
252
|
+
// is also missing.
|
|
253
|
+
//
|
|
254
|
+
// We fetch ONLY the base ref (not all refs) so the overhead is
|
|
255
|
+
// bounded — typical PR-style review payload, base = main, one ref.
|
|
256
|
+
// `--no-tags --quiet` keeps the network footprint minimal.
|
|
257
|
+
const remoteCandidate = baseRef.includes('/') ? baseRef : `origin/${baseRef}`;
|
|
258
|
+
if (remoteCandidate.startsWith('origin/')) {
|
|
259
|
+
const bareRef = remoteCandidate.slice('origin/'.length);
|
|
260
|
+
safeExecOptional(cwd, 'git', [
|
|
261
|
+
'fetch',
|
|
262
|
+
'--no-tags',
|
|
263
|
+
'--quiet',
|
|
264
|
+
'origin',
|
|
265
|
+
bareRef,
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
232
268
|
// Resolve the base: accept already-qualified refs (`origin/main`,
|
|
233
269
|
// `refs/heads/foo`) and bare branch names. If the bare name isn't
|
|
234
270
|
// locally resolvable, retry against `origin/<name>` — the common
|
|
@@ -17,7 +17,7 @@ export const beta1DefaultBudgets = {
|
|
|
17
17
|
code: { maxTokens: 80_000, maxToolCalls: 20 },
|
|
18
18
|
build: { maxTokens: 200_000, maxToolCalls: 30 },
|
|
19
19
|
plan: { maxTokens: 200_000, maxToolCalls: 8 },
|
|
20
|
-
explain: { maxTokens: 40_000, maxToolCalls:
|
|
20
|
+
explain: { maxTokens: 40_000, maxToolCalls: 10 },
|
|
21
21
|
review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
|
|
22
22
|
};
|
|
23
23
|
/**
|
|
@@ -1,5 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Path-security gate for Pugi CLI file operations.
|
|
3
|
+
*
|
|
4
|
+
* The original `resolveWorkspacePath` (preserved below) is the read-path
|
|
5
|
+
* gate: it stops relative-path traversal, URL-encoded traversal, and
|
|
6
|
+
* symlink escapes at the leaf. It is kept as the existing call surface
|
|
7
|
+
* for `read` and other non-mutating tools.
|
|
8
|
+
*
|
|
9
|
+
* `assertSafeWritePath` is a stricter, write-only gate ported from
|
|
10
|
+
* KeiSeiLab/KeiSeiKit's Rust `path_guard.rs` (Apache-2.0). It adds:
|
|
11
|
+
*
|
|
12
|
+
* 1. `..` segment rejection BEFORE any filesystem touch.
|
|
13
|
+
* 2. Walk-up canonicalize: finds the deepest existing ancestor,
|
|
14
|
+
* canonicalizes it (resolving every symlink in the existing
|
|
15
|
+
* prefix), then reattaches the non-existent tail. This closes
|
|
16
|
+
* the "parent's parent is a symlink" bypass that catches naive
|
|
17
|
+
* canonicalize-the-parent implementations.
|
|
18
|
+
* 3. Leaf-symlink reject: refuse to write through a symlink even
|
|
19
|
+
* when the file already exists. Covers dangling symlinks too.
|
|
20
|
+
* 4. Fail-CLOSED on empty allowed roots: an empty PUGI_ALLOWED_ROOTS
|
|
21
|
+
* means "no writes anywhere" rather than "writes allowed
|
|
22
|
+
* everywhere".
|
|
23
|
+
* 5. Containment check against canonicalized allowed roots.
|
|
24
|
+
* 6. Denylist of system, credential, and shell-init paths even
|
|
25
|
+
* when they sit inside an allowed root (defense in depth — a
|
|
26
|
+
* misconfigured root pointing at `$HOME` still cannot clobber
|
|
27
|
+
* `~/.ssh/id_ed25519`).
|
|
28
|
+
*
|
|
29
|
+
* ---
|
|
30
|
+
* Portions of `assertSafeWritePath` and `canonicalizeWithWalkUp` are
|
|
31
|
+
* derived from KeiSeiLab/KeiSeiKit `kei-mcp/src/handlers/safe_tools/
|
|
32
|
+
* path_guard.rs` (Apache-2.0). See `licenses/keiseikit-LICENSE-NOTICE.md`
|
|
33
|
+
* at the repo root for attribution and the full upstream license
|
|
34
|
+
* reference.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, lstatSync, realpathSync, } from 'node:fs';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep, } from 'node:path';
|
|
3
39
|
/**
|
|
4
40
|
* Resolve and validate that an inputPath stays within the workspace.
|
|
5
41
|
*
|
|
@@ -60,4 +96,250 @@ function isInsideWorkspace(child, workspaceRoot) {
|
|
|
60
96
|
const rel = relative(workspaceRoot, child);
|
|
61
97
|
return Boolean(rel) && !rel.startsWith('..') && rel !== '..';
|
|
62
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Apply the full KeiSei write-path guard. Returns the canonical
|
|
101
|
+
* absolute path on success, throws on rejection.
|
|
102
|
+
*
|
|
103
|
+
* Use this for any operation that mutates the filesystem (`write`,
|
|
104
|
+
* `edit`, atomic-rename targets). The error message is safe to surface
|
|
105
|
+
* to the operator — it never echoes filesystem secrets.
|
|
106
|
+
*/
|
|
107
|
+
export function assertSafeWritePath(inputPath, options = {}) {
|
|
108
|
+
if (typeof inputPath !== 'string' || inputPath.length === 0) {
|
|
109
|
+
throw new Error('file_path: empty');
|
|
110
|
+
}
|
|
111
|
+
if (inputPath.includes('\0')) {
|
|
112
|
+
throw new Error('file_path: null byte rejected');
|
|
113
|
+
}
|
|
114
|
+
// KeiSei step #1: reject `..` segments before any FS work. We split on
|
|
115
|
+
// both `/` and the platform separator so a Windows-style `..\foo`
|
|
116
|
+
// input cannot smuggle a traversal through on macOS test paths.
|
|
117
|
+
const decoded = decodeURIComponent(inputPath);
|
|
118
|
+
const segments = decoded.split(/[/\\]/);
|
|
119
|
+
if (segments.some((seg) => seg === '..')) {
|
|
120
|
+
throw new Error(`file_path: '..' segment not allowed in ${inputPath}`);
|
|
121
|
+
}
|
|
122
|
+
// KeiSei step #3: refuse to write through a symlink leaf. We must
|
|
123
|
+
// check the LITERAL input path (pre-canonicalize) because `realpath`
|
|
124
|
+
// unconditionally resolves symlinks — so by the time we have the
|
|
125
|
+
// canonical path the symlink fingerprint is already gone. `lstatSync`
|
|
126
|
+
// does NOT follow symlinks, which is exactly what we need.
|
|
127
|
+
const cwdRoot = options.root ?? process.cwd();
|
|
128
|
+
const literalAbsolute = isAbsolute(decoded) ? decoded : resolve(cwdRoot, decoded);
|
|
129
|
+
try {
|
|
130
|
+
const meta = lstatSync(literalAbsolute);
|
|
131
|
+
if (meta.isSymbolicLink()) {
|
|
132
|
+
throw new Error(`file_path: leaf is a symlink (refusing to follow): ${literalAbsolute}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const code = error.code;
|
|
137
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
138
|
+
throw error;
|
|
139
|
+
// ENOENT/ENOTDIR is fine — the leaf simply does not exist yet
|
|
140
|
+
// (write-create path). The walk-up canonicalize below handles it.
|
|
141
|
+
}
|
|
142
|
+
const canonical = canonicalizeWithWalkUp(decoded, cwdRoot);
|
|
143
|
+
// KeiSei step #4: fail-CLOSED on empty allowed roots.
|
|
144
|
+
const roots = computeAllowedRoots(options);
|
|
145
|
+
if (roots.length === 0) {
|
|
146
|
+
throw new Error("file_path: allowed_roots is empty — refusing all writes " +
|
|
147
|
+
'(set PUGI_ALLOWED_ROOTS to a non-empty value or run from a real cwd)');
|
|
148
|
+
}
|
|
149
|
+
// KeiSei step #5: containment check against canonicalized allowed
|
|
150
|
+
// roots. Each root is normalized to end in `sep` so `/tmp/wsX` does
|
|
151
|
+
// not accidentally pass containment for `/tmp/ws`.
|
|
152
|
+
const inAllowedRoot = roots.some((r) => isContainedIn(canonical, r));
|
|
153
|
+
if (!inAllowedRoot) {
|
|
154
|
+
throw new Error(`file_path: outside allowed roots ${JSON.stringify(roots)}: ${canonical}`);
|
|
155
|
+
}
|
|
156
|
+
// KeiSei step #6: denylist. Applied AFTER containment so a
|
|
157
|
+
// misconfigured root pointing at `/` or `$HOME` still cannot clobber
|
|
158
|
+
// sensitive files. We canonicalize HOME so a $HOME that lives under
|
|
159
|
+
// a symlinked prefix (macOS /var → /private/var, Linux /home →
|
|
160
|
+
// /usr/home on some FreeBSD-style mounts) still matches the
|
|
161
|
+
// canonical write target.
|
|
162
|
+
assertNotDenylisted(canonical, canonicalizeHomeDir(options.homeDir ?? homedir()));
|
|
163
|
+
return canonical;
|
|
164
|
+
}
|
|
165
|
+
function canonicalizeHomeDir(raw) {
|
|
166
|
+
if (!raw)
|
|
167
|
+
return raw;
|
|
168
|
+
try {
|
|
169
|
+
return realpathSync.native(raw);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return raw;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Ported from KeiSeiKit `canonicalize_with_walk_up`. Finds the deepest
|
|
177
|
+
* existing ancestor, canonicalizes it (resolving every symlink in the
|
|
178
|
+
* existing prefix), then reattaches the non-existent tail components.
|
|
179
|
+
*
|
|
180
|
+
* This closes the "parent's parent is a symlink" bypass where naive
|
|
181
|
+
* implementations canonicalize only the immediate parent.
|
|
182
|
+
*/
|
|
183
|
+
export function canonicalizeWithWalkUp(inputPath, cwd) {
|
|
184
|
+
const absolute = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath);
|
|
185
|
+
let current = absolute;
|
|
186
|
+
const tail = [];
|
|
187
|
+
// Hard cap on walk-up iterations — defense against pathological
|
|
188
|
+
// inputs that somehow evade `dirname` termination.
|
|
189
|
+
const maxIterations = 4096;
|
|
190
|
+
let iterations = 0;
|
|
191
|
+
while (true) {
|
|
192
|
+
iterations += 1;
|
|
193
|
+
if (iterations > maxIterations) {
|
|
194
|
+
throw new Error(`file_path: walk-up exceeded ${maxIterations} iterations: ${absolute}`);
|
|
195
|
+
}
|
|
196
|
+
if (existsSync(current)) {
|
|
197
|
+
let canonicalExisting;
|
|
198
|
+
try {
|
|
199
|
+
canonicalExisting = realpathSync.native(current);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
throw new Error(`file_path: canonicalize ${current}: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
// Reattach tail in original order (we pushed leaf-first).
|
|
205
|
+
let result = canonicalExisting;
|
|
206
|
+
for (let i = tail.length - 1; i >= 0; i -= 1) {
|
|
207
|
+
result = resolve(result, tail[i]);
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
const name = basename(current);
|
|
212
|
+
const parent = dirname(current);
|
|
213
|
+
if (!name || parent === current) {
|
|
214
|
+
throw new Error(`file_path: walked to root without finding existing dir: ${absolute}`);
|
|
215
|
+
}
|
|
216
|
+
tail.push(name);
|
|
217
|
+
current = parent;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function computeAllowedRoots(options) {
|
|
221
|
+
const envValue = options.allowedRootsEnv ?? process.env.PUGI_ALLOWED_ROOTS;
|
|
222
|
+
if (envValue !== undefined) {
|
|
223
|
+
return envValue
|
|
224
|
+
.split(':')
|
|
225
|
+
.filter((s) => s.length > 0)
|
|
226
|
+
.map(canonicalizeRoot)
|
|
227
|
+
.filter((s) => s !== null);
|
|
228
|
+
}
|
|
229
|
+
// No env override: implicit root is the caller-supplied cwd (defaults
|
|
230
|
+
// to `process.cwd()`).
|
|
231
|
+
const cwd = options.root ?? process.cwd();
|
|
232
|
+
const canon = canonicalizeRoot(cwd);
|
|
233
|
+
return canon ? [canon] : [];
|
|
234
|
+
}
|
|
235
|
+
function canonicalizeRoot(raw) {
|
|
236
|
+
if (!raw)
|
|
237
|
+
return null;
|
|
238
|
+
let canon;
|
|
239
|
+
try {
|
|
240
|
+
canon = realpathSync.native(raw);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
canon = resolve(raw);
|
|
244
|
+
}
|
|
245
|
+
if (!canon)
|
|
246
|
+
return null;
|
|
247
|
+
return canon.endsWith(sep) ? canon : canon + sep;
|
|
248
|
+
}
|
|
249
|
+
function isContainedIn(canonicalChild, rootWithSep) {
|
|
250
|
+
const rootNoSep = rootWithSep.endsWith(sep)
|
|
251
|
+
? rootWithSep.slice(0, -1)
|
|
252
|
+
: rootWithSep;
|
|
253
|
+
if (canonicalChild === rootNoSep)
|
|
254
|
+
return true;
|
|
255
|
+
return canonicalChild.startsWith(rootWithSep);
|
|
256
|
+
}
|
|
257
|
+
function assertNotDenylisted(canonical, homeDir) {
|
|
258
|
+
// System paths — match as prefix-with-separator so `/etc-fake/` is
|
|
259
|
+
// not denied while `/etc/passwd` is.
|
|
260
|
+
const systemDenyPrefixes = [
|
|
261
|
+
'/etc/',
|
|
262
|
+
'/usr/',
|
|
263
|
+
'/System/',
|
|
264
|
+
'/Library/Application Support/',
|
|
265
|
+
'/var/db/',
|
|
266
|
+
'/var/log/',
|
|
267
|
+
'/var/root/',
|
|
268
|
+
'/private/etc/',
|
|
269
|
+
'/private/usr/',
|
|
270
|
+
'/private/var/db/',
|
|
271
|
+
'/private/var/log/',
|
|
272
|
+
'/private/var/root/',
|
|
273
|
+
'/root/',
|
|
274
|
+
'/bin/',
|
|
275
|
+
'/sbin/',
|
|
276
|
+
];
|
|
277
|
+
for (const prefix of systemDenyPrefixes) {
|
|
278
|
+
if (canonical.startsWith(prefix)) {
|
|
279
|
+
throw new Error(`file_path: denied (system dir): ${canonical}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!homeDir)
|
|
283
|
+
return;
|
|
284
|
+
// Credential and substrate directories.
|
|
285
|
+
const homeDirSecrets = [
|
|
286
|
+
'.ssh/',
|
|
287
|
+
'.aws/',
|
|
288
|
+
'.gnupg/',
|
|
289
|
+
'.config/gcloud/',
|
|
290
|
+
'.kube/',
|
|
291
|
+
'.docker/',
|
|
292
|
+
'.claude/',
|
|
293
|
+
'.grok/',
|
|
294
|
+
'.gemini/',
|
|
295
|
+
'.copilot/',
|
|
296
|
+
'.kimi/',
|
|
297
|
+
'.pugi/credentials/',
|
|
298
|
+
];
|
|
299
|
+
for (const secret of homeDirSecrets) {
|
|
300
|
+
const full = joinHome(homeDir, secret);
|
|
301
|
+
if (canonical.startsWith(full)) {
|
|
302
|
+
throw new Error(`file_path: denied (secret/substrate dir): ${canonical}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Credential files (exact match).
|
|
306
|
+
const homeFileSecrets = [
|
|
307
|
+
'.npmrc',
|
|
308
|
+
'.cargo/credentials',
|
|
309
|
+
'.cargo/credentials.toml',
|
|
310
|
+
'.docker/config.json',
|
|
311
|
+
'.netrc',
|
|
312
|
+
'.pgpass',
|
|
313
|
+
];
|
|
314
|
+
for (const secret of homeFileSecrets) {
|
|
315
|
+
const full = joinHome(homeDir, secret);
|
|
316
|
+
if (canonical === full) {
|
|
317
|
+
throw new Error(`file_path: denied (credential file): ${canonical}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Shell-init files (exact match).
|
|
321
|
+
const initFiles = [
|
|
322
|
+
'.zshrc',
|
|
323
|
+
'.bashrc',
|
|
324
|
+
'.profile',
|
|
325
|
+
'.bash_profile',
|
|
326
|
+
'.zprofile',
|
|
327
|
+
'.zshenv',
|
|
328
|
+
'.bash_login',
|
|
329
|
+
'.bash_logout',
|
|
330
|
+
'.inputrc',
|
|
331
|
+
'.gitconfig',
|
|
332
|
+
'.config/fish/config.fish',
|
|
333
|
+
];
|
|
334
|
+
for (const file of initFiles) {
|
|
335
|
+
const full = joinHome(homeDir, file);
|
|
336
|
+
if (canonical === full) {
|
|
337
|
+
throw new Error(`file_path: denied (shell-init file): ${canonical}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function joinHome(homeDir, rest) {
|
|
342
|
+
const trimmedHome = homeDir.endsWith(sep) ? homeDir.slice(0, -1) : homeDir;
|
|
343
|
+
return `${trimmedHome}${sep}${rest}`;
|
|
344
|
+
}
|
|
63
345
|
//# sourceMappingURL=path-security.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-worktree sweep — leak L23 (Pugi α7).
|
|
3
|
+
*
|
|
4
|
+
* Pairs with `WorktreeManager` to find worktrees whose agent has either:
|
|
5
|
+
*
|
|
6
|
+
* 1. No corresponding progress JSON under `.pugi/agent-progress/` (the
|
|
7
|
+
* writer never wrote one, OR the file was archived away), OR
|
|
8
|
+
* 2. A progress JSON whose `status` is `completed` or `failed` (the
|
|
9
|
+
* agent finished — the worktree is no longer load-bearing).
|
|
10
|
+
*
|
|
11
|
+
* Crashed agents (status `running` with a stale lastUpdate) are NOT
|
|
12
|
+
* swept; the leak-research contract says we LEAVE worktrees of crashed
|
|
13
|
+
* agents in place for forensics. The operator can `pugi worktrees
|
|
14
|
+
* cleanup <id>` manually after the post-mortem.
|
|
15
|
+
*
|
|
16
|
+
* Implementation notes:
|
|
17
|
+
*
|
|
18
|
+
* - The cleanup is read-then-write: list the manager state, classify
|
|
19
|
+
* each entry, then call `manager.cleanup()` per stale id. Errors per
|
|
20
|
+
* entry never abort the loop — they accumulate in the report.
|
|
21
|
+
* - The progress directory we look at is the SAME `.pugi/agent-progress/`
|
|
22
|
+
* root the writer uses (resolved via the writer's env-aware helper).
|
|
23
|
+
* - `runStaleCleanup` is the only public entry point. The internal
|
|
24
|
+
* classifier is exported as `classifyStale` for spec coverage.
|
|
25
|
+
*
|
|
26
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { resolveProgressDir } from '../agent-progress/writer.js';
|
|
31
|
+
import { validateAgentProgress } from '../agent-progress/schema.js';
|
|
32
|
+
import { WorktreeManager, } from './manager.js';
|
|
33
|
+
/**
|
|
34
|
+
* Classify a single worktree listing against the on-disk progress JSON.
|
|
35
|
+
* Pure-ish — only reads from the progress file when one exists. Exposed
|
|
36
|
+
* for the spec; production callers go through `runStaleCleanup`.
|
|
37
|
+
*/
|
|
38
|
+
export function classifyStale(listing, progressDir, options = {}) {
|
|
39
|
+
const grace = options.crashedGraceMs ?? 30 * 60 * 1000;
|
|
40
|
+
const now = options.now ?? Date.now;
|
|
41
|
+
const progressPath = join(progressDir, `${listing.agentId}.json`);
|
|
42
|
+
if (!existsSync(progressPath)) {
|
|
43
|
+
return 'orphan_no_progress';
|
|
44
|
+
}
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = readFileSync(progressPath, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Unreadable progress file — treat as orphan so the operator can
|
|
51
|
+
// see it in the cleanup report. We never act on it silently.
|
|
52
|
+
return 'orphan_no_progress';
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return 'orphan_no_progress';
|
|
60
|
+
}
|
|
61
|
+
const validation = validateAgentProgress(parsed);
|
|
62
|
+
if (!validation.ok)
|
|
63
|
+
return 'orphan_no_progress';
|
|
64
|
+
const progress = validation.value;
|
|
65
|
+
if (progress.status === 'completed')
|
|
66
|
+
return 'stale_completed';
|
|
67
|
+
if (progress.status === 'failed')
|
|
68
|
+
return 'stale_failed';
|
|
69
|
+
// status === 'running' — check for crashed (stale lastUpdate)
|
|
70
|
+
const lastTs = Date.parse(progress.lastUpdate);
|
|
71
|
+
if (Number.isFinite(lastTs) && now() - lastTs > grace) {
|
|
72
|
+
return 'crashed_running';
|
|
73
|
+
}
|
|
74
|
+
return 'active';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run a single stale-worktree sweep. Removes only `stale_completed`,
|
|
78
|
+
* `stale_failed`, and `orphan_no_progress` entries. Crashed-running
|
|
79
|
+
* entries are LEFT IN PLACE per the L23 forensics contract, but they
|
|
80
|
+
* appear in the report so the operator can act on them manually.
|
|
81
|
+
*/
|
|
82
|
+
export function runStaleCleanup(managerOpts, options = {}) {
|
|
83
|
+
const manager = new WorktreeManager(managerOpts);
|
|
84
|
+
const report = {
|
|
85
|
+
scanned: 0,
|
|
86
|
+
classified: [],
|
|
87
|
+
removed: [],
|
|
88
|
+
preserved: [],
|
|
89
|
+
errors: [],
|
|
90
|
+
};
|
|
91
|
+
const listResult = manager.list();
|
|
92
|
+
if (!listResult.ok) {
|
|
93
|
+
report.errors.push({ agentId: '', detail: `list failed: ${listResult.detail}` });
|
|
94
|
+
return report;
|
|
95
|
+
}
|
|
96
|
+
const progressDir = options.progressDir ?? resolveProgressDir();
|
|
97
|
+
const listings = listResult.value;
|
|
98
|
+
report.scanned = listings.length;
|
|
99
|
+
for (const entry of listings) {
|
|
100
|
+
const cls = classifyStale(entry, progressDir, options);
|
|
101
|
+
report.classified.push({ agentId: entry.agentId, path: entry.path, class: cls });
|
|
102
|
+
if (cls === 'active' || cls === 'crashed_running') {
|
|
103
|
+
report.preserved.push({ agentId: entry.agentId, reason: cls });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (options.dryRun) {
|
|
107
|
+
// Dry-run reports the classification only — no cleanup, no remove.
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const cleanup = manager.cleanup(entry.agentId);
|
|
111
|
+
if (cleanup.ok) {
|
|
112
|
+
report.removed.push(entry.path);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
report.errors.push({
|
|
116
|
+
agentId: entry.agentId,
|
|
117
|
+
detail: `${cleanup.reason}: ${cleanup.detail}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return report;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=cleanup.js.map
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorktreeManager — leak L23 (Pugi α7).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code spawns a separate git worktree per dispatched sub-agent so
|
|
5
|
+
* parallel agents never collide on the filesystem. This module is Pugi's
|
|
6
|
+
* parity surface: it binds a worktree to an `agentId` (the same kebab-case
|
|
7
|
+
* id the agent-progress JSON uses) and exposes a tiny CRUD-shaped API
|
|
8
|
+
* the dispatcher can call before/after spawning a sub-persona.
|
|
9
|
+
*
|
|
10
|
+
* Differences from the existing `core/edits/worktree.ts` primitive:
|
|
11
|
+
*
|
|
12
|
+
* - The edits primitive keys worktrees by UUID and serves the manual
|
|
13
|
+
* `pugi worktree create/promote/drop` operator surface (one-shot
|
|
14
|
+
* scratch trees that get promoted back to the operator's tree).
|
|
15
|
+
* - This manager keys worktrees by AGENT ID so the dispatcher (and any
|
|
16
|
+
* observer like `pugi worktrees list`) can correlate a worktree
|
|
17
|
+
* with the in-flight agent driving it.
|
|
18
|
+
*
|
|
19
|
+
* The two namespaces coexist safely because the directory shapes differ:
|
|
20
|
+
*
|
|
21
|
+
* - manager: `<cwd>/.pugi/worktrees/<agent-id>/` (kebab-case)
|
|
22
|
+
* - primitive `<cwd>/.pugi/worktrees/<uuid>/` (dashed hex)
|
|
23
|
+
*
|
|
24
|
+
* The manager refuses to operate on a path whose basename does not match
|
|
25
|
+
* the agent-id regex `[a-zA-Z0-9_-]+` and does NOT collide with the UUID
|
|
26
|
+
* shape (`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`).
|
|
27
|
+
*
|
|
28
|
+
* Concurrency:
|
|
29
|
+
*
|
|
30
|
+
* - `create(agentId)` is mutex'd per agent-id in process so two
|
|
31
|
+
* concurrent dispatch hooks racing on the same id resolve to the
|
|
32
|
+
* same handle (the second call awaits the first).
|
|
33
|
+
* - At the filesystem level the worktree dir's existence is the lock:
|
|
34
|
+
* if a stale dir for an agent-id survives from a previous process
|
|
35
|
+
* `create` returns `already_exists` so the caller can decide to
|
|
36
|
+
* reuse-or-cleanup explicitly (avoids `git worktree add` racing on
|
|
37
|
+
* the same path which surfaces a noisy git error).
|
|
38
|
+
*
|
|
39
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
40
|
+
*/
|
|
41
|
+
import { spawnSync } from 'node:child_process';
|
|
42
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
43
|
+
import { resolve, sep } from 'node:path';
|
|
44
|
+
/**
|
|
45
|
+
* Filename-safe + dispatcher-safe agent id.
|
|
46
|
+
*
|
|
47
|
+
* Mirrors the regex on `AgentProgress.agentId` so an id that can write a
|
|
48
|
+
* progress file is exactly the id this manager will accept. The UUID
|
|
49
|
+
* exclusion below makes sure a caller never accidentally collides with
|
|
50
|
+
* the existing UUID-based scratch worktrees managed by
|
|
51
|
+
* `core/edits/worktree.ts`.
|
|
52
|
+
*/
|
|
53
|
+
const AGENT_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
54
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
55
|
+
/** Validate an agent id. Exported for spec coverage + caller pre-checks. */
|
|
56
|
+
export function isValidAgentId(id) {
|
|
57
|
+
if (typeof id !== 'string' || id.length === 0 || id.length > 128)
|
|
58
|
+
return false;
|
|
59
|
+
if (!AGENT_ID_RE.test(id))
|
|
60
|
+
return false;
|
|
61
|
+
if (UUID_RE.test(id))
|
|
62
|
+
return false;
|
|
63
|
+
// Disallow `.` / `..` and the archive subdir name reserved by
|
|
64
|
+
// agent-progress cleanup so list/scan never crosses paths.
|
|
65
|
+
if (id === '.' || id === '..' || id === 'archive')
|
|
66
|
+
return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the absolute path for an agent's worktree without touching the
|
|
71
|
+
* filesystem. Exposed so the dispatcher can compute a `cwd` for the
|
|
72
|
+
* spawned child without invoking the manager (useful in dry-run paths).
|
|
73
|
+
*/
|
|
74
|
+
export function worktreePathFor(cwd, agentId) {
|
|
75
|
+
return resolve(cwd, '.pugi', 'worktrees', agentId);
|
|
76
|
+
}
|
|
77
|
+
/** Shape an agent-id + slug into the canonical branch name. */
|
|
78
|
+
export function branchNameFor(agentId, slug) {
|
|
79
|
+
const clean = (slug ?? '')
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
82
|
+
.replace(/^-+|-+$/g, '')
|
|
83
|
+
.slice(0, 24);
|
|
84
|
+
return clean.length > 0 ? `agent/${agentId}-${clean}` : `agent/${agentId}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Class wrapping git worktree operations for a single workspace root.
|
|
88
|
+
* Stateless across instances except for the per-agent-id mutex map.
|
|
89
|
+
*/
|
|
90
|
+
export class WorktreeManager {
|
|
91
|
+
cwd;
|
|
92
|
+
spawnGit;
|
|
93
|
+
inFlight = new Map();
|
|
94
|
+
constructor(options) {
|
|
95
|
+
this.cwd = options.cwd;
|
|
96
|
+
this.spawnGit = options.spawnGit ?? defaultSpawnGit;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Create a worktree bound to `agentId`. Idempotent under concurrent
|
|
100
|
+
* callers: the second concurrent `create(<same id>)` awaits the first.
|
|
101
|
+
* A subsequent `create()` after a process restart on an EXISTING dir
|
|
102
|
+
* returns `already_exists` so the caller can choose to cleanup-then-
|
|
103
|
+
* recreate explicitly.
|
|
104
|
+
*/
|
|
105
|
+
async create(agentId, options = {}) {
|
|
106
|
+
if (!isValidAgentId(agentId)) {
|
|
107
|
+
return { ok: false, reason: 'invalid_agent_id', detail: `agent id ${String(agentId)} does not match ${AGENT_ID_RE} or collides with UUID shape` };
|
|
108
|
+
}
|
|
109
|
+
const pending = this.inFlight.get(agentId);
|
|
110
|
+
if (pending)
|
|
111
|
+
return pending;
|
|
112
|
+
const promise = this.createUnlocked(agentId, options);
|
|
113
|
+
this.inFlight.set(agentId, promise);
|
|
114
|
+
try {
|
|
115
|
+
return await promise;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
this.inFlight.delete(agentId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async createUnlocked(agentId, options) {
|
|
122
|
+
const gitDir = this.spawnGit(['rev-parse', '--git-dir'], this.cwd);
|
|
123
|
+
if (gitDir.status !== 0) {
|
|
124
|
+
return { ok: false, reason: 'not_a_git_repo', detail: `not a git repo: ${this.cwd}` };
|
|
125
|
+
}
|
|
126
|
+
const target = worktreePathFor(this.cwd, agentId);
|
|
127
|
+
if (existsSync(target)) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: 'already_exists',
|
|
131
|
+
detail: `worktree already exists at ${target}; call cleanup(${agentId}) first if reuse intended`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const baseRef = options.baseRef ?? 'HEAD';
|
|
135
|
+
const baseShaResult = this.spawnGit(['rev-parse', baseRef], this.cwd);
|
|
136
|
+
if (baseShaResult.status !== 0) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: 'git_command_failed',
|
|
140
|
+
detail: `cannot resolve base ref ${baseRef}: ${baseShaResult.stderr}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const baseSha = baseShaResult.stdout.trim();
|
|
144
|
+
const branch = branchNameFor(agentId, options.slug);
|
|
145
|
+
// Ensure the parent exists before git creates the leaf. git worktree
|
|
146
|
+
// add will create the leaf itself; pre-creating .pugi/worktrees/ keeps
|
|
147
|
+
// the failure mode local to git (we never have to mkdir the leaf).
|
|
148
|
+
const worktreesRoot = resolve(this.cwd, '.pugi', 'worktrees');
|
|
149
|
+
mkdirSync(worktreesRoot, { recursive: true });
|
|
150
|
+
// `-b <branch> <path> <base>` creates a NEW branch off `base`,
|
|
151
|
+
// checked out at `path`. We deliberately do NOT use `--detach` here
|
|
152
|
+
// (unlike the scratch primitive) so the branch survives across
|
|
153
|
+
// process restarts and the operator can `git push agent/<id>-<slug>`
|
|
154
|
+
// if they want to inspect the child's work outside the worktree.
|
|
155
|
+
const create = this.spawnGit(['worktree', 'add', '-b', branch, target, baseSha], this.cwd);
|
|
156
|
+
if (create.status !== 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: 'git_command_failed',
|
|
160
|
+
detail: `git worktree add failed: ${create.stderr || create.stdout}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
value: { agentId, path: target, branch, baseSha },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Remove the worktree bound to `agentId`. Idempotent: a missing path
|
|
170
|
+
* surfaces `worktree_missing` rather than throwing, so a double-call
|
|
171
|
+
* during dispatch teardown never crashes the parent.
|
|
172
|
+
*/
|
|
173
|
+
cleanup(agentId) {
|
|
174
|
+
if (!isValidAgentId(agentId)) {
|
|
175
|
+
return { ok: false, reason: 'invalid_agent_id', detail: `agent id ${String(agentId)} is invalid` };
|
|
176
|
+
}
|
|
177
|
+
const target = worktreePathFor(this.cwd, agentId);
|
|
178
|
+
// Guard: never let cleanup touch a path that escapes the worktrees
|
|
179
|
+
// root (defensive — the worktreePathFor builder is already bounded
|
|
180
|
+
// because the agent id regex rejects '..', but containment check
|
|
181
|
+
// is cheap and gives us a second line of defense).
|
|
182
|
+
const root = resolve(this.cwd, '.pugi', 'worktrees');
|
|
183
|
+
if (!target.startsWith(root + sep)) {
|
|
184
|
+
return { ok: false, reason: 'invalid_agent_id', detail: `resolved path ${target} escapes ${root}` };
|
|
185
|
+
}
|
|
186
|
+
if (!existsSync(target)) {
|
|
187
|
+
// Best-effort prune so a metadata-only orphan goes away too.
|
|
188
|
+
this.spawnGit(['worktree', 'prune'], this.cwd);
|
|
189
|
+
return { ok: false, reason: 'worktree_missing', detail: `no worktree at ${target}` };
|
|
190
|
+
}
|
|
191
|
+
const remove = this.spawnGit(['worktree', 'remove', '--force', target], this.cwd);
|
|
192
|
+
if (remove.status !== 0) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: 'git_command_failed',
|
|
196
|
+
detail: `git worktree remove failed: ${remove.stderr || remove.stdout}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, value: { removed: target } };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Enumerate worktrees the manager knows about. Read-only and never
|
|
203
|
+
* touches git metadata; the optional `gitTracked` flag on each entry
|
|
204
|
+
* surfaces the cross-check against `git worktree list --porcelain`.
|
|
205
|
+
*/
|
|
206
|
+
list() {
|
|
207
|
+
const root = resolve(this.cwd, '.pugi', 'worktrees');
|
|
208
|
+
if (!existsSync(root)) {
|
|
209
|
+
return { ok: true, value: [] };
|
|
210
|
+
}
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = readdirSync(root);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: 'git_command_failed',
|
|
219
|
+
detail: `readdir ${root} failed: ${err.message}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const tracked = this.collectGitTrackedWorktrees();
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const name of entries) {
|
|
225
|
+
if (!isValidAgentId(name))
|
|
226
|
+
continue;
|
|
227
|
+
const path = resolve(root, name);
|
|
228
|
+
let isDir = false;
|
|
229
|
+
try {
|
|
230
|
+
isDir = statSync(path).isDirectory();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!isDir)
|
|
236
|
+
continue;
|
|
237
|
+
const meta = tracked.get(path);
|
|
238
|
+
const branch = meta?.branch ?? branchNameFor(name);
|
|
239
|
+
const baseSha = meta?.head ?? '';
|
|
240
|
+
out.push({
|
|
241
|
+
agentId: name,
|
|
242
|
+
path,
|
|
243
|
+
branch,
|
|
244
|
+
baseSha,
|
|
245
|
+
gitTracked: meta !== undefined,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, value: out };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Parse `git worktree list --porcelain` into a `path → {branch, head}`
|
|
252
|
+
* map. Errors degrade to an empty map — `list()` still surfaces the
|
|
253
|
+
* directory entries; the caller sees `gitTracked: false` rows and can
|
|
254
|
+
* decide.
|
|
255
|
+
*/
|
|
256
|
+
collectGitTrackedWorktrees() {
|
|
257
|
+
const out = new Map();
|
|
258
|
+
const list = this.spawnGit(['worktree', 'list', '--porcelain'], this.cwd);
|
|
259
|
+
if (list.status !== 0)
|
|
260
|
+
return out;
|
|
261
|
+
let currentPath = null;
|
|
262
|
+
let currentHead = '';
|
|
263
|
+
let currentBranch = '';
|
|
264
|
+
const commit = () => {
|
|
265
|
+
if (currentPath) {
|
|
266
|
+
out.set(currentPath, { branch: currentBranch, head: currentHead });
|
|
267
|
+
}
|
|
268
|
+
currentPath = null;
|
|
269
|
+
currentHead = '';
|
|
270
|
+
currentBranch = '';
|
|
271
|
+
};
|
|
272
|
+
for (const raw of list.stdout.split('\n')) {
|
|
273
|
+
const line = raw.trimEnd();
|
|
274
|
+
if (line.length === 0) {
|
|
275
|
+
commit();
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (line.startsWith('worktree ')) {
|
|
279
|
+
// New record — flush the previous one if any.
|
|
280
|
+
commit();
|
|
281
|
+
currentPath = line.slice('worktree '.length);
|
|
282
|
+
}
|
|
283
|
+
else if (line.startsWith('HEAD ')) {
|
|
284
|
+
currentHead = line.slice('HEAD '.length);
|
|
285
|
+
}
|
|
286
|
+
else if (line.startsWith('branch ')) {
|
|
287
|
+
// `branch refs/heads/agent/foo` → `agent/foo`
|
|
288
|
+
const ref = line.slice('branch '.length);
|
|
289
|
+
currentBranch = ref.replace(/^refs\/heads\//, '');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
commit();
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function defaultSpawnGit(args, cwd) {
|
|
297
|
+
return spawnSync('git', args, {
|
|
298
|
+
cwd,
|
|
299
|
+
encoding: 'utf8',
|
|
300
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=manager.js.map
|
package/dist/runtime/cli.js
CHANGED
|
@@ -62,6 +62,7 @@ import { runAgentsCommand } from './commands/agents.js';
|
|
|
62
62
|
import { runLspCommand } from './commands/lsp.js';
|
|
63
63
|
import { runPatchCommand } from './commands/patch.js';
|
|
64
64
|
import { runWorktreeCommand } from './commands/worktree.js';
|
|
65
|
+
import { runWorktreesCommand } from './commands/worktrees.js';
|
|
65
66
|
import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
66
67
|
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
67
68
|
import { runMcpCommand } from './commands/mcp.js';
|
|
@@ -213,6 +214,10 @@ const handlers = {
|
|
|
213
214
|
web: dispatchWeb,
|
|
214
215
|
whoami,
|
|
215
216
|
worktree: dispatchWorktree,
|
|
217
|
+
// L23 (2026-05-27): `pugi worktrees <op>` (plural) — agent-bound
|
|
218
|
+
// worktree manager: `list`, `cleanup <agent-id>`, `cleanup --all-stale`.
|
|
219
|
+
// Distinct from the singular `pugi worktree` (UUID-keyed scratch).
|
|
220
|
+
worktrees: dispatchWorktrees,
|
|
216
221
|
};
|
|
217
222
|
/**
|
|
218
223
|
* α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
|
|
@@ -1075,6 +1080,24 @@ async function dispatchWorktree(args, flags, _session) {
|
|
|
1075
1080
|
if (result.exitCode !== 0)
|
|
1076
1081
|
process.exitCode = result.exitCode;
|
|
1077
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* L23 (2026-05-27): `pugi worktrees <op>` — agent-bound worktree
|
|
1085
|
+
* manager surface. Distinct from singular `pugi worktree` which
|
|
1086
|
+
* manages UUID-keyed scratch worktrees for the manual create/promote/
|
|
1087
|
+
* drop flow. The plural surface keys worktrees by agent id (the same
|
|
1088
|
+
* id `.pugi/agent-progress/<id>.json` uses) so an operator can
|
|
1089
|
+
* correlate a worktree with the in-flight agent driving it.
|
|
1090
|
+
*/
|
|
1091
|
+
async function dispatchWorktrees(args, flags, _session) {
|
|
1092
|
+
const result = await runWorktreesCommand(args, {
|
|
1093
|
+
cwd: process.cwd(),
|
|
1094
|
+
json: flags.json,
|
|
1095
|
+
dryRun: flags.dryRun,
|
|
1096
|
+
});
|
|
1097
|
+
console.log(result.text);
|
|
1098
|
+
if (result.exitCode !== 0)
|
|
1099
|
+
process.exitCode = result.exitCode;
|
|
1100
|
+
}
|
|
1078
1101
|
export async function runCli(argv) {
|
|
1079
1102
|
const { command, args, flags, isBareInvocation } = parseArgs(argv);
|
|
1080
1103
|
// Leak L22 — print the one-line bare banner once per invocation when
|
|
@@ -1708,7 +1731,7 @@ const COMMAND_HELP_BODIES = {
|
|
|
1708
1731
|
explain: [
|
|
1709
1732
|
'pugi explain "<question>" — read-only Q&A about the workspace.',
|
|
1710
1733
|
'',
|
|
1711
|
-
'Calls the engine loop in explain mode (budget:
|
|
1734
|
+
'Calls the engine loop in explain mode (budget: 10 calls / 40k tokens).',
|
|
1712
1735
|
'No file writes; safe to run against unfamiliar code.',
|
|
1713
1736
|
'',
|
|
1714
1737
|
'Examples:',
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi worktrees <op>` — leak L23 (Pugi α7).
|
|
3
|
+
*
|
|
4
|
+
* Plural surface for the agent-bound worktree manager. Distinct from the
|
|
5
|
+
* singular `pugi worktree <op>` (α7.7 Phase 1) which manages UUID-keyed
|
|
6
|
+
* scratch worktrees for the manual `create / promote / drop` flow.
|
|
7
|
+
*
|
|
8
|
+
* pugi worktrees list # show active agent worktrees
|
|
9
|
+
* pugi worktrees cleanup <agent-id> # remove one
|
|
10
|
+
* pugi worktrees cleanup --all-stale # sweep orphans + completed/failed
|
|
11
|
+
* pugi worktrees cleanup --all-stale --dry-run
|
|
12
|
+
*
|
|
13
|
+
* Output: human-readable by default, NDJSON envelope under --json.
|
|
14
|
+
*
|
|
15
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
16
|
+
*/
|
|
17
|
+
import { runStaleCleanup } from '../../core/worktree-manager/cleanup.js';
|
|
18
|
+
import { WorktreeManager } from '../../core/worktree-manager/manager.js';
|
|
19
|
+
export async function runWorktreesCommand(args, opts) {
|
|
20
|
+
const [op, ...rest] = args;
|
|
21
|
+
if (!op)
|
|
22
|
+
return usage();
|
|
23
|
+
if (op === 'list') {
|
|
24
|
+
return runList(opts);
|
|
25
|
+
}
|
|
26
|
+
if (op === 'cleanup') {
|
|
27
|
+
return runCleanup(rest, opts);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
text: `unknown worktrees operation: ${op}. Supported: list, cleanup`,
|
|
32
|
+
exitCode: 2,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function runList(opts) {
|
|
36
|
+
const manager = new WorktreeManager({ cwd: opts.cwd });
|
|
37
|
+
const result = manager.list();
|
|
38
|
+
if (!result.ok) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
text: opts.json
|
|
42
|
+
? JSON.stringify(result, null, 2)
|
|
43
|
+
: `worktrees list failed: ${result.reason}: ${result.detail}`,
|
|
44
|
+
exitCode: 1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const rows = result.value;
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
text: JSON.stringify({ worktrees: rows }, null, 2),
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (rows.length === 0) {
|
|
56
|
+
return { ok: true, text: 'no agent worktrees', exitCode: 0 };
|
|
57
|
+
}
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push('AGENT ID BRANCH GIT PATH');
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
lines.push(`${pad(row.agentId, 28)}${pad(row.branch, 36)}${pad(row.gitTracked ? 'yes' : 'no', 8)}${row.path}`);
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, text: lines.join('\n'), exitCode: 0 };
|
|
64
|
+
}
|
|
65
|
+
function runCleanup(args, opts) {
|
|
66
|
+
// --all-stale sweeps every classified stale entry. Otherwise the next
|
|
67
|
+
// positional arg is the agent id to remove.
|
|
68
|
+
const allStale = args.includes('--all-stale');
|
|
69
|
+
if (allStale) {
|
|
70
|
+
const report = runStaleCleanup({ cwd: opts.cwd }, opts.dryRun ? { dryRun: true } : {});
|
|
71
|
+
return formatStaleReport(report, opts);
|
|
72
|
+
}
|
|
73
|
+
const agentId = args.find((a) => !a.startsWith('--'));
|
|
74
|
+
if (!agentId) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
text: 'Usage: pugi worktrees cleanup <agent-id> | --all-stale [--dry-run]',
|
|
78
|
+
exitCode: 2,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const manager = new WorktreeManager({ cwd: opts.cwd });
|
|
82
|
+
const result = manager.cleanup(agentId);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
// Missing-worktree is idempotent: surface the structured note but
|
|
85
|
+
// return exit 0 so a double-cleanup in a teardown hook never trips
|
|
86
|
+
// CI. Every other failure mode (invalid id, git failure) stays
|
|
87
|
+
// exit 1.
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
text: opts.json
|
|
91
|
+
? JSON.stringify(result, null, 2)
|
|
92
|
+
: `cleanup failed: ${result.reason}: ${result.detail}`,
|
|
93
|
+
exitCode: result.reason === 'worktree_missing' ? 0 : 1,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
text: opts.json
|
|
99
|
+
? JSON.stringify({ removed: result.value.removed }, null, 2)
|
|
100
|
+
: `worktree removed: ${result.value.removed}`,
|
|
101
|
+
exitCode: 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function formatStaleReport(report, opts) {
|
|
105
|
+
if (opts.json) {
|
|
106
|
+
return {
|
|
107
|
+
ok: report.errors.length === 0,
|
|
108
|
+
text: JSON.stringify(report, null, 2),
|
|
109
|
+
exitCode: report.errors.length === 0 ? 0 : 1,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const lines = [];
|
|
113
|
+
lines.push(`scanned: ${report.scanned} worktree(s)`);
|
|
114
|
+
if (report.removed.length > 0) {
|
|
115
|
+
lines.push(`removed: ${report.removed.length}`);
|
|
116
|
+
for (const r of report.removed)
|
|
117
|
+
lines.push(` - ${r}`);
|
|
118
|
+
}
|
|
119
|
+
if (report.preserved.length > 0) {
|
|
120
|
+
lines.push(`preserved: ${report.preserved.length}`);
|
|
121
|
+
for (const p of report.preserved)
|
|
122
|
+
lines.push(` - ${p.agentId} (${p.reason})`);
|
|
123
|
+
}
|
|
124
|
+
if (opts.dryRun) {
|
|
125
|
+
lines.push('dry-run: classifications only, nothing removed');
|
|
126
|
+
for (const c of report.classified)
|
|
127
|
+
lines.push(` - ${c.agentId}: ${c.class}`);
|
|
128
|
+
}
|
|
129
|
+
if (report.errors.length > 0) {
|
|
130
|
+
lines.push(`errors: ${report.errors.length}`);
|
|
131
|
+
for (const e of report.errors)
|
|
132
|
+
lines.push(` - ${e.agentId}: ${e.detail}`);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: report.errors.length === 0,
|
|
136
|
+
text: lines.join('\n'),
|
|
137
|
+
exitCode: report.errors.length === 0 ? 0 : 1,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function usage() {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
text: 'Usage: pugi worktrees <op>\n' +
|
|
144
|
+
' pugi worktrees list\n' +
|
|
145
|
+
' pugi worktrees cleanup <agent-id>\n' +
|
|
146
|
+
' pugi worktrees cleanup --all-stale [--dry-run]',
|
|
147
|
+
exitCode: 2,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function pad(s, n) {
|
|
151
|
+
if (s.length >= n)
|
|
152
|
+
return `${s.slice(0, n - 1)} `;
|
|
153
|
+
return s + ' '.repeat(n - s.length);
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=worktrees.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.50');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/dist/tools/bash.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* drops Windows shell support for M1.
|
|
28
28
|
*/
|
|
29
29
|
import { randomUUID } from 'node:crypto';
|
|
30
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
30
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from 'node:fs';
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
33
33
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -60,6 +60,32 @@ export async function bashTool(input, ctx) {
|
|
|
60
60
|
const additionalDirectories = ctx.additionalDirectories ?? [];
|
|
61
61
|
const source = ctx.source ?? 'agent';
|
|
62
62
|
const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
|
|
63
|
+
// Cwd carry-over decision (also re-checked post-run).
|
|
64
|
+
const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
|
|
65
|
+
// Workspace-git-boundary guard (CEO P0 #51, 2026-05-29).
|
|
66
|
+
// Runs BEFORE the permission gate so the boundary escape message is
|
|
67
|
+
// the one the operator/engine sees, regardless of permission policy.
|
|
68
|
+
// The leak is structural (git silently writes to an ancestor .git
|
|
69
|
+
// when the workspace lacks one), not a policy violation, so the
|
|
70
|
+
// diagnostic must surface even when the permission gate would
|
|
71
|
+
// otherwise have asked or auto-allowed.
|
|
72
|
+
const boundaryBlock = enforceGitBoundary(cmd, startCwd, ctx.root);
|
|
73
|
+
if (boundaryBlock !== null) {
|
|
74
|
+
emitEvent(ctx.session, 'bash.git_boundary_escape', {
|
|
75
|
+
cmd,
|
|
76
|
+
workspaceRoot: ctx.root,
|
|
77
|
+
resolvedToplevel: boundaryBlock.resolvedToplevel ?? null,
|
|
78
|
+
});
|
|
79
|
+
recordToolResult(ctx.session, toolCallId, 'error', boundaryBlock.reason);
|
|
80
|
+
return {
|
|
81
|
+
stdout: '',
|
|
82
|
+
stderr: boundaryBlock.reason,
|
|
83
|
+
exitCode: 126,
|
|
84
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
85
|
+
truncated: false,
|
|
86
|
+
timedOut: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
63
89
|
// Permission gate via the new class-aware engine.
|
|
64
90
|
const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
|
|
65
91
|
workspaceRoot: ctx.root,
|
|
@@ -78,8 +104,6 @@ export async function bashTool(input, ctx) {
|
|
|
78
104
|
timedOut: false,
|
|
79
105
|
};
|
|
80
106
|
}
|
|
81
|
-
// Cwd carry-over decision (also re-checked post-run).
|
|
82
|
-
const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
|
|
83
107
|
// Background job branch.
|
|
84
108
|
if (input.background === true) {
|
|
85
109
|
return runBackground({ cmd, ctx, toolCallId, startCwd, additionalDirectories });
|
|
@@ -565,6 +589,160 @@ function readRegistryEntriesSync() {
|
|
|
565
589
|
return [];
|
|
566
590
|
}
|
|
567
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* Workspace-git-boundary guard (CEO P0 #51, 2026-05-29).
|
|
594
|
+
*
|
|
595
|
+
* Background: CEO live REPL surfaced a scenario where the customer
|
|
596
|
+
* workspace dir was created INSIDE another git repository (the Pugi
|
|
597
|
+
* monorepo itself). The model emitted `git init && git add . && git
|
|
598
|
+
* commit -m ...` against that workspace. The workspace had no `.git`
|
|
599
|
+
* of its own so git silently walked up to the outer repo's `.git` and
|
|
600
|
+
* committed the customer's files directly to the monorepo's main
|
|
601
|
+
* branch. Had the outer remote been FF-permissive, those files would
|
|
602
|
+
* have pushed to production. This is a customer-of-customer leak.
|
|
603
|
+
*
|
|
604
|
+
* The guard: when the agent emits a mutating git op (add / commit /
|
|
605
|
+
* push / rebase / reset / checkout) and the effective git toplevel
|
|
606
|
+
* (`git -C $cwd rev-parse --show-toplevel`) sits OUTSIDE the workspace
|
|
607
|
+
* root, block the command. The model is steered (via the persona
|
|
608
|
+
* prompt) to run `git init` first; the guard is the defensive net so
|
|
609
|
+
* a careless model emission cannot cross the boundary.
|
|
610
|
+
*
|
|
611
|
+
* Exported so the spec can exercise the predicate in isolation without
|
|
612
|
+
* having to drive the whole bash tool.
|
|
613
|
+
*/
|
|
614
|
+
export const GIT_BOUNDARY_BLOCK_PREFIX = 'git boundary escape:';
|
|
615
|
+
/**
|
|
616
|
+
* Subcommands we treat as definitely mutating for the boundary check.
|
|
617
|
+
* We intentionally OMIT subcommands that have common read-only modes
|
|
618
|
+
* (`branch --list`, `tag --list`, `stash list`, `remote -v`) to keep
|
|
619
|
+
* the guard precise. The CEO P0 #51 leak vector is files written to
|
|
620
|
+
* an ancestor repo's working tree / refs, which the included set
|
|
621
|
+
* fully covers. The omitted subcommands can still create refs in the
|
|
622
|
+
* outer .git, but they do not move customer files into the outer
|
|
623
|
+
* repo's commit graph, so the leak severity is lower and the
|
|
624
|
+
* ergonomic cost of false positives on `--list` flags is higher.
|
|
625
|
+
*/
|
|
626
|
+
const MUTATING_GIT_SUBCOMMANDS = new Set([
|
|
627
|
+
'add',
|
|
628
|
+
'commit',
|
|
629
|
+
'push',
|
|
630
|
+
'rebase',
|
|
631
|
+
'reset',
|
|
632
|
+
'checkout',
|
|
633
|
+
'merge',
|
|
634
|
+
'restore',
|
|
635
|
+
'switch',
|
|
636
|
+
'cherry-pick',
|
|
637
|
+
'am',
|
|
638
|
+
'apply',
|
|
639
|
+
'clean',
|
|
640
|
+
'rm',
|
|
641
|
+
'mv',
|
|
642
|
+
]);
|
|
643
|
+
/**
|
|
644
|
+
* Inspect a shell command for mutating git operations. Returns the
|
|
645
|
+
* first matching subcommand (e.g. 'commit') or null when none of the
|
|
646
|
+
* components are mutating git ops.
|
|
647
|
+
*
|
|
648
|
+
* We split on `&&`, `||`, `;`, `|` so a compound like
|
|
649
|
+
* `mkdir foo && cd foo && git add .` is correctly flagged on the
|
|
650
|
+
* trailing git component.
|
|
651
|
+
*/
|
|
652
|
+
export function detectMutatingGitOp(cmd) {
|
|
653
|
+
const components = cmd.split(/\s*(?:&&|\|\||;|\|)\s*/);
|
|
654
|
+
for (const raw of components) {
|
|
655
|
+
const component = raw.trim();
|
|
656
|
+
if (component === '')
|
|
657
|
+
continue;
|
|
658
|
+
// Strip leading `sudo` wrapper which would otherwise hide the verb.
|
|
659
|
+
const stripped = component.replace(/^sudo\s+/, '');
|
|
660
|
+
// Match `git [<global-flags>] <subcommand> ...`. Global flags we
|
|
661
|
+
// tolerate:
|
|
662
|
+
// - long flag: `--no-pager`, `--git-dir=.git`
|
|
663
|
+
// - short flag with attached value: `-C <path>`, `-c <k=v>`
|
|
664
|
+
// - bare short flag: `-P`
|
|
665
|
+
// Anything weirder falls through and the predicate returns null,
|
|
666
|
+
// which means the guard does not fire on that component — safer
|
|
667
|
+
// to err open here because the destructive classifier and the
|
|
668
|
+
// outer permission gate are independent defences.
|
|
669
|
+
const match = stripped.match(/^git(?:\s+(?:--[A-Za-z][A-Za-z0-9-]*(?:=\S+)?|-[CcP](?:\s+\S+)?|-[A-Za-z]+))*\s+([a-z][a-z0-9-]*)\b/);
|
|
670
|
+
if (!match)
|
|
671
|
+
continue;
|
|
672
|
+
const subcommand = match[1];
|
|
673
|
+
if (subcommand && MUTATING_GIT_SUBCOMMANDS.has(subcommand)) {
|
|
674
|
+
return subcommand;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Resolve the workspace's effective git boundary. Returns:
|
|
681
|
+
* - the absolute path of the .git toplevel that owns `cwd`
|
|
682
|
+
* - null when no .git ancestor exists at all (standalone, no repo)
|
|
683
|
+
*
|
|
684
|
+
* Pure filesystem walk so the guard does not depend on git being on
|
|
685
|
+
* PATH. We look for either a `.git` directory or a `.git` file (the
|
|
686
|
+
* worktree case where `.git` is a pointer file).
|
|
687
|
+
*/
|
|
688
|
+
export function resolveGitToplevel(cwd) {
|
|
689
|
+
let dir = cwd;
|
|
690
|
+
while (true) {
|
|
691
|
+
const dotGit = join(dir, '.git');
|
|
692
|
+
if (existsSync(dotGit))
|
|
693
|
+
return dir;
|
|
694
|
+
const parent = resolve(dir, '..');
|
|
695
|
+
if (parent === dir)
|
|
696
|
+
return null;
|
|
697
|
+
dir = parent;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* The actual guard. Returns null when the command is allowed; returns
|
|
702
|
+
* a block descriptor when it should be denied. The block message uses
|
|
703
|
+
* the literal prefix `git boundary escape:` so callers (and the spec)
|
|
704
|
+
* can pattern-match.
|
|
705
|
+
*/
|
|
706
|
+
export function enforceGitBoundary(cmd, startCwd, workspaceRoot) {
|
|
707
|
+
const subcommand = detectMutatingGitOp(cmd);
|
|
708
|
+
if (subcommand === null)
|
|
709
|
+
return null;
|
|
710
|
+
// Resolve symlinks on both sides so a /var → /private/var macOS
|
|
711
|
+
// realpath divergence does not produce a false escape.
|
|
712
|
+
const root = safeRealpath(workspaceRoot);
|
|
713
|
+
const toplevel = resolveGitToplevel(safeRealpath(startCwd));
|
|
714
|
+
const resolvedToplevel = toplevel === null ? null : safeRealpath(toplevel);
|
|
715
|
+
if (resolvedToplevel === root)
|
|
716
|
+
return null;
|
|
717
|
+
// Either no .git anywhere (standalone) OR the .git that wins is an
|
|
718
|
+
// ancestor — both are escape scenarios. Operator must run `git init`
|
|
719
|
+
// explicitly inside the workspace.
|
|
720
|
+
if (resolvedToplevel === null) {
|
|
721
|
+
return {
|
|
722
|
+
subcommand,
|
|
723
|
+
resolvedToplevel: null,
|
|
724
|
+
reason: `${GIT_BOUNDARY_BLOCK_PREFIX} workspace root '${workspaceRoot}' has no .git ` +
|
|
725
|
+
`and no ancestor repository exists. Run \`git init\` in the workspace first ` +
|
|
726
|
+
`before \`git ${subcommand}\`.`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
subcommand,
|
|
731
|
+
resolvedToplevel,
|
|
732
|
+
reason: `${GIT_BOUNDARY_BLOCK_PREFIX} workspace root '${workspaceRoot}' has no .git; ` +
|
|
733
|
+
`outer toplevel is '${resolvedToplevel}'. Run \`git init\` in the workspace ` +
|
|
734
|
+
`first before \`git ${subcommand}\` (otherwise the operation would write to ` +
|
|
735
|
+
`the ancestor repository, not the workspace).`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function safeRealpath(path) {
|
|
739
|
+
try {
|
|
740
|
+
return realpathSync(path);
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
return path;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
568
746
|
function removeRegistryEntrySync(jobId) {
|
|
569
747
|
const path = join(homedir(), '.pugi', 'jobs.json');
|
|
570
748
|
const entries = readRegistryEntriesSync().filter((entry) => entry.id !== jobId);
|
|
@@ -592,6 +770,28 @@ export function bashToolSync(input, ctx) {
|
|
|
592
770
|
const additionalDirectories = ctx.additionalDirectories ?? [];
|
|
593
771
|
const source = ctx.source ?? 'agent';
|
|
594
772
|
const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
|
|
773
|
+
const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
|
|
774
|
+
// Workspace-git-boundary guard (CEO P0 #51, 2026-05-29). Fires
|
|
775
|
+
// BEFORE the permission gate so the structural boundary diagnostic
|
|
776
|
+
// is the one the operator sees. See the async path for the full
|
|
777
|
+
// rationale.
|
|
778
|
+
const boundaryBlock = enforceGitBoundary(cmd, startCwd, ctx.root);
|
|
779
|
+
if (boundaryBlock !== null) {
|
|
780
|
+
emitEvent(ctx.session, 'bash.git_boundary_escape', {
|
|
781
|
+
cmd,
|
|
782
|
+
workspaceRoot: ctx.root,
|
|
783
|
+
resolvedToplevel: boundaryBlock.resolvedToplevel ?? null,
|
|
784
|
+
});
|
|
785
|
+
recordToolResult(ctx.session, toolCallId, 'error', boundaryBlock.reason);
|
|
786
|
+
return {
|
|
787
|
+
stdout: '',
|
|
788
|
+
stderr: boundaryBlock.reason,
|
|
789
|
+
exitCode: 126,
|
|
790
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
791
|
+
truncated: false,
|
|
792
|
+
timedOut: false,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
595
795
|
const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
|
|
596
796
|
workspaceRoot: ctx.root,
|
|
597
797
|
additionalDirectories,
|
|
@@ -609,7 +809,6 @@ export function bashToolSync(input, ctx) {
|
|
|
609
809
|
timedOut: false,
|
|
610
810
|
};
|
|
611
811
|
}
|
|
612
|
-
const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
|
|
613
812
|
const timeoutMs = sanitizeTimeout(input.timeoutMs);
|
|
614
813
|
const childEnv = buildChildEnv();
|
|
615
814
|
const result = spawnSync('/bin/sh', ['-c', cmd], {
|
|
@@ -101,21 +101,33 @@ export function loadPugMascotAnsi() {
|
|
|
101
101
|
// icon, clipboard, hyperlinks, color-palette change). Drop them
|
|
102
102
|
// so a corrupted asset cannot rename the operator's terminal tab
|
|
103
103
|
// or smuggle a hyperlink into the splash region.
|
|
104
|
-
// 2. Drop CSI ? <
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
104
|
+
// 2. Drop ALL CSI ? <numbers and semicolons> [lh] (DEC private-mode
|
|
105
|
+
// set / reset). The legitimate chafa output for a splash is
|
|
106
|
+
// truecolor SGR (`CSI 38;2;R;G;B m`) plus cursor-positioning —
|
|
107
|
+
// no private-mode toggle ever appears там legitimately. A
|
|
108
|
+
// permissive deny-all pattern covers every disruptive private
|
|
109
|
+
// mode in one regex:
|
|
110
|
+
// - cursor visibility (25)
|
|
111
|
+
// - alt-screen buffer (47, 1047, 1048, 1049)
|
|
112
|
+
// - mouse tracking (1000, 1001, 1002, 1003, 1004, 1005, 1006, 1015)
|
|
113
|
+
// - bracketed paste (2004)
|
|
114
|
+
// - focus reporting (1004)
|
|
115
|
+
// - multi-mode forms (e.g. `CSI ? 47 ; 1049 h` — legal per
|
|
116
|
+
// xterm ctlseqs but missed by the previous single-mode regex)
|
|
117
|
+
// - any future private mode a corrupt asset might emit
|
|
118
|
+
// Allowlisting the modes the splash needs is impossible because
|
|
119
|
+
// the splash needs ZERO of them — chafa renders glyph-by-glyph,
|
|
120
|
+
// not via private-mode toggles. A pure deny-all is strictly
|
|
121
|
+
// safer than enumerating known-bad modes one-by-one.
|
|
108
122
|
// 3. Drop CSI 6 n (cursor-position report). Would inject a fake
|
|
109
123
|
// CPR into the operator's stdin stream.
|
|
110
124
|
// 4. Drop CSI [23]J / CSI [23]K (full screen / line clear). A
|
|
111
125
|
// chafa render uses cursor-positioning per row, not bulk
|
|
112
126
|
// erases; bulk clears would wipe whatever the operator already
|
|
113
127
|
// had on screen above the splash.
|
|
114
|
-
// The cursor-hide/show wrappers (CSI ? 25 [lh]) are handled by
|
|
115
|
-
// the same CSI-?-mode pattern as the mouse / alt-screen modes.
|
|
116
128
|
const stripped = raw
|
|
117
129
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
118
|
-
.replace(/\x1b\[\?
|
|
130
|
+
.replace(/\x1b\[\?[0-9;]+[lh]/g, '')
|
|
119
131
|
.replace(/\x1b\[6n/g, '')
|
|
120
132
|
.replace(/\x1b\[[23]?[JK]/g, '');
|
|
121
133
|
if (stripped.trim().length === 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.50",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"undici": "^8.3.0",
|
|
56
56
|
"zod": "^3.23.0",
|
|
57
57
|
"@pugi/personas": "0.1.2",
|
|
58
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
58
|
+
"@pugi/sdk": "0.1.0-beta.50"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^22.0.0",
|