@yagni-app/code-staging 1.0.9-staging.1292.1 → 1.0.9-staging.1301.1

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.
@@ -52,9 +52,9 @@ export interface ResilientFetchOpts {
52
52
  export declare function resilientFetch(url: string, init: RequestInit, opts?: ResilientFetchOpts): Promise<Response>;
53
53
  /**
54
54
  * Map a non-ok response to a friendly, body-truncated message for a tool's throw.
55
- * Auth statuses get an actionable re-login hint; everything else carries the
56
- * status plus a capped slice of the body so a giant HTML error never floods the
57
- * agent. No em-dashes (house copy rule).
55
+ * Authentication failures get a re-login hint; permission denials stay distinct.
56
+ * Other failures carry the status plus a capped slice of the body so a giant
57
+ * HTML error never floods the agent. No em-dashes (house copy rule).
58
58
  */
59
59
  export declare function friendlyFetchError(label: string, res: Response): Promise<string>;
60
60
  //# sourceMappingURL=resilientFetch.d.ts.map
@@ -105,9 +105,9 @@ export async function resilientFetch(url, init, opts = {}) {
105
105
  }
106
106
  /**
107
107
  * Map a non-ok response to a friendly, body-truncated message for a tool's throw.
108
- * Auth statuses get an actionable re-login hint; everything else carries the
109
- * status plus a capped slice of the body so a giant HTML error never floods the
110
- * agent. No em-dashes (house copy rule).
108
+ * Authentication failures get a re-login hint; permission denials stay distinct.
109
+ * Other failures carry the status plus a capped slice of the body so a giant
110
+ * HTML error never floods the agent. No em-dashes (house copy rule).
111
111
  */
112
112
  export async function friendlyFetchError(label, res) {
113
113
  let body = "";
@@ -119,9 +119,12 @@ export async function friendlyFetchError(label, res) {
119
119
  }
120
120
  if (body.length > MAX_ERROR_BODY)
121
121
  body = `${body.slice(0, MAX_ERROR_BODY)} ...`;
122
- if (res.status === 401 || res.status === 403) {
122
+ if (res.status === 401) {
123
123
  return `${label} is not authorized (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`;
124
124
  }
125
+ if (res.status === 403) {
126
+ return `${label} does not have permission (HTTP 403). Report the permission limitation if it blocks the task.`;
127
+ }
125
128
  if (res.status === 429) {
126
129
  return `${label} was rate limited (HTTP 429). Try again in a moment.`;
127
130
  }
@@ -19,6 +19,7 @@
19
19
  * allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
20
20
  * — loopback bypasses the proxy via no_proxy).
21
21
  */
22
+ import { type WorktreeGitAccess } from "./worktreeGit.js";
22
23
  import type { PermissionRule } from "../permissionRules/loadConfig.js";
23
24
  export interface SandboxNetworkSettings {
24
25
  allowedDomains?: string[];
@@ -95,6 +96,10 @@ export interface SandboxRuntimeMerge {
95
96
  allowWrite: string[];
96
97
  denyWrite: string[];
97
98
  };
99
+ /** git safe.directory entries for srt (GIT_CONFIG_* env): the worktree
100
+ * root + common git dir, so bwrap's uid-mapping does not trip "dubious
101
+ * ownership" on the main-repo path. Empty when not a worktree session. */
102
+ gitSafeDirectories: string[];
98
103
  /** Permission rules that could not map into sandbox restrictions (surfaced
99
104
  * in diagnostics — never silently dropped). */
100
105
  droppedRules: string[];
@@ -111,5 +116,5 @@ export interface SandboxRuntimeMerge {
111
116
  * cwd-relative bare) resolved per-source; sandbox.filesystem.* uses standard
112
117
  * path semantics (/ = absolute) exactly like Claude's two resolvers.
113
118
  */
114
- export declare function mergeRulesIntoSandbox(settings: SandboxSettings, rules: readonly PermissionRule[], base: SandboxRuntimePaths): SandboxRuntimeMerge;
119
+ export declare function mergeRulesIntoSandbox(settings: SandboxSettings, rules: readonly PermissionRule[], base: SandboxRuntimePaths, worktreeGit?: WorktreeGitAccess | null): SandboxRuntimeMerge;
115
120
  //# sourceMappingURL=config.d.ts.map
@@ -295,7 +295,7 @@ homeDirDefault = homeDirDefaultFn;
295
295
  * cwd-relative bare) resolved per-source; sandbox.filesystem.* uses standard
296
296
  * path semantics (/ = absolute) exactly like Claude's two resolvers.
297
297
  */
298
- export function mergeRulesIntoSandbox(settings, rules, base) {
298
+ export function mergeRulesIntoSandbox(settings, rules, base, worktreeGit) {
299
299
  const homeDir = base.homeDir ?? homeDirDefault();
300
300
  const allowWrite = new Set([base.cwd, tmpdir()]);
301
301
  const denyWrite = new Set();
@@ -360,6 +360,23 @@ export function mergeRulesIntoSandbox(settings, rules, base) {
360
360
  denyRead.add(resolveSandboxFsPath(p, base));
361
361
  for (const p of settings.filesystem?.allowRead ?? [])
362
362
  allowRead.add(resolveSandboxFsPath(p, base));
363
+ // Linked-worktree git access: allow writes to the shared common git dir
364
+ // only (routine worktree git — index.lock, refs, objects — never writes
365
+ // outside it), and pin hooks + config read-only within the newly allowed
366
+ // dir. The explicit config/hooks denies are redundant on macOS (srt's
367
+ // mandatory globs already cover **/.git/hooks and **/.git/config) but
368
+ // load-bearing on Linux, where srt's deny enforcement only ro-binds
369
+ // denies WITHIN allowWrite paths and its cwd-anchored mandatory sweep
370
+ // never sees the main repo — and for bare-repo worktrees, where the
371
+ // `.git` globs match nothing. Pure-merge contract: the caller passes the
372
+ // resolved value; this function never touches the filesystem.
373
+ const gitSafeDirectories = [];
374
+ if (worktreeGit) {
375
+ allowWrite.add(worktreeGit.commonGitDir);
376
+ denyWrite.add(join(worktreeGit.commonGitDir, "hooks"));
377
+ denyWrite.add(join(worktreeGit.commonGitDir, "config"));
378
+ gitSafeDirectories.push(worktreeGit.worktreeRoot, worktreeGit.commonGitDir);
379
+ }
363
380
  // Protected paths: always denyWrite, never exempted (Claude parity + our
364
381
  // own surfaces). Note srt denyWrite also denies read-of-ignored writes.
365
382
  denyWrite.add(join(base.userStateHome, "config.json"));
@@ -382,6 +399,7 @@ export function mergeRulesIntoSandbox(settings, rules, base) {
382
399
  allowWrite: [...allowWrite],
383
400
  denyWrite: [...denyWrite],
384
401
  },
402
+ gitSafeDirectories,
385
403
  droppedRules,
386
404
  };
387
405
  }
@@ -10,6 +10,7 @@
10
10
  * bracket. Pure functions (config building) live in config.ts.
11
11
  */
12
12
  import { type SandboxSettings, type SandboxRuntimeMerge } from "./config.js";
13
+ import { type WorktreeGitAccess } from "./worktreeGit.js";
13
14
  import type { PermissionRule } from "../permissionRules/loadConfig.js";
14
15
  /** Where the network ask-callback surfaces a decision. */
15
16
  export type NetworkAskHandler = (host: string) => Promise<boolean>;
@@ -39,6 +40,9 @@ export interface SandboxSessionState {
39
40
  dependencies: SandboxDependencyStatus | null;
40
41
  /** The settings snapshot this session initialized with. */
41
42
  settings: SandboxSettings | null;
43
+ /** Cached linked-worktree git resolution — resolved once at initialize()
44
+ * (worktree linkage does not change mid-session), undefined until then. */
45
+ worktreeGit: WorktreeGitAccess | null | undefined;
42
46
  }
43
47
  /** Default vendored rg shipped with the CLI install. */
44
48
  export declare function defaultRgPath(env?: NodeJS.ProcessEnv): string | undefined;
@@ -69,7 +73,9 @@ export declare class YagniSandboxManager {
69
73
  /**
70
74
  * Build the runtime filesystem/network config from current settings +
71
75
  * permission rules. Exposed for diagnostics (/sandbox config display) and
72
- * tests; initialize() consumes it internally.
76
+ * tests; initialize() consumes it internally. Uses the cached worktree
77
+ * resolution when initialize() already ran; a pre-init caller (tests,
78
+ * panel) resolves live if the cwd is a linked worktree root.
73
79
  */
74
80
  buildRuntimeMerge(rules: readonly PermissionRule[]): SandboxRuntimeMerge;
75
81
  private runtimeConfig;
@@ -94,7 +100,8 @@ export declare class YagniSandboxManager {
94
100
  refreshConfig(rules: readonly PermissionRule[]): void;
95
101
  /**
96
102
  * Re-init from scratch (config grant changed semantics srt can't hot-swap,
97
- * or a toggle). Tears down proxies + violation store first.
103
+ * or a toggle). Tears down proxies + violation store first. The worktree
104
+ * cache clears with the state reset and re-resolves on the next init.
98
105
  */
99
106
  reinitialize(rules: readonly PermissionRule[]): Promise<string | undefined>;
100
107
  /** Tear down srt (proxies, violation store, seatbelt state). */
@@ -13,6 +13,7 @@ import { SandboxManager as SrtSandboxManager } from "@anthropic-ai/sandbox-runti
13
13
  import { logEvent } from "../errorSink.js";
14
14
  import { codeStateHome } from "../stateHome.js";
15
15
  import { loadSandboxSettings, mergeRulesIntoSandbox, } from "./config.js";
16
+ import { resolveWorktreeGitAccess } from "./worktreeGit.js";
16
17
  /** Default vendored rg shipped with the CLI install. */
17
18
  export function defaultRgPath(env = process.env) {
18
19
  const agentDir = env.PI_CODING_AGENT_DIR;
@@ -21,7 +22,7 @@ export function defaultRgPath(env = process.env) {
21
22
  return `${agentDir.replace(/\/$/, "")}/bin/rg`;
22
23
  }
23
24
  export class YagniSandboxManager {
24
- state = { initialized: false, dependencies: null, settings: null };
25
+ state = { initialized: false, dependencies: null, settings: null, worktreeGit: undefined };
25
26
  askHandler = null;
26
27
  opts;
27
28
  /** Config warnings already logged this process — buildRuntimeMerge runs
@@ -95,7 +96,9 @@ export class YagniSandboxManager {
95
96
  /**
96
97
  * Build the runtime filesystem/network config from current settings +
97
98
  * permission rules. Exposed for diagnostics (/sandbox config display) and
98
- * tests; initialize() consumes it internally.
99
+ * tests; initialize() consumes it internally. Uses the cached worktree
100
+ * resolution when initialize() already ran; a pre-init caller (tests,
101
+ * panel) resolves live if the cwd is a linked worktree root.
99
102
  */
100
103
  buildRuntimeMerge(rules) {
101
104
  const { settings, diagnostics } = loadSandboxSettings({
@@ -105,12 +108,15 @@ export class YagniSandboxManager {
105
108
  stateHomeOverride: this.opts.stateHomeOverride,
106
109
  });
107
110
  this.surfaceConfigDiagnostics(diagnostics);
111
+ const worktreeGit = this.state.worktreeGit !== undefined
112
+ ? this.state.worktreeGit
113
+ : resolveWorktreeGitAccess(this.opts.cwd);
108
114
  return mergeRulesIntoSandbox(settings, rules, {
109
115
  cwd: this.opts.cwd,
110
116
  userStateHome: this.opts.stateHomeOverride ?? codeStateHome(null, this.opts.env, this.opts.userHome),
111
117
  projectRoot: this.opts.projectRoot ?? null,
112
118
  homeDir: this.opts.userHome,
113
- });
119
+ }, worktreeGit);
114
120
  }
115
121
  runtimeConfig(merge, settings) {
116
122
  return {
@@ -129,6 +135,9 @@ export class YagniSandboxManager {
129
135
  allowWrite: merge.filesystem.allowWrite,
130
136
  denyWrite: merge.filesystem.denyWrite,
131
137
  },
138
+ ...(merge.gitSafeDirectories.length > 0
139
+ ? { git: { safeDirectories: merge.gitSafeDirectories } }
140
+ : {}),
132
141
  ignoreViolations: settings.ignoreViolations,
133
142
  enableWeakerNestedSandbox: settings.enableWeakerNestedSandbox,
134
143
  enableWeakerNetworkIsolation: settings.enableWeakerNetworkIsolation,
@@ -176,6 +185,21 @@ export class YagniSandboxManager {
176
185
  this.state.dependencies = null;
177
186
  return `sandbox initialization failed: ${err instanceof Error ? err.message : String(err)}`;
178
187
  }
188
+ // Cache the worktree resolution only after a successful init — a failed
189
+ // init leaves it undefined so buildRuntimeMerge keeps resolving live.
190
+ this.state.worktreeGit = resolveWorktreeGitAccess(this.opts.cwd);
191
+ // Security-relevant allow widening needs its trail: one line when the
192
+ // session's git writes were allowed outside cwd (path-only metadata,
193
+ // same exposure class as sandbox_config_warning fields; silent when
194
+ // null — that is every non-worktree session).
195
+ if (this.state.worktreeGit) {
196
+ logEvent({
197
+ source: "sandbox",
198
+ level: "info",
199
+ event: "sandbox_worktree_git_allow",
200
+ fields: { commonGitDir: this.state.worktreeGit.commonGitDir },
201
+ });
202
+ }
179
203
  this.state.initialized = true;
180
204
  this.state.settings = load.settings;
181
205
  this.opts.events?.onStateChange?.(true);
@@ -198,7 +222,8 @@ export class YagniSandboxManager {
198
222
  }
199
223
  /**
200
224
  * Re-init from scratch (config grant changed semantics srt can't hot-swap,
201
- * or a toggle). Tears down proxies + violation store first.
225
+ * or a toggle). Tears down proxies + violation store first. The worktree
226
+ * cache clears with the state reset and re-resolves on the next init.
202
227
  */
203
228
  async reinitialize(rules) {
204
229
  if (this.state.initialized) {
@@ -208,7 +233,7 @@ export class YagniSandboxManager {
208
233
  }
209
234
  /** Tear down srt (proxies, violation store, seatbelt state). */
210
235
  async reset() {
211
- this.state = { initialized: false, dependencies: null, settings: null };
236
+ this.state = { initialized: false, dependencies: null, settings: null, worktreeGit: undefined };
212
237
  try {
213
238
  await SrtSandboxManager.reset();
214
239
  }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { Box, Container, HStack, Key, SelectList, Text, matchesKey, } from "@earendil-works/pi-tui";
22
22
  import { mergeRulesIntoSandbox } from "./config.js";
23
+ import { resolveWorktreeGitAccess } from "./worktreeGit.js";
23
24
  // ---------------------------------------------------------------------------
24
25
  // Pure derivation helpers (unit-tested; no TUI dependency)
25
26
  // ---------------------------------------------------------------------------
@@ -335,7 +336,7 @@ export function buildPanelState(settings, sessionToggledOff, rules, paths, sessi
335
336
  sessionToggledOff,
336
337
  dependencyErrors: dependencyStatus.errors,
337
338
  dependencyWarnings: dependencyStatus.warnings,
338
- merge: mergeRulesIntoSandbox(settings, rules, paths),
339
+ merge: mergeRulesIntoSandbox(settings, rules, paths, resolveWorktreeGitAccess(paths.cwd)),
339
340
  sessionGrants: [...sessionGrants],
340
341
  };
341
342
  }
@@ -22,6 +22,7 @@ import { codeStateHome } from "../stateHome.js";
22
22
  import { isDebug } from "../diagnostics.js";
23
23
  import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
24
24
  import { loadSandboxSettings } from "./config.js";
25
+ import { resolveWorktreeGitAccess } from "./worktreeGit.js";
25
26
  import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
26
27
  import { YagniSandboxManager } from "./manager.js";
27
28
  import { SandboxPanel, buildPanelState } from "./panel.js";
@@ -68,6 +69,12 @@ export function makeBashComposition(manager, settings, cwd) {
68
69
  ? `Network: only these domains (wildcards ok): ${allowed.join(", ")}`
69
70
  : "Network: no domains pre-allowed — the first contact to each host prompts the user");
70
71
  restrictions.push(`Filesystem writes: working directory, /tmp, and paths granted by Edit(...) allow rules`);
72
+ // A linked-worktree session additionally allows the shared common git
73
+ // dir (git operations work sandboxed there); the description must say
74
+ // so or the model under-reports what it can do.
75
+ if (resolveWorktreeGitAccess(cwd)) {
76
+ restrictions.push("Filesystem writes (git): the linked worktree's shared .git directory — git operations (add/commit/branch/checkout) are allowed and should run sandboxed; its hooks/ and config remain read-only");
77
+ }
71
78
  if (s.filesystem?.denyWrite?.length)
72
79
  restrictions.push(`Denied writes: ${s.filesystem.denyWrite.join(", ")}`);
73
80
  const strictNote = s.allowUnsandboxedCommands === false
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Linked-worktree git resolution for the sandbox. When the session cwd is
3
+ * the root of a linked worktree, routine git writes (status/add/commit/
4
+ * branch/checkout) land in the MAIN repo's shared `.git` directory —
5
+ * outside the sandbox's default write allowlist ({cwd, tmpdir()}), so every
6
+ * one of them fails with EPERM and forces the dangerouslyDisableSandbox
7
+ * retry path. Resolving the shared common git dir here lets the sandbox
8
+ * allow exactly that directory (never the main checkout's working tree).
9
+ *
10
+ * Resolution chain (Claude Code's resolveCanonicalRoot, src/utils/git.ts,
11
+ * applied to the sandbox problem — Claude's own sandbox side only does a
12
+ * shape check on the gitdir path, which would trust an attacker-crafted
13
+ * `.git` file; the full chain here is deliberately stricter):
14
+ * cwd/.git file → `gitdir:` → resolve against cwd → <gitdir>/commondir →
15
+ * resolve against gitdir → common git dir. Both validation checks from
16
+ * the reference are enforced before anything is returned:
17
+ * 1. structural — the worktree gitdir is a direct child of
18
+ * <commonDir>/worktrees/ (the commondir we read lives where git put it,
19
+ * not wherever an attacker's `.git` file pointed);
20
+ * 2. back-link — <gitdir>/gitdir points back at THIS cwd's .git (an
21
+ * attacker cannot borrow an existing worktree entry of another repo).
22
+ *
23
+ * Fail-through cases (all return null, no behavior change vs the
24
+ * pre-worktree-allow sandbox):
25
+ * plain main checkout (.git is a directory → EISDIR), no repo at all,
26
+ * submodule (.git file but no commondir), failed structural/back-link
27
+ * validation, any read/parse error.
28
+ *
29
+ * Bare-repo worktrees are supported: there the common dir is the bare repo
30
+ * itself (`<bare>/worktrees/<name>/commondir` → `<bare>`), so the allowed
31
+ * directory IS the common dir (Claude's `/.git/worktrees/` shape marker
32
+ * silently no-ops there; ours resolves it).
33
+ *
34
+ * Root-only by design (Claude sandbox-side parity): a session started in a
35
+ * worktree SUBDIRECTORY keeps the pre-existing behavior — the `.git` file
36
+ * sits only at the worktree root.
37
+ */
38
+ /** Resolved write-target facts for a linked-worktree session. */
39
+ export interface WorktreeGitAccess {
40
+ /** The shared common git dir git writes actually land in. */
41
+ commonGitDir: string;
42
+ /** Realpath'd worktree root (safe.directory needs the on-disk spelling). */
43
+ worktreeRoot: string;
44
+ }
45
+ /**
46
+ * Resolve the shared common git dir when cwd is a linked worktree root.
47
+ * Returns null for every non-worktree / untrusted shape — callers treat
48
+ * null as "no extra entries" and never widen the sandbox.
49
+ *
50
+ * Null REASONS are surfaced (debug level, paths only) whenever a `gitdir:`
51
+ * .git file existed but validation refused: a legitimate worktree that
52
+ * silently misses the allow is indistinguishable from "not a worktree"
53
+ * without them. The plain-repo / no-repo fall-throughs stay silent — they
54
+ * are the common case and carry no signal.
55
+ */
56
+ export declare function resolveWorktreeGitAccess(cwd: string): WorktreeGitAccess | null;
57
+ //# sourceMappingURL=worktreeGit.d.ts.map
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Linked-worktree git resolution for the sandbox. When the session cwd is
3
+ * the root of a linked worktree, routine git writes (status/add/commit/
4
+ * branch/checkout) land in the MAIN repo's shared `.git` directory —
5
+ * outside the sandbox's default write allowlist ({cwd, tmpdir()}), so every
6
+ * one of them fails with EPERM and forces the dangerouslyDisableSandbox
7
+ * retry path. Resolving the shared common git dir here lets the sandbox
8
+ * allow exactly that directory (never the main checkout's working tree).
9
+ *
10
+ * Resolution chain (Claude Code's resolveCanonicalRoot, src/utils/git.ts,
11
+ * applied to the sandbox problem — Claude's own sandbox side only does a
12
+ * shape check on the gitdir path, which would trust an attacker-crafted
13
+ * `.git` file; the full chain here is deliberately stricter):
14
+ * cwd/.git file → `gitdir:` → resolve against cwd → <gitdir>/commondir →
15
+ * resolve against gitdir → common git dir. Both validation checks from
16
+ * the reference are enforced before anything is returned:
17
+ * 1. structural — the worktree gitdir is a direct child of
18
+ * <commonDir>/worktrees/ (the commondir we read lives where git put it,
19
+ * not wherever an attacker's `.git` file pointed);
20
+ * 2. back-link — <gitdir>/gitdir points back at THIS cwd's .git (an
21
+ * attacker cannot borrow an existing worktree entry of another repo).
22
+ *
23
+ * Fail-through cases (all return null, no behavior change vs the
24
+ * pre-worktree-allow sandbox):
25
+ * plain main checkout (.git is a directory → EISDIR), no repo at all,
26
+ * submodule (.git file but no commondir), failed structural/back-link
27
+ * validation, any read/parse error.
28
+ *
29
+ * Bare-repo worktrees are supported: there the common dir is the bare repo
30
+ * itself (`<bare>/worktrees/<name>/commondir` → `<bare>`), so the allowed
31
+ * directory IS the common dir (Claude's `/.git/worktrees/` shape marker
32
+ * silently no-ops there; ours resolves it).
33
+ *
34
+ * Root-only by design (Claude sandbox-side parity): a session started in a
35
+ * worktree SUBDIRECTORY keeps the pre-existing behavior — the `.git` file
36
+ * sits only at the worktree root.
37
+ */
38
+ import { readFileSync, realpathSync } from "node:fs";
39
+ import { dirname, join, resolve } from "node:path";
40
+ import { logEvent } from "../errorSink.js";
41
+ import { isDebug } from "../diagnostics.js";
42
+ /**
43
+ * Resolve the shared common git dir when cwd is a linked worktree root.
44
+ * Returns null for every non-worktree / untrusted shape — callers treat
45
+ * null as "no extra entries" and never widen the sandbox.
46
+ *
47
+ * Null REASONS are surfaced (debug level, paths only) whenever a `gitdir:`
48
+ * .git file existed but validation refused: a legitimate worktree that
49
+ * silently misses the allow is indistinguishable from "not a worktree"
50
+ * without them. The plain-repo / no-repo fall-throughs stay silent — they
51
+ * are the common case and carry no signal.
52
+ */
53
+ export function resolveWorktreeGitAccess(cwd) {
54
+ let gitContent;
55
+ try {
56
+ gitContent = readFileSync(join(cwd, ".git"), "utf-8").trim();
57
+ }
58
+ catch {
59
+ // No .git, or .git is a directory (plain main checkout → EISDIR).
60
+ return null;
61
+ }
62
+ if (!gitContent.startsWith("gitdir:"))
63
+ return null;
64
+ // gitdir may be relative (rare, but git accepts it) — resolve against cwd.
65
+ const worktreeGitDir = resolve(cwd, gitContent.slice("gitdir:".length).trim());
66
+ const debugRefused = (reason) => {
67
+ if (!isDebug())
68
+ return;
69
+ logEvent({
70
+ source: "sandbox",
71
+ level: "debug",
72
+ event: "sandbox_worktree_git_refused",
73
+ fields: { reason, worktreeGitDir },
74
+ });
75
+ };
76
+ let commonContent;
77
+ try {
78
+ // Submodules have a .git file but no commondir (ENOENT → fall through).
79
+ commonContent = readFileSync(join(worktreeGitDir, "commondir"), "utf-8").trim();
80
+ }
81
+ catch {
82
+ debugRefused("no-commondir");
83
+ return null;
84
+ }
85
+ const commonDir = resolve(worktreeGitDir, commonContent);
86
+ // Structural check: the worktree gitdir must be a direct child of
87
+ // <commonDir>/worktrees — the commondir we just read must live inside the
88
+ // common dir it named, not at an arbitrary attacker-chosen path. win32
89
+ // compares case-insensitively (git preserves the casing used at worktree
90
+ // creation, so genuine worktrees can carry casing drift between the
91
+ // gitdir spelling and the commondir resolution); POSIX compares exactly.
92
+ const structuralMatches = process.platform === "win32"
93
+ ? dirname(worktreeGitDir).toLowerCase() === join(commonDir, "worktrees").toLowerCase()
94
+ : dirname(worktreeGitDir) === join(commonDir, "worktrees");
95
+ if (!structuralMatches) {
96
+ debugRefused("structural-mismatch");
97
+ return null;
98
+ }
99
+ // Back-link check: git writes <worktreeGitDir>/gitdir pointing back at
100
+ // this worktree's .git. Realpath the worktree root (not the .git entry —
101
+ // a symlinked .git must not be followed) so legitimate worktrees reached
102
+ // through a symlinked path (macOS /tmp → /private/tmp) still validate.
103
+ // win32: git's strbuf_realpath expands 8.3 short names (C:\\RUNNER~1 →
104
+ // runneradmin) when it writes the back-link, while Node's JS-side
105
+ // realpath does not — both sides go through realpathSync.native (which
106
+ // expands them) and compare case-insensitively, or every genuine
107
+ // worktree under a short-named TEMP would fail closed on spelling.
108
+ // POSIX compares exactly (symlinks already resolved by realpath).
109
+ let rawBacklink;
110
+ try {
111
+ rawBacklink = readFileSync(join(worktreeGitDir, "gitdir"), "utf-8").trim();
112
+ }
113
+ catch {
114
+ debugRefused("no-backlink");
115
+ return null;
116
+ }
117
+ let backlink;
118
+ try {
119
+ backlink = process.platform === "win32" ? realpathSync.native(rawBacklink) : realpathSync(rawBacklink);
120
+ }
121
+ catch {
122
+ // Exists but cannot be canonicalized: dangling symlink, permissions —
123
+ // a different failure than an absent back-link, and worth its own code.
124
+ debugRefused("backlink-unreadable");
125
+ return null;
126
+ }
127
+ let realCwd;
128
+ try {
129
+ realCwd = process.platform === "win32" ? realpathSync.native(cwd) : realpathSync(cwd);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ const backlinkMatches = process.platform === "win32"
135
+ ? backlink.toLowerCase() === join(realCwd, ".git").toLowerCase()
136
+ : backlink === join(realCwd, ".git");
137
+ if (!backlinkMatches) {
138
+ debugRefused("backlink-mismatch");
139
+ return null;
140
+ }
141
+ return {
142
+ commonGitDir: safeRealpath(commonDir),
143
+ worktreeRoot: realCwd,
144
+ };
145
+ }
146
+ /** realpath that tolerates a vanished path (returns the input spelling) —
147
+ * post-validation only, so a stale spelling can never widen anything: the
148
+ * validation above already proved the entry structure exists. */
149
+ function safeRealpath(p) {
150
+ try {
151
+ return realpathSync(p);
152
+ }
153
+ catch {
154
+ return p;
155
+ }
156
+ }
157
+ //# sourceMappingURL=worktreeGit.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.9-staging.1292.1",
3
+ "version": "1.0.9-staging.1301.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "be851fcdc82a066edbe8ebb394685bc1b61169f8"
61
+ "yagniSourceSha": "2d51041ec00bbf091fa349e5c0a199cf52d4fe1e"
62
62
  }