@gajae-code/coding-agent 0.16.7 → 0.17.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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -15,7 +15,16 @@ import {
15
15
  tryParseJson,
16
16
  } from "@gajae-code/utils";
17
17
  import type { ExtensionModule } from "../capability/extension-module";
18
- import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../capability/fs";
18
+ import {
19
+ capturePathIdentity,
20
+ type FileIdentity,
21
+ invalidate as invalidateFsCache,
22
+ isSingleLinkRegularFileAt,
23
+ type ReadFileOptions,
24
+ type ReadScope,
25
+ readDirEntries,
26
+ readFile,
27
+ } from "../capability/fs";
19
28
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
20
29
  import type { Skill, SkillFrontmatter } from "../capability/skill";
21
30
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -52,7 +61,9 @@ export const SOURCE_PATHS = {
52
61
  get userAgent() {
53
62
  return `${getConfigDirName()}/agent`;
54
63
  },
55
- projectDir: CONFIG_DIR_NAME,
64
+ get projectDir() {
65
+ return CONFIG_DIR_NAME;
66
+ },
56
67
  },
57
68
  claude: {
58
69
  userBase: ".claude",
@@ -190,6 +201,35 @@ export function getProjectPath(ctx: LoadContext, source: SourceId, subpath: stri
190
201
  return path.join(ctx.cwd, paths.projectDir, subpath);
191
202
  }
192
203
 
204
+ /** Build the filesystem authority for a provider read. */
205
+ export function getReadOptions(
206
+ ctx: Pick<LoadContext, "home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity">,
207
+ scope: ReadScope,
208
+ ): ReadFileOptions | undefined {
209
+ if (!ctx.isolatedHome) return undefined;
210
+ return {
211
+ isolatedHome: true,
212
+ home: ctx.home,
213
+ homeIdentity: ctx.homeIdentity,
214
+ userAgentDir: scope === "native" ? ctx.userAgentDir : undefined,
215
+ userAgentIdentity: scope === "native" ? ctx.userAgentIdentity : undefined,
216
+ scope,
217
+ bypassCache: true,
218
+ };
219
+ }
220
+
221
+ export async function getReadOptionsForContainment(
222
+ ctx: Pick<LoadContext, "home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity">,
223
+ scope: ReadScope,
224
+ containmentRoot?: string,
225
+ ): Promise<ReadFileOptions | undefined> {
226
+ const options = getReadOptions(ctx, scope);
227
+ if (!options || !containmentRoot) return options;
228
+ const containmentRootIdentity = await capturePathIdentity(containmentRoot);
229
+ if (!containmentRootIdentity) return undefined;
230
+ return { ...options, containmentRoot, containmentRootIdentity };
231
+ }
232
+
193
233
  /**
194
234
  * Create source metadata for an item.
195
235
  */
@@ -413,6 +453,13 @@ async function globIf(
413
453
  }
414
454
  }
415
455
 
