diff --git a/.env.local.example b/.env.local.example index b7c5a5be..cae58e4f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 990c0fa7..e9e2b471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --version ` support for blocked skill and plugin submissions. + ## 0.19.2 - 2026-06-05 ### Fixes diff --git a/bun.lock b/bun.lock index 823e54ce..5288a3f6 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b230bcf0..e1fa7c16 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/auth.test.ts b/convex/auth.test.ts index 834883ae..eeaf74b5 100644 --- a/convex/auth.test.ts +++ b/convex/auth.test.ts @@ -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); }); }); diff --git a/convex/auth.ts b/convex/auth.ts index 1be0940f..fe91012a 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -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 }; diff --git a/convex/devSeed.ts b/convex/devSeed.ts index 672e283b..ec43bbef 100644 --- a/convex/devSeed.ts +++ b/convex/devSeed.ts @@ -3664,6 +3664,10 @@ export const getAccountDeletionFixtureState: ReturnType { + 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, + }); + }, +}); diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 1839a7d1..b6fb9b03 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -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) => { + 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 --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) => { + 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()); diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index f4c71a2b..f9bbf372 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -110,6 +110,10 @@ export const __handlers = { skillsGetRouterV1Handler, publishSkillV1Handler, skillSecurityVerdictsV1Handler, + skillScanSubmitV1Handler, + skillScanGetRouterV1Handler, + skillScanBatchSubmitV1Handler, + skillScanBatchStatusV1Handler, skillsPostRouterV1Handler, skillsDeleteRouterV1Handler, exportSkillsV1Handler, diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 386a6fb6..9e42872c 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -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) { ]); } +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 --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 --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; + 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, diff --git a/convex/lib/emails.test.ts b/convex/lib/emails.test.ts new file mode 100644 index 00000000..595c1c28 --- /dev/null +++ b/convex/lib/emails.test.ts @@ -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("Scanner:"); + 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."); + }); +}); diff --git a/convex/lib/emails.ts b/convex/lib/emails.ts new file mode 100644 index 00000000..af9384e6 --- /dev/null +++ b/convex/lib/emails.ts @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function normalizeReasonInput(args: Pick) { + 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 ` + + + + + ${escapeHtml(args.title)} + + + ${escapeHtml( + args.preheader, + )} + + + + +
+ + + + +
+ ${args.body} +
+
+ +`; +} + +function textLink(href: string, label: string) { + return `${escapeHtml(label)}`; +} + +function detailLine(label: string, value: string) { + return `

${escapeHtml(label)}: ${escapeHtml(value)}

`; +} + +function sectionHeading(label: string) { + return `

${escapeHtml(label)}

`; +} + +function paragraph(value: string) { + return `

${escapeHtml(value)}

`; +} + +function bulletList(items: string[]) { + return `
    ${items + .map((item) => `
  • ${escapeHtml(item)}
  • `) + .join("")}
`; +} + +function commandBlock(command: string) { + return `
${escapeHtml(command)}
`; +} + +function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) { + const version = args.version?.trim() || ""; + 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), + `

You can ${textLink(APPEALS_URL, "appeal this decision")} if you believe this was a mistake.

`, + `

If you already appealed, you do not need to send a separate support email.

`, + 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)}` + : "", + `

Settings: ${textLink("https://clawhub.ai/settings", "open ClawHub settings")}

`, + 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}.`), + `

Docs: ${textLink(MODERATION_GUIDELINES_URL, "moderation and account safety")}

