@yagni-app/code 1.0.9 → 1.1.0

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.
@@ -6,8 +6,8 @@
6
6
  * at lifecycle events. The config format mirrors Claude Code's `settings.json`
7
7
  * hooks shape so a user can copy-paste between them.
8
8
  *
9
- * Supported events (8): SessionStart, UserPromptSubmit, PreToolUse,
10
- * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
9
+ * Supported events (9): SessionStart, UserPromptSubmit, PreToolUse,
10
+ * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd, Stop.
11
11
  *
12
12
  * Exit code semantics (matching Claude Code / Codex):
13
13
  * 0 = success; stdout parsed as JSON for structured decisions
@@ -21,8 +21,10 @@ import { existsSync, readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
  import { codeStateHome } from "./stateHome.js";
24
+ import { isDriverCaller } from "./config.js";
24
25
  import { isDebug } from "./diagnostics.js";
25
26
  import { logEvent } from "./errorSink.js";
27
+ import { scrubSecrets } from "./pipeline/scrubSecrets.js";
26
28
  const SUPPORTED_EVENTS = [
27
29
  "SessionStart",
28
30
  "UserPromptSubmit",
@@ -32,6 +34,7 @@ const SUPPORTED_EVENTS = [
32
34
  "PreCompact",
33
35
  "PostCompact",
34
36
  "SessionEnd",
37
+ "Stop",
35
38
  ];
36
39
  // ---------------------------------------------------------------------------
37
40
  // Config loading
@@ -301,8 +304,72 @@ export function parseCompactCancel(stdout) {
301
304
  return parsed.continue === false;
302
305
  }
303
306
  // ---------------------------------------------------------------------------
307
+ // Stop capture
308
+ // ---------------------------------------------------------------------------
309
+ /**
310
+ * Extract the last assistant message's text and stopReason from an
311
+ * agent_end event. Walks backwards (the shape cmux/state.ts walks forwards)
312
+ * so an aborted final turn with no text still reports its stopReason —
313
+ * the reason is what decides whether Stop hooks fire at all.
314
+ */
315
+ function lastAssistantInfo(event) {
316
+ const messages = event?.messages;
317
+ if (!Array.isArray(messages))
318
+ return undefined;
319
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
320
+ const message = messages[index];
321
+ if (!message || typeof message !== "object")
322
+ continue;
323
+ const typed = message;
324
+ if (typed.role !== "assistant")
325
+ continue;
326
+ const text = typeof typed.content === "string"
327
+ ? typed.content.trim() || undefined
328
+ : textFromBlocks(typed.content);
329
+ return { text, stopReason: typeof typed.stopReason === "string" ? typed.stopReason : undefined };
330
+ }
331
+ return undefined;
332
+ }
333
+ /** Join text blocks from a content array (mirrors cmux's textFromContent). */
334
+ function textFromBlocks(content) {
335
+ if (!Array.isArray(content))
336
+ return undefined;
337
+ const parts = [];
338
+ for (const block of content) {
339
+ if (!block || typeof block !== "object")
340
+ continue;
341
+ const typed = block;
342
+ if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim()) {
343
+ parts.push(typed.text);
344
+ }
345
+ }
346
+ return parts.join("\n") || undefined;
347
+ }
348
+ // ---------------------------------------------------------------------------
304
349
  // Diagnostic logging
305
350
  // ---------------------------------------------------------------------------
351
+ /**
352
+ * A short upload-safe label for a hook-exec exception, for the always-on
353
+ * trail line. Tiers, in order: a Node error code ("ENOENT", "ETIMEDOUT"),
354
+ * the message's first token (a machine word like "spawn"), the constructor
355
+ * name, and typeof for non-Error throws (execImpl is an injected seam —
356
+ * a string throw is possible). Message-derived tiers are scrubbed — a
357
+ * message can begin with content (a credentials URL), and this label rides
358
+ * the default-on (upload-safe) tier.
359
+ */
360
+ export function hookErrorLabel(err) {
361
+ const code = err?.code;
362
+ // Scrub BEFORE slicing: a secret-shaped value longer than the 40-char cap
363
+ // would be split first and the fragment could match no pattern.
364
+ if (typeof code === "string" && code)
365
+ return scrubSecrets(code).slice(0, 40);
366
+ if (err instanceof Error) {
367
+ if (err.message)
368
+ return scrubSecrets(err.message.split(/[\s:]+/)[0]).slice(0, 40);
369
+ return err.constructor.name;
370
+ }
371
+ return typeof err;
372
+ }
306
373
  function logHookEvent(env, payload) {
307
374
  if (!isDebug(env))
308
375
  return;
@@ -656,6 +723,64 @@ export function registerHooks(pi, deps = {}) {
656
723
  }
657
724
  });
658
725
  }
