@letrquan/book 0.2.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 (103) hide show
  1. package/CHANGELOG.md +2504 -0
  2. package/LICENSE +123 -0
  3. package/README.md +1383 -0
  4. package/dist/app-MOXXWEOY.js +18945 -0
  5. package/dist/app-MOXXWEOY.js.map +1 -0
  6. package/dist/chunk-24AXE6SP.js +12 -0
  7. package/dist/chunk-24AXE6SP.js.map +1 -0
  8. package/dist/chunk-3U5IBM24.js +213 -0
  9. package/dist/chunk-3U5IBM24.js.map +1 -0
  10. package/dist/chunk-574C6MQR.js +743 -0
  11. package/dist/chunk-574C6MQR.js.map +1 -0
  12. package/dist/chunk-5GDZ22YP.js +449 -0
  13. package/dist/chunk-5GDZ22YP.js.map +1 -0
  14. package/dist/chunk-5KLVH3PY.js +167 -0
  15. package/dist/chunk-5KLVH3PY.js.map +1 -0
  16. package/dist/chunk-5RDRAO4B.js +92 -0
  17. package/dist/chunk-5RDRAO4B.js.map +1 -0
  18. package/dist/chunk-7N4J557C.js +10 -0
  19. package/dist/chunk-7N4J557C.js.map +1 -0
  20. package/dist/chunk-A7JLBK2Y.js +3506 -0
  21. package/dist/chunk-A7JLBK2Y.js.map +1 -0
  22. package/dist/chunk-ANSJPYGI.js +84 -0
  23. package/dist/chunk-ANSJPYGI.js.map +1 -0
  24. package/dist/chunk-BTZZUCLI.js +264 -0
  25. package/dist/chunk-BTZZUCLI.js.map +1 -0
  26. package/dist/chunk-C7D4NJ2D.js +217 -0
  27. package/dist/chunk-C7D4NJ2D.js.map +1 -0
  28. package/dist/chunk-CAGOB2N4.js +208 -0
  29. package/dist/chunk-CAGOB2N4.js.map +1 -0
  30. package/dist/chunk-DGMQEI6Y.js +98 -0
  31. package/dist/chunk-DGMQEI6Y.js.map +1 -0
  32. package/dist/chunk-E75IPST5.js +289 -0
  33. package/dist/chunk-E75IPST5.js.map +1 -0
  34. package/dist/chunk-EYGD6X2T.js +180 -0
  35. package/dist/chunk-EYGD6X2T.js.map +1 -0
  36. package/dist/chunk-GTVB4HEM.js +765 -0
  37. package/dist/chunk-GTVB4HEM.js.map +1 -0
  38. package/dist/chunk-J7MIZ5CA.js +1907 -0
  39. package/dist/chunk-J7MIZ5CA.js.map +1 -0
  40. package/dist/chunk-KWHQ2XXL.js +626 -0
  41. package/dist/chunk-KWHQ2XXL.js.map +1 -0
  42. package/dist/chunk-L6LK2QIQ.js +657 -0
  43. package/dist/chunk-L6LK2QIQ.js.map +1 -0
  44. package/dist/chunk-MAQXCUR4.js +1007 -0
  45. package/dist/chunk-MAQXCUR4.js.map +1 -0
  46. package/dist/chunk-S4XL7HOM.js +402 -0
  47. package/dist/chunk-S4XL7HOM.js.map +1 -0
  48. package/dist/chunk-URLQAXTR.js +60 -0
  49. package/dist/chunk-URLQAXTR.js.map +1 -0
  50. package/dist/chunk-V4BOYR52.js +3504 -0
  51. package/dist/chunk-V4BOYR52.js.map +1 -0
  52. package/dist/chunk-VZQXX3WW.js +210 -0
  53. package/dist/chunk-VZQXX3WW.js.map +1 -0
  54. package/dist/chunk-WKBO5R4O.js +34 -0
  55. package/dist/chunk-WKBO5R4O.js.map +1 -0
  56. package/dist/chunk-XIFFTATU.js +15753 -0
  57. package/dist/chunk-XIFFTATU.js.map +1 -0
  58. package/dist/command-approvals-6Y57VCWZ.js +28 -0
  59. package/dist/command-approvals-6Y57VCWZ.js.map +1 -0
  60. package/dist/context-ZC5IFEAX.js +25 -0
  61. package/dist/context-ZC5IFEAX.js.map +1 -0
  62. package/dist/hook-approvals-CTUFKEJT.js +22 -0
  63. package/dist/hook-approvals-CTUFKEJT.js.map +1 -0
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +2582 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/interactive-assets-CCAAWVKH.js +31 -0
  68. package/dist/interactive-assets-CCAAWVKH.js.map +1 -0
  69. package/dist/job-runner.d.ts +2 -0
  70. package/dist/job-runner.js +235 -0
  71. package/dist/job-runner.js.map +1 -0
  72. package/dist/loader-4IDS2RIE.js +15 -0
  73. package/dist/loader-4IDS2RIE.js.map +1 -0
  74. package/dist/mcp-approvals-WNPH65BK.js +18 -0
  75. package/dist/mcp-approvals-WNPH65BK.js.map +1 -0
  76. package/dist/mcp-config-YIJIES2C.js +16 -0
  77. package/dist/mcp-config-YIJIES2C.js.map +1 -0
  78. package/dist/model-window-store-YFVNXCIP.js +27 -0
  79. package/dist/model-window-store-YFVNXCIP.js.map +1 -0
  80. package/dist/openai-compatible-RHZUBHV6.js +10 -0
  81. package/dist/openai-compatible-RHZUBHV6.js.map +1 -0
  82. package/dist/permission-approvals-76PVREFD.js +14 -0
  83. package/dist/permission-approvals-76PVREFD.js.map +1 -0
  84. package/dist/permissions-CHCKTFQQ.js +28 -0
  85. package/dist/permissions-CHCKTFQQ.js.map +1 -0
  86. package/dist/sandbox-CNVAQDA7.js +23 -0
  87. package/dist/sandbox-CNVAQDA7.js.map +1 -0
  88. package/dist/scrollback-LUKLIBVI.js +136 -0
  89. package/dist/scrollback-LUKLIBVI.js.map +1 -0
  90. package/dist/sdk.d.ts +4574 -0
  91. package/dist/sdk.js +665 -0
  92. package/dist/sdk.js.map +1 -0
  93. package/dist/settings-loader-M2QP5TX2.js +21 -0
  94. package/dist/settings-loader-M2QP5TX2.js.map +1 -0
  95. package/dist/settings-removed-JWWPT4WA.js +19 -0
  96. package/dist/settings-removed-JWWPT4WA.js.map +1 -0
  97. package/dist/shell-selection-V6QAYXPX.js +17 -0
  98. package/dist/shell-selection-V6QAYXPX.js.map +1 -0
  99. package/package.json +143 -0
  100. package/patches/ink+6.8.0.patch +13 -0
  101. package/scripts/apply-ink-patch.mjs +29 -0
  102. package/scripts/ink-patch.mjs +59 -0
  103. package/scripts/verify-ink-patch.mjs +11 -0
