chainlesschain 0.162.123 → 0.162.124

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 (196) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/{AIOps-C5CSd8pY.js → AIOps-BsDJlD_l.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-BugJtiJc.js → ActionButton-COjZZKWG.js} +1 -1
  5. package/src/assets/web-panel/assets/Analytics-4ZDZE7lc.js +3 -0
  6. package/src/assets/web-panel/assets/{AppLayout-Cb0eHetM.js → AppLayout-CYH5k2Rp.js} +5 -5
  7. package/src/assets/web-panel/assets/{Audit-qjq04wh8.js → Audit-BKlFbLdl.js} +1 -1
  8. package/src/assets/web-panel/assets/Backup-CiX61Qc2.js +1 -0
  9. package/src/assets/web-panel/assets/{BaseInput-CZ0c7QHk.js → BaseInput-CDxIQokd.js} +1 -1
  10. package/src/assets/web-panel/assets/{Chat-CaKDT3wV.js → Chat-CaKgdRab.js} +6 -6
  11. package/src/assets/web-panel/assets/ChatBubbleRenderer-DgvpwU5o.js +1 -0
  12. package/src/assets/web-panel/assets/{Checkbox-C6-Iu3bG.js → Checkbox-CjiWfu4s.js} +1 -1
  13. package/src/assets/web-panel/assets/{Codegen-TGLFk6P1.js → Codegen-tKWGzajw.js} +1 -1
  14. package/src/assets/web-panel/assets/{Col-DVBrO7AN.js → Col-ZKWREyNs.js} +1 -1
  15. package/src/assets/web-panel/assets/{Community-tg6-Rik1.js → Community-CmLuPBUU.js} +1 -1
  16. package/src/assets/web-panel/assets/{Compact-DM4ZgFSE.js → Compact-DqknM98n.js} +1 -1
  17. package/src/assets/web-panel/assets/Compliance-PZw0aBLI.js +1 -0
  18. package/src/assets/web-panel/assets/{Cowork-CJwiWkUY.js → Cowork-Dp29kHsC.js} +2 -2
  19. package/src/assets/web-panel/assets/{Cron-dsdXHvrO.js → Cron-zmEJAetz.js} +2 -2
  20. package/src/assets/web-panel/assets/{Crosschain-p7_5Gq-7.js → Crosschain-Sb-uM7Ty.js} +1 -1
  21. package/src/assets/web-panel/assets/{DID-BB_LBLYT.js → DID-D_UFNzJ5.js} +2 -2
  22. package/src/assets/web-panel/assets/{Dashboard-Bh_HkNSD.js → Dashboard-Btag698v.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dropdown-dTFtVnKk.js → Dropdown-CH7l3KCs.js} +1 -1
  24. package/src/assets/web-panel/assets/{EmailListRenderer-yqdttzXV.js → EmailListRenderer-Bud2M3XX.js} +1 -1
  25. package/src/assets/web-panel/assets/{FamilyGuardDashboard-DfX3YpUU.js → FamilyGuardDashboard-Bd2_8uME.js} +1 -1
  26. package/src/assets/web-panel/assets/{Federation-BJApfPV4.js → Federation-1jhstF5P.js} +1 -1
  27. package/src/assets/web-panel/assets/{FormItemContext-DNMwIh7X.js → FormItemContext-CpSH464Y.js} +1 -1
  28. package/src/assets/web-panel/assets/{GenericCardRenderer-C-T8UQf0.js → GenericCardRenderer-CANo8O-y.js} +1 -1
  29. package/src/assets/web-panel/assets/{Git-SURFhkLi.js → Git-hvuZVoD3.js} +2 -2
  30. package/src/assets/web-panel/assets/{Governance-B4u_KsfE.js → Governance-BQrWksKt.js} +1 -1
  31. package/src/assets/web-panel/assets/{Inference-2kr_rKQd.js → Inference-jEBTp12v.js} +1 -1
  32. package/src/assets/web-panel/assets/KnowledgeGraph-tDiV2taf.js +1 -0
  33. package/src/assets/web-panel/assets/Logs-DDkTnedu.js +2 -0
  34. package/src/assets/web-panel/assets/{Marketplace-W9Iz8b2m.js → Marketplace-9Yi32avt.js} +1 -1
  35. package/src/assets/web-panel/assets/{McpTools-D0waMomb.js → McpTools-Qx78XyOT.js} +5 -5
  36. package/src/assets/web-panel/assets/{Memory-Ci768GQ3.js → Memory-CXYDO1oT.js} +2 -2
  37. package/src/assets/web-panel/assets/MobileBridge-MfUfkHA0.js +1 -0
  38. package/src/assets/web-panel/assets/{MobileProjects-RpFuAiNz.js → MobileProjects-DEDbWNIk.js} +1 -1
  39. package/src/assets/web-panel/assets/{Mtc-DJdw8btT.js → Mtc-Dwa_vlR8.js} +2 -2
  40. package/src/assets/web-panel/assets/{MtcAudit-Dc596XgH.js → MtcAudit-DAVkxhJv.js} +2 -2
  41. package/src/assets/web-panel/assets/{Multisig--Qs7eGsH.js → Multisig-C3upmgPN.js} +3 -3
  42. package/src/assets/web-panel/assets/{NLProgramming-D3y71FDX.js → NLProgramming-DO3CZ0_z.js} +1 -1
  43. package/src/assets/web-panel/assets/{Notes-uMxjj66_.js → Notes-B1Oy3FbC.js} +4 -4
  44. package/src/assets/web-panel/assets/{NotificationSettings-C4ikBOsm.js → NotificationSettings-Bs4-Fc6b.js} +1 -1
  45. package/src/assets/web-panel/assets/{OrderTableRenderer-BpmdunAz.js → OrderTableRenderer-CEy1pIeq.js} +1 -1
  46. package/src/assets/web-panel/assets/Organization-D4cJ1UNo.js +4 -0
  47. package/src/assets/web-panel/assets/{Overflow-B-4_IpbB.js → Overflow-fdzvlF7x.js} +1 -1
  48. package/src/assets/web-panel/assets/{P2P-DdOf9BZs.js → P2P-Y13PwXBA.js} +2 -2
  49. package/src/assets/web-panel/assets/{PdhVaultBrowser-DDU2Zc9Q.js → PdhVaultBrowser-Ck1zCLe6.js} +3 -3
  50. package/src/assets/web-panel/assets/Permissions-CvEXP6LC.js +4 -0
  51. package/src/assets/web-panel/assets/{PersonalDataHub-B3WcvKni.js → PersonalDataHub-JeP7IWMW.js} +3 -3
  52. package/src/assets/web-panel/assets/{Pipeline-DASuPDc3.js → Pipeline-BZ7wRzG1.js} +1 -1
  53. package/src/assets/web-panel/assets/{Privacy-DnixwmhJ.js → Privacy-BJ6qj9Hv.js} +1 -1
  54. package/src/assets/web-panel/assets/{ProjectInit-BQjMDkML.js → ProjectInit-DeWd9UcS.js} +2 -2
  55. package/src/assets/web-panel/assets/ProjectSettings-DUpVuU9F.js +2 -0
  56. package/src/assets/web-panel/assets/{Projects-BGWEJIVT.js → Projects-y1WbI337.js} +1 -1
  57. package/src/assets/web-panel/assets/{Providers-Bl0dw6cI.js → Providers-DECW00ek.js} +1 -1
  58. package/src/assets/web-panel/assets/{QuickAsk-BDrk7l3t.js → QuickAsk-Dl-pK9Io.js} +1 -1
  59. package/src/assets/web-panel/assets/{Recommend-BPKof_CA.js → Recommend-Bju04wTd.js} +1 -1
  60. package/src/assets/web-panel/assets/{Reputation-6MNJLklK.js → Reputation-BUPa3nEk.js} +1 -1
  61. package/src/assets/web-panel/assets/{Row-DmGJwpfd.js → Row-WYqqaq_5.js} +1 -1
  62. package/src/assets/web-panel/assets/{RssFeed-9-Fxh3_w.js → RssFeed-iyQT5q9l.js} +3 -3
  63. package/src/assets/web-panel/assets/{Search-CCHjey_D.js → Search-CrfwJF0R.js} +1 -1
  64. package/src/assets/web-panel/assets/{Security-D6CySV7r.js → Security-c4Jxf5Ih.js} +4 -4
  65. package/src/assets/web-panel/assets/{Services-Co8Mk8qE.js → Services-CRuisolj.js} +2 -2
  66. package/src/assets/web-panel/assets/{Skeleton-BdON8M-1.js → Skeleton-CCnzJkb-.js} +1 -1
  67. package/src/assets/web-panel/assets/{Skills-CEJE1Go1.js → Skills-CZURCwZg.js} +1 -1
  68. package/src/assets/web-panel/assets/{Sla-BSxFvFX9.js → Sla-CrMzaEi2.js} +1 -1
  69. package/src/assets/web-panel/assets/SpeechSettings-jTDOM5eY.js +1 -0
  70. package/src/assets/web-panel/assets/{SyncSettings-52NXsfyj.js → SyncSettings-CJWVtd87.js} +2 -2
  71. package/src/assets/web-panel/assets/{Tasks-He7CweW6.js → Tasks-61lrQ_lS.js} +1 -1
  72. package/src/assets/web-panel/assets/Templates-Cg-iF2g1.js +1 -0
  73. package/src/assets/web-panel/assets/{Tenant-Dd5XNbYF.js → Tenant-DrDv1FFc.js} +1 -1
  74. package/src/assets/web-panel/assets/Terminal-CP15hcNS.js +3 -0
  75. package/src/assets/web-panel/assets/{TimelineRenderer-BdfqBQmM.js → TimelineRenderer-Sl5uXrkN.js} +1 -1
  76. package/src/assets/web-panel/assets/{Tokens-CsgNsrdQ.js → Tokens-yRIZTVsR.js} +1 -1
  77. package/src/assets/web-panel/assets/{Trigger-Dt_N_rvo.js → Trigger-BLCQEOF2.js} +1 -1
  78. package/src/assets/web-panel/assets/{Trust-B1lJzsPn.js → Trust-D5xq8z6s.js} +1 -1
  79. package/src/assets/web-panel/assets/{UkeySign-CW8YdFxg.js → UkeySign-Dwp0zXQ6.js} +1 -1
  80. package/src/assets/web-panel/assets/{VideoEditing-CU6o7qvp.js → VideoEditing-CIHA55Sx.js} +1 -1
  81. package/src/assets/web-panel/assets/Wallet-Hjajvnto.js +4 -0
  82. package/src/assets/web-panel/assets/WebAuthn-DqSURUvI.js +5 -0
  83. package/src/assets/web-panel/assets/{WorkflowEditor-BcCA2U8H.js → WorkflowEditor-B5bHRwUG.js} +1 -1
  84. package/src/assets/web-panel/assets/{chat-Cxl5DGsc.js → chat-DsheLKkG.js} +1 -1
  85. package/src/assets/web-panel/assets/{colors-FS_7Xggs.js → colors-CST2UkgL.js} +1 -1
  86. package/src/assets/web-panel/assets/{compact-item-BzXjd3wJ.js → compact-item-yqEoy1L7.js} +1 -1
  87. package/src/assets/web-panel/assets/{createContext-DhF9bi_u.js → createContext-Jwp78HFY.js} +1 -1
  88. package/src/assets/web-panel/assets/devWarning-C8to6gQk.js +1 -0
  89. package/src/assets/web-panel/assets/{hasIn-Y1a0aV9N.js → hasIn-CVS5mFEP.js} +1 -1
  90. package/src/assets/web-panel/assets/{index-DfaSKEVD.js → index-AnW25bEq.js} +1 -1
  91. package/src/assets/web-panel/assets/{index-Dmjxpu0L.js → index-BCHAUdel.js} +1 -1
  92. package/src/assets/web-panel/assets/{index-B48PZ-xM.js → index-BLTUVd0V.js} +1 -1
  93. package/src/assets/web-panel/assets/{index-CRSkKj9M.js → index-BeblNJDH.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-uLoKviUT.js → index-BuI27WSx.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-BTltEtoG.js → index-BzetOasL.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-CETvk0NN.js → index-C0FZScMe.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-DZJMsVI1.js → index-C130LLPq.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-Cgo1J_Kf.js → index-C8rq85gu.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-BBtULjKY.js → index-CDAPnZUs.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BepInmSi.js → index-CIE2n8k_.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-D3IVAG9l.js → index-CRBbVS43.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-Ci7PSoQn.js → index-CSBPc-sI.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-CxYHV8nb.js → index-CTd3lDxp.js} +1 -1
  104. package/src/assets/web-panel/assets/index-CUWj5L2s.js +1 -0
  105. package/src/assets/web-panel/assets/{index-CzlCP4jP.js → index-ChRL6rgA.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-DVTex5M8.js → index-CjfN2CpJ.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-1iacQgo5.js → index-Cjig5i3a.js} +3 -3
  108. package/src/assets/web-panel/assets/{index-JUukFpxi.js → index-Crk4KWLW.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-BllceSdr.js → index-CrlRa054.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-Cn9lbqaS.js → index-CtyEOGuj.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-Bt_0ygjA.js → index-Cu6Ql64n.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-B_Z0PHC0.js → index-Cx_t5rWw.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-CELVZPNt.js → index-Cy0dNzkp.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-GCd5hs58.js → index-CzsKK0tQ.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-BnvaUocg.js → index-DDAigsel.js} +1 -1
  116. package/src/assets/web-panel/assets/index-DZfnYE6e.js +1 -0
  117. package/src/assets/web-panel/assets/{index-C2S8McM1.js → index-D_1b7uh6.js} +1 -1
  118. package/src/assets/web-panel/assets/{index--fcP34eT.js → index-D_e9gwfn.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DgHWZVjv.js → index-DbxAlpPC.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-BNmWkPqm.js → index-De600Jjm.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DxGVbuqr.js → index-Dgyn8-wN.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-BzQkdgQW.js → index-DiK4vSZa.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CuTKOay7.js → index-DpYn5v2k.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-Sl-sLj3T.js → index-DxaU_lza.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-Na733kBa.js → index-Dxljh9Fu.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-DLyIxnjc.js → index-R-VGFpi6.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-DUcVF8e6.js → index-SjxCCf5h.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-1khPb4ZS.js → index-diWkx2Wq.js} +1 -1
  129. package/src/assets/web-panel/assets/{initDefaultProps-D6QmlnQ-.js → initDefaultProps-ClAjrsQ-.js} +1 -1
  130. package/src/assets/web-panel/assets/{motion-BHrTGY1_.js → motion-BalXv_60.js} +1 -1
  131. package/src/assets/web-panel/assets/{move-Bpph6B_N.js → move-DMelzIW-.js} +1 -1
  132. package/src/assets/web-panel/assets/{omit-Dx0YMbWn.js → omit-oxecfU3b.js} +1 -1
  133. package/src/assets/web-panel/assets/{pickAttrs-D-a8Fm6C.js → pickAttrs-DJ5F5M6x.js} +1 -1
  134. package/src/assets/web-panel/assets/{placementArrow-DXFSVgxv.js → placementArrow-DdbKsant.js} +1 -1
  135. package/src/assets/web-panel/assets/{responsiveObserve-CxhQ1EaK.js → responsiveObserve-BUuM5cmS.js} +1 -1
  136. package/src/assets/web-panel/assets/{slide-IlBI0XU8.js → slide-DSV0ccvR.js} +1 -1
  137. package/src/assets/web-panel/assets/{statusUtils-COoXVzje.js → statusUtils-B-6oLAXf.js} +1 -1
  138. package/src/assets/web-panel/assets/{styleChecker-DCuq89M_.js → styleChecker-DdCAHtsZ.js} +1 -1
  139. package/src/assets/web-panel/assets/{useFlexGapSupport-tfNE1ALO.js → useFlexGapSupport-ZORkcoZd.js} +1 -1
  140. package/src/assets/web-panel/assets/{useFs-DD4bzBkU.js → useFs-BFp46LoP.js} +1 -1
  141. package/src/assets/web-panel/assets/{usePersonalDataHub-DVkH1hbT.js → usePersonalDataHub-H7rxgbdW.js} +1 -1
  142. package/src/assets/web-panel/assets/{vnode-D1ZmXyxM.js → vnode-eIq4Tk0J.js} +1 -1
  143. package/src/assets/web-panel/assets/{zoom-DblBUHfi.js → zoom-CTNrv8-H.js} +1 -1
  144. package/src/assets/web-panel/index.html +1 -1
  145. package/src/commands/ask.js +24 -1
  146. package/src/commands/init.js +5 -4
  147. package/src/commands/instinct.js +18 -5
  148. package/src/commands/mcp.js +30 -3
  149. package/src/commands/memory.js +18 -5
  150. package/src/gateways/http/envelope-http-server.js +17 -2
  151. package/src/gateways/terminal/PtyManager.js +15 -2
  152. package/src/gateways/ws/ws-server.js +20 -0
  153. package/src/gateways/ws/ws-session-gateway.js +16 -0
  154. package/src/harness/mcp-client.js +57 -0
  155. package/src/harness/worktree-isolator.js +20 -9
  156. package/src/lib/agent-core.js +3 -0
  157. package/src/lib/autonomous-agent.js +7 -6
  158. package/src/lib/chat-intent-service.js +4 -2
  159. package/src/lib/checkpoint-store.js +23 -0
  160. package/src/lib/credential-guard.js +7 -0
  161. package/src/lib/git-integration.js +57 -1
  162. package/src/lib/hook-runner.cjs +11 -1
  163. package/src/lib/interactive-planner.js +4 -3
  164. package/src/lib/json-schema-output.js +47 -0
  165. package/src/lib/learning/reflection-engine.js +4 -3
  166. package/src/lib/learning/skill-improver.js +4 -3
  167. package/src/lib/learning/skill-synthesizer.js +4 -3
  168. package/src/lib/mcp-oauth.js +208 -20
  169. package/src/lib/mcp-serve.js +56 -6
  170. package/src/lib/orchestrator.js +4 -2
  171. package/src/lib/process-manager.js +31 -3
  172. package/src/lib/repl-completer.js +29 -0
  173. package/src/lib/slot-filler.js +4 -3
  174. package/src/lib/sub-agent-context.js +6 -0
  175. package/src/lib/web-fetch.js +54 -3
  176. package/src/lib/workflow-engine.js +35 -11
  177. package/src/runtime/agent-core.js +248 -26
  178. package/src/runtime/headless-stream.js +124 -17
  179. package/src/assets/web-panel/assets/Analytics-CzxpcV9E.js +0 -3
  180. package/src/assets/web-panel/assets/Backup-C2Y_XNBA.js +0 -1
  181. package/src/assets/web-panel/assets/ChatBubbleRenderer-TeKnMwPj.js +0 -1
  182. package/src/assets/web-panel/assets/Compliance-DFeWryxt.js +0 -1
  183. package/src/assets/web-panel/assets/KnowledgeGraph-h-nL7J6x.js +0 -1
  184. package/src/assets/web-panel/assets/Logs-DjuWREBJ.js +0 -2
  185. package/src/assets/web-panel/assets/MobileBridge-4jAehfcz.js +0 -3
  186. package/src/assets/web-panel/assets/Organization-BBEmFNGM.js +0 -4
  187. package/src/assets/web-panel/assets/Permissions-B1ebgevF.js +0 -4
  188. package/src/assets/web-panel/assets/ProjectSettings-dHyZ3SxW.js +0 -2
  189. package/src/assets/web-panel/assets/SpeechSettings-C3rShYlV.js +0 -1
  190. package/src/assets/web-panel/assets/Templates-UtjBlJIO.js +0 -1
  191. package/src/assets/web-panel/assets/Terminal-pFyh42cu.js +0 -3
  192. package/src/assets/web-panel/assets/Wallet-DPxjEhbN.js +0 -4
  193. package/src/assets/web-panel/assets/WebAuthn-CuJ29YAF.js +0 -5
  194. package/src/assets/web-panel/assets/devWarning-B1j78JeH.js +0 -1
  195. package/src/assets/web-panel/assets/index-BaN6wKWx.js +0 -1
  196. package/src/assets/web-panel/assets/index-N7JKRLCC.js +0 -1
