@cdoing/core 0.1.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 (378) hide show
  1. package/dist/agents/coordinator.d.ts +114 -0
  2. package/dist/agents/coordinator.d.ts.map +1 -0
  3. package/dist/agents/coordinator.js +158 -0
  4. package/dist/agents/coordinator.js.map +1 -0
  5. package/dist/context-providers/clipboard.d.ts +13 -0
  6. package/dist/context-providers/clipboard.d.ts.map +1 -0
  7. package/dist/context-providers/clipboard.js +53 -0
  8. package/dist/context-providers/clipboard.js.map +1 -0
  9. package/dist/context-providers/codebase.d.ts +46 -0
  10. package/dist/context-providers/codebase.d.ts.map +1 -0
  11. package/dist/context-providers/codebase.js +273 -0
  12. package/dist/context-providers/codebase.js.map +1 -0
  13. package/dist/context-providers/diff.d.ts +18 -0
  14. package/dist/context-providers/diff.d.ts.map +1 -0
  15. package/dist/context-providers/diff.js +63 -0
  16. package/dist/context-providers/diff.js.map +1 -0
  17. package/dist/context-providers/docs.d.ts +21 -0
  18. package/dist/context-providers/docs.d.ts.map +1 -0
  19. package/dist/context-providers/docs.js +180 -0
  20. package/dist/context-providers/docs.js.map +1 -0
  21. package/dist/context-providers/file-include.d.ts +13 -0
  22. package/dist/context-providers/file-include.d.ts.map +1 -0
  23. package/dist/context-providers/file-include.js +82 -0
  24. package/dist/context-providers/file-include.js.map +1 -0
  25. package/dist/context-providers/folder.d.ts +19 -0
  26. package/dist/context-providers/folder.d.ts.map +1 -0
  27. package/dist/context-providers/folder.js +130 -0
  28. package/dist/context-providers/folder.js.map +1 -0
  29. package/dist/context-providers/git.d.ts +19 -0
  30. package/dist/context-providers/git.d.ts.map +1 -0
  31. package/dist/context-providers/git.js +74 -0
  32. package/dist/context-providers/git.js.map +1 -0
  33. package/dist/context-providers/index.d.ts +26 -0
  34. package/dist/context-providers/index.d.ts.map +1 -0
  35. package/dist/context-providers/index.js +37 -0
  36. package/dist/context-providers/index.js.map +1 -0
  37. package/dist/context-providers/open-files.d.ts +25 -0
  38. package/dist/context-providers/open-files.d.ts.map +1 -0
  39. package/dist/context-providers/open-files.js +134 -0
  40. package/dist/context-providers/open-files.js.map +1 -0
  41. package/dist/context-providers/problems.d.ts +24 -0
  42. package/dist/context-providers/problems.d.ts.map +1 -0
  43. package/dist/context-providers/problems.js +97 -0
  44. package/dist/context-providers/problems.js.map +1 -0
  45. package/dist/context-providers/registry.d.ts +61 -0
  46. package/dist/context-providers/registry.d.ts.map +1 -0
  47. package/dist/context-providers/registry.js +92 -0
  48. package/dist/context-providers/registry.js.map +1 -0
  49. package/dist/context-providers/terminal.d.ts +25 -0
  50. package/dist/context-providers/terminal.d.ts.map +1 -0
  51. package/dist/context-providers/terminal.js +55 -0
  52. package/dist/context-providers/terminal.js.map +1 -0
  53. package/dist/context-providers/tree.d.ts +29 -0
  54. package/dist/context-providers/tree.d.ts.map +1 -0
  55. package/dist/context-providers/tree.js +172 -0
  56. package/dist/context-providers/tree.js.map +1 -0
  57. package/dist/context-providers/types.d.ts +72 -0
  58. package/dist/context-providers/types.d.ts.map +1 -0
  59. package/dist/context-providers/types.js +10 -0
  60. package/dist/context-providers/types.js.map +1 -0
  61. package/dist/context-providers/url.d.ts +27 -0
  62. package/dist/context-providers/url.d.ts.map +1 -0
  63. package/dist/context-providers/url.js +131 -0
  64. package/dist/context-providers/url.js.map +1 -0
  65. package/dist/effort/index.d.ts +78 -0
  66. package/dist/effort/index.d.ts.map +1 -0
  67. package/dist/effort/index.js +146 -0
  68. package/dist/effort/index.js.map +1 -0
  69. package/dist/hooks/index.d.ts +47 -0
  70. package/dist/hooks/index.d.ts.map +1 -0
  71. package/dist/hooks/index.js +151 -0
  72. package/dist/hooks/index.js.map +1 -0
  73. package/dist/index.d.ts +75 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/index.js +152 -0
  76. package/dist/index.js.map +1 -0
  77. package/dist/indexing/chunker.d.ts +25 -0
  78. package/dist/indexing/chunker.d.ts.map +1 -0
  79. package/dist/indexing/chunker.js +217 -0
  80. package/dist/indexing/chunker.js.map +1 -0
  81. package/dist/indexing/database.d.ts +49 -0
  82. package/dist/indexing/database.d.ts.map +1 -0
  83. package/dist/indexing/database.js +287 -0
  84. package/dist/indexing/database.js.map +1 -0
  85. package/dist/indexing/index.d.ts +9 -0
  86. package/dist/indexing/index.d.ts.map +1 -0
  87. package/dist/indexing/index.js +13 -0
  88. package/dist/indexing/index.js.map +1 -0
  89. package/dist/indexing/indexer.d.ts +63 -0
  90. package/dist/indexing/indexer.d.ts.map +1 -0
  91. package/dist/indexing/indexer.js +352 -0
  92. package/dist/indexing/indexer.js.map +1 -0
  93. package/dist/indexing/recent-edits-cache.d.ts +77 -0
  94. package/dist/indexing/recent-edits-cache.d.ts.map +1 -0
  95. package/dist/indexing/recent-edits-cache.js +123 -0
  96. package/dist/indexing/recent-edits-cache.js.map +1 -0
  97. package/dist/indexing/types.d.ts +39 -0
  98. package/dist/indexing/types.d.ts.map +1 -0
  99. package/dist/indexing/types.js +6 -0
  100. package/dist/indexing/types.js.map +1 -0
  101. package/dist/mcp/index.d.ts +33 -0
  102. package/dist/mcp/index.d.ts.map +1 -0
  103. package/dist/mcp/index.js +37 -0
  104. package/dist/mcp/index.js.map +1 -0
  105. package/dist/mcp/manager.d.ts +123 -0
  106. package/dist/mcp/manager.d.ts.map +1 -0
  107. package/dist/mcp/manager.js +331 -0
  108. package/dist/mcp/manager.js.map +1 -0
  109. package/dist/oauth.d.ts +33 -0
  110. package/dist/oauth.d.ts.map +1 -0
  111. package/dist/oauth.js +312 -0
  112. package/dist/oauth.js.map +1 -0
  113. package/dist/permissions/index.d.ts +216 -0
  114. package/dist/permissions/index.d.ts.map +1 -0
  115. package/dist/permissions/index.js +938 -0
  116. package/dist/permissions/index.js.map +1 -0
  117. package/dist/plan/index.d.ts +20 -0
  118. package/dist/plan/index.d.ts.map +1 -0
  119. package/dist/plan/index.js +24 -0
  120. package/dist/plan/index.js.map +1 -0
  121. package/dist/plan/manager.d.ts +101 -0
  122. package/dist/plan/manager.d.ts.map +1 -0
  123. package/dist/plan/manager.js +170 -0
  124. package/dist/plan/manager.js.map +1 -0
  125. package/dist/rules/index.d.ts +28 -0
  126. package/dist/rules/index.d.ts.map +1 -0
  127. package/dist/rules/index.js +31 -0
  128. package/dist/rules/index.js.map +1 -0
  129. package/dist/rules/manager.d.ts +77 -0
  130. package/dist/rules/manager.d.ts.map +1 -0
  131. package/dist/rules/manager.js +279 -0
  132. package/dist/rules/manager.js.map +1 -0
  133. package/dist/rules/types.d.ts +34 -0
  134. package/dist/rules/types.d.ts.map +1 -0
  135. package/dist/rules/types.js +9 -0
  136. package/dist/rules/types.js.map +1 -0
  137. package/dist/sandbox/filesystem.d.ts +20 -0
  138. package/dist/sandbox/filesystem.d.ts.map +1 -0
  139. package/dist/sandbox/filesystem.js +141 -0
  140. package/dist/sandbox/filesystem.js.map +1 -0
  141. package/dist/sandbox/index.d.ts +4 -0
  142. package/dist/sandbox/index.d.ts.map +1 -0
  143. package/dist/sandbox/index.js +8 -0
  144. package/dist/sandbox/index.js.map +1 -0
  145. package/dist/sandbox/manager.d.ts +47 -0
  146. package/dist/sandbox/manager.d.ts.map +1 -0
  147. package/dist/sandbox/manager.js +220 -0
  148. package/dist/sandbox/manager.js.map +1 -0
  149. package/dist/sandbox/network.d.ts +14 -0
  150. package/dist/sandbox/network.d.ts.map +1 -0
  151. package/dist/sandbox/network.js +87 -0
  152. package/dist/sandbox/network.js.map +1 -0
  153. package/dist/sandbox/types.d.ts +42 -0
  154. package/dist/sandbox/types.d.ts.map +1 -0
  155. package/dist/sandbox/types.js +25 -0
  156. package/dist/sandbox/types.js.map +1 -0
  157. package/dist/tools/ast-edit.d.ts +57 -0
  158. package/dist/tools/ast-edit.d.ts.map +1 -0
  159. package/dist/tools/ast-edit.js +443 -0
  160. package/dist/tools/ast-edit.js.map +1 -0
  161. package/dist/tools/code-verify.d.ts +8 -0
  162. package/dist/tools/code-verify.d.ts.map +1 -0
  163. package/dist/tools/code-verify.js +159 -0
  164. package/dist/tools/code-verify.js.map +1 -0
  165. package/dist/tools/codebase-search.d.ts +17 -0
  166. package/dist/tools/codebase-search.d.ts.map +1 -0
  167. package/dist/tools/codebase-search.js +104 -0
  168. package/dist/tools/codebase-search.js.map +1 -0
  169. package/dist/tools/file-delete.d.ts +26 -0
  170. package/dist/tools/file-delete.d.ts.map +1 -0
  171. package/dist/tools/file-delete.js +179 -0
  172. package/dist/tools/file-delete.js.map +1 -0
  173. package/dist/tools/file-edit.d.ts +10 -0
  174. package/dist/tools/file-edit.d.ts.map +1 -0
  175. package/dist/tools/file-edit.js +138 -0
  176. package/dist/tools/file-edit.js.map +1 -0
  177. package/dist/tools/file-read.d.ts +12 -0
  178. package/dist/tools/file-read.d.ts.map +1 -0
  179. package/dist/tools/file-read.js +211 -0
  180. package/dist/tools/file-read.js.map +1 -0
  181. package/dist/tools/file-run.d.ts +10 -0
  182. package/dist/tools/file-run.d.ts.map +1 -0
  183. package/dist/tools/file-run.js +179 -0
  184. package/dist/tools/file-run.js.map +1 -0
  185. package/dist/tools/file-write.d.ts +10 -0
  186. package/dist/tools/file-write.d.ts.map +1 -0
  187. package/dist/tools/file-write.js +134 -0
  188. package/dist/tools/file-write.js.map +1 -0
  189. package/dist/tools/glob-search.d.ts +8 -0
  190. package/dist/tools/glob-search.d.ts.map +1 -0
  191. package/dist/tools/glob-search.js +108 -0
  192. package/dist/tools/glob-search.js.map +1 -0
  193. package/dist/tools/grep-search.d.ts +8 -0
  194. package/dist/tools/grep-search.d.ts.map +1 -0
  195. package/dist/tools/grep-search.js +139 -0
  196. package/dist/tools/grep-search.js.map +1 -0
  197. package/dist/tools/list-dir.d.ts +16 -0
  198. package/dist/tools/list-dir.d.ts.map +1 -0
  199. package/dist/tools/list-dir.js +183 -0
  200. package/dist/tools/list-dir.js.map +1 -0
  201. package/dist/tools/multi-edit.d.ts +16 -0
  202. package/dist/tools/multi-edit.d.ts.map +1 -0
  203. package/dist/tools/multi-edit.js +163 -0
  204. package/dist/tools/multi-edit.js.map +1 -0
  205. package/dist/tools/notebook-edit.d.ts +31 -0
  206. package/dist/tools/notebook-edit.d.ts.map +1 -0
  207. package/dist/tools/notebook-edit.js +321 -0
  208. package/dist/tools/notebook-edit.js.map +1 -0
  209. package/dist/tools/registry.d.ts +16 -0
  210. package/dist/tools/registry.d.ts.map +1 -0
  211. package/dist/tools/registry.js +41 -0
  212. package/dist/tools/registry.js.map +1 -0
  213. package/dist/tools/shell-exec.d.ts +12 -0
  214. package/dist/tools/shell-exec.d.ts.map +1 -0
  215. package/dist/tools/shell-exec.js +261 -0
  216. package/dist/tools/shell-exec.js.map +1 -0
  217. package/dist/tools/sub-agent-manager.d.ts +57 -0
  218. package/dist/tools/sub-agent-manager.d.ts.map +1 -0
  219. package/dist/tools/sub-agent-manager.js +153 -0
  220. package/dist/tools/sub-agent-manager.js.map +1 -0
  221. package/dist/tools/sub-agent-status.d.ts +12 -0
  222. package/dist/tools/sub-agent-status.d.ts.map +1 -0
  223. package/dist/tools/sub-agent-status.js +59 -0
  224. package/dist/tools/sub-agent-status.js.map +1 -0
  225. package/dist/tools/sub-agent-terminate.d.ts +12 -0
  226. package/dist/tools/sub-agent-terminate.d.ts.map +1 -0
  227. package/dist/tools/sub-agent-terminate.js +55 -0
  228. package/dist/tools/sub-agent-terminate.js.map +1 -0
  229. package/dist/tools/sub-agent.d.ts +34 -0
  230. package/dist/tools/sub-agent.d.ts.map +1 -0
  231. package/dist/tools/sub-agent.js +140 -0
  232. package/dist/tools/sub-agent.js.map +1 -0
  233. package/dist/tools/system-info.d.ts +24 -0
  234. package/dist/tools/system-info.d.ts.map +1 -0
  235. package/dist/tools/system-info.js +220 -0
  236. package/dist/tools/system-info.js.map +1 -0
  237. package/dist/tools/todo.d.ts +16 -0
  238. package/dist/tools/todo.d.ts.map +1 -0
  239. package/dist/tools/todo.js +144 -0
  240. package/dist/tools/todo.js.map +1 -0
  241. package/dist/tools/types.d.ts +20 -0
  242. package/dist/tools/types.d.ts.map +1 -0
  243. package/dist/tools/types.js +3 -0
  244. package/dist/tools/types.js.map +1 -0
  245. package/dist/tools/view-diff.d.ts +11 -0
  246. package/dist/tools/view-diff.d.ts.map +1 -0
  247. package/dist/tools/view-diff.js +88 -0
  248. package/dist/tools/view-diff.js.map +1 -0
  249. package/dist/tools/view-repo-map.d.ts +18 -0
  250. package/dist/tools/view-repo-map.d.ts.map +1 -0
  251. package/dist/tools/view-repo-map.js +245 -0
  252. package/dist/tools/view-repo-map.js.map +1 -0
  253. package/dist/tools/web-fetch.d.ts +13 -0
  254. package/dist/tools/web-fetch.d.ts.map +1 -0
  255. package/dist/tools/web-fetch.js +106 -0
  256. package/dist/tools/web-fetch.js.map +1 -0
  257. package/dist/tools/web-search.d.ts +10 -0
  258. package/dist/tools/web-search.d.ts.map +1 -0
  259. package/dist/tools/web-search.js +106 -0
  260. package/dist/tools/web-search.js.map +1 -0
  261. package/dist/utils/gitignore.d.ts +10 -0
  262. package/dist/utils/gitignore.d.ts.map +1 -0
  263. package/dist/utils/gitignore.js +104 -0
  264. package/dist/utils/gitignore.js.map +1 -0
  265. package/dist/utils/lazy-apply.d.ts +45 -0
  266. package/dist/utils/lazy-apply.d.ts.map +1 -0
  267. package/dist/utils/lazy-apply.js +164 -0
  268. package/dist/utils/lazy-apply.js.map +1 -0
  269. package/dist/utils/memory.d.ts +36 -0
  270. package/dist/utils/memory.d.ts.map +1 -0
  271. package/dist/utils/memory.js +136 -0
  272. package/dist/utils/memory.js.map +1 -0
  273. package/dist/utils/path-matching.d.ts +24 -0
  274. package/dist/utils/path-matching.d.ts.map +1 -0
  275. package/dist/utils/path-matching.js +116 -0
  276. package/dist/utils/path-matching.js.map +1 -0
  277. package/dist/utils/path-safety.d.ts +13 -0
  278. package/dist/utils/path-safety.d.ts.map +1 -0
  279. package/dist/utils/path-safety.js +54 -0
  280. package/dist/utils/path-safety.js.map +1 -0
  281. package/dist/utils/project-config.d.ts +18 -0
  282. package/dist/utils/project-config.d.ts.map +1 -0
  283. package/dist/utils/project-config.js +76 -0
  284. package/dist/utils/project-config.js.map +1 -0
  285. package/dist/utils/search-match.d.ts +63 -0
  286. package/dist/utils/search-match.d.ts.map +1 -0
  287. package/dist/utils/search-match.js +426 -0
  288. package/dist/utils/search-match.js.map +1 -0
  289. package/dist/utils/shell-paths.d.ts +17 -0
  290. package/dist/utils/shell-paths.d.ts.map +1 -0
  291. package/dist/utils/shell-paths.js +107 -0
  292. package/dist/utils/shell-paths.js.map +1 -0
  293. package/dist/utils/streaming-diff.d.ts +45 -0
  294. package/dist/utils/streaming-diff.d.ts.map +1 -0
  295. package/dist/utils/streaming-diff.js +230 -0
  296. package/dist/utils/streaming-diff.js.map +1 -0
  297. package/dist/utils/todo.d.ts +47 -0
  298. package/dist/utils/todo.d.ts.map +1 -0
  299. package/dist/utils/todo.js +102 -0
  300. package/dist/utils/todo.js.map +1 -0
  301. package/package.json +23 -0
  302. package/src/agents/coordinator.ts +240 -0
  303. package/src/context-providers/clipboard.ts +48 -0
  304. package/src/context-providers/codebase.ts +274 -0
  305. package/src/context-providers/diff.ts +66 -0
  306. package/src/context-providers/docs.ts +160 -0
  307. package/src/context-providers/file-include.ts +54 -0
  308. package/src/context-providers/folder.ts +106 -0
  309. package/src/context-providers/git.ts +72 -0
  310. package/src/context-providers/index.ts +26 -0
  311. package/src/context-providers/open-files.ts +113 -0
  312. package/src/context-providers/problems.ts +100 -0
  313. package/src/context-providers/registry.ts +99 -0
  314. package/src/context-providers/terminal.ts +58 -0
  315. package/src/context-providers/tree.ts +161 -0
  316. package/src/context-providers/types.ts +84 -0
  317. package/src/context-providers/url.ts +138 -0
  318. package/src/effort/index.ts +177 -0
  319. package/src/hooks/index.ts +148 -0
  320. package/src/index.ts +114 -0
  321. package/src/indexing/README.md +267 -0
  322. package/src/indexing/chunker.ts +206 -0
  323. package/src/indexing/database.ts +299 -0
  324. package/src/indexing/index.ts +15 -0
  325. package/src/indexing/indexer.ts +383 -0
  326. package/src/indexing/recent-edits-cache.ts +150 -0
  327. package/src/indexing/types.ts +44 -0
  328. package/src/mcp/index.ts +33 -0
  329. package/src/mcp/manager.ts +385 -0
  330. package/src/oauth.ts +330 -0
  331. package/src/permissions/index.ts +1011 -0
  332. package/src/plan/index.ts +20 -0
  333. package/src/plan/manager.ts +233 -0
  334. package/src/rules/index.ts +28 -0
  335. package/src/rules/manager.ts +276 -0
  336. package/src/rules/types.ts +40 -0
  337. package/src/sandbox/filesystem.ts +135 -0
  338. package/src/sandbox/index.ts +9 -0
  339. package/src/sandbox/manager.ts +213 -0
  340. package/src/sandbox/network.ts +101 -0
  341. package/src/sandbox/types.ts +63 -0
  342. package/src/tools/ast-edit.ts +493 -0
  343. package/src/tools/code-verify.ts +143 -0
  344. package/src/tools/codebase-search.ts +117 -0
  345. package/src/tools/file-delete.ts +155 -0
  346. package/src/tools/file-edit.ts +115 -0
  347. package/src/tools/file-read.ts +195 -0
  348. package/src/tools/file-run.ts +158 -0
  349. package/src/tools/file-write.ts +104 -0
  350. package/src/tools/glob-search.ts +80 -0
  351. package/src/tools/grep-search.ts +120 -0
  352. package/src/tools/list-dir.ts +172 -0
  353. package/src/tools/multi-edit.ts +138 -0
  354. package/src/tools/notebook-edit.ts +342 -0
  355. package/src/tools/registry.ts +43 -0
  356. package/src/tools/shell-exec.ts +251 -0
  357. package/src/tools/sub-agent-manager.ts +183 -0
  358. package/src/tools/sub-agent-status.ts +67 -0
  359. package/src/tools/sub-agent-terminate.ts +62 -0
  360. package/src/tools/sub-agent.ts +162 -0
  361. package/src/tools/system-info.ts +248 -0
  362. package/src/tools/todo.ts +149 -0
  363. package/src/tools/types.ts +21 -0
  364. package/src/tools/view-diff.ts +99 -0
  365. package/src/tools/view-repo-map.ts +249 -0
  366. package/src/tools/web-fetch.ts +118 -0
  367. package/src/tools/web-search.ts +129 -0
  368. package/src/utils/gitignore.ts +73 -0
  369. package/src/utils/lazy-apply.ts +189 -0
  370. package/src/utils/memory.ts +124 -0
  371. package/src/utils/path-matching.ts +84 -0
  372. package/src/utils/path-safety.ts +19 -0
  373. package/src/utils/project-config.ts +41 -0
  374. package/src/utils/search-match.ts +495 -0
  375. package/src/utils/shell-paths.ts +79 -0
  376. package/src/utils/streaming-diff.ts +260 -0
  377. package/src/utils/todo.ts +115 -0
  378. package/tsconfig.json +18 -0
