feat: improve account appeal notifications (#2520)

* feat: improve account appeal notifications

Adds Resend-backed account-action emails for bans/restores, public-safe ban reason summaries, scanner-triggered appeal scan guidance, and inline banned-account sign-in appeal messaging.

Autoreview findings addressed:

- [P2] Wire restored-account email into unban flow

- [P2] Add the scan upload test to the CI selection

* feat: quarantine malicious artifact uploads

* fix: warn on repeated malicious rejections

* fix: route banned sign-ins to appeal page

* feat: add stored scan report downloads

* chore: bump clawhub cli to 0.20.0

* test: cover malicious skill ban journey

* test: align deletion cleanup expectations after merge

* fix: hide deleted account packages before cleanup

* fix: finalize appeals moderation flow
This commit is contained in:
Patrick Erichsen
2026-06-06 16:55:23 -07:00
committed by GitHub
parent 8f7c21ac02
commit 8e1bad4ac1
59 changed files with 4583 additions and 377 deletions
+5
View File
@@ -24,3 +24,8 @@ DEV_AUTH_SECRET=
# Embeddings
OPENAI_API_KEY=
# Transactional email
RESEND_API_KEY=
CLAWHUB_SECURITY_EMAIL=security@notifications.openclaw.ai
CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security <noreply@notifications.openclaw.ai>
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
## 0.20.0 - 2026-06-06
### Changes
- CLI/API: replace local `clawhub scan` uploads with stored submitted-version scan report downloads, including owner-authorized `clawhub scan download <name> --version <version>` support for blocked skill and plugin submissions.
## 0.19.2 - 2026-06-05
### Fixes
+73 -96
View File
@@ -1,6 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"configVersion": 1,
"workspaces": {
"": {
"name": "clawhub",
@@ -43,6 +43,7 @@
"rehype-raw": "7.0.0",
"rehype-sanitize": "6.0.0",
"remark-gfm": "4.0.1",
"resend": "6.12.4",
"semver": "7.8.2",
"shiki": "4.2.0",
"sonner": "2.0.7",
@@ -81,7 +82,7 @@
},
"packages/clawhub": {
"name": "clawhub",
"version": "0.19.2",
"version": "0.20.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js",
@@ -160,39 +161,39 @@
"@auth/core": ["@auth/core@0.37.4", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^5.9.6", "oauth4webapi": "^3.1.1", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
@@ -212,7 +213,7 @@
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.4", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.5", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
@@ -274,7 +275,7 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
"@faker-js/faker": ["@faker-js/faker@10.4.0", "", {}, "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw=="],
@@ -540,35 +541,35 @@
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gCYzGOSkYY6Z034suzd20euvds7lPzMEEla62DJGE/ZAlR4OMBnNbvnBSsIGUCAr52gaWMsloGxP4tVGtN5aCA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQBD77MNgu+4Z6RAyg69acugdrhhVoWesr3l47zohYZ2YV2fwkWMArkN/2p4l6Ei+Sno7W5q+UsKdVWq5Ens0w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-p/8cXUTK4Sob604e+xxPhVSbDFf29E6J0l/xESM9rdCfn3aDai3nEs6TnMHUsdD5aNlFz0+gDbiGlozLKGa2YA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KbtOSlVv6fElujiZWMcC3aQYhEwLVVf073RcwlSmpGQvIsKZFUqc0ef4sjUuurRwfbiI6JJXji9DQn+86hawmQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9fZ9i0o0/MQaw7om6Z6TsT7tfCk0jtbEFtC+aPqZL5RNsGWNcHvn6EHgL3dAprjq+AZzPTAQjg2JtpJaMt+6pg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tog7T66i+yFyIuuAnjL6xmW182W/qTBOUt6BtQ6lBIM1Eikh/fSMz4HGgvuCp5uU0zuIVWng7kDYthjCMOHcg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4b7yruLIIj/oZ3GpcLOvxcLCLDMraohn3IhQfN2hBP4w9UekG0DTIajWguJosRGfySf/+h/NwRUiMKoCpxCrqQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QRDOVZd0bhQ5jLsUsCC3dUxDWdTSVY9WMznowZgCGOrZfLLgctWpelhUASEiBwsXfat/JwYnVd1EaxMhqyT+UQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ypxT+Hq76NFG7woFbNbySnGEajFuYuIXeKz/jfCU+lXUoxfi3zLE6OG/ZQNeK3RpZSYJlAe2bokpsQ046CaieQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IdovCmfROFmpTLahdecTDFL74aLERVYN68F/mLZjfVh6LfoplPfI6deyHNMTcVujbokDV5k05XrFO22zfv+qjg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pcA8xlFp2tyk9T2R6Fi/rPe3bQ1MA+sSMDNUU5Ogu80GHOatkE4P8YCreGAvZErm5Ho2YRXnyvNrWiRncfVysQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4+fexHayrLCWpriPh4c6dNvL4an34DEZCG7zOM/FD5QNF6h8DT+bDXzyB/kfC8lDJbaFb7jKShtnjDQFXVQEjg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-SbL++MNmOw6QamrwIGDMSSfM4ceTzFr+RjbOExJSLLBinScU4WI5OdA413h1qwPw2yH7lVF1+H4svQ+6mSXKTQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+xTE6XC7wBgk0VKRXGG+QAnyW5S9b8vfsFpiMjf0waQTmSQSU8onsH/beyZ8X4aXVveJnotiy7VDjLOaW8bTrg=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ogji1TQNqH3ACLnYr+1Ns1nyrJ0CO2P585u9Hsh02pXvtFiFpgtgT2b3P4PnCOU86VVCvqtAeCN4OftMT8KU4w=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -590,6 +591,8 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
@@ -728,9 +731,7 @@
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"ansis": ["ansis@4.3.0", "", {}, "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -744,23 +745,19 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -776,7 +773,7 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -836,13 +833,13 @@
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.354", "", {}, "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"env-runner": ["env-runner@0.1.7", "", { "dependencies": { "crossws": "^0.4.4", "exsolve": "^1.0.8", "httpxy": "^0.5.0", "srvx": "^0.11.13" }, "peerDependencies": { "@netlify/runtime": "^4", "miniflare": "^4.20260317.3" }, "optionalPeers": ["@netlify/runtime", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA=="],
"env-runner": ["env-runner@0.1.9", "", { "dependencies": { "crossws": "^0.4.5", "exsolve": "^1.0.8", "httpxy": "^0.5.3", "srvx": "^0.11.15" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": "^0.2.0", "miniflare": "^4.20260515.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
@@ -862,11 +859,13 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -874,8 +873,6 @@
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -884,8 +881,6 @@
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="],
@@ -924,7 +919,7 @@
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"httpxy": ["httpxy@0.5.1", "", {}, "sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A=="],
"httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -934,29 +929,21 @@
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="],
"isbot": ["isbot@5.1.41", "", {}, "sha512-9WFV/Vhh0FEj6CQ7MoHweEL9/vLKPjeoD2I2htbAjX7kbW7VJs3OCpWOVyd+JraNTWVU6/DRx2MZy2KaUNXHcg=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -970,7 +957,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
@@ -980,7 +967,7 @@
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1010,7 +997,7 @@
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
@@ -1020,7 +1007,7 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
@@ -1130,13 +1117,11 @@
"nitro": ["nitro@3.0.260429-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.20", "hookable": "^6.1.1", "nf3": "^0.3.16", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.17", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.6", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.2", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KweLVCUN5X9v9g+4yxAyRcz3FcOlnjmt9FyrAIWDxJETJmNT7I0JV0clgsONjo2nI0U5gwedXYA3RaNtF5XWzg=="],
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
"oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
"ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="],
@@ -1180,6 +1165,8 @@
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
"postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="],
"postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
@@ -1190,7 +1177,7 @@
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -1208,7 +1195,7 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
@@ -1230,9 +1217,11 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resend": ["resend@6.12.4", "", { "dependencies": { "postal-mime": "2.7.4", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="],
"rolldown": ["rolldown@1.1.0", "", { "dependencies": { "@oxc-project/types": "=0.134.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.0", "@rolldown/binding-darwin-arm64": "1.1.0", "@rolldown/binding-darwin-x64": "1.1.0", "@rolldown/binding-freebsd-x64": "1.1.0", "@rolldown/binding-linux-arm-gnueabihf": "1.1.0", "@rolldown/binding-linux-arm64-gnu": "1.1.0", "@rolldown/binding-linux-arm64-musl": "1.1.0", "@rolldown/binding-linux-ppc64-gnu": "1.1.0", "@rolldown/binding-linux-s390x-gnu": "1.1.0", "@rolldown/binding-linux-x64-gnu": "1.1.0", "@rolldown/binding-linux-x64-musl": "1.1.0", "@rolldown/binding-openharmony-arm64": "1.1.0", "@rolldown/binding-wasm32-wasi": "1.1.0", "@rolldown/binding-win32-arm64-msvc": "1.1.0", "@rolldown/binding-win32-x64-msvc": "1.1.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-zpMvlJhs5PkXRTtKc0CaLBVI9AR/VDiJFpM+kx//hgToEca7FgMlGjaRIisXBcb19T76LswgmKECSQ96hjWr5A=="],
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
@@ -1248,7 +1237,7 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
@@ -1266,10 +1255,12 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
"srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
@@ -1298,7 +1289,7 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -1306,11 +1297,9 @@
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
@@ -1480,27 +1469,17 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/router-plugin/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"jsdom/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"make-dir/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.134.0", "", {}, "sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -1526,8 +1505,6 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tanstack/router-plugin/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
+4
View File
@@ -19,6 +19,7 @@ import type * as devSeed from "../devSeed.js";
import type * as devSeedExtra from "../devSeedExtra.js";
import type * as downloadMetrics from "../downloadMetrics.js";
import type * as downloads from "../downloads.js";
import type * as emailsNode from "../emailsNode.js";
import type * as functions from "../functions.js";
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
import type * as githubBackups from "../githubBackups.js";
@@ -59,6 +60,7 @@ import type * as lib_contentTypes from "../lib/contentTypes.js";
import type * as lib_depRegistryScan from "../lib/depRegistryScan.js";
import type * as lib_devAuth from "../lib/devAuth.js";
import type * as lib_devSeed from "../lib/devSeed.js";
import type * as lib_emails from "../lib/emails.js";
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
@@ -171,6 +173,7 @@ declare const fullApi: ApiFromModules<{
devSeedExtra: typeof devSeedExtra;
downloadMetrics: typeof downloadMetrics;
downloads: typeof downloads;
emailsNode: typeof emailsNode;
functions: typeof functions;
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
githubBackups: typeof githubBackups;
@@ -211,6 +214,7 @@ declare const fullApi: ApiFromModules<{
"lib/depRegistryScan": typeof lib_depRegistryScan;
"lib/devAuth": typeof lib_devAuth;
"lib/devSeed": typeof lib_devSeed;
"lib/emails": typeof lib_emails;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
+2 -2
View File
@@ -128,7 +128,7 @@ describe("handleDeletedUserSignIn", () => {
expect(ctx.db.patch).not.toHaveBeenCalled();
});
it("includes the moderator ban reason in the sign-in error", async () => {
it("does not leak the moderator ban reason in the sign-in error", async () => {
const { ctx } = makeCtx({
user: { deletedAt: 123, banReason: "Chargeback fraud" },
banRecords: [{ action: "user.ban" }],
@@ -136,6 +136,6 @@ describe("handleDeletedUserSignIn", () => {
await expect(
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
).rejects.toThrow(`${BANNED_REAUTH_MESSAGE} Reason: Chargeback fraud`);
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
});
});
+5 -9
View File
@@ -9,19 +9,15 @@ import { isLocalDevAuthEnabled } from "./lib/devAuth";
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
export const BANNED_REAUTH_MESSAGE =
"This account has been banned and cannot sign in. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.";
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
export const DELETED_ACCOUNT_REAUTH_MESSAGE =
"This account has been permanently deleted and cannot be restored.";
const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
const DEV_PERSONAS = new Set(["owner", "user", "admin", "officialOrgMember"]);
const DEV_PERSONAS = new Set(["owner", "user", "admin", "officialOrgMember", "abusePublisher"]);
function getBannedReauthMessage(reason: string | undefined) {
const normalizedReason = reason?.trim();
if (!normalizedReason || normalizedReason.toLowerCase() === "malware auto-ban") {
return BANNED_REAUTH_MESSAGE;
}
return `${BANNED_REAUTH_MESSAGE} Reason: ${normalizedReason}`;
function getBannedReauthMessage(_reason: string | undefined) {
return BANNED_REAUTH_MESSAGE;
}
export async function handleDeletedUserSignIn(
@@ -98,7 +94,7 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
const persona = typeof credentials.persona === "string" ? credentials.persona : "";
if (!DEV_PERSONAS.has(persona)) throw new Error("Unknown dev persona");
const userId: Id<"users"> = await ctx.runMutation(internal.users.upsertDevPersonaInternal, {
persona: persona as "owner" | "user" | "admin" | "officialOrgMember",
persona: persona as "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher",
devAuthSecret,
});
return { userId };
+4
View File
@@ -3664,6 +3664,10 @@ export const getAccountDeletionFixtureState: ReturnType<typeof rawInternalMutati
skillActive: Boolean(skill && !skill.softDeletedAt),
skillSoftDeletedAt: skill?.softDeletedAt ?? null,
packageExists: Boolean(pkg),
skillPubliclyVisible: Boolean(
skill && !skill.softDeletedAt && !skill.hiddenAt && skill.moderationStatus !== "removed",
),
packagePubliclyVisible: Boolean(pkg && !pkg.softDeletedAt),
packageActive: Boolean(pkg && !pkg.softDeletedAt),
packageSoftDeletedAt: pkg?.softDeletedAt ?? null,
authAccountCount: authAccounts.length,
+158
View File
@@ -0,0 +1,158 @@
"use node";
import { mkdir, appendFile } from "node:fs/promises";
import { dirname } from "node:path";
import { v } from "convex/values";
import { Resend } from "resend";
import { internalAction } from "./functions";
import {
buildBanNotificationEmail,
buildMaliciousArtifactEmail,
buildRestoredAccountEmail,
type NotificationArtifact,
} from "./lib/emails";
const DEFAULT_FROM = "ClawHub Security <noreply@notifications.openclaw.ai>";
const DEFAULT_REPLY_TO = "security@notifications.openclaw.ai";
const notificationArtifactValidator = v.object({
kind: v.union(v.literal("skill"), v.literal("plugin")),
name: v.string(),
});
type SendEmailArgs = {
idempotencyKey: string;
to: string;
subject: string;
text: string;
html: string;
};
function getEmailConfig() {
return {
apiKey: process.env.RESEND_API_KEY,
from: process.env.CLAWHUB_SECURITY_EMAIL_FROM || DEFAULT_FROM,
replyTo: process.env.CLAWHUB_SECURITY_EMAIL || DEFAULT_REPLY_TO,
};
}
async function sendTransactionalEmail(args: SendEmailArgs) {
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE?.trim();
if (captureFile) {
await mkdir(dirname(captureFile), { recursive: true });
await appendFile(
captureFile,
`${JSON.stringify({ ...args, capturedAt: Date.now() })}\n`,
"utf8",
);
return { ok: true as const, id: "local-capture" };
}
const config = getEmailConfig();
if (!config.apiKey) {
console.warn(`[emails] RESEND_API_KEY is not configured; skipped ${args.idempotencyKey}`);
return { ok: false as const, reason: "missing_api_key" as const };
}
try {
const resend = new Resend(config.apiKey);
const result = await resend.emails.send(
{
from: config.from,
to: args.to,
replyTo: config.replyTo,
subject: args.subject,
text: args.text,
html: args.html,
},
{ idempotencyKey: args.idempotencyKey },
);
if (result.error) {
console.error("[emails] Resend error", result.error);
return { ok: false as const, reason: "resend_error" as const };
}
return { ok: true as const, id: result.data?.id ?? null };
} catch (error) {
console.error("[emails] Send failed", error);
return { ok: false as const, reason: "send_error" as const };
}
}
export const sendBanNotificationInternal = internalAction({
args: {
userId: v.id("users"),
bannedAt: v.number(),
to: v.string(),
handle: v.optional(v.string()),
source: v.union(v.literal("manual"), v.literal("autoban")),
reason: v.optional(v.string()),
trigger: v.optional(v.string()),
artifact: v.optional(notificationArtifactValidator),
},
handler: async (_ctx, args) => {
const email = buildBanNotificationEmail({
handle: args.handle,
source: args.source,
reason: args.reason,
trigger: args.trigger,
artifact: args.artifact as NotificationArtifact | undefined,
});
return await sendTransactionalEmail({
idempotencyKey: `ban:${args.userId}:${args.bannedAt}`,
to: args.to,
subject: email.subject,
text: email.text,
html: email.html,
});
},
});
export const sendRestoredAccountNotificationInternal = internalAction({
args: {
userId: v.id("users"),
restoredAt: v.number(),
to: v.string(),
handle: v.optional(v.string()),
restoredListings: v.optional(v.array(notificationArtifactValidator)),
},
handler: async (_ctx, args) => {
const email = buildRestoredAccountEmail({
handle: args.handle,
restoredListings: args.restoredListings as NotificationArtifact[] | undefined,
});
return await sendTransactionalEmail({
idempotencyKey: `account-restored:${args.userId}:${args.restoredAt}`,
to: args.to,
subject: email.subject,
text: email.text,
html: email.html,
});
},
});
export const sendMaliciousArtifactNotificationInternal = internalAction({
args: {
userId: v.id("users"),
findingAt: v.number(),
to: v.string(),
handle: v.optional(v.string()),
artifact: notificationArtifactValidator,
version: v.optional(v.string()),
trigger: v.optional(v.string()),
},
handler: async (_ctx, args) => {
const email = buildMaliciousArtifactEmail({
handle: args.handle,
artifact: args.artifact as NotificationArtifact,
version: args.version,
trigger: args.trigger,
});
return await sendTransactionalEmail({
idempotencyKey: `malicious-artifact:${args.userId}:${args.findingAt}:${args.artifact.kind}:${args.artifact.name}:${args.version ?? ""}`,
to: args.to,
subject: email.subject,
text: email.text,
html: email.html,
});
},
});
+70
View File
@@ -257,6 +257,76 @@ beforeEach(() => {
});
describe("httpApiV1 handlers", () => {
it("rejects local scan upload submissions with scan-download guidance", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:owner",
user: { _id: "users:owner", role: "user" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
});
const response = await __handlers.skillScanSubmitV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/-/scan", {
method: "POST",
body: JSON.stringify({ source: { kind: "upload" } }),
}),
);
expect(response.status).toBe(410);
expect(await response.text()).toContain("clawhub scan download <slug> --version <version>");
});
it("downloads stored scan reports for submitted skill versions", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:owner",
user: { _id: "users:owner", role: "user" },
} as never);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
expect(args).toMatchObject({
actorUserId: "users:owner",
kind: "skill",
name: "demo-skill",
version: "1.2.3",
});
return {
ok: true,
scanId: "skill:demo-skill:1.2.3",
status: "succeeded",
sourceKind: "published",
update: false,
writtenBack: true,
artifact: {
kind: "skill",
slug: "demo-skill",
version: "1.2.3",
},
report: {
clawscan: { status: "malicious", checkedAt: 1 },
skillspector: null,
staticAnalysis: null,
virustotal: null,
},
createdAt: 1,
updatedAt: 1,
completedAt: 1,
};
});
const response = await __handlers.skillScanGetRouterV1Handler(
makeCtx({ runQuery }),
new Request("https://example.com/api/v1/skills/-/scan/download/demo-skill?version=1.2.3"),
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/zip");
expect(response.headers.get("Content-Disposition")).toBe(
'attachment; filename="clawhub-scan-demo-skill-1.2.3.zip"',
);
expect((await response.arrayBuffer()).byteLength).toBeGreaterThan(0);
});
it("search returns empty results for blank query", async () => {
const runAction = vi.fn();
const runMutation = vi.fn().mockResolvedValue(okRate());
+4
View File
@@ -110,6 +110,10 @@ export const __handlers = {
skillsGetRouterV1Handler,
publishSkillV1Handler,
skillSecurityVerdictsV1Handler,
skillScanSubmitV1Handler,
skillScanGetRouterV1Handler,
skillScanBatchSubmitV1Handler,
skillScanBatchStatusV1Handler,
skillsPostRouterV1Handler,
skillsDeleteRouterV1Handler,
exportSkillsV1Handler,
+47 -36
View File
@@ -48,7 +48,6 @@ import {
getPathSegments,
json,
parseJsonPayload,
parseMultipartSkillScan,
parseMultipartPublish,
parsePublishBody,
publicApiOrigin,
@@ -294,9 +293,9 @@ const internalRefs = internal as unknown as {
getByIdInternal: unknown;
};
securityScan: {
createUploadedSkillScanRequestInternal: unknown;
createPublishedSkillScanRequestInternal: unknown;
enqueueBulkSkillRescanBatchForAdminInternal: unknown;
getStoredScanReportForUserInternal: unknown;
getSkillScanRequestForUserInternal: unknown;
getBulkSkillRescanBatchStatusForAdminInternal: unknown;
requestSkillRescanForUserInternal: unknown;
@@ -334,10 +333,6 @@ function isMultipartRequest(request: Request) {
);
}
async function deleteStoredScanFiles(ctx: ActionCtx, files: Array<{ storageId: Id<"_storage"> }>) {
await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId)));
}
function encodeJsonEntry(value: unknown) {
return new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`);
}
@@ -396,6 +391,16 @@ function buildSkillScanReportZip(status: Record<string, unknown>) {
]);
}
function safeScanReportFilenamePart(value: string) {
return (
value
.replace(/^@/, "")
.replaceAll("/", "-")
.replaceAll(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "artifact"
);
}
async function handleSkillScanBatchSubmit(ctx: ActionCtx, request: Request, headers: HeadersInit) {
const auth = await requireApiTokenUserOrResponse(ctx, request, headers);
if (!auth.ok) return auth.response;
@@ -1123,35 +1128,11 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
try {
if (isMultipartRequest(request)) {
const multipart = await parseMultipartSkillScan(ctx, request, (payload) => {
const parsed = parseArk(
ApiV1SkillScanSubmitRequestSchema,
payload,
"Skill scan payload",
) as {
source: { kind: "upload" } | { kind: "published"; slug: string; version?: string };
update?: boolean;
};
if (parsed.source.kind !== "upload") {
throw new Error("multipart scan payload must use source.kind=upload");
}
if (parsed.update === true) {
throw new Error("update is not valid for uploaded scans");
}
return parsed;
});
const result = await runMutationRef(
ctx,
internalRefs.securityScan.createUploadedSkillScanRequestInternal,
{
actorUserId: auth.userId,
files: multipart.files,
},
).catch(async (error) => {
await deleteStoredScanFiles(ctx, multipart.files);
throw error;
});
return json(result, 202, rate.headers);
return text(
"Local upload scans are no longer supported. Upload a version, then use `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
410,
rate.headers,
);
}
const body = parseArk(
@@ -1163,7 +1144,11 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
update?: boolean;
};
if (body.source.kind === "upload") {
return text("uploaded scans must use multipart/form-data", 400, rate.headers);
return text(
"Local upload scans are no longer supported. Upload a version, then use `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
410,
rate.headers,
);
}
const result = await runMutationRef(
ctx,
@@ -1197,6 +1182,32 @@ export async function skillScanGetRouterV1Handler(ctx: ActionCtx, request: Reque
if (!scanId) return text("scanId required", 400, rate.headers);
try {
if (segments.length === 2 && scanId === "download") {
const name = (segments[1] ?? "").trim();
const url = new URL(request.url);
const version = url.searchParams.get("version")?.trim() ?? "";
const kind = url.searchParams.get("kind")?.trim() === "plugin" ? "plugin" : "skill";
if (!name) return text("name required", 400, rate.headers);
if (!version) return text("version required", 400, rate.headers);
const status = (await runQueryRef(
ctx,
internalRefs.securityScan.getStoredScanReportForUserInternal,
{
actorUserId: auth.userId,
kind,
name,
version,
},
)) as Record<string, unknown>;
const zip = buildSkillScanReportZip(status);
const headers = mergeHeaders(rate.headers, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="clawhub-scan-${safeScanReportFilenamePart(name)}-${safeScanReportFilenamePart(version)}.zip"`,
});
return new Response(zip, { status: 200, headers });
}
const status = (await runQueryRef(
ctx,
internalRefs.securityScan.getSkillScanRequestForUserInternal,
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
APPEALS_URL,
buildMaliciousArtifactEmail,
buildBanNotificationEmail,
buildRestoredAccountEmail,
} from "./emails";
describe("moderation notification email copy", () => {
it("builds public-safe malicious skill context with appeal but no local scan guidance", () => {
const email = buildBanNotificationEmail({
handle: "gingiris",
source: "autoban",
reason: "malicious.llm_malicious",
artifact: { kind: "skill", name: "gingiris-launch" },
trigger: "scanner.llm.malicious",
});
expect(email.subject).toBe("Your ClawHub account was disabled");
expect(email.context).toMatchObject({
appealUrl: APPEALS_URL,
artifact: { kind: "skill", name: "gingiris-launch" },
scannerLabel: "ClawScan",
findingSummary: "ClawScan classified the uploaded skill as malicious.",
});
expect(email.text).toContain("Skill: gingiris-launch");
expect(email.text).not.toContain("Scanner:");
expect(email.html).not.toContain("<strong>Scanner:</strong>");
expect(email.text).not.toContain("republishing");
expect(email.html).not.toContain("republishing");
expect(email.text).not.toContain("To support your appeal, include scan results");
expect(email.html).not.toContain("Include scan results with your appeal");
expect(email.text).toContain("Appeal: https://appeals.openclaw.ai/");
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
expect(email.text).not.toContain("https://docs.openclaw.ai/clawhub/cli#scan-path");
});
it("does not leak raw manual moderator notes into outbound email", () => {
const email = buildBanNotificationEmail({
handle: "target",
source: "manual",
reason: "internal reviewer note: reporter=user_123 secret finding id=abc",
});
expect(email.context.findingSummary).toBe(
"ClawHub staff disabled the account after a security review.",
);
expect(email.text).not.toContain("internal reviewer note");
expect(email.text).not.toContain("reporter=user_123");
expect(email.html).not.toContain("secret finding id");
});
it("uses rate-limit copy without scan remediation guidance", () => {
const email = buildBanNotificationEmail({
handle: "publish-loop",
source: "manual",
reason: "rate limit triggered by automated CLI publishing",
});
expect(email.context).toMatchObject({
scannerLabel: null,
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
});
expect(email.text).toContain("Publishing automation");
expect(email.text).not.toContain("clawhub scan");
expect(email.text).not.toContain("Include scan results");
expect(email.html).not.toContain("Include scan results");
expect(email.html).not.toContain("fixed local copy");
});
it("builds restored-account copy that explains tokens stay revoked", () => {
const email = buildRestoredAccountEmail({
handle: "restored",
restoredListings: [
{ kind: "skill", name: "safe-one" },
{ kind: "plugin", name: "@scope/demo" },
],
});
expect(email.subject).toBe("Your ClawHub account was restored");
expect(email.text).toContain("Your ClawHub account can sign in again.");
expect(email.text).toContain("Skill: safe-one");
expect(email.text).toContain("Plugin: @scope/demo");
expect(email.text).toContain("Previously revoked API tokens stay revoked.");
});
it("builds malicious artifact copy without account appeal language", () => {
const email = buildMaliciousArtifactEmail({
handle: "publisher",
artifact: { kind: "skill", name: "demo-skill" },
version: "1.2.3",
trigger: "malicious.llm_malicious",
});
expect(email.subject).toBe("ClawHub blocked a skill version");
expect(email.text).toContain("Skill: demo-skill");
expect(email.text).toContain("Version: 1.2.3");
expect(email.text).toContain("clawhub scan download demo-skill --version 1.2.3");
expect(email.text).toContain("Increment the version number before uploading the fixed skill.");
expect(email.text).toContain("https://docs.openclaw.ai/clawhub/moderation");
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
expect(email.text).not.toContain("fixed local copy");
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
expect(email.html).toContain("Repeated malicious rejections may lead to account disablement");
expect(email.text).not.toContain(APPEALS_URL);
expect(email.html).not.toContain(APPEALS_URL);
expect(email.html).not.toContain("appeal this decision");
});
it("builds plugin scan download copy with an explicit artifact kind", () => {
const email = buildMaliciousArtifactEmail({
handle: "publisher",
artifact: { kind: "plugin", name: "@scope/demo" },
version: "2.0.0",
trigger: "malicious.static",
});
expect(email.text).toContain("Plugin: @scope/demo");
expect(email.text).toContain("clawhub scan download @scope/demo --version 2.0.0 --kind plugin");
expect(email.text).toContain("Increment the version number before uploading the fixed plugin.");
});
});
+352
View File
@@ -0,0 +1,352 @@
export const APPEALS_URL = "https://appeals.openclaw.ai/";
export const MODERATION_GUIDELINES_URL = "https://docs.openclaw.ai/clawhub/moderation";
export const MALICIOUS_REJECTION_ACCOUNT_WARNING =
"Repeated malicious rejections may lead to account disablement.";
export type NotificationArtifact = {
kind: "skill" | "plugin";
name: string;
};
export type BanNotificationSource = "manual" | "autoban";
export type BanNotificationEmailArgs = {
handle?: string;
source: BanNotificationSource;
reason?: string;
trigger?: string;
artifact?: NotificationArtifact;
};
export type BanNotificationEmailContext = {
appealUrl: typeof APPEALS_URL;
artifact: NotificationArtifact | null;
scannerLabel: string | null;
findingSummary: string;
};
export type TransactionalEmail = {
subject: string;
context: BanNotificationEmailContext;
text: string;
html: string;
};
export type RestoredAccountEmailArgs = {
handle?: string;
restoredListings?: NotificationArtifact[];
};
export type MaliciousArtifactEmailArgs = {
handle?: string;
artifact: NotificationArtifact;
version?: string;
trigger?: string;
};
type BanReasonSummary = {
scannerLabel: string | null;
findingSummary: string;
};
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeReasonInput(args: Pick<BanNotificationEmailArgs, "reason" | "trigger">) {
return `${args.reason ?? ""} ${args.trigger ?? ""}`.trim().toLowerCase();
}
function summarizeBanReason(args: BanNotificationEmailArgs): BanReasonSummary {
const normalized = normalizeReasonInput(args);
if (args.source === "autoban") {
if (normalized.includes("virustotal") || normalized.includes("virus_total")) {
return {
scannerLabel: "VirusTotal",
findingSummary: "VirusTotal telemetry contributed to a malicious upload finding.",
};
}
if (normalized.includes("static")) {
return {
scannerLabel: "Static analysis",
findingSummary: "Static analysis flagged malicious upload patterns.",
};
}
if (
normalized.includes("clawscan") ||
normalized.includes("llm") ||
normalized.includes("malicious")
) {
return {
scannerLabel: "ClawScan",
findingSummary: "ClawScan classified the uploaded skill as malicious.",
};
}
return {
scannerLabel: "ClawHub security checks",
findingSummary: "ClawHub security checks classified the uploaded skill as malicious.",
};
}
if (/rate[-\s]?limit|publishing automation|automated(?: cli)? publishing/.test(normalized)) {
return {
scannerLabel: null,
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
};
}
return {
scannerLabel: null,
findingSummary: "ClawHub staff disabled the account after a security review.",
};
}
function artifactLabel(artifact: NotificationArtifact) {
return `${artifact.kind === "skill" ? "Skill" : "Plugin"}: ${artifact.name}`;
}
function greeting(handle: string | undefined) {
return handle?.trim() ? `Hi ${handle.trim()},` : "Hi,";
}
function emailShell(args: { preheader: string; title: string; body: string }) {
return `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>${escapeHtml(args.title)}</title>
</head>
<body style="margin:0;background:#ffffff;color:#1f2328;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(
args.preheader,
)}</span>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#ffffff;margin:0;padding:24px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;">
<tr>
<td style="padding:0;font-size:15px;line-height:22px;color:#1f2328;">
${args.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function textLink(href: string, label: string) {
return `<a href="${escapeHtml(href)}" style="color:#0969da;text-decoration:underline;">${escapeHtml(label)}</a>`;
}
function detailLine(label: string, value: string) {
return `<p style="margin:0 0 6px;font-size:15px;line-height:22px;color:#1f2328;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
}
function sectionHeading(label: string) {
return `<p style="margin:18px 0 8px;font-size:15px;line-height:22px;color:#1f2328;"><strong>${escapeHtml(label)}</strong></p>`;
}
function paragraph(value: string) {
return `<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">${escapeHtml(value)}</p>`;
}
function bulletList(items: string[]) {
return `<ul style="margin:0 0 14px;padding-left:22px;font-size:15px;line-height:22px;color:#1f2328;">${items
.map((item) => `<li style="margin:0 0 6px;">${escapeHtml(item)}</li>`)
.join("")}</ul>`;
}
function commandBlock(command: string) {
return `<pre style="margin:8px 0 14px;padding:10px 12px;background:#f6f8fa;border:1px solid #d8dee4;border-radius:6px;white-space:pre-wrap;color:#1f2328;font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',monospace;font-size:13px;line-height:20px;"><code>${escapeHtml(command)}</code></pre>`;
}
function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) {
const version = args.version?.trim() || "<version>";
const kindFlag = args.artifact.kind === "plugin" ? " --kind plugin" : "";
return `clawhub scan download ${args.artifact.name} --version ${version}${kindFlag}`;
}
export function buildBanNotificationEmail(args: BanNotificationEmailArgs): TransactionalEmail {
const summary = summarizeBanReason(args);
const artifact = args.artifact ?? null;
const context: BanNotificationEmailContext = {
appealUrl: APPEALS_URL,
artifact,
scannerLabel: summary.scannerLabel,
findingSummary: summary.findingSummary,
};
const lines = [
greeting(args.handle),
"",
"Your ClawHub account was disabled.",
`Reason: ${context.findingSummary}`,
];
if (artifact) lines.push(artifactLabel(artifact));
lines.push(
"",
"What changed:",
"- Your ClawHub account cannot sign in.",
"- Existing API tokens for the account have been revoked.",
"- Published listings owned by the account may be hidden from public view.",
"",
`Appeal: ${APPEALS_URL}`,
);
lines.push("", "ClawHub Security");
const impactItems = [
"Your ClawHub account cannot sign in.",
"Existing API tokens for the account have been revoked.",
"Published listings owned by the account may be hidden from public view.",
];
const detailLines = [
detailLine("Reason", context.findingSummary),
...(artifact
? [detailLine(artifact.kind === "skill" ? "Skill" : "Plugin", artifact.name)]
: []),
].join("");
const html = emailShell({
title: "Your ClawHub account was disabled",
preheader: context.findingSummary,
body: [
paragraph(greeting(args.handle)),
paragraph("We disabled your ClawHub account after an account-safety review."),
detailLines,
sectionHeading("What changed"),
bulletList(impactItems),
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">You can ${textLink(APPEALS_URL, "appeal this decision")} if you believe this was a mistake.</p>`,
`<p style="margin:18px 0 0;color:#6a737d;font-size:13px;line-height:20px;">If you already appealed, you do not need to send a separate support email.</p>`,
paragraph("ClawHub Security"),
].join(""),
});
return {
subject: "Your ClawHub account was disabled",
context,
text: lines.join("\n"),
html,
};
}
export function buildRestoredAccountEmail(args: RestoredAccountEmailArgs) {
const restoredListings = args.restoredListings ?? [];
const listingLines = restoredListings.map(artifactLabel);
const lines = [
greeting(args.handle),
"",
"Your ClawHub account can sign in again.",
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
];
if (listingLines.length > 0) {
lines.push("", "Restored listings:", ...listingLines);
}
lines.push("", "ClawHub Security");
const html = emailShell({
title: "Your ClawHub account was restored",
preheader: "Your ClawHub account can sign in again.",
body: [
paragraph(greeting(args.handle)),
paragraph("Your ClawHub account can sign in again."),
paragraph(
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
),
listingLines.length > 0
? `${sectionHeading("Restored listings")}${bulletList(listingLines)}`
: "",
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">Settings: ${textLink("https://clawhub.ai/settings", "open ClawHub settings")}</p>`,
paragraph("ClawHub Security"),
].join(""),
});
return {
subject: "Your ClawHub account was restored",
text: lines.join("\n"),
html,
};
}
export function buildMaliciousArtifactEmail(args: MaliciousArtifactEmailArgs) {
const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin";
const artifactLabelText = artifactLabel(args.artifact);
const scanDownloadCommand = buildScanDownloadCommand(args);
const findingSummary =
args.trigger?.includes("static") === true
? "Static analysis flagged malicious upload patterns."
: args.trigger?.includes("virustotal") === true || args.trigger?.includes("vt_") === true
? "VirusTotal telemetry contributed to a malicious upload finding."
: "ClawScan classified the uploaded artifact as malicious.";
const subject = `ClawHub blocked a ${artifactKind} version`;
const lines = [
greeting(args.handle),
"",
`ClawHub blocked a ${artifactKind} version after a security scan.`,
`Reason: ${findingSummary}`,
artifactLabelText,
];
if (args.version?.trim()) lines.push(`Version: ${args.version.trim()}`);
lines.push(
"",
"What changed:",
"- This version was not made public.",
"- Your account can still sign in.",
`- You can upload a fixed version of this ${artifactKind}.`,
`- ${MALICIOUS_REJECTION_ACCOUNT_WARNING}`,
"",
"Download the scan results for the blocked submitted version:",
scanDownloadCommand,
`Docs: ${MODERATION_GUIDELINES_URL}`,
`Increment the version number before uploading the fixed ${artifactKind}.`,
"",
"ClawHub Security",
);
const detailLines = [
detailLine("Reason", findingSummary),
detailLine(args.artifact.kind === "skill" ? "Skill" : "Plugin", args.artifact.name),
...(args.version?.trim() ? [detailLine("Version", args.version.trim())] : []),
].join("");
const html = emailShell({
title: subject,
preheader: `${artifactLabelText} was blocked by ClawHub security scans.`,
body: [
paragraph(greeting(args.handle)),
paragraph(`ClawHub blocked a ${artifactKind} version after a security scan.`),
detailLines,
sectionHeading("What changed"),
bulletList([
"This version was not made public.",
"Your account can still sign in.",
`You can upload a fixed version of this ${artifactKind}.`,
MALICIOUS_REJECTION_ACCOUNT_WARNING,
]),
sectionHeading("Review the blocked-version scan results"),
paragraph("Download the scan results for the blocked submitted version."),
commandBlock(scanDownloadCommand),
paragraph(`Increment the version number before uploading the fixed ${artifactKind}.`),
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">Docs: ${textLink(MODERATION_GUIDELINES_URL, "moderation and account safety")}</p>`,
paragraph("ClawHub Security"),
].join(""),
});
return {
subject,
text: lines.join("\n"),
html,
};
}
+15 -8
View File
@@ -73,14 +73,21 @@ describe("assertValidSkillSlug", () => {
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
});
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages", "publishers"])(
"rejects reserved slug %s",
(slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
// length rule; we only assert that a throw happens for every entry.
expect(() => assertValidSkillSlug(slug)).toThrow();
},
);
it.each([
"account-banned",
"admin",
"settings",
"api",
"openclaw",
"clawhub",
"souls",
"packages",
"publishers",
])("rejects reserved slug %s", (slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
// length rule; we only assert that a throw happens for every entry.
expect(() => assertValidSkillSlug(slug)).toThrow();
});
it.each(["openclaw", "publishers"])(
"emits the reserved-specific error for long reserved slug %s",
+1
View File
@@ -25,6 +25,7 @@ const MAX_SLUG_LENGTH = 96;
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
// Current top-level route segments under src/routes/.
"about",
"account-banned",
"admin",
"cli",
"dashboard",
+417 -20
View File
@@ -30,6 +30,7 @@ import {
listPublicPage,
listPageForViewerInternal,
listVersions,
updateReleaseLlmAnalysisInternal,
updateReleaseStaticScanInternal,
applyAccountDeletionToOwnedPackagesBatchInternal,
applyPublisherDeletionToOwnedPackagesBatchInternal,
@@ -559,6 +560,23 @@ const updateReleaseStaticScanInternalHandler = (
unknown
>
)._handler;
const updateReleaseLlmAnalysisInternalHandler = (
updateReleaseLlmAnalysisInternal as unknown as WrappedHandler<
{
releaseId: string;
llmAnalysis: {
status: string;
verdict?: string;
confidence?: string;
summary?: string;
guidance?: string;
findings?: string;
checkedAt: number;
};
},
unknown
>
)._handler;
const softDeletePackageInternalHandler = (
softDeletePackageInternal as unknown as WrappedHandler<
{ userId: string; name: string },
@@ -728,6 +746,8 @@ function makeReleaseDoc(overrides: Partial<Record<string, unknown>> = {}) {
version: "1.0.0",
createdAt: 1,
softDeletedAt: undefined,
createdBy: "users:owner",
publishActor: { kind: "user", userId: "users:owner" },
...overrides,
};
}
@@ -4977,7 +4997,7 @@ describe("packages public queries", () => {
files: [],
integritySha256: "abc123",
}),
).rejects.toThrow("Version 1.0.0 already exists");
).rejects.toThrow("Version 1.0.0 already exists. Increment the version number and try again.");
});
it("treats matching workflow duplicate package releases as idempotent", async () => {
@@ -8550,6 +8570,383 @@ describe("package scan backfill", () => {
expect.objectContaining({ scanStatus: "pending" }),
);
});
it("quarantines a malicious latest plugin release and restores the previous clean latest", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const patch = vi.fn().mockResolvedValue(undefined);
const runAfter = vi.fn().mockResolvedValue(undefined);
const previousRelease = makeReleaseDoc({
_id: "packageReleases:demo-1",
packageId: "packages:demo",
version: "1.0.0",
distTags: [],
verification: { scanStatus: "clean" },
createdAt: 1_600_000_000_000,
});
const candidateRelease = makeReleaseDoc({
_id: "packageReleases:demo-2",
packageId: "packages:demo",
version: "2.0.0",
runtimeId: "demo.plugin",
sourceRepo: "openclaw/demo-malicious",
distTags: ["latest"],
verification: { scanStatus: "pending" },
createdBy: "users:member",
publishActor: { kind: "user", userId: "users:member" },
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No static findings.",
engineVersion: "test",
checkedAt: 1,
},
createdAt: 1_700_000_000_000,
});
const pkg = makePackageDoc({
_id: "packages:demo",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
runtimeId: "malicious.plugin",
sourceRepo: "openclaw/demo-malicious",
latestReleaseId: "packageReleases:demo-2",
tags: { latest: "packageReleases:demo-2" },
latestVersionSummary: { version: "2.0.0", verification: { scanStatus: "pending" } },
verification: { scanStatus: "pending" },
scanStatus: "pending",
});
await updateReleaseLlmAnalysisInternalHandler(
{
db: {
get: vi.fn(async (id: string) => {
if (id === "packageReleases:demo-2") return candidateRelease;
if (id === "packages:demo") return pkg;
if (id === "publishers:org") {
return {
_id: "publishers:org",
kind: "org",
handle: "org",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "packageReleases") {
return {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([previousRelease, candidateRelease]),
})),
};
}
if (table === "packageSearchDigest") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: "packageSearchDigest:demo",
packageId: "packages:demo",
scanStatus: "pending",
}),
})),
};
}
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(),
patch,
replace: vi.fn(),
delete: vi.fn(),
normalizeId: vi.fn(),
},
scheduler: { runAfter },
} as never,
{
releaseId: "packageReleases:demo-2",
llmAnalysis: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "ClawScan found malicious behavior.",
guidance: "Fix locally and rescan.",
checkedAt: 1_700_000_000_000,
},
},
);
expect(patch).toHaveBeenCalledWith(
"packageReleases:demo-2",
expect.objectContaining({
softDeletedAt: 1_700_000_000_000,
verification: expect.objectContaining({ scanStatus: "malicious" }),
}),
);
expect(patch).toHaveBeenCalledWith(
"packageReleases:demo-1",
expect.objectContaining({ distTags: ["latest"] }),
);
expect(patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
latestReleaseId: "packageReleases:demo-1",
runtimeId: undefined,
sourceRepo: undefined,
scanStatus: "clean",
tags: { latest: "packageReleases:demo-1" },
}),
);
expect(runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
ownerUserId: "users:member",
artifactKind: "plugin",
artifactName: "demo-plugin",
version: "2.0.0",
}),
);
});
it("quarantines a malicious non-latest plugin release without changing the clean latest", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const patch = vi.fn().mockResolvedValue(undefined);
const runAfter = vi.fn().mockResolvedValue(undefined);
const candidateRelease = makeReleaseDoc({
_id: "packageReleases:demo-beta",
packageId: "packages:demo",
version: "1.5.0",
distTags: ["beta"],
verification: { scanStatus: "pending" },
createdBy: "users:member",
publishActor: { kind: "user", userId: "users:member" },
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No static findings.",
engineVersion: "test",
checkedAt: 1,
},
createdAt: 1_650_000_000_000,
});
const pkg = makePackageDoc({
_id: "packages:demo",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
latestReleaseId: "packageReleases:demo-latest",
tags: {
latest: "packageReleases:demo-latest",
beta: "packageReleases:demo-beta",
},
latestVersionSummary: { version: "2.0.0", verification: { scanStatus: "clean" } },
verification: { scanStatus: "clean" },
scanStatus: "clean",
});
await updateReleaseLlmAnalysisInternalHandler(
{
db: {
get: vi.fn(async (id: string) => {
if (id === "packageReleases:demo-beta") return candidateRelease;
if (id === "packages:demo") return pkg;
if (id === "publishers:org") {
return {
_id: "publishers:org",
kind: "org",
handle: "org",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "packageSearchDigest") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: "packageSearchDigest:demo",
packageId: "packages:demo",
scanStatus: "clean",
}),
})),
};
}
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(),
patch,
replace: vi.fn(),
delete: vi.fn(),
normalizeId: vi.fn(),
},
scheduler: { runAfter },
} as never,
{
releaseId: "packageReleases:demo-beta",
llmAnalysis: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "ClawScan found malicious behavior.",
guidance: "Fix locally and rescan.",
checkedAt: 1_700_000_000_000,
},
},
);
expect(patch).toHaveBeenCalledWith(
"packageReleases:demo-beta",
expect.objectContaining({
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
}),
);
expect(patch).toHaveBeenCalledWith(
"packageReleases:demo-beta",
expect.objectContaining({
softDeletedAt: 1_700_000_000_000,
verification: expect.objectContaining({ scanStatus: "malicious" }),
}),
);
expect(patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
tags: { latest: "packageReleases:demo-latest" },
}),
);
expect(patch).not.toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({ latestReleaseId: "packageReleases:demo-beta" }),
);
expect(runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
ownerUserId: "users:member",
artifactKind: "plugin",
artifactName: "demo-plugin",
version: "1.5.0",
}),
);
});
it("keeps a first malicious plugin release out of public package lists", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const patch = vi.fn().mockResolvedValue(undefined);
const candidateRelease = makeReleaseDoc({
_id: "packageReleases:demo-1",
packageId: "packages:demo",
version: "1.0.0",
distTags: ["latest"],
verification: { scanStatus: "pending" },
createdAt: 1_700_000_000_000,
});
const pkg = makePackageDoc({
_id: "packages:demo",
latestReleaseId: "packageReleases:demo-1",
tags: { latest: "packageReleases:demo-1" },
latestVersionSummary: { version: "1.0.0", verification: { scanStatus: "pending" } },
verification: { scanStatus: "pending" },
scanStatus: "pending",
});
await updateReleaseLlmAnalysisInternalHandler(
{
db: {
get: vi.fn(async (id: string) => {
if (id === "packageReleases:demo-1") return candidateRelease;
if (id === "packages:demo") return pkg;
return null;
}),
query: vi.fn((table: string) => {
if (table === "packageReleases") {
return {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([candidateRelease]),
})),
};
}
if (table === "packageSearchDigest") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: "packageSearchDigest:demo",
packageId: "packages:demo",
scanStatus: "pending",
}),
})),
};
}
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(),
patch,
replace: vi.fn(),
delete: vi.fn(),
normalizeId: vi.fn(),
},
} as never,
{
releaseId: "packageReleases:demo-1",
llmAnalysis: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "ClawScan found malicious behavior.",
guidance: "Fix locally and rescan.",
checkedAt: 1_700_000_000_000,
},
},
);
expect(patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
latestReleaseId: undefined,
latestVersionSummary: undefined,
scanStatus: "malicious",
tags: {},
}),
);
expect(patch).toHaveBeenCalledWith(
"packageSearchDigest:demo",
expect.objectContaining({
latestVersion: undefined,
scanStatus: "malicious",
}),
);
});
});
// ---------------------------------------------------------------------------
@@ -9019,12 +9416,6 @@ describe("owned package sanction batches", () => {
});
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
packageId: "packages:org-plugin",
actorUserId: "users:owner",
deletedAt: 3_000,
source: "publisher.delete",
});
expect(patch).toHaveBeenCalledWith(
"packages:org-plugin",
expect.objectContaining({
@@ -9034,6 +9425,12 @@ describe("owned package sanction batches", () => {
softDeletedByRole: "user",
}),
);
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
packageId: "packages:org-plugin",
actorUserId: "users:owner",
deletedAt: 3_000,
source: "publisher.delete",
});
expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:org-plugin", expect.anything());
});
@@ -9525,7 +9922,7 @@ describe("owned package sanction batches", () => {
expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything());
});
it("schedules hard deletes for account-deleted packages", async () => {
it("hides and schedules hard deletes for account-deleted packages", async () => {
const { ctx, patch, runAfter } = makeOwnedPackageBatchCtx();
const result = await applyAccountDeletionToOwnedPackagesBatchInternalHandler(ctx as never, {
@@ -9534,12 +9931,6 @@ describe("owned package sanction batches", () => {
});
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
packageId: "packages:demo",
actorUserId: "users:owner",
deletedAt: 3_000,
source: "account.delete",
});
expect(patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
@@ -9549,6 +9940,12 @@ describe("owned package sanction batches", () => {
softDeletedByRole: "user",
}),
);
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
packageId: "packages:demo",
actorUserId: "users:owner",
deletedAt: 3_000,
source: "account.delete",
});
});
it("schedules hard deletes for account-deleted packages owned through the user's personal publisher", async () => {
@@ -9588,12 +9985,6 @@ describe("owned package sanction batches", () => {
});
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
packageId: "packages:personal-publisher",
actorUserId: "users:owner",
deletedAt: 3_000,
source: "account.delete",
});
expect(patch).toHaveBeenCalledWith(
"packages:personal-publisher",
expect.objectContaining({
@@ -9603,6 +9994,12 @@ describe("owned package sanction batches", () => {
softDeletedByRole: "user",
}),
);
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
packageId: "packages:personal-publisher",
actorUserId: "users:owner",
deletedAt: 3_000,
source: "account.delete",
});
});
it("does not delete org-owned packages when deleting a member account", async () => {
+211 -5
View File
@@ -3279,6 +3279,28 @@ function rebuildPackageTagsFromActiveReleases(releases: Doc<"packageReleases">[]
return tags;
}
function packageLatestSummaryFromRelease(release: Doc<"packageReleases"> | null) {
return release
? {
version: release.version,
createdAt: release.createdAt,
changelog: release.changelog,
compatibility: release.compatibility,
capabilities: release.capabilities,
verification: release.verification,
artifact: packageArtifactSummary(release),
}
: undefined;
}
function packageRuntimeIdFromRelease(release: Doc<"packageReleases"> | null) {
return release?.runtimeId ?? release?.capabilities?.runtimeId;
}
function packageSourceRepoFromRelease(release: Doc<"packageReleases"> | null) {
return release?.sourceRepo ?? release?.verification?.sourceRepo;
}
async function restorePackageDoc(
ctx: Pick<MutationCtx, "db">,
pkg: Doc<"packages">,
@@ -3378,7 +3400,7 @@ async function restorePackageDoc(
compatibility: nextLatest?.compatibility,
capabilities: nextLatest?.capabilities,
verification: nextLatest?.verification,
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : undefined,
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : pkg.scanStatus,
updatedAt: now,
};
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
@@ -6533,7 +6555,9 @@ export const insertReleaseInternal = internalMutation({
releaseId: releaseExists._id,
};
}
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
throw new ConvexError(
`Version ${nextVersionLabel} already exists. Increment the version number and try again.`,
);
}
}
const priorReleases = existing
@@ -6571,6 +6595,8 @@ export const insertReleaseInternal = internalMutation({
normalizedBundleManifest: args.normalizedBundleManifest,
compatibility: args.compatibility,
capabilities: nextCapabilities,
runtimeId: args.runtimeId,
sourceRepo: args.sourceRepo,
verification: args.verification,
staticScan: args.staticScan,
source: args.source,
@@ -6642,10 +6668,186 @@ function isReleaseActive(
return Boolean(release && !release.softDeletedAt);
}
async function syncLatestPackageVerification(ctx: MutationCtx, release: Doc<"packageReleases">) {
async function recordMaliciousPluginReleaseFinding(
ctx: Pick<MutationCtx, "scheduler">,
pkg: Doc<"packages">,
release: Doc<"packageReleases">,
trigger: string,
) {
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
ownerUserId: release.createdBy,
artifactKind: "plugin",
artifactName: pkg.normalizedName,
version: release.version,
trigger,
...(release.sha256hash ? { sha256hash: release.sha256hash } : {}),
});
}
async function quarantineMaliciousNonLatestPackageRelease(
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
pkg: Doc<"packages">,
release: Doc<"packageReleases">,
trigger: string,
) {
const now = Date.now();
const maliciousVerification = release.verification
? { ...release.verification, scanStatus: "malicious" as const }
: release.verification;
await ctx.db.patch(release._id, {
verification: maliciousVerification,
softDeletedAt: now,
});
const nextTags = Object.fromEntries(
Object.entries(pkg.tags ?? {}).filter(([, releaseId]) => releaseId !== release._id),
) as Doc<"packages">["tags"];
if (Object.keys(nextTags).length !== Object.keys(pkg.tags ?? {}).length) {
const packagePatch: Partial<Doc<"packages">> = {
tags: nextTags,
updatedAt: now,
};
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
await ctx.db.patch(pkg._id, packagePatch);
const owner = await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
});
await upsertPackageSearchDigest(ctx, {
...extractPackageDigestFields(nextPackage),
ownerHandle: owner?.handle ?? "",
ownerKind: owner?.kind,
});
}
if (ctx.scheduler) {
await recordMaliciousPluginReleaseFinding(
ctx as Pick<MutationCtx, "scheduler">,
pkg,
release,
trigger,
);
}
}
async function quarantineMaliciousLatestPackageRelease(
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
pkg: Doc<"packages">,
release: Doc<"packageReleases">,
trigger: string,
) {
const now = Date.now();
const maliciousVerification = release.verification
? { ...release.verification, scanStatus: "malicious" as const }
: release.verification;
const quarantinedRelease = {
...release,
verification: maliciousVerification,
softDeletedAt: now,
} as Doc<"packageReleases">;
await ctx.db.patch(release._id, {
verification: maliciousVerification,
softDeletedAt: now,
});
const releases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
.collect();
const activeNonMaliciousReleases = releases
.map((candidate) => (candidate._id === release._id ? quarantinedRelease : candidate))
.filter(
(candidate) =>
!candidate.softDeletedAt && resolvePackageReleaseScanStatus(candidate) !== "malicious",
);
const nextLatest = getPreferredRestoredPackageRelease(pkg.family, activeNonMaliciousReleases);
const nextTags = rebuildPackageTagsFromActiveReleases(activeNonMaliciousReleases);
if (nextLatest) {
nextTags.latest = nextLatest._id;
if (!(nextLatest.distTags ?? []).includes("latest")) {
await ctx.db.patch(nextLatest._id, {
distTags: [...(nextLatest.distTags ?? []), "latest"],
});
}
}
const restoredRuntimeId = packageRuntimeIdFromRelease(nextLatest);
const restoredSourceRepo = packageSourceRepoFromRelease(nextLatest);
const packagePatch: Partial<Doc<"packages">> = {
tags: nextTags,
latestReleaseId: nextLatest?._id,
latestVersionSummary: packageLatestSummaryFromRelease(nextLatest),
summary: nextLatest?.summary,
sourceRepo: restoredSourceRepo,
runtimeId: restoredRuntimeId,
capabilityTags: nextLatest?.capabilities?.capabilityTags,
executesCode:
typeof nextLatest?.capabilities?.executesCode === "boolean"
? nextLatest.capabilities.executesCode
: undefined,
compatibility: nextLatest?.compatibility,
capabilities: nextLatest?.capabilities,
verification: nextLatest?.verification,
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : "malicious",
updatedAt: now,
};
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
await ctx.db.patch(pkg._id, packagePatch);
const owner = await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
});
await upsertPackageSearchDigest(ctx, {
...extractPackageDigestFields(nextPackage),
ownerHandle: owner?.handle ?? "",
ownerKind: owner?.kind,
});
if (ctx.scheduler) {
await recordMaliciousPluginReleaseFinding(
ctx as Pick<MutationCtx, "scheduler">,
pkg,
release,
trigger,
);
}
}
type SyncLatestPackageVerificationOptions = {
quarantineMaliciousLatest?: boolean;
maliciousTrigger?: string;
};
async function syncLatestPackageVerification(
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
release: Doc<"packageReleases">,
options: SyncLatestPackageVerificationOptions = {},
) {
const pkg = await ctx.db.get(release.packageId);
if (!pkg || pkg.latestReleaseId !== release._id) return;
const scanStatus = resolvePackageReleaseScanStatus(release);
if (!pkg) return;
if (scanStatus === "malicious" && options.quarantineMaliciousLatest) {
if (pkg.latestReleaseId !== release._id) {
await quarantineMaliciousNonLatestPackageRelease(
ctx,
pkg,
release,
options.maliciousTrigger ?? "malicious.llm_malicious",
);
return;
}
await quarantineMaliciousLatestPackageRelease(
ctx,
pkg,
release,
options.maliciousTrigger ?? "malicious.llm_malicious",
);
return;
}
if (pkg.latestReleaseId !== release._id) return;
const nextVerification = pkg.verification
? {
@@ -6762,7 +6964,11 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
...release,
llmAnalysis: args.llmAnalysis,
} as Doc<"packageReleases">;
await syncLatestPackageVerification(ctx, updatedRelease);
const llmVerdict = (args.llmAnalysis.verdict ?? args.llmAnalysis.status).trim().toLowerCase();
await syncLatestPackageVerification(ctx, updatedRelease, {
quarantineMaliciousLatest: llmVerdict === "malicious",
maliciousTrigger: "malicious.llm_malicious",
});
},
});
+3
View File
@@ -818,6 +818,7 @@ const skillVersions = defineTable({
),
changelog: v.string(),
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
icon: v.optional(v.string()),
files: v.array(
v.object({
path: v.string(),
@@ -1242,6 +1243,8 @@ const packageReleases = defineTable({
normalizedBundleManifest: v.optional(v.any()),
compatibility: packageCompatibilityValidator,
capabilities: packageCapabilitiesValidator,
runtimeId: v.optional(v.string()),
sourceRepo: v.optional(v.string()),
verification: packageVerificationValidator,
sha256hash: v.optional(v.string()),
vtAnalysis: v.optional(vtAnalysisValidator),
+357
View File
@@ -10,6 +10,7 @@ import {
failCodexScanJob,
getBulkSkillRescanBatchStatusForAdminInternal,
getSkillScanRequestForUserInternal,
getStoredScanReportForUserInternal,
pruneExpiredSkillScanRequestsInternal,
requestPackageRescanForUserInternal,
requestPackageRescan,
@@ -221,6 +222,28 @@ const getSkillScanRequestForUserInternalHandler = (
>
)._handler;
const getStoredScanReportForUserInternalHandler = (
getStoredScanReportForUserInternal as unknown as WrappedHandler<
{
actorUserId: string;
kind: "skill" | "plugin";
name: string;
version: string;
},
{
ok: true;
status: string;
artifact: Record<string, unknown>;
report: {
clawscan: Record<string, unknown> | null;
skillspector: Record<string, unknown> | null;
staticAnalysis: Record<string, unknown> | null;
virustotal: Record<string, unknown> | null;
};
}
>
)._handler;
const claimedJob = {
_id: "securityScanJobs:1",
_creationTime: 1,
@@ -685,6 +708,77 @@ function makeSkillScanStatusCtx(options: {
};
}
function makeStoredScanReportCtx(options: {
actor: Record<string, unknown>;
docs: Record<string, Record<string, unknown>>;
membership?: Record<string, unknown> | null;
}) {
const docs = new Map<string, Record<string, unknown>>([
[String(options.actor._id), options.actor],
...Object.entries(options.docs),
]);
const get = vi.fn(async (id: string) => docs.get(id) ?? null);
const query = vi.fn((table: string) => ({
withIndex: vi.fn((_indexName: string, buildRange: (q: { eq: typeof eq }) => unknown) => {
const equals = new Map<string, unknown>();
function eq(field: string, value: unknown) {
equals.set(field, value);
return { eq };
}
buildRange({ eq });
return {
unique: vi.fn(async () => {
if (table === "publisherMembers") return options.membership ?? null;
if (table === "skills") {
return (
Array.from(docs.values()).find(
(doc) => String(doc._id).startsWith("skills:") && doc.slug === equals.get("slug"),
) ?? null
);
}
if (table === "skillVersions") {
return (
Array.from(docs.values()).find(
(doc) =>
String(doc._id).startsWith("skillVersions:") &&
doc.skillId === equals.get("skillId") &&
doc.version === equals.get("version"),
) ?? null
);
}
if (table === "packages") {
return (
Array.from(docs.values()).find(
(doc) =>
String(doc._id).startsWith("packages:") &&
doc.normalizedName === equals.get("normalizedName"),
) ?? null
);
}
if (table === "packageReleases") {
return (
Array.from(docs.values()).find(
(doc) =>
String(doc._id).startsWith("packageReleases:") &&
doc.packageId === equals.get("packageId") &&
doc.version === equals.get("version"),
) ?? null
);
}
return null;
}),
};
}),
}));
return {
db: {
get,
query,
},
};
}
describe("securityScan", () => {
afterEach(() => {
vi.unstubAllEnvs();
@@ -743,6 +837,269 @@ describe("securityScan", () => {
);
});
it("returns stored scan reports for hidden skill versions to the owner", async () => {
const ctx = makeStoredScanReportCtx({
actor: { _id: "users:owner", role: "user" },
docs: {
"skills:hidden": {
_id: "skills:hidden",
slug: "hidden-skill",
displayName: "Hidden Skill",
ownerUserId: "users:owner",
},
"skillVersions:hidden": {
_id: "skillVersions:hidden",
skillId: "skills:hidden",
version: "1.2.3",
softDeletedAt: 1_700_000_100_000,
files: [],
sha256hash: "abc123",
llmAnalysis: {
status: "malicious",
summary: "Attempts to exfiltrate credentials.",
checkedAt: 1_700_000_000_000,
},
staticScan: {
status: "malicious",
reasonCodes: ["network.exfiltration"],
findings: [],
summary: "Credential exfiltration pattern.",
checkedAt: 1_700_000_000_000,
},
createdAt: 1_700_000_000_000,
},
},
});
const report = await getStoredScanReportForUserInternalHandler(ctx, {
actorUserId: "users:owner",
kind: "skill",
name: "hidden-skill",
version: "1.2.3",
});
expect(report).toMatchObject({
ok: true,
status: "succeeded",
artifact: {
kind: "skill",
slug: "hidden-skill",
displayName: "Hidden Skill",
version: "1.2.3",
},
report: {
clawscan: {
status: "malicious",
summary: "Attempts to exfiltrate credentials.",
},
staticAnalysis: {
status: "malicious",
summary: "Credential exfiltration pattern.",
},
},
});
});
it("returns stored scan reports for hidden org skill versions to publisher-role uploaders", async () => {
const ctx = makeStoredScanReportCtx({
actor: { _id: "users:member", role: "user" },
membership: {
_id: "publisherMembers:member",
publisherId: "publishers:org",
userId: "users:member",
role: "publisher",
},
docs: {
"publishers:org": {
_id: "publishers:org",
kind: "org",
handle: "org",
},
"skills:hidden": {
_id: "skills:hidden",
slug: "hidden-skill",
displayName: "Hidden Skill",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
},
"skillVersions:hidden": {
_id: "skillVersions:hidden",
skillId: "skills:hidden",
version: "1.2.3",
softDeletedAt: 1_700_000_100_000,
files: [],
sha256hash: "abc123",
llmAnalysis: {
status: "malicious",
summary: "Attempts to exfiltrate credentials.",
checkedAt: 1_700_000_000_000,
},
createdAt: 1_700_000_000_000,
},
},
});
const report = await getStoredScanReportForUserInternalHandler(ctx, {
actorUserId: "users:member",
kind: "skill",
name: "hidden-skill",
version: "1.2.3",
});
expect(report).toMatchObject({
ok: true,
artifact: {
kind: "skill",
slug: "hidden-skill",
displayName: "Hidden Skill",
version: "1.2.3",
},
});
});
it("denies stored scan reports to non-owners", async () => {
const ctx = makeStoredScanReportCtx({
actor: { _id: "users:intruder", role: "user" },
docs: {
"skills:hidden": {
_id: "skills:hidden",
slug: "hidden-skill",
displayName: "Hidden Skill",
ownerUserId: "users:owner",
},
"skillVersions:hidden": {
_id: "skillVersions:hidden",
skillId: "skills:hidden",
version: "1.2.3",
softDeletedAt: 1,
files: [],
llmAnalysis: { status: "malicious", checkedAt: 1 },
createdAt: 1,
},
},
});
await expect(
getStoredScanReportForUserInternalHandler(ctx, {
actorUserId: "users:intruder",
kind: "skill",
name: "hidden-skill",
version: "1.2.3",
}),
).rejects.toThrow("Forbidden");
});
it("returns stored scan reports for hidden plugin releases to platform moderators", async () => {
const ctx = makeStoredScanReportCtx({
actor: { _id: "users:moderator", role: "moderator" },
docs: {
"packages:plugin": {
_id: "packages:plugin",
name: "@scope/demo",
normalizedName: "@scope/demo",
displayName: "Demo Plugin",
ownerUserId: "users:owner",
},
"packageReleases:hidden": {
_id: "packageReleases:hidden",
packageId: "packages:plugin",
version: "2.0.0",
softDeletedAt: 1_700_000_100_000,
files: [],
integritySha256: "def456",
llmAnalysis: {
status: "malicious",
summary: "Runs unexpected shell commands.",
checkedAt: 1_700_000_000_000,
},
createdAt: 1_700_000_000_000,
},
},
});
const report = await getStoredScanReportForUserInternalHandler(ctx, {
actorUserId: "users:moderator",
kind: "plugin",
name: "@scope/demo",
version: "2.0.0",
});
expect(report).toMatchObject({
ok: true,
status: "succeeded",
artifact: {
kind: "plugin",
name: "@scope/demo",
displayName: "Demo Plugin",
version: "2.0.0",
},
report: {
clawscan: {
status: "malicious",
summary: "Runs unexpected shell commands.",
},
},
});
});
it("returns stored scan reports for hidden org plugin releases to publisher-role uploaders", async () => {
const ctx = makeStoredScanReportCtx({
actor: { _id: "users:member", role: "user" },
membership: {
_id: "publisherMembers:member",
publisherId: "publishers:org",
userId: "users:member",
role: "publisher",
},
docs: {
"publishers:org": {
_id: "publishers:org",
kind: "org",
handle: "org",
},
"packages:plugin": {
_id: "packages:plugin",
name: "@org/demo",
normalizedName: "@org/demo",
displayName: "Org Plugin",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
},
"packageReleases:hidden": {
_id: "packageReleases:hidden",
packageId: "packages:plugin",
version: "2.0.0",
softDeletedAt: 1_700_000_100_000,
files: [],
integritySha256: "def456",
llmAnalysis: {
status: "malicious",
summary: "Runs unexpected shell commands.",
checkedAt: 1_700_000_000_000,
},
createdAt: 1_700_000_000_000,
},
},
});
const report = await getStoredScanReportForUserInternalHandler(ctx, {
actorUserId: "users:member",
kind: "plugin",
name: "@org/demo",
version: "2.0.0",
});
expect(report).toMatchObject({
ok: true,
artifact: {
kind: "plugin",
name: "@org/demo",
displayName: "Org Plugin",
version: "2.0.0",
},
});
});
it("lets skill owners request skill rescans through the API helper", async () => {
const { ctx, inserts } = makeRescanCtx({
actorId: "users:owner",
+190
View File
@@ -88,6 +88,8 @@ type SkillSpectorAnalysisForStorage = {
checkedAt: number;
};
type StoredScanArtifactKind = "skill" | "plugin";
const jobSourceValidator = v.union(
v.literal("publish"),
v.literal("clawscan-note"),
@@ -793,6 +795,54 @@ function skillScanReportFromRequest(request: Doc<"skillScanRequests">) {
};
}
function storedScanReportFromArtifact(
artifact: Pick<
Doc<"skillVersions"> | Doc<"packageReleases">,
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
>,
) {
return {
clawscan: artifact.llmAnalysis ?? null,
skillspector: artifact.skillSpectorAnalysis ?? null,
staticAnalysis: artifact.staticScan ?? null,
virustotal: artifact.vtAnalysis
? {
...artifact.vtAnalysis,
...artifact.vtAnalysis.engineStats,
}
: null,
};
}
function hasStoredScanReport(
artifact: Pick<
Doc<"skillVersions"> | Doc<"packageReleases">,
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
>,
) {
return Boolean(
artifact.llmAnalysis ||
artifact.skillSpectorAnalysis ||
artifact.staticScan ||
artifact.vtAnalysis,
);
}
function completedAtFromStoredScanReport(
artifact: Pick<
Doc<"skillVersions"> | Doc<"packageReleases">,
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
>,
) {
const checkedAtValues = [
artifact.llmAnalysis?.checkedAt,
artifact.skillSpectorAnalysis?.checkedAt,
artifact.staticScan?.checkedAt,
artifact.vtAnalysis?.checkedAt,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return checkedAtValues.length > 0 ? Math.max(...checkedAtValues) : undefined;
}
function skillScanArtifactFromRequest(request: Doc<"skillScanRequests">) {
return {
...(request.slug ? { slug: request.slug } : {}),
@@ -1111,6 +1161,146 @@ export const getSkillScanRequestForUserInternal = internalQuery({
},
});
export const getStoredScanReportForUserInternal = internalQuery({
args: {
actorUserId: v.id("users"),
kind: v.union(v.literal("skill"), v.literal("plugin")),
name: v.string(),
version: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const name = args.name.trim();
const versionLabel = args.version.trim();
if (!name) throw new ConvexError("Name required");
if (!versionLabel) throw new ConvexError("Version required");
return args.kind === "plugin"
? await getStoredPackageScanReportForUser(ctx, {
actor,
kind: args.kind,
name,
version: versionLabel,
})
: await getStoredSkillScanReportForUser(ctx, {
actor,
kind: args.kind,
name,
version: versionLabel,
});
},
});
async function getStoredSkillScanReportForUser(
ctx: QueryCtx,
args: {
actor: Doc<"users">;
kind: StoredScanArtifactKind;
name: string;
version: string;
},
) {
const slug = args.name.toLowerCase();
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (!skill) throw new ConvexError("Skill not found");
await assertCanManageOwnedResource(ctx, {
actor: args.actor,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
allowedPublisherRoles: ["publisher"],
allowPlatformModerator: true,
});
const version = await ctx.db
.query("skillVersions")
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version))
.unique();
if (!version) throw new ConvexError("Skill version not found");
if (!hasStoredScanReport(version)) throw new ConvexError("Scan results not found");
const completedAt = completedAtFromStoredScanReport(version);
return {
ok: true as const,
scanId: `skill:${skill.slug}:${version.version}`,
status: "succeeded" as const,
sourceKind: "published" as const,
update: false,
writtenBack: true,
artifact: {
kind: args.kind,
slug: skill.slug,
displayName: skill.displayName,
version: version.version,
...(version.sha256hash ? { sha256hash: version.sha256hash } : {}),
fileCount: version.files.length,
},
report: storedScanReportFromArtifact(version),
createdAt: version.createdAt,
updatedAt: Math.max(version.createdAt, completedAt ?? version.createdAt),
completedAt,
};
}
async function getStoredPackageScanReportForUser(
ctx: QueryCtx,
args: {
actor: Doc<"users">;
kind: StoredScanArtifactKind;
name: string;
version: string;
},
) {
const normalizedName = normalizePackageName(args.name);
const pkg = await ctx.db
.query("packages")
.withIndex("by_name", (q) => q.eq("normalizedName", normalizedName))
.unique();
if (!pkg || pkg.family === "skill") throw new ConvexError("Plugin not found");
await assertCanManageOwnedResource(ctx, {
actor: args.actor,
ownerUserId: pkg.ownerUserId,
ownerPublisherId: pkg.ownerPublisherId,
allowedPublisherRoles: ["publisher"],
allowPlatformModerator: true,
});
const release = await ctx.db
.query("packageReleases")
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", args.version))
.unique();
if (!release) throw new ConvexError("Plugin version not found");
if (!hasStoredScanReport(release)) throw new ConvexError("Scan results not found");
const completedAt = completedAtFromStoredScanReport(release);
return {
ok: true as const,
scanId: `plugin:${pkg.normalizedName}:${release.version}`,
status: "succeeded" as const,
sourceKind: "published" as const,
update: false,
writtenBack: true,
artifact: {
kind: args.kind,
name: pkg.name,
displayName: pkg.displayName,
version: release.version,
...(release.integritySha256 ? { sha256hash: release.integritySha256 } : {}),
fileCount: release.files.length,
},
report: storedScanReportFromArtifact(release),
createdAt: release.createdAt,
updatedAt: Math.max(release.createdAt, completedAt ?? release.createdAt),
completedAt,
};
}
export const recordSkillScanRequestSucceededInternal = internalMutation({
args: {
scanId: v.id("skillScanRequests"),
+22 -4
View File
@@ -152,7 +152,11 @@ type Captured = {
allPatches: Array<{ id: string; value: Record<string, unknown> }>;
};
function buildDb(skill: SkillDoc, captured: Captured) {
function buildDb(
skill: SkillDoc,
captured: Captured,
existingVersion?: Record<string, unknown> | null,
) {
// Trigger-driven code (syncSkillSearchDigestForSkill -> getOwnerPublisher)
// will ask for publishers via `db.get(ownerPublisherId)`. Return null so
// getOwnerPublisher falls back to resolving the publisher from the owner user.
@@ -229,7 +233,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
if (name !== "by_skill_version") {
throw new Error(`unexpected skillVersions index ${name}`);
}
return { unique: async () => null };
return { unique: async () => existingVersion ?? null };
},
};
}
@@ -377,7 +381,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
return db;
}
function buildCtx(skill: SkillDoc) {
function buildCtx(skill: SkillDoc, existingVersion?: Record<string, unknown> | null) {
const captured: Captured = {
skillPatches: [],
embeddingInserts: [],
@@ -385,7 +389,7 @@ function buildCtx(skill: SkillDoc) {
versionInserted: null,
allPatches: [],
};
const db = buildDb(skill, captured);
const db = buildDb(skill, captured, existingVersion);
const ctx = {
db,
scheduler: { runAfter: vi.fn() },
@@ -394,6 +398,20 @@ function buildCtx(skill: SkillDoc) {
}
describe("skills.insertVersion latest-tag protection", () => {
it("tells authors to increment the version when publishing a duplicate skill version", async () => {
const skill = buildExistingSkill();
const { ctx, captured } = buildCtx(skill, {
_id: "skillVersions:existing",
skillId: SKILL_ID,
version: "1.0.1",
});
await expect(
insertVersionHandler(ctx as never, buildPublishArgs({ version: "1.0.1" }) as never),
).rejects.toThrow("Version 1.0.1 already exists. Increment the version number and try again.");
expect(captured.versionInserted).toBeNull();
});
it("ignores stale clawScanNote values when inserting skill versions", async () => {
const skill = buildExistingSkill();
const { ctx, captured } = buildCtx(skill);
+7
View File
@@ -62,6 +62,13 @@ function makeCtx({
}),
};
}
if (table === "skillVersions") {
return {
withIndex: () => ({
take: async () => [],
}),
};
}
throw new Error(`Unexpected table ${table}`);
});
const scheduler = { runAfter: vi.fn() };
+534 -17
View File
@@ -198,6 +198,7 @@ describe("skills anti-spam guards", () => {
_id: "skills:1",
slug: "taken-skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:1",
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
@@ -278,6 +279,7 @@ describe("skills anti-spam guards", () => {
_id: "skills:1",
slug: "taken-skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:1",
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
@@ -1491,11 +1493,44 @@ describe("skills anti-spam guards", () => {
).toBe(false);
});
it("schedules owner autoban when the latest version ClawScan verdict becomes malicious", async () => {
const version = {
it("quarantines a malicious latest skill version and restores the previous clean latest", async () => {
const previousVersion = {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: Date.now() - 10_000,
changelog: "Initial release",
changelogSource: "user",
parsed: {
frontmatter: { description: "Clean version" },
metadata: {},
clawdis: { tools: [] },
},
capabilityTags: ["automation"],
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No issues",
engineVersion: "v2.2.0",
checkedAt: Date.now(),
},
llmAnalysis: { status: "clean", checkedAt: Date.now() },
};
const version = {
_id: "skillVersions:2",
skillId: "skills:1",
version: "2.0.0",
createdBy: "users:member",
createdAt: Date.now(),
changelog: "Bad release",
changelogSource: "user",
parsed: {
frontmatter: { name: "Bad Skill", description: "Bad version" },
metadata: {},
clawdis: { tools: [] },
},
capabilityTags: ["network"],
staticScan: {
status: "clean",
reasonCodes: [],
@@ -1509,13 +1544,37 @@ describe("skills anti-spam guards", () => {
const skill = {
_id: "skills:1",
slug: "spam-skill",
displayName: "Bad Skill",
summary: "Bad version",
icon: "lucide:Sparkles",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:1",
ownerPublisherId: undefined,
latestVersionId: "skillVersions:2",
latestVersionSummary: {
version: "2.0.0",
createdAt: version.createdAt,
changelog: "Bad release",
changelogSource: "user",
clawdis: { tools: [] },
apiKeyRequired: undefined,
},
tags: {
latest: "skillVersions:2",
beta: "skillVersions:2",
stable: "skillVersions:1",
},
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 2 },
badges: {},
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
moderationReason: undefined,
createdAt: Date.now() - 20_000,
updatedAt: Date.now(),
};
const owner = {
_id: "users:owner",
handle: "owner",
role: "user",
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
@@ -1523,6 +1582,348 @@ describe("skills anti-spam guards", () => {
deactivatedAt: undefined,
};
const patch = vi.fn();
const insert = vi.fn();
const runAfter = vi.fn();
const db = {
get: vi.fn(async (id: string) => {
if (id === "skillVersions:1") return previousVersion;
if (id === "skillVersions:2") return version;
if (id === "skills:1") return skill;
if (id === "users:owner") return owner;
return null;
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table);
if (globalStatsQuery) return globalStatsQuery;
if (table === "skills") {
return {
withIndex: (name: string) => {
if (name === "by_owner") {
return {
order: () => ({
take: async () => [],
}),
};
}
throw new Error(`unexpected skills index ${name}`);
},
};
}
if (table === "skillVersions") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
return {
collect: async () => [version, previousVersion],
};
},
};
}
if (table === "skillEmbeddings") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
return {
collect: async () => [
{
_id: "skillEmbeddings:old",
skillId: "skills:1",
versionId: "skillVersions:1",
isApproved: true,
isLatest: false,
},
{
_id: "skillEmbeddings:new",
skillId: "skills:1",
versionId: "skillVersions:2",
isApproved: true,
isLatest: true,
},
],
};
},
};
}
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
normalizeId: vi.fn(),
};
await updateVersionLlmAnalysisHandler(
{ db, scheduler: { runAfter } } as never,
{
versionId: "skillVersions:2",
llmAnalysis: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "ClawScan found malicious behavior.",
guidance: "Do not install.",
checkedAt: Date.now(),
},
} as never,
);
expect(patch).toHaveBeenCalledWith(
"skillVersions:2",
expect.objectContaining({
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
}),
);
expect(patch).toHaveBeenCalledWith(
"skillVersions:2",
expect.objectContaining({
softDeletedAt: expect.any(Number),
}),
);
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
displayName: "spam-skill",
summary: "Clean version",
icon: "lucide:Sparkles",
latestVersionId: "skillVersions:1",
tags: {
latest: "skillVersions:1",
stable: "skillVersions:1",
},
latestVersionSummary: expect.objectContaining({
version: "1.0.0",
changelog: "Initial release",
}),
capabilityTags: ["automation"],
moderationStatus: "active",
moderationReason: "scanner.llm.clean",
moderationVerdict: "clean",
moderationFlags: undefined,
}),
);
expect(insert).toHaveBeenCalledWith(
"skillSearchDigest",
expect.objectContaining({
skillId: "skills:1",
latestVersionId: "skillVersions:1",
latestVersionSkillId: "skills:1",
}),
);
expect(runAfter).toHaveBeenCalledWith(
0,
internal.users.recordMaliciousArtifactFindingInternal,
expect.objectContaining({
ownerUserId: "users:member",
artifactKind: "skill",
artifactName: "spam-skill",
version: "2.0.0",
sha256hash: "h".repeat(64),
trigger: "malicious.llm_malicious",
}),
);
});
it("quarantines a malicious non-latest skill version without changing the clean latest", async () => {
const latestVersion = {
_id: "skillVersions:latest",
skillId: "skills:1",
version: "2.0.0",
createdAt: Date.now(),
changelog: "Latest release",
changelogSource: "user",
parsed: {
frontmatter: { name: "Clean Latest", description: "Clean latest version" },
metadata: {},
clawdis: { tools: [] },
},
capabilityTags: ["automation"],
llmAnalysis: { status: "clean", checkedAt: Date.now() },
};
const backportVersion = {
_id: "skillVersions:backport",
skillId: "skills:1",
version: "1.5.0",
createdBy: "users:member",
createdAt: Date.now() - 1_000,
changelog: "Backport release",
changelogSource: "user",
parsed: {
frontmatter: { name: "Backport", description: "Backport version" },
metadata: {},
clawdis: { tools: [] },
},
capabilityTags: ["network"],
sha256hash: "b".repeat(64),
};
const skill = {
_id: "skills:1",
slug: "spam-skill",
displayName: "Clean Latest",
summary: "Clean latest version",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
latestVersionId: "skillVersions:latest",
latestVersionSummary: {
version: "2.0.0",
createdAt: latestVersion.createdAt,
changelog: "Latest release",
changelogSource: "user",
clawdis: { tools: [] },
apiKeyRequired: undefined,
},
tags: {
latest: "skillVersions:latest",
beta: "skillVersions:backport",
},
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 2 },
badges: {},
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
moderationReason: undefined,
createdAt: Date.now() - 20_000,
updatedAt: Date.now(),
};
const owner = {
_id: "users:owner",
handle: "owner",
role: "user",
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
deactivatedAt: undefined,
};
const patch = vi.fn();
const insert = vi.fn();
const runAfter = vi.fn();
const db = {
get: vi.fn(async (id: string) => {
if (id === "skillVersions:latest") return latestVersion;
if (id === "skillVersions:backport") return backportVersion;
if (id === "skills:1") return skill;
if (id === "users:owner") return owner;
return null;
}),
query: vi.fn((table: string) => {
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
normalizeId: vi.fn(),
};
await updateVersionLlmAnalysisHandler(
{ db, scheduler: { runAfter } } as never,
{
versionId: "skillVersions:backport",
llmAnalysis: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "ClawScan found malicious behavior.",
guidance: "Do not install.",
checkedAt: Date.now(),
},
} as never,
);
expect(patch).toHaveBeenCalledWith(
"skillVersions:backport",
expect.objectContaining({
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
}),
);
expect(patch).toHaveBeenCalledWith(
"skillVersions:backport",
expect.objectContaining({ softDeletedAt: expect.any(Number) }),
);
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
tags: { latest: "skillVersions:latest" },
}),
);
expect(patch).not.toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({ latestVersionId: "skillVersions:backport" }),
);
expect(runAfter).toHaveBeenCalledWith(
0,
internal.users.recordMaliciousArtifactFindingInternal,
expect.objectContaining({
ownerUserId: "users:member",
artifactKind: "skill",
artifactName: "spam-skill",
version: "1.5.0",
sha256hash: "b".repeat(64),
}),
);
});
it("quarantines a malicious first skill version without publishing a latest version", async () => {
const version = {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: Date.now(),
changelog: "Initial release",
changelogSource: "user",
parsed: {
frontmatter: { description: "Bad first version" },
metadata: {},
clawdis: { tools: [] },
},
capabilityTags: ["network"],
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No issues",
engineVersion: "v2.2.0",
checkedAt: Date.now(),
},
sha256hash: "h".repeat(64),
};
const skill = {
_id: "skills:1",
slug: "new-spam-skill",
displayName: "New Spam Skill",
summary: "Bad first version",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
latestVersionId: "skillVersions:1",
latestVersionSummary: {
version: "1.0.0",
createdAt: version.createdAt,
changelog: "Initial release",
changelogSource: "user",
clawdis: { tools: [] },
apiKeyRequired: undefined,
},
tags: { latest: "skillVersions:1" },
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1 },
badges: {},
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
moderationReason: undefined,
createdAt: Date.now(),
updatedAt: Date.now(),
};
const owner = {
_id: "users:owner",
handle: "owner",
role: "user",
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
deactivatedAt: undefined,
};
const patch = vi.fn();
const insert = vi.fn();
const runAfter = vi.fn();
const db = {
get: vi.fn(async (id: string) => {
@@ -1548,10 +1949,40 @@ describe("skills anti-spam guards", () => {
},
};
}
if (table === "skillVersions") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
return {
collect: async () => [version],
};
},
};
}
if (table === "skillEmbeddings") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
return {
collect: async () => [
{
_id: "skillEmbeddings:1",
skillId: "skills:1",
versionId: "skillVersions:1",
isApproved: true,
isLatest: true,
},
],
};
},
};
}
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
throw new Error(`unexpected table ${table}`);
}),
patch,
insert: vi.fn(),
insert,
normalizeId: vi.fn(),
};
@@ -1570,32 +2001,63 @@ describe("skills anti-spam guards", () => {
} as never,
);
expect(patch).toHaveBeenCalledWith(
"skillVersions:1",
expect.objectContaining({
softDeletedAt: expect.any(Number),
}),
);
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
latestVersionId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: undefined,
moderationStatus: "hidden",
moderationReason: "scanner.llm.malicious",
moderationVerdict: "malicious",
moderationFlags: ["blocked.malware"],
}),
);
expect(patch).toHaveBeenCalledWith(
"globalStats:1",
expect.objectContaining({
activeSkillsCount: 99,
}),
);
expect(insert).toHaveBeenCalledWith(
"skillSearchDigest",
expect.objectContaining({
skillId: "skills:1",
latestVersionId: undefined,
latestVersionSkillId: undefined,
}),
);
expect(runAfter).toHaveBeenCalledWith(
0,
internal.users.autobanMalwareAuthorInternal,
internal.users.recordMaliciousArtifactFindingInternal,
expect.objectContaining({
ownerUserId: "users:owner",
slug: "spam-skill",
sha256hash: "h".repeat(64),
trigger: "malicious.llm_malicious",
artifactKind: "skill",
artifactName: "new-spam-skill",
version: "1.0.0",
}),
);
});
it("persists ClawScan malware lock when malicious lands on an existing moderation hold", async () => {
it("quarantines ClawScan malware while preserving an existing moderation hold", async () => {
const version = {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: Date.now(),
changelog: "Initial release",
changelogSource: "user",
parsed: {
frontmatter: { description: "Held for review" },
metadata: {},
},
staticScan: {
status: "clean",
reasonCodes: [],
@@ -1609,14 +2071,36 @@ describe("skills anti-spam guards", () => {
const skill = {
_id: "skills:1",
slug: "quality-held-spam",
displayName: "Quality Held Spam",
summary: "Held for review",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
latestVersionId: "skillVersions:1",
latestVersionSummary: {
version: "1.0.0",
createdAt: version.createdAt,
changelog: "Initial release",
changelogSource: "user",
clawdis: undefined,
apiKeyRequired: undefined,
},
tags: { latest: "skillVersions:1" },
badges: {},
softDeletedAt: undefined,
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1 },
moderationStatus: "hidden",
moderationReason: "user.moderation",
moderationFlags: undefined,
createdAt: Date.now() - 20_000,
updatedAt: Date.now(),
};
const owner = {
_id: "users:owner",
handle: "owner",
role: "user",
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
@@ -1635,6 +2119,26 @@ describe("skills anti-spam guards", () => {
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table);
if (globalStatsQuery) return globalStatsQuery;
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
if (table === "skillVersions") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
return {
collect: async () => [version],
};
},
};
}
if (table === "skillEmbeddings") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
return { collect: async () => [] };
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
@@ -1659,21 +2163,34 @@ describe("skills anti-spam guards", () => {
expect(patch).toHaveBeenCalledWith("skillVersions:1", expect.any(Object));
expect(patch).toHaveBeenCalledWith(
"skills:1",
"skillVersions:1",
expect.objectContaining({
moderationStatus: "hidden",
moderationReason: "scanner.llm.malicious",
moderationVerdict: "malicious",
moderationFlags: ["blocked.malware"],
moderationReasonCodes: ["malicious.llm_malicious"],
softDeletedAt: expect.any(Number),
}),
);
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
latestVersionId: undefined,
latestVersionSummary: undefined,
tags: {},
}),
);
expect(
patch.mock.calls.some(
([id, value]) =>
id === "skills:1" &&
(value as Record<string, unknown>).moderationReason === "scanner.llm.malicious",
),
).toBe(false);
expect(runAfter).toHaveBeenCalledWith(
0,
internal.users.autobanMalwareAuthorInternal,
internal.users.recordMaliciousArtifactFindingInternal,
expect.objectContaining({
ownerUserId: "users:owner",
slug: "quality-held-spam",
artifactKind: "skill",
artifactName: "quality-held-spam",
version: "1.0.0",
sha256hash: "h".repeat(64),
trigger: "malicious.llm_malicious",
}),
+240 -13
View File
@@ -487,7 +487,7 @@ async function patchStructuredModerationFromVersion(
skill: Doc<"skills">,
version: Pick<
Doc<"skillVersions">,
"_id" | "staticScan" | "vtAnalysis" | "llmAnalysis" | "sha256hash"
"_id" | "version" | "staticScan" | "vtAnalysis" | "llmAnalysis" | "sha256hash"
>,
) {
const now = Date.now();
@@ -507,11 +507,17 @@ async function patchStructuredModerationFromVersion(
const shouldPersistClawScanMalwareBlock =
patch.moderationVerdict === "malicious" && isClawScanMaliciousAnalysis(version.llmAnalysis);
if (shouldPersistClawScanMalwareBlock) {
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
await quarantineMaliciousLatestSkillVersion(ctx, skill, version, owner, now, patch);
return;
}
// A ClawScan-malicious result is itself a security lock. Persist it even
// when the skill was already hidden by a user or quality hold so a later
// hold lift cannot restore a latest-version malware verdict.
if (shouldPreserveExistingModerationLock(skill) && !shouldPersistClawScanMalwareBlock) {
await scheduleClawScanAutobanForMalware(ctx, skill, version, patch);
if (shouldPreserveExistingModerationLock(skill)) {
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
return;
}
@@ -519,13 +525,206 @@ async function patchStructuredModerationFromVersion(
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await scheduleClawScanAutobanForMalware(ctx, skill, version, patch);
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
}
async function scheduleClawScanAutobanForMalware(
function latestVersionSummaryFromSkillVersion(
version: Pick<
Doc<"skillVersions">,
"version" | "createdAt" | "changelog" | "changelogSource" | "parsed" | "apiKeyRequired"
>,
): NonNullable<Doc<"skills">["latestVersionSummary"]> {
return {
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource,
clawdis: version.parsed?.clawdis,
apiKeyRequired: version.apiKeyRequired,
};
}
function skillSummaryFromSkillVersion(
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
) {
return version?.parsed?.frontmatter
? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim() || undefined
: undefined;
}
function skillDisplayNameFromSkillVersion(
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
) {
return version?.parsed?.frontmatter
? getFrontmatterValue(version.parsed.frontmatter, "name")?.trim() || undefined
: undefined;
}
function skillIconFromSkillVersion(version: Pick<Doc<"skillVersions">, "icon"> | null | undefined) {
return version && "icon" in version ? version.icon : undefined;
}
function isKnownMaliciousSkillVersion(
version: Pick<Doc<"skillVersions">, "_id" | "staticScan" | "vtAnalysis" | "llmAnalysis">,
) {
const patch = buildStructuredModerationPatch({
staticScan: version.staticScan,
vtAnalysis: version.vtAnalysis,
llmAnalysis: version.llmAnalysis,
vtStatus: version.vtAnalysis?.status,
llmStatus: version.llmAnalysis?.status,
sourceVersionId: version._id,
});
return patch.moderationVerdict === "malicious";
}
function compareSkillVersionsForRestore(
left: Pick<Doc<"skillVersions">, "version" | "createdAt">,
right: Pick<Doc<"skillVersions">, "version" | "createdAt">,
) {
const leftValid = semver.valid(left.version);
const rightValid = semver.valid(right.version);
if (leftValid && rightValid) return semver.rcompare(leftValid, rightValid);
if (leftValid) return -1;
if (rightValid) return 1;
return right.createdAt - left.createdAt;
}
async function findReplacementLatestSkillVersion(
ctx: MutationCtx,
skillId: Id<"skills">,
quarantinedVersionId: Id<"skillVersions">,
) {
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
.collect();
return (
versions
.filter(
(candidate) =>
candidate._id !== quarantinedVersionId &&
!candidate.softDeletedAt &&
!isKnownMaliciousSkillVersion(candidate),
)
.sort(compareSkillVersionsForRestore)[0] ?? null
);
}
async function clearSkillEmbeddingsLatestVersion(
ctx: MutationCtx,
skillId: Id<"skills">,
now: number,
) {
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId);
for (const embedding of embeddings) {
if (
!embedding.isLatest &&
embedding.visibility === embeddingVisibilityFor(false, embedding.isApproved)
) {
continue;
}
await ctx.db.patch(embedding._id, {
isLatest: false,
visibility: embeddingVisibilityFor(false, embedding.isApproved),
updatedAt: now,
});
}
}
async function quarantineMaliciousLatestSkillVersion(
ctx: MutationCtx,
skill: Doc<"skills">,
version: Pick<Doc<"skillVersions">, "llmAnalysis" | "sha256hash">,
version: Pick<Doc<"skillVersions">, "_id">,
owner: Doc<"users"> | null | undefined,
now: number,
maliciousPatch: SkillModerationPatch,
) {
await ctx.db.patch(version._id, { softDeletedAt: now });
const replacement = await findReplacementLatestSkillVersion(ctx, skill._id, version._id);
const nextTags: Record<string, Id<"skillVersions">> = {};
for (const [tag, versionId] of Object.entries(skill.tags ?? {})) {
if (versionId === version._id || tag === "latest") continue;
nextTags[tag] = versionId;
}
if (replacement) {
nextTags.latest = replacement._id;
}
const patch: Partial<Doc<"skills">> = {
displayName: replacement
? (skillDisplayNameFromSkillVersion(replacement) ?? skill.slug)
: skill.displayName,
summary: replacement ? skillSummaryFromSkillVersion(replacement) : skill.summary,
icon: replacement ? (skillIconFromSkillVersion(replacement) ?? skill.icon) : skill.icon,
latestVersionId: replacement?._id,
latestVersionSummary: replacement
? latestVersionSummaryFromSkillVersion(replacement)
: undefined,
tags: nextTags,
capabilityTags: replacement?.capabilityTags,
updatedAt: now,
};
if (!shouldPreserveExistingModerationLock(skill)) {
const basePatch = replacement
? buildScannerModerationPatchFromVersion({
owner,
version: replacement,
now,
})
: maliciousPatch;
Object.assign(
patch,
applySkillManualOverrideToSkillPatch({
skill,
basePatch,
now,
stripUpdatedAt: true,
}),
);
}
const nextSkill = { ...skill, ...patch } as Doc<"skills">;
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
if (replacement) {
await setSkillEmbeddingsLatestVersion(ctx, skill._id, replacement._id, now);
} else {
await clearSkillEmbeddingsLatestVersion(ctx, skill._id, now);
}
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
}
async function quarantineMaliciousNonLatestSkillVersion(
ctx: MutationCtx,
skill: Doc<"skills">,
versionId: Id<"skillVersions">,
now: number,
) {
await ctx.db.patch(versionId, { softDeletedAt: now });
const nextTags = Object.fromEntries(
Object.entries(skill.tags ?? {}).filter(([, taggedVersionId]) => taggedVersionId !== versionId),
) as Record<string, Id<"skillVersions">>;
if (Object.keys(nextTags).length === Object.keys(skill.tags ?? {}).length) return;
const patch: Partial<Doc<"skills">> = {
tags: nextTags,
updatedAt: now,
};
const nextSkill = { ...skill, ...patch } as Doc<"skills">;
await ctx.db.patch(skill._id, patch);
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
}
async function scheduleClawScanMaliciousArtifactFinding(
ctx: MutationCtx,
skill: Doc<"skills">,
version: Pick<Doc<"skillVersions">, "llmAnalysis" | "sha256hash" | "version"> &
Partial<Pick<Doc<"skillVersions">, "createdBy">>,
patch: SkillModerationPatch,
) {
if (
@@ -533,9 +732,11 @@ async function scheduleClawScanAutobanForMalware(
skill.ownerUserId &&
isClawScanMaliciousAnalysis(version.llmAnalysis)
) {
await ctx.scheduler.runAfter(0, internal.users.autobanMalwareAuthorInternal, {
ownerUserId: skill.ownerUserId,
slug: skill.slug,
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
ownerUserId: version.createdBy ?? skill.ownerUserId,
artifactKind: "skill",
artifactName: skill.slug,
version: version.version,
...(version.sha256hash ? { sha256hash: version.sha256hash } : {}),
trigger:
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.llm_")) ??
@@ -8042,7 +8243,30 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
if (args.moderationMode === "preserve") return;
const skill = await ctx.db.get(version.skillId);
if (!skill || skill.latestVersionId !== version._id) return;
if (!skill) return;
if (skill.latestVersionId !== version._id) {
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
const now = Date.now();
const basePatch = buildScannerModerationPatchFromVersion({
owner,
version: nextVersion,
now,
});
const patch = applySkillManualOverrideToSkillPatch({
skill,
basePatch,
now,
stripUpdatedAt: true,
});
if (
patch.moderationVerdict === "malicious" &&
isClawScanMaliciousAnalysis(args.llmAnalysis)
) {
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, nextVersion, patch);
await quarantineMaliciousNonLatestSkillVersion(ctx, skill, version._id, now);
}
return;
}
await patchStructuredModerationFromVersion(ctx, skill, nextVersion);
},
});
@@ -10683,13 +10907,16 @@ export const insertVersion = internalMutation({
}
if (!skill) throw new Error("Skill creation failed");
const versionIcon = args.icon !== undefined ? normalizeSkillIconValue(args.icon) : skill.icon;
const existingVersion = await ctx.db
.query("skillVersions")
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version))
.unique();
if (existingVersion) {
throw new ConvexError("Version already exists");
throw new ConvexError(
`Version ${args.version} already exists. Increment the version number and try again.`,
);
}
const versionId = await ctx.db.insert("skillVersions", {
@@ -10699,6 +10926,7 @@ export const insertVersion = internalMutation({
sourceProvenance: args.sourceProvenance,
changelog: args.changelog,
changelogSource: args.changelogSource,
icon: versionIcon,
files: args.files,
parsed: args.parsed,
capabilityTags: args.capabilityTags,
@@ -10757,8 +10985,7 @@ export const insertVersion = internalMutation({
// displayName / summary so backport publishes can't surprise the card.
// Only update when the publisher explicitly picked one this time —
// omitting `args.icon` keeps the previously stored value.
const nextIcon =
isNewLatest && args.icon !== undefined ? normalizeSkillIconValue(args.icon) : skill.icon;
const nextIcon = isNewLatest ? versionIcon : skill.icon;
const derivedFlags = deriveModerationFlags({
skill: {
slug: skill.slug,
+451 -6
View File
@@ -23,6 +23,8 @@ const {
list,
searchInternal,
banUserInternal,
autobanMalwareAuthorInternal,
recordMaliciousArtifactFindingInternal,
unbanUserForBanAppealServiceInternal,
reclassifyBanInternal,
me,
@@ -57,7 +59,7 @@ const purgeSelfDeletedAccountRecoveryBatchInternalHandler = (
)._handler;
const upsertDevPersonaInternalHandler = (
upsertDevPersonaInternal as unknown as WrappedHandler<
{ persona: "owner" | "user" | "admin" | "officialOrgMember" },
{ persona: "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher" },
unknown
>
)._handler;
@@ -150,6 +152,7 @@ function makeDevPersonaCtx() {
const publishers = new Map<string, Record<string, unknown>>();
const publisherMembers: Array<Record<string, unknown>> = [];
const officialPublishers: Array<Record<string, unknown>> = [];
const auditLogs: Array<Record<string, unknown>> = [];
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
const patches: Array<{ id: string; value: Record<string, unknown> }> = [];
@@ -290,6 +293,30 @@ function makeDevPersonaCtx() {
}),
};
}
if (table === "auditLogs") {
return {
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
if (name !== "by_target") throw new Error(`Unexpected auditLogs index ${name}`);
let targetType = "";
let targetId = "";
const q = {
eq: (field: string, value: string) => {
if (field === "targetType") targetType = value;
if (field === "targetId") targetId = value;
return q;
},
};
builder?.(q);
return {
collect: vi.fn(async () =>
auditLogs.filter(
(entry) => entry.targetType === targetType && entry.targetId === targetId,
),
),
};
}),
};
}
if (table === "packages" || table === "skills") {
return {
withIndex: vi.fn((name: string) => {
@@ -310,8 +337,10 @@ function makeDevPersonaCtx() {
return {
ctx: { db: { patch, get, insert, query, normalizeId: vi.fn() } } as never,
auditLogs,
inserts,
patches,
users,
};
}
@@ -384,10 +413,14 @@ function makeListCtx(
function makeBanCtx(options: { auditLogs?: Array<Record<string, unknown>> } = {}) {
const patch = vi.fn();
const insert = vi.fn();
const auditLogs = options.auditLogs ?? [];
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
if (table === "auditLogs") auditLogs.unshift(value);
return `${table}:inserted`;
});
const get = vi.fn();
const runMutation = vi.fn();
const auditLogs = options.auditLogs ?? [];
const runAfter = vi.fn();
const apiTokens = [{ _id: "apiTokens:1", revokedAt: undefined }];
const userComments = [
{
@@ -424,8 +457,12 @@ function makeBanCtx(options: { auditLogs?: Array<Record<string, unknown>> } = {}
},
}));
const ctx = { db: { patch, insert, get, query, normalizeId: vi.fn() }, runMutation } as never;
return { ctx, patch, insert, get, runMutation };
const ctx = {
db: { patch, insert, get, query, normalizeId: vi.fn() },
runMutation,
scheduler: { runAfter },
} as never;
return { ctx, patch, insert, get, runMutation, runAfter };
}
function makeBanAppealContextCtx(options: {
@@ -1019,6 +1056,32 @@ describe("ensureHandler", () => {
});
describe("users.upsertDevPersonaInternal", () => {
it("rejects a dev persona sign-in when the persona is banned", async () => {
process.env.DEV_AUTH_ENABLED = "1";
process.env.CONVEX_DEPLOYMENT = "local:dev";
process.env.CONVEX_SITE_URL = "http://localhost:3210";
const { auditLogs, ctx, patches, users } = makeDevPersonaCtx();
users.set("users:banned", {
_id: "users:banned",
_creationTime: 1,
handle: "local-abuse",
displayName: "Local Abuse Test Publisher",
deletedAt: 1_700_000_000_000,
});
auditLogs.push({
action: "user.autoban.malware",
targetType: "user",
targetId: "users:banned",
});
await expect(
upsertDevPersonaInternalHandler(ctx, {
persona: "abusePublisher",
}),
).rejects.toThrow(/account has been banned/i);
expect(patches).toEqual([]);
});
it("seeds a non-platform-admin user who manages an official org", async () => {
process.env.DEV_AUTH_ENABLED = "1";
process.env.CONVEX_DEPLOYMENT = "local:dev";
@@ -2683,6 +2746,54 @@ describe("users.banUserInternal", () => {
);
});
it("schedules a public-safe ban notification email when the target has email", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, runMutation, runAfter } = makeBanCtx();
get.mockImplementation(async (id: string) => {
if (id === "users:actor") return { _id: "users:actor", role: "moderator" };
if (id === "users:target") {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
};
}
if (id === "souls:1") return { _id: "souls:1", stats: { comments: 3 } };
return null;
});
runMutation
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
.mockResolvedValueOnce(undefined);
const handler = (
banUserInternal as unknown as {
_handler: (
ctx: unknown,
args: { actorUserId: string; targetUserId: string; reason?: string },
) => Promise<unknown>;
}
)._handler;
await handler(ctx, {
actorUserId: "users:actor",
targetUserId: "users:target",
reason: "rate limit triggered by automated CLI publishing",
});
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
userId: "users:target",
bannedAt: 1_700_000_000_000,
to: "target@example.com",
handle: "target-user",
source: "manual",
reason: "rate limit triggered by automated CLI publishing",
});
});
it("re-ban of already banned user still cleans lingering comments", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, patch, runMutation } = makeBanCtx();
@@ -2737,6 +2848,331 @@ describe("users.banUserInternal", () => {
});
});
describe("users.autobanMalwareAuthorInternal", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("schedules a malicious skill notification with trigger context", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, runMutation, runAfter } = makeBanCtx();
get.mockImplementation(async (id: string) => {
if (id === "users:target") {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
};
}
if (id === "souls:1") return { _id: "souls:1", stats: { comments: 3 } };
return null;
});
runMutation
.mockResolvedValueOnce({ hiddenCount: 1, scheduled: false })
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
.mockResolvedValueOnce(undefined);
const handler = (
autobanMalwareAuthorInternal as unknown as {
_handler: (
ctx: unknown,
args: {
ownerUserId: string;
sha256hash?: string;
slug: string;
trigger?: string;
},
) => Promise<unknown>;
}
)._handler;
await handler(ctx, {
ownerUserId: "users:target",
sha256hash: "abc123",
slug: "gingiris-launch",
trigger: "malicious.llm_malicious",
});
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
userId: "users:target",
bannedAt: 1_700_000_000_000,
to: "target@example.com",
handle: "target-user",
source: "autoban",
reason: "malicious.llm_malicious",
trigger: "malicious.llm_malicious",
artifact: { kind: "skill", name: "gingiris-launch" },
});
});
});
describe("users.recordMaliciousArtifactFindingInternal", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("emails artifact-level remediation without banning on the first malicious finding", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, insert, runMutation, runAfter } = makeBanCtx();
get.mockImplementation(async (id: string) => {
if (id === "users:target") {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
};
}
return null;
});
const handler = (
recordMaliciousArtifactFindingInternal as unknown as {
_handler: (
ctx: unknown,
args: {
ownerUserId: string;
artifactKind: "skill" | "plugin";
artifactName: string;
version?: string;
trigger?: string;
sha256hash?: string;
},
) => Promise<unknown>;
}
)._handler;
const result = await handler(ctx, {
ownerUserId: "users:target",
artifactKind: "skill",
artifactName: "demo-skill",
version: "1.0.0",
trigger: "malicious.llm_malicious",
sha256hash: "abc123",
});
expect(result).toMatchObject({ ok: true, escalated: false });
expect(insert).toHaveBeenCalledWith(
"auditLogs",
expect.objectContaining({
actorUserId: "users:target",
action: "user.malicious_artifact.finding",
targetType: "user",
targetId: "users:target",
metadata: expect.objectContaining({
artifactKind: "skill",
artifactName: "demo-skill",
version: "1.0.0",
trigger: "malicious.llm_malicious",
sha256hash: "abc123",
}),
}),
);
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
userId: "users:target",
findingAt: 1_700_000_000_000,
to: "target@example.com",
handle: "target-user",
artifact: { kind: "skill", name: "demo-skill" },
version: "1.0.0",
trigger: "malicious.llm_malicious",
});
expect(runMutation).not.toHaveBeenCalled();
});
it("escalates to account ban on the third malicious attempt for one artifact", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, runMutation, runAfter } = makeBanCtx({
auditLogs: [
{
action: "user.malicious_artifact.finding",
targetType: "user",
targetId: "users:target",
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
createdAt: 1_699_999_000_000,
},
{
action: "user.malicious_artifact.finding",
targetType: "user",
targetId: "users:target",
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
createdAt: 1_699_998_000_000,
},
],
});
get.mockImplementation(async (id: string) => {
if (id === "users:target") {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
};
}
return null;
});
runMutation.mockResolvedValue({ ok: true, alreadyBanned: false });
const handler = (
recordMaliciousArtifactFindingInternal as unknown as {
_handler: (
ctx: unknown,
args: {
ownerUserId: string;
artifactKind: "skill" | "plugin";
artifactName: string;
version?: string;
trigger?: string;
sha256hash?: string;
},
) => Promise<unknown>;
}
)._handler;
const result = await handler(ctx, {
ownerUserId: "users:target",
artifactKind: "skill",
artifactName: "demo-skill",
version: "1.0.2",
trigger: "malicious.llm_malicious",
});
expect(result).toMatchObject({ ok: true, escalated: true, reason: "attempt_threshold" });
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
ownerUserId: "users:target",
slug: "demo-skill",
trigger: "malicious.llm_malicious",
artifactKind: "skill",
artifactName: "demo-skill",
});
expect(runAfter).not.toHaveBeenCalled();
});
it("does not escalate on the second malicious attempt for one artifact", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, runMutation } = makeBanCtx({
auditLogs: [
{
action: "user.malicious_artifact.finding",
targetType: "user",
targetId: "users:target",
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
createdAt: 1_699_999_000_000,
},
],
});
get.mockImplementation(async (id: string) => {
if (id === "users:target") {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
};
}
return null;
});
const handler = (
recordMaliciousArtifactFindingInternal as unknown as {
_handler: (
ctx: unknown,
args: {
ownerUserId: string;
artifactKind: "skill" | "plugin";
artifactName: string;
version?: string;
trigger?: string;
sha256hash?: string;
},
) => Promise<unknown>;
}
)._handler;
const result = await handler(ctx, {
ownerUserId: "users:target",
artifactKind: "skill",
artifactName: "demo-skill",
version: "1.0.1",
trigger: "malicious.llm_malicious",
});
expect(result).toMatchObject({ ok: true, escalated: false });
expect(runMutation).not.toHaveBeenCalled();
});
it("escalates to account ban on the second distinct malicious artifact", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { ctx, get, runMutation, runAfter } = makeBanCtx({
auditLogs: [
{
action: "user.malicious_artifact.finding",
targetType: "user",
targetId: "users:target",
metadata: { artifactKind: "skill", artifactName: "first-skill" },
createdAt: 1_699_999_000_000,
},
],
});
get.mockImplementation(async (id: string) => {
if (id === "users:target") {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
};
}
return null;
});
runMutation.mockResolvedValue({ ok: true, alreadyBanned: false });
const handler = (
recordMaliciousArtifactFindingInternal as unknown as {
_handler: (
ctx: unknown,
args: {
ownerUserId: string;
artifactKind: "skill" | "plugin";
artifactName: string;
version?: string;
trigger?: string;
sha256hash?: string;
},
) => Promise<unknown>;
}
)._handler;
const result = await handler(ctx, {
ownerUserId: "users:target",
artifactKind: "plugin",
artifactName: "@scope/second-plugin",
version: "1.0.0",
trigger: "malicious.llm_malicious",
});
expect(result).toMatchObject({
ok: true,
escalated: true,
reason: "distinct_artifact_threshold",
});
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
ownerUserId: "users:target",
slug: "@scope/second-plugin",
trigger: "malicious.llm_malicious",
artifactKind: "plugin",
artifactName: "@scope/second-plugin",
});
expect(runAfter).not.toHaveBeenCalled();
});
});
describe("users.unbanUserForBanAppealServiceInternal", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -2744,7 +3180,7 @@ describe("users.unbanUserForBanAppealServiceInternal", () => {
it("restores ban-hidden skills and packages for accepted appeals", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
const { ctx, get, patch, insert, runMutation } = makeBanCtx({
const { ctx, get, patch, insert, runMutation, runAfter } = makeBanCtx({
auditLogs: [
{
_id: "auditLogs:ban",
@@ -2760,6 +3196,8 @@ describe("users.unbanUserForBanAppealServiceInternal", () => {
return {
_id: "users:target",
role: "user",
handle: "target-user",
email: "target@example.com",
deletedAt: 1_700_000_000_000,
deactivatedAt: undefined,
banReason: "malware auto-ban",
@@ -2818,6 +3256,13 @@ describe("users.unbanUserForBanAppealServiceInternal", () => {
}),
}),
);
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
userId: "users:target",
restoredAt: 1_700_000_100_000,
to: "target@example.com",
handle: "target-user",
restoredListings: undefined,
});
expect(result).toEqual({
ok: true,
alreadyUnbanned: false,
+264 -1
View File
@@ -1,4 +1,4 @@
import { v } from "convex/values";
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
@@ -45,6 +45,12 @@ const AUTOBAN_AUDIT_MATCH_WINDOW_MS = 5_000;
const BAN_AUDIT_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
const BAN_APPEAL_AUTH_ACCOUNT_MATCH_LIMIT = 20;
const AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE = 100;
const MALICIOUS_ARTIFACT_FINDING_ACTION = "user.malicious_artifact.finding";
const MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD = 2;
const MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD = 3;
const MALICIOUS_ARTIFACT_AUDIT_LOOKBACK = 100;
const DEV_PERSONA_BANNED_REAUTH_MESSAGE =
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
const ACCOUNT_RECOVERY_PURGE_LIMIT_DEFAULT = 25;
const ACCOUNT_RECOVERY_PURGE_LIMIT_MAX = 100;
const accountRecoveryPurgeModeValidator = v.optional(
@@ -92,6 +98,83 @@ type AccountRecoveryPurgeCandidate = {
selfDeleteAuditCreatedAt: number | null;
};
type BanEmailTarget = Pick<Doc<"users">, "_id" | "email" | "handle">;
type MaliciousArtifactKind = "skill" | "plugin";
type MaliciousArtifactFinding = {
artifactKind: MaliciousArtifactKind;
artifactName: string;
};
async function scheduleBanNotificationEmail(
ctx: Pick<MutationCtx, "scheduler">,
args: {
target: BanEmailTarget;
bannedAt: number;
source: "manual" | "autoban";
reason?: string;
trigger?: string;
artifact?: { kind: "skill" | "plugin"; name: string };
},
) {
const to = args.target.email?.trim();
if (!to) return;
await ctx.scheduler.runAfter(0, internal.emailsNode.sendBanNotificationInternal, {
userId: args.target._id,
bannedAt: args.bannedAt,
to,
handle: args.target.handle,
source: args.source,
reason: args.reason,
trigger: args.trigger,
artifact: args.artifact,
});
}
async function scheduleRestoredAccountNotificationEmail(
ctx: Pick<MutationCtx, "scheduler">,
args: {
target: BanEmailTarget;
restoredAt: number;
restoredListings?: Array<{ kind: "skill" | "plugin"; name: string }>;
},
) {
const to = args.target.email?.trim();
if (!to) return;
await ctx.scheduler.runAfter(0, internal.emailsNode.sendRestoredAccountNotificationInternal, {
userId: args.target._id,
restoredAt: args.restoredAt,
to,
handle: args.target.handle,
restoredListings: args.restoredListings,
});
}
async function scheduleMaliciousArtifactNotificationEmail(
ctx: Pick<MutationCtx, "scheduler">,
args: {
target: BanEmailTarget;
findingAt: number;
artifact: { kind: MaliciousArtifactKind; name: string };
version?: string;
trigger?: string;
},
) {
const to = args.target.email?.trim();
if (!to) return;
await ctx.scheduler.runAfter(0, internal.emailsNode.sendMaliciousArtifactNotificationInternal, {
userId: args.target._id,
findingAt: args.findingAt,
to,
handle: args.target.handle,
artifact: args.artifact,
version: args.version,
trigger: args.trigger,
});
}
async function getAutobanPersonalPublisherId(
ctx: Pick<QueryCtx | MutationCtx, "db">,
owner: Pick<Doc<"users">, "_id" | "personalPublisherId"> | null | undefined,
@@ -293,6 +376,12 @@ const DEV_PERSONAS = {
displayName: "Local Official Org Member",
role: "user",
},
abusePublisher: {
handle: "local-abuse",
displayName: "Local Abuse Test Publisher",
email: "local-abuse@example.test",
role: "user",
},
} as const;
const DEV_OFFICIAL_ORG = {
@@ -303,6 +392,14 @@ const DEV_OFFICIAL_ORG = {
type DevPersona = keyof typeof DEV_PERSONAS;
async function hasBlockingBanAudit(ctx: Pick<MutationCtx, "db">, userId: Id<"users">) {
const banRecords = await ctx.db
.query("auditLogs")
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", userId.toString()))
.collect();
return banRecords.some((record) => BAN_AUDIT_ACTIONS.has(record.action));
}
export const getById = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => toPublicUser(await ctx.db.get(args.userId)),
@@ -320,6 +417,7 @@ export const upsertDevPersonaInternal = internalMutation({
v.literal("user"),
v.literal("admin"),
v.literal("officialOrgMember"),
v.literal("abusePublisher"),
),
devAuthSecret: v.optional(v.string()),
},
@@ -335,6 +433,7 @@ export const upsertDevPersonaInternal = internalMutation({
handle: persona.handle,
displayName: persona.displayName,
name: persona.displayName,
email: "email" in persona ? persona.email : undefined,
role: persona.role,
githubCreatedAt: DEV_PERSONA_GITHUB_CREATED_AT,
deletedAt: undefined,
@@ -343,6 +442,13 @@ export const upsertDevPersonaInternal = internalMutation({
banReason: undefined,
updatedAt: now,
};
if (
existing &&
(existing.deletedAt || existing.deactivatedAt) &&
(await hasBlockingBanAudit(ctx, existing._id))
) {
throw new ConvexError(DEV_PERSONA_BANNED_REAUTH_MESSAGE);
}
const userId =
existing?._id ??
(await ctx.db.insert("users", {
@@ -2257,6 +2363,13 @@ async function banUserWithActor(
createdAt: now,
});
await scheduleBanNotificationEmail(ctx, {
target,
bannedAt: now,
source: "manual",
reason,
});
return {
ok: true as const,
alreadyBanned: false,
@@ -2335,6 +2448,11 @@ async function unbanUserForBanAppealService(
createdAt: now,
});
await scheduleRestoredAccountNotificationEmail(ctx, {
target,
restoredAt: now,
});
return {
ok: true as const,
alreadyUnbanned: false,
@@ -2414,6 +2532,11 @@ async function unbanUserWithActor(
createdAt: now,
});
await scheduleRestoredAccountNotificationEmail(ctx, {
target,
restoredAt: now,
});
return {
ok: true as const,
alreadyUnbanned: false,
@@ -2705,6 +2828,132 @@ export const ensurePublisherHandleInternal = internalMutation({
handler: async (ctx, args) => await ensurePublisherHandleWithActor(ctx, args),
});
function normalizeMaliciousArtifactName(name: string) {
return name.trim().toLowerCase();
}
function readMaliciousArtifactFindingFromAudit(
log: Doc<"auditLogs">,
): MaliciousArtifactFinding | null {
if (log.action !== MALICIOUS_ARTIFACT_FINDING_ACTION) return null;
const metadata = log.metadata as
| {
artifactKind?: unknown;
artifactName?: unknown;
}
| undefined;
const artifactKind = metadata?.artifactKind;
const artifactName = typeof metadata?.artifactName === "string" ? metadata.artifactName : "";
if ((artifactKind !== "skill" && artifactKind !== "plugin") || !artifactName.trim()) {
return null;
}
return { artifactKind, artifactName };
}
function getMaliciousArtifactEscalationReason(findings: MaliciousArtifactFinding[]) {
const distinctArtifacts = new Set<string>();
const attemptsByArtifact = new Map<string, number>();
for (const finding of findings) {
const artifactKey = `${finding.artifactKind}:${normalizeMaliciousArtifactName(
finding.artifactName,
)}`;
distinctArtifacts.add(artifactKey);
attemptsByArtifact.set(artifactKey, (attemptsByArtifact.get(artifactKey) ?? 0) + 1);
}
if (distinctArtifacts.size >= MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD) {
return "distinct_artifact_threshold" as const;
}
for (const attempts of attemptsByArtifact.values()) {
if (attempts >= MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD) {
return "attempt_threshold" as const;
}
}
return null;
}
export const recordMaliciousArtifactFindingInternal = internalMutation({
args: {
ownerUserId: v.id("users"),
artifactKind: v.union(v.literal("skill"), v.literal("plugin")),
artifactName: v.string(),
version: v.optional(v.string()),
trigger: v.optional(v.string()),
sha256hash: v.optional(v.string()),
},
handler: async (ctx, args) => {
const target = await ctx.db.get(args.ownerUserId);
if (!target) return { ok: false as const, reason: "user_not_found" as const };
if (target.deletedAt || target.deactivatedAt) return { ok: true as const, alreadyBanned: true };
const artifactName = args.artifactName.trim();
if (!artifactName) {
return { ok: false as const, reason: "missing_artifact" as const };
}
const now = Date.now();
const trigger = args.trigger?.trim() || "scanner.malicious";
const version = args.version?.trim() || undefined;
const sha256hash = args.sha256hash?.trim() || undefined;
await ctx.db.insert("auditLogs", {
actorUserId: args.ownerUserId,
action: MALICIOUS_ARTIFACT_FINDING_ACTION,
targetType: "user",
targetId: args.ownerUserId,
metadata: {
artifactKind: args.artifactKind,
artifactName,
version,
trigger,
sha256hash,
},
createdAt: now,
});
if (target.role === "admin" || target.role === "moderator") {
await scheduleMaliciousArtifactNotificationEmail(ctx, {
target,
findingAt: now,
artifact: { kind: args.artifactKind, name: artifactName },
version,
trigger,
});
return { ok: true as const, escalated: false as const, reason: "protected_role" as const };
}
const auditLogs = await ctx.db
.query("auditLogs")
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", args.ownerUserId))
.order("desc")
.take(MALICIOUS_ARTIFACT_AUDIT_LOOKBACK);
const priorFindings = auditLogs
.map(readMaliciousArtifactFindingFromAudit)
.filter((finding): finding is MaliciousArtifactFinding => Boolean(finding));
const escalationReason = getMaliciousArtifactEscalationReason(priorFindings);
if (!escalationReason) {
await scheduleMaliciousArtifactNotificationEmail(ctx, {
target,
findingAt: now,
artifact: { kind: args.artifactKind, name: artifactName },
version,
trigger,
});
return { ok: true as const, escalated: false as const };
}
await ctx.runMutation(internal.users.autobanMalwareAuthorInternal, {
ownerUserId: args.ownerUserId,
slug: artifactName,
trigger,
...(sha256hash ? { sha256hash } : {}),
artifactKind: args.artifactKind,
artifactName,
});
return { ok: true as const, escalated: true as const, reason: escalationReason };
},
});
/**
* Auto-ban a user whose skill was flagged malicious by a scanner.
* Skips moderators/admins. No actor required this is a system-level action.
@@ -2715,6 +2964,8 @@ export const autobanMalwareAuthorInternal = internalMutation({
sha256hash: v.optional(v.string()),
slug: v.string(),
trigger: v.optional(v.string()),
artifactKind: v.optional(v.union(v.literal("skill"), v.literal("plugin"))),
artifactName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const target = await ctx.db.get(args.ownerUserId);
@@ -2807,6 +3058,18 @@ export const autobanMalwareAuthorInternal = internalMutation({
createdAt: now,
});
const trigger = args.trigger?.trim() || "scanner.malicious";
const artifactKind = args.artifactKind ?? "skill";
const artifactName = args.artifactName?.trim() || args.slug;
await scheduleBanNotificationEmail(ctx, {
target,
bannedAt: now,
source: "autoban",
reason: trigger,
trigger,
artifact: { kind: artifactKind, name: artifactName },
});
console.warn(
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
);
+6 -3
View File
@@ -17,8 +17,9 @@ Use GitHub to sign in at [clawhub.ai](https://clawhub.ai).
Deleted, banned, or disabled accounts cannot complete normal ClawHub sign-in.
If sign-in returns you to a logged-out state, your account may not be in good
standing. [Open a GitHub issue](https://github.com/openclaw/clawhub/issues/new)
if you believe this is a mistake.
standing. If your account was banned or disabled, use the
[ClawHub appeal form](https://appeals.openclaw.ai/) if you believe this is a
mistake.
## CLI login
@@ -87,4 +88,6 @@ Revoked, invalid, or missing tokens return `401 Unauthorized`. Sign in again
with `clawhub login` or provide a fresh token with `clawhub login --token`.
Deleted, banned, or disabled accounts cannot continue using existing API tokens.
If you believe this is a mistake, [open a GitHub issue](https://github.com/openclaw/clawhub/issues/new).
If your account was banned or disabled, use the
[ClawHub appeal form](https://appeals.openclaw.ai/) if you believe this is a
mistake.
+16 -4
View File
@@ -189,25 +189,37 @@ Stores your API token + cached registry URL.
clawhub skill publish ./my-skill --version 1.0.0
```
### `scan [path]`
### `scan --slug <slug>`
- Requires `clawhub login`.
- Runs ClawHub ClawScan through `POST /api/v1/skills/-/scan`, then polls until the scan is terminal.
- Scans are asynchronous and may take time to complete. While queued, the terminal spinner shows the current prioritized scan position and how many scans are ahead.
- Local path scans are always ephemeral. They upload the local skill bundle for scanning, print the security report, and never create or update a published skill/version.
- Published scans require ownership or publisher management access. Moderators/admins can use the same backend through `clawhub-mod`.
- `--update` is valid only with `--slug`; it writes successful published scan results back to the selected version.
- `--output <file.zip>` downloads the full report archive with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`.
- `--json` prints the full poll response for automation.
- Local path scans are no longer supported. Upload a new version, then use `scan download` to retrieve the stored scan results for that submitted version.
```bash
clawhub scan ./my-skill
clawhub scan ./my-skill --output report.zip
clawhub scan --slug gifgrep
clawhub scan --slug gifgrep --version 1.2.3
clawhub scan --slug gifgrep --update --output report.zip
```
### `scan download <name>`
- Requires `clawhub login`.
- Downloads the stored scan report ZIP for a submitted skill or plugin version, including versions that were blocked or hidden by ClawHub security checks.
- Skill downloads use the skill slug and default to `--kind skill`.
- Plugin downloads use the package name and require `--kind plugin`.
- `--version` is required so authors inspect the exact submitted version that ClawHub blocked.
- `--output <file.zip>` chooses the destination path.
```bash
clawhub scan download gifgrep --version 1.2.3
clawhub scan download @scope/demo --version 2.0.0 --kind plugin --output report.zip
```
#### GitHub Actions
ClawHub ships an official reusable workflow at
+12 -7
View File
@@ -373,10 +373,8 @@ Notes:
Authenticated submit endpoint for new ClawScan jobs.
Local upload scans use `multipart/form-data`:
- `payload`: JSON string, usually `{ "source": { "kind": "upload" }, "update": false }`
- `files`: repeated local skill files
Local upload scans are no longer supported. Requests using
`multipart/form-data` or `{ "source": { "kind": "upload" } }` return `410`.
Published scans use JSON:
@@ -389,12 +387,10 @@ Published scans use JSON:
Notes:
- Local upload scans require auth but are ephemeral. They never mutate public skill, version, moderation, or trust state.
- Scan request payloads and downloadable reports expire from the scan-request store after the retention window.
- Local upload scans reject `update: true`.
- Published scans require owner/publisher management access, or platform moderator/admin authority.
- Published scans write back only when `update: true` and the scan completes successfully.
- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "upload|published", "update": false, "queue": { "queuedAhead": 0, "queuedAheadIsEstimate": false, "position": 1, "running": 0, "runningIsEstimate": false, "note": "Scans are asynchronous and may take time to complete." } }`.
- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "published", "update": false, "queue": { "queuedAhead": 0, "queuedAheadIsEstimate": false, "position": 1, "running": 0, "runningIsEstimate": false, "note": "Scans are asynchronous and may take time to complete." } }`.
- Scan jobs are asynchronous. Manual scan requests are prioritized ahead of normal publish/backfill work, but completion still depends on worker availability.
### `GET /api/v1/skills/-/scan/{scanId}`
@@ -413,6 +409,15 @@ Authenticated report archive endpoint.
- Requires a succeeded scan; non-terminal scans return `409`.
- Returns a ZIP with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`.
### `GET /api/v1/skills/-/scan/download/{name}?version=<version>&kind=skill|plugin`
Authenticated stored report archive endpoint for submitted versions.
- Requires owner/publisher management access to the skill or plugin, or platform moderator/admin authority.
- Returns stored scan results for the exact submitted version, including blocked or hidden versions.
- `kind` defaults to `skill`; use `kind=plugin` for plugin/package scans.
- Returns the same ZIP shape as scan-request downloads.
### `POST /api/v1/skills/-/scan/batch`
Admin-only canonical batch rescan route. It accepts the same payload shape as legacy `POST /api/v1/skills/-/rescan-batch`.
+8 -3
View File
@@ -79,9 +79,14 @@ result in account bans, token revocation, hidden content, or removed listings.
Deleted, banned, or disabled accounts cannot use ClawHub API tokens. If CLI auth
starts failing after account action, sign in to the web UI to review account
state. If sign-in or normal CLI access is blocked,
[open a GitHub issue](https://github.com/openclaw/clawhub/issues/new) for
recovery review.
state. If sign-in or normal CLI access is blocked by a ban or disabled account,
use the [ClawHub appeal form](https://appeals.openclaw.ai/) for recovery review.
If a scanner-triggered email names a skill or plugin version as malicious,
download the stored scan results for the blocked submitted version:
`clawhub scan download <slug> --version <version>`. For plugins, add
`--kind plugin`. Review the scan output, fix the listing, increment the version
number, and upload the fixed version.
## Publisher guidance
+130 -1
View File
@@ -132,13 +132,142 @@ describe("clawhub e2e", () => {
);
await rm(workdir, { recursive: true, force: true });
expect(result.status).toBe(0);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(result.stderr).not.toMatch(/API response:/);
} finally {
await rm(cfg.dir, { recursive: true, force: true });
}
});
it("cli scan rejects local folders before submitting a scan", async () => {
let requestCount = 0;
const server = createServer(async (_req, res) => {
requestCount += 1;
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const cfg = await makeTempConfig(registry, "test-token");
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-scan-"));
try {
const skillDir = join(workdir, "my-skill");
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, "SKILL.md"), "# Local Skill\n", "utf8");
const result = await spawnCommand(
"bun",
[
"clawhub",
"scan",
"./my-skill",
"--workdir",
workdir,
"--site",
registry,
"--registry",
registry,
],
{
cwd: process.cwd(),
env: {
...process.env,
CLAWHUB_CONFIG_PATH: cfg.path,
CLAWHUB_DISABLE_TELEMETRY: "1",
},
},
);
expect(result.status).not.toBe(0);
expect(requestCount).toBe(0);
expect(result.stderr).toContain("Local folder scans are no longer supported");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(workdir, { recursive: true, force: true });
await rm(cfg.dir, { recursive: true, force: true });
}
});
it("cli scan download fetches a stored submitted-version scan report", async () => {
const reportZip = zipSync({
"manifest.json": strToU8(
`${JSON.stringify({
scanId: "skill:demo-skill:1.2.3",
sourceKind: "published",
status: "succeeded",
})}\n`,
),
"clawscan.json": strToU8(`${JSON.stringify({ status: "malicious" })}\n`),
});
let requestedAuthorization = "";
let requestedPath = "";
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
requestedPath = `${url.pathname}${url.search}`;
if (
req.method === "GET" &&
url.pathname === `${ApiRoutes.skillScans}/download/demo-skill` &&
url.searchParams.get("version") === "1.2.3" &&
url.searchParams.get("kind") === "skill"
) {
requestedAuthorization = req.headers.authorization ?? "";
res.writeHead(200, { "Content-Type": "application/zip" });
res.end(Buffer.from(reportZip));
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const cfg = await makeTempConfig(registry, "test-token");
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-scan-download-"));
try {
const result = await spawnCommand(
"bun",
[
"clawhub",
"scan",
"download",
"demo-skill",
"--version",
"1.2.3",
"--output",
"report.zip",
"--workdir",
workdir,
"--site",
registry,
"--registry",
registry,
],
{
cwd: process.cwd(),
env: {
...process.env,
CLAWHUB_CONFIG_PATH: cfg.path,
CLAWHUB_DISABLE_TELEMETRY: "1",
},
},
);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(requestedAuthorization).toBe("Bearer test-token");
expect(requestedPath).toBe(
`${ApiRoutes.skillScans}/download/demo-skill?version=1.2.3&kind=skill`,
);
expect(result.stdout).toContain("Report ZIP:");
const downloaded = await readFile(join(workdir, "report.zip"));
expect(unzipSync(downloaded)).toHaveProperty("manifest.json");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(workdir, { recursive: true, force: true });
await rm(cfg.dir, { recursive: true, force: true });
}
});
it("assumes a logged-in user (whoami succeeds)", async () => {
const registry = getRegistry();
const site = getSite();
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
import {
expectHealthyPage,
expectNoFatalErrorUi,
trackRuntimeErrors,
waitForHydration,
@@ -91,6 +92,8 @@ type AccountDeletionFixtureState = {
skillActive: boolean;
skillSoftDeletedAt: number | null;
packageExists: boolean;
skillPubliclyVisible: boolean;
packagePubliclyVisible: boolean;
packageActive: boolean;
packageSoftDeletedAt: number | null;
authAccountCount: number;
@@ -216,6 +219,8 @@ test("users can permanently delete their account and personal publisher resource
deletedAt: null,
},
publisherExists: false,
skillPubliclyVisible: false,
packagePubliclyVisible: false,
skillActive: false,
packageActive: false,
authAccountCount: 0,
@@ -227,6 +232,8 @@ test("users can permanently delete their account and personal publisher resource
expect(finalState.user.deactivatedAt).toEqual(expect.any(Number));
expect(finalState.user.purgedAt).toEqual(expect.any(Number));
}
await expectHealthyPage(page, errors);
errors.length = 0;
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
await waitForHydration(page);
+33 -7
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { waitForHydration } from "../helpers/runtimeErrors";
type DevPersona = "owner" | "user" | "admin";
type DevPersona = "owner" | "user" | "admin" | "abusePublisher";
// The quality gate fingerprints line shape, so vary local-auth fixtures by slug.
const FINGERPRINT_SALT_LINES = [
@@ -38,12 +38,27 @@ function fingerprintSaltBlock(args: { slug: string; versionLabel: string }) {
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
const displayName =
persona === "owner" ? "Local Owner" : persona === "user" ? "Local User" : "Local Admin";
persona === "owner"
? "Local Owner"
: persona === "user"
? "Local User"
: persona === "abusePublisher"
? "Local Abuse Test Publisher"
: "Local Admin";
const displayNamePattern =
persona === "abusePublisher"
? `${escapeRegExp("Local Abuse Test Publishe")}.*`
: escapeRegExp(displayName);
const exactHandle =
persona === "owner"
? `${escapeRegExp(expectedHandle)}(?![-\\w])`
: escapeRegExp(expectedHandle);
return new RegExp(`@(?:${exactHandle}|${escapeRegExp(displayName)})`, "i");
return new RegExp(`@(?:${exactHandle}|${displayNamePattern})`, "i");
}
function devPersonaMenuLabel(persona: DevPersona) {
if (persona === "abusePublisher") return "abuse publisher";
return persona;
}
export function skillMd(args: { slug: string; displayName: string; versionLabel: string }) {
@@ -80,7 +95,12 @@ export function escapeRegExp(value: string) {
}
export async function expectLocalPersonaActive(page: Page, persona: DevPersona) {
const expectedHandle = persona === "owner" ? "local" : `local-${persona}`;
const expectedHandle =
persona === "owner"
? "local"
: persona === "abusePublisher"
? "local-abuse"
: `local-${persona}`;
await expect(page.locator("header .user-trigger")).toContainText(
devPersonaHeaderPattern(persona, expectedHandle),
{ timeout: 15_000 },
@@ -92,7 +112,9 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
await waitForHydration(page);
await page.getByRole("button", { name: "Open local dev personas" }).click();
await page.getByRole("menuitem", { name: new RegExp(`use ${persona}`, "i") }).click();
await page
.getByRole("menuitem", { name: new RegExp(`use ${devPersonaMenuLabel(persona)}`, "i") })
.click();
try {
await expectLocalPersonaActive(page, persona);
} catch {
@@ -101,7 +123,11 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
await expectLocalPersonaActive(page, persona);
}
return persona === "owner" ? "local" : `local-${persona}`;
return persona === "owner"
? "local"
: persona === "abusePublisher"
? "local-abuse"
: `local-${persona}`;
}
export async function signInAsLocalOwner(page: Page) {
@@ -140,7 +166,7 @@ export async function selectOwnerHandle(page: Page, selector: string, ownerHandl
await ownerControl.click();
await page
.getByRole("option", {
name: new RegExp(`@${escapeRegExp(ownerHandle)}(?:\\b|\\s|·)`, "i"),
name: new RegExp(`@${escapeRegExp(ownerHandle)}(?:\\s|·|$)`, "i"),
})
.click();
}
@@ -0,0 +1,230 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { expect, test } from "@playwright/test";
import convexBrowser from "convex/browser";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
import { publishSkillVersion, signInAsLocalPublisher } from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
"malicious skill ban flow requires the local dev auth runner",
);
test.setTimeout(180_000);
const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token";
const { ConvexHttpClient } = convexBrowser;
type ConvexHttpClientInstance = InstanceType<typeof ConvexHttpClient>;
type ClaimedScanJob = {
job: { _id: Id<"securityScanJobs">; leaseToken: string };
target?: { skill?: { slug?: string }; version?: { version?: string } };
};
type CapturedEmail = {
idempotencyKey: string;
to: string;
subject: string;
text: string;
html: string;
capturedAt: number;
};
function convexClient() {
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) throw new Error("VITE_CONVEX_URL is required");
return new ConvexHttpClient(convexUrl);
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function readCapturedEmails() {
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE;
if (!captureFile) throw new Error("CLAWHUB_EMAIL_CAPTURE_FILE is required");
if (!existsSync(captureFile)) return [];
const raw = await readFile(captureFile, "utf8");
return raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as CapturedEmail);
}
async function waitForCapturedEmails(predicate: (emails: CapturedEmail[]) => boolean) {
const deadline = Date.now() + 20_000;
let latest: CapturedEmail[] = [];
while (Date.now() < deadline) {
latest = await readCapturedEmails();
if (predicate(latest)) return latest;
await sleep(500);
}
throw new Error(
`Timed out waiting for captured emails. Saw: ${latest
.map((email) => email.subject)
.join(", ")}`,
);
}
async function waitForClaimedScanJob(
client: ConvexHttpClientInstance,
slug: string,
version: string,
) {
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
const jobs = (await client.action(api.securityScan.claimCodexScanJobs, {
token: WORKER_TOKEN,
workerId: `pw-malicious-skill-${slug}-${version}`,
limit: 20,
leaseMs: 60_000,
})) as ClaimedScanJob[];
const match = jobs.find(
(job) => job.target?.skill?.slug === slug && job.target?.version?.version === version,
);
if (match) return match;
await sleep(500);
}
throw new Error(`Timed out waiting for security scan job for ${slug}@${version}`);
}
async function completeScan(
client: ConvexHttpClientInstance,
args: { slug: string; version: string; verdict: "benign" | "malicious" },
) {
const scanJob = await waitForClaimedScanJob(client, args.slug, args.version);
const malicious = args.verdict === "malicious";
await client.action(api.securityScan.completeCodexScanJob, {
token: WORKER_TOKEN,
jobId: scanJob.job._id,
leaseToken: scanJob.job.leaseToken,
runId: "playwright-local-auth",
llmAnalysis: {
status: malicious ? "malicious" : "clean",
verdict: args.verdict,
confidence: "high",
summary: malicious
? "Synthetic local e2e malicious verdict."
: "Synthetic local e2e clean verdict.",
guidance: malicious
? "Synthetic local e2e blocked upload."
: "Synthetic local e2e clean upload.",
model: "mock-local-e2e",
checkedAt: Date.now(),
},
});
}
async function expectCurrentVersion(page: import("@playwright/test").Page, version: string) {
const metadata = page.locator(".sidebar-metadata");
await expect(metadata.getByText("Current version", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(metadata.getByText(`v${version}`, { exact: true })).toBeVisible({
timeout: 30_000,
});
}
function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
return errors.filter(
(error) => !(error.includes("CONVEX M(users:ensure)") && error.includes("User not found")),
);
}
test("malicious skill retries keep the clean latest visible, email the publisher, and ban on third rejection", async ({
page,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const client = convexClient();
const slug = `pw-malware-${Date.now().toString(36)}`;
const displayName = "Playwright Malicious Skill Flow";
const ownerHandle = await signInAsLocalPublisher(page, "abusePublisher");
await publishSkillVersion(page, testInfo, {
ownerHandle,
slug,
displayName,
version: "1.0.0",
versionLabel: "clean baseline release",
changelog: "Clean baseline release before malicious retry validation.",
});
await completeScan(client, { slug, version: "1.0.0", verdict: "benign" });
await page.reload({ waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expectCurrentVersion(page, "1.0.0");
const maliciousVersions = ["1.0.1", "1.0.2", "1.0.3"] as const;
const finalMaliciousVersion = maliciousVersions[maliciousVersions.length - 1];
for (const version of maliciousVersions) {
await page.getByRole("link", { name: "New version" }).click();
await expect(page).toHaveURL(/\/skills\/publish\?updateSlug=/);
await publishSkillVersion(page, testInfo, {
ownerHandle,
slug,
displayName,
version,
versionLabel: `malicious retry ${version}`,
changelog: `Synthetic malicious retry ${version}.`,
});
await completeScan(client, { slug, version, verdict: "malicious" });
if (version === finalMaliciousVersion) {
await waitForCapturedEmails((emails) =>
emails.some((email) => email.subject === "Your ClawHub account was disabled"),
);
} else {
await waitForCapturedEmails(
(emails) =>
emails.filter(
(email) =>
email.subject === "ClawHub blocked a skill version" &&
email.text.includes(`Version: ${version}`) &&
email.text.includes(`clawhub scan download ${slug} --version ${version}`),
).length === 1,
);
}
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
await waitForHydration(page);
if (version !== finalMaliciousVersion) {
await expectCurrentVersion(page, "1.0.0");
}
}
const emails = await waitForCapturedEmails(
(captured) =>
captured.filter((email) => email.subject === "ClawHub blocked a skill version").length ===
2 && captured.some((email) => email.subject === "Your ClawHub account was disabled"),
);
const artifactEmails = emails.filter(
(email) => email.subject === "ClawHub blocked a skill version",
);
expect(artifactEmails).toHaveLength(2);
for (const email of artifactEmails) {
expect(email.text).toContain("Your account can still sign in.");
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
expect(email.text).not.toContain("appeals.openclaw.ai");
}
const accountBanEmail = emails.find(
(email) => email.subject === "Your ClawHub account was disabled",
);
expect(accountBanEmail?.text).toContain("Appeal: https://appeals.openclaw.ai/");
expect(accountBanEmail?.text).not.toContain("clawhub scan download");
await page.getByRole("button", { name: "Open local dev personas" }).click();
await page.getByRole("menuitem", { name: /sign out/i }).click();
await page.goto("/dashboard?error_description=This%20account%20has%20been%20banned", {
waitUntil: "domcontentloaded",
});
await expect(page).toHaveURL(/\/account-banned$/, { timeout: 30_000 });
await expect(
page.getByRole("heading", { name: "Your ClawHub account has been banned" }),
).toBeVisible();
await expect(page.getByRole("link", { name: "Open an appeal" })).toHaveAttribute(
"href",
"https://appeals.openclaw.ai/",
);
await expectHealthyPage(page, withoutExpectedBannedSessionTeardownErrors(errors));
});
+2 -1
View File
@@ -12,7 +12,7 @@
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-mod verify",
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
@@ -109,6 +109,7 @@
"rehype-raw": "7.0.0",
"rehype-sanitize": "6.0.0",
"remark-gfm": "4.0.1",
"resend": "6.12.4",
"semver": "7.8.2",
"shiki": "4.2.0",
"sonner": "2.0.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawhub",
"version": "0.19.2",
"version": "0.20.0",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"homepage": "https://clawhub.ai",
"bugs": {
+20 -4
View File
@@ -31,7 +31,7 @@ import {
} from "./cli/commands/packages.js";
import { cmdPublish } from "./cli/commands/publish.js";
import { cmdCreatePublisher } from "./cli/commands/publishers.js";
import { cmdScan } from "./cli/commands/scan.js";
import { cmdScan, cmdScanDownload } from "./cli/commands/scan.js";
import {
cmdExplore,
cmdInstall,
@@ -374,9 +374,9 @@ registerCommand(program, ["publish"])
await cmdPublish(opts, folder, options);
});
registerCommand(program, ["scan"])
.description("Run ClawScan on a local skill bundle or one of your published skills")
.argument("[path]", "Local skill folder path")
const scanCmd = registerCommand(program, ["scan"])
.description("Run or download ClawHub scan reports")
.argument("[path]", "Deprecated local skill folder path")
.option("--slug <slug>", "Published skill slug to scan")
.option("--version <version>", "Published skill version to scan")
.option("--update", "Write published scan results back to the selected version")
@@ -387,6 +387,22 @@ registerCommand(program, ["scan"])
await cmdScan(opts, folder, options);
});
registerCommand(scanCmd, ["scan", "download"])
.description("Download stored scan results for a submitted skill or plugin version")
.argument("<name>", "Skill slug or plugin package name")
.option("--version <version>", "Submitted version to download scan results for")
.option("--kind <kind>", "Artifact kind: skill or plugin", "skill")
.option("-o, --output <path>", "Output ZIP file")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
const parentOptions = scanCmd.opts<{ version?: string; output?: string }>();
await cmdScanDownload(opts, name, {
...options,
version: options.version ?? parentOptions.version,
output: options.output ?? parentOptions.output,
});
});
registerCommand(program, ["delete"])
.description("Soft-delete one of your skills")
.argument("<slug>", "Skill slug")
+78 -42
View File
@@ -23,7 +23,7 @@ vi.mock("../registry.js", () => registryMocks.moduleFactory());
vi.mock("../../http.js", () => httpMocks.moduleFactory());
vi.mock("../ui.js", () => uiMocks.moduleFactory());
const { cmdScan } = await import("./scan");
const { cmdScan, cmdScanDownload } = await import("./scan");
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
@@ -88,7 +88,7 @@ afterEach(() => {
});
describe("cmdScan", () => {
it("uploads a local skill bundle and polls until complete", async () => {
it("rejects local folder scans because stored submitted-version reports are canonical", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "local-skill");
@@ -96,47 +96,13 @@ describe("cmdScan", () => {
await writeFile(join(folder, "SKILL.md"), "# Local Skill\n", "utf8");
await writeFile(join(folder, "notes.md"), "notes\n", "utf8");
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
scanId: "scan_123",
jobId: "job_123",
status: "queued",
sourceKind: "upload",
update: false,
});
httpMocks.apiRequest.mockResolvedValueOnce(completedScan({ sourceKind: "upload" }));
await cmdScan(makeGlobalOpts(workdir), "local-skill", {});
const submitCall = httpMocks.apiRequestForm.mock.calls[0];
expect(submitCall?.[1]).toMatchObject({
method: "POST",
path: ApiRoutes.skillScans,
token: "tkn",
});
if (!submitCall) throw new Error("missing scan submit call");
const form = (submitCall[1] as { form: FormData }).form;
const payloadRaw = form.get("payload");
expect(typeof payloadRaw).toBe("string");
expect(JSON.parse(payloadRaw as string)).toEqual({
source: { kind: "upload" },
update: false,
});
const files = form?.getAll("files") as Array<Blob & { name?: string }>;
expect(files.map((file) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
method: "GET",
path: `${ApiRoutes.skillScans}/scan_123`,
token: "tkn",
}),
expect.anything(),
);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("ClawScan"));
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining("No suspicious behavior found."),
await expect(cmdScan(makeGlobalOpts(workdir), "local-skill", {})).rejects.toThrow(
"Local folder scans are no longer supported",
);
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
expect(registryMocks.getRegistry).not.toHaveBeenCalled();
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
@@ -212,3 +178,73 @@ describe("cmdScan", () => {
);
});
});
describe("cmdScanDownload", () => {
it("downloads stored scan results for a submitted skill version", async () => {
const workdir = await makeTmpWorkdir();
try {
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
await cmdScanDownload(makeGlobalOpts(workdir), "demo-skill", {
version: "1.2.3",
output: "scan.zip",
});
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
path: `${ApiRoutes.skillScans}/download/demo-skill?version=1.2.3&kind=skill`,
token: "tkn",
});
expect(await readFile(join(workdir, "scan.zip"))).toEqual(Buffer.from([80, 75, 3, 4]));
expect(mockLog).toHaveBeenCalledWith(`Report ZIP: ${join(workdir, "scan.zip")}`);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("requires a version because rejected versions must be addressed explicitly", async () => {
await expect(cmdScanDownload(makeGlobalOpts(), "demo-skill", {})).rejects.toThrow(
"--version required",
);
});
it("sanitizes the version in the default report filename", async () => {
const workdir = await makeTmpWorkdir();
try {
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
await cmdScanDownload(makeGlobalOpts(workdir), "demo-skill", {
version: "../../evil",
});
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
path: `${ApiRoutes.skillScans}/download/demo-skill?version=..%2F..%2Fevil&kind=skill`,
token: "tkn",
});
expect(await readFile(join(workdir, "clawhub-scan-demo-skill-..-..-evil.zip"))).toEqual(
Buffer.from([80, 75, 3, 4]),
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("supports plugin scan report downloads with an explicit kind", async () => {
const workdir = await makeTmpWorkdir();
try {
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
await cmdScanDownload(makeGlobalOpts(workdir), "@scope/demo", {
kind: "plugin",
version: "2.0.0",
output: "plugin-scan.zip",
});
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
path: `${ApiRoutes.skillScans}/download/%40scope%2Fdemo?version=2.0.0&kind=plugin`,
token: "tkn",
});
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
});
+49 -33
View File
@@ -1,13 +1,12 @@
import { mkdir, stat, writeFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { apiRequest, apiRequestForm, fetchBinary } from "../../http.js";
import { apiRequest, fetchBinary } from "../../http.js";
import {
ApiRoutes,
ApiV1SkillScanStatusResponseSchema,
ApiV1SkillScanSubmitResponseSchema,
type ApiV1SkillScanStatusResponse,
} from "../../schema/index.js";
import { listTextFiles } from "../../skills.js";
import { requireAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import type { GlobalOpts } from "../types.js";
@@ -24,19 +23,24 @@ type ScanOptions = {
json?: boolean;
};
type ScanDownloadOptions = {
kind?: "skill" | "plugin";
version?: string;
output?: string;
};
type ReportRecord = Record<string, unknown>;
export async function cmdScan(opts: GlobalOpts, pathArg: string | undefined, options: ScanOptions) {
validateScanOptions(pathArg, options);
if (pathArg?.trim()) rejectLocalScan();
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner("Submitting scan");
try {
const submitted = pathArg
? await submitLocalScan(opts, registry, token, pathArg)
: await submitPublishedScan(registry, token, options);
const submitted = await submitPublishedScan(registry, token, options);
spinner.text = formatScanProgress("queued", submitted.scanId, submitted.queue);
const status = await pollScan(registry, token, submitted.scanId, spinner);
@@ -68,6 +72,34 @@ export async function cmdScan(opts: GlobalOpts, pathArg: string | undefined, opt
}
}
export async function cmdScanDownload(
opts: GlobalOpts,
nameArg: string,
options: ScanDownloadOptions,
) {
const name = nameArg.trim();
if (!name) fail("Skill or plugin name required");
const version = options.version?.trim();
if (!version) fail("--version required");
const kind = options.kind ?? "skill";
if (kind !== "skill" && kind !== "plugin") fail('--kind must be "skill" or "plugin"');
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const output = resolve(
opts.workdir,
options.output ?? `clawhub-scan-${safeOutputName(name)}-${safeOutputName(version)}.zip`,
);
const query = new URLSearchParams({ version, kind });
const bytes = await fetchBinary(registry, {
path: `${ApiRoutes.skillScans}/download/${encodeURIComponent(name)}?${query.toString()}`,
token,
});
await mkdir(dirname(output), { recursive: true });
await writeFile(output, bytes);
console.log(`Report ZIP: ${output}`);
}
function validateScanOptions(pathArg: string | undefined, options: ScanOptions) {
const hasPath = Boolean(pathArg?.trim());
const hasSlug = Boolean(options.slug?.trim());
@@ -76,33 +108,9 @@ function validateScanOptions(pathArg: string | undefined, options: ScanOptions)
if (hasPath && options.update) fail("--update is only valid with --slug");
}
async function submitLocalScan(opts: GlobalOpts, registry: string, token: string, pathArg: string) {
const folder = resolve(opts.workdir, pathArg);
const folderStat = await stat(folder).catch(() => null);
if (!folderStat?.isDirectory()) fail("Path must be a folder");
const files = await listTextFiles(folder);
if (
!files.some((file) => {
const lower = file.relPath.toLowerCase();
return lower === "skill.md";
})
) {
fail("SKILL.md required");
}
if (files.length === 0) fail("No files found");
const form = new FormData();
form.set("payload", JSON.stringify({ source: { kind: "upload" }, update: false }));
for (const file of files) {
const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? "text/plain" });
form.append("files", blob, file.relPath);
}
return await apiRequestForm(
registry,
{ method: "POST", path: ApiRoutes.skillScans, token, form },
ApiV1SkillScanSubmitResponseSchema,
function rejectLocalScan(): never {
fail(
"Local folder scans are no longer supported. Upload a new version, then run `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
);
}
@@ -321,3 +329,11 @@ function dateValue(value: unknown) {
? new Date(value).toISOString()
: undefined;
}
function safeOutputName(value: string) {
return value
.replace(/^@/, "")
.replaceAll("/", "-")
.replaceAll(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
+4
View File
@@ -33,6 +33,7 @@ type LocalDeploymentConfig = {
const managedChildren = new Set<ChildProcess>();
const tempDir = mkdtempSync(join(tmpdir(), "clawhub-pw-local-auth-"));
const envFile = join(tempDir, ".env.local");
const emailCaptureFile = join(tempDir, "emails.jsonl");
const localConvexStateBackupDir = join(tempDir, "convex-local-default.backup");
const localEnvBackupFile = join(tempDir, ".env.local.backup");
let backedUpLocalConvexState = false;
@@ -339,6 +340,7 @@ async function main() {
...process.env,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID ?? "local-dev",
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET ?? "local-dev",
CLAWHUB_EMAIL_CAPTURE_FILE: process.env.CLAWHUB_EMAIL_CAPTURE_FILE ?? emailCaptureFile,
CONVEX_AGENT_MODE: process.env.CONVEX_AGENT_MODE ?? "anonymous",
CONVEX_SITE_URL: convexSiteUrl,
DEV_AUTH_ENABLED: "1",
@@ -362,6 +364,7 @@ async function main() {
[
`AUTH_GITHUB_ID=${e2eEnv.AUTH_GITHUB_ID}`,
`AUTH_GITHUB_SECRET=${e2eEnv.AUTH_GITHUB_SECRET}`,
`CLAWHUB_EMAIL_CAPTURE_FILE=${e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE}`,
...(deployment ? [`CONVEX_DEPLOYMENT=${deployment}`] : []),
`CONVEX_SITE_URL=${convexSiteUrl}`,
...(deployment ? [`DEV_AUTH_CONVEX_DEPLOYMENT=${devAuthDeploymentMarker(deployment)}`] : []),
@@ -408,6 +411,7 @@ async function main() {
await setLocalConvexEnv(convexUrl, [
{ name: "AUTH_GITHUB_ID", value: e2eEnv.AUTH_GITHUB_ID ?? "local-dev" },
{ name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" },
{ name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" },
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: localAuthDeployment },
{ name: "DEV_AUTH_ENABLED", value: "1" },
{ name: "JWKS", value: authKeys.JWKS },
+6
View File
@@ -102,6 +102,12 @@ Ensure Convex env is set (auth + embeddings):
- `JWT_PRIVATE_KEY`
- `JWKS`
- `OPENAI_API_KEY`
- `RESEND_API_KEY` for account-ban notification email
- `CLAWHUB_SECURITY_EMAIL` for account-action replies, defaulting to
`security@notifications.openclaw.ai`
- `CLAWHUB_SECURITY_EMAIL_FROM` for the outbound From header, defaulting to
`ClawHub Security <noreply@notifications.openclaw.ai>` on the verified Resend
domain
- `SITE_URL` (your web app URL)
- Optional webhook env (see `docs/webhook.md`)
- Recommended GitHub App env for authenticated GitHub API reads used by publish
+19 -5
View File
@@ -108,10 +108,15 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
compatibility, but the first-class CLI and docs surface is deprecated.
Publisher recovery for false positives should use reports or out-of-band
support, while account bans require out-of-band support.
- Any ClawScan path that determines a skill is malicious must hide the skill and
schedule the same account-level autoban/token-revocation workflow. Static
scan findings are ClawScan input context only and must not schedule autobans
or set public/install-blocking trust by themselves.
- Any ClawScan path that determines a skill or plugin release is malicious must
block that candidate version and notify the publisher with local
`clawhub scan` remediation guidance. Scanner-triggered emails are
artifact-level and must not link to account appeals. Account-level autoban,
token revocation, and appeal email only happen after the silent escalation
thresholds: two distinct malicious artifacts or three malicious attempts on
the same artifact. Static scan findings are ClawScan input context only and
must not schedule account autobans or set public/install-blocking trust by
themselves.
- Pending skill ownership transfers must not be accepted when the requesting
owner is deleted/deactivated or when the skill is malicious, hidden, or
removed. The accept path is the final shared gate before ownership changes,
@@ -216,7 +221,9 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
post-Codex veto. The release worker must not downgrade a benign Codex verdict
solely from regex telemetry.
- Artifacts remain visible while Codex runs unless another non-scanner moderation
hold applies. Codex malicious verdicts hide/block.
hold applies. Codex malicious verdicts block the candidate version. On updates,
the previous clean/current public version remains live; on first versions,
nothing public is promoted.
- Plugins under `@openclaw/*` owned by the OpenClaw publisher are trusted by
default. They may still be audited, but scanner telemetry alone must not
downgrade them.
@@ -298,6 +305,13 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
on the restored user's package audit rows.
Ban context lookup must tolerate duplicate Convex Auth account rows by
selecting the currently banned user with matching ban audit evidence.
- Ban notification emails must be public-safe: include the high-level action
reason, affected skill/plugin when known, the external appeals link, and
scanner context when the account was escalated from repeated malicious
artifacts. Artifact-level scanner emails must instead say the version was
blocked, keep appeals out of the copy, and link existing CLI scan docs. Emails
must not expose raw moderator notes, reporter identifiers, internal finding
ids, or other staff-only ban reason text.
- Unban restore batches only restore packages/plugins hidden by the matching
ban timestamp and must stop if the user has been banned again.
- Stale unban restore batches must stop if the user was banned again before a
+1 -1
View File
@@ -188,7 +188,7 @@ describe("restored UI design contract", () => {
const settingsSource = settings();
expect(settingsSource).not.toMatch(/tweakcn|custom theme|overlay/i);
expect(settingsSource).not.toMatch(/density|compact|relaxed|high contrast|code font size/i);
expect(settingsSource).not.toMatch(/density|relaxed|high contrast|code font size/i);
expect(settingsSource).not.toMatch(/default view|experimental features/i);
const themeSource = theme();
+9 -9
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ACCESS_DENIED_SIGN_IN_MESSAGE,
AUTH_CODE_NO_SESSION_MESSAGE,
BANNED_SIGN_IN_MESSAGE,
BANNED_ACCOUNT_PATH,
DELETED_SIGN_IN_MESSAGE,
} from "../lib/authErrorMessage";
import { getAuthErrorSnapshot, clearAuthError, setAuthError } from "../lib/useAuthError";
@@ -88,7 +88,7 @@ describe("AuthCodeHandler", () => {
);
});
it("surfaces user-facing sign-in errors from code verification", async () => {
it("routes banned-account errors from code verification to the account-banned page", async () => {
signInMock.mockRejectedValue(
new Error("[CONVEX A] Server Error Called by client ConvexError: Account banned"),
);
@@ -97,8 +97,9 @@ describe("AuthCodeHandler", () => {
render(<AuthCodeHandler />);
await waitFor(() => {
expect(getAuthErrorSnapshot()).toBe(BANNED_SIGN_IN_MESSAGE);
expect(window.location.pathname).toBe(BANNED_ACCOUNT_PATH);
});
expect(getAuthErrorSnapshot()).toBeNull();
});
it("restarts GitHub sign-in once when code verification finishes without a session", async () => {
@@ -186,7 +187,7 @@ describe("AuthErrorHandler", () => {
expect(getAuthErrorSnapshot()).toBeNull();
});
it("surfaces provider errors from the URL and strips them", async () => {
it("routes banned-account provider errors to the account-banned page", async () => {
window.history.replaceState(
null,
"",
@@ -196,12 +197,11 @@ describe("AuthErrorHandler", () => {
render(<AuthErrorHandler />);
await waitFor(() => {
expect(getAuthErrorSnapshot()).toBe(BANNED_SIGN_IN_MESSAGE);
expect(window.location.pathname).toBe(BANNED_ACCOUNT_PATH);
});
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
"/sign-in?next=%2Fdashboard#section",
);
expect(getAuthErrorSnapshot()).toBeNull();
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("");
});
it("falls back to the provider error when there is no description", async () => {
+37 -5
View File
@@ -5,7 +5,9 @@ import { convex } from "../convex/client";
import {
AUTH_CODE_NO_SESSION_MESSAGE,
getUserFacingAuthError,
isBannedAccountAuthError,
normalizeAuthErrorMessage,
routeToBannedAccountPage as navigateToBannedAccountPage,
} from "../lib/authErrorMessage";
import { clearAuthError, setAuthError, useAuthError } from "../lib/useAuthError";
import { AuthErrorMessage } from "./AuthErrorMessage";
@@ -33,6 +35,20 @@ function getPendingAuthCode() {
};
}
function routeAuthErrorToBannedAccountPage() {
if (typeof window === "undefined") return;
clearAuthError();
navigateToBannedAccountPage();
}
function handleAuthErrorMessage(message: string) {
if (isBannedAccountAuthError(message)) {
routeAuthErrorToBannedAccountPage();
return;
}
setAuthError(message);
}
export function AuthCodeHandler() {
const { signIn } = useAuthActions();
const handledCodeRef = useRef<string | null>(null);
@@ -78,8 +94,13 @@ export function AuthCodeHandler() {
}
})
.catch((error) => {
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
if (isBannedAccountAuthError(message)) {
routeAuthErrorToBannedAccountPage();
return;
}
window.history.replaceState(null, "", pending.relativeUrl);
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
setAuthError(message);
});
return;
}
@@ -88,8 +109,13 @@ export function AuthCodeHandler() {
setAuthError(AUTH_CODE_NO_SESSION_MESSAGE);
})
.catch((error) => {
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
if (isBannedAccountAuthError(message)) {
routeAuthErrorToBannedAccountPage();
return;
}
window.history.replaceState(null, "", pending.relativeUrl);
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
setAuthError(message);
});
}, [signInWithGitHub]);
@@ -118,10 +144,16 @@ export function AuthErrorHandler() {
if (handledErrorRef.current === pending.description) return;
handledErrorRef.current = pending.description;
window.history.replaceState(null, "", pending.relativeUrl);
setAuthError(
normalizeAuthErrorMessage(pending.description, "Sign in failed. Please try again."),
const message = normalizeAuthErrorMessage(
pending.description,
"Sign in failed. Please try again.",
);
if (isBannedAccountAuthError(message)) {
routeAuthErrorToBannedAccountPage();
return;
}
window.history.replaceState(null, "", pending.relativeUrl);
handleAuthErrorMessage(message);
}, []);
return null;
+11 -4
View File
@@ -1,25 +1,32 @@
import {
ACCOUNT_APPEAL_LINK_TEXT,
ACCOUNT_APPEAL_URL,
CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT,
CLAWHUB_ACCOUNT_ISSUE_URL,
} from "../lib/authErrorMessage";
export function AuthErrorMessage({ message }: { message: string }) {
const linkStart = message.indexOf(CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT);
const link =
message.indexOf(ACCOUNT_APPEAL_LINK_TEXT) === -1
? { text: CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT, href: CLAWHUB_ACCOUNT_ISSUE_URL }
: { text: ACCOUNT_APPEAL_LINK_TEXT, href: ACCOUNT_APPEAL_URL };
const linkStart = message.indexOf(link.text);
if (linkStart === -1) return <>{message}</>;
const before = message.slice(0, linkStart);
const after = message.slice(linkStart + CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT.length);
const after = message.slice(linkStart + link.text.length);
return (
<>
{before}
<a
href={CLAWHUB_ACCOUNT_ISSUE_URL}
href={link.href}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-2"
>
{CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT}
{link.text}
</a>
{after}
</>
+14 -2
View File
@@ -2,6 +2,7 @@ import { useAuthActions } from "@convex-dev/auth/react";
import { Building2, Check, Shield, User, UserCog, Wrench } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { isBannedAccountAuthError, routeToBannedAccountPage } from "../lib/authErrorMessage";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
@@ -13,7 +14,7 @@ import {
} from "./ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
type DevPersona = "owner" | "user" | "admin" | "officialOrgMember";
type DevPersona = "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher";
const DEV_PERSONA_AUTH_TIMEOUT_MS = 10_000;
@@ -47,6 +48,12 @@ const PERSONAS: Array<{
description: "@local-official-member",
icon: Building2,
},
{
value: "abusePublisher",
label: "Use Abuse Publisher",
description: "@local-abuse",
icon: Wrench,
},
];
function isLocalDevPersonaEnabled() {
@@ -115,7 +122,12 @@ export function DevPersonaFab() {
}
toast.success(`Using ${persona} persona`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Dev persona sign-in failed");
const message = error instanceof Error ? error.message : "Dev persona sign-in failed";
if (isBannedAccountAuthError(message)) {
routeToBannedAccountPage();
return;
}
toast.error(message);
} finally {
setBusyPersona(null);
}
+13 -3
View File
@@ -13,7 +13,11 @@ import {
Sun,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import {
getUserFacingAuthError,
isBannedAccountAuthError,
routeToBannedAccountPage,
} from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import { NAV_ICONS } from "../lib/marketplaceIcons";
import { filterNavItems, PRIMARY_NAV_ITEMS, SECONDARY_NAV_ITEMS } from "../lib/nav-items";
@@ -509,9 +513,15 @@ export default function Header() {
}
})
.catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
const message = getUserFacingAuthError(
error,
"Sign in failed. Please try again.",
);
if (isBannedAccountAuthError(message)) {
routeToBannedAccountPage();
return;
}
setAuthError(message);
});
}}
>
+22
View File
@@ -8,6 +8,8 @@ const signInMock = vi.fn();
const clearAuthErrorMock = vi.fn();
const setAuthErrorMock = vi.fn();
const getUserFacingAuthErrorMock = vi.fn();
const isBannedAccountAuthErrorMock = vi.fn();
const routeToBannedAccountPageMock = vi.fn();
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
@@ -23,6 +25,8 @@ vi.mock("../lib/useAuthError", () => ({
vi.mock("../lib/authErrorMessage", () => ({
getUserFacingAuthError: (error: unknown, fallback: string) =>
getUserFacingAuthErrorMock(error, fallback),
isBannedAccountAuthError: (message: string) => isBannedAccountAuthErrorMock(message),
routeToBannedAccountPage: () => routeToBannedAccountPageMock(),
}));
describe("SignInButton", () => {
@@ -31,7 +35,10 @@ describe("SignInButton", () => {
clearAuthErrorMock.mockReset();
setAuthErrorMock.mockReset();
getUserFacingAuthErrorMock.mockReset();
isBannedAccountAuthErrorMock.mockReset();
routeToBannedAccountPageMock.mockReset();
getUserFacingAuthErrorMock.mockImplementation((_, fallback) => fallback);
isBannedAccountAuthErrorMock.mockReturnValue(false);
window.history.replaceState(null, "", "/skills?q=test#top");
});
@@ -99,4 +106,19 @@ describe("SignInButton", () => {
expect(setAuthErrorMock).toHaveBeenCalledWith("GitHub auth unavailable");
});
});
it("routes banned-account sign-in rejections to the banned account page", async () => {
const failure = new Error("oauth failed");
signInMock.mockRejectedValue(failure);
getUserFacingAuthErrorMock.mockReturnValue("This account has been banned.");
isBannedAccountAuthErrorMock.mockReturnValue(true);
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1);
});
expect(setAuthErrorMock).not.toHaveBeenCalled();
});
});
+11 -2
View File
@@ -1,6 +1,10 @@
import { useAuthActions } from "@convex-dev/auth/react";
import type { ComponentProps } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import {
getUserFacingAuthError,
isBannedAccountAuthError,
routeToBannedAccountPage,
} from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { Button } from "./ui/button";
@@ -28,7 +32,12 @@ export function SignInButton({ redirectTo, children = "Sign In", ...props }: Sig
}
})
.catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
if (isBannedAccountAuthError(message)) {
routeToBannedAccountPage();
return;
}
setAuthError(message);
});
}}
>
+37 -5
View File
@@ -2,12 +2,15 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { Star } from "lucide-react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SignInPrompt } from "./SignInPrompt";
const signInMock = vi.fn();
const clearAuthErrorMock = vi.fn();
const setAuthErrorMock = vi.fn();
const isBannedAccountAuthErrorMock = vi.fn();
const routeToBannedAccountPageMock = vi.fn();
let authErrorMock: string | null = null;
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
@@ -18,15 +21,31 @@ vi.mock("@convex-dev/auth/react", () => ({
vi.mock("../lib/useAuthError", () => ({
clearAuthError: () => clearAuthErrorMock(),
setAuthError: (message: string) => setAuthErrorMock(message),
useAuthError: () => ({ error: authErrorMock, clear: clearAuthErrorMock }),
}));
vi.mock("../lib/authErrorMessage", () => ({
ACCOUNT_APPEAL_LINK_TEXT: "appeal this decision",
ACCOUNT_APPEAL_URL: "https://appeals.openclaw.ai/",
CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT: "open a GitHub issue",
CLAWHUB_ACCOUNT_ISSUE_URL: "https://github.com/openclaw/clawhub/issues/new",
getUserFacingAuthError: (_error: unknown, fallback: string) => fallback,
isBannedAccountAuthError: (message: string | null | undefined) =>
isBannedAccountAuthErrorMock(message),
routeToBannedAccountPage: () => routeToBannedAccountPageMock(),
}));
describe("SignInPrompt", () => {
beforeEach(() => {
authErrorMock = null;
clearAuthErrorMock.mockReset();
setAuthErrorMock.mockReset();
isBannedAccountAuthErrorMock.mockReset();
routeToBannedAccountPageMock.mockReset();
signInMock.mockReset();
isBannedAccountAuthErrorMock.mockReturnValue(false);
});
it("renders title and description", () => {
render(<SignInPrompt title="Sign in to test" description="Test description" />);
expect(screen.getByRole("heading", { name: "Sign in to test" })).toBeTruthy();
@@ -58,16 +77,29 @@ describe("SignInPrompt", () => {
expect(onDismissError).toHaveBeenCalledTimes(1);
});
it("links account issue guidance in auth errors", () => {
it("routes explicit banned-account auth errors to the banned account page", () => {
isBannedAccountAuthErrorMock.mockReturnValue(true);
render(
<SignInPrompt
title="Sign in"
error="Sign in failed. Please open a GitHub issue if you believe this is a mistake."
error="This ClawHub account is not in good standing and cannot sign in. Please appeal this decision if you believe this is a mistake."
/>,
);
const link = screen.getByRole("link", { name: "open a GitHub issue" });
expect(link.getAttribute("href")).toBe("https://github.com/openclaw/clawhub/issues/new");
expect(screen.queryByRole("alert")).toBeNull();
expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1);
});
it("routes global banned-account auth errors to the banned account page", () => {
authErrorMock =
"This ClawHub account is not in good standing and cannot sign in. Please appeal this decision if you believe this is a mistake.";
isBannedAccountAuthErrorMock.mockReturnValue(true);
render(<SignInPrompt title="Sign in" />);
expect(screen.queryByRole("alert")).toBeNull();
expect(clearAuthErrorMock).toHaveBeenCalledTimes(1);
expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1);
});
it("does not render dismiss button when onDismissError is missing", () => {
+21 -5
View File
@@ -1,6 +1,8 @@
import type { LucideIcon } from "lucide-react";
import { LockKeyhole } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { isBannedAccountAuthError, routeToBannedAccountPage } from "../lib/authErrorMessage";
import { useAuthError } from "../lib/useAuthError";
import { AuthErrorMessage } from "./AuthErrorMessage";
import { SignInButton } from "./SignInButton";
@@ -33,6 +35,20 @@ export function SignInPrompt({
onDismissError,
className,
}: SignInPromptProps) {
const { error: globalAuthError, clear: clearGlobalAuthError } = useAuthError();
const rawVisibleError = error ?? globalAuthError;
const isBannedAuthError = isBannedAccountAuthError(rawVisibleError);
const visibleError = isBannedAuthError ? null : rawVisibleError;
const dismissVisibleError = error
? onDismissError
: (onDismissError ?? (globalAuthError ? clearGlobalAuthError : undefined));
useEffect(() => {
if (!isBannedAuthError) return;
if (globalAuthError) clearGlobalAuthError();
routeToBannedAccountPage();
}, [clearGlobalAuthError, globalAuthError, isBannedAuthError]);
const defaultAction = (
<SignInButton
size="sm"
@@ -74,16 +90,16 @@ export function SignInPrompt({
{description}
</p>
) : null}
{error ? (
{visibleError ? (
<p
className="mt-3 rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300"
role="alert"
>
<AuthErrorMessage message={error} />{" "}
{onDismissError ? (
<AuthErrorMessage message={visibleError} />{" "}
{dismissVisibleError ? (
<button
type="button"
onClick={onDismissError}
onClick={dismissVisibleError}
aria-label="Dismiss"
className="cursor-pointer border-none bg-transparent px-0.5 text-inherit"
>
+11 -2
View File
@@ -7,7 +7,11 @@ import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import {
getUserFacingAuthError,
isBannedAccountAuthError,
routeToBannedAccountPage,
} from "../lib/authErrorMessage";
import { getSkillCategoryForSkill } from "../lib/categories";
import { getUserFacingConvexError } from "../lib/convexError";
import { canManageSkill, isModerator } from "../lib/roles";
@@ -685,7 +689,12 @@ export function SkillDetailPage({
? "/"
: `${window.location.pathname}${window.location.search}${window.location.hash}`;
void signIn("github", redirectTo ? { redirectTo } : undefined).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
if (isBannedAccountAuthError(message)) {
routeToBannedAccountPage();
return;
}
setAuthError(message);
});
};
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
ACCOUNT_APPEAL_LINK_TEXT,
ACCOUNT_APPEAL_URL,
BANNED_ACCOUNT_PATH,
BANNED_SIGN_IN_MESSAGE,
getUserFacingAuthError,
isBannedAccountAuthError,
normalizeAuthErrorMessage,
routeToBannedAccountPage,
} from "./authErrorMessage";
describe("authErrorMessage", () => {
it("routes banned-account sign-in errors to the appeals site", () => {
expect(normalizeAuthErrorMessage("Account banned", "fallback")).toBe(BANNED_SIGN_IN_MESSAGE);
expect(isBannedAccountAuthError("This account has been disabled.")).toBe(true);
expect(BANNED_SIGN_IN_MESSAGE).toContain(ACCOUNT_APPEAL_LINK_TEXT);
expect(ACCOUNT_APPEAL_URL).toBe("https://appeals.openclaw.ai/");
});
it("navigates banned-account auth failures to the dedicated page", () => {
window.history.replaceState(null, "", "/dashboard?error_description=Account%20banned");
routeToBannedAccountPage();
expect(window.location.pathname).toBe(BANNED_ACCOUNT_PATH);
expect(window.location.search).toBe("");
});
it("does not route deleted-account errors to appeals", () => {
expect(
normalizeAuthErrorMessage(
"This account has been permanently deleted and cannot be restored.",
"fallback",
),
).toBe("This ClawHub account was permanently deleted and cannot sign in again.");
});
it("does not treat generic permission denials as banned sign-in failures", () => {
const message = getUserFacingAuthError(new Error("Forbidden"), "fallback");
expect(message).toBe(
"This ClawHub account does not have permission to perform this action, or the account is not in good standing.",
);
expect(isBannedAccountAuthError(message)).toBe(false);
});
});
+27 -10
View File
@@ -2,13 +2,38 @@ import { getUserFacingConvexError } from "./convexError";
export const CLAWHUB_ACCOUNT_ISSUE_URL = "https://github.com/openclaw/clawhub/issues/new";
export const CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT = "open a GitHub issue";
export const ACCOUNT_APPEAL_URL = "https://appeals.openclaw.ai/";
export const ACCOUNT_APPEAL_LINK_TEXT = "appeal this decision";
export const BANNED_ACCOUNT_PATH = "/account-banned";
export const BANNED_SIGN_IN_MESSAGE = `This ClawHub account is not in good standing and cannot sign in. Please ${CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT} if you believe this is a mistake.`;
export const BANNED_SIGN_IN_MESSAGE = `This ClawHub account is not in good standing and cannot sign in. Please ${ACCOUNT_APPEAL_LINK_TEXT} if you believe this is a mistake.`;
export const DELETED_SIGN_IN_MESSAGE =
"This ClawHub account was permanently deleted and cannot sign in again.";
export const ACCESS_DENIED_SIGN_IN_MESSAGE = `Sign in was denied. Please try signing in with GitHub again. If this keeps happening, please ${CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT}.`;
export const AUTH_CODE_NO_SESSION_MESSAGE = `Sign in did not complete. Please try signing in with GitHub again. If this keeps happening, please ${CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT}.`;
export function isBannedAccountAuthError(message: string | null | undefined) {
const lowered = message?.trim().toLowerCase();
if (!lowered) return false;
return (
lowered.includes("account banned") ||
lowered.includes("account has been banned") ||
lowered.includes("account is banned") ||
lowered.includes("not in good standing and cannot sign in") ||
lowered.includes("account disabled") ||
lowered.includes("account has been disabled") ||
lowered.includes("account is disabled")
);
}
export function routeToBannedAccountPage() {
if (typeof window === "undefined") return;
window.history.replaceState(null, "", BANNED_ACCOUNT_PATH);
window.dispatchEvent(
typeof PopStateEvent === "function" ? new PopStateEvent("popstate") : new Event("popstate"),
);
}
export function normalizeAuthErrorMessage(message: string | null | undefined, fallback: string) {
const normalized = message?.trim();
if (!normalized) return fallback;
@@ -19,15 +44,7 @@ export function normalizeAuthErrorMessage(message: string | null | undefined, fa
if (lowered.includes("cannot be restored") && lowered.includes("deleted")) {
return DELETED_SIGN_IN_MESSAGE;
}
if (
lowered.includes("account banned") ||
lowered.includes("account has been banned") ||
lowered.includes("account is banned") ||
lowered.includes("not in good standing") ||
lowered.includes("account disabled") ||
lowered.includes("account has been disabled") ||
lowered.includes("account is disabled")
) {
if (isBannedAccountAuthError(lowered)) {
return BANNED_SIGN_IN_MESSAGE;
}
+21
View File
@@ -20,6 +20,7 @@ import { Route as ImportRouteImport } from './routes/import'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as AuditsRouteImport } from './routes/audits'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as AccountBannedRouteImport } from './routes/account-banned'
import { Route as SlugRouteImport } from './routes/$slug'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users/index'
@@ -110,6 +111,11 @@ const AdminRoute = AdminRouteImport.update({
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const AccountBannedRoute = AccountBannedRouteImport.update({
id: '/account-banned',
path: '/account-banned',
getParentRoute: () => rootRouteImport,
} as any)
const SlugRoute = SlugRouteImport.update({
id: '/$slug',
path: '/$slug',
@@ -290,6 +296,7 @@ const PluginsScopeNameSecurityScannerRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$slug': typeof SlugRoute
'/account-banned': typeof AccountBannedRoute
'/admin': typeof AdminRoute
'/audits': typeof AuditsRoute
'/dashboard': typeof DashboardRoute
@@ -337,6 +344,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$slug': typeof SlugRoute
'/account-banned': typeof AccountBannedRoute
'/admin': typeof AdminRoute
'/audits': typeof AuditsRoute
'/dashboard': typeof DashboardRoute
@@ -385,6 +393,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$slug': typeof SlugRoute
'/account-banned': typeof AccountBannedRoute
'/admin': typeof AdminRoute
'/audits': typeof AuditsRoute
'/dashboard': typeof DashboardRoute
@@ -434,6 +443,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/$slug'
| '/account-banned'
| '/admin'
| '/audits'
| '/dashboard'
@@ -481,6 +491,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/$slug'
| '/account-banned'
| '/admin'
| '/audits'
| '/dashboard'
@@ -528,6 +539,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/$slug'
| '/account-banned'
| '/admin'
| '/audits'
| '/dashboard'
@@ -576,6 +588,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SlugRoute: typeof SlugRoute
AccountBannedRoute: typeof AccountBannedRoute
AdminRoute: typeof AdminRoute
AuditsRoute: typeof AuditsRoute
DashboardRoute: typeof DashboardRoute
@@ -691,6 +704,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/account-banned': {
id: '/account-banned'
path: '/account-banned'
fullPath: '/account-banned'
preLoaderRoute: typeof AccountBannedRouteImport
parentRoute: typeof rootRouteImport
}
'/$slug': {
id: '/$slug'
path: '/$slug'
@@ -982,6 +1002,7 @@ const PluginsScopeNameRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SlugRoute: SlugRoute,
AccountBannedRoute: AccountBannedRoute,
AdminRoute: AdminRoute,
AuditsRoute: AuditsRoute,
DashboardRoute: DashboardRoute,
+30
View File
@@ -0,0 +1,30 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AccountBannedPage } from "./account-banned";
vi.mock("@tanstack/react-router", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
return {
...actual,
createFileRoute: () => (config: unknown) => config,
};
});
describe("AccountBannedPage", () => {
it("renders appeal-only banned account guidance", () => {
render(<AccountBannedPage />);
expect(
screen.getByRole("heading", { name: "Your ClawHub account has been banned" }),
).toBeTruthy();
expect(screen.getByText("This account cannot sign in to ClawHub.")).toBeTruthy();
const appealLink = screen.getByRole("link", { name: "Open an appeal" });
expect(appealLink.getAttribute("href")).toBe("https://appeals.openclaw.ai/");
expect(screen.queryByRole("button", { name: /sign in/i })).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
});
+40 -1
View File
@@ -1,4 +1,10 @@
import { createRootRoute, HeadContent, Scripts, useLocation } from "@tanstack/react-router";
import {
createRootRoute,
HeadContent,
redirect,
Scripts,
useLocation,
} from "@tanstack/react-router";
import { Analytics } from "@vercel/analytics/react";
import { useEffect } from "react";
import { Toaster } from "sonner";
@@ -9,12 +15,29 @@ import { ErrorBoundary } from "../components/ErrorBoundary";
import { Footer } from "../components/Footer";
import { GenericNotFoundPage } from "../components/GenericNotFoundPage";
import Header from "../components/Header";
import {
BANNED_ACCOUNT_PATH,
isBannedAccountAuthError,
normalizeAuthErrorMessage,
} from "../lib/authErrorMessage";
import { getSiteDescription, getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
import appCss from "../styles.css?url";
const OG_IMAGE_VERSION = "20260420-12";
export const Route = createRootRoute({
beforeLoad: ({ location }) => {
if (location.pathname === BANNED_ACCOUNT_PATH) return;
const authError = getAuthErrorDescription(location);
if (!authError) return;
const message = normalizeAuthErrorMessage(authError, "");
if (!isBannedAccountAuthError(message)) return;
throw redirect({
to: BANNED_ACCOUNT_PATH,
replace: true,
});
},
head: () => {
const mode = getSiteMode();
const siteName = getSiteName(mode);
@@ -117,6 +140,22 @@ export const Route = createRootRoute({
notFoundComponent: GenericNotFoundPage,
});
function getAuthErrorDescription(location: { search?: unknown; searchStr?: string }) {
const fromSearch =
getSearchStringValue(location.search, "error_description") ??
getSearchStringValue(location.search, "error");
if (fromSearch) return fromSearch;
if (!location.searchStr) return null;
const params = new URLSearchParams(location.searchStr);
return params.get("error_description")?.trim() || params.get("error")?.trim() || null;
}
function getSearchStringValue(search: unknown, key: string) {
if (!search || typeof search !== "object") return null;
const value = (search as Record<string, unknown>)[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function RootDocument({ children }: { children: React.ReactNode }) {
useEffect(() => {
document.documentElement.dataset.clawhubHydrated = "true";
+46
View File
@@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { ShieldX } from "lucide-react";
import { Button } from "../components/ui/button";
import { ACCOUNT_APPEAL_URL } from "../lib/authErrorMessage";
export const Route = createFileRoute("/account-banned")({
component: AccountBannedPage,
});
export function AccountBannedPage() {
return (
<main className="relative mx-auto flex min-h-[430px] w-full flex-col overflow-hidden px-4 pb-12 pt-20 sm:px-6 sm:pt-24 lg:px-6">
<div
aria-hidden="true"
className="pointer-events-none absolute -top-20 inset-x-10 h-64"
style={{
background:
"linear-gradient(to bottom, color-mix(in srgb, var(--accent) 16%, transparent), color-mix(in srgb, var(--accent) 4%, transparent) 42%, transparent 74%)",
filter: "blur(2px)",
maskImage: "linear-gradient(to right, transparent, black 22%, black 78%, transparent)",
WebkitMaskImage:
"linear-gradient(to right, transparent, black 22%, black 78%, transparent)",
}}
/>
<section className="relative z-10 mx-auto w-full max-w-[780px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] px-5 py-8 shadow-[0_18px_50px_rgba(0,0,0,0.12)] sm:px-8 sm:py-10">
<span className="mb-5 inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink-soft)]">
<ShieldX size={22} />
</span>
<h1 className="font-display text-2xl font-black leading-tight text-[color:var(--ink)] sm:text-4xl">
Your ClawHub account has been banned
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-[color:var(--ink-soft)]">
This account cannot sign in to ClawHub.
</p>
<p className="mt-2 max-w-2xl text-base leading-7 text-[color:var(--ink-soft)]">
Visit appeals.openclaw.ai to open an appeal if you believe this was a mistake.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<Button asChild variant="primary">
<a href={ACCOUNT_APPEAL_URL}>Open an appeal</a>
</Button>
</div>
</section>
</main>
);
}