@@ -62,6 +62,43 @@ export function randomState(bytes = 16) {
62
62
  return base64url(_deps.randomBytes(bytes));
63
63
  }
64
64
 
65
+ /**
66
+ * Is `hostname` a loopback address? Plain http is tolerated only for these (a
67
+ * self-hosted MCP server on the dev box). Handles the bracketed IPv6 form that
68
+ * `new URL().hostname` yields (e.g. "[::1]") and the whole 127.0.0.0/8 range.
69
+ */
70
+ export function isLoopbackHost(hostname) {
71
+ const h = String(hostname || "")
72
+ .replace(/^\[|\]$/g, "")
73
+ .toLowerCase();
74
+ if (h === "localhost") return true;
75
+ if (h === "::1") return true;
76
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
77
+ }
78
+
79
+ /**
80
+ * Require an OAuth endpoint to be HTTPS (or plain http on loopback for local
81
+ * dev). Authorization codes, PKCE verifiers, client secrets, and bearer/refresh
82
+ * tokens flow through these endpoints, so a discovered (or stored) endpoint that
83
+ * downgrades to cleartext http on a remote host must be refused rather than
84
+ * leaking secrets over the wire (RFC 6749 §3.1.2.1 / RFC 8252). Returns the
85
+ * endpoint unchanged when allowed; throws otherwise.
86
+ */
87
+ export function assertSecureEndpoint(endpoint, label = "endpoint") {
88
+ let u;
89
+ try {
90
+ u = new URL(endpoint);
91
+ } catch {
92
+ throw new Error(`OAuth ${label} is not a valid URL: ${endpoint}`);
93
+ }
94
+ if (u.protocol === "https:") return endpoint;
95
+ if (u.protocol === "http:" && isLoopbackHost(u.hostname)) return endpoint;
96
+ throw new Error(
97
+ `refusing to use insecure OAuth ${label} (${endpoint}); ` +
98
+ `HTTPS is required for non-loopback endpoints`,
99
+ );
100
+ }
101
+
65
102
  /** Minimal HTML-escape for values reflected into the callback page. */
