mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-06-13 20:03:35 +00:00
Compare commits
17 Commits
fix-xss-ru
...
fix-2099-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecf3d1bac | ||
|
|
c40770ae5a | ||
|
|
7a0a7f486e | ||
|
|
42487ee6fd | ||
|
|
bc07f7a1fd | ||
|
|
9e150cfd48 | ||
|
|
38b298d5b2 | ||
|
|
8435428dfc | ||
|
|
0d22ba3501 | ||
|
|
37ffc76005 | ||
|
|
982070e51f | ||
|
|
68a700837c | ||
|
|
5212308979 | ||
|
|
3d349d40b9 | ||
|
|
6a63e35e75 | ||
|
|
04127de5d1 | ||
|
|
a67587c816 |
@@ -19,7 +19,7 @@
|
||||
"url": "https://github.com/42Crunch-AI/claude-plugins.git",
|
||||
"path": "plugins/api-security-testing",
|
||||
"ref": "v1.5.5",
|
||||
"sha": "5c8074d846b852c21da23bbf6effbfdabb18ba2d"
|
||||
"sha": "b404d99a3f0bc1f3e74a1638671e2e3319187e2c"
|
||||
},
|
||||
"homepage": "https://42crunch.com"
|
||||
},
|
||||
@@ -35,7 +35,7 @@
|
||||
"url": "https://github.com/adobe/skills.git",
|
||||
"path": "plugins/creative-cloud/adobe-for-creativity",
|
||||
"ref": "main",
|
||||
"sha": "ecd1e2b2c493ba0627774f36a897bd44d47fef1d"
|
||||
"sha": "0a015c06894332091b79e055e0404fbc1a18c9fe"
|
||||
},
|
||||
"homepage": "https://github.com/adobe/skills/tree/main/plugins/creative-cloud/adobe-for-creativity"
|
||||
},
|
||||
@@ -57,7 +57,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/SalesforceAIResearch/agentforce-adlc.git",
|
||||
"sha": "5ddccc36737b8bdc3dcabb3d6f51daa350c3d16d"
|
||||
"sha": "1584dd52f388482db78949456addfa29a4c9d9c3"
|
||||
},
|
||||
"homepage": "https://github.com/SalesforceAIResearch/agentforce-adlc"
|
||||
},
|
||||
@@ -120,7 +120,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/amazon-location-service",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -193,7 +193,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/astronomer/agents.git",
|
||||
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
|
||||
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
|
||||
},
|
||||
"homepage": "https://github.com/astronomer/agents"
|
||||
},
|
||||
@@ -203,7 +203,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/atlanhq/agent-toolkit.git",
|
||||
"sha": "790398c87378f128bdc74c31bb7ecfb8e4695f29"
|
||||
"sha": "b0efcc8e6adc64d052b634ac1103932390413fd9"
|
||||
},
|
||||
"homepage": "https://docs.atlan.com/"
|
||||
},
|
||||
@@ -226,7 +226,7 @@
|
||||
"source": "url",
|
||||
"url": "https://github.com/BrainBlend-AI/atomic-agents.git",
|
||||
"path": "claude-plugin/atomic-agents",
|
||||
"sha": "c4e905c49884747be65e7ed42ccfb118c67f57ac"
|
||||
"sha": "bb9708ec7c4c7145bd64033dbece0bfaed0c2ad5"
|
||||
},
|
||||
"homepage": "https://github.com/BrainBlend-AI/atomic-agents",
|
||||
"tags": [
|
||||
@@ -245,7 +245,7 @@
|
||||
"url": "https://github.com/auth0/agent-skills.git",
|
||||
"path": "plugins/auth0",
|
||||
"ref": "main",
|
||||
"sha": "c771dc1c77bfd5a67686afb464ccebd227c02b0f"
|
||||
"sha": "c38453f6a99bbfeaf73b5be81db987ec6af982da"
|
||||
},
|
||||
"homepage": "https://auth0.com/docs/quickstart/agent-skills"
|
||||
},
|
||||
@@ -274,7 +274,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/aws-amplify",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -335,7 +335,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/aws-serverless",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -346,7 +346,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/microsoft/azure-skills.git",
|
||||
"sha": "d02fd24f151f5133650eaa78e7da3cac2cedd72f"
|
||||
"sha": "7cb89c221ecc9eccb71580aaff3695408cdeef2b"
|
||||
},
|
||||
"homepage": "https://github.com/microsoft/azure-skills"
|
||||
},
|
||||
@@ -412,7 +412,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/brightdata/skills.git",
|
||||
"sha": "071e9d4db77c8561e333799f25ea85f11f7b667d"
|
||||
"sha": "da73549126e5834a9230ee5532d4917d43aedf11"
|
||||
},
|
||||
"homepage": "https://docs.brightdata.com"
|
||||
},
|
||||
@@ -442,7 +442,7 @@
|
||||
"url": "https://github.com/carta/plugins.git",
|
||||
"path": "plugins/carta-cap-table",
|
||||
"ref": "main",
|
||||
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
|
||||
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
|
||||
},
|
||||
"homepage": "https://carta.com"
|
||||
},
|
||||
@@ -458,7 +458,7 @@
|
||||
"url": "https://github.com/carta/plugins.git",
|
||||
"path": "plugins/carta-crm",
|
||||
"ref": "main",
|
||||
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
|
||||
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
|
||||
},
|
||||
"homepage": "https://carta.com"
|
||||
},
|
||||
@@ -474,7 +474,7 @@
|
||||
"url": "https://github.com/carta/plugins.git",
|
||||
"path": "plugins/carta-investors",
|
||||
"ref": "main",
|
||||
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
|
||||
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
|
||||
},
|
||||
"homepage": "https://carta.com"
|
||||
},
|
||||
@@ -501,7 +501,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/ChromeDevTools/chrome-devtools-mcp.git",
|
||||
"sha": "60be3e6bc157bd1121ea1d4b6ad59e37a73cac3e"
|
||||
"sha": "2e039c09e1a273581d9b51081a0feb8a57791947"
|
||||
},
|
||||
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp"
|
||||
},
|
||||
@@ -716,7 +716,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/CodSpeedHQ/codspeed.git",
|
||||
"sha": "ecf3c2ebf959479126d631ad39d317738d559388"
|
||||
"sha": "407dd3c930b8dc5e5655a2d91a65d88f01829955"
|
||||
},
|
||||
"homepage": "https://codspeed.io"
|
||||
},
|
||||
@@ -753,7 +753,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/get-convex/convex-backend-skill.git",
|
||||
"sha": "5e59870cda2a5892e18a7164d1a46fcf57b70bea"
|
||||
"sha": "ece93250d560f0ce32a24223dea92b33050b2a66"
|
||||
},
|
||||
"homepage": "https://github.com/get-convex/convex-backend-skill",
|
||||
"keywords": [
|
||||
@@ -784,7 +784,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/CrowdStrike/foundry-skills.git",
|
||||
"sha": "99edea095f4e32ed008706b55257d0893fb93387"
|
||||
"sha": "fb25d60ecdbc0129071802dad210a65168ca55a9"
|
||||
},
|
||||
"homepage": "https://github.com/CrowdStrike/foundry-skills"
|
||||
},
|
||||
@@ -830,7 +830,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/dash0hq/dash0-agent-plugin.git",
|
||||
"sha": "2909be7ebc2804af464e0d7f660ccc2b62d94623"
|
||||
"sha": "d1ad56f86f2a9ae74eccf1df2bb2985c963005b1"
|
||||
},
|
||||
"homepage": "https://dash0.com/"
|
||||
},
|
||||
@@ -841,7 +841,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/astronomer/agents.git",
|
||||
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
|
||||
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
|
||||
},
|
||||
"homepage": "https://github.com/astronomer/agents"
|
||||
},
|
||||
@@ -855,7 +855,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/gemini-cli-extensions/data-agent-kit-starter-pack.git",
|
||||
"sha": "7bc75b5e53d6eaae103132fd1a47de26239e4ae4"
|
||||
"sha": "86eb482b33d943aa4242ae6f06d627ec12064d46"
|
||||
},
|
||||
"homepage": "https://github.com/gemini-cli-extensions/data-agent-kit-starter-pack"
|
||||
},
|
||||
@@ -865,7 +865,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/astronomer/agents.git",
|
||||
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
|
||||
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
|
||||
},
|
||||
"homepage": "https://github.com/astronomer/agents"
|
||||
},
|
||||
@@ -878,7 +878,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/databases-on-aws",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -920,7 +920,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/datarobot-oss/datarobot-agent-skills.git",
|
||||
"sha": "8124faae2154117382b1046aa74d8901a3ffe930"
|
||||
"sha": "4c3dfbd259bc2c6c815f7575d27ca26bc09d0d17"
|
||||
},
|
||||
"homepage": "https://datarobot.com"
|
||||
},
|
||||
@@ -946,7 +946,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/deploy-on-aws",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -1048,7 +1048,7 @@
|
||||
"url": "https://github.com/expo/skills.git",
|
||||
"path": "plugins/expo",
|
||||
"ref": "main",
|
||||
"sha": "510373b50956ef4dc84c20bb4c9cce70b618aa06"
|
||||
"sha": "fdd3df12151a208853fe540ffea9a67773446377"
|
||||
},
|
||||
"homepage": "https://github.com/expo/skills/blob/main/plugins/expo/README.md"
|
||||
},
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/firecrawl/firecrawl-claude-plugin.git",
|
||||
"sha": "01d11b30ace699a27f9ea7decf6ce6c9857f71ff"
|
||||
"sha": "e71cec486062680f0c8f8823afcb3558ad81ce60"
|
||||
},
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-claude-plugin.git"
|
||||
},
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/huggingface/skills.git",
|
||||
"sha": "7a493b09c81aae09a41bd2e1fa33dfc0f68acd75"
|
||||
"sha": "df627be1837523c91ac6df472e3dc543d3107bd9"
|
||||
},
|
||||
"homepage": "https://github.com/huggingface/skills.git"
|
||||
},
|
||||
@@ -1231,7 +1231,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/hunter-io/claude-plugin.git",
|
||||
"sha": "c67942395cde155e9ad4ed8e3a137926f9992fb8"
|
||||
"sha": "9b6146520c48f9dcc6092f106e5c1a5762ca3e7a"
|
||||
},
|
||||
"homepage": "https://hunter.io"
|
||||
},
|
||||
@@ -1245,7 +1245,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/heygen-com/hyperframes.git",
|
||||
"sha": "7ea4d1c1314bd60d5273efa92626bd1d0f9c621d"
|
||||
"sha": "bc3701f5905c5ba7c8cf03c3bbe3a49162d2b1f1"
|
||||
},
|
||||
"homepage": "https://hyperframes.heygen.com"
|
||||
},
|
||||
@@ -1410,7 +1410,7 @@
|
||||
"url": "https://github.com/pydantic/skills.git",
|
||||
"path": "plugins/logfire",
|
||||
"ref": "main",
|
||||
"sha": "0c38c5bb5679f6cc41956bbbf811396a0d108ac9"
|
||||
"sha": "eb17c0da94de81488825c0198475233dc1f06393"
|
||||
},
|
||||
"homepage": "https://github.com/pydantic/skills/tree/main/plugins/logfire"
|
||||
},
|
||||
@@ -1523,7 +1523,7 @@
|
||||
"url": "https://github.com/mercadopago/mercadopago-claude-marketplace.git",
|
||||
"path": "plugins/mercadopago",
|
||||
"ref": "main",
|
||||
"sha": "f52c138924d8035b39e8fe02d41c6712fc41ceb4"
|
||||
"sha": "ba967158392bec9f0c199cd39196af64222f0ab0"
|
||||
},
|
||||
"homepage": "https://github.com/mercadopago/mercadopago-claude-marketplace/tree/main/plugins/mercadopago"
|
||||
},
|
||||
@@ -1638,7 +1638,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/Nimbleway/agent-skills.git",
|
||||
"sha": "95ed06468957ddc9de609b25c390b30c3864eac8"
|
||||
"sha": "9736dfc757f5ed4f05da0480b202af09e93a27de"
|
||||
},
|
||||
"homepage": "https://docs.nimbleway.com/integrations/agent-skills/plugin-installation"
|
||||
},
|
||||
@@ -1665,7 +1665,7 @@
|
||||
"url": "https://github.com/oracle-samples/oracle-aidp-samples.git",
|
||||
"path": "ai/claude-code-plugins/oracle-ai-data-platform-workbench-spark-connectors",
|
||||
"ref": "main",
|
||||
"sha": "f7ea9cae6fce69a4e3798dfc1d5216ac1d0dd7e8"
|
||||
"sha": "6e59f24cd3e8870649e7f9b2e3e106502b43fd5f"
|
||||
},
|
||||
"homepage": "https://docs.oracle.com/en/cloud/paas/ai-data-platform/index.html"
|
||||
},
|
||||
@@ -1681,7 +1681,7 @@
|
||||
"url": "https://github.com/growthxai/output.git",
|
||||
"path": "coding_assistants/claude/plugins/outputai",
|
||||
"ref": "main",
|
||||
"sha": "93dd22ee568a97911a332b5aa0d9cebb2b6f7da1"
|
||||
"sha": "0eeffece25b6f471c48b705a214471164b8c5946"
|
||||
},
|
||||
"homepage": "https://output.ai"
|
||||
},
|
||||
@@ -1846,7 +1846,7 @@
|
||||
"url": "https://github.com/pydantic/skills.git",
|
||||
"path": "plugins/ai",
|
||||
"ref": "main",
|
||||
"sha": "0c38c5bb5679f6cc41956bbbf811396a0d108ac9"
|
||||
"sha": "eb17c0da94de81488825c0198475233dc1f06393"
|
||||
},
|
||||
"homepage": "https://github.com/pydantic/skills/tree/main/plugins/ai"
|
||||
},
|
||||
@@ -1884,7 +1884,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/qdrant/skills.git",
|
||||
"sha": "1390c811e03922b822dc9e12b832ba4dc82e0bf0"
|
||||
"sha": "ea62a9857dabcc169597549da7681bd6d4cd13e9"
|
||||
},
|
||||
"homepage": "https://skills.qdrant.tech"
|
||||
},
|
||||
@@ -1895,7 +1895,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/qodo-ai/qodo-skills.git",
|
||||
"sha": "b1eb0389480ee6de8df874f40a230ed2625ef0d3"
|
||||
"sha": "8aec13d6ac60feb9d9f84f36aa1753234de17dc8"
|
||||
},
|
||||
"homepage": "https://github.com/qodo-ai/qodo-skills.git"
|
||||
},
|
||||
@@ -1909,7 +1909,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/TheQtCompanyRnD/agent-skills.git",
|
||||
"sha": "23772fa2264b3ff1037a96164b2c28d2b29a4c2f"
|
||||
"sha": "a7189a7bc17e616b725e7ce4e46a4f5ebd50d94f"
|
||||
},
|
||||
"homepage": "https://www.qt.io/"
|
||||
},
|
||||
@@ -1923,7 +1923,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/quarkusio/quarkus-agent-mcp.git",
|
||||
"sha": "77fd36284a80b3ed1bde3d2fe48a0b2f99e4941e"
|
||||
"sha": "32cad78bd9040efe31794cfc10f70caf2a724dd9"
|
||||
},
|
||||
"homepage": "https://quarkus.io"
|
||||
},
|
||||
@@ -1975,7 +1975,7 @@
|
||||
"url": "https://github.com/redis/agent-skills.git",
|
||||
"path": "plugins/redis-development",
|
||||
"ref": "main",
|
||||
"sha": "18da4e42371f7eee0dcfafd8461effd41de351e9"
|
||||
"sha": "5ca2e1a2d82a768221e8f71a02e3ca095a37d38e"
|
||||
},
|
||||
"homepage": "https://redis.io"
|
||||
},
|
||||
@@ -1985,7 +1985,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/Digital-Process-Tools/claude-remember.git",
|
||||
"sha": "c9b34417a8132f0416411a0ca51d009a256a3acc"
|
||||
"sha": "c2c82ab5fd2f4f5c0cddc9c7d8a749655dec4cb9"
|
||||
},
|
||||
"homepage": "https://github.com/Digital-Process-Tools/claude-remember"
|
||||
},
|
||||
@@ -1999,7 +1999,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/resend/resend-skills.git",
|
||||
"sha": "78469829399beec62b8f815f109ebfcfa3b0680b"
|
||||
"sha": "376d1c3fb37cc7d22ab21cce836f4d6f323922de"
|
||||
},
|
||||
"homepage": "https://resend.com"
|
||||
},
|
||||
@@ -2097,7 +2097,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/sanity-io/agent-toolkit.git",
|
||||
"sha": "236348e29b31e834ce71e4e2e3072184dd1c1e27"
|
||||
"sha": "d7545f5cc6f8fb39554083b52ad074a6d912db9f"
|
||||
},
|
||||
"homepage": "https://www.sanity.io"
|
||||
},
|
||||
@@ -2131,7 +2131,7 @@
|
||||
"url": "https://github.com/SAP/open-ux-tools.git",
|
||||
"path": "packages/fiori-mcp-server",
|
||||
"ref": "main",
|
||||
"sha": "d2a6fce818f3c046c5bbb041507be4632f926602"
|
||||
"sha": "7432d23a7b5c3bd1c0a01cf76696bf0c417ecd1f"
|
||||
},
|
||||
"homepage": "https://github.com/SAP/open-ux-tools/tree/main/packages/fiori-mcp-server"
|
||||
},
|
||||
@@ -2198,7 +2198,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/getsentry/sentry-for-claude.git",
|
||||
"sha": "ed0875684192bb8a050297a896657ff9db1ffdf5"
|
||||
"sha": "d6123be331e2224b037e1ffefd27c806e7566dcf"
|
||||
},
|
||||
"homepage": "https://github.com/getsentry/sentry-for-claude/tree/main"
|
||||
},
|
||||
@@ -2214,7 +2214,7 @@
|
||||
"url": "https://github.com/getsentry/cli.git",
|
||||
"path": "plugins/sentry-cli",
|
||||
"ref": "main",
|
||||
"sha": "d9bcd70eaa467fb3ddf591bfbfb0686fd1e9c016"
|
||||
"sha": "db90767935558db16c45036f89e68edaa1dde106"
|
||||
},
|
||||
"homepage": "https://sentry.io"
|
||||
},
|
||||
@@ -2279,7 +2279,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/Shopify/Shopify-AI-Toolkit.git",
|
||||
"sha": "c164cf45c4bc1d17bbc105168d99a4f744cfaac2"
|
||||
"sha": "859be93bfc858f183ff5eb40183e35a4d91d2950"
|
||||
},
|
||||
"homepage": "https://shopify.dev"
|
||||
},
|
||||
@@ -2364,7 +2364,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/spotify/ads-claude-plugin.git",
|
||||
"sha": "7ed948b85337f6b31a82dfaa8f033b6843659fa3"
|
||||
"sha": "73b8bd490e02d3ed0bb4c8e228a470c46f995154"
|
||||
},
|
||||
"homepage": "https://github.com/spotify/ads-claude-plugin"
|
||||
},
|
||||
@@ -2377,7 +2377,7 @@
|
||||
"url": "https://github.com/stripe/ai.git",
|
||||
"path": "providers/claude/plugin",
|
||||
"ref": "main",
|
||||
"sha": "a34795211da530a168f581122011bb5ceb2e4bd0"
|
||||
"sha": "99425a010474c6aab745a975d06764e323c2c4d4"
|
||||
},
|
||||
"homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
|
||||
},
|
||||
@@ -2400,7 +2400,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/supabase-community/supabase-plugin.git",
|
||||
"sha": "1b910c021aee8c9c054196f0e840b3a65e1a7c63"
|
||||
"sha": "3217ac038647f6901a166f3264a32f01833f73ba"
|
||||
},
|
||||
"homepage": "https://github.com/supabase-community/supabase-plugin"
|
||||
},
|
||||
@@ -2445,7 +2445,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/JetBrains/teamcity-cli.git",
|
||||
"sha": "7f8419738b452108ff181365be30c1fab0a6905e"
|
||||
"sha": "9436b94b228579ba952aba809357776c3db9ce1a"
|
||||
},
|
||||
"homepage": "https://www.jetbrains.com/teamcity/"
|
||||
},
|
||||
@@ -2538,7 +2538,7 @@
|
||||
"url": "https://github.com/UI5/plugins-coding-agents.git",
|
||||
"path": "plugins/ui5",
|
||||
"ref": "main",
|
||||
"sha": "78f657e6a5004b5cdd1b998aabea616023eeabbb"
|
||||
"sha": "7acd8328399a221e161ae5bb04a5675696f92920"
|
||||
},
|
||||
"homepage": "https://github.com/UI5/plugins-coding-agents"
|
||||
},
|
||||
@@ -2556,7 +2556,7 @@
|
||||
"url": "https://github.com/UI5/plugins-coding-agents.git",
|
||||
"path": "plugins/ui5-typescript-conversion",
|
||||
"ref": "main",
|
||||
"sha": "78f657e6a5004b5cdd1b998aabea616023eeabbb"
|
||||
"sha": "7acd8328399a221e161ae5bb04a5675696f92920"
|
||||
},
|
||||
"homepage": "https://github.com/UI5/plugins-coding-agents"
|
||||
},
|
||||
@@ -2595,7 +2595,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/explorium-ai/vibeprospecting-plugin.git",
|
||||
"sha": "ada4d569dbf70194fe18750ecbc5170e9a3f120a"
|
||||
"sha": "c00b11db4efc3e7b7aaffc10d71db33c806d5607"
|
||||
},
|
||||
"homepage": "https://www.vibeprospecting.ai/product/claude-plugin"
|
||||
},
|
||||
@@ -2620,7 +2620,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/wix/skills.git",
|
||||
"sha": "5da7e749a466ef9ddcdb2822099b940b9a1bc151"
|
||||
"sha": "c5b343f2dadba06da91ee6de07272161fb68d40d"
|
||||
},
|
||||
"homepage": "https://dev.wix.com/docs/wix-cli/guides/development/about-wix-skills"
|
||||
},
|
||||
@@ -2727,7 +2727,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/zscaler/zscaler-mcp-server.git",
|
||||
"sha": "8409e1661b7f7171bfbb9297e1ecfc61c28b6d92"
|
||||
"sha": "be37fb604a07dc9c5a4c3e009312c4f11acaa6d3"
|
||||
},
|
||||
"homepage": "https://github.com/zscaler/zscaler-mcp-server"
|
||||
}
|
||||
|
||||
@@ -10,15 +10,42 @@ import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
def state_dir():
|
||||
"""Return the absolute path of the plugin's state directory.
|
||||
|
||||
Resolution precedence (highest first):
|
||||
1. SECURITY_WARNINGS_STATE_DIR — plugin-specific override (existing)
|
||||
2. CLAUDE_CONFIG_DIR/security — CC's config-dir env var (#1868)
|
||||
3. ~/.claude/security — default fallback
|
||||
|
||||
Empty-string env vars are treated as not-set so a misconfigured shell
|
||||
(`CLAUDE_CONFIG_DIR=` with no value) doesn't silently write to
|
||||
/security at the filesystem root.
|
||||
|
||||
Returns a fully-expanded absolute path (no literal `~`) so subprocess
|
||||
callers can pass it through to code that doesn't re-expand tildes.
|
||||
|
||||
Called per-invocation rather than cached at import time so test
|
||||
monkeypatches of the env vars take effect — the plugin's hooks each
|
||||
run as fresh subprocesses in production, so the per-call cost is
|
||||
negligible compared to subprocess spawn.
|
||||
"""
|
||||
explicit = os.environ.get("SECURITY_WARNINGS_STATE_DIR")
|
||||
if explicit:
|
||||
return os.path.expanduser(explicit)
|
||||
cc_config = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
if cc_config:
|
||||
return os.path.expanduser(os.path.join(cc_config, "security"))
|
||||
return os.path.expanduser("~/.claude/security")
|
||||
|
||||
|
||||
# Debug log file. Lives under the plugin state dir (default ~/.claude/security/)
|
||||
# rather than /tmp because /tmp is world-writable on multi-user hosts (TOCTOU /
|
||||
# symlink-attack surface, cross-user log leakage). Overridable per-process via
|
||||
# SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR.
|
||||
_DEFAULT_STATE_DIR = os.path.expanduser(
|
||||
os.environ.get("SECURITY_WARNINGS_STATE_DIR") or "~/.claude/security"
|
||||
)
|
||||
# SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR
|
||||
# (plugin-specific override) or CLAUDE_CONFIG_DIR (CC-wide config dir, #1868).
|
||||
DEBUG_LOG_FILE = os.environ.get("SECURITY_GUIDANCE_DEBUG_LOG") or os.path.join(
|
||||
_DEFAULT_STATE_DIR, "log.txt"
|
||||
state_dir(), "log.txt"
|
||||
)
|
||||
# Cap the debug log so parallel-worker fleets don't fill disk. When the active
|
||||
# file exceeds this it's atomically rotated to <file>.1 (overwriting any prior
|
||||
|
||||
@@ -138,7 +138,17 @@ def restore_unreviewed_stop_state(session_id, paths, baseline_sha):
|
||||
|
||||
|
||||
def get_baseline_file_content(session_id, file_path, cwd):
|
||||
"""Get the content of a file at the baseline SHA. Returns None if unavailable."""
|
||||
"""Get the content of a file at the baseline SHA. Returns None if unavailable.
|
||||
|
||||
Decode the file content as UTF-8 with errors="replace" rather than using
|
||||
text=True: source files in user repos can be latin-1 / cp1252 / shift-jis
|
||||
/ etc., and on Windows text=True would decode via locale.getpreferredencoding()
|
||||
in strict mode and raise UnicodeDecodeError in the subprocess reader
|
||||
thread — leaving result.stdout=None and propagating AttributeError when
|
||||
the caller tries to use it. Same class as the existing migrations at
|
||||
security_reminder_hook.py:540 (reflog subjects) and :1115 (commit
|
||||
diffs); this helper was missed in that pass. See
|
||||
anthropics/claude-plugins-official#2056."""
|
||||
baseline_sha = load_baseline_sha(session_id)
|
||||
if not baseline_sha:
|
||||
return None
|
||||
@@ -151,12 +161,12 @@ def get_baseline_file_content(session_id, file_path, cwd):
|
||||
return None
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "show", f"{baseline_sha}:{rel_path}"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5
|
||||
cwd=cwd, capture_output=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return (result.stdout or b"").decode("utf-8", errors="replace")
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -173,11 +183,16 @@ def capture_git_baseline(cwd):
|
||||
and `compute_v2_review_set` subtracts that set so pre-existing untracked
|
||||
files are not reviewed as Claude-authored.
|
||||
"""
|
||||
# stdout is a SHA so text=True is safe on stdout, but a non-ASCII
|
||||
# filename in `git stash create`'s STDERR warning (e.g. a worktree
|
||||
# with `Ávila_report.txt` triggers a quotePath/locale warning) would
|
||||
# trip the stderr reader thread on Windows cp1252. Decode both streams
|
||||
# leniently for symmetry with _list_untracked. See #2056.
|
||||
try:
|
||||
# Check if HEAD exists (i.e., repo has at least one commit)
|
||||
head_check = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "HEAD"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5
|
||||
cwd=cwd, capture_output=True, timeout=5
|
||||
)
|
||||
if head_check.returncode != 0:
|
||||
# No commits yet — skip review rather than creating commits in the user's repo
|
||||
@@ -186,20 +201,20 @@ def capture_git_baseline(cwd):
|
||||
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "stash", "create"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=15
|
||||
cwd=cwd, capture_output=True, timeout=15
|
||||
)
|
||||
sha = result.stdout.strip()
|
||||
sha = (result.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
if sha:
|
||||
return sha
|
||||
|
||||
# Working tree is clean — stash create returns empty. Use HEAD.
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "HEAD"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5
|
||||
cwd=cwd, capture_output=True, timeout=5
|
||||
)
|
||||
sha = result.stdout.strip()
|
||||
sha = (result.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
return sha if sha else None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
|
||||
debug_log(f"Failed to capture git baseline: {e}")
|
||||
return None
|
||||
|
||||
@@ -323,19 +338,35 @@ def _list_untracked(cwd):
|
||||
mtime is captured so an in-place edit during the turn is still reviewed.
|
||||
|
||||
Uses ls-files (not status) for the UPS path: the index diff isn't needed,
|
||||
and ls-files --others only walks the worktree against .gitignore."""
|
||||
and ls-files --others only walks the worktree against .gitignore.
|
||||
|
||||
Decodes stdout/stderr as UTF-8 with errors="replace" instead of using
|
||||
text=True. With core.quotePath=false git emits raw UTF-8 bytes for
|
||||
non-ASCII filenames; text=True decodes via locale.getpreferredencoding()
|
||||
in strict mode — on Windows that's cp1252 with several undefined bytes
|
||||
(0x81/0x8D/0x8F/0x90/0x9D), all of which appear in UTF-8 encodings of
|
||||
common accented capitals (Á Í Ï Ð Ý) and most CJK/emoji codepoints.
|
||||
A non-ASCII filename in the worktree crashed the subprocess reader
|
||||
thread, left r.stdout=None, and propagated AttributeError out of the
|
||||
helper — silently losing the baseline snapshot every UserPromptSubmit.
|
||||
See anthropics/claude-plugins-official#2056. The sibling helpers in
|
||||
gitutil.py already follow the lenient pattern; this function and
|
||||
capture_git_baseline / _git_name_only / _git_status_porcelain were
|
||||
the holdouts."""
|
||||
try:
|
||||
repo = _git_toplevel(cwd) or cwd
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false", "ls-files",
|
||||
"--others", "--exclude-standard", "-z"],
|
||||
cwd=repo, capture_output=True, text=True, timeout=15,
|
||||
cwd=repo, capture_output=True, timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
debug_log(f"_list_untracked rc={r.returncode}: {r.stderr[:200]}")
|
||||
stderr_str = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||
debug_log(f"_list_untracked rc={r.returncode}: {stderr_str[:200]}")
|
||||
return {}
|
||||
stdout = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
out = {}
|
||||
for p in r.stdout.split("\0"):
|
||||
for p in stdout.split("\0"):
|
||||
if not p:
|
||||
continue
|
||||
try:
|
||||
@@ -346,7 +377,9 @@ def _list_untracked(cwd):
|
||||
debug_log(f"_list_untracked: capped at {UNTRACKED_BASELINE_CAP}")
|
||||
break
|
||||
return out
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
|
||||
# ValueError guards against any future strict-decode regression
|
||||
# so the helper degrades to {} instead of crashing the hook.
|
||||
debug_log(f"_list_untracked error: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Shared state-dir resolver: SECURITY_WARNINGS_STATE_DIR → CLAUDE_CONFIG_DIR/security
|
||||
# → ~/.claude/security. See _base.state_dir for resolution precedence. Re-aliased
|
||||
# here to match the existing local name (state_dir was already a local var in
|
||||
# main() and _maybe_emit_user_notice).
|
||||
from _base import state_dir as _resolve_state_dir
|
||||
|
||||
# Outcome codes for the sdk_bootstrap metric. Values are stable for telemetry.
|
||||
NOOP_SYSTEM = 0 # claude_agent_sdk already importable in system python
|
||||
NOOP_VENV = 1 # venv already built and SDK imports from it
|
||||
@@ -32,6 +38,8 @@ BUILD_FAILED = 3 # venv create or pip install raised/timed out
|
||||
# llm.py also matches Windows venv layout (Lib/site-packages). Don't reuse the
|
||||
# value — telemetry rows from older plugin builds still emit 4.
|
||||
SKIP_SENTINEL = 5 # another SessionStart is currently building
|
||||
HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load
|
||||
# here no matter how the venv was built. See #2071.
|
||||
|
||||
|
||||
def _sdk_on_syspath() -> bool:
|
||||
@@ -62,13 +70,33 @@ def main() -> tuple[int, str, str]:
|
||||
err_phase / err_kind are non-empty only on BUILD_FAILED — they let
|
||||
telemetry split bootstrap failures by root cause.
|
||||
"""
|
||||
# Honesty check (fixes the misleading NOOP_VENV in #2071): the SDK
|
||||
# requires Python >=3.10 and uses 3.10+ syntax (match statements,
|
||||
# PEP 604 unions). On a 3.9 hook interpreter we CANNOT import it no
|
||||
# matter how the venv was built — llm.py runs in this same interpreter
|
||||
# and the syntax-level import will SyntaxError. macOS ships 3.9.6 as
|
||||
# the default `python3` and `/usr/bin` precedes Homebrew in PATH, so
|
||||
# this case is the default state for a large share of macOS users.
|
||||
#
|
||||
# sg-python.sh now prefers python3.10+ binaries so most users won't
|
||||
# reach this branch; the fallback to 3.9 is preserved for the
|
||||
# pattern-warning hooks that don't need the SDK. Reporting
|
||||
# HOOK_PY_INCOMPATIBLE here:
|
||||
# (a) avoids 30-60s of wasted pip install,
|
||||
# (b) avoids the lie where the venv_py probe says NOOP_VENV but the
|
||||
# consumer import fails, and
|
||||
# (c) gives telemetry a clean bucket to size the affected fleet.
|
||||
if sys.version_info < (3, 10):
|
||||
return (
|
||||
HOOK_PY_INCOMPATIBLE,
|
||||
"hook_py",
|
||||
f"py_{sys.version_info[0]}.{sys.version_info[1]}",
|
||||
)
|
||||
|
||||
if _sdk_on_syspath():
|
||||
return NOOP_SYSTEM, "", ""
|
||||
|
||||
state_dir = Path(
|
||||
os.environ.get("SECURITY_WARNINGS_STATE_DIR")
|
||||
or os.path.expanduser("~/.claude/security")
|
||||
)
|
||||
state_dir = Path(_resolve_state_dir())
|
||||
venv = state_dir / "agent-sdk-venv"
|
||||
# Windows venvs put the interpreter at Scripts\python.exe; POSIX uses bin/python.
|
||||
if sys.platform == "win32":
|
||||
@@ -195,6 +223,53 @@ def main() -> tuple[int, str, str]:
|
||||
sentinel.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _maybe_emit_user_notice(outcome: int, pv: int) -> str | None:
|
||||
"""Return a one-time user-visible notice when the agentic reviewer is
|
||||
in a persistent broken state on this machine, or None if we've already
|
||||
shown the notice for this plugin version (or shouldn't show one).
|
||||
|
||||
The marker file is plugin-version-keyed: a future plugin update can
|
||||
re-notify if behavior changes (e.g. we ship out-of-process SDK in v3
|
||||
and want to tell affected users it's fixed). Failures to write the
|
||||
marker degrade to "skip the notice this session" so we don't spam
|
||||
every SessionStart on a read-only home dir.
|
||||
|
||||
Currently only HOOK_PY_INCOMPATIBLE qualifies. BUILD_FAILED is
|
||||
intentionally excluded — it covers transient causes (network failure,
|
||||
pip registry hiccup, in-flight rebuild) where the next session may
|
||||
succeed and a permanent notice would mislead.
|
||||
"""
|
||||
if outcome != HOOK_PY_INCOMPATIBLE:
|
||||
return None
|
||||
try:
|
||||
state_dir = Path(_resolve_state_dir())
|
||||
marker = state_dir / f".agentic_unavailable_notice_v{pv or 0}"
|
||||
if marker.exists():
|
||||
return None
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Write timestamp + Python version so the marker is self-documenting
|
||||
# if a user goes looking. O_EXCL would be racier with no real win
|
||||
# (two concurrent SessionStarts both showing the notice once is fine).
|
||||
marker.write_text(
|
||||
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
|
||||
f"py={sys.version_info[0]}.{sys.version_info[1]}\n"
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
return (
|
||||
f"⚠ security-guidance plugin: the cross-file commit reviewer "
|
||||
f"(layer 3 of 3 — catches IDOR, auth-bypass, cross-file SSRF) "
|
||||
f"is unavailable in this environment. It requires Python ≥3.10, "
|
||||
f"but the hook is running on "
|
||||
f"{sys.version_info[0]}.{sys.version_info[1]}.\n\n"
|
||||
f"Pattern checks and the single-shot LLM diff review are still "
|
||||
f"active. To enable the deeper reviewer, install Python 3.10+ "
|
||||
f"(e.g. `brew install python` on macOS) and restart Claude Code.\n\n"
|
||||
f"This notice is shown once per plugin version. "
|
||||
f"See: github.com/anthropics/claude-plugins-official/issues/2071"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Tell the harness this is async — venv create + pip install can take
|
||||
# 30-60s on a cold cache, well past the default sync hook timeout.
|
||||
@@ -231,4 +306,18 @@ if __name__ == "__main__":
|
||||
pv = _plugin_version_int()
|
||||
if pv:
|
||||
metrics["pv"] = pv
|
||||
print(json.dumps({"metrics": metrics}), flush=True)
|
||||
response: dict[str, object] = {"metrics": metrics}
|
||||
# One-time user-visible notice when the agentic reviewer is dead on
|
||||
# arrival. Uses hookSpecificOutput.additionalContext (SessionStart's
|
||||
# supported channel for surfacing text to both the model and the user)
|
||||
# plus systemMessage as a belt-and-suspenders. Marker-file-gated so
|
||||
# this fires exactly once per plugin version per install — see
|
||||
# _maybe_emit_user_notice.
|
||||
notice = _maybe_emit_user_notice(outcome, pv)
|
||||
if notice:
|
||||
response["hookSpecificOutput"] = {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": notice,
|
||||
}
|
||||
response["systemMessage"] = notice
|
||||
print(json.dumps(response), flush=True)
|
||||
|
||||
@@ -32,12 +32,17 @@ GIT_CMD = [
|
||||
def _git_rev_parse_head(cwd):
|
||||
"""Return the current HEAD SHA, or None if not a git repo / no commits."""
|
||||
try:
|
||||
# See #2099: text=True on Windows cp1252 crashes the reader thread on
|
||||
# any UTF-8 byte undefined in cp1252 (e.g. via a git error message
|
||||
# referencing a non-ASCII filename in stderr). stdout is a SHA so it
|
||||
# IS safe; stderr is not. capture_output=True with bytes-by-default
|
||||
# never decodes, so the reader thread can't crash.
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "HEAD"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5
|
||||
cwd=cwd, capture_output=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
return result.stdout.decode("utf-8", errors="replace").strip()
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return None
|
||||
@@ -52,13 +57,17 @@ def _find_git_index(cwd):
|
||||
Returns the absolute path to the index file, or None.
|
||||
"""
|
||||
try:
|
||||
# See #2099: stdout here is a PATH which can contain non-ASCII bytes
|
||||
# (e.g. C:\אבטחה\repo\.git). text=True decodes via cp1252 strict on
|
||||
# Windows → crashes the reader thread → returns stdout=None →
|
||||
# caller does .strip() on None → AttributeError. Decode manually.
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--git-dir"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5
|
||||
cwd=cwd, capture_output=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
git_dir = result.stdout.strip()
|
||||
git_dir = result.stdout.decode("utf-8", errors="replace").strip()
|
||||
if not os.path.isabs(git_dir):
|
||||
git_dir = os.path.join(cwd, git_dir)
|
||||
index_path = os.path.join(git_dir, "index")
|
||||
@@ -128,9 +137,13 @@ def _temp_index(cwd, untracked_paths=None):
|
||||
else:
|
||||
add_args = None
|
||||
if add_args:
|
||||
# No stdout used here (only returncode matters), but text=True
|
||||
# still spawns reader threads that decode stderr — git error
|
||||
# messages can reference non-ASCII filenames and crash on
|
||||
# cp1252. See #2099. Drop text=True so bytes stay raw.
|
||||
subprocess.run(
|
||||
[*GIT_CMD, "add", "--intent-to-add"] + add_args,
|
||||
cwd=cwd, capture_output=True, text=True, timeout=10,
|
||||
cwd=cwd, capture_output=True, timeout=10,
|
||||
env=env,
|
||||
)
|
||||
yield env
|
||||
@@ -144,11 +157,17 @@ def _temp_index(cwd, untracked_paths=None):
|
||||
def _git_toplevel(cwd):
|
||||
"""Absolute repo root for `cwd`, or None if not in a work tree."""
|
||||
try:
|
||||
# See #2099: stdout is a PATH — `C:\אבטחה\repo` returned as UTF-8
|
||||
# bytes by git. text=True would decode via cp1252 strict on Windows
|
||||
# → reader-thread crash. Decode manually with errors="replace".
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5,
|
||||
cwd=cwd, capture_output=True, timeout=5,
|
||||
)
|
||||
return r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else None
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
path = r.stdout.decode("utf-8", errors="replace").strip()
|
||||
return path if path else None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
@@ -164,13 +183,15 @@ def _git_dir(repo_root):
|
||||
callers can degrade (push-sweep state is best-effort).
|
||||
"""
|
||||
try:
|
||||
# See #2099: stdout is a PATH (shared gitdir), may be non-ASCII.
|
||||
# Decode bytes manually to avoid cp1252 reader-thread crash.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--git-common-dir"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
d = r.stdout.strip()
|
||||
d = r.stdout.decode("utf-8", errors="replace").strip()
|
||||
return d if os.path.isabs(d) else os.path.join(repo_root, d)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return None
|
||||
@@ -179,13 +200,15 @@ def _git_dir(repo_root):
|
||||
def _git_rev_list_range(repo_root, base, head="HEAD"):
|
||||
"""Shas in `base..head`, oldest→newest. Empty list on error."""
|
||||
try:
|
||||
# See #2099: stdout is ASCII SHAs, but stderr can carry git error
|
||||
# messages referencing non-ASCII filenames — keep bytes raw.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-list", "--reverse", f"{base}..{head}"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=10,
|
||||
cwd=repo_root, capture_output=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
return [s for s in r.stdout.strip().split("\n") if s]
|
||||
return [s for s in r.stdout.decode("utf-8", errors="replace").strip().split("\n") if s]
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return []
|
||||
|
||||
@@ -199,8 +222,15 @@ def _git_diff_range(repo_root, base, head="HEAD"):
|
||||
them reviewed — otherwise unreviewed commits get permanently silenced.
|
||||
"""
|
||||
try:
|
||||
# core.quotePath=false makes git emit raw UTF-8 in `diff --git a/... b/...`
|
||||
# headers instead of C-quoting non-ASCII path bytes (`"a/\303\201vila/..."`
|
||||
# vs `a/Ávila/...`). The downstream `re.match(r'^a/(.+?) b/(.+)$', ...)`
|
||||
# in parse_diff_into_files / extract_file_paths_from_diff matches the
|
||||
# raw form only — quoted headers slip past and the entire file is
|
||||
# silently dropped from review. See #2082 (sibling of #2056 / #2075).
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", base, head],
|
||||
[*GIT_CMD, "-c", "core.quotePath=false",
|
||||
"diff", "-p", "--no-color", "--no-ext-diff", base, head],
|
||||
cwd=repo_root, capture_output=True, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -213,9 +243,11 @@ def _git_diff_range(repo_root, base, head="HEAD"):
|
||||
def _detect_main_branch(repo_root):
|
||||
for ref in ("origin/HEAD", "origin/main", "origin/master", "main", "master"):
|
||||
try:
|
||||
# See #2099: stdout is a SHA but stderr can carry non-ASCII git
|
||||
# warnings — keep bytes raw to avoid cp1252 reader-thread crash.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--verify", "-q", ref],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
return ref
|
||||
@@ -259,19 +291,29 @@ def _git_reflog_recent_commits(repo_root, max_age_s=120, max_n=5):
|
||||
# %gs (the reflog subject) is `commit: <commit-msg first line>` and can
|
||||
# contain `|`; put it LAST so split("|", 2) leaves it intact. %H is
|
||||
# hex and %ct is integer, so the first two fields are delimiter-safe.
|
||||
#
|
||||
# Bytes + decode utf-8/replace: %gs embeds commit-message subjects
|
||||
# which git stores as raw bytes — commits can be authored in
|
||||
# latin-1 / cp1252 / shift-jis etc., and text=True would raise
|
||||
# UnicodeDecodeError in the subprocess reader thread on Windows
|
||||
# cp1252 (subprocess.run returns r.stdout=None, then
|
||||
# r.stdout.splitlines() AttributeErrors). Mirrors the existing
|
||||
# migration at security_reminder_hook.py:540 — same pattern was
|
||||
# missed here. See anthropics/claude-plugins-official#2056.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "log", "-g", "-n", str(max_n),
|
||||
"--format=%H|%ct|%gs", "HEAD"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
|
||||
return [], 0
|
||||
if r.returncode != 0:
|
||||
return [], 0
|
||||
stdout = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
import time as _time
|
||||
now = int(_time.time())
|
||||
fresh, stale = [], 0
|
||||
for idx, line in enumerate(r.stdout.splitlines()):
|
||||
for idx, line in enumerate(stdout.splitlines()):
|
||||
parts = line.split("|", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
@@ -306,23 +348,31 @@ def _git_name_only(cwd, base, include_untracked=False):
|
||||
must distinguish None (error → don't trust as a filter) from set()
|
||||
(genuinely nothing changed). `-c core.quotePath=false -z` keeps non-ASCII
|
||||
and space-containing paths intact."""
|
||||
# Decode stdout/stderr as UTF-8 with errors="replace" instead of using
|
||||
# text=True. core.quotePath=false makes git emit raw UTF-8 for non-ASCII
|
||||
# paths, and text=True on Windows decodes via cp1252 strict — a non-ASCII
|
||||
# changed path would crash the subprocess reader thread, leave
|
||||
# result.stdout=None, and propagate AttributeError out of the helper.
|
||||
# Same fix shape as diffstate._list_untracked. See #2056.
|
||||
def _run(env):
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false", "diff", "--name-only", "-z", base],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=30,
|
||||
cwd=cwd, capture_output=True, timeout=30,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
debug_log(f"_git_name_only({base!r}) rc={result.returncode}: {result.stderr[:200]}")
|
||||
stderr_str = (result.stderr or b"").decode("utf-8", errors="replace")
|
||||
debug_log(f"_git_name_only({base!r}) rc={result.returncode}: {stderr_str[:200]}")
|
||||
return None
|
||||
return {p for p in result.stdout.split("\0") if p}
|
||||
stdout = (result.stdout or b"").decode("utf-8", errors="replace")
|
||||
return {p for p in stdout.split("\0") if p}
|
||||
|
||||
try:
|
||||
if not include_untracked:
|
||||
return _run(None)
|
||||
with _temp_index(cwd) as env:
|
||||
return _run(env)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
|
||||
debug_log(f"_git_name_only({base!r}) error: {e}")
|
||||
return None
|
||||
|
||||
@@ -339,17 +389,22 @@ def _git_status_porcelain(cwd):
|
||||
collapses to `dir/`). Required so the untracked set subtracts cleanly
|
||||
against the UPS-time `_list_untracked` snapshot, which uses ls-files and
|
||||
therefore always lists individual files."""
|
||||
# Lenient decode: same UTF-8 + errors="replace" pattern as the
|
||||
# sibling helpers — a non-ASCII path in the worktree would otherwise
|
||||
# crash the cp1252 reader thread on Windows. See #2056.
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false", "status",
|
||||
"--porcelain=v1", "-uall", "-z"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=30,
|
||||
cwd=cwd, capture_output=True, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
debug_log(f"_git_status_porcelain rc={r.returncode}: {r.stderr[:200]}")
|
||||
stderr_str = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||
debug_log(f"_git_status_porcelain rc={r.returncode}: {stderr_str[:200]}")
|
||||
return None, None
|
||||
tracked, untracked = set(), set()
|
||||
entries = r.stdout.split("\0")
|
||||
stdout = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
entries = stdout.split("\0")
|
||||
i = 0
|
||||
while i < len(entries):
|
||||
e = entries[i]
|
||||
@@ -368,7 +423,9 @@ def _git_status_porcelain(cwd):
|
||||
i += 1
|
||||
i += 1
|
||||
return tracked, untracked
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
|
||||
# ValueError guards against any future strict-decode regression
|
||||
# so the helper degrades to (None, None) instead of crashing.
|
||||
debug_log(f"_git_status_porcelain error: {e}")
|
||||
return None, None
|
||||
|
||||
@@ -378,9 +435,12 @@ def _is_ancestor(cwd, maybe_ancestor, descendant):
|
||||
"""True if `maybe_ancestor` is reachable from `descendant` (i.e. HEAD
|
||||
moved forward via commit/merge, not sideways via checkout)."""
|
||||
try:
|
||||
# See #2099: only returncode matters, but text=True spawns reader
|
||||
# threads that decode stderr — git error messages can carry non-ASCII
|
||||
# filenames. Drop text=True to keep bytes raw, avoid cp1252 crash.
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "merge-base", "--is-ancestor", maybe_ancestor, descendant],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5,
|
||||
cwd=cwd, capture_output=True, timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
@@ -411,7 +471,11 @@ def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_pa
|
||||
# change exists to fix.
|
||||
return ""
|
||||
|
||||
cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
|
||||
# core.quotePath=false: emit raw UTF-8 in `diff --git a/... b/...` headers
|
||||
# so non-ASCII paths aren't C-quoted past the downstream parse_diff_into_files
|
||||
# regex. See #2082 (sibling of #2056 / #2075).
|
||||
cmd = [*GIT_CMD, "-c", "core.quotePath=false",
|
||||
"diff", "--no-color", "--no-ext-diff", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
|
||||
try:
|
||||
with _temp_index(cwd, untracked_paths) as env:
|
||||
# env is None when no index could be found (bare repo / not a
|
||||
|
||||
@@ -49,6 +49,30 @@
|
||||
"asyncRewake": true,
|
||||
"rewakeMessage": "Background security review of pushed commits not yet reviewed — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
|
||||
"rewakeSummary": "Push security review found issues"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
|
||||
"if": "Bash(gt create:*)",
|
||||
"asyncRewake": true,
|
||||
"rewakeMessage": "Background security review of commit — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
|
||||
"rewakeSummary": "Commit security review found issues"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
|
||||
"if": "Bash(gt modify:*)",
|
||||
"asyncRewake": true,
|
||||
"rewakeMessage": "Background security review of commit — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
|
||||
"rewakeSummary": "Commit security review found issues"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
|
||||
"if": "Bash(gt submit:*)",
|
||||
"asyncRewake": true,
|
||||
"rewakeMessage": "Background security review of pushed commits not yet reviewed — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
|
||||
"rewakeSummary": "Push security review found issues"
|
||||
}
|
||||
],
|
||||
"matcher": "Bash"
|
||||
|
||||
@@ -27,7 +27,7 @@ from typing import Optional, Tuple, Dict, Any, List
|
||||
|
||||
import extensibility
|
||||
import review_api
|
||||
from _base import debug_log, _record_usage, _PV, PROVENANCE_TAG # noqa: F401
|
||||
from _base import debug_log, _record_usage, _PV, PROVENANCE_TAG, state_dir as _resolve_state_dir # noqa: F401
|
||||
from session_state import with_locked_state
|
||||
|
||||
|
||||
@@ -355,10 +355,7 @@ def _call_claude_via_sdk(prompt, output_schema, *, max_tokens=16000, model=None)
|
||||
# Try the venv ensure_agent_sdk.py builds. Same fallback logic as
|
||||
# agentic_review() — duplicated here so the 3P path doesn't require
|
||||
# the agentic path to have run first.
|
||||
_state_dir = os.environ.get(
|
||||
"SECURITY_WARNINGS_STATE_DIR",
|
||||
os.path.expanduser("~/.claude/security"),
|
||||
)
|
||||
_state_dir = _resolve_state_dir()
|
||||
_inject_agent_sdk_venv_into_syspath(_state_dir)
|
||||
try:
|
||||
import asyncio as _asyncio # noqa: F811
|
||||
@@ -1145,10 +1142,7 @@ def agentic_review(
|
||||
# ~/.claude/security/ with the SDK installed; try that as a fallback
|
||||
# before giving up. The system import is attempted first so users
|
||||
# who DO have it never touch the venv.
|
||||
_state_dir = os.environ.get(
|
||||
"SECURITY_WARNINGS_STATE_DIR",
|
||||
os.path.expanduser("~/.claude/security"),
|
||||
)
|
||||
_state_dir = _resolve_state_dir()
|
||||
_venv_tried = _inject_agent_sdk_venv_into_syspath(_state_dir)
|
||||
try:
|
||||
import asyncio as _asyncio # noqa: F811
|
||||
|
||||
@@ -82,6 +82,7 @@ from _base import ( # noqa: E402,F401
|
||||
PROVENANCE_TAG, PROVENANCE_BANNER,
|
||||
_read_plugin_version_int, _PV, _USAGE, _USAGE_LOCK,
|
||||
_PRICE_PER_MTOK, _PRICE_DEFAULT, _record_usage, _usage_metrics,
|
||||
state_dir as _resolve_state_dir,
|
||||
)
|
||||
import extensibility # noqa: E402
|
||||
from patterns import ( # noqa: E402,F401
|
||||
@@ -190,7 +191,13 @@ CONTINUATION_SUFFIX = (
|
||||
"response."
|
||||
)
|
||||
|
||||
def emit_metrics(metrics, rewake_summary=None):
|
||||
def emit_metrics(
|
||||
metrics,
|
||||
rewake_summary=None,
|
||||
additional_context=None,
|
||||
system_message=None,
|
||||
hook_event_name="PostToolUse",
|
||||
):
|
||||
"""
|
||||
Write a SyncHookJSONOutput line to stdout for Claude Code to pick up.
|
||||
For asyncRewake (Stop) hooks, CC scans stdout for the first {-prefixed line
|
||||
@@ -213,6 +220,27 @@ def emit_metrics(metrics, rewake_summary=None):
|
||||
rewakeSummary in hooks.json, shown to the user in the terminal as the
|
||||
task-notification one-liner. Must be in the same JSON line as the metrics
|
||||
because CC stops scanning stdout after the first {-prefixed line.
|
||||
|
||||
`additional_context` (asyncRewake findings): model-visible guidance text
|
||||
that CC surfaces via the modern hook-output protocol
|
||||
(hookSpecificOutput.additionalContext) instead of the legacy stderr +
|
||||
exit(2) pair. The caller passes the finding-explanation text it would
|
||||
have written to stderr; the JSON channel carries it cleanly so CC's UI
|
||||
shows the reason properly instead of "Permission denied with no reason".
|
||||
See anthropics/claude-plugins-official#1375 and #1783. Empty/None
|
||||
means no hookSpecificOutput field is emitted (preserves backward compat
|
||||
for legacy emit-sites that only want metrics).
|
||||
|
||||
`system_message` (optional, asyncRewake only): user-visible TUI message,
|
||||
distinct from rewakeSummary which is the task-notification one-liner.
|
||||
Use sparingly — the rewakeMessage in hooks.json is the primary user
|
||||
surface; systemMessage adds a per-fire override when the static
|
||||
rewakeMessage isn't specific enough for the finding being shown.
|
||||
|
||||
`hook_event_name` (used only when additional_context is set): which event
|
||||
the hookSpecificOutput attaches to. Defaults to "PostToolUse" since the
|
||||
commit-review and push-sweep handlers are the most common callers;
|
||||
handle_stop_hook explicitly passes "Stop".
|
||||
"""
|
||||
head = {}
|
||||
if _PV and "pv" not in metrics:
|
||||
@@ -223,6 +251,17 @@ def emit_metrics(metrics, rewake_summary=None):
|
||||
out = {"metrics": metrics}
|
||||
if rewake_summary:
|
||||
out["rewakeSummary"] = rewake_summary
|
||||
if additional_context:
|
||||
# Wrap in hookSpecificOutput per CC's modern hook-output contract.
|
||||
# Drops the legacy `sys.stderr.write(...) + sys.exit(2)` shape that
|
||||
# left CC's UI showing "denied with no reason" (#1783) and triggered
|
||||
# "json output validation failed" on older CC versions (#1375).
|
||||
out["hookSpecificOutput"] = {
|
||||
"hookEventName": hook_event_name,
|
||||
"additionalContext": additional_context,
|
||||
}
|
||||
if system_message:
|
||||
out["systemMessage"] = system_message
|
||||
print(json.dumps(out), flush=True)
|
||||
|
||||
# =====================================================================
|
||||
@@ -510,7 +549,11 @@ def handle_user_prompt_submit(input_data):
|
||||
elif sha:
|
||||
debug_log(f"Captured git baseline: {sha[:12]}")
|
||||
else:
|
||||
debug_log("Failed to capture git baseline (not a git repo?)")
|
||||
# Show cwd so the next reporter can immediately see when this isn't
|
||||
# actually "not a git repo" but a path-encoding / permissions / git
|
||||
# invocation failure. See #2099.
|
||||
debug_log(f"Failed to capture git baseline (cwd={cwd!r}) — not a git repo, "
|
||||
f"or git invocation failed (check log entries above)")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
@@ -594,8 +637,29 @@ _COMMIT_SHA_RE = re.compile(r'^\[[^\]]*?\b([0-9a-f]{7,40})\]', re.MULTILINE)
|
||||
# detection — it does NOT tolerate `git -c k=v commit` global options, which
|
||||
# keeps this hook aligned with CC's commit attribution on what counts as a
|
||||
# commit.
|
||||
_GIT_COMMIT_RE = re.compile(r'\bgit\s+commit(?:\s|$)')
|
||||
_GIT_AMEND_RE = re.compile(r'\s--amend\b')
|
||||
#
|
||||
# Also matches `gt create` and `gt modify` — Graphite's stacked-PR wrapper
|
||||
# around git. `gt create` produces a new commit (mapped to git commit
|
||||
# semantics); `gt modify` amends the current commit (mapped to git commit
|
||||
# --amend, also flagged by _GIT_AMEND_RE below). The hooks.json matcher
|
||||
# widening for `gt create:*` / `gt modify:*` / `gt submit:*` ships in the
|
||||
# same change set — without that widening this regex change is dead code
|
||||
# because the hook subprocess never spawns for gt invocations. See #2048.
|
||||
_GIT_COMMIT_RE = re.compile(
|
||||
# `git -C <path>` and `git -c key=val` global options are allowed between
|
||||
# `git` and `commit` (mirrors the long-standing tolerance in
|
||||
# _GIT_PUSH_RE). Without this, `git -C /repo commit` is silently dropped
|
||||
# by the handler — see #2089's secondary finding. The gt branch has no
|
||||
# global-option layer to worry about.
|
||||
r'\bgit(?:\s+-[Cc]\s+\S+|\s+--\S+=\S+)*\s+commit\b'
|
||||
r'|\bgt\s+(?:create|modify)\b'
|
||||
)
|
||||
# Match either the `--amend` flag (with the leading whitespace boundary
|
||||
# preserved from the original) OR `gt modify` which is semantically an
|
||||
# amend. The handler treats matches as "find the pre-amend SHA via reflog
|
||||
# and diff against THAT, not against the post-amend HEAD's parent" — same
|
||||
# code path for both git --amend and gt modify.
|
||||
_GIT_AMEND_RE = re.compile(r'(?:\s--amend\b|\bgt\s+modify\b)')
|
||||
|
||||
# Rolling-window cap on LLM commit-review calls. See atomic_check_rate_limit
|
||||
# docstring for the rationale that motivated the switch from a lifetime cap.
|
||||
@@ -624,8 +688,13 @@ COMMIT_REVIEW_RATE_WINDOW_S = int(
|
||||
# entry would buy minimal extra coverage (sessions that push only via gh) at
|
||||
# the cost of an extra python spawn on every `... && gh pr create` compound
|
||||
# (the common case). Those sessions are caught on their next standalone `git push`.
|
||||
# Matches `git push` (with optional `-c k=v` / `-C path` global options
|
||||
# CC's hooks.json matcher doesn't tolerate) OR `gt submit` — Graphite's
|
||||
# stacked-PR push command. gt submit forwards to `git push` internally,
|
||||
# but the bash hook fires on Claude's top-level command so we need to
|
||||
# recognize gt submit at the matcher level. See #2048.
|
||||
_GIT_PUSH_RE = re.compile(
|
||||
r'\bgit(?:\s+-[cC]\s+\S+|\s+--\S+=\S+)*\s+push\b'
|
||||
r'(?:\bgit(?:\s+-[cC]\s+\S+|\s+--\S+=\S+)*\s+push\b|\bgt\s+submit\b)'
|
||||
)
|
||||
|
||||
# `git push` stdout: "abc1234..def5678 branch -> branch" (or `+abc..def` on
|
||||
@@ -791,23 +860,30 @@ def _detect_prev_upstream(repo_root, bash_output):
|
||||
# @{u}@{1} — only meaningful if an upstream is configured.
|
||||
for ref in ("@{u}@{1}", "@{push}@{1}"):
|
||||
try:
|
||||
# See #2099: stdout is a SHA but stderr can carry non-ASCII git
|
||||
# warnings — keep bytes raw to avoid cp1252 reader-thread crash.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--verify", "-q", ref],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
return r.stdout.strip()
|
||||
sha = r.stdout.decode("utf-8", errors="replace").strip()
|
||||
if r.returncode == 0 and sha:
|
||||
return sha
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
main = _detect_main_branch(repo_root)
|
||||
if main:
|
||||
try:
|
||||
# See #2099: drop text=True; decode bytes manually so a
|
||||
# cp1252-undefined byte in git's stderr doesn't crash the
|
||||
# reader thread.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "merge-base", "HEAD", main],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
return r.stdout.strip()
|
||||
sha = r.stdout.decode("utf-8", errors="replace").strip()
|
||||
if r.returncode == 0 and sha:
|
||||
return sha
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
return None
|
||||
@@ -1118,16 +1194,21 @@ def handle_commit_review_posttooluse(input_data):
|
||||
resolved = 0
|
||||
for sha in shas:
|
||||
try:
|
||||
# core.quotePath=false: emit raw UTF-8 in `diff --git a/... b/...`
|
||||
# headers so non-ASCII paths aren't C-quoted past the downstream
|
||||
# parse_diff_into_files regex (sibling of #2056 / #2075). See #2082.
|
||||
if pre_amend_sha:
|
||||
# Delta review: pre-amend → post-amend. `git diff` (not show)
|
||||
# so the output is a pure unified diff with no commit header.
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "diff", "--no-color", "--no-ext-diff", pre_amend_sha, sha, "--"],
|
||||
[*GIT_CMD, "-c", "core.quotePath=false",
|
||||
"diff", "--no-color", "--no-ext-diff", pre_amend_sha, sha, "--"],
|
||||
cwd=repo_root, capture_output=True, timeout=15
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "show", "-p", "--no-color", "--no-ext-diff", sha, "--"],
|
||||
[*GIT_CMD, "-c", "core.quotePath=false",
|
||||
"show", "-p", "--no-color", "--no-ext-diff", sha, "--"],
|
||||
cwd=repo_root, capture_output=True, timeout=15
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
@@ -1254,12 +1335,13 @@ def handle_commit_review_posttooluse(input_data):
|
||||
try:
|
||||
full_shas = []
|
||||
for s in shas:
|
||||
# See #2099: drop text=True; decode manually for cp1252 safety.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--verify", "-q", s],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
full_shas.append(r.stdout.strip())
|
||||
full_shas.append(r.stdout.decode("utf-8", errors="replace").strip())
|
||||
_append_reviewed_shas(repo_root, full_shas, vulns_found=len(vulns or []))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1361,18 +1443,26 @@ def handle_commit_review_posttooluse(input_data):
|
||||
if s in sev:
|
||||
sev[s] += 1
|
||||
|
||||
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
|
||||
# still lists deduped entries. Pass via additional_context so CC surfaces
|
||||
# the reason via hookSpecificOutput.additionalContext instead of empty
|
||||
# stdout (#1783) / stderr-only "json output validation failed" (#1375).
|
||||
_commit_guidance = (PROVENANCE_BANNER + "\n\n"
|
||||
+ _format_vulns_guidance(new_vulns)
|
||||
+ CONTINUATION_SUFFIX + "\n")
|
||||
emit_metrics({
|
||||
"vulns_found": len(new_vulns), **_base, **_agentic_m,
|
||||
"critical_count": sev["critical"], "high_count": sev["high"],
|
||||
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
||||
**({"deduped": n_deduped} if n_deduped else {}),
|
||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"))
|
||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"),
|
||||
additional_context=_commit_guidance,
|
||||
hook_event_name="PostToolUse")
|
||||
|
||||
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
|
||||
# still lists deduped entries.
|
||||
sys.stderr.write(PROVENANCE_BANNER + "\n\n"
|
||||
+ _format_vulns_guidance(new_vulns)
|
||||
+ CONTINUATION_SUFFIX + "\n")
|
||||
# exit(2) is preserved per the asyncRewake protocol — it's what CC
|
||||
# uses as the "force fix" signal that triggers the rewakeMessage flow.
|
||||
# The stderr.write was removed; additional_context above now carries
|
||||
# the same text via the modern JSON channel. See #1358/#1375/#1783.
|
||||
sys.exit(2)
|
||||
|
||||
def handle_push_sweep_posttooluse(input_data):
|
||||
@@ -1453,9 +1543,10 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
# both.
|
||||
head = None
|
||||
try:
|
||||
# See #2099: drop text=True; decode manually for cp1252 safety.
|
||||
r = subprocess.run([*GIT_CMD, "rev-parse", "HEAD"], cwd=repo_root,
|
||||
capture_output=True, text=True, timeout=5)
|
||||
head = r.stdout.strip() if r.returncode == 0 else None
|
||||
capture_output=True, timeout=5)
|
||||
head = r.stdout.decode("utf-8", errors="replace").strip() if r.returncode == 0 else None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
push_section = _push_section(bash_output or "")
|
||||
@@ -1485,14 +1576,15 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
quiet_success = False
|
||||
if not (bash_output or "").strip() and not interrupted:
|
||||
try:
|
||||
# See #2099: drop text=True; decode manually for cp1252 safety.
|
||||
r_cur = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--verify", "-q", "@{u}"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5)
|
||||
cwd=repo_root, capture_output=True, timeout=5)
|
||||
r_prev = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--verify", "-q", "@{u}@{1}"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5)
|
||||
cur = r_cur.stdout.strip() if r_cur.returncode == 0 else ""
|
||||
prev_u = r_prev.stdout.strip() if r_prev.returncode == 0 else ""
|
||||
cwd=repo_root, capture_output=True, timeout=5)
|
||||
cur = r_cur.stdout.decode("utf-8", errors="replace").strip() if r_cur.returncode == 0 else ""
|
||||
prev_u = r_prev.stdout.decode("utf-8", errors="replace").strip() if r_prev.returncode == 0 else ""
|
||||
quiet_success = bool(cur and prev_u and cur == head and prev_u != cur)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
@@ -1506,11 +1598,12 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
# reviewed-shas state.
|
||||
for local_ref in new_branch_matches:
|
||||
try:
|
||||
# See #2099: drop text=True; decode manually for cp1252 safety.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "rev-parse", "--verify", "-q", local_ref],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
||||
cwd=repo_root, capture_output=True, timeout=5,
|
||||
)
|
||||
local_sha = r.stdout.strip() if r.returncode == 0 else ""
|
||||
local_sha = r.stdout.decode("utf-8", errors="replace").strip() if r.returncode == 0 else ""
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
local_sha = ""
|
||||
if local_sha and local_sha != head:
|
||||
@@ -1629,17 +1722,23 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
# Metrics — keep within the 10-key cap; agentic sub-metrics are dropped
|
||||
# here in favour of the push-sweep funnel keys (telemetry can join on session_id
|
||||
# to the per-commit fires for agentic detail). rewake_summary must ride
|
||||
# this line (CC reads only the first {-prefixed stdout line); it's a
|
||||
# no-op when new_vulns is empty since we exit 0 below.
|
||||
emit_metrics({
|
||||
# this line (CC reads only the first {-prefixed stdout line); the emit
|
||||
# is deferred to the two exit points below so the with-vulns path can
|
||||
# also pass additional_context in the same JSON line (#1375/#1783) —
|
||||
# the by-design "CC keeps only the first JSON line" constraint means
|
||||
# we can't emit twice. Builds the shared metrics dict here; vulns path
|
||||
# adds additional_context, no-vulns path emits as-is.
|
||||
_push_metrics = {
|
||||
**_base, "pushed": len(push_range), "unreviewed": len(tail),
|
||||
"prefix_advanced": prefix_advanced, "vulns_found": len(new_vulns),
|
||||
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
||||
**({"deduped": n_deduped} if n_deduped else {}),
|
||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Push security review found"))
|
||||
}
|
||||
_push_rewake_summary = _format_vulns_summary(new_vulns, prefix="Push security review found")
|
||||
|
||||
if not new_vulns:
|
||||
debug_log("Push sweep: no new findings")
|
||||
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary)
|
||||
sys.exit(0)
|
||||
|
||||
# First-push of a big branch can surface many findings at once across
|
||||
@@ -1692,9 +1791,14 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
guidance = _format_vulns_guidance(reported) or ""
|
||||
else:
|
||||
guidance = concrete_guidance or _format_vulns_guidance(reported) or ""
|
||||
sys.stderr.write(
|
||||
PROVENANCE_BANNER + "\n\n" + guidance + CONTINUATION_SUFFIX + "\n"
|
||||
)
|
||||
# Emit metrics + additional_context together — single JSON line is the
|
||||
# contract CC's hook parser expects. exit(2) preserved as the asyncRewake
|
||||
# "force fix" trigger (see comment near handle_commit_review_posttooluse).
|
||||
# See #1358 / #1375 / #1783.
|
||||
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary,
|
||||
additional_context=(PROVENANCE_BANNER + "\n\n"
|
||||
+ guidance + CONTINUATION_SUFFIX + "\n"),
|
||||
hook_event_name="PostToolUse")
|
||||
sys.exit(2)
|
||||
|
||||
def handle_stop_hook(input_data):
|
||||
@@ -1927,6 +2031,11 @@ def handle_stop_hook(input_data):
|
||||
# untracked_baseline_n is the signal for whether the UPS-time
|
||||
# untracked-snapshot capture actually ran.
|
||||
sweep_trimmed = {k: v for k, v in sweep.items() if k != "warn_unresolved_mask"}
|
||||
# Pass guidance via additional_context so CC surfaces the findings via
|
||||
# hookSpecificOutput.additionalContext instead of stderr-only (which
|
||||
# was the cause of "json output validation failed" / empty-reason UI in
|
||||
# #1375 / #1783). exit(2) preserved as the asyncRewake "force fix"
|
||||
# signal — that's the documented mechanism. See #1358 / #1375 / #1783.
|
||||
emit_metrics({
|
||||
"vulns_found": len(vulns),
|
||||
"untracked_baseline_n": len(untracked_at_baseline),
|
||||
@@ -1940,10 +2049,10 @@ def handle_stop_hook(input_data):
|
||||
**({"diff_truncated": llm._last_review_truncated_bytes}
|
||||
if llm._last_review_truncated_bytes else {}),
|
||||
**sweep_trimmed,
|
||||
}, rewake_summary=_format_vulns_summary(vulns))
|
||||
|
||||
# Exit code 2 with stderr forces Claude to continue and fix
|
||||
sys.stderr.write(PROVENANCE_BANNER + "\n\n" + concrete_guidance + CONTINUATION_SUFFIX + "\n")
|
||||
}, rewake_summary=_format_vulns_summary(vulns),
|
||||
additional_context=(PROVENANCE_BANNER + "\n\n"
|
||||
+ concrete_guidance + CONTINUATION_SUFFIX + "\n"),
|
||||
hook_event_name="Stop")
|
||||
sys.exit(2)
|
||||
|
||||
if llm._last_call_claude_http_error is not None:
|
||||
@@ -1971,10 +2080,7 @@ def handle_stop_hook(input_data):
|
||||
})
|
||||
sys.exit(0)
|
||||
|
||||
_SDK_BOOTSTRAP_THROTTLE = os.path.join(
|
||||
os.environ.get("SECURITY_WARNINGS_STATE_DIR")
|
||||
or os.path.expanduser("~/.claude/security"),
|
||||
".sdk_bootstrap_spawned")
|
||||
_SDK_BOOTSTRAP_THROTTLE = os.path.join(_resolve_state_dir(), ".sdk_bootstrap_spawned")
|
||||
|
||||
def _maybe_bootstrap_agent_sdk_async():
|
||||
"""Fire-and-forget SDK bootstrap, for remote-pod environments.
|
||||
|
||||
@@ -19,7 +19,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from _base import debug_log
|
||||
from _base import debug_log, state_dir as _state_dir
|
||||
|
||||
|
||||
def _state_key(session_id):
|
||||
@@ -36,20 +36,20 @@ def _state_key(session_id):
|
||||
|
||||
def get_state_file(session_id):
|
||||
"""Get session-specific state file path."""
|
||||
state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security"))
|
||||
state_dir = _state_dir()
|
||||
return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.json")
|
||||
|
||||
|
||||
def get_lock_file(session_id):
|
||||
"""Get session-specific lock file path."""
|
||||
state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security"))
|
||||
state_dir = _state_dir()
|
||||
return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.lock")
|
||||
|
||||
|
||||
def cleanup_old_state_files():
|
||||
"""Remove state files and lock files older than 30 days."""
|
||||
try:
|
||||
state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security"))
|
||||
state_dir = _state_dir()
|
||||
if not os.path.exists(state_dir):
|
||||
return
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
# "${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
|
||||
set -e
|
||||
|
||||
# Force UTF-8 for ALL Python filesystem + IO operations (PEP 540).
|
||||
# Without this, Windows Python defaults `locale.getpreferredencoding()` to
|
||||
# cp1252 — which makes `text=True` in subprocess.run / open() / json.load
|
||||
# crash the internal reader thread on any byte that's undefined in cp1252
|
||||
# (e.g. the 0x81 byte from ف, present in any path/filename with
|
||||
# Arabic/Hebrew/CJK characters). See #2056, #2099.
|
||||
#
|
||||
# No-op on macOS/Linux (already UTF-8). Must be set BEFORE Python starts —
|
||||
# changing it from inside the interpreter has no effect.
|
||||
export PYTHONUTF8=1
|
||||
|
||||
# Git Bash / MSYS on Windows hands script paths to this shim in POSIX form
|
||||
# (`/c/Users/...`). When we exec a Windows `python.exe` (which we do on
|
||||
# Windows since `python3` is the Microsoft Store stub), python interprets the
|
||||
@@ -47,21 +58,65 @@ fi
|
||||
|
||||
probe() {
|
||||
# $1..N: the interpreter command (may be multi-word like `py -3`)
|
||||
# Probe writes the major version to stdout and exits 0 iff it's >=3.
|
||||
"$@" -c 'import sys; print(sys.version_info[0])' 2>/dev/null
|
||||
# Writes "<major>.<minor>" to stdout and exits 0 iff at least Python 3.
|
||||
"$@" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null
|
||||
}
|
||||
|
||||
# True iff arg is a "M.m" version string >= 3.10. claude_agent_sdk requires
|
||||
# Python >= 3.10; below that, pip install fails ("No matching distribution")
|
||||
# and the LLM-powered review (Stop / commit / push) silently no-ops while
|
||||
# pattern checks (PostToolUse regex) keep working. macOS ships 3.9.6 as the
|
||||
# default `python3` on current versions, so this guard matters in practice.
|
||||
# See anthropics/claude-plugins-official#2071.
|
||||
is_sdk_compatible() {
|
||||
case "$1" in
|
||||
3.1[0-9]|3.[2-9][0-9]|[4-9].*|[1-9][0-9].*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Pass 1 — try minor-versioned binaries in descending order. These are only
|
||||
# present if the user explicitly installed them (Homebrew / python.org / pyenv),
|
||||
# so picking one here always upgrades over the system `python3`. Highest
|
||||
# available wins; the user doesn't have to PATH-prefer it.
|
||||
for cmd in "python3.13" "python3.12" "python3.11" "python3.10"; do
|
||||
v=$(probe "$cmd") || continue
|
||||
if is_sdk_compatible "$v"; then
|
||||
exec "$cmd" "$@"
|
||||
fi
|
||||
done
|
||||
|
||||
# Pass 2 — bare interpreters, but only if SDK-compatible. Covers Linux distros
|
||||
# that ship 3.10+ as the default `python3`, and Windows where `python` /
|
||||
# `py -3` resolves to the user's python.org install.
|
||||
for cmd in "python3" "python" "py -3"; do
|
||||
# Word-split intentionally so `py -3` works
|
||||
# shellcheck disable=SC2086
|
||||
v=$(probe $cmd) || continue
|
||||
if [ "$v" = "3" ]; then
|
||||
if is_sdk_compatible "$v"; then
|
||||
# shellcheck disable=SC2086
|
||||
exec $cmd "$@"
|
||||
fi
|
||||
done
|
||||
|
||||
# Pass 3 — fallback to any Python 3, even <3.10. Pattern-based checks
|
||||
# (PostToolUse regex on Edit/Write) only need 3.6+ and are useful on their
|
||||
# own; the SDK-dependent paths will detect the version mismatch and degrade
|
||||
# inside the Python code. Without this fallback, the entire plugin would
|
||||
# stop working on default macOS, which is a regression vs today.
|
||||
for cmd in "python3" "python" "py -3"; do
|
||||
# shellcheck disable=SC2086
|
||||
v=$(probe $cmd) || continue
|
||||
# Accept anything that successfully reported a "M.m" string.
|
||||
case "$v" in
|
||||
[0-9]*.[0-9]*)
|
||||
# shellcheck disable=SC2086
|
||||
exec $cmd "$@"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "security-guidance: no working Python 3 interpreter found." >&2
|
||||
echo " tried: python3, python, py -3" >&2
|
||||
echo " tried: python3.13, python3.12, python3.11, python3.10, python3, python, py -3" >&2
|
||||
echo " on Windows, install Python from https://python.org (NOT the Microsoft Store)" >&2
|
||||
echo " on macOS, install Python 3.10+ via Homebrew (\`brew install python\`)" >&2
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user