@letrquan/book 0.3.0 → 0.4.0-beta.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +1024 -0
  2. package/README.md +6 -3
  3. package/dist/{additional-roots-H7UNAEHS.js → additional-roots-VAWRQ625.js} +8 -7
  4. package/dist/{app-GE4OHJOC.js → app-J7BVPSUH.js} +5397 -2953
  5. package/dist/app-J7BVPSUH.js.map +1 -0
  6. package/dist/chunk-32WBCUAN.js +660 -0
  7. package/dist/chunk-32WBCUAN.js.map +1 -0
  8. package/dist/{chunk-525CAZPY.js → chunk-4DKHLZRI.js} +6 -6
  9. package/dist/{chunk-4AS7LKKQ.js → chunk-62EEAUID.js} +2 -2
  10. package/dist/{chunk-AQEQX4FF.js → chunk-AHC7OU75.js} +385 -55
  11. package/dist/chunk-AHC7OU75.js.map +1 -0
  12. package/dist/{chunk-EFJ2NH6E.js → chunk-AJZWLKLQ.js} +63 -14
  13. package/dist/chunk-AJZWLKLQ.js.map +1 -0
  14. package/dist/chunk-B5FC3MYZ.js +395 -0
  15. package/dist/chunk-B5FC3MYZ.js.map +1 -0
  16. package/dist/{chunk-67UZEWWF.js → chunk-DNFQTSMJ.js} +53 -11
  17. package/dist/chunk-DNFQTSMJ.js.map +1 -0
  18. package/dist/{chunk-TLX3H2CL.js → chunk-E5WLQSKA.js} +231 -40
  19. package/dist/chunk-E5WLQSKA.js.map +1 -0
  20. package/dist/chunk-ECLC4CIJ.js +1181 -0
  21. package/dist/chunk-ECLC4CIJ.js.map +1 -0
  22. package/dist/chunk-EEX6QNXV.js +127 -0
  23. package/dist/chunk-EEX6QNXV.js.map +1 -0
  24. package/dist/chunk-EFXVSQ5J.js +931 -0
  25. package/dist/chunk-EFXVSQ5J.js.map +1 -0
  26. package/dist/{chunk-RFJ6Q6LE.js → chunk-ENLB7NYV.js} +577 -164
  27. package/dist/chunk-ENLB7NYV.js.map +1 -0
  28. package/dist/{chunk-IME7R2UI.js → chunk-FPR7SKIE.js} +1268 -883
  29. package/dist/chunk-FPR7SKIE.js.map +1 -0
  30. package/dist/{chunk-VPNLCMVR.js → chunk-HFB74CLM.js} +13 -5
  31. package/dist/chunk-HFB74CLM.js.map +1 -0
  32. package/dist/{chunk-5RDRAO4B.js → chunk-HOZXKOE3.js} +37 -1
  33. package/dist/chunk-HOZXKOE3.js.map +1 -0
  34. package/dist/chunk-IDYWN37T.js +36 -0
  35. package/dist/chunk-IDYWN37T.js.map +1 -0
  36. package/dist/{chunk-YC6W4EJH.js → chunk-JGF5HLSI.js} +193 -75
  37. package/dist/chunk-JGF5HLSI.js.map +1 -0
  38. package/dist/{chunk-5YHTKWJN.js → chunk-KDNWMKXV.js} +2447 -907
  39. package/dist/chunk-KDNWMKXV.js.map +1 -0
  40. package/dist/{chunk-Y2VHN7TC.js → chunk-MEOB2DZA.js} +11 -1
  41. package/dist/chunk-MEOB2DZA.js.map +1 -0
  42. package/dist/{chunk-KBQS35OV.js → chunk-MV5PQNIA.js} +2 -2
  43. package/dist/chunk-N2G4Q7ME.js +2672 -0
  44. package/dist/chunk-N2G4Q7ME.js.map +1 -0
  45. package/dist/{chunk-62QL5QUP.js → chunk-OJBMDM5H.js} +17 -1
  46. package/dist/chunk-OJBMDM5H.js.map +1 -0
  47. package/dist/{chunk-56SIEMDX.js → chunk-PTDC7ZUP.js} +2 -2
  48. package/dist/{chunk-X4NJT436.js → chunk-REASC2QG.js} +67 -28
  49. package/dist/chunk-REASC2QG.js.map +1 -0
  50. package/dist/chunk-SBTU5BEC.js +114 -0
  51. package/dist/chunk-SBTU5BEC.js.map +1 -0
  52. package/dist/{chunk-HQI7NYGX.js → chunk-SWPDKLNM.js} +2 -2
  53. package/dist/{chunk-W3XFHEF4.js → chunk-UCFJ43OS.js} +140 -28
  54. package/dist/chunk-UCFJ43OS.js.map +1 -0
  55. package/dist/{chunk-QYBD22H5.js → chunk-W7SLYS3M.js} +80 -16
  56. package/dist/chunk-W7SLYS3M.js.map +1 -0
  57. package/dist/{chunk-SOW4WRQP.js → chunk-WV3O5PGX.js} +3 -3
  58. package/dist/{chunk-JTPOI3KU.js → chunk-XJRJVXJR.js} +262 -38
  59. package/dist/chunk-XJRJVXJR.js.map +1 -0
  60. package/dist/{chunk-224FJZLH.js → chunk-YIL6HTJX.js} +228 -5
  61. package/dist/chunk-YIL6HTJX.js.map +1 -0
  62. package/dist/{command-approvals-VBIU6BLM.js → command-approvals-ZRY66LCW.js} +5 -4
  63. package/dist/context-C3RAP6K5.js +41 -0
  64. package/dist/{hook-approvals-ZVT4V7IM.js → hook-approvals-DPQD5H65.js} +5 -4
  65. package/dist/index.js +657 -754
  66. package/dist/index.js.map +1 -1
  67. package/dist/{interactive-assets-T4I4SEHU.js → interactive-assets-73AQO5WP.js} +11 -6
  68. package/dist/{interactive-assets-T4I4SEHU.js.map → interactive-assets-73AQO5WP.js.map} +1 -1
  69. package/dist/job-runner.js +10 -3
  70. package/dist/job-runner.js.map +1 -1
  71. package/dist/job-supervisor.js +13 -4
  72. package/dist/job-supervisor.js.map +1 -1
  73. package/dist/loader-DQNPDKOX.js +27 -0
  74. package/dist/{mcp-approvals-HJ2VDYMC.js → mcp-approvals-27LJYADQ.js} +5 -4
  75. package/dist/{mcp-config-RNB5LEVE.js → mcp-config-GXSODNCM.js} +3 -2
  76. package/dist/memory-store-I6Z54VUN.js +51 -0
  77. package/dist/{model-window-store-MTSQ5JDJ.js → model-window-store-6VKXXTPJ.js} +4 -3
  78. package/dist/{openai-compatible-2OB54YST.js → openai-compatible-IPUNBJ53.js} +5 -3
  79. package/dist/{permission-approvals-YLV6UC5W.js → permission-approvals-Q5IMFEZI.js} +5 -4
  80. package/dist/{permissions-DHOKF3W2.js → permissions-7Y35X6HZ.js} +26 -9
  81. package/dist/provider-approvals-5AGH3RVT.js +76 -0
  82. package/dist/{sandbox-WRYTZH3D.js → sandbox-N2RY72GN.js} +6 -5
  83. package/dist/{scrollback-35ME2GFX.js → scrollback-GD76GNM4.js} +36 -28
  84. package/dist/scrollback-GD76GNM4.js.map +1 -0
  85. package/dist/sdk.d.ts +876 -64
  86. package/dist/sdk.js +57 -37
  87. package/dist/sdk.js.map +1 -1
  88. package/dist/settings-loader-KDBH45AQ.js +43 -0
  89. package/dist/{settings-removed-JWWPT4WA.js → settings-removed-3ATWJEF3.js} +2 -2
  90. package/dist/shell-selection-Z5WORZU4.js +31 -0
  91. package/dist/shell-selection-Z5WORZU4.js.map +1 -0
  92. package/package.json +5 -5
  93. package/dist/app-GE4OHJOC.js.map +0 -1
  94. package/dist/chunk-224FJZLH.js.map +0 -1
  95. package/dist/chunk-5RDRAO4B.js.map +0 -1
  96. package/dist/chunk-5YHTKWJN.js.map +0 -1
  97. package/dist/chunk-62QL5QUP.js.map +0 -1
  98. package/dist/chunk-67UZEWWF.js.map +0 -1
  99. package/dist/chunk-6OQQOMZR.js +0 -785
  100. package/dist/chunk-6OQQOMZR.js.map +0 -1
  101. package/dist/chunk-AQEQX4FF.js.map +0 -1
  102. package/dist/chunk-C7ADUNPR.js +0 -273
  103. package/dist/chunk-C7ADUNPR.js.map +0 -1
  104. package/dist/chunk-EFJ2NH6E.js.map +0 -1
  105. package/dist/chunk-IME7R2UI.js.map +0 -1
  106. package/dist/chunk-J62VZYOB.js +0 -1339
  107. package/dist/chunk-J62VZYOB.js.map +0 -1
  108. package/dist/chunk-JTPOI3KU.js.map +0 -1
  109. package/dist/chunk-QYBD22H5.js.map +0 -1
  110. package/dist/chunk-RFJ6Q6LE.js.map +0 -1
  111. package/dist/chunk-S4JMID4G.js +0 -259
  112. package/dist/chunk-S4JMID4G.js.map +0 -1
  113. package/dist/chunk-TLX3H2CL.js.map +0 -1
  114. package/dist/chunk-VPNLCMVR.js.map +0 -1
  115. package/dist/chunk-W3XFHEF4.js.map +0 -1
  116. package/dist/chunk-X4NJT436.js.map +0 -1
  117. package/dist/chunk-X66I2NQO.js +0 -137
  118. package/dist/chunk-X66I2NQO.js.map +0 -1
  119. package/dist/chunk-Y2VHN7TC.js.map +0 -1
  120. package/dist/chunk-YC6W4EJH.js.map +0 -1
  121. package/dist/context-CBOBDY5Q.js +0 -38
  122. package/dist/loader-S77OVTHH.js +0 -18
  123. package/dist/memory-store-DZUT5HUH.js +0 -75
  124. package/dist/scrollback-35ME2GFX.js.map +0 -1
  125. package/dist/settings-loader-JUNPGAAM.js +0 -35
  126. package/dist/shell-selection-CDHGJ4GC.js +0 -17
  127. /package/dist/{additional-roots-H7UNAEHS.js.map → additional-roots-VAWRQ625.js.map} +0 -0
  128. /package/dist/{chunk-525CAZPY.js.map → chunk-4DKHLZRI.js.map} +0 -0
  129. /package/dist/{chunk-4AS7LKKQ.js.map → chunk-62EEAUID.js.map} +0 -0
  130. /package/dist/{chunk-KBQS35OV.js.map → chunk-MV5PQNIA.js.map} +0 -0
  131. /package/dist/{chunk-56SIEMDX.js.map → chunk-PTDC7ZUP.js.map} +0 -0
  132. /package/dist/{chunk-HQI7NYGX.js.map → chunk-SWPDKLNM.js.map} +0 -0
  133. /package/dist/{chunk-SOW4WRQP.js.map → chunk-WV3O5PGX.js.map} +0 -0
  134. /package/dist/{command-approvals-VBIU6BLM.js.map → command-approvals-ZRY66LCW.js.map} +0 -0
  135. /package/dist/{context-CBOBDY5Q.js.map → context-C3RAP6K5.js.map} +0 -0
  136. /package/dist/{hook-approvals-ZVT4V7IM.js.map → hook-approvals-DPQD5H65.js.map} +0 -0
  137. /package/dist/{loader-S77OVTHH.js.map → loader-DQNPDKOX.js.map} +0 -0
  138. /package/dist/{mcp-approvals-HJ2VDYMC.js.map → mcp-approvals-27LJYADQ.js.map} +0 -0
  139. /package/dist/{mcp-config-RNB5LEVE.js.map → mcp-config-GXSODNCM.js.map} +0 -0
  140. /package/dist/{memory-store-DZUT5HUH.js.map → memory-store-I6Z54VUN.js.map} +0 -0
  141. /package/dist/{model-window-store-MTSQ5JDJ.js.map → model-window-store-6VKXXTPJ.js.map} +0 -0
  142. /package/dist/{openai-compatible-2OB54YST.js.map → openai-compatible-IPUNBJ53.js.map} +0 -0
  143. /package/dist/{permission-approvals-YLV6UC5W.js.map → permission-approvals-Q5IMFEZI.js.map} +0 -0
  144. /package/dist/{permissions-DHOKF3W2.js.map → permissions-7Y35X6HZ.js.map} +0 -0
  145. /package/dist/{sandbox-WRYTZH3D.js.map → provider-approvals-5AGH3RVT.js.map} +0 -0
  146. /package/dist/{settings-loader-JUNPGAAM.js.map → sandbox-N2RY72GN.js.map} +0 -0
  147. /package/dist/{settings-removed-JWWPT4WA.js.map → settings-loader-KDBH45AQ.js.map} +0 -0
  148. /package/dist/{shell-selection-CDHGJ4GC.js.map → settings-removed-3ATWJEF3.js.map} +0 -0
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  updateWorkspaceTrust
3
- } from "./chunk-VPNLCMVR.js";
3
+ } from "./chunk-HFB74CLM.js";
4
4
  import {
5
5
  HOOK_EVENTS
6
- } from "./chunk-X4NJT436.js";
6
+ } from "./chunk-REASC2QG.js";
7
7
 