66
103
  function escapeHtml(s) {
67
104
  return String(s).replace(
@@ -209,6 +246,7 @@ export async function registerClient(
209
246
  "server has no registration_endpoint and no --client-id was given",
210
247
  );
211
248
  }
249
+ assertSecureEndpoint(metadata.registration_endpoint, "registration_endpoint");
212
250
  const reg = await fetchJson(metadata.registration_endpoint, {
213
251
  method: "POST",
214
252
  headers: { "content-type": "application/json" },
@@ -230,6 +268,10 @@ export function buildAuthorizeUrl(
230
268
  metadata,
231
269
  { clientId, redirectUri, scope, codeChallenge, state, resource },
232
270
  ) {
271
+ assertSecureEndpoint(
272
+ metadata.authorization_endpoint,
273
+ "authorization_endpoint",
274
+ );
233
275
  const u = new URL(metadata.authorization_endpoint);
234
276
  u.searchParams.set("response_type", "code");
235
277
  u.searchParams.set("client_id", clientId);
@@ -261,6 +303,7 @@ export async function exchangeCodeForToken(
261
303
  });
262
304
  if (clientSecret) body.set("client_secret", clientSecret);
263
305
  if (resource) body.set("resource", resource);
306
+ assertSecureEndpoint(metadata.token_endpoint, "token_endpoint");
264
307
  const tok = await fetchJson(metadata.token_endpoint, {
265
308
  method: "POST",
266
309
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -287,6 +330,7 @@ export async function refreshAccessToken(
287
330
  });
288
331
  if (clientSecret) body.set("client_secret", clientSecret);
289
332
  if (resource) body.set("resource", resource);
333
+ assertSecureEndpoint(metadata.token_endpoint, "token_endpoint");
290
334
  const tok = await fetchJson(metadata.token_endpoint, {
291
335
  method: "POST",
292
336
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -372,26 +416,126 @@ function _writeStoreSecure(file, store) {
372
416
  }
373
417
  }
374
418
 
419
+ /** Bounded synchronous sleep (the store lock is held only for a few file ops). */
420
+ function _sleepSyncMs(ms) {
421
+ try {
422
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
423
+ } catch {
424
+ const end = Date.now() + ms;
425
+ while (Date.now() < end) {
426
+ /* spin — Atomics.wait unavailable (should not happen on modern Node) */
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Run `fn` while holding a cross-process advisory lock on the token store.
433
+ *
434
+ * saveStoredToken / deleteStoredToken are read-modify-write (load → mutate →
435
+ * write). Two concurrent `cc mcp login` PROCESSES for different servers would
436
+ * each load the old store and the second writer would clobber the first's new
437
+ * entry (lost update) — the atomic temp+rename only prevents a torn file, not a
438
+ * lost update. An O_EXCL lockfile makes the whole RMW mutually exclusive across
439
+ * processes, so each writer re-reads the latest store inside the lock.
440
+ *
441
+ * Robustness: a stale lock (holder crashed) older than STALE_MS is stolen; if
442
+ * the lock can't be acquired within MAX_WAIT_MS we proceed anyway (degrading to
443
+ * the pre-lock last-writer-wins rather than failing the login). When the
444
+ * injected fs lacks the lock primitives (test fs / exotic fs) we run `fn`
445
+ * directly — single-process correctness is unchanged, only cross-process mutual
446
+ * exclusion is lost. Lock timing uses the wall clock (not the `now` test seam).
447
+ */
448
+ function withStoreLock(file, fn) {
449
+ const fs = _deps.fs;
450
+ if (
451
+ typeof fs.openSync !== "function" ||
452
+ typeof fs.closeSync !== "function" ||
453
+ typeof fs.unlinkSync !== "function"
454
+ ) {
455
+ return fn(); // no lock primitives → run unlocked (back-compat)
456
+ }
457
+ const STALE_MS = 10_000;
458
+ const MAX_WAIT_MS = 5_000;
459
+ const lockPath = `${file}.lock`;
460
+ try {
461
+ fs.mkdirSync(pathDefault.dirname(file), { recursive: true, mode: 0o700 });
462
+ } catch {
463
+ /* dir may already exist — openSync below reports any real problem */
464
+ }
465
+ const start = Date.now();
466
+ let fd = null;
467
+ for (;;) {
468
+ try {
469
+ fd = fs.openSync(lockPath, "wx"); // O_CREAT|O_EXCL — fails if held
470
+ break;
471
+ } catch (err) {
472
+ if (!err || err.code !== "EEXIST") {
473
+ // Unexpected (e.g. ENOENT/EACCES): don't fail the login over the lock —
474
+ // proceed unlocked, same as a missing-primitives fs.
475
+ return fn();
476
+ }
477
+ let stale = false;
478
+ try {
479
+ const st = fs.statSync(lockPath);
480
+ const mtime = st.mtimeMs != null ? st.mtimeMs : 0;
481
+ stale = Date.now() - mtime > STALE_MS;
482
+ } catch {
483
+ /* lock vanished between open and stat → retry immediately */
484
+ }
485
+ if (stale) {
486
+ try {
487
+ fs.unlinkSync(lockPath); // steal a lock from a crashed holder
488
+ } catch {
489
+ /* someone else stole it first — retry */
490
+ }
491
+ continue;
492
+ }
493
+ if (Date.now() - start > MAX_WAIT_MS) break; // give up waiting; proceed
494
+ _sleepSyncMs(50);
495
+ }
496
+ }
497
+ try {
498
+ return fn();
499
+ } finally {
500
+ if (fd !== null) {
501
+ try {
502
+ fs.closeSync(fd);
503
+ } catch {
504
+ /* ignore */
505
+ }
506
+ try {
507
+ fs.unlinkSync(lockPath);
508
+ } catch {
509
+ /* ignore — a stale-steal by another process may have removed it */
510
+ }
511
+ }
512
+ }
513
+ }
514
+
375
515
  export function saveStoredToken(serverUrl, record) {
376
516
  const file = tokenStorePath();
377
- const store = loadTokenStore();
378
- store[serverKey(serverUrl)] = { server: serverKey(serverUrl), ...record };
379
- _writeStoreSecure(file, store);
380
- return store[serverKey(serverUrl)];
517
+ return withStoreLock(file, () => {
518
+ const store = loadTokenStore(); // re-read inside the lock (latest state)
519
+ store[serverKey(serverUrl)] = { server: serverKey(serverUrl), ...record };
520
+ _writeStoreSecure(file, store);
521
+ return store[serverKey(serverUrl)];
522
+ });
381
523
  }
382
524
 
383
525
  export function deleteStoredToken(serverUrl) {
384
526
  const file = tokenStorePath();
385
- const store = loadTokenStore();
386
- const key = serverKey(serverUrl);
387
- if (!(key in store)) return false;
388
- delete store[key];
389
- try {
390
- _writeStoreSecure(file, store);
391
- } catch {
392
- /* best-effort */
393
- }
394
- return true;
527
+ return withStoreLock(file, () => {
528
+ const store = loadTokenStore(); // re-read inside the lock (latest state)
529
+ const key = serverKey(serverUrl);
530
+ if (!(key in store)) return false;
531
+ delete store[key];
532
+ try {
533
+ _writeStoreSecure(file, store);
534
+ } catch {
535
+ /* best-effort */
536
+ }
537
+ return true;
538
+ });
395
539
  }
396
540
 
397
541
  /** True when a record's access token is missing or within `skewMs` of expiry. */
@@ -440,12 +584,25 @@ function defaultOpenBrowser(url) {
440
584
  }
441
585
  }
442
586
 
443
- /** Wait for the OAuth redirect on a localhost callback server; resolve {code,state}. */
587
+ /**
588
+ * Wait for the OAuth redirect on a localhost callback server; resolve
589
+ * {code,state}.
590
+ *
591
+ * When `expectedState` is given, a SUCCESS callback (`code`) is only accepted if
592
+ * its `state` matches: the callback port is fixed and guessable, so a duplicate
593
+ * browser request, a probe, or a drive-by request from a malicious local page
594
+ * must not abort the real login or inject a forged code — those are answered
595
+ * politely but the server keeps waiting for the genuine redirect (until the
596
+ * backstop timeout). A provider `error` is always terminal (so denying consent
597
+ * fails fast even if the provider omits state on error). With no `expectedState`
598
+ * the legacy contract is preserved (first `/callback` hit is terminal).
599
+ */
444
600
  export function waitForCallback({
445
601
  port,
446
602
  host = "127.0.0.1",
447
603
  path = "/callback",
448
604
  timeout = 300_000,
605
+ expectedState = null,
449
606
  }) {
450
607
  return new Promise((resolve, reject) => {
451
608
  const server = _deps.createServer((req, res) => {
@@ -465,13 +622,41 @@ export function waitForCallback({
465
622
  const code = u.searchParams.get("code");
466
623
  const state = u.searchParams.get("state");
467
624
  const error = u.searchParams.get("error");
468
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
469
- res.end(renderCallbackPage(error));
625
+
626
+ // Provider error (e.g. user denied) — always terminal so we fail fast.
627
+ if (error) {
628
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
629
+ res.end(renderCallbackPage(error));
630
+ server.close();
631
+ clearTimeout(timer);
632
+ reject(new Error(`authorization error: ${error}`));
633
+ return;
634
+ }
635
+
636
+ const stateOk = expectedState == null || state === expectedState;
637
+ if (code && stateOk) {
638
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
639
+ res.end(renderCallbackPage(null));
640
+ server.close();
641
+ clearTimeout(timer);
642
+ resolve({ code, state });
643
+ return;
644
+ }
645
+
646
+ // A mismatched/forged/duplicate request to our fixed port must not abort
647
+ // the real login — answer it but keep listening for the genuine redirect.
648
+ if (expectedState != null) {
649
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
650
+ res.end(renderCallbackPage("state mismatch"));
651
+ return;
652
+ }
653
+
654
+ // Legacy contract (no expectedState): a /callback hit with no code ends it.
655
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
656
+ res.end(renderCallbackPage("missing code"));
470
657
  server.close();
471
658
  clearTimeout(timer);
472
- if (error) reject(new Error(`authorization error: ${error}`));
473
- else if (!code) reject(new Error("no authorization code in callback"));
474
- else resolve({ code, state });
659
+ reject(new Error("no authorization code in callback"));
475
660
  });
476
661
  const timer = setTimeout(() => {
477
662
  server.close();
@@ -540,6 +725,9 @@ export async function authorizeInteractive(serverUrl, opts = {}) {
540
725
  host,
541
726
  path: redirectPath,
542
727
  timeout,
728
+ // Ignore callbacks that don't carry the state we issued (forged / drive-by /
729
+ // duplicate hits to the fixed localhost port) instead of aborting the login.
730
+ expectedState: state,
543
731
  });
544
732
  const opened = _deps.openBrowser(authorizeUrl);
545
733
  writeOut(
@@ -17,7 +17,7 @@
17
17
  import http from "http";
18
18
  import fsDefault from "fs";
19
19
  import pathDefault from "path";
20
- import { randomBytes } from "crypto";
20
+ import { randomBytes, timingSafeEqual } from "crypto";
21
21
 
22
22
  export const MAX_READ_BYTES = 200 * 1024;
23
23
  export const MAX_LIST_ENTRIES = 500;
@@ -29,14 +29,65 @@ export const _deps = { fs: fsDefault, path: pathDefault };
29
29
 
30
30
  /** Resolve `rel` inside `root`; throws on escape (.. traversal, abs paths out). */
31
31
  export function confine(root, rel, deps = _deps) {
32
- const abs = deps.path.resolve(root, rel || ".");
33
- const normRoot = deps.path.resolve(root);
34
- if (abs !== normRoot && !abs.startsWith(normRoot + deps.path.sep)) {
32
+ const { path } = deps;
33
+ const fs = deps.fs;
34
+ const normRoot = path.resolve(root);
35
+ const abs = path.resolve(normRoot, rel || ".");
36
+ // Fast string guard: blocks `..` traversal and absolute-path escapes.
37
+ if (abs !== normRoot && !abs.startsWith(normRoot + path.sep)) {
35
38
  throw new Error(`path escapes serve root: ${rel}`);
36
39
  }
40
+ // Symlink guard: path.resolve does NOT follow symlinks, so a symlink INSIDE
41
+ // root pointing OUTSIDE would pass the string check yet the fs op (read/write/
42
+ // list) would escape the serve boundary — e.g. reading ~/.ssh through a
43
+ // workspace symlink, defeating a `--read-only` exposure. Resolve the real path
44
+ // of the deepest EXISTING ancestor (a not-yet-created file can't be a symlink)
45
+ // and re-check containment against the real root.
46
+ if (fs && typeof fs.realpathSync === "function") {
47
+ let realRoot;
48
+ try {
49
+ realRoot = fs.realpathSync(normRoot);
50
+ } catch {
51
+ return abs; // root itself unresolvable → the string guard is all we have
52
+ }
53
+ let probe = abs;
54
+ for (;;) {
55
+ let real;
56
+ try {
57
+ real = fs.realpathSync(probe);
58
+ } catch {
59
+ const parent = path.dirname(probe);
60
+ if (parent === probe) break; // reached fs root, nothing existed
61
+ probe = parent;
62
+ continue;
63
+ }
64
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
65
+ throw new Error(`path escapes serve root (symlink): ${rel}`);
66
+ }
67
+ break;
68
+ }
69
+ }
37
70
  return abs;
38
71
  }
39
72
 
73
+ /**
74
+ * Constant-time check of an `Authorization: Bearer <token>` header against the
75
+ * expected token. A plain `!==` leaks timing that could let a caller recover the
76
+ * token byte-by-byte; hash-length differs are rejected up front (the token is a
77
+ * fixed-length random value, so its length is not secret), then `timingSafeEqual`
78
+ * compares the bytes. Mirrors the ws-server's token check.
79
+ */
80
+ export function bearerMatches(authHeader, token) {
81
+ const prefix = "Bearer ";
82
+ if (typeof authHeader !== "string" || !authHeader.startsWith(prefix)) {
83
+ return false;
84
+ }
85
+ const a = Buffer.from(authHeader.slice(prefix.length), "utf-8");
86
+ const b = Buffer.from(String(token), "utf-8");
87
+ if (a.length !== b.length) return false;
88
+ return timingSafeEqual(a, b);
89
+ }
90
+
40
91
  function ok(text) {
41
92
  return { content: [{ type: "text", text: String(text) }] };
42
93
  }
@@ -194,8 +245,7 @@ export function startMcpServe(opts = {}) {
194
245
  return send(405, rpcError(null, -32600, "POST only"));
195
246
  }
196
247
  if (token) {
197
- const auth = req.headers.authorization || "";
198
- if (auth !== `Bearer ${token}`) {
248
+ if (!bearerMatches(req.headers.authorization, token)) {
199
249
  return send(401, rpcError(null, -32001, "unauthorized"));
200
250
  }
201
251
  }
@@ -27,6 +27,7 @@ import crypto from "crypto";
27
27
  import { AgentRouter } from "./agent-router.js";
28
28
  import { NotificationManager } from "./notifiers/index.js";
29
29
  import { createChatFn } from "./cowork-adapter.js";
30
+ import { firstBalancedJson } from "./json-schema-output.js";
30
31
 
31
32
  /* ---------- _deps injection (Vitest CJS mock pattern) ---------- */
32
33
  export const _deps = { execSync };
@@ -403,8 +404,9 @@ export class Orchestrator extends EventEmitter {
403
404
 
404
405
  /** Extract the first JSON array from an LLM response string. */
405
406
  function _extractJson(text) {
406
- const match = text.match(/\[[\s\S]*\]/);
407
- return match ? match[0] : "[]";
407
+ // Balanced extraction stops at the first complete array, so trailing prose
408
+ // (or a second array) no longer over-captures into an unparseable string.
409
+ return firstBalancedJson(text, "[") || "[]";
408
410
  }
409
411
 
410
412
  /** Parse error lines from CI output (test failures, lint errors). */
@@ -58,6 +58,17 @@ export function startApp(options = {}) {
58
58
  return child.pid;
59
59
  }
60
60
 
61
+ /**
62
+ * Parse a PID-file's contents into a valid positive integer, or null.
63
+ * A corrupt / empty / partially-written file must not yield NaN — that NaN
64
+ * would otherwise flow into `taskkill /PID NaN` or `process.kill(NaN, …)`,
65
+ * which silently fails to act on the real process.
66
+ */
67
+ export function parsePid(content) {
68
+ const pid = parseInt(String(content).trim(), 10);
69
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
70
+ }
71
+
61
72
  export function stopApp() {
62
73
  const pidFile = getPidFilePath();
63
74
 
@@ -66,7 +77,16 @@ export function stopApp() {
66
77
  return false;
67
78
  }
68
79
 
69
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
80
+ const pid = parsePid(readFileSync(pidFile, "utf-8"));
81
+ if (pid === null) {
82
+ logger.warn("PID file is empty or corrupt; removing it.");
83
+ try {
84
+ unlinkSync(pidFile);
85
+ } catch {
86
+ /* best-effort */
87
+ }
88
+ return false;
89
+ }
70
90
 
71
91
  try {
72
92
  if (isWindows()) {
@@ -90,7 +110,15 @@ export function isAppRunning() {
90
110
  return false;
91
111
  }
92
112
 
93
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
113
+ const pid = parsePid(readFileSync(pidFile, "utf-8"));
114
+ if (pid === null) {
115
+ try {
116
+ unlinkSync(pidFile);
117
+ } catch {
118
+ /* best-effort */
119
+ }
120
+ return false;
121
+ }
94
122
 
95
123
  try {
96
124
  process.kill(pid, 0);
@@ -104,7 +132,7 @@ export function isAppRunning() {
104
132
  export function getAppPid() {
105
133
  const pidFile = getPidFilePath();
106
134
  if (!existsSync(pidFile)) return null;
107
- return parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
135
+ return parsePid(readFileSync(pidFile, "utf-8"));
108
136
  }
109
137
 
110
138
  function findExecutable(binDir, extension) {
@@ -32,6 +32,20 @@ export function extractAtPrefix(line) {
32
32
  return m ? { prefix: m[2] } : null;
33
33
  }
34
34
 
35
+ /**
36
+ * In `!` bash mode (the line starts with `!`), the trailing whitespace-delimited
37
+ * token completes to a filesystem path (Claude-Code 2.1.193 bash-mode file
38
+ * autocomplete). Returns `{ prefix }` — the token with any leading `!` stripped
39
+ * (so `!cat src/fo` → `src/fo`, `!scr` → `scr`) — or null when not in bash mode.
40
+ */
41
+ export function extractBashPathPrefix(line) {
42
+ if (!/^\s*!/.test(line || "")) return null;
43
+ const m = /(\S*)$/.exec(line || "");
44
+ let tok = m ? m[1] : "";
45
+ if (tok.startsWith("!")) tok = tok.slice(1); // `!src/fo` → `src/fo`
46
+ return { prefix: tok };
47
+ }
48
+
35
49
  /** Normalize to forward slashes (what @refs use on every platform). */
36
50
  function fwd(p) {
37
51
  return String(p).replace(/\\/g, "/");
@@ -183,6 +197,21 @@ export function makeAtCompleter(opts = {}) {
183
197
  return [hits, line];
184
198
  }
185
199
  }
200
+ // `!` bash-mode file-path completion (Claude-Code 2.1.193 parity): the
201
+ // trailing token completes to filesystem paths under cwd — a shell command
202
+ // runs from cwd, so this uses the filesystem candidate set only (not the
203
+ // editor's open-file list). Checked before @refs so a `@` inside a bash
204
+ // command (e.g. `!grep @x file`) still completes the trailing path token.
205
+ const bash = extractBashPathPrefix(line);
206
+ if (bash) {
207
+ const fromFs = fileCandidates(bash.prefix, {
208
+ cwd: getCwd(),
209
+ deps: opts.deps,
210
+ });
211
+ // readline replaces the trailing `prefix` with the chosen path, leaving
212
+ // the `!` and the rest of the command intact.
213
+ return [fromFs.slice(0, MAX_CANDIDATES), bash.prefix];
214
+ }
186
215
  const at = extractAtPrefix(line);
187
216
  if (!at) return [[], line];
188
217
  refreshIde(); // async top-up for the NEXT tab; this one uses the cache
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { EventEmitter } from "events";
11
+ import { firstBalancedJson } from "./json-schema-output.js";
11
12
 
12
13
  /**
13
14
  * Required slots per intent type — must be filled before execution.
@@ -341,9 +342,9 @@ Keep values concise (single words or short strings).`;
341
342
  ]);
342
343
 
343
344
  const content = response?.message?.content || response?.content || "";
344
- const jsonMatch = content.match(/\{[\s\S]*\}/);
345
- if (jsonMatch) {
346
- const parsed = JSON.parse(jsonMatch[0]);
345
+ const jsonText = firstBalancedJson(content, "{");
346
+ if (jsonText) {
347
+ const parsed = JSON.parse(jsonText);
347
348
  // Filter out null values
348
349
  const result = {};
349
350
  for (const [key, value] of Object.entries(parsed)) {
@@ -74,6 +74,9 @@ export class SubAgentContext {
74
74
  this.inheritedContext = options.inheritedContext || null;
75
75
  this.allowedTools = options.allowedTools || null; // null = all
76
76
  this.depth = options.depth || 1; // nesting level (parent main loop = 0)
77
+ // Shared run-wide TOTAL-sub-agent counter (one object across the whole tree)
78
+ // so this sub-agent's own spawns draw from the same breadth pool.
79
+ this.subAgentBudget = options.subAgentBudget || null;
77
80
  this.cwd = options.cwd || process.cwd();
78
81
  this.status = "active";
79
82
  this.result = null;
@@ -252,6 +255,9 @@ export class SubAgentContext {
252
255
  cwd: this.cwd,
253
256
  // Nesting level: lets a nested spawn_sub_agent see — and cap — its depth.
254
257
  subAgentDepth: this.depth,
258
+ // Shared total-sub-agent counter so a nested spawn_sub_agent draws from
259
+ // (and is bounded by) the run's single breadth pool.
260
+ subAgentBudget: this.subAgentBudget,
255
261
  ...loopOptions,
256
262
  };
257
263
  if (this.iterationBudget) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import http from "http";
9
9
  import https from "https";
10
+ import { lookup as dnsLookup } from "dns";
10
11
  import { URL } from "url";
11
12
 
12
13
  const DEFAULT_MAX_BYTES = 2_000_000;
@@ -139,7 +140,46 @@ function stripTags(html) {
139
140
  return String(html).replace(/<[^>]+>/g, "");
140
141
  }
141
142
 
142
- async function _doRequest(parsed, { maxBytes, timeout, headers }) {
143
+ /**
144
+ * A `lookup` for http(s).request that resolves the hostname and REJECTS the
145
+ * connection if any resolved IP is private/loopback/link-local. checkAllowed
146
+ * only inspects the URL's literal hostname, so a public-looking domain that
147
+ * DNS-resolves to an internal IP (e.g. 169.254.169.254 cloud metadata) would
148
+ * otherwise sail through — classic DNS-based SSRF. Because Node connects to the
149
+ * exact address this returns, it also closes the DNS-rebinding TOCTOU window.
150
+ * Returns a Node-style lookup fn; `allowPrivateHosts` opts out (same flag as
151
+ * checkAllowed).
152
+ */
153
+ export function makeSafeLookup(allowPrivateHosts, deps = _deps) {
154
+ const lookup = deps.lookup || dnsLookup;
155
+ return (hostname, options, callback) => {
156
+ const cb = typeof options === "function" ? options : callback;
157
+ const opts = typeof options === "function" ? {} : options || {};
158
+ lookup(hostname, { ...opts, all: true }, (err, addresses) => {
159
+ if (err) return cb(err);
160
+ const list = Array.isArray(addresses) ? addresses : [];
161
+ if (list.length === 0) {
162
+ return cb(new Error(`could not resolve host: ${hostname}`));
163
+ }
164
+ if (!allowPrivateHosts) {
165
+ for (const a of list) {
166
+ if (isPrivateHost(a.address)) {
167
+ return cb(
168
+ new Error(`private/loopback resolved IP blocked: ${a.address}`),
169
+ );
170
+ }
171
+ }
172
+ }
173
+ const chosen = list[0];
174
+ cb(null, chosen.address, chosen.family);
175
+ });
176
+ };
177
+ }
178
+
179
+ async function _doRequest(
180
+ parsed,
181
+ { maxBytes, timeout, headers, allowPrivateHosts },
182
+ ) {
143
183
  const lib = parsed.protocol === "https:" ? https : http;
144
184
  return new Promise((resolve, reject) => {
145
185
  const req = lib.request(
@@ -149,6 +189,10 @@ async function _doRequest(parsed, { maxBytes, timeout, headers }) {
149
189
  hostname: parsed.hostname,
150
190
  port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
151
191
  path: parsed.pathname + parsed.search,
192
+ // Resolve-and-check: reject the connection if the host resolves to a
193
+ // private IP (DNS-SSRF + rebinding guard). String-host check is in
194
+ // checkAllowed; this covers the resolved address.
195
+ lookup: makeSafeLookup(allowPrivateHosts),
152
196
  headers: {
153
197
  "User-Agent": "ChainlessChain-Agent/1.0",
154
198
  Accept: "*/*",
@@ -210,7 +254,12 @@ export async function webFetch(url, options = {}) {
210
254
  let redirects = 0;
211
255
  let response;
212
256
  while (true) {
213
- response = await _doRequest(parsed, { maxBytes, timeout, headers });
257
+ response = await _doRequest(parsed, {
258
+ maxBytes,
259
+ timeout,
260
+ headers,
261
+ allowPrivateHosts: config.allowPrivateHosts,
262
+ });
214
263
  if (!response.redirect) break;
215
264
  if (++redirects > maxRedirects) {
216
265
  return { error: "too many redirects" };
@@ -250,7 +299,9 @@ export async function webFetch(url, options = {}) {
250
299
  };
251
300
  }
252
301
 
253
- export const _deps = { http, https };
302
+ // `lookup` is injectable so tests can resolve a domain to a private IP without
303
+ // real network; production uses the system resolver.
304
+ export const _deps = { http, https, lookup: dnsLookup };
254
305
 
255
306
  // ===== V2 Surface: Web Fetch governance overlay (CLI v0.142.0) =====
256
307
  export const WFET_TARGET_MATURITY_V2 = Object.freeze({