chainlesschain 0.162.166 → 0.162.168

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 (185) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-LbDx7jL6.js → AIOps-CDzzuWZV.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-DnurtK34.js → ActionButton-Dy-8MmQi.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-DXSsV1am.js → Analytics-BtJ_VWkx.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-dqRpzNuD.js → AppLayout-DkjiI4VS.js} +5 -5
  6. package/src/assets/web-panel/assets/{Artifacts-BwdmYepK.js → Artifacts-h8PyDO86.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-oSX0kOUG.js → Audit-DiDV0C6l.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-C2KxJahT.js → BackgroundAgents-DTw_aOaf.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-B4TkxWkC.js → Backup-CihFuUO9.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-CX5-KxeU.js → BaseInput-CqP2QNoR.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-O_HzD-PL.js → Chat-tm-ygX-p.js} +5 -5
  12. package/src/assets/web-panel/assets/{ChatBubbleRenderer-Br8saHQA.js → ChatBubbleRenderer-BVKEhj_y.js} +1 -1
  13. package/src/assets/web-panel/assets/{Checkbox-ChepDpC1.js → Checkbox-BFMyRN3Q.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-D9z8kc7q.js → Codegen-B1gTeoAS.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-B-H5eoNw.js → Col-1GP8MvKL.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-Ccyp_Kk5.js → Community-BLY938F6.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-BNvBznp_.js → Compact-DVt_p36I.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-D1FjRd8n.js → Compliance-DxqEBXVG.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-CV9B9u-8.js → Cowork-CyWrM-T8.js} +3 -3
  20. package/src/assets/web-panel/assets/{Cron-BowJ4VIx.js → Cron-Domu6aI9.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-BJc6jzRF.js → Crosschain-C8LKpjHJ.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-BEp8NV6D.js → DID-XBy15RZ5.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-Dls0B3YB.js → Dashboard-DDAyPD-V.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-DQ7zXVtD.js → Dropdown-BHV0MZ13.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-B9q79tOl.js → EmailListRenderer-DmFGW4PX.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-CD8Ie-88.js → FamilyGuardDashboard-CqYdbKvd.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-5o7XU5Vf.js → Federation-YP5-Puv5.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-DeKbMvOs.js → FormItemContext-BOmcIcTe.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-CZiQvz_z.js → GenericCardRenderer-CvTZdoc4.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-CTvFxVV8.js → Git-Q2oxua0L.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-DwzFIjHt.js → Governance-D-WpyWEU.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-CJBk59c-.js → Inference-NUtsYHQo.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-DfsaXN2z.js → KnowledgeGraph-BHQLa8fr.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-DVRYf18S.js → Logs-6WsR5Q1G.js} +2 -2
  35. package/src/assets/web-panel/assets/MarkdownRenderer-DuWoDwMx.js +1 -0
  36. package/src/assets/web-panel/assets/{Marketplace-BuIwyE4t.js → Marketplace-CigohRRW.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-D3U0o78E.js → McpTools-jcp6jVLf.js} +5 -5
  38. package/src/assets/web-panel/assets/{Memory-QYVmyb3b.js → Memory-B00NU1ph.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-BFgrj6eZ.js → MobileBridge-Cs_p3PzU.js} +1 -1
  40. package/src/assets/web-panel/assets/MobileProjects-BFs2WljZ.js +1 -0
  41. package/src/assets/web-panel/assets/{Mtc-BZQu8TcH.js → Mtc-CSEflwzw.js} +5 -5
  42. package/src/assets/web-panel/assets/{MtcAudit-BnFfEkLo.js → MtcAudit-dxSiEZ96.js} +2 -2
  43. package/src/assets/web-panel/assets/{Multisig-iUnNS9Cm.js → Multisig-CDAFEAyq.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-DNNz1y9Y.js → NLProgramming-HoFpZNiB.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-CZezNYRU.js → Notes-tAoQvVcE.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-DBvrplIR.js → NotificationSettings-Czo2M9F4.js} +1 -1
  47. package/src/assets/web-panel/assets/{OrderTableRenderer-CTDtee49.js → OrderTableRenderer-CM1kNTp6.js} +1 -1
  48. package/src/assets/web-panel/assets/{Organization-CCOUJX1R.js → Organization-Cudr0VOC.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-DcZghdk0.js → Overflow-BSXeDKiN.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-jmddWX9q.js → P2P-Cqv_g-F9.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-wfjMcczl.js → PdhVaultBrowser-7rjprPh8.js} +4 -4
  52. package/src/assets/web-panel/assets/{Permissions-DU1GJjca.js → Permissions-97pIDQKE.js} +3 -3
  53. package/src/assets/web-panel/assets/{PersonalDataHub-BzTMzDF9.js → PersonalDataHub-bparK9eQ.js} +3 -3
  54. package/src/assets/web-panel/assets/{Pipeline-DPmjlpVc.js → Pipeline-CJgerM09.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-B6HRLpfK.js → Privacy-JqFS3PN7.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-Cyy86AsN.js → ProjectInit-DMITd-2c.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-Bl_WQxJ5.js → ProjectSettings-1UkdJ8Ii.js} +2 -2
  58. package/src/assets/web-panel/assets/{Projects-Co9yKThA.js → Projects-CYZqegqw.js} +1 -1
  59. package/src/assets/web-panel/assets/{Providers-DLa4NlIs.js → Providers-ChfXz3U0.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-BvzM7q5f.js → QrScannerModal-Rzkka-mf.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-BN8yyyUj.js → QuickAsk-DEz9HaON.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-QsVN4w0i.js → Recommend-EFGP8AVu.js} +1 -1
  63. package/src/assets/web-panel/assets/{RemoteSession-tkDXw-TY.js → RemoteSession-6um0lGGy.js} +2 -2
  64. package/src/assets/web-panel/assets/{Reputation-DAifkGsy.js → Reputation-CA1BXTup.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-D7dZEyak.js → Row-stTnDJ9y.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-DB5sImTA.js → RssFeed-B10qCCoG.js} +2 -2
  67. package/src/assets/web-panel/assets/{Search-3py3TO9V.js → Search-DSJroZ6p.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-DhNUfT_k.js → Security-BpXkwiMW.js} +4 -4
  69. package/src/assets/web-panel/assets/{Services-DjJAA_5D.js → Services-BPQxjwK6.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-u95JjK3n.js → Skeleton-CfbKAMul.js} +1 -1
  71. package/src/assets/web-panel/assets/{Skills-DPo3L0Tz.js → Skills-CttYTfX0.js} +1 -1
  72. package/src/assets/web-panel/assets/{Sla-DWhkAZV1.js → Sla-ByluHich.js} +1 -1
  73. package/src/assets/web-panel/assets/{SpeechSettings-PUegMtqV.js → SpeechSettings-BdZXKsfV.js} +1 -1
  74. package/src/assets/web-panel/assets/{SyncSettings-BMNYrnrU.js → SyncSettings-xCR7iDzy.js} +2 -2
  75. package/src/assets/web-panel/assets/{Tasks-CYm_oTJC.js → Tasks-qTko_INn.js} +1 -1
  76. package/src/assets/web-panel/assets/{Templates-Dw9Er5DN.js → Templates-CkIzhgDJ.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-7P3QIBSo.js → Tenant-BIaCaaK8.js} +1 -1
  78. package/src/assets/web-panel/assets/Terminal-B2Qq38XP.js +3 -0
  79. package/src/assets/web-panel/assets/{TimelineRenderer-DbCd1bDH.js → TimelineRenderer-D5eQDnEF.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-EQofpA0t.js → Tokens-DmeJ8NMQ.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-ChNLLypd.js → Trigger-C5sgb3C7.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-dlVGUKs5.js → Trust-H2vOb3Rb.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-BXyBUhNF.js → UkeySign-NmPtHK2u.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-BE8-68ge.js → VideoEditing-DHv-Ti7K.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-7ZIJOwNg.js → Wallet-BYZ3-PRP.js} +4 -4
  86. package/src/assets/web-panel/assets/{WebAuthn-DgNd2Amr.js → WebAuthn-BaSlP7om.js} +4 -4
  87. package/src/assets/web-panel/assets/{WorkflowEditor-Z-Un-9l5.js → WorkflowEditor-CZ7IHuIi.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-CtVDEkUc.js → chat-Dd9kY8gn.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-PXtJ2f1B.js → colors-yhzo6QSV.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item-oN96nuBi.js → compact-item-C1JneVdM.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-okCJXQXa.js → createContext-C_waVCg2.js} +1 -1
  92. package/src/assets/web-panel/assets/devWarning-DaDlyVDY.js +1 -0
  93. package/src/assets/web-panel/assets/{hasIn-pcHm--4o.js → hasIn-CkVg0Xnh.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-BeAWCGEK.js → index-1VYhIr6D.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-DC0bqrqV.js → index-BTMiVIBI.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-CQYes2mk.js → index-BYOyb5X2.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-vW0lwj0Y.js → index-BjDnI7oM.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-BUUJ2WfG.js → index-BlJNvg-2.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-C0rfJBU3.js → index-BlzbSUK9.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-Rk0X8lVz.js → index-BrN2hAeE.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-BDR2mi1p.js → index-BsSsurcn.js} +1 -1
  102. package/src/assets/web-panel/assets/index-BtsLExcc.js +1 -0
  103. package/src/assets/web-panel/assets/{index-jAY55MSx.js → index-Bwuu3Bf3.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-_SaFHNsG.js → index-Bx3A6X4J.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-T3QE8LuV.js → index-C-Sscgnv.js} +1 -1
  106. package/src/assets/web-panel/assets/index-C3qDvvIb.js +1 -0
  107. package/src/assets/web-panel/assets/{index-BUTyx-Lk.js → index-C4HNdMCt.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-nMqLdwao.js → index-CBwmwTNa.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-BuME9k4Y.js → index-CErewFeT.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-BHJIfdCV.js → index-CGtoL5mR.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-D0bfypsF.js → index-CUDjGt_V.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-Bl6N7ZyX.js → index-C_qqYZT7.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-CXhezK4d.js → index-C_riAa6d.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-BnhHxZul.js → index-CfEMjbhu.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-Cs1fGk1Z.js → index-ClQhh6v3.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-BjVIZfdA.js → index-CmiLmG0i.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-CWUJlCvK.js → index-D1xIbAxQ.js} +3 -3
  118. package/src/assets/web-panel/assets/{index-DfL10EcN.js → index-D6UXDH2D.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-C4ravjdv.js → index-D7kQ_4uL.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-DtsyPnF5.js → index-DPkqQ2Pr.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-CQ1ZzJjN.js → index-DUA96wEj.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-5aO2V0V4.js → index-DXHk9Vof.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CopGaCI_.js → index-DapftLUr.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-D1efSh1X.js → index-Dez7TUgG.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-DBcKBb1E.js → index-DhZgULsY.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-Bht9n8Sm.js → index-Diquc7-l.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-CAQktO-6.js → index-DuGtLiTW.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-CK0H3pQW.js → index-U_nLXGIx.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-BZKk4Lnm.js → index-_M8El5iz.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-Cc_MkksW.js → index-_VmzCYFn.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-mB1UV7wA.js → index-_rZxgFtc.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-BEtzqJw3.js → index-ewb0-LoH.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-CG1jyiI7.js → initDefaultProps-DNN6qtGa.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-C8BYa98D.js → motion-BcM9exeo.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-D1hqwitW.js → move-DsKJ-YvA.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-C2lol8rp.js → mtc-parser-glgGkxaK.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-Ct6Zghfi.js → omit-BwHCU50S.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-CSYbYfdc.js → pickAttrs-BVp-bLt7.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-WCk8di0z.js → placementArrow-CZNe0r-w.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-CEC8CZ-3.js → responsiveObserve-DUafj9pU.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-BsXyd7Lq.js → slide-H4NqXQxu.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-tDKldiUT.js → statusUtils-UjMzzGZ5.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-BIS2unhx.js → styleChecker-qO5aCjyk.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-yFTVMDUv.js → useFlexGapSupport-ChecYwC8.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-CZYbJwkO.js → useFs-BR0QvwO3.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-sbEHRLAM.js → usePersonalDataHub-DitK2Qgz.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-BVjeSEQK.js → vnode-dxSb40dY.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-BZKVvEZ6.js → zoom-ekJk8YB2.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/commands/agent.js +75 -0
  151. package/src/commands/session.js +132 -0
  152. package/src/data/changelog.json +8 -2
  153. package/src/harness/jsonl-session-store.js +9 -0
  154. package/src/harness/session-mirror.js +212 -6
  155. package/src/harness/worktree-isolator.js +10 -2
  156. package/src/lib/agent-core.js +1 -0
  157. package/src/lib/async-hook-queue.cjs +288 -0
  158. package/src/lib/async-hook-supervisor.cjs +52 -0
  159. package/src/lib/background-phase-reporter.js +116 -0
  160. package/src/lib/doctor-checkup.js +184 -4
  161. package/src/lib/hook-runner.cjs +248 -36
  162. package/src/lib/install-command-policy.js +343 -0
  163. package/src/lib/jsonl-session-store.js +1 -0
  164. package/src/lib/lsp/__tests__/lsp-server-registry.test.js +27 -0
  165. package/src/lib/lsp/code-intelligence.js +5 -1
  166. package/src/lib/lsp/lsp-server-registry.js +14 -0
  167. package/src/lib/lsp/workspace-roots.js +115 -0
  168. package/src/lib/runtime-checkup.js +33 -0
  169. package/src/lib/settings-hook-events.cjs +173 -3
  170. package/src/lib/settings-hooks.cjs +4 -0
  171. package/src/lib/shell-approval.js +47 -1
  172. package/src/lib/sub-agent-context.js +13 -0
  173. package/src/repl/add-dir.js +59 -1
  174. package/src/repl/agent-repl.js +85 -3
  175. package/src/runtime/agent-core.js +411 -82
  176. package/src/runtime/headless-runner.js +111 -9
  177. package/src/runtime/headless-stream.js +39 -0
  178. package/src/runtime/system-prompt.js +39 -12
  179. package/src/workers/background-agent-worker.js +8 -0
  180. package/src/assets/web-panel/assets/MarkdownRenderer-BL_WsWZM.js +0 -1
  181. package/src/assets/web-panel/assets/MobileProjects-Bp5QB-46.js +0 -1
  182. package/src/assets/web-panel/assets/Terminal-ClPrfWW7.js +0 -3
  183. package/src/assets/web-panel/assets/devWarning-BzV6dk1B.js +0 -1
  184. package/src/assets/web-panel/assets/index-BFrAwXHp.js +0 -1
  185. package/src/assets/web-panel/assets/index-DJQFP47u.js +0 -1
@@ -26,8 +26,9 @@ const {
26
26
  buildPowershellArgv,
27
27
  loadShellConfig,
28
28
  } = require("./shell-selector.cjs");
29
+ const { mergeHookDecisions } = require("./hook-event-bus.cjs");
29
30
 
30
- const _deps = { spawnSync: cpDefault.spawnSync };
31
+ const _deps = { spawnSync: cpDefault.spawnSync, spawn: cpDefault.spawn };
31
32
 
32
33
  const HOOK_DECISIONS = Object.freeze({
33
34
  BLOCK: "block",
@@ -116,43 +117,65 @@ function tryParseDecision(stdout) {
116
117
  * stdout?:string, stderr?:string, additionalContext?:string,
117
118
  * nonBlockingError?:boolean, error?:string }}
118
119
  */
120
+ // Circuit breaker (gap 2026-07-11 P2 "Hooks 硬化"): N consecutive non-blocking
121
+ // failures (spawn error / timeout / non-zero non-2 exit) of the SAME command
122
+ // trip an open state — further runs are skipped for a cooldown, then one
123
+ // half-open trial runs; success (or a real block/ask decision) closes it.
124
+ // CC_HOOK_BREAKER_THRESHOLD=0 disables. Extracted so the sync + async runners
125
+ // share ONE breaker implementation.
126
+ function _breakerShortCircuit(command, breaker) {
127
+ if (breaker.threshold <= 0) return null;
128
+ const st = _breaker.get(command);
129
+ if (st && st.fails >= breaker.threshold) {
130
+ const now = Date.now();
131
+ if (now - st.openedAt < breaker.cooldownMs) {
132
+ return {
133
+ decision: HOOK_DECISIONS.CONTINUE,
134
+ reason: `hook circuit breaker open (${st.fails} consecutive failures) — skipped for cooldown`,
135
+ exitCode: null,
136
+ skipped: true,
137
+ breakerOpen: true,
138
+ };
139
+ }
140
+ // half-open: let this one trial through; the outcome records below.
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function _breakerRecord(command, breaker, result) {
146
+ if (breaker.threshold <= 0) return;
147
+ if (result.nonBlockingError) {
148
+ const st = _breaker.get(command) || { fails: 0, openedAt: 0 };
149
+ st.fails += 1;
150
+ if (st.fails >= breaker.threshold) st.openedAt = Date.now();
151
+ _breaker.set(command, st);
152
+ } else {
153
+ _breaker.delete(command); // success / block / ask closes the breaker
154
+ }
155
+ }
156
+
119
157
  function runCommandHook(command, input = {}, opts = {}) {
120
158
  if (!command) {
121
159
  return { decision: HOOK_DECISIONS.CONTINUE, reason: null, exitCode: 0 };
122
160
  }
123
- // Circuit breaker (gap 2026-07-11 P2 "Hooks 硬化"): N consecutive
124
- // non-blocking failures (spawn error / timeout / non-zero non-2 exit) of
125
- // the SAME command trip an open state — further runs are skipped for a
126
- // cooldown, then one half-open trial runs; success (or a real block/ask
127
- // decision) closes it. CC_HOOK_BREAKER_THRESHOLD=0 disables.
128
161
  const breaker = hookBreakerConfig();
129
- if (breaker.threshold > 0) {
130
- const st = _breaker.get(command);
131
- if (st && st.fails >= breaker.threshold) {
132
- const now = Date.now();
133
- if (now - st.openedAt < breaker.cooldownMs) {
134
- return {
135
- decision: HOOK_DECISIONS.CONTINUE,
136
- reason: `hook circuit breaker open (${st.fails} consecutive failures) — skipped for cooldown`,
137
- exitCode: null,
138
- skipped: true,
139
- breakerOpen: true,
140
- };
141
- }
142
- // half-open: let this one trial through; the outcome below decides.
143
- }
144
- }
162
+ const shortCircuit = _breakerShortCircuit(command, breaker);
163
+ if (shortCircuit) return shortCircuit;
145
164
  const result = _runCommandHookInner(command, input, opts);
146
- if (breaker.threshold > 0) {
147
- if (result.nonBlockingError) {
148
- const st = _breaker.get(command) || { fails: 0, openedAt: 0 };
149
- st.fails += 1;
150
- if (st.fails >= breaker.threshold) st.openedAt = Date.now();
151
- _breaker.set(command, st);
152
- } else {
153
- _breaker.delete(command); // success / block / ask closes the breaker
154
- }
165
+ _breakerRecord(command, breaker, result);
166
+ return result;
167
+ }
168
+
169
+ /** Async counterpart of runCommandHook (same breaker + protocol). */
170
+ async function runCommandHookAsync(command, input = {}, opts = {}) {
171
+ if (!command) {
172
+ return { decision: HOOK_DECISIONS.CONTINUE, reason: null, exitCode: 0 };
155
173
  }
174
+ const breaker = hookBreakerConfig();
175
+ const shortCircuit = _breakerShortCircuit(command, breaker);
176
+ if (shortCircuit) return shortCircuit;
177
+ const result = await _runCommandHookInnerAsync(command, input, opts);
178
+ _breakerRecord(command, breaker, result);
156
179
  return result;
157
180
  }
158
181
 
@@ -230,7 +253,19 @@ function _runCommandHookInner(command, input = {}, opts = {}) {
230
253
  nonBlockingError: true,
231
254
  };
232
255
  }
233
- // spawnSync surfaces timeout/ENOENT on res.error (status null) — non-blocking.
256
+ return interpretHookOutcome(res);
257
+ }
258
+
259
+ /**
260
+ * Interpret a finished hook process — a spawnSync result OR the collected output
261
+ * of an async spawn — into a normalized decision. The exit-code / JSON-stdout
262
+ * protocol lives in ONE place so the sync and async runners can never drift.
263
+ *
264
+ * @param {{error?:Error|null, status:number|null, stdout?:string, stderr?:string}} res
265
+ */
266
+ function interpretHookOutcome(res) {
267
+ // A surfaced spawn error (timeout / ENOENT, status null) is non-blocking — a
268
+ // broken hook must never wedge the agent; only an explicit block blocks.
234
269
  if (res.error) {
235
270
  return {
236
271
  decision: HOOK_DECISIONS.CONTINUE,
@@ -287,19 +322,157 @@ function _runCommandHookInner(command, input = {}, opts = {}) {
287
322
  }
288
323
 
289
324
  /**
290
- * Run a list of command hooks in order; first BLOCK (or ASK) short-circuits.
291
- * @returns {{ decision, reason, hook?, results }}
325
+ * Async single-hook runner — mirrors _runCommandHookInner but uses async spawn
326
+ * so a batch can run CONCURRENTLY (see runHooksParallel). stdin gets the JSON
327
+ * payload; stdout/stderr are collected (bounded); a hard timeout SIGKILLs a hung
328
+ * hook and reports it as a non-blocking error. Interpretation is shared via
329
+ * interpretHookOutcome so the exit-code protocol matches the sync path exactly.
330
+ */
331
+ async function _runCommandHookInnerAsync(command, input = {}, opts = {}) {
332
+ const { cwd, event } = opts;
333
+ const _rawTimeout = Number(opts.timeout);
334
+ const timeout =
335
+ Number.isFinite(_rawTimeout) && _rawTimeout > 0
336
+ ? Math.min(_rawTimeout, 600000)
337
+ : 60000;
338
+ const shellKind = normalizeShellKind(opts.shell);
339
+ const usePowershell = shellKind === "powershell" || shellKind === "pwsh";
340
+ const wd = cwd || process.cwd();
341
+ const inputJson = JSON.stringify({
342
+ ...input,
343
+ schema_version: input.schema_version ?? HOOK_PAYLOAD_SCHEMA_VERSION,
344
+ });
345
+ const spawnEnv = {
346
+ ...process.env,
347
+ CLAUDE_HOOK_EVENT: event || input.hook_event_name || "",
348
+ };
349
+
350
+ let child;
351
+ try {
352
+ if (usePowershell) {
353
+ const { executionPolicy } = loadShellConfig({ cwd: wd });
354
+ const inv = buildPowershellArgv(command, shellKind, { executionPolicy });
355
+ child = _deps.spawn(inv.file, inv.argv, { cwd: wd, env: spawnEnv });
356
+ } else {
357
+ child = _deps.spawn(command, { cwd: wd, env: spawnEnv, shell: true });
358
+ }
359
+ } catch (err) {
360
+ return {
361
+ decision: HOOK_DECISIONS.CONTINUE,
362
+ reason: `hook spawn failed: ${err.message}`,
363
+ exitCode: null,
364
+ error: err.message,
365
+ nonBlockingError: true,
366
+ };
367
+ }
368
+
369
+ return await new Promise((resolve) => {
370
+ const MAXB = 8 * 1024 * 1024;
371
+ let stdout = "";
372
+ let stderr = "";
373
+ let settled = false;
374
+ const done = (res) => {
375
+ if (settled) return;
376
+ settled = true;
377
+ clearTimeout(timer);
378
+ resolve(interpretHookOutcome(res));
379
+ };
380
+ const timer = setTimeout(() => {
381
+ try {
382
+ child.kill("SIGKILL");
383
+ } catch {
384
+ /* already exited */
385
+ }
386
+ done({
387
+ error: new Error(`hook timed out after ${timeout}ms`),
388
+ status: null,
389
+ stdout,
390
+ stderr,
391
+ });
392
+ }, timeout);
393
+ if (typeof timer.unref === "function") timer.unref();
394
+
395
+ child.on("error", (err) =>
396
+ done({ error: err, status: null, stdout, stderr }),
397
+ );
398
+ if (child.stdout) {
399
+ child.stdout.on("data", (d) => {
400
+ if (stdout.length < MAXB) stdout += d.toString("utf8");
401
+ });
402
+ }
403
+ if (child.stderr) {
404
+ child.stderr.on("data", (d) => {
405
+ if (stderr.length < MAXB) stderr += d.toString("utf8");
406
+ });
407
+ }
408
+ child.on("close", (code) =>
409
+ done({ error: null, status: code, stdout, stderr }),
410
+ );
411
+ // Feed the payload to the hook's stdin. A fast-exiting hook may close its
412
+ // end first (EPIPE) — that is harmless, the exit code still governs.
413
+ try {
414
+ if (child.stdin) {
415
+ child.stdin.on("error", () => {});
416
+ child.stdin.write(inputJson);
417
+ child.stdin.end();
418
+ }
419
+ } catch {
420
+ /* stdin already closed */
421
+ }
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Run a list of command hooks and reduce to one decision.
427
+ *
428
+ * Default (`opts.mergeStrict` falsy): in-order, the first BLOCK **or** ASK
429
+ * short-circuits — byte-identical to the historical behavior.
430
+ *
431
+ * `opts.mergeStrict: true`: run EVERY matching hook (no short-circuit) then take
432
+ * the STRICTEST decision (block > ask > allow > continue) via mergeHookDecisions.
433
+ * This closes the safety gap where an earlier hook's `ask` masks a LATER hook's
434
+ * `block` (the short-circuit path returns `ask` and never runs the blocker).
435
+ * Claude-Code parity: all matching hooks contribute to the merged decision.
436
+ * spawnSync is synchronous so execution is sequential — but the MERGE is
437
+ * order-independent, so the outcome matches a parallel run.
438
+ *
439
+ * @returns {{ decision, reason, hook?, results, contributing? }}
292
440
  */
293
441
  function runHooks(commandHooks, input = {}, opts = {}) {
294
442
  const results = [];
295
- for (const h of commandHooks || []) {
296
- const r = runCommandHook(h.command, input, {
443
+ const hooks = commandHooks || [];
444
+ const runOne = (h) =>
445
+ runCommandHook(h.command, input, {
297
446
  ...opts,
298
447
  timeout: h.timeout != null ? h.timeout * 1000 : opts.timeout,
299
448
  // per-hook shell selection (P1 #8): `{ "type":"command", "command":…,
300
449
  // "shell":"powershell" }` in the settings hook entry
301
450
  shell: h.shell != null ? h.shell : opts.shell,
302
451
  });
452
+
453
+ if (opts.mergeStrict) {
454
+ const decisions = [];
455
+ for (const h of hooks) {
456
+ const r = runOne(h);
457
+ results.push({ command: h.command, ...r });
458
+ decisions.push({
459
+ decision: r.decision,
460
+ reason: r.reason,
461
+ hook: h.command,
462
+ });
463
+ }
464
+ const merged = mergeHookDecisions(decisions);
465
+ return {
466
+ decision: merged.decision,
467
+ reason: merged.reason,
468
+ hook: merged.hook,
469
+ results,
470
+ contributing: merged.contributing,
471
+ };
472
+ }
473
+
474
+ for (const h of hooks) {
475
+ const r = runOne(h);
303
476
  results.push({ command: h.command, ...r });
304
477
  if (
305
478
  r.decision === HOOK_DECISIONS.BLOCK ||
@@ -316,9 +489,48 @@ function runHooks(commandHooks, input = {}, opts = {}) {
316
489
  return { decision: HOOK_DECISIONS.CONTINUE, reason: null, results };
317
490
  }
318
491
 
492
+ /**
493
+ * Run every matching hook CONCURRENTLY (async spawn) and reduce to the strictest
494
+ * decision (block > ask > allow > continue) via mergeHookDecisions — the true-
495
+ * parallel form of runHooks({mergeStrict}). Wall-clock is the slowest single hook
496
+ * rather than their sum; the merge is order-independent so the outcome matches
497
+ * the sequential strict merge exactly. Same per-hook timeout/shell resolution as
498
+ * runHooks. Returns the same shape ({decision, reason, hook, results,
499
+ * contributing}) so the decision consumers are unchanged.
500
+ */
501
+ async function runHooksParallel(commandHooks, input = {}, opts = {}) {
502
+ const hooks = commandHooks || [];
503
+ const settled = await Promise.all(
504
+ hooks.map((h) =>
505
+ runCommandHookAsync(h.command, input, {
506
+ ...opts,
507
+ timeout: h.timeout != null ? h.timeout * 1000 : opts.timeout,
508
+ shell: h.shell != null ? h.shell : opts.shell,
509
+ }).then((r) => ({ h, r })),
510
+ ),
511
+ );
512
+ const results = settled.map(({ h, r }) => ({ command: h.command, ...r }));
513
+ const decisions = settled.map(({ h, r }) => ({
514
+ decision: r.decision,
515
+ reason: r.reason,
516
+ hook: h.command,
517
+ }));
518
+ const merged = mergeHookDecisions(decisions);
519
+ return {
520
+ decision: merged.decision,
521
+ reason: merged.reason,
522
+ hook: merged.hook,
523
+ results,
524
+ contributing: merged.contributing,
525
+ };
526
+ }
527
+
319
528
  module.exports = {
320
529
  runCommandHook,
530
+ runCommandHookAsync,
321
531
  runHooks,
532
+ runHooksParallel,
533
+ interpretHookOutcome,
322
534
  tryParseDecision,
323
535
  HOOK_DECISIONS,
324
536
  HOOK_PAYLOAD_SCHEMA_VERSION,
@@ -0,0 +1,343 @@
1
+ /**
2
+ * install-command-policy — a UNIFIED classifier + audit for package-install
3
+ * shell commands across managers (npm / pnpm / yarn / bun / pip / pipx / poetry /
4
+ * conda / brew / winget / choco / apt / dnf / yum / apk / snap / gem / cargo /
5
+ * go / composer / dotnet).
6
+ *
7
+ * The gap (docs/…INCREMENTAL_GAP_ANALYSIS P0 "跨平台沙箱"): "npm、pip、winget、brew
8
+ * 等安装入口需要统一权限类型与审计事件". Today a `run_shell` install command is just
9
+ * another shell command — there is no single place that recognizes "this fetches
10
+ * and runs third-party code" regardless of which tool does it, and no unified
11
+ * audit trail. This module supplies both, as PURE detection (no I/O) plus a
12
+ * best-effort injected-IO audit sink, so it can be reused by the shell approval
13
+ * path today and other entry points (run_code, hooks) later.
14
+ *
15
+ * Design: a per-manager table maps a binary to its install subcommand(s), a
16
+ * global-scope signal, and package extraction. `classifyInstallCommand` splits a
17
+ * compound command into segments (reusing the shell-policy splitter) so
18
+ * `npm i foo && pip install bar` reports BOTH installs. Nothing here changes a
19
+ * permission decision — callers decide whether to raise a risk floor; the
20
+ * classification is advisory + auditable.
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import shellPolicy from "../runtime/coding-agent-shell-policy.cjs";
27
+
28
+ const { splitCommandSegments, tokenizeShellCommand } = shellPolicy;
29
+
30
+ // Leading tokens that wrap the real command and must be stripped before the
31
+ // binary is read: privilege elevation, env-var assignments, and `env`/`command`.
32
+ const WRAPPER_TOKENS = new Set([
33
+ "sudo",
34
+ "doas",
35
+ "env",
36
+ "command",
37
+ "exec",
38
+ "nice",
39
+ ]);
40
+
41
+ /**
42
+ * Per-manager install detection. `sub` = subcommand keywords that mean "install".
43
+ * `system` = the install always affects the whole machine/user (no project
44
+ * scope), so it is treated as global regardless of flags. `globalFlags`/
45
+ * `globalWords` mark a per-tool global install. `packageWord` (dotnet) requires a
46
+ * keyword before the package name.
47
+ */
48
+ const MANAGERS = [
49
+ {
50
+ name: "npm",
51
+ bin: /^npm$/,
52
+ sub: ["install", "i", "add", "ci"],
53
+ globalFlags: ["-g", "--global"],
54
+ },
55
+ {
56
+ name: "pnpm",
57
+ bin: /^pnpm$/,
58
+ sub: ["install", "i", "add"],
59
+ globalFlags: ["-g", "--global"],
60
+ },
61
+ {
62
+ name: "yarn",
63
+ bin: /^yarn$/,
64
+ sub: ["add", "install"],
65
+ globalWords: ["global"],
66
+ },
67
+ {
68
+ name: "bun",
69
+ bin: /^bun$/,
70
+ sub: ["install", "i", "add"],
71
+ globalFlags: ["-g", "--global"],
72
+ },
73
+ { name: "pip", bin: /^pip[0-9.]*$/, sub: ["install"], globalFlags: [] },
74
+ { name: "pipx", bin: /^pipx$/, sub: ["install"], system: true },
75
+ { name: "poetry", bin: /^poetry$/, sub: ["add"], globalFlags: [] },
76
+ { name: "conda", bin: /^conda$/, sub: ["install"], globalFlags: [] },
77
+ { name: "brew", bin: /^brew$/, sub: ["install"], system: true },
78
+ { name: "winget", bin: /^winget$/, sub: ["install"], system: true },
79
+ { name: "choco", bin: /^choco(latey)?$/, sub: ["install"], system: true },
80
+ { name: "apt", bin: /^apt(-get)?$/, sub: ["install"], system: true },
81
+ { name: "dnf", bin: /^(dnf|yum)$/, sub: ["install"], system: true },
82
+ { name: "apk", bin: /^apk$/, sub: ["add"], system: true },
83
+ { name: "snap", bin: /^snap$/, sub: ["install"], system: true },
84
+ { name: "gem", bin: /^gem$/, sub: ["install"], system: true },
85
+ { name: "cargo", bin: /^cargo$/, sub: ["install"], system: true },
86
+ { name: "go", bin: /^go$/, sub: ["install", "get"], system: true },
87
+ {
88
+ name: "composer",
89
+ bin: /^composer$/,
90
+ sub: ["require", "install"],
91
+ globalWords: ["global"],
92
+ },
93
+ {
94
+ name: "dotnet",
95
+ bin: /^dotnet$/,
96
+ sub: ["add"],
97
+ packageWord: "package",
98
+ system: false,
99
+ },
100
+ ];
101
+
102
+ /** Basename of a binary path, minus a Windows .exe/.cmd/.bat/.ps1 suffix. */
103
+ function normalizeBin(token) {
104
+ const base = String(token || "")
105
+ .replace(/\\/g, "/")
106
+ .split("/")
107
+ .pop();
108
+ return base.replace(/\.(exe|cmd|bat|ps1)$/i, "").toLowerCase();
109
+ }
110
+
111
+ /** Strip leading wrapper tokens (sudo/env/VAR=val) → the real argv, or null. */
112
+ function unwrapArgv(tokens) {
113
+ let i = 0;
114
+ while (i < tokens.length) {
115
+ const t = tokens[i];
116
+ if (WRAPPER_TOKENS.has(t) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
117
+ i += 1;
118
+ continue;
119
+ }
120
+ break;
121
+ }
122
+ return tokens.slice(i);
123
+ }
124
+
125
+ /**
126
+ * Classify ONE already-split command segment. Returns an install descriptor or
127
+ * null. Pure.
128
+ * @returns {{manager:string, subcommand:string, packages:string[], global:boolean}|null}
129
+ */
130
+ export function classifyInstallSegment(segment) {
131
+ let argv = unwrapArgv(tokenizeShellCommand(segment));
132
+ if (argv.length === 0) return null;
133
+ // Module invocation: `python -m pip install …` (and `py -m pip …`) is the
134
+ // canonical way run_code's auto-install — and plenty of humans — invoke pip;
135
+ // treat the module as the binary so it classifies like a direct `pip`.
136
+ if (
137
+ /^(python[0-9.]*|py)$/.test(normalizeBin(argv[0])) &&
138
+ argv[1] === "-m" &&
139
+ argv.length > 2
140
+ ) {
141
+ argv = argv.slice(2);
142
+ }
143
+ const bin = normalizeBin(argv[0]);
144
+ const mgr = MANAGERS.find((m) => m.bin.test(bin));
145
+ if (!mgr) return null;
146
+
147
+ const rest = argv.slice(1);
148
+ // Find the install subcommand token (first non-flag token that matches). For
149
+ // dotnet, the trigger is `add … package`, so also require the package word.
150
+ let subIdx = -1;
151
+ let subcommand = null;
152
+ for (let i = 0; i < rest.length; i++) {
153
+ const tok = rest[i];
154
+ if (mgr.sub.includes(tok)) {
155
+ subIdx = i;
156
+ subcommand = tok;
157
+ break;
158
+ }
159
+ }
160
+ if (subIdx === -1) return null;
161
+ if (mgr.packageWord && !rest.slice(subIdx + 1).includes(mgr.packageWord)) {
162
+ return null; // e.g. `dotnet add reference` is not a package install
163
+ }
164
+
165
+ // Everything after the subcommand (and, for dotnet, after `package`) that is
166
+ // not a flag = the requested packages (best-effort; enough for audit).
167
+ let argsAfter = rest.slice(subIdx + 1);
168
+ if (mgr.packageWord) {
169
+ const pw = argsAfter.indexOf(mgr.packageWord);
170
+ argsAfter = pw >= 0 ? argsAfter.slice(pw + 1) : argsAfter;
171
+ }
172
+ const packages = argsAfter.filter((t) => !t.startsWith("-"));
173
+
174
+ const global =
175
+ mgr.system === true ||
176
+ (mgr.globalFlags || []).some((f) => rest.includes(f)) ||
177
+ (mgr.globalWords || []).some((w) => rest.includes(w));
178
+
179
+ return { manager: mgr.name, subcommand, packages, global };
180
+ }
181
+
182
+ /**
183
+ * Classify a full (possibly compound) shell command. Splits on `&&`/`||`/`;`/`|`
184
+ * so `npm i a && pip install b` reports BOTH. Returns `{ isInstall, installs }`.
185
+ * Pure.
186
+ */
187
+ export function classifyInstallCommand(command) {
188
+ const segments = splitCommandSegments(String(command || ""));
189
+ const installs = [];
190
+ for (const seg of segments) {
191
+ const hit = classifyInstallSegment(seg);
192
+ if (hit) installs.push(hit);
193
+ }
194
+ return { isInstall: installs.length > 0, installs };
195
+ }
196
+
197
+ /** True if any classified install is global/system-scoped (higher blast radius). */
198
+ export function hasGlobalInstall(classification) {
199
+ return !!(
200
+ classification &&
201
+ Array.isArray(classification.installs) &&
202
+ classification.installs.some((i) => i.global)
203
+ );
204
+ }
205
+
206
+ // ── remote-script execution ("curl … | sh") ────────────────────────────────
207
+ // The most dangerous "fetch and run third-party code" vector isn't a package
208
+ // manager at all — it's piping a download straight into a shell, or running a
209
+ // shell over a command/process substitution that downloads. It runs UNPINNED,
210
+ // UNVERIFIED, attacker-mutable code with the agent's privileges. These patterns
211
+ // must be recognized on the RAW command (the pipe/substitution structure is lost
212
+ // once split into segments), so they use anchored regexes rather than the
213
+ // per-segment classifier.
214
+ const REMOTE_FETCHERS = "curl|wget|fetch|iwr|invoke-webrequest|http|httpie";
215
+ const SHELL_INTERPRETERS =
216
+ "sh|bash|zsh|ksh|dash|fish|python[0-9.]*|ruby|perl|node|deno|pwsh|powershell|eval|source";
217
+
218
+ // `curl URL | sh`, `wget -O- URL | sudo bash`, `curl URL | tee f | sh`. After
219
+ // the pipe, skip inline wrappers (`sudo `/`env `, space-separated) AND piped
220
+ // pass-throughs (`tee f |`, `cat |`) before the interpreter.
221
+ const PIPE_TO_SHELL_RE = new RegExp(
222
+ `\\b(${REMOTE_FETCHERS})\\b[^|]*\\|\\s*(?:(?:sudo|env|command|nice)\\s+|(?:tee|cat)\\b[^|]*\\|\\s*)*(${SHELL_INTERPRETERS})\\b`,
223
+ "i",
224
+ );
225
+ // `bash -c "$(curl URL)"`, `sh <(curl URL)`, `eval "$(wget -O- URL)"`
226
+ const SUBST_TO_SHELL_RE = new RegExp(
227
+ `\\b(${SHELL_INTERPRETERS})\\b[^\\n]*[$<]\\(\\s*(?:sudo\\s+)?(${REMOTE_FETCHERS})\\b`,
228
+ "i",
229
+ );
230
+
231
+ function firstUrl(s) {
232
+ const m = String(s).match(/\bhttps?:\/\/[^\s'"|)>]+/i);
233
+ return m ? m[0] : null;
234
+ }
235
+
236
+ /**
237
+ * Detect remote-script execution in a raw shell command: a network fetcher piped
238
+ * into (or command/process-substituted into) a shell interpreter. Returns
239
+ * `{ isRemoteExec, matches:[{pattern, fetcher, interpreter, url}] }`. Pure.
240
+ */
241
+ export function classifyRemoteExecCommand(command) {
242
+ const s = String(command || "");
243
+ const matches = [];
244
+ const pipe = PIPE_TO_SHELL_RE.exec(s);
245
+ if (pipe) {
246
+ matches.push({
247
+ pattern: "pipe-to-shell",
248
+ fetcher: pipe[1].toLowerCase(),
249
+ interpreter: pipe[2].toLowerCase(),
250
+ url: firstUrl(s),
251
+ });
252
+ }
253
+ const subst = SUBST_TO_SHELL_RE.exec(s);
254
+ if (subst) {
255
+ matches.push({
256
+ pattern: "cmdsubst-to-shell",
257
+ interpreter: subst[1].toLowerCase(),
258
+ fetcher: subst[2].toLowerCase(),
259
+ url: firstUrl(s),
260
+ });
261
+ }
262
+ return { isRemoteExec: matches.length > 0, matches };
263
+ }
264
+
265
+ /**
266
+ * Combined "untrusted code acquisition" classification: package installs AND
267
+ * remote-script execution. `flagged` is true if EITHER fires — the single signal
268
+ * a caller gates/audits on. Pure.
269
+ */
270
+ export function classifyCodeAcquisition(command) {
271
+ const install = classifyInstallCommand(command);
272
+ const remote = classifyRemoteExecCommand(command);
273
+ return {
274
+ flagged: install.isInstall || remote.isRemoteExec,
275
+ isInstall: install.isInstall,
276
+ installs: install.installs,
277
+ isRemoteExec: remote.isRemoteExec,
278
+ remoteExec: remote.matches,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Resolve the install-command policy from env/settings (opt-in, so the default
284
+ * shell path is byte-unchanged). `audit` enables the audit trail; `riskFloor`
285
+ * ("low"|"medium"|"high") is the MINIMUM risk an install command is treated as
286
+ * (never lowers an already-higher risk).
287
+ */
288
+ export function resolveInstallPolicy({
289
+ env = process.env,
290
+ settings = null,
291
+ } = {}) {
292
+ const s = (settings && settings.installPolicy) || {};
293
+ const envAudit = env.CC_INSTALL_AUDIT;
294
+ const audit =
295
+ envAudit === "1" ? true : envAudit === "0" ? false : s.audit === true;
296
+ const riskFloor =
297
+ env.CC_INSTALL_RISK_FLOOR ||
298
+ (typeof s.riskFloor === "string" ? s.riskFloor : null) ||
299
+ null;
300
+ return {
301
+ audit,
302
+ riskFloor: riskFloor ? String(riskFloor).toLowerCase() : null,
303
+ enabled: audit || !!riskFloor,
304
+ };
305
+ }
306
+
307
+ const RISK_RANK = { low: 0, medium: 1, high: 2 };
308
+
309
+ /**
310
+ * Apply a risk floor: return whichever of `currentRisk` / `floor` is HIGHER
311
+ * (never downgrades). Unknown values pass `currentRisk` through unchanged.
312
+ */
313
+ export function applyRiskFloor(currentRisk, floor) {
314
+ if (!floor || !(floor in RISK_RANK)) return currentRisk;
315
+ const cur = RISK_RANK[currentRisk];
316
+ if (cur == null) return currentRisk;
317
+ return RISK_RANK[floor] > cur ? floor : currentRisk;
318
+ }
319
+
320
+ /** Best-effort append of one install-command audit record. Never throws. */
321
+ export function recordInstallCommandAudit(entry, opts = {}) {
322
+ try {
323
+ const baseDir =
324
+ opts.baseDir ||
325
+ (opts.env || process.env).CC_AUDIT_DIR ||
326
+ path.join(os.homedir(), ".chainlesschain", "audit");
327
+ const _fs = opts.fs || fs;
328
+ _fs.mkdirSync(baseDir, { recursive: true });
329
+ const line = JSON.stringify({
330
+ ts: new Date(opts.now ? opts.now() : Date.now()).toISOString(),
331
+ kind: "install-command",
332
+ ...entry,
333
+ });
334
+ _fs.appendFileSync(
335
+ path.join(baseDir, "install-commands.jsonl"),
336
+ line + "\n",
337
+ "utf-8",
338
+ );
339
+ return true;
340
+ } catch {
341
+ return false; // audit is best-effort — never affect the run
342
+ }
343
+ }
@@ -21,6 +21,7 @@ export {
21
21
  readEvents,
22
22
  rebuildMessages,
23
23
  listJsonlSessions,
24
+ listSessionIds,
24
25
  renameSession,
25
26
  pruneJsonlSessions,
26
27
  forkSession,