@snowyroad/braid 0.94.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-JA7L7WBG.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-JA7L7WBG.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
+ }
1191
1387
  }
1192
1388
  return null;
1193
1389
  }
1194
- function evaluateAcpPermission(mode, configDirAbs, req) {
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
+ }
1555
+ }
1556
+ return null;
1557
+ }
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}`,
@@ -4151,7 +4563,18 @@ var RelayClient = class {
4151
4563
  candidates: Array.isArray(msg.candidates) ? msg.candidates.map((c) => untrusted(String(c ?? ""))) : void 0,
4152
4564
  history,
4153
4565
  // BL-126: server-minted attribution key — pure pass-through, never invented by bridge.
4154
- dispatchId: typeof msg.dispatchId === "string" && msg.dispatchId.length > 0 ? msg.dispatchId : void 0
4566
+ dispatchId: typeof msg.dispatchId === "string" && msg.dispatchId.length > 0 ? msg.dispatchId : void 0,
4567
+ // D14: additive Lisa Loop signal — pass-through, mostly untransformed. Absent
4568
+ // on every non-lisa msg (msg.lisa undefined), so non-lisa signals are unaffected.
4569
+ // Defensive shape-narrowing for parity with the sibling fields above: a
4570
+ // malformed lisa (string/null/array) is dropped rather than passed through raw.
4571
+ // Review Minor 1 (T11 final wave): freshSession is ROUTING-relevant (D13 — it
4572
+ // decides whether the turn runs on the ephemeral one-shot session at all), so
4573
+ // unlike the other (display-only) lisa fields it is coerced to a STRICT boolean
4574
+ // here rather than passed through raw. A relay drift like `freshSession:"false"`
4575
+ // (string) is truthy in JS and would otherwise wrongly route to the ephemeral
4576
+ // path; `=== true` makes anything but the literal boolean true resolve false.
4577
+ lisa: typeof msg.lisa === "object" && msg.lisa !== null && !Array.isArray(msg.lisa) ? { ...msg.lisa, freshSession: msg.lisa.freshSession === true } : void 0
4155
4578
  };
4156
4579
  this.flowCbs.forEach((cb) => cb(signal));
4157
4580
  }
@@ -4611,9 +5034,9 @@ var RelayClient = class {
4611
5034
  // ── End private memory lane ───────────────────────────────────────────────────
4612
5035
  // ── Task board ──────────────────────────────────────────────────────────────
4613
5036
  /** Shared helper: fetch to a task endpoint and return {ok, status, json}. Callers surface errors. */
4614
- async taskFetch(method, path4, body) {
5037
+ async taskFetch(method, path5, body) {
4615
5038
  const bodyStr = body === void 0 ? void 0 : JSON.stringify(body);
4616
- 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" });
4617
5040
  return { ok: r.ok, status: r.ok ? r.status : r.status ?? 0, json: r.ok ? r.json : r.json };
4618
5041
  }
4619
5042
  async createTask(channelId, title, description) {
@@ -4670,7 +5093,7 @@ var RelayClient = class {
4670
5093
  * guard so malformed JSON is rejected with a clear error before any fetch call.
4671
5094
  * Byte-parity contract: the EXACT docJson string is embedded as the "doc" value
4672
5095
  * so what the bridge signs and what the relay stores are identical bytes. */
4673
- async artifactWriteFetch(method, path4, docJson, extraFields) {
5096
+ async artifactWriteFetch(method, path5, docJson, extraFields) {
4674
5097
  try {
4675
5098
  JSON.parse(docJson);
4676
5099
  } catch (e) {
@@ -4678,7 +5101,7 @@ var RelayClient = class {
4678
5101
  }
4679
5102
  const extras = JSON.stringify(extraFields);
4680
5103
  const body = `{"doc":${docJson},${extras.slice(1)}`;
4681
- 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" });
4682
5105
  return { ok: r.ok, status: r.ok ? r.status : r.status ?? 0, json: r.ok ? r.json : r.json };
4683
5106
  }
4684
5107
  /** Try to produce an artifact signature. Fail-open: returns undefined on any error. */
@@ -4744,9 +5167,9 @@ var RelayClient = class {
4744
5167
  // attributed by the relay's own authenticated caller identity.
4745
5168
  /** Shared helper for canvas calls: same {ok,status,json} shape as taskFetch,
4746
5169
  * under its own requestClass so canvas HTTP metrics are distinguishable. */
4747
- async canvasFetch(method, path4, body) {
5170
+ async canvasFetch(method, path5, body) {
4748
5171
  const bodyStr = body === void 0 ? void 0 : JSON.stringify(body);
4749
- 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" });
4750
5173
  return { ok: r.ok, status: r.ok ? r.status : r.status ?? 0, json: r.ok ? r.json : r.json };
4751
5174
  }
4752
5175
  async getCanvas(channelId) {
@@ -4818,7 +5241,7 @@ var RelayClient = class {
4818
5241
  /** Post a bounded-flow reply (turn or synthesis) to the flow-scoped endpoint.
4819
5242
  * agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
4820
5243
  * synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
4821
- async postFlowMessage(channelId, flowId, content, usage, dispatchId) {
5244
+ async postFlowMessage(channelId, flowId, content, usage, dispatchId, lisaFresh) {
4822
5245
  const ch = this.pathId(channelId, "channelId");
4823
5246
  const fl = this.pathId(flowId, "flowId");
4824
5247
  if (!ch || !fl) return;
@@ -4837,6 +5260,10 @@ var RelayClient = class {
4837
5260
  // BL-126: echo the server-minted attribution key unchanged. Never invented here.
4838
5261
  ...dispatchId ? { dispatchId } : {},
4839
5262
  ...signature ? { signature } : {},
5263
+ // D13: additive marker for a lisa fresh-session (ephemeral one-shot) turn's
5264
+ // reply. Sent ONLY on lisa turns (true); every other flow reply omits the field
5265
+ // entirely, byte-identical to the pre-lisa body shape.
5266
+ ...lisaFresh ? { lisaFresh: true } : {},
4840
5267
  ...this.usageBody(usage)
4841
5268
  });
4842
5269
  const r = await this.httpJson(`/channels/${ch}/flows/${fl}/messages`, {
@@ -5041,13 +5468,13 @@ var REFLECTION_REQUIRED_FIELDS = ["stage", "failure_family", "impacted_unit", "p
5041
5468
  var REFLECTION_CLUSTER_FIELDS = ["stage", "failure_family", "impacted_unit"];
5042
5469
  var VALID_NOTE_KINDS = /* @__PURE__ */ new Set(["note", ...STRUCTURED_NOTE_KINDS]);
5043
5470
  function brokerSocketDir() {
5044
- return path2.join(os.tmpdir(), "braid-broker");
5471
+ return path3.join(os.tmpdir(), "braid-broker");
5045
5472
  }
5046
5473
  function brokerSocketPath(uuid, platform = process.platform) {
5047
5474
  if (platform === "win32") {
5048
5475
  return `\\\\.\\pipe\\braid-broker-${uuid}`;
5049
5476
  }
5050
- return path2.join(brokerSocketDir(), `${uuid}.sock`);
5477
+ return path3.join(brokerSocketDir(), `${uuid}.sock`);
5051
5478
  }
5052
5479
  var SourceBroker = class {
5053
5480
  /** `platform` injected so the win32 named-pipe branch is unit-testable on a POSIX host
@@ -5092,6 +5519,16 @@ var SourceBroker = class {
5092
5519
  });
5093
5520
  return { socketPath: this.socketPath, token: this.token };
5094
5521
  }
5522
+ // G2 (lisa loop, D13 review Minor 3): the ephemeral one-shot lisa session (a blind
5523
+ // critic or builder round) inherits this SAME per-channel broker's MCP tools via the
5524
+ // startOpts replay in ChannelSession.runLisaFreshTurn (session.ts). That is safe
5525
+ // TODAY only because NO route below reads channel/flow MESSAGES (only sources,
5526
+ // reactions, notes, memory, tasks, artifacts, canvas). If a future route is added
5527
+ // here that reads channel or flow messages, it MUST be gated off lisa turns (or
5528
+ // otherwise structurally blocked from the ephemeral session) — reachable from a
5529
+ // blind critic, it would silently leak the transcript the relay's read-filter is
5530
+ // deliberately withholding (D9/G2b) and defeat critic blindness. Do not add a
5531
+ // channel/flow message-read route here without addressing this.
5095
5532
  async handle(req, res) {
5096
5533
  const send = (status, body2) => {
5097
5534
  const s = JSON.stringify(body2);
@@ -5376,7 +5813,7 @@ function grantSrtWinRunnerRX(deps) {
5376
5813
  return;
5377
5814
  }
5378
5815
  const systemRoot = deps?.systemRoot ?? process.env["SystemRoot"] ?? "C:\\Windows";
5379
- const icacls = path3.win32.join(systemRoot, "System32", "icacls.exe");
5816
+ const icacls = path4.win32.join(systemRoot, "System32", "icacls.exe");
5380
5817
  const spawnFn = deps?.spawnSync ?? ((cmd, args) => nodeSpawnSync2(cmd, args, { shell: false }));
5381
5818
  const result = spawnFn(icacls, [runnerPath, "/grant", "sandbox-runtime-users:(RX)"]);
5382
5819
  if (result.status !== 0) {
@@ -5392,7 +5829,7 @@ function brokerSocketNetworkExtras() {
5392
5829
  const dir = brokerSocketDir();
5393
5830
  const subpaths = [dir];
5394
5831
  try {
5395
- const resolved = path3.join(realpathSync2(path3.dirname(dir)), path3.basename(dir));
5832
+ const resolved = path4.join(realpathSync4(path4.dirname(dir)), path4.basename(dir));
5396
5833
  if (resolved !== dir) subpaths.push(resolved);
5397
5834
  } catch {
5398
5835
  }
@@ -5568,7 +6005,7 @@ function formatRateLimitNotice(agentName, provider, message) {
5568
6005
 
5569
6006
  // src/adapter.ts
5570
6007
  function defaultToolPolicy() {
5571
- return { mode: "readonly", configDirAbs: resolve2(configDir(process.env)) };
6008
+ return { mode: "readonly", configDirAbs: resolve2(configDir(process.env)), allowReadAbs: [] };
5572
6009
  }
5573
6010
  var ADAPTER_VERSIONS = {
5574
6011
  // Chosen 2026-06-11 (latest verified releases at pin time).
@@ -5588,13 +6025,13 @@ function pinned(pkg) {
5588
6025
  function computeNpxLaunch(platform, execPath, exists) {
5589
6026
  const nodeDir = dirname3(execPath);
5590
6027
  if (platform === "win32") {
5591
- const npxCliJs = join5(nodeDir, "node_modules", "npm", "bin", "npx-cli.js");
6028
+ const npxCliJs = join6(nodeDir, "node_modules", "npm", "bin", "npx-cli.js");
5592
6029
  if (exists(npxCliJs)) {
5593
6030
  return { command: execPath, argsPrefix: [npxCliJs] };
5594
6031
  }
5595
6032
  }
5596
6033
  for (const name of ["npx", "npx.cmd"]) {
5597
- const candidate = join5(nodeDir, name);
6034
+ const candidate = join6(nodeDir, name);
5598
6035
  if (exists(candidate)) {
5599
6036
  return { command: candidate, argsPrefix: [] };
5600
6037
  }
@@ -5617,7 +6054,7 @@ function which(cmd, pathEnv = process.env.PATH ?? "", platform = process.platfor
5617
6054
  const name = platform === "win32" && !cmd.toLowerCase().endsWith(".exe") ? `${cmd}.exe` : cmd;
5618
6055
  for (const dir of pathEnv.split(delimiter)) {
5619
6056
  if (!dir) continue;
5620
- const candidate = join5(dir, name);
6057
+ const candidate = join6(dir, name);
5621
6058
  try {
5622
6059
  accessSync(candidate, constants.X_OK);
5623
6060
  if (statSync2(candidate).isFile()) return resolve2(candidate);
@@ -5745,11 +6182,11 @@ function launchSpecFor(agent) {
5745
6182
  }
5746
6183
  }
5747
6184
  function handoffFallbackDirs(name, home) {
5748
- const dirs = [join5(home, ".local", "bin")];
5749
- 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"));
5750
6187
  return dirs;
5751
6188
  }
5752
- 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) {
5753
6190
  const onPath = which(name, pathEnv, platform);
5754
6191
  if (onPath) return onPath;
5755
6192
  return which(name, handoffFallbackDirs(name, home).join(delimiter), platform);
@@ -5913,6 +6350,16 @@ var AcpAdapter = class {
5913
6350
  this.noticeCbs.push(cb);
5914
6351
  },
5915
6352
  converseLocal: (text, overrideMode) => this.converseLocal(text, overrideMode),
6353
+ // Review Minor 3: converseLocalWithUsage is exposed ONLY on a NO-PERSISTENCE
6354
+ // (ephemeral, one-shot) construction — this.session undefined is the exact
6355
+ // invariant D13 already encodes (no persistence wired => AcpClient.start()'s
6356
+ // newSession path). A WARM adapter (session wired, e.g. createAdapter(cfg,
6357
+ // channelId)) never gets this method on its AgentSession at all, so a future
6358
+ // warm caller cannot silently advance the channel's disk-backed cumulative
6359
+ // cost baseline — the method simply does not exist to call.
6360
+ ...this.session ? {} : {
6361
+ converseLocalWithUsage: (text, overrideMode) => this.converseLocalWithUsage(text, overrideMode)
6362
+ },
5916
6363
  stop: async () => {
5917
6364
  this.stopped = true;
5918
6365
  await this.client?.stop();
@@ -5960,6 +6407,22 @@ var AcpAdapter = class {
5960
6407
  return `[braid] local turn failed: ${err?.message ?? String(err)}`;
5961
6408
  }
5962
6409
  }
6410
+ /**
6411
+ * D13/D20: like converseLocal, but for a lisa fresh-session (ephemeral one-shot)
6412
+ * turn — ALSO returns usage, converted via this.usageSource (for the ephemeral
6413
+ * construction path, an isolated non-persisted source; see createEphemeralLisaAdapter).
6414
+ * Deliberately does NOT swallow errors into an error-string reply: a lisa turn that
6415
+ * fails must fail loudly (nothing posts to the flow), never post a garbled
6416
+ * "local turn failed" string as though it were a real builder/critic report.
6417
+ */
6418
+ async converseLocalWithUsage(text, overrideMode) {
6419
+ const client = this.client;
6420
+ if (!client || this.stopped || this.gaveUp || this.handoffPaused) {
6421
+ throw new Error("[braid] agent unavailable for local conversation");
6422
+ }
6423
+ const result = await client.submit(text, overrideMode);
6424
+ return { text: result.text, usage: this.usageSource?.forTurn(result.usage) };
6425
+ }
5963
6426
  /**
5964
6427
  * Run one real channel turn against the warm session. On a turn that fails because
5965
6428
  * the subprocess crashed, supervise a restart (single-flight, capped) and best-effort
@@ -6205,17 +6668,20 @@ var ClaudeAdapter = class {
6205
6668
  permissionMode: "default",
6206
6669
  canUseTool: async (toolName, toolInput) => {
6207
6670
  const mode = overrideQueue[0] ?? policy.mode;
6208
- 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
+ });
6209
6675
  if (verdict.allow) return { behavior: "allow", updatedInput: toolInput };
6210
6676
  console.warn(`[braid] denied agent tool use: ${sanitizeForTty(verdict.reason)}`);
6211
6677
  this.telemetry?.captureBridgeEvent("tool_call_blocked", {
6212
6678
  tool: toolName,
6213
6679
  mode,
6214
6680
  path: "sdk",
6215
- reason: verdict.deniedByMode ? "mode" : "tripwire"
6681
+ reason: verdict.deniedByScope ? "scope" : verdict.deniedByMode ? "mode" : "tripwire"
6216
6682
  });
6217
6683
  if (verdict.deniedByMode) {
6218
- const hint = takeDenialHint(policy);
6684
+ const hint = takeDenialHint(policy, "operator", verdict.deniedByScope ? "scope" : "mode", process.cwd());
6219
6685
  if (hint) console.warn(sanitizeForTty(hint));
6220
6686
  }
6221
6687
  return { behavior: "deny", message: verdict.reason };
@@ -6262,13 +6728,31 @@ var ClaudeAdapter = class {
6262
6728
  };
6263
6729
  }
6264
6730
  };
6265
- function createAdapter(cfg, channelId, telemetry) {
6731
+ function safeAllowRead(cfg) {
6732
+ try {
6733
+ return resolveScope(cfg, cfg.agent, process.env).runtimeConfig.filesystem.allowRead;
6734
+ } catch {
6735
+ return [];
6736
+ }
6737
+ }
6738
+ function acpPolicyAndClientFactory(cfg, telemetry) {
6266
6739
  const policy = {
6267
6740
  mode: cfg.toolMode,
6268
6741
  configDirAbs: resolve2(configDir(process.env)),
6269
- agentName: cfg.agentName
6742
+ agentName: cfg.agentName,
6270
6743
  // for the once-per-process "braid tools full <name>" denial hint
6744
+ allowReadAbs: safeAllowRead(cfg)
6271
6745
  };
6746
+ const makeClient = (launch) => new AcpClient(
6747
+ launch,
6748
+ (l) => scopedLaunch(l, cfg, cfg.agent),
6749
+ afterChildExit,
6750
+ telemetry
6751
+ );
6752
+ return { policy, makeClient };
6753
+ }
6754
+ function createAdapter(cfg, channelId, telemetry) {
6755
+ const { policy, makeClient } = acpPolicyAndClientFactory(cfg, telemetry);
6272
6756
  if (cfg.agentMode !== "acp") return new ClaudeAdapter(policy, telemetry);
6273
6757
  if (channelId) {
6274
6758
  const prev = detectProviderSwitch(configDir(process.env), cfg.relayWsUrl, cfg.agentName, channelId, cfg.agent);
@@ -6288,14 +6772,13 @@ function createAdapter(cfg, channelId, telemetry) {
6288
6772
  }
6289
6773
  const session = channelId ? makeSessionPersistence(cfg, channelId) : void 0;
6290
6774
  const usageSource = channelId ? makeUsageSource(cfg, channelId) : void 0;
6291
- const makeClient = (launch) => new AcpClient(
6292
- launch,
6293
- (l) => scopedLaunch(l, cfg, cfg.agent),
6294
- afterChildExit,
6295
- telemetry
6296
- );
6297
6775
  return new AcpAdapter(cfg.agent, makeClient, void 0, policy, session, usageSource, cfg.agentName, cfg.stripEnvKeys, cfg.fallbackModel);
6298
6776
  }
6777
+ function createEphemeralLisaAdapter(cfg, telemetry) {
6778
+ const { policy, makeClient } = acpPolicyAndClientFactory(cfg, telemetry);
6779
+ const usageSource = makeEphemeralUsageSource(cfg);
6780
+ return new AcpAdapter(cfg.agent, makeClient, void 0, policy, void 0, usageSource, cfg.agentName, cfg.stripEnvKeys, cfg.fallbackModel);
6781
+ }
6299
6782
  function makeSessionPersistence(cfg, channelId) {
6300
6783
  const dir = configDir(process.env);
6301
6784
  return {
@@ -6326,6 +6809,22 @@ function makeUsageSource(cfg, channelId) {
6326
6809
  saveBaseline: (cumulative, currency) => updateStoredCost(dir, cfg.relayWsUrl, cfg.agentName, channelId, cumulative, currency, cfg.agent)
6327
6810
  });
6328
6811
  }
6812
+ function makeEphemeralUsageSource(cfg) {
6813
+ return new AcpUsageSource({
6814
+ model: cfg.model,
6815
+ provider: cfg.agent,
6816
+ authMode: effectiveUsageAuthMode({
6817
+ agent: cfg.agent,
6818
+ agentMode: cfg.agentMode,
6819
+ authMode: cfg.authMode ?? "auto",
6820
+ env: process.env,
6821
+ hasSubscriptionLogin: detectSubscriptionLogin(process.env)
6822
+ }),
6823
+ loadBaseline: () => void 0,
6824
+ saveBaseline: () => {
6825
+ }
6826
+ });
6827
+ }
6329
6828
 
6330
6829
  // src/providerPrompt.ts
6331
6830
  var PROVIDER_LABELS = {
@@ -6460,7 +6959,7 @@ function detectSubscriptionLogin(env) {
6460
6959
  return void 0;
6461
6960
  }
6462
6961
  };
6463
- const home = env.HOME?.trim() || homedir5();
6962
+ const home = env.HOME?.trim() || homedir6();
6464
6963
  return hasSubscriptionLogin(env, readText, home, keychainProbeFor(home));
6465
6964
  }
6466
6965
  function isHttpsOrLocalhostUrl(s) {
@@ -6874,7 +7373,7 @@ function parseClaudeConfigModel(jsonText) {
6874
7373
  function readClaudeConfigModel(agent) {
6875
7374
  if (agent !== "claude-code") return null;
6876
7375
  try {
6877
- const text = readFileSync3(join6(homedir5(), ".claude.json"), "utf8");
7376
+ const text = readFileSync3(join7(homedir6(), ".claude.json"), "utf8");
6878
7377
  return parseClaudeConfigModel(text);
6879
7378
  } catch {
6880
7379
  return null;
@@ -7164,6 +7663,7 @@ export {
7164
7663
  acquireAgentLock,
7165
7664
  pinEndpoint,
7166
7665
  updateAgentWorkspace,
7666
+ isFilesystemRoot,
7167
7667
  narrowestMode,
7168
7668
  narrowestOverride,
7169
7669
  toolModeLabel,
@@ -7191,6 +7691,7 @@ export {
7191
7691
  interactiveHandoffSupported,
7192
7692
  interactiveResumeSpecFor,
7193
7693
  createAdapter,
7694
+ createEphemeralLisaAdapter,
7194
7695
  handoffCwdFor,
7195
7696
  PROVIDER_LABELS,
7196
7697
  chooseFirstRunProvider,