726
+ // --- Stop (agent finished responding; driver turn complete) ---
727
+ // Two-phase capture, mirroring the cmux bridge: agent_settled carries no
728
+ // payload, so the last assistant message is captured at agent_end and
729
+ // consumed at settled. Firing is VOID, not awaited — pi awaits settled
730
+ // handlers before unblocking the TUI's prompt loop and RPC waitForIdle,
731
+ // so an awaited hook would keep a finished session looking busy for up
732
+ // to the full hook timeout.
733
+ const stopGroups = config["Stop"] ?? [];
734
+ if (stopGroups.length > 0 && isDriverCaller(env)) {
735
+ let pendingStop;
736
+ pi.on("agent_end", (event) => {
737
+ pendingStop = lastAssistantInfo(event);
738
+ });
739
+ pi.on("agent_settled", (_event, ctx) => {
740
+ // hasUI is fixed per session — checking it first keeps the capture
741
+ // consume order-independent (a headless session simply never fires).
742
+ if (!ctx.hasUI)
743
+ return;
744
+ const captured = pendingStop;
745
+ if (!captured)
746
+ return;
747
+ // Mid-retry/compaction/continuation settles are not the turn's end —
748
+ // pi sets isIdle only once no automatic follow-up work will run. The
749
+ // capture is NOT consumed here: a settle that isn't idle leaves it for
750
+ // the genuine end-of-turn settle (an agent_end between the two
751
+ // overwrites it with the continuation's own final message).
752
+ try {
753
+ if (!ctx.isIdle())
754
+ return;
755
+ }
756
+ catch {
757
+ return;
758
+ }
759
+ pendingStop = undefined;
760
+ // Claude Code parity: its query loop returns before running Stop hooks
761
+ // when the user aborted or the model errored — "finished, your move"
762
+ // would be a lie for a killed turn. Retry/continuation agent_ends
763
+ // overwrite the capture, so an error followed by a successful retry
764
+ // still fires (with the retry's message).
765
+ if (captured.stopReason === "aborted" || captured.stopReason === "error")
766
+ return;
767
+ const cwd = ctx.cwd;
768
+ const inputJson = JSON.stringify({
769
+ session_id: sessionId,
770
+ cwd,
771
+ hook_event_name: "Stop",
772
+ stop_hook_active: false,
773
+ ...(captured.text ? { last_assistant_message: captured.text } : {}),
774
+ });
775
+ void (async () => {
776
+ for (const group of filterByTrust(stopGroups, trusted(ctx))) {
777
+ for (const entry of group.hooks) {
778
+ await runSideEffectHook(entry.command, inputJson, cwd, "Stop", ctx, env, execImpl);
779
+ }
780
+ }
781
+ })();
782
+ });
783
+ }
659
784
  }
660
785
  /** Run a side-effect-only hook (no control effects, output ignored). */
661
786
  async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, execImpl) {
@@ -665,6 +790,13 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
665
790
  YAGNI_HOOK_CWD: cwd,
666
791
  YAGNI_HOOK_SESSION_ID: env.YAGNI_SESSION_ID ?? "",
667
792
  });
793
+ const failed = !!result.error || (result.exitCode !== 0 && result.exitCode !== null);
794
+ // One outcome, two tiers: the debug line carries the full detail
795
+ // (stderr, timing) and is YAGNI_DEBUG-gated; the warn line is always-on
796
+ // and upload-safe, so "hook fired and failed" is distinguishable from
797
+ // "hook never fired" in a default session. The command rides the warn
798
+ // line scrubbed (below) so N hooks on one event produce distinguishable
799
+ // lines without leaking anything the user embedded in the command.
668
800
  logHookEvent(env, {
669
801
  event: eventName,
670
802
  command,
@@ -674,14 +806,47 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
674
806
  ...(result.error ? { error: result.error } : {}),
675
807
  ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
676
808
  });
677
- if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) {
809
+ if (failed) {
810
+ logEvent({
811
+ source: "hooks",
812
+ level: "warn",
813
+ event: "hook_failed",
814
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
815
+ fields: {
816
+ event_name: eventName,
817
+ // Scrubbed at write time: the trail file stores lines raw (scrubbing
818
+ // otherwise happens only at readSessionTrail), and a user's hook
819
+ // command can embed secrets inline (curl -H "Authorization: Bearer …").
820
+ // The full, unscrubbed command stays on the YAGNI_DEBUG tier.
821
+ command: scrubSecrets(command).slice(0, 80),
822
+ exit_code: result.exitCode,
823
+ // Scrub the whole error string before taking the first colon token —
824
+ // result.error embeds a raw err.message (spawn failed/err paths in
825
+ // execHook), and the sibling hookErrorLabel path scrubs message-derived
826
+ // tiers the same way.
827
+ ...(result.error ? { error: scrubSecrets(result.error).split(":")[0] } : {}),
828
+ },
829
+ });
678
830
  if (ctx.hasUI && result.stderr.trim()) {
679
831
  ctx.ui.notify(`Hook '${eventName}' exited with code ${result.exitCode}: ${result.stderr.trim().slice(0, 200)}`, "warning");
680
832
  }
681
833
  }
682
834
  }
