@snowyroad/braid 0.97.0 → 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) |
@@ -247,7 +248,7 @@ descendant process and cannot be shed.
247
248
  (github.com, gitlab.com, bitbucket.org) are NOT reachable from inside the jail:
248
249
  authenticated git goes through the bridge's git tools. Grant a project data directory, never a credential store.
249
250
 
250
- **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.
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).
251
252
 
252
253
  - **Unix socket access (Linux):** on Linux, seccomp-bpf cannot filter Unix sockets
253
254
  by path. The default `compat` IPC profile allows all pathname sockets. The `strict`
@@ -255,6 +256,27 @@ descendant process and cannot be shed.
255
256
  env-var hygiene to block common IPC paths. See [docs/ipc-profiles.md](docs/ipc-profiles.md)
256
257
  for the full profile reference, support matrix, and selection commands.
257
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
+
258
280
  **Requirements:** macOS needs `ripgrep` (`brew install ripgrep`). Linux needs
259
281
  `bubblewrap`, `socat`, and `ripgrep`. On an unsupported platform (e.g. Windows) the
260
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-7LMCYNHB.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-7LMCYNHB.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();
@@ -5520,6 +5540,63 @@ var RelayClient = class _RelayClient {
5520
5540
  const body = r.json;
5521
5541
  return Array.isArray(body?.emojis) ? body.emojis.filter((e) => typeof e === "string") : [];
5522
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
+ }
5523
5600
  /** Post a bounded-flow reply (turn or synthesis) to the flow-scoped endpoint.
5524
5601
  * agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
5525
5602
  * synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
@@ -5761,17 +5838,26 @@ function brokerSocketPath(uuid, platform = process.platform) {
5761
5838
  var SourceBroker = class {
5762
5839
  /** `platform` injected so the win32 named-pipe branch is unit-testable on a POSIX host
5763
5840
  * (same seam as startControlSocket / controlSocketPath). */
5764
- constructor(channelId, reader, platform = process.platform) {
5841
+ constructor(channelId, reader, platform = process.platform, gateway) {
5765
5842
  this.channelId = channelId;
5766
5843
  this.reader = reader;
5767
5844
  this.platform = platform;
5845
+ this.gateway = gateway;
5768
5846
  }
5769
5847
  channelId;
5770
5848
  reader;
5771
5849
  platform;
5850
+ gateway;
5772
5851
  server = null;
5773
5852
  token = randomBytes(24).toString("hex");
5774
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
+ }
5775
5861
  async start() {
5776
5862
  if (this.server) throw new Error("SourceBroker already started");
5777
5863
  const isWin32 = this.platform === "win32";
@@ -5811,6 +5897,9 @@ var SourceBroker = class {
5811
5897
  // blind critic, it would silently leak the transcript the relay's read-filter is
5812
5898
  // deliberately withholding (D9/G2b) and defeat critic blindness. Do not add a
5813
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).
5814
5903
  async handle(req, res) {
5815
5904
  const send = (status, body2) => {
5816
5905
  const s = JSON.stringify(body2);
@@ -6062,6 +6151,22 @@ var SourceBroker = class {
6062
6151
  const r = await this.reader.appendCanvasSection(this.channelId, heading, content);
6063
6152
  return send(r.status, r.json ?? { ok: r.ok });
6064
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
+ }
6065
6170
  return send(404, { error: "unknown route" });
6066
6171
  } catch (err) {
6067
6172
  return send(500, { error: String(err) });
@@ -7998,6 +8103,9 @@ export {
7998
8103
  sanitizeForTty,
7999
8104
  restoreTerminal,
8000
8105
  isShellSafeName,
8106
+ productionWin32Deps,
8107
+ resolveCurrentUser,
8108
+ applyWin32Acl,
8001
8109
  assertKeystorePerms,
8002
8110
  configDir,
8003
8111
  readPathWithFallback,