@snowyroad/braid 0.96.8 → 0.98.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.
package/README.md CHANGED
@@ -107,6 +107,7 @@ with `npx @snowyroad/braid` if you did not install globally.
107
107
  | `braid tools <readonly\|full> [name]` | Set what the agent may do when channel members ask (readonly = read + reply; full = run commands + edit files) |
108
108
  | `braid ipc <compat\|strict> [name]` | Set the IPC confinement profile for a saved agent |
109
109
  | `braid scope [name]` | Show the effective OS sandbox scope; `braid scope allow <agent> <host>` approves a custom model-endpoint host |
110
+ | `braid git-auth <set\|show\|clear> <agent> ...` | Give a saved agent git access to one repository with a local fine-grained token (`set <agent> <owner/repo> --mode <autonomous\|pr-only> [--allow-workflows] [--allow-risk-paths] [--allow-public] --token-stdin`), or show or remove that grant. See [Giving an agent git access](#giving-an-agent-git-access-without-your-credentials) |
110
111
  | `braid service <install\|uninstall\|start\|stop\|restart\|status\|logs> [name]` | Run a saved agent as a background service (starts at login, restarts on crash) — see [docs/SERVICE-MODE.md](docs/SERVICE-MODE.md) |
111
112
  | `braid update` | Update the pinned service runtime and restart all installed services atomically |
112
113
  | `braid attach [name]` | Watch a running service's live activity over the local control socket (same machine) |
@@ -231,7 +232,7 @@ descendant process and cannot be shed.
231
232
  dirs** — `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube/config`, and the bridge's own
232
233
  credential store (`~/.braid`).
233
234
  - **Network:** a curated allow-list (provider API hosts, package registries, and
234
- the major source-code hosts) so normal work keeps working; everything else is
235
+ read-only source-content hosts such as codeload.github.com) so normal work keeps working; everything else is
235
236
  blocked. Operator-extensible.
236
237
  - **Fail-closed:** in **full** tool mode the bridge REFUSES to start if the OS
237
238
  sandbox facility is unavailable, and tells you how to fix it. In read-and-reply
@@ -243,9 +244,11 @@ descendant process and cannot be shed.
243
244
  or the `BRAID_SCOPE_ALLOW_WRITE` / `BRAID_SCOPE_ALLOW_READ` / `BRAID_SCOPE_ALLOW_DOMAINS`
244
245
  env vars (see below). Credential directories (`~/.ssh`, `~/.aws`, `~/.config/gh`,
245
246
  and the rest of the protected inventory) are reserved and CANNOT be granted: a
246
- grant that lands inside one is refused at startup with a SCOPE ERROR. A tool CLI
247
- that only needs network still works inside the jail (`github.com` is in the default
248
- allow-list); grant a project data directory, never a credential store.
247
+ grant that lands inside one is refused at startup with a SCOPE ERROR. Code hosts
248
+ (github.com, gitlab.com, bitbucket.org) are NOT reachable from inside the jail:
249
+ authenticated git goes through the bridge's git tools. Grant a project data directory, never a credential store.
250
+
251
+ **Code hosts are not reachable from the jail.** `github.com`, `api.github.com`, `gitlab.com` and `bitbucket.org` are a reserved set: an `--allow-domain` or `scope.network.allowedDomains` entry that reaches them is refused at startup. To accept that risk for one agent (for example, `git+https` dependencies during installs) run `braid scope allow-code-hosts <agent>`; the startup banner then carries a warning. The agent's own `git` runs with credential helpers disabled, so it cannot read your `gh` or keychain credentials. To let an agent push, fetch and open pull requests without those credentials, see [Giving an agent git access](#giving-an-agent-git-access-without-your-credentials).
249
252
 
250
253
  - **Unix socket access (Linux):** on Linux, seccomp-bpf cannot filter Unix sockets
251
254
  by path. The default `compat` IPC profile allows all pathname sockets. The `strict`
