@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.
- package/dist/extension/resilientFetch.d.ts +3 -3
- package/dist/extension/resilientFetch.js +7 -4
- package/dist/extension/sandbox/config.d.ts +6 -1
- package/dist/extension/sandbox/config.js +19 -1
- package/dist/extension/sandbox/manager.d.ts +9 -2
- package/dist/extension/sandbox/manager.js +30 -5
- package/dist/extension/sandbox/panel.js +2 -1
- package/dist/extension/sandbox/session.js +7 -0
- package/dist/extension/sandbox/worktreeGit.d.ts +57 -0
- package/dist/extension/sandbox/worktreeGit.js +157 -0
- package/package.json +2 -2
|
@@ -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
|
-
*
|
|
56
|
-
* status plus a capped slice of the body so a giant
|
|
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
|
-
*
|
|
109
|
-
* status plus a capped slice of the body so a giant
|
|
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
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "2d51041ec00bbf091fa349e5c0a199cf52d4fe1e"
|
|
62
62
|
}
|