chainlesschain 0.162.154 → 0.162.155

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 (198) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/.build-hash +1 -1
  4. package/src/assets/web-panel/assets/{AIOps-CIpoXyVI.js → AIOps-Cth_zZeA.js} +1 -1
  5. package/src/assets/web-panel/assets/{ActionButton-Ch_QXIPk.js → ActionButton-zfwA3Oxn.js} +1 -1
  6. package/src/assets/web-panel/assets/{Analytics-CvaBYShv.js → Analytics-FeKc4aU2.js} +2 -2
  7. package/src/assets/web-panel/assets/AppLayout-C6WIrDZk.js +9 -0
  8. package/src/assets/web-panel/assets/{Audit-Bng8eHCW.js → Audit-C2gRq5Qi.js} +1 -1
  9. package/src/assets/web-panel/assets/BackgroundAgents-Dq2340Kl.css +1 -0
  10. package/src/assets/web-panel/assets/BackgroundAgents-TeTIFiW-.js +1 -0
  11. package/src/assets/web-panel/assets/{Backup-DvBs8-lm.js → Backup-CLJk3k6i.js} +1 -1
  12. package/src/assets/web-panel/assets/{BaseInput-DKwajm0g.js → BaseInput-5ms537NY.js} +1 -1
  13. package/src/assets/web-panel/assets/{Chat-BoGpZ3uO.js → Chat-4Nr5U9PJ.js} +5 -5
  14. package/src/assets/web-panel/assets/ChatBubbleRenderer-BFEnA8LN.js +1 -0
  15. package/src/assets/web-panel/assets/{Checkbox-BMJEmxFQ.js → Checkbox-B2Bca5ev.js} +1 -1
  16. package/src/assets/web-panel/assets/{Codegen-Bsoag5CV.js → Codegen-D6Uysdbv.js} +1 -1
  17. package/src/assets/web-panel/assets/{Col-CDjH9-sp.js → Col-B3XQ8xc7.js} +1 -1
  18. package/src/assets/web-panel/assets/{Community-Cg2QHPQ6.js → Community-CqwKtoPz.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compact-BU2BaqP-.js → Compact-BZZINTus.js} +1 -1
  20. package/src/assets/web-panel/assets/{Compliance-D6ZYMCqZ.js → Compliance-Dr3BXkWA.js} +1 -1
  21. package/src/assets/web-panel/assets/{Cowork-DaZ8VW-I.js → Cowork-rx_x9Wqc.js} +2 -2
  22. package/src/assets/web-panel/assets/{Cron-DQY1RH9v.js → Cron-BpZr7rHc.js} +2 -2
  23. package/src/assets/web-panel/assets/{Crosschain-BVR28vk9.js → Crosschain-CeV5EYVg.js} +1 -1
  24. package/src/assets/web-panel/assets/{DID-N44KvTP-.js → DID-fe2Qaowz.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dashboard-Bjhlx6wS.js → Dashboard-Fh_EdWVa.js} +2 -2
  26. package/src/assets/web-panel/assets/{Dropdown-CpLYxpy1.js → Dropdown-N1PIwWYf.js} +1 -1
  27. package/src/assets/web-panel/assets/{EmailListRenderer-srEITSPI.js → EmailListRenderer-B6vK93Ed.js} +1 -1
  28. package/src/assets/web-panel/assets/{FamilyGuardDashboard-juOC_fX2.js → FamilyGuardDashboard-e3iUZhAg.js} +1 -1
  29. package/src/assets/web-panel/assets/{Federation-nRaQtaXW.js → Federation-uInc9YcN.js} +1 -1
  30. package/src/assets/web-panel/assets/{FormItemContext-DflhTj09.js → FormItemContext-DmQZSX0z.js} +1 -1
  31. package/src/assets/web-panel/assets/{GenericCardRenderer-DulI_3NI.js → GenericCardRenderer-CV_Z8OAo.js} +1 -1
  32. package/src/assets/web-panel/assets/{Git-BxOPefXn.js → Git-jXAhNN8d.js} +2 -2
  33. package/src/assets/web-panel/assets/{Governance-C6G_Wnwi.js → Governance-Blw1esBB.js} +1 -1
  34. package/src/assets/web-panel/assets/{Inference-BnFYa2BF.js → Inference-CoK8Ia0Z.js} +1 -1
  35. package/src/assets/web-panel/assets/{KnowledgeGraph-TqQLEabm.js → KnowledgeGraph-5xrglMTK.js} +1 -1
  36. package/src/assets/web-panel/assets/{Logs-BH4AVh7s.js → Logs-BGIG9798.js} +2 -2
  37. package/src/assets/web-panel/assets/{Marketplace-DoWLTaEE.js → Marketplace-DFqhJMuY.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-D6yC7OG_.js → McpTools-7U8tEhwK.js} +5 -5
  39. package/src/assets/web-panel/assets/{Memory-DLCeHj21.js → Memory-BrvboPLE.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-BZBE0GRl.js → MobileBridge-nzcd7d1H.js} +1 -1
  41. package/src/assets/web-panel/assets/{MobileProjects-DJA15G1J.js → MobileProjects-DETVNKa7.js} +1 -1
  42. package/src/assets/web-panel/assets/{Mtc-CyTKJDHn.js → Mtc-SZcCYec3.js} +4 -4
  43. package/src/assets/web-panel/assets/{MtcAudit-DZKjgEVc.js → MtcAudit-Cx_d3N17.js} +5 -5
  44. package/src/assets/web-panel/assets/{Multisig-2rMPNhSO.js → Multisig-DtcmSUo-.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-VeMk4Fx-.js → NLProgramming-Dgx2QHl-.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-jr2GHMhh.js → Notes-oHTGEHoJ.js} +3 -3
  47. package/src/assets/web-panel/assets/{NotificationSettings-WN9VZyFb.js → NotificationSettings-qKD-IlOl.js} +1 -1
  48. package/src/assets/web-panel/assets/{OrderTableRenderer-CjNO-e9T.js → OrderTableRenderer-CiU4Sy1J.js} +1 -1
  49. package/src/assets/web-panel/assets/{Organization-DdjVHuzT.js → Organization-BBNXnltP.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-l8vBsGGq.js → Overflow-Df2fDbvT.js} +1 -1
  51. package/src/assets/web-panel/assets/{OverrideContext-BlgL9440.js → OverrideContext-C0w0P4A3.js} +1 -1
  52. package/src/assets/web-panel/assets/{P2P-CVQDHHwq.js → P2P-DUHj6zjO.js} +2 -2
  53. package/src/assets/web-panel/assets/{PdhVaultBrowser-CTsNh8qE.js → PdhVaultBrowser-B2nWgdoV.js} +4 -4
  54. package/src/assets/web-panel/assets/{Permissions-DLW87uyK.js → Permissions-CiW0afny.js} +3 -3
  55. package/src/assets/web-panel/assets/{PersonalDataHub-N8OvASTx.js → PersonalDataHub-cc1mHU_D.js} +2 -2
  56. package/src/assets/web-panel/assets/{Pipeline-BPZmdlz2.js → Pipeline-B93zTapU.js} +1 -1
  57. package/src/assets/web-panel/assets/{Privacy-MFZw_GNh.js → Privacy-Db1jXWfd.js} +1 -1
  58. package/src/assets/web-panel/assets/{ProjectInit-zW0pEsfU.js → ProjectInit-DFoZ1h9U.js} +2 -2
  59. package/src/assets/web-panel/assets/{ProjectSettings-CppVHr_o.js → ProjectSettings-BkkUnmYs.js} +2 -2
  60. package/src/assets/web-panel/assets/{Projects-BTglkJO2.js → Projects-Xa779XLC.js} +1 -1
  61. package/src/assets/web-panel/assets/{Providers-CPHZ6JKT.js → Providers-DOF01IEN.js} +1 -1
  62. package/src/assets/web-panel/assets/{QrScannerModal-DO4klNZ2.js → QrScannerModal-TohRsKd0.js} +1 -1
  63. package/src/assets/web-panel/assets/{QuickAsk-B37TTqUa.js → QuickAsk-DjLcr1ZU.js} +1 -1
  64. package/src/assets/web-panel/assets/{Recommend-n7KIfK7y.js → Recommend--Ism6bMx.js} +1 -1
  65. package/src/assets/web-panel/assets/{RemoteSession-SjVoS9mm.js → RemoteSession-B8CL3YHM.js} +2 -2
  66. package/src/assets/web-panel/assets/{Reputation-Dt6RTeHi.js → Reputation-CaRxB3Ep.js} +1 -1
  67. package/src/assets/web-panel/assets/{Row-Dty6jz5V.js → Row-BioxZBBH.js} +1 -1
  68. package/src/assets/web-panel/assets/{RssFeed-DDLhqw5j.js → RssFeed-B2LipwH3.js} +3 -3
  69. package/src/assets/web-panel/assets/{Search-DrYbXi-W.js → Search-BpjLBV8u.js} +1 -1
  70. package/src/assets/web-panel/assets/{Security-Fc9Z6fbB.js → Security-TibHuJWP.js} +3 -3
  71. package/src/assets/web-panel/assets/{Services-BAhPgjSM.js → Services-DtkuWseW.js} +2 -2
  72. package/src/assets/web-panel/assets/{Skeleton-8JF7EPfE.js → Skeleton-BIcPQae6.js} +1 -1
  73. package/src/assets/web-panel/assets/Skills-DoTWIQOX.js +1 -0
  74. package/src/assets/web-panel/assets/{Sla-CSvjcATc.js → Sla-uUSU185m.js} +1 -1
  75. package/src/assets/web-panel/assets/{SpeechSettings-Demx7Gnx.js → SpeechSettings-B7Bgc9mk.js} +1 -1
  76. package/src/assets/web-panel/assets/{SyncSettings-CKLnRWJr.js → SyncSettings-K-XrJ2s9.js} +2 -2
  77. package/src/assets/web-panel/assets/{Tasks-mhITRxjv.js → Tasks-B3hEXNcI.js} +1 -1
  78. package/src/assets/web-panel/assets/{Templates-AjQI5rxD.js → Templates-CaFnyGDK.js} +1 -1
  79. package/src/assets/web-panel/assets/{Tenant-dbtEh1yR.js → Tenant-C2X0PtEC.js} +1 -1
  80. package/src/assets/web-panel/assets/{Terminal-DX-9jjfk.js → Terminal-CKU5yVsT.js} +2 -2
  81. package/src/assets/web-panel/assets/{TimelineRenderer-C11EDZRt.js → TimelineRenderer-8trZeYDC.js} +1 -1
  82. package/src/assets/web-panel/assets/{Tokens-GiA9fiZn.js → Tokens-BUEEe9US.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trigger-BPcnclJn.js → Trigger-COU97I7Y.js} +1 -1
  84. package/src/assets/web-panel/assets/{Trust-C-DVY8nq.js → Trust-DNWQChaP.js} +1 -1
  85. package/src/assets/web-panel/assets/{UkeySign-DxodSwME.js → UkeySign-DWX_VtCp.js} +1 -1
  86. package/src/assets/web-panel/assets/{VideoEditing-D_WV0O8O.js → VideoEditing-D9eIDhsp.js} +1 -1
  87. package/src/assets/web-panel/assets/{Wallet-DSeNlVSU.js → Wallet-BOnCCgIL.js} +4 -4
  88. package/src/assets/web-panel/assets/{WebAuthn-Bwuuvpz1.js → WebAuthn-DN4dD-W8.js} +4 -4
  89. package/src/assets/web-panel/assets/{WorkflowEditor-8M96EU1N.js → WorkflowEditor-ClvDb_jX.js} +1 -1
  90. package/src/assets/web-panel/assets/{chat-D5S6SmLm.js → chat-DIxQ_Eeb.js} +1 -1
  91. package/src/assets/web-panel/assets/{collapseMotion-DVfhbsdP.js → collapseMotion-CqM1QOwM.js} +1 -1
  92. package/src/assets/web-panel/assets/{colors-5fbnGkAP.js → colors-BOX3WKcP.js} +1 -1
  93. package/src/assets/web-panel/assets/{compact-item-DaSuuMlW.js → compact-item-Bny82VDi.js} +1 -1
  94. package/src/assets/web-panel/assets/{createContext-BNOHcXAF.js → createContext-Cnoy3enS.js} +1 -1
  95. package/src/assets/web-panel/assets/devWarning-Dr9dzlQ5.js +1 -0
  96. package/src/assets/web-panel/assets/{echarts-CL0F2SnX.js → echarts-C61Vn3mP.js} +1 -1
  97. package/src/assets/web-panel/assets/{hasIn-DONwZUra.js → hasIn-BJ7fkx3F.js} +1 -1
  98. package/src/assets/web-panel/assets/{icons-BsDmGpcT.js → icons-DEmcnxBf.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-B_yDD9Lm.js → index--CcGiwAI.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-5_fj5gVd.js → index--Xb2lq6E.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-uR5KTNZA.js → index-2N9Y1cLx.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-MOgO5Yvx.js → index-7GLXvG8p.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-CLXf-8ek.js → index-B1-h2QrC.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-D7gs_3Ck.js → index-B6WoW8DJ.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-CztESwB4.js → index-B7Fah_BX.js} +1 -1
  106. package/src/assets/web-panel/assets/index-B92M3t6B.js +1 -0
  107. package/src/assets/web-panel/assets/{index-BEO5f7Vf.js → index-BDkN6YOI.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-_6S41ikV.js → index-BJ8-oLHI.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-CrkCFohi.js → index-BM0mQhVv.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-BrAJSu-v.js → index-BP7HFsAK.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-BcqOK7Lv.js → index-BWPey0Uf.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-BLzMuz38.js → index-BbZynivw.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-DbdQBn4n.js → index-BcmXbIwv.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-KwV9g_cf.js → index-Bk5yST66.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-ClGqO-2X.js → index-Bvheiep2.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-Dd7xwGcD.js → index-C--PPqwi.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-DP2-3A_j.js → index-C1nHEOFE.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-CAYmc5Nh.js → index-CEPTiBWy.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-LRIt-AFl.js → index-CEq5Vh0i.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-Dm5A-MN5.js → index-CN6emwbq.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-Cf2OCa8q.js → index-CQ-xLCVb.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-DHx2lhHW.js → index-CW6n-y1L.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-DM3Ug61e.js → index-CZ6CHiCQ.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-BJp4kRjw.js → index-CZCTan8Q.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-PsMErAB1.js → index-CnjtAKdw.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-CEzd5m5a.js → index-D2qNdrNW.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-B4L3gsNA.js → index-D9QEc7Tq.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BcWMZqb4.js → index-DBhkJqVF.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-CSfYJqXt.js → index-DSRIjAWO.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-CmUOjyL6.js → index-DxX8hhmL.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-RkInlwV0.js → index-Kio1ekYg.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-CTZeOnzO.js → index-OaNFXV8N.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-BK2N5yVW.js → index-Ty4w18mR.js} +4 -4
  134. package/src/assets/web-panel/assets/{index-Cn5LtyIH.js → index-ceH9DsAi.js} +1 -1
  135. package/src/assets/web-panel/assets/index-d2UcGpK-.js +1 -0
  136. package/src/assets/web-panel/assets/{index-xo2f4DZR.js → index-qOBKrw-g.js} +1 -1
  137. package/src/assets/web-panel/assets/{index-vYtprlNs.js → index-rn6xd12s.js} +1 -1
  138. package/src/assets/web-panel/assets/{initDefaultProps-CxwLynp9.js → initDefaultProps-BmxMBuUZ.js} +1 -1
  139. package/src/assets/web-panel/assets/{motion-XbPvGJbW.js → motion-DnXNwo2k.js} +1 -1
  140. package/src/assets/web-panel/assets/{move-D1u5pcgS.js → move-WnIJwmZN.js} +1 -1
  141. package/src/assets/web-panel/assets/{mtc-parser-BDxhTBMH.js → mtc-parser-DAto-c2w.js} +1 -1
  142. package/src/assets/web-panel/assets/{omit-EtIFGg5k.js → omit-BtvW9W0H.js} +1 -1
  143. package/src/assets/web-panel/assets/{pickAttrs-D33sWb0l.js → pickAttrs-DFqI9-_C.js} +1 -1
  144. package/src/assets/web-panel/assets/{placementArrow-BwFRzaGo.js → placementArrow-Dax6wOK3.js} +1 -1
  145. package/src/assets/web-panel/assets/{responsiveObserve-CBk2WqvQ.js → responsiveObserve-BUFWWC83.js} +1 -1
  146. package/src/assets/web-panel/assets/{slide-B52HtrKn.js → slide-DyARyuSz.js} +1 -1
  147. package/src/assets/web-panel/assets/{statusUtils-D4qxqWnH.js → statusUtils-BZSDugpi.js} +1 -1
  148. package/src/assets/web-panel/assets/{styleChecker-BxaCFlOL.js → styleChecker-B7o_K-4R.js} +1 -1
  149. package/src/assets/web-panel/assets/useFlexGapSupport-Dg7Bg8VG.js +1 -0
  150. package/src/assets/web-panel/assets/{useFs-C6VAU1_6.js → useFs-DQYXo5Rk.js} +1 -1
  151. package/src/assets/web-panel/assets/{useMergedState-Dc9sO2d7.js → useMergedState-DXbJa_zE.js} +1 -1
  152. package/src/assets/web-panel/assets/{usePersonalDataHub-rEG6oXG8.js → usePersonalDataHub-E8NBAnkq.js} +1 -1
  153. package/src/assets/web-panel/assets/{useRefs-CAeFRzPf.js → useRefs-BrCfMLMc.js} +1 -1
  154. package/src/assets/web-panel/assets/{useState-LwExUYDO.js → useState-CnxtbLoC.js} +1 -1
  155. package/src/assets/web-panel/assets/{vendor-BMxUs6UX.js → vendor-Bn1JzzAn.js} +1 -1
  156. package/src/assets/web-panel/assets/{vnode-DOTafkEi.js → vnode-DD71_w1b.js} +1 -1
  157. package/src/assets/web-panel/assets/{zoom-BuwwN5K2.js → zoom-zc9EMd3q.js} +1 -1
  158. package/src/assets/web-panel/index.html +3 -3
  159. package/src/command-manifest.json +36 -1
  160. package/src/commands/agent.js +23 -2
  161. package/src/commands/agents.js +62 -1
  162. package/src/commands/auto-mode.js +87 -0
  163. package/src/commands/background-session.js +499 -0
  164. package/src/commands/command.js +4 -1
  165. package/src/commands/permissions.js +49 -0
  166. package/src/commands/remote-control.js +407 -0
  167. package/src/data/changelog.json +7 -2
  168. package/src/gateways/ws/background-agent-protocol.js +337 -0
  169. package/src/gateways/ws/message-dispatcher.js +10 -0
  170. package/src/gateways/ws/remote-session-protocol.js +102 -1
  171. package/src/gateways/ws/ws-server.js +61 -0
  172. package/src/harness/mcp-client.js +8 -0
  173. package/src/index.js +10 -0
  174. package/src/lib/auto-mode-config.js +396 -0
  175. package/src/lib/background-agent-supervisor.js +202 -17
  176. package/src/lib/background-session-transport.js +315 -0
  177. package/src/lib/permission-denial-store.js +159 -0
  178. package/src/lib/remote-approval-bridge.js +360 -0
  179. package/src/lib/remote-control.js +270 -0
  180. package/src/lib/repl-denials.js +26 -0
  181. package/src/lib/shell-approval.js +4 -0
  182. package/src/lib/ws-rpc-client.js +150 -0
  183. package/src/repl/agent-repl.js +208 -16
  184. package/src/repl/permission-tier.js +31 -0
  185. package/src/runtime/agent-core.js +57 -0
  186. package/src/runtime/headless-runner.js +126 -8
  187. package/src/runtime/headless-stream.js +87 -1
  188. package/src/runtime/mcp-config.js +11 -0
  189. package/src/runtime/mcp-tool-search.js +0 -0
  190. package/src/runtime/policies/agent-policy.js +17 -0
  191. package/src/workers/background-agent-worker.js +218 -37
  192. package/src/assets/web-panel/assets/AppLayout-B6Q6olcr.js +0 -9
  193. package/src/assets/web-panel/assets/ChatBubbleRenderer-mDQl_PXO.js +0 -1
  194. package/src/assets/web-panel/assets/Skills-DL39dY0t.js +0 -1
  195. package/src/assets/web-panel/assets/devWarning-K_woVElh.js +0 -1
  196. package/src/assets/web-panel/assets/index-3ILvaBpo.js +0 -1
  197. package/src/assets/web-panel/assets/index-JpB-FoZq.js +0 -1
  198. package/src/assets/web-panel/assets/useFlexGapSupport-B1lWlqXN.js +0 -1
