@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.
@@ -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
- safeExec(cwd, 'git', ['fetch', 'origin', `pull/${pr}/head:${tempRef}`]);
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 next
167
- // run will overwrite it via `fetch ... :ref` anyway.
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: 5 },
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
- import { realpathSync } from 'node:fs';
2
- import { basename, relative, resolve } from 'node:path';
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
@@ -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: 5 calls / 20k tokens).',
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
@@ -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.49');
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.
@@ -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 ? <mode> [hl] for mouse-tracking and screen-buffer
105
- // switch modes (1000, 1001, 1002, 1003, 1004, 1005, 1006, 1015,
106
- // 1049, 47, 1047, 1048). These would either start swallowing
107
- // mouse input or flip the terminal into the alternate screen.
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\[\?(?:25|47|1000|1001|1002|1003|1004|1005|1006|1015|1047|1048|1049)[lh]/g, '')
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.49",
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.49"
58
+ "@pugi/sdk": "0.1.0-beta.50"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",