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.
- package/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-LbDx7jL6.js → AIOps-CDzzuWZV.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DnurtK34.js → ActionButton-Dy-8MmQi.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-DXSsV1am.js → Analytics-BtJ_VWkx.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-dqRpzNuD.js → AppLayout-DkjiI4VS.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-BwdmYepK.js → Artifacts-h8PyDO86.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-oSX0kOUG.js → Audit-DiDV0C6l.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-C2KxJahT.js → BackgroundAgents-DTw_aOaf.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-B4TkxWkC.js → Backup-CihFuUO9.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-CX5-KxeU.js → BaseInput-CqP2QNoR.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-O_HzD-PL.js → Chat-tm-ygX-p.js} +5 -5
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-Br8saHQA.js → ChatBubbleRenderer-BVKEhj_y.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-ChepDpC1.js → Checkbox-BFMyRN3Q.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-D9z8kc7q.js → Codegen-B1gTeoAS.js} +1 -1
- package/src/assets/web-panel/assets/{Col-B-H5eoNw.js → Col-1GP8MvKL.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Ccyp_Kk5.js → Community-BLY938F6.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BNvBznp_.js → Compact-DVt_p36I.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-D1FjRd8n.js → Compliance-DxqEBXVG.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-CV9B9u-8.js → Cowork-CyWrM-T8.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-BowJ4VIx.js → Cron-Domu6aI9.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BJc6jzRF.js → Crosschain-C8LKpjHJ.js} +1 -1
- package/src/assets/web-panel/assets/{DID-BEp8NV6D.js → DID-XBy15RZ5.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Dls0B3YB.js → Dashboard-DDAyPD-V.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-DQ7zXVtD.js → Dropdown-BHV0MZ13.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-B9q79tOl.js → EmailListRenderer-DmFGW4PX.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-CD8Ie-88.js → FamilyGuardDashboard-CqYdbKvd.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-5o7XU5Vf.js → Federation-YP5-Puv5.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DeKbMvOs.js → FormItemContext-BOmcIcTe.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-CZiQvz_z.js → GenericCardRenderer-CvTZdoc4.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CTvFxVV8.js → Git-Q2oxua0L.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-DwzFIjHt.js → Governance-D-WpyWEU.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CJBk59c-.js → Inference-NUtsYHQo.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DfsaXN2z.js → KnowledgeGraph-BHQLa8fr.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DVRYf18S.js → Logs-6WsR5Q1G.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-DuWoDwMx.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-BuIwyE4t.js → Marketplace-CigohRRW.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-D3U0o78E.js → McpTools-jcp6jVLf.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-QYVmyb3b.js → Memory-B00NU1ph.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-BFgrj6eZ.js → MobileBridge-Cs_p3PzU.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-BFs2WljZ.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-BZQu8TcH.js → Mtc-CSEflwzw.js} +5 -5
- package/src/assets/web-panel/assets/{MtcAudit-BnFfEkLo.js → MtcAudit-dxSiEZ96.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-iUnNS9Cm.js → Multisig-CDAFEAyq.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-DNNz1y9Y.js → NLProgramming-HoFpZNiB.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-CZezNYRU.js → Notes-tAoQvVcE.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-DBvrplIR.js → NotificationSettings-Czo2M9F4.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-CTDtee49.js → OrderTableRenderer-CM1kNTp6.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-CCOUJX1R.js → Organization-Cudr0VOC.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-DcZghdk0.js → Overflow-BSXeDKiN.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-jmddWX9q.js → P2P-Cqv_g-F9.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-wfjMcczl.js → PdhVaultBrowser-7rjprPh8.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-DU1GJjca.js → Permissions-97pIDQKE.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-BzTMzDF9.js → PersonalDataHub-bparK9eQ.js} +3 -3
- package/src/assets/web-panel/assets/{Pipeline-DPmjlpVc.js → Pipeline-CJgerM09.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B6HRLpfK.js → Privacy-JqFS3PN7.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Cyy86AsN.js → ProjectInit-DMITd-2c.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-Bl_WQxJ5.js → ProjectSettings-1UkdJ8Ii.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-Co9yKThA.js → Projects-CYZqegqw.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-DLa4NlIs.js → Providers-ChfXz3U0.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-BvzM7q5f.js → QrScannerModal-Rzkka-mf.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-BN8yyyUj.js → QuickAsk-DEz9HaON.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-QsVN4w0i.js → Recommend-EFGP8AVu.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-tkDXw-TY.js → RemoteSession-6um0lGGy.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-DAifkGsy.js → Reputation-CA1BXTup.js} +1 -1
- package/src/assets/web-panel/assets/{Row-D7dZEyak.js → Row-stTnDJ9y.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DB5sImTA.js → RssFeed-B10qCCoG.js} +2 -2
- package/src/assets/web-panel/assets/{Search-3py3TO9V.js → Search-DSJroZ6p.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DhNUfT_k.js → Security-BpXkwiMW.js} +4 -4
- package/src/assets/web-panel/assets/{Services-DjJAA_5D.js → Services-BPQxjwK6.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-u95JjK3n.js → Skeleton-CfbKAMul.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-DPo3L0Tz.js → Skills-CttYTfX0.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-DWhkAZV1.js → Sla-ByluHich.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-PUegMtqV.js → SpeechSettings-BdZXKsfV.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-BMNYrnrU.js → SyncSettings-xCR7iDzy.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-CYm_oTJC.js → Tasks-qTko_INn.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-Dw9Er5DN.js → Templates-CkIzhgDJ.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-7P3QIBSo.js → Tenant-BIaCaaK8.js} +1 -1
- package/src/assets/web-panel/assets/Terminal-B2Qq38XP.js +3 -0
- package/src/assets/web-panel/assets/{TimelineRenderer-DbCd1bDH.js → TimelineRenderer-D5eQDnEF.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-EQofpA0t.js → Tokens-DmeJ8NMQ.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-ChNLLypd.js → Trigger-C5sgb3C7.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-dlVGUKs5.js → Trust-H2vOb3Rb.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BXyBUhNF.js → UkeySign-NmPtHK2u.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BE8-68ge.js → VideoEditing-DHv-Ti7K.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-7ZIJOwNg.js → Wallet-BYZ3-PRP.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DgNd2Amr.js → WebAuthn-BaSlP7om.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-Z-Un-9l5.js → WorkflowEditor-CZ7IHuIi.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CtVDEkUc.js → chat-Dd9kY8gn.js} +1 -1
- package/src/assets/web-panel/assets/{colors-PXtJ2f1B.js → colors-yhzo6QSV.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-oN96nuBi.js → compact-item-C1JneVdM.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-okCJXQXa.js → createContext-C_waVCg2.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DaDlyVDY.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-pcHm--4o.js → hasIn-CkVg0Xnh.js} +1 -1
- package/src/assets/web-panel/assets/{index-BeAWCGEK.js → index-1VYhIr6D.js} +1 -1
- package/src/assets/web-panel/assets/{index-DC0bqrqV.js → index-BTMiVIBI.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQYes2mk.js → index-BYOyb5X2.js} +1 -1
- package/src/assets/web-panel/assets/{index-vW0lwj0Y.js → index-BjDnI7oM.js} +1 -1
- package/src/assets/web-panel/assets/{index-BUUJ2WfG.js → index-BlJNvg-2.js} +1 -1
- package/src/assets/web-panel/assets/{index-C0rfJBU3.js → index-BlzbSUK9.js} +1 -1
- package/src/assets/web-panel/assets/{index-Rk0X8lVz.js → index-BrN2hAeE.js} +1 -1
- package/src/assets/web-panel/assets/{index-BDR2mi1p.js → index-BsSsurcn.js} +1 -1
- package/src/assets/web-panel/assets/index-BtsLExcc.js +1 -0
- package/src/assets/web-panel/assets/{index-jAY55MSx.js → index-Bwuu3Bf3.js} +1 -1
- package/src/assets/web-panel/assets/{index-_SaFHNsG.js → index-Bx3A6X4J.js} +1 -1
- package/src/assets/web-panel/assets/{index-T3QE8LuV.js → index-C-Sscgnv.js} +1 -1
- package/src/assets/web-panel/assets/index-C3qDvvIb.js +1 -0
- package/src/assets/web-panel/assets/{index-BUTyx-Lk.js → index-C4HNdMCt.js} +1 -1
- package/src/assets/web-panel/assets/{index-nMqLdwao.js → index-CBwmwTNa.js} +1 -1
- package/src/assets/web-panel/assets/{index-BuME9k4Y.js → index-CErewFeT.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHJIfdCV.js → index-CGtoL5mR.js} +1 -1
- package/src/assets/web-panel/assets/{index-D0bfypsF.js → index-CUDjGt_V.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bl6N7ZyX.js → index-C_qqYZT7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CXhezK4d.js → index-C_riAa6d.js} +1 -1
- package/src/assets/web-panel/assets/{index-BnhHxZul.js → index-CfEMjbhu.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cs1fGk1Z.js → index-ClQhh6v3.js} +1 -1
- package/src/assets/web-panel/assets/{index-BjVIZfdA.js → index-CmiLmG0i.js} +1 -1
- package/src/assets/web-panel/assets/{index-CWUJlCvK.js → index-D1xIbAxQ.js} +3 -3
- package/src/assets/web-panel/assets/{index-DfL10EcN.js → index-D6UXDH2D.js} +1 -1
- package/src/assets/web-panel/assets/{index-C4ravjdv.js → index-D7kQ_4uL.js} +1 -1
- package/src/assets/web-panel/assets/{index-DtsyPnF5.js → index-DPkqQ2Pr.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQ1ZzJjN.js → index-DUA96wEj.js} +1 -1
- package/src/assets/web-panel/assets/{index-5aO2V0V4.js → index-DXHk9Vof.js} +1 -1
- package/src/assets/web-panel/assets/{index-CopGaCI_.js → index-DapftLUr.js} +1 -1
- package/src/assets/web-panel/assets/{index-D1efSh1X.js → index-Dez7TUgG.js} +1 -1
- package/src/assets/web-panel/assets/{index-DBcKBb1E.js → index-DhZgULsY.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bht9n8Sm.js → index-Diquc7-l.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAQktO-6.js → index-DuGtLiTW.js} +1 -1
- package/src/assets/web-panel/assets/{index-CK0H3pQW.js → index-U_nLXGIx.js} +1 -1
- package/src/assets/web-panel/assets/{index-BZKk4Lnm.js → index-_M8El5iz.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cc_MkksW.js → index-_VmzCYFn.js} +1 -1
- package/src/assets/web-panel/assets/{index-mB1UV7wA.js → index-_rZxgFtc.js} +1 -1
- package/src/assets/web-panel/assets/{index-BEtzqJw3.js → index-ewb0-LoH.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-CG1jyiI7.js → initDefaultProps-DNN6qtGa.js} +1 -1
- package/src/assets/web-panel/assets/{motion-C8BYa98D.js → motion-BcM9exeo.js} +1 -1
- package/src/assets/web-panel/assets/{move-D1hqwitW.js → move-DsKJ-YvA.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C2lol8rp.js → mtc-parser-glgGkxaK.js} +1 -1
- package/src/assets/web-panel/assets/{omit-Ct6Zghfi.js → omit-BwHCU50S.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CSYbYfdc.js → pickAttrs-BVp-bLt7.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-WCk8di0z.js → placementArrow-CZNe0r-w.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CEC8CZ-3.js → responsiveObserve-DUafj9pU.js} +1 -1
- package/src/assets/web-panel/assets/{slide-BsXyd7Lq.js → slide-H4NqXQxu.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-tDKldiUT.js → statusUtils-UjMzzGZ5.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BIS2unhx.js → styleChecker-qO5aCjyk.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-yFTVMDUv.js → useFlexGapSupport-ChecYwC8.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-CZYbJwkO.js → useFs-BR0QvwO3.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-sbEHRLAM.js → usePersonalDataHub-DitK2Qgz.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-BVjeSEQK.js → vnode-dxSb40dY.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-BZKVvEZ6.js → zoom-ekJk8YB2.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agent.js +75 -0
- package/src/commands/session.js +132 -0
- package/src/data/changelog.json +8 -2
- package/src/harness/jsonl-session-store.js +9 -0
- package/src/harness/session-mirror.js +212 -6
- package/src/harness/worktree-isolator.js +10 -2
- package/src/lib/agent-core.js +1 -0
- package/src/lib/async-hook-queue.cjs +288 -0
- package/src/lib/async-hook-supervisor.cjs +52 -0
- package/src/lib/background-phase-reporter.js +116 -0
- package/src/lib/doctor-checkup.js +184 -4
- package/src/lib/hook-runner.cjs +248 -36
- package/src/lib/install-command-policy.js +343 -0
- package/src/lib/jsonl-session-store.js +1 -0
- package/src/lib/lsp/__tests__/lsp-server-registry.test.js +27 -0
- package/src/lib/lsp/code-intelligence.js +5 -1
- package/src/lib/lsp/lsp-server-registry.js +14 -0
- package/src/lib/lsp/workspace-roots.js +115 -0
- package/src/lib/runtime-checkup.js +33 -0
- package/src/lib/settings-hook-events.cjs +173 -3
- package/src/lib/settings-hooks.cjs +4 -0
- package/src/lib/shell-approval.js +47 -1
- package/src/lib/sub-agent-context.js +13 -0
- package/src/repl/add-dir.js +59 -1
- package/src/repl/agent-repl.js +85 -3
- package/src/runtime/agent-core.js +411 -82
- package/src/runtime/headless-runner.js +111 -9
- package/src/runtime/headless-stream.js +39 -0
- package/src/runtime/system-prompt.js +39 -12
- package/src/workers/background-agent-worker.js +8 -0
- package/src/assets/web-panel/assets/MarkdownRenderer-BL_WsWZM.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-Bp5QB-46.js +0 -1
- package/src/assets/web-panel/assets/Terminal-ClPrfWW7.js +0 -3
- package/src/assets/web-panel/assets/devWarning-BzV6dk1B.js +0 -1
- package/src/assets/web-panel/assets/index-BFrAwXHp.js +0 -1
- package/src/assets/web-panel/assets/index-DJQFP47u.js +0 -1
package/src/lib/hook-runner.cjs
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
291
|
-
*
|
|
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
|
-
|
|
296
|
-
|
|
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
|
+
}
|