janissary 0.5.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 (268) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +28 -0
  3. package/agent-names.json +52 -0
  4. package/bin/janus.mjs +27 -0
  5. package/chrome-extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
  6. package/chrome-extension/content-script.js +62 -0
  7. package/chrome-extension/manifest.json +25 -0
  8. package/chrome-extension/rules.json +16 -0
  9. package/dist/acp/index.js +93 -0
  10. package/dist/acp/loop.js +75 -0
  11. package/dist/acp/manager.js +126 -0
  12. package/dist/acp/rate-limit.js +8 -0
  13. package/dist/acp/runner.js +18 -0
  14. package/dist/acp/tools.js +27 -0
  15. package/dist/acp-loop.js +75 -0
  16. package/dist/acp-manager.js +109 -0
  17. package/dist/acp-runner.js +18 -0
  18. package/dist/acp.js +91 -0
  19. package/dist/agent/commands.js +29 -0
  20. package/dist/agent/communication-manager.js +71 -0
  21. package/dist/agent/state.js +58 -0
  22. package/dist/agent-commands.js +29 -0
  23. package/dist/agent-communication-manager.js +71 -0
  24. package/dist/agent-state.js +58 -0
  25. package/dist/app-themes.js +13 -0
  26. package/dist/browser/command.js +104 -0
  27. package/dist/browser/index.js +124 -0
  28. package/dist/browser/tab-helpers.js +51 -0
  29. package/dist/browser/tab.js +99 -0
  30. package/dist/browser-command.js +104 -0
  31. package/dist/browser-tab-helpers.js +51 -0
  32. package/dist/browser-tab.js +99 -0
  33. package/dist/browser.js +124 -0
  34. package/dist/buffer.js +42 -0
  35. package/dist/bus.js +52 -0
  36. package/dist/capture/manager.js +48 -0
  37. package/dist/capture/router.js +18 -0
  38. package/dist/capture-manager.js +48 -0
  39. package/dist/capture-router.js +20 -0
  40. package/dist/chrome-extension-loader.js +62 -0
  41. package/dist/cli-args.js +80 -0
  42. package/dist/command-manager.js +126 -0
  43. package/dist/command-router.js +14 -0
  44. package/dist/commands/acp-reset.js +13 -0
  45. package/dist/commands/acp.js +5 -0
  46. package/dist/commands/agent.js +5 -0
  47. package/dist/commands/broadcast.js +22 -0
  48. package/dist/commands/browser.js +5 -0
  49. package/dist/commands/clear.js +5 -0
  50. package/dist/commands/close.js +42 -0
  51. package/dist/commands/connection.js +5 -0
  52. package/dist/commands/db.js +5 -0
  53. package/dist/commands/edit.js +18 -0
  54. package/dist/commands/files.js +13 -0
  55. package/dist/commands/hist.js +7 -0
  56. package/dist/commands/index.js +62 -0
  57. package/dist/commands/monitor.js +61 -0
  58. package/dist/commands/msg.js +17 -0
  59. package/dist/commands/next.js +7 -0
  60. package/dist/commands/notifications.js +16 -0
  61. package/dist/commands/notify.js +20 -0
  62. package/dist/commands/open.js +41 -0
  63. package/dist/commands/profile.js +5 -0
  64. package/dist/commands/queue.js +37 -0
  65. package/dist/commands/quit.js +6 -0
  66. package/dist/commands/rename.js +12 -0
  67. package/dist/commands/resolve-target.js +12 -0
  68. package/dist/commands/schedule.js +74 -0
  69. package/dist/commands/search.js +26 -0
  70. package/dist/commands/send.js +51 -0
  71. package/dist/commands/state.js +9 -0
  72. package/dist/commands/syntax.js +36 -0
  73. package/dist/commands/tasks.js +7 -0
  74. package/dist/commands/theme.js +41 -0
  75. package/dist/commands/types.js +1 -0
  76. package/dist/commands.js +70 -0
  77. package/dist/completion/browser.js +24 -0
  78. package/dist/completion/fs.js +26 -0
  79. package/dist/completion/handlers.js +81 -0
  80. package/dist/completion/helpers.js +43 -0
  81. package/dist/completion/index.js +43 -0
  82. package/dist/completion-browser.js +24 -0
  83. package/dist/completion-fs.js +26 -0
  84. package/dist/completion-handlers.js +69 -0
  85. package/dist/completion-helpers.js +43 -0
  86. package/dist/completion.js +42 -0
  87. package/dist/config.js +66 -0
  88. package/dist/connection/close.js +41 -0
  89. package/dist/connection/manager.js +93 -0
  90. package/dist/connection/parsing.js +28 -0
  91. package/dist/connection-close.js +41 -0
  92. package/dist/connection-manager.js +93 -0
  93. package/dist/connection-parsing.js +28 -0
  94. package/dist/connections.js +75 -0
  95. package/dist/controller-events.js +34 -0
  96. package/dist/controller-file-tree.js +36 -0
  97. package/dist/controller.js +201 -0
  98. package/dist/database/index.js +83 -0
  99. package/dist/database/manager.js +67 -0
  100. package/dist/database/parsing.js +54 -0
  101. package/dist/database/query.js +41 -0
  102. package/dist/database-manager.js +67 -0
  103. package/dist/database-parsing.js +54 -0
  104. package/dist/database-query.js +41 -0
  105. package/dist/database.js +83 -0
  106. package/dist/datetime.js +15 -0
  107. package/dist/editor/save.js +27 -0
  108. package/dist/editor/sync.js +10 -0
  109. package/dist/editor/watch-manager.js +84 -0
  110. package/dist/editor-save.js +24 -0
  111. package/dist/editor-watch-manager.js +84 -0
  112. package/dist/file-tree-args.js +36 -0
  113. package/dist/file-tree-git-refresh.js +33 -0
  114. package/dist/file-tree-manager.js +252 -0
  115. package/dist/file-tree-moves.js +26 -0
  116. package/dist/file-tree-open.js +56 -0
  117. package/dist/file-tree-rebuild.js +22 -0
  118. package/dist/file-tree-watch.js +27 -0
  119. package/dist/file-tree.js +82 -0
  120. package/dist/git-status.js +64 -0
  121. package/dist/github-token.js +19 -0
  122. package/dist/global-history.js +48 -0
  123. package/dist/harness/auto-approve.js +79 -0
  124. package/dist/harness/busy-status.js +91 -0
  125. package/dist/harness/capture-file.js +28 -0
  126. package/dist/harness/index.js +87 -0
  127. package/dist/harness/manager.js +182 -0
  128. package/dist/harness/models.js +8 -0
  129. package/dist/harness/recorder.js +89 -0
  130. package/dist/harness/recording-file.js +25 -0
  131. package/dist/harness/screen.js +79 -0
  132. package/dist/harness-capture-file.js +28 -0
  133. package/dist/harness-manager.js +112 -0
  134. package/dist/harness-models.js +8 -0
  135. package/dist/harness-screen.js +72 -0
  136. package/dist/harness.js +54 -0
  137. package/dist/index.js +163 -0
  138. package/dist/instance-lock.js +34 -0
  139. package/dist/interactive.js +33 -0
  140. package/dist/main.js +203 -0
  141. package/dist/managers.js +1 -0
  142. package/dist/message-handler.js +134 -0
  143. package/dist/messaging.js +47 -0
  144. package/dist/monitor/acp.js +24 -0
  145. package/dist/monitor/ask.js +31 -0
  146. package/dist/monitor/context.js +27 -0
  147. package/dist/monitor/editor-feed.js +49 -0
  148. package/dist/monitor/feed-diff.js +22 -0
  149. package/dist/monitor/feeds.js +19 -0
  150. package/dist/monitor/framing.js +29 -0
  151. package/dist/monitor/harness-feed.js +21 -0
  152. package/dist/monitor/info.js +29 -0
  153. package/dist/monitor/manager.js +221 -0
  154. package/dist/monitor/page-feed.js +21 -0
  155. package/dist/monitor/parsing.js +82 -0
  156. package/dist/monitor/reply.js +11 -0
  157. package/dist/monitor/session.js +36 -0
  158. package/dist/monitor/stop.js +33 -0
  159. package/dist/monitor/suggestion.js +12 -0
  160. package/dist/monitor/targets.js +69 -0
  161. package/dist/monitor/window.js +109 -0
  162. package/dist/monitor-acp.js +22 -0
  163. package/dist/monitor-info.js +18 -0
  164. package/dist/monitor-manager.js +233 -0
  165. package/dist/monitor-parsing.js +68 -0
  166. package/dist/monitor-session.js +19 -0
  167. package/dist/monitor-targets.js +37 -0
  168. package/dist/monitor-window.js +81 -0
  169. package/dist/notifications-tab.js +32 -0
  170. package/dist/notifications.js +87 -0
  171. package/dist/open-file-manager.js +100 -0
  172. package/dist/openers/editor.js +41 -0
  173. package/dist/openers/external-viewer.js +11 -0
  174. package/dist/openers/image.js +27 -0
  175. package/dist/openers/index.js +16 -0
  176. package/dist/openers/markdown.js +20 -0
  177. package/dist/openers/os-open.js +24 -0
  178. package/dist/openers/page.js +51 -0
  179. package/dist/openers/size.js +13 -0
  180. package/dist/openers/types.js +1 -0
  181. package/dist/page/sync.js +10 -0
  182. package/dist/paths.js +45 -0
  183. package/dist/persona-parsing.js +49 -0
  184. package/dist/personas.js +44 -0
  185. package/dist/profile/agent-opener.js +154 -0
  186. package/dist/profile/files.js +17 -0
  187. package/dist/profile/manager.js +72 -0
  188. package/dist/profile/monitors.js +23 -0
  189. package/dist/profile/notifications.js +11 -0
  190. package/dist/profile-agent-opener.js +136 -0
  191. package/dist/profile-manager.js +72 -0
  192. package/dist/profiles.js +143 -0
  193. package/dist/protocol.js +1 -0
  194. package/dist/pseudoterminal-manager.js +106 -0
  195. package/dist/pty.js +41 -0
  196. package/dist/recognizers/acp.js +38 -0
  197. package/dist/recognizers/analyze.js +49 -0
  198. package/dist/recognizers/bash.js +51 -0
  199. package/dist/recognizers/db.js +26 -0
  200. package/dist/recognizers/index.js +1 -0
  201. package/dist/recognizers/lexicon.js +11 -0
  202. package/dist/recognizers/types.js +5 -0
  203. package/dist/resolve.js +39 -0
  204. package/dist/route-choice.js +11 -0
  205. package/dist/sandbox/index.js +217 -0
  206. package/dist/sandbox/paths.js +178 -0
  207. package/dist/sandbox/profile.js +161 -0
  208. package/dist/sandbox-paths.js +159 -0
  209. package/dist/sandbox-profile.js +161 -0
  210. package/dist/sandbox.js +217 -0
  211. package/dist/schedule/display.js +19 -0
  212. package/dist/schedule/helpers.js +76 -0
  213. package/dist/schedule/index.js +76 -0
  214. package/dist/schedule/manager.js +101 -0
  215. package/dist/schedule/parsing.js +63 -0
  216. package/dist/schedule/time.js +34 -0
  217. package/dist/schedule-display.js +19 -0
  218. package/dist/schedule-helpers.js +76 -0
  219. package/dist/schedule-manager.js +98 -0
  220. package/dist/schedule-parsing.js +63 -0
  221. package/dist/schedule-time.js +34 -0
  222. package/dist/schedule.js +76 -0
  223. package/dist/search-matches.js +40 -0
  224. package/dist/security.js +44 -0
  225. package/dist/shell-manager.js +139 -0
  226. package/dist/shell.js +59 -0
  227. package/dist/ssh-manager.js +42 -0
  228. package/dist/ssh.js +39 -0
  229. package/dist/startup-errors.js +21 -0
  230. package/dist/state-event.js +14 -0
  231. package/dist/state-format.js +38 -0
  232. package/dist/syntax-themes.js +14 -0
  233. package/dist/tab/agent-state.js +19 -0
  234. package/dist/tab/cleanup.js +33 -0
  235. package/dist/tab/colors.js +43 -0
  236. package/dist/tab/creators.js +95 -0
  237. package/dist/tab/dock.js +35 -0
  238. package/dist/tab/focus-history.js +41 -0
  239. package/dist/tab/formatting-handlers.js +77 -0
  240. package/dist/tab/formatting.js +21 -0
  241. package/dist/tab/history.js +14 -0
  242. package/dist/tab/index.js +65 -0
  243. package/dist/tab/manager.js +329 -0
  244. package/dist/tab/openers.js +35 -0
  245. package/dist/tab/queue.js +33 -0
  246. package/dist/tab/rehydrate.js +15 -0
  247. package/dist/tab/reorder.js +15 -0
  248. package/dist/tab/transcript.js +15 -0
  249. package/dist/tab/utils.js +55 -0
  250. package/dist/tab/view.js +42 -0
  251. package/dist/tab-cleanup.js +27 -0
  252. package/dist/tab-colors.js +43 -0
  253. package/dist/tab-creators.js +85 -0
  254. package/dist/tab-formatting-handlers.js +77 -0
  255. package/dist/tab-formatting.js +21 -0
  256. package/dist/tab-manager.js +418 -0
  257. package/dist/tab-utils.js +55 -0
  258. package/dist/tab.js +57 -0
  259. package/dist/tasks.js +32 -0
  260. package/dist/transcript/logger.js +36 -0
  261. package/dist/transcript/store.js +66 -0
  262. package/dist/types.js +6 -0
  263. package/dist/user-agent.js +65 -0
  264. package/dist/word-wrapping.js +42 -0
  265. package/dist/workspace-manager.js +40 -0
  266. package/dist/workspace.js +120 -0
  267. package/help.md +97 -0
  268. package/package.json +138 -0
