@oh-my-pi/pi-coding-agent 17.1.8 → 17.2.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 (246) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-dj46zzrm.md} +110 -0
  3. package/dist/cli.js +10009 -9654
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/model-discovery.d.ts +12 -0
  13. package/dist/types/config/settings-schema.d.ts +73 -8
  14. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  15. package/dist/types/cursor.d.ts +193 -8
  16. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  17. package/dist/types/edit/hashline/diff.d.ts +9 -1
  18. package/dist/types/edit/index.d.ts +8 -1
  19. package/dist/types/edit/streaming.d.ts +8 -1
  20. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  21. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +25 -0
  23. package/dist/types/internal-urls/index.d.ts +1 -0
  24. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  25. package/dist/types/internal-urls/security-protocol.d.ts +15 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  28. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  29. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  30. package/dist/types/lsp/client.d.ts +2 -0
  31. package/dist/types/main.d.ts +2 -0
  32. package/dist/types/mcp/manager.d.ts +33 -4
  33. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  35. package/dist/types/modes/components/session-selector.d.ts +6 -0
  36. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  38. package/dist/types/modes/components/usage-row.d.ts +2 -0
  39. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  40. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  41. package/dist/types/modes/interactive-mode.d.ts +4 -2
  42. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  44. package/dist/types/modes/theme/theme.d.ts +12 -1
  45. package/dist/types/modes/types.d.ts +2 -1
  46. package/dist/types/sdk.d.ts +18 -0
  47. package/dist/types/security/auth.d.ts +30 -0
  48. package/dist/types/security/cloud.d.ts +79 -0
  49. package/dist/types/security/comparison.d.ts +49 -0
  50. package/dist/types/security/contracts/ids.d.ts +15 -0
  51. package/dist/types/security/contracts/index.d.ts +4 -0
  52. package/dist/types/security/contracts/schemas.d.ts +660 -0
  53. package/dist/types/security/contracts/types.d.ts +234 -0
  54. package/dist/types/security/contracts/validation.d.ts +5 -0
  55. package/dist/types/security/coordinator.d.ts +100 -0
  56. package/dist/types/security/importers/codex-security.d.ts +7 -0
  57. package/dist/types/security/importers/index.d.ts +2 -0
  58. package/dist/types/security/importers/sarif.d.ts +9 -0
  59. package/dist/types/security/index.d.ts +13 -0
  60. package/dist/types/security/preflight.d.ts +61 -0
  61. package/dist/types/security/provenance.d.ts +24 -0
  62. package/dist/types/security/publication.d.ts +78 -0
  63. package/dist/types/security/remediation.d.ts +27 -0
  64. package/dist/types/security/resource-output.d.ts +8 -0
  65. package/dist/types/security/sarif.d.ts +2 -0
  66. package/dist/types/security/store.d.ts +40 -0
  67. package/dist/types/session/agent-session-events.d.ts +2 -0
  68. package/dist/types/session/agent-session-types.d.ts +36 -1
  69. package/dist/types/session/agent-session.d.ts +4 -2
  70. package/dist/types/session/claude-session-store.d.ts +13 -0
  71. package/dist/types/session/codex-session-store.d.ts +14 -0
  72. package/dist/types/session/credential-pin.d.ts +58 -0
  73. package/dist/types/session/foreign-session-import.d.ts +15 -0
  74. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  75. package/dist/types/session/foreign-session-store.d.ts +23 -0
  76. package/dist/types/session/session-advisors.d.ts +47 -2
  77. package/dist/types/session/session-entries.d.ts +19 -1
  78. package/dist/types/session/session-handoff.d.ts +4 -1
  79. package/dist/types/session/session-loader.d.ts +2 -0
  80. package/dist/types/session/session-maintenance.d.ts +1 -1
  81. package/dist/types/session/session-manager.d.ts +21 -0
  82. package/dist/types/session/session-tools.d.ts +25 -1
  83. package/dist/types/session/turn-recovery.d.ts +10 -4
  84. package/dist/types/slash-commands/helpers/security.d.ts +2 -0
  85. package/dist/types/system-prompt.d.ts +4 -1
  86. package/dist/types/task/executor.d.ts +14 -2
  87. package/dist/types/task/render.d.ts +7 -0
  88. package/dist/types/thinking.d.ts +18 -6
  89. package/dist/types/tools/acp-bridge.d.ts +28 -5
  90. package/dist/types/tools/builtin-names.d.ts +1 -1
  91. package/dist/types/tools/grep.d.ts +17 -1
  92. package/dist/types/tools/hub/launch.d.ts +5 -1
  93. package/dist/types/tools/index.d.ts +10 -2
  94. package/dist/types/tools/path-utils.d.ts +19 -0
  95. package/dist/types/tools/read.d.ts +2 -0
  96. package/dist/types/tools/security-scan.d.ts +96 -0
  97. package/dist/types/tools/xdev.d.ts +2 -1
  98. package/dist/types/utils/changelog.d.ts +19 -0
  99. package/dist/types/utils/git.d.ts +10 -0
  100. package/dist/types/utils/jj.d.ts +11 -4
  101. package/dist/types/web/search/types.d.ts +2 -2
  102. package/package.json +12 -12
  103. package/scripts/security-compare.ts +40 -0
  104. package/src/advisor/runtime.ts +138 -12
  105. package/src/advisor/transcript-recorder.ts +56 -0
  106. package/src/auto-thinking/classifier.ts +48 -10
  107. package/src/cli/args.ts +6 -0
  108. package/src/cli/flag-tables.ts +2 -0
  109. package/src/cli/gallery-fixtures/fs.ts +25 -0
  110. package/src/cli/session-picker.ts +34 -17
  111. package/src/cli/ttsr-cli.ts +19 -1
  112. package/src/cli.ts +9 -0
  113. package/src/collab/guest.ts +37 -4
  114. package/src/collab/host.ts +1 -0
  115. package/src/commands/launch.ts +6 -0
  116. package/src/config/model-discovery.ts +32 -5
  117. package/src/config/model-registry.ts +107 -25
  118. package/src/config/settings-schema.ts +77 -6
  119. package/src/config/settings.ts +25 -0
  120. package/src/cursor-bridge-tools.ts +81 -0
  121. package/src/cursor.ts +463 -12
  122. package/src/edit/edit-clipboard.ts +23 -0
  123. package/src/edit/hashline/diff.ts +49 -10
  124. package/src/edit/hashline/execute.ts +38 -6
  125. package/src/edit/hashline/filesystem.ts +27 -3
  126. package/src/edit/index.ts +12 -2
  127. package/src/edit/renderer.ts +4 -4
  128. package/src/edit/streaming.ts +17 -2
  129. package/src/eval/py/prelude.py +7 -3
  130. package/src/export/html/template.js +1 -1
  131. package/src/extensibility/extensions/runner.ts +70 -0
  132. package/src/extensibility/extensions/types.ts +30 -0
  133. package/src/extensibility/legacy-pi-coding-agent-shim.ts +65 -13
  134. package/src/internal-urls/index.ts +1 -0
  135. package/src/internal-urls/local-protocol.ts +3 -2
  136. package/src/internal-urls/mcp-protocol.ts +4 -1
  137. package/src/internal-urls/router.ts +4 -1
  138. package/src/internal-urls/security-protocol.ts +261 -0
  139. package/src/internal-urls/types.ts +1 -1
  140. package/src/launch/terminal-output-worker-client.ts +53 -0
  141. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  142. package/src/launch/terminal-output-worker.ts +23 -0
  143. package/src/lsp/client.ts +18 -3
  144. package/src/lsp/index.ts +7 -0
  145. package/src/lsp/render.ts +1 -1
  146. package/src/main.ts +127 -56
  147. package/src/mcp/manager.ts +120 -18
  148. package/src/modes/acp/acp-agent.ts +15 -9
  149. package/src/modes/components/chat-transcript-builder.ts +30 -11
  150. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  151. package/src/modes/components/read-tool-group.ts +159 -17
  152. package/src/modes/components/session-selector.ts +19 -3
  153. package/src/modes/components/status-line/component.ts +381 -54
  154. package/src/modes/components/tool-execution.ts +78 -31
  155. package/src/modes/components/usage-row.ts +10 -5
  156. package/src/modes/controllers/event-controller.ts +26 -4
  157. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  158. package/src/modes/controllers/selector-controller.ts +94 -36
  159. package/src/modes/controllers/tan-command-controller.ts +14 -0
  160. package/src/modes/interactive-mode.ts +31 -17
  161. package/src/modes/rpc/host-uris.ts +6 -0
  162. package/src/modes/rpc/rpc-client.ts +18 -0
  163. package/src/modes/rpc/rpc-mode.ts +16 -1
  164. package/src/modes/rpc/rpc-types.ts +11 -0
  165. package/src/modes/theme/theme.ts +151 -6
  166. package/src/modes/types.ts +2 -1
  167. package/src/modes/utils/ui-helpers.ts +38 -21
  168. package/src/prompts/agents/security-reviewer.md +75 -0
  169. package/src/prompts/security/scan-coordinator.md +7 -0
  170. package/src/prompts/security/scan-request.md +21 -0
  171. package/src/prompts/security/validate-request.md +8 -0
  172. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  173. package/src/prompts/system/plan-mode-active.md +2 -2
  174. package/src/prompts/system/system-prompt.md +6 -0
  175. package/src/prompts/system/xdev-mount-notice.md +1 -1
  176. package/src/prompts/tools/bash.md +15 -17
  177. package/src/prompts/tools/checkpoint.md +1 -1
  178. package/src/prompts/tools/glob.md +7 -6
  179. package/src/prompts/tools/grep.md +7 -6
  180. package/src/prompts/tools/security-publish.md +1 -0
  181. package/src/prompts/tools/security-scan.md +1 -0
  182. package/src/sdk.ts +212 -21
  183. package/src/security/auth.ts +98 -0
  184. package/src/security/cloud.ts +686 -0
  185. package/src/security/comparison.ts +255 -0
  186. package/src/security/contracts/ids.ts +111 -0
  187. package/src/security/contracts/index.ts +4 -0
  188. package/src/security/contracts/schemas.ts +201 -0
  189. package/src/security/contracts/types.ts +254 -0
  190. package/src/security/contracts/validation.ts +65 -0
  191. package/src/security/coordinator.ts +708 -0
  192. package/src/security/importers/codex-security.ts +387 -0
  193. package/src/security/importers/index.ts +2 -0
  194. package/src/security/importers/sarif.ts +357 -0
  195. package/src/security/index.ts +13 -0
  196. package/src/security/preflight.ts +405 -0
  197. package/src/security/provenance.ts +106 -0
  198. package/src/security/publication.ts +326 -0
  199. package/src/security/remediation.ts +93 -0
  200. package/src/security/resource-output.ts +50 -0
  201. package/src/security/sarif.ts +78 -0
  202. package/src/security/store.ts +430 -0
  203. package/src/session/agent-session-events.ts +1 -0
  204. package/src/session/agent-session-types.ts +40 -1
  205. package/src/session/agent-session.ts +302 -175
  206. package/src/session/claude-session-store.ts +426 -0
  207. package/src/session/codex-session-store.ts +673 -0
  208. package/src/session/credential-pin.ts +93 -0
  209. package/src/session/foreign-session-import.ts +52 -0
  210. package/src/session/foreign-session-jsonl.ts +29 -0
  211. package/src/session/foreign-session-store.ts +26 -0
  212. package/src/session/model-controls.ts +11 -3
  213. package/src/session/session-advisors.ts +140 -16
  214. package/src/session/session-entries.ts +21 -1
  215. package/src/session/session-handoff.ts +20 -3
  216. package/src/session/session-loader.ts +25 -9
  217. package/src/session/session-maintenance.ts +59 -11
  218. package/src/session/session-manager.ts +61 -0
  219. package/src/session/session-tools.ts +107 -5
  220. package/src/session/settings-stream-fn.ts +1 -0
  221. package/src/session/turn-recovery.ts +97 -51
  222. package/src/slash-commands/builtin-registry.ts +152 -3
  223. package/src/slash-commands/helpers/security.ts +451 -0
  224. package/src/system-prompt.ts +7 -2
  225. package/src/task/agents.ts +2 -0
  226. package/src/task/executor.ts +20 -4
  227. package/src/task/render.ts +20 -4
  228. package/src/task/structured-subagent.ts +6 -4
  229. package/src/thinking.ts +29 -10
  230. package/src/tools/acp-bridge.ts +52 -8
  231. package/src/tools/browser.ts +5 -0
  232. package/src/tools/builtin-names.ts +1 -0
  233. package/src/tools/checkpoint.ts +0 -13
  234. package/src/tools/grep.ts +64 -8
  235. package/src/tools/hub/launch.ts +10 -15
  236. package/src/tools/index.ts +37 -5
  237. package/src/tools/path-utils.ts +91 -0
  238. package/src/tools/read.ts +3 -0
  239. package/src/tools/security-scan.ts +287 -0
  240. package/src/tools/write.ts +9 -3
  241. package/src/tools/xdev.ts +39 -14
  242. package/src/utils/changelog.ts +144 -4
  243. package/src/utils/git.ts +61 -14
  244. package/src/utils/jj.ts +19 -13
  245. package/src/web/search/providers/codex.ts +34 -0
  246. package/src/web/search/types.ts +1 -1
