mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-07-23 17:23:29 +00:00
Compare commits
79 Commits
fix-2082-d
...
fix-2159-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70c28b9c2f | ||
|
|
c7abc99aa1 | ||
|
|
3a0bba1d81 | ||
|
|
c326beefc4 | ||
|
|
cc8231f0e8 | ||
|
|
768c45f7c7 | ||
|
|
890215ba95 | ||
|
|
77c7b71496 | ||
|
|
8e3e55e512 | ||
|
|
a8c0b70061 | ||
|
|
4167d03e26 | ||
|
|
8900b21abc | ||
|
|
0e4789818a | ||
|
|
89a3934ffa | ||
|
|
4053de6b37 | ||
|
|
907ad63743 | ||
|
|
75c86cb7ad | ||
|
|
b9447ff795 | ||
|
|
9a8303c6d9 | ||
|
|
9557d751dc | ||
|
|
eac1df92db | ||
|
|
4263502749 | ||
|
|
c0c282b87b | ||
|
|
abeea5843f | ||
|
|
a5d43627ef | ||
|
|
6a8591db5f | ||
|
|
31fd7f0923 | ||
|
|
9cb21aab75 | ||
|
|
7328989616 | ||
|
|
8a7f6912b2 | ||
|
|
a6ce4ca3d5 | ||
|
|
785a75e88b | ||
|
|
697a046997 | ||
|
|
8d45b83d6c | ||
|
|
87e08885b5 | ||
|
|
2de71f5542 | ||
|
|
798cb06aa3 | ||
|
|
2a22053549 | ||
|
|
754f7f2f54 | ||
|
|
05107962e7 | ||
|
|
56a8f8df52 | ||
|
|
bf7e852731 | ||
|
|
3866e34b15 | ||
|
|
3d490adc34 | ||
|
|
08d1b59559 | ||
|
|
49880c89fe | ||
|
|
7951b76e19 | ||
|
|
9cc1748a65 | ||
|
|
009392eee4 | ||
|
|
9f6eae5114 | ||
|
|
1fe78a3f60 | ||
|
|
267c4e6f06 | ||
|
|
12b3721b22 | ||
|
|
e11db042eb | ||
|
|
b92bc59595 | ||
|
|
fcdcd079e3 | ||
|
|
5adb5a2d26 | ||
|
|
a63dc11763 | ||
|
|
025f4d4477 | ||
|
|
e586a0fc00 | ||
|
|
0d82eac145 | ||
|
|
17b532f92e | ||
|
|
475038edfc | ||
|
|
3d368d2972 | ||
|
|
84011d43b1 | ||
|
|
2a822c0787 | ||
|
|
a40c9f1e83 | ||
|
|
c7a3e2ffa0 | ||
|
|
1ecf3d1bac | ||
|
|
c40770ae5a | ||
|
|
7a0a7f486e | ||
|
|
42487ee6fd | ||
|
|
bc07f7a1fd | ||
|
|
9e150cfd48 | ||
|
|
8435428dfc | ||
|
|
0d22ba3501 | ||
|
|
37ffc76005 | ||
|
|
982070e51f | ||
|
|
5212308979 |
@@ -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": "8d74ee6b6fdce4a1c46b98b5a66706c9393fc369"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -107,7 +107,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/gemini-cli-extensions/alloydb.git",
|
||||
"sha": "4a75653275b095fcacf1508796b0fee8cc758c07"
|
||||
"sha": "bbf4eb3664faf129ab8ff8c4b959d7e59c03d347"
|
||||
},
|
||||
"homepage": "https://cloud.google.com/alloydb"
|
||||
},
|
||||
@@ -120,7 +120,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/amazon-location-service",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "187edde6e122116b43211049195627a5069bda80"
|
||||
},
|
||||
"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": "cda594f40503cd2ae144052237fa2890e024828c"
|
||||
},
|
||||
"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": "57d6099f499fe21572bdf943396630bd3d968550"
|
||||
},
|
||||
"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": "187edde6e122116b43211049195627a5069bda80"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -335,10 +335,26 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/aws-serverless",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "187edde6e122116b43211049195627a5069bda80"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
{
|
||||
"name": "aws-startup-advisor",
|
||||
"description": "Skills for startups building on AWS: an Activate knowledge base (FAQ, credits, programs, partner offers, sample architectures, 277+ learn articles), a prompt library of 29+ copy-paste prompts plus installable agents, and an interactive discovery workflow that scaffolds an AWS architecture.",
|
||||
"author": {
|
||||
"name": "Amazon Web Services"
|
||||
},
|
||||
"category": "development",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "https://github.com/awslabs/startups.git",
|
||||
"path": "advisor/plugins/aws-startup-advisor",
|
||||
"ref": "main",
|
||||
"sha": "23a4f5eaf74a0f0fcf86f8a05bd36618a3f5faae"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/startups"
|
||||
},
|
||||
{
|
||||
"name": "azure",
|
||||
"description": "Transform Claude into an Azure expert. This plugin integrates the Azure MCP server and specialized Azure skills to move beyond generic advice. It enables Claude to perform real-world tasks: listing resources, validating deployments, diagnosing infrastructure issues, and optimizing costs across 50+ Azure services.",
|
||||
@@ -346,7 +362,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/microsoft/azure-skills.git",
|
||||
"sha": "d02fd24f151f5133650eaa78e7da3cac2cedd72f"
|
||||
"sha": "d3440b8a4f138585a512ecd4e0c54ede13ab1cc2"
|
||||
},
|
||||
"homepage": "https://github.com/microsoft/azure-skills"
|
||||
},
|
||||
@@ -384,7 +400,7 @@
|
||||
"url": "https://github.com/Bigdata-com/bigdata-plugins-marketplace.git",
|
||||
"path": "plugins/bigdata-com",
|
||||
"ref": "main",
|
||||
"sha": "c77a09caabdc8783adbcbf8bbe05a0f57da12b19"
|
||||
"sha": "67c30be97a0a3f46bc6e8d56df449ae108eda9c5"
|
||||
},
|
||||
"homepage": "https://docs.bigdata.com"
|
||||
},
|
||||
@@ -412,7 +428,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/brightdata/skills.git",
|
||||
"sha": "071e9d4db77c8561e333799f25ea85f11f7b667d"
|
||||
"sha": "68651246ad1819b98a1fc15ce10239e55406ff37"
|
||||
},
|
||||
"homepage": "https://docs.brightdata.com"
|
||||
},
|
||||
@@ -442,7 +458,7 @@
|
||||
"url": "https://github.com/carta/plugins.git",
|
||||
"path": "plugins/carta-cap-table",
|
||||
"ref": "main",
|
||||
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
|
||||
"sha": "49cfc652620672f3e4d4ca31c4ba4c6ebcc568de"
|
||||
},
|
||||
"homepage": "https://carta.com"
|
||||
},
|
||||
@@ -458,7 +474,7 @@
|
||||
"url": "https://github.com/carta/plugins.git",
|
||||
"path": "plugins/carta-crm",
|
||||
"ref": "main",
|
||||
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
|
||||
"sha": "49cfc652620672f3e4d4ca31c4ba4c6ebcc568de"
|
||||
},
|
||||
"homepage": "https://carta.com"
|
||||
},
|
||||
@@ -474,7 +490,7 @@
|
||||
"url": "https://github.com/carta/plugins.git",
|
||||
"path": "plugins/carta-investors",
|
||||
"ref": "main",
|
||||
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
|
||||
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
|
||||
},
|
||||
"homepage": "https://carta.com"
|
||||
},
|
||||
@@ -490,7 +506,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/cap-js/mcp-server.git",
|
||||
"sha": "92dc99f5ba0c56957ed5d390484693a69ebd1206"
|
||||
"sha": "9658cea90c782a6ab007ac16278c90fa4feca0ed"
|
||||
},
|
||||
"homepage": "https://cap.cloud.sap/"
|
||||
},
|
||||
@@ -501,7 +517,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/ChromeDevTools/chrome-devtools-mcp.git",
|
||||
"sha": "60be3e6bc157bd1121ea1d4b6ad59e37a73cac3e"
|
||||
"sha": "692b28adac70f6475737ffbc4e6e2a0bb65569db"
|
||||
},
|
||||
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp"
|
||||
},
|
||||
@@ -595,7 +611,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/ClickHouse/clickhouse-claude-code-plugin.git",
|
||||
"sha": "36889764f504cb92ab71ffe54b4c55488290ed7f"
|
||||
"sha": "bfd22b9ca64288c149a623c45ad933b23f9de019"
|
||||
},
|
||||
"homepage": "https://github.com/ClickHouse/clickhouse-claude-code-plugin"
|
||||
},
|
||||
@@ -716,7 +732,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/CodSpeedHQ/codspeed.git",
|
||||
"sha": "ecf3c2ebf959479126d631ad39d317738d559388"
|
||||
"sha": "f79d57d207f039e44a31a976564715f7731e71b6"
|
||||
},
|
||||
"homepage": "https://codspeed.io"
|
||||
},
|
||||
@@ -753,7 +769,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/get-convex/convex-backend-skill.git",
|
||||
"sha": "5e59870cda2a5892e18a7164d1a46fcf57b70bea"
|
||||
"sha": "002f9c834cdb834ddef1e4867d87cb6e80f0acba"
|
||||
},
|
||||
"homepage": "https://github.com/get-convex/convex-backend-skill",
|
||||
"keywords": [
|
||||
@@ -784,7 +800,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 +846,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/dash0hq/dash0-agent-plugin.git",
|
||||
"sha": "2909be7ebc2804af464e0d7f660ccc2b62d94623"
|
||||
"sha": "0ec3db6b75d98badccf533597ce9a22fec744f5f"
|
||||
},
|
||||
"homepage": "https://dash0.com/"
|
||||
},
|
||||
@@ -841,7 +857,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/astronomer/agents.git",
|
||||
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
|
||||
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
|
||||
},
|
||||
"homepage": "https://github.com/astronomer/agents"
|
||||
},
|
||||
@@ -855,7 +871,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 +881,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/astronomer/agents.git",
|
||||
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
|
||||
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
|
||||
},
|
||||
"homepage": "https://github.com/astronomer/agents"
|
||||
},
|
||||
@@ -878,7 +894,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/databases-on-aws",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "187edde6e122116b43211049195627a5069bda80"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -920,7 +936,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/datarobot-oss/datarobot-agent-skills.git",
|
||||
"sha": "8124faae2154117382b1046aa74d8901a3ffe930"
|
||||
"sha": "1bef28436b91fc945b8529d1a57b471cde3154ea"
|
||||
},
|
||||
"homepage": "https://datarobot.com"
|
||||
},
|
||||
@@ -946,7 +962,7 @@
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/deploy-on-aws",
|
||||
"ref": "main",
|
||||
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
|
||||
"sha": "187edde6e122116b43211049195627a5069bda80"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
@@ -962,7 +978,7 @@
|
||||
"url": "https://github.com/wonderwhy-er/DesktopCommanderMCP.git",
|
||||
"path": "plugins/claude",
|
||||
"ref": "main",
|
||||
"sha": "9c44119a480ec6460f82d59aeb90cf274bc3dd7b"
|
||||
"sha": "ce4669cca7a07bd5d493cedb01df400790ec9e23"
|
||||
},
|
||||
"homepage": "https://desktopcommander.app"
|
||||
},
|
||||
@@ -982,7 +998,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/dominodatalab/domino-claude-plugin.git",
|
||||
"sha": "47c6e0a7daa11b21eb6e12779c9d679569e8ffe2"
|
||||
"sha": "56c3fc39d2f2f26d58d0f27d4dad138b0edec456"
|
||||
},
|
||||
"homepage": "https://www.domino.ai"
|
||||
},
|
||||
@@ -1010,7 +1026,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/DuendeSoftware/duende-skills.git",
|
||||
"sha": "2c803785061db150d8ecea098327b404b74dbf6a"
|
||||
"sha": "72e39de9f10c5dafaa7f32f58fcdbd5a8f3e5c14"
|
||||
},
|
||||
"homepage": "https://duendesoftware.com"
|
||||
},
|
||||
@@ -1048,7 +1064,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 +1130,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/firecrawl/firecrawl-claude-plugin.git",
|
||||
"sha": "01d11b30ace699a27f9ea7decf6ce6c9857f71ff"
|
||||
"sha": "81178096fa7ae860285923948b8ba13d03c7fa0c"
|
||||
},
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-claude-plugin.git"
|
||||
},
|
||||
@@ -1217,7 +1233,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/huggingface/skills.git",
|
||||
"sha": "7a493b09c81aae09a41bd2e1fa33dfc0f68acd75"
|
||||
"sha": "49abf82b2ef2ff2bcc6ca072aac0fe8627390a1d"
|
||||
},
|
||||
"homepage": "https://github.com/huggingface/skills.git"
|
||||
},
|
||||
@@ -1231,7 +1247,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/hunter-io/claude-plugin.git",
|
||||
"sha": "c67942395cde155e9ad4ed8e3a137926f9992fb8"
|
||||
"sha": "4eb5fbbcb75e0c4c4f2c0d8aa02756165fdde629"
|
||||
},
|
||||
"homepage": "https://hunter.io"
|
||||
},
|
||||
@@ -1245,7 +1261,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/heygen-com/hyperframes.git",
|
||||
"sha": "7ea4d1c1314bd60d5273efa92626bd1d0f9c621d"
|
||||
"sha": "3c7e2f36497de471e5a63ecc6d582522d206b208"
|
||||
},
|
||||
"homepage": "https://hyperframes.heygen.com"
|
||||
},
|
||||
@@ -1410,7 +1426,7 @@
|
||||
"url": "https://github.com/pydantic/skills.git",
|
||||
"path": "plugins/logfire",
|
||||
"ref": "main",
|
||||
"sha": "0c38c5bb5679f6cc41956bbbf811396a0d108ac9"
|
||||
"sha": "56dadc9c3b4551b7baf01a9a9f728dc72383bd07"
|
||||
},
|
||||
"homepage": "https://github.com/pydantic/skills/tree/main/plugins/logfire"
|
||||
},
|
||||
@@ -1485,7 +1501,7 @@
|
||||
"url": "https://github.com/modelcontextprotocol/ext-apps.git",
|
||||
"path": "plugins/mcp-apps",
|
||||
"ref": "main",
|
||||
"sha": "9a37ad71827d076af06978fa7f7f510449687061"
|
||||
"sha": "7d4434e991516d91286dded5bb10266cd6cddd02"
|
||||
},
|
||||
"homepage": "https://modelcontextprotocol.io"
|
||||
},
|
||||
@@ -1523,7 +1539,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 +1654,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"
|
||||
},
|
||||
@@ -1653,6 +1669,22 @@
|
||||
},
|
||||
"homepage": "https://github.com/makenotion/claude-code-notion-plugin"
|
||||
},
|
||||
{
|
||||
"name": "nvidia-skills",
|
||||
"description": "NVIDIA agent skills for accelerated-computing workflows — starting with cuOpt vehicle-routing optimization (VRP, TSP, PDP) via the cuOpt Python API.",
|
||||
"author": {
|
||||
"name": "NVIDIA"
|
||||
},
|
||||
"category": "development",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "https://github.com/NVIDIA/skills.git",
|
||||
"path": "plugins/nvidia-skills",
|
||||
"ref": "main",
|
||||
"sha": "91dbdf239b8af3b4c1511e0cfa5b505235a08ab1"
|
||||
},
|
||||
"homepage": "https://github.com/NVIDIA/skills"
|
||||
},
|
||||
{
|
||||
"name": "oracle-ai-data-platform-workbench-spark-connectors",
|
||||
"description": "Oracle AI Data Platform Workbench Spark connectors for Claude Code. 18 connector skills covering every data source workbench customers commonly need: Oracle Autonomous DB family (ALH/ADW/ATP) via wallet/IAM-DB-Token/API-key, ExaCS, Fusion ERP REST, Fusion BICC, EPM Cloud Planning, Essbase 21c, OCI Streaming (Kafka), OCI Object Storage, Apache Iceberg, plus external systems (PostgreSQL, MySQL/HeatWave, SQL Server, Snowflake, Azure ADLS Gen2, AWS S3, generic REST, custom JDBC, Excel). Live-validated on the workbench `tpcds` cluster (Spark 3.5.0): 17 PASS / 4 ship-as-is out of 21 test rows.",
|
||||
@@ -1665,7 +1697,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 +1713,7 @@
|
||||
"url": "https://github.com/growthxai/output.git",
|
||||
"path": "coding_assistants/claude/plugins/outputai",
|
||||
"ref": "main",
|
||||
"sha": "93dd22ee568a97911a332b5aa0d9cebb2b6f7da1"
|
||||
"sha": "5a87ebc13343ce6ebabac0bcc443c1da0bcf2459"
|
||||
},
|
||||
"homepage": "https://output.ai"
|
||||
},
|
||||
@@ -1729,7 +1761,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/gopigment/ai-plugins.git",
|
||||
"sha": "4bf16c80558416b9d69fa6531af8588fb2fcbe27"
|
||||
"sha": "abf36e64750d1323a4cc5fe79161597668231224"
|
||||
},
|
||||
"homepage": "https://www.pigment.com"
|
||||
},
|
||||
@@ -1801,7 +1833,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/gitroomhq/postiz-agent.git",
|
||||
"sha": "238aede6c72672b3201ae0ee533ec0cd53eb51d1"
|
||||
"sha": "41c5a9dbd6b2776863e7c05c22e7a385c208321c"
|
||||
},
|
||||
"homepage": "https://postiz.com/agent"
|
||||
},
|
||||
@@ -1846,7 +1878,7 @@
|
||||
"url": "https://github.com/pydantic/skills.git",
|
||||
"path": "plugins/ai",
|
||||
"ref": "main",
|
||||
"sha": "0c38c5bb5679f6cc41956bbbf811396a0d108ac9"
|
||||
"sha": "56dadc9c3b4551b7baf01a9a9f728dc72383bd07"
|
||||
},
|
||||
"homepage": "https://github.com/pydantic/skills/tree/main/plugins/ai"
|
||||
},
|
||||
@@ -1884,7 +1916,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/qdrant/skills.git",
|
||||
"sha": "1390c811e03922b822dc9e12b832ba4dc82e0bf0"
|
||||
"sha": "ea62a9857dabcc169597549da7681bd6d4cd13e9"
|
||||
},
|
||||
"homepage": "https://skills.qdrant.tech"
|
||||
},
|
||||
@@ -1895,7 +1927,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 +1941,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/TheQtCompanyRnD/agent-skills.git",
|
||||
"sha": "23772fa2264b3ff1037a96164b2c28d2b29a4c2f"
|
||||
"sha": "a7189a7bc17e616b725e7ce4e46a4f5ebd50d94f"
|
||||
},
|
||||
"homepage": "https://www.qt.io/"
|
||||
},
|
||||
@@ -1923,7 +1955,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/quarkusio/quarkus-agent-mcp.git",
|
||||
"sha": "77fd36284a80b3ed1bde3d2fe48a0b2f99e4941e"
|
||||
"sha": "065b4595cf9116f76bf87323fe29573d72a77a65"
|
||||
},
|
||||
"homepage": "https://quarkus.io"
|
||||
},
|
||||
@@ -1975,7 +2007,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 +2017,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 +2031,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/resend/resend-skills.git",
|
||||
"sha": "78469829399beec62b8f815f109ebfcfa3b0680b"
|
||||
"sha": "376d1c3fb37cc7d22ab21cce836f4d6f323922de"
|
||||
},
|
||||
"homepage": "https://resend.com"
|
||||
},
|
||||
@@ -2087,6 +2119,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sagemaker-ai",
|
||||
"description": "Build, train, and deploy AI models with deep AWS AI/ML expertise brought directly into your coding assistants, covering the surface area of Amazon SageMaker AI.",
|
||||
"category": "development",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "https://github.com/awslabs/agent-plugins.git",
|
||||
"path": "plugins/sagemaker-ai",
|
||||
"ref": "main",
|
||||
"sha": "187edde6e122116b43211049195627a5069bda80"
|
||||
},
|
||||
"homepage": "https://github.com/awslabs/agent-plugins"
|
||||
},
|
||||
{
|
||||
"name": "sanity",
|
||||
"description": "Sanity content platform integration with MCP server, agent skills, and slash commands. Query and author content, build and optimize GROQ queries, design schemas, and set up Visual Editing.",
|
||||
@@ -2097,7 +2142,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/sanity-io/agent-toolkit.git",
|
||||
"sha": "236348e29b31e834ce71e4e2e3072184dd1c1e27"
|
||||
"sha": "d7545f5cc6f8fb39554083b52ad074a6d912db9f"
|
||||
},
|
||||
"homepage": "https://www.sanity.io"
|
||||
},
|
||||
@@ -2131,7 +2176,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"
|
||||
},
|
||||
@@ -2170,7 +2215,7 @@
|
||||
{
|
||||
"name": "security-guidance",
|
||||
"description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
@@ -2198,7 +2243,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/getsentry/sentry-for-claude.git",
|
||||
"sha": "ed0875684192bb8a050297a896657ff9db1ffdf5"
|
||||
"sha": "849303a8411c242d250885ffe714235a3bc2f5fe"
|
||||
},
|
||||
"homepage": "https://github.com/getsentry/sentry-for-claude/tree/main"
|
||||
},
|
||||
@@ -2214,7 +2259,7 @@
|
||||
"url": "https://github.com/getsentry/cli.git",
|
||||
"path": "plugins/sentry-cli",
|
||||
"ref": "main",
|
||||
"sha": "d9bcd70eaa467fb3ddf591bfbfb0686fd1e9c016"
|
||||
"sha": "5abcacdf8f0b613bceee7aa7aebb9db1d75ed5e2"
|
||||
},
|
||||
"homepage": "https://sentry.io"
|
||||
},
|
||||
@@ -2279,7 +2324,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/Shopify/Shopify-AI-Toolkit.git",
|
||||
"sha": "c164cf45c4bc1d17bbc105168d99a4f744cfaac2"
|
||||
"sha": "859be93bfc858f183ff5eb40183e35a4d91d2950"
|
||||
},
|
||||
"homepage": "https://shopify.dev"
|
||||
},
|
||||
@@ -2364,7 +2409,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 +2422,7 @@
|
||||
"url": "https://github.com/stripe/ai.git",
|
||||
"path": "providers/claude/plugin",
|
||||
"ref": "main",
|
||||
"sha": "a34795211da530a168f581122011bb5ceb2e4bd0"
|
||||
"sha": "38cc559cab7eab62f11bc3809b93af45f28063f6"
|
||||
},
|
||||
"homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
|
||||
},
|
||||
@@ -2400,7 +2445,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"
|
||||
},
|
||||
@@ -2411,7 +2456,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/obra/superpowers.git",
|
||||
"sha": "f2cbfbefebbfef77321e4c9abc9e949826bea9d7"
|
||||
"sha": "6fd4507659784c351abbd2bc264c7162cfd386dc"
|
||||
},
|
||||
"homepage": "https://github.com/obra/superpowers.git"
|
||||
},
|
||||
@@ -2445,7 +2490,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/JetBrains/teamcity-cli.git",
|
||||
"sha": "7f8419738b452108ff181365be30c1fab0a6905e"
|
||||
"sha": "533c8cb20928be912188fd8ae40fcba24cc720ca"
|
||||
},
|
||||
"homepage": "https://www.jetbrains.com/teamcity/"
|
||||
},
|
||||
@@ -2476,7 +2521,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/togethercomputer/skills.git",
|
||||
"sha": "a1277729f7914d886df213de922865d30a214a9d"
|
||||
"sha": "f957e2929edebde0c364e804f8b38a3714db92b4"
|
||||
},
|
||||
"homepage": "https://www.together.ai"
|
||||
},
|
||||
@@ -2538,7 +2583,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 +2601,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 +2640,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/explorium-ai/vibeprospecting-plugin.git",
|
||||
"sha": "ada4d569dbf70194fe18750ecbc5170e9a3f120a"
|
||||
"sha": "7ed0c4e2965ee315132c3c714609b46b23b5edc0"
|
||||
},
|
||||
"homepage": "https://www.vibeprospecting.ai/product/claude-plugin"
|
||||
},
|
||||
@@ -2620,7 +2665,7 @@
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/wix/skills.git",
|
||||
"sha": "5da7e749a466ef9ddcdb2822099b940b9a1bc151"
|
||||
"sha": "2da8231fcdc49a48af64b050ba5e1a85c3968670"
|
||||
},
|
||||
"homepage": "https://dev.wix.com/docs/wix-cli/guides/development/about-wix-skills"
|
||||
},
|
||||
@@ -2727,7 +2772,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"
|
||||
}
|
||||
|
||||
81
.github/workflows/bump-plugin-shas.yml
vendored
81
.github/workflows/bump-plugin-shas.yml
vendored
@@ -2,25 +2,24 @@ name: Bump Plugin SHAs
|
||||
|
||||
# Nightly sweep: for each external entry whose upstream HEAD has moved past
|
||||
# its pinned SHA, validate at the new SHA with `claude plugin validate`
|
||||
# inline, then open one PR with all passing bumps. Each run force-resets the
|
||||
# bump/plugin-shas branch, so a previous night's unmerged PR is replaced (and
|
||||
# its review state discarded) — review and merge same-day to avoid churn.
|
||||
# inline, then open one PR per bumped plugin on branch `bump/<slug>`.
|
||||
# Failing entries stay isolated in their own PR; passing bumps merge
|
||||
# independently.
|
||||
#
|
||||
# Bot-free — uses the default GITHUB_TOKEN. PRs opened with GITHUB_TOKEN don't
|
||||
# trigger on:pull_request workflows, so the policy scan (`Scan Plugins`, a
|
||||
# required status check on main) would never run and the bump PR could never
|
||||
# merge. workflow_dispatch is exempt from that recursion guard, so we dispatch
|
||||
# the scan ourselves on the bump branch after the PR is opened. The check run
|
||||
# lands on the branch HEAD — the same SHA as the PR head — and satisfies the
|
||||
# required check.
|
||||
# trigger on:pull_request workflows, so the required status checks on main
|
||||
# (`scan` from Scan Plugins, `check` from Check MCP URLs, `validate` from
|
||||
# Validate Plugins) would never run and the bump PR could never merge.
|
||||
# workflow_dispatch is exempt from that recursion guard, so we dispatch all
|
||||
# three ourselves against each per-entry bump branch after its PR is opened.
|
||||
# Each check run lands on the branch HEAD — the same SHA as the PR head — and
|
||||
# satisfies the corresponding required check. (Each of those workflows runs
|
||||
# its job unconditionally on workflow_dispatch, so a dispatch always reports.)
|
||||
#
|
||||
# max-bumps is set above the external-entry count so a single run can clear
|
||||
# any backlog. The cost-control mechanisms are downstream:
|
||||
# - scan-plugins.yml caches verdicts by (plugin, sha) so an unchanged SHA
|
||||
# is never re-scanned across nightly force-resets.
|
||||
# - revert-failed-bumps.yml drops policy-failing entries from the bump PR
|
||||
# so one bad upstream can't block the rest.
|
||||
# See those files for details.
|
||||
# max-bumps caps the per-night work for cost control. Per-entry scans are
|
||||
# more expensive than a single batched scan, so the cap is conservative.
|
||||
# The composite action skips entries that already have an open bump PR, so
|
||||
# re-dispatches don't pile up duplicate work.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -30,12 +29,12 @@ on:
|
||||
max_bumps:
|
||||
description: Cap on plugins bumped this run
|
||||
required: false
|
||||
default: '130'
|
||||
default: '30'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write # gh workflow run scan-plugins.yml on the bump branch
|
||||
actions: write # gh workflow run {scan-plugins,check-mcp-urls,validate-plugins}.yml per bump branch
|
||||
|
||||
concurrency:
|
||||
group: bump-plugin-shas
|
||||
@@ -43,8 +42,8 @@ concurrency:
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
# Per-bump cost is ~2s (ls-remote + shallow clone + validate); 130 entries
|
||||
# is ~5 min. The 60 min ceiling absorbs slow upstreams without letting a
|
||||
# Per-bump cost is ~2s (ls-remote + shallow clone + validate); 30 entries
|
||||
# is ~1-2 min. The 60 min ceiling absorbs slow upstreams without letting a
|
||||
# pathological run consume the default 360 min budget.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -52,18 +51,44 @@ jobs:
|
||||
|
||||
# createCommitOnBranch-based bump so commits are signed by GitHub and
|
||||
# satisfy the org-level required_signatures ruleset on main.
|
||||
- uses: anthropics/claude-plugins-community/.github/actions/bump-plugin-shas@c41c6911de0afffd2bc5cd8b21fb1e06444ee13b
|
||||
- uses: anthropics/claude-plugins-community/.github/actions/bump-plugin-shas@e2019b2a01f11aa1484c53540b1cfab5eebbc299
|
||||
id: bump
|
||||
with:
|
||||
marketplace-path: .claude-plugin/marketplace.json
|
||||
max-bumps: ${{ inputs.max_bumps || '130' }}
|
||||
max-bumps: ${{ inputs.max_bumps || '30' }}
|
||||
pr-mode: per-entry
|
||||
claude-cli-version: latest
|
||||
|
||||
# `bump/plugin-shas` is the action's default `pr-branch`. The scan diffs
|
||||
# the branch against origin/main (the action's base-ref fallback when
|
||||
# there's no pull_request event) and scans only the bumped entries.
|
||||
- name: Dispatch policy scan on bump branch
|
||||
if: steps.bump.outputs.pr-url != ''
|
||||
# Per-entry fan-out: dispatch the three required checks against each bump
|
||||
# branch. `pr-urls` is a JSON array of {name, old_sha, new_sha, branch,
|
||||
# pr_url} entries emitted by the composite action when pr-mode is
|
||||
# per-entry. All three (scan / check / validate) are required on main and
|
||||
# none fire on the GITHUB_TOKEN-opened PR, so each must be dispatched.
|
||||
# A single failed dispatch (transient API error / rate limit) must not
|
||||
# strand the remaining branches, so we attempt every dispatch, then fail
|
||||
# the step if any failed: a missing required check would otherwise leave
|
||||
# its bump PR silently blocked behind a green run, and the composite
|
||||
# action skips slugs with an open PR so it would never be retried.
|
||||
- name: Dispatch required checks per per-entry PR
|
||||
if: steps.bump.outputs.pr-urls != '' && steps.bump.outputs.pr-urls != '[]'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run scan-plugins.yml --ref bump/plugin-shas
|
||||
PR_URLS: ${{ steps.bump.outputs.pr-urls }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dispatch_failures="$(mktemp)"
|
||||
jq -c '.[]' <<<"$PR_URLS" | while read -r entry; do
|
||||
branch=$(jq -r '.branch' <<<"$entry")
|
||||
name=$(jq -r '.name' <<<"$entry")
|
||||
for wf in scan-plugins check-mcp-urls validate-plugins; do
|
||||
echo "Dispatching ${wf}.yml against $branch ($name)"
|
||||
if ! gh workflow run "${wf}.yml" --ref "$branch"; then
|
||||
echo "::error::Failed to dispatch ${wf}.yml against $branch ($name) — required check will be missing; re-dispatch with: gh workflow run ${wf}.yml --ref $branch"
|
||||
echo "${wf} ${branch}" >> "$dispatch_failures"
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ -s "$dispatch_failures" ]; then
|
||||
echo "::error::$(wc -l < "$dispatch_failures" | tr -d ' ') required-check dispatch(es) failed; the affected bump PR(s) are blocked until re-dispatched (see annotations above)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
8
.github/workflows/validate-plugins.yml
vendored
8
.github/workflows/validate-plugins.yml
vendored
@@ -12,6 +12,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.claude-plugin/**'
|
||||
# `validate` is a required status check on main. Bump PRs are opened with
|
||||
# GITHUB_TOKEN, which doesn't fire on:pull_request (recursion guard), so the
|
||||
# path-filtered trigger above never reports on them and the PR would be
|
||||
# blocked forever. The bump workflow dispatches this against each per-entry
|
||||
# bump branch instead; the check run lands on the branch HEAD (= PR head)
|
||||
# and satisfies the required check. The validate job runs unconditionally,
|
||||
# so a dispatch always reports.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "security-guidance",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.",
|
||||
"author": {
|
||||
"name": "David Dworken",
|
||||
|
||||
@@ -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
|
||||
@@ -89,7 +116,18 @@ _PV = _read_plugin_version_int()
|
||||
# Emitted via _usage_metrics() into the existing emit_metrics() channel so
|
||||
# hook metrics rows carry per-invocation token/cost totals
|
||||
# alongside the existing skip_reason / vulns_found fields.
|
||||
_USAGE = {"in": 0, "out": 0, "cr": 0, "cw": 0, "cost": 0.0, "n": 0}
|
||||
_USAGE = {
|
||||
"in": 0, "out": 0, "cr": 0, "cw": 0, "cost": 0.0, "n": 0,
|
||||
# HTTP error visibility (#2098 visibility gap — see emit comment in
|
||||
# _usage_metrics). Without this, API failures from `_call_claude` left
|
||||
# zero fingerprint in telemetry: the call returns None, the caller's
|
||||
# emit_metrics carries no api_calls field, and the failure is
|
||||
# indistinguishable from "no review needed". The deprecation outage
|
||||
# that broke every commit-review LLM call was invisible until users
|
||||
# reported it manually.
|
||||
"http_err_last": 0, # most recent HTTP error code this invocation
|
||||
"http_err_count": 0, # total HTTP errors (4xx + 5xx + network)
|
||||
}
|
||||
_USAGE_LOCK = threading.Lock()
|
||||
|
||||
# $/Mtok (input, output). Used only for the raw-HTTP path; the SDK path
|
||||
@@ -139,19 +177,55 @@ def _record_usage(usage, model, cost_usd=None):
|
||||
_USAGE["n"] += 1
|
||||
|
||||
|
||||
def _record_http_error(status):
|
||||
"""Record an HTTP error from an LLM API call. `status` is the HTTP
|
||||
status code (integer 400–599) or -1 for network/timeout errors. Stored
|
||||
in `_USAGE["http_err_last"]` (most recent) and counted in
|
||||
`_USAGE["http_err_count"]`. Snapshot via `_usage_metrics()` so every
|
||||
subsequent `emit_metrics` includes the failure fingerprint.
|
||||
|
||||
Background: without this, the most recent example was the #2098
|
||||
deprecation 400. Every hook fire's LLM call returned HTTP 400; the
|
||||
plugin caught it and returned None; the emit_metrics carried no
|
||||
api_calls field; aggregate dashboards looked normal. The failure
|
||||
only became visible when a user manually reported errors out of
|
||||
their debug log. With this field, a category-of-failure spike (4xx,
|
||||
5xx, or -1 network) is queryable from BQ in real time.
|
||||
"""
|
||||
try:
|
||||
s = int(status)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
with _USAGE_LOCK:
|
||||
_USAGE["http_err_last"] = s
|
||||
_USAGE["http_err_count"] += 1
|
||||
|
||||
|
||||
def _usage_metrics():
|
||||
"""Snapshot the accumulator as metric keys. Returns {} when no API calls
|
||||
were made so skip-path emits don't burn key budget. cost_usd rounded to
|
||||
1e-6 to keep the float finite/short for the zod schema."""
|
||||
with _USAGE_LOCK:
|
||||
if _USAGE["n"] == 0:
|
||||
return {}
|
||||
return {
|
||||
"tok_in": _USAGE["in"],
|
||||
"tok_out": _USAGE["out"],
|
||||
"tok_cache_r": _USAGE["cr"],
|
||||
"tok_cache_w": _USAGE["cw"],
|
||||
"cost_usd": round(_USAGE["cost"], 6),
|
||||
"api_calls": _USAGE["n"],
|
||||
}
|
||||
AND no HTTP errors were made so skip-path emits don't burn key budget.
|
||||
cost_usd rounded to 1e-6 to keep the float finite/short for the zod
|
||||
schema.
|
||||
|
||||
HTTP errors (`http_err_last`, `http_err_count`) emitted ONLY when
|
||||
`http_err_count > 0` so successful calls don't pad every metrics row
|
||||
with two zero fields.
|
||||
"""
|
||||
with _USAGE_LOCK:
|
||||
if _USAGE["n"] == 0 and _USAGE["http_err_count"] == 0:
|
||||
return {}
|
||||
out = {}
|
||||
if _USAGE["n"] > 0:
|
||||
out.update({
|
||||
"tok_in": _USAGE["in"],
|
||||
"tok_out": _USAGE["out"],
|
||||
"tok_cache_r": _USAGE["cr"],
|
||||
"tok_cache_w": _USAGE["cw"],
|
||||
"cost_usd": round(_USAGE["cost"], 6),
|
||||
"api_calls": _USAGE["n"],
|
||||
})
|
||||
if _USAGE["http_err_count"] > 0:
|
||||
out["http_err_last"] = _USAGE["http_err_last"]
|
||||
out["http_err_count"] = _USAGE["http_err_count"]
|
||||
return out
|
||||
|
||||
|
||||
@@ -355,9 +355,9 @@ def _list_untracked(cwd):
|
||||
the holdouts."""
|
||||
try:
|
||||
repo = _git_toplevel(cwd) or cwd
|
||||
# core.quotePath=false comes from GIT_CMD globally (see gitutil.py).
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false", "ls-files",
|
||||
"--others", "--exclude-standard", "-z"],
|
||||
[*GIT_CMD, "ls-files", "--others", "--exclude-standard", "-z"],
|
||||
cwd=repo, capture_output=True, timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +42,122 @@ HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load
|
||||
# here no matter how the venv was built. See #2071.
|
||||
|
||||
|
||||
# Phase + err-kind integer encoding for sdk_bootstrap_phase / sdk_bootstrap_err.
|
||||
#
|
||||
# Earlier versions emitted these as STRINGS (e.g. "pip", "dns_fail"). CC's
|
||||
# plugin-metrics pipeline silently drops plugin-emitted string values —
|
||||
# only `bool|finite-number` plugin metrics reach BigQuery. (CC-core
|
||||
# metrics like `subscription_type` are exempt because they're injected
|
||||
# downstream of plugin validation.) Confirmed empirically: 185K
|
||||
# BUILD_FAILED rows in BQ had `sdk_bootstrap_phase`/`sdk_bootstrap_err`
|
||||
# = NULL despite the Python code emitting them. This left ~28K
|
||||
# BUILD_FAILED sessions/day with no diagnostic split — flying blind on
|
||||
# the real failure modes (pip-no-match vs dns-fail vs ssl-verify etc.).
|
||||
#
|
||||
# Fix: encode as small integers per the maps below. Values are
|
||||
# APPEND-ONLY for telemetry stability. Reserve 99 as the "unknown /
|
||||
# uncategorized" bucket so an unmapped err_kind (e.g., a new exception
|
||||
# type) still emits a non-zero signal.
|
||||
SDK_BOOTSTRAP_PHASE_CODES = {
|
||||
"pre": 1, # pre-venv (state_dir.mkdir, sentinel open)
|
||||
"venv": 2, # python -m venv --clear
|
||||
"pip": 3, # pip install
|
||||
"main": 4, # uncaught exception above main()
|
||||
}
|
||||
SDK_BOOTSTRAP_ERR_CODES = {
|
||||
"pip_no_match": 1,
|
||||
"dns_fail": 2,
|
||||
"conn_refused": 3,
|
||||
"ssl_verify": 4,
|
||||
"perm_denied": 5,
|
||||
"no_pip": 6,
|
||||
"disk_full": 7,
|
||||
"proxy_auth": 8,
|
||||
"stderr_timeout": 9, # pip stderr containing "timeout"/"timed out"
|
||||
"subprocess_timeout": 10, # subprocess.TimeoutExpired (>120s)
|
||||
# Venv-stage specific categories added after PR #2112 telemetry surfaced
|
||||
# 2,406 phase=2/err=99 sessions in the first 3h of v2.0.1 — venv phase
|
||||
# failing in ways the original pip-flavored patterns didn't catch. These
|
||||
# all split out of what was previously collapsing to _uncategorized.
|
||||
"venv_ensurepip_fail": 11, # Debian/Ubuntu missing python3-venv;
|
||||
# stderr mentions ensurepip non-zero exit
|
||||
# or "ensurepip is not available"
|
||||
"venv_path_too_long": 12, # Windows MAX_PATH (260) or POSIX
|
||||
# ENAMETOOLONG — venv writes deep paths
|
||||
# under state_dir/agent-sdk-venv/Lib/...
|
||||
"venv_no_module": 13, # `python3 -m venv` itself missing — "No
|
||||
# module named 'venv'" / "No module named venv"
|
||||
"venv_already_exists": 14, # Errno 17 / "file exists" — sentinel race
|
||||
# past O_EXCL or stale dir survived --clear
|
||||
"venv_setup_failed": 15, # Generic "virtual environment was not
|
||||
# created successfully" — catches the long
|
||||
# tail of venv setup failures that don't
|
||||
# match a more specific category above
|
||||
# 16–98 reserved for future categories; APPEND-ONLY.
|
||||
# 99 catches everything else (including "exc:<TypeName>" and "other:<tail>"
|
||||
# — the original string is debug-loggable but the integer is what makes
|
||||
# it to telemetry). For the "other:" tail, `sdk_bootstrap_stderr_sig`
|
||||
# carries a bounded integer hash so we can still distinguish patterns
|
||||
# in BQ aggregation.
|
||||
"_uncategorized": 99,
|
||||
}
|
||||
|
||||
|
||||
def _encode_phase(s):
|
||||
"""Map err_phase string to its telemetry integer code, or 0 if unset.
|
||||
Empty/None → 0 lets `if encoded:` cleanly skip emission. Per
|
||||
SDK_BOOTSTRAP_PHASE_CODES, valid codes are 1-4."""
|
||||
return SDK_BOOTSTRAP_PHASE_CODES.get((s or "").strip(), 0)
|
||||
|
||||
|
||||
def _encode_err_kind(s):
|
||||
"""Map err_kind string to its telemetry integer code, or 0 if unset.
|
||||
Direct hits use the static map; "exc:<X>" and "other:<tail>" both
|
||||
collapse to _uncategorized (99) — the raw string survives in debug
|
||||
logs, only the integer reaches BQ."""
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return 0
|
||||
if s in SDK_BOOTSTRAP_ERR_CODES:
|
||||
return SDK_BOOTSTRAP_ERR_CODES[s]
|
||||
# Prefix matches for the catch-all categories
|
||||
if s.startswith("exc:") or s.startswith("other:") or s == "other":
|
||||
return SDK_BOOTSTRAP_ERR_CODES["_uncategorized"]
|
||||
# Unknown string — still emit as uncategorized rather than dropping
|
||||
return SDK_BOOTSTRAP_ERR_CODES["_uncategorized"]
|
||||
|
||||
|
||||
def _encode_stderr_sig(err_kind):
|
||||
"""Bounded integer hash of the stderr tail captured in "other:<tail>"
|
||||
err_kinds. Lets us distinguish patterns INSIDE the _uncategorized
|
||||
(code 99) bucket without unbounded cardinality.
|
||||
|
||||
Returns 0 for non-"other:" err_kinds (so the field auto-omits from
|
||||
emit_metrics on categorized failures — see the emit block in main()).
|
||||
|
||||
Strategy: take the tail's first ~30 chars (post-lowercase, post-trim),
|
||||
SHA-1, fold the first 2 bytes to 0–999. Different stderr messages
|
||||
cluster into different buckets; same stderr always maps to the same
|
||||
bucket. Cardinality is bounded at 1000, well below any "high
|
||||
cardinality" alarm — and a real failure mode typically produces
|
||||
near-identical stderr across thousands of machines, so 1000 buckets
|
||||
is comfortably wide.
|
||||
|
||||
Why first ~30 chars: stderr like "ERROR: Command failed: <full
|
||||
path>" varies the tail wildly (paths) but the categorization signal
|
||||
is in the leading words. Dropping the suffix focuses the hash on
|
||||
the discriminative part.
|
||||
"""
|
||||
if not err_kind or not err_kind.startswith("other:"):
|
||||
return 0
|
||||
import hashlib
|
||||
tail = err_kind[len("other:"):].strip().lower()[:30]
|
||||
if not tail:
|
||||
return 0
|
||||
h = hashlib.sha1(tail.encode("utf-8", errors="replace")).digest()
|
||||
return int.from_bytes(h[:2], "big") % 1000
|
||||
|
||||
|
||||
def _sdk_on_syspath() -> bool:
|
||||
# find_spec is ~10ms; actually importing the SDK pulls in
|
||||
# transitive deps and costs ~800ms — too heavy for a
|
||||
@@ -90,10 +212,7 @@ def main() -> tuple[int, str, str]:
|
||||
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":
|
||||
@@ -177,7 +296,34 @@ def main() -> tuple[int, str, str]:
|
||||
else:
|
||||
stderr_str = str(stderr_b)
|
||||
s = stderr_str.lower()
|
||||
if "no matching distribution" in s or "could not find a version" in s:
|
||||
# Venv-specific patterns checked FIRST — they overlap with some pip
|
||||
# patterns (e.g. "no module named ensurepip" could match no_pip OR
|
||||
# venv_ensurepip_fail; the venv-stage interpretation is the right
|
||||
# one when err_phase=="venv"). Order is venv-most-specific →
|
||||
# pip-historical → generic.
|
||||
if err_phase == "venv" and (
|
||||
"ensurepip is not available" in s
|
||||
or ("ensurepip" in s and "returned non-zero" in s)
|
||||
or "the virtual environment was not created" in s and "ensurepip" in s
|
||||
):
|
||||
err_kind = "venv_ensurepip_fail"
|
||||
elif err_phase == "venv" and (
|
||||
"[errno 36]" in s
|
||||
or "file name too long" in s
|
||||
or "path too long" in s
|
||||
):
|
||||
err_kind = "venv_path_too_long"
|
||||
elif err_phase == "venv" and (
|
||||
"no module named venv" in s
|
||||
or "no module named 'venv'" in s
|
||||
):
|
||||
err_kind = "venv_no_module"
|
||||
elif err_phase == "venv" and (
|
||||
"[errno 17]" in s
|
||||
or ("file exists" in s and "venv" in s)
|
||||
):
|
||||
err_kind = "venv_already_exists"
|
||||
elif "no matching distribution" in s or "could not find a version" in s:
|
||||
err_kind = "pip_no_match"
|
||||
elif "name or service not known" in s or "name resolution" in s \
|
||||
or "nodename nor servname" in s or "temporary failure in name" in s:
|
||||
@@ -196,6 +342,15 @@ def main() -> tuple[int, str, str]:
|
||||
err_kind = "proxy_auth"
|
||||
elif "timeout" in s or "timed out" in s:
|
||||
err_kind = "stderr_timeout"
|
||||
elif err_phase == "venv" and (
|
||||
"virtual environment was not created" in s
|
||||
or "error: command" in s and "venv" in s
|
||||
):
|
||||
# Generic venv-setup catch-all — matched AFTER the more specific
|
||||
# venv patterns above so we don't shadow them, but BEFORE the
|
||||
# other: fallback so generic venv setup failures get their own
|
||||
# bucket instead of polluting the long-tail signature space.
|
||||
err_kind = "venv_setup_failed"
|
||||
else:
|
||||
# First 60 chars of the last non-empty stderr line — bounded to
|
||||
# stay inside CC's metric value-length budget. Real failure modes
|
||||
@@ -239,10 +394,7 @@ def _maybe_emit_user_notice(outcome: int, pv: int) -> str | None:
|
||||
if outcome != HOOK_PY_INCOMPATIBLE:
|
||||
return None
|
||||
try:
|
||||
state_dir = Path(
|
||||
os.environ.get("SECURITY_WARNINGS_STATE_DIR")
|
||||
or os.path.expanduser("~/.claude/security")
|
||||
)
|
||||
state_dir = Path(_resolve_state_dir())
|
||||
marker = state_dir / f".agentic_unavailable_notice_v{pv or 0}"
|
||||
if marker.exists():
|
||||
return None
|
||||
@@ -288,21 +440,33 @@ if __name__ == "__main__":
|
||||
# and takes the FIRST non-{"async":...} JSON line as the hook response;
|
||||
# its `metrics` key is forwarded to the hook metrics event on the
|
||||
# next attachments pass. Must be a single line — the registry splits on
|
||||
# \n and json-parses each independently. Values must be bool|number OR
|
||||
# short strings (CC accepts string metric values if they're not
|
||||
# null). Stay inside the 10-key emit cap.
|
||||
# \n and json-parses each independently.
|
||||
#
|
||||
# IMPORTANT — values must be bool|finite-number. The validation comment
|
||||
# has historically said "or short strings" but that was wrong: CC's
|
||||
# plugin-metrics pipeline silently drops plugin-emitted string values.
|
||||
# Stay inside the 10-key emit cap.
|
||||
metrics: dict[str, object] = {
|
||||
"sdk_bootstrap": outcome,
|
||||
"sdk_bootstrap_ms": round((time.perf_counter() - t0) * 1000),
|
||||
}
|
||||
if err_kind:
|
||||
# Truncate defensively; categorized values are <40 chars but the
|
||||
# `other:<tail>` mode could be longer. err_phase may be empty for
|
||||
# pre-venv failures (state_dir.mkdir perm-denied, sentinel O_EXCL
|
||||
# raising a non-FileExistsError OSError) — emit as "pre" so the
|
||||
# err_kind isn't silently dropped.
|
||||
metrics["sdk_bootstrap_phase"] = (err_phase or "pre")[:16]
|
||||
metrics["sdk_bootstrap_err"] = err_kind[:96]
|
||||
# Encode phase + err_kind as integer codes (see
|
||||
# SDK_BOOTSTRAP_PHASE_CODES / SDK_BOOTSTRAP_ERR_CODES). Earlier
|
||||
# versions emitted these as strings and CC dropped them — restoring
|
||||
# the diagnostic split that 28K BUILD_FAILED/day need to triage by
|
||||
# root cause. err_phase defaults to "pre" when empty (pre-venv
|
||||
# failure path, e.g. state_dir.mkdir perm-denied).
|
||||
metrics["sdk_bootstrap_phase"] = _encode_phase(err_phase or "pre")
|
||||
metrics["sdk_bootstrap_err"] = _encode_err_kind(err_kind)
|
||||
# For "other:<tail>" (encoded err==99), emit a bounded integer
|
||||
# hash of the stderr tail so BQ can distinguish patterns inside
|
||||
# the _uncategorized bucket without unbounded cardinality. Zero
|
||||
# when err_kind is categorized — the schema reader treats 0 as
|
||||
# "no signal", matching the absence convention.
|
||||
sig = _encode_stderr_sig(err_kind)
|
||||
if sig:
|
||||
metrics["sdk_bootstrap_stderr_sig"] = sig
|
||||
pv = _plugin_version_int()
|
||||
if pv:
|
||||
metrics["pv"] = pv
|
||||
|
||||
@@ -26,18 +26,34 @@ GIT_CMD = [
|
||||
"git",
|
||||
"-c", "core.fsmonitor=false",
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
# core.quotePath=false: emit raw UTF-8 in path-emitting commands instead
|
||||
# of C-quoting non-ASCII bytes (default `"\\303\\201vila/..."` vs
|
||||
# `Ávila/...`). Downstream parsers — both ours (parse_diff_into_files,
|
||||
# extract_file_paths_from_diff) and Python stdlib (os.path.isabs,
|
||||
# os.path.join) — expect raw paths and silently drop / mishandle the
|
||||
# quoted form. Adding the flag globally to GIT_CMD covers every
|
||||
# subprocess.run site that uses the splat — diff feeders, rev-parse
|
||||
# path queries (--show-toplevel, --git-dir, --git-common-dir),
|
||||
# reflog %gs subjects, ls-files, status, etc. — without per-site
|
||||
# flag duplication. See #2082, #2099.
|
||||
"-c", "core.quotePath=false",
|
||||
]
|
||||
|
||||
|
||||
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 +68,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 +148,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 +168,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 +194,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 +211,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,15 +233,12 @@ 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).
|
||||
# GIT_CMD globally passes core.quotePath=false (see definition) so
|
||||
# non-ASCII paths in `diff --git a/... b/...` headers come through as
|
||||
# raw UTF-8, not C-quoted. Required by the downstream
|
||||
# parse_diff_into_files / extract_file_paths_from_diff regex.
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false",
|
||||
"diff", "-p", "--no-color", "--no-ext-diff", base, head],
|
||||
[*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", base, head],
|
||||
cwd=repo_root, capture_output=True, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -220,9 +251,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
|
||||
@@ -330,8 +363,9 @@ def _git_name_only(cwd, base, include_untracked=False):
|
||||
# result.stdout=None, and propagate AttributeError out of the helper.
|
||||
# Same fix shape as diffstate._list_untracked. See #2056.
|
||||
def _run(env):
|
||||
# core.quotePath=false comes from GIT_CMD globally (see definition).
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false", "diff", "--name-only", "-z", base],
|
||||
[*GIT_CMD, "diff", "--name-only", "-z", base],
|
||||
cwd=cwd, capture_output=True, timeout=30,
|
||||
env=env,
|
||||
)
|
||||
@@ -368,9 +402,9 @@ def _git_status_porcelain(cwd):
|
||||
# sibling helpers — a non-ASCII path in the worktree would otherwise
|
||||
# crash the cp1252 reader thread on Windows. See #2056.
|
||||
try:
|
||||
# core.quotePath=false comes from GIT_CMD globally (see definition).
|
||||
r = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false", "status",
|
||||
"--porcelain=v1", "-uall", "-z"],
|
||||
[*GIT_CMD, "status", "--porcelain=v1", "-uall", "-z"],
|
||||
cwd=cwd, capture_output=True, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -410,9 +444,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):
|
||||
@@ -443,11 +480,8 @@ def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_pa
|
||||
# change exists to fix.
|
||||
return ""
|
||||
|
||||
# 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
|
||||
# core.quotePath=false comes from GIT_CMD globally (see definition).
|
||||
cmd = [*GIT_CMD, "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, _record_http_error, _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
|
||||
@@ -371,6 +368,7 @@ def _call_claude_via_sdk(prompt, output_schema, *, max_tokens=16000, model=None)
|
||||
except Exception as e:
|
||||
debug_log(f"3P sdk-single-turn: SDK unavailable ({e})")
|
||||
_last_call_claude_http_error = -1
|
||||
_record_http_error(-1)
|
||||
return None
|
||||
|
||||
cli_path = os.environ.get("SG_AGENTIC_CLI_PATH") or None
|
||||
@@ -428,6 +426,7 @@ def _call_claude_via_sdk(prompt, output_schema, *, max_tokens=16000, model=None)
|
||||
except _asyncio.TimeoutError:
|
||||
debug_log("3P sdk-single-turn: timeout after 60s")
|
||||
_last_call_claude_http_error = -1
|
||||
_record_http_error(-1)
|
||||
return None
|
||||
except Exception as e:
|
||||
debug_log(f"3P sdk-single-turn: query failed ({e})")
|
||||
@@ -436,6 +435,7 @@ def _call_claude_via_sdk(prompt, output_schema, *, max_tokens=16000, model=None)
|
||||
for _l in _captured_stderr[:20]:
|
||||
debug_log(f" | {_l.rstrip()}")
|
||||
_last_call_claude_http_error = -1
|
||||
_record_http_error(-1)
|
||||
return None
|
||||
|
||||
|
||||
@@ -482,10 +482,21 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
|
||||
"max_tokens": max_tokens,
|
||||
"system": CLAUDE_CODE_SYSTEM_PROMPT,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"output_format": {
|
||||
"type": "json_schema",
|
||||
"schema": output_schema
|
||||
}
|
||||
# API moved the structured-output schema from top-level `output_format`
|
||||
# to `output_config.format` per
|
||||
# https://platform.claude.com/docs/en/build-with-claude/structured-outputs.
|
||||
# The old form "continues to work for a transition period" for some
|
||||
# auth modes (API key + non-streaming), but is rejected with
|
||||
# `invalid_request_error: output_format: This field is deprecated.
|
||||
# Use 'output_config.format' instead.` for others (OAuth Bearer +
|
||||
# newer CLI versions hit it consistently — reporter saw 462 errors
|
||||
# in one day). See #2098.
|
||||
"output_config": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"schema": output_schema,
|
||||
},
|
||||
},
|
||||
}
|
||||
if thinking_budget > 0:
|
||||
# Models trained on adaptive thinking (4.6+) reject the budget_tokens
|
||||
@@ -493,7 +504,10 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
|
||||
# models (4.5 and earlier, all 3.x) reject adaptive. Pick by model.
|
||||
if _model_supports_adaptive_thinking(payload["model"]):
|
||||
payload["thinking"] = {"type": "adaptive"}
|
||||
payload["output_config"] = {"effort": "high"}
|
||||
# Merge `effort` into the existing output_config dict (which
|
||||
# now carries the `format` schema) rather than reassigning —
|
||||
# otherwise the schema is silently overwritten. See #2098.
|
||||
payload["output_config"]["effort"] = "high"
|
||||
else:
|
||||
payload["thinking"] = {
|
||||
"type": "enabled",
|
||||
@@ -531,6 +545,7 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
|
||||
error_body = e.read().decode("utf-8") if e.fp else ""
|
||||
debug_log(f"API error: {e.code} - {error_body[:200]}")
|
||||
_last_call_claude_http_error = e.code
|
||||
_record_http_error(e.code)
|
||||
return None
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt < 2:
|
||||
@@ -540,6 +555,7 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
|
||||
else:
|
||||
debug_log(f"Request failed after retries: {e}")
|
||||
_last_call_claude_http_error = -1
|
||||
_record_http_error(-1)
|
||||
return None
|
||||
|
||||
if not response_data:
|
||||
@@ -548,6 +564,7 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
|
||||
# call uses the token; record the 401 so callers don't see error=None.
|
||||
if _last_call_claude_http_error is None:
|
||||
_last_call_claude_http_error = 401
|
||||
_record_http_error(401)
|
||||
return None
|
||||
|
||||
# Find the text block (skip thinking blocks)
|
||||
@@ -1145,10 +1162,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,45 @@ 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.
|
||||
Delivery channel depends on `hook_event_name` because CC's hook-output
|
||||
contract is NOT symmetric across events:
|
||||
|
||||
- PostToolUse (commit-review, push-sweep): surfaced via the modern
|
||||
hookSpecificOutput.additionalContext protocol. `PostToolUse` is a
|
||||
member of CC's hookSpecificOutput discriminated union
|
||||
(coreSchemas.ts), so the JSON validates and metrics/rewakeSummary
|
||||
are consumed. See #1375 / #1783 for why this replaced the legacy
|
||||
stderr + exit(2) shape for PostToolUse.
|
||||
|
||||
- Stop / SubagentStop: there is NO `Stop` member in that union, so
|
||||
emitting hookSpecificOutput{hookEventName:"Stop"} makes the whole
|
||||
line fail isSyncHookJSONOutput validation — which on the asyncRewake
|
||||
path silently drops metrics AND rewakeSummary, and (because the
|
||||
legacy stderr write was removed) leaks the raw JSON to the model as
|
||||
the rewake body. CC's asyncRewake delivery actually reads
|
||||
`stderr || stdout` for the model-visible body and only scans stdout
|
||||
JSON for metrics+rewakeSummary — it never reads additionalContext
|
||||
on this path. So for Stop we use the documented clean pattern:
|
||||
guidance on stderr, valid JSON (metrics + rewakeSummary +
|
||||
top-level decision/reason) on stdout. The top-level decision:"block"
|
||||
+ reason also covers the sync-fallback path (single-shot `claude -p`,
|
||||
where asyncRewake degrades to a sync Stop hook that reads
|
||||
decision/reason). See #2159.
|
||||
|
||||
Empty/None additional_context emits neither channel (back-compat for
|
||||
metrics-only callers).
|
||||
|
||||
`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): selects the
|
||||
delivery channel above. Defaults to "PostToolUse" (commit-review and
|
||||
push-sweep are the most common callers); handle_stop_hook passes "Stop".
|
||||
"""
|
||||
head = {}
|
||||
if _PV and "pv" not in metrics:
|
||||
@@ -223,6 +269,26 @@ def emit_metrics(metrics, rewake_summary=None):
|
||||
out = {"metrics": metrics}
|
||||
if rewake_summary:
|
||||
out["rewakeSummary"] = rewake_summary
|
||||
if additional_context:
|
||||
if hook_event_name in ("Stop", "SubagentStop"):
|
||||
# Stop is NOT in CC's hookSpecificOutput union — emitting it there
|
||||
# fails schema validation and drops metrics+rewakeSummary (#2159).
|
||||
# Clean pattern: guidance on stderr (the asyncRewake body channel,
|
||||
# delivered via `stderr || stdout`), top-level decision/reason for
|
||||
# the sync-fallback path. stdout JSON stays valid so metrics +
|
||||
# rewakeSummary survive.
|
||||
sys.stderr.write(additional_context)
|
||||
sys.stderr.flush()
|
||||
out["decision"] = "block"
|
||||
out["reason"] = additional_context
|
||||
else:
|
||||
# PostToolUse et al. — valid union member; modern protocol.
|
||||
out["hookSpecificOutput"] = {
|
||||
"hookEventName": hook_event_name,
|
||||
"additionalContext": additional_context,
|
||||
}
|
||||
if system_message:
|
||||
out["systemMessage"] = system_message
|
||||
print(json.dumps(out), flush=True)
|
||||
|
||||
# =====================================================================
|
||||
@@ -510,7 +576,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 +664,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 +715,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 +887,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
|
||||
@@ -1121,18 +1224,18 @@ def handle_commit_review_posttooluse(input_data):
|
||||
# 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.
|
||||
# core.quotePath=false comes from GIT_CMD globally (see gitutil.py).
|
||||
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, "-c", "core.quotePath=false",
|
||||
"diff", "--no-color", "--no-ext-diff", pre_amend_sha, sha, "--"],
|
||||
[*GIT_CMD, "diff", "--no-color", "--no-ext-diff",
|
||||
pre_amend_sha, sha, "--"],
|
||||
cwd=repo_root, capture_output=True, timeout=15
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[*GIT_CMD, "-c", "core.quotePath=false",
|
||||
"show", "-p", "--no-color", "--no-ext-diff", sha, "--"],
|
||||
[*GIT_CMD, "show", "-p", "--no-color", "--no-ext-diff", sha, "--"],
|
||||
cwd=repo_root, capture_output=True, timeout=15
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
@@ -1259,12 +1362,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
|
||||
@@ -1366,18 +1470,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):
|
||||
@@ -1458,9 +1570,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 "")
|
||||
@@ -1490,14 +1603,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
|
||||
@@ -1511,11 +1625,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:
|
||||
@@ -1634,17 +1749,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
|
||||
@@ -1697,9 +1818,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):
|
||||
@@ -1932,6 +2058,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),
|
||||
@@ -1945,10 +2076,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:
|
||||
@@ -1976,10 +2107,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
|
||||
|
||||
Reference in New Issue
Block a user