one-code-extension 0.1.4 → 0.3.1

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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +222 -177
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +128 -0
  8. package/docs/guide/configuration.md +173 -0
  9. package/docs/guide/differences-from-claude-code.md +152 -0
  10. package/docs/guide/doctor.md +88 -0
  11. package/docs/guide/hooks.md +111 -0
  12. package/docs/guide/installation.md +126 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
  14. package/docs/guide/providers-and-models.md +196 -0
  15. package/docs/guide/reference.md +226 -0
  16. package/docs/guide/sessions-and-context.md +192 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +178 -0
  18. package/docs/guide/subagents-and-workflows.md +195 -0
  19. package/docs/guide/tasks-and-background-work.md +113 -0
  20. package/docs/guide/terminal-interface.md +157 -0
  21. package/docs/guide/tools.md +177 -0
  22. package/docs/guide/troubleshooting.md +177 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -0,0 +1,108 @@
1
+ /**
2
+ * /mcp panel state machine (pure).
3
+ *
4
+ * Two views: the server list (cursor over entries; group headings are
5
+ * render-only and never landed on) and the detail view for one server (cursor
6
+ * over its numbered action list). `applyMcpKey` mutates the state in place and
7
+ * returns an effect when a key demands I/O — reconnect, disable, enable,
8
+ * authenticate — which the wiring performs and reflects back through the entries
9
+ * it renders. The wiring passes the current entries so "the selected row" always
10
+ * matches what was drawn (async reconnects reorder the list).
11
+ */
12
+
13
+ import type { McpKey } from "./keys.ts";
14
+ import { actionsFor, type McpEntry } from "./model.ts";
15
+
16
+ export interface McpPanelState {
17
+ cursor: number;
18
+ detail?: { name: string; actionCursor: number };
19
+ }
20
+
21
+ export type McpEffect =
22
+ | { kind: "close" }
23
+ | { kind: "reconnect"; entry: McpEntry }
24
+ | { kind: "disable"; entry: McpEntry }
25
+ | { kind: "enable"; entry: McpEntry }
26
+ | { kind: "authenticate"; entry: McpEntry };
27
+
28
+ export function initialMcpState(): McpPanelState {
29
+ return { cursor: 0 };
30
+ }
31
+
32
+ export function clampMcpCursor(state: McpPanelState, entries: McpEntry[]): void {
33
+ state.cursor = entries.length === 0 ? 0 : Math.max(0, Math.min(state.cursor, entries.length - 1));
34
+ }
35
+
36
+ /** Resolve the detail view's entry against the current list (rows shift under async work). */
37
+ export function detailEntry(state: McpPanelState, entries: McpEntry[]): McpEntry | undefined {
38
+ return state.detail ? entries.find((entry) => entry.name === state.detail!.name) : undefined;
39
+ }
40
+
41
+ function effectForAction(entry: McpEntry, index: number): McpEffect | undefined {
42
+ const action = actionsFor(entry)[index];
43
+ if (!action) return undefined;
44
+ switch (action.key) {
45
+ case "reconnect":
46
+ return { kind: "reconnect", entry };
47
+ case "disable":
48
+ return { kind: "disable", entry };
49
+ case "enable":
50
+ return { kind: "enable", entry };
51
+ case "authenticate":
52
+ return { kind: "authenticate", entry };
53
+ }
54
+ }
55
+
56
+ export function applyMcpKey(state: McpPanelState, key: McpKey, entries: McpEntry[]): McpEffect | undefined {
57
+ // Detail view.
58
+ if (state.detail) {
59
+ const entry = detailEntry(state, entries);
60
+ if (!entry) {
61
+ // The server vanished (e.g. config changed under an async refresh).
62
+ if (key.kind === "back" || key.kind === "enter") state.detail = undefined;
63
+ if (key.kind === "close") return { kind: "close" };
64
+ return undefined;
65
+ }
66
+ const actions = actionsFor(entry);
67
+ const detail = state.detail;
68
+ detail.actionCursor = Math.max(0, Math.min(detail.actionCursor, actions.length - 1));
69
+ switch (key.kind) {
70
+ case "up":
71
+ detail.actionCursor = Math.max(0, detail.actionCursor - 1);
72
+ return undefined;
73
+ case "down":
74
+ detail.actionCursor = Math.min(actions.length - 1, detail.actionCursor + 1);
75
+ return undefined;
76
+ case "enter":
77
+ return effectForAction(entry, detail.actionCursor);
78
+ case "digit":
79
+ return effectForAction(entry, key.value - 1);
80
+ case "back":
81
+ state.detail = undefined;
82
+ return undefined;
83
+ case "close":
84
+ return { kind: "close" };
85
+ }
86
+ }
87
+
88
+ // List view.
89
+ clampMcpCursor(state, entries);
90
+ switch (key.kind) {
91
+ case "up":
92
+ state.cursor = Math.max(0, state.cursor - 1);
93
+ return undefined;
94
+ case "down":
95
+ state.cursor = Math.min(Math.max(0, entries.length - 1), state.cursor + 1);
96
+ return undefined;
97
+ case "enter": {
98
+ const entry = entries[state.cursor];
99
+ if (entry) state.detail = { name: entry.name, actionCursor: 0 };
100
+ return undefined;
101
+ }
102
+ case "back":
103
+ case "close":
104
+ return { kind: "close" };
105
+ case "digit":
106
+ return undefined;
107
+ }
108
+ }
@@ -9,12 +9,23 @@
9
9
 
