chainlesschain 0.162.151 → 0.162.153

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 (187) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/.build-hash +1 -1
  4. package/src/assets/web-panel/assets/{AIOps-DuEOzebi.js → AIOps-CBilhs1R.js} +1 -1
  5. package/src/assets/web-panel/assets/{ActionButton-C6GiJa5H.js → ActionButton-Cf-RnC5Y.js} +1 -1
  6. package/src/assets/web-panel/assets/{Analytics-BBIiL_pO.js → Analytics-S8tkGhw0.js} +3 -3
  7. package/src/assets/web-panel/assets/{AppLayout-CuI2gZRy.js → AppLayout-CNXI3Bw5.js} +5 -5
  8. package/src/assets/web-panel/assets/{Audit-CbUjfr-K.js → Audit-DF2zVFZ0.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-CsSW9ljf.js → Backup-CMEiwQZL.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-kM3rLe7F.js → BaseInput-rwLw4K70.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-kfE51YN3.js → Chat-C478H8u9.js} +5 -5
  12. package/src/assets/web-panel/assets/ChatBubbleRenderer-DNQsKN61.js +1 -0
  13. package/src/assets/web-panel/assets/{Checkbox-jYb1uj-M.js → Checkbox-D-47X635.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-X9XiD5Lj.js → Codegen-Cb9Ihed5.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-BfbHu1xI.js → Col-CQsdzczt.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-c4oJMFS-.js → Community-DiqzeAy-.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-BFmnZpH7.js → Compact-Bq-APyyF.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-s9shVHBS.js → Compliance-Dnb1YxPr.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-C5da--TC.js → Cowork-CZljerdQ.js} +3 -3
  20. package/src/assets/web-panel/assets/{Cron-9kNbyNE4.js → Cron-gAdbZOq7.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-C1k3NJD4.js → Crosschain-DMPGXttD.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-DkheEvNK.js → DID-C7dBDAv8.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-C3FuSGmJ.js → Dashboard-ByR4VcF8.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-CAV6FMFD.js → Dropdown-Bc4tbiQN.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-Cc-Ulc4l.js → EmailListRenderer-BhUN2pDw.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-8CitHEtv.js → FamilyGuardDashboard-DpJuANCD.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-DFSZ5KDt.js → Federation-BEm4TvcE.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-CEWCtnG2.js → FormItemContext-JnkPkhiC.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-CwRzNzmX.js → GenericCardRenderer-BfCzFUIC.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-0rgDM7cJ.js → Git-1c0MWpmD.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-CfBKtK9F.js → Governance-C_j3EBpv.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-CzcUVvud.js → Inference-Cb7XRgtY.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-Dwc4YL4A.js → KnowledgeGraph-DoHPvHyK.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-DgFPwR2F.js → Logs-CYriKpy-.js} +2 -2
  35. package/src/assets/web-panel/assets/{Marketplace-DnpPTbGU.js → Marketplace-BT56-GD0.js} +1 -1
  36. package/src/assets/web-panel/assets/{McpTools-DRxxINyW.js → McpTools-B6XX_wnZ.js} +4 -4
  37. package/src/assets/web-panel/assets/{Memory-OhmW_6Z3.js → Memory-DRgNuaQc.js} +2 -2
  38. package/src/assets/web-panel/assets/{MobileBridge-DkTW-RK5.js → MobileBridge-B0qgvBN2.js} +1 -1
  39. package/src/assets/web-panel/assets/MobileProjects-CRRfswf7.js +1 -0
  40. package/src/assets/web-panel/assets/{Mtc--vmkL0AS.js → Mtc-DnSFj9jw.js} +4 -4
  41. package/src/assets/web-panel/assets/{MtcAudit-DGoFWjD4.js → MtcAudit-c0tuz1sm.js} +4 -4
  42. package/src/assets/web-panel/assets/{Multisig-B4kWgq95.js → Multisig-CVLBGioX.js} +3 -3
  43. package/src/assets/web-panel/assets/{NLProgramming-YQwvommE.js → NLProgramming-CFaqmNbx.js} +1 -1
  44. package/src/assets/web-panel/assets/{Notes-CgjtUADJ.js → Notes-BM_mNwU_.js} +3 -3
  45. package/src/assets/web-panel/assets/{NotificationSettings-6JDrpBG5.js → NotificationSettings-Sa1k3ReS.js} +1 -1
  46. package/src/assets/web-panel/assets/OrderTableRenderer-lWzmtqBh.js +1 -0
  47. package/src/assets/web-panel/assets/{Organization-8McOT_OF.js → Organization-C2blcq-P.js} +4 -4
  48. package/src/assets/web-panel/assets/{Overflow-DAZpSCGc.js → Overflow-B3PwOJnK.js} +1 -1
  49. package/src/assets/web-panel/assets/{P2P-B7l2ZWLy.js → P2P-DaB9T6tb.js} +2 -2
  50. package/src/assets/web-panel/assets/{PdhVaultBrowser-W1Ud-NAL.js → PdhVaultBrowser-BpEBZ72n.js} +5 -5
  51. package/src/assets/web-panel/assets/{Permissions-Czm_w9u_.js → Permissions-BTkWInyH.js} +4 -4
  52. package/src/assets/web-panel/assets/{PersonalDataHub-B4F0_ZVM.js → PersonalDataHub-LHEwNmyL.js} +2 -2
  53. package/src/assets/web-panel/assets/{Pipeline-B37aFiWv.js → Pipeline-DvWIxqH-.js} +1 -1
  54. package/src/assets/web-panel/assets/{Privacy-94KBnRrD.js → Privacy-BDbpBagU.js} +1 -1
  55. package/src/assets/web-panel/assets/{ProjectInit-B5h8_dlh.js → ProjectInit-DLeMDWWv.js} +2 -2
  56. package/src/assets/web-panel/assets/ProjectSettings-BK3NsxIz.js +2 -0
  57. package/src/assets/web-panel/assets/Projects-BPWPg9zA.js +1 -0
  58. package/src/assets/web-panel/assets/{Providers-B3cNWwJg.js → Providers-D2EvmPAd.js} +1 -1
  59. package/src/assets/web-panel/assets/{QrScannerModal-CHRvOw1R.js → QrScannerModal-DAcu2fNg.js} +1 -1
  60. package/src/assets/web-panel/assets/{QuickAsk-D1JMgFEd.js → QuickAsk-B7U850Yt.js} +1 -1
  61. package/src/assets/web-panel/assets/{Recommend-EYWGR79p.js → Recommend-bxNXLzOR.js} +1 -1
  62. package/src/assets/web-panel/assets/{RemoteSession-BAMC2pJt.js → RemoteSession-BsCjAfSJ.js} +2 -2
  63. package/src/assets/web-panel/assets/{Reputation-X2Hk1XG_.js → Reputation-DZ9LPu-R.js} +1 -1
  64. package/src/assets/web-panel/assets/{Row-Eq98LRtU.js → Row-cIYkCB3Y.js} +1 -1
  65. package/src/assets/web-panel/assets/{RssFeed-i76s6z_o.js → RssFeed-BxTNTtTp.js} +2 -2
  66. package/src/assets/web-panel/assets/{Search-D_Es7CAI.js → Search-DSar6GhE.js} +1 -1
  67. package/src/assets/web-panel/assets/{Security-kayD2e94.js → Security-oCbIGysL.js} +4 -4
  68. package/src/assets/web-panel/assets/{Services-Ctsm6ul8.js → Services-D8yojrNv.js} +2 -2
  69. package/src/assets/web-panel/assets/{Skeleton-CRNYlfdf.js → Skeleton-vTA6P1eL.js} +1 -1
  70. package/src/assets/web-panel/assets/{Skills-DkZYpF4-.js → Skills-BXLf_KgZ.js} +1 -1
  71. package/src/assets/web-panel/assets/{Sla-DXkoBcOP.js → Sla-Cpwxzz__.js} +1 -1
  72. package/src/assets/web-panel/assets/{SpeechSettings-DXi_GI1d.js → SpeechSettings-DcPN9kCb.js} +1 -1
  73. package/src/assets/web-panel/assets/{SyncSettings-rNKpfomn.js → SyncSettings-DY0U5Vuj.js} +2 -2
  74. package/src/assets/web-panel/assets/{Tasks-XF4uPtG-.js → Tasks-DKu3-sZJ.js} +1 -1
  75. package/src/assets/web-panel/assets/{Templates-i0Hzfeyu.js → Templates-MD3nfWGm.js} +1 -1
  76. package/src/assets/web-panel/assets/{Tenant-sMpHAi27.js → Tenant-DKVnyGXM.js} +1 -1
  77. package/src/assets/web-panel/assets/Terminal-DQMIZfWf.js +3 -0
  78. package/src/assets/web-panel/assets/{TimelineRenderer-yuC6K7Rf.js → TimelineRenderer-CugmNZTs.js} +1 -1
  79. package/src/assets/web-panel/assets/{Tokens-B8O8i0s6.js → Tokens-Cjvlttu6.js} +1 -1
  80. package/src/assets/web-panel/assets/{Trigger-Bv6yrlCr.js → Trigger-CzNOEcoI.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trust-C4mW95Ev.js → Trust-D5jg9iPA.js} +1 -1
  82. package/src/assets/web-panel/assets/{UkeySign-DV6yKaHn.js → UkeySign-59bMlvxY.js} +1 -1
  83. package/src/assets/web-panel/assets/{VideoEditing-UI479-sD.js → VideoEditing-DmCzSBqn.js} +1 -1
  84. package/src/assets/web-panel/assets/{Wallet-M5oV9EVP.js → Wallet-DoFe8V6W.js} +4 -4
  85. package/src/assets/web-panel/assets/{WebAuthn-DDyDHPDR.js → WebAuthn-9POLER-w.js} +5 -5
  86. package/src/assets/web-panel/assets/{WorkflowEditor-RoBv4rqk.js → WorkflowEditor-cFQH0Og6.js} +1 -1
  87. package/src/assets/web-panel/assets/{chat-Cw1mE5LS.js → chat-D3fR2NkZ.js} +1 -1
  88. package/src/assets/web-panel/assets/{colors-BFP62Gwf.js → colors-DiLEwE_P.js} +1 -1
  89. package/src/assets/web-panel/assets/{compact-item-17HL6pyC.js → compact-item-CGfFDdic.js} +1 -1
  90. package/src/assets/web-panel/assets/{createContext-D55X4fZX.js → createContext-BbIbOPL2.js} +1 -1
  91. package/src/assets/web-panel/assets/devWarning-B6FRgsmP.js +1 -0
  92. package/src/assets/web-panel/assets/{hasIn-D3E5mNsP.js → hasIn-DqyWp63_.js} +1 -1
  93. package/src/assets/web-panel/assets/{index-DOjA8bvj.js → index-BCoDp6o_.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-tVRPgYGr.js → index-BKeRBUtO.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-RYZgFIwo.js → index-BKxvHCqz.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-Da27u6j0.js → index-BiLP6SNu.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-CFYGn88j.js → index-BtcjYHgn.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-paVubXzn.js → index-BtwvOuvO.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-D4LHSde4.js → index-ByE5tKjP.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-C_MfcrLf.js → index-C1S30j-Z.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-DkBVYlE4.js → index-C2lZXIEA.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-ITeEHb8L.js → index-CH8gSMSA.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-BC6zxOlU.js → index-CHpI6g_S.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-KtLJXn4s.js → index-CIleIMTT.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-BY_xZ2jy.js → index-CToOzfR2.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-Bzpp2z2S.js → index-CZQshOyI.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-CvCFKojj.js → index-ComvO4hy.js} +1 -1
  108. package/src/assets/web-panel/assets/index-Cq411qtc.js +1 -0
  109. package/src/assets/web-panel/assets/{index-tUH_6bdJ.js → index-CweXQM71.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-Cdvk2_2q.js → index-CySlHabI.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-gAVxDiTZ.js → index-D16FSRMW.js} +1 -1
  112. package/src/assets/web-panel/assets/index-D1oX0DK3.js +1 -0
  113. package/src/assets/web-panel/assets/{index-CLEzsZ8A.js → index-D3vQv3Kq.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-BEa1RW4c.js → index-DH6qaU1a.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-BQmZg5si.js → index-DPxmTldu.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-ASPYTzMK.js → index-DQH--Uw5.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-uSsiwgZ2.js → index-Db9UJVjj.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-CR9rNUvi.js → index-DdLwxj-F.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-Bq83UBDr.js → index-DdjXemK-.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-H9_X1Cg_.js → index-DzuGkFvy.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DD_qGBrt.js → index-FFlp8Jx9.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-BnGpft-Z.js → index-Km36XQ3z.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CjCwi7we.js → index-auI9fxYH.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-DzG7mkgW.js → index-fCfaVIr7.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-DtAnPBWP.js → index-hVGbmc7y.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-DCLu84x-.js → index-kL8f4MRS.js} +1 -1
  127. package/src/assets/web-panel/assets/{index--44J50mc.js → index-ns6bNnGo.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-CW6LLoJO.js → index-qHvMk0U3.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-DIRvdwBX.js → index-rTrejDjk.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-CGyTos4p.js → index-tLaW5UzZ.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-BgN5G5vF.js → index-xm1KHUEQ.js} +27 -27
  132. package/src/assets/web-panel/assets/{initDefaultProps-Ckp7xVZS.js → initDefaultProps-pg_7pnd6.js} +1 -1
  133. package/src/assets/web-panel/assets/{motion-DUZgCpn6.js → motion-B5GoJSZL.js} +1 -1
  134. package/src/assets/web-panel/assets/{move-B2rttglH.js → move-BaJoe2FW.js} +1 -1
  135. package/src/assets/web-panel/assets/{mtc-parser-BYWcLTuN.js → mtc-parser-CiQGbqtr.js} +1 -1
  136. package/src/assets/web-panel/assets/{omit-DhHqKeFx.js → omit-BFNEsKvU.js} +1 -1
  137. package/src/assets/web-panel/assets/{pickAttrs-ANpJaWMO.js → pickAttrs-YbSoANHm.js} +1 -1
  138. package/src/assets/web-panel/assets/{placementArrow-CfZWe7CA.js → placementArrow-C40MJPIH.js} +1 -1
  139. package/src/assets/web-panel/assets/{responsiveObserve-D7Xz2y-R.js → responsiveObserve-LaVz2zF6.js} +1 -1
  140. package/src/assets/web-panel/assets/{slide-DekqCBY9.js → slide-BkSoAGxF.js} +1 -1
  141. package/src/assets/web-panel/assets/{statusUtils-BET6XU7h.js → statusUtils-C4jWjohA.js} +1 -1
  142. package/src/assets/web-panel/assets/{styleChecker-qHmJjV62.js → styleChecker-BGqeIh-m.js} +1 -1
  143. package/src/assets/web-panel/assets/{useFlexGapSupport-BoMCTNyu.js → useFlexGapSupport-JsR5NMrM.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFs-TdW-NgFC.js → useFs-DOiP38RR.js} +1 -1
  145. package/src/assets/web-panel/assets/{usePersonalDataHub-Dyox7wCu.js → usePersonalDataHub-C-nC0ymt.js} +1 -1
  146. package/src/assets/web-panel/assets/{vnode-XUn5S8CP.js → vnode-BalilHOU.js} +1 -1
  147. package/src/assets/web-panel/assets/{zoom-Ls77i8hn.js → zoom-DjUELqks.js} +1 -1
  148. package/src/assets/web-panel/index.html +1 -1
  149. package/src/commands/agent.js +8 -1
  150. package/src/commands/eval.js +88 -23
  151. package/src/commands/plugin.js +10 -0
  152. package/src/commands/team.js +60 -3
  153. package/src/data/changelog.json +17 -2
  154. package/src/harness/mcp-client.js +115 -1
  155. package/src/harness/remote-command-ledger.js +60 -10
  156. package/src/lib/agent-sandbox.js +11 -5
  157. package/src/lib/agent-team/task-lease.js +28 -0
  158. package/src/lib/agent-team/team-runner.js +45 -0
  159. package/src/lib/config-manager.js +14 -1
  160. package/src/lib/eval/tasks.js +62 -41
  161. package/src/lib/eval/trend.js +7 -1
  162. package/src/lib/lsp/__tests__/lsp-client.test.js +12 -6
  163. package/src/lib/lsp/code-intelligence.js +55 -17
  164. package/src/lib/lsp/lsp-client.js +7 -1
  165. package/src/lib/plugin-runtime/install.js +20 -1
  166. package/src/lib/plugin-runtime/policy.js +13 -0
  167. package/src/lib/plugin-runtime/remote-source.js +34 -0
  168. package/src/lib/plugin-runtime/signature.js +79 -22
  169. package/src/lib/plugin-security.js +13 -14
  170. package/src/lib/sub-agent-context.js +28 -7
  171. package/src/lib/telemetry/span-recorder.js +30 -7
  172. package/src/repl/agent-repl.js +64 -21
  173. package/src/repl/permission-prompt.js +58 -0
  174. package/src/runtime/__tests__/auto-pin.test.js +27 -4
  175. package/src/runtime/agent-core.js +236 -1
  176. package/src/runtime/auto-pin.js +19 -0
  177. package/src/runtime/coding-agent-contract-shared.cjs +5 -0
  178. package/src/runtime/headless-runner.js +23 -5
  179. package/src/assets/web-panel/assets/ChatBubbleRenderer-Bg2FTW6A.js +0 -1
  180. package/src/assets/web-panel/assets/MobileProjects-DpCGV_lI.js +0 -1
  181. package/src/assets/web-panel/assets/OrderTableRenderer-PM6IlxjO.js +0 -1
  182. package/src/assets/web-panel/assets/ProjectSettings-lmeI3Lpm.js +0 -2
  183. package/src/assets/web-panel/assets/Projects-D5AYOasl.js +0 -1
  184. package/src/assets/web-panel/assets/Terminal-BoZvaYAl.js +0 -3
  185. package/src/assets/web-panel/assets/devWarning-CqbHYuRc.js +0 -1
  186. package/src/assets/web-panel/assets/index-CKBI1U5K.js +0 -1
  187. package/src/assets/web-panel/assets/index-CkLp6DTO.js +0 -1