@@ -0,0 +1,289 @@
1
+ import {
2
+ applyShellInjections,
3
+ assertProjectCommandApproved,
4
+ extractShellInjections
5
+ } from "./chunk-5KLVH3PY.js";
6
+ import {
7
+ resolveBookHome
8
+ } from "./chunk-24AXE6SP.js";
9
+
10
+ // src/commands/loader.ts
11
+ import { readFileSync, existsSync, readdirSync, statSync } from "fs";
12
+ import { join, extname } from "path";
13
+
14
+ // src/frontmatter.ts
15
+ function parseFrontmatter(raw) {
16
+ const normalized = raw.replace(/\r\n?/g, "\n");
17
+ const lines = normalized.split("\n");
18
+ if (lines[0]?.trim() !== "---") {
19
+ return { body: raw, frontmatter: {} };
20
+ }
21
+ const endIdx = lines.indexOf("---", 1);
22
+ if (endIdx === -1) {
23
+ return { body: raw, frontmatter: {} };
24
+ }
25
+ const fmLines = lines.slice(1, endIdx);
26
+ const body = lines.slice(endIdx + 1).join("\n").trim();
27
+ const frontmatter = {};
28
+ let currentKey = null;
29
+ let currentArray = [];
30
+ for (const line of fmLines) {
31
+ const trimmed = line.trim();
32
+ if (!trimmed) continue;
33
+ const arrayMatch = trimmed.match(/^-\s+(.+)$/);
34
+ if (arrayMatch && currentKey) {
35
+ currentArray.push(arrayMatch[1]);
36
+ continue;
37
+ }
38
+ if (currentKey) {
39
+ if (currentArray.length > 0) {
40
+ frontmatter[currentKey] = currentArray;
41
+ }
42
+ currentKey = null;
43
+ currentArray = [];
44
+ }
45
+ const kvMatch = trimmed.match(/^([a-z][a-zA-Z0-9_-]*)\s*:\s*(.*)$/);
46
+ if (kvMatch) {
47
+ const key = kvMatch[1];
48
+ const value = kvMatch[2].trim();
49
+ if (value === "") {
50
+ currentKey = key;
51
+ currentArray = [];
52
+ } else {
53
+ const unquoted = value.replace(/^["'](.*)["']$/, "$1");
54
+ if ((key === "tools" || key === "allowed-tools") && unquoted.startsWith("[") && unquoted.endsWith("]")) {
55
+ frontmatter[key] = unquoted.slice(1, -1).split(",").map((item) => item.trim().replace(/^["'](.*)["']$/, "$1")).filter(Boolean);
56
+ } else {
57
+ frontmatter[key] = unquoted;
58
+ }
59
+ }
60
+ }
61
+ }
62
+ if (currentKey && currentArray.length > 0) {
63
+ frontmatter[currentKey] = currentArray;
64
+ }
65
+ return { body, frontmatter };
66
+ }
67
+
68
+ // src/commands/resolve.ts
69
+ import { exec } from "child_process";
70
+ function parseSlashInput(value) {
71
+ if (!value.startsWith("/")) return null;
72
+ const body = value.slice(1);
73
+ const separator = body.search(/\s/);
74
+ return separator === -1 ? { name: body, rawArguments: "" } : { name: body.slice(0, separator), rawArguments: body.slice(separator + 1).trim() };
75
+ }
76
+ function parseArgs(raw) {
77
+ const trimmed = raw.trim();
78
+ if (!trimmed) return [];
79
+ const argv = [];
80
+ let current = "";
81
+ let inDouble = false;
82
+ let inSingle = false;
83
+ for (let i = 0; i < trimmed.length; i++) {
84
+ const ch = trimmed[i];
85
+ if (inDouble) {
86
+ if (ch === '"') {
87
+ inDouble = false;
88
+ } else {
89
+ current += ch;
90
+ }
91
+ } else if (inSingle) {
92
+ if (ch === "'") {
93
+ inSingle = false;
94
+ } else {
95
+ current += ch;
96
+ }
97
+ } else {
98
+ if (ch === '"') {
99
+ inDouble = true;
100
+ } else if (ch === "'") {
101
+ inSingle = true;
102
+ } else if (ch === " " || ch === " ") {
103
+ if (current.length > 0) {
104
+ argv.push(current);
105
+ current = "";
106
+ }
107
+ } else {
108
+ current += ch;
109
+ }
110
+ }
111
+ }
112
+ if (current.length > 0) {
113
+ argv.push(current);
114
+ }
115
+ return argv;
116
+ }
117
+ function resolveVariables(body, argv, namedArgs, context) {
118
+ let resolved = body;
119
+ const allArgs = argv.join(" ");
120
+ resolved = resolved.replace(/\$ARGUMENTS|\$\*/g, allArgs);
121
+ for (let i = 1; i <= 9; i++) {
122
+ const val = i <= argv.length ? argv[i - 1] : "";
123
+ resolved = resolved.replace(new RegExp(`\\$${i}`, "g"), val);
124
+ }
125
+ for (let i = 0; i < namedArgs.length; i++) {
126
+ const name = namedArgs[i];
127
+ const val = i < argv.length ? argv[i] : "";
128
+ resolved = resolved.replace(new RegExp(`\\$${escapeRegex(name)}`, "g"), val);
129
+ }
130
+ if (context?.sessionId) {
131
+ resolved = resolved.replace(/\$\{BOOK_SESSION_ID\}/g, context.sessionId);
132
+ }
133
+ if (context?.workspace) {
134
+ resolved = resolved.replace(/\$\{BOOK_WORKSPACE\}/g, context.workspace);
135
+ }
136
+ if (context?.model) {
137
+ resolved = resolved.replace(/\$\{BOOK_MODEL\}/g, context.model);
138
+ }
139
+ resolved = resolved.replace(/\$\{BOOK_DATE\}/g, (/* @__PURE__ */ new Date()).toISOString().split("T")[0]);
140
+ return resolved;
141
+ }
142
+ async function resolveShellInjection(body, workspace, signal) {
143
+ const errors = [];
144
+ const injections = extractShellInjections(body);
145
+ const outputs = [];
146
+ for (const injection of injections) {
147
+ outputs.push(await executeInjection(injection, workspace, errors, signal));
148
+ }
149
+ return { resolved: applyShellInjections(body, injections, outputs), errors };
150
+ }
151
+ async function resolveCommandBody(command, args, context, signal) {
152
+ assertProjectCommandApproved(command, {
153
+ commands: { projectCommands: context?.projectCommands }
154
+ });
155
+ const argv = parseArgs(args);
156
+ const namedArgs = command.arguments ?? [];
157
+ const { resolved: afterShell, errors } = await resolveShellInjection(
158
+ command.body,
159
+ context?.workspace ?? process.cwd(),
160
+ signal
161
+ );
162
+ const resolved = resolveVariables(afterShell, argv, namedArgs, context);
163
+ return { resolved, shellErrors: errors };
164
+ }
165
+ function commandEnforcementContext(command, resolvedBody) {
166
+ return command.allowedTools || command.model ? {
167
+ command,
168
+ resolvedBody,
169
+ modelOverride: command.model,
170
+ allowedTools: command.allowedTools
171
+ } : void 0;
172
+ }
173
+ function executeInjection(injection, workspace, errors, signal) {
174
+ return new Promise((resolve) => {
175
+ exec(
176
+ injection.command,
177
+ {
178
+ cwd: workspace,
179
+ encoding: "utf8",
180
+ timeout: 5e3,
181
+ maxBuffer: 1024 * 1024,
182
+ signal,
183
+ windowsHide: true
184
+ },
185
+ (error, stdout) => {
186
+ if (!error) {
187
+ resolve(stdout.trim());
188
+ return;
189
+ }
190
+ const message = error.message;
191
+ errors.push(
192
+ injection.kind === "block" ? `Shell block failed: ${message}` : `Shell command '${injection.command}' failed: ${message}`
193
+ );
194
+ resolve(`[shell error: ${message}]`);
195
+ }
196
+ );
197
+ });
198
+ }
199
+ function escapeRegex(s) {
200
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
201
+ }
202
+
203
+ // src/commands/loader.ts
204
+ function loadCommandFile(filePath, source) {
205
+ if (!existsSync(filePath) || extname(filePath) !== ".md") return null;
206
+ const raw = readFileSync(filePath, "utf-8");
207
+ const { body, frontmatter } = parseFrontmatter(raw);
208
+ const name = filePath.split(/[/\\]/).pop().replace(/\.md$/, "");
209
+ return {
210
+ name,
211
+ description: frontmatter.description || name,
212
+ argumentHint: frontmatter["argument-hint"],
213
+ arguments: Array.isArray(frontmatter.arguments) ? frontmatter.arguments : typeof frontmatter.arguments === "string" ? frontmatter.arguments.split(/[,\s]+/).filter(Boolean) : void 0,
214
+ allowedTools: Array.isArray(frontmatter["allowed-tools"]) ? frontmatter["allowed-tools"] : void 0,
215
+ model: frontmatter.model,
216
+ body,
217
+ source
218
+ };
219
+ }
220
+ function discoverCommands(workspace) {
221
+ const userDir = join(resolveBookHome(), "commands");
222
+ const projectDir = join(workspace, ".book", "commands");
223
+ const byName = /* @__PURE__ */ new Map();
224
+ for (const dir of [userDir, projectDir]) {
225
+ if (!existsSync(dir)) continue;
226
+ let entries;
227
+ try {
228
+ entries = readdirSync(dir);
229
+ } catch {
230
+ continue;
231
+ }
232
+ for (const entry of entries) {
233
+ if (!entry.endsWith(".md")) continue;
234
+ const fullPath = join(dir, entry);
235
+ let stat;
236
+ try {
237
+ stat = statSync(fullPath);
238
+ } catch {
239
+ continue;
240
+ }
241
+ if (!stat.isFile()) continue;
242
+ const source = dir === userDir ? "user" : "project";
243
+ const cmd = loadCommandFile(fullPath, source);
244
+ if (cmd) {
245
+ byName.set(cmd.name, cmd);
246
+ }
247
+ }
248
+ }
249
+ return Array.from(byName.values());
250
+ }
251
+ function generateCommandListing(commands, budgetChars = 1024) {
252
+ const visible = commands.filter((c) => c.source !== void 0);
253
+ if (visible.length === 0) return "";
254
+ const lines = [];
255
+ lines.push("## Available slash commands");
256
+ lines.push(
257
+ "The user can invoke these by typing /name. The host expands an invoked command into the conversation before it reaches you. If a message merely mentions a command name, treat it as a reference \u2014 do not attempt to execute the command yourself."
258
+ );
259
+ let remaining = budgetChars - lines.join("\n").length;
260
+ let shown = 0;
261
+ for (const cmd of visible) {
262
+ const hint = cmd.argumentHint ? ` ${cmd.argumentHint}` : "";
263
+ const entry = `- **/${cmd.name}${hint}**: ${cmd.description}`;
264
+ if (entry.length > remaining) {
265
+ const bare = `- **/${cmd.name}**`;
266
+ if (bare.length <= remaining) {
267
+ lines.push(bare);
268
+ shown++;
269
+ remaining -= bare.length + 1;
270
+ }
271
+ break;
272
+ }
273
+ lines.push(entry);
274
+ shown++;
275
+ remaining -= entry.length + 1;
276
+ }
277
+ if (shown < visible.length) lines.push(`- \u2026and ${visible.length - shown} more not shown`);
278
+ return lines.join("\n");
279
+ }
280
+
281
+ export {
282
+ parseFrontmatter,
283
+ parseSlashInput,
284
+ resolveCommandBody,
285
+ commandEnforcementContext,
286
+ discoverCommands,
287
+ generateCommandListing
288
+ };
289
+ //# sourceMappingURL=chunk-E75IPST5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/loader.ts","../src/frontmatter.ts","../src/commands/resolve.ts"],"sourcesContent":["import { readFileSync, existsSync, readdirSync, statSync } from 'fs';\r\nimport { join, extname } from 'path';\r\nimport { parseFrontmatter } from '../frontmatter.js';\r\nimport type { SlashCommand } from '../types/commands.js';\r\nimport { resolveBookHome } from '../book-home.js';\r\n\r\n/**\r\n * Load a single .md command file. Returns null if the file doesn't exist.\r\n */\r\nfunction loadCommandFile(filePath: string, source: 'user' | 'project'): SlashCommand | null {\r\n if (!existsSync(filePath) || extname(filePath) !== '.md') return null;\r\n const raw = readFileSync(filePath, 'utf-8');\r\n const { body, frontmatter } = parseFrontmatter(raw);\r\n const name = filePath.split(/[/\\\\]/).pop()!.replace(/\\.md$/, '');\r\n return {\r\n name,\r\n description: (frontmatter.description as string) || name,\r\n argumentHint: frontmatter['argument-hint'] as string | undefined,\r\n arguments: Array.isArray(frontmatter.arguments)\r\n ? (frontmatter.arguments as string[])\r\n : typeof frontmatter.arguments === 'string'\r\n ? (frontmatter.arguments as string).split(/[,\\s]+/).filter(Boolean)\r\n : undefined,\r\n allowedTools: Array.isArray(frontmatter['allowed-tools'])\r\n ? (frontmatter['allowed-tools'] as string[])\r\n : undefined,\r\n model: frontmatter.model as string | undefined,\r\n body,\r\n source,\r\n };\r\n}\r\n\r\n/**\r\n * Discover all slash commands from the conventional directories.\r\n *\r\n * Search order (project wins on name collision):\r\n * 1. ~/.book/commands/*.md (user)\r\n * 2. .book/commands/*.md (project)\r\n *\r\n * Project commands with the same name as a user command override the user one.\r\n */\r\nexport function discoverCommands(workspace: string): SlashCommand[] {\r\n const userDir = join(resolveBookHome(), 'commands');\r\n const projectDir = join(workspace, '.book', 'commands');\r\n\r\n const byName = new Map<string, SlashCommand>();\r\n\r\n // User commands first (lowest priority).\r\n for (const dir of [userDir, projectDir]) {\r\n if (!existsSync(dir)) continue;\r\n let entries: string[];\r\n try {\r\n entries = readdirSync(dir);\r\n } catch {\r\n continue;\r\n }\r\n for (const entry of entries) {\r\n if (!entry.endsWith('.md')) continue;\r\n const fullPath = join(dir, entry);\r\n let stat;\r\n try {\r\n stat = statSync(fullPath);\r\n } catch {\r\n continue;\r\n }\r\n if (!stat.isFile()) continue;\r\n const source = dir === userDir ? 'user' : 'project';\r\n const cmd = loadCommandFile(fullPath, source);\r\n if (cmd) {\r\n byName.set(cmd.name, cmd); // project overrides user via set\r\n }\r\n }\r\n }\r\n\r\n return Array.from(byName.values());\r\n}\r\n\r\n// Re-export resolveCommandBody from resolve.ts for backward compatibility.\r\nexport { resolveCommandBody } from './resolve.js';\r\n\r\n/**\r\n * Generate a compact text listing of available commands for injection into the\r\n * system prompt. Commands with disableModelInvocation are excluded.\r\n *\r\n * Pattern: mirror generateSkillListing from src/skills.ts.\r\n */\r\nexport function generateCommandListing(\r\n commands: SlashCommand[],\r\n budgetChars: number = 1024,\r\n): string {\r\n const visible = commands.filter((c) => c.source !== undefined); // all commands are visible\r\n if (visible.length === 0) return '';\r\n\r\n const lines: string[] = [];\r\n lines.push('## Available slash commands');\r\n // No command bodies follow, and nothing downstream resolves a bare `/name`\r\n // string: the host expands an invoked command before the turn reaches the\r\n // model. Promising \"execute its instructions below\" taught a mechanism that\r\n // does not exist.\r\n lines.push(\r\n 'The user can invoke these by typing /name. The host expands an invoked command into the conversation before it reaches you. If a message merely mentions a command name, treat it as a reference — do not attempt to execute the command yourself.',\r\n );\r\n\r\n let remaining = budgetChars - lines.join('\\n').length;\r\n let shown = 0;\r\n\r\n for (const cmd of visible) {\r\n const hint = cmd.argumentHint ? ` ${cmd.argumentHint}` : '';\r\n const entry = `- **/${cmd.name}${hint}**: ${cmd.description}`;\r\n if (entry.length > remaining) {\r\n // Try bare name\r\n const bare = `- **/${cmd.name}**`;\r\n if (bare.length <= remaining) {\r\n lines.push(bare);\r\n shown++;\r\n remaining -= bare.length + 1;\r\n }\r\n break;\r\n }\r\n lines.push(entry);\r\n shown++;\r\n remaining -= entry.length + 1;\r\n }\r\n\r\n if (shown < visible.length) lines.push(`- …and ${visible.length - shown} more not shown`);\r\n\r\n return lines.join('\\n');\r\n}\r\n","/**\r\n * Minimal YAML frontmatter parser shared by commands, skills, and subagents.\r\n *\r\n * Handles string values, array values (lines starting with \"-\"), and quoted\r\n * values. Returns a {body, frontmatter} pair. If no frontmatter is found\r\n * (missing opening or closing ---), the entire input is treated as the body.\r\n */\r\nexport function parseFrontmatter(raw: string): {\r\n body: string;\r\n frontmatter: Record<string, unknown>;\r\n} {\r\n // Normalize line endings before looking for the closing delimiter. Skill\r\n // packages are commonly authored on Windows and use CRLF; comparing a raw\r\n // `---\\r` line with the delimiter used to make otherwise valid metadata look\r\n // like an unparsed body.\r\n const normalized = raw.replace(/\\r\\n?/g, '\\n');\r\n const lines = normalized.split('\\n');\r\n // Must start with ---\r\n if (lines[0]?.trim() !== '---') {\r\n return { body: raw, frontmatter: {} };\r\n }\r\n const endIdx = lines.indexOf('---', 1);\r\n if (endIdx === -1) {\r\n // No closing ---; treat entire file as body.\r\n return { body: raw, frontmatter: {} };\r\n }\r\n const fmLines = lines.slice(1, endIdx);\r\n const body = lines\r\n .slice(endIdx + 1)\r\n .join('\\n')\r\n .trim();\r\n const frontmatter: Record<string, unknown> = {};\r\n let currentKey: string | null = null;\r\n let currentArray: string[] = [];\r\n\r\n for (const line of fmLines) {\r\n const trimmed = line.trim();\r\n if (!trimmed) continue;\r\n\r\n // Array item: \"- value\"\r\n const arrayMatch = trimmed.match(/^-\\s+(.+)$/);\r\n if (arrayMatch && currentKey) {\r\n currentArray.push(arrayMatch[1]);\r\n continue;\r\n }\r\n\r\n // Flush any pending array.\r\n if (currentKey) {\r\n if (currentArray.length > 0) {\r\n frontmatter[currentKey] = currentArray;\r\n }\r\n currentKey = null;\r\n currentArray = [];\r\n }\r\n\r\n // key: value\r\n const kvMatch = trimmed.match(/^([a-z][a-zA-Z0-9_-]*)\\s*:\\s*(.*)$/);\r\n if (kvMatch) {\r\n const key = kvMatch[1];\r\n const value = kvMatch[2].trim();\r\n // Could be the start of an array (value empty or starts the array on next line).\r\n if (value === '') {\r\n currentKey = key;\r\n currentArray = [];\r\n } else {\r\n // Unquote if quoted.\r\n const unquoted = value.replace(/^[\"'](.*)[\"']$/, '$1');\r\n if (\r\n (key === 'tools' || key === 'allowed-tools') &&\r\n unquoted.startsWith('[') &&\r\n unquoted.endsWith(']')\r\n ) {\r\n frontmatter[key] = unquoted\r\n .slice(1, -1)\r\n .split(',')\r\n .map((item) => item.trim().replace(/^[\"'](.*)[\"']$/, '$1'))\r\n .filter(Boolean);\r\n } else {\r\n frontmatter[key] = unquoted;\r\n }\r\n }\r\n }\r\n }\r\n // Flush any trailing array.\r\n if (currentKey && currentArray.length > 0) {\r\n frontmatter[currentKey] = currentArray;\r\n }\r\n\r\n return { body, frontmatter };\r\n}\r\n","import { exec } from 'child_process';\r\nimport { assertProjectCommandApproved, type ProjectCommandStore } from '../command-approvals.js';\r\nimport type { CommandContext, SlashCommand } from '../types/commands.js';\r\nimport {\r\n applyShellInjections,\r\n extractShellInjections,\r\n type ShellInjection,\r\n} from './shell-injection.js';\r\n\r\nexport interface ParsedSlashInput {\r\n name: string;\r\n rawArguments: string;\r\n}\r\n\r\n/** Parse slash input once; command identity is always an exact token match. */\r\nexport function parseSlashInput(value: string): ParsedSlashInput | null {\r\n if (!value.startsWith('/')) return null;\r\n const body = value.slice(1);\r\n const separator = body.search(/\\s/);\r\n return separator === -1\r\n ? { name: body, rawArguments: '' }\r\n : { name: body.slice(0, separator), rawArguments: body.slice(separator + 1).trim() };\r\n}\r\n\r\n/**\r\n * Parse raw argument string with shell-style quoting.\r\n *\r\n * Examples:\r\n * \"hello world\" → [\"hello\", \"world\"]\r\n * '\"hello world\" arg' → [\"hello world\", \"arg\"]\r\n * '' → []\r\n */\r\nexport function parseArgs(raw: string): string[] {\r\n const trimmed = raw.trim();\r\n if (!trimmed) return [];\r\n\r\n const argv: string[] = [];\r\n let current = '';\r\n let inDouble = false;\r\n let inSingle = false;\r\n\r\n for (let i = 0; i < trimmed.length; i++) {\r\n const ch = trimmed[i];\r\n\r\n if (inDouble) {\r\n if (ch === '\"') {\r\n inDouble = false;\r\n } else {\r\n current += ch;\r\n }\r\n } else if (inSingle) {\r\n if (ch === \"'\") {\r\n inSingle = false;\r\n } else {\r\n current += ch;\r\n }\r\n } else {\r\n if (ch === '\"') {\r\n inDouble = true;\r\n } else if (ch === \"'\") {\r\n inSingle = true;\r\n } else if (ch === ' ' || ch === '\\t') {\r\n if (current.length > 0) {\r\n argv.push(current);\r\n current = '';\r\n }\r\n } else {\r\n current += ch;\r\n }\r\n }\r\n }\r\n\r\n if (current.length > 0) {\r\n argv.push(current);\r\n }\r\n\r\n return argv;\r\n}\r\n\r\n/**\r\n * Resolve all variables in a command body.\r\n *\r\n * Supported variables:\r\n * $ARGUMENTS / $* — all arguments joined by spaces\r\n * $1 through $9 — positional arguments (1-indexed)\r\n * $name — named argument from `arguments` frontmatter\r\n * ${BOOK_SESSION_ID} — current session ID\r\n * ${BOOK_WORKSPACE} — workspace root path\r\n * ${BOOK_MODEL} — current model name\r\n * ${BOOK_DATE} — current date (YYYY-MM-DD)\r\n */\r\nexport function resolveVariables(\r\n body: string,\r\n argv: string[],\r\n namedArgs: string[],\r\n context?: { sessionId?: string; workspace?: string; model?: string },\r\n): string {\r\n let resolved = body;\r\n\r\n // $ARGUMENTS / $* — all arguments joined\r\n const allArgs = argv.join(' ');\r\n resolved = resolved.replace(/\\$ARGUMENTS|\\$\\*/g, allArgs);\r\n\r\n // $1..$9 — positional arguments (1-indexed)\r\n for (let i = 1; i <= 9; i++) {\r\n const val = i <= argv.length ? argv[i - 1] : '';\r\n resolved = resolved.replace(new RegExp(`\\\\$${i}`, 'g'), val);\r\n }\r\n\r\n // $name — named arguments from `arguments` frontmatter\r\n for (let i = 0; i < namedArgs.length; i++) {\r\n const name = namedArgs[i];\r\n const val = i < argv.length ? argv[i] : '';\r\n resolved = resolved.replace(new RegExp(`\\\\$${escapeRegex(name)}`, 'g'), val);\r\n }\r\n\r\n // ${ENV_VAR} style\r\n if (context?.sessionId) {\r\n resolved = resolved.replace(/\\$\\{BOOK_SESSION_ID\\}/g, context.sessionId);\r\n }\r\n if (context?.workspace) {\r\n resolved = resolved.replace(/\\$\\{BOOK_WORKSPACE\\}/g, context.workspace);\r\n }\r\n if (context?.model) {\r\n resolved = resolved.replace(/\\$\\{BOOK_MODEL\\}/g, context.model);\r\n }\r\n resolved = resolved.replace(/\\$\\{BOOK_DATE\\}/g, new Date().toISOString().split('T')[0]);\r\n\r\n return resolved;\r\n}\r\n\r\n/**\r\n * Resolve inline shell commands and fenced shell blocks in a command body.\r\n *\r\n * Spans come from a single scan of the raw body (`shell-injection.ts`), which\r\n * is also what the approval fingerprint digests, and output is substituted\r\n * back without rescanning: a command that prints an injection marker cannot\r\n * get it executed.\r\n *\r\n * Each shell command runs with a 5-second timeout. Output replaces the\r\n * placeholder inline. Errors are collected.\r\n */\r\nexport async function resolveShellInjection(\r\n body: string,\r\n workspace: string,\r\n signal?: AbortSignal,\r\n): Promise<{ resolved: string; errors: string[] }> {\r\n const errors: string[] = [];\r\n const injections = extractShellInjections(body);\r\n const outputs: string[] = [];\r\n // Sequential: a body may depend on one command having run before the next.\r\n for (const injection of injections) {\r\n outputs.push(await executeInjection(injection, workspace, errors, signal));\r\n }\r\n return { resolved: applyShellInjections(body, injections, outputs), errors };\r\n}\r\n\r\n/**\r\n * Everything a command body may need substituted into it.\r\n */\r\nexport interface CommandResolutionContext {\r\n sessionId?: string;\r\n workspace?: string;\r\n model?: string;\r\n /**\r\n * Recorded decisions about repository-declared shell substitution\r\n * (`settings.commands.projectCommands`).\r\n *\r\n * Omitting it fails closed: a project command that substitutes shell is\r\n * refused rather than run on an unrecorded decision, so a host that has not\r\n * been wired for the gate cannot quietly reopen it.\r\n */\r\n projectCommands?: ProjectCommandStore;\r\n}\r\n\r\n/**\r\n * Resolve a command body by substituting arguments, named args, env vars,\r\n * and shell injections.\r\n *\r\n * Returns the fully resolved body and any shell errors encountered. Throws\r\n * `ProjectCommandApprovalError` when a repository-declared command would run\r\n * shell the user has not approved.\r\n */\r\nexport async function resolveCommandBody(\r\n command: SlashCommand,\r\n args: string,\r\n context?: CommandResolutionContext,\r\n signal?: AbortSignal,\r\n): Promise<{ resolved: string; shellErrors: string[] }> {\r\n // Before anything runs: a checked-in body can execute shell outside the\r\n // permission system, so the decision is checked ahead of the first spawn.\r\n assertProjectCommandApproved(command, {\r\n commands: { projectCommands: context?.projectCommands },\r\n });\r\n\r\n const argv = parseArgs(args);\r\n const namedArgs = command.arguments ?? [];\r\n\r\n // Step 1: Resolve shell injections first (raw body, before var substitution).\r\n // This allows shell commands to produce text that may contain $variables,\r\n // and is why the approved fingerprint digests the raw body: no argument can\r\n // reach the shell.\r\n const { resolved: afterShell, errors } = await resolveShellInjection(\r\n command.body,\r\n context?.workspace ?? process.cwd(),\r\n signal,\r\n );\r\n\r\n // Step 2: Resolve variables ($ARGUMENTS, $1..$9, $name, ${ENV}).\r\n const resolved = resolveVariables(afterShell, argv, namedArgs, context);\r\n\r\n return { resolved, shellErrors: errors };\r\n}\r\n\r\n/**\r\n * Frontmatter enforcement carried into the agent loop for an invoked custom\r\n * command, or undefined when the command restricts nothing.\r\n *\r\n * Shared so every host (TUI, print/headless) enforces `allowed-tools` and\r\n * `model` identically.\r\n */\r\nexport function commandEnforcementContext(\r\n command: SlashCommand,\r\n resolvedBody: string,\r\n): CommandContext | undefined {\r\n return command.allowedTools || command.model\r\n ? {\r\n command,\r\n resolvedBody,\r\n modelOverride: command.model,\r\n allowedTools: command.allowedTools,\r\n }\r\n : undefined;\r\n}\r\n\r\nfunction executeInjection(\r\n injection: ShellInjection,\r\n workspace: string,\r\n errors: string[],\r\n signal?: AbortSignal,\r\n): Promise<string> {\r\n return new Promise((resolve) => {\r\n exec(\r\n injection.command,\r\n {\r\n cwd: workspace,\r\n encoding: 'utf8',\r\n timeout: 5_000,\r\n maxBuffer: 1024 * 1024,\r\n signal,\r\n windowsHide: true,\r\n },\r\n (error, stdout) => {\r\n if (!error) {\r\n resolve(stdout.trim());\r\n return;\r\n }\r\n const message = error.message;\r\n errors.push(\r\n injection.kind === 'block'\r\n ? `Shell block failed: ${message}`\r\n : `Shell command '${injection.command}' failed: ${message}`,\r\n );\r\n resolve(`[shell error: ${message}]`);\r\n },\r\n );\r\n });\r\n}\r\n\r\nfunction escapeRegex(s: string): string {\r\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\r\n}\r\n"],"mappings":"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","names":[]}
@@ -0,0 +1,180 @@
1
+ // src/sandbox.ts
2
+ import { accessSync, constants, existsSync, statSync } from "fs";
3
+ import { delimiter, join, resolve } from "path";
4
+ import { homedir, platform } from "os";
5
+
6
+ // src/tools/glob-regex.ts
7
+ function globToRegex(pattern) {
8
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*|\*/g, ".*");
9
+ return new RegExp("^" + escaped + "$");
10
+ }
11
+
12
+ // src/sandbox.ts
13
+ function isExecutableFile(path) {
14
+ try {
15
+ if (!statSync(path).isFile()) return false;
16
+ accessSync(path, constants.X_OK);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+ function detectBwrap() {
23
+ const candidates = [
24
+ ...(process.env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, "bwrap")),
25
+ "/usr/local/bin/bwrap",
26
+ "/opt/homebrew/bin/bwrap",
27
+ "/usr/bin/bwrap"
28
+ ];
29
+ return candidates.find(isExecutableFile) ?? null;
30
+ }
31
+ function matchesExcludedCommand(command, patterns = []) {
32
+ return patterns.some((pattern) => globToRegex(pattern).test(command));
33
+ }
34
+ function sandboxBackendAvailable() {
35
+ return platform() !== "win32" && detectBwrap() !== null;
36
+ }
37
+ function sandboxCoverage(command, settings, backendAvailable = sandboxBackendAvailable) {
38
+ if (!settings.enabled) return { sandboxed: false, reason: "disabled" };
39
+ if (matchesExcludedCommand(command, settings.excludedCommands))
40
+ return { sandboxed: false, reason: "excluded" };
41
+ if (!backendAvailable()) return { sandboxed: false, reason: "unavailable" };
42
+ return { sandboxed: true };
43
+ }
44
+ var SKIP_REASON_DETAIL = {
45
+ disabled: "sandboxing is turned off (sandbox.enabled is false)",
46
+ excluded: "the command matches a sandbox.excludedCommands pattern, which runs it unsandboxed",
47
+ unavailable: "the bubblewrap (bwrap) sandbox backend is unavailable on this platform or is not installed"
48
+ };
49
+ var SKIP_REASON_REMEDY = {
50
+ disabled: "Set sandbox.enabled to true",
51
+ excluded: "Remove the matching pattern from sandbox.excludedCommands",
52
+ unavailable: "Install bubblewrap (bwrap)"
53
+ };
54
+ function unsandboxedRefusalMessage(reason) {
55
+ return [
56
+ "Refused to run this command outside the sandbox:",
57
+ `${SKIP_REASON_DETAIL[reason]}.`,
58
+ "sandbox.allowUnsandboxedCommands is false, so unsandboxed commands are not permitted.",
59
+ `${SKIP_REASON_REMEDY[reason]}, or set sandbox.allowUnsandboxedCommands to true to allow this command to run unsandboxed.`
60
+ ].join(" ");
61
+ }
62
+ function sandboxPolicySummary(settings, state) {
63
+ return {
64
+ unsandboxedCommands: settings.allowUnsandboxedCommands ? "allowed (sandbox.allowUnsandboxedCommands=true)" : "refused (sandbox.allowUnsandboxedCommands=false)",
65
+ 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)"
66
+ };
67
+ }
68
+ var realSandboxHost = {
69
+ path: { resolve, join },
70
+ exists: existsSync,
71
+ isDirectory: (path) => statSync(path).isDirectory(),
72
+ homedir
73
+ };
74
+ function expandPath(path, host) {
75
+ if (path === "~") return host.homedir();
76
+ if (path.startsWith("~/")) return host.path.join(host.homedir(), path.slice(2));
77
+ return host.path.resolve(path);
78
+ }
79
+ function bindIfPresent(args, flag, path, host) {
80
+ const target = expandPath(path, host);
81
+ if (host.exists(target)) args.push(flag, target, target);
82
+ }
83
+ function unbindablePaths(settings, host = realSandboxHost) {
84
+ return [
85
+ ...settings.filesystem.allowWrite,
86
+ ...settings.filesystem.denyWrite,
87
+ ...settings.filesystem.denyRead
88
+ ].filter((path) => !host.exists(expandPath(path, host)));
89
+ }
90
+ function hasDomainPolicy(settings) {
91
+ return settings.network.allowedDomains.length > 0 || settings.network.deniedDomains.length > 0;
92
+ }
93
+ function buildSandboxExecution(bwrapPath, command, workspaceRoot, settings, host = realSandboxHost) {
94
+ const args = [];
95
+ args.push("--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent");
96
+ args.push("--proc", "/proc");
97
+ args.push("--dev", "/dev");
98
+ for (const dir of ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/opt"]) {
99
+ bindIfPresent(args, "--ro-bind", dir, host);
100
+ }
101
+ args.push("--tmpfs", "/tmp");
102
+ bindIfPresent(args, "--bind", workspaceRoot, host);
103
+ for (const path of settings.filesystem.allowWrite) bindIfPresent(args, "--bind", path, host);
104
+ for (const path of settings.filesystem.denyWrite) bindIfPresent(args, "--ro-bind", path, host);
105
+ for (const path of settings.filesystem.denyRead) {
106
+ const target = expandPath(path, host);
107
+ if (!host.exists(target)) continue;
108
+ if (host.isDirectory(target)) args.push("--tmpfs", target);
109
+ else args.push("--ro-bind", "/dev/null", target);
110
+ }
111
+ args.push(hasDomainPolicy(settings) ? "--unshare-net" : "--share-net");
112
+ args.push("--cap-drop", "ALL");
113
+ args.push("--", "/bin/bash", "-c", command);
114
+ return { file: bwrapPath, args };
115
+ }
116
+ function createSandbox(settings) {
117
+ if (!settings.enabled) return null;
118
+ const os = platform();
119
+ if (os === "win32") {
120
+ if (settings.failIfUnavailable) {
121
+ throw new Error(
122
+ "Bash sandbox is not available on Windows. Disable sandbox.enabled or set failIfUnavailable to false."
123
+ );
124
+ }
125
+ console.warn("\u26A0 Bash sandbox is not available on Windows. Commands will run unsandboxed.");
126
+ return null;
127
+ }
128
+ const bwrap = detectBwrap();
129
+ if (!bwrap) {
130
+ if (settings.failIfUnavailable) {
131
+ throw new Error(
132
+ "bubblewrap (bwrap) not found. Install it to enable bash sandboxing, or set failIfUnavailable to false."
133
+ );
134
+ }
135
+ console.warn(
136
+ "\u26A0 bubblewrap (bwrap) not found \u2014 install it for bash sandboxing. Commands will run unsandboxed."
137
+ );
138
+ return null;
139
+ }
140
+ if (hasDomainPolicy(settings)) {
141
+ console.warn(
142
+ "\u26A0 sandbox.network domain rules cannot be enforced by bubblewrap, which has no per-domain filtering. Network access is disabled entirely for sandboxed commands."
143
+ );
144
+ }
145
+ const unbindable = unbindablePaths(settings);
146
+ if (unbindable.length > 0) {
147
+ console.warn(
148
+ `\u26A0 sandbox.filesystem rules skipped \u2014 nothing exists at: ${unbindable.join(", ")}. These paths are not protected.`
149
+ );
150
+ }
151
+ return {
152
+ wrap(command, workspaceRoot) {
153
+ return buildSandboxExecution(bwrap, command, workspaceRoot, settings);
154
+ },
155
+ describe() {
156
+ return [
157
+ `bubblewrap (${bwrap})`,
158
+ hasDomainPolicy(settings) ? "network disabled" : "host network shared",
159
+ `${settings.filesystem.allowWrite.length} extra writable path(s)`,
160
+ `${settings.filesystem.denyWrite.length} read-only path(s)`,
161
+ `${settings.filesystem.denyRead.length} masked path(s)`,
162
+ unbindable.length > 0 ? `${unbindable.length} skipped (missing)` : "all paths resolved"
163
+ ].join("; ");
164
+ }
165
+ };
166
+ }
167
+
168
+ export {
169
+ globToRegex,
170
+ matchesExcludedCommand,
171
+ sandboxBackendAvailable,
172
+ sandboxCoverage,
173
+ unsandboxedRefusalMessage,
174
+ sandboxPolicySummary,
175
+ unbindablePaths,
176
+ hasDomainPolicy,
177
+ buildSandboxExecution,
178
+ createSandbox
179
+ };
180
+ //# sourceMappingURL=chunk-EYGD6X2T.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sandbox.ts","../src/tools/glob-regex.ts"],"sourcesContent":["import { accessSync, constants, existsSync, statSync } from 'fs';\r\nimport { delimiter, join, resolve } from 'path';\r\nimport { homedir, platform } from 'os';\r\nimport type { ResolvedSettings } from './settings.js';\r\nimport type { CommandExecution } from './types/runtime.js';\r\nimport { globToRegex } from './tools/glob-regex.js';\r\n\r\nexport interface Sandbox {\r\n /**\r\n * Resolve a shell command into a sandboxed argv spawn.\r\n *\r\n * `workspaceRoot` is the only directory bound writable by default, and it is\r\n * deliberately not the command's working directory: `workdir` is a\r\n * model-supplied tool argument, and binding it would let the model widen its\r\n * own sandbox to any path — `workdir: \"/\"` would shadow every other mount and\r\n * hand back the whole host filesystem, read-write. Callers must keep the\r\n * working directory inside the workspace; extra paths go through\r\n * `sandbox.filesystem.allowWrite`.\r\n *\r\n * Returns null if the sandbox is unavailable.\r\n */\r\n wrap(command: string, workspaceRoot: string): CommandExecution | null;\r\n /** One-line description of the policy actually enforced, for diagnostics. */\r\n describe(): string;\r\n}\r\n\r\n/**\r\n * Try to detect bubblewrap (bwrap) on the system PATH.\r\n * Returns the bwrap binary path or null if not found.\r\n */\r\nfunction isExecutableFile(path: string): boolean {\r\n try {\r\n if (!statSync(path).isFile()) return false;\r\n accessSync(path, constants.X_OK);\r\n return true;\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\nfunction detectBwrap(): string | null {\r\n const candidates = [\r\n ...(process.env.PATH ?? '')\r\n .split(delimiter)\r\n .filter(Boolean)\r\n .map((dir) => join(dir, 'bwrap')),\r\n '/usr/local/bin/bwrap',\r\n '/opt/homebrew/bin/bwrap',\r\n '/usr/bin/bwrap',\r\n ];\r\n return candidates.find(isExecutableFile) ?? null;\r\n}\r\n\r\n/**\r\n * Why a specific command will not run inside a real sandbox boundary.\r\n *\r\n * These are the only three ways a Bash command escapes the sandbox, and both\r\n * settings that depend on \"is this command actually sandboxed?\"\r\n * (`allowUnsandboxedCommands` and `autoAllowBashIfSandboxed`) are decided from\r\n * this one enumeration so they can never disagree about a given command.\r\n */\r\nexport type SandboxSkipReason = 'disabled' | 'excluded' | 'unavailable';\r\n\r\nexport interface SandboxCoverage {\r\n /** True only when this exact command will execute inside a bubblewrap namespace. */\r\n sandboxed: boolean;\r\n /** Set whenever `sandboxed` is false. */\r\n reason?: SandboxSkipReason;\r\n}\r\n\r\n/**\r\n * Does a command match one of the `sandbox.excludedCommands` escape patterns?\r\n *\r\n * The whole command string is matched, not just its first line: the permission\r\n * path and the execution path must agree about the same text, and\r\n * `getPrimaryArg` truncates a multi-line command to its first line.\r\n */\r\nexport function matchesExcludedCommand(command: string, patterns: string[] = []): boolean {\r\n return patterns.some((pattern) => globToRegex(pattern).test(command));\r\n}\r\n\r\n/**\r\n * Is the bubblewrap backend usable on this machine at all? Independent of\r\n * settings, so callers can distinguish \"turned off\" from \"cannot be turned on\".\r\n */\r\nexport function sandboxBackendAvailable(): boolean {\r\n return platform() !== 'win32' && detectBwrap() !== null;\r\n}\r\n\r\n/**\r\n * Decide whether one specific command genuinely executes inside the sandbox.\r\n *\r\n * Ordering is deliberate. `enabled` is checked first because \"sandboxing is\r\n * off\" is the actionable diagnosis even on a host with no bwrap installed, and\r\n * because it is false by default — the common path never touches the\r\n * filesystem. `excludedCommands` is checked before the backend probe for the\r\n * same reason: an excluded command is unsandboxed regardless of what is\r\n * installed, so probing PATH for it would be wasted work.\r\n *\r\n * `backendAvailable` is injectable purely so tests can exercise the\r\n * missing-bwrap branch on a host that has bwrap installed.\r\n */\r\nexport function sandboxCoverage(\r\n command: string,\r\n settings: ResolvedSettings['sandbox'],\r\n backendAvailable: () => boolean = sandboxBackendAvailable,\r\n): SandboxCoverage {\r\n if (!settings.enabled) return { sandboxed: false, reason: 'disabled' };\r\n if (matchesExcludedCommand(command, settings.excludedCommands))\r\n return { sandboxed: false, reason: 'excluded' };\r\n if (!backendAvailable()) return { sandboxed: false, reason: 'unavailable' };\r\n return { sandboxed: true };\r\n}\r\n\r\nconst SKIP_REASON_DETAIL: Record<SandboxSkipReason, string> = {\r\n disabled: 'sandboxing is turned off (sandbox.enabled is false)',\r\n excluded: 'the command matches a sandbox.excludedCommands pattern, which runs it unsandboxed',\r\n unavailable:\r\n 'the bubblewrap (bwrap) sandbox backend is unavailable on this platform or is not installed',\r\n};\r\n\r\nconst SKIP_REASON_REMEDY: Record<SandboxSkipReason, string> = {\r\n disabled: 'Set sandbox.enabled to true',\r\n excluded: 'Remove the matching pattern from sandbox.excludedCommands',\r\n unavailable: 'Install bubblewrap (bwrap)',\r\n};\r\n\r\n/**\r\n * The refusal shown when `sandbox.allowUnsandboxedCommands` is false and a\r\n * command would otherwise have run outside the sandbox. It names the setting\r\n * that caused the refusal and the specific reason the command could not be\r\n * sandboxed, because \"permission denied\" with neither is unactionable.\r\n */\r\nexport function unsandboxedRefusalMessage(reason: SandboxSkipReason): string {\r\n return [\r\n 'Refused to run this command outside the sandbox:',\r\n `${SKIP_REASON_DETAIL[reason]}.`,\r\n 'sandbox.allowUnsandboxedCommands is false, so unsandboxed commands are not permitted.',\r\n `${SKIP_REASON_REMEDY[reason]}, or set sandbox.allowUnsandboxedCommands to true to allow this command to run unsandboxed.`,\r\n ].join(' ');\r\n}\r\n\r\n/**\r\n * Everything outside `sandbox.*` that decides whether the two policy switches\r\n * can actually bite. Passed as one object rather than as trailing booleans so a\r\n * new condition cannot be added to the permission path and forgotten here —\r\n * which is how `book doctor` starts reporting a policy stronger than the\r\n * enforced one.\r\n */\r\nexport interface SandboxPolicyState {\r\n /** A sandbox was really created, so some command can genuinely be confined. */\r\n sandboxActive: boolean;\r\n /**\r\n * The user configured `permissions.deny` / `permissions.ask` rules. See\r\n * `sandboxAutoAllows` in permissions.ts: any such rule keeps the default ask,\r\n * because a shell line evades a glob far too easily for the matched rules to\r\n * be the whole protection.\r\n */\r\n adjudicationConfigured: boolean;\r\n}\r\n\r\n/**\r\n * Effective (not merely configured) state of the two policy switches, for\r\n * `book doctor`. A setting whose configured value cannot bite is reported as\r\n * inert rather than as enabled, so the reported policy matches the enforced one.\r\n */\r\nexport function sandboxPolicySummary(\r\n settings: ResolvedSettings['sandbox'],\r\n state: SandboxPolicyState,\r\n): { unsandboxedCommands: string; autoAllowBash: string } {\r\n return {\r\n unsandboxedCommands: settings.allowUnsandboxedCommands\r\n ? 'allowed (sandbox.allowUnsandboxedCommands=true)'\r\n : 'refused (sandbox.allowUnsandboxedCommands=false)',\r\n autoAllowBash: !settings.autoAllowBashIfSandboxed\r\n ? 'off — every Bash call goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=false)'\r\n : !state.sandboxActive\r\n ? 'inert — no command is sandboxed here, so nothing is auto-allowed (sandbox.autoAllowBashIfSandboxed=true)'\r\n : state.adjudicationConfigured\r\n ? 'inert — permissions.deny/ask rules are configured, so every Bash call still goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=true)'\r\n : 'on for genuinely sandboxed commands (sandbox.autoAllowBashIfSandboxed=true)',\r\n };\r\n}\r\n\r\n/**\r\n * The host facts the argv builder depends on: which paths exist, whether they\r\n * are directories, and the path semantics used to resolve configured entries.\r\n *\r\n * Injectable so the generated argv can be pinned by tests on any platform.\r\n * The unit suite runs on Windows CI, where probing the real filesystem finds\r\n * none of the POSIX system mounts (`resolve('/usr')` lands on `C:\\usr`), so a\r\n * test against the real host would be asserting on binds that were never\r\n * emitted. Production always uses the real host below.\r\n */\r\nexport interface SandboxHost {\r\n /** Path semantics for configured entries; tests inject path.win32/path.posix. */\r\n path: { resolve(...segments: string[]): string; join(...segments: string[]): string };\r\n exists(path: string): boolean;\r\n isDirectory(path: string): boolean;\r\n homedir(): string;\r\n}\r\n\r\nconst realSandboxHost: SandboxHost = {\r\n path: { resolve, join },\r\n exists: existsSync,\r\n isDirectory: (path) => statSync(path).isDirectory(),\r\n homedir,\r\n};\r\n\r\n/**\r\n * Resolve a configured sandbox path. `~` is expanded the same way the rest of\r\n * the codebase expands it; a relative entry stays relative to the process cwd,\r\n * which `unbindablePaths` reports so it does not pass silently.\r\n */\r\nfunction expandPath(path: string, host: SandboxHost): string {\r\n if (path === '~') return host.homedir();\r\n if (path.startsWith('~/')) return host.path.join(host.homedir(), path.slice(2));\r\n return host.path.resolve(path);\r\n}\r\n\r\n/** bwrap fails the whole invocation if a bind source does not exist. */\r\nfunction bindIfPresent(args: string[], flag: string, path: string, host: SandboxHost): void {\r\n const target = expandPath(path, host);\r\n if (host.exists(target)) args.push(flag, target, target);\r\n}\r\n\r\n/**\r\n * Configured filesystem paths that cannot be applied because nothing exists at\r\n * them. bwrap aborts the whole invocation on a missing bind source, so these\r\n * have to be skipped — but a skipped rule is unenforced policy the user\r\n * believes is active, so it is reported rather than dropped quietly.\r\n */\r\nexport function unbindablePaths(\r\n settings: ResolvedSettings['sandbox'],\r\n host: SandboxHost = realSandboxHost,\r\n): string[] {\r\n return [\r\n ...settings.filesystem.allowWrite,\r\n ...settings.filesystem.denyWrite,\r\n ...settings.filesystem.denyRead,\r\n ].filter((path) => !host.exists(expandPath(path, host)));\r\n}\r\n\r\n/**\r\n * True when the declared network policy asks for something finer than\r\n * all-or-nothing. bwrap has no DNS or domain awareness, so a per-domain policy\r\n * cannot be honoured as written.\r\n */\r\nexport function hasDomainPolicy(settings: ResolvedSettings['sandbox']): boolean {\r\n return settings.network.allowedDomains.length > 0 || settings.network.deniedDomains.length > 0;\r\n}\r\n\r\n/**\r\n * Build the bubblewrap argument vector for one command.\r\n *\r\n * Mount order is significant: bwrap applies operations in sequence and a later\r\n * mount shadows an earlier one covering the same path. The workspace bind\r\n * therefore comes *after* the system read-only binds and the /tmp tmpfs (a\r\n * workspace under /usr/local or /tmp would otherwise be silently shadowed), and\r\n * explicit filesystem policy comes after the workspace so it can override it.\r\n *\r\n * Exported for testing: it does not require bwrap to be installed, and with an\r\n * injected `host` its output is fully determined by its arguments.\r\n */\r\nexport function buildSandboxExecution(\r\n bwrapPath: string,\r\n command: string,\r\n workspaceRoot: string,\r\n settings: ResolvedSettings['sandbox'],\r\n host: SandboxHost = realSandboxHost,\r\n): CommandExecution {\r\n const args: string[] = [];\r\n\r\n // Fresh PID/IPC/UTS namespaces, and the sandbox dies if the spawning process\r\n // does.\r\n //\r\n // Deliberately NOT --new-session: it makes bwrap call setsid(), which moves\r\n // the sandboxed tree into its own process group. Every teardown path here\r\n // (KillShell, foreground timeout, Ctrl-C) signals the group Node created with\r\n // `detached: true` and confirms death with `kill(-pgid, 0)`, so the group\r\n // would read as empty while the command kept running — a kill that reports\r\n // success and does nothing. The TIOCSTI hardening --new-session buys is moot\r\n // anyway: all three spawn sites pipe stdio and never hand over a tty.\r\n args.push('--unshare-pid', '--unshare-ipc', '--unshare-uts', '--die-with-parent');\r\n args.push('--proc', '/proc');\r\n args.push('--dev', '/dev');\r\n\r\n // Minimal system directories, read-only.\r\n for (const dir of ['/usr', '/lib', '/lib64', '/bin', '/sbin', '/etc', '/opt']) {\r\n bindIfPresent(args, '--ro-bind', dir, host);\r\n }\r\n\r\n args.push('--tmpfs', '/tmp');\r\n\r\n // The workspace is writable. Bound after /tmp so a workspace inside /tmp\r\n // (every mkdtemp-based test run, among others) survives the tmpfs.\r\n bindIfPresent(args, '--bind', workspaceRoot, host);\r\n\r\n // Declared filesystem policy overrides the defaults above.\r\n for (const path of settings.filesystem.allowWrite) bindIfPresent(args, '--bind', path, host);\r\n for (const path of settings.filesystem.denyWrite) bindIfPresent(args, '--ro-bind', path, host);\r\n // bwrap cannot unmount a subpath, so a denied path is masked instead. The\r\n // mask has to match the kind: --tmpfs needs to mkdir its target, so pointing\r\n // it at a file aborts the entire invocation with \"Not a directory\" — and a\r\n // credentials *file* is the most natural thing to deny. Files are masked with\r\n // /dev/null instead, which makes reads fail outright.\r\n for (const path of settings.filesystem.denyRead) {\r\n const target = expandPath(path, host);\r\n if (!host.exists(target)) continue;\r\n if (host.isDirectory(target)) args.push('--tmpfs', target);\r\n else args.push('--ro-bind', '/dev/null', target);\r\n }\r\n\r\n // bwrap can only share or unshare the network wholesale. A declared\r\n // per-domain policy cannot be enforced as written, so fail closed rather\r\n // than hand out the unrestricted host network the caller did not ask for.\r\n args.push(hasDomainPolicy(settings) ? '--unshare-net' : '--share-net');\r\n\r\n args.push('--cap-drop', 'ALL');\r\n\r\n // The command string is a single argv element: bash inside the sandbox\r\n // parses it, and nothing outside the sandbox ever does.\r\n args.push('--', '/bin/bash', '-c', command);\r\n\r\n return { file: bwrapPath, args };\r\n}\r\n\r\n/**\r\n * Create a sandbox wrapper if bwrap is available on this platform.\r\n * On Windows, returns null with a warning (sandbox not supported).\r\n *\r\n * @param settings - Resolved sandbox settings\r\n * @returns Sandbox instance or null\r\n */\r\nexport function createSandbox(settings: ResolvedSettings['sandbox']): Sandbox | null {\r\n if (!settings.enabled) return null;\r\n\r\n const os = platform();\r\n if (os === 'win32') {\r\n if (settings.failIfUnavailable) {\r\n throw new Error(\r\n 'Bash sandbox is not available on Windows. Disable sandbox.enabled or set failIfUnavailable to false.',\r\n );\r\n }\r\n console.warn('⚠ Bash sandbox is not available on Windows. Commands will run unsandboxed.');\r\n return null;\r\n }\r\n\r\n const bwrap = detectBwrap();\r\n if (!bwrap) {\r\n if (settings.failIfUnavailable) {\r\n throw new Error(\r\n 'bubblewrap (bwrap) not found. Install it to enable bash sandboxing, or set failIfUnavailable to false.',\r\n );\r\n }\r\n console.warn(\r\n '⚠ bubblewrap (bwrap) not found — install it for bash sandboxing. Commands will run unsandboxed.',\r\n );\r\n return null;\r\n }\r\n\r\n // Diagnostics belong to whoever builds the sandbox, and callers are expected\r\n // to build it once per session — `createSandbox` runs per Bash call would\r\n // repeat these on every command.\r\n if (hasDomainPolicy(settings)) {\r\n console.warn(\r\n '⚠ sandbox.network domain rules cannot be enforced by bubblewrap, which has no per-domain filtering. Network access is disabled entirely for sandboxed commands.',\r\n );\r\n }\r\n const unbindable = unbindablePaths(settings);\r\n if (unbindable.length > 0) {\r\n console.warn(\r\n `⚠ sandbox.filesystem rules skipped — nothing exists at: ${unbindable.join(', ')}. These paths are not protected.`,\r\n );\r\n }\r\n\r\n return {\r\n wrap(command: string, workspaceRoot: string): CommandExecution {\r\n return buildSandboxExecution(bwrap, command, workspaceRoot, settings);\r\n },\r\n describe(): string {\r\n return [\r\n `bubblewrap (${bwrap})`,\r\n hasDomainPolicy(settings) ? 'network disabled' : 'host network shared',\r\n `${settings.filesystem.allowWrite.length} extra writable path(s)`,\r\n `${settings.filesystem.denyWrite.length} read-only path(s)`,\r\n `${settings.filesystem.denyRead.length} masked path(s)`,\r\n unbindable.length > 0 ? `${unbindable.length} skipped (missing)` : 'all paths resolved',\r\n ].join('; ');\r\n },\r\n };\r\n}\r\n","/**\r\n * Convert a glob pattern to a regex. Supports * (any chars) and ** (same as *).\r\n * The pattern is anchored at both ends (^...$).\r\n */\r\nexport function globToRegex(pattern: string): RegExp {\r\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&').replace(/\\*\\*|\\*/g, '.*');\r\n return new RegExp('^' + escaped + '$');\r\n}\r\n"],"mappings":"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","names":[]}