@snowyroad/braid 0.82.0 → 0.83.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.
@@ -1,2517 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/types.ts
4
- var ACP_AGENT_IDS = [
5
- "claude-code",
6
- "codex",
7
- "gemini",
8
- "grok",
9
- "cursor",
10
- "opencode",
11
- "goose",
12
- "cline",
13
- "copilot",
14
- "qwen"
15
- ];
16
- var AUTH_MODES = ["subscription", "api-key", "auto"];
17
-
18
- // src/knobs.ts
19
- var EMPTY_RUN_FLAGS = Object.freeze({
20
- allowWrite: [],
21
- allowRead: [],
22
- allowDomains: []
23
- });
24
- var KNOWN_RUN_FLAGS = [
25
- "--provider",
26
- "--model",
27
- "--fallback-model",
28
- "--tools",
29
- "--scope",
30
- "--auth",
31
- "--allow-write",
32
- "--allow-read",
33
- "--allow-domain",
34
- "--invite",
35
- "--ipc-profile",
36
- "--socket",
37
- "--no-socket"
38
- ];
39
- var VALID_SCOPE = ["on", "off"];
40
- var VALID_TOOLS = ["readonly", "full"];
41
- var VALID_IPC_PROFILES = ["compat", "strict"];
42
- function parseRunFlags(argv) {
43
- const flags = { allowWrite: [], allowRead: [], allowDomains: [] };
44
- const rest = [];
45
- const take = (name, i, inline) => {
46
- if (inline !== void 0) return { value: inline, next: i };
47
- const v = argv[i + 1];
48
- if (v === void 0 || v.startsWith("--")) return { error: `Missing value for ${name}` };
49
- return { value: v, next: i + 1 };
50
- };
51
- for (let i = 0; i < argv.length; i++) {
52
- const tok = argv[i];
53
- if (!tok.startsWith("--")) {
54
- rest.push(tok);
55
- continue;
56
- }
57
- const eq = tok.indexOf("=");
58
- const name = eq === -1 ? tok : tok.slice(0, eq);
59
- const inline = eq === -1 ? void 0 : tok.slice(eq + 1);
60
- if (name === "--invite") {
61
- rest.push(tok);
62
- if (inline === void 0 && argv[i + 1] !== void 0) {
63
- rest.push(argv[i + 1]);
64
- i++;
65
- }
66
- continue;
67
- }
68
- if (name === "--socket" || name === "--no-socket") {
69
- continue;
70
- }
71
- if (!KNOWN_RUN_FLAGS.includes(name)) {
72
- return { ok: false, error: `Unknown flag: ${name}` };
73
- }
74
- const got = take(name, i, inline);
75
- if ("error" in got) return { ok: false, error: got.error };
76
- i = got.next;
77
- const v = got.value.trim();
78
- switch (name) {
79
- case "--provider":
80
- if (!ACP_AGENT_IDS.includes(v)) {
81
- return { ok: false, error: `Invalid --provider: ${v}. Expected one of: ${ACP_AGENT_IDS.join(", ")}` };
82
- }
83
- flags.provider = v;
84
- break;
85
- case "--model":
86
- flags.model = v;
87
- break;
88
- case "--fallback-model":
89
- flags.fallbackModel = v;
90
- break;
91
- case "--tools":
92
- if (!VALID_TOOLS.includes(v)) {
93
- return { ok: false, error: `Invalid --tools: ${v}. Expected "readonly" or "full"` };
94
- }
95
- flags.tools = v;
96
- break;
97
- case "--scope":
98
- if (!VALID_SCOPE.includes(v)) {
99
- return { ok: false, error: `Invalid --scope: ${v}. Expected "on" or "off"` };
100
- }
101
- flags.scope = v;
102
- break;
103
- case "--auth":
104
- if (!AUTH_MODES.includes(v)) {
105
- return { ok: false, error: `Invalid --auth: ${v}. Expected ${AUTH_MODES.join(", ")}` };
106
- }
107
- flags.auth = v;
108
- break;
109
- case "--allow-write":
110
- flags.allowWrite.push(v);
111
- break;
112
- case "--allow-read":
113
- flags.allowRead.push(v);
114
- break;
115
- case "--allow-domain":
116
- flags.allowDomains.push(v);
117
- break;
118
- case "--ipc-profile":
119
- if (!VALID_IPC_PROFILES.includes(v)) {
120
- return { ok: false, error: `Invalid --ipc-profile: ${v}. Expected "compat" or "strict"` };
121
- }
122
- flags.ipcProfile = v;
123
- break;
124
- }
125
- }
126
- return { ok: true, flags, rest };
127
- }
128
- var DEFAULT_CLAUDE_FALLBACK_MODEL = "claude-sonnet-4-6";
129
- function parseListEnv(val) {
130
- if (!val) return [];
131
- return val.split(/[,:]/).map((s) => s.trim()).filter((s) => s.length > 0);
132
- }
133
- function resolveKnobs(flags, env, stored) {
134
- const f = flags ?? EMPTY_RUN_FLAGS;
135
- let provider;
136
- const envAgent = env.BRAID_AGENT?.trim();
137
- if (f.provider) provider = { value: f.provider, origin: "flag" };
138
- else if (envAgent && ACP_AGENT_IDS.includes(envAgent)) provider = { value: envAgent, origin: "env" };
139
- else if (stored?.provider && ACP_AGENT_IDS.includes(stored.provider)) provider = { value: stored.provider, origin: "file" };
140
- else provider = { value: "claude-code", origin: "default" };
141
- let model;
142
- const envModel = env.BRAID_MODEL?.trim();
143
- if (f.model !== void 0) model = { value: f.model, origin: "flag" };
144
- else if (envModel) model = { value: envModel, origin: "env" };
145
- else if (stored?.model) model = { value: stored.model, origin: "file" };
146
- else model = { value: "", origin: "default" };
147
- let toolMode;
148
- const envTool = env.BRAID_TOOL_MODE?.trim();
149
- if (f.tools) toolMode = { value: f.tools, origin: "flag" };
150
- else if (envTool === "readonly" || envTool === "full") toolMode = { value: envTool, origin: "env" };
151
- else if (stored?.toolMode) toolMode = { value: stored.toolMode, origin: "file" };
152
- else toolMode = { value: void 0, origin: "default" };
153
- let scopeOff;
154
- if (f.scope) scopeOff = { value: f.scope === "off", origin: "flag" };
155
- else if (env.BRAID_SCOPE === "off") scopeOff = { value: true, origin: "env" };
156
- else if (stored?.scope?.disabled === true) scopeOff = { value: true, origin: "file" };
157
- else scopeOff = { value: false, origin: "default" };
158
- let auth;
159
- const envAuth = env.BRAID_AUTH?.trim();
160
- if (f.auth) auth = { value: f.auth, origin: "flag" };
161
- else if (envAuth && AUTH_MODES.includes(envAuth)) auth = { value: envAuth, origin: "env" };
162
- else if (stored?.auth && AUTH_MODES.includes(stored.auth)) auth = { value: stored.auth, origin: "file" };
163
- else auth = { value: "auto", origin: "default" };
164
- let fallbackModel;
165
- if (f.fallbackModel !== void 0 && f.fallbackModel !== "") fallbackModel = { value: f.fallbackModel, origin: "flag" };
166
- else if (stored?.fallbackModel) fallbackModel = { value: stored.fallbackModel, origin: "file" };
167
- else fallbackModel = {
168
- value: provider.value === "claude-code" ? DEFAULT_CLAUDE_FALLBACK_MODEL : void 0,
169
- origin: "default"
170
- };
171
- return {
172
- provider,
173
- model,
174
- toolMode,
175
- scopeOff,
176
- auth,
177
- fallbackModel,
178
- allowWrite: { flag: [...f.allowWrite], env: parseListEnv(env.BRAID_SCOPE_ALLOW_WRITE), file: [...stored?.scope?.allowWrite ?? []] },
179
- allowRead: { flag: [...f.allowRead], env: parseListEnv(env.BRAID_SCOPE_ALLOW_READ), file: [...stored?.scope?.allowRead ?? []] },
180
- allowDomains: { flag: [...f.allowDomains], env: parseListEnv(env.BRAID_SCOPE_ALLOW_DOMAINS), file: [...stored?.scope?.network?.allowedDomains ?? []] }
181
- };
182
- }
183
- function newBeyondFile(list) {
184
- const file = new Set(list.file);
185
- return {
186
- env: list.env.filter((x) => !file.has(x)),
187
- flag: list.flag.filter((x) => !file.has(x))
188
- };
189
- }
190
- function seg(n, singular, plural) {
191
- if (n === 0) return null;
192
- return `+${n} ${n === 1 ? singular : plural}`;
193
- }
194
- function computePostureWarnings(flags, env, stored) {
195
- if (stored === void 0) return [];
196
- const lines = [];
197
- const k = resolveKnobs(flags, env, stored);
198
- if (k.toolMode.value === "full" && stored.toolMode !== "full" && (k.toolMode.origin === "env" || k.toolMode.origin === "flag")) {
199
- const src = k.toolMode.origin === "env" ? "env BRAID_TOOL_MODE" : "--tools";
200
- const saved = stored.toolMode ? `saved choice is "${stored.toolMode}"` : "saved choice is unset (readonly default)";
201
- lines.push(`[posture] tools: full (${src}) \u2014 ${saved}; this run only`);
202
- }
203
- if (flags.ipcProfile !== void 0) {
204
- const savedProfile = stored.scope?.ipcProfile;
205
- const savedLabel = savedProfile !== void 0 ? `"${savedProfile}"` : "unset";
206
- lines.push(`[posture] ipc profile: ${flags.ipcProfile} (--ipc-profile) \u2014 saved profile is ${savedLabel}; this run only`);
207
- }
208
- if (k.scopeOff.value && stored.scope?.disabled !== true && (k.scopeOff.origin === "env" || k.scopeOff.origin === "flag")) {
209
- const src = k.scopeOff.origin === "env" ? "env BRAID_SCOPE" : "--scope off";
210
- lines.push(`[posture] sandbox: OFF (${src}) \u2014 this run only`);
211
- }
212
- if (k.auth.value === "api-key" && stored.auth === "subscription" && (k.auth.origin === "env" || k.auth.origin === "flag")) {
213
- const src = k.auth.origin === "env" ? "env BRAID_AUTH" : "--auth api-key";
214
- lines.push(`[posture] auth: api-key (${src}) \u2014 saved choice is "subscription"; bills per-token, this run only`);
215
- }
216
- const w = newBeyondFile(k.allowWrite);
217
- const r = newBeyondFile(k.allowRead);
218
- const d = newBeyondFile(k.allowDomains);
219
- const layerLine = (layer) => {
220
- const parts = [
221
- seg((layer === "env" ? w.env : w.flag).length, "write path", "write paths"),
222
- seg((layer === "env" ? r.env : r.flag).length, "read path", "read paths"),
223
- seg((layer === "env" ? d.env : d.flag).length, "domain", "domains")
224
- ].filter((p) => p !== null);
225
- if (parts.length === 0) return null;
226
- const src = layer === "env" ? "env BRAID_SCOPE_ALLOW_*" : "--allow-write/--allow-read/--allow-domain";
227
- return `[posture] sandbox: ${parts.join(", ")} (${src}) \u2014 beyond saved scope; this run only`;
228
- };
229
- const envLine = layerLine("env");
230
- if (envLine) lines.push(envLine);
231
- const flagLine = layerLine("flag");
232
- if (flagLine) lines.push(flagLine);
233
- return lines;
234
- }
235
-
236
- // src/auth.ts
237
- var ANTHROPIC_KEY_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
238
- function nonEmpty(v) {
239
- return typeof v === "string" && v.trim() !== "";
240
- }
241
- function presentKeyEnvVars(env) {
242
- return ANTHROPIC_KEY_ENV_KEYS.filter((k) => nonEmpty(env[k]));
243
- }
244
- function hasSubscriptionLogin(env, readText, homeDir, probeKeychain = () => false) {
245
- if (nonEmpty(env.CLAUDE_CODE_OAUTH_TOKEN)) return true;
246
- const sep2 = homeDir.endsWith("/") ? "" : "/";
247
- const creds = safeJson(readText(`${homeDir}${sep2}.claude/.credentials.json`));
248
- if (creds && isNonEmptyObject(creds.claudeAiOauth)) return true;
249
- try {
250
- if (probeKeychain()) return true;
251
- } catch {
252
- }
253
- return false;
254
- }
255
- function safeJson(text) {
256
- if (text === void 0) return null;
257
- try {
258
- const v = JSON.parse(text);
259
- return typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
260
- } catch {
261
- return null;
262
- }
263
- }
264
- function isNonEmptyObject(v) {
265
- return typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length > 0;
266
- }
267
- function resolveAnthropicAuth(input) {
268
- const { agent, agentMode, authMode, env } = input;
269
- if (agent !== "claude-code" || agentMode !== "acp") return { stripKeys: [], notices: [] };
270
- const present = presentKeyEnvVars(env);
271
- const baseUrlSet = nonEmpty(env.ANTHROPIC_BASE_URL);
272
- const notices = [];
273
- if (authMode === "api-key") {
274
- if (present.length === 0) {
275
- notices.push(
276
- "[auth] api-key mode is set but no ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN is present; Claude Code will have no API credential to authenticate with."
277
- );
278
- }
279
- return { stripKeys: [], notices };
280
- }
281
- if (authMode === "subscription") {
282
- if (baseUrlSet && present.length > 0) {
283
- notices.push(
284
- `[auth] subscription mode: removing ${present.join(" / ")} so your Claude subscription is used. NOTE: ANTHROPIC_BASE_URL is also set, so the custom gateway will no longer receive a token.`
285
- );
286
- } else if (!input.hasSubscriptionLogin) {
287
- const tail = present.length > 0 ? `${present.join(" / ")} will be removed, leaving no working credential. ` : "";
288
- notices.push(
289
- `[auth] subscription mode is set but no Claude subscription login was detected. ${tail}Run \`claude\` to log in, or use --auth api-key with an ANTHROPIC_API_KEY.`
290
- );
291
- }
292
- return { stripKeys: present, notices };
293
- }
294
- if (baseUrlSet) return { stripKeys: [], notices };
295
- if (present.length === 0) return { stripKeys: [], notices };
296
- if (input.hasSubscriptionLogin) {
297
- notices.push(
298
- `[auth] using your Claude subscription login; ignoring ${present.join(" / ")} in the environment (set --auth api-key to bill the API per-token instead).`
299
- );
300
- return { stripKeys: present, notices };
301
- }
302
- notices.push(
303
- `[auth] no Claude subscription login found; authenticating with ${present.join(" / ")} (bills per-token). Run \`claude\` to log in, or set --auth subscription once you have.`
304
- );
305
- return { stripKeys: [], notices };
306
- }
307
- function effectiveAnthropicCredential(input) {
308
- if (input.agent !== "claude-code" || input.agentMode !== "acp") return "the provider's own login";
309
- const { stripKeys } = resolveAnthropicAuth(input);
310
- if (stripKeys.length > 0) return "your Claude subscription login";
311
- if (nonEmpty(input.env.ANTHROPIC_BASE_URL) && presentKeyEnvVars(input.env).length > 0) {
312
- return "a custom gateway (ANTHROPIC_BASE_URL)";
313
- }
314
- if (presentKeyEnvVars(input.env).length > 0) return "an API key (bills per-token)";
315
- return "your Claude subscription login";
316
- }
317
-
318
- // src/authProbe.ts
319
- import { execFileSync } from "child_process";
320
- import { homedir } from "os";
321
- function macKeychainHasClaudeLogin() {
322
- if (process.platform !== "darwin") return false;
323
- try {
324
- execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
325
- stdio: "ignore",
326
- timeout: 2e3
327
- });
328
- return true;
329
- } catch {
330
- return false;
331
- }
332
- }
333
- function keychainProbeFor(home) {
334
- return home === homedir() ? macKeychainHasClaudeLogin : () => false;
335
- }
336
-
337
- // src/invite.ts
338
- var REQUIRED_FIELDS = ["relayUrl", "code"];
339
- function decodeInvite(code) {
340
- const trimmed = (code ?? "").trim();
341
- if (trimmed === "") throw new Error("Invalid invite: empty");
342
- const json = Buffer.from(trimmed, "base64url").toString("utf8");
343
- let parsed;
344
- try {
345
- parsed = JSON.parse(json);
346
- } catch {
347
- throw new Error("Invalid invite: payload is not valid JSON");
348
- }
349
- if (typeof parsed !== "object" || parsed === null) {
350
- throw new Error("Invalid invite: payload is not an object");
351
- }
352
- const p = parsed;
353
- for (const field of REQUIRED_FIELDS) {
354
- const v = p[field];
355
- if (typeof v !== "string" || v.trim() === "") {
356
- throw new Error(`Invalid invite: missing or empty field "${field}"`);
357
- }
358
- }
359
- const relayUrl = p.relayUrl.trim();
360
- if (!/^wss?:\/\//.test(relayUrl)) {
361
- throw new Error("Invalid invite: relayUrl must start with ws:// or wss://");
362
- }
363
- return {
364
- relayUrl,
365
- code: p.code.trim()
366
- };
367
- }
368
-
369
- // src/redeem.ts
370
- var InviteExpiredError = class extends Error {
371
- constructor() {
372
- super("This invite has expired or been revoked. Ask an admin to mint a new one.");
373
- this.name = "InviteExpiredError";
374
- }
375
- };
376
- async function redeemInvite(relayHttpUrl, code) {
377
- const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-HW3QESWO.js");
378
- const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/invites/redeem`, {
379
- method: "POST",
380
- headers: { "content-type": "application/json" },
381
- body: JSON.stringify({ code }),
382
- requestClass: "relay.invite.redeem"
383
- });
384
- if (!r.ok) {
385
- if (r.category === "http" && r.status === 410) {
386
- throw new InviteExpiredError();
387
- }
388
- if (r.category === "http") throw new Error(`Invite redemption failed (HTTP ${r.status}).`);
389
- throw new Error(`Invite redemption failed (${r.category}).`);
390
- }
391
- const data = r.json;
392
- if (!data.ok || !data.agentKey || !data.agentId || !data.agentUuid) {
393
- throw new Error("Invite redemption returned an incomplete response.");
394
- }
395
- return {
396
- agentKey: data.agentKey,
397
- agentId: data.agentId,
398
- agentName: data.agentName ?? data.agentId,
399
- agentUuid: data.agentUuid,
400
- channelId: data.channelId
401
- };
402
- }
403
-
404
- // src/config.ts
405
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "fs";
406
- import { dirname as dirname2, join as join4 } from "path";
407
- import { homedir as homedir4 } from "os";
408
-
409
- // src/keystore.ts
410
- import { spawnSync as nodeSpawnSync } from "child_process";
411
- import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, unlinkSync, chmodSync, openSync, closeSync, existsSync, statSync } from "fs";
412
- import { homedir as homedir2 } from "os";
413
- import { join, dirname } from "path";
414
- import path from "path";
415
-
416
- // src/token.ts
417
- var RebootstrapError = class extends Error {
418
- constructor() {
419
- super(
420
- "This agent's credential was revoked or invalidated. Re-issue a connection from the website."
421
- );
422
- this.name = "RebootstrapError";
423
- }
424
- };
425
- var MAX_TOKEN_LENGTH = 8192;
426
- var MAX_TOKEN_TTL_S = 48 * 3600;
427
- var CLOCK_SKEW_S = 300;
428
- function decodeJwtClaims(token) {
429
- const parts = token.split(".");
430
- if (parts.length !== 3) return null;
431
- try {
432
- const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
433
- if (typeof header !== "object" || header === null || Array.isArray(header)) return null;
434
- const alg = header.alg;
435
- if (typeof alg !== "string" || alg.toLowerCase() === "none") return null;
436
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
437
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null;
438
- return payload;
439
- } catch {
440
- return null;
441
- }
442
- }
443
- function validateReplacementToken(newToken, currentToken, nowMs = Date.now()) {
444
- if (newToken.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "token too large" };
445
- const claims = decodeJwtClaims(newToken);
446
- if (!claims) return { ok: false, reason: "not a well-formed JWT" };
447
- const exp = claims.exp;
448
- if (typeof exp !== "number" || !Number.isFinite(exp)) {
449
- return { ok: false, reason: "missing or non-numeric exp claim" };
450
- }
451
- const nowS = nowMs / 1e3;
452
- if (exp <= nowS - CLOCK_SKEW_S) return { ok: false, reason: "already expired" };
453
- if (exp > nowS + MAX_TOKEN_TTL_S) return { ok: false, reason: "expiry implausibly far in the future" };
454
- const current = decodeJwtClaims(currentToken);
455
- if (current) {
456
- if (typeof current.sub === "string" && typeof claims.sub === "string" && current.sub !== claims.sub) {
457
- return { ok: false, reason: "subject (sub) does not match the current token" };
458
- }
459
- if (typeof current.iss === "string" && typeof claims.iss === "string" && current.iss !== claims.iss) {
460
- return { ok: false, reason: "issuer (iss) does not match the current token" };
461
- }
462
- }
463
- return { ok: true };
464
- }
465
- async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch, dpopProof) {
466
- const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-HW3QESWO.js");
467
- const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/agents/token`, {
468
- method: "POST",
469
- headers: {
470
- Authorization: `Bearer ${agentKey}`,
471
- ...dpopProof ? { "Content-Type": "application/json" } : {}
472
- },
473
- ...dpopProof ? { body: JSON.stringify({ dpop: dpopProof }) } : {},
474
- fetchFn,
475
- requestClass: "relay.token.mint"
476
- });
477
- if (!r.ok) {
478
- if (r.category === "http" && r.status === 401) throw new RebootstrapError();
479
- if (r.category === "http") throw new Error(`Token mint failed (HTTP ${r.status}).`);
480
- throw new Error(`Token mint failed (${r.category}).`);
481
- }
482
- const data = r.json;
483
- if (!data.ok || !data.accessToken || !data.agentKey) {
484
- throw new Error("Token mint returned an incomplete response.");
485
- }
486
- return {
487
- accessToken: data.accessToken,
488
- expiresIn: typeof data.expiresIn === "number" ? data.expiresIn : 3600,
489
- agentKey: data.agentKey,
490
- ...typeof data.clerkIssuer === "string" && data.clerkIssuer ? { clerkIssuer: data.clerkIssuer } : {},
491
- ...typeof data.cardAttestation === "string" && data.cardAttestation ? { cardAttestation: data.cardAttestation } : {}
492
- };
493
- }
494
-
495
- // src/exitCodes.ts
496
- var EXIT_OK = 0;
497
- var EXIT_NEEDS_HUMAN = 78;
498
- var EXIT_CRASH = 1;
499
- var KeystorePermsError = class extends Error {
500
- constructor(message) {
501
- super(message);
502
- this.name = "KeystorePermsError";
503
- }
504
- };
505
- var NEEDS_HUMAN_CLOSE_CODES = /* @__PURE__ */ new Set([4002, 4003, 4004]);
506
- function classifyFatal(codeOrError) {
507
- if (typeof codeOrError === "number") {
508
- return NEEDS_HUMAN_CLOSE_CODES.has(codeOrError) ? EXIT_NEEDS_HUMAN : EXIT_CRASH;
509
- }
510
- if (codeOrError instanceof RebootstrapError || codeOrError instanceof KeystorePermsError) {
511
- return EXIT_NEEDS_HUMAN;
512
- }
513
- return EXIT_CRASH;
514
- }
515
-
516
- // src/keystore.ts
517
- function productionWin32Deps() {
518
- return {
519
- platform: process.platform,
520
- env: process.env,
521
- spawnSync: (cmd, args) => {
522
- const r = nodeSpawnSync(cmd, args, { shell: false, encoding: "utf8" });
523
- return { status: r.status, stdout: typeof r.stdout === "string" ? r.stdout : void 0, error: r.error };
524
- }
525
- };
526
- }
527
- function resolveCurrentUser(deps) {
528
- const env = deps.env ?? process.env;
529
- const systemRoot = env["SystemRoot"] ?? "C:\\Windows";
530
- const whoami = path.win32.join(systemRoot, "System32", "whoami.exe");
531
- const result = deps.spawnSync(whoami, ["/user", "/fo", "csv", "/nh"]);
532
- if (result.error || result.status !== 0) {
533
- throw new KeystorePermsError(
534
- `[braid] refusing to start: could not resolve current user SID (whoami exited ${result.status ?? "null"}${result.error ? ": " + result.error.message : ""}). Run 'whoami /user' to diagnose.`
535
- );
536
- }
537
- const stdout = result.stdout ?? "";
538
- const allMatches = [...stdout.matchAll(/"(S-\d[\d-]+)"/g)];
539
- const sidMatch = allMatches.length > 0 ? allMatches[allMatches.length - 1] : null;
540
- if (!sidMatch) {
541
- throw new KeystorePermsError(
542
- `[braid] refusing to start: could not parse SID from whoami output. Run 'whoami /user /fo csv /nh' to diagnose.`
543
- );
544
- }
545
- const nameMatch = stdout.match(/^"([^"]+)"/);
546
- const userName = nameMatch ? nameMatch[1].toLowerCase() : "";
547
- return { sid: sidMatch[1], userName };
548
- }
549
- function applyWin32Acl(target, sid, kind, deps) {
550
- const env = deps.env ?? process.env;
551
- const systemRoot = env["SystemRoot"] ?? "C:\\Windows";
552
- const icacls = path.win32.join(systemRoot, "System32", "icacls.exe");
553
- const resetResult = deps.spawnSync(icacls, [target, "/reset"]);
554
- if (resetResult.error || resetResult.status !== 0) {
555
- throw new KeystorePermsError(
556
- `[braid] refusing to start: icacls /reset failed on ${target} (exit ${resetResult.status ?? "null"}${resetResult.error ? ": " + resetResult.error.message : ""}). Command: icacls ${target} /reset`
557
- );
558
- }
559
- const grant = kind === "dir" ? `*${sid}:(OI)(CI)F` : `*${sid}:F`;
560
- const args = [target, "/inheritance:r", "/grant:r", grant];
561
- const result = deps.spawnSync(icacls, args);
562
- if (result.error || result.status !== 0) {
563
- throw new KeystorePermsError(
564
- `[braid] refusing to start: icacls failed to set ACL on ${target} (exit ${result.status ?? "null"}${result.error ? ": " + result.error.message : ""}). Command: icacls ${args.join(" ")}`
565
- );
566
- }
567
- }
568
- function verifyWin32Acl(target, sid, deps, userName = "") {
569
- const env = deps.env ?? process.env;
570
- const systemRoot = env["SystemRoot"] ?? "C:\\Windows";
571
- const icacls = path.win32.join(systemRoot, "System32", "icacls.exe");
572
- const result = deps.spawnSync(icacls, [target]);
573
- if (result.error || result.status !== 0) {
574
- console.warn(
575
- `[braid] warn: icacls verify on ${target} exited ${result.status ?? "null"} \u2014 could not confirm ACL.`
576
- );
577
- return [];
578
- }
579
- const stdout = result.stdout ?? "";
580
- const ALLOWED = /* @__PURE__ */ new Set([
581
- `*${sid}`,
582
- "NT AUTHORITY\\SYSTEM",
583
- "BUILTIN\\Administrators"
584
- ]);
585
- const unexpected = [];
586
- for (const line of stdout.split(/\r?\n/)) {
587
- const trimmed = line.trim();
588
- if (!trimmed) continue;
589
- const colonIdx = trimmed.search(/:[(A-Z]/);
590
- if (colonIdx < 0) continue;
591
- let principalRaw = trimmed.slice(0, colonIdx).trim();
592
- if (!principalRaw) continue;
593
- if (principalRaw.startsWith(target)) {
594
- principalRaw = principalRaw.slice(target.length).trim();
595
- }
596
- if (!principalRaw) continue;
597
- const principal = principalRaw;
598
- if (ALLOWED.has(principal)) continue;
599
- if (principal === sid) continue;
600
- if (userName && principal.toLowerCase() === userName) continue;
601
- unexpected.push(principal);
602
- }
603
- if (unexpected.length > 0) {
604
- console.warn(
605
- `[braid] warn: unexpected principals in ACL on ${target}: ${unexpected.join(", ")}. Consider running: icacls "${target}" /inheritance:r /grant:r "*${sid}:(OI)(CI)F"`
606
- );
607
- }
608
- return unexpected;
609
- }
610
- function assertKeystorePerms(dir, agentFile, deps) {
611
- const platform = deps?.platform ?? process.platform;
612
- if (platform === "win32") {
613
- const d = deps ?? productionWin32Deps();
614
- const fsExists = d.existsSync ?? existsSync;
615
- if (!fsExists(dir)) return;
616
- const { sid, userName } = resolveCurrentUser(d);
617
- applyWin32Acl(dir, sid, "dir", d);
618
- const filePresent = agentFile && agentFile.trim() !== "" && fsExists(agentFile);
619
- if (filePresent) {
620
- applyWin32Acl(agentFile, sid, "file", d);
621
- }
622
- verifyWin32Acl(dir, sid, d, userName);
623
- if (filePresent) {
624
- verifyWin32Acl(agentFile, sid, d, userName);
625
- }
626
- return;
627
- }
628
- try {
629
- const dmode = statSync(dir).mode & 511;
630
- if ((dmode & 63) !== 0) {
631
- throw new KeystorePermsError(
632
- `[braid] refusing to start: the state directory is accessible to other users on this machine.
633
- path : ${dir}
634
- mode : ${dmode.toString(8).padStart(3, "0")} (expected 700)
635
- fix : chmod 700 ${dir}`
636
- );
637
- }
638
- } catch (err) {
639
- if (err instanceof KeystorePermsError) throw err;
640
- }
641
- try {
642
- const fmode = statSync(agentFile).mode & 511;
643
- if ((fmode & 63) !== 0) {
644
- throw new KeystorePermsError(
645
- `[braid] refusing to start: your agent credential file is readable by other users on this machine.
646
- path : ${agentFile}
647
- mode : ${fmode.toString(8).padStart(3, "0")} (expected 600)
648
- fix : chmod 600 ${agentFile}`
649
- );
650
- }
651
- } catch (err) {
652
- if (err instanceof KeystorePermsError) throw err;
653
- }
654
- }
655
- function configDir(env = process.env) {
656
- const override = env.BRAID_CONFIG_DIR?.trim();
657
- return override && override !== "" ? override : join(homedir2(), ".braid");
658
- }
659
- function legacyConfigDir(env = process.env) {
660
- const override = env.BRAID_CONFIG_DIR?.trim();
661
- if (override && override !== "") return null;
662
- return join(homedir2(), ".arp");
663
- }
664
- function readPathWithFallback(dir, segments, env = process.env) {
665
- const primary = join(dir, ...segments);
666
- if (existsSync(primary)) return primary;
667
- const legacy = legacyTwinPath(dir, segments, env);
668
- if (legacy && existsSync(legacy)) return legacy;
669
- return primary;
670
- }
671
- function legacyTwinPath(dir, segments, env = process.env) {
672
- const legacy = legacyConfigDir(env);
673
- if (!legacy || dir !== configDir(env)) return null;
674
- return join(legacy, ...segments);
675
- }
676
- function relayHostSegment(relayUrl) {
677
- try {
678
- const u = new URL(relayUrl);
679
- return `${u.hostname}${u.port ? "_" + u.port : ""}`.replace(/[^a-zA-Z0-9._-]/g, "_");
680
- } catch {
681
- return relayUrl.replace(/[^a-zA-Z0-9._-]/g, "_");
682
- }
683
- }
684
- function agentFilePath(dir, relayUrl, agentName) {
685
- const safeName = agentName.replace(/[^a-zA-Z0-9._-]/g, "_");
686
- return join(dir, "agents", relayHostSegment(relayUrl), `${safeName}.json`);
687
- }
688
- function legacyAgentFilePath(dir, relayUrl, agentName, env = process.env) {
689
- const safeName = agentName.replace(/[^a-zA-Z0-9._-]/g, "_");
690
- return legacyTwinPath(dir, ["agents", relayHostSegment(relayUrl), `${safeName}.json`], env);
691
- }
692
- function saveAgent(dir, agent, deps) {
693
- const file = agentFilePath(dir, agent.relayUrl, agent.agentName);
694
- mkdirSync(dirname(file), { recursive: true, mode: 448 });
695
- const tmp = `${file}.tmp-${process.pid}`;
696
- writeFileSync(tmp, JSON.stringify(agent, null, 2) + "\n", { mode: 384 });
697
- renameSync(tmp, file);
698
- try {
699
- chmodSync(file, 384);
700
- } catch {
701
- }
702
- const platform = deps?.platform ?? process.platform;
703
- if (platform === "win32") {
704
- const d = deps ?? productionWin32Deps();
705
- try {
706
- const { sid } = resolveCurrentUser(d);
707
- applyWin32Acl(file, sid, "file", d);
708
- } catch (err) {
709
- console.warn(
710
- `[braid] warn: could not set ACL on agent file ${file} (startup gate will enforce on next start): ${err instanceof Error ? err.message : String(err)}`
711
- );
712
- }
713
- }
714
- return file;
715
- }
716
- function parsePinnedEndpoints(v) {
717
- if (!Array.isArray(v)) return void 0;
718
- const out = [];
719
- for (const item of v) {
720
- if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
721
- const o = item;
722
- if (typeof o.host === "string" && o.host.trim() !== "" && typeof o.source === "string" && o.source.trim() !== "" && typeof o.pinnedAt === "string" && o.pinnedAt.trim() !== "") {
723
- out.push({ host: o.host.trim(), source: o.source.trim(), pinnedAt: o.pinnedAt.trim() });
724
- }
725
- }
726
- return out.length > 0 ? out : void 0;
727
- }
728
- function parseScope(v) {
729
- if (typeof v !== "object" || v === null || Array.isArray(v)) return void 0;
730
- const o = v;
731
- const strArr = (x) => Array.isArray(x) && x.every((s) => typeof s === "string") ? x : void 0;
732
- const out = {};
733
- const allowWrite = strArr(o.allowWrite);
734
- if (allowWrite) out.allowWrite = allowWrite;
735
- const allowRead = strArr(o.allowRead);
736
- if (allowRead) out.allowRead = allowRead;
737
- const denyWrite = strArr(o.denyWrite);
738
- if (denyWrite) out.denyWrite = denyWrite;
739
- const denyRead = strArr(o.denyRead);
740
- if (denyRead) out.denyRead = denyRead;
741
- if (typeof o.disabled === "boolean") out.disabled = o.disabled;
742
- if (o.ipcProfile === "compat" || o.ipcProfile === "strict") out.ipcProfile = o.ipcProfile;
743
- if (typeof o.network === "object" && o.network !== null && !Array.isArray(o.network)) {
744
- const n = o.network;
745
- const net = {};
746
- const allowedDomains = strArr(n.allowedDomains);
747
- if (allowedDomains) net.allowedDomains = allowedDomains;
748
- const deniedDomains = strArr(n.deniedDomains);
749
- if (deniedDomains) net.deniedDomains = deniedDomains;
750
- if (Object.keys(net).length > 0) out.network = net;
751
- }
752
- return Object.keys(out).length > 0 ? out : void 0;
753
- }
754
- function parseStoredAgent(file) {
755
- let raw;
756
- try {
757
- raw = readFileSync(file, "utf8");
758
- } catch {
759
- throw new Error(`No saved credential at ${file}`);
760
- }
761
- let p;
762
- try {
763
- p = JSON.parse(raw);
764
- } catch {
765
- throw new Error(`Corrupt credential file at ${file}`);
766
- }
767
- const a = p;
768
- for (const field of ["relayUrl", "agentId", "agentName", "agentUuid", "agentKey"]) {
769
- if (typeof a[field] !== "string" || a[field].trim() === "") {
770
- throw new Error(`Corrupt credential file at ${file} (missing "${field}")`);
771
- }
772
- }
773
- const tm = a.toolMode;
774
- const toolMode = tm === "readonly" || tm === "full" ? tm : void 0;
775
- const signingPrivateJwk = typeof a.signingPrivateJwk === "string" && a.signingPrivateJwk.trim() !== "" ? a.signingPrivateJwk : void 0;
776
- const signingKid = typeof a.signingKid === "string" && a.signingKid.trim() !== "" ? a.signingKid : void 0;
777
- const clerkIssuer = typeof a.clerkIssuer === "string" && a.clerkIssuer.trim() !== "" ? a.clerkIssuer : void 0;
778
- const scope = parseScope(a.scope);
779
- const pinnedEndpoints = parsePinnedEndpoints(a.pinnedEndpoints);
780
- const providerRaw = typeof a.provider === "string" ? a.provider.trim() : void 0;
781
- const provider = providerRaw && providerRaw !== "" ? providerRaw : void 0;
782
- const model = typeof a.model === "string" && a.model.trim() !== "" ? a.model.trim() : void 0;
783
- const fallbackModel = typeof a.fallbackModel === "string" && a.fallbackModel.trim() !== "" ? a.fallbackModel.trim() : void 0;
784
- const authRaw = typeof a.auth === "string" ? a.auth.trim() : void 0;
785
- const auth = authRaw && AUTH_MODES.includes(authRaw) ? authRaw : void 0;
786
- return {
787
- relayUrl: a.relayUrl.trim(),
788
- agentId: a.agentId.trim(),
789
- agentName: a.agentName.trim(),
790
- agentUuid: a.agentUuid.trim(),
791
- agentKey: a.agentKey.trim(),
792
- ...toolMode ? { toolMode } : {},
793
- ...provider ? { provider } : {},
794
- ...model ? { model } : {},
795
- ...auth ? { auth } : {},
796
- ...fallbackModel ? { fallbackModel } : {},
797
- ...signingPrivateJwk ? { signingPrivateJwk } : {},
798
- ...signingKid ? { signingKid } : {},
799
- ...clerkIssuer ? { clerkIssuer } : {},
800
- ...scope ? { scope } : {},
801
- ...pinnedEndpoints ? { pinnedEndpoints } : {}
802
- };
803
- }
804
- function listAgents(dir) {
805
- const seen = /* @__PURE__ */ new Set();
806
- const out = scanAgentsDir(dir, seen);
807
- const legacy = legacyConfigDir();
808
- if (legacy && dir === configDir()) {
809
- out.push(...scanAgentsDir(legacy, seen));
810
- }
811
- return out;
812
- }
813
- function scanAgentsDir(dir, seen) {
814
- const root = join(dir, "agents");
815
- const out = [];
816
- let hosts = [];
817
- try {
818
- hosts = readdirSync(root);
819
- } catch {
820
- return out;
821
- }
822
- for (const host of hosts) {
823
- let files = [];
824
- try {
825
- files = readdirSync(join(root, host));
826
- } catch {
827
- continue;
828
- }
829
- for (const f of files) {
830
- if (!f.endsWith(".json")) continue;
831
- const key = join(host, f);
832
- if (seen.has(key)) continue;
833
- const file = join(root, host, f);
834
- try {
835
- out.push({ file, agent: parseStoredAgent(file) });
836
- seen.add(key);
837
- } catch {
838
- }
839
- }
840
- }
841
- return out;
842
- }
843
- function loadAgent(dir, agentName, multiAgentRemedy = "Run: npx @snowyroad/braid start <name>") {
844
- const all = listAgents(dir);
845
- if (agentName && agentName.trim() !== "") {
846
- const matches = all.filter((e) => e.agent.agentName === agentName.trim());
847
- if (matches.length === 0) {
848
- const safeName = agentName.trim().replace(/[^a-zA-Z0-9._-]/g, "_");
849
- const root = join(dir, "agents");
850
- let hosts = [];
851
- try {
852
- hosts = readdirSync(root);
853
- } catch {
854
- }
855
- for (const host of hosts) {
856
- const candidate = join(root, host, `${safeName}.json`);
857
- if (existsSync(candidate)) parseStoredAgent(candidate);
858
- }
859
- throw new Error(
860
- `No saved credential for "${agentName}". Run: npx @snowyroad/braid join <code>`
861
- );
862
- }
863
- if (matches.length > 1) {
864
- const relays = matches.map((m) => m.agent.relayUrl).join(", ");
865
- throw new Error(
866
- `Agent "${agentName}" has credentials for multiple relays (${relays}). Set BRAID_CONFIG_DIR or remove the stale file.`
867
- );
868
- }
869
- return matches[0];
870
- }
871
- if (all.length === 0) {
872
- throw new Error("No saved credentials. Run: npx @snowyroad/braid join <code>");
873
- }
874
- if (all.length > 1) {
875
- const names = all.map((e) => e.agent.agentName).join(", ");
876
- throw new Error(`Multiple saved agents (${names}). ${multiAgentRemedy}`);
877
- }
878
- return all[0];
879
- }
880
- function acquireAgentLock(agentFile) {
881
- const lockFile = `${agentFile}.lock`;
882
- const tryAcquire = () => {
883
- try {
884
- const fd = openSync(lockFile, "wx", 384);
885
- writeFileSync(lockFile, String(process.pid));
886
- closeSync(fd);
887
- return true;
888
- } catch {
889
- return false;
890
- }
891
- };
892
- if (!tryAcquire()) {
893
- let holderPid = NaN;
894
- try {
895
- holderPid = Number(readFileSync(lockFile, "utf8").trim());
896
- } catch {
897
- }
898
- if (holderPid === process.pid) {
899
- return () => {
900
- try {
901
- unlinkSync(lockFile);
902
- } catch {
903
- }
904
- };
905
- }
906
- let holderAlive = false;
907
- if (Number.isFinite(holderPid) && holderPid > 0) {
908
- try {
909
- process.kill(holderPid, 0);
910
- holderAlive = true;
911
- } catch (err) {
912
- holderAlive = err.code === "EPERM";
913
- }
914
- }
915
- if (holderAlive) {
916
- throw new Error(
917
- `This agent is already running on this machine (pid ${holderPid}). Running it twice would trip credential reuse detection.`
918
- );
919
- }
920
- try {
921
- unlinkSync(lockFile);
922
- } catch {
923
- }
924
- if (!tryAcquire()) {
925
- throw new Error("This agent is already running on this machine.");
926
- }
927
- }
928
- return () => {
929
- try {
930
- if (existsSync(lockFile) && readFileSync(lockFile, "utf8").trim() === String(process.pid)) {
931
- unlinkSync(lockFile);
932
- }
933
- } catch {
934
- }
935
- };
936
- }
937
- function acquireAgentLockWithLegacy(agentFile, legacyAgentFile) {
938
- let releaseLegacy = null;
939
- if (legacyAgentFile !== null && existsSync(legacyAgentFile)) {
940
- releaseLegacy = acquireAgentLock(legacyAgentFile);
941
- }
942
- let releasePrimary;
943
- try {
944
- releasePrimary = acquireAgentLock(agentFile);
945
- } catch (err) {
946
- releaseLegacy?.();
947
- throw err;
948
- }
949
- return () => {
950
- releasePrimary();
951
- releaseLegacy?.();
952
- };
953
- }
954
- function pinEndpoint(dir, agentName, pin) {
955
- const entry = loadAgent(dir, agentName);
956
- const agent = entry.agent;
957
- const existing = agent.pinnedEndpoints ?? [];
958
- const next = existing.filter((p) => p.source !== pin.source);
959
- next.push({ host: pin.host, source: pin.source, pinnedAt: (/* @__PURE__ */ new Date()).toISOString() });
960
- agent.pinnedEndpoints = next;
961
- saveAgent(dir, agent);
962
- }
963
-
964
- // src/relayPrompt.ts
965
- import { createInterface } from "readline";
966
-
967
- // src/tty.ts
968
- var ANSI_RE = /\u001b(?:\[[0-?]*[ -\/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-Z\\-_])/g;
969
- var CTRL_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
970
- function sanitizeForTty(s) {
971
- return s.replace(ANSI_RE, "").replace(CTRL_RE, "");
972
- }
973
- function pipeSanitizedToTty(src, dst = process.stderr) {
974
- if (!src) return;
975
- src.setEncoding("utf8");
976
- src.on("data", (chunk) => {
977
- const clean = sanitizeForTty(chunk);
978
- if (clean) dst.write(clean);
979
- });
980
- }
981
- var ESC = String.fromCharCode(27);
982
- function restoreTerminal(out = process.stdout, stdin = process.stdin) {
983
- if (!out.isTTY) return;
984
- out.write(
985
- `${ESC}[?1l${ESC}>${ESC}[?25h${ESC}[?2004l${ESC}[>4;0m${ESC}[?1000l${ESC}[?1002l${ESC}[?1003l${ESC}[?1006l${ESC}7${ESC}[r${ESC}8`
986
- );
987
- try {
988
- if (stdin.isTTY) stdin.setRawMode?.(false);
989
- } catch {
990
- }
991
- }
992
- function isShellSafeName(s) {
993
- return /^[A-Za-z0-9_.-]+$/.test(s);
994
- }
995
-
996
- // src/relayPrompt.ts
997
- function buildRelayPrompt(agentName, matches) {
998
- const name = sanitizeForTty(agentName);
999
- const rows = matches.map((m, i) => {
1000
- const n = i + 1;
1001
- const pad = n < 10 ? " " : "";
1002
- return ` ${pad}${n}) ${sanitizeForTty(m.agent.relayUrl)}`;
1003
- }).join("\n");
1004
- return `Agent "${name}" has saved credentials for multiple relays.
1005
- ${rows}
1006
- Which relay should it connect to? [1-${matches.length}]: `;
1007
- }
1008
- async function promptRelaySelection(agentName, matches, input, output) {
1009
- const rl = createInterface({ input, output });
1010
- return new Promise((resolve2) => {
1011
- let resolved = false;
1012
- const done = (result) => {
1013
- if (resolved) return;
1014
- resolved = true;
1015
- rl.close();
1016
- resolve2(result);
1017
- };
1018
- output.write(buildRelayPrompt(agentName, matches));
1019
- rl.on("line", (line) => {
1020
- const t = line.trim();
1021
- const n = Number(t);
1022
- if (Number.isInteger(n) && n >= 1 && n <= matches.length) {
1023
- done(matches[n - 1]);
1024
- return;
1025
- }
1026
- output.write(`Please enter 1-${matches.length}: `);
1027
- });
1028
- rl.once("close", () => done(null));
1029
- });
1030
- }
1031
- async function chooseRelayWhenAmbiguous(agentName, matches, io = { input: process.stdin, output: process.stderr }) {
1032
- if (io.input.isTTY === true) {
1033
- return await promptRelaySelection(agentName, matches, io.input, io.output);
1034
- }
1035
- return null;
1036
- }
1037
-
1038
- // src/toolPolicy.ts
1039
- import { homedir as homedir3 } from "os";
1040
- import { join as join2, resolve, sep } from "path";
1041
- function narrowestMode(...modes) {
1042
- for (const m of modes) {
1043
- if (m === "readonly") return "readonly";
1044
- }
1045
- return "full";
1046
- }
1047
- function narrowestOverride(effective, standing) {
1048
- return effective === "readonly" && standing !== "readonly" ? "readonly" : void 0;
1049
- }
1050
- function toolModeLabel(mode) {
1051
- return mode === "full" ? "full access" : "read and reply";
1052
- }
1053
- function toolStatusLine(mode, cause = "operator") {
1054
- if (mode === "full") {
1055
- return "Tool status: the operator has granted this agent full tool access.";
1056
- }
1057
- if (cause === "channel") {
1058
- return "Tool status: your operator granted this agent full tool access, but THIS CHANNEL restricts you to read-only here. You can still read channel context, sources, tasks, artifacts, and memory, but requests to run commands, edit files, or write to the channel are denied for this channel. This is governed by the channel's own configuration (its capability bundle), NOT your agent's bridge tool mode \u2014 changing the agent's bridge settings will not lift it. A channel admin must adjust the channel's capability bundle to allow writes.";
1059
- }
1060
- return "Tool status: this agent runs in read-and-reply mode; requests to run commands or edit files are denied by the operator's bridge settings (changeable with: braid tools full <agent>).";
1061
- }
1062
- var denialHintShown = false;
1063
- function takeDenialHint(policy, cause = "operator") {
1064
- if (denialHintShown) return null;
1065
- denialHintShown = true;
1066
- if (cause === "channel") {
1067
- return `[braid] This channel restricts this agent to read-only (its capability bundle or a read-only channel policy). The agent's own tool mode is unchanged; adjust the channel's capability bundle in the web app to allow writes.`;
1068
- }
1069
- const raw = policy.agentName;
1070
- const name = raw && isShellSafeName(raw) ? raw : "<agent-name>";
1071
- return `[braid] To allow tools for this agent: stop the bridge, run \`braid tools full ${name}\`, then start it again (advanced override: BRAID_TOOL_MODE=full).`;
1072
- }
1073
- function parseToolMode(env) {
1074
- const raw = env.BRAID_TOOL_MODE?.trim();
1075
- if (!raw) return "readonly";
1076
- if (raw === "readonly" || raw === "full") return raw;
1077
- throw new Error(
1078
- `Invalid BRAID_TOOL_MODE: ${raw}. Expected "readonly" (default) or "full"`
1079
- );
1080
- }
1081
- var READONLY_ACP_KINDS = /* @__PURE__ */ new Set(["read", "search", "think"]);
1082
- var READONLY_BRAID_MCP_TOOLS = /* @__PURE__ */ new Set([
1083
- "list_sources",
1084
- "read_source",
1085
- "recall",
1086
- "recall_private",
1087
- "list_tasks",
1088
- "list_artifacts",
1089
- "get_artifact"
1090
- ]);
1091
- var FLOOR_BRAID_MCP_TOOLS = /* @__PURE__ */ new Set(["raise_attention"]);
1092
- function braidMcpReadToolName(title) {
1093
- if (typeof title !== "string" || !title.startsWith("mcp__")) return null;
1094
- const short = title.slice(title.lastIndexOf("__") + 2);
1095
- return READONLY_BRAID_MCP_TOOLS.has(short) ? short : null;
1096
- }
1097
- function braidMcpFloorToolName(title) {
1098
- if (typeof title !== "string" || !title.startsWith("mcp__")) return null;
1099
- const short = title.slice(title.lastIndexOf("__") + 2);
1100
- return FLOOR_BRAID_MCP_TOOLS.has(short) ? short : null;
1101
- }
1102
- function expandTilde(p) {
1103
- if (p === "~") return homedir3();
1104
- if (p.startsWith("~/") || p.startsWith(`~${sep}`)) return join2(homedir3(), p.slice(2));
1105
- return p;
1106
- }
1107
- function isInsideConfigDir(configDirAbs, rawPath) {
1108
- const cfg = resolve(expandTilde(configDirAbs));
1109
- const p = resolve(expandTilde(rawPath));
1110
- return p === cfg || p.startsWith(cfg + sep);
1111
- }
1112
- function pathsFromInput(input) {
1113
- if (input == null || typeof input !== "object") return [];
1114
- const o = input;
1115
- const out = [];
1116
- for (const key of ["file_path", "path", "notebook_path"]) {
1117
- const v = o[key];
1118
- if (typeof v === "string" && v.trim() !== "") out.push(v);
1119
- }
1120
- return out;
1121
- }
1122
- function findConfigDirHit(configDirAbs, paths) {
1123
- for (const p of paths) {
1124
- if (isInsideConfigDir(configDirAbs, p)) return p;
1125
- }
1126
- return null;
1127
- }
1128
- function evaluateAcpPermission(mode, configDirAbs, req) {
1129
- const tc = req.toolCall;
1130
- const locationPaths = (tc?.locations ?? []).map((l) => l?.path).filter((p) => typeof p === "string" && p.trim() !== "");
1131
- const candidates = [...locationPaths, ...pathsFromInput(tc?.rawInput)];
1132
- const hit = findConfigDirHit(configDirAbs, candidates);
1133
- if (hit) {
1134
- return { allow: false, reason: `tool call touches the Braid config dir (${hit})` };
1135
- }
1136
- const arpFloor = braidMcpFloorToolName(tc?.title);
1137
- if (arpFloor) {
1138
- return { allow: true, reason: `floor capability (${arpFloor}): help-request escape hatch, never gated by tool mode` };
1139
- }
1140
- if (mode === "full") return { allow: true, reason: "BRAID_TOOL_MODE=full" };
1141
- const kind = tc?.kind ?? null;
1142
- if (kind != null && READONLY_ACP_KINDS.has(kind)) {
1143
- return { allow: true, reason: `read-only tool kind "${kind}"` };
1144
- }
1145
- const arpRead = braidMcpReadToolName(tc?.title);
1146
- if (arpRead) {
1147
- return { allow: true, reason: `read-only Braid tool ${arpRead}` };
1148
- }
1149
- return {
1150
- allow: false,
1151
- reason: `tool kind "${kind ?? "unknown"}" is not read-only (readonly / read-and-reply mode)`,
1152
- deniedByMode: true
1153
- };
1154
- }
1155
- var READONLY_SDK_TOOLS = /* @__PURE__ */ new Set([
1156
- "Read",
1157
- "Grep",
1158
- "Glob",
1159
- "TodoWrite",
1160
- "ExitPlanMode"
1161
- ]);
1162
- function evaluateSdkTool(mode, configDirAbs, toolName, input) {
1163
- const hit = findConfigDirHit(configDirAbs, pathsFromInput(input));
1164
- if (hit) {
1165
- return { allow: false, reason: `tool ${toolName} touches the Braid config dir (${hit})` };
1166
- }
1167
- const arpFloor = braidMcpFloorToolName(toolName);
1168
- if (arpFloor) {
1169
- return { allow: true, reason: `floor capability (${arpFloor}): help-request escape hatch, never gated by tool mode` };
1170
- }
1171
- if (mode === "full") return { allow: true, reason: "BRAID_TOOL_MODE=full" };
1172
- if (READONLY_SDK_TOOLS.has(toolName)) {
1173
- return { allow: true, reason: `read-only tool ${toolName}` };
1174
- }
1175
- const arpRead = braidMcpReadToolName(toolName);
1176
- if (arpRead) {
1177
- return { allow: true, reason: `read-only Braid tool ${arpRead}` };
1178
- }
1179
- return {
1180
- allow: false,
1181
- reason: `tool ${toolName} is not read-only (readonly / read-and-reply mode)`,
1182
- deniedByMode: true
1183
- };
1184
- }
1185
- function pickRejectOptionId(req) {
1186
- const opts = req.options ?? [];
1187
- const once = opts.find((o) => o.kind === "reject_once");
1188
- if (once) return once.optionId;
1189
- const always = opts.find((o) => o.kind === "reject_always");
1190
- if (always) return always.optionId;
1191
- return null;
1192
- }
1193
-
1194
- // src/toolModePrompt.ts
1195
- import { createInterface as createInterface2 } from "readline";
1196
- function buildToolModePrompt(agentName) {
1197
- const name = sanitizeForTty(agentName);
1198
- return `How much can ${name} do on this machine when channel members ask?
1199
- 1) Read and reply only (recommended): the agent can read and respond, but requests to run commands or edit files are denied
1200
- 2) Full access: channel content can drive the agent to run commands and edit files on this machine
1201
- Choose [1/2] (default 1): `;
1202
- }
1203
- function buildDefaultNote(agentName) {
1204
- const name = sanitizeForTty(agentName);
1205
- const cmdName = isShellSafeName(agentName) ? agentName : "<agent-name>";
1206
- return `[braid] Tool access not chosen for ${name}; defaulting to read and reply. To allow tools later: braid tools full ${cmdName}
1207
- `;
1208
- }
1209
- async function promptToolMode(agentName, input, output) {
1210
- const rl = createInterface2({ input, output });
1211
- let closed = false;
1212
- rl.once("close", () => {
1213
- closed = true;
1214
- });
1215
- const ask = (q) => new Promise((resolve2) => {
1216
- if (closed) {
1217
- resolve2(null);
1218
- return;
1219
- }
1220
- rl.once("close", () => resolve2(null));
1221
- rl.question(q, (answer) => resolve2(answer));
1222
- });
1223
- try {
1224
- let query = buildToolModePrompt(agentName);
1225
- for (; ; ) {
1226
- const answer = await ask(query);
1227
- if (answer === null) return null;
1228
- const t = answer.trim();
1229
- if (t === "" || t === "1") return "readonly";
1230
- if (t === "2") return "full";
1231
- query = "Please enter 1 or 2 (or press Enter for 1): ";
1232
- }
1233
- } finally {
1234
- rl.close();
1235
- }
1236
- }
1237
- async function chooseFirstRunToolMode(agentName, io = { input: process.stdin, output: process.stdout }, env = process.env) {
1238
- if (io.input.isTTY === true) {
1239
- const chosen = await promptToolMode(agentName, io.input, io.output);
1240
- if (chosen !== null) return { mode: chosen, persist: true };
1241
- } else if (env.BRAID_SERVICE === "1") {
1242
- return { mode: "readonly", persist: true };
1243
- }
1244
- io.output.write(buildDefaultNote(agentName));
1245
- return { mode: "readonly", persist: false };
1246
- }
1247
-
1248
- // src/providerPrompt.ts
1249
- import { createInterface as createInterface3 } from "readline";
1250
- function buildProviderPrompt(agentName) {
1251
- const name = sanitizeForTty(agentName);
1252
- const rows = ACP_AGENT_IDS.map((id, i) => {
1253
- const n = i + 1;
1254
- const pad = n < 10 ? " " : "";
1255
- return ` ${pad}${n}) ${id}${id === "claude-code" ? " (default)" : ""}`;
1256
- }).join("\n");
1257
- return `Which agent should ${name} run?
1258
- ${rows}
1259
- Choose [1-${ACP_AGENT_IDS.length}] (default 1): `;
1260
- }
1261
- async function promptProvider(agentName, input, output) {
1262
- const rl = createInterface3({ input, output });
1263
- return new Promise((resolve2) => {
1264
- let resolved = false;
1265
- const done = (result) => {
1266
- if (resolved) return;
1267
- resolved = true;
1268
- rl.close();
1269
- resolve2(result);
1270
- };
1271
- output.write(buildProviderPrompt(agentName));
1272
- rl.on("line", (line) => {
1273
- const t = line.trim();
1274
- if (t === "") {
1275
- done("claude-code");
1276
- return;
1277
- }
1278
- const n = Number(t);
1279
- if (Number.isInteger(n) && n >= 1 && n <= ACP_AGENT_IDS.length) {
1280
- done(ACP_AGENT_IDS[n - 1]);
1281
- return;
1282
- }
1283
- if (ACP_AGENT_IDS.includes(t)) {
1284
- done(t);
1285
- return;
1286
- }
1287
- output.write(
1288
- `Please enter 1-${ACP_AGENT_IDS.length}, a provider name, or press Enter for claude-code: `
1289
- );
1290
- });
1291
- rl.once("close", () => done(null));
1292
- });
1293
- }
1294
- async function chooseFirstRunProvider(agentName, io = { input: process.stdin, output: process.stdout }) {
1295
- if (io.input.isTTY === true) {
1296
- const chosen = await promptProvider(agentName, io.input, io.output);
1297
- if (chosen !== null) return { provider: chosen, persist: true };
1298
- }
1299
- const name = sanitizeForTty(agentName);
1300
- io.output.write(
1301
- `[braid] Provider not chosen for ${name}; defaulting to claude-code. To run a different agent: BRAID_AGENT=<provider> npx @snowyroad/braid start, or re-join with --provider.
1302
- `
1303
- );
1304
- return { provider: "claude-code", persist: false };
1305
- }
1306
-
1307
- // src/scope/scopeConfig.ts
1308
- import { realpathSync, readdirSync as readdirSync2, lstatSync } from "fs";
1309
- import { execSync } from "child_process";
1310
- import { join as join3 } from "path";
1311
- function resolveIpcProfile(scope, flagValue) {
1312
- if (flagValue !== void 0) return flagValue;
1313
- if (scope?.ipcProfile !== void 0) return scope.ipcProfile;
1314
- return "compat";
1315
- }
1316
- var STRICT_IPC_ENV_STRIP = [
1317
- "SSH_AUTH_SOCK",
1318
- "DOCKER_HOST",
1319
- "DBUS_SESSION_BUS_ADDRESS",
1320
- "DBUS_SYSTEM_BUS_ADDRESS",
1321
- "GPG_AGENT_INFO",
1322
- "DISPLAY",
1323
- "WAYLAND_DISPLAY"
1324
- ];
1325
- function strictIpcDenyRead(env, lstatFn = lstatSync) {
1326
- const base = ["/run", "/var/run", "/tmp/.X11-unix"];
1327
- const xdg = env["XDG_RUNTIME_DIR"];
1328
- const candidates = xdg ? (() => {
1329
- const segments = xdg.split("/").filter((s) => s.length > 0);
1330
- if (!xdg.startsWith("/") || segments.length < 2) {
1331
- console.warn(
1332
- `[braid] warn: XDG_RUNTIME_DIR="${xdg}" failed the sanity gate (must be absolute, not "/", and have \u22652 path segments) \u2014 skipping from strict IPC denyRead shroud`
1333
- );
1334
- return base;
1335
- }
1336
- return [...base, xdg];
1337
- })() : base;
1338
- return candidates.filter((p) => {
1339
- try {
1340
- return !lstatFn(p).isSymbolicLink();
1341
- } catch {
1342
- return true;
1343
- }
1344
- });
1345
- }
1346
- var DEFAULT_ALLOWED_DOMAINS = [
1347
- // Anthropic / Claude
1348
- "api.anthropic.com",
1349
- "*.anthropic.com",
1350
- // OpenAI / Codex
1351
- "api.openai.com",
1352
- // Google / Gemini
1353
- "generativelanguage.googleapis.com",
1354
- "*.googleapis.com",
1355
- // xAI / Grok
1356
- "api.x.ai",
1357
- // Cursor
1358
- "api.cursor.sh",
1359
- // opencode (BL-169): its serve process fetches the models.dev registry AT BOOT;
1360
- // without it opencode dies inside the jail (`ServeError` / ACP connection closed
1361
- // on every connect). `models.dev` is repro-CONFIRMED (blocked → 403, allowed →
1362
- // 200 through the srt proxy). `opencode.ai` (opencode's own service) and the
1363
- // ChatGPT-sign-in OAuth hosts are opencode's documented auth/boot endpoints
1364
- // (from the 2026-07-02 dry-run that hard-failed opencode). claude-code was
1365
- // unaffected because all its hosts were already listed above.
1366
- "models.dev",
1367
- "opencode.ai",
1368
- "*.opencode.ai",
1369
- "auth.openai.com",
1370
- "chatgpt.com",
1371
- // npm
1372
- "registry.npmjs.org",
1373
- "*.npmjs.org",
1374
- // PyPI
1375
- "pypi.org",
1376
- "files.pythonhosted.org",
1377
- // Go modules
1378
- "proxy.golang.org",
1379
- // Crates.io
1380
- "crates.io",
1381
- "static.crates.io",
1382
- // Source hosts
1383
- "github.com",
1384
- "*.github.com",
1385
- "*.githubusercontent.com",
1386
- "gitlab.com",
1387
- "bitbucket.org"
1388
- ];
1389
- var PROVIDER_PATHS = {
1390
- "claude-code": ["~/.claude", "~/.claude.json"],
1391
- codex: ["~/.codex"],
1392
- gemini: ["~/.gemini"],
1393
- grok: ["~/.grok"],
1394
- cursor: ["~/.cursor"],
1395
- opencode: ["~/.local/share/opencode", "~/.config/opencode"],
1396
- goose: ["~/.config/goose"],
1397
- cline: ["~/.cline"],
1398
- copilot: ["~/.copilot"],
1399
- qwen: ["~/.qwen"]
1400
- };
1401
- function expandHome(p, home) {
1402
- if (p.startsWith("~/")) return `${home}${p.slice(1)}`;
1403
- if (p === "~") return home;
1404
- return p;
1405
- }
1406
- function canonicalize(p) {
1407
- try {
1408
- return realpathSync(p);
1409
- } catch {
1410
- return p;
1411
- }
1412
- }
1413
- function parseEnvList(val) {
1414
- if (!val) return [];
1415
- return val.split(/[,:]/).map((s) => s.trim()).filter((s) => s.length > 0);
1416
- }
1417
- function dedup(arr) {
1418
- return [...new Set(arr)];
1419
- }
1420
- function darwinMdsCacheDir() {
1421
- try {
1422
- const cacheRoot = execSync("/usr/bin/getconf DARWIN_USER_CACHE_DIR", {
1423
- encoding: "utf8"
1424
- }).trim();
1425
- if (!cacheRoot) return [];
1426
- return [`${cacheRoot.replace(/\/+$/, "")}/mds`];
1427
- } catch {
1428
- return [];
1429
- }
1430
- }
1431
- function resolvePaths(paths, home) {
1432
- return dedup(paths.map((p) => canonicalize(expandHome(p, home))));
1433
- }
1434
- function foldCase(p, platform) {
1435
- return platform === "darwin" || platform === "win32" ? p.toLowerCase() : p;
1436
- }
1437
- function checkGrantsAgainstReservations(grants, reservations, platform) {
1438
- const sep2 = platform === "win32" ? "\\" : "/";
1439
- const out = [];
1440
- for (const g of grants) {
1441
- const gf = foldCase(g.path, platform);
1442
- for (const r of reservations) {
1443
- const rf = foldCase(r, platform);
1444
- if (gf === rf) {
1445
- out.push({ grant: g.path, source: g.source, reservation: r, relation: "equal" });
1446
- } else if (gf.startsWith(rf + sep2)) {
1447
- out.push({ grant: g.path, source: g.source, reservation: r, relation: "descendant" });
1448
- }
1449
- }
1450
- }
1451
- return out;
1452
- }
1453
- function resolveScope(cfg, provider, env, platform = process.platform) {
1454
- const scope = cfg.scope ?? {};
1455
- const home = env["HOME"] ?? "";
1456
- const cwd = env["PWD"] ?? process.cwd();
1457
- const tmpDir = env["TMPDIR"] ?? "/tmp";
1458
- const braidConfigDir = env["BRAID_CONFIG_DIR"]?.trim() || expandHome("~/.braid", home);
1459
- const legacyArpConfigDir = expandHome("~/.arp", home);
1460
- const protectedConfigDirs = braidConfigDir === legacyArpConfigDir ? [braidConfigDir] : [braidConfigDir, legacyArpConfigDir];
1461
- const flagOverride = cfg.scopeFlags?.scopeOverride;
1462
- const disabled = flagOverride !== void 0 ? flagOverride === "off" : scope.disabled === true || env["BRAID_SCOPE"] === "off";
1463
- const secretDirs = [
1464
- "~/.ssh",
1465
- "~/.aws",
1466
- "~/.gnupg",
1467
- "~/.kube/config"
1468
- ].map((p) => expandHome(p, home));
1469
- const protectedConfigDirEntries = protectedConfigDirs.flatMap((configDirPath) => {
1470
- if (platform !== "win32") return [configDirPath];
1471
- try {
1472
- const children = readdirSync2(configDirPath, { withFileTypes: true });
1473
- const denied = children.filter((entry) => entry.name !== "runtime").map((entry) => canonicalize(join3(configDirPath, entry.name)));
1474
- if (denied.length === 0) return [configDirPath];
1475
- return denied;
1476
- } catch {
1477
- return [configDirPath];
1478
- }
1479
- });
1480
- const providerPaths = PROVIDER_PATHS[provider].map((p) => expandHome(p, home));
1481
- const uid = process.getuid?.();
1482
- const claudeTmpRoot = provider === "claude-code" && uid !== void 0 ? [`${canonicalize("/tmp")}/claude-${uid}`, `${canonicalize("/tmp")}/claude-*-cwd`] : [];
1483
- const mdsCacheDir = platform === "darwin" ? darwinMdsCacheDir() : [];
1484
- const opAllowWrite = (scope.allowWrite ?? []).map((p) => expandHome(p, home));
1485
- const envAllowWrite = parseEnvList(env["BRAID_SCOPE_ALLOW_WRITE"]).map((p) => expandHome(p, home));
1486
- const flagAllowWrite = (cfg.scopeFlags?.allowWrite ?? []).map((p) => expandHome(p, home));
1487
- const rawAllowWrite = [
1488
- cwd,
1489
- ...providerPaths,
1490
- ...claudeTmpRoot,
1491
- ...mdsCacheDir,
1492
- expandHome("~/.npm", home),
1493
- tmpDir,
1494
- ...opAllowWrite,
1495
- ...envAllowWrite,
1496
- ...flagAllowWrite
1497
- ];
1498
- const allowWrite = resolvePaths(rawAllowWrite, home);
1499
- const rawDenyWrite = [
1500
- ...secretDirs,
1501
- ...protectedConfigDirEntries,
1502
- ...(scope.denyWrite ?? []).map((p) => expandHome(p, home)),
1503
- ...parseEnvList(env["BRAID_SCOPE_DENY_WRITE"]).map((p) => expandHome(p, home))
1504
- ];
1505
- const denyWrite = resolvePaths(rawDenyWrite, home);
1506
- const ipcProfile = resolveIpcProfile(scope, cfg.scopeFlags?.ipcProfile);
1507
- const rawDenyRead = [
1508
- ...secretDirs,
1509
- ...protectedConfigDirEntries,
1510
- ...(scope.denyRead ?? []).map((p) => expandHome(p, home)),
1511
- ...parseEnvList(env["BRAID_SCOPE_DENY_READ"]).map((p) => expandHome(p, home))
1512
- ];
1513
- const denyRead = dedup([
1514
- ...resolvePaths(rawDenyRead, home),
1515
- ...ipcProfile === "strict" && platform === "linux" ? strictIpcDenyRead(env) : []
1516
- ]);
1517
- const opAllowRead = (scope.allowRead ?? []).map((p) => expandHome(p, home));
1518
- const envAllowRead = parseEnvList(env["BRAID_SCOPE_ALLOW_READ"]).map((p) => expandHome(p, home));
1519
- const flagAllowRead = (cfg.scopeFlags?.allowRead ?? []).map((p) => expandHome(p, home));
1520
- const rawAllowRead = [
1521
- ...opAllowRead,
1522
- ...envAllowRead,
1523
- ...flagAllowRead
1524
- ];
1525
- const allowRead = resolvePaths(rawAllowRead, home);
1526
- if (!disabled) {
1527
- const reservations = resolvePaths([...secretDirs, ...protectedConfigDirs], home);
1528
- const labelled = (paths, source) => resolvePaths(paths, home).map((p) => ({ path: p, source }));
1529
- const grants = [
1530
- ...labelled([cwd], "launch directory (cwd)"),
1531
- ...labelled(providerPaths, "provider default paths"),
1532
- ...labelled(opAllowWrite, "scope.allowWrite"),
1533
- ...labelled(envAllowWrite, "BRAID_SCOPE_ALLOW_WRITE"),
1534
- ...labelled(flagAllowWrite, "--allow-write"),
1535
- ...labelled(opAllowRead, "scope.allowRead"),
1536
- ...labelled(envAllowRead, "BRAID_SCOPE_ALLOW_READ"),
1537
- ...labelled(flagAllowRead, "--allow-read")
1538
- ];
1539
- const conflicts = checkGrantsAgainstReservations(grants, reservations, platform);
1540
- if (conflicts.length > 0) {
1541
- const detail = conflicts.map((c) => ` - ${c.source}: "${c.grant}" is ${c.relation === "equal" ? "" : "inside "}the protected path "${c.reservation}"`).join("\n");
1542
- throw new Error(
1543
- `[braid] SCOPE ERROR: a grant covers a protected credential/secret path and was refused:
1544
- ${detail}
1545
- Protected paths (never grantable): ${reservations.join(", ")}
1546
- Narrow the grant to a path outside the protected set. (Ancestor grants like "~" are fine \u2014 the protected dirs are carved back out automatically.)`
1547
- );
1548
- }
1549
- const writableDefaults = resolvePaths(
1550
- [...providerPaths, expandHome("~/.npm", home), tmpDir],
1551
- home
1552
- );
1553
- const resolvedConfigDir = canonicalize(braidConfigDir);
1554
- const placement = checkGrantsAgainstReservations(
1555
- [{ path: resolvedConfigDir, source: "BRAID_CONFIG_DIR" }],
1556
- writableDefaults,
1557
- platform
1558
- );
1559
- if (placement.length > 0) {
1560
- const c = placement[0];
1561
- throw new Error(
1562
- `[braid] SCOPE ERROR: BRAID_CONFIG_DIR ("${c.grant}") is ${c.relation === "equal" ? "" : "inside "}a default provider-writable location ("${c.reservation}"). The bridge credential store must live outside provider/tmp/npm paths. Move it (default: ~/.braid) or unset BRAID_CONFIG_DIR.`
1563
- );
1564
- }
1565
- }
1566
- const allowedDomains = dedup([
1567
- ...DEFAULT_ALLOWED_DOMAINS,
1568
- ...scope.network?.allowedDomains ?? [],
1569
- ...cfg.derivedAllowedDomains ?? [],
1570
- ...parseEnvList(env["BRAID_SCOPE_ALLOW_DOMAINS"]),
1571
- ...cfg.scopeFlags?.allowDomains ?? []
1572
- ]);
1573
- const deniedDomains = dedup([
1574
- ...scope.network?.deniedDomains ?? [],
1575
- ...parseEnvList(env["BRAID_SCOPE_DENY_DOMAINS"])
1576
- ]);
1577
- function describe() {
1578
- const lines = [];
1579
- if (disabled) {
1580
- lines.push("[scope] SANDBOX DISABLED (BRAID_SCOPE=off or scope.disabled:true) \u2014 agent runs unconfined");
1581
- return lines;
1582
- }
1583
- lines.push("[scope] Agent sandbox is ACTIVE");
1584
- lines.push(
1585
- `[scope] protected (never readable/writable by the agent): ${resolvePaths([...secretDirs, ...protectedConfigDirs], home).join(", ")}`
1586
- );
1587
- lines.push(`[scope] cwd (writable): ${cwd}`);
1588
- if (providerPaths.length > 0) {
1589
- lines.push(`[scope] provider paths (writable): ${providerPaths.join(", ")}`);
1590
- }
1591
- lines.push(`[scope] allowed domains (${allowedDomains.length}): ${allowedDomains.slice(0, 5).join(", ")}${allowedDomains.length > 5 ? " ..." : ""}`);
1592
- if ((cfg.derivedAllowedDomains ?? []).length > 0) {
1593
- lines.push(`[scope] model endpoints (derived): ${(cfg.derivedAllowedDomains ?? []).join(", ")}`);
1594
- }
1595
- lines.push(`[scope] denied read: ${denyRead.join(", ")}`);
1596
- if (denyWrite.length > 0) {
1597
- lines.push(`[scope] denied write: ${denyWrite.join(", ")}`);
1598
- }
1599
- if (deniedDomains.length > 0) {
1600
- lines.push(`[scope] denied domains: ${deniedDomains.join(", ")}`);
1601
- }
1602
- if (ipcProfile === "compat") {
1603
- lines.push("[scope] ipc profile: compat \u2014 all unix sockets reachable inside the jail (Linux seccomp constraint)");
1604
- } else {
1605
- lines.push("[scope] ipc profile: strict \u2014 /run, /var/run, XDG_RUNTIME_DIR, /tmp/.X11-unix hidden; socket env vars stripped");
1606
- }
1607
- lines.push("[scope] To widen: add paths to scope.allowWrite/allowRead in agent config, or BRAID_SCOPE_ALLOW_WRITE/BRAID_SCOPE=off env.");
1608
- return lines;
1609
- }
1610
- return {
1611
- runtimeConfig: {
1612
- filesystem: { allowWrite, denyWrite, allowRead, denyRead },
1613
- network: { allowedDomains, deniedDomains }
1614
- },
1615
- disabled,
1616
- ipcProfile,
1617
- describe
1618
- };
1619
- }
1620
-
1621
- // src/cardSigning.ts
1622
- import canonicalize2 from "canonicalize";
1623
- import {
1624
- FlattenedSign,
1625
- flattenedVerify,
1626
- importJWK,
1627
- exportJWK,
1628
- calculateJwkThumbprint,
1629
- generateKeyPair,
1630
- CompactSign
1631
- } from "jose";
1632
- import { randomUUID } from "crypto";
1633
- var SIGNING_ALG = "EdDSA";
1634
- var REQUIRED_TOP = /* @__PURE__ */ new Set(["name", "description", "skills"]);
1635
- var RELAY_OWNED_TOP = [
1636
- "version",
1637
- "provider",
1638
- "braidKeyAttestation",
1639
- "arpKeyAttestation"
1640
- // braid-rename: legacy-accept (old peers' cards must keep verifying)
1641
- ];
1642
- function isEmptyValue(v) {
1643
- if (v === "" || v === null || v === void 0) return true;
1644
- if (Array.isArray(v)) return v.length === 0;
1645
- if (typeof v === "object") return Object.keys(v).length === 0;
1646
- return false;
1647
- }
1648
- function pruneNode(node, requiredKeys) {
1649
- if (Array.isArray(node)) return node.map((e) => e && typeof e === "object" ? pruneNode(e, /* @__PURE__ */ new Set()) : e);
1650
- if (!node || typeof node !== "object") return node;
1651
- const out = {};
1652
- for (const [k, v] of Object.entries(node)) {
1653
- const pv = v && typeof v === "object" ? pruneNode(v, /* @__PURE__ */ new Set()) : v;
1654
- if (requiredKeys.has(k)) {
1655
- out[k] = pv;
1656
- continue;
1657
- }
1658
- if (isEmptyValue(pv)) continue;
1659
- out[k] = pv;
1660
- }
1661
- return out;
1662
- }
1663
- function pruneForSigning(card) {
1664
- const clone = JSON.parse(JSON.stringify(card ?? {}));
1665
- delete clone.signatures;
1666
- for (const f of RELAY_OWNED_TOP) delete clone[f];
1667
- if (Array.isArray(clone.supportedInterfaces)) {
1668
- clone.supportedInterfaces = clone.supportedInterfaces.map((i) => {
1669
- if (i && typeof i === "object") {
1670
- const { url, ...rest } = i;
1671
- return rest;
1672
- }
1673
- return i;
1674
- });
1675
- }
1676
- return pruneNode(clone, REQUIRED_TOP);
1677
- }
1678
- function canonicalCardString(card) {
1679
- return canonicalize2(pruneForSigning(card));
1680
- }
1681
- function publicOkpJwk(jwk) {
1682
- return { kty: jwk.kty, crv: jwk.crv, x: jwk.x };
1683
- }
1684
- async function generateSigningKey() {
1685
- const { privateKey } = await generateKeyPair(SIGNING_ALG, { crv: "Ed25519", extractable: true });
1686
- const privateJwk = await exportJWK(privateKey);
1687
- const kid = await calculateJwkThumbprint(publicOkpJwk(privateJwk));
1688
- return { privateJwk, kid };
1689
- }
1690
- async function buildDpopProof(privateJwk, htu) {
1691
- const key = await importJWK(privateJwk, SIGNING_ALG);
1692
- const pub = publicOkpJwk(privateJwk);
1693
- const payload = new TextEncoder().encode(JSON.stringify({
1694
- htm: "POST",
1695
- htu,
1696
- iat: Math.floor(Date.now() / 1e3),
1697
- jti: randomUUID()
1698
- }));
1699
- return await new CompactSign(payload).setProtectedHeader({ alg: SIGNING_ALG, typ: "dpop+jwt", jwk: pub }).sign(key);
1700
- }
1701
- async function signCard(card, privateJwk) {
1702
- const key = await importJWK(privateJwk, SIGNING_ALG);
1703
- const pub = publicOkpJwk(privateJwk);
1704
- const kid = await calculateJwkThumbprint(pub);
1705
- const payload = new TextEncoder().encode(canonicalCardString(card));
1706
- const jws = await new FlattenedSign(payload).setProtectedHeader({ alg: SIGNING_ALG, typ: "JOSE", kid, jwk: pub }).sign(key);
1707
- return [{ protected: jws.protected, signature: jws.signature }];
1708
- }
1709
- async function verifyCardSignature(card) {
1710
- const sigs = card?.signatures;
1711
- if (!Array.isArray(sigs) || sigs.length === 0) return { status: "unsigned" };
1712
- const payload = Buffer.from(canonicalCardString(card), "utf8").toString("base64url");
1713
- for (const sig of sigs) {
1714
- try {
1715
- if (!sig || typeof sig.protected !== "string" || typeof sig.signature !== "string") continue;
1716
- const header = JSON.parse(Buffer.from(sig.protected, "base64url").toString("utf8"));
1717
- if (header.alg !== SIGNING_ALG || !header.jwk) continue;
1718
- const pub = publicOkpJwk(header.jwk);
1719
- const kid = await calculateJwkThumbprint(pub);
1720
- if (typeof header.kid !== "string" || header.kid !== kid) continue;
1721
- const key = await importJWK(pub, SIGNING_ALG);
1722
- await flattenedVerify({ protected: sig.protected, payload, signature: sig.signature }, key, {
1723
- algorithms: [SIGNING_ALG]
1724
- });
1725
- return { status: "valid", kid };
1726
- } catch {
1727
- }
1728
- }
1729
- return { status: "invalid" };
1730
- }
1731
-
1732
- // src/messageSigning.ts
1733
- import { createHash, createPrivateKey, sign as cryptoSign } from "crypto";
1734
- var MSG_SIG_VERSION = "braid-msg-v1";
1735
- function cleanContent(content) {
1736
- let out = "";
1737
- for (const ch of content) {
1738
- const cp = ch.codePointAt(0);
1739
- const isCc = cp <= 31 || cp >= 127 && cp <= 159;
1740
- if (!isCc || ch === "\n" || ch === "\r" || ch === " ") out += ch;
1741
- }
1742
- return out.trim();
1743
- }
1744
- function contentSha256Hex(content) {
1745
- return createHash("sha256").update(content, "utf8").digest("hex");
1746
- }
1747
- function buildMessageEnvelope(kid, f) {
1748
- return [
1749
- MSG_SIG_VERSION,
1750
- f.agentUuid,
1751
- kid,
1752
- f.channelId,
1753
- f.flowId ?? "",
1754
- f.messageId,
1755
- String(f.ts),
1756
- contentSha256Hex(f.content)
1757
- ].join("\n");
1758
- }
1759
- function makeMessageSigner(privateJwkJson, kid) {
1760
- let key = null;
1761
- return (f) => {
1762
- if (!key) key = createPrivateKey({ key: JSON.parse(privateJwkJson), format: "jwk" });
1763
- const sig = cryptoSign(null, Buffer.from(buildMessageEnvelope(kid, f), "utf8"), key);
1764
- return `${MSG_SIG_VERSION}.${kid}.${f.ts}.${sig.toString("base64url")}`;
1765
- };
1766
- }
1767
- var ART_SIG_VERSION = "braid-art-v1";
1768
- function buildArtifactEnvelope(kid, f) {
1769
- return [
1770
- ART_SIG_VERSION,
1771
- f.agentUuid,
1772
- kid,
1773
- f.channelId,
1774
- f.artifactId,
1775
- String(f.ts),
1776
- contentSha256Hex(f.docJson)
1777
- ].join("\n");
1778
- }
1779
- function makeArtifactSigner(privateJwkJson, kid) {
1780
- let key = null;
1781
- return (f) => {
1782
- if (!key) key = createPrivateKey({ key: JSON.parse(privateJwkJson), format: "jwk" });
1783
- const sig = cryptoSign(null, Buffer.from(buildArtifactEnvelope(kid, f), "utf8"), key);
1784
- return `${ART_SIG_VERSION}.${kid}.${f.ts}.${sig.toString("base64url")}`;
1785
- };
1786
- }
1787
-
1788
- // src/config.ts
1789
- var DEFAULT_MODEL = "claude-opus-4-8";
1790
- var DEFAULT_AGENT_MODE = "acp";
1791
- var DEFAULT_AGENT = "claude-code";
1792
- var VALID_AGENT_MODES = ["acp", "generic"];
1793
- function positiveIntEnv(v, dflt) {
1794
- const n = v == null ? NaN : Number(v);
1795
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt;
1796
- }
1797
- function required(env, key) {
1798
- const v = env[key];
1799
- if (!v || v.trim() === "") throw new Error(`Missing required env var: ${key}`);
1800
- return v.trim();
1801
- }
1802
- var GEMINI_AUTH_ENV_KEYS = [
1803
- "GEMINI_API_KEY",
1804
- "GOOGLE_API_KEY",
1805
- "GOOGLE_GENAI_USE_VERTEXAI",
1806
- "GOOGLE_APPLICATION_CREDENTIALS",
1807
- "GOOGLE_CLOUD_PROJECT"
1808
- ];
1809
- function warnIfGeminiAuthMissing(agent, agentMode, env) {
1810
- if (agent !== "gemini" || agentMode !== "acp") return;
1811
- const hasAuth = GEMINI_AUTH_ENV_KEYS.some((k) => env[k] && env[k].trim() !== "");
1812
- if (hasAuth) return;
1813
- console.error(
1814
- `[braid] WARNING: gemini selected but no Gemini API key found in the environment. Google deprecated gemini-cli's free "Sign in with Google" tier on 2026-06-18; without a key the agent will fail to authenticate (IneligibleTierError) and channel messages routed to it will be dropped. Set a Google AI Studio key before starting:
1815
- export GEMINI_API_KEY=... (get one free at https://aistudio.google.com/apikey)
1816
- Vertex AI / enterprise users: set GOOGLE_GENAI_USE_VERTEXAI=true and GOOGLE_CLOUD_PROJECT instead.`
1817
- );
1818
- }
1819
- var ANTHROPIC_KEY_ENV_KEYS2 = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
1820
- function detectSubscriptionLogin(env) {
1821
- const readText = (p) => {
1822
- try {
1823
- return readFileSync2(p, "utf8");
1824
- } catch {
1825
- return void 0;
1826
- }
1827
- };
1828
- const home = env.HOME?.trim() || homedir4();
1829
- return hasSubscriptionLogin(env, readText, home, keychainProbeFor(home));
1830
- }
1831
- function isHttpsOrLocalhostUrl(s) {
1832
- if (/[\s;&|`$(){}<>"'\\]/.test(s)) return false;
1833
- let parsed;
1834
- try {
1835
- parsed = new URL(s);
1836
- } catch {
1837
- return false;
1838
- }
1839
- if (parsed.protocol === "https:") return true;
1840
- if (parsed.protocol !== "http:") return false;
1841
- const h = parsed.hostname.toLowerCase();
1842
- return h === "localhost" || h === "127.0.0.1";
1843
- }
1844
- function warnIfAnthropicKeyWillBill(agent, agentMode, env) {
1845
- if (agent !== "claude-code" || agentMode !== "acp") return;
1846
- const baseUrl = env.ANTHROPIC_BASE_URL?.trim() ?? "";
1847
- if (baseUrl === "") return;
1848
- if (!isHttpsOrLocalhostUrl(baseUrl)) {
1849
- console.error(
1850
- `[braid] WARNING: ANTHROPIC_BASE_URL is set to "${baseUrl}" which is not https or a localhost address. The gateway key will be sent over a non-encrypted connection. Use an https base URL outside of local development.`
1851
- );
1852
- }
1853
- const present = ANTHROPIC_KEY_ENV_KEYS2.filter((k) => env[k] && env[k].trim() !== "");
1854
- if (present.length === 0) {
1855
- console.error(
1856
- `[braid] WARNING: ANTHROPIC_BASE_URL is set to "${baseUrl}" but neither ANTHROPIC_AUTH_TOKEN nor ANTHROPIC_API_KEY is present. Claude Code will likely fail to authenticate with the gateway. Set ANTHROPIC_AUTH_TOKEN to your gateway key before starting.`
1857
- );
1858
- return;
1859
- }
1860
- console.error(
1861
- `[braid] NOTE: ${present.join(" / ")} is set and ANTHROPIC_BASE_URL is "${baseUrl}". Claude Code will send the token to that gateway, not to Anthropic. This bills your gateway account, not your Anthropic account. (Keeping it set is correct -- removing it would disable the gateway.)`
1862
- );
1863
- }
1864
- function resolveAgentSelection(env, providerOverride) {
1865
- const agentMode = env.BRAID_AGENT_MODE?.trim() || DEFAULT_AGENT_MODE;
1866
- if (!VALID_AGENT_MODES.includes(agentMode)) {
1867
- throw new Error(
1868
- `Invalid BRAID_AGENT_MODE: ${agentMode}. Expected one of: ${VALID_AGENT_MODES.join(", ")}`
1869
- );
1870
- }
1871
- const agent = providerOverride ?? (env.BRAID_AGENT?.trim() || DEFAULT_AGENT);
1872
- if (!ACP_AGENT_IDS.includes(agent)) {
1873
- throw new Error(
1874
- `Invalid BRAID_AGENT: ${agent}. Expected one of: ${ACP_AGENT_IDS.join(", ")}`
1875
- );
1876
- }
1877
- if (agentMode === "generic") required(env, "ANTHROPIC_API_KEY");
1878
- warnIfGeminiAuthMissing(agent, agentMode, env);
1879
- warnIfAnthropicKeyWillBill(agent, agentMode, env);
1880
- return { agentMode, agent };
1881
- }
1882
- function isLoopbackHost(hostname) {
1883
- const h = hostname.toLowerCase();
1884
- return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]" || h.endsWith(".localhost");
1885
- }
1886
- function validateRelayWsUrl(url, env, sourceLabel) {
1887
- let parsed;
1888
- try {
1889
- parsed = new URL(url);
1890
- } catch {
1891
- throw new Error(`Invalid relay URL from ${sourceLabel}: must start with ws:// or wss://, got: ${url}`);
1892
- }
1893
- if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
1894
- throw new Error(`Invalid relay URL from ${sourceLabel}: must start with ws:// or wss://, got: ${url}`);
1895
- }
1896
- if (parsed.protocol === "wss:") return;
1897
- if (isLoopbackHost(parsed.hostname)) return;
1898
- if (env.BRAID_ALLOW_INSECURE === "1") {
1899
- console.error(
1900
- `[braid] WARNING: connecting over cleartext ws:// to ${parsed.hostname} (${sourceLabel}). Credentials and traffic are exposed to the network path. Use wss:// outside local development.`
1901
- );
1902
- return;
1903
- }
1904
- throw new Error(
1905
- `Refusing cleartext ws:// relay URL from ${sourceLabel}: ${url}. Credentials sent over ws:// to a non-loopback host are exposed to the network path. Use wss:// instead, or set BRAID_ALLOW_INSECURE=1 for local development.`
1906
- );
1907
- }
1908
- function loadConfig(env) {
1909
- const { agentMode, agent } = resolveAgentSelection(env);
1910
- const relayWsUrl = required(env, "BRAID_RELAY_URL");
1911
- validateRelayWsUrl(relayWsUrl, env, "BRAID_RELAY_URL");
1912
- const relayHttpUrl = wsToHttp(relayWsUrl);
1913
- const agentName = required(env, "BRAID_AGENT_NAME");
1914
- const knobs = resolveKnobs(void 0, env, void 0);
1915
- const authMode = knobs.auth.value;
1916
- const authRes = resolveAnthropicAuth({
1917
- agent,
1918
- agentMode,
1919
- authMode,
1920
- env,
1921
- hasSubscriptionLogin: detectSubscriptionLogin(env)
1922
- });
1923
- return {
1924
- relayWsUrl,
1925
- relayHttpUrl,
1926
- token: required(env, "BRAID_TOKEN"),
1927
- agentId: required(env, "BRAID_AGENT_ID"),
1928
- agentName,
1929
- agentUuid: required(env, "BRAID_AGENT_UUID"),
1930
- agentMode,
1931
- agent,
1932
- model: env.BRAID_MODEL?.trim() || (agentMode === "acp" ? "" : DEFAULT_MODEL),
1933
- toolMode: parseToolMode(env),
1934
- catchUpTtlMs: positiveIntEnv(env.BRAID_CATCHUP_TTL_MS, 72e5),
1935
- catchUpMaxMentions: positiveIntEnv(env.BRAID_CATCHUP_MAX_MENTIONS, 3),
1936
- authMode,
1937
- ...authRes.stripKeys.length > 0 ? { stripEnvKeys: authRes.stripKeys } : {},
1938
- ...authRes.notices.length > 0 ? { authNotices: authRes.notices } : {},
1939
- ...knobs.fallbackModel.value ? { fallbackModel: knobs.fallbackModel.value } : {}
1940
- };
1941
- }
1942
- function wsToHttp(relayWsUrl) {
1943
- return relayWsUrl.replace(/^wss:\/\//i, "https://").replace(/^ws:\/\//i, "http://");
1944
- }
1945
- async function buildFromStoredAgent(dir, stored, env, flags) {
1946
- if (stored.provider !== void 0 && !ACP_AGENT_IDS.includes(stored.provider)) {
1947
- throw new Error(
1948
- `Invalid provider in saved agent config: ${stored.provider}. Expected one of: ${ACP_AGENT_IDS.join(", ")}`
1949
- );
1950
- }
1951
- const envAgentRaw = env.BRAID_AGENT?.trim();
1952
- const providerOverride = flags?.provider ?? (envAgentRaw ? void 0 : stored.provider);
1953
- const { agentMode, agent } = resolveAgentSelection(env, providerOverride);
1954
- const postureWarnings = computePostureWarnings(flags ?? EMPTY_RUN_FLAGS, env, stored);
1955
- const relayWsUrl = stored.relayUrl;
1956
- const file = agentFilePath(dir, stored.relayUrl, stored.agentName);
1957
- validateRelayWsUrl(relayWsUrl, env, `stored credential ${file}`);
1958
- const relayHttpUrl = wsToHttp(relayWsUrl);
1959
- mkdirSync2(dirname2(file), { recursive: true, mode: 448 });
1960
- const legacyFile = legacyAgentFilePath(dir, stored.relayUrl, stored.agentName, env);
1961
- const legacyTwinExisted = legacyFile !== null && existsSync2(legacyFile);
1962
- const release = acquireAgentLockWithLegacy(file, legacyTwinExisted ? legacyFile : null);
1963
- process.once("exit", release);
1964
- let current = stored;
1965
- const envToolMode = env.BRAID_TOOL_MODE?.trim();
1966
- let toolMode;
1967
- if (flags?.tools) {
1968
- toolMode = flags.tools;
1969
- } else if (envToolMode) {
1970
- toolMode = parseToolMode(env);
1971
- } else if (current.toolMode) {
1972
- toolMode = current.toolMode;
1973
- } else {
1974
- const choice = await chooseFirstRunToolMode(current.agentName);
1975
- toolMode = choice.mode;
1976
- if (choice.persist) {
1977
- current = { ...current, toolMode };
1978
- saveAgent(dir, current);
1979
- }
1980
- }
1981
- let signingPrivateJwk = current.signingPrivateJwk;
1982
- if (!signingPrivateJwk) {
1983
- try {
1984
- const gen = await generateSigningKey();
1985
- signingPrivateJwk = JSON.stringify(gen.privateJwk);
1986
- current = { ...current, signingPrivateJwk, signingKid: gen.kid };
1987
- saveAgent(dir, current);
1988
- } catch (e) {
1989
- console.warn("[braid] signing key generation failed; cards will be unsigned:", String(e));
1990
- }
1991
- }
1992
- let clerkIssuer = current.clerkIssuer;
1993
- let cardAttestation;
1994
- let inflight = null;
1995
- const mintToken = () => {
1996
- if (inflight) return inflight;
1997
- inflight = (async () => {
1998
- try {
1999
- let dpop;
2000
- if (signingPrivateJwk) {
2001
- try {
2002
- dpop = await buildDpopProof(JSON.parse(signingPrivateJwk), `${relayHttpUrl.replace(/\/$/, "")}/agents/token`);
2003
- } catch (e) {
2004
- console.warn("[braid] dpop proof build failed; minting without attestation:", String(e));
2005
- }
2006
- }
2007
- const r = await mintAccessToken(relayHttpUrl, current.agentKey, fetch, dpop);
2008
- current = { ...current, agentKey: r.agentKey };
2009
- if (r.clerkIssuer && r.clerkIssuer !== clerkIssuer) {
2010
- clerkIssuer = r.clerkIssuer;
2011
- current = { ...current, clerkIssuer };
2012
- }
2013
- saveAgent(dir, current);
2014
- if (legacyTwinExisted) {
2015
- const legacyRoot = legacyConfigDir(env);
2016
- if (legacyRoot) {
2017
- try {
2018
- saveAgent(legacyRoot, current);
2019
- } catch (e) {
2020
- console.warn(
2021
- "[braid] warn: rotated key saved, but updating the pre-rename credential copy failed; an installed pre-rename service may be left with a stale key (migrate it with `braid service install`):",
2022
- String(e)
2023
- );
2024
- }
2025
- }
2026
- }
2027
- cardAttestation = r.cardAttestation;
2028
- return r.accessToken;
2029
- } finally {
2030
- inflight = null;
2031
- }
2032
- })();
2033
- return inflight;
2034
- };
2035
- const token = await mintToken();
2036
- const signCardFn = signingPrivateJwk ? async (card) => {
2037
- try {
2038
- const sigs = await signCard(card, JSON.parse(signingPrivateJwk));
2039
- const signed = { ...card, signatures: sigs };
2040
- return cardAttestation ? { ...signed, braidKeyAttestation: cardAttestation } : signed;
2041
- } catch (e) {
2042
- console.warn("[braid] card signing failed; publishing unsigned:", String(e));
2043
- return card;
2044
- }
2045
- } : void 0;
2046
- const signMessageFn = signingPrivateJwk && current.signingKid ? makeMessageSigner(signingPrivateJwk, current.signingKid) : void 0;
2047
- const signArtifactFn = signingPrivateJwk && current.signingKid ? makeArtifactSigner(signingPrivateJwk, current.signingKid) : void 0;
2048
- const knobs = resolveKnobs(flags, env, current);
2049
- const authMode = knobs.auth.value;
2050
- const authRes = resolveAnthropicAuth({
2051
- agent,
2052
- agentMode,
2053
- authMode,
2054
- env,
2055
- hasSubscriptionLogin: detectSubscriptionLogin(env)
2056
- });
2057
- return {
2058
- relayWsUrl,
2059
- relayHttpUrl,
2060
- token,
2061
- agentId: stored.agentId,
2062
- agentName: stored.agentName,
2063
- agentUuid: stored.agentUuid,
2064
- agentMode,
2065
- agent,
2066
- model: flags?.model ?? (env.BRAID_MODEL?.trim() || void 0) ?? stored.model ?? (agentMode === "acp" ? "" : DEFAULT_MODEL),
2067
- toolMode,
2068
- catchUpTtlMs: positiveIntEnv(env.BRAID_CATCHUP_TTL_MS, 72e5),
2069
- catchUpMaxMentions: positiveIntEnv(env.BRAID_CATCHUP_MAX_MENTIONS, 3),
2070
- // Persisted per-agent sandbox scope (BL-005): threads into the startup gate,
2071
- // initScopeRuntime, scopedLaunch, and the handoff. Absent = defaults + env only.
2072
- ...current.scope ? { scope: current.scope } : {},
2073
- // BL-149b: per-run scope widenings from flags; consumed by resolveScope.
2074
- // A-11: ipcProfile is included in the guard and the literal so --ipc-profile
2075
- // is not silently discarded before resolveScope reads scopeFlags?.ipcProfile.
2076
- ...flags && (flags.allowWrite.length > 0 || flags.allowRead.length > 0 || flags.allowDomains.length > 0 || flags.scope !== void 0 || flags.ipcProfile !== void 0) ? { scopeFlags: {
2077
- allowWrite: flags.allowWrite,
2078
- allowRead: flags.allowRead,
2079
- allowDomains: flags.allowDomains,
2080
- ...flags.scope !== void 0 ? { scopeOverride: flags.scope } : {},
2081
- ...flags.ipcProfile !== void 0 ? { ipcProfile: flags.ipcProfile } : {}
2082
- } } : {},
2083
- ...postureWarnings.length > 0 ? { postureWarnings } : {},
2084
- authMode,
2085
- ...authRes.stripKeys.length > 0 ? { stripEnvKeys: authRes.stripKeys } : {},
2086
- ...authRes.notices.length > 0 ? { authNotices: authRes.notices } : {},
2087
- ...knobs.fallbackModel.value ? { fallbackModel: knobs.fallbackModel.value } : {},
2088
- mintToken,
2089
- agentFile: file,
2090
- ...clerkIssuer ? { clerkIssuer } : {},
2091
- ...signCardFn ? { signCard: signCardFn } : {},
2092
- ...signMessageFn ? { signMessage: signMessageFn } : {},
2093
- ...signArtifactFn ? { signArtifact: signArtifactFn } : {}
2094
- };
2095
- }
2096
- function parseToolModeForJoin(env) {
2097
- const raw = env.BRAID_TOOL_MODE?.trim();
2098
- if (!raw) return void 0;
2099
- if (raw === "readonly" || raw === "full") return raw;
2100
- console.error(
2101
- `[braid] WARNING: invalid BRAID_TOOL_MODE "${raw}" \u2014 expected "readonly" or "full". Ignoring and falling through to the first-run tool-mode prompt.`
2102
- );
2103
- return void 0;
2104
- }
2105
- async function loadConfigFromInvite(code, env, flags) {
2106
- resolveAgentSelection(env, flags?.provider);
2107
- const joinToolMode = flags?.tools ?? parseToolModeForJoin(env);
2108
- const envForBuild = env.BRAID_TOOL_MODE && joinToolMode === void 0 ? { ...env, BRAID_TOOL_MODE: void 0 } : env;
2109
- const inv = decodeInvite(code);
2110
- validateRelayWsUrl(inv.relayUrl, env, "invite");
2111
- const relayWsUrl = inv.relayUrl;
2112
- const relayHttpUrl = wsToHttp(relayWsUrl);
2113
- const bundle = await redeemInvite(relayHttpUrl, inv.code);
2114
- let provider = flags?.provider;
2115
- let providerExplicit = provider !== void 0;
2116
- if (provider === void 0) {
2117
- const envAgent = env.BRAID_AGENT?.trim();
2118
- if (envAgent) {
2119
- provider = envAgent;
2120
- providerExplicit = true;
2121
- }
2122
- }
2123
- if (provider === void 0) {
2124
- const choice = await chooseFirstRunProvider(bundle.agentName);
2125
- provider = choice.provider;
2126
- providerExplicit = choice.persist;
2127
- }
2128
- const joinModel = flags?.model ?? (env.BRAID_MODEL?.trim() || void 0);
2129
- const scopePersist = buildJoinScopeBlock(flags, env);
2130
- const dir = configDir(env);
2131
- const stored = {
2132
- relayUrl: relayWsUrl,
2133
- agentId: bundle.agentId,
2134
- agentName: bundle.agentName,
2135
- agentUuid: bundle.agentUuid,
2136
- agentKey: bundle.agentKey,
2137
- ...joinToolMode ? { toolMode: joinToolMode } : {},
2138
- ...providerExplicit ? { provider } : {},
2139
- ...joinModel ? { model: joinModel } : {},
2140
- ...scopePersist ? { scope: scopePersist } : {},
2141
- ...flags?.auth ? { auth: flags.auth } : {},
2142
- ...flags?.fallbackModel ? { fallbackModel: flags.fallbackModel } : {}
2143
- };
2144
- const file = saveAgent(dir, stored);
2145
- const cmdName = isShellSafeName(bundle.agentName) ? bundle.agentName : "<agent-name>";
2146
- console.log(`[braid] credential saved to ${file} (restart later with: npx @snowyroad/braid start ${cmdName})`);
2147
- return buildFromStoredAgent(dir, stored, envForBuild, flags);
2148
- }
2149
- function buildJoinScopeBlock(flags, env) {
2150
- const split = (v) => v ? v.split(/[,:]/).map((s) => s.trim()).filter((s) => s.length > 0) : [];
2151
- const dedup2 = (arr) => [...new Set(arr)];
2152
- const allowWrite = dedup2([...split(env.BRAID_SCOPE_ALLOW_WRITE), ...flags?.allowWrite ?? []]);
2153
- const allowRead = dedup2([...split(env.BRAID_SCOPE_ALLOW_READ), ...flags?.allowRead ?? []]);
2154
- const allowedDomains = dedup2([...split(env.BRAID_SCOPE_ALLOW_DOMAINS), ...flags?.allowDomains ?? []]);
2155
- const disabled = flags?.scope === "off" || flags?.scope === void 0 && env.BRAID_SCOPE === "off";
2156
- if (allowWrite.length === 0 && allowRead.length === 0 && allowedDomains.length === 0 && !disabled) {
2157
- return void 0;
2158
- }
2159
- return {
2160
- ...allowWrite.length > 0 ? { allowWrite } : {},
2161
- ...allowRead.length > 0 ? { allowRead } : {},
2162
- ...allowedDomains.length > 0 ? { network: { allowedDomains } } : {},
2163
- ...disabled ? { disabled: true } : {}
2164
- };
2165
- }
2166
- async function loadConfigFromStore(agentName, env, flags, io) {
2167
- const dir = configDir(env);
2168
- const entry = await resolveStoredAgent(dir, agentName, io);
2169
- return buildFromStoredAgent(dir, entry.agent, env, flags);
2170
- }
2171
- async function resolveStoredAgent(dir, agentName, io) {
2172
- const name = agentName?.trim();
2173
- if (name) {
2174
- const matches = listAgents(dir).filter((e) => e.agent.agentName === name);
2175
- if (matches.length > 1) {
2176
- const chosen = await chooseRelayWhenAmbiguous(name, matches, io);
2177
- return chosen ?? loadAgent(dir, agentName);
2178
- }
2179
- }
2180
- return loadAgent(dir, agentName);
2181
- }
2182
- function getFlag(argv, name) {
2183
- for (let i = 0; i < argv.length; i++) {
2184
- const a = argv[i];
2185
- if (a === name) return argv[i + 1] ?? "";
2186
- if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
2187
- }
2188
- return void 0;
2189
- }
2190
- async function resolveConfig(argv, env) {
2191
- const parsed = parseRunFlags(argv);
2192
- if (!parsed.ok) throw new Error(parsed.error);
2193
- const { flags, rest } = parsed;
2194
- let positional = rest;
2195
- if (positional[0] === "join") {
2196
- const code = positional[1];
2197
- if (!code || code.trim() === "") throw new Error("Missing value for join");
2198
- return loadConfigFromInvite(code.trim(), env, flags);
2199
- }
2200
- if (positional[0] === "start") {
2201
- const restAfter = positional.slice(1);
2202
- const name = restAfter[0] && !restAfter[0].startsWith("-") ? restAfter[0].trim() : void 0;
2203
- if (name) return loadConfigFromStore(name, env, flags);
2204
- positional = restAfter;
2205
- }
2206
- const argInvite = getFlag(positional, "--invite");
2207
- if (argInvite !== void 0 && argInvite.trim() === "") {
2208
- throw new Error("Missing value for --invite");
2209
- }
2210
- const invite = (argInvite ?? env.BRAID_INVITE)?.trim();
2211
- if (invite) return loadConfigFromInvite(invite, env, flags);
2212
- if (env.BRAID_TOKEN && env.BRAID_TOKEN.trim() !== "") return loadConfig(env);
2213
- return loadConfigFromStore(void 0, env, flags);
2214
- }
2215
- function parseClaudeConfigModel(jsonText) {
2216
- try {
2217
- const parsed = JSON.parse(jsonText);
2218
- const m = typeof parsed.model === "string" ? parsed.model.trim() : "";
2219
- return m || null;
2220
- } catch {
2221
- return null;
2222
- }
2223
- }
2224
- function readClaudeConfigModel(agent) {
2225
- if (agent !== "claude-code") return null;
2226
- try {
2227
- const text = readFileSync2(join4(homedir4(), ".claude.json"), "utf8");
2228
- return parseClaudeConfigModel(text);
2229
- } catch {
2230
- return null;
2231
- }
2232
- }
2233
- function redactConfig(cfg) {
2234
- let model;
2235
- if (cfg.agentMode !== "acp") {
2236
- model = cfg.model;
2237
- } else if (cfg.model.trim() !== "") {
2238
- model = cfg.model;
2239
- } else {
2240
- const discovered = readClaudeConfigModel(cfg.agent);
2241
- model = discovered ? `${discovered} (provider default)` : "(provider default)";
2242
- }
2243
- return { ...cfg, token: "<redacted>", model };
2244
- }
2245
- function buildVersion() {
2246
- try {
2247
- const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
2248
- return String(pkg.version ?? "");
2249
- } catch {
2250
- return "";
2251
- }
2252
- }
2253
- function startingLines(cfg) {
2254
- const row = (label, value) => `${label.padEnd(8)} : ${value}`;
2255
- const anthropicCredential = cfg.agentMode !== "acp" || cfg.agent === "claude-code";
2256
- const catchUpHours = cfg.catchUpTtlMs % 36e5 === 0 ? `${cfg.catchUpTtlMs / 36e5}h` : `${Math.round(cfg.catchUpTtlMs / 6e4)}min`;
2257
- return [
2258
- // The running braid version — the fastest way to confirm which build a live bridge
2259
- // is (BL-229). Sourced from the runtimeVersion threaded onto config; an honest
2260
- // placeholder shows when it could not be read, never a blank row.
2261
- row("version", cfg.runtimeVersion ? sanitizeForTty(cfg.runtimeVersion) : "(unknown)"),
2262
- row("agent", sanitizeForTty(cfg.agentName)),
2263
- row("relay", sanitizeForTty(cfg.relayWsUrl)),
2264
- row("provider", cfg.agentMode === "acp" ? cfg.agent : "bundled SDK (generic mode)"),
2265
- row("model", sanitizeForTty(redactConfig(cfg).model)),
2266
- row("fallback", cfg.fallbackModel ? sanitizeForTty(cfg.fallbackModel) : "(none \u2014 a rate limit is surfaced, not switched)"),
2267
- row("tools", cfg.toolMode),
2268
- row("auth", anthropicCredential ? cfg.authMode ?? "auto" : `(managed by ${cfg.agent})`),
2269
- row("sandbox", cfg.scope?.disabled ? "OFF (unconfined)" : "on"),
2270
- row("ipc", resolveIpcProfile(cfg.scope, cfg.scopeFlags?.ipcProfile)),
2271
- row("catch-up", `${catchUpHours} window \xB7 up to ${cfg.catchUpMaxMentions} missed mentions`),
2272
- row("keystore", cfg.agentFile ? sanitizeForTty(cfg.agentFile) : "(env-provided credential \u2014 no saved file)")
2273
- ];
2274
- }
2275
-
2276
- // src/http.ts
2277
- var DEFAULT_TIMEOUT_MS = 15e3;
2278
- var DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
2279
- var warnedInsecure = false;
2280
- function warnInsecureOnce(source) {
2281
- if (warnedInsecure) return;
2282
- warnedInsecure = true;
2283
- console.error(`[braid] WARNING: cleartext http to a non-loopback host allowed by BRAID_ALLOW_INSECURE=1 (${source}). Never use this outside development.`);
2284
- }
2285
- function assertHttpEndpoint(url, source, env = process.env) {
2286
- let parsed;
2287
- try {
2288
- parsed = new URL(url);
2289
- } catch {
2290
- throw new Error(`[braid] invalid URL from ${source}: not parseable`);
2291
- }
2292
- if (parsed.protocol === "https:") return;
2293
- if (parsed.protocol !== "http:") throw new Error(`[braid] refusing non-http(s) URL from ${source}`);
2294
- if (isLoopbackHost(parsed.hostname)) return;
2295
- if (env.BRAID_ALLOW_INSECURE === "1") {
2296
- warnInsecureOnce(source);
2297
- return;
2298
- }
2299
- throw new Error(
2300
- `[braid] refusing cleartext http to a non-loopback host (from ${source}). Use https, or BRAID_ALLOW_INSECURE=1 for local development only.`
2301
- );
2302
- }
2303
- var metrics = /* @__PURE__ */ new Map();
2304
- function record(cls, ms, err) {
2305
- const m = metrics.get(cls) ?? { count: 0, errors: {}, totalMs: 0, maxMs: 0 };
2306
- m.count++;
2307
- m.totalMs += ms;
2308
- if (ms > m.maxMs) m.maxMs = ms;
2309
- if (err) m.errors[err] = (m.errors[err] ?? 0) + 1;
2310
- metrics.set(cls, m);
2311
- }
2312
- function snapshotHttpMetrics() {
2313
- return Object.fromEntries([...metrics.entries()].map(([k, v]) => [k, { ...v, errors: { ...v.errors } }]));
2314
- }
2315
- function resetHttpMetricsForTest() {
2316
- metrics.clear();
2317
- warnedInsecure = false;
2318
- }
2319
- async function boundedJsonFetch(url, opts) {
2320
- assertHttpEndpoint(url, opts.requestClass, opts.env ?? process.env);
2321
- const fetchFn = opts.fetchFn ?? fetch;
2322
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2323
- const maxBody = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
2324
- const started = Date.now();
2325
- const done = (r) => {
2326
- record(opts.requestClass, Date.now() - started, r.ok ? void 0 : r.category);
2327
- return r;
2328
- };
2329
- let res;
2330
- try {
2331
- const signal = AbortSignal.timeout(timeoutMs);
2332
- const timeoutPromise = new Promise((_, reject) => {
2333
- const err = Object.assign(new Error(`fetch timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
2334
- if (signal.aborted) {
2335
- reject(err);
2336
- return;
2337
- }
2338
- signal.addEventListener("abort", () => reject(err), { once: true });
2339
- });
2340
- res = await Promise.race([
2341
- fetchFn(url, {
2342
- method: opts.method ?? "GET",
2343
- ...opts.headers ? { headers: opts.headers } : {},
2344
- ...opts.body !== void 0 ? { body: opts.body } : {},
2345
- signal
2346
- }),
2347
- timeoutPromise
2348
- ]);
2349
- } catch (err) {
2350
- const name = err?.name ?? "";
2351
- const isTimeout = name === "TimeoutError" || name === "AbortError";
2352
- return done({ ok: false, category: isTimeout ? "timeout" : "network", detail: name || "fetch failed" });
2353
- }
2354
- if (!res.ok) {
2355
- const errJson = await (async () => {
2356
- try {
2357
- const ct2 = res.headers?.get?.("content-type");
2358
- if (ct2) {
2359
- const mime = ct2.split(";")[0].trim().toLowerCase();
2360
- if (mime !== "application/json" && !mime.endsWith("+json")) return void 0;
2361
- }
2362
- const cl2 = Number(res.headers?.get?.("content-length") ?? NaN);
2363
- if (Number.isFinite(cl2) && cl2 > maxBody) {
2364
- try {
2365
- await res.body?.cancel?.();
2366
- } catch {
2367
- }
2368
- return void 0;
2369
- }
2370
- let text2;
2371
- const body = res.body;
2372
- if (body && typeof body.getReader === "function") {
2373
- const reader = body.getReader();
2374
- const chunks = [];
2375
- let total = 0;
2376
- for (; ; ) {
2377
- const { done: eof, value } = await reader.read();
2378
- if (eof) break;
2379
- total += value.byteLength;
2380
- if (total > maxBody) {
2381
- try {
2382
- await reader.cancel();
2383
- } catch {
2384
- }
2385
- return void 0;
2386
- }
2387
- chunks.push(value);
2388
- }
2389
- text2 = Buffer.concat(chunks).toString("utf8");
2390
- } else if (typeof res.text === "function") {
2391
- text2 = await res.text();
2392
- if (text2 !== void 0 && Buffer.byteLength(text2, "utf8") > maxBody) return void 0;
2393
- } else if (typeof res.json === "function") {
2394
- return await res.json();
2395
- }
2396
- if (text2 === void 0 || text2 === "") return void 0;
2397
- return JSON.parse(text2);
2398
- } catch {
2399
- return void 0;
2400
- }
2401
- })();
2402
- return done({ ok: false, category: "http", status: res.status, detail: `HTTP ${res.status}`, ...errJson !== void 0 ? { json: errJson } : {} });
2403
- }
2404
- const ct = res.headers?.get?.("content-type");
2405
- if (ct) {
2406
- const mime = ct.split(";")[0].trim().toLowerCase();
2407
- if (mime !== "application/json" && !mime.endsWith("+json")) {
2408
- try {
2409
- await res.body?.cancel?.();
2410
- } catch {
2411
- }
2412
- return done({ ok: false, category: "bad_content_type", status: res.status, detail: mime });
2413
- }
2414
- }
2415
- const cl = Number(res.headers?.get?.("content-length") ?? NaN);
2416
- if (Number.isFinite(cl) && cl > maxBody) {
2417
- try {
2418
- await res.body?.cancel?.();
2419
- } catch {
2420
- }
2421
- return done({ ok: false, category: "too_large", status: res.status, detail: `content-length ${cl} > ${maxBody}` });
2422
- }
2423
- let text;
2424
- try {
2425
- const body = res.body;
2426
- if (body && typeof body.getReader === "function") {
2427
- const reader = body.getReader();
2428
- const chunks = [];
2429
- let total = 0;
2430
- for (; ; ) {
2431
- const { done: eof, value } = await reader.read();
2432
- if (eof) break;
2433
- total += value.byteLength;
2434
- if (total > maxBody) {
2435
- try {
2436
- await reader.cancel();
2437
- } catch {
2438
- }
2439
- return done({ ok: false, category: "too_large", status: res.status, detail: `body exceeded ${maxBody}` });
2440
- }
2441
- chunks.push(value);
2442
- }
2443
- text = Buffer.concat(chunks).toString("utf8");
2444
- } else if (typeof res.text === "function") {
2445
- text = await res.text();
2446
- if (text !== void 0 && Buffer.byteLength(text, "utf8") > maxBody) {
2447
- return done({ ok: false, category: "too_large", status: res.status, detail: `body exceeded ${maxBody}` });
2448
- }
2449
- }
2450
- } catch (err) {
2451
- return done({ ok: false, category: "network", detail: String(err?.message ?? err) });
2452
- }
2453
- try {
2454
- const parsed = text !== void 0 ? text === "" ? void 0 : JSON.parse(text) : await res.json();
2455
- return done({ ok: true, status: res.status, json: parsed });
2456
- } catch {
2457
- return done({ ok: false, category: "bad_json", status: res.status, detail: "malformed JSON body" });
2458
- }
2459
- }
2460
-
2461
- export {
2462
- ACP_AGENT_IDS,
2463
- parseRunFlags,
2464
- resolveKnobs,
2465
- newBeyondFile,
2466
- hasSubscriptionLogin,
2467
- effectiveAnthropicCredential,
2468
- macKeychainHasClaudeLogin,
2469
- decodeInvite,
2470
- assertHttpEndpoint,
2471
- snapshotHttpMetrics,
2472
- resetHttpMetricsForTest,
2473
- boundedJsonFetch,
2474
- InviteExpiredError,
2475
- redeemInvite,
2476
- RebootstrapError,
2477
- validateReplacementToken,
2478
- mintAccessToken,
2479
- EXIT_OK,
2480
- EXIT_NEEDS_HUMAN,
2481
- EXIT_CRASH,
2482
- KeystorePermsError,
2483
- classifyFatal,
2484
- assertKeystorePerms,
2485
- configDir,
2486
- readPathWithFallback,
2487
- legacyTwinPath,
2488
- relayHostSegment,
2489
- saveAgent,
2490
- listAgents,
2491
- loadAgent,
2492
- acquireAgentLock,
2493
- pinEndpoint,
2494
- sanitizeForTty,
2495
- pipeSanitizedToTty,
2496
- restoreTerminal,
2497
- isShellSafeName,
2498
- narrowestMode,
2499
- narrowestOverride,
2500
- toolModeLabel,
2501
- toolStatusLine,
2502
- takeDenialHint,
2503
- evaluateAcpPermission,
2504
- evaluateSdkTool,
2505
- pickRejectOptionId,
2506
- resolveIpcProfile,
2507
- STRICT_IPC_ENV_STRIP,
2508
- resolveScope,
2509
- verifyCardSignature,
2510
- cleanContent,
2511
- resolveAgentSelection,
2512
- validateRelayWsUrl,
2513
- wsToHttp,
2514
- resolveConfig,
2515
- buildVersion,
2516
- startingLines
2517
- };