@@ -30,6 +30,39 @@ const STRINGUTIL_V2 =
30
30
  " return str.charAt(0).toUpperCase() + str.slice(1);\n" +
31
31
  "}\n";
32
32
 
33
+ // Harness files the agent is told NOT to edit, shared by setup and check so the
34
+ // check can byte-compare them (same anti-cheat as STRINGUTIL_V2 above). A mere
35
+ // presence-regex is neuterable: prepending `console.log('ALL OK');
36
+ // process.exit(0)` keeps the assertion text present while never running it.
37
+ const RUN_CHECKS_MJS = [
38
+ "import { sum } from './calc.js';",
39
+ "const cases = [[1,2,3],[10,5,15],[0,0,0]];",
40
+ "for (const [a,b,want] of cases) {",
41
+ " if (sum(a,b) !== want) {",
42
+ " console.error(`FAIL sum(${a},${b})=${sum(a,b)} want ${want}`);",
43
+ " process.exit(1);",
44
+ " }",
45
+ "}",
46
+ "console.log('ALL OK');",
47
+ "",
48
+ ].join("\n");
49
+
50
+ const BUILD_MJS =
51
+ 'import { banner } from "./app.mjs";\n' +
52
+ 'if (banner() !== "v1.0.0") {\n' +
53
+ ' console.error("BUILD FAIL: " + banner());\n' +
54
+ " process.exit(1);\n" +
55
+ "}\n" +
56
+ 'console.log("BUILD OK");\n';
57
+
58
+ const RUN_MJS =
59
+ 'import { label } from "./app.mjs";\n' +
60
+ 'if (label("hello") !== "Hello") {\n' +
61
+ ' console.error("FAIL: " + label("hello"));\n' +
62
+ " process.exit(1);\n" +
63
+ "}\n" +
64
+ 'console.log("OK");\n';
65
+
33
66
  export const BUILTIN_TASKS = [
34
67
  {
35
68
  id: "create-file",
@@ -134,32 +167,22 @@ export const BUILTIN_TASKS = [
134
167
  );
135
168
  fs.writeFileSync(
136
169
  path.join(dir, "run-checks.mjs"),
137
- [
138
- "import { sum } from './calc.js';",
139
- "const cases = [[1,2,3],[10,5,15],[0,0,0]];",
140
- "for (const [a,b,want] of cases) {",
141
- " if (sum(a,b) !== want) {",
142
- " console.error(`FAIL sum(${a},${b})=${sum(a,b)} want ${want}`);",
143
- " process.exit(1);",
144
- " }",
145
- "}",
146
- "console.log('ALL OK');",
147
- "",
148
- ].join("\n"),
170
+ RUN_CHECKS_MJS,
149
171
  "utf8",
150
172
  );
151
173
  },
152
174
  check: (dir) => {
153
- // Objective: the test harness must actually pass. Guard against the agent
154
- // "fixing" it by neutering run-checks.mjs require the assertions to
155
- // still be present.
175
+ // Objective: the test harness must actually pass. Byte-compare the
176
+ // harness a presence-regex alone is neuterable (prepend an early
177
+ // `console.log('ALL OK'); process.exit(0)` and the assertion text is
178
+ // still there but never runs).
156
179
  const checks = read(dir, "run-checks.mjs");
157
180
  if (checks == null)
158
181
  return { pass: false, detail: "run-checks.mjs missing" };
159
- if (!/sum\(a,\s*b\)\s*!==\s*want/.test(checks)) {
182
+ if (checks !== RUN_CHECKS_MJS) {
160
183
  return {
161
184
  pass: false,
162
- detail: "run-checks.mjs assertions were altered",
185
+ detail: "run-checks.mjs was edited (fix calc.js instead)",
163
186
  };
164
187
  }
165
188
  try {
@@ -487,23 +510,19 @@ export const BUILTIN_TASKS = [
487
510
  'export function banner() {\n return "v" + VERSION;\n}\n',
488
511
  "utf8",
489
512
  );
490
- fs.writeFileSync(
491
- path.join(dir, "build.mjs"),
492
- 'import { banner } from "./app.mjs";\n' +
493
- 'if (banner() !== "v1.0.0") {\n' +
494
- ' console.error("BUILD FAIL: " + banner());\n' +
495
- " process.exit(1);\n" +
496
- "}\n" +
497
- 'console.log("BUILD OK");\n',
498
- "utf8",
499
- );
513
+ fs.writeFileSync(path.join(dir, "build.mjs"), BUILD_MJS, "utf8");
500
514
  },
501
515
  check: (dir) => {
502
516
  const build = read(dir, "build.mjs");
503
517
  if (build == null) return { pass: false, detail: "build.mjs missing" };
504
- // Guard: fix the modules, don't neuter the build harness.
505
- if (!/banner\(\)\s*!==\s*"v1\.0\.0"/.test(build)) {
506
- return { pass: false, detail: "build.mjs assertion was altered" };
518
+ // Guard: fix the modules, don't neuter the build harness. Byte-compare —
519
+ // a presence-regex is satisfiable by a rewritten build.mjs that defines
520
+ // its own banner() and never imports app.mjs.
521
+ if (build !== BUILD_MJS) {
522
+ return {
523
+ pass: false,
524
+ detail: "build.mjs was edited (reconcile config.mjs/app.mjs instead)",
525
+ };
507
526
  }
508
527
  try {
509
528
  const out = execFileSync(process.execPath, ["build.mjs"], {
@@ -534,7 +553,7 @@ export const BUILTIN_TASKS = [
534
553
  "`capitalize` export was renamed to `toTitle` (same behavior). Do all of: " +
535
554
  "(1) bump stringutil to ^2.0.0 in package.json; (2) update app.mjs to " +
536
555
  "import and use `toTitle` instead of `capitalize`; (3) do NOT edit " +
537
- "stringutil.mjs. Afterwards `node run.mjs` must print OK.",
556
+ "stringutil.mjs or run.mjs. Afterwards `node run.mjs` must print OK.",
538
557
  setup: (dir) => {
539
558
  fs.writeFileSync(
540
559
  path.join(dir, "package.json"),
@@ -558,16 +577,7 @@ export const BUILTIN_TASKS = [
558
577
  "export function label(s) {\n return capitalize(s);\n}\n",
559
578
  "utf8",
560
579
  );
561
- fs.writeFileSync(
562
- path.join(dir, "run.mjs"),
563
- 'import { label } from "./app.mjs";\n' +
564
- 'if (label("hello") !== "Hello") {\n' +
565
- ' console.error("FAIL: " + label("hello"));\n' +
566
- " process.exit(1);\n" +
567
- "}\n" +
568
- 'console.log("OK");\n',
569
- "utf8",
570
- );
580
+ fs.writeFileSync(path.join(dir, "run.mjs"), RUN_MJS, "utf8");
571
581
  },
572
582
  check: (dir) => {
573
583
  // The dependency is vendored — the agent must adapt the caller, NOT edit
@@ -580,6 +590,17 @@ export const BUILTIN_TASKS = [
580
590
  detail: "the stringutil dependency was edited (adapt the caller)",
581
591
  };
582
592
  }
593
+ // The run harness is equally off-limits: without this guard an agent
594
+ // could overwrite run.mjs with `console.log("OK")` and pass the textual
595
+ // checks below without app.mjs ever being executed.
596
+ const runner = read(dir, "run.mjs");
597
+ if (runner == null) return { pass: false, detail: "run.mjs missing" };
598
+ if (runner !== RUN_MJS) {
599
+ return {
600
+ pass: false,
601
+ detail: "run.mjs was edited (adapt app.mjs/package.json instead)",
602
+ };
603
+ }
583
604
  const pkg = read(dir, "package.json");
584
605
  if (pkg == null || !/"stringutil"\s*:\s*"[\^~]?2\./.test(pkg)) {
585
606
  return {
@@ -84,7 +84,13 @@ function ordered(records) {
84
84
  * }}
85
85
  */
86
86
  export function computeTrend(runs, { regressionThreshold = 0 } = {}) {
87
- const norm = ordered((runs || []).map((r) => summarizeRun(r)));
87
+ // A dry-run record is always 0% (the no-op agent creates nothing) folding
88
+ // it in would flag every previously-passing task as a regression (or, in the
89
+ // other direction, fake an "all fixed" jump). Smoke-testing with
90
+ // `--dry-run --history` must not poison the release gate.
91
+ const norm = ordered(
92
+ (runs || []).filter((r) => r?.dryRun !== true).map((r) => summarizeRun(r)),
93
+ );
88
94
  const history = norm.map((r) => r.passRate);
89
95
  if (norm.length === 0) {
90
96
  return {
@@ -225,14 +225,20 @@ describe("uri <-> path", () => {
225
225
  describe("normalizeUri", () => {
226
226
  it("lowercases the windows drive letter so client/server keys match", () => {
227
227
  // path.resolve yields C:, servers echo c: — must collapse to one key
228
- expect(normalizeUri("file:///C%3A/proj/a.ts")).toBe(
229
- "file:///c%3A/proj/a.ts",
230
- );
231
- expect(normalizeUri("file:///c%3A/proj/a.ts")).toBe(
232
- "file:///c%3A/proj/a.ts",
233
- );
228
+ expect(normalizeUri("file:///C%3A/proj/a.ts")).toBe("file:///c:/proj/a.ts");
229
+ expect(normalizeUri("file:///c%3A/proj/a.ts")).toBe("file:///c:/proj/a.ts");
234
230
  expect(normalizeUri("file:///C:/proj/a.ts")).toBe("file:///c:/proj/a.ts");
235
231
  });
232
+ it("collapses the %3A and bare-colon drive forms to ONE key (gopls)", () => {
233
+ // The client sends percent-encoded didOpen URIs (file:///C%3A/…);
234
+ // tsserver/pyright echo that form back, but gopls REWRITES to a bare
235
+ // colon (file:///C:/…). Before this canonicalization the two forms were
236
+ // distinct diagnostics-Map keys, so every gopls publish missed the
237
+ // didOpen key and `cc code-intel diag` on Go was permanently empty.
238
+ const ours = normalizeUri("file:///C%3A/Users/u/goproj/broken.go");
239
+ const goplsPublished = normalizeUri("file:///C:/Users/u/goproj/broken.go");
240
+ expect(ours).toBe(goplsPublished);
241
+ });
236
242
  it("leaves POSIX file URIs unchanged", () => {
237
243
  expect(normalizeUri("file:///home/u/a.ts")).toBe("file:///home/u/a.ts");
238
244
  });
@@ -85,11 +85,15 @@ export class CodeIntelligence {
85
85
  async references(filePath, line, col, { includeDeclaration = true } = {}) {
86
86
  const ready = await this._ensure(filePath);
87
87
  if (ready.unavailable) return unavailable(ready.reason);
88
- const result = await ready.client.request("textDocument/references", {
89
- textDocument: { uri: ready.uri },
90
- position: toLspPosition({ line, col }),
91
- context: { includeDeclaration },
92
- });
88
+ const result = await this._readRequest(
89
+ ready.client,
90
+ "textDocument/references",
91
+ {
92
+ textDocument: { uri: ready.uri },
93
+ position: toLspPosition({ line, col }),
94
+ context: { includeDeclaration },
95
+ },
96
+ );
93
97
  return { available: true, locations: this._normalizeLocations(result) };
94
98
  }
95
99
 
@@ -97,7 +101,7 @@ export class CodeIntelligence {
97
101
  async hover(filePath, line, col) {
98
102
  const ready = await this._ensure(filePath);
99
103
  if (ready.unavailable) return unavailable(ready.reason);
100
- const result = await ready.client.request("textDocument/hover", {
104
+ const result = await this._readRequest(ready.client, "textDocument/hover", {
101
105
  textDocument: { uri: ready.uri },
102
106
  position: toLspPosition({ line, col }),
103
107
  });
@@ -108,9 +112,13 @@ export class CodeIntelligence {
108
112
  async documentSymbols(filePath) {
109
113
  const ready = await this._ensure(filePath);
110
114
  if (ready.unavailable) return unavailable(ready.reason);
111
- const result = await ready.client.request("textDocument/documentSymbol", {
112
- textDocument: { uri: ready.uri },
113
- });
115
+ const result = await this._readRequest(
116
+ ready.client,
117
+ "textDocument/documentSymbol",
118
+ {
119
+ textDocument: { uri: ready.uri },
120
+ },
121
+ );
114
122
  return {
115
123
  available: true,
116
124
  symbols: this._normalizeSymbols(result, ready.uri),
@@ -124,7 +132,9 @@ export class CodeIntelligence {
124
132
  if (!seed) return unavailable("no indexable file found to start a server");
125
133
  const ready = await this.manager.ensureFor(seed, this._ensureOpts);
126
134
  if (ready.unavailable) return unavailable(ready.reason);
127
- const result = await ready.client.request("workspace/symbol", { query });
135
+ const result = await this._readRequest(ready.client, "workspace/symbol", {
136
+ query,
137
+ });
128
138
  return { available: true, symbols: this._normalizeSymbols(result) };
129
139
  }
130
140
 
@@ -160,7 +170,8 @@ export class CodeIntelligence {
160
170
  const caps = ready.client.serverCapabilities || {};
161
171
  if (caps.diagnosticProvider) {
162
172
  try {
163
- const report = await ready.client.request(
173
+ const report = await this._readRequest(
174
+ ready.client,
164
175
  "textDocument/diagnostic",
165
176
  { textDocument: { uri: ready.uri } },
166
177
  { timeoutMs },
@@ -182,11 +193,15 @@ export class CodeIntelligence {
182
193
  async renamePreview(filePath, line, col, newName) {
183
194
  const ready = await this._ensure(filePath);
184
195
  if (ready.unavailable) return unavailable(ready.reason);
185
- const result = await ready.client.request("textDocument/rename", {
186
- textDocument: { uri: ready.uri },
187
- position: toLspPosition({ line, col }),
188
- newName,
189
- });
196
+ const result = await this._readRequest(
197
+ ready.client,
198
+ "textDocument/rename",
199
+ {
200
+ textDocument: { uri: ready.uri },
201
+ position: toLspPosition({ line, col }),
202
+ newName,
203
+ },
204
+ );
190
205
  return { available: true, edits: this._normalizeWorkspaceEdit(result) };
191
206
  }
192
207
 
@@ -196,10 +211,33 @@ export class CodeIntelligence {
196
211
 
197
212
  // ---- internals ----
198
213
 
214
+ /**
215
+ * Issue an idempotent READ request with a bounded ContentModified retry.
216
+ * LSP -32801 (ContentModified) means "server state changed mid-request —
217
+ * the result would be stale, ask again"; rust-analyzer emits it freely
218
+ * while its index is still settling after didOpen. Read-only queries can
219
+ * always be retried; mutating requests must NOT go through here.
220
+ */
221
+ async _readRequest(client, method, params, opts) {
222
+ let lastErr;
223
+ for (let attempt = 0; attempt < 4; attempt++) {
224
+ try {
225
+ return await client.request(method, params, opts);
226
+ } catch (err) {
227
+ const contentModified =
228
+ err?.code === -32801 || /content modified/i.test(err?.message || "");
229
+ if (!contentModified) throw err;
230
+ lastErr = err;
231
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
232
+ }
233
+ }
234
+ throw lastErr;
235
+ }
236
+
199
237
  async _locationQuery(method, filePath, line, col) {
200
238
  const ready = await this._ensure(filePath);
201
239
  if (ready.unavailable) return unavailable(ready.reason);
202
- const result = await ready.client.request(method, {
240
+ const result = await this._readRequest(ready.client, method, {
203
241
  textDocument: { uri: ready.uri },
204
242
  position: toLspPosition({ line, col }),
205
243
  });
@@ -297,9 +297,15 @@ export function pathToFileUri(p) {
297
297
  */
298
298
  export function normalizeUri(uri) {
299
299
  if (!uri) return uri;
300
+ // Canonicalize BOTH the drive-letter case AND the colon encoding. Servers
301
+ // differ in the form they publish back: tsserver/pyright echo the client's
302
+ // percent-encoded `c%3A/…`, but gopls rewrites to a bare `C:/…` — keeping
303
+ // the incoming colon form made the two forms distinct Map keys, so every
304
+ // gopls publishDiagnostics missed the didOpen key and Go diagnostics were
305
+ // permanently empty regardless of timeout.
300
306
  return String(uri).replace(
301
307
  /^(file:\/\/\/)([A-Za-z])(%3[Aa]|:)/,
302
- (_m, p, drive, colon) => p + drive.toLowerCase() + colon,
308
+ (_m, p, drive) => p + drive.toLowerCase() + ":",
303
309
  );
304
310
  }
305
311
 
@@ -29,7 +29,7 @@ import {
29
29
  discoverPlugins,
30
30
  } from "./scopes.js";
31
31
  import { verifyPluginManifest } from "../plugin-security.js";
32
- import { writePluginLock } from "./signature.js";
32
+ import { writePluginLock, LOCK_FILENAME } from "./signature.js";
33
33
 
34
34
  export const _deps = {
35
35
  existsSync: fs.existsSync,
@@ -97,6 +97,12 @@ export function installFromDirectory(srcDir, opts = {}) {
97
97
  _deps.mkdirSync(dest, { recursive: true });
98
98
  copyDirGuarded(src, dest, dest);
99
99
 
100
+ // A lock may ONLY exist if THIS installer wrote it. The source is untrusted
101
+ // and may ship its own `.plugin-lock.json` (copied verbatim above) — on an
102
+ // unsigned install that forged lock would otherwise survive into the
103
+ // "immutable" version dir and defeat load-time requireSignedPlugins.
104
+ _deps.rmSync(path.join(dest, LOCK_FILENAME), { force: true });
105
+
100
106
  // Record the verified signature into the installed (immutable) version dir so
101
107
  // load-time enforcement can re-check it. The manifest was copied verbatim, so
102
108
  // its bytes/sha in `dest` match what we verified in `src`.
@@ -110,6 +116,8 @@ export function installFromDirectory(srcDir, opts = {}) {
110
116
  sha256: verification.sha256,
111
117
  publicKeySha256: verification.publicKeySha256,
112
118
  signatureVerified: verification.signatureVerified === true,
119
+ signatureBase64: verification.signatureBase64,
120
+ publicKeyPem: verification.publicKeyPem,
113
121
  });
114
122
  }
115
123
 
@@ -245,6 +253,17 @@ export function parseGitSource(raw) {
245
253
  * command line — no injection). Caller removes the temp dir's parent.
246
254
  */
247
255
  export function fetchGitRepo(url, ref) {
256
+ // git argv-injection guard: a value starting with "-" is parsed by git as an
257
+ // OPTION, not a URL/ref — e.g. a registry-supplied ref "-f" reaches
258
+ // `git checkout <ref>` on the full-clone retry path, and an option-looking
259
+ // url reaches `git clone`. Real git URLs/refs never start with "-"
260
+ // (check-ref-format forbids it), so reject instead of trying to escape.
261
+ if (String(url).startsWith("-")) {
262
+ throw new Error(`refusing git source that looks like an option: ${url}`);
263
+ }
264
+ if (ref != null && String(ref).startsWith("-")) {
265
+ throw new Error(`refusing git ref that looks like an option: ${ref}`);
266
+ }
248
267
  const base = _deps.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-git-"));
249
268
  const dir = path.join(base, "repo");
250
269
  const run = (args) =>
@@ -115,6 +115,19 @@ export function filterByManagedPolicy(plugins, managed) {
115
115
  continue;
116
116
  }
117
117
  if (requireSigned) {
118
+ // Fail closed when no signing keys are pinned: a lock's signature can be
119
+ // SELF-signed by whoever authored the plugin (the lock lives in a
120
+ // writable dir), so "signed by anyone" is not a guarantee. Without
121
+ // trustedPluginKeySha256 the gate would accept any self-signed drop-in.
122
+ if (!trustedKeys || trustedKeys.size === 0) {
123
+ dropped.push({
124
+ name: p.name,
125
+ reason:
126
+ "requireSignedPlugins: no trustedPluginKeySha256 fingerprints " +
127
+ "configured (fail-closed — pin the org's signing keys)",
128
+ });
129
+ continue;
130
+ }
118
131
  const v = verifyInstalledSignature(p, { trustedKeys });
119
132
  if (!v.signed) {
120
133
  dropped.push({
@@ -46,6 +46,39 @@ export function isRemoteSource(raw) {
46
46
  return /^https?:\/\//i.test(s) && /\.json(\?.*)?(#.*)?$/i.test(s);
47
47
  }
48
48
 
49
+ /**
50
+ * Enforce HTTPS for registry URLs. The registry supplies BOTH the git source
51
+ * and its claimed sha256 — over plain HTTP a network MITM controls the two
52
+ * together, so the integrity check verifies nothing. Loopback is exempt (a
53
+ * local dev registry isn't MITM-able off-host); anything else needs the
54
+ * explicit opt-in (opts.allowInsecure / CC_PLUGIN_REGISTRY_ALLOW_HTTP=1).
55
+ */
56
+ export function assertRegistryUrlSafe(url, { allowInsecure = false } = {}) {
57
+ let u;
58
+ try {
59
+ u = new URL(String(url));
60
+ } catch {
61
+ throw new Error(`invalid registry URL: ${url}`);
62
+ }
63
+ if (u.protocol === "https:") return;
64
+ if (u.protocol !== "http:") {
65
+ throw new Error(`registry URL must be http(s): ${url}`);
66
+ }
67
+ // WHATWG URL keeps the brackets on IPv6 hostnames — strip before comparing.
68
+ const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
69
+ const loopback =
70
+ host === "localhost" || host === "127.0.0.1" || host === "::1";
71
+ const optIn =
72
+ allowInsecure === true || process.env.CC_PLUGIN_REGISTRY_ALLOW_HTTP === "1";
73
+ if (loopback || optIn) return;
74
+ throw new Error(
75
+ `plain-HTTP registry rejected: ${url} — a network MITM controls both the ` +
76
+ `source and its sha256, so the integrity check verifies nothing. Use ` +
77
+ `https, or opt in with --allow-insecure-registry / ` +
78
+ `CC_PLUGIN_REGISTRY_ALLOW_HTTP=1 on a trusted network.`,
79
+ );
80
+ }
81
+
49
82
  /** Where a fetched registry is cached (content-addressed by its URL). */
50
83
  export function registryCachePath(url, cacheDir) {
51
84
  const dir =
@@ -93,6 +126,7 @@ export async function fetchRegistry(url, opts = {}) {
93
126
  timeoutMs = DEFAULT_TIMEOUT_MS,
94
127
  allowCache = true,
95
128
  } = opts;
129
+ assertRegistryUrlSafe(url, { allowInsecure: opts.allowInsecure });
96
130
  const cachePath = registryCachePath(url, cacheDir);
97
131
  const headers = { Accept: "application/json" };
98
132
  if (token) headers.Authorization = `Bearer ${token}`;
@@ -7,20 +7,24 @@
7
7
  * install records the verification result in a `.plugin-lock.json` inside the
8
8
  * immutable version dir; discovery then re-checks it fail-closed.
9
9
  *
10
- * The lock records the manifest's sha256 + the signing key's fingerprint. At
11
- * load we RE-HASH the on-disk manifest and compare so a manifest tampered with
12
- * after install (even inside the version dir) fails the check. The signature
13
- * itself covers only the manifest file, not every component file that is a
10
+ * The lock persists the DETACHED SIGNATURE + PUBLIC KEY themselves (lockVersion
11
+ * 2), and load-time verification re-runs the actual Ed25519 verify over the
12
+ * on-disk manifest bytes. The lock file lives in a writable plugin dir, so
13
+ * nothing in it can be TRUSTED a recorded boolean or fingerprint could simply
14
+ * be forged by whoever authored the plugin. What a forger CANNOT do is produce a
15
+ * signature that verifies under a key the org has pinned via
16
+ * trustedPluginKeySha256 — which is why the key fingerprint used for the trust
17
+ * check is COMPUTED from the embedded public key, never read from the lock.
18
+ * The signature covers only the manifest file, not every component file — a
14
19
  * known limitation (documented), an integrity anchor rather than a full
15
- * supply-chain seal; the immutable version dir + this re-hash catch manifest
16
- * tampering, which is where the component wiring is declared.
20
+ * supply-chain seal; the manifest is where the component wiring is declared.
17
21
  */
18
22
 
19
- import { createHash } from "node:crypto";
23
+ import { createHash, createPublicKey, verify } from "node:crypto";
20
24
  import fs from "fs";
21
25
  import path from "path";
22
26
 
23
- const LOCK = ".plugin-lock.json";
27
+ export const LOCK_FILENAME = ".plugin-lock.json";
24
28
 
25
29
  export const _deps = {
26
30
  readFileSync: fs.readFileSync,
@@ -31,18 +35,29 @@ export const _deps = {
31
35
  /** Record a verified signature into the version dir. */
32
36
  export function writePluginLock(
33
37
  versionDir,
34
- { manifestFile, sha256, publicKeySha256, signatureVerified },
38
+ {
39
+ manifestFile,
40
+ sha256,
41
+ publicKeySha256,
42
+ signatureVerified,
43
+ signatureBase64,
44
+ publicKeyPem,
45
+ },
35
46
  ) {
36
47
  const rel = path.relative(versionDir, manifestFile).replace(/\\/g, "/");
37
48
  const lock = {
38
- lockVersion: 1,
49
+ lockVersion: 2,
39
50
  manifest: rel || "plugin.json",
40
51
  sha256,
41
52
  publicKeySha256: publicKeySha256 || null,
42
53
  signatureVerified: signatureVerified === true,
54
+ // The material load-time enforcement actually re-verifies. Without these a
55
+ // lock is just self-asserted JSON and counts as unsigned.
56
+ signatureBase64: signatureBase64 || null,
57
+ publicKeyPem: publicKeyPem || null,
43
58
  };
44
59
  _deps.writeFileSync(
45
- path.join(versionDir, LOCK),
60
+ path.join(versionDir, LOCK_FILENAME),
46
61
  JSON.stringify(lock, null, 2),
47
62
  "utf8",
48
63
  );
@@ -51,7 +66,7 @@ export function writePluginLock(
51
66
 
52
67
  /** Read a version dir's signature lock, or null when absent/unreadable. */
53
68
  export function readPluginLock(versionDir) {
54
- const p = path.join(versionDir, LOCK);
69
+ const p = path.join(versionDir, LOCK_FILENAME);
55
70
  if (!_deps.existsSync(p)) return null;
56
71
  try {
57
72
  return JSON.parse(_deps.readFileSync(p, "utf8"));
@@ -62,9 +77,11 @@ export function readPluginLock(versionDir) {
62
77
 
63
78
  /**
64
79
  * Verify an installed plugin's recorded signature against its on-disk manifest.
65
- * `signed:true` ONLY when a lock exists, was signature-verified, the manifest
66
- * still hashes to the locked sha256 (tamper detection), and — when trustedKeys
67
- * is a non-empty Set — the signing key fingerprint is among them.
80
+ * `signed:true` ONLY when a lock exists, carries real signature material, the
81
+ * embedded Ed25519 signature VERIFIES over the on-disk manifest bytes, and —
82
+ * when trustedKeys is a non-empty Set — the signing key's COMPUTED fingerprint
83
+ * is among them. Every field of the lock is treated as attacker-writable; the
84
+ * only trust anchors are the cryptographic verify and the caller's trustedKeys.
68
85
  *
69
86
  * @param {{root:string}} plugin
70
87
  * @param {object} [opts] { trustedKeys?: Set<string> }
@@ -96,28 +113,68 @@ export function verifyInstalledSignature(plugin, opts = {}) {
96
113
  if (!_deps.existsSync(manifestFile)) {
97
114
  return { signed: false, reason: "locked manifest file is missing" };
98
115
  }
99
- let sha;
116
+ let bytes;
100
117
  try {
101
- sha = createHash("sha256")
102
- .update(_deps.readFileSync(manifestFile))
103
- .digest("hex");
118
+ bytes = _deps.readFileSync(manifestFile);
104
119
  } catch {
105
120
  return { signed: false, reason: "manifest unreadable" };
106
121
  }
122
+ const sha = createHash("sha256").update(bytes).digest("hex");
107
123
  if (sha.toLowerCase() !== String(lock.sha256 || "").toLowerCase()) {
108
124
  return {
109
125
  signed: false,
110
126
  reason: "manifest tampered since install (sha256 mismatch)",
111
127
  };
112
128
  }
129
+ // A lock without real signature material is unverifiable, and therefore
130
+ // worthless as a signing gate: `signatureVerified:true` is just a JSON field
131
+ // anyone can write. Legacy (lockVersion 1) locks land here too — reinstalling
132
+ // the plugin with --signature re-records a verifiable lock.
133
+ if (!lock.signatureBase64 || !lock.publicKeyPem) {
134
+ return {
135
+ signed: false,
136
+ reason:
137
+ "lock lacks signature material (legacy or hand-written lock — " +
138
+ "reinstall the plugin with --signature to re-record it)",
139
+ };
140
+ }
141
+ let keyObject;
142
+ let publicKeySha256;
143
+ try {
144
+ keyObject = createPublicKey(lock.publicKeyPem);
145
+ // Fingerprint is COMPUTED from the embedded key — the lock's own
146
+ // publicKeySha256 field is attacker-writable and never used for trust.
147
+ publicKeySha256 = createHash("sha256")
148
+ .update(keyObject.export({ type: "spki", format: "der" }))
149
+ .digest("hex");
150
+ } catch {
151
+ return { signed: false, reason: "lock public key is not a valid key" };
152
+ }
153
+ let sigOk = false;
154
+ try {
155
+ sigOk = verify(
156
+ null,
157
+ bytes,
158
+ keyObject,
159
+ Buffer.from(String(lock.signatureBase64), "base64"),
160
+ );
161
+ } catch {
162
+ sigOk = false;
163
+ }
164
+ if (!sigOk) {
165
+ return {
166
+ signed: false,
167
+ reason: "recorded signature does not verify over the on-disk manifest",
168
+ };
169
+ }
113
170
  const trustedKeys = opts.trustedKeys;
114
171
  if (trustedKeys && trustedKeys.size > 0) {
115
- if (!lock.publicKeySha256 || !trustedKeys.has(lock.publicKeySha256)) {
172
+ if (!trustedKeys.has(publicKeySha256)) {
116
173
  return {
117
174
  signed: false,
118
- reason: `signing key not trusted (${lock.publicKeySha256 || "unsigned"})`,
175
+ reason: `signing key not trusted (${publicKeySha256})`,
119
176
  };
120
177
  }
121
178
  }
122
- return { signed: true, publicKeySha256: lock.publicKeySha256 };
179
+ return { signed: true, publicKeySha256 };
123
180
  }