683
- catch {
684
- // Fail-soft: a hook error never breaks the session
835
+ catch (err) {
836
+ // Fail-soft: a hook error never breaks the session — but never silent.
837
+ // The label distinguishes "spawn ENOENT" from a timeout (see
838
+ // hookErrorLabel); the command is scrubbed like the sibling path's.
839
+ logEvent({
840
+ source: "hooks",
841
+ level: "warn",
842
+ event: "hook_exec_threw",
843
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
844
+ fields: {
845
+ event_name: eventName,
846
+ command: scrubSecrets(command).slice(0, 80),
847
+ error: hookErrorLabel(err),
848
+ },
849
+ });
685
850
  }
686
851
  }
687
852
  // ---------------------------------------------------------------------------
@@ -7,6 +7,6 @@
7
7
  export { McpServerConfig, McpScope, PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
8
8
  export { ProjectApprovalState, decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
9
9
  export { McpAuthFile, StoredOAuthEntry, deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
10
- export { revokeTokensOnRemove } from "./auth.js";
10
+ export { authenticate, revokeTokensOnRemove } from "./auth.js";
11
11
  export { probeServer, McpHealthResult, McpHealthStatus } from "./manager.js";
12
12
  //# sourceMappingURL=cliConfig.d.ts.map
@@ -7,6 +7,6 @@
7
7
  export { PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
8
8
  export { decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
9
9
  export { deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
10
- export { revokeTokensOnRemove } from "./auth.js";
10
+ export { authenticate, revokeTokensOnRemove } from "./auth.js";
11
11
  export { probeServer } from "./manager.js";
12
12
  //# sourceMappingURL=cliConfig.js.map
@@ -36,6 +36,8 @@ export interface McpHttpServerConfig {
36
36
  url: string;
37
37
  headers?: Record<string, string>;
38
38
  oauth?: McpOAuthConfig;
39
+ /** Optional explicit tool names, used by the Worker connection to avoid duplicating existing context tools. */
40
+ tools?: string[];
39
41
  }
40
42
  export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
41
43
  export interface ScopedMcpServerConfig {
@@ -267,6 +267,8 @@ export function validateServerConfig(value) {
267
267
  return { ok: true };
268
268
  }
269
269
  if (type === "http" || type === "sse") {
270
+ if (v["tools"] !== undefined && (!Array.isArray(v["tools"]) || v["tools"].length > 100 || v["tools"].some(name => typeof name !== "string" || name.length === 0 || name.length > 128)))
271
+ return { ok: false, message: "tools must be an array of up to 100 tool names" };
270
272
  if (typeof v["url"] !== "string" || v["url"].length === 0) {
271
273
  return { ok: false, message: `${type} server requires a non-empty "url"` };
272
274
  }
@@ -14,7 +14,11 @@ import { buildMcpToolName } from "./names.js";
14
14
  export const MAX_MCP_DESCRIPTION_LENGTH = 2048;
15
15
  export async function registerServerTools(pi, manager, serverName, env = process.env) {
16
16
  const server = manager.get(serverName);
17
- const result = { tools: [], mutatingToolNames: [], warnings: [] };
17
+ const result = {
18
+ tools: [],
19
+ mutatingToolNames: [],
20
+ warnings: [],
21
+ };
18
22
  if (!server?.client)
19
23
  return result;
20
24
  let toolList;
@@ -26,7 +30,12 @@ export async function registerServerTools(pi, manager, serverName, env = process
26
30
  return result;
27
31
  }
28
32
  const toolTimeout = toolTimeoutFromEnv(env);
33
+ const selectedTools = server.config.type === "http" || server.config.type === "sse"
34
+ ? server.config.tools
35
+ : undefined;
29
36
  for (const tool of toolList.tools ?? []) {
37
+ if (selectedTools && !selectedTools.includes(tool.name))
38
+ continue;
30
39
  const fullToolName = buildMcpToolName(serverName, tool.name);
31
40
  if (!fullToolName) {
32
41
  result.warnings.push(`${serverName}: tool "${tool.name}" produced an empty wire name; skipped`);
@@ -45,7 +54,10 @@ export async function registerServerTools(pi, manager, serverName, env = process
45
54
  throw new Error(`MCP server "${serverName}" is not connected (try /mcp reconnect).`);
46
55
  }
47
56
  const args = (params && typeof params === "object" ? params : {});
48
- const call = active.client.callTool({ name: tool.name, arguments: args });
57
+ const call = active.client.callTool({
58
+ name: tool.name,
59
+ arguments: args,
60
+ });
49
61
  const settled = toolTimeout
50
62
  ? await withTimeout(call, toolTimeout, `MCP tool call timed out after ${toolTimeout}ms`)
51
63
  : await call;
@@ -53,7 +65,12 @@ export async function registerServerTools(pi, manager, serverName, env = process
53
65
  },
54
66
  };
55
67
  pi.registerTool(definition);
56
- result.tools.push({ toolName: fullToolName, serverName, originalName: tool.name, description });
68
+ result.tools.push({
69
+ toolName: fullToolName,
70
+ serverName,
71
+ originalName: tool.name,
72
+ description,
73
+ });
57
74
  if (mutating)
58
75
  result.mutatingToolNames.push(fullToolName);
59
76
  }
@@ -67,6 +84,19 @@ export function capDescription(description) {
67
84
  /** Cheap heuristic in the spirit of Claude Code's input-hint check; per-tool annotations arrive via listTools only in newer servers. */
68
85
  export function looksMutating(toolName, description) {
69
86
  const name = toolName.toLowerCase();
87
+ if ([
88
+ "critique_plan",
89
+ "review_pr",
90
+ "test_pr",
91
+ "validate_qa_replay",
92
+ "accept_qa_replay",
93
+ "authorize_qa_fork",
94
+ "engage_worker",
95
+ "propose_instruction_change",
96
+ "prepare_review_publication",
97
+ "resolve_decision",
98
+ ].includes(name))
99
+ return true;
70
100
  const writeHints = /^(create|add|update|edit|delete|remove|set|write|send|post|put|patch|deploy|publish|close|merge|assign|move|archive|trash|restore)/;
71
101
  if (writeHints.test(name))
72
102
  return true;
@@ -91,16 +121,49 @@ function schemaFor(inputSchema) {
91
121
  }
92
122
  function renderCallResult(settled, serverName, toolName) {
93
123
  const parts = [];
124
+ const images = [];
125
+ let imageBytes = 0;
94
126
  for (const item of settled.content ?? []) {
95
- if (item && typeof item === "object" && item.type === "text") {
127
+ if (item &&
128
+ typeof item === "object" &&
129
+ item.type === "text") {
96
130
  parts.push(String(item.text ?? ""));
97
131
  }
132
+ else if (item &&
133
+ typeof item === "object" &&
134
+ "type" in item &&
135
+ item.type === "image") {
136
+ const value = item;
137
+ if (typeof value.data !== "string" ||
138
+ typeof value.mimeType !== "string" ||
139
+ !["image/png", "image/jpeg", "image/webp"].includes(value.mimeType) ||
140
+ value.data.length > 12 * 1024 * 1024 ||
141
+ images.length >= 8 ||
142
+ value.data.length % 4 !== 0 ||
143
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(value.data))
144
+ throw new Error("MCP image is unsupported or exceeds its limit.");
145
+ const bytes = Buffer.byteLength(value.data, "base64");
146
+ imageBytes += bytes;
147
+ if (!bytes || imageBytes > 8 * 1024 * 1024)
148
+ throw new Error("MCP image is unsupported or exceeds its limit.");
149
+ images.push({
150
+ type: "image",
151
+ data: value.data,
152
+ mimeType: value.mimeType,
153
+ });
154
+ }
98
155
  }
99
- const text = parts.join("\n") || "(no text content)";
156
+ const text = parts.join("\n") ||
157
+ (images.length
158
+ ? `Image returned by ${serverName}.${toolName} (untrusted tool content).`
159
+ : "(no text content)");
100
160
  if (settled.isError === true) {
101
161
  throw new Error(`MCP tool error from "${serverName}.${toolName}": ${text}`);
102
162
  }
103
- return { content: [{ type: "text", text }], details: { server: serverName, tool: toolName } };
163
+ return {
164
+ content: [{ type: "text", text }, ...images],
165
+ details: { server: serverName, tool: toolName },
166
+ };
104
167
  }
105
168
  function withTimeout(promise, ms, message) {
106
169
  return new Promise((resolve, reject) => {
@@ -22,6 +22,11 @@ const PATTERNS = [
22
22
  [/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
23
23
  // Long base64-ish blobs (likely keys/JWTs)
24
24
  [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]"],
25
+ // Opaque bearer tokens in Authorization headers: no provider prefix, no
26
+ // secret-named key, and usually under the base64 length threshold — none of
27
+ // the patterns above catch them. Keep the scheme, redact the token. Header
28
+ // names and auth schemes are case-insensitive (RFC 9110) — hence the flag.
29
+ [/(\bauthorization\s*[:=]\s*["']?bearer\s+)([a-z0-9._~+/=-]+)/gi, "$1[REDACTED]"],
25
30
  ];
26
31
  export function scrubSecrets(text) {
27
32
  let out = text;
@@ -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
  }