8
8
  // src/hook-approvals.ts
9
9
  import { createHash } from "crypto";
@@ -81,4 +81,4 @@ export {
81
81
  describeDeclaredHook,
82
82
  persistProjectHookChoice
83
83
  };
84
- //# sourceMappingURL=chunk-SOW4WRQP.js.map
84
+ //# sourceMappingURL=chunk-WV3O5PGX.js.map
@@ -1,10 +1,10 @@
1
+ import {
2
+ createDebugLogger
3
+ } from "./chunk-OJBMDM5H.js";
1
4
  import {
2
5
  isOutside,
3
6
  resolveWorkspacePath
4
- } from "./chunk-X66I2NQO.js";
5
- import {
6
- createDebugLogger
7
- } from "./chunk-62QL5QUP.js";
7
+ } from "./chunk-YIL6HTJX.js";
8
8
 
9
9
  // src/sandbox.ts
10
10
  import {
@@ -20,7 +20,7 @@ import {
20
20
  realpathSync,
21
21
  statSync
22
22
  } from "fs";
23
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path";
23
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path";
24
24
  import { homedir, platform } from "os";
25
25
 
26
26
  // src/tools/glob-regex.ts
@@ -28,8 +28,8 @@ import { posix, win32 } from "path";
28
28
  import picomatch from "picomatch";
29
29
  import { convertPathToPattern } from "tinyglobby";
30
30
  function globToRegex(pattern) {
31
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*|\*/g, ".*");
32
- return new RegExp("^" + escaped + "$");
31
+ const source = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*|\*|\?/g, (wildcard) => wildcard === "?" ? "." : ".*");
32
+ return new RegExp("^" + source + "$");
33
33
  }
