chainlesschain 0.162.181 → 0.162.183

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 (195) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-windows-sandbox-helper.ps1 +163 -0
  3. package/src/assets/web-panel/assets/{AIOps-cq0dwbnf.js → AIOps-BapS7lz4.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-CvcgGl3w.js → ActionButton-CXYdXEUI.js} +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-hM2-jFqg.js → Analytics-CKR5Lexn.js} +3 -3
  6. package/src/assets/web-panel/assets/{AppLayout-B9_Gsd_g.js → AppLayout-DATVUOXB.js} +5 -5
  7. package/src/assets/web-panel/assets/{Artifacts-B7bu52Og.js → Artifacts-vb_0ZlV1.js} +1 -1
  8. package/src/assets/web-panel/assets/{Audit-DThfDgqB.js → Audit-BI1l9GWt.js} +1 -1
  9. package/src/assets/web-panel/assets/{BackgroundAgents-DjlFiD4v.js → BackgroundAgents-BFXbaUEf.js} +1 -1
  10. package/src/assets/web-panel/assets/{Backup-CK9xJKnG.js → Backup-DEYBJJ6H.js} +1 -1
  11. package/src/assets/web-panel/assets/{BaseInput-DEiSU8K8.js → BaseInput-COdzeO0M.js} +1 -1
  12. package/src/assets/web-panel/assets/{Chat-CpT96czj.js → Chat-oSN6sfM5.js} +6 -6
  13. package/src/assets/web-panel/assets/ChatBubbleRenderer-CI_jT0ru.js +1 -0
  14. package/src/assets/web-panel/assets/{Checkbox-C1GfDnO0.js → Checkbox-BezEa4tm.js} +1 -1
  15. package/src/assets/web-panel/assets/{Codegen-3DAKgiLx.js → Codegen-CNEL0Ife.js} +1 -1
  16. package/src/assets/web-panel/assets/{Col-74cOwZU1.js → Col-Bf2Y2Bn1.js} +1 -1
  17. package/src/assets/web-panel/assets/{Community-tn9Kkz9j.js → Community-BfUlu4Hc.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compact-BcoXH7m4.js → Compact-BN4xWP9e.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compliance-DIPGUBgi.js → Compliance-Bt6BKmEP.js} +1 -1
  20. package/src/assets/web-panel/assets/{Cowork-doD5_91c.js → Cowork-DKLp8YxO.js} +2 -2
  21. package/src/assets/web-panel/assets/{Cron-C-IDvubz.js → Cron-o7Hhw6ky.js} +2 -2
  22. package/src/assets/web-panel/assets/{Crosschain-CdcCoBdj.js → Crosschain-DaNXWJIw.js} +1 -1
  23. package/src/assets/web-panel/assets/{DID-DdH5aMMA.js → DID-B-Qa2mha.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dashboard-DHLA4mm0.js → Dashboard-CMX0Qnxw.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dropdown-dEeCt7Md.js → Dropdown-BLuLyRu_.js} +1 -1
  26. package/src/assets/web-panel/assets/{EmailListRenderer-BMaecFv9.js → EmailListRenderer-CFBAeMij.js} +1 -1
  27. package/src/assets/web-panel/assets/{FamilyGuardDashboard-DUaUukTN.js → FamilyGuardDashboard-DWzXQ67V.js} +1 -1
  28. package/src/assets/web-panel/assets/{Federation-CMBE0_rp.js → Federation-DB1QSLgr.js} +1 -1
  29. package/src/assets/web-panel/assets/{FormItemContext-PG-JDbXq.js → FormItemContext-C-OKCToP.js} +1 -1
  30. package/src/assets/web-panel/assets/{GenericCardRenderer-Bcb0p88m.js → GenericCardRenderer-DMJFtFZf.js} +1 -1
  31. package/src/assets/web-panel/assets/{Git-Bgv15myB.js → Git-DsGSujOV.js} +2 -2
  32. package/src/assets/web-panel/assets/{Governance-DB8YTArI.js → Governance-B0iS9NEW.js} +1 -1
  33. package/src/assets/web-panel/assets/{Inference-BQMwCWaS.js → Inference-CH004H7c.js} +1 -1
  34. package/src/assets/web-panel/assets/{KnowledgeGraph-B1_ZziiB.js → KnowledgeGraph-Q9MTQ83c.js} +1 -1
  35. package/src/assets/web-panel/assets/{Logs-DovEEnt_.js → Logs-B_fyjZK9.js} +2 -2
  36. package/src/assets/web-panel/assets/{MarkdownRenderer-D201OL4T.js → MarkdownRenderer-C0o4FtpD.js} +1 -1
  37. package/src/assets/web-panel/assets/{Marketplace-BlCXSpyB.js → Marketplace-BIStF9d1.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-CsCR09Wj.js → McpTools-pGfal768.js} +5 -5
  39. package/src/assets/web-panel/assets/{Memory-CBVRLKno.js → Memory-xbNqpIqB.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-C9OIafgA.js → MobileBridge-B5EQGrIK.js} +1 -1
  41. package/src/assets/web-panel/assets/MobileProjects-VTc3gT1y.js +1 -0
  42. package/src/assets/web-panel/assets/{Mtc-vE-HdvLj.js → Mtc-a_rmfxJH.js} +4 -4
  43. package/src/assets/web-panel/assets/{MtcAudit-CPLaekCd.js → MtcAudit-BmKuofUr.js} +4 -4
  44. package/src/assets/web-panel/assets/{Multisig-C_UP9C2o.js → Multisig-BQbJ-CAN.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-2nODJcU1.js → NLProgramming-JtPJKX25.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-BhE-SJGu.js → Notes-ZFKznTSG.js} +3 -3
  47. package/src/assets/web-panel/assets/{NotificationSettings-D21q7BNU.js → NotificationSettings-B6aHRBBR.js} +1 -1
  48. package/src/assets/web-panel/assets/{OrderTableRenderer-0b62GYOD.js → OrderTableRenderer-DdcsOqEz.js} +1 -1
  49. package/src/assets/web-panel/assets/{Organization-CEqmw_sb.js → Organization-BFDTAUgS.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-MW-gMDO_.js → Overflow-0FoTfbWH.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-CY0hjI_G.js → P2P-CWd90etw.js} +2 -2
  52. package/src/assets/web-panel/assets/{PdhVaultBrowser-BbWh7Man.js → PdhVaultBrowser-DtCGC-W-.js} +3 -3
  53. package/src/assets/web-panel/assets/{Permissions-ChqEA_4Q.js → Permissions-CvQ7nr7T.js} +4 -4
  54. package/src/assets/web-panel/assets/{PersonalDataHub-B3YrbYfD.js → PersonalDataHub-rdNPcR-Z.js} +5 -5
  55. package/src/assets/web-panel/assets/{Pipeline-B8WBhhlW.js → Pipeline-CmrfiNOs.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-Cmb0PXNx.js → Privacy-D7ep1h_t.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-hF6zW3Nz.js → ProjectInit-C0xKINHS.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-BFSVfpW0.js → ProjectSettings-AOmZFp-F.js} +2 -2
  59. package/src/assets/web-panel/assets/Projects-BWHSkXZP.js +1 -0
  60. package/src/assets/web-panel/assets/{Providers-B8yux8Dx.js → Providers-Cb7JCTPL.js} +1 -1
  61. package/src/assets/web-panel/assets/{QrScannerModal-N76cQluO.js → QrScannerModal-BDMehcXC.js} +1 -1
  62. package/src/assets/web-panel/assets/{QuickAsk-ClvZ0KYR.js → QuickAsk-BF6_1nvx.js} +1 -1
  63. package/src/assets/web-panel/assets/{Recommend-B-GfgBpT.js → Recommend-BHfT-A28.js} +1 -1
  64. package/src/assets/web-panel/assets/RemoteSession-B6EEf8hh.js +5 -0
  65. package/src/assets/web-panel/assets/{Reputation-DJIZzQeG.js → Reputation-CsWHLEcj.js} +1 -1
  66. package/src/assets/web-panel/assets/{Row-COzxu_bb.js → Row-D6srnWhm.js} +1 -1
  67. package/src/assets/web-panel/assets/{RssFeed-CoYsJQuE.js → RssFeed-Xhr-KyVd.js} +3 -3
  68. package/src/assets/web-panel/assets/{Search-DC77Mq9S.js → Search-vIbIJvwL.js} +1 -1
  69. package/src/assets/web-panel/assets/{Security-Loy2EdMO.js → Security-Bzi5Aiko.js} +4 -4
  70. package/src/assets/web-panel/assets/{Services-GUD07Eu0.js → Services-BHtVX52P.js} +2 -2
  71. package/src/assets/web-panel/assets/{Skeleton-DWzkptYU.js → Skeleton-C0R8B2jf.js} +1 -1
  72. package/src/assets/web-panel/assets/{Skills-CEDn1r-Z.js → Skills-DtT6rWOX.js} +1 -1
  73. package/src/assets/web-panel/assets/{Sla-ub_k4pXh.js → Sla-0B37mxVU.js} +1 -1
  74. package/src/assets/web-panel/assets/{SpeechSettings-D7HLX9CA.js → SpeechSettings-0V2jE1Bs.js} +1 -1
  75. package/src/assets/web-panel/assets/{SyncSettings-DbwOvl6m.js → SyncSettings-CYedRzaj.js} +2 -2
  76. package/src/assets/web-panel/assets/Tasks-D-R1x8vm.js +1 -0
  77. package/src/assets/web-panel/assets/{Templates-BNVeIhud.js → Templates-BKoHztGT.js} +1 -1
  78. package/src/assets/web-panel/assets/{Tenant-PS9AzhXU.js → Tenant--17nwVNZ.js} +1 -1
  79. package/src/assets/web-panel/assets/{Terminal-Cgb4eLB_.js → Terminal-Dnkos7vW.js} +2 -2
  80. package/src/assets/web-panel/assets/{TimelineRenderer-CrmXMOkg.js → TimelineRenderer-C5jx4AE3.js} +1 -1
  81. package/src/assets/web-panel/assets/{Tokens-DD-VDHw1.js → Tokens-DYJKYa9t.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trigger-BsoN_n-n.js → Trigger-DChpWiQ9.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trust-n8LmN4ZV.js → Trust-DfWG52EA.js} +1 -1
  84. package/src/assets/web-panel/assets/{UkeySign-C9_7MImA.js → UkeySign-CMugYcyA.js} +1 -1
  85. package/src/assets/web-panel/assets/{VideoEditing-DyBteNgn.js → VideoEditing-BOSmrHAd.js} +1 -1
  86. package/src/assets/web-panel/assets/{Wallet-BS5ixeXI.js → Wallet-D5g3k3Sa.js} +4 -4
  87. package/src/assets/web-panel/assets/{WebAuthn-spc4UImU.js → WebAuthn-D-7u4Mkc.js} +3 -3
  88. package/src/assets/web-panel/assets/{WorkflowEditor-D7iVvaBO.js → WorkflowEditor-CFzZMSUJ.js} +1 -1
  89. package/src/assets/web-panel/assets/{chat-6CmBkHrL.js → chat-VbR6l6mc.js} +1 -1
  90. package/src/assets/web-panel/assets/{colors-BGoCLkxI.js → colors-BSZn8EDS.js} +1 -1
  91. package/src/assets/web-panel/assets/{compact-item-fYZBetMO.js → compact-item-y74obMEq.js} +1 -1
  92. package/src/assets/web-panel/assets/{createContext-BeM-1UVj.js → createContext-C326bjex.js} +1 -1
  93. package/src/assets/web-panel/assets/devWarning-D3Bho_6u.js +1 -0
  94. package/src/assets/web-panel/assets/{hasIn-CE7ZuEnV.js → hasIn-D_INJQT_.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-ukg99D1V.js → index-4PIeTM--.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-B2U-ObMq.js → index-4eyH-eC2.js} +1 -1
  97. package/src/assets/web-panel/assets/index-5iAFwYx1.js +1 -0
  98. package/src/assets/web-panel/assets/{index-CRLjEP1H.js → index-9PIVuXqo.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-BfPL-m46.js → index-B7SrC-On.js} +1 -1
  100. package/src/assets/web-panel/assets/{index--lap6cf7.js → index-BJi3UqBt.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-B3CDgvNW.js → index-BY8-nCgs.js} +1 -1
  102. package/src/assets/web-panel/assets/index-BfnB7LPx.js +1 -0
  103. package/src/assets/web-panel/assets/{index-De0quFDw.js → index-Bfw2hf2s.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-BunaQCa0.js → index-BjcWKg1S.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-BsMfoaeo.js → index-BnSfnSMP.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-C0v6YKKO.js → index-Bnua8IX3.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-C4SJVjs4.js → index-C-ZCaUJc.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BNZDILcO.js → index-C2NyllnW.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-YjcumOZ4.js → index-C327pLbw.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-CRnUE8Cb.js → index-CMHqs3Jb.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-CIpVrf5P.js → index-CfLJ42kv.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-BEVm0lCS.js → index-Cr3uuz0C.js} +3 -3
  113. package/src/assets/web-panel/assets/{index-Dw1PyYPm.js → index-CsovTpx9.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-lJBjB1QX.js → index-CzE53HiB.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-CpIGIB85.js → index-DBkzrpiL.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-DoXk5KJ8.js → index-DNMxIsDg.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-DsvfrESp.js → index-DUU2606o.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-Cip8emOQ.js → index-DdyjrCAu.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-ATcSr_2O.js → index-DkH1JvEt.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-BV551878.js → index-Dlxh9D8D.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-BU_2qiDk.js → index-DuIkfnTF.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-zb7rVOhV.js → index-DwHSZLXg.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-MtOuq2i6.js → index-E_v1ZCTP.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-B8BsdNV_.js → index-FQBsuExX.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-WUrJ6S1j.js → index-GA80ir_u.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-RMZ8a7Yt.js → index-IZAWt0H8.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-D1r9tT3J.js → index-Iw0FfqUz.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BowH1eLQ.js → index-KF9Zmqln.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-Puq6Luf1.js → index-abmn_8Yz.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-QIpnpZgz.js → index-gINyI1rh.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-C9Y3k0HI.js → index-jYaogFE8.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-B4wiTEOM.js → index-nSn2-C4N.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-DT3zPSVX.js → index-qMA4GYdQ.js} +1 -1
  134. package/src/assets/web-panel/assets/{initDefaultProps-CYHMs1ag.js → initDefaultProps-DI0Zaf8-.js} +1 -1
  135. package/src/assets/web-panel/assets/{motion-DDB2Ffk5.js → motion-BZDKSy3Z.js} +1 -1
  136. package/src/assets/web-panel/assets/{move-Bg3cD-dX.js → move-DsIW01Vp.js} +1 -1
  137. package/src/assets/web-panel/assets/{mtc-parser-B5nedjTS.js → mtc-parser-CohkDhsf.js} +1 -1
  138. package/src/assets/web-panel/assets/{omit-BHyuUILu.js → omit-DpiN5rzg.js} +1 -1
  139. package/src/assets/web-panel/assets/{pickAttrs-C9YQnEN9.js → pickAttrs-qbuqni3y.js} +1 -1
  140. package/src/assets/web-panel/assets/{placementArrow-DBUFYUe7.js → placementArrow-mBky6714.js} +1 -1
  141. package/src/assets/web-panel/assets/{responsiveObserve-nBs2Obnk.js → responsiveObserve-Bas3eQ2K.js} +1 -1
  142. package/src/assets/web-panel/assets/{slide-DkCEaXNU.js → slide-CBx_SKoK.js} +1 -1
  143. package/src/assets/web-panel/assets/{statusUtils-aqE1Wi6U.js → statusUtils-BlYguJSV.js} +1 -1
  144. package/src/assets/web-panel/assets/{styleChecker-CN5bhfHB.js → styleChecker-BCYQmRGv.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFlexGapSupport-B_oX_kvJ.js → useFlexGapSupport-Dp5dybLM.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFs-rCuXHeme.js → useFs-DHAMBgA2.js} +1 -1
  147. package/src/assets/web-panel/assets/{usePersonalDataHub-DZR9Abyd.js → usePersonalDataHub-W6jsSw76.js} +1 -1
  148. package/src/assets/web-panel/assets/{vnode-ConCYQlX.js → vnode-BkI6aayc.js} +1 -1
  149. package/src/assets/web-panel/assets/{zoom-CUdDxG5M.js → zoom-C_IMGueq.js} +1 -1
  150. package/src/assets/web-panel/index.html +1 -1
  151. package/src/commands/agenda.js +115 -3
  152. package/src/commands/agent.js +15 -0
  153. package/src/commands/background-session.js +49 -0
  154. package/src/commands/plugin.js +235 -23
  155. package/src/commands/session.js +13 -0
  156. package/src/data/changelog.json +26 -0
  157. package/src/gateways/terminal/PtyManager.js +45 -4
  158. package/src/gateways/ws/ws-server.js +10 -1
  159. package/src/harness/background-task-manager.js +70 -2
  160. package/src/harness/jsonl-session-store.js +6 -4
  161. package/src/harness/worktree-isolator.js +14 -3
  162. package/src/lib/agent-core.js +1 -0
  163. package/src/lib/agent-schedule-store.js +45 -22
  164. package/src/lib/agent-team/team-runner.js +19 -1
  165. package/src/lib/background-agent-supervisor.js +49 -0
  166. package/src/lib/hook-runner.js +6 -2
  167. package/src/lib/packer/pkg-config-generator.js +3 -1
  168. package/src/lib/plugin-runtime/bin.js +1148 -70
  169. package/src/lib/plugin-runtime/install.js +226 -7
  170. package/src/lib/plugin-runtime/manifest.js +68 -12
  171. package/src/lib/plugin-runtime/scopes.js +33 -12
  172. package/src/lib/plugin-usage-attribution.js +44 -0
  173. package/src/lib/process-execution-broker/index.js +626 -48
  174. package/src/lib/process-execution-broker/platform-sandbox.js +4971 -266
  175. package/src/lib/process-execution-broker/windows-sandbox-helper.dll +0 -0
  176. package/src/lib/process-execution-broker/windows-sandbox-helper.exe +0 -0
  177. package/src/lib/process-execution-broker/windows-sandbox.cs +3856 -0
  178. package/src/lib/repl-bang-memorize.js +67 -10
  179. package/src/lib/session-usage.js +77 -0
  180. package/src/lib/slash-commands.js +27 -4
  181. package/src/repl/agent-repl.js +10 -9
  182. package/src/runtime/agent-core.js +376 -52
  183. package/src/runtime/agent-runtime.js +13 -1
  184. package/src/runtime/headless-runner.js +13 -4
  185. package/src/runtime/headless-stream.js +20 -1
  186. package/src/runtime/mcp-config.js +15 -5
  187. package/src/assets/web-panel/assets/ChatBubbleRenderer-BKPPNmZI.js +0 -1
  188. package/src/assets/web-panel/assets/MobileProjects-D_zoJ8Xs.js +0 -1
  189. package/src/assets/web-panel/assets/Projects-DAWkVFkc.js +0 -1
  190. package/src/assets/web-panel/assets/RemoteSession-mlglCdQi.js +0 -5
  191. package/src/assets/web-panel/assets/Tasks-CBQzoAu5.js +0 -1
  192. package/src/assets/web-panel/assets/devWarning-BzPwHnw8.js +0 -1
  193. package/src/assets/web-panel/assets/index-DacrAdc-.js +0 -1
  194. package/src/assets/web-panel/assets/index-EHzNJozo.js +0 -1
  195. package/src/lib/process-execution-broker/windows-sandbox.ps1 +0 -999
