@snowyroad/braid 0.97.0 → 0.98.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/README.md +23 -1
- package/dist/{chunk-2OHYN3KD.js → chunk-3SHZYLWH.js} +112 -4
- package/dist/cli.js +1297 -129
- package/dist/{http-7LMCYNHB.js → http-7QNDIXBN.js} +1 -1
- package/dist/mcp/server.js +120 -2
- package/package.json +1 -1
- package/service-shrinkwrap.json +2 -2
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-
|
|
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-
|
|
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,
|