34
34
  var log = createDebugLogger("tools:glob");
35
35
  var MAX_GLOB_PATTERN_LENGTH = 1e4;
@@ -332,9 +332,11 @@ function unsandboxedRefusalMessage(reason) {
332
332
  ].join(" ");
333
333
  }
334
334
  function sandboxPolicySummary(settings, state) {
335
+ const gitDirectories = settings.filesystem.allowGitWrites ? "writable; hooks, config and pointer files read-only (sandbox.filesystem.allowGitWrites=true)" : "read-only (sandbox.filesystem.allowGitWrites=false)";
335
336
  return {
336
337
  unsandboxedCommands: settings.allowUnsandboxedCommands ? "allowed (sandbox.allowUnsandboxedCommands=true)" : "refused (sandbox.allowUnsandboxedCommands=false)",
337
- autoAllowBash: !settings.autoAllowBashIfSandboxed ? "off \u2014 every Bash call goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=false)" : !state.sandboxActive ? "inert \u2014 no command is sandboxed here, so nothing is auto-allowed (sandbox.autoAllowBashIfSandboxed=true)" : state.adjudicationConfigured ? "inert \u2014 permissions.deny/ask rules are configured, so every Bash call still goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=true)" : "on for genuinely sandboxed commands (sandbox.autoAllowBashIfSandboxed=true)"
338
+ autoAllowBash: !settings.autoAllowBashIfSandboxed ? "off \u2014 every Bash call goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=false)" : !state.sandboxActive ? "inert \u2014 no command is sandboxed here, so nothing is auto-allowed (sandbox.autoAllowBashIfSandboxed=true)" : state.adjudicationConfigured ? "inert \u2014 permissions.deny/ask rules are configured, so every Bash call still goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=true)" : "on for genuinely sandboxed commands (sandbox.autoAllowBashIfSandboxed=true)",
339
+ gitDirectories
338
340
  };
339
341
  }