@@ -1,32 +1,36 @@
1
1
  /**
2
- * Plugin `bin` component put a trusted plugin's executables on PATH for the
3
- * session (Phase 3.3n). A plugin ships helper executables under `bin/`; making
4
- * them resolvable means the agent's `run_shell` (and monitors/hooks, which
5
- * inherit the process env) can invoke `some-plugin-tool` by name.
2
+ * Trusted plugin `bin` discovery and direct invocation.
6
3
  *
7
- * Wiring: prepend the trusted plugins' bin dirs to `process.env.PATH` at session
8
- * start and RESTORE the original PATH at session end session-scoped, so no
9
- * plugin executable stays resolvable after the session. Every child process
10
- * inherits process.env, so this one mutation covers run_shell (foreground +
11
- * background), the monitor supervisor, and hook commands uniformly.
12
- *
13
- * SECURITY: an executable on PATH runs code, so like hooks/LSP/MCP/monitors
14
- * only TRUSTED plugins contribute (user/local auto-trusted; project needs
15
- * `cc plugin trust`). An untrusted cloned-repo plugin can NOT shadow a system
16
- * command or get its binary invoked.
4
+ * Plugin binaries used to be exposed by prepending their directories to the
5
+ * process-wide PATH. That made the eventual target impossible to prove at the
6
+ * Broker boundary: a shell could resolve a different file, a compound command
7
+ * could append an unsandboxed process, and every unrelated descendant inherited
8
+ * the plugin PATH. Policy-bearing bins therefore never enter PATH. `run_shell`
9
+ * resolves their declared alias here, converts one command into literal argv,
10
+ * attests the exact on-disk target, and passes the absolute launch target to
11
+ * ProcessExecutionBroker with `shell:false`. Legacy bins without a policy keep
12
+ * their historical PATH compatibility.
17
13
  */