@@ -0,0 +1,360 @@
1
+ /**
2
+ * RemoteApprovalBridge — routes interactive permission asks from a LOCAL
3
+ * agent process (headless run / REPL) to paired mobile/web devices
4
+ * (gap-analysis 第四阶段 #2).
5
+ *
6
+ * The bridge is the remote-session HOST for a client-hosted session: it
7
+ * connects to a cc WS server (self-hosted or `cc remote-control start`),
8
+ * registers a remote session under this process's agent session id, and then
9
+ *
10
+ * ask: publishes a `permission.request` runtime event (fan-out delivers
11
+ * to WS members, E2EE relay members, and triggers vendor push wake —
12
+ * the event type matches isApprovalRequestEvent).
13
+ * answer: paired devices publish `approval.resolve`; because the session is
14
+ * NOT server-hosted, the server forwards it back to this host as a
15
+ * `remote-session-control` frame (batch-18 protocol extension) and
16
+ * the bridge settles the pending gate decision.
17
+ * after: publishes `permission.resolved` so device UIs can clear the card.
18
+ *
19
+ * `makeConfirmer()` adapts this to the ApprovalGate confirmer contract
20
+ * (`async (ctx) => boolean`) with an optional local fallback racing the
21
+ * remote decision and a fail-closed timeout.
22
+ */
23
+
24
+ import { randomBytes } from "crypto";
25
+ import { WsRpcClient } from "./ws-rpc-client.js";
26
+ import {
27
+ buildDirectPairingUri,
28
+ pickLanAddress,
29
+ renderQrCode,
30
+ resolveRemoteControlOptions,
31
+ } from "./remote-control.js";
32
+
33
+ const DEFAULT_DECISION_TIMEOUT_MS = 5 * 60 * 1000;
34
+
35
+ export class RemoteApprovalBridge {
36
+ constructor({
37
+ wsUrl,
38
+ token = null,
39
+ agentSessionId,
40
+ name = null,
41
+ scopes = ["observe", "approve"],
42
+ decisionTimeoutMs = DEFAULT_DECISION_TIMEOUT_MS,
43
+ createClient = null,
44
+ now = Date.now,
45
+ } = {}) {
46
+ if (!wsUrl) throw new Error("wsUrl is required");
47
+ if (!agentSessionId) throw new Error("agentSessionId is required");
48
+ this.wsUrl = wsUrl;
49
+ this.token = token;
50
+ this.agentSessionId = agentSessionId;
51
+ this.name = name || `approval-bridge:${agentSessionId}`;
52
+ this.scopes = scopes;
53
+ this.decisionTimeoutMs = decisionTimeoutMs;
54
+ this._createClient = createClient || ((url) => new WsRpcClient({ url }));
55
+ this._now = now;
56
+ this.client = null;
57
+ this.remoteSessionId = null;
58
+ this.pairing = null;
59
+ this._pending = new Map(); // requestId → { resolve, timer }
60
+ this._counter = 0;
61
+ this._closed = false;
62
+ }
63
+
64
+ /** Connect, register the client-hosted remote session, start listening. */
65
+ async start() {
66
+ this.client = this._createClient(this.wsUrl);
67
+ await this.client.connect();
68
+ await this.client.auth(this.token);
69
+ this.client.onEvent((message) => this._onServerEvent(message));
70
+ const created = await this.client.request("remote-session-create", {
71
+ sessionId: this.agentSessionId,
72
+ name: this.name,
73
+ scopes: this.scopes,
74
+ });
75
+ this.remoteSessionId = created.session.sessionId;
76
+ this.pairing = created.pairing;
77
+ return this;
78
+ }
79
+
80
+ /**
81
+ * Pairing descriptor for devices. Relay-configured servers return a ready
82
+ * E2EE URI; otherwise build the direct-LAN URI from the caller's endpoint.
83
+ */
84
+ pairingInfo({ lanWsUrl = null } = {}) {
85
+ if (!this.pairing) return null;
86
+ const uri =
87
+ this.pairing.uri ||
88
+ buildDirectPairingUri({
89
+ wsUrl: lanWsUrl || this.wsUrl,
90
+ serverToken: this.token,
91
+ remoteSessionId: this.remoteSessionId,
92
+ agentSessionId: this.agentSessionId,
93
+ pairingToken: this.pairing.token,
94
+ scopes: this.pairing.scopes,
95
+ expiresAt: this.pairing.expiresAt,
96
+ });
97
+ return {
98
+ uri,
99
+ remoteSessionId: this.remoteSessionId,
100
+ scopes: this.pairing.scopes,
101
+ expiresAt: this.pairing.expiresAt,
102
+ };
103
+ }
104
+
105
+ _onServerEvent(message) {
106
+ if (
107
+ message?.type !== "remote-session-control" ||
108
+ message.remoteSessionId !== this.remoteSessionId ||
109
+ message.event?.type !== "approval.resolve"
110
+ ) {
111
+ return;
112
+ }
113
+ const requestId = message.event.requestId || message.event.approvalId;
114
+ const pending = requestId ? this._pending.get(requestId) : null;
115
+ if (!pending) return;
116
+ this._pending.delete(requestId);
117
+ clearTimeout(pending.timer);
118
+ const answer = message.event.answer ?? message.event.approved;
119
+ pending.resolve({
120
+ approved: answer === true || answer === "true" || answer === "yes",
121
+ via: "remote",
122
+ from: message.from || null,
123
+ });
124
+ }
125
+
126
+ /** Number of devices that can approve (excludes this host). */
127
+ async approverCount() {
128
+ try {
129
+ const devices = await this.client.request("remote-session-devices", {
130
+ remoteSessionId: this.remoteSessionId,
131
+ });
132
+ return (devices.devices || []).filter(
133
+ (device) => !device.isHost && device.scopes?.includes("approve"),
134
+ ).length;
135
+ } catch {
136
+ return 0;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Publish a permission ask to paired devices and await the decision.
142
+ * Resolves `{approved, via, from}`; times out fail-closed (`approved:false,
143
+ * via:"timeout"`). The `permission.request` type triggers vendor push wake.
144
+ */
145
+ requestDecision({
146
+ tool,
147
+ action = null,
148
+ detail = null,
149
+ timeoutMs,
150
+ onRequestId = null,
151
+ } = {}) {
152
+ const requestId = `ra-${process.pid}-${++this._counter}-${randomBytes(4).toString("hex")}`;
153
+ if (onRequestId) {
154
+ try {
155
+ onRequestId(requestId);
156
+ } catch {
157
+ // observer only
158
+ }
159
+ }
160
+ return new Promise((resolve) => {
161
+ const timer = setTimeout(() => {
162
+ this._pending.delete(requestId);
163
+ this._publish({
164
+ type: "permission.resolved",
165
+ requestId,
166
+ approved: false,
167
+ via: "timeout",
168
+ });
169
+ resolve({ approved: false, via: "timeout", from: null });
170
+ }, timeoutMs || this.decisionTimeoutMs);
171
+ if (typeof timer.unref === "function") timer.unref();
172
+ this._pending.set(requestId, {
173
+ timer,
174
+ resolve: (decision) => {
175
+ this._publish({
176
+ type: "permission.resolved",
177
+ requestId,
178
+ approved: decision.approved,
179
+ via: decision.via,
180
+ });
181
+ resolve(decision);
182
+ },
183
+ });
184
+ this._publish({
185
+ type: "permission.request",
186
+ requestId,
187
+ tool: tool || null,
188
+ action,
189
+ detail,
190
+ askedAt: this._now(),
191
+ });
192
+ });
193
+ }
194
+
195
+ /** Settle a pending ask locally (e.g. the terminal user answered first). */
196
+ resolveLocally(requestId, approved) {
197
+ const pending = this._pending.get(requestId);
198
+ if (!pending) return false;
199
+ this._pending.delete(requestId);
200
+ clearTimeout(pending.timer);
201
+ pending.resolve({ approved, via: "local", from: null });
202
+ return true;
203
+ }
204
+
205
+ _publish(event) {
206
+ if (this._closed || !this.client) return;
207
+ this.client
208
+ .request("remote-session-publish", {
209
+ remoteSessionId: this.remoteSessionId,
210
+ event,
211
+ })
212
+ .catch(() => {
213
+ // Best-effort — a device that missed the request simply cannot answer;
214
+ // the timeout keeps the gate fail-closed.
215
+ });
216
+ }
217
+
218
+ /**
219
+ * ApprovalGate confirmer (`async (ctx) => boolean`). With a `fallback`
220
+ * confirmer, the LOCAL prompt races the remote decision (first answer wins);
221
+ * without one (headless), the remote decision alone gates, fail-closed on
222
+ * timeout.
223
+ */
224
+ makeConfirmer({ fallback = null, timeoutMs = null, onAsk = null } = {}) {
225
+ return async (ctx) => {
226
+ const ask = {
227
+ tool: ctx?.tool || ctx?.name || null,
228
+ action: ctx?.action || null,
229
+ detail:
230
+ typeof ctx?.command === "string"
231
+ ? ctx.command
232
+ : ctx?.args
233
+ ? JSON.stringify(ctx.args).slice(0, 2000)
234
+ : null,
235
+ };
236
+ if (onAsk) {
237
+ try {
238
+ onAsk(ask);
239
+ } catch {
240
+ // observer only
241
+ }
242
+ }
243
+ let requestId = null;
244
+ const remote = this.requestDecision({
245
+ ...ask,
246
+ timeoutMs,
247
+ onRequestId: (id) => {
248
+ requestId = id;
249
+ },
250
+ });
251
+ if (!fallback) {
252
+ const decision = await remote;
253
+ return decision.approved;
254
+ }
255
+ const local = Promise.resolve()
256
+ .then(() => fallback(ctx))
257
+ .then((approved) => ({ approved: approved === true, via: "local" }));
258
+ const decision = await Promise.race([remote, local]);
259
+ // Local answer won → settle the remote ask too (publishes
260
+ // permission.resolved so device UIs clear the pending card).
261
+ if (decision.via === "local" && requestId) {
262
+ this.resolveLocally(requestId, decision.approved);
263
+ }
264
+ return decision.approved;
265
+ };
266
+ }
267
+
268
+ async close() {
269
+ this._closed = true;
270
+ for (const [requestId, pending] of this._pending) {
271
+ clearTimeout(pending.timer);
272
+ pending.resolve({ approved: false, via: "closed", from: null });
273
+ this._pending.delete(requestId);
274
+ }
275
+ try {
276
+ if (this.client && this.remoteSessionId) {
277
+ await this.client
278
+ .request("remote-session-close", {
279
+ remoteSessionId: this.remoteSessionId,
280
+ })
281
+ .catch(() => undefined);
282
+ }
283
+ } finally {
284
+ this.client?.close();
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * One-call assembly for headless runs (`cc agent -p --remote-control`):
291
+ * self-hosts a lightweight WS server on an OS-assigned port (no sessionManager
292
+ * needed — the session stays client-hosted), starts a bridge for this run's
293
+ * session, prints the pairing URI (+ optional QR) to stderr, and returns the
294
+ * gate confirmer + teardown. Relay settings (env/config) flow through, so a
295
+ * configured relay yields an E2EE pairing URI reaching devices off-LAN.
296
+ */
297
+ export async function startHeadlessRemoteApproval({
298
+ agentSessionId,
299
+ env = process.env,
300
+ config = {},
301
+ writeErr = () => {},
302
+ isText = false,
303
+ decisionTimeoutMs = undefined,
304
+ deps = {},
305
+ } = {}) {
306
+ const { ChainlessChainWSServer } =
307
+ deps.serverModule || (await import("../gateways/ws/ws-server.js"));
308
+ const resolved = resolveRemoteControlOptions({ flags: {}, env, config });
309
+ const server = new ChainlessChainWSServer({
310
+ port: 0,
311
+ host: resolved.host,
312
+ token: resolved.token,
313
+ remoteSessionRelayUrl: resolved.relayUrl,
314
+ remoteSessionPeerId: resolved.peerId,
315
+ });
316
+ await server.start();
317
+ const bridge = new RemoteApprovalBridge({
318
+ wsUrl: `ws://127.0.0.1:${server.port}`,
319
+ token: resolved.token,
320
+ agentSessionId,
321
+ scopes: ["observe", "approve"],
322
+ decisionTimeoutMs,
323
+ });
324
+ try {
325
+ await bridge.start();
326
+ } catch (err) {
327
+ await server.stop().catch(() => undefined);
328
+ throw err;
329
+ }
330
+ const lanAddress = deps.lanAddress || pickLanAddress() || "127.0.0.1";
331
+ const pairing = bridge.pairingInfo({
332
+ lanWsUrl: `ws://${lanAddress}:${server.port}`,
333
+ });
334
+ if (isText) {
335
+ writeErr(
336
+ " remote-control: approvals can be answered from a paired device\n",
337
+ );
338
+ writeErr(` pairing: ${pairing.uri}\n`);
339
+ const qr = await renderQrCode(pairing.uri, deps);
340
+ if (qr) writeErr(qr + "\n");
341
+ }
342
+ return {
343
+ pairing,
344
+ server,
345
+ bridge,
346
+ confirmer: bridge.makeConfirmer({
347
+ onAsk: (ask) => {
348
+ if (isText) {
349
+ writeErr(
350
+ ` permission(${ask.tool || "?"}): waiting for remote approval…\n`,
351
+ );
352
+ }
353
+ },
354
+ }),
355
+ close: async () => {
356
+ await bridge.close().catch(() => undefined);
357
+ await server.stop().catch(() => undefined);
358
+ },
359
+ };
360
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * remote-control core — option resolution, pairing payloads, and discovery
3
+ * state for the unified `cc remote-control` entry (gap-analysis 第四阶段 #1).
4
+ *
5
+ * The heavy lifting (WS server, remote-session registry, E2EE relay, push)
6
+ * already exists under src/gateways + src/harness; this module only composes
7
+ * it into one entry point:
8
+ *
9
+ * - resolveRemoteControlOptions() flags > env > config > defaults
10
+ * - buildDirectPairingUri() LAN pairing descriptor (no relay needed)
11
+ * - parseDirectPairingUri() inverse, used by clients + tests
12
+ * - pickLanAddress() best-effort non-internal IPv4
13
+ * - state file read/write ~/.chainlesschain/remote-control/<port>.json
14
+ * so `cc remote-control status/stop` can discover a running host process.
15
+ * The file is 0600 and CONTAINS THE SERVER TOKEN — same local trust model
16
+ * as ~/.chainlesschain/ide/<port>.json (readable only by the OS user).
17
+ * - renderQrCode() lazy OPTIONAL `qrcode` import; returns
18
+ * null when the package is not installed (URI printing is the contract,
19
+ * the QR is progressive enhancement — no hard dependency, trap #6/#27).
20
+ */
21
+
22
+ import fs from "fs";
23
+ import os from "os";
24
+ import path from "path";
25
+ import { randomBytes } from "crypto";
26
+ import { REMOTE_SESSION_SCOPES } from "../harness/remote-session-registry.js";
27
+
28
+ export const REMOTE_CONTROL_DEFAULT_PORT = 18800;
29
+ export const REMOTE_CONTROL_PAIRING_SCHEME =
30
+ "chainlesschain://remote-control/pair#";
31
+ export const REMOTE_CONTROL_DEFAULT_SCOPES = Object.freeze([
32
+ "observe",
33
+ "prompt",
34
+ "approve",
35
+ "interrupt",
36
+ ]);
37
+
38
+ function b64url(value) {
39
+ return Buffer.from(value, "utf8").toString("base64url");
40
+ }
41
+
42
+ function unb64url(value) {
43
+ return Buffer.from(String(value), "base64url").toString("utf8");
44
+ }
45
+
46
+ /** Parse a comma/space separated scope list, validating against the registry. */
47
+ export function parseScopes(raw) {
48
+ if (raw == null || raw === "") return [...REMOTE_CONTROL_DEFAULT_SCOPES];
49
+ const scopes = String(raw)
50
+ .split(/[\s,]+/)
51
+ .map((scope) => scope.trim())
52
+ .filter(Boolean);
53
+ if (scopes.length === 0) return [...REMOTE_CONTROL_DEFAULT_SCOPES];
54
+ for (const scope of scopes) {
55
+ if (!REMOTE_SESSION_SCOPES.includes(scope)) {
56
+ throw new Error(
57
+ `Unknown remote-control scope "${scope}". Valid: ${REMOTE_SESSION_SCOPES.join(", ")}`,
58
+ );
59
+ }
60
+ }
61
+ return [...new Set(scopes)];
62
+ }
63
+
64
+ /**
65
+ * Resolve effective start options. Precedence: explicit flags > environment >
66
+ * config (`remoteControl` / legacy `remoteSession` blocks) > defaults. A
67
+ * missing token is AUTO-GENERATED (32 hex chars) — the WS server must never
68
+ * come up unauthenticated on the unified entry, even on loopback, because the
69
+ * pairing URI embeds the token and pairing is the whole point.
70
+ */
71
+ export function resolveRemoteControlOptions({
72
+ flags = {},
73
+ env = process.env,
74
+ config = {},
75
+ } = {}) {
76
+ const rc = config.remoteControl || {};
77
+ const legacy = config.remoteSession || {};
78
+ const port = Number(
79
+ flags.port ?? env.CC_REMOTE_CONTROL_PORT ?? rc.port ?? undefined,
80
+ );
81
+ const resolvedPort =
82
+ Number.isFinite(port) && port >= 1 && port <= 65535
83
+ ? Math.floor(port)
84
+ : REMOTE_CONTROL_DEFAULT_PORT;
85
+ const token =
86
+ flags.token ||
87
+ env.CC_REMOTE_CONTROL_TOKEN ||
88
+ rc.token ||
89
+ randomBytes(16).toString("hex");
90
+ const relayUrl =
91
+ flags.relayUrl ||
92
+ env.CC_REMOTE_SESSION_RELAY_URL ||
93
+ rc.relayUrl ||
94
+ legacy.relayUrl ||
95
+ null;
96
+ const peerId =
97
+ flags.peerId ||
98
+ env.CC_REMOTE_SESSION_PEER_ID ||
99
+ rc.peerId ||
100
+ legacy.peerId ||
101
+ (relayUrl
102
+ ? `cc-host-${os.hostname()}-${randomBytes(4).toString("hex")}`
103
+ : null);
104
+ return {
105
+ port: resolvedPort,
106
+ host: flags.host || rc.host || "0.0.0.0",
107
+ token,
108
+ relayUrl,
109
+ peerId,
110
+ scopes: parseScopes(flags.scopes ?? rc.scopes ?? null),
111
+ name: flags.name || rc.name || `remote-control @ ${os.hostname()}`,
112
+ };
113
+ }
114
+
115
+ /** First non-internal IPv4 address, or null. Pure over an interfaces map. */
116
+ export function pickLanAddress(interfaces = os.networkInterfaces()) {
117
+ for (const entries of Object.values(interfaces || {})) {
118
+ for (const entry of entries || []) {
119
+ const family =
120
+ entry.family === "IPv4" || entry.family === 4 ? "IPv4" : entry.family;
121
+ if (family === "IPv4" && !entry.internal && entry.address) {
122
+ return entry.address;
123
+ }
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Direct (LAN) pairing descriptor for hosts without a signaling relay: the
131
+ * device connects straight to the host's WS endpoint, authenticates with the
132
+ * embedded server token, then joins the remote session with the one-time
133
+ * pairing token. Encodes as `chainlesschain://remote-control/pair#<b64url json>`
134
+ * so the same string works as QR payload and deep link.
135
+ */
136
+ export function buildDirectPairingUri({
137
+ wsUrl,
138
+ serverToken,
139
+ remoteSessionId,
140
+ agentSessionId,
141
+ pairingToken,
142
+ scopes,
143
+ expiresAt,
144
+ }) {
145
+ if (!wsUrl || !remoteSessionId || !pairingToken) {
146
+ throw new Error("wsUrl, remoteSessionId and pairingToken are required");
147
+ }
148
+ const payload = {
149
+ v: 1,
150
+ transport: "direct",
151
+ wsUrl,
152
+ serverToken: serverToken || null,
153
+ remoteSessionId,
154
+ agentSessionId: agentSessionId || null,
155
+ pairingToken,
156
+ scopes: Array.isArray(scopes) ? scopes : null,
157
+ expiresAt: expiresAt ?? null,
158
+ };
159
+ return REMOTE_CONTROL_PAIRING_SCHEME + b64url(JSON.stringify(payload));
160
+ }
161
+
162
+ /** Inverse of buildDirectPairingUri. Throws on malformed input. */
163
+ export function parseDirectPairingUri(uri) {
164
+ if (
165
+ typeof uri !== "string" ||
166
+ !uri.startsWith(REMOTE_CONTROL_PAIRING_SCHEME)
167
+ ) {
168
+ throw new Error("Not a remote-control pairing URI");
169
+ }
170
+ const payload = JSON.parse(
171
+ unb64url(uri.slice(REMOTE_CONTROL_PAIRING_SCHEME.length)),
172
+ );
173
+ if (payload.v !== 1 || payload.transport !== "direct") {
174
+ throw new Error(
175
+ `Unsupported remote-control pairing payload (v=${payload.v}, transport=${payload.transport})`,
176
+ );
177
+ }
178
+ return payload;
179
+ }
180
+
181
+ // ─── discovery state files ────────────────────────────────────────────────
182
+
183
+ export function remoteControlStateDir(homedir = os.homedir()) {
184
+ return path.join(homedir, ".chainlesschain", "remote-control");
185
+ }
186
+
187
+ function stateFilePath(dir, port) {
188
+ return path.join(dir, `${port}.json`);
189
+ }
190
+
191
+ /**
192
+ * Persist a running host's discovery record (0600 file, 0700 dir). Contains
193
+ * the server token — local-user trust domain only, mirroring the IDE bridge.
194
+ */
195
+ export function writeRemoteControlState(state, { dir } = {}) {
196
+ const stateDir = dir || remoteControlStateDir();
197
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
198
+ const file = stateFilePath(stateDir, state.port);
199
+ fs.writeFileSync(file, JSON.stringify(state, null, 2), {
200
+ encoding: "utf-8",
201
+ mode: 0o600,
202
+ });
203
+ return file;
204
+ }
205
+
206
+ export function removeRemoteControlState(port, { dir } = {}) {
207
+ const file = stateFilePath(dir || remoteControlStateDir(), port);
208
+ try {
209
+ fs.unlinkSync(file);
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ export function isPidAlive(pid) {
217
+ if (!Number.isFinite(pid) || pid <= 0) return false;
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ } catch (err) {
222
+ // EPERM = alive but owned elsewhere; ESRCH = gone.
223
+ return err.code === "EPERM";
224
+ }
225
+ }
226
+
227
+ /**
228
+ * All discovery records with liveness annotated. Unreadable/corrupt files are
229
+ * reported as `invalid` rather than thrown (a stale half-written file must
230
+ * never break `status`).
231
+ */
232
+ export function readRemoteControlStates({ dir } = {}) {
233
+ const stateDir = dir || remoteControlStateDir();
234
+ let names = [];
235
+ try {
236
+ names = fs.readdirSync(stateDir).filter((name) => name.endsWith(".json"));
237
+ } catch {
238
+ return [];
239
+ }
240
+ const states = [];
241
+ for (const name of names) {
242
+ const file = path.join(stateDir, name);
243
+ try {
244
+ const state = JSON.parse(fs.readFileSync(file, "utf-8"));
245
+ states.push({
246
+ ...state,
247
+ stateFile: file,
248
+ alive: isPidAlive(state.pid),
249
+ });
250
+ } catch {
251
+ states.push({ stateFile: file, invalid: true, alive: false });
252
+ }
253
+ }
254
+ return states;
255
+ }
256
+
257
+ /**
258
+ * Terminal QR for a pairing URI via the OPTIONAL `qrcode` package. Returns
259
+ * null when unavailable/failed — callers always print the URI regardless.
260
+ */
261
+ export async function renderQrCode(text, _deps = {}) {
262
+ try {
263
+ const importer = _deps.importer || ((spec) => import(spec));
264
+ const mod = await importer("qrcode");
265
+ const qrcode = mod.default || mod;
266
+ return await qrcode.toString(text, { type: "terminal", small: true });
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
@@ -61,6 +61,10 @@ export function classifyDenial(ev = {}) {
61
61
  (shellPol ? "shell-policy" : viaFromPrefix(errStr)) ||
62
62
  "policy";
63
63
  const rule = (policy && policy.rule) || (shellPol && shellPol.ruleId) || null;
64
+ const chain =
65
+ result && Array.isArray(result.permissionChain)
66
+ ? result.permissionChain
67
+ : null;
64
68
  return {
65
69
  tool: tool || "?",
66
70
  summary: String(
@@ -69,9 +73,29 @@ export function classifyDenial(ev = {}) {
69
73
  reason: errStr || "(denied)",
70
74
  via,
71
75
  rule,
76
+ ...(chain ? { chain } : {}),
72
77
  };
73
78
  }
74
79
 
80
+ /**
81
+ * One compact line for a layer-by-layer permission chain, e.g.
82
+ * `settings-rules→no-match · shell-policy→warn · approval-gate→deny (user-deny, trusted)`.
83
+ * Returns null when there is nothing to explain.
84
+ */
85
+ export function formatDenialChain(chain) {
86
+ if (!Array.isArray(chain) || chain.length === 0) return null;
87
+ const parts = chain.map((step) => {
88
+ const base = `${step.layer}→${step.outcome ?? "?"}`;
89
+ const detail = [
90
+ step.rule ? String(step.rule) : null,
91
+ step.via && step.via !== step.outcome ? String(step.via) : null,
92
+ step.policy ? String(step.policy) : null,
93
+ ].filter(Boolean);
94
+ return detail.length ? `${base} (${detail.join(", ")})` : base;
95
+ });
96
+ return parts.join(" · ");
97
+ }
98
+
75
99
  /** Two denials are "the same" attempt when tool + command + rule + via match. */
76
100
  function sameDenial(a, b) {
77
101
  return (
@@ -131,6 +155,8 @@ export function formatDenials(log, opts = {}) {
131
155
  const what = d.summary ? `${d.tool} ${d.summary}` : d.tool || "?";
132
156
  const times = d.count > 1 ? ` ×${d.count}` : "";
133
157
  lines.push(` • ${what}${times} [${where}${ago}]`);
158
+ const chainLine = formatDenialChain(d.chain);
159
+ if (chainLine) lines.push(` chain: ${chainLine}`);
134
160
  if (d.reason) lines.push(` ${d.reason}`);
135
161
  }
136
162
  return lines.join("\n");
@@ -87,5 +87,9 @@ export async function evaluateShellCommandWithApproval({
87
87
  shellPolicy,
88
88
  riskLevel,
89
89
  policy: gateResult.policy,
90
+ // Explainability passthrough (autoMode.decisions wrapper attaches these):
91
+ // which gate rule fired and why — surfaced in the permission chain.
92
+ gateReason: gateResult.reason ?? null,
93
+ gateRule: gateResult.rule ?? null,
90
94
  };
91
95
  }