@snowyroad/braid 0.95.0 → 0.96.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.
@@ -400,7 +400,7 @@ var InviteExpiredError = class extends Error {
400
400
  }
401
401
  };
402
402
  async function redeemInvite(relayHttpUrl, code) {
403
- const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-O3LXKBAJ.js");
403
+ const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-5RVYBQJV.js");
404
404
  const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/invites/redeem`, {
405
405
  method: "POST",
406
406
  headers: { "content-type": "application/json" },
@@ -432,8 +432,8 @@ async function redeemInvite(relayHttpUrl, code) {
432
432
 
433
433
  // src/config.ts
434
434
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3 } from "fs";
435
- import { dirname as dirname4, join as join6 } from "path";
436
- import { homedir as homedir5 } from "os";
435
+ import { dirname as dirname4, join as join7 } from "path";
436
+ import { homedir as homedir6 } from "os";
437
437
 
438
438
  // src/keystore.ts
439
439
  import { spawnSync as nodeSpawnSync } from "child_process";
@@ -492,7 +492,7 @@ function validateReplacementToken(newToken, currentToken, nowMs = Date.now()) {
492
492
  return { ok: true };
493
493
  }
494
494
  async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch, dpopProof) {
495
- const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-O3LXKBAJ.js");
495
+ const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-5RVYBQJV.js");
496
496
  const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/agents/token`, {
497
497
  method: "POST",
498
498
  headers: {
@@ -1101,8 +1101,195 @@ async function chooseRelayWhenAmbiguous(agentName, matches, io = { input: proces
1101
1101
  }
1102
1102
 
1103
1103
  // src/toolPolicy.ts
1104
+ import { homedir as homedir4 } from "os";
1105
+ import { join as join3, resolve, sep } from "path";
1106
+
1107
+ // src/scope/protectedPaths.ts
1108
+ import path2 from "path";
1109
+ function join2(platform, ...segments) {
1110
+ return platform === "win32" ? path2.win32.join(...segments) : path2.posix.join(...segments);
1111
+ }
1112
+ function isAbsolute(platform, p) {
1113
+ return platform === "win32" ? path2.win32.isAbsolute(p) : path2.posix.isAbsolute(p);
1114
+ }
1115
+ function dedupe(paths) {
1116
+ return [...new Set(paths)];
1117
+ }
1118
+ function osDeniableReadPaths(home, env, platform) {
1119
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
1120
+ const paths = [
1121
+ join2(platform, home, ".ssh"),
1122
+ join2(platform, home, ".aws"),
1123
+ join2(platform, home, ".gnupg"),
1124
+ join2(platform, home, ".kube/config"),
1125
+ join2(platform, home, ".npmrc"),
1126
+ join2(platform, home, ".netrc"),
1127
+ join2(platform, home, ".config/gh"),
1128
+ join2(platform, home, ".docker/config.json"),
1129
+ join2(platform, home, ".git-credentials"),
1130
+ join2(platform, home, ".config/git/credentials"),
1131
+ join2(platform, home, ".config/gcloud"),
1132
+ join2(platform, home, ".azure"),
1133
+ join2(platform, home, ".zsh_history"),
1134
+ join2(platform, home, ".bash_history"),
1135
+ join2(platform, home, ".local/share/fish/fish_history"),
1136
+ join2(platform, home, ".pypirc"),
1137
+ join2(platform, home, ".cargo/credentials"),
1138
+ join2(platform, home, ".cargo/credentials.toml"),
1139
+ join2(platform, home, ".gem/credentials"),
1140
+ join2(platform, home, ".m2/settings.xml"),
1141
+ join2(platform, home, ".gradle/gradle.properties"),
1142
+ join2(platform, home, ".composer/auth.json"),
1143
+ join2(platform, home, ".bundle/config"),
1144
+ join2(platform, home, ".terraform.d/credentials.tfrc.json"),
1145
+ join2(platform, home, ".terraformrc"),
1146
+ join2(platform, home, ".vault-token")
1147
+ ];
1148
+ if (xdgConfigHome && isAbsolute(platform, xdgConfigHome)) {
1149
+ paths.push(join2(platform, xdgConfigHome, "gh"));
1150
+ paths.push(join2(platform, xdgConfigHome, "git/credentials"));
1151
+ }
1152
+ if (platform === "win32") {
1153
+ const appData = env.APPDATA ?? join2(platform, home, "AppData/Roaming");
1154
+ const userProfile = env.USERPROFILE ?? home;
1155
+ paths.push(join2(platform, appData, "GitHub CLI"));
1156
+ paths.push(join2(platform, appData, "gcloud"));
1157
+ paths.push(join2(platform, appData, "Microsoft/Windows/PowerShell/PSReadLine"));
1158
+ paths.push(join2(platform, userProfile, "_netrc"));
1159
+ }
1160
+ return dedupe(paths);
1161
+ }
1162
+ function toolOnlyReadPaths(home, _env, platform) {
1163
+ return dedupe([
1164
+ join2(platform, home, ".claude/.credentials.json"),
1165
+ join2(platform, home, ".codex/auth.json"),
1166
+ join2(platform, home, ".gemini/oauth_creds.json"),
1167
+ join2(platform, home, ".gemini/google_accounts.json"),
1168
+ join2(platform, home, ".local/share/opencode/auth.json")
1169
+ ]);
1170
+ }
1171
+ function protectedReadPaths(home, env, platform, braidConfigDirs) {
1172
+ return dedupe([
1173
+ ...osDeniableReadPaths(home, env, platform),
1174
+ ...toolOnlyReadPaths(home, env, platform),
1175
+ ...braidConfigDirs
1176
+ ]);
1177
+ }
1178
+
1179
+ // src/readScope.ts
1180
+ import { realpathSync } from "fs";
1104
1181
  import { homedir as homedir3 } from "os";
1105
- import { join as join2, resolve, sep } from "path";
1182
+ import * as posixPath from "path/posix";
1183
+ import * as win32Path from "path/win32";
1184
+ function pathModuleFor(platform) {
1185
+ return platform === "win32" ? win32Path : posixPath;
1186
+ }
1187
+ function foldCase(p, platform) {
1188
+ return platform === "darwin" || platform === "win32" ? p.toLowerCase() : p;
1189
+ }
1190
+ function canonicalize(lexical, pathMod, realpath) {
1191
+ let current = lexical;
1192
+ let remainder = "";
1193
+ while (true) {
1194
+ try {
1195
+ const resolved = realpath(current);
1196
+ return pathMod.normalize(remainder ? pathMod.join(resolved, remainder) : resolved);
1197
+ } catch {
1198
+ const parent = pathMod.dirname(current);
1199
+ if (parent === current) {
1200
+ return lexical;
1201
+ }
1202
+ const base = pathMod.basename(current);
1203
+ remainder = remainder ? pathMod.join(base, remainder) : base;
1204
+ current = parent;
1205
+ }
1206
+ }
1207
+ }
1208
+ function expandHome(raw, home, pathMod) {
1209
+ if (raw === "~") {
1210
+ return home;
1211
+ }
1212
+ if (raw.startsWith("~/") || pathMod === win32Path && raw.startsWith("~\\")) {
1213
+ return home + pathMod.sep + raw.slice(2);
1214
+ }
1215
+ if (raw.startsWith("~")) {
1216
+ return null;
1217
+ }
1218
+ return raw;
1219
+ }
1220
+ function normalizeCandidate(raw, scope) {
1221
+ if (!raw) {
1222
+ return null;
1223
+ }
1224
+ const platform = scope.platform ?? process.platform;
1225
+ const pathMod = pathModuleFor(platform);
1226
+ const home = scope.home ?? homedir3();
1227
+ const expanded = expandHome(raw, home, pathMod);
1228
+ if (expanded === null) {
1229
+ return null;
1230
+ }
1231
+ const absolute = pathMod.isAbsolute(expanded) ? expanded : platform === "win32" ? pathMod.resolve(scope.cwd, expanded) : scope.cwd + pathMod.sep + expanded;
1232
+ const lexical = pathMod.normalize(absolute);
1233
+ const realpath = scope.realpath ?? realpathSync.native;
1234
+ const canonical = canonicalize(absolute, pathMod, realpath);
1235
+ return { lexical, canonical };
1236
+ }
1237
+ function isInsideRoot(p, root, pathMod, platform) {
1238
+ const foldedP = foldCase(p, platform);
1239
+ const foldedRoot = foldCase(root, platform);
1240
+ return foldedP === foldedRoot || foldedP.startsWith(foldedRoot + pathMod.sep);
1241
+ }
1242
+ function isSameDrive(p, root, platform) {
1243
+ if (platform !== "win32") {
1244
+ return true;
1245
+ }
1246
+ if (p.startsWith("\\\\") || root.startsWith("\\\\")) {
1247
+ return p.toLowerCase() === root.toLowerCase() || p.toLowerCase().startsWith(root.toLowerCase());
1248
+ }
1249
+ const driveOf = (s) => {
1250
+ const parsed = win32Path.parse(s);
1251
+ return parsed.root.slice(0, 2).toLowerCase();
1252
+ };
1253
+ return driveOf(p) === driveOf(root);
1254
+ }
1255
+ function formIsInsideRootSet(form, roots, pathMod, platform) {
1256
+ if (platform === "win32" && form.startsWith("\\\\")) {
1257
+ return false;
1258
+ }
1259
+ for (const root of roots) {
1260
+ if (platform === "win32" && !isSameDrive(form, root, platform)) {
1261
+ continue;
1262
+ }
1263
+ if (isInsideRoot(form, root, pathMod, platform)) {
1264
+ return true;
1265
+ }
1266
+ }
1267
+ return false;
1268
+ }
1269
+ function isInsideRootSet(raw, scope) {
1270
+ const platform = scope.platform ?? process.platform;
1271
+ const pathMod = pathModuleFor(platform);
1272
+ const normalized = normalizeCandidate(raw, scope);
1273
+ if (!normalized) {
1274
+ return false;
1275
+ }
1276
+ const roots = [scope.cwd, ...scope.allowRead].map((r) => {
1277
+ const n = pathMod.normalize(r);
1278
+ return n.length > 1 && n.endsWith(pathMod.sep) && !isFilesystemRoot(n, platform) ? n.slice(0, -1) : n;
1279
+ });
1280
+ const lexicalOk = formIsInsideRootSet(normalized.lexical, roots, pathMod, platform);
1281
+ const canonicalOk = formIsInsideRootSet(normalized.canonical, roots, pathMod, platform);
1282
+ return lexicalOk && canonicalOk;
1283
+ }
1284
+ function isFilesystemRoot(p, platform) {
1285
+ if (!p) {
1286
+ return false;
1287
+ }
1288
+ const pathMod = pathModuleFor(platform);
1289
+ return pathMod.parse(p).root === p;
1290
+ }
1291
+
1292
+ // src/toolPolicy.ts
1106
1293
  function narrowestMode(...modes) {
1107
1294
  for (const m of modes) {
1108
1295
  if (m === "readonly") return "readonly";
@@ -1122,10 +1309,10 @@ function toolStatusLine(mode, cause = "operator") {
1122
1309
  if (cause === "channel") {
1123
1310
  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.";
1124
1311
  }
1125
- 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>).";
1312
+ 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>). File reads are limited to the launch directory and any --allow-read paths.";
1126
1313
  }
1127
1314
  var denialHintShown = false;
1128
- function takeDenialHint(policy, cause = "operator") {
1315
+ function takeDenialHint(policy, cause = "operator", kind = "mode", cwd) {
1129
1316
  if (denialHintShown) return null;
1130
1317
  denialHintShown = true;
1131
1318
  if (cause === "channel") {
@@ -1133,6 +1320,10 @@ function takeDenialHint(policy, cause = "operator") {
1133
1320
  }
1134
1321
  const raw = policy.agentName;
1135
1322
  const name = raw && isShellSafeName(raw) ? raw : "<agent-name>";
1323
+ if (kind === "scope") {
1324
+ const where = cwd ? ` (${cwd})` : "";
1325
+ return `[braid] In read-and-reply mode this agent may only read files under its launch directory${where}. To widen: start it with --allow-read <dir>, or add the path to scope.allowRead in its agent config. For full tool access: braid tools full ${name}.`;
1326
+ }
1136
1327
  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).`;
1137
1328
  }
1138
1329
  function parseToolMode(env) {
@@ -1166,14 +1357,17 @@ function braidMcpFloorToolName(title) {
1166
1357
  return FLOOR_BRAID_MCP_TOOLS.has(short) ? short : null;
1167
1358
  }
1168
1359
  function expandTilde(p) {
1169
- if (p === "~") return homedir3();
1170
- if (p.startsWith("~/") || p.startsWith(`~${sep}`)) return join2(homedir3(), p.slice(2));
1360
+ if (p === "~") return homedir4();
1361
+ if (p.startsWith("~/") || p.startsWith(`~${sep}`)) return join3(homedir4(), p.slice(2));
1171
1362
  return p;
1172
1363
  }
1173
- function isInsideConfigDir(configDirAbs, rawPath) {
1174
- const cfg = resolve(expandTilde(configDirAbs));
1175
- const p = resolve(expandTilde(rawPath));
1176
- return p === cfg || p.startsWith(cfg + sep);
1364
+ function foldCase2(p, platform = process.platform) {
1365
+ return platform === "darwin" || platform === "win32" ? p.toLowerCase() : p;
1366
+ }
1367
+ function isInsidePath(protectedAbs, rawPath, cwd = process.cwd()) {
1368
+ const prot = foldCase2(resolve(expandTilde(protectedAbs)));
1369
+ const p = foldCase2(resolve(cwd, expandTilde(rawPath)));
1370
+ return p === prot || p.startsWith(prot + sep);
1177
1371
  }
1178
1372
  function pathsFromInput(input) {
1179
1373
  if (input == null || typeof input !== "object") return [];
@@ -1185,19 +1379,189 @@ function pathsFromInput(input) {
1185
1379
  }
1186
1380
  return out;
1187
1381
  }
1188
- function findConfigDirHit(configDirAbs, paths) {
1382
+ function findProtectedHit(protectedAbs, paths, cwd = process.cwd()) {
1189
1383
  for (const p of paths) {
1190
- if (isInsideConfigDir(configDirAbs, p)) return p;
1384
+ for (const prot of protectedAbs) {
1385
+ if (isInsidePath(prot, p, cwd)) return p;
1386
+ }
1387
+ }
1388
+ return null;
1389
+ }
1390
+ function legacyArpDir() {
1391
+ return join3(homedir4(), ".arp");
1392
+ }
1393
+ function protectedInventory(configDirAbs) {
1394
+ const home = homedir4();
1395
+ return protectedReadPaths(home, process.env, process.platform, [configDirAbs, legacyArpDir()]);
1396
+ }
1397
+ function protectedHitReason(configDirAbs, hit, cwd, toolName) {
1398
+ const subject = toolName ? `tool ${toolName}` : "tool call";
1399
+ if (isInsidePath(configDirAbs, hit, cwd) || isInsidePath(legacyArpDir(), hit, cwd)) {
1400
+ return `${subject} touches the Braid config dir (${hit})`;
1401
+ }
1402
+ return `${subject} touches a protected credential path (${hit})`;
1403
+ }
1404
+ var ENV_BASENAME_RE = /^\.env(\..+)?$/;
1405
+ function basenameOf(p) {
1406
+ const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
1407
+ return idx === -1 ? p : p.slice(idx + 1);
1408
+ }
1409
+ function unmaskGlobSegment(seg2) {
1410
+ return seg2.replace(/\[([^\]])\]/g, "$1").replace(/\\(.)/g, "$1").trim();
1411
+ }
1412
+ function formEscapesRootSet(form, platform) {
1413
+ if (form.startsWith("/") || form.startsWith("~")) return true;
1414
+ if (/^[A-Za-z]:[\\/]/.test(form)) return true;
1415
+ if (form.startsWith("\\\\")) return true;
1416
+ const posixSegs = form.split("/");
1417
+ if (posixSegs.some((s) => unmaskGlobSegment(s) === "..")) return true;
1418
+ if (platform === "win32" && form.split(/[\\/]/).some((s) => unmaskGlobSegment(s) === "..")) return true;
1419
+ return false;
1420
+ }
1421
+ function splitTopLevelCommas(inner) {
1422
+ const parts = [];
1423
+ let depth = 0;
1424
+ let start = 0;
1425
+ for (let i = 0; i < inner.length; i++) {
1426
+ const c = inner[i];
1427
+ if (c === "\\") {
1428
+ i++;
1429
+ continue;
1430
+ }
1431
+ if (c === "{") depth++;
1432
+ else if (c === "}" && depth > 0) depth--;
1433
+ else if (c === "," && depth === 0) {
1434
+ parts.push(inner.slice(start, i));
1435
+ start = i + 1;
1436
+ }
1437
+ }
1438
+ parts.push(inner.slice(start));
1439
+ return parts;
1440
+ }
1441
+ var BRACE_FORM_BUDGET = 1e4;
1442
+ var MAX_PATTERN_LEN = 512;
1443
+ var MAX_BRACE_GROUPS = 8;
1444
+ function expandBraces(pattern) {
1445
+ let depth = 0;
1446
+ let open = -1;
1447
+ for (let i = 0; i < pattern.length; i++) {
1448
+ const c = pattern[i];
1449
+ if (c === "\\") {
1450
+ i++;
1451
+ continue;
1452
+ }
1453
+ if (c === "{") {
1454
+ if (depth === 0) open = i;
1455
+ depth++;
1456
+ } else if (c === "}" && depth > 0) {
1457
+ depth--;
1458
+ if (depth === 0) {
1459
+ const opts = splitTopLevelCommas(pattern.slice(open + 1, i));
1460
+ if (opts.length <= 1) continue;
1461
+ const pre = pattern.slice(0, open);
1462
+ const post = pattern.slice(i + 1);
1463
+ const out = [];
1464
+ for (const opt of opts) {
1465
+ const sub = expandBraces(pre + opt + post);
1466
+ if (sub === null) return null;
1467
+ for (const s of sub) {
1468
+ if (out.length >= BRACE_FORM_BUDGET) return null;
1469
+ out.push(s);
1470
+ }
1471
+ }
1472
+ return out;
1473
+ }
1474
+ }
1475
+ }
1476
+ return [pattern];
1477
+ }
1478
+ function patternEscapesRootSet(pattern, platform) {
1479
+ if (pattern.length > MAX_PATTERN_LEN) return true;
1480
+ if ((pattern.match(/\{/g)?.length ?? 0) > MAX_BRACE_GROUPS) return true;
1481
+ const forms = expandBraces(pattern);
1482
+ if (forms === null) return true;
1483
+ return forms.some((f) => formEscapesRootSet(f, platform));
1484
+ }
1485
+ function patternBasenameTargetsEnv(pattern, platform) {
1486
+ if (pattern.length > MAX_PATTERN_LEN) return false;
1487
+ const forms = expandBraces(pattern);
1488
+ if (forms === null) return false;
1489
+ return forms.some((f) => {
1490
+ const segs = platform === "win32" ? f.split(/[\\/]/) : f.split("/");
1491
+ const base = unmaskGlobSegment(segs[segs.length - 1] ?? "");
1492
+ return ENV_BASENAME_RE.test(base.toLowerCase());
1493
+ });
1494
+ }
1495
+ function checkReadonlyRootSet(kind, candidates, patternFields, hasReadIntent, unknownShape, scope) {
1496
+ const platform = scope.platform ?? process.platform;
1497
+ const foldBasename = (s) => platform === "darwin" || platform === "win32" ? s.toLowerCase() : s;
1498
+ if (isFilesystemRoot(scope.cwd, platform) && hasReadIntent) {
1499
+ return {
1500
+ allow: false,
1501
+ reason: "readonly file reads have no working-directory boundary (bridge started at the filesystem root, e.g. a service install); pass --allow-read <dir> or set a working directory (BL-290)",
1502
+ deniedByMode: true,
1503
+ deniedByScope: true
1504
+ // remedy is --allow-read (a working directory), not `braid tools full`
1505
+ };
1506
+ }
1507
+ for (const p of candidates) {
1508
+ if (ENV_BASENAME_RE.test(foldBasename(basenameOf(p)))) {
1509
+ return {
1510
+ allow: false,
1511
+ reason: `read of ${p} touches a .env file, denied in readonly mode (D6)`,
1512
+ deniedByMode: true
1513
+ // .env IS readable in full mode, so `braid tools full` is the right hint
1514
+ };
1515
+ }
1516
+ }
1517
+ for (const pattern of patternFields) {
1518
+ if (patternEscapesRootSet(pattern, platform)) {
1519
+ return {
1520
+ allow: false,
1521
+ reason: `${kind} pattern "${pattern}" may not be absolute, ~-relative, drive/UNC-rooted, or contain ".." segments (readonly root-set escape)`,
1522
+ deniedByMode: true,
1523
+ deniedByScope: true
1524
+ // remedy is --allow-read / a narrower pattern
1525
+ };
1526
+ }
1527
+ if (patternBasenameTargetsEnv(pattern, platform)) {
1528
+ return {
1529
+ allow: false,
1530
+ reason: `${kind} glob "${pattern}" targets a .env file, denied in readonly mode (D6)`,
1531
+ deniedByMode: true
1532
+ };
1533
+ }
1534
+ }
1535
+ if (unknownShape) {
1536
+ return {
1537
+ allow: false,
1538
+ reason: `tool kind "${kind}" rawInput has no recognizable path field (unknown read shape)`,
1539
+ deniedByMode: true
1540
+ };
1541
+ }
1542
+ if (candidates.length === 0) {
1543
+ return null;
1544
+ }
1545
+ for (const p of candidates) {
1546
+ if (!isInsideRootSet(p, scope)) {
1547
+ const grants = scope.allowRead.length > 0 ? `, ${scope.allowRead.join(", ")}` : "";
1548
+ return {
1549
+ allow: false,
1550
+ reason: `read of ${p} is outside the readonly root set [${scope.cwd}${grants}]; widen with --allow-read <dir> (or scope.allowRead), or braid tools full <name>`,
1551
+ deniedByScope: true,
1552
+ deniedByMode: true
1553
+ };
1554
+ }
1191
1555
  }
1192
1556
  return null;
1193
1557
  }
1194
- function evaluateAcpPermission(mode, configDirAbs, req) {
1558
+ function evaluateAcpPermission(mode, configDirAbs, req, scope) {
1195
1559
  const tc = req.toolCall;
1196
1560
  const locationPaths = (tc?.locations ?? []).map((l) => l?.path).filter((p) => typeof p === "string" && p.trim() !== "");
1197
1561
  const candidates = [...locationPaths, ...pathsFromInput(tc?.rawInput)];
1198
- const hit = findConfigDirHit(configDirAbs, candidates);
1562
+ const hit = findProtectedHit(protectedInventory(configDirAbs), candidates, scope.cwd);
1199
1563
  if (hit) {
1200
- return { allow: false, reason: `tool call touches the Braid config dir (${hit})` };
1564
+ return { allow: false, reason: protectedHitReason(configDirAbs, hit, scope.cwd) };
1201
1565
  }
1202
1566
  const arpFloor = braidMcpFloorToolName(tc?.title);
1203
1567
  if (arpFloor) {
@@ -1205,6 +1569,21 @@ function evaluateAcpPermission(mode, configDirAbs, req) {
1205
1569
  }
1206
1570
  if (mode === "full") return { allow: true, reason: "BRAID_TOOL_MODE=full" };
1207
1571
  const kind = tc?.kind ?? null;
1572
+ if (kind === "read" || kind === "search") {
1573
+ const rawInput = tc?.rawInput;
1574
+ const rawInputIsObject = rawInput != null && typeof rawInput === "object";
1575
+ const rawInputPresent = rawInputIsObject && Object.keys(rawInput).length > 0 || rawInput != null && !rawInputIsObject;
1576
+ const patternFields = [];
1577
+ if (rawInputIsObject) {
1578
+ const o = rawInput;
1579
+ if (typeof o.glob === "string") patternFields.push(o.glob);
1580
+ }
1581
+ const searchPatternPresent = kind === "search" && rawInputIsObject && typeof rawInput.pattern === "string";
1582
+ const hasReadIntent = candidates.length > 0 || patternFields.length > 0 || rawInputPresent || locationPaths.length > 0;
1583
+ const unknownShape = rawInputPresent && candidates.length === 0 && patternFields.length === 0 && !searchPatternPresent && locationPaths.length === 0;
1584
+ const denial = checkReadonlyRootSet(kind, candidates, patternFields, hasReadIntent, unknownShape, scope);
1585
+ if (denial) return denial;
1586
+ }
1208
1587
  if (kind != null && READONLY_ACP_KINDS.has(kind)) {
1209
1588
  return { allow: true, reason: `read-only tool kind "${kind}"` };
1210
1589
  }
@@ -1225,10 +1604,11 @@ var READONLY_SDK_TOOLS = /* @__PURE__ */ new Set([
1225
1604
  "TodoWrite",
1226
1605
  "ExitPlanMode"
1227
1606
  ]);
1228
- function evaluateSdkTool(mode, configDirAbs, toolName, input) {
1229
- const hit = findConfigDirHit(configDirAbs, pathsFromInput(input));
1607
+ function evaluateSdkTool(mode, configDirAbs, toolName, input, scope) {
1608
+ const inputPaths = pathsFromInput(input);
1609
+ const hit = findProtectedHit(protectedInventory(configDirAbs), inputPaths, scope.cwd);
1230
1610
  if (hit) {
1231
- return { allow: false, reason: `tool ${toolName} touches the Braid config dir (${hit})` };
1611
+ return { allow: false, reason: protectedHitReason(configDirAbs, hit, scope.cwd, toolName) };
1232
1612
  }
1233
1613
  const arpFloor = braidMcpFloorToolName(toolName);
1234
1614
  if (arpFloor) {
@@ -1236,6 +1616,24 @@ function evaluateSdkTool(mode, configDirAbs, toolName, input) {
1236
1616
  }
1237
1617
  if (mode === "full") return { allow: true, reason: "BRAID_TOOL_MODE=full" };
1238
1618
  if (READONLY_SDK_TOOLS.has(toolName)) {
1619
+ if (toolName === "Read" || toolName === "Grep" || toolName === "Glob") {
1620
+ const candidates = inputPaths;
1621
+ const patternFields = [];
1622
+ if (toolName === "Glob" && typeof input.pattern === "string") patternFields.push(input.pattern);
1623
+ if (toolName === "Grep" && typeof input.glob === "string") patternFields.push(input.glob);
1624
+ const kind = toolName === "Read" ? "read" : "search";
1625
+ const unknownShape = toolName === "Read" && candidates.length === 0;
1626
+ const denial = checkReadonlyRootSet(
1627
+ kind,
1628
+ candidates,
1629
+ patternFields,
1630
+ /* hasReadIntent */
1631
+ true,
1632
+ unknownShape,
1633
+ scope
1634
+ );
1635
+ if (denial) return denial;
1636
+ }
1239
1637
  return { allow: true, reason: `read-only tool ${toolName}` };
1240
1638
  }
1241
1639
  const arpRead = braidMcpReadToolName(toolName);
@@ -1317,12 +1715,13 @@ import { createInterface as createInterface3 } from "readline";
1317
1715
  // src/adapter.ts
1318
1716
  import { query } from "@anthropic-ai/claude-agent-sdk";
1319
1717
  import { accessSync, constants, existsSync as existsSync2, statSync as statSync2 } from "fs";
1320
- import { delimiter, dirname as dirname3, join as join5, resolve as resolve2 } from "path";
1321
- import { homedir as homedir4 } from "os";
1718
+ import { delimiter, dirname as dirname3, join as join6, resolve as resolve2 } from "path";
1719
+ import { homedir as homedir5 } from "os";
1322
1720
 
1323
1721
  // src/acp/client.ts
1324
1722
  import { spawn } from "child_process";
1325
1723
  import { Readable, Writable } from "stream";
1724
+ import { realpathSync as realpathSync2 } from "fs";
1326
1725
  import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
1327
1726
 
1328
1727
  // src/acp/filterStream.ts
@@ -1395,7 +1794,7 @@ function capLines(s, maxLines) {
1395
1794
  if (lines.length <= maxLines) return { value: s, truncated: false };
1396
1795
  return { value: lines.slice(0, maxLines).join("\n") + BRIDGE_CAPS.truncationSuffix, truncated: true };
1397
1796
  }
1398
- function buildActivityContent(raw) {
1797
+ function buildActivityContent(raw, kind) {
1399
1798
  if (!Array.isArray(raw) || raw.length === 0) return null;
1400
1799
  const out = [];
1401
1800
  let total = 0;
@@ -1403,13 +1802,13 @@ function buildActivityContent(raw) {
1403
1802
  if (typeof item !== "object" || item === null) continue;
1404
1803
  const it = item;
1405
1804
  let built = null;
1406
- if (it.type === "diff" && typeof it.path === "string" && typeof it.newText === "string") {
1805
+ if (it.type === "diff" && kind === "edit" && typeof it.path === "string" && typeof it.newText === "string") {
1407
1806
  const oldText = typeof it.oldText === "string" ? it.oldText : "";
1408
1807
  let patch = createTwoFilesPatch(it.path, it.path, oldText, it.newText, void 0, void 0, { context: 3 });
1409
1808
  const byLines = capLines(patch, BRIDGE_CAPS.diffPatchLines);
1410
1809
  const byBytes = capBytes(byLines.value, BRIDGE_CAPS.diffPatchBytes);
1411
1810
  built = { type: "diff", path: it.path, patch: byBytes.value, truncated: byLines.truncated || byBytes.truncated };
1412
- } else if (it.type === "content") {
1811
+ } else if (it.type === "content" && kind === "execute") {
1413
1812
  const block = it.content;
1414
1813
  if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
1415
1814
  const capped = capBytes(block.text, BRIDGE_CAPS.textBytes);
@@ -1558,6 +1957,13 @@ function pickAllowOption(req) {
1558
1957
  "ACP request_permission had no allow option (allow_once/allow_always); refusing to auto-select a non-allow option"
1559
1958
  );
1560
1959
  }
1960
+ function acpSessionCwd(cwd) {
1961
+ try {
1962
+ return realpathSync2.native(cwd);
1963
+ } catch {
1964
+ return cwd;
1965
+ }
1966
+ }
1561
1967
  function safeAcpToolName(tc) {
1562
1968
  const title = tc?.title;
1563
1969
  if (typeof title === "string") {
@@ -1573,7 +1979,7 @@ var AcpClient = class {
1573
1979
  this.afterChildExitHook = afterChildExitHook;
1574
1980
  this.telemetry = telemetry;
1575
1981
  this.warmupCeilingMs = warmupCeilingMs;
1576
- this.policy = launch.policy ?? { mode: "readonly", configDirAbs: configDir(process.env) };
1982
+ this.policy = launch.policy ?? { mode: "readonly", configDirAbs: configDir(process.env), allowReadAbs: [] };
1577
1983
  }
1578
1984
  launch;
1579
1985
  scopeWrap;
@@ -1771,7 +2177,10 @@ var AcpClient = class {
1771
2177
  },
1772
2178
  requestPermission: async (req) => {
1773
2179
  const mode = this.currentTurnOverrideMode ?? this.policy.mode;
1774
- const verdict = evaluateAcpPermission(mode, this.policy.configDirAbs, req);
2180
+ const verdict = evaluateAcpPermission(mode, this.policy.configDirAbs, req, {
2181
+ cwd: acpSessionCwd(this.launch.cwd),
2182
+ allowRead: this.policy.allowReadAbs ?? []
2183
+ });
1775
2184
  if (verdict.allow) {
1776
2185
  return {
1777
2186
  outcome: { outcome: "selected", optionId: pickAllowOption(req) }
@@ -1782,11 +2191,11 @@ var AcpClient = class {
1782
2191
  tool: safeAcpToolName(req.toolCall),
1783
2192
  mode,
1784
2193
  path: "acp",
1785
- reason: verdict.deniedByMode ? "mode" : "tripwire"
2194
+ reason: verdict.deniedByScope ? "scope" : verdict.deniedByMode ? "mode" : "tripwire"
1786
2195
  });
1787
2196
  if (verdict.deniedByMode) {
1788
2197
  const cause = this.currentTurnOverrideMode === "readonly" && this.policy.mode !== "readonly" ? "channel" : "operator";
1789
- const hint = takeDenialHint(this.policy, cause);
2198
+ const hint = takeDenialHint(this.policy, cause, verdict.deniedByScope ? "scope" : "mode", acpSessionCwd(this.launch.cwd));
1790
2199
  if (hint) console.warn(sanitizeForTty(hint));
1791
2200
  }
1792
2201
  const rejectId = pickRejectOptionId(req);
@@ -1863,6 +2272,8 @@ var AcpClient = class {
1863
2272
  const sink = this.launch.onActivity;
1864
2273
  if (!sink) return;
1865
2274
  const locations = u.locations && u.locations.length > 0 ? u.locations.map((l) => l.line == null ? { path: l.path } : { path: l.path, line: l.line }) : null;
2275
+ if (u.kind) this.activeTurnBuffer?.kinds.set(u.toolCallId, u.kind);
2276
+ const kind = u.kind ?? this.activeTurnBuffer?.kinds.get(u.toolCallId) ?? null;
1866
2277
  const event = {
1867
2278
  turnId,
1868
2279
  toolCallId: u.toolCallId,
@@ -1872,7 +2283,7 @@ var AcpClient = class {
1872
2283
  status: u.status ?? null,
1873
2284
  locations,
1874
2285
  ts: Date.now(),
1875
- content: buildActivityContent(u.content)
2286
+ content: buildActivityContent(u.content, kind)
1876
2287
  };
1877
2288
  try {
1878
2289
  sink(event);
@@ -1932,7 +2343,8 @@ var AcpClient = class {
1932
2343
  const buffer = {
1933
2344
  text: "",
1934
2345
  turnId,
1935
- thoughts: new ThoughtBuffer(turnId)
2346
+ thoughts: new ThoughtBuffer(turnId),
2347
+ kinds: /* @__PURE__ */ new Map()
1936
2348
  };
1937
2349
  this.activeTurnBuffer = buffer;
1938
2350
  this.currentTurnOverrideMode = overrideMode;
@@ -2048,7 +2460,7 @@ var AcpClient = class {
2048
2460
 
2049
2461
  // src/sessionStore.ts
2050
2462
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, chmodSync as chmodSync2 } from "fs";
2051
- import { join as join3, dirname as dirname2 } from "path";
2463
+ import { join as join4, dirname as dirname2 } from "path";
2052
2464
  function mergeSessionPointer(existing, sessionId, cwd) {
2053
2465
  return existing ? { ...existing, sessionId, cwd } : { sessionId, cwd };
2054
2466
  }
@@ -2056,7 +2468,7 @@ function safe(s) {
2056
2468
  return s.replace(/[^a-zA-Z0-9._-]/g, "_");
2057
2469
  }
2058
2470
  function sessionFilePath(dir, relayUrl, agentName, channelId) {
2059
- return join3(dir, "sessions", relayHostSegment(relayUrl), `${safe(agentName)}__${safe(channelId)}.json`);
2471
+ return join4(dir, "sessions", relayHostSegment(relayUrl), `${safe(agentName)}__${safe(channelId)}.json`);
2060
2472
  }
2061
2473
  function parseSlot(p) {
2062
2474
  if (typeof p.sessionId === "string" && p.sessionId.trim() !== "" && typeof p.cwd === "string" && p.cwd.trim() !== "") {
@@ -2281,14 +2693,14 @@ function modelLaunchFor(agent, model) {
2281
2693
 
2282
2694
  // src/scope/sandbox.ts
2283
2695
  import { SandboxManager, SandboxRuntimeConfigSchema, installWindowsSandbox, getSrtWinPath } from "@anthropic-ai/sandbox-runtime";
2284
- import path3 from "path";
2285
- import { realpathSync as realpathSync2 } from "fs";
2696
+ import path4 from "path";
2697
+ import { realpathSync as realpathSync4 } from "fs";
2286
2698
  import { spawnSync as nodeSpawnSync2 } from "child_process";
2287
2699
 
2288
2700
  // src/scope/scopeConfig.ts
2289
- import { realpathSync, readdirSync as readdirSync2, lstatSync } from "fs";
2701
+ import { realpathSync as realpathSync3, readdirSync as readdirSync2, lstatSync } from "fs";
2290
2702
  import { execSync } from "child_process";
2291
- import { join as join4 } from "path";
2703
+ import { join as join5 } from "path";
2292
2704
  function resolveIpcProfile(scope, flagValue) {
2293
2705
  if (flagValue !== void 0) return flagValue;
2294
2706
  if (scope?.ipcProfile !== void 0) return scope.ipcProfile;
@@ -2379,14 +2791,14 @@ var PROVIDER_PATHS = {
2379
2791
  copilot: ["~/.copilot"],
2380
2792
  qwen: ["~/.qwen"]
2381
2793
  };
2382
- function expandHome(p, home) {
2794
+ function expandHome2(p, home) {
2383
2795
  if (p.startsWith("~/")) return `${home}${p.slice(1)}`;
2384
2796
  if (p === "~") return home;
2385
2797
  return p;
2386
2798
  }
2387
- function canonicalize(p) {
2799
+ function canonicalize2(p) {
2388
2800
  try {
2389
- return realpathSync(p);
2801
+ return realpathSync3(p);
2390
2802
  } catch {
2391
2803
  return p;
2392
2804
  }
@@ -2410,18 +2822,18 @@ function darwinMdsCacheDir() {
2410
2822
  }
2411
2823
  }
2412
2824
  function resolvePaths(paths, home) {
2413
- return dedup(paths.map((p) => canonicalize(expandHome(p, home))));
2825
+ return dedup(paths.map((p) => canonicalize2(expandHome2(p, home))));
2414
2826
  }
2415
- function foldCase(p, platform) {
2827
+ function foldCase3(p, platform) {
2416
2828
  return platform === "darwin" || platform === "win32" ? p.toLowerCase() : p;
2417
2829
  }
2418
2830
  function checkGrantsAgainstReservations(grants, reservations, platform) {
2419
2831
  const sep2 = platform === "win32" ? "\\" : "/";
2420
2832
  const out = [];
2421
2833
  for (const g of grants) {
2422
- const gf = foldCase(g.path, platform);
2834
+ const gf = foldCase3(g.path, platform);
2423
2835
  for (const r of reservations) {
2424
- const rf = foldCase(r, platform);
2836
+ const rf = foldCase3(r, platform);
2425
2837
  if (gf === rf) {
2426
2838
  out.push({ grant: g.path, source: g.source, reservation: r, relation: "equal" });
2427
2839
  } else if (gf.startsWith(rf + sep2)) {
@@ -2436,41 +2848,36 @@ function resolveScope(cfg, provider, env, platform = process.platform) {
2436
2848
  const home = env["HOME"] ?? "";
2437
2849
  const cwd = env["PWD"] ?? process.cwd();
2438
2850
  const tmpDir = env["TMPDIR"] ?? "/tmp";
2439
- const braidConfigDir = env["BRAID_CONFIG_DIR"]?.trim() || expandHome("~/.braid", home);
2440
- const legacyArpConfigDir = expandHome("~/.arp", home);
2851
+ const braidConfigDir = env["BRAID_CONFIG_DIR"]?.trim() || expandHome2("~/.braid", home);
2852
+ const legacyArpConfigDir = expandHome2("~/.arp", home);
2441
2853
  const protectedConfigDirs = braidConfigDir === legacyArpConfigDir ? [braidConfigDir] : [braidConfigDir, legacyArpConfigDir];
2442
2854
  const flagOverride = cfg.scopeFlags?.scopeOverride;
2443
2855
  const disabled = flagOverride !== void 0 ? flagOverride === "off" : scope.disabled === true || env["BRAID_SCOPE"] === "off";
2444
- const secretDirs = [
2445
- "~/.ssh",
2446
- "~/.aws",
2447
- "~/.gnupg",
2448
- "~/.kube/config"
2449
- ].map((p) => expandHome(p, home));
2856
+ const secretDirs = osDeniableReadPaths(home, env, platform);
2450
2857
  const protectedConfigDirEntries = protectedConfigDirs.flatMap((configDirPath) => {
2451
2858
  if (platform !== "win32") return [configDirPath];
2452
2859
  try {
2453
2860
  const children = readdirSync2(configDirPath, { withFileTypes: true });
2454
- const denied = children.filter((entry) => entry.name !== "runtime").map((entry) => canonicalize(join4(configDirPath, entry.name)));
2861
+ const denied = children.filter((entry) => entry.name !== "runtime").map((entry) => canonicalize2(join5(configDirPath, entry.name)));
2455
2862
  if (denied.length === 0) return [configDirPath];
2456
2863
  return denied;
2457
2864
  } catch {
2458
2865
  return [configDirPath];
2459
2866
  }
2460
2867
  });
2461
- const providerPaths = PROVIDER_PATHS[provider].map((p) => expandHome(p, home));
2868
+ const providerPaths = PROVIDER_PATHS[provider].map((p) => expandHome2(p, home));
2462
2869
  const uid = process.getuid?.();
2463
- const claudeTmpRoot = provider === "claude-code" && uid !== void 0 ? [`${canonicalize("/tmp")}/claude-${uid}`, `${canonicalize("/tmp")}/claude-*-cwd`] : [];
2870
+ const claudeTmpRoot = provider === "claude-code" && uid !== void 0 ? [`${canonicalize2("/tmp")}/claude-${uid}`, `${canonicalize2("/tmp")}/claude-*-cwd`] : [];
2464
2871
  const mdsCacheDir = platform === "darwin" ? darwinMdsCacheDir() : [];
2465
- const opAllowWrite = (scope.allowWrite ?? []).map((p) => expandHome(p, home));
2466
- const envAllowWrite = parseEnvList(env["BRAID_SCOPE_ALLOW_WRITE"]).map((p) => expandHome(p, home));
2467
- const flagAllowWrite = (cfg.scopeFlags?.allowWrite ?? []).map((p) => expandHome(p, home));
2872
+ const opAllowWrite = (scope.allowWrite ?? []).map((p) => expandHome2(p, home));
2873
+ const envAllowWrite = parseEnvList(env["BRAID_SCOPE_ALLOW_WRITE"]).map((p) => expandHome2(p, home));
2874
+ const flagAllowWrite = (cfg.scopeFlags?.allowWrite ?? []).map((p) => expandHome2(p, home));
2468
2875
  const rawAllowWrite = [
2469
2876
  cwd,
2470
2877
  ...providerPaths,
2471
2878
  ...claudeTmpRoot,
2472
2879
  ...mdsCacheDir,
2473
- expandHome("~/.npm", home),
2880
+ expandHome2("~/.npm", home),
2474
2881
  tmpDir,
2475
2882
  ...opAllowWrite,
2476
2883
  ...envAllowWrite,
@@ -2480,24 +2887,24 @@ function resolveScope(cfg, provider, env, platform = process.platform) {
2480
2887
  const rawDenyWrite = [
2481
2888
  ...secretDirs,
2482
2889
  ...protectedConfigDirEntries,
2483
- ...(scope.denyWrite ?? []).map((p) => expandHome(p, home)),
2484
- ...parseEnvList(env["BRAID_SCOPE_DENY_WRITE"]).map((p) => expandHome(p, home))
2890
+ ...(scope.denyWrite ?? []).map((p) => expandHome2(p, home)),
2891
+ ...parseEnvList(env["BRAID_SCOPE_DENY_WRITE"]).map((p) => expandHome2(p, home))
2485
2892
  ];
2486
2893
  const denyWrite = resolvePaths(rawDenyWrite, home);
2487
2894
  const ipcProfile = resolveIpcProfile(scope, cfg.scopeFlags?.ipcProfile);
2488
2895
  const rawDenyRead = [
2489
2896
  ...secretDirs,
2490
2897
  ...protectedConfigDirEntries,
2491
- ...(scope.denyRead ?? []).map((p) => expandHome(p, home)),
2492
- ...parseEnvList(env["BRAID_SCOPE_DENY_READ"]).map((p) => expandHome(p, home))
2898
+ ...(scope.denyRead ?? []).map((p) => expandHome2(p, home)),
2899
+ ...parseEnvList(env["BRAID_SCOPE_DENY_READ"]).map((p) => expandHome2(p, home))
2493
2900
  ];
2494
2901
  const denyRead = dedup([
2495
2902
  ...resolvePaths(rawDenyRead, home),
2496
2903
  ...ipcProfile === "strict" && platform === "linux" ? strictIpcDenyRead(env) : []
2497
2904
  ]);
2498
- const opAllowRead = (scope.allowRead ?? []).map((p) => expandHome(p, home));
2499
- const envAllowRead = parseEnvList(env["BRAID_SCOPE_ALLOW_READ"]).map((p) => expandHome(p, home));
2500
- const flagAllowRead = (cfg.scopeFlags?.allowRead ?? []).map((p) => expandHome(p, home));
2905
+ const opAllowRead = (scope.allowRead ?? []).map((p) => expandHome2(p, home));
2906
+ const envAllowRead = parseEnvList(env["BRAID_SCOPE_ALLOW_READ"]).map((p) => expandHome2(p, home));
2907
+ const flagAllowRead = (cfg.scopeFlags?.allowRead ?? []).map((p) => expandHome2(p, home));
2501
2908
  const rawAllowRead = [
2502
2909
  ...opAllowRead,
2503
2910
  ...envAllowRead,
@@ -2528,10 +2935,10 @@ Narrow the grant to a path outside the protected set. (Ancestor grants like "~"
2528
2935
  );
2529
2936
  }
2530
2937
  const writableDefaults = resolvePaths(
2531
- [...providerPaths, expandHome("~/.npm", home), tmpDir],
2938
+ [...providerPaths, expandHome2("~/.npm", home), tmpDir],
2532
2939
  home
2533
2940
  );
2534
- const resolvedConfigDir = canonicalize(braidConfigDir);
2941
+ const resolvedConfigDir = canonicalize2(braidConfigDir);
2535
2942
  const placement = checkGrantsAgainstReservations(
2536
2943
  [{ path: resolvedConfigDir, source: "BRAID_CONFIG_DIR" }],
2537
2944
  writableDefaults,
@@ -2562,10 +2969,15 @@ Narrow the grant to a path outside the protected set. (Ancestor grants like "~"
2562
2969
  return lines;
2563
2970
  }
2564
2971
  lines.push("[scope] Agent sandbox is ACTIVE");
2565
- lines.push(
2566
- `[scope] protected (never readable/writable by the agent): ${resolvePaths([...secretDirs, ...protectedConfigDirs], home).join(", ")}`
2567
- );
2972
+ {
2973
+ const configDirsShown = resolvePaths(protectedConfigDirs, home);
2974
+ const credentialPaths = resolvePaths(secretDirs, home);
2975
+ lines.push(
2976
+ `[scope] protected (never readable/writable by the agent): ${configDirsShown.join(", ")}, + ${credentialPaths.length} credential paths incl. ${credentialPaths.slice(0, 2).join(", ")}, ...`
2977
+ );
2978
+ }
2568
2979
  lines.push(`[scope] cwd (writable): ${cwd}`);
2980
+ lines.push(`[scope] file tools may read (readonly): ${cwd}${allowRead.length ? ", " + allowRead.join(", ") : ""}`);
2569
2981
  if (providerPaths.length > 0) {
2570
2982
  lines.push(`[scope] provider paths (writable): ${providerPaths.join(", ")}`);
2571
2983
  }
@@ -2602,7 +3014,7 @@ Narrow the grant to a path outside the protected set. (Ancestor grants like "~"
2602
3014
  // src/mcp/broker.ts
2603
3015
  import http from "http";
2604
3016
  import os from "os";
2605
- import path2 from "path";
3017
+ import path3 from "path";
2606
3018
  import fs from "fs";
2607
3019
  import { randomUUID as randomUUID4, randomBytes } from "crypto";
2608
3020
 
@@ -3026,7 +3438,7 @@ function classifyCatchUp(messages, agentName, nowMs, opts) {
3026
3438
  import { createRemoteJWKSet, jwtVerify } from "jose";
3027
3439
 
3028
3440
  // src/cardSigning.ts
3029
- import canonicalize2 from "canonicalize";
3441
+ import canonicalize3 from "canonicalize";
3030
3442
  import {
3031
3443
  FlattenedSign,
3032
3444
  flattenedVerify,
@@ -3083,7 +3495,7 @@ function pruneForSigning(card) {
3083
3495
  return pruneNode(clone, REQUIRED_TOP);
3084
3496
  }
3085
3497
  function canonicalCardString(card) {
3086
- return canonicalize2(pruneForSigning(card));
3498
+ return canonicalize3(pruneForSigning(card));
3087
3499
  }
3088
3500
  function publicOkpJwk(jwk) {
3089
3501
  return { kty: jwk.kty, crv: jwk.crv, x: jwk.x };
@@ -3403,8 +3815,8 @@ var RelayClient = class {
3403
3815
  * boundedJsonFetch. All relay HTTP call sites route through this. Never throws (returns
3404
3816
  * a BoundedJsonResult union). Plain-object fetch doubles ({ok,status,json}) are supported
3405
3817
  * by the underlying helper for test compatibility. */
3406
- httpJson(path4, opts) {
3407
- return boundedJsonFetch(`${this.cfg.relayHttpUrl}${path4}`, {
3818
+ httpJson(path5, opts) {
3819
+ return boundedJsonFetch(`${this.cfg.relayHttpUrl}${path5}`, {
3408
3820
  ...opts,
3409
3821
  headers: {
3410
3822
  Authorization: `Bearer ${this.cfg.token}`,
@@ -4622,9 +5034,9 @@ var RelayClient = class {
4622
5034
  // ── End private memory lane ───────────────────────────────────────────────────
4623
5035
  // ── Task board ──────────────────────────────────────────────────────────────
4624
5036
  /** Shared helper: fetch to a task endpoint and return {ok, status, json}. Callers surface errors. */
4625
- async taskFetch(method, path4, body) {
5037
+ async taskFetch(method, path5, body) {
4626
5038
  const bodyStr = body === void 0 ? void 0 : JSON.stringify(body);
4627
- const r = await this.httpJson(path4, { method, body: bodyStr, requestClass: "relay.task" });
5039
+ const r = await this.httpJson(path5, { method, body: bodyStr, requestClass: "relay.task" });
4628
5040
  return { ok: r.ok, status: r.ok ? r.status : r.status ?? 0, json: r.ok ? r.json : r.json };
4629
5041
  }
4630
5042
  async createTask(channelId, title, description) {
@@ -4681,7 +5093,7 @@ var RelayClient = class {
4681
5093
  * guard so malformed JSON is rejected with a clear error before any fetch call.
4682
5094
  * Byte-parity contract: the EXACT docJson string is embedded as the "doc" value
4683
5095
  * so what the bridge signs and what the relay stores are identical bytes. */
4684
- async artifactWriteFetch(method, path4, docJson, extraFields) {
5096
+ async artifactWriteFetch(method, path5, docJson, extraFields) {
4685
5097
  try {
4686
5098
  JSON.parse(docJson);
4687
5099
  } catch (e) {
@@ -4689,7 +5101,7 @@ var RelayClient = class {
4689
5101
  }
4690
5102
  const extras = JSON.stringify(extraFields);
4691
5103
  const body = `{"doc":${docJson},${extras.slice(1)}`;
4692
- const r = await this.httpJson(path4, { method, body, requestClass: "relay.artifact.write" });
5104
+ const r = await this.httpJson(path5, { method, body, requestClass: "relay.artifact.write" });
4693
5105
  return { ok: r.ok, status: r.ok ? r.status : r.status ?? 0, json: r.ok ? r.json : r.json };
4694
5106
  }
4695
5107
  /** Try to produce an artifact signature. Fail-open: returns undefined on any error. */
@@ -4755,9 +5167,9 @@ var RelayClient = class {
4755
5167
  // attributed by the relay's own authenticated caller identity.
4756
5168
  /** Shared helper for canvas calls: same {ok,status,json} shape as taskFetch,
4757
5169
  * under its own requestClass so canvas HTTP metrics are distinguishable. */
4758
- async canvasFetch(method, path4, body) {
5170
+ async canvasFetch(method, path5, body) {
4759
5171
  const bodyStr = body === void 0 ? void 0 : JSON.stringify(body);
4760
- const r = await this.httpJson(path4, { method, body: bodyStr, requestClass: "relay.canvas" });
5172
+ const r = await this.httpJson(path5, { method, body: bodyStr, requestClass: "relay.canvas" });
4761
5173
  return { ok: r.ok, status: r.ok ? r.status : r.status ?? 0, json: r.ok ? r.json : r.json };
4762
5174
  }
4763
5175
  async getCanvas(channelId) {
@@ -5056,13 +5468,13 @@ var REFLECTION_REQUIRED_FIELDS = ["stage", "failure_family", "impacted_unit", "p
5056
5468
  var REFLECTION_CLUSTER_FIELDS = ["stage", "failure_family", "impacted_unit"];
5057
5469
  var VALID_NOTE_KINDS = /* @__PURE__ */ new Set(["note", ...STRUCTURED_NOTE_KINDS]);
5058
5470
  function brokerSocketDir() {
5059
- return path2.join(os.tmpdir(), "braid-broker");
5471
+ return path3.join(os.tmpdir(), "braid-broker");
5060
5472
  }
5061
5473
  function brokerSocketPath(uuid, platform = process.platform) {
5062
5474
  if (platform === "win32") {
5063
5475
  return `\\\\.\\pipe\\braid-broker-${uuid}`;
5064
5476
  }
5065
- return path2.join(brokerSocketDir(), `${uuid}.sock`);
5477
+ return path3.join(brokerSocketDir(), `${uuid}.sock`);
5066
5478
  }
5067
5479
  var SourceBroker = class {
5068
5480
  /** `platform` injected so the win32 named-pipe branch is unit-testable on a POSIX host
@@ -5401,7 +5813,7 @@ function grantSrtWinRunnerRX(deps) {
5401
5813
  return;
5402
5814
  }
5403
5815
  const systemRoot = deps?.systemRoot ?? process.env["SystemRoot"] ?? "C:\\Windows";
5404
- const icacls = path3.win32.join(systemRoot, "System32", "icacls.exe");
5816
+ const icacls = path4.win32.join(systemRoot, "System32", "icacls.exe");
5405
5817
  const spawnFn = deps?.spawnSync ?? ((cmd, args) => nodeSpawnSync2(cmd, args, { shell: false }));
5406
5818
  const result = spawnFn(icacls, [runnerPath, "/grant", "sandbox-runtime-users:(RX)"]);
5407
5819
  if (result.status !== 0) {
@@ -5417,7 +5829,7 @@ function brokerSocketNetworkExtras() {
5417
5829
  const dir = brokerSocketDir();
5418
5830
  const subpaths = [dir];
5419
5831
  try {
5420
- const resolved = path3.join(realpathSync2(path3.dirname(dir)), path3.basename(dir));
5832
+ const resolved = path4.join(realpathSync4(path4.dirname(dir)), path4.basename(dir));
5421
5833
  if (resolved !== dir) subpaths.push(resolved);
5422
5834
  } catch {
5423
5835
  }
@@ -5593,7 +6005,7 @@ function formatRateLimitNotice(agentName, provider, message) {
5593
6005
 
5594
6006
  // src/adapter.ts
5595
6007
  function defaultToolPolicy() {
5596
- return { mode: "readonly", configDirAbs: resolve2(configDir(process.env)) };
6008
+ return { mode: "readonly", configDirAbs: resolve2(configDir(process.env)), allowReadAbs: [] };
5597
6009
  }
5598
6010
  var ADAPTER_VERSIONS = {
5599
6011
  // Chosen 2026-06-11 (latest verified releases at pin time).
@@ -5613,13 +6025,13 @@ function pinned(pkg) {
5613
6025
  function computeNpxLaunch(platform, execPath, exists) {
5614
6026
  const nodeDir = dirname3(execPath);
5615
6027
  if (platform === "win32") {
5616
- const npxCliJs = join5(nodeDir, "node_modules", "npm", "bin", "npx-cli.js");
6028
+ const npxCliJs = join6(nodeDir, "node_modules", "npm", "bin", "npx-cli.js");
5617
6029
  if (exists(npxCliJs)) {
5618
6030
  return { command: execPath, argsPrefix: [npxCliJs] };
5619
6031
  }
5620
6032
  }
5621
6033
  for (const name of ["npx", "npx.cmd"]) {
5622
- const candidate = join5(nodeDir, name);
6034
+ const candidate = join6(nodeDir, name);
5623
6035
  if (exists(candidate)) {
5624
6036
  return { command: candidate, argsPrefix: [] };
5625
6037
  }
@@ -5642,7 +6054,7 @@ function which(cmd, pathEnv = process.env.PATH ?? "", platform = process.platfor
5642
6054
  const name = platform === "win32" && !cmd.toLowerCase().endsWith(".exe") ? `${cmd}.exe` : cmd;
5643
6055
  for (const dir of pathEnv.split(delimiter)) {
5644
6056
  if (!dir) continue;
5645
- const candidate = join5(dir, name);
6057
+ const candidate = join6(dir, name);
5646
6058
  try {
5647
6059
  accessSync(candidate, constants.X_OK);
5648
6060
  if (statSync2(candidate).isFile()) return resolve2(candidate);
@@ -5770,11 +6182,11 @@ function launchSpecFor(agent) {
5770
6182
  }
5771
6183
  }
5772
6184
  function handoffFallbackDirs(name, home) {
5773
- const dirs = [join5(home, ".local", "bin")];
5774
- if (name === "claude") dirs.push(join5(home, ".claude", "local"));
6185
+ const dirs = [join6(home, ".local", "bin")];
6186
+ if (name === "claude") dirs.push(join6(home, ".claude", "local"));
5775
6187
  return dirs;
5776
6188
  }
5777
- function findHandoffBinary(name, pathEnv = process.env.PATH ?? "", home = homedir4(), platform = process.platform) {
6189
+ function findHandoffBinary(name, pathEnv = process.env.PATH ?? "", home = homedir5(), platform = process.platform) {
5778
6190
  const onPath = which(name, pathEnv, platform);
5779
6191
  if (onPath) return onPath;
5780
6192
  return which(name, handoffFallbackDirs(name, home).join(delimiter), platform);
@@ -6256,17 +6668,20 @@ var ClaudeAdapter = class {
6256
6668
  permissionMode: "default",
6257
6669
  canUseTool: async (toolName, toolInput) => {
6258
6670
  const mode = overrideQueue[0] ?? policy.mode;
6259
- const verdict = evaluateSdkTool(mode, policy.configDirAbs, toolName, toolInput);
6671
+ const verdict = evaluateSdkTool(mode, policy.configDirAbs, toolName, toolInput, {
6672
+ cwd: process.cwd(),
6673
+ allowRead: policy.allowReadAbs ?? []
6674
+ });
6260
6675
  if (verdict.allow) return { behavior: "allow", updatedInput: toolInput };
6261
6676
  console.warn(`[braid] denied agent tool use: ${sanitizeForTty(verdict.reason)}`);
6262
6677
  this.telemetry?.captureBridgeEvent("tool_call_blocked", {
6263
6678
  tool: toolName,
6264
6679
  mode,
6265
6680
  path: "sdk",
6266
- reason: verdict.deniedByMode ? "mode" : "tripwire"
6681
+ reason: verdict.deniedByScope ? "scope" : verdict.deniedByMode ? "mode" : "tripwire"
6267
6682
  });
6268
6683
  if (verdict.deniedByMode) {
6269
- const hint = takeDenialHint(policy);
6684
+ const hint = takeDenialHint(policy, "operator", verdict.deniedByScope ? "scope" : "mode", process.cwd());
6270
6685
  if (hint) console.warn(sanitizeForTty(hint));
6271
6686
  }
6272
6687
  return { behavior: "deny", message: verdict.reason };
@@ -6313,12 +6728,20 @@ var ClaudeAdapter = class {
6313
6728
  };
6314
6729
  }
6315
6730
  };
6731
+ function safeAllowRead(cfg) {
6732
+ try {
6733
+ return resolveScope(cfg, cfg.agent, process.env).runtimeConfig.filesystem.allowRead;
6734
+ } catch {
6735
+ return [];
6736
+ }
6737
+ }
6316
6738
  function acpPolicyAndClientFactory(cfg, telemetry) {
6317
6739
  const policy = {
6318
6740
  mode: cfg.toolMode,
6319
6741
  configDirAbs: resolve2(configDir(process.env)),
6320
- agentName: cfg.agentName
6742
+ agentName: cfg.agentName,
6321
6743
  // for the once-per-process "braid tools full <name>" denial hint
6744
+ allowReadAbs: safeAllowRead(cfg)
6322
6745
  };
6323
6746
  const makeClient = (launch) => new AcpClient(
6324
6747
  launch,
@@ -6536,7 +6959,7 @@ function detectSubscriptionLogin(env) {
6536
6959
  return void 0;
6537
6960
  }
6538
6961
  };
6539
- const home = env.HOME?.trim() || homedir5();
6962
+ const home = env.HOME?.trim() || homedir6();
6540
6963
  return hasSubscriptionLogin(env, readText, home, keychainProbeFor(home));
6541
6964
  }
6542
6965
  function isHttpsOrLocalhostUrl(s) {
@@ -6950,7 +7373,7 @@ function parseClaudeConfigModel(jsonText) {
6950
7373
  function readClaudeConfigModel(agent) {
6951
7374
  if (agent !== "claude-code") return null;
6952
7375
  try {
6953
- const text = readFileSync3(join6(homedir5(), ".claude.json"), "utf8");
7376
+ const text = readFileSync3(join7(homedir6(), ".claude.json"), "utf8");
6954
7377
  return parseClaudeConfigModel(text);
6955
7378
  } catch {
6956
7379
  return null;
@@ -7240,6 +7663,7 @@ export {
7240
7663
  acquireAgentLock,
7241
7664
  pinEndpoint,
7242
7665
  updateAgentWorkspace,
7666
+ isFilesystemRoot,
7243
7667
  narrowestMode,
7244
7668
  narrowestOverride,
7245
7669
  toolModeLabel,