`, + paragraph("ClawHub Security"), + ].join(""), + }); + + return { + subject, + text: lines.join("\n"), + html, + }; +} diff --git a/convex/lib/skillSlugValidator.test.ts b/convex/lib/skillSlugValidator.test.ts index c0928b8f..6a6cca02 100644 --- a/convex/lib/skillSlugValidator.test.ts +++ b/convex/lib/skillSlugValidator.test.ts @@ -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", diff --git a/convex/lib/skillSlugValidator.ts b/convex/lib/skillSlugValidator.ts index f38311af..7076a050 100644 --- a/convex/lib/skillSlugValidator.ts +++ b/convex/lib/skillSlugValidator.ts @@ -25,6 +25,7 @@ const MAX_SLUG_LENGTH = 96; const RESERVED_SKILL_SLUGS: ReadonlySet = new Set([ // Current top-level route segments under src/routes/. "about", + "account-banned", "admin", "cli", "dashboard", diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 658e53d8..84500a64 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -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> = {}) { 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 () => { diff --git a/convex/packages.ts b/convex/packages.ts index f7e346c8..0a9a7055 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -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, 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, + 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 & Partial>, + 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> = { + 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, + pkg, + release, + trigger, + ); + } +} + +async function quarantineMaliciousLatestPackageRelease( + ctx: Pick & Partial>, + 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> = { + 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, + pkg, + release, + trigger, + ); + } +} + +type SyncLatestPackageVerificationOptions = { + quarantineMaliciousLatest?: boolean; + maliciousTrigger?: string; +}; + +async function syncLatestPackageVerification( + ctx: Pick & Partial>, + 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", + }); }, }); diff --git a/convex/schema.ts b/convex/schema.ts index f701cebd..bd6d8c55 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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), diff --git a/convex/securityScan.test.ts b/convex/securityScan.test.ts index a8cb84f5..9bbd2b95 100644 --- a/convex/securityScan.test.ts +++ b/convex/securityScan.test.ts @@ -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; + report: { + clawscan: Record | null; + skillspector: Record | null; + staticAnalysis: Record | null; + virustotal: Record | null; + }; + } + > +)._handler; + const claimedJob = { _id: "securityScanJobs:1", _creationTime: 1, @@ -685,6 +708,77 @@ function makeSkillScanStatusCtx(options: { }; } +function makeStoredScanReportCtx(options: { + actor: Record; + docs: Record>; + membership?: Record | null; +}) { + const docs = new Map>([ + [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(); + 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", diff --git a/convex/securityScan.ts b/convex/securityScan.ts index 3c9f7a71..75646a6c 100644 --- a/convex/securityScan.ts +++ b/convex/securityScan.ts @@ -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"), diff --git a/convex/skills.backportLatest.test.ts b/convex/skills.backportLatest.test.ts index 9c531af3..03859aef 100644 --- a/convex/skills.backportLatest.test.ts +++ b/convex/skills.backportLatest.test.ts @@ -152,7 +152,11 @@ type Captured = { allPatches: Array<{ id: string; value: Record }>; }; -function buildDb(skill: SkillDoc, captured: Captured) { +function buildDb( + skill: SkillDoc, + captured: Captured, + existingVersion?: Record | 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 | 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); diff --git a/convex/skills.banBatches.test.ts b/convex/skills.banBatches.test.ts index 0ea3505a..0bea70c1 100644 --- a/convex/skills.banBatches.test.ts +++ b/convex/skills.banBatches.test.ts @@ -62,6 +62,13 @@ function makeCtx({ }), }; } + if (table === "skillVersions") { + return { + withIndex: () => ({ + take: async () => [], + }), + }; + } throw new Error(`Unexpected table ${table}`); }); const scheduler = { runAfter: vi.fn() }; diff --git a/convex/skills.rateLimit.test.ts b/convex/skills.rateLimit.test.ts index 442cd525..2363ee20 100644 --- a/convex/skills.rateLimit.test.ts +++ b/convex/skills.rateLimit.test.ts @@ -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).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", }), diff --git a/convex/skills.ts b/convex/skills.ts index 79462fa7..99aa7787 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -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["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, "parsed"> | null | undefined, +) { + return version?.parsed?.frontmatter + ? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim() || undefined + : undefined; +} + +function skillDisplayNameFromSkillVersion( + version: Pick, "parsed"> | null | undefined, +) { + return version?.parsed?.frontmatter + ? getFrontmatterValue(version.parsed.frontmatter, "name")?.trim() || undefined + : undefined; +} + +function skillIconFromSkillVersion(version: Pick, "icon"> | null | undefined) { + return version && "icon" in version ? version.icon : undefined; +} + +function isKnownMaliciousSkillVersion( + version: Pick, "_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, "version" | "createdAt">, + right: Pick, "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, "llmAnalysis" | "sha256hash">, + version: Pick, "_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> = {}; + 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> = { + 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>; + if (Object.keys(nextTags).length === Object.keys(skill.tags ?? {}).length) return; + + const patch: Partial> = { + 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, "llmAnalysis" | "sha256hash" | "version"> & + Partial, "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, diff --git a/convex/users.test.ts b/convex/users.test.ts index 89fd671f..87c87e96 100644 --- a/convex/users.test.ts +++ b/convex/users.test.ts @@ -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>(); const publisherMembers: Array> = []; const officialPublishers: Array> = []; + const auditLogs: Array> = []; const inserts: Array<{ table: string; value: Record }> = []; const patches: Array<{ id: string; value: Record }> = []; @@ -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> } = {}) { const patch = vi.fn(); - const insert = vi.fn(); + const auditLogs = options.auditLogs ?? []; + const insert = vi.fn(async (table: string, value: Record) => { + 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> } = {} }, })); - 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; + } + )._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; + } + )._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; + } + )._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; + } + )._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; + } + )._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; + } + )._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, diff --git a/convex/users.ts b/convex/users.ts index f1a994a9..2dc760f8 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -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, "_id" | "email" | "handle">; +type MaliciousArtifactKind = "skill" | "plugin"; +type MaliciousArtifactFinding = { + artifactKind: MaliciousArtifactKind; + artifactName: string; +}; + +async function scheduleBanNotificationEmail( + ctx: Pick, + 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, + 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, + 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, owner: Pick, "_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, 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(); + const attemptsByArtifact = new Map(); + + 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}`, ); diff --git a/docs/auth.md b/docs/auth.md index 7a44ceef..cef2fe94 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -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. diff --git a/docs/cli.md b/docs/cli.md index 2bf908f8..a5498fdb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -189,25 +189,37 @@ Stores your API token + cached registry URL. clawhub skill publish ./my-skill --version 1.0.0 ``` -### `scan [path]` +### `scan --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 ` 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 ` + +- 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 ` 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 diff --git a/docs/http-api.md b/docs/http-api.md index 38947842..9c956b29 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -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=&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`. diff --git a/docs/moderation.md b/docs/moderation.md index 7171b032..9cf7c5ea 100644 --- a/docs/moderation.md +++ b/docs/moderation.md @@ -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 --version `. For plugins, add +`--kind plugin`. Review the scan output, fix the listing, increment the version +number, and upload the fixed version. ## Publisher guidance diff --git a/e2e/clawhub.e2e.test.ts b/e2e/clawhub.e2e.test.ts index 36272185..056b73a0 100644 --- a/e2e/clawhub.e2e.test.ts +++ b/e2e/clawhub.e2e.test.ts @@ -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((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((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((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((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(); diff --git a/e2e/local-auth/delete-account-resources.pw.test.ts b/e2e/local-auth/delete-account-resources.pw.test.ts index 0278d6c2..3384eb3f 100644 --- a/e2e/local-auth/delete-account-resources.pw.test.ts +++ b/e2e/local-auth/delete-account-resources.pw.test.ts @@ -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); diff --git a/e2e/local-auth/helpers.ts b/e2e/local-auth/helpers.ts index e4d4de9d..d995e587 100644 --- a/e2e/local-auth/helpers.ts +++ b/e2e/local-auth/helpers.ts @@ -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(); } diff --git a/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts b/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts new file mode 100644 index 00000000..3a54d75e --- /dev/null +++ b/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts @@ -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; + +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)); +}); diff --git a/package.json b/package.json index 1e15b4d3..a9cc0c80 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/clawhub/package.json b/packages/clawhub/package.json index a5aa963c..849c0996 100644 --- a/packages/clawhub/package.json +++ b/packages/clawhub/package.json @@ -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": { diff --git a/packages/clawhub/src/cli.ts b/packages/clawhub/src/cli.ts index 0b337171..704bfef3 100644 --- a/packages/clawhub/src/cli.ts +++ b/packages/clawhub/src/cli.ts @@ -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 ", "Published skill slug to scan") .option("--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("", "Skill slug or plugin package name") + .option("--version ", "Submitted version to download scan results for") + .option("--kind ", "Artifact kind: skill or plugin", "skill") + .option("-o, --output ", "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("", "Skill slug") diff --git a/packages/clawhub/src/cli/commands/scan.test.ts b/packages/clawhub/src/cli/commands/scan.test.ts index 02255ccb..e36bbcc8 100644 --- a/packages/clawhub/src/cli/commands/scan.test.ts +++ b/packages/clawhub/src/cli/commands/scan.test.ts @@ -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; - 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 }); + } + }); +}); diff --git a/packages/clawhub/src/cli/commands/scan.ts b/packages/clawhub/src/cli/commands/scan.ts index b369fbe4..db73cf84 100644 --- a/packages/clawhub/src/cli/commands/scan.ts +++ b/packages/clawhub/src/cli/commands/scan.ts @@ -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; 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 --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, ""); +} diff --git a/scripts/run-playwright-local-auth.ts b/scripts/run-playwright-local-auth.ts index bd88efc7..e9f4fada 100644 --- a/scripts/run-playwright-local-auth.ts +++ b/scripts/run-playwright-local-auth.ts @@ -33,6 +33,7 @@ type LocalDeploymentConfig = { const managedChildren = new Set(); 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 }, diff --git a/specs/deploy.md b/specs/deploy.md index 44ad9ed0..9c5664c2 100644 --- a/specs/deploy.md +++ b/specs/deploy.md @@ -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 ` 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 diff --git a/specs/security-moderation.md b/specs/security-moderation.md index 6544d893..e5e46ee1 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -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 diff --git a/src/__tests__/ui-design-contract.test.ts b/src/__tests__/ui-design-contract.test.ts index 2bb4c3f6..6478d22e 100644 --- a/src/__tests__/ui-design-contract.test.ts +++ b/src/__tests__/ui-design-contract.test.ts @@ -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(); diff --git a/src/components/AppProviders.test.tsx b/src/components/AppProviders.test.tsx index abc6646a..4ce9bde0 100644 --- a/src/components/AppProviders.test.tsx +++ b/src/components/AppProviders.test.tsx @@ -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(); 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(); 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 () => { diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index f3d40ddd..cc555345 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -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(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; diff --git a/src/components/AuthErrorMessage.tsx b/src/components/AuthErrorMessage.tsx index da2108aa..4011a502 100644 --- a/src/components/AuthErrorMessage.tsx +++ b/src/components/AuthErrorMessage.tsx @@ -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} - {CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT} + {link.text} {after} diff --git a/src/components/DevPersonaFab.tsx b/src/components/DevPersonaFab.tsx index 3cb8f639..6463aa29 100644 --- a/src/components/DevPersonaFab.tsx +++ b/src/components/DevPersonaFab.tsx @@ -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); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 801ea7df..9583f34c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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); }); }} > diff --git a/src/components/SignInButton.test.tsx b/src/components/SignInButton.test.tsx index b0e3dd2a..2f547f07 100644 --- a/src/components/SignInButton.test.tsx +++ b/src/components/SignInButton.test.tsx @@ -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(); + fireEvent.click(screen.getByRole("button", { name: "Sign In" })); + + await waitFor(() => { + expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1); + }); + expect(setAuthErrorMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 544d4da9..0bd473da 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -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); }); }} > diff --git a/src/components/SignInPrompt.test.tsx b/src/components/SignInPrompt.test.tsx index 1b7e56c0..89aacdfc 100644 --- a/src/components/SignInPrompt.test.tsx +++ b/src/components/SignInPrompt.test.tsx @@ -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(); 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( , ); - 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(); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(clearAuthErrorMock).toHaveBeenCalledTimes(1); + expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1); }); it("does not render dismiss button when onDismissError is missing", () => { diff --git a/src/components/SignInPrompt.tsx b/src/components/SignInPrompt.tsx index 2fa850b7..9d52c717 100644 --- a/src/components/SignInPrompt.tsx +++ b/src/components/SignInPrompt.tsx @@ -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 = ( ) : null} - {error ? ( + {visibleError ? (

- {" "} - {onDismissError ? ( + {" "} + {dismissVisibleError ? ( + + + + ); +}