@@ -0,0 +1,49 @@
1
+ import { bashRecognizer } from './bash.js';
2
+ import { dbRecognizer as databaseRecognizer } from './db.js';
3
+ import { acpRecognizer } from './acp.js';
4
+ export const recognizers = [bashRecognizer, databaseRecognizer, acpRecognizer];
5
+ // A command is auto-dispatched only when the best match is at least this reliable and clearly
6
+ // ahead of the runner-up; otherwise the user is asked to choose.
7
+ export const HIGH_RELIABILITY = 0.7;
8
+ const DOMINANCE_MARGIN = 0.15;
9
+ // Poll every recognizer and decide how to route the command. The highest-reliability match wins
10
+ // when it is confident enough; otherwise the result is ambiguous and the caller should prompt.
11
+ export function analyzeCommand(command, context) {
12
+ const matches = recognizers
13
+ .map((r) => ({ route: r.route, ...r.recognize(command, context) }))
14
+ .filter((r) => r.match)
15
+ .map(({ route, reliability }) => ({ route, reliability }))
16
+ .toSorted((a, b) => b.reliability - a.reliability);
17
+ const top = matches[0];
18
+ const runnerUp = matches[1];
19
+ const isConfident = top !== undefined &&
20
+ top.reliability >= HIGH_RELIABILITY &&
21
+ (runnerUp === undefined || top.reliability - runnerUp.reliability >= DOMINANCE_MARGIN);
22
+ if (isConfident)
23
+ return { kind: 'route', route: top.route, reliability: top.reliability };
24
+ return { kind: 'ambiguous', candidates: matches };
25
+ }
26
+ // The routes a user may pick from in the chooser. Always offers shell and acp; offers a db
27
+ // choice per open connection (the query needs a concrete database to target).
28
+ export function routeChoices(openDbs) {
29
+ const choices = [{ label: 'shell', route: 'shell' }];
30
+ for (const database of openDbs)
31
+ choices.push({ label: `db query → ${database}`, route: 'db', dbName: database });
32
+ choices.push({ label: 'acp (agent prompt)', route: 'acp' });
33
+ return choices;
34
+ }
35
+ // Rewrite a bare command into the explicit, prefixed form for `choice`'s route, so it can be
36
+ // dispatched through the normal command pipeline.
37
+ export function toPrefixedCommand(command, choice) {
38
+ switch (choice.route) {
39
+ case 'shell': {
40
+ return `shell ${command}`;
41
+ }
42
+ case 'acp': {
43
+ return `acp ${command}`;
44
+ }
45
+ case 'db': {
46
+ return `db sqlite query ${choice.dbName} ${command}`;
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,51 @@
1
+ import { FUNCTION_WORDS } from './lexicon.js';
2
+ // Common executables/builtins that strongly signal a shell command when they lead the line.
3
+ const COMMON_COMMANDS = new Set([
4
+ 'ls', 'cd', 'pwd', 'cat', 'echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'find', 'head',
5
+ 'tail', 'less', 'more', 'touch', 'rm', 'rmdir', 'cp', 'mv', 'mkdir', 'chmod', 'chown', 'ln',
6
+ 'ps', 'kill', 'top', 'htop', 'df', 'du', 'tar', 'gzip', 'gunzip', 'zip', 'unzip', 'curl',
7
+ 'wget', 'ssh', 'scp', 'rsync', 'git', 'npm', 'npx', 'node', 'yarn', 'pnpm', 'deno', 'bun',
8
+ 'python', 'python3', 'pip', 'pip3', 'ruby', 'gem', 'go', 'cargo', 'rustc', 'make', 'cmake',
9
+ 'sed', 'awk', 'sort', 'uniq', 'wc', 'cut', 'tr', 'xargs', 'tee', 'date', 'whoami', 'which',
10
+ 'env', 'export', 'unset', 'source', 'sleep', 'history', 'man', 'ping', 'brew', 'apt', 'yum',
11
+ 'docker', 'kubectl', 'code', 'vim', 'nano', 'emacs', 'open', 'say', 'jq', 'sqlite3', 'psql',
12
+ 'mysql', 'test', 'true', 'false', 'set', 'alias', 'jobs', 'bg', 'fg', 'nohup', 'time',
13
+ ]);
14
+ // Commands that are also everyday English words, so a sentence can plausibly start with one
15
+ // ("find the largest file", "which file is longest"). For these, a prose-looking line is treated
16
+ // as a natural-language prompt rather than a shell invocation.
17
+ const AMBIGUOUS_COMMANDS = new Set([
18
+ 'which', 'find', 'make', 'test', 'set', 'time', 'date', 'open', 'say', 'help', 'sort', 'do',
19
+ 'who', 'head', 'tail', 'last', 'touch', 'kill', 'cut', 'less', 'more', 'top', 'tee',
20
+ ]);
21
+ // Shell metacharacters: pipes, redirects, chaining, command substitution, env refs. A bare `*`
22
+ // is deliberately excluded — it is weak evidence of a shell command and collides with SQL (`SELECT *`).
23
+ const SHELL_OPERATORS = /[|&;<>`]|\$\(|\$\{|\$[A-Za-z_]/;
24
+ const PATH_LIKE = /^(\.{1,2}\/|\/|~\/)/;
25
+ const FLAG = /^-{1,2}[A-Za-z]/;
26
+ // Recognize a shell/bash command. Leading with a known command is the strongest signal; shell
27
+ // operators or an explicit executable path also count. A command word doubling as English
28
+ // ("which", "find", …) at the head of a prose sentence is discounted so it routes to the agent.
29
+ export const bashRecognizer = {
30
+ route: 'shell',
31
+ recognize: (command) => {
32
+ const trimmed = command.trim();
33
+ const tokens = trimmed.split(/\s+/);
34
+ const first = (tokens[0] ?? '').toLowerCase();
35
+ const proseWords = tokens.slice(1).filter((t) => FUNCTION_WORDS.has(t.toLowerCase())).length;
36
+ const isLooksProse = tokens.length >= 3 && proseWords >= 1;
37
+ let score = 0;
38
+ if (COMMON_COMMANDS.has(first)) {
39
+ score = AMBIGUOUS_COMMANDS.has(first) && isLooksProse ? 0.25 : 0.9;
40
+ }
41
+ else if (PATH_LIKE.test(first)) {
42
+ score = 0.85;
43
+ }
44
+ if (SHELL_OPERATORS.test(trimmed))
45
+ score = Math.max(score, 0.6) + 0.1;
46
+ if (tokens.length > 1 && FLAG.test(tokens[1]))
47
+ score += 0.05;
48
+ score = Math.min(score, 0.98);
49
+ return { match: score >= 0.5, reliability: score };
50
+ },
51
+ };
@@ -0,0 +1,26 @@
1
+ // Statement keywords that, when they lead the line, mark a SQL query.
2
+ const SQL_START = new Set([
3
+ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP', 'ALTER', 'WITH', 'PRAGMA',
4
+ 'EXPLAIN', 'VACUUM', 'ATTACH', 'DETACH', 'REPLACE', 'BEGIN', 'COMMIT', 'ROLLBACK', 'TRUNCATE',
5
+ ]);
6
+ // Secondary keywords that reinforce a SQL match when present anywhere in the statement.
7
+ const SQL_KEYWORDS = ['FROM', 'WHERE', 'JOIN', 'INTO', 'VALUES', 'SET', 'TABLE', 'GROUP BY', 'ORDER BY', 'LIMIT'];
8
+ // Recognize a SQL/db query. Gated on the tab having an open database connection — without one
9
+ // there is nothing to run the query against, so the route is not offered (per design).
10
+ export const dbRecognizer = {
11
+ route: 'db',
12
+ recognize: (command, context) => {
13
+ if (context.openDbs.length === 0)
14
+ return { match: false, reliability: 0 };
15
+ const trimmed = command.trim();
16
+ const first = /^([A-Za-z]+)\b/.exec(trimmed)?.[1]?.toUpperCase();
17
+ let score = 0;
18
+ score = first && SQL_START.has(first) ? 0.8 : score;
19
+ const hits = SQL_KEYWORDS.filter((k) => new RegExp(String.raw `\b${k.replace(' ', String.raw `\s+`)}\b`, 'i').test(trimmed)).length;
20
+ score += Math.min(0.18, hits * 0.09);
21
+ if (/;\s*$/.test(trimmed))
22
+ score += 0.05;
23
+ score = Math.min(score, 0.98);
24
+ return { match: score >= 0.5, reliability: score };
25
+ },
26
+ };
@@ -0,0 +1 @@
1
+ export { analyzeCommand, routeChoices, toPrefixedCommand } from './analyze.js';
@@ -0,0 +1,11 @@
1
+ // Grammatical "function words" (articles, copulas, auxiliaries, pronouns, prepositions,
2
+ // conjunctions, interrogatives). They pepper natural-language sentences but rarely appear,
3
+ // unquoted, in a shell invocation. Recognizers use their presence to tell a prose request
4
+ // ("find the largest file") apart from a command ("find . -name x").
5
+ export const FUNCTION_WORDS = new Set([
6
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'am', 'be', 'been', 'being', 'of', 'to', 'for',
7
+ 'and', 'or', 'but', 'that', 'this', 'these', 'those', 'with', 'from', 'into', 'my', 'your',
8
+ 'his', 'her', 'their', 'our', 'it', 'its', 'you', 'he', 'she', 'they', 'we', 'do', 'does',
9
+ 'did', 'should', 'would', 'could', 'can', 'will', 'than', 'then', 'what', 'why', 'how', 'who',
10
+ 'whom', 'whose', 'when', 'where', 'which',
11
+ ]);
@@ -0,0 +1,5 @@
1
+ // A command typed without a prefix may still be a shell command, a SQL/db query, or an agent
2
+ // (acp) prompt. Each route has a "command recognizer" that reads the raw command and reports
3
+ // whether it looks like that route and how reliable the guess is. The analysis module
4
+ // (`analyze.ts`) polls every recognizer and routes by the most reliable match.
5
+ export {};
@@ -0,0 +1,39 @@
1
+ import { getOutput } from './commands.js';
2
+ import { commands } from './commands/index.js';
3
+ /**
4
+ * Classify a prompt input into the action it represents, mirroring the dispatch order
5
+ * used when a command is typed into a tab. Pure: callers decide the side effects (a
6
+ * foreground tab can run interactive/app commands; a remote agent refuses them).
7
+ *
8
+ * - `shell`: run in a shell. Explicitly requested via a leading `shell ` keyword — there
9
+ * is no bare auto-run, so a non-built-in typed without the keyword is unknown.
10
+ * - `app`: an application built-in that needs live state.
11
+ * - `output`: a built-in with textual output to display (also the "unknown command" reply).
12
+ * - `empty`: nothing to do.
13
+ */
14
+ export function resolveCommand(raw) {
15
+ const trimmed = raw.trim();
16
+ if (!trimmed)
17
+ return { kind: 'empty' };
18
+ // Shell commands are launched with the `shell` keyword, which is stripped before the
19
+ // command reaches the shell.
20
+ if (/^shell\b/i.test(trimmed)) {
21
+ return { kind: 'shell', cmd: trimmed.replace(/^shell\b\s*/i, '') };
22
+ }
23
+ const command = trimmed.replace(/^\//, '');
24
+ for (const c of commands) {
25
+ if (c.match(command)) {
26
+ return { kind: 'app', name: c.name, cmd: command };
27
+ }
28
+ }
29
+ const output = getOutput(command);
30
+ if (output !== null) {
31
+ // `getOutput` returns the "Unknown command: ..." message for anything unrecognized; surface
32
+ // that as `unknown` so the interactive dispatcher can run command recognition on it.
33
+ const kind = output.startsWith('Unknown command:') ? 'unknown' : 'output';
34
+ return { kind, cmd: command, output };
35
+ }
36
+ // Shell commands require the `shell` keyword (handled above); a bare non-built-in is
37
+ // reported as unknown rather than auto-run in the shell.
38
+ return { kind: 'empty' };
39
+ }
@@ -0,0 +1,11 @@
1
+ import { analyzeCommand } from './recognizers/index.js';
2
+ // Whether `input` resolves to a route (and if so, which one). A bare 'db' target auto-resolves
3
+ // when exactly one db is open — the caller only needs to prompt when there's a real ambiguity.
4
+ export function resolveRouteChoice(input, openDbs) {
5
+ const decision = analyzeCommand(input, { openDbs });
6
+ if (decision.kind !== 'route' || (decision.route === 'db' && openDbs.length !== 1))
7
+ return undefined;
8
+ return decision.route === 'db'
9
+ ? { label: '', route: 'db', dbName: openDbs[0] }
10
+ : { label: '', route: decision.route };
11
+ }
@@ -0,0 +1,217 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { SANDBOX_PROFILE, SANDBOX_PROFILE_OFFLINE } from './profile.js';
6
+ import { HOME_WRITE_CARVEOUTS, HOME_READ_CARVEINS, SECRET_DENY_PATHS, HOME_READ_LISTING_DIRS, HOME_WRITE_PREFIX_CARVEOUTS, WRITE_CARVEOUT_PARAMS, READ_CARVEIN_PARAMS, SECRET_DENY_PARAMS, LISTING_DIR_PARAMS, WRITE_PREFIX_PARAMS, ENV_SCRUB_PATTERNS, } from './paths.js';
7
+ import { getConfig } from '../config.js';
8
+ let cachedAvailable;
9
+ // Whether Seatbelt sandboxing can be applied on this machine: darwin, with `sandbox-exec` on
10
+ // `PATH`. Cached after the first check.
11
+ export function sandboxAvailable() {
12
+ cachedAvailable ??= process.platform === 'darwin' && existsSync('/usr/bin/sandbox-exec');
13
+ return cachedAvailable;
14
+ }
15
+ // A one-line notice to append to a newly created workspaced tab's transcript when its processes
16
+ // will NOT actually be confined — either the config toggle is off, or `sandbox-exec` isn't
17
+ // available on this machine. Undefined when sandboxing is active, so callers only surface it when
18
+ // there's something to say.
19
+ export function sandboxNotice() {
20
+ if (!getConfig().sandboxWorkspaces)
21
+ return 'workspace isolation off: sandboxWorkspaces disabled in config';
22
+ if (!sandboxAvailable())
23
+ return 'workspace isolation off: sandbox-exec unavailable';
24
+ return undefined;
25
+ }
26
+ // Resolve a path through any symlinks (macOS's `/tmp` → `/private/tmp` being the common case) —
27
+ // Seatbelt's `subpath` rules match against the resolved path, so an unresolved path silently
28
+ // fails to carve in. Falls back to the input path if it doesn't exist (yet).
29
+ function resolvePath(p) {
30
+ try {
31
+ return realpathSync(p);
32
+ }
33
+ catch {
34
+ return p;
35
+ }
36
+ }
37
+ let cachedDarwinUserCacheDir;
38
+ // The real per-user `/var/folders/<xx>/<hash>/C/` cache directory macOS's `confstr(3)` hands out —
39
+ // NOT the same as `$TMPDIR`, which `sandboxSpawn` overrides to a workspace-local path below, and
40
+ // NOT its `.../T/` (temp) sibling, which stays denied (that's where `os.tmpdir()`-based scratch
41
+ // dirs land — carving it in too would let a sandboxed process write anywhere a plain `mktemp`
42
+ // call resolves to, defeating the outside-the-workspace write deny). System frameworks look the
43
+ // cache path up directly via `confstr`, bypassing our `TMPDIR` override entirely, and write
44
+ // lock/cache files into it regardless (e.g. Security.framework's legacy MDS subsystem locks
45
+ // `.../C/mds/mds.lock` on every `SecItemCopyMatching` call — denied, the call silently fails
46
+ // rather than erroring, so a sandboxed harness reads back "not logged in" even with a valid
47
+ // Keychain item). Cached: it's fixed for the life of the host process.
48
+ function darwinUserCacheDir() {
49
+ if (cachedDarwinUserCacheDir)
50
+ return cachedDarwinUserCacheDir;
51
+ try {
52
+ const cacheDir = execFileSync('getconf', ['DARWIN_USER_CACHE_DIR']).toString().trim();
53
+ cachedDarwinUserCacheDir = resolvePath(cacheDir);
54
+ }
55
+ catch {
56
+ cachedDarwinUserCacheDir = '/nonexistent-janissary-darwin-user-cache-dir-placeholder';
57
+ }
58
+ return cachedDarwinUserCacheDir;
59
+ }
60
+ let cachedClaudeScratchDir;
61
+ // The harness CLI's own per-user scratch tree, `/private/tmp/claude-<uid>/`, under which it creates
62
+ // a per-project/session scratchpad directory before running any tool — including a plain shell
63
+ // command. This is a fixed, UID-keyed path entirely outside `$HOME`, our workspace-local `TMPDIR`
64
+ // override, and the Darwin user cache dir above, so without carving it in, every tool invocation
65
+ // inside a sandboxed harness session fails at that housekeeping step, before the tool's own command
66
+ // ever runs. Cached: the UID is fixed for the life of the host process.
67
+ function claudeScratchDir() {
68
+ if (cachedClaudeScratchDir)
69
+ return cachedClaudeScratchDir;
70
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
71
+ cachedClaudeScratchDir = resolvePath(`/private/tmp/claude-${uid}`);
72
+ return cachedClaudeScratchDir;
73
+ }
74
+ // Walk up from a resolved executable's path to the nearest ancestor directory literally named
75
+ // `node_modules`, if any. A globally-installed npm package (`npm` itself included) is laid out as
76
+ // `.../node_modules/<pkg>/bin/<script>`, where the script requires sibling files from `<pkg>/lib/`
77
+ // and beyond — carving in only the immediate `bin/` directory (as for a single bundled binary like
78
+ // claude.exe) leaves those sibling requires denied. Carving in the whole `node_modules/` directory
79
+ // instead covers every globally-installed package uniformly (not just the one being run), which is
80
+ // harmless — it's still just $HOME-scoped code, not secrets. Falls back to the script's own
81
+ // directory when there's no `node_modules` ancestor (a single-file bundled binary).
82
+ function packageRootDir(resolvedBin) {
83
+ let dir = path.dirname(resolvedBin);
84
+ for (;;) {
85
+ if (path.basename(dir) === 'node_modules')
86
+ return dir;
87
+ const parent = path.dirname(dir);
88
+ if (parent === dir)
89
+ return path.dirname(resolvedBin);
90
+ dir = parent;
91
+ }
92
+ }
93
+ // Resolve `command` to an absolute path the way `execvp`/`posix_spawn` would (a `PATH` search for a
94
+ // bare name, or the path itself if it already contains a separator) so its containing directory can
95
+ // be carved into the read allow-list. Without this, a harness binary installed under `$HOME` (e.g.
96
+ // via nvm, or `~/.opencode/bin`) can't read its own executable — which some system frameworks need
97
+ // to do internally (Keychain's `SecItemCopyMatching` calls `CFBundleGetMainBundle`, which reopens the
98
+ // calling process's own binary and its directory to determine code identity for ACL matching; denied,
99
+ // the harness looks logged out even though its Keychain item is intact). Returns both the literal
100
+ // (unresolved — e.g. many npm-global installs are a `bin/foo` symlink into `lib/node_modules/...`)
101
+ // and fully realpath-resolved directory (widened to the enclosing `node_modules/` via
102
+ // `packageRootDir` above, for packages — like npm — that are more than a single bundled file), same
103
+ // dual reasoning as `dualParams` in sandbox-profile.ts: a framework may `opendir`/`lstat` the
104
+ // symlink's own directory as well as the resolved target's. Falls back to a path that matches
105
+ // nothing so the profile's params always have a bound value.
106
+ function resolveExecutableDirs(command) {
107
+ const fallback = '/nonexistent-janissary-self-bin-placeholder';
108
+ const literalBin = command.includes('/')
109
+ ? (existsSync(command) ? command : undefined)
110
+ : (process.env.PATH ?? '').split(':').filter(Boolean)
111
+ .map((dir) => path.join(dir, command))
112
+ .find((candidate) => existsSync(candidate));
113
+ if (!literalBin)
114
+ return { literal: fallback, real: fallback };
115
+ return { literal: path.dirname(literalBin), real: packageRootDir(resolvePath(literalBin)) };
116
+ }
117
+ // The directory of the Node binary currently running the janissary server itself
118
+ // (`process.execPath`), in both literal and realpath-resolved form (same dual reasoning as
119
+ // `resolveExecutableDirs` above — an nvm-managed `node` is commonly a symlink). Carved into the
120
+ // read allow-list so a script running inside the sandbox (e.g. a project's own `.claude/settings.json`
121
+ // hook) can reliably invoke a known-good `node` via the `JANISSARY_NODE` env var below, instead of
122
+ // hoping a bare `node` on the sandboxed process's PATH resolves to a working binary — PATH
123
+ // resolution order inside a spawned/hook context doesn't always match the server's own.
124
+ function serverNodeDirs() {
125
+ const execPath = process.execPath;
126
+ return { literal: path.dirname(execPath), real: path.dirname(resolvePath(execPath)) };
127
+ }
128
+ // The workspace clone's own git objects directory. Falls back to a parent repo's real objects
129
+ // directory via a `--shared` alternates file, if one is present (older, locally-shared clones) —
130
+ // today's independent clones of `origin` have no alternates file, so this always hits the fallback.
131
+ function parentGitObjectsDir(workspaceDir) {
132
+ const fallback = path.join(workspaceDir, '.git', 'objects');
133
+ try {
134
+ const alternatesPath = path.join(workspaceDir, '.git', 'objects', 'info', 'alternates');
135
+ const target = readFileSync(alternatesPath, 'utf8').trim().split('\n', 1)[0];
136
+ return target ? resolvePath(target) : fallback;
137
+ }
138
+ catch {
139
+ return fallback;
140
+ }
141
+ }
142
+ // Drop credential-shaped vars and agent-socket escape vectors (see `sandbox-profile.ts`).
143
+ function scrubEnv(env) {
144
+ const scrubbed = {};
145
+ for (const [key, value] of Object.entries(env)) {
146
+ if (ENV_SCRUB_PATTERNS.some((pattern) => pattern.test(key)))
147
+ continue;
148
+ scrubbed[key] = value;
149
+ }
150
+ return scrubbed;
151
+ }
152
+ // `-D <param>=<path>` for each home-relative table entry, in both its literal (`~/…`, as named —
153
+ // covers an `lstat`/`readlink` of a symlinked dotfile) and fully realpath-resolved (covers a
154
+ // `read`/`open` that follows the symlink) forms. See the comment on `dualParams` in
155
+ // sandbox-profile.ts for why both are needed.
156
+ function homeDParams(home, relPaths, params) {
157
+ const args = [];
158
+ for (const [i, rel] of relPaths.entries()) {
159
+ const literalPath = path.join(home, rel);
160
+ args.push('-D', `${params.literal[i]}=${literalPath}`, '-D', `${params.real[i]}=${resolvePath(literalPath)}`);
161
+ }
162
+ return args;
163
+ }
164
+ // Wrap a spawn invocation (`command` + `args` — the same shape `child_process.spawn`/node-pty's
165
+ // `spawn` take) for a workspaced tab. Returns the input unchanged when there's nothing to
166
+ // sandbox: no `workspaceDir`, the `sandboxWorkspaces` config toggle is off, or `sandbox-exec`
167
+ // isn't available (e.g. non-darwin). Otherwise returns `sandbox-exec -p <profile> -D … -- <command>
168
+ // <args>` plus a credential-scrubbed environment with `TMPDIR` set to the workspace's private
169
+ // temp dir.
170
+ export function sandboxSpawn(options, command, args, env = process.env) {
171
+ if (!options.workspaceDir || !getConfig().sandboxWorkspaces || !sandboxAvailable()) {
172
+ return { command, args, env };
173
+ }
174
+ const workspaceDir = resolvePath(options.workspaceDir);
175
+ const tmpDir = resolvePath(`${options.workspaceDir}.tmp`);
176
+ const home = resolvePath(homedir());
177
+ const gitObjects = parentGitObjectsDir(options.workspaceDir);
178
+ const selfDirs = resolveExecutableDirs(options.selfBinaryHint ?? command);
179
+ const darwinCacheDir = darwinUserCacheDir();
180
+ const scratchDir = claudeScratchDir();
181
+ const serverNodeDir = serverNodeDirs();
182
+ const scrubbed = scrubEnv(env);
183
+ scrubbed.TMPDIR = tmpDir;
184
+ scrubbed.JANISSARY_NODE = process.execPath;
185
+ if (options.githubToken) {
186
+ scrubbed.GH_TOKEN = options.githubToken;
187
+ // `gh` reads `~/.config/gh/hosts.yml` on every invocation regardless of `GH_TOKEN`, and its
188
+ // config loader treats the sandbox's EPERM deny on that file (see SECRET_DENY_PATHS in
189
+ // sandbox-profile.ts) as fatal, refusing to run at all. Pointing `GH_CONFIG_DIR` at an empty,
190
+ // workspace-private directory instead gives `gh` a genuinely absent hosts.yml (real ENOENT),
191
+ // which it handles by falling through to `GH_TOKEN` normally.
192
+ scrubbed.GH_CONFIG_DIR = path.join(tmpDir, 'gh-config');
193
+ }
194
+ const profile = options.offline ? SANDBOX_PROFILE_OFFLINE : SANDBOX_PROFILE;
195
+ const dParams = [
196
+ '-D', `WORKSPACE=${workspaceDir}`,
197
+ '-D', `TMPDIR=${tmpDir}`,
198
+ '-D', `HOME=${home}`,
199
+ '-D', `GIT_OBJECTS=${gitObjects}`,
200
+ '-D', `SELF_DIR_L=${selfDirs.literal}`,
201
+ '-D', `SELF_DIR_R=${selfDirs.real}`,
202
+ '-D', `DARWIN_USER_CACHE_DIR=${darwinCacheDir}`,
203
+ '-D', `CLAUDE_SCRATCH_DIR=${scratchDir}`,
204
+ '-D', `SERVER_NODE_DIR_L=${serverNodeDir.literal}`,
205
+ '-D', `SERVER_NODE_DIR_R=${serverNodeDir.real}`,
206
+ ...homeDParams(home, HOME_WRITE_CARVEOUTS, WRITE_CARVEOUT_PARAMS),
207
+ ...homeDParams(home, HOME_READ_CARVEINS, READ_CARVEIN_PARAMS),
208
+ ...homeDParams(home, SECRET_DENY_PATHS, SECRET_DENY_PARAMS),
209
+ ...homeDParams(home, HOME_READ_LISTING_DIRS, LISTING_DIR_PARAMS),
210
+ ...homeDParams(home, HOME_WRITE_PREFIX_CARVEOUTS, WRITE_PREFIX_PARAMS),
211
+ ];
212
+ return {
213
+ command: 'sandbox-exec',
214
+ args: ['-p', profile, ...dParams, '--', command, ...args],
215
+ env: scrubbed,
216
+ };
217
+ }
@@ -0,0 +1,178 @@
1
+ // Table-driven path/env lists the Seatbelt profile in sandbox-profile.ts is built from.
2
+ // Extending any restriction is a one-line table change here.
3
+ // Narrow write carve-outs: harness auth/state directories and package-manager cache subpaths —
4
+ // never the whole `~/.claude`, `~/.cache`, or `~/.npm` (broad cache write access would let a
5
+ // sandboxed agent poison packages/plugins that other, non-sandboxed processes later consume).
6
+ // The `.claude/` entries are the session-state paths a sandboxed `claude` writes while running:
7
+ // transcripts, session env, task/todo/job state, shell snapshots, edit history, debug logs,
8
+ // plans, paste cache, and its stats/statsig telemetry caches. Deliberately absent: `.claude/plugins`
9
+ // (write access would let a sandboxed agent replace plugin code the unsandboxed CLI later runs —
10
+ // reads are carved in below), `daemon`/`ide`/`backups`, and the auto-updater's state files.
11
+ export const HOME_WRITE_CARVEOUTS = [
12
+ '.claude/projects',
13
+ '.claude/session-env',
14
+ '.claude/sessions',
15
+ '.claude/tasks',
16
+ '.claude/todos',
17
+ '.claude/jobs',
18
+ '.claude/shell-snapshots',
19
+ '.claude/file-history',
20
+ '.claude/history.jsonl',
21
+ '.claude/debug',
22
+ '.claude/plans',
23
+ '.claude/paste-cache',
24
+ '.claude/cache',
25
+ '.claude/statsig',
26
+ '.claude/stats-cache.json',
27
+ '.claude/mcp-needs-auth-cache.json',
28
+ '.claude.json',
29
+ '.codex',
30
+ '.config/opencode',
31
+ '.local/share/opencode',
32
+ '.local/state/opencode',
33
+ '.npm/_cacache',
34
+ '.npm/_logs',
35
+ '.npm/_npx',
36
+ '.cache/pip',
37
+ '.cache/yarn',
38
+ ];
39
+ // Read carve-ins: the write carve-outs (a harness needs to read its own state), plus read-only
40
+ // extras — `.claude/settings.json` and the customization dirs a sandboxed `claude` loads on
41
+ // startup but must not modify (`plugins`, `skills`, `agents`, `commands`, `keybindings.json`),
42
+ // `.gitconfig` and `.gitexcludes` (the latter is whatever `core.excludesfile`
43
+ // in the former points to; every git invocation reads both), `.config/gh/config.yml` (`gh`'s
44
+ // general settings — git_protocol, editor, prompt — as opposed to `hosts.yml`, which can hold a
45
+ // plaintext OAuth token and stays denied via `SECRET_DENY_PATHS`; a workspaced `gh` authenticates
46
+ // via the injected `GH_TOKEN` env var instead, see `github-token.ts`), `Library/Keychains` (see
47
+ // the comment on `SECRET_DENY_PATHS` — read access is needed for any Keychain lookup to work at all
48
+ // on this OS, including harness login), `.nvm` (nvm's own `nvm.sh`/`bash_completion` loader
49
+ // scripts plus every installed Node version's binaries and libs under `versions/` — broader than
50
+ // SERVER_NODE_DIR_L/R in sandbox.ts, which only carves in the one version the janissary server
51
+ // itself runs on; a sandboxed process that sources `nvm.sh` or shells out to a bare `node`/`npm`/
52
+ // `npx`, or to a harness installed under a different nvm-managed version, needs to read the whole
53
+ // tree. Execute access for these binaries doesn't need a separate carve-in — `process-exec` is
54
+ // allowed everywhere except `/tmp`/`/private/tmp`, so this read carve-in is the only gap), `.rvm`
55
+ // (same reasoning as `.nvm`, for Ruby: `rvm`'s own loader script plus every installed Ruby
56
+ // version's binaries and gems under `rubies/`/`gems/`), and `.bash_profile`/`.bashrc` (a
57
+ // login/interactive bash shell sources these on startup; without the carve-in, spawning
58
+ // `bash -lc` inside the sandbox fails that read silently and the shell starts with none of the
59
+ // user's aliases/functions/PATH additions).
60
+ export const HOME_READ_CARVEINS = [
61
+ ...HOME_WRITE_CARVEOUTS,
62
+ '.claude/settings.json', '.claude/plugins', '.claude/skills', '.claude/agents', '.claude/commands', '.claude/keybindings.json',
63
+ '.gitconfig', '.gitexcludes', '.config/gh/config.yml', 'Library/Keychains', '.nvm', '.rvm',
64
+ '.bash_profile', '.bashrc',
65
+ ];
66
+ // Directories where `opendir`/`readdir` must work even though most of their *contents* aren't
67
+ // separately carved in. `.claude` itself: a settings read-modify-write (e.g. `claude --effort`, or
68
+ // any tool following this project's own "read raw settings before writing" convention) opens the
69
+ // directory node itself to enumerate settings files before touching `settings.json` — a plain
70
+ // `subpath` carve-in of the file alone only covers `open()`ing that one path, not the parent
71
+ // directory's `file-read-data` (Seatbelt's listing operation, distinct from the `file-read-metadata`
72
+ // stat access already allowed HOME-wide — see the deny-then-carve-in comment in sandbox-profile.ts).
73
+ // These use `literal`, not `subpath`: the directory node itself becomes listable, but files inside
74
+ // it stay gated by the specific carve-ins in HOME_READ_CARVEINS above (or stay denied).
75
+ export const HOME_READ_LISTING_DIRS = ['.claude'];
76
+ // Write carve-outs matched by Seatbelt `prefix` (string prefix on the full path) rather than
77
+ // `subpath` (path-component match) or `literal` (exact match). `.claude/settings.json` itself is
78
+ // deliberately absent from HOME_WRITE_CARVEOUTS above — but a settings read-modify-write (e.g.
79
+ // `claude --effort`) writes it via an atomic-rename pattern: create a sibling temp file
80
+ // (`settings.json.tmp.<pid>.<random>`, a fresh, unpredictable name every write), fsync it, then
81
+ // rename it over the target. `subpath`/`literal` can't carve in a name that doesn't exist yet and
82
+ // varies per write; `prefix` matches both the base filename and every `<base>.tmp.*` sibling
83
+ // because the base string is a literal character-prefix of each. Narrower than carving in the
84
+ // whole `.claude` directory for writes (still deliberately avoided, per the comment on
85
+ // HOME_WRITE_CARVEOUTS above) — this only ever matches paths starting with the exact
86
+ // `settings.json` string.
87
+ //
88
+ // `Library/Keychains/login.keychain-db` is here for the same atomic-write reason, and is the fix
89
+ // for a specific failure mode: OAuth-based harness auth (e.g. Claude Code) reads its credential
90
+ // from the login Keychain fine — reads are carved in via HOME_READ_CARVEINS — but when the access
91
+ // token expires mid-session, persisting the *refreshed* token back is a Keychain write, which hit
92
+ // the top-level deny-default and silently failed, so the stale expired token kept being sent and
93
+ // the provider returned 401 invalid-credentials. The write lands as an atomic-rename temp sibling
94
+ // `login.keychain-db.sb-<random>` (confirmed in the Sandbox deny log: `file-write-create
95
+ // .../login.keychain-db.sb-151e5e06-tFbiQ8`), so a `subpath`/`literal` carve-in of the base name
96
+ // can't cover it — `prefix` does, and also naturally covers the DB's SQLite sidecars
97
+ // (`login.keychain-db-wal`/`-shm`/`-journal`), all of which share the base as a character-prefix.
98
+ // Deliberately the login keychain DB only, not the whole `~/Library/Keychains` subtree: the DB
99
+ // stays encrypted and per-item ACL-enforced by securityd regardless of raw file writability (same
100
+ // argument the read carve-in rests on — see SECRET_DENY_PATHS), and the narrow prefix keeps a
101
+ // sandboxed process from tampering with unrelated keychain files. If a data-protection keychain
102
+ // (`<UUID>/keychain-2.db`) is ever in play, its own write deny will surface separately in the log.
103
+ export const HOME_WRITE_PREFIX_CARVEOUTS = ['.claude/settings.json', 'Library/Keychains/login.keychain-db'];
104
+ // Secret paths denied last, even inside a carve-in.
105
+ //
106
+ // `Library/Keychains` is deliberately NOT here: reads are carved in (HOME_READ_CARVEINS) and one
107
+ // narrow write is carved in (HOME_WRITE_PREFIX_CARVEOUTS covers `login.keychain-db` and its
108
+ // atomic-write/SQLite siblings, so a harness can persist a refreshed OAuth token — see that
109
+ // comment); every other write under the subtree stays denied by the top-level deny-default. Even
110
+ // "modern" Keychain Services calls (SecItemCopyMatching) fall through to
111
+ // a legacy CDSA/MDS implementation on this OS that reads the keychain DB file directly rather than
112
+ // only talking to securityd over IPC; denying that read blocks every keychain lookup a sandboxed
113
+ // harness makes, including its own OAuth credential, and it shows up as "not logged in" rather
114
+ // than a permission error. The DB stays encrypted and per-item ACL-enforced by securityd regardless
115
+ // of raw file readability, so this doesn't hand out plaintext secrets — it's a materially larger
116
+ // read surface than the other entries here, but the alternative breaks harness login outright.
117
+ //
118
+ // `.config/gh/hosts.yml`'s deny below now reads as ENOENT too (see the errno qualifier on the
119
+ // final deny rule in buildProfile), which is exactly what `gh` needs — but `sandbox.ts` still
120
+ // separately points `GH_CONFIG_DIR` at an empty, workspace-writable directory whenever a scoped
121
+ // token is injected, so `gh` finds a real, uncomplicated absent `hosts.yml` there rather than
122
+ // depending on this deny rule's errno behavior. Kept as defense in depth: `gh`'s Go config loader
123
+ // treats any read error there as fatal, so relying on a single mechanism to keep that read from
124
+ // ever mattering felt worth avoiding.
125
+ export const SECRET_DENY_PATHS = [
126
+ // On installs without Keychain access (Linux, some containers) this file holds the harness
127
+ // OAuth token in plaintext. Already outside every carve-in above, but denied explicitly so a
128
+ // future widening of the `.claude/` entries can't silently expose it.
129
+ '.claude/.credentials.json',
130
+ '.ssh', '.aws', '.gnupg', '.kube', '.netrc', '.config/gh/hosts.yml', '.docker',
131
+ '.config/gcloud', '.azure', '.cargo/credentials', '.cargo/credentials.toml',
132
+ '.pypirc', '.m2/settings.xml', '.terraform.d',
133
+ '.bash_history', '.zsh_history', '.python_history', '.node_repl_history',
134
+ 'Library/Application Support/Google/Chrome',
135
+ 'Library/Application Support/Firefox',
136
+ 'Library/Application Support/BraveSoftware',
137
+ 'Library/Safari',
138
+ ];
139
+ // Env vars scrubbed from a workspaced process's environment: credential-shaped vars and
140
+ // agent-socket / credential-helper escape vectors that bypass the file-read denies above (e.g.
141
+ // `SSH_AUTH_SOCK` lets a process use the user's SSH keys without ever reading `~/.ssh`). LLM
142
+ // provider keys (`ANTHROPIC_*`, `OPENAI_*`, `GEMINI_*`/`GOOGLE_*`) are deliberately NOT matched —
143
+ // the harnesses and the ACP agent need their own credentials to function.
144
+ export const ENV_SCRUB_PATTERNS = [
145
+ /^AWS_/, /^GITHUB_TOKEN$/, /^GH_TOKEN$/, /^NPM_TOKEN$/, /^DOCKER_/, /^KUBECONFIG$/,
146
+ /_SECRET$/, /_PASSWORD$/,
147
+ /^SSH_AUTH_SOCK$/, /^GPG_AGENT_INFO$/, /^GNUPGHOME$/,
148
+ /^GIT_ASKPASS$/, /^GIT_CREDENTIAL_HELPER$/, /^KRB5CCNAME$/,
149
+ ];
150
+ function paramName(prefix, index) {
151
+ return `${prefix}${index}`;
152
+ }
153
+ // Two `-D` param names per table entry, in the same order — `sandbox.ts` pairs these with the
154
+ // literal (`~/…`, unresolved beyond `$HOME` itself) and fully realpath-resolved forms of the same
155
+ // path. Both are needed: many dotfiles (`.gitconfig`, `.npmrc`, …) are themselves symlinks (e.g.
156
+ // managed by a dotfile manager), and Seatbelt evaluates an `lstat`/`readlink` of the symlink node
157
+ // against its literal path but a `read`/`open` that follows it against the resolved target —
158
+ // carving in only one leaves the other operation denied.
159
+ export function dualParams(prefix, count) {
160
+ return {
161
+ literal: Array.from({ length: count }, (_, i) => paramName(`${prefix}L`, i)),
162
+ real: Array.from({ length: count }, (_, i) => paramName(`${prefix}R`, i)),
163
+ };
164
+ }
165
+ export const WRITE_CARVEOUT_PARAMS = dualParams('W', HOME_WRITE_CARVEOUTS.length);
166
+ export const READ_CARVEIN_PARAMS = dualParams('R', HOME_READ_CARVEINS.length);
167
+ export const SECRET_DENY_PARAMS = dualParams('S', SECRET_DENY_PATHS.length);
168
+ export const LISTING_DIR_PARAMS = dualParams('D', HOME_READ_LISTING_DIRS.length);
169
+ export const WRITE_PREFIX_PARAMS = dualParams('P', HOME_WRITE_PREFIX_CARVEOUTS.length);
170
+ export const clausesFor = (params) => [...params.literal, ...params.real].map((p) => ` (subpath (param "${p}"))`).join('\n');
171
+ // Same shape as `clausesFor`, but matches only the exact path (Seatbelt `literal`) — used for
172
+ // HOME_READ_LISTING_DIRS, where a directory node must be listable without granting recursive
173
+ // access to everything under it (that's what `subpath` would do).
174
+ export const literalClausesFor = (params) => [...params.literal, ...params.real].map((p) => ` (literal (param "${p}"))`).join('\n');
175
+ // Same shape again, but Seatbelt `prefix` (string-prefix match) — used for
176
+ // HOME_WRITE_PREFIX_CARVEOUTS, where the writable path's exact name isn't known ahead of time
177
+ // (a randomly-named atomic-write temp sibling of a fixed base filename).
178
+ export const prefixClausesFor = (params) => [...params.literal, ...params.real].map((p) => ` (prefix (param "${p}"))`).join('\n');