@@ -373,10 +373,12 @@ function buildExecutorOptions(
373
373
  getArtifactsDir: session.getArtifactsDir ?? (() => null),
374
374
  getSessionId: session.getSessionId ?? (() => null),
375
375
  };
376
- const enableMCP = !policy.planMode && (session.enableMCP ?? true);
376
+ const restrictToolNames = policy.planMode || session.restrictToolNames === true;
377
+ const enableMCP = !restrictToolNames && (session.enableMCP ?? true);
377
378
  return {
378
379
  cwd: session.cwd,
379
380
  additionalDirectories: session.additionalDirectories,
381
+ getApiKey: session.getApiKey,
380
382
  agent: policy.effectiveAgent,
381
383
  task: renderSubagentPrompt(request.assignment),
382
384
  assignment: request.assignment.trim(),
@@ -408,7 +410,7 @@ function buildExecutorOptions(
408
410
  enableLsp: policy.enableLsp,
409
411
  enableIrc: policy.enableIrc,
410
412
  maxRuntimeMs: request.maxRuntimeMs,
411
- restrictToolNames: policy.planMode,
413
+ restrictToolNames,
412
414
  keepAlive: request.keepAlive,
413
415
  signal: request.signal,
414
416
  eventBus: session.eventBus,
@@ -424,8 +426,8 @@ function buildExecutorOptions(
424
426
  workspaceTree: session.workspaceTree,
425
427
  promptTemplates: session.promptTemplates,
426
428
  rules: session.rules,
427
- preloadedExtensionPaths: policy.planMode ? [] : session.extensionPaths,
428
- preloadedCustomToolPaths: policy.planMode ? [] : session.customToolPaths,
429
+ preloadedExtensionPaths: restrictToolNames ? [] : session.extensionPaths,
430
+ preloadedCustomToolPaths: restrictToolNames ? [] : session.customToolPaths,
429
431
  localProtocolOptions,
430
432
  parentArtifactManager: session.getArtifactManager?.() ?? undefined,
431
433
  parentHindsightSessionState: session.getHindsightSessionState?.(),
package/src/thinking.ts CHANGED
@@ -191,7 +191,7 @@ export interface ConfiguredThinkingLevelMetadata {
191
191
  const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = {
192
192
  value: AUTO_THINKING,
193
193
  label: "auto",
194
- description: "Auto-detect per prompt (low–xhigh)",
194
+ description: "Auto-detect per prompt",
195
195
  };
196
196
 
197
197
  /**
@@ -236,6 +236,12 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
236
236
  * above Low (falling back to the full supported set only when the model maxes
237
237
  * out below Low). Within that pool the request snaps to the highest level not
238
238
  * exceeding it, or the pool minimum when the request is below the pool.
239
+ * `ceiling` bounds the pool from above, so a policy ceiling survives the model
240
+ * clamp: a sparse ladder such as `["max"]` must not snap an `xhigh` request up
241
+ * to `max`. The Low floor is resolved against the model's own ladder *before*
242
+ * the ceiling applies — a ceiling that hides every tier at or above Low means
243
+ * there is nothing legal to pick (`undefined`), not a licence to fall through
244
+ * to a sub-Low tier the model happens to expose.
239
245
  *
240
246
  * Returns `undefined` for reasoning-capable models without a controllable
241
247
  * effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
@@ -244,12 +250,19 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
244
250
  * forward a concrete effort that would then trip {@link requireSupportedEffort}
245
251
  * downstream.
246
252
  */
247
- export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort | undefined {
253
+ export function clampAutoThinkingEffort(
254
+ model: Model | undefined,
255
+ effort: Effort,
256
+ ceiling: Effort = Effort.Max,
257
+ ): Effort | undefined {
248
258
  const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
249
259
  if (supported.length === 0) return undefined;
250
260
  const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
251
- const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
252
- const pool = eligible.length > 0 ? eligible : supported;
261
+ const ceilingIndex = THINKING_EFFORTS.indexOf(ceiling);
262
+ const atOrAboveLow = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
263
+ const floored = atOrAboveLow.length > 0 ? atOrAboveLow : supported;
264
+ const pool = floored.filter(level => THINKING_EFFORTS.indexOf(level) <= ceilingIndex);
265
+ if (pool.length === 0) return undefined;
253
266
  const requestedIndex = THINKING_EFFORTS.indexOf(effort);
254
267
  let chosen = pool[0];
255
268
  for (const candidate of pool) {
@@ -351,14 +364,20 @@ export function modelSupportsEffortCeiling(model: Model, ceiling: Effort): boole
351
364
 
352
365
  /**
353
366
  * The provisional concrete level shown while `auto` is configured but before a
354
- * turn has been classified. Prefers the model's `defaultLevel`, otherwise High,
355
- * clamped into the auto range. Auto never provisions {@link Effort.Max} (the
356
- * classifier ceiling is XHigh; only an explicit user request reaches Max), so a
357
- * `defaultLevel` of `max` is capped at XHigh before clamping. Returns
358
- * `undefined` for non-reasoning models.
367
+ * turn has been classified, and the fallback when classification fails. Prefers
368
+ * the model's `defaultLevel`, otherwise High, clamped into the auto range.
369
+ *
370
+ * Deliberately stays below {@link Effort.Max}: the placeholder must not bill the
371
+ * top tier for a turn nobody classified, so XHigh is passed as a hard ceiling
372
+ * rather than only capping the preferred level — otherwise a sparse `["max"]`
373
+ * ladder would snap straight back up. A model whose ladder offers nothing at or
374
+ * below XHigh therefore has no provisional level, and `auto` leaves the current
375
+ * one in place. Classification itself may still resolve Max on models that
376
+ * expose the tier when the user opts in. Returns `undefined` for non-reasoning
377
+ * models.
359
378
  */
360
379
  export function resolveProvisionalAutoLevel(model: Model | undefined): Effort | undefined {
361
380
  if (!model?.reasoning) return undefined;
362
381
  const preferred = model.thinking?.defaultLevel ?? Effort.High;
363
- return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred);
382
+ return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred, Effort.XHigh);
364
383
  }
@@ -40,15 +40,39 @@ export function shouldRouteWriteThroughBridge(
40
40
  return absolutePath !== resolvePlanPath(session, state.planFilePath);
41
41
  }
42
42
 
43
+ /**
44
+ * Result of a bridge-routed write: the content actually verified on disk
45
+ * after the client processed the write, plus whether that content diverges
46
+ * from what the tool asked to persist.
47
+ *
48
+ * ACP's `fs/write_text_file` has no "verbatim, no side effects" guarantee —
49
+ * a client (e.g. Zed with `format_on_save: on`) may reformat the buffer as
50
+ * part of handling the write before it settles on disk. Silently trusting
51
+ * the requested `content` as "what's now on disk" lets that drift poison
52
+ * every snapshot/tag/hash a caller derives from the write, which then reads
53
+ * back as unrelated whole-file corruption on the *next* edit. Reading the
54
+ * file back and reporting what's actually there keeps callers honest.
55
+ */
56
+ export interface BridgeWriteResult {
57
+ /** Content actually present on disk immediately after the bridge write. */
58
+ text: string;
59
+ /** `true` when `text` differs from the content the tool asked to write. */
60
+ driftedFromRequest: boolean;
61
+ }
62
+
43
63
  /**
44
64
  * Try to route a file write through the ACP client bridge.
45
65
  *
46
66
  * Performs the full guard check, bridge call (wrapped in {@link ToolError}),
47
- * FS-scan cache invalidation, and session mutation-version bump.
67
+ * a post-write read-back to detect client-side transformation (e.g.
68
+ * format-on-save), FS-scan cache invalidation, and session mutation-version
69
+ * bump.
48
70
  *
49
- * Returns `true` when the bridge was used and the caller must skip the
50
- * writethrough path. Returns `false` when the bridge is unavailable or the
51
- * path should not be routed through it.
71
+ * Returns `undefined` when the bridge is unavailable or the path should not
72
+ * be routed through it the caller must fall back to the writethrough path.
73
+ * Returns a {@link BridgeWriteResult} when the bridge was used; callers MUST
74
+ * use `result.text` (not the content they requested) for any snapshot, hash,
75
+ * or tag derived from this write.
52
76
  */
53
77
  export async function routeWriteThroughBridge(
54
78
  session: ToolSession,
@@ -56,11 +80,11 @@ export async function routeWriteThroughBridge(
56
80
  absolutePath: string,
57
81
  content: string,
58
82
  signal?: AbortSignal,
59
- ): Promise<boolean> {
60
- if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return false;
83
+ ): Promise<BridgeWriteResult | undefined> {
84
+ if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return undefined;
61
85
 
62
86
  const bridge = session.getClientBridge?.();
63
- if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return false;
87
+ if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return undefined;
64
88
 
65
89
  const changeType = (await Bun.file(absolutePath).exists()) ? FileChangeType.Changed : FileChangeType.Created;
66
90
  // The ACP protocol has no cancellation for fs writes; the most we can do is
@@ -77,5 +101,25 @@ export async function routeWriteThroughBridge(
77
101
  }
78
102
  invalidateFsScanAfterWrite(absolutePath);
79
103
  session.bumpFileMutationVersion?.(absolutePath);
80
- return true;
104
+
105
+ // Best-effort verification: the client already flushed the write (that's
106
+ // the whole point of `fs/write_text_file`), so the file on disk reflects
107
+ // whatever the client actually persisted, formatter and all. If the
108
+ // read-back itself fails, fall back to trusting `content` rather than
109
+ // failing an otherwise-successful write. This is a best-effort signal,
110
+ // not a guarantee: ACP defines no ordering between a client acking the
111
+ // write and its own async format-on-save settling, so a client that acks
112
+ // before its formatter runs will look verbatim here. That degrades to
113
+ // the pre-fix behavior for THIS write, but never corrupts state: the
114
+ // next `readText` still observes whatever the client eventually settles
115
+ // on, and a real desync there surfaces as an honest stale-tag error
116
+ // instead of a silently wrong tag.
117
+ let actualText = content;
118
+ try {
119
+ actualText = await Bun.file(absolutePath).text();
120
+ } catch {
121
+ // Unreadable right after a reported-successful write; nothing more we
122
+ // can verify here.
123
+ }
124
+ return { text: actualText, driftedFromRequest: actualText !== content };
81
125
  }
@@ -95,6 +95,11 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
95
95
  const exe = resolveToCwd(app.path, session.cwd);
96
96
  return { kind: "spawned", path: exe };
97
97
  }
98
+ // A configured endpoint is a default, not an override: explicit app options win.
99
+ const configuredCdpUrl = (session.settings.get("browser.cdpUrl") as string | undefined)?.trim();
100
+ if (configuredCdpUrl) {
101
+ return { kind: "connected", cdpUrl: configuredCdpUrl.replace(/\/+$/, "") };
102
+ }
98
103
  const cmuxKind = resolveCmuxKind({
99
104
  settingEnabled: session.settings.get("browser.cmux") as boolean | undefined,
100
105
  });
@@ -16,6 +16,7 @@ export const BUILTIN_TOOL_NAMES = [
16
16
  "computer",
17
17
  "checkpoint",
18
18
  "rewind",
19
+ "security_scan",
19
20
  "task",
20
21
  "hub",
21
22
  "todo",
@@ -50,11 +50,6 @@ export interface RewindToolDetails {
50
50
  meta?: OutputMeta;
51
51
  }
52
52
 
53
- function isTopLevelSession(session: ToolSession): boolean {
54
- const depth = session.taskDepth;
55
- return depth === undefined || depth === 0;
56
- }
57
-
58
53
  export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
59
54
  readonly name = "checkpoint";
60
55
  readonly approval = "read" as const;
@@ -71,7 +66,6 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
71
66
  }
72
67
 
73
68
  static createIf(session: ToolSession): CheckpointTool | null {
74
- if (!isTopLevelSession(session)) return null;
75
69
  return new CheckpointTool(session);
76
70
  }
77
71
 
@@ -82,9 +76,6 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
82
76
  _onUpdate?: AgentToolUpdateCallback<CheckpointToolDetails>,
83
77
  _context?: AgentToolContext,
84
78
  ): Promise<AgentToolResult<CheckpointToolDetails>> {
85
- if (!isTopLevelSession(this.session)) {
86
- throw new ToolError("Checkpoint not available in subagents.");
87
- }
88
79
  if (this.session.getCheckpointState?.()) {
89
80
  throw new ToolError("Checkpoint already active.");
90
81
  }
@@ -117,7 +108,6 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
117
108
  }
118
109
 
119
110
  static createIf(session: ToolSession): RewindTool | null {
120
- if (!isTopLevelSession(session)) return null;
121
111
  return new RewindTool(session);
122
112
  }
123
113
 
@@ -128,9 +118,6 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
128
118
  _onUpdate?: AgentToolUpdateCallback<RewindToolDetails>,
129
119
  _context?: AgentToolContext,
130
120
  ): Promise<AgentToolResult<RewindToolDetails>> {
131
- if (!isTopLevelSession(this.session)) {
132
- throw new ToolError("Checkpoint not available in subagents.");
133
- }
134
121
  if (!this.session.getCheckpointState?.()) {
135
122
  if (this.session.getLastCompletedRewind?.()) {
136
123
  throw new ToolError(
package/src/tools/grep.ts CHANGED
@@ -884,6 +884,22 @@ export interface GrepToolDetails {
884
884
 
885
885
  type SearchParams = typeof searchSchema.infer;
886
886
 
887
+ /**
888
+ * Construction-time overrides for callers that are not the model.
889
+ *
890
+ * The model-facing schema deliberately does not grow these: they exist for
891
+ * wire bridges (the Cursor `pi_grep` frame) whose protocol carries an explicit
892
+ * context width and total match cap, and which would otherwise have to drop
893
+ * them. Unset means "use the session settings / built-in caps" — the behavior
894
+ * every model-issued call keeps.
895
+ */
896
+ export interface GrepToolOptions {
897
+ /** Overrides `grep.contextBefore`/`grep.contextAfter` for every call on this instance. */
898
+ context?: number;
899
+ /** Caps total surfaced matches. Applied on top of the built-in per-file and file-window caps, never above them. */
900
+ totalMatchLimit?: number;
901
+ }
902
+
887
903
  export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails> {
888
904
  readonly name = "grep";
889
905
  readonly approval = (args: unknown): ToolTier => {
@@ -897,7 +913,17 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
897
913
  readonly parameters = searchSchema;
898
914
  readonly strict = true;
899
915
 
900
- constructor(private readonly session: ToolSession) {
916
+ readonly #contextOverride?: number;
917
+ readonly #totalMatchLimit?: number;
918
+
919
+ constructor(
920
+ private readonly session: ToolSession,
921
+ options?: GrepToolOptions,
922
+ ) {
923
+ const context = options?.context;
924
+ this.#contextOverride = context !== undefined ? Math.max(0, Math.floor(context)) : undefined;
925
+ const total = options?.totalMatchLimit;
926
+ this.#totalMatchLimit = total !== undefined ? Math.max(1, Math.floor(total)) : undefined;
901
927
  const displayMode = resolveFileDisplayMode(session);
902
928
  this.description = prompt.render(grepDescription, {
903
929
  IS_HL_MODE: displayMode.hashLines,
@@ -978,8 +1004,8 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
978
1004
  `or pass a UTF-8 text member.`,
979
1005
  );
980
1006
  }
981
- const normalizedContextBefore = this.session.settings.get("grep.contextBefore");
982
- const normalizedContextAfter = this.session.settings.get("grep.contextAfter");
1007
+ const normalizedContextBefore = this.#contextOverride ?? this.session.settings.get("grep.contextBefore");
1008
+ const normalizedContextAfter = this.#contextOverride ?? this.session.settings.get("grep.contextAfter");
983
1009
  const ignoreCase = !(caseSensitive ?? true);
984
1010
  const useGitignore = gitignore ?? true;
985
1011
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
@@ -1297,9 +1323,22 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1297
1323
  // Single-file scopes can't paginate — there is one file by definition.
1298
1324
  const canPaginate = isMultiScope;
1299
1325
  const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
1300
- const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
1301
- const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
1326
+ // A caller with a total match cap is not paginating: the cap bounds the
1327
+ // output, and the only consumer that sets one (`pi_grep`) has no `skip`
1328
+ // field to follow a "use skip=N" suggestion with. Windowing it to the
1329
+ // first 20 files would silently return fewer matches than it asked for
1330
+ // while reporting the cap as unreached.
1331
+ //
1332
+ // The window is cap+1 files, not cap: with one match per file, a cap
1333
+ // of N over exactly N files is complete, while over N+1 files it is
1334
+ // clipped — and only reading that extra file distinguishes the two.
1335
+ // The cap below then does the trimming and records that it bit, so
1336
+ // `match_limit_reached` reaches the frame set.
1337
+ const fileWindow = this.#totalMatchLimit !== undefined ? this.#totalMatchLimit + 1 : DEFAULT_FILE_LIMIT;
1338
+ const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + fileWindow) : fileOrder;
1339
+ const fileLimitReached = canPaginate && totalFiles > skipFiles + fileWindow;
1302
1340
  const selectedMatches: GrepMatch[] = [];
1341
+ let totalMatchLimitReached = false;
1303
1342
  if (windowFiles.length > 0) {
1304
1343
  const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
1305
1344
  const cursors = new Array<number>(lists.length).fill(0);
@@ -1313,6 +1352,14 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1313
1352
  }
1314
1353
  }
1315
1354
  }
1355
+ // Round-robin above interleaves files for diversity, so the cap is
1356
+ // applied after selection rather than as a per-list bound: trimming
1357
+ // mid-rotation would silently favour whichever files sort first.
1358
+ const cap = this.#totalMatchLimit;
1359
+ if (cap !== undefined && selectedMatches.length > cap) {
1360
+ selectedMatches.length = cap;
1361
+ totalMatchLimitReached = true;
1362
+ }
1316
1363
  }
1317
1364
  const nextSkip = skipFiles + windowFiles.length;
1318
1365
  const limitMessage = fileLimitReached
@@ -1503,7 +1550,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1503
1550
  const output = truncation.content;
1504
1551
  const displayText = displayLines.join("\n");
1505
1552
  const truncated = Boolean(
1506
- fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
1553
+ fileLimitReached ||
1554
+ perFileLimitReached ||
1555
+ totalMatchLimitReached ||
1556
+ result.limitReached ||
1557
+ truncation.truncated ||
1558
+ linesTruncated,
1507
1559
  );
1508
1560
  const details: GrepToolDetails = {
1509
1561
  scopePath,
@@ -1517,8 +1569,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1517
1569
  count: fileMatchCounts.get(path) ?? 0,
1518
1570
  })),
1519
1571
  truncated,
1520
- fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
1521
- perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
1572
+ fileLimitReached: fileLimitReached ? fileWindow : undefined,
1573
+ perFileLimitReached: totalMatchLimitReached
1574
+ ? this.#totalMatchLimit
1575
+ : perFileLimitReached
1576
+ ? perFileMatchCap
1577
+ : undefined,
1522
1578
  displayContent: displayText,
1523
1579
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
1524
1580
  };
@@ -12,7 +12,7 @@ import { sanitizeText } from "@oh-my-pi/pi-utils";
12
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
13
13
  import { daemonClientForProject } from "../../launch/client";
14
14
  import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, DaemonState } from "../../launch/protocol";
15
- import type { TerminalOutputOptions } from "../../launch/terminal-output";
15
+ import { renderTerminalOutputIsolated } from "../../launch/terminal-output-worker-client";
16
16
  import type { Theme, ThemeColor } from "../../modes/theme/theme";
17
17
  import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../../tui";
18
18
  import type { ToolSession } from "..";
@@ -271,15 +271,14 @@ function toolContent(result: DaemonRpcResult, params: LaunchParams): string {
271
271
  }
272
272
  }
273
273
 
274
- interface TerminalOutputRuntime {
275
- renderTerminalOutput(output: string, options: TerminalOutputOptions): Promise<string[] | undefined>;
276
- }
277
-
278
- async function renderLegacyTerminalOutput(terminalText: string, params: LaunchParams): Promise<string[] | undefined> {
279
- // `require` keeps xterm out of normal main/Hub startup while retaining display
280
- // compatibility with a legacy broker that was already running during upgrade.
281
- const runtime = require("../../launch/terminal-output") as TerminalOutputRuntime;
282
- return runtime.renderTerminalOutput(terminalText, {
274
+ /** Resolve display rows while keeping legacy raw replay outside the client process. */
275
+ export async function renderLaunchLogTerminalRows(
276
+ result: Extract<DaemonRpcResult, { op: "logs" }>,
277
+ params: Pick<LaunchParams, "head" | "lines">,
278
+ ): Promise<string[] | undefined> {
279
+ if (result.terminalRows !== undefined) return result.terminalRows;
280
+ if (result.terminalText === undefined) return undefined;
281
+ return renderTerminalOutputIsolated(result.terminalText, {
283
282
  head: params.head ?? false,
284
283
  maxRows: Math.min(1_000, Math.floor(params.lines ?? 100)),
285
284
  });
@@ -297,11 +296,7 @@ async function toolDetails(result: DaemonRpcResult, params: LaunchParams): Promi
297
296
  cursor: result.cursor,
298
297
  timedOut: result.timedOut,
299
298
  state: result.state,
300
- terminalRows:
301
- result.terminalRows ??
302
- (result.terminalText === undefined
303
- ? undefined
304
- : await renderLegacyTerminalOutput(result.terminalText, params)),
299
+ terminalRows: await renderLaunchLogTerminalRows(result, params).catch(() => undefined),
305
300
  };
306
301
  case "wait":
307
302
  return { op: "wait", daemon: result.daemon, timedOut: result.timedOut, matched: result.matched };
@@ -1,5 +1,5 @@
1
- import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
- import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
+ import type { AgentOptions, AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
@@ -60,6 +60,7 @@ import { MemoryRetainTool } from "./memory-retain";
60
60
  import { wrapToolWithMetaNotice } from "./output-meta";
61
61
  import { ReadTool } from "./read";
62
62
  import type { PlanProposalHandler } from "./resolve";
63
+ import { SecurityScanTool } from "./security-scan";
63
64
  import { type TodoPhase, TodoTool } from "./todo";
64
65
  import { WriteTool } from "./write";
65
66
  import { isMountableUnderXdev, type XdevState } from "./xdev";
@@ -99,6 +100,7 @@ export * from "./read";
99
100
  export * from "./report-tool-issue";
100
101
  export * from "./resolve";
101
102
  export * from "./review";
103
+ export * from "./security-scan";
102
104
  export * from "./todo";
103
105
  export * from "./tts";
104
106
  export * from "./vibe";
@@ -162,6 +164,8 @@ export interface ToolSession {
162
164
  suppressSpawnAdvisory?: boolean;
163
165
  /** Optional fetch implementation injected into the URL read pipeline (tests, proxies). Defaults to global fetch. */
164
166
  fetch?: FetchImpl;
167
+ /** Provider credential resolver forwarded unchanged to restricted child sessions. */
168
+ getApiKey?: AgentOptions["getApiKey"];
165
169
  /** Skip subprocess-kernel availability checks and warmup */
166
170
  skipPythonPreflight?: boolean;
167
171
  /** Pre-loaded context files (AGENTS.md, etc) */
@@ -191,6 +195,8 @@ export interface ToolSession {
191
195
  customToolPaths?: ToolPathWithSource[];
192
196
  /** Whether LSP integrations are enabled */
193
197
  enableLsp?: boolean;
198
+ /** Whether LSP is limited to navigation and diagnostics. */
199
+ lspReadOnly?: boolean;
194
200
  /** Whether this invocation may expose IRC. `false` removes it even for subagents. */
195
201
  enableIrc?: boolean;
196
202
  /**
@@ -351,6 +357,10 @@ export interface ToolSession {
351
357
  * file changed out-of-band. Lazily initialized by `getFileSnapshotStore`. */
352
358
  fileSnapshotStore?: InMemorySnapshotStore;
353
359
 
360
+ /** Per-session `CUT`/`PASTE` clipboard register shared across edit
361
+ * calls. Lazily initialized by `getEditClipboard`. */
362
+ editClipboard?: Clipboard;
363
+
354
364
  /** Per-session log of unresolved git merge conflict regions surfaced by
355
365
  * `read`. Each entry gets a stable id N referenced by `write conflict://N`
356
366
  * to splice the recorded region with replacement content. Lazily initialized
@@ -394,6 +404,7 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
394
404
  */
395
405
  export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
396
406
  read: s => new ReadTool(s),
407
+ security_scan: s => new SecurityScanTool(s),
397
408
  bash: s => new BashTool(s),
398
409
  edit: s => new EditTool(s),
399
410
  ast_grep: s => new AstGrepTool(s),
@@ -502,6 +513,18 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
502
513
  // unreachable, in which case eval dispatches exclusively to the others.
503
514
  const allowEval = effectivePythonAllowed || allowJs || effectiveRubyAllowed || effectiveJuliaAllowed;
504
515
 
516
+ // Checkpoint and rewind are a pair: listing one without the other strands
517
+ // the agent (it can checkpoint but not rewind, or vice versa). Auto-include
518
+ // the sister tool so a one-sided frontmatter `tools:` entry still works.
519
+ // Unlike the AST/auto-learn convenience auto-includes below, this is a
520
+ // safety pairing — it applies to restricted sessions too.
521
+ if (requestedTools && session.settings.get("checkpoint.enabled")) {
522
+ if (requestedTools.includes("checkpoint") && !requestedTools.includes("rewind")) {
523
+ requestedTools.push("rewind");
524
+ } else if (requestedTools.includes("rewind") && !requestedTools.includes("checkpoint")) {
525
+ requestedTools.push("checkpoint");
526
+ }
527
+ }
505
528
  // Auto-include AST counterparts when their text-based sibling is present.
506
529
  // Restricted callers own the active list and must not have it widened.
507
530
  if (requestedTools && !restrictToolNames) {
@@ -571,10 +594,15 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
571
594
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
572
595
  if (name === "inspect_image") return isInspectImageToolActive(session);
573
596
  if (name === "web_search") return session.settings.get("web_search.enabled");
597
+ if (name === "security_scan") return session.settings.get("security.enabled");
574
598
  if (name === "ask") return session.settings.get("ask.enabled");
575
599
  if (name === "browser") return session.settings.get("browser.enabled");
576
600
  if (name === "computer") return session.settings.get("computer.enabled");
577
- if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
601
+ if (name === "checkpoint" || name === "rewind")
602
+ return (
603
+ session.settings.get("checkpoint.enabled") &&
604
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined)
605
+ );
578
606
  if (name === "hub") {
579
607
  return (
580
608
  !restrictToolNames && session.enableIrc !== false && isIrcEnabled(session.settings, session.taskDepth ?? 0)
@@ -584,11 +612,15 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
584
612
  return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
585
613
  }
586
614
  if (name === "memory_edit") return session.settings.get("memory.backend") === "mnemopi";
587
- if (name === "manage_skill") return session.settings.get("autolearn.enabled") && (session.taskDepth ?? 0) === 0;
615
+ if (name === "manage_skill")
616
+ return (
617
+ session.settings.get("autolearn.enabled") &&
618
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined)
619
+ );
588
620
  if (name === "learn") {
589
621
  return (
590
622
  session.settings.get("autolearn.enabled") &&
591
- (session.taskDepth ?? 0) === 0 &&
623
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined) &&
592
624
  ["hindsight", "mnemopi", "local"].includes(session.settings.get("memory.backend") ?? "")
593
625
  );
594
626
  }
@@ -42,6 +42,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
42
42
  omp: true,
43
43
  pr: true,
44
44
  rule: true,
45
+ security: true,
45
46
  skill: true,
46
47
  ssh: true,
47
48
  vault: true,
@@ -60,6 +61,7 @@ const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
60
61
  "artifact://",
61
62
  "skill://",
62
63
  "rule://",
64
+ "security://",
63
65
  "local://",
64
66
  "mcp://",
65
67
  "ssh://",
@@ -117,6 +119,7 @@ function normalizeAtPrefix(filePath: string): string {
117
119
  withoutAt.startsWith("artifact://") ||
118
120
  withoutAt.startsWith("skill://") ||
119
121
  withoutAt.startsWith("rule://") ||
122
+ withoutAt.startsWith("security://") ||
120
123
  withoutAt.startsWith("local:") ||
121
124
  withoutAt.startsWith("mcp://")
122
125
  ) {
@@ -520,6 +523,94 @@ export function resolveToCwd(filePath: string, cwd: string): string {
520
523
  return path.resolve(cwd, expanded);
521
524
  }
522
525
 
526
+ /**
527
+ * Resolve a path that MUST stay inside `cwd`, or `null` when it would escape.
528
+ *
529
+ * {@link resolveToCwd} deliberately honors absolute paths, `~`, and `..` —
530
+ * correct for a path a user typed, wrong for one a remote peer supplied.
531
+ * Callers handling untrusted input (Cursor's `download_path`) use this instead:
532
+ * only a non-empty relative path landing under the live cwd is accepted, so
533
+ * neither `/etc/passwd` nor `../../escape` can be written through.
534
+ *
535
+ * The lexical check alone is not containment: a symlink inside the workspace
536
+ * can point anywhere, so `out/config` under a `ws/out -> /elsewhere` link is
537
+ * relative, `..`-free, and still writes outside. Both the target and its
538
+ * deepest existing ancestor are therefore realpath-resolved — the ancestor
539
+ * because a download names a file that does not exist yet, so the link in its
540
+ * path is the only thing that can be resolved before the write.
541
+ *
542
+ * The cwd itself is rejected: a download names a file, never the directory.
543
+ */
544
+ export function confineToWorkspace(filePath: string, cwd: string): string | null {
545
+ if (!filePath || path.isAbsolute(filePath)) return null;
546
+ // `~` expands to an absolute path, and an internal URL is not a filesystem
547
+ // target at all; neither is a relative workspace path.
548
+ if (filePath.startsWith("~") || isInternalUrlPath(filePath)) return null;
549
+ const root = path.resolve(cwd);
550
+ const resolved = path.resolve(root, filePath);
551
+ if (!isUnderRootLexical(resolved, root)) return null;
552
+
553
+ // A workspace reached through a link of its own is legitimate (/tmp on
554
+ // macOS), so the real root is the comparison basis. An unresolvable root is
555
+ // not a workspace to contain anything in.
556
+ const realRoot = tryRealpath(root);
557
+ if (!realRoot) return null;
558
+
559
+ // An existing target is authoritative: resolve it outright.
560
+ const realTarget = tryRealpath(resolved);
561
+ if (realTarget) return isUnderRootLexical(realTarget, realRoot) ? resolved : null;
562
+
563
+ // `realpath` also fails on a *dangling* link, and a write follows that link
564
+ // to wherever it points. Chasing the chain to decide would mean
565
+ // reimplementing symlink resolution (multi-hop, relative hops, loops, and
566
+ // a TOCTOU window against a link that can be re-pointed between the check
567
+ // and the write). A download names a file to create, so a path that is
568
+ // already an unresolvable link is refused outright — the one shape where
569
+ // "cannot tell where this lands" is the whole answer.
570
+ if (isSymlink(resolved)) return null;
571
+
572
+ // Otherwise walk up to the deepest ancestor that does exist and check that,
573
+ // then re-apply the segments below it. Those segments are `..`-free by the
574
+ // lexical check above, so they cannot climb back out.
575
+ let ancestor = path.dirname(resolved);
576
+ const tail: string[] = [path.basename(resolved)];
577
+ for (;;) {
578
+ const real = tryRealpath(ancestor);
579
+ if (real) {
580
+ return isUnderRootLexical(path.join(real, ...tail.reverse()), realRoot) ? resolved : null;
581
+ }
582
+ const parent = path.dirname(ancestor);
583
+ // Ran past the root without finding anything real: the workspace itself
584
+ // resolved above, so this cannot happen unless it vanished mid-check.
585
+ if (parent === ancestor || !isUnderRootLexical(ancestor, root)) return null;
586
+ tail.push(path.basename(ancestor));
587
+ ancestor = parent;
588
+ }
589
+ }
590
+
591
+ /** Whether `target` is a strict descendant of `root`, ignoring symlinks. */
592
+ function isUnderRootLexical(target: string, root: string): boolean {
593
+ const relative = path.relative(root, target);
594
+ return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
595
+ }
596
+
597
+ function tryRealpath(target: string): string | null {
598
+ try {
599
+ return fs.realpathSync.native(target);
600
+ } catch {
601
+ return null;
602
+ }
603
+ }
604
+
605
+ /** Whether the path itself is a symlink, without following it. */
606
+ function isSymlink(target: string): boolean {
607
+ try {
608
+ return fs.lstatSync(target).isSymbolicLink();
609
+ } catch {
610
+ return false;
611
+ }
612
+ }
613
+
523
614
  export function formatPathRelativeToCwd(
524
615
  filePath: string,
525
616
  cwd: string,