340
342
  var MAX_CONTROL_FILE_BYTES = 4096;
@@ -352,7 +354,7 @@ function entryKind(path) {
352
354
  }
353
355
  }
354
356
  var realSandboxHost = {
355
- path: { resolve, join, relative, isAbsolute, sep, dirname },
357
+ path: { resolve, join, relative, isAbsolute, sep, dirname, basename },
356
358
  exists: existsSync,
357
359
  // A path that vanished between the probe and the spawn is absent as far as the
358
360
  // mount list is concerned. `statSync` throws for it, and a throw here would
@@ -448,31 +450,76 @@ function canonicalPath(path, host) {
448
450
  function insideWorkspace(candidate, workspaceRoot, host) {
449
451
  return !isOutside(canonicalPath(workspaceRoot, host), canonicalPath(candidate, host), host.path);
450
452
  }
453
+ function workspaceComponents(path, workspaceRoot, host) {
454
+ const root = host.path.resolve(workspaceRoot);
455
+ const resolved = host.path.resolve(path);
456
+ const rel = host.path.relative(root, resolved);
457
+ if (isOutside(root, resolved, host.path) || !rel || rel === ".." || rel.startsWith(".." + host.path.sep))
458
+ return [];
459
+ const segments = rel.split(host.path.sep).filter(Boolean);
460
+ const components = [];
461
+ let current = root;
462
+ for (const segment of segments) {
463
+ current = host.path.join(current, segment);
464
+ components.push(current);
465
+ }
466
+ return components;
467
+ }
468
+ function looksLikeGitDir(dir, host) {
469
+ if (!host.isDirectory(dir)) return false;
470
+ const head = host.path.join(dir, "HEAD");
471
+ if (!host.exists(head)) return false;
472
+ const objects = host.path.join(dir, "objects");
473
+ if (host.isDirectory(objects)) return true;
474
+ const commondir = host.path.join(dir, "commondir");
475
+ if (host.exists(commondir)) return true;
476
+ return false;
477
+ }
451
478
  function workspaceGitDirs(workspaceRoot, host) {
452
479
  const root = host.path.resolve(workspaceRoot);
480
+ const canonicalRoot = canonicalPath(root, host);
453
481
  const found = [];
482
+ const pointerPaths = [];
483
+ const refusals = [];
484
+ const dotGit = host.path.join(root, ".git");
454
485
  const add = (candidate, workTree) => {
455
486
  const dir = host.path.resolve(candidate);
456
487
  if (found.some((entry) => entry.dir === dir)) return;
457
488
  if (!host.isDirectory(dir) || !insideWorkspace(dir, root, host)) return;
489
+ if (dir !== dotGit && !looksLikeGitDir(dir, host)) return;
490
+ if (canonicalPath(dir, host) === canonicalRoot) {
491
+ if (!refusals.some((r) => r.path === dir)) {
492
+ refusals.push({ path: dir, reason: "workspace-root-is-git-dir" });
493
+ }
494
+ return;
495
+ }
458
496
  found.push({ dir, workTree: host.path.resolve(workTree) });
459
497
  };
460
- const dotGit = host.path.join(root, ".git");
461
- if (!host.exists(dotGit)) return found;
498
+ if (!host.exists(dotGit)) return { gitDirs: found, pointerPaths, refusals };
462
499
  if (host.isDirectory(dotGit)) {
463
500
  add(dotGit, root);
464
501
  } else {
465
502
  const target = /^gitdir:\s*(.+)$/m.exec(host.readFile(dotGit) ?? "")?.[1]?.trim();
466
- if (!target) return found;
467
- add(host.path.isAbsolute(target) ? target : host.path.join(root, target), root);
503
+ if (!target) return { gitDirs: found, pointerPaths, refusals };
504
+ const resolvedTarget = host.path.isAbsolute(target) ? target : host.path.join(root, target);
505
+ pointerPaths.push(resolvedTarget);
506
+ add(resolvedTarget, root);
468
507
  }
469
508
  for (let index = 0; index < found.length; index++) {
470
509
  const { dir, workTree } = found[index];
471
510
  const common = host.readFile(host.path.join(dir, "commondir"))?.trim();
472
- if (common) add(host.path.isAbsolute(common) ? common : host.path.join(dir, common), workTree);
511
+ if (common) {
512
+ const resolvedCommon = host.path.isAbsolute(common) ? common : host.path.join(dir, common);
513
+ pointerPaths.push(resolvedCommon);
514
+ add(resolvedCommon, workTree);
515
+ }
473
516
  for (const name of host.readDir(host.path.join(dir, "worktrees"))) {
474
517
  const linked = host.path.join(dir, "worktrees", name);
475
518
  const workTreeGitdir = host.readFile(host.path.join(linked, "gitdir"))?.trim();
519
+ if (workTreeGitdir) {
520
+ const resolvedGitdir = host.path.isAbsolute(workTreeGitdir) ? workTreeGitdir : host.path.join(linked, workTreeGitdir);
521
+ pointerPaths.push(resolvedGitdir);
522
+ }
476
523
  add(linked, workTreeGitdir ? host.path.dirname(workTreeGitdir) : root);
477
524
  }
478
525
  const addModules = (modulesRoot, prefix, depth) => {
@@ -494,9 +541,11 @@ function workspaceGitDirs(workspaceRoot, host) {
494
541
  if (host.isDirectory(pointer)) continue;
495
542
  const target = /^gitdir:\s*(.+)$/m.exec(host.readFile(pointer) ?? "")?.[1]?.trim();
496
543
  if (!target) continue;
497
- add(host.path.isAbsolute(target) ? target : host.path.join(workTree, target), workTree);
544
+ const resolvedTarget = host.path.isAbsolute(target) ? target : host.path.join(workTree, target);
545
+ pointerPaths.push(resolvedTarget);
546
+ add(resolvedTarget, workTree);
498
547
  }
499
- return found;
548
+ return { gitDirs: found, pointerPaths, refusals };
500
549
  }
501
550
  function parseConfigValue(raw) {
502
551
  const text = raw.trim();
@@ -569,12 +618,23 @@ function describeControlPathRefusal(refusal) {
569
618
  if (refusal.reason === "oversized-git-config") {
570
619
  return `Refused to run this command in the sandbox: ${refusal.path} is larger than the ${MAX_GIT_CONFIG_BYTES / 1024 / 1024} MiB read limit for a git config file, so the core.hooksPath it may declare cannot be found and the hooks directory it names cannot be made read-only. Trim the config, or run this command without the sandbox.`;
571
620
  }
621
+ if (refusal.reason === "workspace-root-is-git-dir") {
622
+ return `Refused to run this command in the sandbox: ${refusal.path} is the workspace root and a git directory, so the sandbox cannot make it read-only without making the whole workspace read-only. Run this command without the sandbox.`;
623
+ }
572
624
  return `Refused to run this command in the sandbox: ${refusal.path} is a symlink, and the sandbox cannot protect a symlinked control path. The link itself sits in the writable workspace, so a command could remove it, put a directory or file in its place, and write the settings, hooks or repository config the host then reads through it. Replace the symlink with the file or directory it names, or run this command without the sandbox.`;
573
625
  }
574
- function protectedWorkspacePaths(workspaceRoot, host = realSandboxHost) {
626
+ function protectedWorkspacePaths(workspaceRoot, host = realSandboxHost, options = {}) {
575
627
  const root = host.path.resolve(workspaceRoot);
576
- const result = { pinnedDirectories: [], mounts: [], refusals: [] };
628
+ const canonicalRoot = canonicalPath(root, host);
629
+ const allowGitWrites = options.allowGitWrites ?? false;
630
+ const result = {
631
+ pinnedDirectories: [],
632
+ mounts: [],
633
+ refusals: [],
634
+ gitDirControlMounts: []
635
+ };
577
636
  const seen = /* @__PURE__ */ new Set();
637
+ const safeDenyDirs = (options.denyDirectories ?? []).filter((d) => host.isDirectory(d)).map((d) => canonicalPath(d, host));
578
638
  const target = (path, kind) => {
579
639
  const resolved = host.path.resolve(path);
580
640
  if (seen.has(resolved)) return null;
@@ -584,7 +644,12 @@ function protectedWorkspacePaths(workspaceRoot, host = realSandboxHost) {
584
644
  };
585
645
  const first = host.entryKind(resolved);
586
646
  if (first === "symlink") {
587
- if (!result.refusals.some((entry) => entry.path === resolved)) {
647
+ const parentCanonical = canonicalPath(host.path.dirname(resolved), host);
648
+ const symlinkLivePath = host.path.join(parentCanonical, host.path.basename(resolved));
649
+ const isSafe = safeDenyDirs.some(
650
+ (dir) => !isOutside(dir, symlinkLivePath, host.path) && dir !== symlinkLivePath
651
+ );
652
+ if (!isSafe && !result.refusals.some((entry) => entry.path === resolved)) {
588
653
  result.refusals.push({ path: resolved, reason: "symlinked-control-path" });
589
654
  }
590
655
  return null;
@@ -609,12 +674,34 @@ function protectedWorkspacePaths(workspaceRoot, host = realSandboxHost) {
609
674
  const mount = target(path, "hidden-file");
610
675
  if (mount) result.mounts.push(mount);
611
676
  };
677
+ const { gitDirs, pointerPaths, refusals: gitDirRefusals } = workspaceGitDirs(root, host);
678
+ result.refusals.push(...gitDirRefusals);
679
+ const boundCanonical = [];
680
+ const gitDirMounts = /* @__PURE__ */ new Set();
681
+ if (!allowGitWrites) {
682
+ const canonicalDirs = [...new Set(gitDirs.map((gitDir) => canonicalPath(gitDir.dir, host)))];
683
+ canonicalDirs.sort(
684
+ (left, right) => left.split(host.path.sep).length - right.split(host.path.sep).length
685
+ );
686
+ for (const canonicalDir of canonicalDirs) {
687
+ if (boundCanonical.some((outer) => !isOutside(outer, canonicalDir, host.path))) continue;
688
+ if (!host.exists(canonicalDir)) continue;
689
+ boundCanonical.push(canonicalDir);
690
+ const rel = host.path.relative(canonicalRoot, canonicalDir);
691
+ const bindPath = rel ? host.path.resolve(root, rel) : root;
692
+ const mount = { path: bindPath, kind: "directory", present: true };
693
+ gitDirMounts.add(mount);
694
+ result.mounts.push(mount);
695
+ }
696
+ }
612
697
  addDirectory(host.path.join(root, ".book"));
613
698
  addMaskedFile(host.path.join(root, ".book", "settings.local.json"));
614
699
  addFile(host.path.join(root, ".bookrc.json"));
615
700
  const dotGit = host.path.join(root, ".git");
616
701
  if (host.entryKind(dotGit) === "directory") {
617
- result.pinnedDirectories.push(dotGit);
702
+ if (allowGitWrites) {
703
+ result.pinnedDirectories.push(dotGit);
704
+ }
618
705
  } else {
619
706
  addFile(dotGit);
620
707
  }
@@ -641,7 +728,7 @@ function protectedWorkspacePaths(workspaceRoot, host = realSandboxHost) {
641
728
  readConfig(target2, workTree, depth + 1);
642
729
  }
643
730
  };
644
- for (const gitDir of workspaceGitDirs(root, host)) {
731
+ for (const gitDir of gitDirs) {
645
732
  addDirectory(host.path.join(gitDir.dir, "hooks"));
646
733
  for (const name of ["config", "config.worktree", "commondir", "gitdir"]) {
647
734
  addFile(host.path.join(gitDir.dir, name));
@@ -652,6 +739,78 @@ function protectedWorkspacePaths(workspaceRoot, host = realSandboxHost) {
652
739
  readConfig(host.path.join(gitDir.dir, name), gitDir.workTree, 0);
653
740
  }
654
741
  }
742
+ const presentReadOnlyDirs = result.mounts.filter((mount) => mount.kind === "directory" && mount.present).map((mount) => canonicalPath(mount.path, host));
743
+ const filteredMounts = [];
744
+ const gitDirControlMounts = [];
745
+ for (const mount of result.mounts) {
746
+ if (mount.kind === "hidden-file") {
747
+ filteredMounts.push(mount);
748
+ continue;
749
+ }
750
+ const mountCanonical = canonicalPath(mount.path, host);
751
+ const strictlyInsidePresentReadOnlyDir = presentReadOnlyDirs.some(
752
+ (dir) => !isOutside(dir, mountCanonical, host.path) && dir !== mountCanonical
753
+ );
754
+ if (strictlyInsidePresentReadOnlyDir) {
755
+ if (!allowGitWrites) {
756
+ const strictlyInsideBoundGitDir = boundCanonical.some(
757
+ (dir) => !isOutside(dir, mountCanonical, host.path) && dir !== mountCanonical
758
+ );
759
+ if (strictlyInsideBoundGitDir) {
760
+ gitDirControlMounts.push(mount);
761
+ }
762
+ }
763
+ } else {
764
+ filteredMounts.push(mount);
765
+ }
766
+ }
767
+ result.mounts = filteredMounts;
768
+ result.gitDirControlMounts = gitDirControlMounts;
769
+ if (!allowGitWrites) {
770
+ for (const mount of result.mounts) {
771
+ const mountCanonical = canonicalPath(mount.path, host);
772
+ if (isOutside(canonicalRoot, mountCanonical, host.path) || mountCanonical === canonicalRoot) {
773
+ continue;
774
+ }
775
+ const isInsidePresentReadOnly = presentReadOnlyDirs.some(
776
+ (dir) => !isOutside(dir, mountCanonical, host.path) && dir !== mountCanonical
777
+ );
778
+ if (isInsidePresentReadOnly) continue;
779
+ const ancestors = workspaceComponents(mount.path, root, host).slice(0, -1);
780
+ for (const anc of ancestors) {
781
+ if (!host.isDirectory(anc)) continue;
782
+ const ancCanonical = canonicalPath(anc, host);
783
+ if (presentReadOnlyDirs.some((dir) => !isOutside(dir, ancCanonical, host.path))) continue;
784
+ if (!result.pinnedDirectories.includes(anc)) {
785
+ result.pinnedDirectories.push(anc);
786
+ }
787
+ }
788
+ }
789
+ }
790
+ const safeDirs = [...presentReadOnlyDirs, ...safeDenyDirs];
791
+ const candidatesToCheck = /* @__PURE__ */ new Set([
792
+ ...gitDirs.map((g) => g.dir),
793
+ ...pointerPaths,
794
+ ...result.mounts.map((m) => m.path),
795
+ ...result.gitDirControlMounts.map((m) => m.path)
796
+ ]);
797
+ const checkedComponents = /* @__PURE__ */ new Set();
798
+ for (const cand of candidatesToCheck) {
799
+ for (const comp of workspaceComponents(cand, root, host)) {
800
+ if (checkedComponents.has(comp)) continue;
801
+ checkedComponents.add(comp);
802
+ if (host.entryKind(comp) === "symlink") {
803
+ const parentCanonical = canonicalPath(host.path.dirname(comp), host);
804
+ const symlinkLivePath = host.path.join(parentCanonical, host.path.basename(comp));
805
+ const isSafe = safeDirs.some(
806
+ (dir) => !isOutside(dir, symlinkLivePath, host.path) && dir !== symlinkLivePath
807
+ );
808
+ if (!isSafe && !result.refusals.some((r) => r.path === comp)) {
809
+ result.refusals.push({ path: comp, reason: "symlinked-control-path" });
810
+ }
811
+ }
812
+ }
813
+ }
655
814
  return result;
656
815
  }
657
816
  function mountProtected(args, mount, host) {
@@ -670,7 +829,7 @@ function mountProtected(args, mount, host) {
670
829
  }
671
830
  args.push("--tmpfs", target, "--remount-ro", target);
672
831
  }
673
- function buildSandboxExecution(bwrapPath, command, workspaceRoot, settings, host = realSandboxHost) {
832
+ function buildSandboxExecution(bwrapPath, command, workspaceRoot, settings, host = realSandboxHost, protectedPaths) {
674
833
  const args = [];
675
834
  args.push("--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent");
676
835
  args.push("--proc", "/proc");
@@ -680,26 +839,46 @@ function buildSandboxExecution(bwrapPath, command, workspaceRoot, settings, host
680
839
  }
681
840
  args.push("--tmpfs", "/tmp");
682
841
  bindIfPresent(args, "--bind", workspaceRoot, host);
683
- const protectedPaths = protectedWorkspacePaths(workspaceRoot, host);
842
+ const paths = protectedPaths ?? protectedWorkspacePaths(workspaceRoot, host, {
843
+ allowGitWrites: settings.filesystem.allowGitWrites,
844
+ denyDirectories: [...settings.filesystem.denyWrite, ...settings.filesystem.denyRead].map(
845
+ (p) => expandPath(p, host)
846
+ )
847
+ });
684
848
  const isProtectedArea = (path) => {
685
849
  const canonical = canonicalPath(expandPath(path, host), host);
686
- return protectedPaths.mounts.some(
850
+ return paths.mounts.some(
687
851
  (mount) => !isOutside(canonicalPath(mount.path, host), canonical, host.path)
688
852
  );
689
853
  };
690
- for (const dir of protectedPaths.pinnedDirectories) {
854
+ for (const dir of paths.pinnedDirectories) {
691
855
  args.push("--bind", host.path.resolve(dir), host.path.resolve(dir));
692
856
  }
693
857
  for (const path of settings.filesystem.allowWrite) {
694
858
  if (isProtectedArea(path)) continue;
695
859
  bindIfPresent(args, "--bind", path, host);
696
860
  }
697
- for (const mount of protectedPaths.mounts) mountProtected(args, mount, host);
861
+ for (const mount of paths.mounts) mountProtected(args, mount, host);
862
+ const deferredOptInCanonicals = [];
698
863
  for (const path of settings.filesystem.allowWrite) {
699
864
  if (!isProtectedArea(path)) continue;
700
- bindIfPresent(args, "--bind", path, host);
865
+ const target = expandPath(path, host);
866
+ if (host.exists(target)) {
867
+ args.push("--bind", target, target);
868
+ deferredOptInCanonicals.push(canonicalPath(target, host));
869
+ }
870
+ }
871
+ for (const mount of paths.gitDirControlMounts) {
872
+ const mountCanonical = canonicalPath(mount.path, host);
873
+ const strictlyBelowDeferredOptIn = deferredOptInCanonicals.some(
874
+ (optIn) => !isOutside(optIn, mountCanonical, host.path) && optIn !== mountCanonical
875
+ );
876
+ const explicitlyOptedIn = deferredOptInCanonicals.includes(mountCanonical);
877
+ if (strictlyBelowDeferredOptIn && !explicitlyOptedIn) {
878
+ mountProtected(args, mount, host);
879
+ }
701
880
  }
702
- for (const mount of protectedPaths.mounts) {
881
+ for (const mount of paths.mounts) {
703
882
  if (mount.kind === "hidden-file") mountProtected(args, mount, host);
704
883
  }
705
884
  for (const path of settings.filesystem.denyWrite) bindIfPresent(args, "--ro-bind", path, host);
@@ -730,25 +909,62 @@ function decideSandboxExecution(ctx, command, workdir) {
730
909
  } catch (error) {
731
910
  return { sandboxed: false, error: error instanceof Error ? error.message : String(error) };
732
911
  }
912
+ let protectedPaths;
733
913
  if (sandbox) {
734
- const refusal = protectedWorkspacePaths(ctx.workspaceRoot).refusals[0];
914
+ const denyDirectories = [
915
+ ...ctx.sandbox.filesystem.denyWrite,
916
+ ...ctx.sandbox.filesystem.denyRead
917
+ ].map((p) => expandPath(p, realSandboxHost));
918
+ protectedPaths = protectedWorkspacePaths(ctx.workspaceRoot, realSandboxHost, {
919
+ allowGitWrites: ctx.sandbox.filesystem.allowGitWrites,
920
+ denyDirectories
921
+ });
922
+ const refusal = protectedPaths.refusals[0];
735
923
  if (refusal) return { sandboxed: false, error: describeControlPathRefusal(refusal) };
736
924
  }
737
- const exec = sandbox?.wrap(command, ctx.workspaceRoot);
738
- if (exec) return { sandboxed: true, exec };
925
+ const exec = sandbox?.wrap(command, ctx.workspaceRoot, protectedPaths);
926
+ if (exec) {
927
+ return {
928
+ sandboxed: true,
929
+ exec,
930
+ gitDirReadOnly: !ctx.sandbox.filesystem.allowGitWrites
931
+ };
932
+ }
739
933
  if (ctx.sandbox.failIfUnavailable) {
740
934
  return { sandboxed: false, error: "Sandbox unavailable and failIfUnavailable is set" };
741
935
  }
742
936
  return unsandboxed("unavailable");
743
937
  }
744
- function withGitConfigReadOnlyNotice(text, output, sandboxed, command = "") {
745
- if (!sandboxed || !/could not (?:write|lock) config file/.test(output)) return text;
746
- if (writesAGlobalConfig(command)) return text;
938
+ function withGitConfigReadOnlyNotice(text, output, sandboxed, command = "", gitDirReadOnly) {
939
+ if (!sandboxed) return text;
940
+ const isConfigFileFailure = /could not (?:write|lock) config file/.test(output);
941
+ if (isConfigFileFailure) {
942
+ if (writesAGlobalConfig(command)) return text;
943
+ return gitDirReadOnly ? withReadOnlyGitDirNote(text) : withLegacyGitConfigNote(text);
944
+ }
945
+ if (gitDirReadOnly) {
946
+ const linesWithRo = output.split("\n").filter((line) => /\bRead-only file system\b/i.test(line));
947
+ const runsGit = /(?:^|[;&|(\s])git\b/.test(command);
948
+ const hasDotGitSegment = linesWithRo.some(
949
+ (line) => /(?:^|[/\s'"`])\.git(?=[/\\s'":`]|[\r\n]|$)/.test(line)
950
+ );
951
+ const roFailure = linesWithRo.length > 0 && (runsGit || hasDotGitSegment);
952
+ if (roFailure) {
953
+ return withReadOnlyGitDirNote(text);
954
+ }
955
+ }
956
+ return text;
957
+ }
958
+ function withLegacyGitConfigNote(text) {
747
959
  return `${text}
748
960
  Note: .git/config is read-only inside the sandbox, so git cannot update repository configuration there. Run this command outside the sandbox.`;
749
961
  }
962
+ function withReadOnlyGitDirNote(text) {
963
+ return `${text}
964
+ Note: the repository's git directory is read-only inside the sandbox (sandbox.filesystem.allowGitWrites is off), so git cannot write to it here. Do not retry the command or change sandbox settings yourself; tell the user it has to run outside the sandbox: they can run it, or allow it in their own ~/.book/settings.json, where sandbox.excludedCommands runs matching commands unsandboxed while sandbox.allowUnsandboxedCommands is true and sandbox.filesystem.allowGitWrites lets sandboxed git write again; workspace settings cannot enable either.`;
965
+ }
750
966
  function writesAGlobalConfig(command) {
751
- return /^\s*(?:\S+\s+)*git\s+config\b[^|;&]*\s--(?:global|system)\b/.test(command);
967
+ return /(?:^|[;&|(\s])git\s+[^|;&]*\bconfig\b[^|;&]*\s--(?:global|system)\b/.test(command);
752
968
  }
753
969
  function createSandbox(settings) {
754
970
  if (!settings.enabled) return null;
@@ -786,13 +1002,21 @@ function createSandbox(settings) {
786
1002
  );
787
1003
  }
788
1004
  return {
789
- wrap(command, workspaceRoot) {
790
- return buildSandboxExecution(bwrap, command, workspaceRoot, settings);
1005
+ wrap(command, workspaceRoot, protectedPaths) {
1006
+ return buildSandboxExecution(
1007
+ bwrap,
1008
+ command,
1009
+ workspaceRoot,
1010
+ settings,
1011
+ realSandboxHost,
1012
+ protectedPaths
1013
+ );
791
1014
  },
792
1015
  describe() {
793
1016
  return [
794
1017
  `bubblewrap (${bwrap})`,
795
1018
  hasDomainPolicy(settings) ? "network disabled" : "host network shared",
1019
+ settings.filesystem.allowGitWrites ? "git directories writable (hooks, config and pointer files read-only)" : "git directories read-only",
796
1020
  `${settings.filesystem.allowWrite.length} extra writable path(s)`,
797
1021
  `${settings.filesystem.denyWrite.length} read-only path(s)`,
798
1022
  `${settings.filesystem.denyRead.length} masked path(s)`,
@@ -823,4 +1047,4 @@ export {
823
1047
  withGitConfigReadOnlyNotice,
824
1048
  createSandbox
825
1049
  };
826
- //# sourceMappingURL=chunk-JTPOI3KU.js.map
1050
+ //# sourceMappingURL=chunk-XJRJVXJR.js.map