From f9609a5133f5001afaaff456060e864e3cf4049d Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Mon, 25 May 2026 13:12:17 +0530 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20ArmorCopilot=20for=20GitHub=20Copil?= =?UTF-8?q?ot=20CLI=20=E2=80=94=20initial=20port=20from=20ArmorCodex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArmorCopilot is the GitHub Copilot CLI counterpart of ArmorClaude (Claude Code) and ArmorCodex (OpenAI Codex). Same wedge — intent capture before action, policy enforcement on every tool call, audit to ArmorIQ backend — applied to the local-CLI agentic harness that has real preToolUse/postToolUse hooks. Pivoted to GitHub Copilot CLI after Microsoft Copilot Studio research showed it lacks pre-action intent visibility (only post-LLM-decision webhook), which would degrade us to a policy-only product comparable to Check Point + Zenity. GitHub Copilot CLI is local + hooks + MCP, identical model to Claude/Codex. Phase 0 PoC questions answered from docs (no code experiment needed): - Plugin manifest at .claude-plugin/plugin.json (canonical) - Hook payload via stdin JSON (snake_case + camelCase variants) - Block via stdout JSON {permissionDecision:"deny",permissionDecisionReason} - MCP config at .mcp.json or .github/mcp.json - 13+ hook events available (sessionStart/Pre/Post/userPromptSubmitted/etc) - Plugin install via `copilot plugin install owner/repo` - 2 default marketplaces: github/copilot-plugins + github/awesome-copilot Phase 1 port — 22 files, ~85% verbatim from armorCodex/plugins/armorcodex: packages/armorcopilot-gh/ ├── .claude-plugin/plugin.json plugin manifest (canonical path) ├── .mcp.json MCP server config ├── hooks/hooks.json 5 hooks: sessionStart, userPromptSubmitted, │ preToolUse, permissionRequest, postToolUse ├── package.json npm deps + name @armoriq/armorcopilot-gh ├── README.md install + config + architecture ├── assets/armoriq-logo.png └── scripts/ ├── bootstrap.mjs entry point (mcp / router) ├── hook-router.mjs dispatches hook events to engine ├── policy-mcp.mjs MCP server (3 tools) └── lib/ 12 modules ├── engine.mjs ZERO-DIFF from armorCodex ├── hook-output.mjs ZERO-DIFF (snake_case + permissionDecision │ shapes already match GH Copilot's spec) ├── audit-wal.mjs VERBATIM ├── iap-service.mjs VERBATIM ├── policy.mjs VERBATIM + tool whitelist expanded for GH ├── crypto-policy.mjs VERBATIM ├── fs-store.mjs VERBATIM ├── runtime-state.mjs VERBATIM ├── common.mjs VERBATIM ├── intent-schema.mjs VERBATIM ├── intent.mjs VERBATIM └── config.mjs env prefix ARMORCODEX_ → ARMORCOPILOT_, data dir ~/.codex/armorcodex → ~/.copilot/armorcopilot, llmId openai-codex → github-copilot, mcpName/agentId/userId rebranded All Codex identifier references swept from code AND comments. Plugin runtime auto-discovers .claude-plugin/plugin.json on `copilot plugin install armoriq/armorCopilot:packages/armorcopilot-gh`. Refs #1, parks #2 (the previous Microsoft Copilot Studio skeleton — preserved as tag stash/armorcopilot-ms-2026-05-22 for future revival). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.claude-plugin/plugin.json | 76 ++ packages/armorcopilot-gh/.mcp.json | 8 + packages/armorcopilot-gh/README.md | 64 ++ .../armorcopilot-gh/assets/armoriq-logo.png | Bin 0 -> 36995 bytes packages/armorcopilot-gh/hooks/hooks.json | 40 + packages/armorcopilot-gh/package.json | 20 + .../armorcopilot-gh/scripts/bootstrap.mjs | 78 ++ .../armorcopilot-gh/scripts/hook-router.mjs | 116 +++ .../armorcopilot-gh/scripts/lib/audit-wal.mjs | 261 ++++++ .../armorcopilot-gh/scripts/lib/common.mjs | 415 ++++++++++ .../armorcopilot-gh/scripts/lib/config.mjs | 164 ++++ .../scripts/lib/crypto-policy.mjs | 244 ++++++ .../armorcopilot-gh/scripts/lib/engine.mjs | 746 ++++++++++++++++++ .../armorcopilot-gh/scripts/lib/fs-store.mjs | 36 + .../scripts/lib/hook-output.mjs | 37 + .../scripts/lib/iap-service.mjs | 278 +++++++ .../scripts/lib/intent-schema.mjs | 70 ++ .../armorcopilot-gh/scripts/lib/intent.mjs | 642 +++++++++++++++ .../armorcopilot-gh/scripts/lib/planner.mjs | 171 ++++ .../armorcopilot-gh/scripts/lib/policy.mjs | 615 +++++++++++++++ .../scripts/lib/runtime-state.mjs | 80 ++ .../armorcopilot-gh/scripts/policy-mcp.mjs | 318 ++++++++ 22 files changed, 4479 insertions(+) create mode 100644 packages/armorcopilot-gh/.claude-plugin/plugin.json create mode 100644 packages/armorcopilot-gh/.mcp.json create mode 100644 packages/armorcopilot-gh/README.md create mode 100644 packages/armorcopilot-gh/assets/armoriq-logo.png create mode 100644 packages/armorcopilot-gh/hooks/hooks.json create mode 100644 packages/armorcopilot-gh/package.json create mode 100644 packages/armorcopilot-gh/scripts/bootstrap.mjs create mode 100644 packages/armorcopilot-gh/scripts/hook-router.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/audit-wal.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/common.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/config.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/crypto-policy.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/engine.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/fs-store.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/hook-output.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/iap-service.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/intent-schema.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/intent.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/planner.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/policy.mjs create mode 100644 packages/armorcopilot-gh/scripts/lib/runtime-state.mjs create mode 100644 packages/armorcopilot-gh/scripts/policy-mcp.mjs diff --git a/packages/armorcopilot-gh/.claude-plugin/plugin.json b/packages/armorcopilot-gh/.claude-plugin/plugin.json new file mode 100644 index 0000000..61b3950 --- /dev/null +++ b/packages/armorcopilot-gh/.claude-plugin/plugin.json @@ -0,0 +1,76 @@ +{ + "name": "armorcopilot", + "version": "0.1.0", + "description": "ArmorIQ intent-based security enforcement for GitHub Copilot CLI: pre-tool guardrails with intent verification, optional CSRG cryptographic proofs, and audit logging. Treat as a strong shell guardrail and audit layer — hooks fire on preToolUse / postToolUse / sessionStart / userPromptSubmitted via the official Copilot CLI plugin runtime.", + "author": { + "name": "ArmorIQ", + "email": "license@armoriq.io", + "url": "https://armoriq.ai" + }, + "homepage": "https://armoriq.ai", + "repository": "https://github.com/armoriq/armorCopilot", + "license": "MIT", + "keywords": [ + "security", + "policy", + "audit", + "intent", + "armoriq", + "mcp", + "hooks", + "github-copilot" + ], + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "ArmorCopilot", + "shortDescription": "Intent-based security policy and audit for GitHub Copilot CLI.", + "longDescription": "ArmorIQ intent-based security enforcement for GitHub Copilot CLI. Hooks into preToolUse / postToolUse / sessionStart / userPromptSubmitted events. Provides plan registration through MCP, intent-plan matching, permission gating, and synchronous audit on every tool invocation. Block tools by name, by argument pattern, or by intent drift — all configured from natural language via the policy_update MCP tool.", + "developerName": "ArmorIQ", + "category": "Security", + "capabilities": ["MCP", "Hooks"], + "websiteURL": "https://armoriq.ai", + "privacyPolicyURL": "https://armoriq.ai/privacy-policy", + "termsOfServiceURL": "https://armoriq.ai/terms-of-service", + "brandColor": "#00E5CC", + "composerIcon": "./assets/armoriq-logo.png", + "logo": "./assets/armoriq-logo.png", + "defaultPrompt": [ + "Show me what security rules are protecting this project.", + "Block any commands that fetch URLs or exfiltrate data.", + "Walk me through your plan before running anything." + ] + }, + "userConfig": { + "api_key": { + "type": "string", + "title": "ArmorIQ API Key", + "description": "Your ArmorIQ API key (get one at https://armoriq.ai). Leave blank to run in local-only mode without backend audit/intent.", + "sensitive": true + }, + "mode": { + "type": "string", + "title": "Enforcement Mode", + "description": "enforce = block on policy/intent failures (recommended). monitor = log only, never block.", + "sensitive": false + }, + "intent_required": { + "type": "boolean", + "title": "Require Intent Plan", + "description": "When true, every tool invocation must be backed by a registered intent plan. Disable for advisory-only use.", + "sensitive": false + }, + "crypto_policy_enabled": { + "type": "boolean", + "title": "Enable Crypto Policy Binding", + "description": "Bind policy rules to a Merkle tree so post-issuance tampering is detected.", + "sensitive": false + }, + "use_production": { + "type": "boolean", + "title": "Use Production Endpoints", + "description": "When true, talks to ArmorIQ production. When false, expects a local backend on 127.0.0.1.", + "sensitive": false + } + } +} diff --git a/packages/armorcopilot-gh/.mcp.json b/packages/armorcopilot-gh/.mcp.json new file mode 100644 index 0000000..7fd7761 --- /dev/null +++ b/packages/armorcopilot-gh/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "armorcopilot-policy": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs", "mcp"] + } + } +} diff --git a/packages/armorcopilot-gh/README.md b/packages/armorcopilot-gh/README.md new file mode 100644 index 0000000..34547b0 --- /dev/null +++ b/packages/armorcopilot-gh/README.md @@ -0,0 +1,64 @@ +# ArmorCopilot for GitHub Copilot CLI + +Intent-based security policy + audit for GitHub Copilot CLI. Ports the same enforcement model that powers ArmorClaude and ArmorCodex to the GitHub Copilot CLI plugin runtime. + +## What it does + +- Hooks into `sessionStart`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `permissionRequest` +- Registers Copilot's plan via MCP (`register_intent_plan`) +- Verifies every tool call against the registered plan — out-of-plan tools are blocked even if policy would allow them +- Lets you set policies in natural language ("Block any commands that fetch URLs") via the `policy_update` MCP tool +- Optional CSRG cryptographic proofs for tamper detection +- Synchronous audit log to ArmorIQ backend + +## Install + +```bash +copilot plugin install armoriq/armorCopilot +``` + +The plugin runtime auto-discovers `.claude-plugin/plugin.json` and registers hooks + MCP servers. + +## Configure + +Open the plugin's userConfig in Copilot CLI and paste your ArmorIQ API key. Get one at https://armoriq.ai. Leave blank to run in local-only mode (no backend audit, policies stored on disk). + +## Try in chat + +After install, in any `copilot` session: + +- "Show me what security rules are protecting this project." +- "Block any commands that fetch URLs or exfiltrate data." +- "Walk me through your plan before running anything." + +## Architecture + +``` +GitHub Copilot CLI + ↓ preToolUse hook fires + ↓ runs scripts/bootstrap.mjs router + ↓ engine.mjs evaluates: in-plan? policy-allowed? + ↓ returns stdout JSON: { permissionDecision: "allow|deny|ask" } + ↑ Copilot honors the decision +``` + +The MCP server `armorcopilot-policy` exposes three tools: +- `register_intent_plan` — Copilot calls this to declare its plan +- `policy_update` — user updates policy in natural language +- `policy_read` — list current policies + +## Local development + +```bash +git clone https://github.com/armoriq/armorCopilot +cd armorCopilot/packages/armorcopilot-gh +npm install --omit=dev +copilot plugin install . +``` + +## Refs + +- ArmorClaude (same model for Claude Code): https://github.com/armoriq/armorClaude +- ArmorCodex (same model for OpenAI Codex): https://github.com/armoriq/armorCodex +- GitHub Copilot CLI plugin docs: https://docs.github.com/copilot/concepts/agents/copilot-cli/about-cli-plugins +- GitHub Copilot CLI hooks reference: https://docs.github.com/en/copilot/reference/hooks-configuration diff --git a/packages/armorcopilot-gh/assets/armoriq-logo.png b/packages/armorcopilot-gh/assets/armoriq-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..52f87f119c486f9669b1c7d1d5a161e567b883ea GIT binary patch literal 36995 zcmV)uK$gFWP)94Zol``ng4I@+eJ{ME=Ab0=R0%GoGIUY^P6wJ`R2U45(kdEC4nt3 zdXY6TF(I#-n6R%r@kH<2XTPTOlQUn|_v15OJ@o7IUcK(db53~X_H$nO-mMca{?V^b zdHxMQKj|-S_{}LV`1wu$@;BeycE+pTv-Ru~j@x?fYc~Ak%s2G?`n;Fsu9G2C3I8(_Cl;?HeeG?p>-qLO-gLM%Xr1=R`Rl&YSh(R! zS}uG#nq9oSve3A)v9$1w%FO(iD+{ylZ&d4l6UX6EmL(4@6^n=XuKma)PM=(_R{myr zVgAgy*@e${=4LLdOm;rEIFHM&eA%&|{LX7Pe)t=&-T2-wzh>i!-`jN3lmBz#pH^*| zczymV@?iKl0PNfEr~?4rD;ERUPn~y4?rUeguIGwP|J*+^`Kx!hOV^y2Yb;z4Ri{6O z*6in!>b8%q>GMul)9XBatjB)lSjjzhtf%;-(cYfNjg-od>CNUIo%7s7eOnHBuA9kc ze5zI@EFm4o5`~OouPQp;5k0Q|guYDn$-Tb&tf7AIIcvxBFB<8$|8}6L|6x^0oR%!_ z`A{yZeyUho`sD7Le{`uc^^3E%ocOmd{^~nkz3!V6C-;7N;!Wjmkyou*{9gVauEE0^ zz*kbUe&>ug4*l1=UORf&;;&B4?AiXWojrTbaXWi1J!HUt<%Yh@E7$gTFCWg}g@ZZ$ z-0{B56FpTQD>_lJ=tfy5Y52O`azzkYR+_>Q0yZ?1N{}QGf;d8|6tqyVECJuOk#${? zgRCXJjHRGJpLyFi|M?$|z5Mhy4t?`I6TMenbfSlc;qkBo@D-b0ll>0u&;NS+tNyID z>xR?A$(t^=D^s64tUveKBS(s_T3hm7x+-h^bpf3; zZX=1CkUBuZI(qUp7N)jYy&3qc2MS}W`!mnVsOH~~7VMYxTJal3bLuRyxb?%?g~_)s z{`}@Qe(UY882=_U^i?!F9xhmx4~K?caml9a56?Sg@aw1k{c~$OcDyxPtDR%Fs_#8) z&F~4Ud-E?G$YuUawd!k=dZo`%kuRb)`ipt^0@Ri+ENFDvO;oE@6dCVjGSspuI-M4> zIUlY=utIgUZkU9K(*%i55NicR&8v-+B@1IRL6QWu#dt$O6r$B?5%>;l+olRGz~Q?j z4A5>iP%h>mbPB-%prQ^+8BgR~>E~R#CufNx#`}uLx=H=@Pdt46NxhEvpxs$`+wQ4f zp8Cy+*B^A%`J4K;yyrcc`=*Z{9Hko3J~%kp&s^w<3Hc*xqo2Ivly%u(-}K7b^*5ef z4BGGd(?i$4s$8)CR*TkA*@9i>AzO_$#>UqWrV@_pqpyD$^9vPf#RPLp6{NyJrq~PN zX3>rXmKz}gILP%6piKaFk~TsWQPZYKDN$tspa!;G2apoNu^~OX8)_Sfk)WeGXhi|C znG#KlGC}I2(^6=*V#J9J*DpW|ms**5tt>h*2MC8!Ba9)u3|euF4$YJtwRDaey1G0) znlzT4ms7!Oj~W|#O(E`lpuY2lGu>UkdfB%p-nx3r#6+H4J>*XLP-_u8%KhoN6T?^4 zr=QlInt790nmTVl>XQ!}9(d7);l9T$&+HnNNti7cJOu3)_Uze()oa&a=gyt*2n@&Z zFg-nsQmF@)lR;145E3O(sdWe@hhTP*FAqRV5A#cPL^>vD6@<~Mu0t(q5sW}en>a}l zYHHoRkNKQ}{{%pL0~P`8W;2LF1)W%kItn?z1eaiSEgv>Rn_(EFh*JfRyaAHh-s?VF z(uU140&3-01sECWMKSBb7pgFr_s23}=h^E^xxX9s)l>ymt7{92t*6gjp?PPRB`?nB_L7AfPOr;C4wVqby-VlSG#+`mSu2U?~us zn)wu;c+ab)1fKzmpao409ia@bQbN;$Q=7d9sd!O9rivdr@ zG6-u>BHVoiW6nINQj5z^k)2;e#~!JPCu zN%OI)x%4+lZRVZv;_N?N@o#TFVhc@+0|36CBw&F1r_t}*L5{QIN`oD`0fu&Ektp?MFI z;4?!4#1r#e2wsm1KSNnRhkUkxa<0^+VkS?UHv^MZJ1MlS zSs2mhupDYlS|%{0Xg}PVfCT%de zmwf&$fqvNEl11Xs;R@S=t^1DWw+fP&0RE&o-%f zTcl1WRkpckUif+D;O#1fIhka;!~BY~lmAiNEc5h_|3BILWg8sK)@LLSIw_hhvz zX>1AQkSy?7BV`*j+hn^I2ap3NX^oVv=;_(G_WERD_C3kc>`TA$_ScVJdC^5)x1b04 zh2nUSbGVniZ*6+*U=%ctwrdM-Uq6)pm(@M~vrC>HuQV!lhZ@w)XQ9|B(+hK`)T*>C zEsTxP1ZFP_lI&_)(558u*~AIWj%5sW5`knR74|J*Dl9%*T+{)v5=dztB#cug4M|G} zLb?!)vtdIpGz&UynR2A=q&jXUaS#gGB(-8GJ4#v+iAh{|UWNe5!6N7+eQSbs2xUQQ zK{DZ9GIiaGQr`cTB+S6PHKhUsD!}5pWEP%gped!HIM|XT* zMIyXgQ3eSN`3!xU9t`aaVp7C(9c712Ww%?}u64AmCrUP=D5|%EaH-mCPAxZE*DN+$ zKUnOvzP8wEe_^V!{MoryBXMp_s8cVpaxXfwW|W zvSB9$?4w1a(=(%OzVh+5RH4NsxzDs&u$&?2ZQhm-^@VQUMnaNse$ z%K?(n9oIuXuL3+ps zDPJIP{coQBnvtY((-YCG{_Coq{AYSEPU=pd1TU#)HkcdZ&Y! zn#Q5qE1xSuuoI1WU{TYpOtpXsEox*-gHy1i2qZKtC{hXsDLXtSSVQUSW@@(^sk>Z> zt=$Wu{N+?5{_d{j&XwC2+F#wa7<_SOJ-%#vGyV4+t>_bzkvwl%`fqHP%CDA#V{gFd zp{EoN8+(hjZuQ&UgAYE{9$I%&yEyWidCNa}GPE{Nwe-6uYx<(8YI^BZRefT*sy;W@ zz*iSr`l@Q%`b9mII}_xV67ACx@c@Dj+JRIKEM<53rVE*a3|%ssSV;v#Qk%9h3r!xx z8amO`zygA**@6qna<~SBOWEbPC*H1CA-OOu=RD*r-FxuJz)>YDeR;UB>#U^Kc-mLq z_2vhqSt0QtJibqVu5N98PC2Z;YcM0;JK9?~HiOlQgoNhCOzw&0Q_Y-7IkHS3o1ozxk8^oj1!V@?`= z%(15(`@S!}_c`Z(`n%8C{NbDa?ClrMJnQtcmY#Uh#r304zOeeI|8&;UW8Zr2^i%)! z{ntM8?9cw^voHMO$DZ-NFJJJCp+}v&_{_h1=kV}R?0&4*pdlP6Ef8=w(c$2a_&x|j`1C6$?W#4DwZ_-?W}ewE>u1_a zH&58j>!0{PA9!02#rmK*<%5P3GD5kFt0rE%HkjG@kC~``N^jOZ)|KgFZA+Jh02KWK znv@ch8WzkHnQ;hC3O(hffW=OdLKCYJKI2`>hQq~OgYGKndMNhyquOkN31BuZ0(PQy ze9$nB!ql$MRvO#e+WOvXwe{s%WL?{rUiY)op1`qn|@#kOizt7uz-t6^%%2{aCe4F^thvEng#swS%X(5MQ%?7sD8jYmXyM3x2{wTCdpSyPF+=uEiI}v-u zf9zYe_SE4cpZxJ--gWUdqeY){>iI2#)Tn&J>itT`oODw7+|3s(AHC^)H>`T}vpy>~ zJo=sfBaeFBOg8hz|J^-(#_rkb$7bhRKgpH`cZAvtDq#wlDWD$4;A)Yu6!2{ez6H)O z5}utv3(CmqAkgiuQc#rTid=2BO|FoZFlI|Mh~fzj@7k^>YhO z_Owd2?`=JgedIg;;{1<)=IQTx-;O5}F!xlW?q%~n2@FrqKke+w(I=n1^Z0W=^KI+s zqdx2ok8F+#<&%GM{mu_9w3Gkxa{b#A`1OUw8eAs}DebO&njEy9P7@jtq?Ljb6d+=( z92Nru1qn%EQ{;SJIZdpfi8b2ahta-LE^n#FjugC&%e!{G=^N*~<ZY;HqnsOJgFs6?R0{frP?mHQ2y>Ow(k7&j!OGq!@iz3h3nUz z{la&D^83$v`VK#~=T=4GsP_Mv<)O8MUsTDMMAKqY_FgqkdQ{Ee7fZn3WECCp?s4##NzZc;#w7> z<-C8~ETCExvg^MC>PNAG!0=0C;KA#!=w>jypg%3jZT zoRf6QA_{1`MJVQT)UF|co30Rr37t~VfTcme^(NGjyB}m$Bz8dcfGi<;cLge^?lPXqiKML$Z_-=HaBT%+?+k)DIXTS zJ)0Vd{jS~1^)F9V>eseXENa__%bh^i_34dJkTfog93^FFNNCDcfpXOll%>?@9!9pE zRvT+pk3n*FiyJjK1oU`sX>GhP`@A0AdTD!M=H#zWyneWw;eNh6AOQZ+dnS5o3pXB> zEKHy7r{UkX=Vu<->pK~XZk(aMUf5i0RO@x*a#=tqSkxLeQrHBs)djJF@#jiv-LxG; zuoDttBXnIP`GTs$Uam&s1}oS9-T8X*snJI~>gD$6A!j{r^CdqsQ?OY$;URh)Lz~%n zK%PH*_}5i=>|ZB?@XXmp@UfZ2<}d5@q?$wyf#|`NIUsc)HAo5;K}$8jFd-=dSuq|k zvs=PNVZBmEi9Ytq{33dCKB9UJhpryUjpY2p$IF>x8}qwfcliULiRJy>kKe!G^pU7K z^USm|`){M={PWfg_pcx6@5SWK9T*!~g{i42G}|5Y_4T1nyG>}_6{a9~B@zl%b;)Q9 z!@F=?SY8$(0bGyKS?HuUZd-1CcXu-pO`iBddL3+$t z7cXobd*T=5`1%gGkpFZFaQ z`Tj+x4Ft1OFHP%}w>@IR_@Ab&T5qdShm;bcH6;ZKsq@hw}n zutePF$Nd6e3P^na%+m&@esk>`1~dNOj`tKFv9M=nespMnLQvgCYF<+zyLwjb4Z*jL zYaymU<2XbVMbI=aoUD(9S`9se!)Qac8_K?V$3o*We|T)dDGh$)$#34gyz$K!ba3Ff zMFJZ)UY7pF=J!qek6Zs)fAFBwJK6rX|NN%O@A$n#duA&&JI_!%VJ%qW-3h~t>j zmoRKhb7D4w;sC>W|FD9NUg{@}*JiK1YRy#>6ZS0(+($R} z3xI!g{;2~qH(&Fnqt=Z7bzEJ1WYTH4edRnF%?gD9q{0w|LcwT)S5x1D_omG`3boa4 zAVW<{e*`qOa-ao5A%mNy=ci}u?H|oFq7Qoq9kS`!n?LyDV>Vx0H~9|G?MvY3S8NWR zx_R?-e&C7UwpXt{>3hGr{)*z*n(J@aJ)Km82r0GeLZyNrNs!6sXtW6^_C*Sd{UmN7 zb^(+C42X$kYT*FA`7C-pd9c^1y|}E?*H+V+HCmgLx6jM|h`mn%Y;2*c&OLQ_&y8D8 zddxxVo);`HK60e5Y&o2!jC-cjX+g;Z=G#>x4AWG_ao=s2CTk?KfQX|M(slrv7S$j@ z6Lzg>`!{sl+^1@(yD45f_KAm^joX#cy|^5``4z#jXIwBf^ynitO*Nt~s6y{AE3sbo z%l(kPk6fvYFjWMoAgJXESgBn0RVjpqlt4R-;BxUC2OY#g6TJl=CEr;q!}{@=B>2ZK zo$*hr_vN`SLb7mQ6uVO9Q!~L>w777>hM~UWmZtYSs<)I8DeYIu#z&lT@`W4|rhrIR z2#TSGISgYQ;4o9w;P41)%?{d1#=Dm4yDQ4M>ZZl^`*r{Lxf?I|#E)Kf>iI2`=K$S( z2^>Q|a=WzZ{WF0+rv~>kH|&|&8VV$ z&n`K;l*}wWu`xIKnnxV8=Af`$wR9Sx*{s9nqRgXL!7;WhSFZ-BLd1{+u^@;ALEI&6 z*pirS_?DJ7<<_|5+ODd)Es#UGno|H$A+)3r0HolODlcy<^{T!PH@oe*F2@ zZQFfinCc(iaMO)bF>SasO{sSk0jnU{SAu;dRRlwM-$#?0*JxiqUqH54LMjRGsEu}Q z5d(Sq&<*|N7cSp$^$C}s@y7kWW5=rYrx<`MFS^KUFD*U73hV#0Zg}AE#pykMKQ|{V zx?8kRbdR+`9}GHlfpN=?PIC=Ot!qFlDJF9-wPDMqm~0gKSIyoywQ$XBq`vHqt~>A9 zZ@aW61QYIGwhxrHR{~Fb)0uOFs}DcB<@cOum4-gkOss37#EMiZAi=JKfE3ii(vXJY zwr9c5La@&|oe<4d2d!2MMnUzLi`?*7wnuvlhmH2+{tC6F7k%l>)B3O<55v=bEDq?8 z=LUnN$$!jQ$>XxN%4Vo>&2NH5iK1?moM!SgAtDG0!Lg;mGF;1rrXZj+N`*3g%eV()F(i@___2=xVs`oV_=QnBO z1R1vgZiFJDncx@msL~uX05ah99Uqb)x21!upJTcdG1oigypN#1==2w@gNBRlv1rdc z!Tek-_S0eN-%mvWKm6c%gIllvCCz?=Gg_<}+LJ6i3 z=(GZOUKTS8%Lug{Wcr4#-&Lu8u{g5k--_9_KmY5^7gllLxL+k;))<8~YyV>@&=&-- zezRk8CbB#qj+@0KO}nx2HAuCF22Bix5El6(q^ONyh)*&7$-{YO17dG<_#3lf z_^HtIuMVW$iD=~Z^{&F)%mVvU!+hzcxWZFHK?`)L%O^ps8Ih%8a@hA7%NZ=%_A{5K zr(f{J)8EAX>29ifb=fZf{?7Z(?5ph9acqCqIj-P|we{si4!Qt6r6RDmQM~E4%z)Sc zR}s7l4&p?>adKE#YGBoxgPXe-D!Z@VGyQF6bltxlyXoDFIB-1R5-=cEnc^2(zW3qT zdgr=OxIq+KaNQ!5q*CJUsFE7QH5387LIzp_mgS&Yt-~ge2TCQVcChxb2d#T%PPLxA zKYoM4)Pj3+{OpoV+5V_DoK&Y@QnuBh&G}g|P%dzN79v$Ki&mt4r-TAqs4W-}8yXr~ zjoF1d{SRJiq1M@1OYJLqRv+}PzdQSbm3_Gmgb%y~jz8=DOYKbdi*t$kSUs_?t%p`< zdj`OqNx7CW;3Y=U-I-(WYQwUyxSmNdG%$o_y@j~hMz8NVbK7n{s-%*QZl!XVNp^4O zUIBQ^mMvDaTw8VB|NU^&s#5k*rHo_s6?0HD-d)E+NdKT=PSDE$HVT9<2datcl7dB> zw}NbGAWH4bwwZe8+fLumIS1_Bu6Q1FmzQk1bWs+^zcAfOKio>)Yv-y#l&}vCXoa9+ zA{h{`*uMr^67_l$87}I4&x4{_=?RT;#_9ES^aP^%_^(f#I(%;$YHGR1`A4?@d~|W= zwo@N<*oHqX`(BTxYpkbOK&?{ke#unglpzdgNtQNEO}lP00S&eT&=T6osD*m_hJ`Ts ze#`RDH~`*z+#UBz#*LdVSa$Qh|1sH)KN5NQ>w;9rO0ge_Fr)oM000mGNkl%8-F?d8hRgXR2FMG}p3ctOz>R;kobq%(VXh(`Y09D%C4jLW?s7Bm*jy&<+4k+ya; zQtL-iHh=Cvy#G^6_lTJTneJB!7;{1lk6gBUx%sh3*<0D|i6)4_o|Yh_OvlnCs?^mjwX$=>~N37^oG9Cx>mmER}#AyPD=0n7nmhYn4 zs=;w3T+c$*Ln-gzsJ!mH_;JC^81BJwj{yA5Gu|{(o0@s^=s@|gLMxwmg@&Mxl?-i$ zHbdUC0mFm+DCVjFRQOFv^1E0*jaknXvFeX94w^wH@^+Xx9j{eNV z-`#Cf;%)=@#}`c$)1~DXY#8l3dUc3{=N-neJxmok&9Oijl{q#oaO`R!Zs?dgqsKpZ(U-($t>6qTOqFQaP%8CH_?;* z>h-&4zSD@~9Rs5y5Ei)0%C!y*9zcxjBMFOBwxwiO?=@N#lnM?vI^yvSBg0R!+QCs* zO-%TARUY!LCN$}Cr9!^4F#C+GgY`#0^@+IVH~$AA2x4Q=l0arf7sMv@3PnkRAW_&o zJ%_>Z4NJ<)|4I~wK5*=&1G{W15_)J{k9+%j=f)3NbKca_()V`HOwET$jK2P!ZsUZ( z1$CO>jBFVqcqK{jZLMCR$&o|Ut~pCnyAK&H_dLDQ*m0NtisLQ=_^S6$*Us_yPxTXD#SwJ`o&fw-%I1wkyy>^=fxbJ+r-IE7#6>Fk+X+}>2ySAzbYZRGu- zc|MF=wWlwv4y+#gxUig?`v!WOH{ZOKnpcqw5a^q{= z=;&~JdHESuJ9)yEiPz`viYV_gfZa~gm)Ftr3R15fD3?&HHMmv0QiWkekQqyx=|XX8 zP~8{CP_H*S$QS$QR%`9(r1q8Y@?Uz@fkj(aJ`c6a2`5|{hmL>KLX>`g`=0q-!>iUP zB~~D2JP)bhf?tvZAgPIsMsBv-T``yiU(~3Jp;CU07j^z3TZ_iKFYe^BQu>`FGTL|P z`d_?qwI_}$#)%bH8_4E+P>X;dW)Ye;j4^sr1v6dPt_Aa%UCiZX3JmCT_B5hi!DC5U%S(P(vqNBY}schIXygd@HfqNx~N{F6_8d zEqtc{{?@0?%iVb6_UBX^jYsX8++{~;#ERW6V7s8@bIRQoL9Od?!@_YKZYgURyEhDD zEG{g~HG=TE!kU3!ZrpfTNEex)e zI#CCX<00kDtp&J}Qi$laax-o&-B{l*>y!QBZv#Fs*@m>KBp$v$A%Wba9TO&L zml42K?g5ZM(-Vs*6#spp+S;wO4HXFZwg;_LcLujT z8#>W6ITb9<+!nE9zAKJ&h)738-%20fUYL30w@x`F_lMZOLjcRBJv5NZ{`rRS@u7S+ zLtsis&w}~-o`B{yaSe%zp`{=bNq6ttaeW9O(P{>b*~QAXCgc^zAK0+?L-Kz}$3N%P z^IMfrU+0zk|2IkOnJi5S*K(1@34%BzXl+PA5Oc;(xu8pw0&Ln6B#ju3ovs}$Wd2{! znjQN?f|7se#Z@1l@Z;*-6RbEmVs>h`Q(K}1AT$bnJhw3;G|In8}>WnPeUhz{)wa!h%Z5p-{SeC_En`JkmN9x(0fy-0dy*bei>a8{xaFhYT>bNeoZwh*5 zVX%}Ckuek;Dn^ME#jH~#sQv_vrS%#R_??5vecGzCd1y+k}!csv!NYESXf+wPQ`L3#J03N@bRNJZ*H1g2k7A~fn!cODNGB+8)p{l z*GG^``EoB>?V$T-M*Ac~Sn*a8x&oTBIUgXkqJ|FS>Q%#Q`ZL+5efhMP^_uuw)9nJd zkXU6Wi4QNAa%Is4uVNm}!a-sdO=2bcwgV;a2Zu&sY~N0h%vHnanws)!%yi&9Ac3v~ zgxga`Cjae9Czwp613_0>z1fE1S_*_LU@&Hq5?k8h4MXBeb3tdh7!I4Y=UU6Hb$oG~ z)13*|KnEmnYYBw5-DpGnv=t@Ol4>aKrJ+<$=nCk-<<=57=A_N_ zv4htC$8w{2b*g03{F)^=!yBSr>?XAcH-j1f8! zK@p&)5Y133P#fzmpiO0|05nNy%^kTqvW3D7;r`3AiwCv#z38HgykyVy2YvF))B3LZ z_{V)bfQQyveE!mR_oa)|hstR2kS*_dPv!w+=YFaB*>5|i);m1*)kNFZ)Ei+!L_tHq zXkbl62`FL=T@nPcp=>sbFbdFVH-^et?@x9vEoJvHdm8}LahwxD^ANg}3;BE*iKdSZ z1UnaLF1Pu-G=Y*a5-UYQ^&WGCbbpnQ8y$O=mS=UY__bp=UUEkQ8+`T!w?H9#;!ni*}1Ju^S?xJ3v`>@+ue)5LC{4?W)nKv#t$*H|}9K4V3+W>fG z_wL889bdhw-EN~)Dxuu7(zmAYeHnf??1V9F$8XHcF3jh0`3J4*?8_5xDmzQ_FF9=0 zs^dD9%HgGAzAsI}BM{aP-!d`b{%*VO>*ob;xkt2He_xrKJ8b>~>{XcxpsSi%y?pqqBL*G&B^jL_(UWm8&{M`xx#x)b{Op^2dueokb`V8#ho|AE&o8>|g@xxGx@K+v z9A~>;uC)6-=U_KzzD`@s`&|ov^F0&2-+b3w4{7iG<)+T`ws+feH-9jKx`}3{&<;qQIqH98FNS4md000mGNklsj<5mxDeC%l7@#V1k!ciyr>w^pS-#l)-|3%H+ zSATr^XTSQ_PGh!re=Ig5rQ2Sva7~gV%OX`YQ5CIWLCs4OLMUk34++f)!JW7u2;jOd z8jTuy%XwMKW{*^H+;yiJG_J@=#%`zGTHD)G>aK*1i=)wKth~F`Rq@^s1FCq51Xd$; zyjcfM$HYC@=xc9#U5{>7UN=~9pW>!LA#7BzJTr}+LLRnCVTYaJwLOKWj+ES|{h?3x z_Gg>6Oq|@?nO%Hak8A%$u)I7l)KkXN%nS})y9QCC3f-y=^k=Q7*Z1sv+2wD!^FM05 z=e(`t{1(C6+_U}VYkPD5Z>%SKwB4zW*Qd7SM~l8(RdC#X54ofe``a_zGl z(>I@yNtVa1nwT)g>q;T_;Ci(AF_et|izrU^AWj3xG9=en47z73HSJ_bmJvpQ{7pJo!8d?rL%>5Q!!>|0-1TtW{MoBzD7TrPLtnTr9f66S6nNoZ>3 zPNJ%DDsQTz_@D!@`N>WlHJ_9f@iWt)J}waA@I%*OXs{RU+A=aipeN%Xqv8$qsok&Y z3*7ru>uq_@$r)8&{ELB{`xl3-S+#1QoCmnel=EE7&Fw~+xqh!Vb0U1cowj=S zN&CJCulo3e-&&e_Y|+E8X zi)GgyWpdmC;C8#YHfpyAd&)&L>y55WLiKLa{2twsiP_t6x)^Bj(~TEg@Sp?mjX!uRigwIO8LUd)QJ$;tc~3}Z*(7Z@VY~r%TxEMDY0v$pU2GNJe;hL24&>?Har5SU0cFXDL>ffTaOg&#p1mdi-Uls#H3QX z3EIK3Yoe}{stu3{;%2i6V-2{ji-C4jjh@ zXopC-@;cQli3>I%%xDx46g%&bCaP6!w02mf!Y(|>j&GlRb}6jQA3xlae@YZK`@Fn| zwhoZ%%|TfjL8FDd>mzK{(Ob$RC&XAz#m~x#V3aHI{qB|c(wT4Q3m0bJI-1KouD_7U z#7PIXM{UD}OAxeS{;E5s&6kkJfxaT5+T!rq;od)M?%MvU&v1ije~b5PXT7bbxj6Yx zUgE;D7?Z|Ndh?{#-7onHD z<~0sg^H94{&zcBhYnZ}JLANyUYYLyj1wR67;+Rwr8y81tYxubP;!o6}c6zU<4F zyGRZZGVOvkv4EHjH0)~vN3acSI1&!pR&^0atX+L@FgNo~w6>34#WMC+gN*5rT@Ic& zp3gqTiaMiqno!fa5Cn=QA6*iF5~@pvUmFS@k~Qmx`~SR0*=vkBw7>Ydil#$UuRUtC zul#HedcDnhZA}KKm^7DCiLeoJ@g6g@vw1Alniw7$K{=O$M}D0o?Q6^~{l)zBbmrdh z^kllzA{*B%EG<_jC#R7_34{LU<8j4jgL%3I@in?_>)x2;5|XN(Z8loJE$!^?xFkaJ`oTiW@ytM0ajMQ* zhq#uqrKO?RKjwRmgbhnchF23O9UIM73x&LotfLDfh0GImy>jM^nECAjz$CsGlwVF( z*Ha;0*zmU_(6^tRWH*C+Q2KV~3w zb}sj~=O{b8@e) zFZ_c{I)64;&i5H02to83*0cn=B%q`PGcXgj$1E0XN21eiVD+lL0XGT$L@ZUFe^-BC z&J_KJh-D1R&f;J7`u3x35tSh#I9#-~xWfcN9+*)GLD-;s2u&WLAUJE-Rtncj21ffz zf9|C1<7wLDyNT|_<49qY_zrQWp`%Fz5mm^?z$H4<Wj$x;{eNxtXq>dcYlQej*Y?rkz0Cm|Qdk$J@q7b~H;njU9pVsxsaC{@)$ zK0D3!69#zgKJ+P*>G)smNLTyT1YM=H7ttDhM?}%aI{80cVsII^AJLCaaTaGe_-yH*KfU{nJL_`@v?jW zOG{aqjaqRu7se@1P3@~;+cx>)NfXcFKys=5Ey^pR$0mqEB$^->3c?YClF1-a_1KUD@oEN<@j|zJ}Ayy-5IpnjsG`VE*xZ~p<5{W z&|DZ9TUQboc51^S3AQC=hb1KQ|8;cY1erpfDN_vel`y}1hkL~8(IaJj>1~CUy34;+ z`@2F&pa&b1lP}(|X4M8MA^``8iI66@M19AFP=Z;yT|nSSSbR1Fb{ypkO@-3mS3oaU zo5LCRacOn=Enj*2o5z{yZl71t?uBj+N7bcc3N8*A=`X?&3dZgxpGXN-Q>d9`EuC_p zE7$~z;Nq#p{eWyfhf1rJT)Tb8RBm|W%N?EHFZ~|5Q6#%f3P~NP^fTHVI6lZcUO}SP}qi zXa`EwtU_k5s=jrq`~UtYXP(*Do|*ak4Sju&(d~Ml?+Gkd7RU%kss@}yz@-K@WGS1_ z(1@i*u2jZSwF0M*MIsbx^<}JIGm50TJYaV^Ppj^pIrej#{<)t8-L3OGXPiDH8_gFS zb?~}J^%pYPSOu_hHfkZ=KrZW#QV=~bpQEOPs-*y{UmM zj68es=9`Yc{2pv@7?;`Hw(W1+uy)lU6pdpMoEiyI)?*g8=1VY+C_p4MXA)Q{f~04n zP8qIWyP7l9JluS4Yb8}*t@!fi8#ix`upf@eu_r|hx`M(aO&hHa7imdXfl3H;O<7Gb z#i|=`Q!{5W8A#hAh!u!&@=7M!ox01HnNGmW(nY%!f+hmM)iQ+UsQ@EY>^l^v8zmA& zFGPi{+!y@-h2J{wyxgwI>yOR5_Fv^Q-e_M>8FMqU7#kbwawK_WGaz*L4r~$0hE^7; z)hb3uN73R!4jj0-Tmef9i|8v9uwi6i%noWVD5c@ZmMt%`Sj=5IuefAWHeQ^3a&vC# zrS-*y^@4KLLZU+dy3bX;kTxu84@J#yKrFj_kvE2VZw&^#=GL)g*L`Kcgs3yVcDV1) zV_h3I@9rvHb^fM)U7b0(*AdUk=ycFl3d@U&1W1-e5W&2zoa|@pCHA*eDN5wmXhAKV z%Ku$`-LH$|;~#q6`TOs`hS<1qV_N7Lo}&$1lTxBUxf?~=Znlv1e3ZE_p{Z!q%kz zO3Q3E#RGVlRrN3`>&AP%V=NIK+D_w4Y}v?{`mwZF#|k(AZD|-#Bov0xI-qSOVIxN& z1Vk+QSZz5Cm$HMw}`oY{m=aAYtdl93qjW5IZX%l2GIy%umI_ciEz!cirTuq+NaO zmbbsGyAy>wIex_5zdon+Cr1tz{$j*eYn@ILIm?0P_n;LBblNF0Ru(>|=s1p9BfHbA z4YY>V7Sce&7Wq~jdpA3o!7FQeXup^aw`N;0l zyEZxh!aL)~3~m;5Sg<_*>a@M|sFE*{buE~UlaRA*E>lK1Tc-ath1o1fw2_bQ*6BDN z91iYc&ro{Z)}7mT&($t34IciLS8U!K7{2x=dF7stZMm~bB`qNYj33w*5@Fb3H42IX zOZUn|uzxHra*Z4<4M@{Sp+Z^Ad9LOvuia_ErIsxgi_E-o(!7A?h*XraHsY2xqBx1D ztd^oxGwDjI`~Q07MH9u!VnYZd0VoMHsj=Fv*3e+7cz6^x%Y69pr0Qf)$zjK4XpX0YNPw>N1WoqWdB8i_U9ouQeAq=^`ha7FmxYLHF znGkWd5suwm$V%FmW(rTZsn8+FuirB@=??UNqquhT(&NrOw}JcM2*b3Uq;aFu=>$eO znl`({pyA`rs*^|V{%&8%^)rIghiyrwSBR;nJ7JsVM962bgDn{Ff@0R}4aw;yQnC%( z*=7D;1Ux{8U#pI!?dFpy^Ksvm(9|HBz_dBlfNkk;D^inZaMZ4_sM!|9nI&OtL`u5P zl9PxbOMx00U=yhc2`!zN zPddvl{P@J1Zg)_bPad5pe5wqi=XsVK@qHIc3Ph}1(C2L?)v(n>8U~byhL>^S(7&3d z+|Z`yK~p-Mh%1rn{cukZe6#tO=JGA9?dO|}&?@Kzu{n(LF#tA7QwVXVHOaBQ-ltk= z(jU}oaBWcg+TF$&0Qgytpb!MFf?zLL(m|pK6k#J&sA=DMz`lX_(Rq7BFMC;-s~ByYN>q{cI3F|-h~n=~Laq}E*!L6|AaXkZRx z3s|fyb5YyCBi64Q7|dp$)R~??ftwR~{Lb;Uv)Wzc!#p&Qo2u{{Kw4fNi0 z?4vG#ZGzN**wh1R;(Z{NjF%uEOfLysK?1ppQ^naX?fz+vMGh_6&M{4igGPr(M6140 zP49W;mWc_M*={+hv4vNg?Qw(l->vEIUCaKZdn$xq$iSwInw*+DL6hc$i;U;M<$&je zih8|@n6t3ML1R0aG?MoAnRfWqXrT9hH*UQ5KHpmy{GFSYREo_{g=Hm1^BMrF6^;2j zDS!V_>UR5y1xxmr?_*t#(14wvQUj;dW(}_Vz_>Kk&;+nUyC~uIp3(x55_QXR=YPM* z`#$Dd=WQB{=jQ&(kAmlBY>a0-!LCivVaJA12bSx=BX|{K6(kuFdkHW=>JpOf+wQ$q zSUEH_Z`eE-&1vrY`+Gt8VrJJad$5olE;-^UB_YR{?3UB~$xUuM;o{^w;_A}To`syl=4&Xl!T=o-EOdb@Xm1-n zEVer0&Y-5*kk91dx<0C{aAB&_{GZa;_!Y0%{E6UJ#qamqzG7YrOD2X*A$VmC2E;s^4`Ax$VwAC6-orI2lIcV;OlGkuxSAX%8pkr{Dcob5E2u4pUQ`hz9k zM=!2=|3p5R-S*Oqie9m9Ro~jEQGv(#(Brfi(WVQzfb$&}o|^%uq(8_7mF@~yn4&8t zYF@K&Q+#2F(J=857lS@+6uRh8@|~O&v>zER&Yb-9iI;alqbDZhm=zo{kaeF?o1a{l zb~@-Qm(Wu#b8$osPi&g9Rnxw|0US;;@A@39#*^ zYtfT&`unrqW22dw*L`#1Ro!ogzjWps`pRhj!)U>O)@XmvVBD(15-DtH!R%xia%o?h zGLt0Yf^G$y8CXPm26-ogRx@tRRvXs@()*8NH{JJtW!{+9idIsn2{dE(T9(zF^$qrL zCx=YKc5fk*^?k<%`;N&q!YJT))ktXznL;=vK?nhxi-i>Av#LL(I6gv)D+_~*U zHw_+r(3;U)8g~$Jv1G9}sR2`zF%J}hs|EESBN&a9Lo$^`UJQ|sEIFTUmYv3%GX59X);U)B|CZIK|y zv=L1P6-VeRl~K$WUVrY(fi=7=4v=%#5ag%{Vns%axgww0TnaS}SbXt{6 zPu3ePyW;Qd%EE_Lb?K7+jQ!M}tUcCjR3RJ*hxr2zB466bkSHujYH?GxwrnW!$Kv67 zc|>pwh0RV1Eqz$hgX?4wwG%234bSrsaAveK4nl5-ShfwLIa9_d`sH)JRTOFX*wsCy zKRar4>{a31^o56wjU4XD#1WbrmL`?sy0F>4h&x?r7{Rq2*yPdhW78uM1QA3cp*jjm zyEE|eKh3XM^{r>W?VK9!hlBg2)V&G>aJM!gR)A~p*p0Y@i=-xM*4yo7hn?uqY;awV zT7%j^=`Lu65D?+NHB)+v&zS)GlgVOiotE*@_c*_fam(%fI9<+L7*mos2W1?;i z76i2xK(4!?09HT^Ad|^qX}N|f)Hm#$nw0t8FD?e^e`7DVGh6P3moI^ydQ_>k zT~60#qsAl9GBlakRth$ymQ-DmLcx_PU$Dd>J)ZrP9@qMlf{hI>)uv@+1I1cZw|s^p9_@z<$5QcONE1Ky@e9BrO#b3 z#a3ySsM#n(E1H}U)ZHL2(o)o&sxO1|Pu(ayVKC!8-A}`!HZuXYuN9{O#k^8WE+R<^ zEDfkZ&=R#;9cwlmf)-7mdZf24wbj=qtHImRKk~Axch3B9YPm5R z5*ga|IG;#ve&m5vbB9cDI&B*%u*U_MghUV%q?XXgSONvx%H$n8>rF0}sYu>K1h0`XNn?IIbu9hqHGVK!k zB3>!vma8HU1RtL@`-1^2+or}B;2wpSHtHjN86OTcuPqg|zJMa15Eg_MkVLY@P-v`_ zBNRr**I>`gJQi!MSZ4}br<<*B_yc3#dHe|{{BGsAua3g1RYGVo_B#~_A$}+3_a9{k zYWr-pUSH-ueawbs{2VkJ8U-<2%Vxs?Ggaws=O_qb#fZhXtWrA(z27f#|Hu4au`#OK zi>LQyohRmfw;a&%-q+txvmgUas2WlXK^d4Pn-1E?MXuP3tvhzX@(a_;VR}{1!H2)| zxc6RmRzq@Q%1Ylf#mGtvh6Syl3;6DsNz6LK^vCFzmbRe zPQYNp09R@SaA0eu*T&DvJnRv&ps=toht;cBV{vhjK=WXGIn-Mbl%1QZMDps9!;bjO zfir-aZFAO*B-g%B_c&A8Qo zS!~+mQAo{%3MP*slU6dl$!A`fegxaJu&{)|p)s_CwS9NB`Rxslc-*It-t_K8X1;$< z2}|&}yOqk8#Lq+~J6{?Yr0+CFrxQX^TXpS7$A{?%V~eB)z!ankzAbx1+Ul-+ z?{1}cC;iVZIlEMun|g8ClYge-V65DeN0aV7^97l3T%>{v5m?Y9#a_{1$L_5_2?5*n zKLGmL!e&{*wxC$Dk_;^i zkSky|wweKRmk5^Fq*8pXK`sPkwnAC}j$>1kMqQ6Y6ebuSUrS)x_3g8Z*EHaL;^fGdxR=H)>0En#L%XivWQuERP)7NXfS%u}@%*eVt|Qm+N(%$rrZY zxO4Ise*U2AP24`(ym_tQa}jC6TXsUz@d=BXibiGaEXC|fdNP@ar4%lVKYds_KN*q>K`8ZC2n;* z|Fp9!rP9zxYeDeCAc@;kvm9$ebyIYK#|8?PVahQC-n%XrewNj(pQuH|*~!cT7UK-4 zwbtxAW@K7GLXp^g&@5IcjeIsA&MYnMY5Cq)mosBrPj#!iUoV-$lAtVI3f5@hyH&S5 z_R=fbe!kGD)Z0nIPRP=rkK-6z7Q*G~bzf;h2ntC9W{M;^ww1HebS(uW_NCe1Vc$(Z z8EZ^UzHNB8??{cfbjYC_Q0Jl};es=TMo5o?5|Vc|Ny6N#TXCbei~iA( zk3HwqQ(M2w3)iJ_HrW`Sfr%;`iO6iP)jsx@$cBuSXwG`Cp!P6+p_&%T{iel)zH8pO#D zXXfU1tX;Qmg-WuxsnG6WHK3O-X=6*T;D^SB;6Di0qi&q`%6NQH$^#t1I@f- zpMf0=Mft_;Xdz0)uXAI=|9Q+wCxzzy{Z4H&%%vv*GJN-HmnQjAt*O{yf#M1|3L9X* zA)DiL+O@A73dnSjfRTZspcH%CkfDLp_QLeWLdJT!YbC2Z zOJi|?9v)VjW2ZH^VPShN6hyb$+W?m_N^|BA_Tpl#{c9BZ-uc2aFQq!;i`zM!*qL4E z#NVm6<2{ySnZ|@-=ck0Nk_x87h0-ttQxPd;y^P{JL0SQ=W_*`8aCi%{fI=aMMxz0v z4d><;X6Y*XQKmffsg37-;C82K1ETEOHDAWjq37OI?N}WA( zwcv-|z^aMkPk;Xsi+w=OKH)oLsSJpW5P~xmx`&awxsha=-EW#t{uLD4!4@$zEny1V z%`dCC6O}R+n)Nx@Zj2^(eQZBtwv3^b!wzscXjQiW$F+^%v`CaZWO~G65&QN0)~C+P z&2GQ`7+-h(W~gAV^AK2$4v`__8Q0>J+(M3KM4K~(8AUNSUK}Zq&->jyfAHS2{m%Sy zb89^kpUoBh$va~3$#1@(GjzlwzBF44E)QYt64EAgHEqa<*%XUQVHQSKqF~5|Mfh4w zBLGTLg8!H&VJxu=NDE*`@D+^_0nmR-Q@LUJ+3k~++K(Eg)zi3x<2k3EKktu>zkjKk zToJUzQg2~^yu^so2wvVpBd)>8c`(}D=B`}Ha{w(-r(-)*>?bS7iPDBA9QYapL;#2o z^`HtT%l8C?jI3t05?vRUS6}qs=)FUJa33F3ntrKJ=(B{AYtxk(hY?(QQ-lzdEV0eD zS&5(KGWCNn5E0Br>6&!b=d51M*y}I zuq3fUlxoIFVMEd$HkyLihUR;1lPiW&FfFN8Z^F+Nxp@-o*|S)^s@%Wo%2#aO9N
    _FdO*DPwc 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 literal 0 HcmV?d00001 diff --git a/packages/armorcopilot-gh/hooks/hooks.json b/packages/armorcopilot-gh/hooks/hooks.json new file mode 100644 index 0000000..b08e88a --- /dev/null +++ b/packages/armorcopilot-gh/hooks/hooks.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "timeoutSec": 30 + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "timeoutSec": 30 + } + ], + "preToolUse": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "timeoutSec": 30 + } + ], + "permissionRequest": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "timeoutSec": 30 + } + ], + "postToolUse": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "timeoutSec": 30 + } + ] + } +} diff --git a/packages/armorcopilot-gh/package.json b/packages/armorcopilot-gh/package.json new file mode 100644 index 0000000..c41997c --- /dev/null +++ b/packages/armorcopilot-gh/package.json @@ -0,0 +1,20 @@ +{ + "name": "@armoriq/armorcopilot-gh", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "ArmorIQ intent-based security enforcement for GitHub Copilot CLI: Bash command guardrails with intent verification, optional CSRG cryptographic proofs, and audit logging.", + "scripts": { + "test": "node --test", + "check": "node --test" + }, + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "@armoriq/sdk": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^3.25.76" + }, + "repository": "https://github.com/armoriq/armorCopilot" +} diff --git a/packages/armorcopilot-gh/scripts/bootstrap.mjs b/packages/armorcopilot-gh/scripts/bootstrap.mjs new file mode 100644 index 0000000..8b6f2a3 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/bootstrap.mjs @@ -0,0 +1,78 @@ +// Lazily install npm dependencies on first run, then dispatch to the +// real hook-router or MCP server. This makes the plugin work after +// `copilot plugin install` or repo-local hook setup even when the plugin +// directory has no node_modules. +import { existsSync, writeFileSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.dirname(__dirname); +const installedMarker = path.join(pluginRoot, "node_modules", ".armorcopilot-installed"); +const packageFiles = [ + path.join(pluginRoot, "node_modules", "@armoriq", "sdk", "package.json"), + path.join(pluginRoot, "node_modules", "zod", "package.json"), + path.join(pluginRoot, "node_modules", "@modelcontextprotocol", "sdk", "package.json"), +]; + +// The marker is only trusted when all expected packages are also present. +// Partial installs (e.g. zod present, sdk missing) would previously pass +// the per-file check and the dispatch would crash on a missing import. +function installedOk() { + if (!existsSync(installedMarker)) return false; + if (!packageFiles.every(existsSync)) return false; + try { + const markerVersion = readFileSync(installedMarker, "utf8").trim(); + const pkg = JSON.parse( + readFileSync(path.join(pluginRoot, "package.json"), "utf8") + ); + return markerVersion === pkg.version; + } catch { + return false; + } +} + +if (!installedOk()) { + process.stderr.write("[armorcopilot] installing dependencies (one-time)...\n"); + const result = spawnSync("npm", ["install", "--omit=dev", "--silent", "--no-audit", "--no-fund"], { + cwd: pluginRoot, + stdio: ["ignore", "ignore", "inherit"] + }); + if (result.status !== 0) { + process.stderr.write("[armorcopilot] npm install failed (exit " + result.status + ")\n"); + process.exit(1); + } + try { + const pkg = JSON.parse( + readFileSync(path.join(pluginRoot, "package.json"), "utf8") + ); + writeFileSync(installedMarker, pkg.version || "ok", "utf8"); + } catch { + // best-effort — if we can't write the marker the next run will reinstall + } +} + +// MCP servers and hook routers communicate with Copilot via JSON-RPC / JSON +// over stdio. Any non-JSON write to stdout corrupts the protocol and Copilot +// closes the transport. Redirect console.* to stderr so dependencies (the +// ArmorIQ SDK in particular) can't accidentally pollute the channel. +const _consoleRedirect = (...a) => { + const line = a + .map((x) => (typeof x === "string" ? x : JSON.stringify(x, null, 0))) + .join(" "); + process.stderr.write(line + "\n"); +}; +for (const m of ["log", "info", "warn", "error", "debug", "trace"]) { + console[m] = _consoleRedirect; +} + +const target = process.argv[2]; +if (target === "router") { + await import("./hook-router.mjs"); +} else if (target === "mcp") { + await import("./policy-mcp.mjs"); +} else { + process.stderr.write("[armorcopilot] bootstrap: unknown target '" + target + "'\n"); + process.exit(2); +} diff --git a/packages/armorcopilot-gh/scripts/hook-router.mjs b/packages/armorcopilot-gh/scripts/hook-router.mjs new file mode 100644 index 0000000..4619843 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/hook-router.mjs @@ -0,0 +1,116 @@ +import { loadConfig } from "./lib/config.mjs"; +import { denyPermissionRequest, denyPreTool } from "./lib/hook-output.mjs"; +import { + handlePermissionRequest, + handlePreToolUse, + handlePostToolUse, + handlePostToolUseFailure, + handleSessionEnd, + handleSessionStart, + handleStop, + handleUserPromptSubmit +} from "./lib/engine.mjs"; + +let currentEvent = ""; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function emitJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function debugLog(config, message) { + if (!config.debug) { + return; + } + process.stderr.write(`[armorcopilot] ${message}\n`); +} + +async function main() { + const config = loadConfig(); + const rawInput = await readStdin(); + if (!rawInput.trim()) { + return; + } + let input; + try { + input = JSON.parse(rawInput); + } catch { + // Fail-closed: a malformed hook payload on a PreToolUse looks like + // enforcement missed, so deny in enforce mode instead of silent allow. + // Other events just exit — they can't allow anything on their own. + if (config.mode === "enforce") { + emitJson(denyPreTool("ArmorCopilot hook payload invalid JSON")); + } + return; + } + const event = typeof input.hook_event_name === "string" ? input.hook_event_name : ""; + currentEvent = event; + debugLog(config, `hook=${event}`); + + let output; + + switch (event) { + case "SessionStart": + output = await handleSessionStart(input, config); + break; + case "UserPromptSubmit": + output = await handleUserPromptSubmit(input, config); + break; + case "PreToolUse": + output = await handlePreToolUse(input, config); + break; + case "PermissionRequest": + output = await handlePermissionRequest(input, config); + break; + case "PostToolUse": + output = await handlePostToolUse(input, config); + break; + case "PostToolUseFailure": + output = await handlePostToolUseFailure(input, config); + break; + case "Stop": + output = await handleStop(input, config); + break; + case "SessionEnd": + output = await handleSessionEnd(input, config); + break; + default: + debugLog(config, `unhandled hook event: ${event}`); + return; + } + + if (output) { + emitJson(output); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + let mode = "enforce"; + let debug = false; + try { + const config = loadConfig(); + mode = config.mode; + debug = config.debug; + } catch { + // loadConfig itself threw (e.g. malformed credentials file). Stay + // fail-closed: default to enforce rather than a silent allow. + } + if (debug) { + process.stderr.write(`[armorcopilot] error=${message}\n`); + } + if (mode === "enforce") { + if (currentEvent === "PermissionRequest") { + emitJson(denyPermissionRequest(`ArmorCopilot internal error: ${message}`)); + } else { + emitJson(denyPreTool(`ArmorCopilot internal error: ${message}`)); + } + } +}); diff --git a/packages/armorcopilot-gh/scripts/lib/audit-wal.mjs b/packages/armorcopilot-gh/scripts/lib/audit-wal.mjs new file mode 100644 index 0000000..142c79a --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/audit-wal.mjs @@ -0,0 +1,261 @@ +/** + * Audit Write-Ahead Log + * + * Replaces the in-memory `auditBuffer` in daemon.mjs with an append-only + * JSONL file on disk. Crash-recoverable: a daemon SIGKILL between disk + * write and backend ack loses zero rows, because rows are on disk before + * the caller is acknowledged. + * + * Layout under /audit/: + * current.jsonl — append-only, today's audit rows + * shipped.offset — last byte the backend has acked (atomic write) + * archive/YYYY-MM-DD-NNN.jsonl — rotated segments + * + * Industry pattern (OpenTelemetry Collector / Fluent Bit / Vector.dev / + * Datadog Agent / Loki / Linux auditd). The shape is identical across all + * of them: append → ack caller → background batch → advance offset → + * truncate when fully shipped. + * + * Concurrency: POSIX `O_APPEND` is atomic for writes ≤ PIPE_BUF (≈4096 B + * on macOS/Linux). Each audit row is ~500 bytes typical, so concurrent + * appends from multiple hooks do not interleave. If a row grows past + * ~4 KB the kernel may split the write — we cap appendAuditLine at 4000 + * bytes and reject larger payloads upstream rather than risk corruption. + */ + +import { + appendFile, + mkdir, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +const MAX_LINE_BYTES = 4000; // stay under PIPE_BUF (~4 KB) for atomic appends +const DEFAULT_ROTATE_BYTES = 10 * 1024 * 1024; // 10 MB +const DEFAULT_ROTATE_AGE_MS = 60 * 60 * 1000; // 1 hour + +export function createAuditWal(opts) { + const dir = path.join(opts.dataDir, "audit"); + const currentPath = path.join(dir, "current.jsonl"); + const offsetPath = path.join(dir, "shipped.offset"); + const archiveDir = path.join(dir, "archive"); + const rotateBytes = opts.rotateBytes ?? DEFAULT_ROTATE_BYTES; + const rotateAgeMs = opts.rotateAgeMs ?? DEFAULT_ROTATE_AGE_MS; + + let ensured = false; + async function ensureDirs() { + if (ensured) return; + await mkdir(dir, { recursive: true }); + await mkdir(archiveDir, { recursive: true }); + ensured = true; + } + + // Monotonic per-process sequence — used to recover the enqueue order + // even when concurrent O_APPEND writes land on disk in a different order. + // Resets on daemon restart, but each restart writes its own range that + // is still locally consistent for sorting within that segment. + let seqCounter = 0; + + async function appendLine(row) { + // Stamp the row with enqueue order BEFORE any await — otherwise the + // seq is assigned based on which `await ensureDirs()` resolves first + // (non-deterministic for concurrent callers), which defeats the + // purpose. The synchronous prefix of an async function runs in call + // order; the post-await order does not. + const enriched = { + ...row, + _seq: ++seqCounter, + _enqueuedAt: Date.now(), + }; + await ensureDirs(); + const json = JSON.stringify(enriched); + if (Buffer.byteLength(json, "utf8") > MAX_LINE_BYTES) { + throw new Error( + `audit row too large (${json.length} bytes); cap is ${MAX_LINE_BYTES}`, + ); + } + await appendFile(currentPath, json + "\n", { encoding: "utf8" }); + } + + async function readShippedOffset() { + try { + const raw = await readFile(offsetPath, "utf8"); + const n = parseInt(raw.trim(), 10); + return Number.isFinite(n) && n >= 0 ? n : 0; + } catch (err) { + if (err && err.code === "ENOENT") return 0; + throw err; + } + } + + async function writeShippedOffset(offset) { + await ensureDirs(); + const tmpPath = `${offsetPath}.tmp.${process.pid}.${Date.now()}`; + await writeFile(tmpPath, String(offset), "utf8"); + await rename(tmpPath, offsetPath); + } + + /** + * Read a batch starting at the current shipped.offset. Returns up to + * `maxRows` parseable JSON rows plus the byte offset *after* the last + * row read. The caller is expected to ship the rows, then advance the + * offset via advanceOffset(endOffset). + * + * Skips malformed lines (logs to stderr) so a single bad row can't + * permanently block the stream. + */ + async function readBatch(maxRows = 100) { + await ensureDirs(); + if (!existsSync(currentPath)) return { rows: [], endOffset: 0 }; + + const offset = await readShippedOffset(); + const fh = await open(currentPath, "r"); + try { + const st = await fh.stat(); + if (offset >= st.size) return { rows: [], endOffset: offset }; + const length = st.size - offset; + const buf = Buffer.alloc(length); + await fh.read(buf, 0, length, offset); + + // Scan byte boundaries for \n (0x0a). Each complete line ends at a + // newline; a trailing partial line without \n is left for the next + // read. This is the same shape Fluent Bit / Vector use for tail + // input — never advance past a partial line. + const rows = []; + let pos = 0; + let lineEnd; + while (rows.length < maxRows && (lineEnd = buf.indexOf(0x0a, pos)) !== -1) { + const line = buf.slice(pos, lineEnd).toString("utf8"); + if (line.length > 0) { + try { + rows.push(JSON.parse(line)); + } catch (err) { + process.stderr.write( + `[audit-wal] skipping malformed line at offset ${offset + pos}: ${err?.message ?? err}\n`, + ); + } + } + pos = lineEnd + 1; // skip past the \n + } + // Restore enqueue order. Concurrent O_APPEND writers may have landed + // out of order on disk; the `_seq` stamp we wrote at appendLine time + // is monotonic per daemon process. Fall back to `_enqueuedAt` for + // ties (or for old rows written before the stamps existed). Then + // strip the internal fields so the backend never sees them. + rows.sort(compareForOrder); + const stripped = rows.map((r) => { + const { _seq, _enqueuedAt, ...rest } = r; + return rest; + }); + return { rows: stripped, endOffset: offset + pos }; + } finally { + await fh.close(); + } + } + + function compareForOrder(a, b) { + const aSeq = typeof a?._seq === "number" ? a._seq : null; + const bSeq = typeof b?._seq === "number" ? b._seq : null; + if (aSeq !== null && bSeq !== null) return aSeq - bSeq; + const aTs = typeof a?._enqueuedAt === "number" ? a._enqueuedAt : 0; + const bTs = typeof b?._enqueuedAt === "number" ? b._enqueuedAt : 0; + if (aTs !== bTs) return aTs - bTs; + // Last resort: executed_at on the audit row (the time the tool + // actually fired in Claude Code). String compare on ISO 8601 is correct. + const aEx = typeof a?.executed_at === "string" ? a.executed_at : ""; + const bEx = typeof b?.executed_at === "string" ? b.executed_at : ""; + if (aEx < bEx) return -1; + if (aEx > bEx) return 1; + return 0; + } + + /** + * Advance the shipped offset after a successful backend ack. Then + * check rotation criteria — if current.jsonl is fully shipped AND + * (too big OR too old), rotate it into archive/ and reset offset to 0. + */ + async function advanceOffset(newOffset) { + if (typeof newOffset !== "number" || newOffset < 0) { + throw new Error(`invalid offset: ${newOffset}`); + } + await writeShippedOffset(newOffset); + await rotateIfNeeded(); + } + + async function rotateIfNeeded() { + if (!existsSync(currentPath)) return; + const st = await stat(currentPath); + const offset = await readShippedOffset(); + const fullyShipped = offset >= st.size; + const tooBig = st.size >= rotateBytes; + const tooOld = Date.now() - st.mtimeMs >= rotateAgeMs; + if (!fullyShipped) return; + if (!tooBig && !tooOld) return; + + await ensureDirs(); + const ts = new Date().toISOString().slice(0, 10); + let seq = 1; + let archivePath; + do { + archivePath = path.join(archiveDir, `${ts}-${String(seq).padStart(3, "0")}.jsonl`); + seq += 1; + } while (existsSync(archivePath)); + await rename(currentPath, archivePath); + await writeShippedOffset(0); + } + + /** + * Delete archived segments. Archives are rotated only AFTER they're + * fully shipped (see rotateIfNeeded), so any file in archive/ is safe + * to delete. Cap retention at `keep` newest segments for forensics — + * defaults to 5 (matches Fluent Bit / OTel collector defaults). + */ + async function pruneArchive(keep = 5) { + if (!existsSync(archiveDir)) return []; + const entries = readdirSync(archiveDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ name: f, mtime: statSync(path.join(archiveDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + const toDelete = entries.slice(keep); + const deleted = []; + for (const entry of toDelete) { + try { + await unlink(path.join(archiveDir, entry.name)); + deleted.push(entry.name); + } catch (err) { + process.stderr.write(`[audit-wal] failed to delete ${entry.name}: ${err?.message ?? err}\n`); + } + } + return deleted; + } + + /** + * Total bytes pending ship — current.jsonl size minus shipped offset. + * Useful for the daemon to decide whether the buffer is "hot" (flush + * sooner) or for debug telemetry. + */ + async function pendingBytes() { + if (!existsSync(currentPath)) return 0; + const st = await stat(currentPath); + const offset = await readShippedOffset(); + return Math.max(0, st.size - offset); + } + + return { + appendLine, + readBatch, + advanceOffset, + rotateIfNeeded, + pruneArchive, + pendingBytes, + readShippedOffset, + // Exposed for tests and ops only. + _paths: { currentPath, offsetPath, archiveDir }, + }; +} diff --git a/packages/armorcopilot-gh/scripts/lib/common.mjs b/packages/armorcopilot-gh/scripts/lib/common.mjs new file mode 100644 index 0000000..a0f5411 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/common.mjs @@ -0,0 +1,415 @@ +import { createHash } from "node:crypto"; + +export function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeToolName(name) { + return typeof name === "string" ? name.trim().toLowerCase() : ""; +} + +export function parseBoolean(value, defaultValue = false) { + if (typeof value !== "string") { + return defaultValue; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return defaultValue; + } + if (["1", "true", "yes", "y", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "n", "off"].includes(normalized)) { + return false; + } + return defaultValue; +} + +export function parseInteger(value, defaultValue) { + if (typeof value !== "string") { + return defaultValue; + } + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +export function parseList(value) { + if (typeof value !== "string") { + return []; + } + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function isSubsetValue(candidate, target) { + if (candidate === undefined) { + return true; + } + if (candidate === null || target === null) { + return candidate === target; + } + if (Array.isArray(candidate)) { + if (!Array.isArray(target)) { + return false; + } + return candidate.every((value) => target.some((item) => isSubsetValue(value, item))); + } + if (isPlainObject(candidate)) { + if (!isPlainObject(target)) { + return false; + } + for (const [key, value] of Object.entries(candidate)) { + if (!isSubsetValue(value, target[key])) { + return false; + } + } + return true; + } + return candidate === target; +} + +// --------------------------------------------------------------------------- +// Operator-based matcher: supports $contains, $startsWith, $endsWith, +// $matches (regex), $pathContains (path-canonicalized substring), $equals. +// +// Rule fragments may use either a plain literal (exact match, same as +// isSubsetValue behaviour) or an operator object: { $contains: "..." }. +// --------------------------------------------------------------------------- + +const OPERATOR_KEYS = new Set([ + "$equals", + "$contains", + "$startsWith", + "$endsWith", + "$matches", + "$pathContains" +]); + +export function isMatcherSpec(value) { + if (!isPlainObject(value)) return false; + const keys = Object.keys(value); + if (keys.length === 0) return false; + return keys.every((k) => OPERATOR_KEYS.has(k)); +} + +// Canonicalize a path/string for $pathContains matching. Operates on free +// text: the rule needle and the tool input may be a path like /etc/passwd, +// a path-with-prefix like "ls -la ~/.ssh", or a tool param like file_path. +// Rule: keep enough structure so substring match Just Works. +function canonicalizePath(input) { + if (typeof input !== "string") return ""; + let p = input.trim(); + // ~ becomes $HOME (only at a path boundary so we don't mangle shell tokens + // like "echo ~hi"). + p = p.replace(/(^|[\s"'`(=:])~(?=\/)/g, "$1$HOME"); + // $HOME or ${HOME} becomes sentinel. + p = p.replace(/\$\{?HOME\}?/g, ""); + // Real home prefixes (Linux + macOS) become sentinel so a rule + // mentioning ~/.ssh matches actual paths like /Users/foo/.ssh and + // /home/bar/.ssh. + p = p.replace(/\/(?:home|Users)\/[^/\s'"`)]+/gi, ""); + // Collapse repeated slashes, lowercase for case-insensitive substring. + p = p.replace(/\\/g, "/").replace(/\/+/g, "/"); + return p.toLowerCase(); +} + +export function matchesScalar(spec, actual) { + // Plain literal: exact match (preserves existing behaviour). + if (!isMatcherSpec(spec)) { + return spec === actual; + } + if (typeof actual !== "string" && typeof actual !== "number") { + return false; + } + const haystack = String(actual); + const haystackLower = haystack.toLowerCase(); + for (const [op, raw] of Object.entries(spec)) { + const needle = typeof raw === "string" ? raw : String(raw); + const needleLower = needle.toLowerCase(); + switch (op) { + case "$equals": + if (haystack !== needle) return false; + break; + case "$contains": + if (!haystackLower.includes(needleLower)) return false; + break; + case "$startsWith": + if (!haystackLower.startsWith(needleLower)) return false; + break; + case "$endsWith": + if (!haystackLower.endsWith(needleLower)) return false; + break; + case "$matches": + try { + const re = new RegExp(needle, "i"); + if (!re.test(haystack)) return false; + } catch { + return false; + } + break; + case "$pathContains": { + const actualPath = canonicalizePath(haystack); + const needlePath = canonicalizePath(needle); + const homeStripped = needlePath.replace(/^\/?/, ""); + if ( + actualPath.includes(needlePath) || + (homeStripped && actualPath.includes(homeStripped)) + ) { + break; + } + return false; + } + default: + return false; + } + } + return true; +} + +/** + * Recursive matcher for rule.params against actual tool input. + * Returns { matched, missingKeys }. missingKeys lists rule keys that have no + * counterpart in the tool input, so callers can surface "rule probably won't + * fire" warnings. + */ +export function matchParams(ruleParams, toolInput) { + if (ruleParams === undefined || ruleParams === null) { + return { matched: true, missingKeys: [] }; + } + if (!isPlainObject(ruleParams)) { + return { matched: false, missingKeys: [] }; + } + const target = isPlainObject(toolInput) ? toolInput : {}; + const missingKeys = []; + for (const [key, value] of Object.entries(ruleParams)) { + const actualValue = target[key]; + if (actualValue === undefined && !isMatcherSpec(value)) { + missingKeys.push(key); + continue; + } + if (isMatcherSpec(value)) { + if (actualValue === undefined) { + missingKeys.push(key); + continue; + } + if (!matchesScalar(value, actualValue)) { + return { matched: false, missingKeys }; + } + continue; + } + if (isPlainObject(value)) { + const sub = matchParams(value, actualValue); + missingKeys.push(...sub.missingKeys.map((k) => `${key}.${k}`)); + if (!sub.matched) { + return { matched: false, missingKeys }; + } + continue; + } + if (Array.isArray(value)) { + if (!Array.isArray(actualValue)) { + return { matched: false, missingKeys }; + } + const allFound = value.every((needle) => + actualValue.some((item) => matchesScalar(needle, item) || isSubsetValue(needle, item)) + ); + if (!allFound) { + return { matched: false, missingKeys }; + } + continue; + } + if (value !== actualValue) { + return { matched: false, missingKeys }; + } + } + if (missingKeys.length > 0) { + return { matched: false, missingKeys }; + } + return { matched: true, missingKeys: [] }; +} + +/** + * Apply a single matcher spec across ANY string field in a tool input. + * Used for rules like "deny anything mentioning ~/.ssh" where the user + * doesn't know which parameter key the tool uses. + */ +export function matchesAnyStringField(spec, toolInput, depth = 0) { + if (depth > 4) return false; + if (toolInput === null || toolInput === undefined) return false; + if (typeof toolInput === "string") { + return matchesScalar(spec, toolInput); + } + if (Array.isArray(toolInput)) { + return toolInput.some((entry) => matchesAnyStringField(spec, entry, depth + 1)); + } + if (isPlainObject(toolInput)) { + for (const value of Object.values(toolInput)) { + if (matchesAnyStringField(spec, value, depth + 1)) return true; + } + } + return false; +} + +function sanitizeValue(value, limits, depth) { + if (depth > limits.maxDepth) { + return ""; + } + if (value == null) { + return value; + } + if (typeof value === "string") { + return value.length > limits.maxChars ? `${value.slice(0, limits.maxChars)}...` : value; + } + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") { + return value.toString(); + } + if (typeof value === "function") { + return ""; + } + if (value instanceof Uint8Array) { + return ``; + } + if (Array.isArray(value)) { + return value.slice(0, limits.maxItems).map((entry) => sanitizeValue(entry, limits, depth + 1)); + } + if (isPlainObject(value)) { + const out = {}; + for (const [key, item] of Object.entries(value).slice(0, limits.maxKeys)) { + out[key] = sanitizeValue(item, limits, depth + 1); + } + return out; + } + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return ""; + } +} + +export function sanitizeParams(params, limits) { + const input = isPlainObject(params) ? params : {}; + const sanitized = sanitizeValue(input, limits, 0); + return isPlainObject(sanitized) ? sanitized : {}; +} + +// --------------------------------------------------------------------------- +// Secret redaction — applied to audit payloads before they leave the host. +// Kept deliberately cheap: a handful of regexes run against strings only, +// no deep rebuild when nothing matches. +// --------------------------------------------------------------------------- + +const SECRET_PATTERNS = [ + // Bearer / Authorization tokens in free text + /\b(Bearer\s+)[A-Za-z0-9._\-+/=]{12,}/gi, + // AWS access keys + /\bAKIA[0-9A-Z]{16}\b/g, + // Generic long hex / base64 tokens prefixed by common secret field names + /\b((?:api[_-]?key|secret|token|password|passwd|pwd|authorization)\s*[:=]\s*)["']?[A-Za-z0-9._\-+/=]{12,}["']?/gi, + // GitHub personal access tokens + /\bghp_[A-Za-z0-9]{30,}\b/g, + // JWT-ish three-part tokens + /\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, + // Private key blocks + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g +]; + +function redactString(text) { + let out = text; + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, (match, prefix) => `${prefix || ""}`); + } + return out; +} + +function redactValue(value, depth = 0) { + if (depth > 8) return value; + if (typeof value === "string") { + return redactString(value); + } + if (Array.isArray(value)) { + return value.map((entry) => redactValue(entry, depth + 1)); + } + if (isPlainObject(value)) { + const out = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = redactValue(entry, depth + 1); + } + return out; + } + return value; +} + +export function redactSecrets(value) { + return redactValue(value, 0); +} + +export function nowEpochSeconds() { + return Math.floor(Date.now() / 1000); +} + +export function readString(value) { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +export function parseStepIndex(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + +export function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} + +// --------------------------------------------------------------------------- +// HTTP helpers (shared by intent.mjs and iap-service.mjs) +// --------------------------------------------------------------------------- + +export async function postJson(url, payload, headers, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal + }); + const text = await response.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = null; + } + } + return { ok: response.ok, status: response.status, text, data }; + } finally { + clearTimeout(timeout); + } +} + +export function buildAuthHeaders(config) { + const headers = { "Content-Type": "application/json" }; + if (config.apiKey) { + headers.Authorization = `Bearer ${config.apiKey}`; + headers["X-API-Key"] = config.apiKey; + headers["x-api-key"] = config.apiKey; + } + return headers; +} diff --git a/packages/armorcopilot-gh/scripts/lib/config.mjs b/packages/armorcopilot-gh/scripts/lib/config.mjs new file mode 100644 index 0000000..5198b0a --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/config.mjs @@ -0,0 +1,164 @@ +import { homedir } from "node:os"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { parseBoolean, parseInteger, parseList } from "./common.mjs"; + +/** + * Read a config value from plugin userConfig env, falling back to the + * ARMORCOPILOT_* env var used by repo-local hook installs. + */ +function pluginOpt(env, pluginKey, legacyKey) { + const pluginVal = + env[`COPILOT_PLUGIN_OPTION_${pluginKey}`]?.trim() || + env[`CLAUDE_PLUGIN_OPTION_${pluginKey}`]?.trim(); + if (pluginVal) return pluginVal; + if (legacyKey) return env[legacyKey]?.trim() || ""; + return ""; +} + +export function loadConfig(env = process.env) { + const mode = (pluginOpt(env, "MODE", "ARMORCOPILOT_MODE") || "enforce").toLowerCase(); + const envMode = (env.ARMORIQ_ENV || "production").trim().toLowerCase(); + const useProduction = parseBoolean( + pluginOpt(env, "USE_PRODUCTION", "ARMORCOPILOT_USE_PRODUCTION") || undefined, + envMode === "production" + ); + + // Data directory: prefer plugin-injected storage, then + // ARMORCOPILOT_DATA_DIR, then default ~/.copilot/armorcopilot. + const dataDir = + env.COPILOT_PLUGIN_DATA?.trim() || + env.CLAUDE_PLUGIN_DATA?.trim() || + env.ARMORCOPILOT_DATA_DIR?.trim() || + path.join(homedir(), ".copilot", "armorcopilot"); + + const policyFile = + env.ARMORCOPILOT_POLICY_FILE?.trim() || path.join(dataDir, "policy.json"); + const runtimeFile = + env.ARMORCOPILOT_RUNTIME_FILE?.trim() || path.join(dataDir, "runtime.json"); + + const timeoutMs = parseInteger(env.ARMORCOPILOT_TIMEOUT_MS, 8000); + + // dev branch — points at staging-api for pre-release testing. + // main branch keeps "https://api.armoriq.ai" (prod). When promoting a + // feature from dev → main, resolve the URL conflict in favor of main. + const backendEndpoint = + env.ARMORCOPILOT_BACKEND_ENDPOINT?.trim() || + env.BACKEND_ENDPOINT?.trim() || + (useProduction + ? "https://staging-api.armoriq.ai" + : "http://127.0.0.1:3000"); + + const iapEndpoint = + env.ARMORCOPILOT_IAP_ENDPOINT?.trim() || + env.IAP_ENDPOINT?.trim() || + (useProduction + ? "https://iap.armoriq.ai" + : "http://127.0.0.1:8000"); + + const proxyEndpoint = + env.ARMORCOPILOT_PROXY_ENDPOINT?.trim() || + env.PROXY_ENDPOINT?.trim() || + (useProduction + ? "https://cloud-run-proxy.armoriq.io" + : "http://127.0.0.1:3001"); + + const csrgEndpoint = + pluginOpt(env, "CSRG_ENDPOINT", "CSRG_URL") || iapEndpoint; + + // API key resolution: plugin config → env var → ~/.armoriq/credentials.json + let apiKey = pluginOpt(env, "API_KEY", "ARMORIQ_API_KEY"); + if (!apiKey) { + try { + const credPath = path.join(homedir(), ".armoriq", "credentials.json"); + const creds = JSON.parse(readFileSync(credPath, "utf-8")); + if (creds?.apiKey && typeof creds.apiKey === "string") { + apiKey = creds.apiKey; + } + } catch { + // no credentials file — local-only mode + } + } + + return { + mode: mode === "monitor" ? "monitor" : "enforce", + dataDir, + policyFile, + runtimeFile, + useProduction, + backendEndpoint, + iapEndpoint, + proxyEndpoint, + csrgEndpoint, + apiKey, + useSdkIntent: parseBoolean(env.ARMORCOPILOT_USE_SDK_INTENT, true), + intentEndpoint: env.ARMORCOPILOT_INTENT_URL?.trim() || "", + verifyStepEndpoint: + env.ARMORCOPILOT_VERIFY_STEP_URL?.trim() || + `${backendEndpoint}/iap/verify-step`, + // 10 minutes is long enough for multi-step agentic work without forcing + // a replan mid-turn. Set ARMORCOPILOT_VALIDITY_SECONDS to tighten. + validitySeconds: parseInteger(env.ARMORCOPILOT_VALIDITY_SECONDS, 600), + // Proactively refresh the intent token when it has less than this many + // seconds of life left, so tool calls don't hit the expiry boundary. + refreshThresholdSeconds: parseInteger(env.ARMORCOPILOT_REFRESH_THRESHOLD_SECONDS, 30), + timeoutMs, + // One attempt per tool call is usually right — a hung backend shouldn't + // stall Copilot for timeout * retries. Users who really want retries can + // opt in via ARMORCOPILOT_MAX_RETRIES. + maxRetries: parseInteger(env.ARMORCOPILOT_MAX_RETRIES, 1), + verifySsl: parseBoolean(env.ARMORCOPILOT_VERIFY_SSL, true), + llmId: env.ARMORCOPILOT_LLM_ID?.trim() || "github-copilot", + mcpName: env.ARMORCOPILOT_MCP_NAME?.trim() || "copilot", + userId: env.ARMORCOPILOT_USER_ID?.trim() || "copilot-user", + agentId: env.ARMORCOPILOT_AGENT_ID?.trim() || "copilot", + contextId: env.ARMORCOPILOT_CONTEXT_ID?.trim() || "default", + + // Intent enforcement — default true (enforce plan mode) + intentRequired: parseBoolean( + pluginOpt(env, "INTENT_REQUIRED", "ARMORCOPILOT_INTENT_REQUIRED") || undefined, + true + ), + // CSRG verification disabled by default until tenant OPA policies are + // configured to allow Copilot tools. The OPA default-deny behavior + // blocks all tools when no matching policy exists. Enable once your + // tenant has allow-rules for the tools Copilot uses. + requireCsrgProofs: parseBoolean(env.REQUIRE_CSRG_PROOFS, false), + csrgVerifyEnabled: parseBoolean(env.CSRG_VERIFY_ENABLED, false), + + // Policy management + policyUpdateEnabled: parseBoolean(env.ARMORCOPILOT_POLICY_UPDATE_ENABLED, true), + policyUpdateAllowList: parseList( + env.ARMORCOPILOT_POLICY_UPDATE_ALLOWLIST || "*" + ), + contextHintsEnabled: parseBoolean( + env.ARMORCOPILOT_CONTEXT_HINTS_ENABLED, + true + ), + + // Crypto policy binding (Merkle tree) + cryptoPolicyEnabled: parseBoolean( + pluginOpt(env, "CRYPTO_POLICY_ENABLED", "ARMORCOPILOT_CRYPTO_POLICY_ENABLED") || undefined, + false + ), + + // Audit logging + auditEnabled: parseBoolean( + env.ARMORCOPILOT_AUDIT_ENABLED, + Boolean(apiKey) + ), + + // Plan directive injection (tells Copilot to register a plan via MCP tool) + planningEnabled: parseBoolean(env.ARMORCOPILOT_PLANNING_ENABLED, true), + + // Param sanitization limits + sanitize: { + maxChars: parseInteger(env.ARMORCOPILOT_MAX_PARAM_CHARS, 2000), + maxDepth: parseInteger(env.ARMORCOPILOT_MAX_PARAM_DEPTH, 4), + maxKeys: parseInteger(env.ARMORCOPILOT_MAX_PARAM_KEYS, 50), + maxItems: parseInteger(env.ARMORCOPILOT_MAX_PARAM_ITEMS, 50) + }, + + debug: parseBoolean(env.ARMORCOPILOT_DEBUG, false) + }; +} diff --git a/packages/armorcopilot-gh/scripts/lib/crypto-policy.mjs b/packages/armorcopilot-gh/scripts/lib/crypto-policy.mjs new file mode 100644 index 0000000..586c8e4 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/crypto-policy.mjs @@ -0,0 +1,244 @@ +/** + * Crypto-Bound Policy Service + * + * Embeds policy rules into CSRG tokens with cryptographic (Merkle tree) proofs. + * Ported from ArmorClaw's CryptoPolicyService (crypto-policy.service.ts). + * + * Flow: + * 1. Policy update -> build policy metadata -> call CSRG /intent + * 2. CSRG hashes policy into Merkle tree -> signs with Ed25519 + * 3. Tool execution -> verify policy digest matches token + * + * State is persisted to disk because hooks are stateless short-lived processes. + */ + +import { isPlainObject, postJson, sha256Hex } from "./common.mjs"; +import { readJson, writeJson } from "./fs-store.mjs"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Policy digest computation +// --------------------------------------------------------------------------- + +/** + * Compute a canonical SHA-256 digest of policy rules. + * Must match ArmorClaw's computePolicyDigest exactly. + */ +export function computePolicyDigest(rules) { + if (!Array.isArray(rules)) return sha256Hex("policy|[]"); + const canonical = JSON.stringify( + rules.map((r) => ({ + id: r.id, + action: r.action, + tool: r.tool, + dataClass: r.dataClass, + params: r.params, + scope: r.scope + })), + null, + 0 + ); + return sha256Hex(`policy|${canonical}`); +} + +// --------------------------------------------------------------------------- +// Service factory +// --------------------------------------------------------------------------- + +/** + * Create a CryptoPolicyService instance. + * Adapted for stateless hook execution with file-based persistence. + */ +export function createCryptoPolicyService(config) { + const csrgEndpoint = config.csrgEndpoint || config.iapEndpoint || ""; + const timeoutMs = config.timeoutMs || 30000; + const stateFilePath = path.join(config.dataDir, "crypto-policy-state.json"); + + return { + /** + * Issue a new CSRG policy token with policy embedded in Merkle tree. + */ + async issuePolicyToken(policyState, identity, validitySeconds = 3600) { + const digest = computePolicyDigest(policyState.policy?.rules || []); + + const policyMetadata = { + rules: policyState.policy?.rules || [], + version: policyState.version || 0, + updated_at: policyState.updatedAt || new Date().toISOString(), + updated_by: policyState.updatedBy, + policy_digest: digest + }; + + const plan = buildPolicyPlan(policyState.policy); + + const request = { + plan, + policy: { + global: { + metadata: policyMetadata + } + }, + identity: { + user_id: identity.userId || config.userId || "copilot-user", + agent_id: identity.agentId || config.agentId || "copilot", + context_id: identity.contextId || config.contextId || "default" + }, + validity_seconds: validitySeconds + }; + + const response = await postJson( + `${csrgEndpoint}/intent`, + request, + { "Content-Type": "application/json" }, + timeoutMs + ); + + if (!response.ok || !response.data) { + const msg = response.text || `CSRG /intent failed with status ${response.status}`; + throw new Error(`Policy token issuance failed: ${msg}`); + } + + const token = { + ...response.data, + policy_digest: digest + }; + + // Persist to disk + await writeJson(stateFilePath, { + token, + policyDigest: digest, + issuedAt: Date.now() + }); + + return token; + }, + + /** + * Verify that the current policy digest matches the cached token digest. + * Returns { valid, reason }. + */ + verifyPolicyDigest(currentDigest, tokenDigest) { + if (!tokenDigest) { + return { + valid: false, + reason: "No policy token - policy not cryptographically bound" + }; + } + if (currentDigest !== tokenDigest) { + return { + valid: false, + reason: `Policy mismatch: current=${currentDigest.slice(0, 16)}... token=${tokenDigest.slice(0, 16)}...` + }; + } + return { valid: true, reason: "Policy digest verified" }; + }, + + /** + * Verify a policy rule is included in the token using CSRG /verify/action. + */ + async verifyPolicyRule(ruleId, toolName) { + const cached = await this.loadCachedState(); + if (!cached?.token) { + return { allowed: false, reason: "No policy token cached" }; + } + + const ruleProof = cached.token.step_proofs?.find( + (p) => p.path?.includes(ruleId) || p.path?.includes(toolName) + ); + + if (!ruleProof) { + return { allowed: true, reason: "No specific proof required" }; + } + + const verifyRequest = { + path: ruleProof.path, + value: { tool: toolName, rule_id: ruleId }, + proof: ruleProof.proof, + token: cached.token.token + }; + + const response = await postJson( + `${csrgEndpoint}/verify/action`, + verifyRequest, + { "Content-Type": "application/json" }, + Math.min(timeoutMs, 15000) + ); + + if (!response.ok || !response.data) { + return { + allowed: false, + reason: response.text || "CSRG verification failed" + }; + } + + return response.data; + }, + + /** + * Load persisted crypto policy state from disk. + */ + async loadCachedState() { + return await readJson(stateFilePath, null); + }, + + /** + * Clear persisted crypto policy state. + */ + async clearCache() { + try { + await writeJson(stateFilePath, null); + } catch { /* ignore */ } + } + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert policy rules into a plan structure for CSRG hashing. + * Each rule becomes a step with action "policy_rule:". + * Matches ArmorClaw's CryptoPolicyService.buildPolicyPlan(). + */ +function buildPolicyPlan(policy) { + const rules = Array.isArray(policy?.rules) ? policy.rules : []; + + const steps = rules.map((rule) => ({ + action: `policy_rule:${rule.id}`, + mcp: "armoriq-policy", + description: `Rule: ${rule.action} ${rule.tool}${rule.dataClass ? ` for ${rule.dataClass}` : ""}`, + metadata: { + rule_id: rule.id, + rule_action: rule.action, + rule_tool: rule.tool, + rule_data_class: rule.dataClass, + rule_params: rule.params, + rule_scope: rule.scope + } + })); + + if (steps.length === 0) { + steps.push({ + action: "policy_rule:allow-all", + mcp: "armoriq-policy", + description: "Default: allow all", + metadata: { + rule_id: "allow-all", + rule_action: "allow", + rule_tool: "*", + rule_data_class: undefined, + rule_params: undefined, + rule_scope: undefined + } + }); + } + + return { + steps, + metadata: { + goal: "ArmorIQ policy enforcement", + policy_type: "crypto-bound" + } + }; +} diff --git a/packages/armorcopilot-gh/scripts/lib/engine.mjs b/packages/armorcopilot-gh/scripts/lib/engine.mjs new file mode 100644 index 0000000..3302246 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/engine.mjs @@ -0,0 +1,746 @@ +import { isPlainObject, normalizeToolName, nowEpochSeconds, redactSecrets, sanitizeParams } from "./common.mjs"; +import { addPromptContext, blockPrompt, denyPermissionRequest, denyPreTool } from "./hook-output.mjs"; +import { + checkIntentTokenPlan, + checkToolAgainstPlan, + extractAllowedActions, + findPlanStepIndices, + getSessionTokenUsedStepIndices, + parseCsrgProofHeaders, + recordSessionTokenUsedStepIndices, + requestIntent, + resolveCsrgProofsFromToken, + validateCsrgProofHeaders +} from "./intent.mjs"; +import { createIapService } from "./iap-service.mjs"; +import { + applyPolicyCommand, + computePolicyHash, + evaluatePolicy, + loadPolicyState, + parsePolicyTextCommand +} from "./policy.mjs"; +import { readJson } from "./fs-store.mjs"; +import { unlink } from "node:fs/promises"; +import path from "node:path"; +import { + getSession, + loadRuntimeState, + saveRuntimeState, + upsertDiscoveredTool, + upsertSession +} from "./runtime-state.mjs"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function shouldDeny(config) { + return config.mode === "enforce"; +} + +function buildPolicyContextHints() { + return "For policy changes call `policy_update` (mode: replace rewrites the full ruleset; empty rules clears policy)."; +} + +function actorCandidates(input) { + const out = []; + for (const key of ["session_id", "user_id", "actor_id", "cwd"]) { + const value = input && typeof input[key] === "string" ? input[key].trim() : ""; + if (value) { + out.push(value); + } + } + return out; +} + +function policyCommandLooksLikePrompt(prompt) { + return typeof prompt === "string" && /^\s*policy\b/i.test(prompt); +} + +function isPolicyUpdateAllowed(config, input) { + if (!config.policyUpdateEnabled) { + return { allowed: false, reason: "ArmorCopilot policy updates disabled" }; + } + const allowList = config.policyUpdateAllowList; + if (!Array.isArray(allowList) || allowList.length === 0 || allowList.includes("*")) { + return { allowed: true }; + } + const candidates = actorCandidates(input); + const allowed = candidates.some((entry) => allowList.includes(entry)); + return allowed + ? { allowed: true } + : { + allowed: false, + reason: "ArmorCopilot policy update denied", + candidates + }; +} + +function mergeIntentIntoSession(session, intentResponse) { + if (!intentResponse || intentResponse.skipped) { + return session; + } + const next = { ...session }; + if (typeof intentResponse.tokenRaw === "string") { + next.intentTokenRaw = intentResponse.tokenRaw; + } + if (intentResponse.plan && typeof intentResponse.plan === "object") { + next.plan = intentResponse.plan; + next.allowedActions = Array.from(extractAllowedActions(intentResponse.plan)); + } + if (Number.isFinite(intentResponse.expiresAt)) { + next.expiresAt = intentResponse.expiresAt; + } + return next; +} + +function readIntentTokenRaw(input, session) { + const candidates = [ + input.intentTokenRaw, + input.intent_token_raw, + input.intent_token, + input.intentToken, + session.intentTokenRaw + ]; + for (const value of candidates) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function denyOrAllow(config, reason) { + if (shouldDeny(config)) { + return denyPreTool(reason); + } + return null; +} + +function debugLog(config, message) { + if (config.debug) { + process.stderr.write(`[armorcopilot] ${message}\n`); + } +} + +/** + * Pick the best matching step index in the plan for a given tool call. + * Prefers a step that matches BOTH tool name and parameters, falls back to + * tool name only, then to step 0. Used to populate audit log step_index so + * the backend can advance plan execution state to 'completed'. + */ +function pickStepIndex(plan, toolName, toolInput) { + if (!plan || typeof plan !== "object") return 0; + const { matches, paramMatches } = findPlanStepIndices(plan, toolName, toolInput); + if (paramMatches.length > 0) return paramMatches[0]; + if (matches.length > 0) return matches[0]; + return 0; +} + +// --------------------------------------------------------------------------- +// SessionStart +// --------------------------------------------------------------------------- + +export async function handleSessionStart(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!sessionId) return null; + + const runtimeState = await loadRuntimeState(config.runtimeFile); + upsertSession(runtimeState, sessionId, { + startedAt: nowEpochSeconds(), + discoveredTools: [] + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + + debugLog(config, `session started: ${sessionId}, mode=${config.mode}`); + + const modeLabel = config.mode === "enforce" ? "ENFORCING" : "MONITORING"; + const intentLabel = config.intentRequired ? "required" : "optional"; + return addPromptContext( + `ArmorCopilot active (${modeLabel}, intent=${intentLabel})`, + "SessionStart" + ); +} + +// --------------------------------------------------------------------------- +// UserPromptSubmit +// --------------------------------------------------------------------------- + +export async function handleUserPromptSubmit(input, config) { + const prompt = typeof input.prompt === "string" ? input.prompt : ""; + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!prompt || !sessionId) { + return null; + } + + // --- Policy command handling --- + if (policyCommandLooksLikePrompt(prompt)) { + const allowed = isPolicyUpdateAllowed(config, input); + if (!allowed.allowed) { + return blockPrompt(allowed.reason || "ArmorCopilot policy update denied"); + } + const policyState = await loadPolicyState(config.policyFile); + const command = parsePolicyTextCommand(prompt, policyState); + const actor = actorCandidates(input)[0] || "unknown"; + const result = await applyPolicyCommand({ + policyFilePath: config.policyFile, + state: policyState, + command, + actor + }); + return blockPrompt(result.message); + } + + // --- Store prompt in session --- + const runtimeState = await loadRuntimeState(config.runtimeFile); + upsertSession(runtimeState, sessionId, { + lastPrompt: prompt, + lastPromptAt: nowEpochSeconds() + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + + // --- Inject directive: tell Copilot to register its intent plan --- + // Copilot will call the `register_intent_plan` MCP tool as its first action. + // The MCP tool's inputSchema already describes the JSON shape, so we don't + // duplicate it here — keeps the visible prompt context short. + const parts = []; + if (config.planningEnabled) { + parts.push( + "ArmorCopilot active. Call `register_intent_plan` first; step `action` = tool name, `metadata.inputs` = `{}` matches by name only." + ); + } + if (config.contextHintsEnabled && config.policyUpdateEnabled) { + parts.push(buildPolicyContextHints()); + } + if (parts.length > 0) { + return addPromptContext(parts.join("\n\n")); + } + return null; +} + +// --------------------------------------------------------------------------- +// PreToolUse +// --------------------------------------------------------------------------- + +export async function handlePreToolUse(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + const toolInput = sanitizeParams(input.tool_input, config.sanitize); + if (!toolName) { + // Missing tool_name on a PreToolUse event means the payload shape is + // unexpected. Fail-closed in enforce mode instead of silently allowing. + return denyOrAllow(config, "ArmorCopilot: missing tool_name on PreToolUse"); + } + + // --- Whitelist: ArmorCopilot's own MCP tools must never be blocked, + // otherwise the agent can't register a plan or read/update policy. + // Match the exact MCP prefix from .mcp.json (armorcopilot-policy), + // not any suffix — an evil server called evil__policy_update would + // previously have been whitelisted. --- + const norm = normalizeToolName(toolName); + const armorTools = ["register_intent_plan", "policy_read", "policy_update"]; + // Copilot MCP namespace is `mcp____` and the underlying MCP server name + // can carry hyphens (`armorcopilot-policy`) or be sanitized to underscores + // (`armorcopilot_policy`). Copilot's TUI display also surfaces `.` + // in user-facing strings. Match all reasonable forms — but only accept names + // anchored to our own server identifier so this can't whitelist a malicious + // MCP server that happens to expose a same-named tool. + const ARMOR_SERVER_RE = /(mcp__armorcopilot[-_]policy__|armorcopilot[-_]policy[._])/; + if ( + armorTools.some( + (t) => + norm === t || + (norm.endsWith(t) && ARMOR_SERVER_RE.test(norm)) + ) + ) { + return null; + } + + // --- Whitelist: Copilot introspection / coordination tools that have + // no side effects on user files or systems. Blocking these makes the + // agent fight itself (e.g. ToolSearch is needed to fetch deferred MCP + // tool schemas before they can be called). --- + const safeInternalTools = new Set([ + "toolsearch", + "todowrite", + "listmcpresourcestool", + "readmcpresourcetool", + "read", + "grep", + "glob", + "websearch", + "webfetch" + ]); + if (safeInternalTools.has(norm)) { + return null; + } + + // --- Consume pending plan from register_intent_plan MCP tool --- + // Always consume if a pending file exists — the MCP handler only writes + // it when Copilot has registered a NEW plan, and stale plans must be + // overwritten so each prompt gets its own plan boundary. + // This load is reused for the rest of the PreToolUse handler instead of + // reloading from disk below (fewer disk reads on the hot path). + const runtimeState = await loadRuntimeState(config.runtimeFile); + // Per-session plan file so concurrent Copilot windows don't clobber each + // other. Fall back to the legacy global path for installs that still have + // a write from a pre-upgrade MCP server. + const sessionPendingPath = sessionId + ? path.join(config.dataDir, `pending-plan.${sessionId}.json`) + : null; + const legacyPendingPath = path.join(config.dataDir, "pending-plan.json"); + let pendingPath = sessionPendingPath; + let pending = sessionPendingPath ? await readJson(sessionPendingPath, null) : null; + if (!pending) { + pending = await readJson(legacyPendingPath, null); + if (pending) pendingPath = legacyPendingPath; + } + if (pending && (pending.tokenRaw || pending.plan)) { + upsertSession(runtimeState, sessionId, { + intentTokenRaw: pending.tokenRaw || "", + plan: pending.plan, + allowedActions: Array.isArray(pending.allowedActions) ? pending.allowedActions : [], + expiresAt: pending.expiresAt, + // Reset per-token execution tracking when a new plan replaces the old. + intentExecution: undefined + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + if (pendingPath) await unlink(pendingPath).catch(() => {}); + debugLog(config, "consumed pending plan from register_intent_plan"); + } + + // --- Static policy evaluation --- + const policyState = await loadPolicyState(config.policyFile); + + // Crypto policy digest check (Phase 4 integration point) + if (config.cryptoPolicyEnabled) { + try { + const { createCryptoPolicyService } = await import("./crypto-policy.mjs"); + const cryptoService = createCryptoPolicyService(config); + const currentDigest = computePolicyHash(policyState.policy); + const cachedState = await cryptoService.loadCachedState(); + if (cachedState?.policyDigest) { + const check = cryptoService.verifyPolicyDigest(currentDigest, cachedState.policyDigest); + if (!check.valid) { + return denyOrAllow(config, `ArmorCopilot crypto policy mismatch: ${check.reason}`); + } + } + } catch (error) { + debugLog(config, `crypto policy check error: ${error}`); + } + } + + const policyDecision = evaluatePolicy({ + policy: policyState.policy, + toolName, + toolParams: toolInput + }); + if (!policyDecision.allowed) { + return denyPreTool(policyDecision.reason || "ArmorCopilot policy denied"); + } + + // --- Intent token verification --- + // Reuse the runtimeState loaded above instead of re-reading from disk. + const session = getSession(runtimeState, sessionId) || {}; + let intentTokenRaw = readIntentTokenRaw(input, session); + let localPlan = session.plan; + let localExpiresAt = session.expiresAt; + let remoteAllowed = false; + let tokenCheckMatched = false; + let usedStepIndices = + intentTokenRaw && localPlan + ? getSessionTokenUsedStepIndices(session, intentTokenRaw) + : undefined; + + // Proactive refresh: if the token is about to expire and we still have the + // plan, re-issue silently so the user never sees a "token expired" deny in + // the middle of a multi-step turn. If the refresh fails, flow falls through + // to the existing expiry check below. + const refreshThreshold = Number.isFinite(config.refreshThresholdSeconds) + ? config.refreshThresholdSeconds + : 30; + if ( + intentTokenRaw && + isPlainObject(localPlan) && + Number.isFinite(localExpiresAt) && + localExpiresAt - nowEpochSeconds() < refreshThreshold && + (config.intentEndpoint || (config.useSdkIntent && config.apiKey)) + ) { + try { + const policyHash = computePolicyHash(policyState.policy); + const refreshed = await requestIntent(config, { + prompt: session.lastPrompt || `Refresh intent for ${toolName}`, + plan: localPlan, + session_id: sessionId, + toolName, + toolInput, + policy_hash: policyHash, + policy: policyState.policy, + validitySeconds: config.validitySeconds, + metadata: { source: "copilot", trigger: "auto_refresh" } + }); + if (!refreshed.skipped) { + const merged = mergeIntentIntoSession(session, refreshed); + upsertSession(runtimeState, sessionId, merged); + intentTokenRaw = + typeof merged.intentTokenRaw === "string" + ? merged.intentTokenRaw + : intentTokenRaw; + localPlan = merged.plan || localPlan; + localExpiresAt = merged.expiresAt || localExpiresAt; + debugLog(config, "intent token auto-refreshed near expiry"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLog(config, `auto-refresh failed: ${message}`); + } + } + + // If no token, try to acquire one + if (!intentTokenRaw && (config.intentEndpoint || (config.useSdkIntent && config.apiKey))) { + try { + const policyHash = computePolicyHash(policyState.policy); + const intentResponse = await requestIntent(config, { + prompt: session.lastPrompt || `Use tool ${toolName}`, + session_id: sessionId, + toolName, + toolInput, + policy_hash: policyHash, + policy: policyState.policy, + validitySeconds: config.validitySeconds, + metadata: { + source: "copilot", + trigger: "pre_tool_use" + } + }); + const merged = mergeIntentIntoSession(session, intentResponse); + upsertSession(runtimeState, sessionId, merged); + intentTokenRaw = + typeof merged.intentTokenRaw === "string" ? merged.intentTokenRaw : ""; + localPlan = merged.plan || localPlan; + localExpiresAt = merged.expiresAt || localExpiresAt; + usedStepIndices = + intentTokenRaw && localPlan + ? getSessionTokenUsedStepIndices(merged, intentTokenRaw) + : undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (config.intentRequired && shouldDeny(config)) { + return denyPreTool(`ArmorCopilot intent planning failed: ${message}`); + } + } + } + + // Validate tool against intent token plan + if (intentTokenRaw) { + const tokenCheck = checkIntentTokenPlan({ + intentTokenRaw, + toolName, + toolParams: toolInput + }); + if (tokenCheck.matched) { + tokenCheckMatched = true; + if (tokenCheck.blockReason) { + return denyOrAllow(config, tokenCheck.blockReason); + } + localPlan = tokenCheck.plan || localPlan; + remoteAllowed = true; + } + } + + // --- CSRG proof handling --- + const parsedProofs = parseCsrgProofHeaders(input); + if (parsedProofs.error) { + return denyOrAllow(config, parsedProofs.error); + } + let csrgProofs = parsedProofs.proofs; + if (!csrgProofs && intentTokenRaw && localPlan && typeof localPlan === "object") { + const resolved = resolveCsrgProofsFromToken({ + intentTokenRaw, + plan: localPlan, + toolName, + toolParams: toolInput, + usedStepIndices + }); + if (resolved) { + csrgProofs = resolved; + } + } + const proofError = validateCsrgProofHeaders( + csrgProofs, + config.requireCsrgProofs && + config.csrgVerifyEnabled && + Boolean(config.verifyStepEndpoint) && + Boolean(intentTokenRaw) + ); + if (proofError) { + return denyOrAllow(config, proofError); + } + + // --- Remote step verification --- + if (intentTokenRaw && config.verifyStepEndpoint && config.csrgVerifyEnabled) { + try { + const iapService = createIapService(config); + const verifyResult = await iapService.verifyStep(intentTokenRaw, csrgProofs, toolName); + if (!verifyResult.skipped) { + remoteAllowed = verifyResult.allowed === true; + } + if (verifyResult.allowed === false) { + return denyOrAllow( + config, + verifyResult.reason || `ArmorCopilot intent verification denied for ${toolName}` + ); + } + const merged = mergeIntentIntoSession(session, verifyResult); + upsertSession(runtimeState, sessionId, merged); + localPlan = merged.plan || localPlan; + localExpiresAt = merged.expiresAt || localExpiresAt; + if (typeof verifyResult.stepIndex === "number") { + const indices = usedStepIndices || new Set(); + indices.add(verifyResult.stepIndex); + recordSessionTokenUsedStepIndices(merged, intentTokenRaw, indices); + } else if (usedStepIndices && intentTokenRaw) { + recordSessionTokenUsedStepIndices(merged, intentTokenRaw, usedStepIndices); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const deny = denyOrAllow(config, `ArmorCopilot verify-step failed: ${message}`); + if (deny) { + return deny; + } + } + } + + // --- Expiry check --- + if (Number.isFinite(localExpiresAt) && nowEpochSeconds() > localExpiresAt) { + const deny = denyOrAllow( + config, + "ArmorCopilot intent token expired — call register_intent_plan with your current plan to refresh, then retry the tool" + ); + if (deny) { + return deny; + } + } + + // --- Local plan enforcement (no backend / no token) --- + // When a plan was registered via register_intent_plan but ArmorIQ is not + // configured, enforce the plan locally: tool must be in plan, and params + // (if declared in step.metadata.inputs) must match. + let localPlanMatched = false; + if (!intentTokenRaw && localPlan && typeof localPlan === "object") { + const localCheck = checkToolAgainstPlan({ + plan: localPlan, + toolName, + toolInput + }); + if (localCheck.allowed) { + localPlanMatched = true; + } else { + const deny = denyOrAllow(config, localCheck.reason || "ArmorCopilot intent drift"); + if (deny) { + return deny; + } + } + } + + // --- Enforce intent requirement --- + if (config.intentRequired && !remoteAllowed && !tokenCheckMatched && !localPlanMatched) { + const deny = denyOrAllow(config, "ArmorCopilot intent plan missing for this session"); + if (deny) { + return deny; + } + } + + // --- Record tool for discovery --- + upsertDiscoveredTool(runtimeState, toolName); + await saveRuntimeState(config.runtimeFile, runtimeState); + return null; +} + +// --------------------------------------------------------------------------- +// PermissionRequest +// --------------------------------------------------------------------------- + +export async function handlePermissionRequest(input, config) { + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + const toolInput = sanitizeParams(input.tool_input, config.sanitize); + if (!toolName) { + return null; + } + + const policyState = await loadPolicyState(config.policyFile); + const policyDecision = evaluatePolicy({ + policy: policyState.policy, + toolName, + toolParams: toolInput + }); + if (!policyDecision.allowed && shouldDeny(config)) { + return denyPermissionRequest(policyDecision.reason || "ArmorCopilot policy denied approval request"); + } + + return null; +} + +// --------------------------------------------------------------------------- +// PostToolUse — audit logging +// --------------------------------------------------------------------------- + +export async function handlePostToolUse(input, config) { + if (!config.auditEnabled || !config.apiKey) { + return null; + } + + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + if (!toolName) return null; + + try { + const runtimeState = await loadRuntimeState(config.runtimeFile); + const session = getSession(runtimeState, sessionId) || {}; + const iapService = createIapService(config); + + const intentTokenRaw = session.intentTokenRaw || ""; + let token = intentTokenRaw; + // Extract JWT if embedded in JSON envelope + if (intentTokenRaw.startsWith("{")) { + try { + const parsed = JSON.parse(intentTokenRaw); + token = parsed.jwtToken || parsed.jwt_token || intentTokenRaw; + } catch { /* use raw */ } + } + + // Compute the real step index from the registered plan so the backend's + // updateExecutionProgress can advance plan status to 'completed'. + const inputs = sanitizeParams(input.tool_input, config.sanitize); + const stepIdx = pickStepIndex(session.plan, toolName, inputs); + + const dto = { + token, + step_index: stepIdx, + action: toolName, + tool: toolName, + input: redactSecrets(inputs), + output: redactSecrets(sanitizeParams(input.tool_response, config.sanitize)), + status: "success", + executed_at: new Date().toISOString(), + duration_ms: 0 + }; + + // Await the WAL disk write (~1-2ms) so the row is durable before the + // hook returns. Without the await a crash between read and write loses + // the audit row even though the WAL exists for exactly this reason. + // The slow HTTP ship to /iap/audit/batch still happens async via the + // embedded flusher in policy-mcp.mjs. Mirrors armorClaude#46 fix #5. + try { + await iapService.enqueueAudit(dto); + } catch (error) { + debugLog(config, `audit enqueue failed: ${error}`); + } + debugLog(config, `audit log enqueued for ${toolName} step=${stepIdx}`); + } catch (error) { + // Audit is best-effort — don't block + debugLog(config, `audit log failed: ${error}`); + } + + return null; +} + +// --------------------------------------------------------------------------- +// PostToolUseFailure — audit logging for failed tool calls +// --------------------------------------------------------------------------- + +export async function handlePostToolUseFailure(input, config) { + if (!config.auditEnabled || !config.apiKey) { + return null; + } + + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + if (!toolName) return null; + + try { + const runtimeState = await loadRuntimeState(config.runtimeFile); + const session = getSession(runtimeState, sessionId) || {}; + const iapService = createIapService(config); + + const intentTokenRaw = session.intentTokenRaw || ""; + let token = intentTokenRaw; + if (intentTokenRaw.startsWith("{")) { + try { + const parsed = JSON.parse(intentTokenRaw); + token = parsed.jwtToken || parsed.jwt_token || intentTokenRaw; + } catch { /* use raw */ } + } + + const inputs = sanitizeParams(input.tool_input, config.sanitize); + const stepIdx = pickStepIndex(session.plan, toolName, inputs); + const dto = { + token, + step_index: stepIdx, + action: toolName, + tool: toolName, + input: redactSecrets(inputs), + output: null, + status: "failed", + error_message: typeof input.error === "string" ? redactSecrets(input.error) : "Unknown error", + executed_at: new Date().toISOString(), + duration_ms: 0 + }; + + // Same await rationale as the success path above — see armorClaude#46 fix #5. + try { + await iapService.enqueueAudit(dto); + } catch (error) { + debugLog(config, `audit enqueue (failure) failed: ${error}`); + } + debugLog(config, `audit log (failure) enqueued for ${toolName}`); + } catch (error) { + debugLog(config, `audit log (failure) failed: ${error}`); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Stop — end of turn +// --------------------------------------------------------------------------- + +export async function handleStop(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!sessionId) return null; + + const runtimeState = await loadRuntimeState(config.runtimeFile); + const session = getSession(runtimeState, sessionId); + if (!session) return null; + + // Check if token expired mid-turn + if (Number.isFinite(session.expiresAt) && nowEpochSeconds() > session.expiresAt) { + debugLog(config, "intent token expired during turn"); + } + + upsertSession(runtimeState, sessionId, { + lastStopAt: nowEpochSeconds() + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + return null; +} + +// --------------------------------------------------------------------------- +// SessionEnd — cleanup +// --------------------------------------------------------------------------- + +export async function handleSessionEnd(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!sessionId) return null; + + const runtimeState = await loadRuntimeState(config.runtimeFile); + // Remove the session entirely + if (runtimeState.sessions && runtimeState.sessions[sessionId]) { + delete runtimeState.sessions[sessionId]; + } + await saveRuntimeState(config.runtimeFile, runtimeState); + + debugLog(config, `session ended: ${sessionId}`); + return null; +} diff --git a/packages/armorcopilot-gh/scripts/lib/fs-store.mjs b/packages/armorcopilot-gh/scripts/lib/fs-store.mjs new file mode 100644 index 0000000..2d6843e --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/fs-store.mjs @@ -0,0 +1,36 @@ +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export async function readJson(filePath, fallbackValue) { + try { + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") { + return fallbackValue; + } + // Corrupted JSON (e.g. interrupted write from an older non-atomic build) + // falls back to the default rather than breaking the whole session. + if (error instanceof SyntaxError) { + return fallbackValue; + } + throw error; + } +} + +// Atomic write: write to a sibling tmp file then rename into place. Prevents +// partial/torn JSON when two hooks (PreToolUse + PostToolUse) race or when the +// process is killed mid-write. +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + const payload = JSON.stringify(value, null, 2); + try { + await writeFile(tmpPath, payload, "utf8"); + await rename(tmpPath, filePath); + } catch (error) { + await unlink(tmpPath).catch(() => {}); + throw error; + } +} + diff --git a/packages/armorcopilot-gh/scripts/lib/hook-output.mjs b/packages/armorcopilot-gh/scripts/lib/hook-output.mjs new file mode 100644 index 0000000..9cadc4b --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/hook-output.mjs @@ -0,0 +1,37 @@ +export function denyPreTool(reason) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; +} + +export function denyPermissionRequest(reason) { + return { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: reason + } + } + }; +} + +export function blockPrompt(reason) { + return { + decision: "block", + reason + }; +} + +export function addPromptContext(context, hookEventName = "UserPromptSubmit") { + return { + hookSpecificOutput: { + hookEventName, + additionalContext: context + } + }; +} diff --git a/packages/armorcopilot-gh/scripts/lib/iap-service.mjs b/packages/armorcopilot-gh/scripts/lib/iap-service.mjs new file mode 100644 index 0000000..9b6d53c --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/iap-service.mjs @@ -0,0 +1,278 @@ +/** + * IAP Verification Service + * + * Abstraction over ArmorIQ IAP backend operations: + * - verifyStep: POST /iap/verify-step + * - verifyWithCsrg: POST /verify/action (CSRG Merkle proof) + * - createAuditLog: POST /iap/audit + * + * Ported from ArmorClaw's IAPVerificationService (iap-verfication.service.ts). + */ + +import { + buildAuthHeaders, + isPlainObject, + parseStepIndex, + postJson, + readString +} from "./common.mjs"; +import { createAuditWal } from "./audit-wal.mjs"; + +// Shared WAL instance per dataDir. The MCP server, hook handlers, and any +// fire-and-forget background flusher all enqueue to the same on-disk JSONL +// so the audit pipeline is crash-safe and concurrent-safe. +const walCache = new Map(); +function getAuditWal(config) { + const key = config.dataDir; + let wal = walCache.get(key); + if (!wal) { + wal = createAuditWal({ dataDir: config.dataDir }); + walCache.set(key, wal); + } + return wal; +} + +/** + * Create an IAP service instance from config. + */ +export function createIapService(config) { + const backendEndpoint = config.backendEndpoint || config.verifyStepEndpoint?.replace(/\/iap\/verify-step$/, "") || ""; + const csrgEndpoint = config.csrgEndpoint || config.iapEndpoint || ""; + const timeoutMs = config.timeoutMs || 8000; + const headers = buildAuthHeaders(config); + + return { + /** + * Verify a tool execution step with the IAP backend. + * Equivalent to ArmorClaw IAPVerificationService.verifyStep() + */ + async verifyStep(intentTokenRaw, csrgProofs, toolName) { + const endpoint = config.verifyStepEndpoint; + if (!endpoint || !config.csrgVerifyEnabled) { + return { skipped: true }; + } + + const { token, tokenObj } = getTokenForVerification(intentTokenRaw); + if (!token) { + return { skipped: false, allowed: false, reason: "ArmorIQ intent token missing" }; + } + + const payload = { token }; + if (csrgProofs?.path) { + payload.path = csrgProofs.path; + const stepMatch = csrgProofs.path.match(/\/steps\/\[(\d+)\]/); + if (stepMatch) { + payload.step_index = Number.parseInt(stepMatch[1] || "0", 10); + } + } + if (toolName) { + payload.tool_name = toolName; + } + if (Array.isArray(csrgProofs?.proof)) { + payload.proof = csrgProofs.proof; + } + if (csrgProofs?.valueDigest) { + payload.context = { + csrg_value_digest: csrgProofs.valueDigest, + proof_source: "client" + }; + } + + const response = await postJson(endpoint, payload, headers, timeoutMs); + if (!response.ok && !isPlainObject(response.data)) { + throw new Error( + response.text || `IAP verify-step failed with status ${response.status}` + ); + } + + const data = isPlainObject(response.data) ? response.data : {}; + const tokenRaw = + typeof data.intentTokenRaw === "string" + ? data.intentTokenRaw + : typeof data.tokenRaw === "string" + ? data.tokenRaw + : isPlainObject(data.token) + ? JSON.stringify(data.token) + : undefined; + const parsedFromResponse = tokenRaw ? extractPlanFromResponse(tokenRaw) : null; + const fallbackPlan = isPlainObject(tokenObj?.plan) + ? tokenObj.plan + : isPlainObject(tokenObj?.rawToken?.plan) + ? tokenObj.rawToken.plan + : undefined; + const stepIndex = + parseStepIndex(data?.step?.step_index) ?? + parseStepIndex(data?.execution_state?.current_step) ?? + parseStepIndexFromPath(csrgProofs?.path) ?? + undefined; + + return { + skipped: false, + allowed: data.allowed !== false, + reason: typeof data.reason === "string" ? data.reason : "", + tokenRaw, + plan: isPlainObject(data.plan) ? data.plan : parsedFromResponse?.plan || fallbackPlan, + expiresAt: Number.isFinite(data.expiresAt) ? data.expiresAt : parsedFromResponse?.expiresAt, + stepIndex + }; + }, + + /** + * Verify action directly with CSRG service using Merkle proof. + * Equivalent to ArmorClaw IAPVerificationService.verifyWithCsrg() + */ + async verifyWithCsrg(path, value, proof, token, context) { + if (!config.csrgVerifyEnabled) { + throw new Error("CSRG verification is disabled"); + } + + const payload = { path, value, proof, token, context }; + const response = await postJson( + `${csrgEndpoint}/verify/action`, + payload, + { "Content-Type": "application/json" }, + Math.min(timeoutMs, 15000) + ); + + if (response.ok && response.data) { + return response.data; + } + + if (response.data) { + return { + allowed: false, + reason: + response.data.reason || + `CSRG verification failed: ${response.text || "unknown error"}` + }; + } + + return { + allowed: false, + reason: response.text + ? `CSRG verification failed: ${response.text}` + : `CSRG verification failed with status ${response.status}` + }; + }, + + /** + * Create an audit log entry in the IAP service. + * Equivalent to ArmorClaw IAPVerificationService.createAuditLog() + */ + async createAuditLog(dto) { + const response = await postJson( + `${backendEndpoint}/iap/audit`, + dto, + headers, + timeoutMs + ); + + if (!response.ok || !response.data) { + const message = response.text + ? `IAP audit creation failed: ${response.text}` + : `IAP audit creation failed with status ${response.status}`; + throw new Error(message); + } + + return response.data; + }, + + /** + * Enqueue an audit DTO to the local WAL. Returns immediately after the + * disk append (~1-2ms). A background flusher in policy-mcp.mjs drains + * the WAL in batches and POSTs to /iap/audit. Fire-and-forget callers + * use this to keep hook latency low. + */ + async enqueueAudit(dto) { + const wal = getAuditWal(config); + await wal.appendLine(dto); + }, + + /** + * Ship a batch of audit rows via POST /iap/audit/batch (one HTTP call + * for N rows, ~N× faster than per-row POSTs). Matches armorClaude's + * createAuditLogBatch — same backend endpoint, same payload shape. + * + * Failures throw — caller should NOT advance the WAL offset on failure + * so the next tick retries the same rows. Backend idempotency + * (planId, to_hash unique) keeps retries safe. + */ + async shipAuditBatch(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return { written: 0, failures: [] }; + } + const response = await postJson( + `${backendEndpoint}/iap/audit/batch`, + { rows }, + headers, + timeoutMs + ); + if (!response.ok || !response.data) { + const message = response.text + ? `IAP audit batch failed: ${response.text}` + : `IAP audit batch failed with status ${response.status}`; + throw new Error(message); + } + return response.data; + }, + + csrgProofsRequired() { + return Boolean(config.requireCsrgProofs); + }, + + csrgVerifyIsEnabled() { + return Boolean(config.csrgVerifyEnabled); + } + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getTokenForVerification(intentTokenRaw) { + if (typeof intentTokenRaw !== "string") { + return { token: "", tokenObj: null }; + } + try { + const parsed = JSON.parse(intentTokenRaw); + if (isPlainObject(parsed)) { + const jwtToken = readString(parsed.jwtToken) || readString(parsed.jwt_token); + if (jwtToken) { + return { token: jwtToken, tokenObj: parsed }; + } + return { token: intentTokenRaw, tokenObj: parsed }; + } + return { token: intentTokenRaw, tokenObj: null }; + } catch { + return { token: intentTokenRaw, tokenObj: null }; + } +} + +function extractPlanFromResponse(tokenRaw) { + try { + const parsed = JSON.parse(tokenRaw); + if (!isPlainObject(parsed)) return null; + const plan = + isPlainObject(parsed.plan) + ? parsed.plan + : isPlainObject(parsed.rawToken?.plan) + ? parsed.rawToken.plan + : null; + const expiresAt = + Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : + Number.isFinite(parsed.token?.expires_at) ? parsed.token.expires_at : + undefined; + return plan ? { plan, expiresAt } : null; + } catch { + return null; + } +} + +function parseStepIndexFromPath(path) { + if (!path) return null; + const match = path.match(/\/steps\/\[(\d+)\]/); + if (!match) return null; + const index = Number.parseInt(match[1] || "", 10); + return Number.isFinite(index) ? index : null; +} diff --git a/packages/armorcopilot-gh/scripts/lib/intent-schema.mjs b/packages/armorcopilot-gh/scripts/lib/intent-schema.mjs new file mode 100644 index 0000000..d1ef8d2 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/intent-schema.mjs @@ -0,0 +1,70 @@ +/** + * Shared intent plan schema — single source of truth used by: + * - register_intent_plan MCP tool (validates Copilot's input) + * - register_intent_plan inputSchema (model sees this when invoking the tool) + * + * Copilot has no ExitPlanMode-equivalent event, so unlike ArmorClaude there is + * no plan-file extraction path on Copilot. + */ + +import { z } from "zod"; + +export const PLAN_STEP_SCHEMA = z.object({ + action: z.string().min(1).describe("Tool name (e.g. Read, Edit, Bash, mcp__server__tool)"), + description: z.string().optional().describe("Why this step is needed"), + metadata: z + .object({ + inputs: z + .record(z.string(), z.unknown()) + .optional() + .describe("Expected tool parameters for enforcement") + }) + .optional() +}); + +export const INTENT_PLAN_ZOD = z.object({ + goal: z.string().min(1).describe("One-line summary of what the plan accomplishes"), + steps: z + .array(PLAN_STEP_SCHEMA) + .min(1) + .describe("Ordered list of tool calls the agent intends to make") +}); + +/** + * Human-readable format string injected into Copilot's context so it knows + * exactly what shape to produce. + */ +export const INTENT_PLAN_FORMAT = `{ + "goal": "", + "steps": [ + { + "action": "", + "description": "", + "metadata": { "inputs": { /* expected tool parameters, optional */ } } + } + ] +}`; + +/** + * Normalize a validated plan into the internal format used by requestIntent() + * and the plan enforcement pipeline. + */ +export function normalizeIntentPlan(parsed) { + return { + steps: parsed.steps.map((s) => ({ + // Both `action` and `tool` are populated to match the backend's + // CSRG/policy enforcer expectations: the SDK's invoke() does the + // same (sets tool: action). The backend hashes `step.tool` for + // policy paths like /steps/[i]/tool. + action: s.action, + tool: s.action, + mcp: "copilot", + description: s.description || "", + metadata: s.metadata || {} + })), + metadata: { + goal: parsed.goal, + source: "copilot-registered" + } + }; +} diff --git a/packages/armorcopilot-gh/scripts/lib/intent.mjs b/packages/armorcopilot-gh/scripts/lib/intent.mjs new file mode 100644 index 0000000..f613273 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/intent.mjs @@ -0,0 +1,642 @@ +import armoriqSdk from "@armoriq/sdk"; +import { + buildAuthHeaders, + isPlainObject, + isSubsetValue, + normalizeToolName, + parseStepIndex, + postJson, + readString, + sha256Hex +} from "./common.mjs"; + +const { ArmorIQClient } = armoriqSdk; +const sdkClientCache = new Map(); + +function buildSdkClientKey(config) { + return [ + config.apiKey, + config.userId, + config.agentId, + config.contextId, + config.iapEndpoint, + config.proxyEndpoint, + config.backendEndpoint, + config.useProduction ? "prod" : "dev" + ].join("|"); +} + +function getSdkClient(config) { + const key = buildSdkClientKey(config); + const cached = sdkClientCache.get(key); + if (cached) { + return cached; + } + const client = new ArmorIQClient({ + apiKey: config.apiKey, + userId: config.userId, + agentId: config.agentId, + contextId: config.contextId, + useProduction: config.useProduction, + iapEndpoint: config.iapEndpoint, + proxyEndpoint: config.proxyEndpoint, + backendEndpoint: config.backendEndpoint, + timeout: config.timeoutMs, + maxRetries: config.maxRetries, + verifySsl: config.verifySsl + }); + sdkClientCache.set(key, client); + return client; +} + +function buildFallbackPlan(payload) { + const goal = typeof payload.prompt === "string" ? payload.prompt : "ArmorCopilot intent"; + const plan = { steps: [], metadata: { goal, source: "copilot" } }; + if (typeof payload.toolName === "string" && payload.toolName.trim()) { + plan.steps.push({ + action: payload.toolName.trim(), + mcp: payload.mcpName || "copilot", + metadata: isPlainObject(payload.toolInput) ? { inputs: payload.toolInput } : {} + }); + } + return plan; +} + +function resolvePlan(payload) { + if (isPlainObject(payload.plan)) { + return payload.plan; + } + return buildFallbackPlan(payload); +} + +export function extractPlanFromIntentToken(raw) { + if (typeof raw !== "string" || !raw.trim()) { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isPlainObject(parsed)) { + return null; + } + const rawToken = isPlainObject(parsed.rawToken) ? parsed.rawToken : undefined; + const planCandidate = + (rawToken && isPlainObject(rawToken.plan) ? rawToken.plan : undefined) || + (isPlainObject(parsed.plan) ? parsed.plan : undefined) || + (isPlainObject(parsed.token) && isPlainObject(parsed.token.plan) ? parsed.token.plan : undefined); + if (!planCandidate) { + return null; + } + const expiresAt = + Number.isFinite(parsed.expiresAt) + ? parsed.expiresAt + : isPlainObject(parsed.token) && Number.isFinite(parsed.token.expires_at) + ? parsed.token.expires_at + : undefined; + return { plan: planCandidate, expiresAt }; +} + +export function extractAllowedActions(plan) { + const allowed = new Set(); + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + for (const step of steps) { + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (action.trim()) { + allowed.add(normalizeToolName(action)); + } + } + return allowed; +} + +function findPlanStep(plan, toolName) { + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + const normalizedTool = normalizeToolName(toolName); + for (const step of steps) { + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (normalizeToolName(action) === normalizedTool) { + return step; + } + } + return null; +} + +function getStepInputCandidates(step) { + const candidates = []; + if (isPlainObject(step.metadata) && isPlainObject(step.metadata.inputs)) { + candidates.push(step.metadata.inputs); + } + if (isPlainObject(step.params)) { + candidates.push(step.params); + } + if (isPlainObject(step.arguments)) { + candidates.push(step.arguments); + } + return candidates; +} + +export function findPlanStepIndices(plan, toolName, toolParams) { + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + const normalizedTool = normalizeToolName(toolName); + const matches = []; + const paramMatches = []; + for (let idx = 0; idx < steps.length; idx += 1) { + const step = steps[idx]; + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (normalizeToolName(action) !== normalizedTool) { + continue; + } + matches.push(idx); + if (toolParams) { + const inputCandidates = getStepInputCandidates(step); + if (inputCandidates.some((inputs) => isSubsetValue(inputs, toolParams))) { + paramMatches.push(idx); + } + } + } + return { matches, paramMatches }; +} + +export function checkToolAgainstPlan({ plan, toolName, toolInput }) { + const normalizedTool = normalizeToolName(toolName); + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + if (!steps.length) { + return { allowed: false, reason: "ArmorCopilot intent plan is empty" }; + } + const matches = []; + for (const step of steps) { + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (normalizeToolName(action) === normalizedTool) { + matches.push(step); + } + } + if (!matches.length) { + return { allowed: false, reason: `ArmorCopilot intent drift: tool not in plan (${toolName})` }; + } + if (!isPlainObject(toolInput)) { + return { allowed: true }; + } + let sawConstrainedMatch = false; + for (const step of matches) { + const inputCandidates = getStepInputCandidates(step); + if (inputCandidates.length === 0) { + return { allowed: true }; + } + sawConstrainedMatch = true; + for (const candidate of inputCandidates) { + // Strict subset: every key in declared candidate matches actual input. + if (isSubsetValue(candidate, toolInput)) { + return { allowed: true }; + } + // Lenient fallback: agents (especially gpt-5.4) often declare inputs + // with field names that don't match the real tool (e.g. `cmd` instead + // of Copilot's `command`). If NONE of the declared keys exist on the + // real input, treat it as an over-eager declaration and allow. + // The tool name itself was already matched; the parameter declaration + // was simply wrong-fielded, not a security violation. + if (isPlainObject(candidate) && isPlainObject(toolInput)) { + const declaredKeys = Object.keys(candidate); + if (declaredKeys.length > 0) { + const overlappingKeys = declaredKeys.filter((k) => k in toolInput); + if (overlappingKeys.length === 0) { + return { allowed: true }; + } + } + } + } + } + if (sawConstrainedMatch) { + return { + allowed: false, + reason: `ArmorCopilot intent mismatch: parameters not allowed for ${toolName}` + }; + } + return { allowed: true }; +} + +export function checkIntentTokenPlan({ intentTokenRaw, toolName, toolParams }) { + const parsed = extractPlanFromIntentToken(intentTokenRaw); + if (!parsed) { + return { matched: false }; + } + if (parsed.expiresAt && Date.now() / 1000 > parsed.expiresAt) { + return { + matched: true, + blockReason: + "ArmorIQ intent token expired — call register_intent_plan to refresh, then retry", + plan: parsed.plan + }; + } + const allowedActions = extractAllowedActions(parsed.plan); + if (!allowedActions.has(normalizeToolName(toolName))) { + return { + matched: true, + blockReason: `ArmorIQ intent drift: tool not in plan (${toolName})`, + plan: parsed.plan + }; + } + + // Parameter-level enforcement: check tool params against plan step constraints + if (isPlainObject(toolParams)) { + const paramCheck = checkToolAgainstPlan({ + plan: parsed.plan, + toolName, + toolInput: toolParams + }); + if (!paramCheck.allowed) { + return { + matched: true, + blockReason: paramCheck.reason, + plan: parsed.plan + }; + } + } + + return { + matched: true, + params: isPlainObject(toolParams) ? toolParams : undefined, + plan: parsed.plan + }; +} + +export function parseStepIndexFromPath(path) { + if (!path) { + return null; + } + const match = path.match(/\/steps\/\[(\d+)\]/); + if (!match) { + return null; + } + const index = Number.parseInt(match[1] || "", 10); + return Number.isFinite(index) ? index : null; +} + +function readStepProofsFromToken(tokenObj) { + if (Array.isArray(tokenObj.stepProofs)) { + return tokenObj.stepProofs; + } + if (Array.isArray(tokenObj.step_proofs)) { + return tokenObj.step_proofs; + } + if (isPlainObject(tokenObj.rawToken)) { + if (Array.isArray(tokenObj.rawToken.stepProofs)) { + return tokenObj.rawToken.stepProofs; + } + if (Array.isArray(tokenObj.rawToken.step_proofs)) { + return tokenObj.rawToken.step_proofs; + } + } + return null; +} + +function resolveStepProofEntry(stepProofs, stepIndex) { + const entry = stepProofs[stepIndex]; + if (!entry) { + return null; + } + if (Array.isArray(entry)) { + return { proof: entry, stepIndex }; + } + if (!isPlainObject(entry)) { + return null; + } + const proof = Array.isArray(entry.proof) ? entry.proof : undefined; + const path = + readString(entry.path) || + readString(entry.step_path) || + readString(entry.csrg_path) || + undefined; + const indexFromField = parseStepIndex(entry.step_index) ?? parseStepIndex(entry.stepIndex); + const indexFromPath = parseStepIndexFromPath(path); + const resolvedStepIndex = indexFromField ?? indexFromPath ?? stepIndex; + const valueDigest = + readString(entry.value_digest) || + readString(entry.valueDigest) || + readString(entry.csrg_value_digest) || + undefined; + return { proof, path, valueDigest, stepIndex: resolvedStepIndex }; +} + +function scoreProofPath(path) { + if (!path) { + return 0; + } + if (/\/(action|tool)$/i.test(path)) { + return 3; + } + if (/\/(arguments|params|metadata)$/i.test(path)) { + return 1; + } + return 2; +} + +function chooseProofEntry(entries, usedStepIndices) { + if (!entries.length) { + return null; + } + const stepGroups = new Map(); + for (const entry of entries) { + const list = stepGroups.get(entry.stepIndex) || []; + list.push(entry); + stepGroups.set(entry.stepIndex, list); + } + const orderedStepIndices = Array.from(stepGroups.keys()).sort((a, b) => { + const aUsed = usedStepIndices?.has(a) ? 1 : 0; + const bUsed = usedStepIndices?.has(b) ? 1 : 0; + if (aUsed !== bUsed) { + return aUsed - bUsed; + } + return a - b; + }); + const selectedStepIndex = orderedStepIndices[0]; + if (selectedStepIndex === undefined) { + return null; + } + const candidates = stepGroups.get(selectedStepIndex) || []; + candidates.sort((a, b) => { + const pathScore = scoreProofPath(b.path) - scoreProofPath(a.path); + if (pathScore !== 0) { + return pathScore; + } + const digestScore = Number(Boolean(b.valueDigest)) - Number(Boolean(a.valueDigest)); + if (digestScore !== 0) { + return digestScore; + } + return 0; + }); + return candidates[0] || null; +} + +export function resolveCsrgProofsFromToken({ + intentTokenRaw, + plan, + toolName, + toolParams, + usedStepIndices +}) { + let parsed; + try { + parsed = JSON.parse(intentTokenRaw); + } catch { + return null; + } + if (!isPlainObject(parsed)) { + return null; + } + const stepProofs = readStepProofsFromToken(parsed); + if (!stepProofs || stepProofs.length === 0) { + return null; + } + const normalizedParams = isPlainObject(toolParams) ? toolParams : undefined; + const { matches, paramMatches } = findPlanStepIndices(plan, toolName, normalizedParams); + if (matches.length === 0) { + return null; + } + const resolvedEntries = []; + for (let idx = 0; idx < stepProofs.length; idx += 1) { + const entry = resolveStepProofEntry(stepProofs, idx); + if (!entry?.proof || !Array.isArray(entry.proof)) { + continue; + } + resolvedEntries.push(entry); + } + const entriesMatchingTool = resolvedEntries.filter((entry) => matches.includes(entry.stepIndex)); + if (!entriesMatchingTool.length) { + return null; + } + const entriesMatchingParams = + paramMatches.length > 0 + ? entriesMatchingTool.filter((entry) => paramMatches.includes(entry.stepIndex)) + : []; + const selected = chooseProofEntry( + entriesMatchingParams.length > 0 ? entriesMatchingParams : entriesMatchingTool, + usedStepIndices + ); + if (!selected || !Array.isArray(selected.proof)) { + return null; + } + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + const stepIndex = selected.stepIndex; + const stepObj = steps[stepIndex]; + const action = + isPlainObject(stepObj) && typeof stepObj.action === "string" + ? stepObj.action + : isPlainObject(stepObj) && typeof stepObj.tool === "string" + ? stepObj.tool + : toolName; + return { + path: selected.path || `/steps/[${stepIndex}]/action`, + proof: selected.proof, + valueDigest: selected.valueDigest || sha256Hex(JSON.stringify(action)), + stepIndex + }; +} + +function parseProofValue(raw) { + if (Array.isArray(raw)) { + return raw; + } + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed; + } + return { error: "ArmorIQ CSRG proof header must be a JSON array" }; + } catch { + return { error: "ArmorIQ CSRG proof header invalid JSON" }; + } + } + return undefined; +} + +function readFromHeaderMap(headers, keys) { + if (!isPlainObject(headers)) { + return undefined; + } + for (const key of keys) { + const value = readString(headers[key]); + if (value) { + return value; + } + } + return undefined; +} + +export function parseCsrgProofHeaders(input) { + const headers = isPlainObject(input.headers) ? input.headers : undefined; + const path = + readString(input.csrgPath) || + readString(input.csrg_path) || + readString(input["x-csrg-path"]) || + readFromHeaderMap(headers, ["x-csrg-path", "X-CSRG-Path"]) || + undefined; + const valueDigest = + readString(input.csrgValueDigest) || + readString(input.csrg_value_digest) || + readString(input["x-csrg-value-digest"]) || + readFromHeaderMap(headers, ["x-csrg-value-digest", "X-CSRG-Value-Digest"]) || + undefined; + const proofRaw = + input.csrgProofRaw ?? + input.csrg_proof ?? + input["x-csrg-proof"] ?? + (headers ? headers["x-csrg-proof"] ?? headers["X-CSRG-Proof"] : undefined); + + if (!path && !valueDigest && proofRaw === undefined) { + return {}; + } + const parsedProof = parseProofValue(proofRaw); + if (isPlainObject(parsedProof) && parsedProof.error) { + return { error: parsedProof.error }; + } + return { + proofs: { + path, + valueDigest, + proof: parsedProof + } + }; +} + +export function validateCsrgProofHeaders(proofs, required) { + if (!required) { + return null; + } + if (!proofs) { + return "ArmorIQ CSRG proof headers missing"; + } + if (!proofs.path) { + return "ArmorIQ CSRG path header missing"; + } + if (!proofs.valueDigest) { + return "ArmorIQ CSRG value digest header missing"; + } + if (!proofs.proof || !Array.isArray(proofs.proof)) { + return "ArmorIQ CSRG proof header missing"; + } + return null; +} + +export async function requestIntent(config, payload) { + if (config.intentEndpoint) { + const response = await postJson( + config.intentEndpoint, + payload, + buildAuthHeaders(config), + config.timeoutMs + ); + if (!response.ok) { + throw new Error(response.text || `Intent request failed: ${response.status}`); + } + const data = isPlainObject(response.data) ? response.data : {}; + const tokenRaw = + typeof data.intentTokenRaw === "string" + ? data.intentTokenRaw + : typeof data.tokenRaw === "string" + ? data.tokenRaw + : isPlainObject(data.token) + ? JSON.stringify(data.token) + : undefined; + const parsedFromToken = tokenRaw ? extractPlanFromIntentToken(tokenRaw) : null; + const plan = isPlainObject(data.plan) ? data.plan : parsedFromToken?.plan; + const expiresAt = + Number.isFinite(data.expiresAt) + ? data.expiresAt + : Number.isFinite(data.expires_at) + ? data.expires_at + : parsedFromToken?.expiresAt; + return { + skipped: false, + source: "custom-endpoint", + tokenRaw, + plan, + expiresAt + }; + } + + if (!config.useSdkIntent || !config.apiKey) { + return { skipped: true }; + } + const client = getSdkClient(config); + const plan = resolvePlan({ ...payload, mcpName: config.mcpName }); + const metadata = { + source: "copilot", + session_id: payload.session_id, + policy_hash: payload.policy_hash, + ...payload.metadata + }; + const capture = client.capturePlan(config.llmId, payload.prompt || "", plan, metadata); + const token = await client.getIntentToken(capture, payload.policy, payload.validitySeconds); + const tokenRaw = JSON.stringify(token); + const parsedFromToken = extractPlanFromIntentToken(tokenRaw); + return { + skipped: false, + source: "armoriq-sdk", + tokenRaw, + plan: parsedFromToken?.plan || plan, + expiresAt: Number.isFinite(token.expiresAt) ? token.expiresAt : parsedFromToken?.expiresAt + }; +} + +export function getSessionTokenUsedStepIndices(session, intentTokenRaw) { + if (!session || typeof intentTokenRaw !== "string" || !intentTokenRaw.trim()) { + return undefined; + } + const tokenHash = sha256Hex(intentTokenRaw); + const tracker = isPlainObject(session.intentExecution) ? session.intentExecution : {}; + if (tracker.tokenHash !== tokenHash) { + tracker.tokenHash = tokenHash; + tracker.usedStepIndices = []; + session.intentExecution = tracker; + } + const used = Array.isArray(tracker.usedStepIndices) ? tracker.usedStepIndices : []; + tracker.usedStepIndices = used.filter((value) => Number.isFinite(value)); + session.intentExecution = tracker; + return new Set(tracker.usedStepIndices); +} + +export function recordSessionTokenUsedStepIndices(session, intentTokenRaw, usedStepIndices) { + if (!session || typeof intentTokenRaw !== "string" || !intentTokenRaw.trim()) { + return; + } + const tokenHash = sha256Hex(intentTokenRaw); + session.intentExecution = { + tokenHash, + usedStepIndices: Array.from(usedStepIndices || []).filter((value) => Number.isFinite(value)) + }; +} diff --git a/packages/armorcopilot-gh/scripts/lib/planner.mjs b/packages/armorcopilot-gh/scripts/lib/planner.mjs new file mode 100644 index 0000000..bd42647 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/planner.mjs @@ -0,0 +1,171 @@ +/** + * Plan parsing for ArmorCopilot. + * + * Two capture paths, one schema: + * 1. Plan mode: parse the plan file for a fenced ```json block (preferred) + * or heuristic markdown extraction (fallback) + * 2. No plan mode: Copilot calls register_intent_plan MCP tool directly + * (handled in policy-mcp.mjs, not here) + * + * This module handles only PARSING — plan generation is done by Copilot's own + * LLM via the directive injected in UserPromptSubmit. + */ + +import { readFile } from "node:fs/promises"; +import { normalizeToolName } from "./common.mjs"; + +// --------------------------------------------------------------------------- +// JSON block extraction (preferred — matches the directive's format) +// --------------------------------------------------------------------------- + +/** + * Extract a fenced ```json block from markdown content. + * The UserPromptSubmit directive tells Copilot to include the plan as a + * fenced JSON block in plan mode. + * + * Strategy: scan all ```json blocks and return the LAST one that parses + * cleanly AND looks like an intent plan (has a `steps` array). This avoids + * picking up an example/illustration block earlier in the file. + */ +export function extractPlanJsonBlock(markdown) { + if (!markdown) return null; + const matches = Array.from(markdown.matchAll(/```json\s*([\s\S]*?)```/g)); + if (matches.length === 0) return null; + for (let i = matches.length - 1; i >= 0; i -= 1) { + const raw = matches[i][1]?.trim(); + if (!raw) continue; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + if (parsed && typeof parsed === "object" && Array.isArray(parsed.steps)) { + return parsed; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Plan file parsing (heuristic fallback) +// --------------------------------------------------------------------------- + +/** + * Parse a plan markdown file into a structured plan. + * This is retained for compatibility with imported tests and future Copilot + * plan-file events; current Copilot hooks do not expose ExitPlanMode. + */ +export async function parsePlanFile(planFilePath) { + if (!planFilePath) return null; + let content; + try { + content = await readFile(planFilePath, "utf8"); + } catch { + return null; + } + if (!content.trim()) return null; + return parsePlanMarkdown(content); +} + +/** + * Heuristic: extract tool intentions from markdown content. + * Looks for backtick-wrapped tool names and numbered/bulleted steps. + */ +export function parsePlanMarkdown(markdown) { + const steps = []; + const seenTools = new Set(); + + // Backtick-wrapped identifiers: `Read`, `mcp__server__tool` + const backtickPattern = /`([A-Za-z][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)`/g; + for (const match of markdown.matchAll(backtickPattern)) { + const name = match[1]?.trim(); + if (name && name.length > 1 && name.length < 80) { + seenTools.add(normalizeToolName(name)); + } + } + + // Numbered / bulleted steps + const stepPattern = /^[\s]*(?:\d+[.)]\s+|[-*]\s+)(.+)/gm; + for (const match of markdown.matchAll(stepPattern)) { + const text = match[1]?.trim(); + if (!text || text.length < 3) continue; + const toolRef = extractToolFromStepText(text); + if (toolRef) { + seenTools.add(normalizeToolName(toolRef)); + steps.push({ + action: toolRef, + mcp: "copilot", + description: text, + metadata: {} + }); + } + } + + // If no steps from list parsing, create steps from discovered tool names + if (steps.length === 0) { + for (const toolName of seenTools) { + steps.push({ + action: toolName, + mcp: "copilot", + description: `Use ${toolName}`, + metadata: {} + }); + } + } + + const headingMatch = markdown.match(/^#+\s+(.+)/m); + const goal = headingMatch ? headingMatch[1].trim() : markdown.split("\n")[0]?.trim() || "Plan"; + + return { + steps, + metadata: { goal, source: "plan-file-heuristic" } + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const KNOWN_TOOLS = new Set([ + "read", "write", "edit", "bash", "glob", "grep", "agent", + "webfetch", "websearch", "notebookedit", "askuserquestion", + "taskcreate", "taskupdate", "skill" +]); + +function extractToolFromStepText(text) { + const backtickMatch = text.match(/`([A-Za-z][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)`/); + if (backtickMatch) return backtickMatch[1]; + + const mcpMatch = text.match(/\b(mcp__[a-z0-9_]+__[a-z0-9_]+)\b/i); + if (mcpMatch) return mcpMatch[1]; + + const words = text.split(/\s+/); + const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, ""); + if (KNOWN_TOOLS.has(firstWord)) { + return firstWord.charAt(0).toUpperCase() + firstWord.slice(1); + } + + return null; +} + +/** + * Resolve the plan file path for the current session. + * Resolve a best-effort Copilot-scoped plan path. + */ +export function resolvePlanFilePath(input) { + const transcriptPath = + typeof input?.transcript_path === "string" ? input.transcript_path : ""; + + const sessionMatch = transcriptPath.match( + /sessions\/([^/]+?)(?:\.jsonl)?$/ + ); + const sessionName = sessionMatch ? sessionMatch[1] : null; + + if (sessionName) { + const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; + return `${homeDir}/.copilot/plans/${sessionName}.md`; + } + + return null; +} diff --git a/packages/armorcopilot-gh/scripts/lib/policy.mjs b/packages/armorcopilot-gh/scripts/lib/policy.mjs new file mode 100644 index 0000000..b26bf7c --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/policy.mjs @@ -0,0 +1,615 @@ +import { createHash } from "node:crypto"; +import { + isMatcherSpec, + isPlainObject, + isSubsetValue, + matchParams, + matchesAnyStringField, + matchesScalar, + normalizeToolName +} from "./common.mjs"; +import { readJson, writeJson } from "./fs-store.mjs"; + +const POLICY_ACTIONS = new Set(["allow", "deny", "require_approval"]); +const POLICY_DATA_CLASSES = new Set(["PCI", "PAYMENT", "PHI", "PII"]); + +function normalizeRule(rule) { + if (!isPlainObject(rule)) { + return null; + } + const id = typeof rule.id === "string" ? rule.id.trim() : ""; + const action = typeof rule.action === "string" ? rule.action.trim() : ""; + const tool = typeof rule.tool === "string" ? rule.tool.trim() : ""; + if (!id || !tool || !POLICY_ACTIONS.has(action)) { + return null; + } + const normalized = { + id, + action, + tool + }; + if (typeof rule.dataClass === "string" && POLICY_DATA_CLASSES.has(rule.dataClass.trim())) { + normalized.dataClass = rule.dataClass.trim(); + } + if (isPlainObject(rule.params)) { + normalized.params = rule.params; + } + // anyParam: matcher applied across any string field in the tool input. + // Useful for free-text intents like "deny ~/.ssh" where we don't know + // which key the tool will store the path under. + if (isMatcherSpec(rule.anyParam) || typeof rule.anyParam === "string") { + normalized.anyParam = + typeof rule.anyParam === "string" + ? { $contains: rule.anyParam } + : rule.anyParam; + } + return normalized; +} + +function normalizePolicy(policyLike) { + const input = isPlainObject(policyLike) ? policyLike : {}; + const rulesInput = Array.isArray(input.rules) ? input.rules : []; + const rules = rulesInput.map((rule) => normalizeRule(rule)).filter(Boolean); + return { rules }; +} + +export async function loadPolicyState(policyFilePath) { + const initial = { + version: 0, + updatedAt: new Date().toISOString(), + policy: { rules: [] }, + history: [] + }; + const raw = await readJson(policyFilePath, initial); + const state = isPlainObject(raw) ? raw : initial; + return { + version: Number.isFinite(state.version) ? state.version : 0, + updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : new Date().toISOString(), + updatedBy: typeof state.updatedBy === "string" ? state.updatedBy : undefined, + policy: normalizePolicy(state.policy || state), + history: Array.isArray(state.history) ? state.history : [] + }; +} + +export async function savePolicyState(policyFilePath, state) { + await writeJson(policyFilePath, state); +} + +export function computePolicyHash(policy) { + return createHash("sha256").update(JSON.stringify(normalizePolicy(policy))).digest("hex"); +} + +function toolMatches(ruleTool, toolName) { + if (ruleTool === "*") { + return true; + } + return normalizeToolName(ruleTool) === normalizeToolName(toolName); +} + +function extractStrings(value, depth, texts, keys) { + if (depth > 4) { + return; + } + if (typeof value === "string") { + texts.push(value); + return; + } + if (Array.isArray(value)) { + value.forEach((entry) => extractStrings(entry, depth + 1, texts, keys)); + return; + } + if (isPlainObject(value)) { + for (const [key, entry] of Object.entries(value)) { + keys.push(key); + extractStrings(entry, depth + 1, texts, keys); + } + } +} + +function luhnCheck(value) { + let sum = 0; + let doubleDigit = false; + for (let i = value.length - 1; i >= 0; i -= 1) { + let digit = Number.parseInt(value[i] || "", 10); + if (!Number.isFinite(digit)) { + return false; + } + if (doubleDigit) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + doubleDigit = !doubleDigit; + } + return sum % 10 === 0; +} + +function hasCardNumber(texts) { + const regex = /\b(?:\d[ -]*?){13,19}\b/g; + for (const text of texts) { + const matches = text.match(regex); + if (!matches) { + continue; + } + for (const match of matches) { + const digits = match.replace(/[^\d]/g, ""); + if (digits.length >= 13 && digits.length <= 19 && luhnCheck(digits)) { + return true; + } + } + } + return false; +} + +function hasPaymentKeywords(texts, keys) { + const keywords = ["card", "credit", "payment", "cvv", "iban", "swift", "bank", "routing"]; + const haystack = [...texts, ...keys].join(" ").toLowerCase(); + return keywords.some((keyword) => haystack.includes(keyword)); +} + +function isPaymentTool(toolName) { + return /pay|payment|transfer|charge|crypto|bank|card|stripe|billing/i.test(toolName); +} + +export function detectDataClasses(toolName, toolParams) { + const texts = []; + const keys = []; + extractStrings(toolParams || {}, 0, texts, keys); + const classes = new Set(); + if (hasCardNumber(texts) || hasPaymentKeywords(texts, keys)) { + classes.add("PCI"); + } + if (isPaymentTool(toolName) || hasPaymentKeywords(texts, keys)) { + classes.add("PAYMENT"); + } + return classes; +} + +export function evaluatePolicy({ policy, toolName, toolParams }) { + const rules = normalizePolicy(policy).rules; + const dataClasses = detectDataClasses(toolName, toolParams); + const warnings = []; + + for (const rule of rules) { + if (!toolMatches(rule.tool, toolName)) { + continue; + } + if (rule.dataClass && !dataClasses.has(rule.dataClass)) { + continue; + } + let paramsMatched = true; + if (rule.params) { + const result = matchParams(rule.params, toolParams || {}); + paramsMatched = result.matched; + // Surface "rule probably won't fire": rule references keys absent from + // this tool's input, which usually means the user's intent isn't + // expressible as-is. + if (!result.matched && result.missingKeys.length > 0) { + warnings.push({ + ruleId: rule.id, + tool: rule.tool, + missingKeys: result.missingKeys, + message: `Rule ${rule.id} references keys absent from ${toolName} input: ${result.missingKeys.join(", ")}. Consider using anyParam or operator-based matchers.` + }); + } + } + if (!paramsMatched) { + continue; + } + // anyParam matches if ANY string field in the tool input satisfies the + // matcher. Useful when the user doesn't know which key holds the path. + if (rule.anyParam) { + if (!matchesAnyStringField(rule.anyParam, toolParams || {})) { + continue; + } + } + if (rule.action === "allow") { + return { allowed: true, matchedRule: rule, dataClasses: Array.from(dataClasses), warnings }; + } + if (rule.action === "deny") { + return { + allowed: false, + reason: `ArmorCopilot policy deny: ${rule.id}`, + matchedRule: rule, + dataClasses: Array.from(dataClasses), + warnings + }; + } + if (rule.action === "require_approval") { + return { + allowed: false, + reason: `ArmorCopilot policy requires approval: ${rule.id}`, + matchedRule: rule, + dataClasses: Array.from(dataClasses), + warnings + }; + } + } + + return { allowed: true, dataClasses: Array.from(dataClasses), warnings }; +} + +function truncateReason(text, max = 160) { + const trimmed = text.trim(); + if (trimmed.length <= max) { + return trimmed; + } + return `${trimmed.slice(0, max)}...`; +} + +function formatRule(rule) { + const parts = [`id=${rule.id}`, `action=${rule.action}`, `tool=${rule.tool}`]; + if (rule.dataClass) { + parts.push(`dataClass=${rule.dataClass}`); + } + if (rule.anyParam) { + const op = Object.keys(rule.anyParam)[0]; + const val = rule.anyParam[op]; + parts.push(`match=${op}:${val}`); + } + if (rule.params) { + parts.push(`params=${JSON.stringify(rule.params)}`); + } + return parts.join(" "); +} + +function nextPolicyId(state) { + const ids = state.policy.rules + .map((rule) => rule.id) + .map((id) => { + const match = id.match(/^policy(\d+)$/i); + return match ? Number.parseInt(match[1] || "", 10) : null; + }) + .filter((value) => Number.isFinite(value)); + const max = ids.length ? Math.max(...ids) : 0; + return `policy${max + 1}`; +} + +function inferPolicyAction(text) { + const lower = text.toLowerCase(); + if (/(require\s+approval|needs\s+approval|approval\s+required)/i.test(lower)) { + return "require_approval"; + } + if (/(allow|permit|enable|whitelist)/i.test(lower)) { + return "allow"; + } + if (/(deny|block|disallow|prevent|prohibit|stop)/i.test(lower)) { + return "deny"; + } + return "deny"; +} + +function inferPolicyDataClass(text) { + const lower = text.toLowerCase(); + if (/(credit\s*card|card\s*number|pci)/i.test(lower)) { + return "PCI"; + } + if (/(payment|billing|bank|iban|swift|routing)/i.test(lower)) { + return "PAYMENT"; + } + if (/(phi|health|patient|medical)/i.test(lower)) { + return "PHI"; + } + if (/(pii|ssn|personal\s+data|identity)/i.test(lower)) { + return "PII"; + } + return undefined; +} + +// A tool name must look like a real identifier — letters, digits, underscore, +// hyphen, dot, colon — OR exactly "*". Anything else is rejected so free-text +// like "all tools" or regex fragments can't become rule matchers. +const VALID_TOOL_NAME = /^(?:\*|[A-Za-z][\w.:\-]{0,80})$/; + +function sanitizeToolName(candidate) { + if (typeof candidate !== "string") return null; + const trimmed = candidate.trim(); + if (!trimmed) return null; + return VALID_TOOL_NAME.test(trimmed) ? trimmed : null; +} + +// Detect a path or substring the user wants to block. Looks for things like +// ~/.ssh, /etc/passwd, or quoted/backticked snippets after "block"/"deny". +function inferAnyParamMatcher(text) { + // Quoted snippets first: most explicit. + const quoted = + text.match(/"([^"\n]{2,80})"/) || + text.match(/'([^'\n]{2,80})'/); + if (quoted && quoted[1]) { + return inferMatcherForPhrase(quoted[1]); + } + // Path-like tokens: ~/..., /xxx/yyy, $HOME/... + const pathMatch = text.match(/((?:~|\$\{?HOME\}?|\/)[\w./@\-+~]{2,120})/); + if (pathMatch && pathMatch[1]) { + const candidate = pathMatch[1].replace(/[.,;:)\]}]+$/, ""); + if (candidate.length >= 2) { + return { $pathContains: candidate }; + } + } + return null; +} + +function inferMatcherForPhrase(phrase) { + const trimmed = phrase.trim(); + if (!trimmed) return null; + if (/^(?:~|\$\{?HOME\}?|\/)/.test(trimmed)) { + return { $pathContains: trimmed }; + } + // Looks like a regex: leave operator-based match. + if (/[\\^$+?(){}[\]|]/.test(trimmed)) { + return { $matches: trimmed }; + } + return { $contains: trimmed }; +} + +// Real Copilot tools we recognize. Used to disambiguate "block X for Y" where X +// may or may not be a tool name. Falls back to "*" when X isn't here. +const KNOWN_COPILOT_TOOLS = new Set([ + "*", + "bash", "shell", "write", "read", "edit", "apply_patch", "list_dir", "view_image", "mcp_resource", "websearch", "fetch", + "update_plan", "create_goal", "update_goal", "get_goal", + "spawn_agents_on_csv", "tool_search", "tool_suggest", + "register_intent_plan", "policy_read", "policy_update" +]); + +function inferPolicyTool(text) { + const lower = text.toLowerCase(); + if (/(all\s+tools|any\s+tool|\*\b)/i.test(lower)) { + return "*"; + } + const backtickMatch = text.match(/`([A-Za-z][\w.:\-]{0,80})`/); + const backtickName = sanitizeToolName(backtickMatch?.[1]); + if (backtickName) { + return backtickName; + } + const toolMatch = text.match(/\btool\s*[:=]?\s*([A-Za-z][\w.:\-]{0,80})/i); + const toolName = sanitizeToolName(toolMatch?.[1]); + if (toolName) { + return toolName; + } + const actionMatch = text.match(/\b(?:block|deny|allow|disallow|permit|require)\s+([A-Za-z][\w.:\-]{0,80})/i); + const actionName = sanitizeToolName(actionMatch?.[1]); + if (actionName) { + return actionName; + } + return "*"; +} + +function buildPolicyUpdateFromText(text, state, forceNewId = false) { + const explicitIdMatch = text.match(/\bpolicy[-_]?(\d+)\b/i); + const explicitId = explicitIdMatch && explicitIdMatch[1] ? `policy${explicitIdMatch[1]}` : ""; + const id = forceNewId ? nextPolicyId(state) : explicitId || nextPolicyId(state); + const inferredTool = inferPolicyTool(text); + const anyParam = inferAnyParamMatcher(text); + + // If we found a path/phrase to match AND the inferred tool is a verb like + // "access" or any unknown name, the user means "block this content across + // all tools": promote tool to "*". A real tool name (Bash, apply_patch...) + // stays as-is so users can scope rules to a specific tool when they want. + let tool = inferredTool; + if (anyParam && tool !== "*") { + const normalized = tool.toLowerCase(); + if (!KNOWN_COPILOT_TOOLS.has(normalized)) { + tool = "*"; + } + } + + const rule = { + id, + action: inferPolicyAction(text), + tool, + dataClass: inferPolicyDataClass(text) + }; + if (anyParam) { + rule.anyParam = anyParam; + } + return { + reason: truncateReason(`User policy update: ${text}`), + mode: /replace/i.test(text) ? "replace" : "merge", + rules: [rule] + }; +} + +export function parsePolicyTextCommand(text, state) { + const trimmed = text.trim(); + const lower = trimmed.toLowerCase(); + + if (!/^policy\b/i.test(trimmed)) { + return { kind: "none" }; + } + + // Only the bare "Policy help" / "Policy commands" form triggers help. + // Otherwise "Bash commands containing curl" inside a rule body would + // wrongly route here. + if (/^\s*policy\s+(help|commands)\s*$/i.test(trimmed)) { + return { kind: "help" }; + } + if (/^\s*policy\s+(list|show|view)\s*$/i.test(trimmed)) { + return { kind: "list" }; + } + if (/\breset|clear\s+all|wipe\b/i.test(lower)) { + return { kind: "reset", reason: truncateReason(`Policy reset: ${trimmed}`) }; + } + const reorderMatch = trimmed.match( + /\bpolicy\s*(?:priorit(?:y|ize|ise)|reorder|move)\s+(policy\d+|[a-z0-9][\w.-]*)\s+(?:to\s+)?(\d+)\b/i + ); + if (reorderMatch && reorderMatch[1] && reorderMatch[2]) { + return { + kind: "reorder", + id: reorderMatch[1], + position: Number.parseInt(reorderMatch[2], 10), + reason: truncateReason(`Policy reorder: ${trimmed}`) + }; + } + const deleteMatch = trimmed.match(/\bpolicy\s+delete\s+([a-z0-9][\w.-]*)\b/i); + if (deleteMatch && deleteMatch[1]) { + return { + kind: "delete", + id: deleteMatch[1], + reason: truncateReason(`Policy delete: ${trimmed}`) + }; + } + const getMatch = trimmed.match(/\bpolicy\s+get\s+([a-z0-9][\w.-]*)\b/i); + if (getMatch && getMatch[1]) { + return { kind: "get", id: getMatch[1] }; + } + const newMatch = trimmed.match(/\bpolicy\s+new\s*:\s*(.+)$/i); + if (newMatch && newMatch[1]) { + return { kind: "update", update: buildPolicyUpdateFromText(newMatch[1], state, true) }; + } + const updateMatch = trimmed.match(/\bpolicy\s+update(?:\s+([a-z0-9][\w.-]*))?\s*:\s*(.+)$/i); + if (updateMatch && updateMatch[2]) { + const [_, maybeId, body] = updateMatch; + const full = maybeId ? `${maybeId} ${body}` : body; + return { kind: "update", update: buildPolicyUpdateFromText(full, state, false), hasId: Boolean(maybeId) }; + } + + return { kind: "help" }; +} + +function mergeRules(existing, updates) { + const byId = new Map(); + for (const rule of existing) { + byId.set(rule.id, rule); + } + const newRules = []; + for (const rule of updates) { + if (byId.has(rule.id)) { + byId.set(rule.id, rule); + } else { + newRules.push(rule); + } + } + return [...newRules, ...Array.from(byId.values())]; +} + +async function persistNextState(policyFilePath, oldState, nextPolicy, actor, reason) { + const version = oldState.version + 1; + const updatedAt = new Date().toISOString(); + const entry = { + version, + updatedAt, + updatedBy: actor, + reason, + policy: nextPolicy + }; + const nextState = { + version, + updatedAt, + updatedBy: actor, + policy: nextPolicy, + history: [...oldState.history, entry] + }; + await savePolicyState(policyFilePath, nextState); + return nextState; +} + +function formatPolicyHelp() { + return [ + "Policy commands:", + "1. Policy list", + "2. Policy get policy1", + "3. Policy delete policy1", + "4. Policy reset", + "5. Policy update policy1: block send_email for payment data", + "6. Policy new: block web_fetch for PII", + "7. Policy prioritize policy2 1" + ].join("\n"); +} + +export async function applyPolicyCommand({ policyFilePath, state, command, actor }) { + if (command.kind === "none") { + return { state, message: "" }; + } + if (command.kind === "help") { + return { state, message: formatPolicyHelp() }; + } + if (command.kind === "list") { + if (!state.policy.rules.length) { + return { state, message: `Policy version ${state.version}. No explicit rules.` }; + } + const lines = state.policy.rules.map((rule, idx) => `${idx + 1}. ${formatRule(rule)}`); + return { state, message: `Policy version ${state.version}:\n${lines.join("\n")}` }; + } + if (command.kind === "get") { + const rule = state.policy.rules.find((entry) => entry.id === command.id); + return { + state, + message: rule ? `Policy rule:\n- ${formatRule(rule)}` : `Policy rule not found: ${command.id}` + }; + } + if (command.kind === "reset") { + const nextState = await persistNextState( + policyFilePath, + state, + { rules: [] }, + actor, + command.reason || "Policy reset" + ); + return { state: nextState, message: `Policy reset. Version ${nextState.version}.` }; + } + if (command.kind === "delete") { + const rules = state.policy.rules.filter((rule) => rule.id !== command.id); + const nextState = await persistNextState( + policyFilePath, + state, + { rules }, + actor, + command.reason || `Policy delete: ${command.id}` + ); + return { + state: nextState, + message: + rules.length === state.policy.rules.length + ? `No matching rule removed (${command.id}).` + : `Policy rule removed: ${command.id}. Version ${nextState.version}.` + }; + } + if (command.kind === "reorder") { + const rules = [...state.policy.rules]; + const index = rules.findIndex((rule) => rule.id === command.id); + if (index === -1) { + return { state, message: `Policy rule not found: ${command.id}` }; + } + const clamped = Math.min(Math.max(command.position, 1), rules.length); + const [rule] = rules.splice(index, 1); + rules.splice(clamped - 1, 0, rule); + const nextState = await persistNextState( + policyFilePath, + state, + { rules }, + actor, + command.reason || `Policy reorder: ${command.id}` + ); + return { state: nextState, message: `Policy ${command.id} moved to position ${clamped}.` }; + } + if (command.kind === "update") { + if (!isPlainObject(command.update)) { + return { state, message: "Policy update rejected: invalid payload." }; + } + const mode = command.update.mode === "replace" ? "replace" : "merge"; + const updates = Array.isArray(command.update.rules) + ? command.update.rules.map((rule) => normalizeRule(rule)).filter(Boolean) + : []; + // Allow empty rules in `replace` mode: this is how callers clear all + // policy rules atomically. Reject only when merge-mode update has nothing + // to add, since that would be a no-op. + if (!updates.length && mode !== "replace") { + return { state, message: "Policy update rejected: no valid rules." }; + } + const nextRules = mode === "replace" ? updates : mergeRules(state.policy.rules, updates); + const action = mode === "replace" && updates.length === 0 ? "cleared" : "updated"; + const nextState = await persistNextState( + policyFilePath, + state, + { rules: nextRules }, + actor, + command.update.reason || "Policy update" + ); + return { state: nextState, message: `Policy ${action}. Version ${nextState.version}.` }; + } + return { state, message: "No policy changes applied." }; +} + diff --git a/packages/armorcopilot-gh/scripts/lib/runtime-state.mjs b/packages/armorcopilot-gh/scripts/lib/runtime-state.mjs new file mode 100644 index 0000000..9667839 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/lib/runtime-state.mjs @@ -0,0 +1,80 @@ +import { nowEpochSeconds } from "./common.mjs"; +import { readJson, writeJson } from "./fs-store.mjs"; + +const MAX_SESSION_AGE_SECONDS = 60 * 60 * 24; + +export async function loadRuntimeState(runtimeFilePath) { + const initial = { sessions: {}, discoveredTools: [] }; + const raw = await readJson(runtimeFilePath, initial); + const sessions = raw && typeof raw === "object" && raw.sessions && typeof raw.sessions === "object" + ? raw.sessions + : {}; + const discoveredTools = Array.isArray(raw?.discoveredTools) + ? raw.discoveredTools + : []; + return { sessions, discoveredTools }; +} + +export function getSession(runtimeState, sessionId) { + if (!sessionId) { + return undefined; + } + return runtimeState.sessions[sessionId]; +} + +export function upsertSession(runtimeState, sessionId, patch) { + const prev = getSession(runtimeState, sessionId) || {}; + runtimeState.sessions[sessionId] = { + ...prev, + ...patch, + updatedAt: nowEpochSeconds() + }; + return runtimeState.sessions[sessionId]; +} + +const POST_EXPIRY_GRACE_SECONDS = 60 * 60; + +export function pruneSessions(runtimeState) { + const now = nowEpochSeconds(); + for (const [sessionId, session] of Object.entries(runtimeState.sessions)) { + const updatedAt = Number.isFinite(session.updatedAt) ? session.updatedAt : 0; + if (now - updatedAt > MAX_SESSION_AGE_SECONDS) { + delete runtimeState.sessions[sessionId]; + continue; + } + const expiresAt = Number.isFinite(session.expiresAt) ? session.expiresAt : 0; + if (expiresAt > 0 && now - expiresAt > POST_EXPIRY_GRACE_SECONDS) { + delete runtimeState.sessions[sessionId]; + } + } +} + +export async function saveRuntimeState(runtimeFilePath, runtimeState) { + pruneSessions(runtimeState); + await writeJson(runtimeFilePath, runtimeState); +} + +// --------------------------------------------------------------------------- +// Tool discovery — accumulate known tools across PreToolUse calls +// --------------------------------------------------------------------------- + +export function upsertDiscoveredTool(runtimeState, toolName) { + if (!toolName || typeof toolName !== "string") return; + const name = toolName.trim(); + if (!name) return; + if (!Array.isArray(runtimeState.discoveredTools)) { + runtimeState.discoveredTools = []; + } + const normalized = name.toLowerCase(); + const existing = runtimeState.discoveredTools.map((t) => t.toLowerCase()); + if (!existing.includes(normalized)) { + runtimeState.discoveredTools.push(name); + } +} + +export function getDiscoveredTools(runtimeState) { + return Array.isArray(runtimeState?.discoveredTools) + ? runtimeState.discoveredTools + : []; +} + diff --git a/packages/armorcopilot-gh/scripts/policy-mcp.mjs b/packages/armorcopilot-gh/scripts/policy-mcp.mjs new file mode 100644 index 0000000..38af477 --- /dev/null +++ b/packages/armorcopilot-gh/scripts/policy-mcp.mjs @@ -0,0 +1,318 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import path from "node:path"; +import { z } from "zod"; +import { loadConfig } from "./lib/config.mjs"; +import { writeJson } from "./lib/fs-store.mjs"; +import { extractAllowedActions, requestIntent } from "./lib/intent.mjs"; +import { INTENT_PLAN_ZOD, PLAN_STEP_SCHEMA, normalizeIntentPlan } from "./lib/intent-schema.mjs"; +import { applyPolicyCommand, computePolicyHash, loadPolicyState, parsePolicyTextCommand } from "./lib/policy.mjs"; +import { createAuditWal } from "./lib/audit-wal.mjs"; +import { createIapService } from "./lib/iap-service.mjs"; + +const MATCHER_OPERATORS = z + .object({ + $equals: z.string().optional(), + $contains: z.string().optional(), + $startsWith: z.string().optional(), + $endsWith: z.string().optional(), + $matches: z.string().optional(), + $pathContains: z.string().optional() + }) + .strict(); + +const POLICY_RULE_SCHEMA = z.object({ + id: z.string().min(1), + action: z.enum(["allow", "deny", "require_approval"]), + tool: z.string().min(1), + dataClass: z.enum(["PCI", "PAYMENT", "PHI", "PII"]).optional(), + params: z.record(z.string(), z.unknown()).optional(), + // anyParam: matches a substring or operator spec across any string field + // in the tool input. Plain string is sugar for { $contains: }. + anyParam: z.union([z.string().min(1), MATCHER_OPERATORS]).optional() +}); + +const POLICY_UPDATE_SCHEMA = z.object({ + reason: z.string().min(1), + mode: z.enum(["replace", "merge"]).optional(), + rules: z.array(POLICY_RULE_SCHEMA) +}); + +function toTextResult(text, extra = {}) { + return { + content: [{ type: "text", text }], + structuredContent: { + message: text, + ...extra + } + }; +} + +/** + * Some MCP clients (and Copilot itself sometimes pass complex tool arguments + * as JSON-encoded strings instead of structured objects. Accept either form. + * + * { goal: "...", steps: "[{...}]" } → parse steps as JSON + * { plan: "{\"goal\":...}" } → parse plan envelope as JSON + * { goal: "...", steps: [{...}] } → pass through + */ +function coercePlanArgs(args) { + if (!args || typeof args !== "object") { + return args; + } + // If caller wrapped the entire plan in a `plan` field (string or object), + // unwrap it. + if (args.plan !== undefined) { + let unwrapped = args.plan; + if (typeof unwrapped === "string") { + try { unwrapped = JSON.parse(unwrapped); } catch { /* fall through */ } + } + if (unwrapped && typeof unwrapped === "object") { + args = { ...unwrapped, ...args }; + delete args.plan; + } + } + // Coerce stringified arrays/objects on known fields. + if (typeof args.steps === "string") { + try { args = { ...args, steps: JSON.parse(args.steps) }; } catch { /* leave as-is */ } + } + return args; +} + +async function loadStateAndConfig() { + const config = loadConfig(); + const state = await loadPolicyState(config.policyFile); + return { config, state }; +} + +async function run() { + const server = new McpServer({ + name: "armorcopilot-policy", + version: "0.1.0" + }); + + server.registerTool( + "policy_update", + { + title: "Policy Update", + description: "Manage ArmorCopilot policy rules (update/list/delete/reset)", + inputSchema: { + text: z.string().optional(), + update: POLICY_UPDATE_SCHEMA.optional() + } + }, + async (args) => { + const { config, state } = await loadStateAndConfig(); + if (!config.policyUpdateEnabled) { + return toTextResult("ArmorCopilot policy updates are disabled."); + } + + if (typeof args.text === "string" && args.text.trim()) { + const command = parsePolicyTextCommand(args.text, state); + const result = await applyPolicyCommand({ + policyFilePath: config.policyFile, + state, + command, + actor: "mcp" + }); + return toTextResult(result.message, { version: result.state.version }); + } + + if (args.update) { + // Tolerate JSON-string update payloads (some clients stringify objects). + let updateInput = args.update; + if (typeof updateInput === "string") { + try { updateInput = JSON.parse(updateInput); } catch { /* let validator complain */ } + } + const parsed = POLICY_UPDATE_SCHEMA.safeParse(updateInput); + if (!parsed.success) { + return toTextResult(`Policy update rejected: ${parsed.error.message}`); + } + const result = await applyPolicyCommand({ + policyFilePath: config.policyFile, + state, + command: { + kind: "update", + update: parsed.data + }, + actor: "mcp" + }); + return toTextResult(result.message, { version: result.state.version }); + } + + return toTextResult("Policy update rejected: missing `text` or `update`."); + } + ); + + server.registerTool( + "policy_read", + { + title: "Policy Read", + description: "Read current ArmorCopilot policy state", + inputSchema: { + id: z.string().optional() + } + }, + async (args) => { + const { state } = await loadStateAndConfig(); + if (typeof args.id === "string" && args.id.trim()) { + const rule = state.policy.rules.find((entry) => entry.id === args.id.trim()); + if (!rule) { + return toTextResult(`Policy rule not found: ${args.id}`); + } + return toTextResult(JSON.stringify(rule, null, 2), { rule }); + } + return toTextResult(JSON.stringify(state, null, 2), { + version: state.version, + rules: state.policy.rules + }); + } + ); + + // ----------------------------------------------------------------- + // register_intent_plan — Copilot calls this to declare its plan + // ----------------------------------------------------------------- + server.registerTool( + "register_intent_plan", + { + title: "Register Intent Plan", + description: + "Declare the tools you intend to use for this task. " + + "Required by ArmorCopilot before any other tool call. " + + "Without a registered plan, all tool calls will be blocked.", + // Accept the canonical {goal, steps} shape AND the string-serialized + // variants Copilot sometimes emits (steps as a JSON string, or the + // whole plan wrapped in a `plan` field). The handler below coerces + // them to the canonical shape before validating with INTENT_PLAN_ZOD. + inputSchema: { + goal: z.string().min(1).optional() + .describe("One-line summary of what the plan accomplishes"), + steps: z.union([ + z.array(PLAN_STEP_SCHEMA).min(1), + z.string().min(1) + ]).optional() + .describe("Ordered list of tool calls (array, or JSON-stringified array)"), + plan: z.union([INTENT_PLAN_ZOD, z.string().min(1)]).optional() + .describe("Alternative: pass the whole plan as an object or JSON string") + } + }, + async (args) => { + // Copilot sometimes serializes complex tool arguments as JSON strings + // (e.g. steps: "[{...}]" instead of steps: [{...}]). Tolerate both. + const coerced = coercePlanArgs(args); + const parsed = INTENT_PLAN_ZOD.safeParse(coerced); + if (!parsed.success) { + return toTextResult(`Plan rejected: ${parsed.error.message}`); + } + + const config = loadConfig(); + const plan = normalizeIntentPlan(parsed.data); + + // Write the local plan to pending-plan.json IMMEDIATELY so PreToolUse + // has something to enforce against. The SDK call (if any) runs entirely + // in the background and updates pending-plan.json with the signed + // token when it resolves. + // + // Why fire-and-forget: Copilot's MCP transport closes its stdio pipe + // around the ~1s mark. Any await we do here (loadPolicyState, the + // SDK round-trip, even cold-start latency) eats into that budget. + // Awaiting nothing on the network path keeps the MCP response under + // ~100ms regardless of backend conditions. + const pendingPath = path.join(config.dataDir, "pending-plan.json"); + await writeJson(pendingPath, { + plan, + tokenRaw: "", + allowedActions: Array.from(extractAllowedActions(plan)), + expiresAt: undefined, + registeredAt: Date.now() + }); + + let backendWillIssue = false; + if (config.intentEndpoint || (config.useSdkIntent && config.apiKey)) { + backendWillIssue = true; + // Kick off the SDK call. When it resolves with a signed token, update + // pending-plan.json so PreToolUse picks up the token on subsequent + // calls. Errors are logged to stderr and otherwise swallowed. + (async () => { + try { + const policyState = await loadPolicyState(config.policyFile); + const result = await requestIntent(config, { + prompt: parsed.data.goal, + plan, + session_id: "mcp", + policy_hash: computePolicyHash(policyState.policy), + policy: policyState.policy, + validitySeconds: config.validitySeconds, + metadata: { source: "copilot", planning: "copilot-registered" } + }); + if (result?.tokenRaw) { + await writeJson(pendingPath, { + plan: result.plan || plan, + tokenRaw: result.tokenRaw, + allowedActions: Array.from(extractAllowedActions(result.plan || plan)), + expiresAt: result.expiresAt, + registeredAt: Date.now() + }); + process.stderr.write( + `[armorcopilot] backend token issued, tokenLen=${result.tokenRaw.length}\n` + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[armorcopilot] intent capture failed: ${msg}\n`); + } + })(); + } + + const tokenInfo = backendWillIssue + ? `Plan registered; ArmorIQ token issuing in background.` + : "Plan stored locally (no ArmorIQ backend configured)."; + + return toTextResult( + `Intent registered: ${plan.steps.length} steps. ${tokenInfo}`, + { steps: plan.steps.length, goal: parsed.data.goal } + ); + } + ); + + // Background WAL flusher — drains queued audit rows in batches and ships + // to /iap/audit. Embedded here because the MCP server is already a + // long-lived stdio process; no need for a separate daemon binary the way + // armorClaude needs one (Claude Code spawns a fresh node per hook). + // + // Tuning mirrors armorClaude#44 daemon for cross-product parity: + // - 5s interval (AUDIT_FLUSH_INTERVAL_MS) + // - 100-row batch (AUDIT_FLUSH_THRESHOLD) + // Errors are logged + retried on the next tick (offset isn't advanced + // on failure). + const flusher = setInterval(async () => { + try { + const config = loadConfig(); + if (!config.apiKey) return; // no backend configured; WAL just accumulates locally + const wal = createAuditWal({ dataDir: config.dataDir }); + const { rows, endOffset } = await wal.readBatch(100); + if (rows.length === 0) return; + const iapService = createIapService(config); + await iapService.shipAuditBatch(rows); + await wal.advanceOffset(endOffset); + process.stderr.write( + `[armorcopilot-policy] flushed ${rows.length} audit rows -> offset=${endOffset}\n` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[armorcopilot-policy] flusher: ${msg}\n`); + } + }, 5000); + flusher.unref?.(); + process.on("SIGTERM", () => clearInterval(flusher)); + process.on("SIGINT", () => clearInterval(flusher)); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +run().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write(`[armorcopilot-policy] ${message}\n`); + process.exitCode = 1; +}); From 5b72ae5f1c324ed25b39c71bd499f8fe5edcb048 Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Mon, 25 May 2026 13:32:41 +0530 Subject: [PATCH 2/7] chore: restructure to match armorCodex / armorClaude layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the established ArmorIQ plugin repo layout: / ├── .claude-plugin/marketplace.json repo-level marketplace ├── .agents/plugins/marketplace.json mirror ├── plugins// actual plugin tree └── README.md Changes: - Renamed packages/armorcopilot-gh/ → plugins/armorcopilot/ (drop the "-gh" suffix; the brand is just ArmorCopilot, GitHub Copilot CLI is the only target. If we ever ship for another harness, that gets its own plugin directory under plugins/.) - Added .claude-plugin/marketplace.json at repo root with publisher "ArmorIQ" (matches armorCodex#22 convention — the "Built by..." filter groups plugins by company, not product) - Mirror to .agents/plugins/marketplace.json - package.json name: @armoriq/armorcopilot-gh → @armoriq/armorcopilot - README.md rewritten for the new layout - Removed PLAN_GH_COPILOT.md + CTO_MEETING_BRIEF.md (planning docs for this work — preserved in this branch's commit history if needed; no value at repo root after the port lands) Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/plugins/marketplace.json | 38 +++++++++++++ .claude-plugin/marketplace.json | 38 +++++++++++++ README.md | 52 +++++++++++++++++- .../armorcopilot}/.claude-plugin/plugin.json | 0 .../armorcopilot}/.mcp.json | 0 .../armorcopilot}/README.md | 0 .../armorcopilot}/assets/armoriq-logo.png | Bin .../armorcopilot}/hooks/hooks.json | 0 .../armorcopilot}/package.json | 0 .../armorcopilot}/scripts/bootstrap.mjs | 0 .../armorcopilot}/scripts/hook-router.mjs | 0 .../armorcopilot}/scripts/lib/audit-wal.mjs | 0 .../armorcopilot}/scripts/lib/common.mjs | 0 .../armorcopilot}/scripts/lib/config.mjs | 0 .../scripts/lib/crypto-policy.mjs | 0 .../armorcopilot}/scripts/lib/engine.mjs | 0 .../armorcopilot}/scripts/lib/fs-store.mjs | 0 .../armorcopilot}/scripts/lib/hook-output.mjs | 0 .../armorcopilot}/scripts/lib/iap-service.mjs | 0 .../scripts/lib/intent-schema.mjs | 0 .../armorcopilot}/scripts/lib/intent.mjs | 0 .../armorcopilot}/scripts/lib/planner.mjs | 0 .../armorcopilot}/scripts/lib/policy.mjs | 0 .../scripts/lib/runtime-state.mjs | 0 .../armorcopilot}/scripts/policy-mcp.mjs | 0 25 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .claude-plugin/marketplace.json rename {packages/armorcopilot-gh => plugins/armorcopilot}/.claude-plugin/plugin.json (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/.mcp.json (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/README.md (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/assets/armoriq-logo.png (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/hooks/hooks.json (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/package.json (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/bootstrap.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/hook-router.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/audit-wal.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/common.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/config.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/crypto-policy.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/engine.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/fs-store.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/hook-output.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/iap-service.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/intent-schema.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/intent.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/planner.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/policy.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/lib/runtime-state.mjs (100%) rename {packages/armorcopilot-gh => plugins/armorcopilot}/scripts/policy-mcp.mjs (100%) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..ef87228 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,38 @@ +{ + "name": "armorcopilot", + "owner": { + "name": "ArmorIQ", + "email": "license@armoriq.io", + "url": "https://armoriq.ai" + }, + "metadata": { + "description": "ArmorIQ marketplace: intent-based security enforcement for GitHub Copilot CLI (ArmorCopilot) and other agentic harnesses.", + "version": "0.1.0" + }, + "interface": { + "displayName": "ArmorIQ" + }, + "plugins": [ + { + "name": "armorcopilot", + "source": { + "source": "local", + "path": "./plugins/armorcopilot" + }, + "description": "Intent-based security enforcement for GitHub Copilot CLI: declared plans, policy rules, intent-drift blocking, optional CSRG cryptographic proofs, and audit logging.", + "version": "0.1.0", + "category": "Security", + "tags": ["security", "policy", "audit", "intent", "armoriq", "mcp", "hooks", "github-copilot"], + "homepage": "https://armoriq.ai", + "license": "MIT", + "author": { + "name": "ArmorIQ", + "email": "license@armoriq.io" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + } + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..ef87228 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,38 @@ +{ + "name": "armorcopilot", + "owner": { + "name": "ArmorIQ", + "email": "license@armoriq.io", + "url": "https://armoriq.ai" + }, + "metadata": { + "description": "ArmorIQ marketplace: intent-based security enforcement for GitHub Copilot CLI (ArmorCopilot) and other agentic harnesses.", + "version": "0.1.0" + }, + "interface": { + "displayName": "ArmorIQ" + }, + "plugins": [ + { + "name": "armorcopilot", + "source": { + "source": "local", + "path": "./plugins/armorcopilot" + }, + "description": "Intent-based security enforcement for GitHub Copilot CLI: declared plans, policy rules, intent-drift blocking, optional CSRG cryptographic proofs, and audit logging.", + "version": "0.1.0", + "category": "Security", + "tags": ["security", "policy", "audit", "intent", "armoriq", "mcp", "hooks", "github-copilot"], + "homepage": "https://armoriq.ai", + "license": "MIT", + "author": { + "name": "ArmorIQ", + "email": "license@armoriq.io" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + } + } + ] +} diff --git a/README.md b/README.md index 87d58ec..150554e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ # armorCopilot -ArmorIQ enforcement for Microsoft Copilot Studio (and later GitHub Copilot) — intent-based tool-call interception, audit, and policy enforcement. + +ArmorIQ intent-based security enforcement for **GitHub Copilot CLI** — pre-tool guardrails, intent verification, optional cryptographic proofs, audit logging. + +Counterpart of ArmorClaude (Claude Code) and ArmorCodex (OpenAI Codex). Same wedge, different harness. + +## Install + +```bash +copilot plugin install armoriq/armorCopilot +``` + +The plugin runtime auto-discovers `.claude-plugin/plugin.json` inside `plugins/armorcopilot/` and registers hooks + MCP servers. + +## Configure + +After install, paste your ArmorIQ API key into the plugin's userConfig in Copilot CLI. Get one at https://armoriq.ai. Leave blank for local-only mode (no backend audit, policies stored on disk). + +## What it does + +| Surface | Plugin behavior | +|---|---| +| `sessionStart` / `userPromptSubmitted` | Injects directive telling Copilot to register its intent plan first | +| `preToolUse` | Verifies the tool against the registered plan + policy. Blocks via `{"permissionDecision":"deny",...}` if out-of-plan or policy-denied | +| `postToolUse` | Async audit row to ArmorIQ backend (fire-and-forget WAL) | +| `permissionRequest` | Honors policy decisions before user is prompted | +| MCP tools | `register_intent_plan`, `policy_update` (natural-language), `policy_read` | + +## Layout + +``` +armorCopilot/ +├── .claude-plugin/marketplace.json repo-level marketplace manifest +├── .agents/plugins/marketplace.json mirror (for non-Copilot agent runtimes) +├── plugins/armorcopilot/ the plugin itself +│ ├── .claude-plugin/plugin.json plugin manifest +│ ├── .mcp.json MCP server config +│ ├── hooks/hooks.json 5 hook events wired +│ ├── package.json npm deps +│ ├── README.md plugin-level docs +│ ├── assets/ logo + icons +│ └── scripts/ bootstrap + hook-router + policy-mcp + 12 lib modules +└── README.md this file +``` + +## Refs + +- ArmorClaude: https://github.com/armoriq/armorClaude +- ArmorCodex: https://github.com/armoriq/armorCodex +- GitHub Copilot CLI plugin docs: https://docs.github.com/copilot/concepts/agents/copilot-cli/about-cli-plugins +- GitHub Copilot CLI hooks reference: https://docs.github.com/en/copilot/reference/hooks-configuration +- ArmorIQ platform: https://armoriq.ai diff --git a/packages/armorcopilot-gh/.claude-plugin/plugin.json b/plugins/armorcopilot/.claude-plugin/plugin.json similarity index 100% rename from packages/armorcopilot-gh/.claude-plugin/plugin.json rename to plugins/armorcopilot/.claude-plugin/plugin.json diff --git a/packages/armorcopilot-gh/.mcp.json b/plugins/armorcopilot/.mcp.json similarity index 100% rename from packages/armorcopilot-gh/.mcp.json rename to plugins/armorcopilot/.mcp.json diff --git a/packages/armorcopilot-gh/README.md b/plugins/armorcopilot/README.md similarity index 100% rename from packages/armorcopilot-gh/README.md rename to plugins/armorcopilot/README.md diff --git a/packages/armorcopilot-gh/assets/armoriq-logo.png b/plugins/armorcopilot/assets/armoriq-logo.png similarity index 100% rename from packages/armorcopilot-gh/assets/armoriq-logo.png rename to plugins/armorcopilot/assets/armoriq-logo.png diff --git a/packages/armorcopilot-gh/hooks/hooks.json b/plugins/armorcopilot/hooks/hooks.json similarity index 100% rename from packages/armorcopilot-gh/hooks/hooks.json rename to plugins/armorcopilot/hooks/hooks.json diff --git a/packages/armorcopilot-gh/package.json b/plugins/armorcopilot/package.json similarity index 100% rename from packages/armorcopilot-gh/package.json rename to plugins/armorcopilot/package.json diff --git a/packages/armorcopilot-gh/scripts/bootstrap.mjs b/plugins/armorcopilot/scripts/bootstrap.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/bootstrap.mjs rename to plugins/armorcopilot/scripts/bootstrap.mjs diff --git a/packages/armorcopilot-gh/scripts/hook-router.mjs b/plugins/armorcopilot/scripts/hook-router.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/hook-router.mjs rename to plugins/armorcopilot/scripts/hook-router.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/audit-wal.mjs b/plugins/armorcopilot/scripts/lib/audit-wal.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/audit-wal.mjs rename to plugins/armorcopilot/scripts/lib/audit-wal.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/common.mjs b/plugins/armorcopilot/scripts/lib/common.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/common.mjs rename to plugins/armorcopilot/scripts/lib/common.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/config.mjs b/plugins/armorcopilot/scripts/lib/config.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/config.mjs rename to plugins/armorcopilot/scripts/lib/config.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/crypto-policy.mjs b/plugins/armorcopilot/scripts/lib/crypto-policy.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/crypto-policy.mjs rename to plugins/armorcopilot/scripts/lib/crypto-policy.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/engine.mjs b/plugins/armorcopilot/scripts/lib/engine.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/engine.mjs rename to plugins/armorcopilot/scripts/lib/engine.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/fs-store.mjs b/plugins/armorcopilot/scripts/lib/fs-store.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/fs-store.mjs rename to plugins/armorcopilot/scripts/lib/fs-store.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/hook-output.mjs b/plugins/armorcopilot/scripts/lib/hook-output.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/hook-output.mjs rename to plugins/armorcopilot/scripts/lib/hook-output.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/iap-service.mjs b/plugins/armorcopilot/scripts/lib/iap-service.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/iap-service.mjs rename to plugins/armorcopilot/scripts/lib/iap-service.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/intent-schema.mjs b/plugins/armorcopilot/scripts/lib/intent-schema.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/intent-schema.mjs rename to plugins/armorcopilot/scripts/lib/intent-schema.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/intent.mjs b/plugins/armorcopilot/scripts/lib/intent.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/intent.mjs rename to plugins/armorcopilot/scripts/lib/intent.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/planner.mjs b/plugins/armorcopilot/scripts/lib/planner.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/planner.mjs rename to plugins/armorcopilot/scripts/lib/planner.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/policy.mjs b/plugins/armorcopilot/scripts/lib/policy.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/policy.mjs rename to plugins/armorcopilot/scripts/lib/policy.mjs diff --git a/packages/armorcopilot-gh/scripts/lib/runtime-state.mjs b/plugins/armorcopilot/scripts/lib/runtime-state.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/lib/runtime-state.mjs rename to plugins/armorcopilot/scripts/lib/runtime-state.mjs diff --git a/packages/armorcopilot-gh/scripts/policy-mcp.mjs b/plugins/armorcopilot/scripts/policy-mcp.mjs similarity index 100% rename from packages/armorcopilot-gh/scripts/policy-mcp.mjs rename to plugins/armorcopilot/scripts/policy-mcp.mjs From 822a6b4e84f3f9cbaf343b880a44b0761811dcbc Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Mon, 25 May 2026 15:40:03 +0530 Subject: [PATCH 3/7] fix(plugin): adapt to GitHub Copilot CLI's actual hook payload shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After installing into the real `copilot` CLI and capturing what it sends to hooks, two adapter changes were needed: 1. hooks.json — pass COPILOT_HOOK_EVENT via env per event Copilot CLI does NOT include `hook_event_name` in the hook payload (unlike Claude Code's snake_case variant). Our engine routes by event name, so we set it via the `env` field per hook. 2. hook-router.mjs — normalize Copilot's camelCase payload Copilot sends `sessionId / toolName / toolArgs (string-encoded)` where the engine expects `session_id / tool_name / tool_input (obj)`. Added a normalizer that copies camelCase → snake_case, parses `toolArgs` JSON string to object, and sets `hook_event_name` from the env var. 3. marketplace.json — fix `source` shape Copilot's marketplace schema rejected `{source:"local",path:"./..."}`. Changed to plain string `"./plugins/armorcopilot"` matching the format the official github/copilot-plugins marketplace uses. 4. README.md — fixed leftover `packages/armorcopilot-gh` path Verified end-to-end with `copilot plugin marketplace add` + install: Marketplace "armorcopilot" added successfully. Plugin "armorcopilot" installed successfully. [DEBUG] Loaded 5 hook(s) from 1 plugin(s) [DEBUG] Adding tool: armorcopilot-policy-policy_update [DEBUG] Adding tool: armorcopilot-policy-policy_read [DEBUG] Adding tool: armorcopilot-policy-register_intent_plan [hook stderr] [armorcopilot] session started: ..., mode=enforce [hook stdout] {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit", "additionalContext":"ArmorCopilot active. Call register_intent_plan first..."}} Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/plugins/marketplace.json | 5 +-- .claude-plugin/marketplace.json | 5 +-- plugins/armorcopilot/README.md | 7 ++-- plugins/armorcopilot/hooks/hooks.json | 15 +++++--- plugins/armorcopilot/scripts/hook-router.mjs | 36 +++++++++++++++++++- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index ef87228..56497ed 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -15,10 +15,7 @@ "plugins": [ { "name": "armorcopilot", - "source": { - "source": "local", - "path": "./plugins/armorcopilot" - }, + "source": "./plugins/armorcopilot", "description": "Intent-based security enforcement for GitHub Copilot CLI: declared plans, policy rules, intent-drift blocking, optional CSRG cryptographic proofs, and audit logging.", "version": "0.1.0", "category": "Security", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ef87228..56497ed 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,10 +15,7 @@ "plugins": [ { "name": "armorcopilot", - "source": { - "source": "local", - "path": "./plugins/armorcopilot" - }, + "source": "./plugins/armorcopilot", "description": "Intent-based security enforcement for GitHub Copilot CLI: declared plans, policy rules, intent-drift blocking, optional CSRG cryptographic proofs, and audit logging.", "version": "0.1.0", "category": "Security", diff --git a/plugins/armorcopilot/README.md b/plugins/armorcopilot/README.md index 34547b0..8ba3c73 100644 --- a/plugins/armorcopilot/README.md +++ b/plugins/armorcopilot/README.md @@ -51,9 +51,12 @@ The MCP server `armorcopilot-policy` exposes three tools: ```bash git clone https://github.com/armoriq/armorCopilot -cd armorCopilot/packages/armorcopilot-gh +cd armorCopilot/plugins/armorcopilot npm install --omit=dev -copilot plugin install . + +# Then from the repo root, register the marketplace + install: +copilot plugin marketplace add /path/to/armorCopilot +copilot plugin install armorcopilot@armorcopilot ``` ## Refs diff --git a/plugins/armorcopilot/hooks/hooks.json b/plugins/armorcopilot/hooks/hooks.json index b08e88a..533dba7 100644 --- a/plugins/armorcopilot/hooks/hooks.json +++ b/plugins/armorcopilot/hooks/hooks.json @@ -4,35 +4,40 @@ "sessionStart": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "bash": "node scripts/bootstrap.mjs router", + "env": { "COPILOT_HOOK_EVENT": "SessionStart" }, "timeoutSec": 30 } ], "userPromptSubmitted": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "bash": "node scripts/bootstrap.mjs router", + "env": { "COPILOT_HOOK_EVENT": "UserPromptSubmit" }, "timeoutSec": 30 } ], "preToolUse": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "bash": "node scripts/bootstrap.mjs router", + "env": { "COPILOT_HOOK_EVENT": "PreToolUse" }, "timeoutSec": 30 } ], "permissionRequest": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "bash": "node scripts/bootstrap.mjs router", + "env": { "COPILOT_HOOK_EVENT": "PermissionRequest" }, "timeoutSec": 30 } ], "postToolUse": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\" router", + "bash": "node scripts/bootstrap.mjs router", + "env": { "COPILOT_HOOK_EVENT": "PostToolUse" }, "timeoutSec": 30 } ] diff --git a/plugins/armorcopilot/scripts/hook-router.mjs b/plugins/armorcopilot/scripts/hook-router.mjs index 4619843..3d81b14 100644 --- a/plugins/armorcopilot/scripts/hook-router.mjs +++ b/plugins/armorcopilot/scripts/hook-router.mjs @@ -50,7 +50,41 @@ async function main() { } return; } - const event = typeof input.hook_event_name === "string" ? input.hook_event_name : ""; + // Normalize GitHub Copilot CLI's camelCase payload to the snake_case shape + // the engine expects (matches Claude Code's hook payload format). Copilot + // also doesn't include hook_event_name — it comes from COPILOT_HOOK_EVENT + // set in hooks.json per event. + if (typeof input.sessionId === "string" && !input.session_id) { + input.session_id = input.sessionId; + } + if (typeof input.toolName === "string" && !input.tool_name) { + input.tool_name = input.toolName; + } + if (input.toolArgs !== undefined && input.tool_input === undefined) { + // Copilot serializes tool args as a JSON STRING; parse to object. + if (typeof input.toolArgs === "string") { + try { + input.tool_input = JSON.parse(input.toolArgs); + } catch { + input.tool_input = input.toolArgs; + } + } else { + input.tool_input = input.toolArgs; + } + } + if (typeof input.toolResult !== "undefined" && typeof input.tool_response === "undefined") { + input.tool_response = input.toolResult; + } + if (typeof input.initialPrompt === "string" && !input.prompt) { + input.prompt = input.initialPrompt; + } + const event = + (typeof input.hook_event_name === "string" && input.hook_event_name) || + process.env.COPILOT_HOOK_EVENT || + ""; + if (event && !input.hook_event_name) { + input.hook_event_name = event; + } currentEvent = event; debugLog(config, `hook=${event}`); From 651bfb58c37612f99e9bf3b5caa7efbbefa4371c Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Mon, 25 May 2026 15:58:55 +0530 Subject: [PATCH 4/7] fix(hooks): use absolute path via \$CLAUDE_PLUGIN_ROOT (CWD-safe) The relative `bash: "node scripts/bootstrap.mjs router"` only worked when Copilot CLI happened to chdir to the plugin root before invoking the hook. From other working directories the hook crashed with: Error: Cannot find module '//scripts/bootstrap.mjs' Switched all 5 hook commands to expand \$CLAUDE_PLUGIN_ROOT (set by Copilot CLI for every plugin hook). Now the bash command is: node "\$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs" router This is the same pattern Claude Code uses and is what Copilot CLI expects for plugins shipping scripts under their own plugin root. Verified end-to-end: running `copilot` from /Users//Armoriq (any non-plugin CWD) no longer hits the MODULE_NOT_FOUND error. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/armorcopilot/hooks/hooks.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/armorcopilot/hooks/hooks.json b/plugins/armorcopilot/hooks/hooks.json index 533dba7..2097810 100644 --- a/plugins/armorcopilot/hooks/hooks.json +++ b/plugins/armorcopilot/hooks/hooks.json @@ -4,7 +4,7 @@ "sessionStart": [ { "type": "command", - "bash": "node scripts/bootstrap.mjs router", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", "env": { "COPILOT_HOOK_EVENT": "SessionStart" }, "timeoutSec": 30 } @@ -12,7 +12,7 @@ "userPromptSubmitted": [ { "type": "command", - "bash": "node scripts/bootstrap.mjs router", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", "env": { "COPILOT_HOOK_EVENT": "UserPromptSubmit" }, "timeoutSec": 30 } @@ -20,7 +20,7 @@ "preToolUse": [ { "type": "command", - "bash": "node scripts/bootstrap.mjs router", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", "env": { "COPILOT_HOOK_EVENT": "PreToolUse" }, "timeoutSec": 30 } @@ -28,7 +28,7 @@ "permissionRequest": [ { "type": "command", - "bash": "node scripts/bootstrap.mjs router", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", "env": { "COPILOT_HOOK_EVENT": "PermissionRequest" }, "timeoutSec": 30 } @@ -36,7 +36,7 @@ "postToolUse": [ { "type": "command", - "bash": "node scripts/bootstrap.mjs router", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", "env": { "COPILOT_HOOK_EVENT": "PostToolUse" }, "timeoutSec": 30 } From 6240f069349ed17278a194cd200ad7d142bdc5ec Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Tue, 26 May 2026 16:16:25 +0530 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20install=5Farmorcopilot.sh=20?= =?UTF-8?q?=E2=80=94=20mirrors=20armorCodex's=20curl-pipe=20installer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-user flow: curl -fsSL https://armoriq.ai/install_armorcopilot.sh | bash What it does (in order): 1. Checks prereqs: copilot CLI, node>=20, npm, git 2. Clones armoriq/armorCopilot to ~/.armoriq/armorCopilot 3. npm install --omit=dev for plugin runtime deps 4. npm install -g @armoriq/sdk-dev@latest (installs `armoriq-dev` CLI) 5. copilot plugin marketplace add armoriq/armorCopilot 6. copilot plugin install armorcopilot@armorcopilot 7. armoriq-dev login --product armorcopilot (device-code auth) Idempotent — re-running pulls latest, refreshes deps, reinstalls plugin. Flags: --uninstall remove plugin + marketplace registration --skip-login don't prompt for ArmorIQ login at the end Overrides (testing): ARMORCOPILOT_MARKETPLACE_REPO override marketplace source ARMORCOPILOT_GIT_URL override clone source ARMORCOPILOT_GIT_REF branch / tag (default main) ARMORCOPILOT_INSTALL_HOME where to clone Also commits the plugin's package-lock.json (generated by the local npm install during PoC testing) for reproducible installs. Co-Authored-By: Claude Opus 4.7 (1M context) --- install_armorcopilot.sh | 397 +++++++ plugins/armorcopilot/package-lock.json | 1354 ++++++++++++++++++++++++ 2 files changed, 1751 insertions(+) create mode 100755 install_armorcopilot.sh create mode 100644 plugins/armorcopilot/package-lock.json diff --git a/install_armorcopilot.sh b/install_armorcopilot.sh new file mode 100755 index 0000000..7566569 --- /dev/null +++ b/install_armorcopilot.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ArmorCopilot installer for GitHub Copilot CLI. +# +# Usage: +# curl -fsSL https://armoriq.ai/install_armorcopilot.sh | bash +# +# Works two ways: +# A. curl-pipe (no clone): fetches the plugin into ~/.armoriq/armorCopilot +# B. From an existing checkout: cd armorCopilot && bash install_armorcopilot.sh +# +# What it wires: +# 1. clones the plugin to ~/.armoriq/armorCopilot +# 2. npm install --omit=dev for plugin runtime deps +# 3. installs @armoriq/sdk-dev globally (for the `armoriq-dev` CLI) +# 4. registers the marketplace + installs the plugin in Copilot CLI: +# copilot plugin marketplace add armoriq/armorCopilot +# copilot plugin install armorcopilot@armorcopilot +# 5. runs `armoriq-dev login --product armorcopilot` for device-code auth +# +# Idempotent: re-running pulls the latest, reinstalls deps, refreshes marketplace. +# +# Flags: +# --uninstall remove the plugin + marketplace registration +# --skip-login don't prompt for ArmorIQ login at the end +# +# Non-interactive overrides: +# ARMORCOPILOT_MARKETPLACE_REPO override marketplace source (testing) +# ARMORCOPILOT_GIT_URL override fork source (testing) +# ARMORCOPILOT_GIT_REF branch / tag (default main) +# ARMORCOPILOT_INSTALL_HOME where to clone (default ~/.armoriq/armorCopilot) + +R=$'\033[1;31m' +G=$'\033[32m' +Y=$'\033[33m' +C=$'\033[38;2;0;229;204m' +M=$'\033[38;2;185;112;255m' +B=$'\033[1m' +D=$'\033[0;90m' +N=$'\033[0m' + +MARKETPLACE_REPO="${ARMORCOPILOT_MARKETPLACE_REPO:-armoriq/armorCopilot}" +MARKETPLACE_NAME="armorcopilot" +PLUGIN_NAME="armorcopilot" +PLUGIN_GIT_URL="${ARMORCOPILOT_GIT_URL:-https://github.com/armoriq/armorCopilot.git}" +PLUGIN_GIT_REF="${ARMORCOPILOT_GIT_REF:-main}" +INSTALL_HOME="${ARMORCOPILOT_INSTALL_HOME:-${HOME}/.armoriq/armorCopilot}" +DASHBOARD_URL="https://dev.armoriq.ai" + +# Recover if the caller is running this from a deleted directory (common when +# piping curl into bash from /tmp). +pwd >/dev/null 2>&1 || cd "${HOME:-/}" + +# If invoked via `bash <(curl ...)` BASH_SOURCE may not point at a real file. +SCRIPT_PATH="${BASH_SOURCE[0]:-}" +if [[ -n "${SCRIPT_PATH}" && -f "${SCRIPT_PATH}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)" +else + SCRIPT_DIR="" +fi + +PLUGIN_SUBDIR="plugins/armorcopilot" +if [[ -n "${SCRIPT_DIR}" && -f "${SCRIPT_DIR}/${PLUGIN_SUBDIR}/scripts/bootstrap.mjs" ]]; then + PLUGIN_ROOT="${SCRIPT_DIR}" +else + PLUGIN_ROOT="${INSTALL_HOME}" +fi +PLUGIN_PATH="${PLUGIN_ROOT}/${PLUGIN_SUBDIR}" +BOOTSTRAP_PATH="${PLUGIN_PATH}/scripts/bootstrap.mjs" + +DO_UNINSTALL=0 +SKIP_LOGIN=0 +for arg in "$@"; do + case "$arg" in + --uninstall) DO_UNINSTALL=1 ;; + --skip-login) SKIP_LOGIN=1 ;; + -h|--help) + sed -n '4,32p' "${SCRIPT_PATH:-$0}" 2>/dev/null || true + exit 0 + ;; + esac +done + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + +ok() { printf "${G}✔${N} %s\n" "$*"; } +warn() { printf "${Y}!${N} %s\n" "$*"; } +err() { printf "${R}✘${N} %s\n" "$*" 1>&2; } +info() { printf "${D}·${N} %s\n" "$*"; } +section() { printf "\n${B}${M}┃ %s${N}\n" "$*"; } + +banner() { + cat </dev/null 2>&1; then + err "missing required command: $1" + case "$1" in + copilot) echo " install GitHub Copilot CLI: curl -fsSL https://gh.io/copilot-install | bash" 1>&2 ;; + node) echo " install Node.js >= 20 from https://nodejs.org" 1>&2 ;; + git) echo " install git from https://git-scm.com/downloads" 1>&2 ;; + npm) echo " npm comes bundled with Node.js" 1>&2 ;; + esac + exit 1 + fi +} + +check_node_version() { + local raw major + raw="$(node --version 2>/dev/null || true)" + major="$(printf '%s' "${raw#v}" | cut -d. -f1)" + if [[ -z "${major}" || "${major}" -lt 20 ]]; then + err "Node.js >= 20 required (found ${raw:-none})" + exit 1 + fi +} + +is_promptable() { + [[ -e /dev/tty ]] || return 1 + (: < /dev/tty) 2>/dev/null || return 1 + return 0 +} + +prompt_yes_no() { + local question="$1" default="${2:-Y}" + local hint="(Y/n)" + [[ "$default" == "N" ]] && hint="(y/N)" + if ! is_promptable; then + [[ "$default" == "Y" ]]; return $? + fi + printf "${B}?${N} %s ${D}%s${N} " "$question" "$hint" >&2 + local answer + read -r answer < /dev/tty || answer="" + [[ -z "$answer" ]] && { [[ "$default" == "Y" ]]; return $?; } + [[ "$answer" =~ ^[Yy] ]] +} + +# --------------------------------------------------------------------------- +# Plugin source + Copilot CLI wiring +# --------------------------------------------------------------------------- + +fetch_plugin_source() { + if [[ -f "${BOOTSTRAP_PATH}" ]]; then + info "using existing checkout at ${PLUGIN_ROOT}" + return 0 + fi + + mkdir -p "$(dirname "${INSTALL_HOME}")" + + if [[ -d "${INSTALL_HOME}/.git" ]]; then + info "refreshing ${INSTALL_HOME} (git pull)" + git -C "${INSTALL_HOME}" fetch --quiet origin "${PLUGIN_GIT_REF}" >/dev/null + git -C "${INSTALL_HOME}" reset --hard --quiet "origin/${PLUGIN_GIT_REF}" >/dev/null + ok "updated to ${PLUGIN_GIT_REF}" + else + info "cloning ${PLUGIN_GIT_URL} into ${INSTALL_HOME}" + git clone --quiet --depth 1 --branch "${PLUGIN_GIT_REF}" "${PLUGIN_GIT_URL}" "${INSTALL_HOME}" + ok "cloned to ${INSTALL_HOME}" + fi + + PLUGIN_ROOT="${INSTALL_HOME}" + PLUGIN_PATH="${PLUGIN_ROOT}/${PLUGIN_SUBDIR}" + BOOTSTRAP_PATH="${PLUGIN_PATH}/scripts/bootstrap.mjs" + if [[ ! -f "${BOOTSTRAP_PATH}" ]]; then + err "fetched repo is missing ${PLUGIN_SUBDIR}/scripts/bootstrap.mjs" + exit 1 + fi +} + +install_npm_deps() { + pushd "${PLUGIN_PATH}" >/dev/null + if [[ -d node_modules/@armoriq/sdk-dev && -d node_modules/zod && -d node_modules/@modelcontextprotocol/sdk ]] \ + || [[ -d node_modules/@armoriq/sdk && -d node_modules/zod && -d node_modules/@modelcontextprotocol/sdk ]]; then + info "npm dependencies already present" + else + info "installing npm dependencies (--omit=dev)" + npm install --omit=dev --silent --no-audit --no-fund >/dev/null + ok "npm dependencies installed" + fi + popd >/dev/null +} + +install_armoriq_cli() { + info "installing ArmorIQ CLI ${B}(@armoriq/sdk-dev)${N}" + if npm install -g @armoriq/sdk-dev@latest --silent --no-audit --no-fund >/dev/null 2>&1; then + ok "armoriq-dev CLI ready" + else + warn "couldn't install globally, use ${B}npx @armoriq/sdk-dev${N} instead" + fi +} + +register_marketplace_and_install() { + # Marketplace add accepts owner/repo, URL, or a LOCAL PATH. + # Use the local checkout when available (works without network for the + # marketplace lookup) and otherwise fall back to the GitHub source. + local marketplace_source="${MARKETPLACE_REPO}" + if [[ -f "${PLUGIN_ROOT}/.claude-plugin/marketplace.json" ]]; then + marketplace_source="${PLUGIN_ROOT}" + fi + + info "registering marketplace ${marketplace_source}" + if copilot plugin marketplace add "${marketplace_source}" >/dev/null 2>&1; then + ok "marketplace registered" + else + info "marketplace add skipped (already added)" + fi + + info "installing plugin ${PLUGIN_NAME}@${MARKETPLACE_NAME}" + if copilot plugin install "${PLUGIN_NAME}@${MARKETPLACE_NAME}" >/dev/null 2>&1; then + ok "plugin installed" + else + # On re-run the plugin may already be installed; refresh by uninstall/reinstall. + copilot plugin uninstall "${PLUGIN_NAME}" >/dev/null 2>&1 || true + if copilot plugin install "${PLUGIN_NAME}@${MARKETPLACE_NAME}" >/dev/null 2>&1; then + ok "plugin reinstalled (refreshed)" + else + err "failed to install plugin — try: copilot plugin install ${PLUGIN_NAME}@${MARKETPLACE_NAME}" + exit 1 + fi + fi +} + +verify_install() { + section "Verifying" + local issues=0 + if [[ ! -f "${BOOTSTRAP_PATH}" ]]; then + warn "bootstrap.mjs missing at ${BOOTSTRAP_PATH}" + issues=$((issues+1)) + fi + if ! copilot plugin list 2>&1 | grep -q "${PLUGIN_NAME}@${MARKETPLACE_NAME}"; then + warn "plugin not visible in 'copilot plugin list'" + issues=$((issues+1)) + fi + if [[ "${issues}" -eq 0 ]]; then + ok "armorcopilot is wired up correctly" + else + warn "${issues} verification check(s) failed, see warnings above" + fi +} + +connect_to_armoriq() { + [[ "${SKIP_LOGIN}" -eq 1 ]] && return 0 + + section "Connect to ArmorIQ" + cat </dev/null 2>&1; then + if armoriq-dev login --help 2>&1 | grep -q -- '--product'; then + armoriq-dev login --product "${product}" + else + ARMORIQ_PRODUCT="${product}" armoriq-dev login + fi + elif command -v armoriq >/dev/null 2>&1; then + if armoriq login --help 2>&1 | grep -q -- '--product'; then + armoriq login --product "${product}" + else + ARMORIQ_PRODUCT="${product}" armoriq login + fi + elif command -v npx >/dev/null 2>&1; then + if npx @armoriq/sdk-dev login --help 2>&1 | grep -q -- '--product'; then + npx @armoriq/sdk-dev login --product "${product}" + else + ARMORIQ_PRODUCT="${product}" npx @armoriq/sdk-dev login + fi + else + warn "armoriq CLI not found. Run ${B}npx @armoriq/sdk-dev login${N} manually." + return 0 + fi + + local login_status=$? + if [[ $login_status -eq 0 ]] && [[ -f "$HOME/.armoriq/credentials.json" ]]; then + echo + ok "ArmorIQ connected. Copilot will auto-load the key." + fi +} + +finale() { + echo + printf "${G}${B}ArmorCopilot is installed.${N}\n" + + section "Quick start" + cat < read README.md${N} + ${D}> add a line "this is working" to README.md${N} + + Add policy rules from any prompt (natural language or "Policy ..."): + + ${D}> Policy new: deny webfetch${N} + ${D}> update the policy to not access ~/photos${N} + +EOF + + section "Manage anytime" + cat </dev/null || echo install_armorcopilot.sh) --uninstall${N} + + Plugin: ${C}${PLUGIN_PATH}${N} + Copilot list: ${G}copilot plugin list${N} + Docs: ${C}https://github.com/armoriq/armorCopilot${N} + +EOF +} + +uninstall() { + section "Uninstalling ArmorCopilot" + if copilot plugin uninstall "${PLUGIN_NAME}" >/dev/null 2>&1; then + ok "plugin uninstalled" + else + info "plugin not installed (or already removed)" + fi + if copilot plugin marketplace remove "${MARKETPLACE_NAME}" >/dev/null 2>&1; then + ok "marketplace removed" + else + info "marketplace not registered (or already removed)" + fi + info "Plugin source at ${INSTALL_HOME} left in place. Remove with: rm -rf ${INSTALL_HOME}" +} + +main() { + if [[ "${DO_UNINSTALL}" -eq 1 ]]; then + uninstall + exit 0 + fi + + banner + + section "Checking prerequisites" + require_cmd copilot + require_cmd node + require_cmd npm + require_cmd git + check_node_version + ok "prerequisites OK ($(copilot --version 2>/dev/null | head -1), $(node --version))" + + section "Fetching plugin source" + fetch_plugin_source + + section "Installing dependencies" + install_npm_deps + install_armoriq_cli + + section "Registering Copilot CLI plugin" + register_marketplace_and_install + + verify_install + connect_to_armoriq + finale +} + +main "$@" diff --git a/plugins/armorcopilot/package-lock.json b/plugins/armorcopilot/package-lock.json new file mode 100644 index 0000000..b6adda0 --- /dev/null +++ b/plugins/armorcopilot/package-lock.json @@ -0,0 +1,1354 @@ +{ + "name": "@armoriq/armorcopilot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@armoriq/armorcopilot", + "version": "0.1.0", + "dependencies": { + "@armoriq/sdk": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^3.25.76" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@armoriq/sdk": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@armoriq/sdk/-/sdk-0.3.3.tgz", + "integrity": "sha512-OSBY09wPAHU/8ZESkjC8cUCthbkKId2nSXUVrD2WpZYglpXRxOEnoxS//3CMeVWanXr5nupwsqih5IdgXnfgDQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.7.0", + "js-yaml": "^4.1.1" + }, + "bin": { + "armoriq": "dist/cli/index.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} From cebedf4ab7eb147bcd89954e6a427fad7b8ddf78 Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Thu, 28 May 2026 18:14:24 +0530 Subject: [PATCH 6/7] fix(installer): banner spells ARMORCOPILOT (was inherited ARMORCODEX art) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ASCII art block in install_armorcopilot.sh was copy-pasted from install_armorcodex.sh and never updated, so the banner that flashed during install said ARMORCODEX instead of ARMORCOPILOT. Replaced with ANSI Shadow font output for the correct 'ARMORCOPILOT' wordmark. Verified by running the installer locally — banner now reads correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- install_armorcopilot.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/install_armorcopilot.sh b/install_armorcopilot.sh index 7566569..76c56a8 100755 --- a/install_armorcopilot.sh +++ b/install_armorcopilot.sh @@ -95,12 +95,12 @@ section() { printf "\n${B}${M}┃ %s${N}\n" "$*"; } banner() { cat < Date: Thu, 28 May 2026 18:22:22 +0530 Subject: [PATCH 7/7] fix: address Copilot code review findings on PR #3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review-driven fixes, in priority order: 1. config.mjs — endpoint resolution is now a 3-way switch on envMode (production/staging/local) instead of a confusing 2-way ternary that mapped useProduction → staging-api. Production users now correctly resolve to api.armoriq.ai unless overridden. 2. iap-service.mjs verify-step — fail closed on non-2xx. Previously a non-2xx response with a JSON body skipped the throw and fell through to defaulting `allowed=true`. Now any non-ok response throws. 3. engine.mjs safe-tools whitelist — dropped `websearch` and `webfetch` from the early-exit list. Network egress tools must go through full policy + intent enforcement; they were previously bypassing all guardrails. 4. audit-wal.mjs readBatch — capped per-read size at 4MiB to prevent OOM if the backend is down and the WAL grows large. Plenty of room for `maxRows=100` audit rows (40KB worst case each). 5. policy-mcp.mjs register_intent_plan + intent-schema.mjs — added optional `session_id` field to the plan. When the caller passes it, the pending plan is written to `pending-plan..json` (per-session, matches what engine.mjs already reads). We also mirror to the legacy global path so older readers still work. Eliminates concurrent-session clobbering. 6. hooks/hooks.json — added the 3 missing registrations (postToolUseFailure, agentStop/Stop, sessionEnd). Engine had handlers for all of these but Copilot CLI never invoked them because they weren't registered. Failed tool executions and session cleanup now route through ArmorCopilot. 7. README.md — corrected the "synchronous audit log" claim to reflect the actual WAL-backed async batched pipeline, and updated the install command to show the marketplace add + install flow (plus the curl-pipe installer alternative). Refs Copilot review on PR #3. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/armorcopilot/README.md | 15 +++++++-- plugins/armorcopilot/hooks/hooks.json | 24 ++++++++++++++ .../armorcopilot/scripts/lib/audit-wal.mjs | 7 +++- plugins/armorcopilot/scripts/lib/config.mjs | 30 ++++++++++------- plugins/armorcopilot/scripts/lib/engine.mjs | 8 +++-- .../armorcopilot/scripts/lib/iap-service.mjs | 9 +++-- .../scripts/lib/intent-schema.mjs | 8 ++++- plugins/armorcopilot/scripts/policy-mcp.mjs | 33 +++++++++++++++---- 8 files changed, 106 insertions(+), 28 deletions(-) diff --git a/plugins/armorcopilot/README.md b/plugins/armorcopilot/README.md index 8ba3c73..b033c9d 100644 --- a/plugins/armorcopilot/README.md +++ b/plugins/armorcopilot/README.md @@ -9,15 +9,24 @@ Intent-based security policy + audit for GitHub Copilot CLI. Ports the same enfo - Verifies every tool call against the registered plan — out-of-plan tools are blocked even if policy would allow them - Lets you set policies in natural language ("Block any commands that fetch URLs") via the `policy_update` MCP tool - Optional CSRG cryptographic proofs for tamper detection -- Synchronous audit log to ArmorIQ backend +- Async batched audit pipeline: each tool call is enqueued to a local write-ahead log (durable on disk), then shipped in batches to the ArmorIQ backend by a background flusher inside the MCP server. Durable enqueue, async ship. ## Install +The plugin manifest lives at `plugins/armorcopilot/.claude-plugin/plugin.json` inside the repo. Install via the marketplace flow: + ```bash -copilot plugin install armoriq/armorCopilot +copilot plugin marketplace add armoriq/armorCopilot +copilot plugin install armorcopilot@armorcopilot ``` -The plugin runtime auto-discovers `.claude-plugin/plugin.json` and registers hooks + MCP servers. +The repo's root `.claude-plugin/marketplace.json` declares the plugin source so the marketplace install resolves to the right subdirectory automatically. + +Or use the curl-pipe installer that handles the full wiring (plugin + npm deps + `armoriq-dev` CLI + device-code login): + +```bash +curl -fsSL https://armoriq.ai/install_armorcopilot.sh | bash +``` ## Configure diff --git a/plugins/armorcopilot/hooks/hooks.json b/plugins/armorcopilot/hooks/hooks.json index 2097810..fa936d5 100644 --- a/plugins/armorcopilot/hooks/hooks.json +++ b/plugins/armorcopilot/hooks/hooks.json @@ -40,6 +40,30 @@ "env": { "COPILOT_HOOK_EVENT": "PostToolUse" }, "timeoutSec": 30 } + ], + "postToolUseFailure": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "PostToolUseFailure" }, + "timeoutSec": 30 + } + ], + "agentStop": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "Stop" }, + "timeoutSec": 30 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "SessionEnd" }, + "timeoutSec": 30 + } ] } } diff --git a/plugins/armorcopilot/scripts/lib/audit-wal.mjs b/plugins/armorcopilot/scripts/lib/audit-wal.mjs index 142c79a..dc98ffb 100644 --- a/plugins/armorcopilot/scripts/lib/audit-wal.mjs +++ b/plugins/armorcopilot/scripts/lib/audit-wal.mjs @@ -110,6 +110,11 @@ export function createAuditWal(opts) { * Skips malformed lines (logs to stderr) so a single bad row can't * permanently block the stream. */ + // Cap each read to bound memory if the backend is down + the WAL grows large. + // 4 MiB is enough to comfortably parse `maxRows=100` audit rows (~40KB each + // worst case) while preventing OOM if a partial-line tail keeps growing. + const MAX_READ_BYTES = 4 * 1024 * 1024; + async function readBatch(maxRows = 100) { await ensureDirs(); if (!existsSync(currentPath)) return { rows: [], endOffset: 0 }; @@ -119,7 +124,7 @@ export function createAuditWal(opts) { try { const st = await fh.stat(); if (offset >= st.size) return { rows: [], endOffset: offset }; - const length = st.size - offset; + const length = Math.min(st.size - offset, MAX_READ_BYTES); const buf = Buffer.alloc(length); await fh.read(buf, 0, length, offset); diff --git a/plugins/armorcopilot/scripts/lib/config.mjs b/plugins/armorcopilot/scripts/lib/config.mjs index 5198b0a..0473181 100644 --- a/plugins/armorcopilot/scripts/lib/config.mjs +++ b/plugins/armorcopilot/scripts/lib/config.mjs @@ -39,29 +39,37 @@ export function loadConfig(env = process.env) { const timeoutMs = parseInteger(env.ARMORCOPILOT_TIMEOUT_MS, 8000); - // dev branch — points at staging-api for pre-release testing. - // main branch keeps "https://api.armoriq.ai" (prod). When promoting a - // feature from dev → main, resolve the URL conflict in favor of main. + // 3-way endpoint resolution by envMode (production / staging / local). + // Env var overrides always win; otherwise pick by envMode. + // Previously this was a 2-way ternary that confusingly mapped useProduction + // → staging-api. Reviewers flagged the inversion — fixed by switching on + // envMode explicitly. const backendEndpoint = env.ARMORCOPILOT_BACKEND_ENDPOINT?.trim() || env.BACKEND_ENDPOINT?.trim() || - (useProduction - ? "https://staging-api.armoriq.ai" - : "http://127.0.0.1:3000"); + (envMode === "production" + ? "https://api.armoriq.ai" + : envMode === "staging" + ? "https://staging-api.armoriq.ai" + : "http://127.0.0.1:3000"); const iapEndpoint = env.ARMORCOPILOT_IAP_ENDPOINT?.trim() || env.IAP_ENDPOINT?.trim() || - (useProduction + (envMode === "production" ? "https://iap.armoriq.ai" - : "http://127.0.0.1:8000"); + : envMode === "staging" + ? "https://iap-staging.armoriq.ai" + : "http://127.0.0.1:8000"); const proxyEndpoint = env.ARMORCOPILOT_PROXY_ENDPOINT?.trim() || env.PROXY_ENDPOINT?.trim() || - (useProduction - ? "https://cloud-run-proxy.armoriq.io" - : "http://127.0.0.1:3001"); + (envMode === "production" + ? "https://proxy.armoriq.ai" + : envMode === "staging" + ? "https://cloud-run-proxy.armoriq.io" + : "http://127.0.0.1:3001"); const csrgEndpoint = pluginOpt(env, "CSRG_ENDPOINT", "CSRG_URL") || iapEndpoint; diff --git a/plugins/armorcopilot/scripts/lib/engine.mjs b/plugins/armorcopilot/scripts/lib/engine.mjs index 3302246..a8ac77a 100644 --- a/plugins/armorcopilot/scripts/lib/engine.mjs +++ b/plugins/armorcopilot/scripts/lib/engine.mjs @@ -261,6 +261,10 @@ export async function handlePreToolUse(input, config) { // no side effects on user files or systems. Blocking these makes the // agent fight itself (e.g. ToolSearch is needed to fetch deferred MCP // tool schemas before they can be called). --- + // Read-only / coordination tools that bypass full enforcement to keep the + // hot path fast. Strictly local-only — anything that performs network + // egress (websearch, webfetch) MUST go through policy + intent enforcement + // and cannot be on this list. const safeInternalTools = new Set([ "toolsearch", "todowrite", @@ -268,9 +272,7 @@ export async function handlePreToolUse(input, config) { "readmcpresourcetool", "read", "grep", - "glob", - "websearch", - "webfetch" + "glob" ]); if (safeInternalTools.has(norm)) { return null; diff --git a/plugins/armorcopilot/scripts/lib/iap-service.mjs b/plugins/armorcopilot/scripts/lib/iap-service.mjs index 9b6d53c..be8692a 100644 --- a/plugins/armorcopilot/scripts/lib/iap-service.mjs +++ b/plugins/armorcopilot/scripts/lib/iap-service.mjs @@ -79,9 +79,14 @@ export function createIapService(config) { } const response = await postJson(endpoint, payload, headers, timeoutMs); - if (!response.ok && !isPlainObject(response.data)) { + // Fail-closed on non-2xx: a JSON body with a partial payload must not + // silently allow downstream code to treat `data.allowed` as true by + // default. The only acceptable success signal is response.ok. + if (!response.ok) { throw new Error( - response.text || `IAP verify-step failed with status ${response.status}` + (isPlainObject(response.data) && response.data.message) || + response.text || + `IAP verify-step failed with status ${response.status}` ); } diff --git a/plugins/armorcopilot/scripts/lib/intent-schema.mjs b/plugins/armorcopilot/scripts/lib/intent-schema.mjs index d1ef8d2..6602ce0 100644 --- a/plugins/armorcopilot/scripts/lib/intent-schema.mjs +++ b/plugins/armorcopilot/scripts/lib/intent-schema.mjs @@ -27,7 +27,13 @@ export const INTENT_PLAN_ZOD = z.object({ steps: z .array(PLAN_STEP_SCHEMA) .min(1) - .describe("Ordered list of tool calls the agent intends to make") + .describe("Ordered list of tool calls the agent intends to make"), + session_id: z + .string() + .optional() + .describe( + "Optional — pass the current Copilot session_id so the pending plan is scoped per-session and concurrent sessions don't clobber each other. If omitted, falls back to a shared global pending-plan.json file." + ) }); /** diff --git a/plugins/armorcopilot/scripts/policy-mcp.mjs b/plugins/armorcopilot/scripts/policy-mcp.mjs index 38af477..cfdee64 100644 --- a/plugins/armorcopilot/scripts/policy-mcp.mjs +++ b/plugins/armorcopilot/scripts/policy-mcp.mjs @@ -208,24 +208,43 @@ async function run() { const config = loadConfig(); const plan = normalizeIntentPlan(parsed.data); - // Write the local plan to pending-plan.json IMMEDIATELY so PreToolUse - // has something to enforce against. The SDK call (if any) runs entirely - // in the background and updates pending-plan.json with the signed - // token when it resolves. + // Write the local plan to pending-plan..json IMMEDIATELY + // (or pending-plan.json as a legacy fallback) so PreToolUse has + // something to enforce against. The SDK call (if any) runs entirely + // in the background and updates the same file with the signed token + // when it resolves. + // + // Why per-session: concurrent Copilot sessions would otherwise share + // a single pending-plan.json and clobber each other's plans/tokens. + // PreToolUse in engine.mjs already prefers the session-scoped path + // and falls back to global, so we mirror writes to both paths for + // both pre- and post-fix installs. // // Why fire-and-forget: Copilot's MCP transport closes its stdio pipe // around the ~1s mark. Any await we do here (loadPolicyState, the // SDK round-trip, even cold-start latency) eats into that budget. // Awaiting nothing on the network path keeps the MCP response under // ~100ms regardless of backend conditions. - const pendingPath = path.join(config.dataDir, "pending-plan.json"); - await writeJson(pendingPath, { + const sessionId = typeof parsed.data.session_id === "string" + ? parsed.data.session_id + : ""; + const globalPath = path.join(config.dataDir, "pending-plan.json"); + const sessionPath = sessionId + ? path.join(config.dataDir, `pending-plan.${sessionId}.json`) + : null; + const pendingPath = sessionPath || globalPath; + const pendingPayload = { plan, tokenRaw: "", allowedActions: Array.from(extractAllowedActions(plan)), expiresAt: undefined, registeredAt: Date.now() - }); + }; + await writeJson(pendingPath, pendingPayload); + if (sessionPath) { + // Mirror to the legacy global path so older readers still work. + await writeJson(globalPath, pendingPayload); + } let backendWillIssue = false; if (config.intentEndpoint || (config.useSdkIntent && config.apiKey)) {