@@ -253,6 +256,27 @@ descendant process and cannot be shed.
253
256
  env-var hygiene to block common IPC paths. See [docs/ipc-profiles.md](docs/ipc-profiles.md)
254
257
  for the full profile reference, support matrix, and selection commands.
255
258
 
259
+ ### Giving an agent git access (without your credentials)
260
+
261
+ Code hosts are not reachable from inside the sandbox, and the agent's own `git`
262
+ cannot use your `gh` login or keychain. To let an agent push, fetch and open pull
263
+ requests, give it a **grant** for one repository:
264
+
265
+ - **Braid GitHub App (recommended):** in the web app, Settings > Integrations installs
266
+ the Braid App on the repositories you choose; then Agents > your agent > Git access
267
+ adds a repository and picks the mode. The Braid server mints a 1-hour token narrowed
268
+ to that repository each time the agent needs one; the agent never sees it.
269
+ - **Local token (fallback):** `braid git-auth set <agent> <owner/repo> --mode <autonomous|pr-only> --token-stdin`
270
+ with a fine-grained token scoped to that repository (Contents and Pull requests
271
+ read/write, no Workflows). Stored under `~/.braid`, which the jail cannot read.
272
+
273
+ Modes: **autonomous** pushes to any branch, opens and merges pull requests, and never
274
+ waits for a human. **pr-only** pushes only to `braid/<agent>/*` branches and opens draft
275
+ pull requests for a human to merge. In both modes pushes are fast-forward only, never
276
+ delete or tag, and `.github/workflows` changes need the "allow workflow changes" toggle.
277
+ The agent uses the `braid_git_status`, `braid_git_fetch`, `braid_git_push`, `braid_open_pr`
278
+ and `braid_merge_pr` tools; Braid runs the git commands outside the sandbox.
279
+
256
280
  **Requirements:** macOS needs `ripgrep` (`brew install ripgrep`). Linux needs
257
281
  `bubblewrap`, `socat`, and `ripgrep`. On an unsupported platform (e.g. Windows) the
258
282
  bridge fails closed to read-and-reply.
@@ -400,7 +400,7 @@ var InviteExpiredError = class extends Error {
400
400
  }
401
401
  };