10
10
  import { type TSchema, Type } from "typebox";
11
11
 
12
- /** Claude Code's namespacing: mcp__<server>__<tool>, sanitised for tool-name rules. */
12
+ /**
13
+ * Claude Code's namespacing: `mcp__<server>__<tool>`. Hyphens are KEPT (CC's
14
+ * captures show `mcp__plugin_context7_context7__query-docs`), so a user's
15
+ * existing `mcp__…` permission rules and hook matchers keep matching; anything
16
+ * else outside `[A-Za-z0-9_-]` (a plugin server's `plugin:x:y` colons, dots)
17
+ * becomes `_`, as in CC.
18
+ */
13
19
  export function namespacedToolName(server: string, tool: string): string {
14
- const clean = (part: string) => part.replace(/[^a-zA-Z0-9_]/g, "_");
20
+ const clean = (part: string) => part.replace(/[^a-zA-Z0-9_-]/g, "_");
15
21
  return `mcp__${clean(server)}__${clean(tool)}`;
16
22
  }
17
23
 
24
+ /** CC's name for a plugin-provided server: `plugin:<plugin>:<server>`, so two plugins' `github` servers cannot collide with each other or with the user's. */
25
+ export function pluginServerName(pluginName: string, serverName: string): string {
26
+ return `plugin:${pluginName}:${serverName}`;
27
+ }
28
+
18
29
  export function parseNamespacedToolName(name: string): { server: string; tool: string } | undefined {
19
30
  if (!name.startsWith("mcp__")) return undefined;
20
31
  const rest = name.slice("mcp__".length);
@@ -36,8 +47,26 @@ interface JsonSchema {
36
47
  [key: string]: unknown;
37
48
  }
38
49
 
50
+ /**
51
+ * A server's tool and parameter descriptions ride every request as
52
+ * deferred-definition bytes with no bound; a malicious or careless server can
53
+ * make each one arbitrarily large. Cap them at registration with a labelled
54
+ * marker (review L2). 4 KB is far more than any real description needs.
55
+ */
56
+ export const DESCRIPTION_CAP = 4096;
57
+ export function capDescription(text: string | undefined): string | undefined {
58
+ if (typeof text !== "string") return undefined;
59
+ return text.length > DESCRIPTION_CAP ? `${text.slice(0, DESCRIPTION_CAP)}… [truncated, ${text.length} chars]` : text;
60
+ }
61
+
62
+ /** TypeBox options carrying a (capped) description, or none. */
63
+ function descriptionOptions(schema: JsonSchema): Record<string, unknown> {
64
+ const capped = capDescription(schema.description);
65
+ return capped ? { description: capped } : {};
66
+ }
67
+
39
68
  function convertLeaf(schema: JsonSchema): TSchema | undefined {
40
- const options = schema.description ? { description: schema.description } : {};
69
+ const options = descriptionOptions(schema);
41
70
  const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type;
42
71
 
43
72
  switch (type) {
@@ -67,7 +96,7 @@ function convertLeaf(schema: JsonSchema): TSchema | undefined {
67
96
  export function jsonSchemaToTypeBox(schema: JsonSchema | undefined): TSchema {
68
97
  if (!schema || typeof schema !== "object") return Type.Unknown();
69
98
 
70
- const options = schema.description ? { description: schema.description } : {};
99
+ const options = descriptionOptions(schema);
71
100
 
72
101
  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
73
102
  const literals = schema.enum
@@ -137,7 +166,53 @@ export function describeResourceContents(contents: McpResourceContents[] | undef
137
166
  return parts.join("\n").trim();
138
167
  }
139
168
 
140
- export function describeContent(blocks: McpContentBlock[] | undefined): {
169
+ /** Decoded size a single image block may occupy before it is refused. */
170
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
171
+
172
+ /**
173
+ * A server names an image's `mimeType`, but the provider decodes the bytes: an
174
+ * undecodable block (a truncated PNG, a base64 string with a wrong `mimeType`)
175
+ * makes the provider answer 400 "Could not process image" on that request and
176
+ * on every later one, since the block stays in the history. So the bytes are
177
+ * checked against a small magic-number allow-list here and `mimeType` is
178
+ * derived from them, never trusted; a block that fails becomes a text note.
179
+ */
180
+ /** Base64 length that decodes to just over the byte ceiling (4 base64 chars per 3 bytes). */
181
+ const MAX_IMAGE_BASE64_LENGTH = Math.ceil((MAX_IMAGE_BYTES * 4) / 3);
182
+
183
+ export function validateImageData(
184
+ data: string,
185
+ claimedMime: string | undefined,
186
+ ): { ok: true; mimeType: string } | { ok: false; reason: string; bytes: number } {
187
+ // Bound and sniff from the base64 string itself: decoding the whole payload
188
+ // (up to ~6.7 MB) just to read a 12-byte magic number and a length is pure
189
+ // waste — the block ships as the original base64 string regardless. Cap on
190
+ // the string length, then decode only a 24-char (4-aligned) prefix to sniff.
191
+ if (data.length === 0) return { ok: false, reason: "empty image data", bytes: 0 };
192
+ const approxBytes = Math.floor((data.length * 3) / 4);
193
+ if (data.length > MAX_IMAGE_BASE64_LENGTH) {
194
+ return { ok: false, reason: `image is ~${approxBytes} bytes, over the ${MAX_IMAGE_BYTES}-byte ceiling`, bytes: approxBytes };
195
+ }
196
+ const head = Buffer.from(data.slice(0, 24), "base64");
197
+ if (head.length === 0) return { ok: false, reason: "empty after base64 decode", bytes: 0 };
198
+ const mime = sniffImageMime(head);
199
+ if (!mime) return { ok: false, reason: `unrecognised image format (claimed ${claimedMime ?? "none"})`, bytes: approxBytes };
200
+ return { ok: true, mimeType: mime };
201
+ }
202
+
203
+ /** Magic-number sniff for the formats Anthropic accepts. Returns undefined for anything else. */
204
+ function sniffImageMime(buf: Buffer): string | undefined {
205
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
206
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
207
+ if (buf.length >= 6 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return "image/gif";
208
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "image/webp";
209
+ return undefined;
210
+ }
211
+
212
+ export function describeContent(
213
+ blocks: McpContentBlock[] | undefined,
214
+ serverName?: string,
215
+ ): {
141
216
  text: string;
142
217
  images: Array<{ data: string; mimeType: string }>;
143
218
  } {
@@ -148,7 +223,9 @@ export function describeContent(blocks: McpContentBlock[] | undefined): {
148
223
  if (block.type === "text" && typeof block.text === "string") {
149
224
  texts.push(block.text);
150
225
  } else if (block.type === "image" && block.data) {
151
- images.push({ data: block.data, mimeType: block.mimeType ?? "image/png" });
226
+ const checked = validateImageData(block.data, block.mimeType);
227
+ if (checked.ok) images.push({ data: block.data, mimeType: checked.mimeType });
228
+ else texts.push(`[image content${serverName ? ` from ${serverName}` : ""} could not be decoded: ${checked.reason}, ${checked.bytes} bytes]`);
152
229
  } else if (block.type === "resource" && block.resource) {
153
230
  const { text, uri } = block.resource;
154
231
  texts.push(text ?? `[resource ${uri ?? "(no uri)"}]`);
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Project `.mcp.json` consent. A checked-in `.mcp.json` names commands that
3
+ * run on the user's machine the moment the repo is opened, so its servers
4
+ * connect only after the user approves them — Claude Code prompts "New MCP
5
+ * server found in .mcp.json" and remembers the answer in
6
+ * `enabledMcpjsonServers` / `disabledMcpjsonServers` /
7
+ * `enableAllProjectMcpServers`. One Code honours those three keys READ-ONLY
8
+ * (user and `.claude/settings.local.json` scopes — never the checked-in
9
+ * project `settings.json`, which would let a repo approve itself) and keeps its
10
+ * own answers under `~/.onecode`, keyed to a hash of each server's config so a
11
+ * changed command re-prompts (CC keys by name alone). Servers from
12
+ * `~/.claude.json` and plugins never prompt: the user wrote the first, and
13
+ * installing a plugin was the consent. Servers from `.claude/settings.local.json`
14
+ * ARE prompted (that file can be checked in — `git clone && onecode` would
15
+ * otherwise spawn its servers with no consent, review H1); CC never reads
16
+ * `mcpServers` from that file at all. Its `enableAll*`/`enabledMcpjsonServers`
17
+ * policy keys are honoured only when the file is not git-tracked, for the same
18
+ * reason.
19
+ *
20
+ * A "No" is persisted as a project-scope disable (`lib/mcp-overrides.ts`), so
21
+ * the server shows as disabled in `/mcp` and Enable there brings it back —
22
+ * that click is the consent, recorded here. Escape declines for this session
23
+ * only. Same shape as `hooks/trust.ts`.
24
+ */
25
+
26
+ import { execFile } from "node:child_process";
27
+ import { createHash } from "node:crypto";
28
+ import { promisify } from "node:util";
29
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
+ import { basename, dirname, join } from "node:path";
31
+ import { readSettingsFile, settingsPaths } from "../lib/claude-settings.ts";
32
+ import { boundConsentItems } from "../lib/consent-preview.ts";
33
+ import { oneCodeStateDir } from "../lib/paths.ts";
34
+ import type { McpServer } from "./config.ts";
35
+
36
+ const execFileAsync = promisify(execFile);
37
+
38
+ interface ProjectApprovals {
39
+ /** "Use this and all future MCP servers in this project" was chosen. */
40
+ enableAll?: boolean;
41
+ servers: Record<string, { configHash: string; approvedAt: string }>;
42
+ }
43
+
44
+ interface ApprovalStore {
45
+ version: 1;
46
+ projects: Record<string, ProjectApprovals>;
47
+ }
48
+
49
+ export function approvalStorePath(): string {
50
+ return join(oneCodeStateDir(), "mcp", "project-approvals.json");
51
+ }
52
+
53
+ /**
54
+ * A server whose config a repository can ship (a project `.mcp.json` or a
55
+ * checked-in `.claude/settings.local.json`) — so it is consent-gated. A
56
+ * plugin's `.mcp.json` (also named `.mcp.json`) is excluded: installing the
57
+ * plugin was the consent.
58
+ */
59
+ export function isProjectScopedServer(server: McpServer, pluginConfigPaths: ReadonlySet<string>): boolean {
60
+ if (pluginConfigPaths.has(server.source)) return false;
61
+ const base = basename(server.source);
62
+ return base === ".mcp.json" || base === "settings.local.json";
63
+ }
64
+
65
+ /** The directory the `.mcp.json` lives in — approvals are per config file, not per cwd. */
66
+ export function projectRootOf(server: McpServer): string {
67
+ return dirname(server.source);
68
+ }
69
+
70
+ function canonicalize(value: unknown): unknown {
71
+ if (Array.isArray(value)) return value.map(canonicalize);
72
+ if (value && typeof value === "object") {
73
+ return Object.fromEntries(
74
+ Object.entries(value as Record<string, unknown>)
75
+ .sort(([a], [b]) => a.localeCompare(b))
76
+ .map(([key, inner]) => [key, canonicalize(inner)]),
77
+ );
78
+ }
79
+ return value;
80
+ }
81
+
82
+ /** Everything that decides what the server runs or talks to; `source`/`missingEnv` are not part of it. */
83
+ export function hashServerConfig(server: McpServer): string {
84
+ const material =
85
+ server.kind === "stdio"
86
+ ? { kind: "stdio", command: server.command, args: server.args, env: server.env ?? {} }
87
+ : { kind: "http", url: server.url, headers: server.headers ?? {} };
88
+ return createHash("sha256").update(JSON.stringify(canonicalize(material))).digest("hex");
89
+ }
90
+
91
+ /** Claude Code's own answers, read from its settings (user + local scopes only). */
92
+ export interface ClaudeMcpjsonPolicy {
93
+ enableAll: boolean;
94
+ enabled: Set<string>;
95
+ disabled: Set<string>;
96
+ }
97
+
98
+ function stringArray(value: unknown): string[] {
99
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
100
+ }
101
+
102
+ /**
103
+ * Whether `.claude/settings.local.json`'s policy keys may be trusted: true ONLY
104
+ * when git ran to a verdict and the file is not a tracked file (untracked in a
105
+ * repo, or no repo at all — either way not something a `git clone` shipped).
106
+ * FAILS CLOSED: git missing, a timeout, or any result we cannot interpret
107
+ * returns false, so a checked-in file that we could not prove untracked is not
108
+ * trusted (review H1 — a fail-open here silently re-opens the self-approval
109
+ * hole). Async so it never blocks the event loop from the connect path
110
+ * (findings §15: never do slow synchronous work on the startup path).
111
+ */
112
+ async function localPolicyFileIsTrusted(cwd: string, path: string): Promise<boolean> {
113
+ try {
114
+ await execFileAsync("git", ["ls-files", "--error-unmatch", "--", path], { cwd, timeout: 5_000 });
115
+ return false; // exit 0 → tracked → do not trust its policy keys
116
+ } catch (error) {
117
+ // execFile rejects with `code` = the numeric exit code on a non-zero exit
118
+ // (1 = untracked in a repo, 128 = not a repo — both mean "not tracked, safe
119
+ // to trust"); a spawn failure gives a string code (e.g. "ENOENT") and a
120
+ // timeout gives a non-numeric code — those are "could not decide" → closed.
121
+ return typeof (error as { code?: unknown }).code === "number";
122
+ }
123
+ }
124
+
125
+ export async function readClaudeMcpjsonPolicy(cwd: string, home: string): Promise<ClaudeMcpjsonPolicy> {
126
+ const paths = settingsPaths(cwd, home);
127
+ const policy: ClaudeMcpjsonPolicy = { enableAll: false, enabled: new Set(), disabled: new Set() };
128
+ // Deliberately not `paths.project`: a checked-in settings.json granting
129
+ // enableAllProjectMcpServers would be the repo approving its own servers.
130
+ // The local file is read only when it is provably not git-tracked (an
131
+ // untracked, user-authored file), for the same reason — a checked-in
132
+ // settings.local.json would otherwise approve the repo's own .mcp.json
133
+ // servers (review H1).
134
+ const sources = [paths.user];
135
+ if (await localPolicyFileIsTrusted(cwd, paths.local)) sources.push(paths.local);
136
+ for (const path of sources) {
137
+ const file = readSettingsFile(path);
138
+ if (!file) continue;
139
+ if (typeof file.enableAllProjectMcpServers === "boolean") policy.enableAll = file.enableAllProjectMcpServers;
140
+ for (const name of stringArray(file.enabledMcpjsonServers)) policy.enabled.add(name);
141
+ for (const name of stringArray(file.disabledMcpjsonServers)) policy.disabled.add(name);
142
+ }
143
+ return policy;
144
+ }
145
+
146
+ function readStore(storePath: string): ApprovalStore {
147
+ try {
148
+ const existing = JSON.parse(readFileSync(storePath, "utf-8")) as ApprovalStore;
149
+ if (existing && typeof existing === "object" && existing.projects) return existing;
150
+ } catch {
151
+ // Fresh store.
152
+ }
153
+ return { version: 1, projects: {} };
154
+ }
155
+
156
+ function writeStore(storePath: string, store: ApprovalStore): void {
157
+ try {
158
+ mkdirSync(dirname(storePath), { recursive: true });
159
+ writeFileSync(storePath, `${JSON.stringify(store, null, "\t")}\n`, { mode: 0o600 });
160
+ } catch {
161
+ // Approval still holds for this session; it will just re-prompt next run.
162
+ }
163
+ }
164
+
165
+ export function readStoredApproval(projectRoot: string, storePath = approvalStorePath()): ProjectApprovals | undefined {
166
+ return readStore(storePath).projects[projectRoot];
167
+ }
168
+
169
+ /** Record consent for one server's current config. */
170
+ export function persistApproval(projectRoot: string, server: McpServer, storePath = approvalStorePath()): void {
171
+ const store = readStore(storePath);
172
+ const project = (store.projects[projectRoot] ??= { servers: {} });
173
+ project.servers[server.name] = { configHash: hashServerConfig(server), approvedAt: new Date().toISOString() };
174
+ writeStore(storePath, store);
175
+ }
176
+
177
+ /** Record "this and all future servers in this project". */
178
+ export function persistEnableAll(projectRoot: string, storePath = approvalStorePath()): void {
179
+ const store = readStore(storePath);
180
+ const project = (store.projects[projectRoot] ??= { servers: {} });
181
+ project.enableAll = true;
182
+ writeStore(storePath, store);
183
+ }
184
+
185
+ export const CHOICE_ALL = "Use this and all future MCP servers in this project";
186
+ export const CHOICE_THIS = "Use this MCP server";
187
+ export const CHOICE_THESE = "Use these MCP servers";
188
+ export const CHOICE_NO = "No";
189
+
190
+ /** Claude Code's dialog title for one or several newly found servers. */
191
+ export function promptTitle(names: string[]): string {
192
+ return names.length === 1
193
+ ? `New MCP server found in .mcp.json: ${names[0]}`
194
+ : `${names.length} new MCP servers found in .mcp.json`;
195
+ }
196
+
197
+ /**
198
+ * One line per server naming what would run, for the dialog body. Commands and
199
+ * URLs are shown in full (a truncated one hides where a padded attack lives),
200
+ * env values are never echoed, but the variable NAMES a server's
201
+ * command/args/env/headers reference are listed (from `referencedEnv`, captured
202
+ * before expansion) — approving a URL should not silently ship a credential the
203
+ * header interpolates (review M5).
204
+ */
205
+ export function describeServers(servers: McpServer[]): string {
206
+ const lines = servers.map((server) => {
207
+ const what =
208
+ server.kind === "stdio"
209
+ ? `${server.name}: ${[server.command, ...server.args].join(" ")}`
210
+ : `${server.name}: ${server.url}`;
211
+ const vars = server.referencedEnv ?? [];
212
+ const headerNames = server.kind === "http" ? Object.keys(server.headers ?? {}) : [];
213
+ const notes = [
214
+ vars.length ? `uses ${vars.map((v) => `$${v}`).join(", ")}` : "",
215
+ headerNames.length ? `headers: ${headerNames.join(", ")}` : "",
216
+ ].filter(Boolean);
217
+ return notes.length ? `${what}\n (${notes.join("; ")})` : what;
218
+ });
219
+ // Bound the modal: a very long command/url or many servers cannot flood it.
220
+ return boundConsentItems(lines, "review .mcp.json before approving");
221
+ }
222
+
223
+ export interface McpTrustDeps {
224
+ hasUI: boolean;
225
+ /** `ctx.ui.select` — returns the chosen option, or undefined on Escape. */
226
+ select: (title: string, options: string[]) => Promise<string | undefined>;
227
+ notify: (message: string) => void;
228
+ /** Persist an explicit "No" (project-scope disable), so it does not re-prompt every start. */
229
+ disable: (server: McpServer) => void;
230
+ storePath?: string;
231
+ }
232
+
233
+ export interface McpTrustOutcome {
234
+ /** Servers that may connect (includes every non-project-`.mcp.json` server). */
235
+ approved: McpServer[];
236
+ /** Project `.mcp.json` servers that must not connect, with why. */
237
+ withheld: { server: McpServer; reason: "declined" | "not-approved" | "disabled-by-claude-settings" }[];
238
+ }
239
+
240
+ /** Process-lifetime memory per project+server+hash, so /clear (a new session_start) does not re-ask a "No". */
241
+ const sessionDecisions = new Map<string, boolean>();
242
+ const pendingPrompts = new Map<string, Promise<string | undefined>>();
243
+
244
+ /**
245
+ * Split servers into those that may connect and those withheld, prompting
246
+ * once (one dialog for every new project server) when there is a UI.
247
+ */
248
+ export async function approveMcpServers(
249
+ servers: McpServer[],
250
+ pluginConfigPaths: ReadonlySet<string>,
251
+ cwd: string,
252
+ home: string,
253
+ deps: McpTrustDeps,
254
+ ): Promise<McpTrustOutcome> {
255
+ const approved: McpServer[] = [];
256
+ const withheld: McpTrustOutcome["withheld"] = [];
257
+ const pending: McpServer[] = [];
258
+ // Reading CC's policy shells out to git (localPolicyFileIsTrusted), so only
259
+ // do it when a server actually needs consent — a session with only
260
+ // user-scope or plugin servers must not pay a git spawn at connect.
261
+ const anyProjectScoped = servers.some((s) => isProjectScopedServer(s, pluginConfigPaths));
262
+ const claude: ClaudeMcpjsonPolicy = anyProjectScoped
263
+ ? await readClaudeMcpjsonPolicy(cwd, home)
264
+ : { enableAll: false, enabled: new Set<string>(), disabled: new Set<string>() };
265
+
266
+ for (const server of servers) {
267
+ if (!isProjectScopedServer(server, pluginConfigPaths)) {
268
+ approved.push(server);
269
+ continue;
270
+ }
271
+ if (claude.disabled.has(server.name)) {
272
+ withheld.push({ server, reason: "disabled-by-claude-settings" });
273
+ continue;
274
+ }
275
+ if (claude.enableAll || claude.enabled.has(server.name)) {
276
+ approved.push(server);
277
+ continue;
278
+ }
279
+ const root = projectRootOf(server);
280
+ const hash = hashServerConfig(server);
281
+ const stored = readStoredApproval(root, deps.storePath);
282
+ if (stored?.enableAll || stored?.servers[server.name]?.configHash === hash) {
283
+ approved.push(server);
284
+ continue;
285
+ }
286
+ const remembered = sessionDecisions.get(`${root}:${server.name}:${hash}`);
287
+ if (remembered === true) approved.push(server);
288
+ else if (remembered === false) withheld.push({ server, reason: "declined" });
289
+ else pending.push(server);
290
+ }
291
+
292
+ if (pending.length === 0) return { approved, withheld };
293
+
294
+ if (!deps.hasUI) {
295
+ for (const server of pending) withheld.push({ server, reason: "not-approved" });
296
+ deps.notify(
297
+ `Skipped ${pending.length} MCP server${pending.length === 1 ? "" : "s"} from .mcp.json (${pending.map((s) => s.name).join(", ")}): not yet approved and no UI to ask. Approve once in an interactive session, or set enabledMcpjsonServers in .claude/settings.local.json.`,
298
+ );
299
+ return { approved, withheld };
300
+ }
301
+
302
+ // One dialog for the batch; concurrent callers (a reconnect racing startup) share it.
303
+ const names = pending.map((s) => s.name);
304
+ const key = pending.map((s) => `${projectRootOf(s)}:${s.name}:${hashServerConfig(s)}`).join("|");
305
+ let prompt = pendingPrompts.get(key);
306
+ if (!prompt) {
307
+ const useLabel = pending.length === 1 ? CHOICE_THIS : CHOICE_THESE;
308
+ prompt = deps
309
+ .select(`${promptTitle(names)}\n\n${describeServers(pending)}\n\nThese run on your machine.`, [CHOICE_ALL, useLabel, CHOICE_NO])
310
+ .finally(() => pendingPrompts.delete(key));
311
+ pendingPrompts.set(key, prompt);
312
+ }
313
+ const choice = await prompt;
314
+
315
+ for (const server of pending) {
316
+ const root = projectRootOf(server);
317
+ const decisionKey = `${root}:${server.name}:${hashServerConfig(server)}`;
318
+ if (choice === CHOICE_ALL || choice === CHOICE_THIS || choice === CHOICE_THESE) {
319
+ sessionDecisions.set(decisionKey, true);
320
+ if (choice === CHOICE_ALL) persistEnableAll(root, deps.storePath);
321
+ persistApproval(root, server, deps.storePath);
322
+ approved.push(server);
323
+ } else {
324
+ sessionDecisions.set(decisionKey, false);
325
+ withheld.push({ server, reason: "declined" });
326
+ // An explicit "No" is remembered as a disable; Escape is this session only.
327
+ if (choice === CHOICE_NO) deps.disable(server);
328
+ }
329
+ }
330
+ if (choice !== CHOICE_ALL && choice !== CHOICE_THIS && choice !== CHOICE_THESE) {
331
+ deps.notify(`MCP server${pending.length === 1 ? "" : "s"} from .mcp.json not connected (${names.join(", ")}); enable in /mcp.`);
332
+ }
333
+ return { approved, withheld };
334
+ }
335
+
336
+ /** Test seam. */
337
+ export function resetMcpTrustSessionState(): void {
338
+ sessionDecisions.clear();
339
+ pendingPrompts.clear();
340
+ }