18
14
 
19
15
  import path from "path";
20
16
  import fs from "fs";
17
+ import crypto from "node:crypto";
21
18
  import { discoverPlugins } from "./scopes.js";
22
19
  import { partitionByTrust, warnUntrustedOnce } from "./trust.js";
23
20
  import { componentCapabilityDenial } from "./capabilities.js";
21
+ import { isWithin } from "./manifest.js";
24
22
 
25
23
  // One-time stderr notice when a plugin's bin dir is refused at the COMPONENT
26
24
  // level because the plugin opted into the capability model but did not declare
27
25
  // the `process` capability its executables need. Distinct from the trust gate:
28
26
  // these plugins ARE trusted, but their bin component is denied.
29
27
  const _capabilityDenied = new Set();
28
+ const _sandboxPolicyPins = new Map();
29
+ const _issuedPluginBinInvocations = new WeakMap();
30
+ const _issuedPluginSandboxContracts = new WeakMap();
31
+ const PLUGIN_BIN_MAX_BYTES = 64 * 1024 * 1024;
32
+ const PLUGIN_NODE_RUNTIME_MAX_BYTES = 256 * 1024 * 1024;
33
+ const ATTESTATION_HASH_CHUNK_BYTES = 1024 * 1024;
30
34
  function warnBinCapabilityDeniedOnce(entries) {
31
35
  if (!entries || entries.length === 0) return;
32
36
  if (_capabilityDenied.has("bin-capability")) return;
@@ -48,16 +52,33 @@ export function _resetBinWarnings() {
48
52
  _capabilityDenied.clear();
49
53
  }
50
54
 
55
+ export function _resetPluginBinSandboxPolicyPins() {
56
+ _sandboxPolicyPins.clear();
57
+ }
58
+
51
59
  /**
52
- * Collect trusted, installed plugins' `bin/` directories.
60
+ * Collect trusted, installed plugins' declared bin commands. Unlike a PATH
61
+ * directory, each record preserves the manifest alias, exact target, sandbox
62
+ * requirement, and immutable plugin-version provenance.
63
+ *
53
64
  * @param {object} [opts] { cwd, scopes }
54
- * @returns {Array<{plugin, scope, version, dir}>}
65
+ * @returns {Array<object>}
55
66
  */
56
- export function collectPluginBinDirs(opts = {}) {
67
+ export function collectPluginBinCommands(opts = {}) {
57
68
  let plugins = [];
58
69
  try {
59
- plugins = discoverPlugins({ cwd: opts.cwd, scopes: opts.scopes });
60
- } catch {
70
+ plugins = discoverPlugins({
71
+ cwd: opts.cwd,
72
+ scopes: opts.scopes,
73
+ strictIo: opts.failClosed === true,
74
+ });
75
+ } catch (error) {
76
+ if (opts.failClosed === true) {
77
+ throw pluginBinError(
78
+ "ERR_PLUGIN_BIN_DISCOVERY_FAILED",
79
+ `plugin bin policy discovery failed: ${error.message}`,
80
+ );
81
+ }
61
82
  return [];
62
83
  }
63
84
  const { trusted, skipped } = partitionByTrust(plugins);
@@ -73,23 +94,33 @@ export function collectPluginBinDirs(opts = {}) {
73
94
  if (!p.manifest || p.manifest.ok !== true) continue;
74
95
  const bins = p.manifest.components?.bin;
75
96
  if (!Array.isArray(bins) || bins.length === 0) continue;
76
- // Component-level capability gate: a plugin that declared a permissions
77
- // block but did not declare the `process` capability its executables need
78
- // gets its bin dir refused here (mirrors the MCP collector's denial).
79
97
  const denial = componentCapabilityDenial(p.manifest, ["process"]);
80
98
  if (denial) {
81
99
  denied.push({ name: p.name, reason: denial.reason });
82
100
  continue;
83
101
  }
84
- // Every bin entry's absPath is a file inside the plugin's bin dir; collect
85
- // the unique parent directories (usually just `<root>/bin`).
86
- const dirs = new Set(bins.map((b) => path.dirname(b.absPath)));
87
- for (const dir of dirs) {
102
+ for (const bin of bins) {
103
+ if (
104
+ !bin ||
105
+ typeof bin.name !== "string" ||
106
+ typeof bin.absPath !== "string"
107
+ ) {
108
+ continue;
109
+ }
88
110
  out.push({
89
111
  plugin: p.name,
90
112
  scope: p.scope,
91
113
  version: p.version,
92
- dir,
114
+ root: p.root,
115
+ manifestPath: p.manifest.manifestPath,
116
+ name: bin.name,
117
+ path: bin.path,
118
+ absPath: bin.absPath,
119
+ ...(bin.sandboxPolicy
120
+ ? { sandboxPolicy: bin.sandboxPolicy }
121
+ : p.manifest.sandboxPolicy
122
+ ? { sandboxPolicy: p.manifest.sandboxPolicy }
123
+ : {}),
93
124
  });
94
125
  }
95
126
  }
@@ -98,18 +129,122 @@ export function collectPluginBinDirs(opts = {}) {
98
129
  }
99
130
 
100
131
  /**
101
- * Prepend trusted plugins' bin dirs to `env.PATH` (defaults to process.env).
102
- * Returns a `restore()` that puts PATH back, plus the dirs added. Idempotent per
103
- * call a dir already on PATH is not added twice.
132
+ * Return a tighten-only union of every trusted policy-bearing bin visible from
133
+ * this workspace. The union is intentionally broader than alias attribution:
134
+ * shell indirection (`node <script>`, compound commands, PATH assignment,
135
+ * expansion, wrappers) cannot reliably identify the eventual executable from
136
+ * source text, so every agent shell invocation inherits the union.
137
+ *
138
+ * Pinning by workspace prevents a manifest mutation or trust change from
139
+ * weakening an already observed requirement during the process lifetime.
140
+ */
141
+ export function collectPluginBinSandboxPolicy(opts = {}) {
142
+ const pinKey = path.resolve(opts.cwd || process.cwd());
143
+ const required = new Set(_sandboxPolicyPins.get(pinKey) || []);
144
+ const commands = collectPluginBinCommands({
145
+ ...opts,
146
+ failClosed: true,
147
+ });
148
+ for (const command of commands) {
149
+ for (const boundary of command.sandboxPolicy?.requiredBoundaries || []) {
150
+ required.add(boundary);
151
+ }
152
+ }
153
+ return pinPluginBinSandboxPolicy(
154
+ { requiredBoundaries: [...required] },
155
+ { cwd: pinKey },
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Collect the tighten-only union for a fixed workspace and the requested
161
+ * execution directory, then pin the result to the fixed workspace key.
162
+ * Callers must not let an execution-directory override replace the workspace
163
+ * root: it may only discover additional boundaries.
164
+ */
165
+ export function collectWorkspacePluginBinSandboxPolicy(opts = {}) {
166
+ const workspaceCwd = path.resolve(
167
+ opts.workspaceCwd || opts.cwd || process.cwd(),
168
+ );
169
+ const executionCwd = path.resolve(opts.executionCwd || workspaceCwd);
170
+ const required = new Set();
171
+ for (const policyCwd of new Set([workspaceCwd, executionCwd])) {
172
+ const observed = collectPluginBinSandboxPolicy({
173
+ cwd: policyCwd,
174
+ ...(opts.scopes !== undefined ? { scopes: opts.scopes } : {}),
175
+ });
176
+ for (const boundary of observed?.requiredBoundaries || []) {
177
+ required.add(boundary);
178
+ }
179
+ }
180
+ return pinPluginBinSandboxPolicy(
181
+ { requiredBoundaries: [...required] },
182
+ { cwd: workspaceCwd },
183
+ );
184
+ }
185
+
186
+ export function pinPluginBinSandboxPolicy(policy, opts = {}) {
187
+ const pinKey = path.resolve(opts.cwd || process.cwd());
188
+ const required = new Set(_sandboxPolicyPins.get(pinKey) || []);
189
+ for (const boundary of policy?.requiredBoundaries || []) {
190
+ required.add(boundary);
191
+ }
192
+ if (required.size === 0) return null;
193
+ const pinnedPolicy = Object.freeze({
194
+ requiredBoundaries: Object.freeze([...required].sort()),
195
+ });
196
+ _sandboxPolicyPins.set(pinKey, pinnedPolicy.requiredBoundaries);
197
+ return pinnedPolicy;
198
+ }
199
+
200
+ /**
201
+ * Compatibility view used by plugin status surfaces. Directories are never
202
+ * filtered here; `applyPluginBinPath` decides which legacy-only directories
203
+ * remain safe to expose.
204
+ */
205
+ export function collectPluginBinDirs(opts = {}) {
206
+ const out = [];
207
+ const seen = new Set();
208
+ for (const bin of collectPluginBinCommands(opts)) {
209
+ const dir = path.dirname(bin.absPath);
210
+ const key = `${bin.plugin}\0${dir}`;
211
+ if (seen.has(key)) continue;
212
+ seen.add(key);
213
+ out.push({
214
+ plugin: bin.plugin,
215
+ scope: bin.scope,
216
+ version: bin.version,
217
+ dir,
218
+ });
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Preserve the legacy PATH contract only for plugins that did not request a
225
+ * sandbox boundary. A directory containing even one policy-bearing bin is
226
+ * excluded as a unit: PATH exposes directories, so adding it for a legacy
227
+ * sibling would also make the strict sibling shell-resolvable.
104
228
  *
105
229
  * @param {object} [opts] { cwd, scopes, env }
106
230
  * @returns {{ added: string[], restore: () => void }}
107
231
  */
108
232
  export function applyPluginBinPath(opts = {}) {
109
233
  const env = opts.env || process.env;
110
- const dirs = collectPluginBinDirs({ cwd: opts.cwd, scopes: opts.scopes }).map(
111
- (b) => b.dir,
112
- );
234
+ const byDir = new Map();
235
+ for (const bin of collectPluginBinCommands({
236
+ cwd: opts.cwd,
237
+ scopes: opts.scopes,
238
+ })) {
239
+ const dir = path.dirname(bin.absPath);
240
+ const state = byDir.get(dir) || { legacy: false, strict: false };
241
+ if (bin.sandboxPolicy) state.strict = true;
242
+ else state.legacy = true;
243
+ byDir.set(dir, state);
244
+ }
245
+ const dirs = [...byDir]
246
+ .filter(([, state]) => state.legacy && !state.strict)
247
+ .map(([dir]) => dir);
113
248
  const prevPath = env.PATH;
114
249
  if (dirs.length === 0) {
115
250
  return { added: [], restore: () => {} };
@@ -119,7 +254,7 @@ export function applyPluginBinPath(opts = {}) {
119
254
  .split(path.delimiter)
120
255
  .filter(Boolean),
121
256
  );
122
- const added = dirs.filter((d) => !existing.has(d));
257
+ const added = dirs.filter((dir) => !existing.has(dir));
123
258
  if (added.length === 0) {
124
259
  return { added: [], restore: () => {} };
125
260
  }
@@ -130,53 +265,996 @@ export function applyPluginBinPath(opts = {}) {
130
265
  restore: () => {
131
266
  if (restored) return;
132
267
  restored = true;
133
- // Restore only if nothing else re-touched PATH out from under us; either
134
- // way, put the recorded previous value back (session-scoped semantics).
135
268
  env.PATH = prevPath;
136
269
  },
137
270
  };
138
271
  }
139
272
 
273
+ function pluginBinError(code, message) {
274
+ const error = new Error(message);
275
+ error.code = code;
276
+ error.pluginBinFailClosed = true;
277
+ return error;
278
+ }
279
+
280
+ function hashOpenFile(fd, bytes) {
281
+ const digest = crypto.createHash("sha256");
282
+ const chunk = Buffer.allocUnsafe(
283
+ Math.max(1, Math.min(ATTESTATION_HASH_CHUNK_BYTES, bytes)),
284
+ );
285
+ let offset = 0;
286
+ while (offset < bytes) {
287
+ const read = fs.readSync(
288
+ fd,
289
+ chunk,
290
+ 0,
291
+ Math.min(chunk.length, bytes - offset),
292
+ offset,
293
+ );
294
+ if (read <= 0) {
295
+ throw new Error("file ended before its attested size");
296
+ }
297
+ digest.update(chunk.subarray(0, read));
298
+ offset += read;
299
+ }
300
+ return digest.digest("hex");
301
+ }
302
+
303
+ function preciseOpenFileIdentity(stat) {
304
+ const timestamp = (nanosecondsKey, millisecondsKey) =>
305
+ stat[nanosecondsKey] !== undefined
306
+ ? String(stat[nanosecondsKey])
307
+ : String(Math.trunc(Number(stat[millisecondsKey] || 0) * 1_000_000));
308
+ return Object.freeze({
309
+ dev: String(stat.dev),
310
+ ino: String(stat.ino),
311
+ size: String(stat.size),
312
+ mode: String(stat.mode),
313
+ nlink: String(stat.nlink),
314
+ birthtimeNs: timestamp("birthtimeNs", "birthtimeMs"),
315
+ ctimeNs: timestamp("ctimeNs", "ctimeMs"),
316
+ mtimeNs: timestamp("mtimeNs", "mtimeMs"),
317
+ });
318
+ }
319
+
320
+ function samePreciseOpenFileIdentity(left, right) {
321
+ return (
322
+ left.dev === right.dev &&
323
+ left.ino === right.ino &&
324
+ left.size === right.size &&
325
+ left.mode === right.mode &&
326
+ left.nlink === right.nlink &&
327
+ left.birthtimeNs === right.birthtimeNs &&
328
+ left.ctimeNs === right.ctimeNs &&
329
+ left.mtimeNs === right.mtimeNs
330
+ );
331
+ }
332
+
333
+ function samePreciseFileObject(left, right) {
334
+ return (
335
+ left.dev === right.dev && left.ino === right.ino && left.size === right.size
336
+ );
337
+ }
338
+
339
+ function openFileForAttestation(file) {
340
+ return fs.openSync(
341
+ file,
342
+ Number(fs.constants.O_RDONLY) |
343
+ Number(fs.constants.O_NOFOLLOW || 0) |
344
+ Number(fs.constants.O_NONBLOCK || 0),
345
+ );
346
+ }
347
+
348
+ function assertRegularNonLinkPath(file, label) {
349
+ const stat = fs.lstatSync(file);
350
+ if (stat.isSymbolicLink() || !stat.isFile()) {
351
+ throw new Error(`${label} path is not a regular, non-symlink file`);
352
+ }
353
+ }
354
+
140
355
  /**
141
- * Resolve the first executable token of a shell command to a trusted plugin
142
- * bin. This is intentionally conservative: only an existing file directly
143
- * under a collected plugin bin directory is attributed.
356
+ * Windows/libuv does not guarantee that file IDs reported by stat(path) and
357
+ * fstat(fd) are comparable on every volume. Bind the canonical path to the
358
+ * already-open primary handle through a second handle instead: fstat-to-fstat
359
+ * retains object identity, while the digest also proves byte identity.
360
+ */
361
+ function attestWindowsPathHandle(
362
+ file,
363
+ expectedIdentity,
364
+ expectedSha256,
365
+ maxBytes,
366
+ { label, requireSingleLink = false },
367
+ ) {
368
+ let pathFd;
369
+ try {
370
+ assertRegularNonLinkPath(file, label);
371
+ pathFd = openFileForAttestation(file);
372
+ const before = fs.fstatSync(pathFd, { bigint: true });
373
+ if (!before.isFile()) {
374
+ throw new Error(`${label} path handle is not a regular file`);
375
+ }
376
+ if (before.size < 0n || before.size > BigInt(maxBytes)) {
377
+ throw new Error(
378
+ `${label} path handle exceeds the attestation size limit`,
379
+ );
380
+ }
381
+ const beforeIdentity = preciseOpenFileIdentity(before);
382
+ if (!samePreciseFileObject(expectedIdentity, beforeIdentity)) {
383
+ throw new Error(`${label} path handle identity changed`);
384
+ }
385
+ if (requireSingleLink && before.nlink !== 1n) {
386
+ throw new Error(`${label} path handle must not be hard-linked`);
387
+ }
388
+
389
+ const sha256 = hashOpenFile(pathFd, Number(before.size));
390
+ const after = fs.fstatSync(pathFd, { bigint: true });
391
+ const afterIdentity = preciseOpenFileIdentity(after);
392
+ if (
393
+ !samePreciseOpenFileIdentity(beforeIdentity, afterIdentity) ||
394
+ !samePreciseFileObject(expectedIdentity, afterIdentity)
395
+ ) {
396
+ throw new Error(`${label} path handle changed during attestation`);
397
+ }
398
+ if (sha256 !== expectedSha256) {
399
+ throw new Error(`${label} path handle content changed`);
400
+ }
401
+ if (requireSingleLink && after.nlink !== 1n) {
402
+ throw new Error(`${label} path handle must not be hard-linked`);
403
+ }
404
+ assertRegularNonLinkPath(file, label);
405
+ } finally {
406
+ if (pathFd !== undefined) {
407
+ try {
408
+ fs.closeSync(pathFd);
409
+ } catch {
410
+ /* best-effort */
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ function sameName(left, right) {
417
+ return process.platform === "win32"
418
+ ? String(left).toLowerCase() === String(right).toLowerCase()
419
+ : String(left) === String(right);
420
+ }
421
+
422
+ function firstCommandToken(command) {
423
+ const value = String(command || "");
424
+ let token = "";
425
+ let quote = null;
426
+ let escaping = false;
427
+ let started = false;
428
+
429
+ for (let i = 0; i < value.length; i++) {
430
+ const ch = value[i];
431
+ if (!started && /\s/.test(ch)) continue;
432
+ if (escaping) {
433
+ token += ch;
434
+ escaping = false;
435
+ started = true;
436
+ continue;
437
+ }
438
+ if (quote === '"') {
439
+ if (ch === "\\") {
440
+ const next = value[i + 1];
441
+ if (next === '"' || next === "\\") {
442
+ escaping = true;
443
+ } else {
444
+ token += ch;
445
+ }
446
+ } else if (ch === '"') {
447
+ quote = null;
448
+ } else {
449
+ token += ch;
450
+ }
451
+ started = true;
452
+ continue;
453
+ }
454
+ if (quote === "'") {
455
+ if (ch === "'") quote = null;
456
+ else token += ch;
457
+ started = true;
458
+ continue;
459
+ }
460
+ if (ch === '"' || ch === "'") {
461
+ quote = ch;
462
+ started = true;
463
+ continue;
464
+ }
465
+ if (/\s/.test(ch) || /[|&;<>()`]/.test(ch)) break;
466
+ token += ch;
467
+ started = true;
468
+ }
469
+ return token;
470
+ }
471
+
472
+ /**
473
+ * Parse exactly one command into literal argv. Every unquoted shell control
474
+ * operator is rejected. Once parsed, no shell sees these values.
475
+ */
476
+ export function parsePluginBinCommand(command) {
477
+ if (typeof command !== "string" || command.trim() === "") {
478
+ throw pluginBinError(
479
+ "ERR_PLUGIN_BIN_COMMAND_INVALID",
480
+ "plugin bin command must be a non-empty string",
481
+ );
482
+ }
483
+ if (command.includes("\0")) {
484
+ throw pluginBinError(
485
+ "ERR_PLUGIN_BIN_COMMAND_INVALID",
486
+ "plugin bin command contains a NUL byte",
487
+ );
488
+ }
489
+ const argv = [];
490
+ let current = "";
491
+ let quote = null;
492
+ let escaping = false;
493
+ let tokenStarted = false;
494
+
495
+ const flush = () => {
496
+ if (!tokenStarted) return;
497
+ argv.push(current);
498
+ current = "";
499
+ tokenStarted = false;
500
+ };
501
+
502
+ for (let i = 0; i < command.length; i++) {
503
+ const ch = command[i];
504
+ if (escaping) {
505
+ current += ch;
506
+ tokenStarted = true;
507
+ escaping = false;
508
+ continue;
509
+ }
510
+ if (quote === '"') {
511
+ if (ch === "\\") {
512
+ const next = command[i + 1];
513
+ if (next === '"' || next === "\\") {
514
+ escaping = true;
515
+ } else {
516
+ current += ch;
517
+ tokenStarted = true;
518
+ }
519
+ } else if (ch === '"') {
520
+ quote = null;
521
+ tokenStarted = true;
522
+ } else {
523
+ current += ch;
524
+ tokenStarted = true;
525
+ }
526
+ continue;
527
+ }
528
+ if (quote === "'") {
529
+ if (ch === "'") {
530
+ quote = null;
531
+ tokenStarted = true;
532
+ } else {
533
+ current += ch;
534
+ tokenStarted = true;
535
+ }
536
+ continue;
537
+ }
538
+ if (ch === '"' || ch === "'") {
539
+ quote = ch;
540
+ tokenStarted = true;
541
+ continue;
542
+ }
543
+ if (ch === "\r" || ch === "\n" || /[|&;<>()`]/.test(ch)) {
544
+ throw pluginBinError(
545
+ "ERR_PLUGIN_BIN_COMPOUND_COMMAND",
546
+ "plugin bin commands must be a single direct invocation; shell operators are not allowed",
547
+ );
548
+ }
549
+ if (ch === "$" && command[i + 1] === "(") {
550
+ throw pluginBinError(
551
+ "ERR_PLUGIN_BIN_COMPOUND_COMMAND",
552
+ "plugin bin commands may not contain command substitution",
553
+ );
554
+ }
555
+ if (ch === " " || ch === "\t") {
556
+ flush();
557
+ continue;
558
+ }
559
+ current += ch;
560
+ tokenStarted = true;
561
+ }
562
+ if (quote || escaping) {
563
+ throw pluginBinError(
564
+ "ERR_PLUGIN_BIN_COMMAND_INVALID",
565
+ "plugin bin command contains an unmatched quote or escape",
566
+ );
567
+ }
568
+ flush();
569
+ if (argv.length === 0 || argv[0] === "") {
570
+ throw pluginBinError(
571
+ "ERR_PLUGIN_BIN_COMMAND_INVALID",
572
+ "plugin bin command has no executable",
573
+ );
574
+ }
575
+ return argv;
576
+ }
577
+
578
+ function fileIdentity(file, root) {
579
+ let fd;
580
+ try {
581
+ const rootReal = fs.realpathSync.native(path.resolve(root));
582
+ const lst = fs.lstatSync(file);
583
+ if (lst.isSymbolicLink() || !lst.isFile()) {
584
+ throw new Error("target must be a regular, non-symlink file");
585
+ }
586
+ const targetReal = fs.realpathSync.native(file);
587
+ if (!isWithin(rootReal, targetReal)) {
588
+ throw new Error("target resolves outside the plugin root");
589
+ }
590
+ fd = openFileForAttestation(targetReal);
591
+ const before = fs.fstatSync(fd, { bigint: true });
592
+ if (!before.isFile())
593
+ throw new Error("opened target is not a regular file");
594
+ if (before.size < 0n || before.size > BigInt(PLUGIN_BIN_MAX_BYTES)) {
595
+ throw new Error("target exceeds the attestation size limit");
596
+ }
597
+ const pathBefore = fs.statSync(targetReal, { bigint: true });
598
+ const beforeIdentity = preciseOpenFileIdentity(before);
599
+ const pathBeforeIdentity = preciseOpenFileIdentity(pathBefore);
600
+ if (
601
+ process.platform !== "win32" &&
602
+ !samePreciseFileObject(beforeIdentity, pathBeforeIdentity)
603
+ ) {
604
+ throw new Error("target identity changed during attestation");
605
+ }
606
+ const sha256 = hashOpenFile(fd, Number(before.size));
607
+ const after = fs.fstatSync(fd, { bigint: true });
608
+ const pathAfter = fs.statSync(targetReal, { bigint: true });
609
+ const afterIdentity = preciseOpenFileIdentity(after);
610
+ const pathAfterIdentity = preciseOpenFileIdentity(pathAfter);
611
+ const stable =
612
+ samePreciseOpenFileIdentity(beforeIdentity, afterIdentity) &&
613
+ samePreciseOpenFileIdentity(pathBeforeIdentity, pathAfterIdentity) &&
614
+ (process.platform === "win32" ||
615
+ samePreciseFileObject(afterIdentity, pathAfterIdentity));
616
+ if (!stable) throw new Error("target identity changed during attestation");
617
+ if (process.platform === "win32") {
618
+ attestWindowsPathHandle(
619
+ targetReal,
620
+ afterIdentity,
621
+ sha256,
622
+ PLUGIN_BIN_MAX_BYTES,
623
+ { label: "target", requireSingleLink: true },
624
+ );
625
+ }
626
+ if (after.nlink !== 1n) {
627
+ throw new Error("target must not be hard-linked");
628
+ }
629
+ const metadata = fs.fstatSync(fd);
630
+ const finalStat = fs.fstatSync(fd, { bigint: true });
631
+ const finalIdentity = preciseOpenFileIdentity(finalStat);
632
+ const finalPathIdentity = preciseOpenFileIdentity(
633
+ fs.statSync(targetReal, { bigint: true }),
634
+ );
635
+ if (
636
+ !samePreciseOpenFileIdentity(afterIdentity, finalIdentity) ||
637
+ !samePreciseOpenFileIdentity(pathAfterIdentity, finalPathIdentity) ||
638
+ (process.platform !== "win32" &&
639
+ !samePreciseFileObject(finalIdentity, finalPathIdentity))
640
+ ) {
641
+ throw new Error("target identity changed during attestation");
642
+ }
643
+ if (process.platform === "win32") {
644
+ attestWindowsPathHandle(
645
+ targetReal,
646
+ finalIdentity,
647
+ sha256,
648
+ PLUGIN_BIN_MAX_BYTES,
649
+ { label: "target", requireSingleLink: true },
650
+ );
651
+ }
652
+ if (finalStat.nlink !== 1n) {
653
+ throw new Error("target must not be hard-linked");
654
+ }
655
+ return Object.freeze({
656
+ realPath: targetReal,
657
+ sha256,
658
+ bytes: Number(after.size),
659
+ dev: String(after.dev),
660
+ ino: String(after.ino),
661
+ mtimeMs: metadata.mtimeMs,
662
+ mode: Number(after.mode),
663
+ });
664
+ } catch (err) {
665
+ throw pluginBinError(
666
+ "ERR_PLUGIN_BIN_IDENTITY_UNATTESTED",
667
+ `plugin bin target identity could not be attested: ${err.message}`,
668
+ );
669
+ } finally {
670
+ if (fd !== undefined) {
671
+ try {
672
+ fs.closeSync(fd);
673
+ } catch {
674
+ /* best-effort */
675
+ }
676
+ }
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Attest the exact Node runtime used by a direct policy-bearing plugin bin.
144
682
  *
145
- * @param {string} command
146
- * @param {object} [opts] { cwd, scopes }
147
- * @returns {{pluginId:string,pluginVersion:string,pluginSource:string,scope:string,binPath:string}|null}
683
+ * This identity is intentionally produced outside the plugin manifest. The
684
+ * manifest may request boundaries, but it cannot nominate a broader runtime
685
+ * or filesystem root for a strong-sandbox execution contract.
148
686
  */
149
- export function resolvePluginBinCommand(command, opts = {}) {
687
+ export function attestPluginNodeRuntime(command) {
688
+ let fd;
689
+ try {
690
+ if (
691
+ typeof command !== "string" ||
692
+ !path.isAbsolute(command) ||
693
+ command !== process.execPath
694
+ ) {
695
+ throw new Error("runtime must be the current absolute process.execPath");
696
+ }
697
+ const requestedPath = path.resolve(command);
698
+ const runtimeReal = fs.realpathSync.native(requestedPath);
699
+ const lst = fs.lstatSync(runtimeReal);
700
+ if (lst.isSymbolicLink() || !lst.isFile()) {
701
+ throw new Error("runtime must resolve to a regular file");
702
+ }
703
+ if (process.platform !== "win32" && (lst.mode & 0o111) === 0) {
704
+ throw new Error("runtime is not executable");
705
+ }
706
+ fd = openFileForAttestation(runtimeReal);
707
+ const before = fs.fstatSync(fd, { bigint: true });
708
+ if (
709
+ !before.isFile() ||
710
+ before.size < 0n ||
711
+ before.size > BigInt(PLUGIN_NODE_RUNTIME_MAX_BYTES)
712
+ ) {
713
+ throw new Error("runtime exceeds the attestation size limit");
714
+ }
715
+ const pathBefore = fs.statSync(runtimeReal, { bigint: true });
716
+ const beforeIdentity = preciseOpenFileIdentity(before);
717
+ const pathBeforeIdentity = preciseOpenFileIdentity(pathBefore);
718
+ if (
719
+ process.platform !== "win32" &&
720
+ !samePreciseFileObject(beforeIdentity, pathBeforeIdentity)
721
+ ) {
722
+ throw new Error("runtime identity changed during attestation");
723
+ }
724
+ const sha256 = hashOpenFile(fd, Number(before.size));
725
+ const after = fs.fstatSync(fd, { bigint: true });
726
+ const pathAfter = fs.statSync(runtimeReal, { bigint: true });
727
+ const afterIdentity = preciseOpenFileIdentity(after);
728
+ const pathAfterIdentity = preciseOpenFileIdentity(pathAfter);
729
+ const stable =
730
+ samePreciseOpenFileIdentity(beforeIdentity, afterIdentity) &&
731
+ samePreciseOpenFileIdentity(pathBeforeIdentity, pathAfterIdentity) &&
732
+ (process.platform === "win32" ||
733
+ samePreciseFileObject(afterIdentity, pathAfterIdentity));
734
+ if (!stable) throw new Error("runtime identity changed during attestation");
735
+ if (process.platform === "win32") {
736
+ attestWindowsPathHandle(
737
+ runtimeReal,
738
+ afterIdentity,
739
+ sha256,
740
+ PLUGIN_NODE_RUNTIME_MAX_BYTES,
741
+ { label: "runtime" },
742
+ );
743
+ }
744
+ const metadata = fs.fstatSync(fd);
745
+ const finalIdentity = preciseOpenFileIdentity(
746
+ fs.fstatSync(fd, { bigint: true }),
747
+ );
748
+ const finalPathIdentity = preciseOpenFileIdentity(
749
+ fs.statSync(runtimeReal, { bigint: true }),
750
+ );
751
+ if (
752
+ !samePreciseOpenFileIdentity(afterIdentity, finalIdentity) ||
753
+ !samePreciseOpenFileIdentity(pathAfterIdentity, finalPathIdentity) ||
754
+ (process.platform !== "win32" &&
755
+ !samePreciseFileObject(finalIdentity, finalPathIdentity))
756
+ ) {
757
+ throw new Error("runtime identity changed during attestation");
758
+ }
759
+ if (process.platform === "win32") {
760
+ attestWindowsPathHandle(
761
+ runtimeReal,
762
+ finalIdentity,
763
+ sha256,
764
+ PLUGIN_NODE_RUNTIME_MAX_BYTES,
765
+ { label: "runtime" },
766
+ );
767
+ }
768
+ return Object.freeze({
769
+ requestedPath,
770
+ realPath: runtimeReal,
771
+ sha256,
772
+ bytes: Number(after.size),
773
+ dev: String(after.dev),
774
+ ino: String(after.ino),
775
+ mtimeMs: metadata.mtimeMs,
776
+ mode: Number(after.mode),
777
+ });
778
+ } catch (err) {
779
+ throw pluginBinError(
780
+ "ERR_PLUGIN_NODE_RUNTIME_UNATTESTED",
781
+ `plugin Node runtime identity could not be attested: ${err.message}`,
782
+ );
783
+ } finally {
784
+ if (fd !== undefined) {
785
+ try {
786
+ fs.closeSync(fd);
787
+ } catch {
788
+ /* best-effort */
789
+ }
790
+ }
791
+ }
792
+ }
793
+
794
+ function attestPluginRootIdentity(pluginRoot) {
795
+ const before = fs.lstatSync(pluginRoot);
796
+ const after = fs.statSync(pluginRoot);
797
+ if (
798
+ before.isSymbolicLink() ||
799
+ !before.isDirectory() ||
800
+ !after.isDirectory() ||
801
+ before.dev !== after.dev ||
802
+ before.ino !== after.ino
803
+ ) {
804
+ throw new Error("plugin root identity changed during attestation");
805
+ }
806
+ return Object.freeze({
807
+ realPath: pluginRoot,
808
+ dev: String(after.dev),
809
+ ino: String(after.ino),
810
+ });
811
+ }
812
+
813
+ function launchForTarget(identity, userArgs) {
814
+ const ext = path.extname(identity.realPath).toLowerCase();
815
+ if ([".js", ".cjs", ".mjs"].includes(ext)) {
816
+ return {
817
+ command: process.execPath,
818
+ args: [identity.realPath, ...userArgs],
819
+ runtime: "node",
820
+ };
821
+ }
822
+ if (process.platform === "win32" && [".cmd", ".bat", ".ps1"].includes(ext)) {
823
+ throw pluginBinError(
824
+ "ERR_PLUGIN_BIN_WRAPPER_UNSUPPORTED",
825
+ `plugin bin wrapper ${ext} is not supported by the direct native route; ship a Node script or native executable`,
826
+ );
827
+ }
828
+ if (process.platform !== "win32" && (identity.mode & 0o111) === 0) {
829
+ throw pluginBinError(
830
+ "ERR_PLUGIN_BIN_NOT_EXECUTABLE",
831
+ "plugin bin target is not executable",
832
+ );
833
+ }
834
+ return {
835
+ command: identity.realPath,
836
+ args: userArgs,
837
+ runtime: "native",
838
+ };
839
+ }
840
+
841
+ function matchesEntryToken(token, entry, opts) {
842
+ if (sameName(token, entry.name)) return true;
843
+ if (process.platform === "win32" && !/[\\/]/.test(token)) {
844
+ const executableExtensions = new Set([".exe", ".com", ".cmd", ".bat"]);
845
+ const tokenExt = path.extname(token).toLowerCase();
846
+ const aliasExt = path.extname(entry.name).toLowerCase();
847
+ if (
848
+ executableExtensions.has(tokenExt) &&
849
+ !aliasExt &&
850
+ sameName(token.slice(0, -tokenExt.length), entry.name)
851
+ ) {
852
+ return true;
853
+ }
854
+ if (
855
+ !tokenExt &&
856
+ executableExtensions.has(aliasExt) &&
857
+ sameName(token, entry.name.slice(0, -aliasExt.length))
858
+ ) {
859
+ return true;
860
+ }
861
+ }
862
+ if (!path.isAbsolute(token) && !/[\\/]/.test(token)) return false;
863
+ try {
864
+ const resolved = path.resolve(
865
+ opts.commandCwd || opts.cwd || process.cwd(),
866
+ token,
867
+ );
868
+ return sameName(resolved, path.resolve(entry.absPath));
869
+ } catch {
870
+ return false;
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Resolve a trusted declared alias to a direct Broker invocation. Ordinary
876
+ * commands return null. A matching plugin alias that cannot be parsed or
877
+ * attested throws fail-closed instead of falling back to a shell.
878
+ *
879
+ * @returns {object|null}
880
+ */
881
+ export function resolvePluginBinInvocation(command, opts = {}) {
150
882
  if (typeof command !== "string") return null;
151
- const match = command.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s]+))/);
152
- const token = match?.[1] || match?.[2] || match?.[3] || "";
883
+ const token = firstCommandToken(command);
153
884
  if (!token) return null;
154
- const tokenBase = path.basename(token).toLowerCase();
155
- const candidates = new Set([tokenBase]);
156
- if (process.platform === "win32" && !path.extname(tokenBase)) {
157
- candidates.add(`${tokenBase}.exe`);
158
- candidates.add(`${tokenBase}.cmd`);
159
- candidates.add(`${tokenBase}.bat`);
160
- }
161
- for (const entry of collectPluginBinDirs(opts)) {
162
- for (const candidateName of candidates) {
163
- const binPath = path.join(entry.dir, candidateName);
164
- if (!fs.existsSync(binPath)) continue;
165
- if (path.isAbsolute(token)) {
166
- try {
167
- if (path.resolve(token) !== path.resolve(binPath)) continue;
168
- } catch {
169
- continue;
170
- }
885
+ const matches = collectPluginBinCommands(opts).filter((entry) =>
886
+ matchesEntryToken(token, entry, opts),
887
+ );
888
+ if (matches.length === 0) return null;
889
+ const policyRequired = matches.some((entry) => entry.sandboxPolicy);
890
+ if (matches.length > 1) {
891
+ if (!policyRequired) return null;
892
+ throw pluginBinError(
893
+ "ERR_PLUGIN_BIN_COMMAND_AMBIGUOUS",
894
+ `plugin bin alias "${token}" is declared by multiple trusted plugins`,
895
+ );
896
+ }
897
+ let words;
898
+ try {
899
+ words = parsePluginBinCommand(command);
900
+ } catch (err) {
901
+ if (!policyRequired && err?.pluginBinFailClosed) return null;
902
+ throw err;
903
+ }
904
+ const entry = matches[0];
905
+ if (!matchesEntryToken(words[0], entry, opts)) {
906
+ throw pluginBinError(
907
+ "ERR_PLUGIN_BIN_COMMAND_INVALID",
908
+ "plugin bin command target changed while parsing",
909
+ );
910
+ }
911
+ let identity;
912
+ let launch;
913
+ try {
914
+ identity = fileIdentity(entry.absPath, entry.root);
915
+ launch = launchForTarget(identity, words.slice(1));
916
+ } catch (err) {
917
+ if (!policyRequired && err?.pluginBinFailClosed) return null;
918
+ throw err;
919
+ }
920
+ const sandboxPolicy = entry.sandboxPolicy
921
+ ? Object.freeze({
922
+ requiredBoundaries: Object.freeze([
923
+ ...(entry.sandboxPolicy.requiredBoundaries || []),
924
+ ]),
925
+ })
926
+ : null;
927
+ const invocation = Object.freeze({
928
+ command: launch.command,
929
+ args: Object.freeze([...launch.args]),
930
+ runtime: launch.runtime,
931
+ shell: false,
932
+ pluginId: entry.plugin,
933
+ pluginVersion: entry.version || null,
934
+ pluginSource: entry.manifestPath || entry.root,
935
+ scope: entry.scope || null,
936
+ binName: entry.name,
937
+ binPath: identity.realPath,
938
+ pluginRoot: entry.root,
939
+ executableIdentity: identity,
940
+ ...(sandboxPolicy ? { sandboxPolicy } : {}),
941
+ });
942
+ _issuedPluginBinInvocations.set(
943
+ invocation,
944
+ Object.freeze({
945
+ command,
946
+ cwd: path.resolve(opts.cwd || process.cwd()),
947
+ commandCwd: path.resolve(opts.commandCwd || opts.cwd || process.cwd()),
948
+ scopes: Array.isArray(opts.scopes)
949
+ ? Object.freeze([...opts.scopes])
950
+ : undefined,
951
+ }),
952
+ );
953
+ return invocation;
954
+ }
955
+
956
+ /**
957
+ * Compare the target with the identity captured during resolution. Call this
958
+ * immediately before the Broker method so a version-dir mutation cannot be
959
+ * silently accepted after policy resolution.
960
+ */
961
+ export function reattestPluginBinInvocation(invocation) {
962
+ if (!invocation?.binPath || !invocation?.pluginRoot) {
963
+ throw pluginBinError(
964
+ "ERR_PLUGIN_BIN_IDENTITY_UNATTESTED",
965
+ "plugin bin invocation has no attested target",
966
+ );
967
+ }
968
+ const current = fileIdentity(invocation.binPath, invocation.pluginRoot);
969
+ const expected = invocation.executableIdentity || {};
970
+ for (const field of [
971
+ "realPath",
972
+ "sha256",
973
+ "bytes",
974
+ "dev",
975
+ "ino",
976
+ "mtimeMs",
977
+ ]) {
978
+ if (current[field] !== expected[field]) {
979
+ throw pluginBinError(
980
+ "ERR_PLUGIN_BIN_IDENTITY_CHANGED",
981
+ `plugin bin target identity changed before launch (${field})`,
982
+ );
983
+ }
984
+ }
985
+ return current;
986
+ }
987
+
988
+ /**
989
+ * Issue the private strong-sandbox contract for one resolver-produced
990
+ * policy-bearing Plugin Node or native invocation. Object identity is the
991
+ * capability: callers cannot mint a usable contract by copying public fields.
992
+ */
993
+ export function createPluginSandboxExecutionContract(invocation) {
994
+ const issuedResolution =
995
+ invocation && typeof invocation === "object"
996
+ ? _issuedPluginBinInvocations.get(invocation)
997
+ : null;
998
+ const runtime = invocation?.runtime;
999
+ const runtimeLabel = runtime === "native" ? "native" : "Node";
1000
+ const errorPrefix =
1001
+ runtime === "native" ? "ERR_PLUGIN_NATIVE" : "ERR_PLUGIN_NODE";
1002
+ if (!invocation || typeof invocation !== "object" || !issuedResolution) {
1003
+ throw pluginBinError(
1004
+ `${errorPrefix}_SANDBOX_CONTRACT_UNTRUSTED`,
1005
+ `plugin ${runtimeLabel} sandbox execution contract requires a resolver-issued invocation`,
1006
+ );
1007
+ }
1008
+ // A resolver-issued invocation is itself a one-shot capability. Consume it
1009
+ // before any further work so failed or successful issuance cannot be replayed
1010
+ // after trust, capability, or manifest state changes.
1011
+ _issuedPluginBinInvocations.delete(invocation);
1012
+ const directNode =
1013
+ runtime === "node" &&
1014
+ invocation.command === process.execPath &&
1015
+ invocation.args?.[0] === invocation.binPath;
1016
+ const directNative =
1017
+ runtime === "native" && invocation.command === invocation.binPath;
1018
+ if (
1019
+ (!directNode && !directNative) ||
1020
+ invocation.shell !== false ||
1021
+ !invocation.sandboxPolicy?.requiredBoundaries?.length
1022
+ ) {
1023
+ throw pluginBinError(
1024
+ `${errorPrefix}_SANDBOX_CONTRACT_UNSUPPORTED`,
1025
+ `plugin ${runtimeLabel} sandbox execution contract requires one direct policy-bearing bin`,
1026
+ );
1027
+ }
1028
+
1029
+ try {
1030
+ let currentInvocation;
1031
+ try {
1032
+ currentInvocation = resolvePluginBinInvocation(issuedResolution.command, {
1033
+ cwd: issuedResolution.cwd,
1034
+ commandCwd: issuedResolution.commandCwd,
1035
+ ...(issuedResolution.scopes !== undefined
1036
+ ? { scopes: issuedResolution.scopes }
1037
+ : {}),
1038
+ failClosed: true,
1039
+ });
1040
+ const scalarFields = [
1041
+ "command",
1042
+ "runtime",
1043
+ "shell",
1044
+ "pluginId",
1045
+ "pluginVersion",
1046
+ "pluginSource",
1047
+ "scope",
1048
+ "binName",
1049
+ "binPath",
1050
+ "pluginRoot",
1051
+ ];
1052
+ const identityFields = [
1053
+ "realPath",
1054
+ "sha256",
1055
+ "bytes",
1056
+ "dev",
1057
+ "ino",
1058
+ "mtimeMs",
1059
+ "mode",
1060
+ ];
1061
+ const originalBoundaries = [
1062
+ ...(invocation.sandboxPolicy?.requiredBoundaries || []),
1063
+ ].sort();
1064
+ const currentBoundaries = [
1065
+ ...(currentInvocation?.sandboxPolicy?.requiredBoundaries || []),
1066
+ ].sort();
1067
+ if (
1068
+ !currentInvocation ||
1069
+ scalarFields.some(
1070
+ (field) => currentInvocation[field] !== invocation[field],
1071
+ ) ||
1072
+ currentInvocation.args.length !== invocation.args.length ||
1073
+ currentInvocation.args.some(
1074
+ (value, index) => value !== invocation.args[index],
1075
+ ) ||
1076
+ originalBoundaries.length !== currentBoundaries.length ||
1077
+ originalBoundaries.some(
1078
+ (value, index) => value !== currentBoundaries[index],
1079
+ ) ||
1080
+ identityFields.some(
1081
+ (field) =>
1082
+ currentInvocation.executableIdentity?.[field] !==
1083
+ invocation.executableIdentity?.[field],
1084
+ )
1085
+ ) {
1086
+ throw pluginBinError(
1087
+ `${errorPrefix}_SANDBOX_CONTRACT_STALE`,
1088
+ `plugin ${runtimeLabel} sandbox execution contract provenance changed after resolution`,
1089
+ );
1090
+ }
1091
+ } finally {
1092
+ if (currentInvocation) {
1093
+ _issuedPluginBinInvocations.delete(currentInvocation);
171
1094
  }
172
- return {
173
- pluginId: entry.plugin,
174
- pluginVersion: entry.version || null,
175
- pluginSource: entry.dir,
176
- scope: entry.scope || null,
177
- binPath,
178
- };
179
1095
  }
1096
+ reattestPluginBinInvocation(invocation);
1097
+ const pluginRoot = fs.realpathSync.native(
1098
+ path.resolve(invocation.pluginRoot),
1099
+ );
1100
+ const entryRelative = path.relative(
1101
+ pluginRoot,
1102
+ invocation.executableIdentity.realPath,
1103
+ );
1104
+ if (
1105
+ entryRelative === "" ||
1106
+ entryRelative === ".." ||
1107
+ entryRelative.startsWith(`..${path.sep}`) ||
1108
+ path.isAbsolute(entryRelative)
1109
+ ) {
1110
+ throw new Error("plugin bin target is outside its canonical plugin root");
1111
+ }
1112
+ // The trusted Node runtime is both the Node launch target and the
1113
+ // capability-probe runtime for a native launch. Keeping the probe runtime
1114
+ // in the contract prevents a plugin from nominating a host executable.
1115
+ const runtimeIdentity = attestPluginNodeRuntime(process.execPath);
1116
+ const rootIdentity = attestPluginRootIdentity(pluginRoot);
1117
+ const contract = Object.freeze({
1118
+ contractVersion: 1,
1119
+ kind:
1120
+ runtime === "native"
1121
+ ? "strict-plugin-native-static-elf-bin"
1122
+ : "strict-plugin-node-bin",
1123
+ pluginRoot,
1124
+ workingDirectory: pluginRoot,
1125
+ runtimePath: runtimeIdentity.realPath,
1126
+ rootIdentity,
1127
+ entryIdentity: invocation.executableIdentity,
1128
+ runtimeIdentity,
1129
+ });
1130
+ _issuedPluginSandboxContracts.set(
1131
+ contract,
1132
+ Object.freeze({
1133
+ origin: "plugin:bin",
1134
+ command:
1135
+ runtime === "native" ? invocation.command : runtimeIdentity.realPath,
1136
+ args: invocation.args,
1137
+ cwd: pluginRoot,
1138
+ pluginId: invocation.pluginId,
1139
+ pluginVersion: invocation.pluginVersion ?? null,
1140
+ pluginSource: invocation.pluginSource,
1141
+ pluginExecutableIdentity: invocation.executableIdentity,
1142
+ requiredBoundaries: invocation.sandboxPolicy.requiredBoundaries,
1143
+ sync: true,
1144
+ }),
1145
+ );
1146
+ return contract;
1147
+ } catch (error) {
1148
+ if (error?.pluginBinFailClosed) throw error;
1149
+ throw pluginBinError(
1150
+ `${errorPrefix}_SANDBOX_CONTRACT_UNATTESTED`,
1151
+ `plugin ${runtimeLabel} sandbox execution contract could not be created: ${error.message}`,
1152
+ );
180
1153
  }
181
- return null;
1154
+ }
1155
+
1156
+ /**
1157
+ * Backward-compatible Node-only issuer retained for existing callers.
1158
+ */
1159
+ export function createPluginNodeSandboxExecutionContract(invocation) {
1160
+ if (invocation?.runtime !== "node") {
1161
+ throw pluginBinError(
1162
+ "ERR_PLUGIN_NODE_SANDBOX_CONTRACT_UNSUPPORTED",
1163
+ "plugin Node sandbox execution contract requires one direct policy-bearing Node bin",
1164
+ );
1165
+ }
1166
+ return createPluginSandboxExecutionContract(invocation);
1167
+ }
1168
+
1169
+ /**
1170
+ * Validate that a contract is the exact object issued above and is still
1171
+ * attached to the launch provenance for which it was issued.
1172
+ */
1173
+ function issuedPluginSandboxContractMatches(contract, provenance = {}) {
1174
+ if (!contract || typeof contract !== "object") return false;
1175
+ const issued = _issuedPluginSandboxContracts.get(contract);
1176
+ if (!issued) return false;
1177
+ const args = Array.isArray(provenance.args) ? provenance.args : [];
1178
+ const requiredBoundaries = Array.isArray(provenance.requiredBoundaries)
1179
+ ? provenance.requiredBoundaries
1180
+ : [];
1181
+ return (
1182
+ provenance.origin === issued.origin &&
1183
+ provenance.command === issued.command &&
1184
+ args.length === issued.args.length &&
1185
+ args.every((value, index) => value === issued.args[index]) &&
1186
+ provenance.cwd === issued.cwd &&
1187
+ provenance.pluginId === issued.pluginId &&
1188
+ (provenance.pluginVersion ?? null) === issued.pluginVersion &&
1189
+ provenance.pluginSource === issued.pluginSource &&
1190
+ provenance.pluginExecutableIdentity === issued.pluginExecutableIdentity &&
1191
+ provenance.sync === issued.sync &&
1192
+ issued.requiredBoundaries.every((boundary) =>
1193
+ requiredBoundaries.includes(boundary),
1194
+ )
1195
+ );
1196
+ }
1197
+
1198
+ export function verifyIssuedPluginNodeSandboxExecutionContract(
1199
+ contract,
1200
+ provenance = {},
1201
+ ) {
1202
+ return (
1203
+ contract?.kind === "strict-plugin-node-bin" &&
1204
+ issuedPluginSandboxContractMatches(contract, provenance)
1205
+ );
1206
+ }
1207
+
1208
+ export function verifyIssuedPluginSandboxExecutionContract(
1209
+ contract,
1210
+ provenance = {},
1211
+ ) {
1212
+ return issuedPluginSandboxContractMatches(contract, provenance);
1213
+ }
1214
+
1215
+ /**
1216
+ * Atomically consume a one-launch contract. A trust decision captured for one
1217
+ * Broker call cannot be replayed after trust/policy state changes.
1218
+ */
1219
+ export function consumeIssuedPluginNodeSandboxExecutionContract(
1220
+ contract,
1221
+ provenance = {},
1222
+ ) {
1223
+ if (
1224
+ contract?.kind !== "strict-plugin-node-bin" ||
1225
+ !issuedPluginSandboxContractMatches(contract, provenance)
1226
+ ) {
1227
+ return false;
1228
+ }
1229
+ return _issuedPluginSandboxContracts.delete(contract);
1230
+ }
1231
+
1232
+ export function consumeIssuedPluginSandboxExecutionContract(
1233
+ contract,
1234
+ provenance = {},
1235
+ ) {
1236
+ if (!issuedPluginSandboxContractMatches(contract, provenance)) {
1237
+ return false;
1238
+ }
1239
+ return _issuedPluginSandboxContracts.delete(contract);
1240
+ }
1241
+
1242
+ /**
1243
+ * Backward-compatible provenance resolver. It now succeeds only for commands
1244
+ * that qualify for the direct, attested invocation route.
1245
+ */
1246
+ export function resolvePluginBinCommand(command, opts = {}) {
1247
+ const invocation = resolvePluginBinInvocation(command, opts);
1248
+ if (!invocation) return null;
1249
+ return {
1250
+ pluginId: invocation.pluginId,
1251
+ pluginVersion: invocation.pluginVersion,
1252
+ pluginSource: invocation.pluginSource,
1253
+ scope: invocation.scope,
1254
+ binPath: invocation.binPath,
1255
+ executableIdentity: invocation.executableIdentity,
1256
+ ...(invocation.sandboxPolicy
1257
+ ? { sandboxPolicy: invocation.sandboxPolicy }
1258
+ : {}),
1259
+ };
182
1260
  }