456
+ async function isAllowedIsolatedExtensionPath(
457
+ ctx: Pick<LoadContext, "isolatedHome">,
458
+ filePath: string,
459
+ ): Promise<boolean> {
460
+ return !ctx.isolatedHome || (await isSingleLinkRegularFileAt(filePath));
461
+ }
462
+
416
463
  export interface ScanSkillsFromDirOptions {
417
464
  dir: string;
418
465
  /** Selected authority root whose own identity must not be a symlink. */
@@ -420,6 +467,12 @@ export interface ScanSkillsFromDirOptions {
420
467
  providerId: string;
421
468
  level: "user" | "project";
422
469
  requireDescription?: boolean;
470
+ /** Optional physical root that every discovered skill must remain within. */
471
+ containmentRoot?: string;
472
+ /** Physical repository root permitting the project scan directory itself to be a symlink. */
473
+ linkContainmentRoot?: string;
474
+ /** Filesystem authority for explicit-home reads. */
475
+ scope?: ReadScope;
423
476
  }
424
477
 
425
478
  // Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
@@ -442,6 +495,7 @@ export const SkillDiscoveryTestHooks: {
442
495
  afterAuthorityRootValidated?: (root: string) => void | Promise<void>;
443
496
  afterAuthorityRootMissing?: (root: string) => void | Promise<void>;
444
497
  afterContainedRootValidated?: (root: string) => void | Promise<void>;
498
+ afterScanRootValidated?: (root: string) => void | Promise<void>;
445
499
  } = {};
446
500
 
447
501
  interface DirectoryIdentity {
@@ -450,18 +504,62 @@ interface DirectoryIdentity {
450
504
  realPath: string;
451
505
  }
452
506
 
453
- async function captureDirectoryIdentity(root: string): Promise<DirectoryIdentity | null> {
507
+ interface ScanRootLinkIdentity {
508
+ dev: bigint;
509
+ ino: bigint;
510
+ linkText: string;
511
+ target: DirectoryIdentity;
512
+ }
513
+
514
+ async function captureScanRootLinkIdentity(root: string): Promise<ScanRootLinkIdentity | null> {
454
515
  const observed = await fs.promises.lstat(root, { bigint: true });
455
- if (observed.isSymbolicLink() || !observed.isDirectory()) return null;
516
+ if (!observed.isSymbolicLink()) return null;
517
+ const linkText = await fs.promises.readlink(root);
518
+ let targetPath: string;
519
+ try {
520
+ targetPath = await fs.promises.realpath(root);
521
+ } catch (error) {
522
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
523
+ throw new Error(`Dangling skills directory link: ${root}`);
524
+ throw error;
525
+ }
526
+ const target = await captureDirectoryIdentity(targetPath);
527
+ if (!target) return null;
528
+ return { dev: observed.dev, ino: observed.ino, linkText, target };
529
+ }
530
+
531
+ async function scanRootLinkIdentityIsCurrent(root: string, expected: ScanRootLinkIdentity): Promise<boolean> {
532
+ try {
533
+ const observed = await fs.promises.lstat(root, { bigint: true });
534
+ if (!observed.isSymbolicLink() || observed.dev !== expected.dev || observed.ino !== expected.ino) return false;
535
+ const linkText = await fs.promises.readlink(root);
536
+ if (linkText !== expected.linkText) return false;
537
+ const targetPath = await fs.promises.realpath(root);
538
+ if (normalizePathForComparison(targetPath) !== normalizePathForComparison(expected.target.realPath)) return false;
539
+ return await directoryIdentityIsCurrent(targetPath, expected.target);
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
544
+
545
+ async function captureDirectoryIdentity(root: string, allowSymlink = false): Promise<DirectoryIdentity | null> {
546
+ const observed = allowSymlink
547
+ ? await fs.promises.stat(root, { bigint: true })
548
+ : await fs.promises.lstat(root, { bigint: true });
549
+ if ((!allowSymlink && observed.isSymbolicLink()) || !observed.isDirectory()) return null;
456
550
  const realPath = await fs.promises.realpath(root);
457
551
  const resolved = await fs.promises.stat(realPath, { bigint: true });
458
552
  if (!resolved.isDirectory() || observed.dev !== resolved.dev || observed.ino !== resolved.ino) return null;
459
- return { dev: observed.dev, ino: observed.ino, realPath };
553
+ return { dev: resolved.dev, ino: resolved.ino, realPath };
460
554
  }
461
555
 
462
- async function directoryIdentityIsCurrent(root: string, expected: DirectoryIdentity): Promise<boolean> {
556
+ async function directoryIdentityIsCurrent(
557
+ root: string,
558
+ expected: DirectoryIdentity,
559
+ allowSymlink = false,
560
+ ): Promise<boolean> {
463
561
  try {
464
- const current = await captureDirectoryIdentity(root);
562
+ const current = await captureDirectoryIdentity(root, allowSymlink);
465
563
  return (
466
564
  current !== null &&
467
565
  current.dev === expected.dev &&
@@ -479,12 +577,61 @@ export async function scanSkillsFromDir(
479
577
  ): Promise<LoadResult<Skill>> {
480
578
  const items: Skill[] = [];
481
579
  const warnings: string[] = [];
482
- const { dir, level, providerId, requireDescription = false } = options;
580
+ const { dir, level, providerId, requireDescription = false, containmentRoot, linkContainmentRoot } = options;
581
+ const scope = options.scope ?? (level === "user" ? "user" : "project");
582
+ if (linkContainmentRoot && (level !== "project" || scope !== "project")) {
583
+ warnings.push(`Refusing link containment root outside project scope: ${linkContainmentRoot}`);
584
+ return { items, warnings };
585
+ }
586
+ const readOptions = await getReadOptionsForContainment(_ctx, scope, containmentRoot);
587
+ if (_ctx.isolatedHome && containmentRoot && !readOptions) return { items, warnings };
588
+ const canonicalScanDir = await canonicalizePathWithinHome(_ctx, dir, containmentRoot, scope);
589
+ if (!canonicalScanDir) return { items, warnings };
590
+ // Preserve the lexical project root so a replacement link cannot be hidden
591
+ // by isolated-home canonicalization.
592
+ const scanDir = linkContainmentRoot ? path.resolve(dir) : canonicalScanDir;
593
+ let repositoryIdentity: DirectoryIdentity | null = null;
594
+ let repositoryLexicalRoot: string | undefined;
595
+ if (linkContainmentRoot) {
596
+ try {
597
+ repositoryLexicalRoot = path.resolve(linkContainmentRoot);
598
+ const repositoryRealPath = await fs.promises.realpath(repositoryLexicalRoot);
599
+ repositoryIdentity = await captureDirectoryIdentity(repositoryRealPath);
600
+ if (!repositoryIdentity) {
601
+ warnings.push(`Refusing unsafe repository root: ${linkContainmentRoot}`);
602
+ return { items, warnings };
603
+ }
604
+ const lexicalRelative = path.relative(repositoryLexicalRoot, path.resolve(dir));
605
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
606
+ warnings.push(`Refusing skills directory outside repository root: ${dir}`);
607
+ return { items, warnings };
608
+ }
609
+ } catch (error) {
610
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { items, warnings };
611
+ throw error;
612
+ }
613
+ }
483
614
 
484
615
  let entries: fs.Dirent[];
485
616
  let realRoot: string;
486
617
  let authorityIdentity: DirectoryIdentity | null = null;
487
- let scanRootIdentity: DirectoryIdentity;
618
+ let scanRootIdentity: DirectoryIdentity | null = null;
619
+ let scanRootLinkIdentity: ScanRootLinkIdentity | null = null;
620
+ const scanRootIsCurrent = async (): Promise<boolean> => {
621
+ if (!repositoryIdentity || !linkContainmentRoot) {
622
+ return scanRootIdentity !== null && (await directoryIdentityIsCurrent(scanDir, scanRootIdentity));
623
+ }
624
+ if (
625
+ !repositoryLexicalRoot ||
626
+ normalizePathForComparison(await fs.promises.realpath(repositoryLexicalRoot)) !==
627
+ normalizePathForComparison(repositoryIdentity.realPath) ||
628
+ !(await directoryIdentityIsCurrent(repositoryIdentity.realPath, repositoryIdentity))
629
+ ) {
630
+ return false;
631
+ }
632
+ if (scanRootLinkIdentity) return await scanRootLinkIdentityIsCurrent(scanDir, scanRootLinkIdentity);
633
+ return scanRootIdentity !== null && (await directoryIdentityIsCurrent(scanDir, scanRootIdentity));
634
+ };
488
635
  try {
489
636
  if (options.authorityRoot) {
490
637
  try {
@@ -504,22 +651,36 @@ export async function scanSkillsFromDir(
504
651
  return { items, warnings };
505
652
  }
506
653
  }
507
- const capturedScanRoot = await captureDirectoryIdentity(dir);
508
- if (!capturedScanRoot) {
509
- warnings.push(`Refusing unsafe skills directory: ${dir}`);
510
- return { items, warnings };
654
+ if (linkContainmentRoot) {
655
+ scanRootLinkIdentity = await captureScanRootLinkIdentity(scanDir);
656
+ if (!scanRootLinkIdentity) scanRootIdentity = await captureDirectoryIdentity(scanDir);
657
+ if (!scanRootLinkIdentity && !scanRootIdentity) {
658
+ warnings.push(`Refusing unsafe skills directory: ${dir}`);
659
+ return { items, warnings };
660
+ }
661
+ const capturedRealRoot = scanRootLinkIdentity?.target.realPath ?? scanRootIdentity!.realPath;
662
+ if (!isWithinOrEqual(repositoryIdentity!.realPath, capturedRealRoot)) {
663
+ warnings.push(`Refusing skills directory outside repository root: ${dir}`);
664
+ return { items, warnings };
665
+ }
666
+ realRoot = capturedRealRoot;
667
+ } else {
668
+ scanRootIdentity = await captureDirectoryIdentity(scanDir);
669
+ if (!scanRootIdentity) {
670
+ warnings.push(`Refusing unsafe skills directory: ${dir}`);
671
+ return { items, warnings };
672
+ }
673
+ realRoot = scanRootIdentity.realPath;
511
674
  }
512
- scanRootIdentity = capturedScanRoot;
513
- realRoot = scanRootIdentity.realPath;
514
- entries = await fs.promises.readdir(dir, { withFileTypes: true });
515
- if (!(await directoryIdentityIsCurrent(dir, scanRootIdentity))) {
675
+ entries = await fs.promises.readdir(scanDir, { withFileTypes: true });
676
+ await SkillDiscoveryTestHooks.afterScanRootValidated?.(scanDir);
677
+ if (!(await scanRootIsCurrent())) {
516
678
  warnings.push(`Refusing changed skills directory: ${dir}`);
517
679
  return { items, warnings };
518
680
  }
519
681
  } catch (error) {
520
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
521
- warnings.push(`Failed to read skills directory: ${dir} (${String(error)})`);
522
- }
682
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
683
+ warnings.push(`Failed to read skills directory: ${scanDir} (${String(error)})`);
523
684
  return { items, warnings };
524
685
  }
525
686
  const isWithinRoot = (candidate: string): boolean => {
@@ -528,7 +689,7 @@ export async function scanSkillsFromDir(
528
689
  };
529
690
  const openSkillFileSafely = async (skillPath: string): Promise<FileHandle> => {
530
691
  if (
531
- !(await directoryIdentityIsCurrent(dir, scanRootIdentity)) ||
692
+ !(await scanRootIsCurrent()) ||
532
693
  (options.authorityRoot &&
533
694
  authorityIdentity &&
534
695
  !(await directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)))
@@ -544,7 +705,7 @@ export async function scanSkillsFromDir(
544
705
  handle.stat({ bigint: true }),
545
706
  fs.promises.lstat(skillPath, { bigint: true }),
546
707
  fs.promises.realpath(skillPath),
547
- directoryIdentityIsCurrent(dir, scanRootIdentity),
708
+ scanRootIsCurrent(),
548
709
  options.authorityRoot && authorityIdentity
549
710
  ? directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)
550
711
  : true,
@@ -571,7 +732,16 @@ export async function scanSkillsFromDir(
571
732
  const readSkillContentSafely = async (skillPath: string): Promise<string> => {
572
733
  const handle = await openSkillFileSafely(skillPath);
573
734
  try {
574
- return await handle.readFile({ encoding: "utf8" });
735
+ const content = await handle.readFile({ encoding: "utf8" });
736
+ if (
737
+ !(await scanRootIsCurrent()) ||
738
+ (options.authorityRoot &&
739
+ authorityIdentity &&
740
+ !(await directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)))
741
+ ) {
742
+ throw new Error(`Unsafe skill authority root for: ${skillPath}`);
743
+ }
744
+ return content;
575
745
  } finally {
576
746
  await handle.close();
577
747
  }
@@ -665,7 +835,13 @@ export async function scanSkillsFromDir(
665
835
  for (const entry of entries) {
666
836
  if (entry.name.startsWith(".")) continue;
667
837
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
668
- const skillPath = path.join(dir, entry.name, "SKILL.md");
838
+ const skillPath = await canonicalizePathWithinHome(
839
+ _ctx,
840
+ path.join(scanDir, entry.name, "SKILL.md"),
841
+ containmentRoot,
842
+ scope,
843
+ );
844
+ if (!skillPath) continue;
669
845
  work.push(loadSkill(skillPath));
670
846
  }
671
847
  await Promise.all(work);
@@ -676,24 +852,33 @@ export async function scanSkillsFromDir(
676
852
  return { items, warnings };
677
853
  }
678
854
 
679
- /** Read a regular single-link file that remains contained by its configured root. */
680
- export async function readContainedFile(root: string, filePath: string): Promise<string | null> {
855
+ /** Read a regular file that remains contained by its configured root. */
856
+ export async function readContainedFile(
857
+ root: string,
858
+ filePath: string,
859
+ expectedRootIdentity?: FileIdentity | null,
860
+ ): Promise<string | null> {
861
+ if (expectedRootIdentity === null) return null;
681
862
  let rootIdentity: DirectoryIdentity;
682
863
  try {
683
- const capturedRoot = await captureDirectoryIdentity(root);
864
+ const capturedRoot = await captureDirectoryIdentity(root, true);
684
865
  if (!capturedRoot) return null;
685
866
  rootIdentity = capturedRoot;
867
+ if (
868
+ expectedRootIdentity !== undefined &&
869
+ (rootIdentity.dev !== BigInt(expectedRootIdentity.dev) ||
870
+ rootIdentity.ino !== BigInt(expectedRootIdentity.ino))
871
+ )
872
+ return null;
686
873
  await SkillDiscoveryTestHooks.afterContainedRootValidated?.(root);
687
- if (!(await directoryIdentityIsCurrent(root, rootIdentity))) return null;
874
+ if (!(await directoryIdentityIsCurrent(root, rootIdentity, true))) return null;
688
875
  } catch (error) {
689
876
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
690
877
  throw error;
691
878
  }
692
879
  const relative = path.relative(root, filePath);
693
880
  if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
694
- const flags =
695
- fs.constants.O_RDONLY |
696
- (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
881
+ const flags = fs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (fs.constants.O_NONBLOCK ?? 0));
697
882
  let handle: FileHandle;
698
883
  try {
699
884
  handle = await fs.promises.open(filePath, flags);
@@ -702,20 +887,19 @@ export async function readContainedFile(root: string, filePath: string): Promise
702
887
  return null;
703
888
  }
704
889
  try {
705
- const [opened, observed, currentPath, currentRoot] = await Promise.all([
890
+ const [opened, currentPath, currentTarget, currentRoot] = await Promise.all([
706
891
  handle.stat({ bigint: true }),
707
- fs.promises.lstat(filePath, { bigint: true }),
708
892
  fs.promises.realpath(filePath),
709
- directoryIdentityIsCurrent(root, rootIdentity),
893
+ fs.promises.stat(filePath, { bigint: true }),
894
+ directoryIdentityIsCurrent(root, rootIdentity, true),
710
895
  ]);
711
896
  const currentRelative = path.relative(rootIdentity.realPath, currentPath);
712
897
  if (
713
898
  !opened.isFile() ||
714
899
  opened.nlink !== 1n ||
715
- observed.isSymbolicLink() ||
716
- !observed.isFile() ||
717
- opened.dev !== observed.dev ||
718
- opened.ino !== observed.ino ||
900
+ !currentTarget.isFile() ||
901
+ opened.dev !== currentTarget.dev ||
902
+ opened.ino !== currentTarget.ino ||
719
903
  !currentRoot ||
720
904
  currentRelative.startsWith("..") ||
721
905
  path.isAbsolute(currentRelative)
@@ -723,7 +907,7 @@ export async function readContainedFile(root: string, filePath: string): Promise
723
907
  return null;
724
908
  }
725
909
  const content = await handle.readFile({ encoding: "utf8" });
726
- return (await directoryIdentityIsCurrent(root, rootIdentity)) ? content : null;
910
+ return (await directoryIdentityIsCurrent(root, rootIdentity, true)) ? content : null;
727
911
  } finally {
728
912
  await handle.close();
729
913
  }
@@ -778,10 +962,19 @@ export async function loadFilesFromDir<T>(
778
962
  transform: (name: string, content: string, path: string, source: SourceMeta) => T | null;
779
963
  /** Whether to recurse into subdirectories (default: false) */
780
964
  recursive?: boolean;
965
+ /** Optional physical root that every discovered file must remain within. */
966
+ containmentRoot?: string;
967
+ /** Filesystem authority for explicit-home reads. */
968
+ scope?: ReadScope;
781
969
  },
782
970
  ): Promise<LoadResult<T>> {
783
971
  const items: T[] = [];
784
972
  const warnings: string[] = [];
973
+ const scope = options.scope ?? (level === "user" ? "user" : "project");
974
+ const readOptions = await getReadOptionsForContainment(_ctx, scope, options.containmentRoot);
975
+ if (_ctx.isolatedHome && options.containmentRoot && !readOptions) return { items, warnings };
976
+ const scanDir = await canonicalizePathWithinHome(_ctx, dir, options.containmentRoot, scope);
977
+ if (!scanDir) return { items, warnings };
785
978
  // Build glob pattern based on extensions and recursion
786
979
  const { extensions, recursive = false } = options;
787
980
 
@@ -799,7 +992,7 @@ export async function loadFilesFromDir<T>(
799
992
  const { glob, FileType } = await discoveryNatives();
800
993
  const result = await glob({
801
994
  pattern,
802
- path: dir,
995
+ path: scanDir,
803
996
  gitignore: true,
804
997
  hidden: false,
805
998
  fileType: FileType.File,
@@ -813,13 +1006,21 @@ export async function loadFilesFromDir<T>(
813
1006
  // Read all matching files in parallel
814
1007
  const fileResults = await Promise.all(
815
1008
  matches.map(async match => {
816
- const filePath = path.join(dir, match.path);
817
- const content = await readFile(filePath);
1009
+ const filePath = await canonicalizePathWithinHome(
1010
+ _ctx,
1011
+ path.join(scanDir, match.path),
1012
+ options.containmentRoot,
1013
+ scope,
1014
+ );
1015
+ if (!filePath) return null;
1016
+ const content = await readFile(filePath, readOptions);
818
1017
  return { filePath, content };
819
1018
  }),
820
1019
  );
821
1020
 
822
- for (const { filePath, content } of fileResults) {
1021
+ for (const result of fileResults) {
1022
+ if (!result) continue;
1023
+ const { filePath, content } = result;
823
1024
  if (content === null) {
824
1025
  warnings.push(`Failed to read file: ${filePath}`);
825
1026
  continue;
@@ -858,8 +1059,11 @@ interface ExtensionModuleManifest {
858
1059
  async function readExtensionModuleManifest(
859
1060
  _ctx: LoadContext,
860
1061
  packageJsonPath: string,
1062
+ scope: ReadScope,
861
1063
  ): Promise<ExtensionModuleManifest | null> {
862
- const content = await readFile(packageJsonPath);
1064
+ const resolvedPackageJsonPath = await canonicalizePathWithinHome(_ctx, packageJsonPath, undefined, scope);
1065
+ if (!resolvedPackageJsonPath) return null;
1066
+ const content = await readFile(resolvedPackageJsonPath, getReadOptions(_ctx, scope));
863
1067
  if (!content) return null;
864
1068
 
865
1069
  const pkg = tryParseJson<{ gjc?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content);
@@ -881,47 +1085,66 @@ async function readExtensionModuleManifest(
881
1085
  * No recursion beyond one level. Complex packages must use package.json manifest.
882
1086
  * Uses native glob for fast filesystem scanning with gitignore support.
883
1087
  */
884
- export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: string): Promise<string[]> {
1088
+ export async function discoverExtensionModulePaths(
1089
+ ctx: LoadContext,
1090
+ dir: string,
1091
+ options: { scope?: ReadScope } = {},
1092
+ ): Promise<string[]> {
885
1093
  const discovered = new Set<string>();
1094
+ const scope = options.scope ?? "project";
1095
+ const readOptions = getReadOptions(ctx, scope);
1096
+ const discoveryDir = await canonicalizePathWithinHome(ctx, dir, undefined, scope);
1097
+ if (!discoveryDir) return [];
886
1098
  const { FileType } = await discoveryNatives();
887
1099
  // Find all candidate files in parallel using glob
888
1100
  const [directFiles, indexFiles, packageJsonFiles] = await Promise.all([
889
1101
  // 1. Direct *.ts or *.js files
890
- globIf(dir, "*.{ts,js}", FileType.File, false),
1102
+ globIf(discoveryDir, "*.{ts,js}", FileType.File, false),
891
1103
  // 2. Subdirectory index files
892
- globIf(dir, "*/index.{ts,js}", FileType.File, false),
1104
+ globIf(discoveryDir, "*/index.{ts,js}", FileType.File, false),
893
1105
  // 3. Subdirectory package.json files
894
- globIf(dir, "*/package.json", FileType.File, false),
1106
+ globIf(discoveryDir, "*/package.json", FileType.File, false),
895
1107
  ]);
896
1108
 
897
1109
  // Process direct files
898
1110
  for (const match of directFiles) {
899
1111
  if (match.path.includes("/")) continue;
900
- discovered.add(path.join(dir, match.path));
1112
+ const candidatePath = await canonicalizePathWithinHome(
1113
+ ctx,
1114
+ path.join(discoveryDir, match.path),
1115
+ undefined,
1116
+ scope,
1117
+ );
1118
+ if (candidatePath && (await isAllowedIsolatedExtensionPath(ctx, candidatePath))) discovered.add(candidatePath);
901
1119
  }
902
1120
  // Track which subdirectories have package.json manifests with declared extensions
903
1121
  const subdirsWithDeclaredExtensions = new Set<string>();
904
1122
  for (const match of packageJsonFiles) {
905
1123
  const subdir = path.dirname(match.path); // e.g., "my-extension"
906
- const packageJsonPath = path.join(dir, match.path);
907
- const manifest = await readExtensionModuleManifest(_ctx, packageJsonPath);
1124
+ const packageJsonPath = path.join(discoveryDir, match.path);
1125
+ const manifest = await readExtensionModuleManifest(ctx, packageJsonPath, scope);
908
1126
  const declaredExtensions =
909
1127
  manifest?.extensions?.filter((extPath): extPath is string => typeof extPath === "string") ?? [];
910
1128
  if (declaredExtensions.length === 0) continue;
911
1129
  subdirsWithDeclaredExtensions.add(subdir);
912
- const subdirPath = path.join(dir, subdir);
1130
+ const subdirPath = path.join(discoveryDir, subdir);
913
1131
  for (const extPath of declaredExtensions) {
914
- let resolvedExtPath = path.resolve(subdirPath, extPath);
915
- const entries = await readDirEntries(resolvedExtPath);
1132
+ const configuredPath = path.resolve(subdirPath, extPath);
1133
+ const resolvedConfiguredPath = await canonicalizePathWithinHome(ctx, configuredPath, undefined, scope);
1134
+ if (!resolvedConfiguredPath) continue;
1135
+ let resolvedExtPath = resolvedConfiguredPath;
1136
+ const entries = await readDirEntries(resolvedExtPath, readOptions);
916
1137
  if (entries.length !== 0) {
917
1138
  const pluginFilePath = entries.find(
918
1139
  e => e.isFile() && (e.name === "index.ts" || e.name === "index.js"),
919
1140
  )?.name;
920
1141
  resolvedExtPath = pluginFilePath ? path.join(resolvedExtPath, pluginFilePath) : resolvedExtPath;
921
1142
  }
922
- const content = await readFile(resolvedExtPath);
1143
+ const canonicalExtPath = await canonicalizePathWithinHome(ctx, resolvedExtPath, undefined, scope);
1144
+ if (!canonicalExtPath || !(await isAllowedIsolatedExtensionPath(ctx, canonicalExtPath))) continue;
1145
+ const content = await readFile(canonicalExtPath, readOptions);
923
1146
  if (content !== null) {
924
- discovered.add(resolvedExtPath);
1147
+ discovered.add(canonicalExtPath);
925
1148
  }
926
1149
  }
927
1150
  }
@@ -936,7 +1159,13 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
936
1159
  }
937
1160
  }
938
1161
  for (const preferredPath of preferredIndexBySubdir.values()) {
939
- discovered.add(path.join(dir, preferredPath));
1162
+ const candidatePath = await canonicalizePathWithinHome(
1163
+ ctx,
1164
+ path.join(discoveryDir, preferredPath),
1165
+ undefined,
1166
+ scope,
1167
+ );
1168
+ if (candidatePath && (await isAllowedIsolatedExtensionPath(ctx, candidatePath))) discovered.add(candidatePath);
940
1169
  }
941
1170
  return [...discovered];
942
1171
  }
@@ -1058,34 +1287,62 @@ export function parseClaudePluginsRegistry(content: string): ClaudePluginsRegist
1058
1287
  * This is the single source of truth for "active project root" used by install,
1059
1288
  * uninstall, list, upgrade, discovery, and doctor. Deterministic for a given `cwd`.
1060
1289
  */
1061
- export async function resolveActiveProjectRegistryPath(cwd: string): Promise<string | null> {
1290
+ export async function resolveActiveProjectRegistryPath(
1291
+ cwd: string,
1292
+ homeDir?: string,
1293
+ isolatedHome = false,
1294
+ ): Promise<string | null> {
1062
1295
  // Pass 1: walk up looking for an existing .gjc/ directory (nearest wins).
1063
- // Stop before the provenance-checked home — ~/.gjc/ is the user-level config dir, not a project root.
1064
- const homeDir = getTrustedHomeDir();
1065
- let dir = path.resolve(cwd);
1066
- while (dir !== homeDir) {
1296
+ // Stop before the caller's authoritative home — its .gjc/ is the user-level
1297
+ // config dir, not a project root. Explicit-home capability loading passes
1298
+ // that supplied home instead of leaking the process-global trusted home.
1299
+ const canonicalize = async (value: string): Promise<string> => {
1067
1300
  try {
1068
- const stat = await fs.promises.stat(path.join(dir, getConfigDirName()));
1301
+ return await fs.promises.realpath(value);
1302
+ } catch {
1303
+ return path.resolve(value);
1304
+ }
1305
+ };
1306
+ const explicitHome = homeDir !== undefined;
1307
+ const trustedHome = isolatedHome ? null : await canonicalize(getTrustedHomeDir()).catch(() => null);
1308
+ homeDir = await canonicalize(homeDir ?? getTrustedHomeDir());
1309
+ const canonicalCwd = await canonicalize(cwd);
1310
+ const relativeHome = path.relative(homeDir, canonicalCwd);
1311
+ const explicitBoundary = isolatedHome || (explicitHome && homeDir !== trustedHome);
1312
+ const effectiveStop =
1313
+ !explicitBoundary ||
1314
+ relativeHome === "" ||
1315
+ (relativeHome !== ".." && !relativeHome.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeHome))
1316
+ ? homeDir
1317
+ : canonicalCwd;
1318
+ let dir = canonicalCwd;
1319
+ while (true) {
1320
+ if (dir === effectiveStop && effectiveStop === homeDir) break;
1321
+ try {
1322
+ const stat = await fs.promises.stat(path.join(dir, CONFIG_DIR_NAME));
1069
1323
  if (stat.isDirectory()) {
1070
- return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json");
1324
+ return path.join(dir, CONFIG_DIR_NAME, "plugins", "installed_plugins.json");
1071
1325
  }
1072
1326
  } catch {
1073
1327
  // not found at this level — continue up
1074
1328
  }
1329
+ if (dir === effectiveStop) break;
1075
1330
  const parent = path.dirname(dir);
1076
1331
  if (parent === dir) break; // filesystem root
1077
1332
  dir = parent;
1078
1333
  }
1079
1334
 
1080
1335
  // Pass 2: walk up looking for .git as a fallback anchor.
1081
- dir = path.resolve(cwd);
1082
- while (dir !== homeDir) {
1336
+ dir = canonicalCwd;
1337
+ while (true) {
1338
+ if (dir === effectiveStop && effectiveStop === homeDir) break;
1083
1339
  try {
1084
1340
  await fs.promises.stat(path.join(dir, ".git"));
1085
- return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json");
1341
+ return path.join(dir, CONFIG_DIR_NAME, "plugins", "installed_plugins.json");
1086
1342
  } catch {
1087
1343
  // not found at this level — continue up
1088
1344
  }
1345
+ if (dir === effectiveStop) break;
1089
1346
  const parent = path.dirname(dir);
1090
1347
  if (parent === dir) break; // filesystem root
1091
1348
  dir = parent;
@@ -1111,46 +1368,154 @@ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<
1111
1368
  // Home directory must not be treated as a project root: the fallback path would alias
1112
1369
  // getInstalledPluginsRegistryPath(), causing MarketplaceManager to load the same file
1113
1370
  // as both user and project registry and producing duplicates / disambiguation errors.
1114
- if (path.resolve(cwd) === getTrustedHomeDir()) return undefined;
1115
- return path.join(cwd, getConfigDirName(), "plugins", "installed_plugins.json");
1371
+ const [canonicalCwd, canonicalHome] = await Promise.all([
1372
+ fs.promises.realpath(cwd).catch(() => path.resolve(cwd)),
1373
+ fs.promises.realpath(getTrustedHomeDir()).catch(() => path.resolve(getTrustedHomeDir())),
1374
+ ]);
1375
+ if (canonicalCwd === canonicalHome) return undefined;
1376
+ return path.join(cwd, CONFIG_DIR_NAME, "plugins", "installed_plugins.json");
1116
1377
  }
1117
1378
 
1118
1379
  const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
1119
1380
 
1120
1381
  /** Invalidate the exact user/project registry pair and their resolved-root cache entry. */
1121
- export async function invalidateClaudePluginRoots(home: string, cwd?: string): Promise<void> {
1122
- const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
1123
- invalidateFsCache(path.join(getPluginsDir(home), "installed_plugins.json"));
1124
- if (resolvedProjectPath) invalidateFsCache(resolvedProjectPath);
1125
- pluginRootsCache.delete(`${home}:${resolvedProjectPath ?? ""}`);
1382
+ export async function invalidateClaudePluginRoots(home: string, cwd?: string, isolatedHome = false): Promise<void> {
1383
+ const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd, home, isolatedHome) : null;
1384
+ const canonicalHome = await canonicalizeThroughExistingAncestor(home);
1385
+ const rawGjcRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
1386
+ const gjcRegistryPath = await canonicalizePluginRegistryPath(canonicalHome, rawGjcRegistryPath, isolatedHome);
1387
+ const projectRegistryPath = resolvedProjectPath
1388
+ ? await canonicalizePluginRegistryPath(canonicalHome, resolvedProjectPath, isolatedHome)
1389
+ : undefined;
1390
+ for (const registryPath of new Set([
1391
+ rawGjcRegistryPath,
1392
+ gjcRegistryPath,
1393
+ resolvedProjectPath,
1394
+ projectRegistryPath,
1395
+ ])) {
1396
+ if (registryPath) invalidateFsCache(registryPath);
1397
+ }
1398
+ pluginRootsCache.delete(`${canonicalHome}:${gjcRegistryPath ?? ""}:${projectRegistryPath ?? ""}`);
1399
+ }
1400
+
1401
+ async function canonicalizeThroughExistingAncestor(target: string): Promise<string> {
1402
+ const resolved = path.resolve(target);
1403
+ const suffix: string[] = [];
1404
+ let current = resolved;
1405
+
1406
+ while (true) {
1407
+ try {
1408
+ const real = await fs.promises.realpath(current);
1409
+ return suffix.length > 0 ? path.join(real, ...suffix.reverse()) : real;
1410
+ } catch (error) {
1411
+ const code = (error as NodeJS.ErrnoException).code;
1412
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
1413
+ const parent = path.dirname(current);
1414
+ if (parent === current) return resolved;
1415
+ suffix.push(path.basename(current));
1416
+ current = parent;
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ function isWithinOrEqual(root: string, candidate: string): boolean {
1422
+ const relative = path.relative(root, candidate);
1423
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
1424
+ }
1425
+
1426
+ async function canonicalizePluginRegistryPath(
1427
+ home: string,
1428
+ registryPath: string,
1429
+ isolatedHome: boolean,
1430
+ ): Promise<string | undefined> {
1431
+ const canonical = await canonicalizeThroughExistingAncestor(registryPath);
1432
+ if (isolatedHome) {
1433
+ const canonicalHome = await canonicalizeThroughExistingAncestor(home);
1434
+ if (!isWithinOrEqual(canonicalHome, canonical)) return undefined;
1435
+ }
1436
+ return canonical;
1437
+ }
1438
+
1439
+ /**
1440
+ * Resolve a configured path through existing symlinks when an explicit-home
1441
+ * load is active. Ordinary discovery keeps its historical lexical path
1442
+ * handling; isolated discovery must reject paths that leave the supplied
1443
+ * physical home (or an explicitly selected agent directory), including paths
1444
+ * that escape through an existing symlink. When provided, containmentRoot is
1445
+ * an additional physical boundary, such as a plugin root.
1446
+ */
1447
+ export async function canonicalizePathWithinHome(
1448
+ ctx: Pick<LoadContext, "home" | "isolatedHome" | "userAgentDir" | "homeIdentity">,
1449
+ target: string,
1450
+ containmentRoot?: string,
1451
+ scope: ReadScope = "project",
1452
+ ): Promise<string | undefined> {
1453
+ if (!ctx.isolatedHome) return target;
1454
+ const roots = scope === "native" ? [ctx.home, ctx.userAgentDir] : [ctx.home];
1455
+ const canonicalRoots = await Promise.all(
1456
+ roots.filter((root): root is string => typeof root === "string").map(canonicalizeThroughExistingAncestor),
1457
+ );
1458
+ const canonicalTarget = await canonicalizeThroughExistingAncestor(target);
1459
+ if (!canonicalRoots.some(root => isWithinOrEqual(root, canonicalTarget))) return undefined;
1460
+ if (containmentRoot) {
1461
+ const canonicalContainmentRoot = await canonicalizeThroughExistingAncestor(containmentRoot);
1462
+ if (!isWithinOrEqual(canonicalContainmentRoot, canonicalTarget)) return undefined;
1463
+ }
1464
+ return canonicalTarget;
1465
+ }
1466
+
1467
+ async function resolveIsolatedPluginPath(home: string, value: string): Promise<string | undefined> {
1468
+ const canonicalHome = await canonicalizeThroughExistingAncestor(home);
1469
+ const resolved = path.resolve(canonicalHome, value);
1470
+ const canonical = await canonicalizeThroughExistingAncestor(resolved);
1471
+ if (!isWithinOrEqual(canonicalHome, canonical)) return undefined;
1472
+ return canonical;
1126
1473
  }
1127
1474
 
1128
1475
  /**
1129
1476
  * List installed GJC plugin roots from the GJC plugin registry and, when present,
1130
1477
  * the nearest project-scoped registry resolved from `cwd`.
1131
1478
  *
1132
- * Results are cached per `home:resolvedProjectPath` key to avoid repeated parsing.
1479
+ * Ordinary results are cached per `home:resolvedProjectPath` key to avoid
1480
+ * repeated parsing. Isolated results intentionally bypass the shared cache:
1481
+ * an ordinary load may contain external install roots that an isolated load
1482
+ * must reject, and reusing that result would cross the home boundary.
1133
1483
  */
1134
1484
 
1135
1485
  export async function listClaudePluginRoots(
1136
1486
  home: string,
1137
1487
  cwd?: string,
1488
+ isolatedHome = false,
1489
+ homeIdentity?: FileIdentity,
1138
1490
  ): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
1139
- const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
1140
- const cacheKey = `${home}:${resolvedProjectPath ?? ""}`;
1141
- const cached = pluginRootsCache.get(cacheKey);
1142
- if (cached) return cached;
1491
+ const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd, home, isolatedHome) : null;
1492
+ const canonicalHome = await canonicalizeThroughExistingAncestor(home);
1493
+ const rawGjcRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
1494
+ const gjcRegistryPath = await canonicalizePluginRegistryPath(canonicalHome, rawGjcRegistryPath, isolatedHome);
1495
+ const projectRegistryPath = resolvedProjectPath
1496
+ ? await canonicalizePluginRegistryPath(canonicalHome, resolvedProjectPath, isolatedHome)
1497
+ : undefined;
1498
+ const cacheKey = `${canonicalHome}:${gjcRegistryPath ?? ""}:${projectRegistryPath ?? ""}`;
1499
+ if (!isolatedHome) {
1500
+ const cached = pluginRootsCache.get(cacheKey);
1501
+ if (cached) return cached;
1502
+ }
1143
1503
 
1144
1504
  const roots: ClaudePluginRoot[] = [];
1145
1505
  const warnings: string[] = [];
1146
1506
  const projectRoots: ClaudePluginRoot[] = [];
1507
+ const registryReadOptions: ReadFileOptions | undefined = isolatedHome
1508
+ ? { isolatedHome: true, home: canonicalHome, homeIdentity, scope: "project", bypassCache: true }
1509
+ : undefined;
1147
1510
 
1148
1511
  // ── GJC installed plugins registry ───────────────────────────────────────
1149
1512
  // In production `home` is the provenance-checked home, so `getPluginsDir(home)` resolves to the
1150
1513
  // same XDG-aware path the marketplace writer uses (reads and writes always agree).
1151
1514
  // Tests pass a temp dir, which short-circuits the resolver for deterministic isolation.
1152
- const gjcRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
1153
- const gjcContent = await readFile(gjcRegistryPath);
1515
+ const gjcContent = gjcRegistryPath ? await readFile(gjcRegistryPath, registryReadOptions) : null;
1516
+ if (isolatedHome && !gjcRegistryPath) {
1517
+ warnings.push(`Ignoring GJC plugin registry outside the isolated home: ${rawGjcRegistryPath}`);
1518
+ }
1154
1519
  if (gjcContent) {
1155
1520
  const gjcRegistry = parseClaudePluginsRegistry(gjcContent);
1156
1521
  if (gjcRegistry) {
@@ -1166,20 +1531,33 @@ export async function listClaudePluginRoots(
1166
1531
  const marketplace = pluginId.slice(atIndex + 1);
1167
1532
 
1168
1533
  for (const entry of entries) {
1169
- if (!entry.installPath || typeof entry.installPath !== "string") {
1534
+ if (
1535
+ !entry ||
1536
+ typeof entry !== "object" ||
1537
+ Array.isArray(entry) ||
1538
+ !entry.installPath ||
1539
+ typeof entry.installPath !== "string"
1540
+ ) {
1170
1541
  warnings.push(`Plugin ${pluginId} entry has no installPath`);
1171
1542
  continue;
1172
1543
  }
1173
1544
  if (entry.enabled === false) continue;
1545
+ const installPath = isolatedHome
1546
+ ? await resolveIsolatedPluginPath(home, entry.installPath)
1547
+ : entry.installPath;
1548
+ if (!installPath) {
1549
+ warnings.push(`Plugin ${pluginId} installPath escapes the isolated home`);
1550
+ continue;
1551
+ }
1174
1552
  // Deduplicate by installPath within same ID
1175
- if (roots.some(r => r.id === pluginId && r.path === entry.installPath)) continue;
1553
+ if (roots.some(r => r.id === pluginId && r.path === installPath)) continue;
1176
1554
 
1177
1555
  roots.push({
1178
1556
  id: pluginId,
1179
1557
  marketplace,
1180
1558
  plugin: pluginName,
1181
1559
  version: entry.version || "unknown",
1182
- path: entry.installPath,
1560
+ path: installPath,
1183
1561
  scope: entry.scope || "user",
1184
1562
  });
1185
1563
  }
@@ -1193,7 +1571,10 @@ export async function listClaudePluginRoots(
1193
1571
  // Loaded from the nearest .gjc/plugins/installed_plugins.json relative to cwd.
1194
1572
  // Project entries take precedence over user entries for the same plugin ID.
1195
1573
  if (resolvedProjectPath) {
1196
- const projectContent = await readFile(resolvedProjectPath);
1574
+ const projectContent = projectRegistryPath ? await readFile(projectRegistryPath, registryReadOptions) : null;
1575
+ if (isolatedHome && !projectRegistryPath) {
1576
+ warnings.push(`Ignoring project plugin registry outside the isolated home: ${resolvedProjectPath}`);
1577
+ }
1197
1578
  if (projectContent) {
1198
1579
  const projectRegistry = parseClaudePluginsRegistry(projectContent);
1199
1580
  if (projectRegistry) {
@@ -1207,23 +1588,36 @@ export async function listClaudePluginRoots(
1207
1588
  const pluginName = pluginId.slice(0, atIndex);
1208
1589
  const marketplace = pluginId.slice(atIndex + 1);
1209
1590
  for (const entry of entries) {
1210
- if (!entry.installPath || typeof entry.installPath !== "string") {
1591
+ if (
1592
+ !entry ||
1593
+ typeof entry !== "object" ||
1594
+ Array.isArray(entry) ||
1595
+ !entry.installPath ||
1596
+ typeof entry.installPath !== "string"
1597
+ ) {
1211
1598
  warnings.push(`Plugin ${pluginId} entry has no installPath`);
1212
1599
  continue;
1213
1600
  }
1214
1601
  if (entry.enabled === false) continue;
1602
+ const installPath = isolatedHome
1603
+ ? await resolveIsolatedPluginPath(home, entry.installPath)
1604
+ : entry.installPath;
1605
+ if (!installPath) {
1606
+ warnings.push(`Plugin ${pluginId} installPath escapes the isolated home`);
1607
+ continue;
1608
+ }
1215
1609
  projectRoots.push({
1216
1610
  id: pluginId,
1217
1611
  marketplace,
1218
1612
  plugin: pluginName,
1219
1613
  version: entry.version || "unknown",
1220
- path: entry.installPath,
1614
+ path: installPath,
1221
1615
  scope: "project",
1222
1616
  });
1223
1617
  }
1224
1618
  }
1225
1619
  } else {
1226
- warnings.push(`Failed to parse project plugin registry: ${resolvedProjectPath}`);
1620
+ warnings.push(`Failed to parse project plugin registry: ${projectRegistryPath}`);
1227
1621
  }
1228
1622
  }
1229
1623
  }
@@ -1237,7 +1631,7 @@ export async function listClaudePluginRoots(
1237
1631
  }
1238
1632
 
1239
1633
  const result = { roots, warnings };
1240
- pluginRootsCache.set(cacheKey, result);
1634
+ if (!isolatedHome) pluginRootsCache.set(cacheKey, result);
1241
1635
  return result;
1242
1636
  }
1243
1637