402
402
  async function redeemInvite(relayHttpUrl, code) {
403
- const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-3WYFRJZR.js");
403
+ const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-7QNDIXBN.js");
404
404
  const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/invites/redeem`, {
405
405
  method: "POST",
406
406
  headers: { "content-type": "application/json" },
@@ -492,7 +492,7 @@ function validateReplacementToken(newToken, currentToken, nowMs = Date.now()) {
492
492
  return { ok: true };
493
493
  }
494
494
  async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch, dpopProof) {
495
- const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-3WYFRJZR.js");
495
+ const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-7QNDIXBN.js");
496
496
  const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/agents/token`, {
497
497
  method: "POST",
498
498
  headers: {
@@ -672,7 +672,7 @@ function verifyWin32Acl(target, sid, deps, userName = "") {
672
672
  }
673
673
  return unexpected;
674
674
  }
675
- function assertKeystorePerms(dir, agentFile, deps) {
675
+ function assertKeystorePerms(dir, agentFile, deps, extraSecretFiles = []) {
676
676
  const platform = deps?.platform ?? process.platform;
677
677
  if (platform === "win32") {
678
678
  const d = deps ?? productionWin32Deps();
@@ -688,6 +688,11 @@ function assertKeystorePerms(dir, agentFile, deps) {
688
688
  if (filePresent) {
689
689
  verifyWin32Acl(agentFile, sid, d, userName);
690
690
  }
691
+ for (const extra of extraSecretFiles) {
692
+ if (!extra || !fsExists(extra)) continue;
693
+ applyWin32Acl(extra, sid, "file", d);
694
+ verifyWin32Acl(extra, sid, d, userName);
695
+ }
691
696
  return;
692
697
  }
693
698
  try {
@@ -716,6 +721,21 @@ function assertKeystorePerms(dir, agentFile, deps) {
716
721
  } catch (err) {
717
722
  if (err instanceof KeystorePermsError) throw err;
718
723
  }
724
+ for (const extra of extraSecretFiles) {
725
+ try {
726
+ const emode = statSync(extra).mode & 511;
727
+ if ((emode & 63) !== 0) {
728
+ throw new KeystorePermsError(
729
+ `[braid] refusing to start: a stored secret file is readable by other users on this machine.
730
+ path : ${extra}
731
+ mode : ${emode.toString(8).padStart(3, "0")} (expected 600)
732
+ fix : chmod 600 ${extra}`
733
+ );
734
+ }
735
+ } catch (err) {
736
+ if (err instanceof KeystorePermsError) throw err;
737
+ }
738
+ }
719
739
  }
720
740
  function configDir(env = process.env) {
721
741
  const override = env.BRAID_CONFIG_DIR?.trim();
@@ -812,6 +832,7 @@ function parseScope(v) {
812
832
  if (allowedDomains) net.allowedDomains = allowedDomains;
813
833
  const deniedDomains = strArr(n.deniedDomains);
814
834
  if (deniedDomains) net.deniedDomains = deniedDomains;
835
+ if (typeof n.allowCodeHosts === "boolean") net.allowCodeHosts = n.allowCodeHosts;
815
836
  if (Object.keys(net).length > 0) out.network = net;
816
837
  }
817
838
  return Object.keys(out).length > 0 ? out : void 0;
@@ -1049,6 +1070,13 @@ function pinEndpoint(dir, agentName, pin) {
1049
1070
  agent.pinnedEndpoints = next;
1050
1071
  saveAgent(dir, agent);
1051
1072
  }
1073
+ function setScopeAllowCodeHosts(dir, agentName, value) {
1074
+ const entry = loadAgent(dir, agentName);
1075
+ const agent = entry.agent;
1076
+ const network = { ...agent.scope?.network ?? {}, allowCodeHosts: value };
1077
+ agent.scope = { ...agent.scope ?? {}, network };
1078
+ saveAgent(dir, agent);
1079
+ }
1052
1080
  function updateAgentWorkspace(dir, agentName, info) {
1053
1081
  try {
1054
1082
  const entry = loadAgent(dir, agentName);
@@ -2832,13 +2860,44 @@ var DEFAULT_ALLOWED_DOMAINS = [
2832
2860
  // Crates.io
2833
2861
  "crates.io",
2834
2862
  "static.crates.io",
2835
- // Source hosts
2863
+ // Source hosts: READ-ONLY content delivery only. The code-host write paths
2864
+ // (github.com, api.github.com, gitlab.com, bitbucket.org) left this list on
2865
+ // 2026-10-06 (git gateway D12/G2): with them present, an injected agent could
2866
+ // push private files to an attacker's repo using an attacker-supplied token,
2867
+ // no operator credential needed. Authenticated git goes through the bridge's
2868
+ // git gateway tools; see RESERVED_CODE_HOST_DOMAINS for the enforced floor.
2869
+ "codeload.github.com",
2870
+ "*.githubusercontent.com"
2871
+ ];
2872
+ var RESERVED_CODE_HOST_DOMAINS = [
2836
2873
  "github.com",
2837
2874
  "*.github.com",
2838
- "*.githubusercontent.com",
2839
2875
  "gitlab.com",
2840
- "bitbucket.org"
2876
+ "*.gitlab.com",
2877
+ "bitbucket.org",
2878
+ "*.bitbucket.org"
2841
2879
  ];
2880
+ var RESERVED_CODE_HOST_READ_ONLY = /* @__PURE__ */ new Set(["codeload.github.com"]);
2881
+ function codeHostConflicts(allowed) {
2882
+ const out = [];
2883
+ for (const a of allowed) {
2884
+ const d = a.domain.trim().toLowerCase().replace(/\.$/, "");
2885
+ if (RESERVED_CODE_HOST_READ_ONLY.has(d)) continue;
2886
+ for (const r of RESERVED_CODE_HOST_DOMAINS) {
2887
+ const base = r.startsWith("*.") ? r.slice(2) : r;
2888
+ const isAllMatch = d === "*";
2889
+ const isReservedItself = d === r || d === base;
2890
+ const isSubdomainOfReservedWildcard = r.startsWith("*.") && d.endsWith("." + base);
2891
+ const isBroaderWildcard = d.startsWith("*.") && (base === d.slice(2) || base.endsWith("." + d.slice(2)));
2892
+ const hit = isAllMatch || isReservedItself || isSubdomainOfReservedWildcard || isBroaderWildcard;
2893
+ if (hit) {
2894
+ out.push({ domain: a.domain, source: a.source, reservation: r });
2895
+ break;
2896
+ }
2897
+ }
2898
+ }
2899
+ return out;
2900
+ }
2842
2901
  var PROVIDER_PATHS = {
2843
2902
  "claude-code": ["~/.claude", "~/.claude.json"],
2844
2903
  codex: ["~/.codex"],
@@ -3022,6 +3081,24 @@ Narrow the grant to a path outside the protected set. (Ancestor grants like "~"
3022
3081
  ...scope.network?.deniedDomains ?? [],
3023
3082
  ...parseEnvList(env["BRAID_SCOPE_DENY_DOMAINS"])
3024
3083
  ]);
3084
+ const allowCodeHosts = scope.network?.allowCodeHosts === true;
3085
+ if (!disabled && !allowCodeHosts) {
3086
+ const labelledDomains = [
3087
+ ...(scope.network?.allowedDomains ?? []).map((domain) => ({ domain, source: "scope.network.allowedDomains" })),
3088
+ ...(cfg.derivedAllowedDomains ?? []).map((domain) => ({ domain, source: "derived model endpoint" })),
3089
+ ...parseEnvList(env["BRAID_SCOPE_ALLOW_DOMAINS"]).map((domain) => ({ domain, source: "BRAID_SCOPE_ALLOW_DOMAINS" })),
3090
+ ...(cfg.scopeFlags?.allowDomains ?? []).map((domain) => ({ domain, source: "--allow-domain" }))
3091
+ ];
3092
+ const hits = codeHostConflicts(labelledDomains);
3093
+ if (hits.length > 0) {
3094
+ const detail = hits.map((h) => ` - ${h.source}: "${h.domain}" is a code host (reserved: ${h.reservation})`).join("\n");
3095
+ throw new Error(
3096
+ `[braid] SCOPE ERROR: a network grant reaches a code host and was refused:
3097
+ ${detail}
3098
+ The sandboxed agent must not reach code hosts directly (an injected agent could push private files anywhere on that host). Use the Braid git tools for authenticated git, or run \`braid scope allow-code-hosts <agent>\` to accept the exfiltration risk for this agent.`
3099
+ );
3100
+ }
3101
+ }
3025
3102
  function describe() {
3026
3103
  const lines = [];
3027
3104
  if (disabled) {
@@ -3042,6 +3119,9 @@ Narrow the grant to a path outside the protected set. (Ancestor grants like "~"
3042
3119
  lines.push(`[scope] provider paths (writable): ${providerPaths.join(", ")}`);
3043
3120
  }
3044
3121
  lines.push(`[scope] allowed domains (${allowedDomains.length}): ${allowedDomains.slice(0, 5).join(", ")}${allowedDomains.length > 5 ? " ..." : ""}`);
3122
+ if (allowCodeHosts) {
3123
+ lines.push("[scope] WARNING: direct code-host egress is ON for this agent (exfiltration path accepted by the owner via `braid scope allow-code-hosts`)");
3124
+ }
3045
3125
  if ((cfg.derivedAllowedDomains ?? []).length > 0) {
3046
3126
  lines.push(`[scope] model endpoints (derived): ${(cfg.derivedAllowedDomains ?? []).join(", ")}`);
3047
3127
  }
@@ -5460,6 +5540,63 @@ var RelayClient = class _RelayClient {
5460
5540
  const body = r.json;
5461
5541
  return Array.isArray(body?.emojis) ? body.emojis.filter((e) => typeof e === "string") : [];
5462
5542
  }
5543
+ /** Git gateway (D4): the agent's active app grants, never tokens. Malformed entries are
5544
+ * skipped, unknown fields dropped. Returns null on a non-2xx or malformed body (never throws),
5545
+ * [] only for a genuinely empty list. */
5546
+ async fetchGitGrants() {
5547
+ const r = await this.httpJson("/agents/me/git-grants", { requestClass: "relay.git.grants" });
5548
+ if (!r.ok) return null;
5549
+ const body = r.json;
5550
+ if (!Array.isArray(body?.grants)) return null;
5551
+ const out = [];
5552
+ for (const g of body.grants) {
5553
+ if (!g || typeof g.id !== "string" || typeof g.repo !== "string") continue;
5554
+ if (g.mode !== "autonomous" && g.mode !== "pr-only") continue;
5555
+ const slash = g.repo.indexOf("/");
5556
+ if (slash <= 0 || slash === g.repo.length - 1) continue;
5557
+ out.push({
5558
+ id: g.id,
5559
+ source: "app",
5560
+ repoOwner: g.repo.slice(0, slash),
5561
+ repoName: g.repo.slice(slash + 1),
5562
+ mode: g.mode,
5563
+ allowWorkflows: g.allowWorkflows === true,
5564
+ allowRiskPaths: g.allowRiskPaths === true,
5565
+ allowPublic: g.allowPublic === true
5566
+ });
5567
+ }
5568
+ return out;
5569
+ }
5570
+ /** Git gateway (D4): mint the repo-narrowed 1h token for ONE grant. The response body
5571
+ * carries the token: never log it, never put it in an error message. Null on any non-2xx
5572
+ * (409 grant_changed, 404 grant_not_found) or malformed body. */
5573
+ async mintGitToken(grantId) {
5574
+ const id = this.pathId(grantId, "grantId");
5575
+ if (id === null) return null;
5576
+ const r = await this.httpJson(`/agents/me/git-grants/${id}/token`, {
5577
+ method: "POST",
5578
+ body: JSON.stringify({}),
5579
+ requestClass: "relay.git.mint",
5580
+ timeoutMs: 2e4
5581
+ });
5582
+ if (!r.ok) return null;
5583
+ const b = r.json;
5584
+ if (typeof b?.token !== "string" || !b.token || typeof b.repo !== "string") return null;
5585
+ if (b.mode !== "autonomous" && b.mode !== "pr-only") return null;
5586
+ const expiresAt = typeof b.expiresAt === "string" ? Date.parse(b.expiresAt) : NaN;
5587
+ if (!Number.isFinite(expiresAt)) return null;
5588
+ return {
5589
+ token: b.token,
5590
+ expiresAt,
5591
+ private: typeof b.private === "boolean" ? b.private : null,
5592
+ defaultBranch: typeof b.defaultBranch === "string" ? b.defaultBranch : null,
5593
+ repo: b.repo,
5594
+ mode: b.mode,
5595
+ allowWorkflows: b.allowWorkflows === true,
5596
+ allowRiskPaths: b.allowRiskPaths === true,
5597
+ allowPublic: b.allowPublic === true
5598
+ };
5599
+ }
5463
5600
  /** Post a bounded-flow reply (turn or synthesis) to the flow-scoped endpoint.
5464
5601
  * agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
5465
5602
  * synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
@@ -5701,17 +5838,26 @@ function brokerSocketPath(uuid, platform = process.platform) {
5701
5838
  var SourceBroker = class {
5702
5839
  /** `platform` injected so the win32 named-pipe branch is unit-testable on a POSIX host
5703
5840
  * (same seam as startControlSocket / controlSocketPath). */
5704
- constructor(channelId, reader, platform = process.platform) {
5841
+ constructor(channelId, reader, platform = process.platform, gateway) {
5705
5842
  this.channelId = channelId;
5706
5843
  this.reader = reader;
5707
5844
  this.platform = platform;
5845
+ this.gateway = gateway;
5708
5846
  }
5709
5847
  channelId;
5710
5848
  reader;
5711
5849
  platform;
5850
+ gateway;
5712
5851
  server = null;
5713
5852
  token = randomBytes(24).toString("hex");
5714
5853
  socketPath = "";
5854
+ /** D15: git routes are disabled while a lisa fresh turn runs on this channel. A depth
5855
+ * counter, so overlapping fresh turns keep git off until the LAST one finishes:
5856
+ * false increments, true decrements (never below 0), enabled only at depth 0. */
5857
+ gitDisabledDepth = 0;
5858
+ setGitEnabled(enabled) {
5859
+ this.gitDisabledDepth = Math.max(0, this.gitDisabledDepth + (enabled ? -1 : 1));
5860
+ }
5715
5861
  async start() {
5716
5862
  if (this.server) throw new Error("SourceBroker already started");
5717
5863
  const isWin32 = this.platform === "win32";
@@ -5751,6 +5897,9 @@ var SourceBroker = class {
5751
5897
  // blind critic, it would silently leak the transcript the relay's read-filter is
5752
5898
  // deliberately withholding (D9/G2b) and defeat critic blindness. Do not add a
5753
5899
  // channel/flow message-read route here without addressing this.
5900
+ // The /git/ routes (git gateway, D6/D7) are write and egress routes; they ARE gated
5901
+ // off lisa fresh turns: ChannelSession.onLisaFreshTurn calls setGitEnabled(false)
5902
+ // for the turn's duration, so a critic or builder round gets 403 git_disabled_for_turn (D15).
5754
5903
  async handle(req, res) {
5755
5904
  const send = (status, body2) => {
5756
5905
  const s = JSON.stringify(body2);
@@ -6002,6 +6151,22 @@ var SourceBroker = class {
6002
6151
  const r = await this.reader.appendCanvasSection(this.channelId, heading, content);
6003
6152
  return send(r.status, r.json ?? { ok: r.ok });
6004
6153
  }
6154
+ if (req.url?.startsWith("/git/")) {
6155
+ if (!this.gateway) return send(404, { error: "unknown route" });
6156
+ if (this.gitDisabledDepth > 0) return send(403, { error: "git_disabled_for_turn", text: "Git tools are unavailable during this turn." });
6157
+ const str = (v) => typeof v === "string" ? v : void 0;
6158
+ const pathArg = str(body.path);
6159
+ if (req.url === "/git/pr/merge") {
6160
+ if (!(typeof body.number === "number" && Number.isInteger(body.number) && body.number > 0)) return send(400, { error: "invalid_number", text: "number must be a whole pull request number." });
6161
+ if (body.method !== void 0 && !(typeof body.method === "string" && ["merge", "squash", "rebase"].includes(body.method))) {
6162
+ return send(400, { error: "invalid_method", text: "method must be merge, squash or rebase." });
6163
+ }
6164
+ }
6165
+ const result = req.url === "/git/status" ? await this.gateway.status(this.channelId, { path: pathArg }) : req.url === "/git/fetch" ? await this.gateway.fetch(this.channelId, { path: pathArg }) : req.url === "/git/push" ? await this.gateway.push(this.channelId, { path: pathArg, branch: str(body.branch) ?? "", sha: str(body.sha) }) : req.url === "/git/pr/open" ? await this.gateway.openPr(this.channelId, { path: pathArg, branch: str(body.branch) ?? "", title: str(body.title) ?? "", body: str(body.body), base: str(body.base) }) : req.url === "/git/pr/merge" ? await this.gateway.mergePr(this.channelId, { number: Number(body.number), method: str(body.method) }) : null;
6166
+ if (!result) return send(404, { error: "unknown route" });
6167
+ if (result.ok) return send(200, { ...result.data ?? {}, ok: true, text: result.text });
6168
+ return send(result.status, { error: result.code, text: result.text });
6169
+ }
6005
6170
  return send(404, { error: "unknown route" });
6006
6171
  } catch (err) {
6007
6172
  return send(500, { error: String(err) });
@@ -6225,6 +6390,29 @@ function formatRateLimitNotice(agentName, provider, message) {
6225
6390
  ];
6226
6391
  }
6227
6392
 
6393
+ // src/gitEnv.ts
6394
+ var AGENT_GIT_ENV = {
6395
+ GIT_TERMINAL_PROMPT: "0",
6396
+ GCM_INTERACTIVE: "never",
6397
+ GIT_CONFIG_NOSYSTEM: "1"
6398
+ };
6399
+ var AGENT_GIT_CONFIG = [
6400
+ ["credential.helper", ""],
6401
+ ["core.askPass", ""]
6402
+ ];
6403
+ function withAgentGitEnv(extra, baseGitConfigCount = process.env.GIT_CONFIG_COUNT) {
6404
+ const out = { ...extra, ...AGENT_GIT_ENV };
6405
+ let n = Number.parseInt(extra.GIT_CONFIG_COUNT ?? baseGitConfigCount ?? "0", 10);
6406
+ if (!Number.isFinite(n) || n < 0) n = 0;
6407
+ for (const [key, value] of AGENT_GIT_CONFIG) {
6408
+ out[`GIT_CONFIG_KEY_${n}`] = key;
6409
+ out[`GIT_CONFIG_VALUE_${n}`] = value;
6410
+ n++;
6411
+ }
6412
+ out.GIT_CONFIG_COUNT = String(n);
6413
+ return out;
6414
+ }
6415
+
6228
6416
  // src/adapter.ts
6229
6417
  function defaultToolPolicy() {
6230
6418
  return { mode: "readonly", configDirAbs: resolve2(configDir(process.env)), allowReadAbs: [] };
@@ -6324,6 +6512,10 @@ function resolvePathBinary(binary, hint) {
6324
6512
  return found;
6325
6513
  }
6326
6514
  function launchSpecFor(agent) {
6515
+ const spec = launchSpecForProvider(agent);
6516
+ return { ...spec, env: withAgentGitEnv(spec.env ?? {}) };
6517
+ }
6518
+ function launchSpecForProvider(agent) {
6327
6519
  const cwd = process.cwd();
6328
6520
  switch (agent) {
6329
6521
  case "claude-code": {
@@ -6468,7 +6660,7 @@ function interactiveHandoffSupported(agent) {
6468
6660
  }
6469
6661
  function interactiveResumeSpecFor(agent, sessionId, cwd) {
6470
6662
  const build = HANDOFF_RESUME_SPECS[agent];
6471
- return build ? build(sessionId, cwd) : null;
6663
+ return build ? { ...build(sessionId, cwd), env: withAgentGitEnv({}) } : null;
6472
6664
  }
6473
6665
  var defaultAcpClientFactory = (launch) => new AcpClient(launch);
6474
6666
  var MAX_CONSECUTIVE_RESTARTS = 3;
@@ -7911,6 +8103,9 @@ export {
7911
8103
  sanitizeForTty,
7912
8104
  restoreTerminal,
7913
8105
  isShellSafeName,
8106
+ productionWin32Deps,
8107
+ resolveCurrentUser,
8108
+ applyWin32Acl,
7914
8109
  assertKeystorePerms,
7915
8110
  configDir,
7916
8111
  readPathWithFallback,
@@ -7921,6 +8116,7 @@ export {
7921
8116
  loadAgent,
7922
8117
  acquireAgentLock,
7923
8118
  pinEndpoint,
8119
+ setScopeAllowCodeHosts,
7924
8120
  updateAgentWorkspace,
7925
8121
  isFilesystemRoot,
7926
8122
  narrowestMode,
@@ -7950,6 +8146,7 @@ export {
7950
8146
  scopedLaunch,
7951
8147
  resetScope,
7952
8148
  sandboxInstaller,
8149
+ withAgentGitEnv,
7953
8150
  resolveClaudeBinary,
7954
8151
  describeClaudeBinary,
7955
8152
  interactiveHandoffSupported,