@@ -0,0 +1,1011 @@
1
+ /**
2
+ * Permission Manager
3
+ *
4
+ * Implements the Claude Code permission system:
5
+ * - Settings-based allow/ask/deny rules loaded from .claude/settings.json files
6
+ * - Permission modes: default, acceptEdits, plan, dontAsk, bypassPermissions
7
+ * - Rule evaluation order: deny → ask → allow (deny always wins)
8
+ * - Rule syntax: Tool, Tool(*), Bash(cmd *), Read(path), WebFetch(domain:x), Agent(name)
9
+ * - Settings precedence: local project → shared project → user (~/.claude/settings.json)
10
+ *
11
+ * Also supports legacy runtime stored rules (allow once / always / project).
12
+ *
13
+ * Backward compatible: legacy ASK / AUTO_EDIT / AUTO modes still accepted.
14
+ */
15
+
16
+ import * as readline from "readline";
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import * as os from "os";
20
+ import type { ToolDefinition } from "../tools/types";
21
+ import { matchPath } from "../utils/path-matching";
22
+ import { extractShellPaths } from "../utils/shell-paths";
23
+ import type { SandboxManager } from "../sandbox";
24
+
25
+ // ── Constants ──────────────────────────────────────────────────────────────────
26
+
27
+ const HOME_DIR = os.homedir();
28
+ const GLOBAL_CDOING_DIR = path.join(HOME_DIR, ".cdoing");
29
+ const GLOBAL_PERMISSIONS_FILE = path.join(GLOBAL_CDOING_DIR, "permissions.json");
30
+
31
+ // Settings files — check both .cdoing/ (our format) and .claude/ (Claude Code compat)
32
+ const USER_SETTINGS_FILE = path.join(HOME_DIR, ".cdoing", "settings.json");
33
+ const USER_SETTINGS_FILE_CLAUDE = path.join(HOME_DIR, ".claude", "settings.json");
34
+
35
+ // Managed settings — highest priority, cannot be overridden
36
+ // macOS: /Library/Application Support/cdoing/managed-settings.json
37
+ // Linux: /etc/cdoing/managed-settings.json
38
+ const MANAGED_SETTINGS_FILE = process.platform === "darwin"
39
+ ? "/Library/Application Support/cdoing/managed-settings.json"
40
+ : "/etc/cdoing/managed-settings.json";
41
+ const MANAGED_SETTINGS_FILE_CLAUDE = process.platform === "darwin"
42
+ ? "/Library/Application Support/claude-code/managed-settings.json"
43
+ : "/etc/claude-code/managed-settings.json";
44
+
45
+ // ── Public types ───────────────────────────────────────────────────────────────
46
+
47
+ /** A stored runtime permission rule (session / legacy format) */
48
+ export interface PermissionRule {
49
+ tool: string;
50
+ /** Optional: match only when this input value matches */
51
+ inputMatch?: string;
52
+ createdAt: string;
53
+ }
54
+
55
+ export type PermissionScope = "global" | "project";
56
+
57
+ /**
58
+ * Permission modes as defined in Claude Code docs.
59
+ * Legacy aliases (ask / auto-edit / auto) are kept for backward compatibility.
60
+ */
61
+ export enum PermissionMode {
62
+ // Canonical names
63
+ DEFAULT = "default", // prompt on first use of each tool
64
+ ACCEPT_EDITS = "acceptEdits", // auto-approve file edits, ask for shell
65
+ PLAN = "plan", // read-only: block write + exec tools
66
+ DONT_ASK = "dontAsk", // deny all unless explicitly allowed
67
+ BYPASS = "bypassPermissions", // skip all permission checks
68
+
69
+ // Legacy aliases (normalised in constructor)
70
+ ASK = "ask", // → DEFAULT
71
+ AUTO_EDIT = "auto-edit", // → ACCEPT_EDITS
72
+ AUTO = "auto", // → BYPASS
73
+ }
74
+
75
+ export type PermissionPromptFn = (
76
+ toolName: string,
77
+ message: string,
78
+ hasProject: boolean,
79
+ ) => Promise<"allow" | "always" | "project" | "deny" | "deny_always" | "deny_project">;
80
+
81
+ // ── Internal types ─────────────────────────────────────────────────────────────
82
+
83
+ interface SettingsPermissions {
84
+ allow: string[];
85
+ ask: string[];
86
+ deny: string[];
87
+ }
88
+
89
+ /** Managed-only settings that cannot be overridden */
90
+ interface ManagedOnlySettings {
91
+ disableBypassPermissionsMode?: "disable" | string;
92
+ allowManagedPermissionRulesOnly?: boolean;
93
+ allowManagedHooksOnly?: boolean;
94
+ allowManagedMcpServersOnly?: boolean;
95
+ }
96
+
97
+ // ── Tool → Rule category mapping ───────────────────────────────────────────────
98
+
99
+ /**
100
+ * Maps internal tool names to their Claude Code rule category.
101
+ * Rule category is the prefix used in rule strings: Bash(…), Read(…), etc.
102
+ */
103
+ const TOOL_CATEGORY: Record<string, string> = {
104
+ shell_exec: "Bash",
105
+ file_run: "Bash",
106
+ file_read: "Read",
107
+ glob_search: "Read",
108
+ grep_search: "Read",
109
+ file_write: "Edit",
110
+ file_edit: "Edit",
111
+ multi_edit: "Edit",
112
+ file_delete: "Delete",
113
+ web_fetch: "WebFetch",
114
+ web_search: "WebSearch",
115
+ sub_agent: "Agent",
116
+ };
117
+
118
+ /** Write/exec tools that are blocked in Plan mode */
119
+ const PLAN_BLOCKED = new Set(["shell_exec", "file_run", "file_write", "file_edit", "multi_edit", "file_delete"]);
120
+
121
+ /** File-edit tools that are auto-allowed in acceptEdits mode */
122
+ const ACCEPT_EDITS_AUTO = new Set(["file_write", "file_edit", "multi_edit"]);
123
+
124
+ /**
125
+ * Tools whose "don't ask again" approval is stored permanently to disk
126
+ * (per project directory + command). Matches the Bash row in the tiered table.
127
+ */
128
+ const PERMANENT_APPROVAL_TOOLS = new Set(["shell_exec", "file_run"]);
129
+ // File-modification tools (file_write, file_edit) use session-only approval.
130
+
131
+ // ── Rule parsing ───────────────────────────────────────────────────────────────
132
+
133
+ interface ParsedRule {
134
+ category: string; // e.g. "Bash", "Read", "Edit", "WebFetch", "Agent"
135
+ specifier: string | null; // null = match all uses of this tool
136
+ }
137
+
138
+ function parseRule(rule: string): ParsedRule | null {
139
+ const parenIdx = rule.indexOf("(");
140
+ if (parenIdx === -1) {
141
+ return { category: rule.trim(), specifier: null };
142
+ }
143
+ const closeIdx = rule.lastIndexOf(")");
144
+ if (closeIdx === -1 || closeIdx < parenIdx) return null;
145
+
146
+ const category = rule.substring(0, parenIdx).trim();
147
+ let specifier = rule.substring(parenIdx + 1, closeIdx).trim();
148
+
149
+ // Tool(*) is identical to Tool (match all)
150
+ if (specifier === "*") return { category, specifier: null };
151
+
152
+ // Normalize deprecated ":*" suffix → " *" (e.g. "git add:*" → "git add *")
153
+ if (specifier.endsWith(":*") && category !== "WebFetch") {
154
+ specifier = specifier.slice(0, -2) + " *";
155
+ }
156
+
157
+ return { category, specifier };
158
+ }
159
+
160
+ // ── Bash pattern matching ──────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Shell operator pattern — used to block wildcard rules from matching
164
+ * compound commands like "safe-cmd && evil-cmd".
165
+ */
166
+ const SHELL_OP_RE = /(?:^|[\s;])(?:&&|\|\||;(?!;)|\|)(?:[\s]|$)/;
167
+
168
+ function escapeRegex(s: string): string {
169
+ return s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
170
+ }
171
+
172
+ /**
173
+ * Match a Bash command against a specifier.
174
+ *
175
+ * Rules:
176
+ * - No wildcard → exact match only.
177
+ * - "ls *" → matches "ls -la" but NOT "lsof" (space enforces word boundary).
178
+ * - "ls*" → matches both.
179
+ * - Wildcard rules do NOT match commands that contain shell operators
180
+ * (&&, ||, ;, |) to prevent bypass via compound commands.
181
+ */
182
+ function matchBash(command: string, specifier: string): boolean {
183
+ if (!specifier.includes("*")) {
184
+ return command === specifier;
185
+ }
186
+ // Wildcard: reject compound commands
187
+ if (SHELL_OP_RE.test(command)) return false;
188
+
189
+ // Convert glob-style specifier to anchored regex
190
+ const regexStr = "^" + specifier.split("*").map(escapeRegex).join(".*") + "$";
191
+ return new RegExp(regexStr).test(command);
192
+ }
193
+
194
+ // ── WebFetch domain matching ───────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Match a URL against a WebFetch specifier.
198
+ * "domain:example.com" matches example.com and *.example.com
199
+ */
200
+ function matchWebFetch(url: string, specifier: string): boolean {
201
+ if (specifier.startsWith("domain:")) {
202
+ const domain = specifier.substring(7);
203
+ try {
204
+ const hostname = new URL(url).hostname;
205
+ return hostname === domain || hostname.endsWith("." + domain);
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+ return url === specifier;
211
+ }
212
+
213
+ // ── MCP tool matching ─────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Match an MCP tool name against a rule.
217
+ *
218
+ * MCP tools are named: mcp__<server>__<tool>
219
+ * Rules can be:
220
+ * mcp__puppeteer → matches all tools from "puppeteer" server
221
+ * mcp__puppeteer__* → same (wildcard matches all tools)
222
+ * mcp__puppeteer__navigate → matches only the "navigate" tool
223
+ */
224
+ function matchMcpTool(toolName: string, ruleCategory: string, _specifier: string | null): boolean {
225
+ // Rule without specifier (parsed from "mcp__puppeteer" or "mcp__puppeteer__*")
226
+ // ruleCategory is the full rule string when no parens are used
227
+ const ruleParts = ruleCategory.split("__");
228
+ const toolParts = toolName.split("__");
229
+
230
+ // Both must start with "mcp"
231
+ if (ruleParts[0] !== "mcp" || toolParts[0] !== "mcp") return false;
232
+
233
+ // Server must match
234
+ if (ruleParts.length < 2 || toolParts.length < 2) return false;
235
+ if (ruleParts[1] !== toolParts[1]) return false;
236
+
237
+ // Rule is just mcp__server → matches all tools from that server
238
+ if (ruleParts.length === 2) return true;
239
+
240
+ // Rule is mcp__server__* → matches all tools from that server
241
+ if (ruleParts[2] === "*") return true;
242
+
243
+ // Rule is mcp__server__tool → exact tool match
244
+ if (toolParts.length < 3) return false;
245
+ return ruleParts[2] === toolParts[2];
246
+ }
247
+
248
+ // ── Settings file loading ──────────────────────────────────────────────────────
249
+
250
+ function loadSettingsFile(filePath: string): SettingsPermissions | null {
251
+ try {
252
+ if (!fs.existsSync(filePath)) return null;
253
+ const raw = fs.readFileSync(filePath, "utf-8");
254
+ const data = JSON.parse(raw);
255
+ if (!data.permissions) return null;
256
+ const p = data.permissions;
257
+ return {
258
+ allow: Array.isArray(p.allow) ? p.allow : [],
259
+ ask: Array.isArray(p.ask) ? p.ask : [],
260
+ deny: Array.isArray(p.deny) ? p.deny : [],
261
+ };
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ // ── PermissionManager ──────────────────────────────────────────────────────────
268
+
269
+ export class PermissionManager {
270
+ private mode: PermissionMode;
271
+ private projectDir: string | null = null;
272
+ private cwd: string;
273
+ private customPromptFn: PermissionPromptFn | null = null;
274
+ private sandboxManager: SandboxManager | null = null;
275
+
276
+ // Runtime stored rules (legacy: allow once / always / project-scoped)
277
+ private globalRules: PermissionRule[] = [];
278
+ private projectRules: PermissionRule[] = [];
279
+
280
+ /**
281
+ * Session-only approvals for file-modification tools (file_write, file_edit).
282
+ * Per the tiered permission table: "Until session end" — not written to disk.
283
+ * Key = toolName (no specifier needed; file edit approvals cover all paths).
284
+ */
285
+ private sessionApprovals: Set<string> = new Set();
286
+
287
+ // Settings-based rules loaded from .claude/settings.json files
288
+ private settingsAllow: string[] = [];
289
+ private settingsAsk: string[] = [];
290
+ private settingsDeny: string[] = [];
291
+
292
+ // Managed settings — highest priority, cannot be overridden by user/project
293
+ private managedSettings: ManagedOnlySettings = {};
294
+ private managedAllow: string[] = [];
295
+ private managedAsk: string[] = [];
296
+ private managedDeny: string[] = [];
297
+
298
+ constructor(mode: PermissionMode = PermissionMode.DEFAULT, projectDir?: string) {
299
+ this.projectDir = projectDir || null;
300
+ this.cwd = projectDir || process.cwd();
301
+ this.loadManagedSettings();
302
+ this.loadRuntimeRules();
303
+ this.loadSettingsRules();
304
+ // defaultMode from settings files can override the constructor argument
305
+ const settingsMode = this.readDefaultModeFromSettings();
306
+ this.mode = this.normalizeMode(settingsMode ?? mode);
307
+
308
+ // Managed policy: disableBypassPermissionsMode prevents bypass mode
309
+ if (this.managedSettings.disableBypassPermissionsMode === "disable" &&
310
+ this.mode === PermissionMode.BYPASS) {
311
+ this.mode = PermissionMode.DEFAULT;
312
+ }
313
+ }
314
+
315
+ // ── Managed settings loading ───────────────────────────────────────────────
316
+
317
+ /**
318
+ * Load managed settings from system-level config.
319
+ * Managed settings have the highest priority and cannot be overridden.
320
+ *
321
+ * Paths:
322
+ * macOS: /Library/Application Support/cdoing/managed-settings.json
323
+ * Linux: /etc/cdoing/managed-settings.json
324
+ */
325
+ private loadManagedSettings(): void {
326
+ const managed = loadSettingsFile(MANAGED_SETTINGS_FILE)
327
+ || loadSettingsFile(MANAGED_SETTINGS_FILE_CLAUDE);
328
+
329
+ if (managed) {
330
+ this.managedAllow = managed.allow;
331
+ this.managedAsk = managed.ask;
332
+ this.managedDeny = managed.deny;
333
+ }
334
+
335
+ // Load managed-only settings (disableBypassPermissionsMode, etc.)
336
+ for (const filePath of [MANAGED_SETTINGS_FILE, MANAGED_SETTINGS_FILE_CLAUDE]) {
337
+ try {
338
+ if (!fs.existsSync(filePath)) continue;
339
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
340
+ this.managedSettings = {
341
+ disableBypassPermissionsMode: data.disableBypassPermissionsMode,
342
+ allowManagedPermissionRulesOnly: data.allowManagedPermissionRulesOnly,
343
+ allowManagedHooksOnly: data.allowManagedHooksOnly,
344
+ allowManagedMcpServersOnly: data.allowManagedMcpServersOnly,
345
+ };
346
+ break; // Use first found
347
+ } catch { /* skip malformed files */ }
348
+ }
349
+ }
350
+
351
+ // ── Mode normalisation ─────────────────────────────────────────────────────
352
+
353
+ /** Collapse legacy aliases into canonical mode values. */
354
+ private normalizeMode(mode: PermissionMode | string): PermissionMode {
355
+ switch (mode as string) {
356
+ case "ask": return PermissionMode.DEFAULT;
357
+ case "auto-edit": return PermissionMode.ACCEPT_EDITS;
358
+ case "auto": return PermissionMode.BYPASS;
359
+ default: return mode as PermissionMode;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Read `defaultMode` from settings files (local → shared → user).
365
+ * Higher-precedence files win. Returns null if no file sets it.
366
+ */
367
+ private readDefaultModeFromSettings(): PermissionMode | null {
368
+ const candidates = [
369
+ // Local project overrides (highest priority)
370
+ this.projectDir ? path.join(this.projectDir, ".cdoing", "settings.local.json") : null,
371
+ this.projectDir ? path.join(this.projectDir, ".claude", "settings.local.json") : null,
372
+ // Shared project settings
373
+ this.projectDir ? path.join(this.projectDir, ".cdoing", "settings.json") : null,
374
+ this.projectDir ? path.join(this.projectDir, ".claude", "settings.json") : null,
375
+ // Global user settings
376
+ USER_SETTINGS_FILE,
377
+ USER_SETTINGS_FILE_CLAUDE,
378
+ ];
379
+ for (const filePath of candidates) {
380
+ if (!filePath) continue;
381
+ try {
382
+ if (!fs.existsSync(filePath)) continue;
383
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
384
+ if (data.defaultMode) return this.normalizeMode(data.defaultMode);
385
+ } catch { /* skip malformed files */ }
386
+ }
387
+ return null;
388
+ }
389
+
390
+ // ── Public API ────────────────────────────────────────────────────────────
391
+
392
+ setPromptFn(fn: PermissionPromptFn): void {
393
+ this.customPromptFn = fn;
394
+ }
395
+
396
+ setSandboxManager(sm: SandboxManager): void {
397
+ this.sandboxManager = sm;
398
+ }
399
+
400
+ setMode(mode: PermissionMode): void {
401
+ const normalized = this.normalizeMode(mode);
402
+ // Managed policy: prevent bypass mode if disabled by admin
403
+ if (this.managedSettings.disableBypassPermissionsMode === "disable" &&
404
+ normalized === PermissionMode.BYPASS) {
405
+ return; // Silently refuse to switch to bypass mode
406
+ }
407
+ this.mode = normalized;
408
+ }
409
+
410
+ getMode(): PermissionMode {
411
+ return this.mode;
412
+ }
413
+
414
+ /**
415
+ * Check whether a specific file path is denied by settings rules for a given category.
416
+ * Used by shell_exec to check extracted paths against Read/Edit/Delete rules.
417
+ *
418
+ * Returns "deny" if a deny rule matches the path for the given category.
419
+ * Returns null otherwise (no deny found — the command can proceed through normal permission flow).
420
+ */
421
+ checkPathPermission(filePath: string, category: "Read" | "Edit" | "Delete"): "deny" | null {
422
+ // Only check deny rules — we're not auto-allowing shell commands based on Edit/Read allow rules
423
+ for (const rule of this.settingsDeny) {
424
+ const parsed = parseRule(rule);
425
+ if (!parsed) continue;
426
+ if (parsed.category !== category) continue;
427
+ if (parsed.specifier === null) return "deny"; // Deny all of this category
428
+ if (matchPath(filePath, parsed.specifier, this.projectDir ?? this.cwd, this.cwd)) {
429
+ return "deny";
430
+ }
431
+ }
432
+
433
+ return null;
434
+ }
435
+
436
+ /** Check whether a file path matches any ask rule for the given category. */
437
+ private pathMatchesAsk(filePath: string, category: "Read" | "Edit" | "Delete"): boolean {
438
+ for (const rule of this.settingsAsk) {
439
+ const parsed = parseRule(rule);
440
+ if (!parsed) continue;
441
+ if (parsed.category !== category) continue;
442
+ if (parsed.specifier === null) return true;
443
+ if (matchPath(filePath, parsed.specifier, this.projectDir ?? this.cwd, this.cwd)) {
444
+ return true;
445
+ }
446
+ }
447
+ return false;
448
+ }
449
+
450
+ setProjectDir(dir: string): void {
451
+ this.projectDir = dir;
452
+ this.cwd = dir;
453
+ this.loadProjectRules();
454
+ this.loadSettingsRules();
455
+ // Re-evaluate defaultMode when project changes (local settings may differ)
456
+ const settingsMode = this.readDefaultModeFromSettings();
457
+ if (settingsMode) this.mode = settingsMode;
458
+ }
459
+
460
+ // ── Settings rules loading ─────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Load and merge permission rules from settings files.
464
+ *
465
+ * Precedence (highest to lowest, docs order):
466
+ * 1. Local project — <project>/.claude/settings.local.json (highest)
467
+ * 2. Shared project — <project>/.claude/settings.json
468
+ * 3. User — ~/.claude/settings.json (lowest)
469
+ *
470
+ * Rule evaluation uses "first match wins", so higher-precedence rules are
471
+ * stored at the front of the allow/ask arrays.
472
+ *
473
+ * Deny rules from ALL sources are merged — a deny at any level cannot be
474
+ * overridden by an allow at any other level.
475
+ */
476
+ loadSettingsRules(): void {
477
+ // Managed rules always apply (highest priority)
478
+ const allow: string[] = [...this.managedAllow];
479
+ const ask: string[] = [...this.managedAsk];
480
+ const deny: string[] = [...this.managedDeny];
481
+
482
+ // If allowManagedPermissionRulesOnly is set, skip user/project rules entirely
483
+ if (!this.managedSettings.allowManagedPermissionRulesOnly) {
484
+ // Load each layer (null if file doesn't exist)
485
+ // Load from .cdoing/ (our format) with .claude/ fallback (Claude Code compat)
486
+ const local = this.projectDir
487
+ ? (loadSettingsFile(path.join(this.projectDir, ".cdoing", "settings.local.json"))
488
+ || loadSettingsFile(path.join(this.projectDir, ".claude", "settings.local.json")))
489
+ : null;
490
+ const shared = this.projectDir
491
+ ? (loadSettingsFile(path.join(this.projectDir, ".cdoing", "settings.json"))
492
+ || loadSettingsFile(path.join(this.projectDir, ".claude", "settings.json")))
493
+ : null;
494
+ const user = loadSettingsFile(USER_SETTINGS_FILE)
495
+ || loadSettingsFile(USER_SETTINGS_FILE_CLAUDE);
496
+
497
+ // For allow/ask: highest precedence first so "first match wins" works correctly.
498
+ // Order: managed → local → shared → user
499
+ const orderedSources = [local, shared, user];
500
+
501
+ for (const src of orderedSources) {
502
+ if (!src) continue;
503
+ allow.push(...src.allow);
504
+ ask.push(...src.ask);
505
+ // Deny rules are collected from every layer — any deny wins
506
+ deny.push(...src.deny);
507
+ }
508
+ }
509
+
510
+ this.settingsAllow = allow;
511
+ this.settingsAsk = ask;
512
+ this.settingsDeny = deny;
513
+ }
514
+
515
+ /** Return the currently loaded settings rules (for display / debugging). */
516
+ getSettingsRules(): { allow: string[]; ask: string[]; deny: string[] } {
517
+ return {
518
+ allow: [...this.settingsAllow],
519
+ ask: [...this.settingsAsk],
520
+ deny: [...this.settingsDeny],
521
+ };
522
+ }
523
+
524
+ // ── Rule matching ──────────────────────────────────────────────────────────
525
+
526
+ /**
527
+ * Test whether a settings rule string covers a specific tool invocation.
528
+ */
529
+ private matchesRule(
530
+ rule: string,
531
+ toolName: string,
532
+ input: Record<string, unknown>,
533
+ ): boolean {
534
+ const parsed = parseRule(rule);
535
+ if (!parsed) return false;
536
+
537
+ const category = TOOL_CATEGORY[toolName] || toolName;
538
+
539
+ // Rule must target this tool's category (or the raw tool name)
540
+ if (parsed.category !== category && parsed.category !== toolName) return false;
541
+
542
+ // No specifier → match all uses of this tool
543
+ if (parsed.specifier === null) return true;
544
+
545
+ // Specifier matching is category-specific
546
+ switch (category) {
547
+ case "Bash": {
548
+ const cmd = String(input.command || "");
549
+ return matchBash(cmd, parsed.specifier);
550
+ }
551
+ case "Read":
552
+ case "Edit":
553
+ case "Delete": {
554
+ const fp = String(input.file_path || input.path || input.pattern || "");
555
+ if (!fp) return false;
556
+ return matchPath(fp, parsed.specifier, this.projectDir ?? this.cwd, this.cwd);
557
+ }
558
+ case "WebFetch": {
559
+ const url = String(input.url || "");
560
+ return matchWebFetch(url, parsed.specifier);
561
+ }
562
+ case "Agent": {
563
+ const name = String(input.name || input.agent_name || "");
564
+ return name === parsed.specifier;
565
+ }
566
+ default: {
567
+ // MCP tools: match mcp__server or mcp__server__tool patterns
568
+ // e.g. rule "mcp__puppeteer" matches tool "mcp__puppeteer__navigate"
569
+ // rule "mcp__puppeteer__*" matches all tools from puppeteer server
570
+ // rule "mcp__puppeteer__navigate" matches exact tool
571
+ if (toolName.startsWith("mcp__") && parsed.category.startsWith("mcp__")) {
572
+ return matchMcpTool(toolName, parsed.category, parsed.specifier);
573
+ }
574
+ // Fallback: compare specifier against first input value
575
+ const first = String(Object.values(input)[0] ?? "");
576
+ return first === parsed.specifier;
577
+ }
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Evaluate all settings rules for a tool call.
583
+ *
584
+ * Evaluation order per docs: deny → ask → allow.
585
+ * - deny wins over everything.
586
+ * - ask overrides allow (lets you "ask for rm *" even when "allow Bash").
587
+ * - allow auto-approves when no deny/ask rule matched.
588
+ * Returns null when no rule matches (fall back to mode behaviour).
589
+ */
590
+ private evaluateRules(
591
+ toolName: string,
592
+ input: Record<string, unknown>,
593
+ ): "deny" | "allow" | "ask" | null {
594
+ // 1. Deny — checked first, always wins
595
+ for (const rule of this.settingsDeny) {
596
+ if (this.matchesRule(rule, toolName, input)) return "deny";
597
+ }
598
+ // 2. Ask — overrides allow for fine-grained "still prompt" control
599
+ for (const rule of this.settingsAsk) {
600
+ if (this.matchesRule(rule, toolName, input)) return "ask";
601
+ }
602
+ // 3. Allow — auto-approve if no deny/ask matched
603
+ for (const rule of this.settingsAllow) {
604
+ if (this.matchesRule(rule, toolName, input)) return "allow";
605
+ }
606
+ return null;
607
+ }
608
+
609
+ // ── Permission request ─────────────────────────────────────────────────────
610
+
611
+ /**
612
+ * Determine whether a tool call is allowed.
613
+ *
614
+ * Decision flow:
615
+ * 1. bypassPermissions mode → allow all.
616
+ * 2. Tools that never require permission → allow.
617
+ * 3. Settings deny rule matches → deny.
618
+ * 4. Settings ask rule matches → prompt user (overrides stored/allow).
619
+ * 5. Settings allow rule matches → allow.
620
+ * 6. Session-only approval exists (file-edit tools) → allow.
621
+ * 7. Runtime stored rule matches (Bash tools, persisted) → allow.
622
+ * 8. Fall back to mode:
623
+ * - plan → deny write/exec tools.
624
+ * - dontAsk → deny anything not explicitly allowed.
625
+ * - acceptEdits → allow file edits; prompt for others.
626
+ * - default → prompt user.
627
+ */
628
+ async requestPermission(
629
+ toolDef: ToolDefinition,
630
+ input: Record<string, unknown>,
631
+ ): Promise<boolean> {
632
+ if (this.mode === PermissionMode.BYPASS) return true;
633
+ if (!toolDef.requiresPermission) return true;
634
+
635
+ const toolName = toolDef.name;
636
+ const ruleResult = this.evaluateRules(toolName, input);
637
+
638
+ if (ruleResult === "deny") return false;
639
+
640
+ // ask rule: prompt even if a stored/allow rule would otherwise bypass it
641
+ if (ruleResult === "ask") {
642
+ return this.askUser(toolName, this.describeAction(toolDef, input), input);
643
+ }
644
+
645
+ if (ruleResult === "allow") return true;
646
+
647
+ // For Bash tools: check path-level ask rules against files extracted from the command.
648
+ // e.g. "ask": ["Edit(src/sensitive/*)"] triggers when mv/cp/> targets that path.
649
+ if (ruleResult === null && TOOL_CATEGORY[toolName] === "Bash") {
650
+ const cmd = String(input.command || "");
651
+ if (cmd) {
652
+ const paths = extractShellPaths(cmd, this.cwd);
653
+ const needsAsk =
654
+ paths.read.some((p) => this.pathMatchesAsk(p, "Read")) ||
655
+ paths.write.some((p) => this.pathMatchesAsk(p, "Edit")) ||
656
+ paths.delete.some((p) => this.pathMatchesAsk(p, "Delete"));
657
+ if (needsAsk) {
658
+ return this.askUser(toolName, this.describeAction(toolDef, input), input);
659
+ }
660
+ }
661
+ }
662
+
663
+ // Session-only approval for file-modification tools ("until session end")
664
+ if (this.sessionApprovals.has(toolName)) return true;
665
+
666
+ // Persistent stored rules for Bash tools ("permanently per project + command")
667
+ if (this.hasStoredPermission(toolName, input)) return true;
668
+
669
+ if (this.mode === PermissionMode.PLAN) return !PLAN_BLOCKED.has(toolName);
670
+ if (this.mode === PermissionMode.DONT_ASK) return false;
671
+
672
+ if (this.mode === PermissionMode.ACCEPT_EDITS && ACCEPT_EDITS_AUTO.has(toolName)) return true;
673
+
674
+ // Sandbox auto-allow mode: if sandbox is enabled and in auto-allow mode,
675
+ // auto-approve commands that pass sandbox checks (no user prompt needed).
676
+ if (this.sandboxManager?.isEnabled() && this.sandboxManager.getMode() === "auto-allow") {
677
+ const category = TOOL_CATEGORY[toolName];
678
+ if (category === "Bash") {
679
+ const cmd = String(input.command || "");
680
+ const check = this.sandboxManager.checkShellCommand(cmd, input.dangerouslyDisableSandbox as boolean);
681
+ if (check.allowed) return true;
682
+ } else if (category === "Edit") {
683
+ const fp = String(input.file_path || "");
684
+ if (fp) {
685
+ const check = this.sandboxManager.checkFileWrite(fp);
686
+ if (check.allowed) return true;
687
+ }
688
+ }
689
+ }
690
+
691
+ return this.askUser(toolName, this.describeAction(toolDef, input), input);
692
+ }
693
+
694
+ // ── Runtime stored rules (legacy) ─────────────────────────────────────────
695
+
696
+ private getProjectPermissionsFile(): string | null {
697
+ if (!this.projectDir) return null;
698
+ return path.join(this.projectDir, ".cdoing", "permissions.json");
699
+ }
700
+
701
+ private loadRuntimeRules(): void {
702
+ this.loadGlobalRules();
703
+ this.loadProjectRules();
704
+ }
705
+
706
+ private loadGlobalRules(): void {
707
+ try {
708
+ if (fs.existsSync(GLOBAL_PERMISSIONS_FILE)) {
709
+ const data = JSON.parse(fs.readFileSync(GLOBAL_PERMISSIONS_FILE, "utf-8"));
710
+ this.globalRules = Array.isArray(data.rules) ? data.rules : [];
711
+ }
712
+ } catch {
713
+ this.globalRules = [];
714
+ }
715
+ }
716
+
717
+ private loadProjectRules(): void {
718
+ this.projectRules = [];
719
+ const file = this.getProjectPermissionsFile();
720
+ if (!file) return;
721
+ try {
722
+ if (fs.existsSync(file)) {
723
+ const data = JSON.parse(fs.readFileSync(file, "utf-8"));
724
+ this.projectRules = Array.isArray(data.rules) ? data.rules : [];
725
+ }
726
+ } catch {
727
+ this.projectRules = [];
728
+ }
729
+ }
730
+
731
+ private saveGlobalRules(): void {
732
+ if (!fs.existsSync(GLOBAL_CDOING_DIR)) {
733
+ fs.mkdirSync(GLOBAL_CDOING_DIR, { recursive: true });
734
+ }
735
+ fs.writeFileSync(
736
+ GLOBAL_PERMISSIONS_FILE,
737
+ JSON.stringify({ rules: this.globalRules }, null, 2),
738
+ "utf-8",
739
+ );
740
+ }
741
+
742
+ private saveProjectRules(): void {
743
+ const file = this.getProjectPermissionsFile();
744
+ if (!file) return;
745
+ const dir = path.dirname(file);
746
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
747
+ fs.writeFileSync(file, JSON.stringify({ rules: this.projectRules }, null, 2), "utf-8");
748
+ }
749
+
750
+ private hasStoredPermission(toolName: string, input: Record<string, unknown>): boolean {
751
+ const all = [...this.globalRules, ...this.projectRules];
752
+ return all.some((rule) => {
753
+ if (rule.tool !== toolName) return false;
754
+ if (!rule.inputMatch) return true;
755
+ const value = String(
756
+ input.file_path ?? input.command ?? input.pattern ?? Object.values(input)[0] ?? "",
757
+ );
758
+ return value === rule.inputMatch;
759
+ });
760
+ }
761
+
762
+ private addRule(toolName: string, scope: PermissionScope, inputMatch?: string): void {
763
+ // Build a settings-style rule string (e.g., "Edit", "Bash(npm test *)")
764
+ const category = TOOL_CATEGORY[toolName] || toolName;
765
+ const ruleString = inputMatch ? `${category}(${inputMatch})` : category;
766
+
767
+ // Persist to settings.json (like Claude Code does)
768
+ this.addToSettingsAllow(ruleString, scope);
769
+
770
+ // Also add session approval for immediate effect
771
+ this.sessionApprovals.add(toolName);
772
+
773
+ // Legacy: persist to permissions.json for backward compat
774
+ if (PERMANENT_APPROVAL_TOOLS.has(toolName)) {
775
+ const rules = scope === "project" ? this.projectRules : this.globalRules;
776
+ const exists = rules.some((r) => r.tool === toolName && r.inputMatch === inputMatch);
777
+ if (!exists) {
778
+ rules.push({ tool: toolName, inputMatch, createdAt: new Date().toISOString() });
779
+ scope === "project" ? this.saveProjectRules() : this.saveGlobalRules();
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Save a deny rule to settings.json so it persists across sessions.
786
+ * Creates the .cdoing/ folder and settings.json file if they don't exist.
787
+ */
788
+ private addDenyRule(toolName: string, scope: PermissionScope, inputMatch?: string): void {
789
+ const category = TOOL_CATEGORY[toolName] || toolName;
790
+ const ruleString = inputMatch ? `${category}(${inputMatch})` : category;
791
+
792
+ // Persist deny to settings.json
793
+ this.addToSettingsDeny(ruleString, scope);
794
+ }
795
+
796
+ /**
797
+ * Persist a permission rule to settings.json.
798
+ *
799
+ * Writes to:
800
+ * - Project scope → <project>/.cdoing/settings.json
801
+ * - Global scope → ~/.cdoing/settings.json
802
+ */
803
+ private addToSettingsAllow(rule: string, scope: PermissionScope): void {
804
+ this.addToSettingsField(rule, "allow", scope);
805
+ }
806
+
807
+ /**
808
+ * Persist a deny rule to settings.json.
809
+ *
810
+ * Writes to:
811
+ * - Project scope → <project>/.cdoing/settings.json
812
+ * - Global scope → ~/.cdoing/settings.json
813
+ */
814
+ private addToSettingsDeny(rule: string, scope: PermissionScope): void {
815
+ this.addToSettingsField(rule, "deny", scope);
816
+ }
817
+
818
+ /**
819
+ * Persist a permission rule to a specific field (allow/deny/ask) in settings.json.
820
+ * Creates the directory and file if they don't exist.
821
+ */
822
+ private addToSettingsField(rule: string, field: "allow" | "deny" | "ask", scope: PermissionScope): void {
823
+ const filePath = scope === "project" && this.projectDir
824
+ ? path.join(this.projectDir, ".cdoing", "settings.json")
825
+ : USER_SETTINGS_FILE;
826
+
827
+ try {
828
+ const dir = path.dirname(filePath);
829
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
830
+
831
+ let data: Record<string, unknown> = {};
832
+ if (fs.existsSync(filePath)) {
833
+ data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
834
+ }
835
+
836
+ if (!data.permissions) data.permissions = {};
837
+ const perms = data.permissions as Record<string, string[]>;
838
+ if (!Array.isArray(perms[field])) perms[field] = [];
839
+
840
+ // Don't add duplicates
841
+ if (!perms[field].includes(rule)) {
842
+ perms[field].push(rule);
843
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
844
+
845
+ // Reload settings so the new rule takes effect immediately
846
+ this.loadSettingsRules();
847
+ }
848
+ } catch {
849
+ // If we can't write settings, fall back to session-only
850
+ }
851
+ }
852
+
853
+ /** Check whether bypass mode is disabled by managed settings. */
854
+ isBypassDisabled(): boolean {
855
+ return this.managedSettings.disableBypassPermissionsMode === "disable";
856
+ }
857
+
858
+ /** Return managed-only settings for external inspection. */
859
+ getManagedSettings(): Readonly<ManagedOnlySettings> {
860
+ return { ...this.managedSettings };
861
+ }
862
+
863
+ /**
864
+ * Remove a permission rule from settings.json.
865
+ * Removes from the specified field (allow/deny/ask) in the given scope.
866
+ */
867
+ removeSettingsRule(rule: string, field: "allow" | "deny" | "ask", scope: PermissionScope): void {
868
+ const filePath = scope === "project" && this.projectDir
869
+ ? path.join(this.projectDir, ".cdoing", "settings.json")
870
+ : USER_SETTINGS_FILE;
871
+
872
+ try {
873
+ if (!fs.existsSync(filePath)) return;
874
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
875
+ if (!data.permissions) return;
876
+ const perms = data.permissions as Record<string, string[]>;
877
+ if (!Array.isArray(perms[field])) return;
878
+
879
+ const idx = perms[field].indexOf(rule);
880
+ if (idx !== -1) {
881
+ perms[field].splice(idx, 1);
882
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
883
+ this.loadSettingsRules();
884
+ }
885
+ } catch { /* ignore errors */ }
886
+ }
887
+
888
+ removeRule(toolName?: string, scope?: PermissionScope): void {
889
+ if (scope === "project" || !scope) {
890
+ this.projectRules = toolName
891
+ ? this.projectRules.filter((r) => r.tool !== toolName)
892
+ : [];
893
+ this.saveProjectRules();
894
+ }
895
+ if (scope === "global" || !scope) {
896
+ this.globalRules = toolName
897
+ ? this.globalRules.filter((r) => r.tool !== toolName)
898
+ : [];
899
+ this.saveGlobalRules();
900
+ }
901
+ }
902
+
903
+ getStoredRules(): {
904
+ global: ReadonlyArray<PermissionRule>;
905
+ project: ReadonlyArray<PermissionRule>;
906
+ } {
907
+ return { global: this.globalRules, project: this.projectRules };
908
+ }
909
+
910
+ // ── User prompt ────────────────────────────────────────────────────────────
911
+
912
+ private describeAction(toolDef: ToolDefinition, input: Record<string, unknown>): string {
913
+ if (toolDef.permissionMessage) {
914
+ const msg = toolDef.permissionMessage(input);
915
+ if (msg && !msg.includes("undefined")) return msg;
916
+ }
917
+ const value =
918
+ input.file_path ?? input.command ?? input.pattern ?? Object.values(input)[0];
919
+ return `${toolDef.name.replace(/_/g, " ")}: ${value ?? "(no details)"}`;
920
+ }
921
+
922
+ /**
923
+ * Ask the user whether to allow a tool call.
924
+ *
925
+ * Options:
926
+ * y / Enter → Allow once
927
+ * a → Always allow globally (~/.cdoing/permissions.json)
928
+ * p → Allow for this project (.cdoing/permissions.json)
929
+ * n → Deny
930
+ *
931
+ * Uses customPromptFn if set (e.g. VS Code UI), otherwise falls back to CLI readline.
932
+ */
933
+ private async askUser(toolName: string, message: string, input?: Record<string, unknown>): Promise<boolean> {
934
+ const hasProject = !!this.projectDir;
935
+ const label = toolName.replace(/_/g, " ");
936
+
937
+ // Build input match for persistent rules (command for Bash, path for Edit/Read/Delete)
938
+ const category = TOOL_CATEGORY[toolName] || toolName;
939
+ let inputMatch: string | undefined;
940
+ if (input) {
941
+ if (category === "Bash") {
942
+ inputMatch = String(input.command || "") || undefined;
943
+ } else if (category === "Read" || category === "Edit" || category === "Delete") {
944
+ inputMatch = String(input.file_path || input.path || input.pattern || "") || undefined;
945
+ } else if (category === "WebFetch") {
946
+ const url = String(input.url || "");
947
+ try { inputMatch = `domain:${new URL(url).hostname}`; } catch { inputMatch = url || undefined; }
948
+ }
949
+ }
950
+
951
+ if (this.customPromptFn) {
952
+ const choice = await this.customPromptFn(toolName, message, hasProject);
953
+ if (choice === "always") {
954
+ this.addRule(toolName, "global", inputMatch);
955
+ return true;
956
+ }
957
+ if (choice === "project" && hasProject) {
958
+ this.addRule(toolName, "project", inputMatch);
959
+ return true;
960
+ }
961
+ if (choice === "deny_always") {
962
+ this.addDenyRule(toolName, "global", inputMatch);
963
+ return false;
964
+ }
965
+ if (choice === "deny_project" && hasProject) {
966
+ this.addDenyRule(toolName, "project", inputMatch);
967
+ return false;
968
+ }
969
+ if (choice === "deny") return false;
970
+ return choice === "allow";
971
+ }
972
+
973
+ // CLI readline fallback
974
+ return new Promise((resolve) => {
975
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
976
+ const projectHint = hasProject ? ` · (p)roject allow` : "";
977
+ const denyProjectHint = hasProject ? ` · (dp) deny project` : "";
978
+
979
+ rl.question(
980
+ `\n \x1b[33m⚡ Permission:\x1b[0m ${message}\n` +
981
+ ` \x1b[2m(y)es, allow once · (a)lways allow${projectHint} · (n)o, deny once · (d)eny always${denyProjectHint}\x1b[0m\n` +
982
+ ` \x1b[2mChoice [Y/a${hasProject ? "/p" : ""}/n/d${hasProject ? "/dp" : ""}]:\x1b[0m `,
983
+ (answer: string) => {
984
+ rl.close();
985
+ const a = answer.trim().toLowerCase();
986
+ if (a === "a" || a === "always") {
987
+ this.addRule(toolName, "global", inputMatch);
988
+ console.log(` \x1b[32m✓ Permission saved globally for ${label}\x1b[0m`);
989
+ resolve(true);
990
+ } else if ((a === "p" || a === "project") && hasProject) {
991
+ this.addRule(toolName, "project", inputMatch);
992
+ console.log(` \x1b[32m✓ Permission saved for project for ${label}\x1b[0m`);
993
+ resolve(true);
994
+ } else if (a === "d" || a === "deny always") {
995
+ this.addDenyRule(toolName, "global", inputMatch);
996
+ console.log(` \x1b[31m✗ Deny rule saved globally for ${label}\x1b[0m`);
997
+ resolve(false);
998
+ } else if ((a === "dp" || a === "deny project") && hasProject) {
999
+ this.addDenyRule(toolName, "project", inputMatch);
1000
+ console.log(` \x1b[31m✗ Deny rule saved for project for ${label}\x1b[0m`);
1001
+ resolve(false);
1002
+ } else if (a === "n" || a === "no") {
1003
+ resolve(false);
1004
+ } else {
1005
+ resolve(true); // y / Enter → allow once
1006
+ }
1007
+ },
1008
+ );
1009
+ });
1010
+ }
1011
+ }