diff --git a/LICENSE b/LICENSE index 9467b88..dfcb589 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,25 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +SUPPLEMENTARY NOTICE — REDACTION DISCLAIMER + +This software attempts to detect and redact personally identifiable information +(PII), secrets, and other sensitive data before it is sent to a language model. +However, NO REDACTION SYSTEM IS PERFECT. The authors and contributors make NO +guarantee, representation, or warranty — express or implied — that all +sensitive data will be detected or redacted. + +The authors and contributors SHALL NOT BE LIABLE for any sensitive data, +secrets, credentials, personally identifiable information, or other +confidential material that passes through this software unredacted, whether due +to limitations of regex patterns, AI model accuracy, misconfiguration, software +defects, or any other cause. + +YOU ARE SOLELY RESPONSIBLE for reviewing your configuration, testing redaction +coverage against your specific data, and implementing additional safeguards as +appropriate for your use case. This software is provided as one layer in a +defense-in-depth strategy and must not be relied upon as the sole mechanism for +preventing data exposure. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..543ba2b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1042 @@ +{ + "name": "opencode-vibeguard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-vibeguard", + "version": "0.1.0", + "license": "MIT", + "optionalDependencies": { + "@huggingface/transformers": "^4.2.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT", + "optional": true + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT", + "optional": true + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT", + "optional": true + }, + "node_modules/protobufjs": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT", + "optional": true + } + } +} diff --git a/package.json b/package.json index 8098fba..dc4411f 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,19 @@ "exports": "./src/index.js", "files": [ "src", + "scripts", "README-zh.md", "vibeguard.config.json.example" ], "scripts": { + "pretest": "node scripts/generate-test-secrets.js", "test": "node --test", - "prepack": "npm test" + "prepack": "npm test", + "postinstall": "node scripts/download-model.js", + "download-model": "node scripts/download-model.js" + }, + "optionalDependencies": { + "@huggingface/transformers": "^4.2.0" }, "keywords": [ "opencode", @@ -21,7 +28,9 @@ "privacy", "redaction", "secrets", - "pii" + "pii", + "privacy-filter", + "ai" ], "repository": { "type": "git", @@ -31,5 +40,8 @@ "url": "https://github.com/inkdust2021/opencode-vibeguard/issues" }, "homepage": "https://github.com/inkdust2021/opencode-vibeguard#readme", - "license": "MIT" + "license": "MIT", + "bin": { + "vibeguard-server": "src/model-server.js" + } } diff --git a/scripts/download-model.js b/scripts/download-model.js new file mode 100644 index 0000000..14fe68c --- /dev/null +++ b/scripts/download-model.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Pre-download the Privacy Filter model so it's cached locally before runtime. + * Runs automatically via `npm install` (postinstall). + * + * If @huggingface/transformers isn't installed or the download fails, + * this script exits 0 (success) so it never breaks `npm install`. + */ + +const MODEL = process.env.VIBEGUARD_AI_MODEL || "openai/privacy-filter" +const DTYPE = process.env.VIBEGUARD_AI_DTYPE || "q4" + +async function main() { + let transformers + try { + transformers = await import("@huggingface/transformers") + } catch { + // transformers not installed (optionalDependency) — nothing to download + return + } + + console.log(`[vibeguard] Downloading AI model: ${MODEL} (dtype=${DTYPE})...`) + console.log("[vibeguard] This is a one-time download (~400MB for q4). Please wait.") + + const start = Date.now() + try { + const pipe = await transformers.pipeline("token-classification", MODEL, { + dtype: DTYPE, + device: "cpu", + }) + // Dispose immediately — we only needed to trigger the download/cache + if (typeof pipe.dispose === "function") await pipe.dispose() + const elapsed = ((Date.now() - start) / 1000).toFixed(1) + console.log(`[vibeguard] Model downloaded and cached successfully (${elapsed}s).`) + } catch (err) { + console.log(`[vibeguard] Model download failed: ${err.message}`) + console.log("[vibeguard] The model will be downloaded on first use instead.") + } +} + +main().catch(() => {}) diff --git a/scripts/generate-test-secrets.js b/scripts/generate-test-secrets.js new file mode 100644 index 0000000..e1cc83d --- /dev/null +++ b/scripts/generate-test-secrets.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Generate realistic test secrets for AI model integration tests. + * + * These secrets are assembled at build time from random/fake components + * so they never appear as literal strings in version-controlled source. + * GitHub push protection scans source code, not runtime output. + * + * Output: test/.secrets.json (gitignored) + * + * The generated values are structurally valid (correct prefixes, lengths, + * character sets) so the AI model recognizes them as real secrets — which + * is the whole point of the integration tests. + * + * Usage: + * node scripts/generate-test-secrets.js + * # produces test/.secrets.json + */ + +import fs from "node:fs" +import path from "node:path" +import crypto from "node:crypto" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, "..") +const OUT_DIR = path.join(ROOT, "test") +const OUT_FILE = path.join(OUT_DIR, ".secrets.json") + +// --------------------------------------------------------------------------- +// Generators — each produces a structurally valid but fake secret +// --------------------------------------------------------------------------- + +/** Random string from a charset. */ +function rand(len, charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") { + const bytes = crypto.randomBytes(len) + return Array.from(bytes, (b) => charset[b % charset.length]).join("") +} + +/** GitHub Personal Access Token: ghp_ + 36 alphanum */ +function makeGithubPAT() { + return "ghp_" + rand(36) +} + +/** + * Stripe live secret key: sk_live_ + account ID + _ + random + * Format: sk_live_<8 alphanum><14 mixed>00<8 alphanum> + * Total length after prefix is ~50+ chars to look realistic. + */ +function makeStripeKey() { + return "sk_live_" + rand(8) + rand(42) +} + +/** + * Slack incoming webhook URL. + * Format: https://hooks.slack.com/services/T<9 alphanum>/B<9 alphanum>/<24 alphanum> + */ +function makeSlackWebhook() { + return `https://hooks.slack.com/services/T${rand(9, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")}/B${rand(9, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")}/${rand(24)}` +} + +/** + * AWS secret access key: 40-char base64-ish string. + * Uses the format from AWS docs but with random content. + */ +function makeAWSSecretKey() { + // AWS secret keys are 40 chars, base64-alphabet + / + return rand(40, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/+") +} + +/** + * JWT (HS256 signed). + * We build a real JWT structure so the AI recognizes the eyJ... pattern. + */ +function makeJWT() { + const header = { alg: "HS256", typ: "JWT" } + const payload = { sub: rand(10, "0123456789"), name: "Test User", iat: Math.floor(Date.now() / 1000) } + const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url") + const unsigned = b64url(header) + "." + b64url(payload) + // Fake signature — 32 random bytes, base64url-encoded + const sig = crypto.randomBytes(32).toString("base64url") + return unsigned + "." + sig +} + +/** + * JDBC password: mix of printable ASCII (special chars make it harder for regex). + */ +function makeJDBCPassword() { + return rand(16, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*") +} + +/** + * MongoDB password: alphanumeric with mixed case. + */ +function makeMongoPassword() { + return rand(12) +} + +// --------------------------------------------------------------------------- +// Build fixture +// --------------------------------------------------------------------------- + +const secrets = { + github_pat: makeGithubPAT(), + stripe_key: makeStripeKey(), + slack_webhook: makeSlackWebhook(), + aws_secret_key: makeAWSSecretKey(), + jwt: makeJWT(), + jdbc_password: makeJDBCPassword(), + mongo_password: makeMongoPassword(), +} + +// Build the full text snippets the tests will use +const fixtures = { + ...secrets, + // Full text strings ready for detect() calls + jdbc_text: `app.datasource.url=jdbc:postgresql://db.internal:5432/mydb?user=svc_account&password=${secrets.jdbc_password}`, + mongo_text: `const client = new MongoClient("mongodb://admin:${secrets.mongo_password}@cluster0.abc123.mongodb.net/prod?retryWrites=true")`, + github_text: `const config = { token: "${secrets.github_pat}" }`, + slack_text: `Post deploy notifications to ${secrets.slack_webhook}`, + aws_text: `export AWS_SECRET_ACCESS_KEY="${secrets.aws_secret_key}"`, + jwt_text: `headers: { "Authorization": "Bearer ${secrets.jwt}" }`, + stripe_text: JSON.stringify({ + payment: { + provider: "stripe", + secret: secrets.stripe_key, + }, + }, null, 2), +} + +// Write output +fs.mkdirSync(OUT_DIR, { recursive: true }) +fs.writeFileSync(OUT_FILE, JSON.stringify(fixtures, null, 2) + "\n", "utf8") + +console.log(`Generated ${Object.keys(secrets).length} test secrets → ${OUT_FILE}`) diff --git a/src/ai-detect.js b/src/ai-detect.js new file mode 100644 index 0000000..2da67a1 --- /dev/null +++ b/src/ai-detect.js @@ -0,0 +1,534 @@ +/** + * AI-based PII/secret detection — thin HTTP client. + * + * Instead of loading the ~400MB ONNX model in-process, this module + * talks to a shared model-server daemon over a Unix domain socket. + * Multiple OpenCode instances share one model copy in RAM. + * + * On first call, if the server isn't running, this module spawns it + * as a detached background process and waits for it to become ready. + * + * The detectWithAI() signature is unchanged — engine.js and index.js + * don't need to know about the server. + */ + +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import os from "node:os" +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" +import { spawn, execFileSync } from "node:child_process" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +/** How long to wait for the server to become ready (model download + load). */ +const SERVER_READY_TIMEOUT_MS = 180_000 // 3 min (model may need downloading) + +/** Interval between health polls when waiting for server readiness. */ +const HEALTH_POLL_MS = 500 + +/** Per-request timeout for /detect calls. */ +const REQUEST_TIMEOUT_MS = 30_000 + +/** Cooldown after server spawn failure before retrying. */ +const SPAWN_COOLDOWN_MS = 60_000 + +/** Idle timeout passed to the server (20 min). */ +const IDLE_TIMEOUT_MS = 20 * 60_000 + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +let _log = () => {} // no-op; set via setLogger() +let _serverReady = false // true once health check confirmed ready +let _spawnFailedAt = 0 // timestamp of last spawn failure +let _socketPath = null // resolved lazily + +/** + * Set the logger function. Called from index.js. + * @param {Function} logFn - (level, message) => void + */ +export function setLogger(logFn) { + if (typeof logFn === "function") _log = logFn +} + +// --------------------------------------------------------------------------- +// Socket path +// --------------------------------------------------------------------------- +function getSocketPath() { + if (_socketPath) return _socketPath + const dir = process.env.TMPDIR || os.tmpdir() || "/tmp" + const uid = process.getuid?.() ?? process.pid + _socketPath = path.join(dir, `vibeguard-${uid}.sock`) + return _socketPath +} + +function getLockPath() { + return getSocketPath().replace(/\.sock$/, ".lock") +} + +function getPidPath() { + return getSocketPath().replace(/\.sock$/, ".pid") +} + +function getLogPath() { + return getSocketPath().replace(/\.sock$/, ".log") +} + +// --------------------------------------------------------------------------- +// HTTP helpers (over Unix socket) +// --------------------------------------------------------------------------- + +/** + * Make an HTTP request to the model server over Unix socket. + * Returns parsed JSON body or null on failure. + */ +function serverRequest(method, urlPath, body, timeoutMs) { + return new Promise((resolve) => { + const socketPath = getSocketPath() + const opts = { + socketPath, + path: urlPath, + method, + headers: {}, + timeout: timeoutMs, + } + + let payload = null + if (body) { + payload = JSON.stringify(body) + opts.headers["Content-Type"] = "application/json" + opts.headers["Content-Length"] = Buffer.byteLength(payload) + } + + const req = http.request(opts, (res) => { + const chunks = [] + res.on("data", (c) => chunks.push(c)) + res.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))) + } catch { + resolve(null) + } + }) + }) + + req.on("error", () => resolve(null)) + req.on("timeout", () => { + req.destroy() + resolve(null) + }) + + if (payload) req.write(payload) + req.end() + }) +} + +// --------------------------------------------------------------------------- +// Server spawn lock (O_EXCL atomic create + PID validation) +// --------------------------------------------------------------------------- + +/** + * Acquire a spawn lock using atomic file creation (O_EXCL). + * Returns true if lock was acquired, false if another process holds it. + * + * The lockfile contains the PID of the holder. If the holder is dead + * (stale lock), we remove it and retry once. + */ +function tryLock() { + const lockPath = getLockPath() + for (let attempt = 0; attempt < 2; attempt++) { + try { + // O_CREAT | O_EXCL | O_WRONLY — atomic create-or-fail + const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY) + // Write our PID so others can check liveness + fs.writeSync(fd, String(process.pid)) + fs.closeSync(fd) + return true + } catch (err) { + if (err.code !== "EEXIST") return false + // Lock file exists — check if holder is alive + try { + const holderPid = Number(fs.readFileSync(lockPath, "utf8").trim()) + if (Number.isFinite(holderPid) && holderPid > 0 && isProcessAlive(holderPid)) { + return false // holder is alive, lock is valid + } + } catch { + /* can't read — try to remove */ + } + // Holder is dead or file unreadable — remove stale lock and retry + try { fs.unlinkSync(lockPath) } catch { /* ok */ } + } + } + return false +} + +function releaseLock() { + try { + fs.unlinkSync(getLockPath()) + } catch { + /* ignore */ + } +} + +/** + * Check if a process with the given PID is alive. + */ +function isProcessAlive(pid) { + try { + process.kill(pid, 0) // signal 0 = existence check + return true + } catch { + return false + } +} + +/** + * Check if the server is running by reading PID file + socket existence. + */ +function isServerRunning() { + const pidPath = getPidPath() + const socketPath = getSocketPath() + try { + if (!fs.existsSync(socketPath)) return false + const pidStr = fs.readFileSync(pidPath, "utf8").trim() + const pid = Number(pidStr) + if (!Number.isFinite(pid) || pid <= 0) return false + return isProcessAlive(pid) + } catch { + return false + } +} + +/** + * Clean up stale socket/pid/lock files left by a crashed server. + */ +function cleanupStaleFiles() { + for (const f of [getSocketPath(), getPidPath(), getLockPath()]) { + try { fs.unlinkSync(f) } catch { /* ok */ } + } +} + +/** + * Find a Node.js binary suitable for running the model server. + * + * process.execPath is NOT reliable — when running inside OpenCode (Bun), + * it points to the opencode binary, not Node. So we resolve explicitly: + * 1. $NODE_BIN env var (explicit override) + * 2. `which node` (PATH lookup) + * 3. process.execPath (last resort — only works if host IS Node) + */ +function findNodeBin() { + // Explicit override + if (process.env.NODE_BIN) return process.env.NODE_BIN + + // PATH lookup — works for nvm, fnm, brew, system node + try { + const resolved = execFileSync("which", ["node"], { + encoding: "utf8", + timeout: 5000, + }).trim() + if (resolved && fs.existsSync(resolved)) return resolved + } catch { /* which failed — continue */ } + + // Last resort: only valid if the host runtime IS Node (not Bun/Deno/opencode) + const execName = path.basename(process.execPath).toLowerCase() + if (execName === "node" || execName.startsWith("node")) { + return process.execPath + } + + return null +} + +/** + * Spawn the model server as a detached background process. + */ +function spawnServer(aiConfig) { + const serverScript = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "model-server.js" + ) + + const model = aiConfig.model || "openai/privacy-filter" + const dtype = aiConfig.dtype || "q4" + const device = aiConfig.device || "cpu" + + const nodeBin = findNodeBin() + if (!nodeBin) { + _log("error", "Cannot find Node.js binary. Install Node.js or set NODE_BIN env var.") + return null + } + + const logPath = getLogPath() + let logFd = null + let stdout, stderr + try { + logFd = fs.openSync(logPath, "a") + stdout = logFd + stderr = logFd + } catch { + stdout = "ignore" + stderr = "ignore" + } + + try { + const child = spawn( + nodeBin, + [ + serverScript, + "--model", + model, + "--dtype", + dtype, + "--device", + device, + "--socket", + getSocketPath(), + "--idle-timeout", + String(IDLE_TIMEOUT_MS), + ], + { + detached: true, + stdio: ["ignore", stdout, stderr], + env: { ...process.env }, + } + ) + + child.unref() + _log("info", `Spawned model server (pid=${child.pid}, node=${nodeBin}, model=${model}, dtype=${dtype})`) + return child.pid + } catch (err) { + _log("error", `spawn() failed: ${err.message}`) + return null + } finally { + // Close the log fd in the parent — the child inherited a dup. + // In a finally block so it's closed even if spawn() throws. + if (logFd !== null) { + try { fs.closeSync(logFd) } catch { /* ok */ } + } + } +} + +/** + * Ensure the server is running. Spawn if needed, wait for readiness. + * Uses a lockfile to prevent multiple simultaneous spawns. + * Returns true if server is ready, false if unavailable. + */ +async function ensureServer(aiConfig, debug) { + // Fast path: already confirmed ready + if (_serverReady) { + // Quick health check to confirm it's still alive + const h = await serverRequest("GET", "/health", null, 2000) + if (h?.status === "ready") return true + // Server died — reset and try to respawn + _serverReady = false + } + + // Cooldown after spawn failure + if (_spawnFailedAt > 0) { + const elapsed = Date.now() - _spawnFailedAt + if (elapsed < SPAWN_COOLDOWN_MS) return false + _spawnFailedAt = 0 + } + + // Check if server is already running (maybe another instance spawned it) + if (isServerRunning()) { + return await waitForReady(aiConfig, debug) + } + + // Need to spawn — acquire lock to prevent races + const gotLock = tryLock() + if (!gotLock) { + // Couldn't acquire lock — another instance is spawning. Just wait. + return await waitForReady(aiConfig, debug) + } + + try { + // Double-check after acquiring lock (another instance may have won) + if (isServerRunning()) { + return await waitForReady(aiConfig, debug) + } + + // Re-read config before spawning — if ai.enabled is false, bail out. + // This lets users disable AI without restarting opencode sessions. + try { + const cfgPath = path.join(os.homedir(), ".config", "opencode", "vibeguard.config.json") + const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")) + if (cfg?.ai?.enabled === false) { + if (debug) _log("info", "AI disabled in config — not spawning server") + return false + } + } catch { /* config unreadable — proceed with spawn */ } + + // Clean up any stale files from a crashed server + cleanupStaleFiles() + + // Spawn the server + const pid = spawnServer(aiConfig) + if (!pid) { + _spawnFailedAt = Date.now() + _log("error", "Failed to spawn model server") + return false + } + + // Wait for server to become ready + return await waitForReady(aiConfig, debug) + } finally { + releaseLock() + } +} + +/** + * Poll /health until the server reports "ready" or we time out. + * Bails early on server error or consecutive connection failures. + */ +async function waitForReady(aiConfig, debug) { + const deadline = Date.now() + SERVER_READY_TIMEOUT_MS + let lastStatus = "" + let consecutiveFailures = 0 + + while (Date.now() < deadline) { + const h = await serverRequest("GET", "/health", null, 5000) + + if (h?.status === "ready") { + // Verify model matches + const expected = aiConfig.model || "openai/privacy-filter" + if (h.model && h.model !== expected) { + _log("warn", `Server loaded model "${h.model}" but config expects "${expected}". Using regex-only.`) + // Set cooldown so we don't hot-loop re-checking on every call + _spawnFailedAt = Date.now() + return false + } + _serverReady = true + if (debug) _log("info", "Model server ready") + return true + } + + if (h?.status === "error") { + _log("error", `Model server error: ${h.error || "unknown"}`) + _spawnFailedAt = Date.now() + return false + } + + if (h?.status === "loading") { + consecutiveFailures = 0 + if (lastStatus !== "loading" && debug) { + _log("info", "Model server is loading the model, waiting...") + } + lastStatus = "loading" + } else { + // null response = connection refused / server not up yet + consecutiveFailures++ + // If we get 10+ consecutive connection failures after the server + // should have started, it's probably dead — bail early + if (consecutiveFailures >= 10) { + _log("warn", "Model server not responding after multiple attempts. Using regex-only.") + _spawnFailedAt = Date.now() + return false + } + } + + // Wait before next poll + await new Promise((r) => setTimeout(r, HEALTH_POLL_MS)) + } + + _log("warn", "Timed out waiting for model server. Using regex-only.") + _spawnFailedAt = Date.now() + return false +} + +// --------------------------------------------------------------------------- +// Public API (signature unchanged from original) +// --------------------------------------------------------------------------- + +/** + * Detect PII/secrets in text using the Privacy Filter model. + * + * Sends the text to the shared model server for inference. + * If the server isn't running, spawns it and waits for readiness. + * If anything fails, returns [] (regex-only fallback). + * + * @param {string} text - Input text to scan + * @param {object} aiConfig - AI configuration from vibeguard config + * @param {boolean} debug - Enable debug logging + * @returns {Promise>} + */ +export async function detectWithAI(text, aiConfig, debug) { + if (!text || typeof text !== "string" || text.length === 0) return [] + + const ready = await ensureServer(aiConfig, debug) + if (!ready) return [] + + try { + const result = await serverRequest("POST", "/detect", { + text, + categories: aiConfig.categories, + requestedModel: aiConfig.model || "openai/privacy-filter", + }, REQUEST_TIMEOUT_MS) + + if (!result) { + // Connection failed — server may have died + _serverReady = false + return [] + } + + if (result.error && !result.spans) { + // Server-side error (503, 409, etc.) + if (debug || !aiConfig.silentFallback) { + _log("warn", `Server error: ${result.error}`) + } + return [] + } + + const spans = Array.isArray(result.spans) ? result.spans : [] + + if (debug && spans.length > 0) { + _log("debug", `AI detected ${spans.length} span(s): ${spans.map((s) => s.category).join(", ")}`) + } + + return spans + } catch (err) { + if (!aiConfig.silentFallback || debug) { + _log("error", `AI detection error: ${err.message}, falling back to regex-only`) + } + _serverReady = false + return [] + } +} + +/** + * Dispose — no-op. The server manages its own lifecycle (idle timeout). + */ +export async function disposeAI() { + // Server exits on its own after 20min of inactivity. + // Calling dispose from the plugin process would kill the shared + // server for all other OpenCode instances. So this is intentionally a no-op. +} + +/** + * Check if AI detection is potentially available. + * Returns true if the server is running OR if we can spawn one + * (i.e. @huggingface/transformers is installed). + */ +export async function isAIAvailable() { + // Fast check: is the server already running? + if (isServerRunning()) return true + + // Can we spawn? Check if transformers is installed (without loading it). + try { + // import.meta.resolve does path resolution only — doesn't execute the module + if (import.meta.resolve) { + import.meta.resolve("@huggingface/transformers") + return true + } + // Fallback for runtimes that don't support import.meta.resolve: + // use createRequire to do a path-only resolution (no module loading) + const require = createRequire(import.meta.url) + require.resolve("@huggingface/transformers") + return true + } catch { + return false + } +} diff --git a/src/config.js b/src/config.js index a8c1552..55423ae 100644 --- a/src/config.js +++ b/src/config.js @@ -30,6 +30,27 @@ function readJson(filepath) { .catch(() => null) } +/** + * Normalize the opt-in AI detection config. + * All fields have safe defaults; AI is disabled unless explicitly enabled. + */ +function normalizeAiConfig(raw) { + const ai = raw && typeof raw === "object" ? raw : {} + return { + enabled: Boolean(ai.enabled), + // Model to use for token classification (default: openai/privacy-filter) + model: typeof ai.model === "string" ? ai.model : "openai/privacy-filter", + // Quantization dtype (default: q4 ~400MB; alternatives: fp32, fp16, q8) + dtype: typeof ai.dtype === "string" ? ai.dtype : "q4", + // Inference device (default: cpu; alternative: webgpu if available) + device: typeof ai.device === "string" ? ai.device : "cpu", + // Which Privacy Filter categories to use. Empty = all. + categories: Array.isArray(ai.categories) ? ai.categories.map((c) => String(c)) : [], + // If true, fall back silently to regex-only when AI is unavailable. + silentFallback: ai.silentFallback !== false, + } +} + function normalizeConfig(raw) { const cfg = raw && typeof raw === "object" ? raw : {} @@ -44,6 +65,8 @@ function normalizeConfig(raw) { const patterns = cfg.patterns && typeof cfg.patterns === "object" ? cfg.patterns : {} + const ai = normalizeAiConfig(cfg.ai) + return { enabled, debug, @@ -51,6 +74,7 @@ function normalizeConfig(raw) { ttlMs, maxMappings, patterns, + ai, } } diff --git a/src/deep.js b/src/deep.js index 60586f6..a071808 100644 --- a/src/deep.js +++ b/src/deep.js @@ -27,7 +27,7 @@ export function restoreDeep(value, session) { for (let i = 0; i < node.length; i++) { const v = node[i] if (typeof v === "string") node[i] = restoreText(v, session) - if (v && typeof v === "object") walk(v) + else if (v && typeof v === "object") walk(v) } return } @@ -37,7 +37,7 @@ export function restoreDeep(value, session) { for (const key of Object.keys(node)) { const v = node[key] if (typeof v === "string") node[key] = restoreText(v, session) - if (v && typeof v === "object") walk(v) + else if (v && typeof v === "object") walk(v) } } @@ -64,7 +64,7 @@ export function redactDeep(value, patterns, session) { for (let i = 0; i < node.length; i++) { const v = node[i] if (typeof v === "string") node[i] = redactText(v, patterns, session).text - if (v && typeof v === "object") walk(v) + else if (v && typeof v === "object") walk(v) } return } @@ -74,7 +74,7 @@ export function redactDeep(value, patterns, session) { for (const key of Object.keys(node)) { const v = node[key] if (typeof v === "string") node[key] = redactText(v, patterns, session).text - if (v && typeof v === "object") walk(v) + else if (v && typeof v === "object") walk(v) } } diff --git a/src/engine.js b/src/engine.js index be01d0b..f9eed68 100644 --- a/src/engine.js +++ b/src/engine.js @@ -1,3 +1,53 @@ +// ai-detect.js is imported lazily in redactTextWithAI() to avoid pulling in +// Transformers.js infrastructure when AI detection is disabled. +let _detectWithAI = null + +// --------------------------------------------------------------------------- +// {{preserve:...}} bypass markers +// --------------------------------------------------------------------------- +const PRESERVE_RE = /\{\{preserve:([\s\S]*?)\}\}/g + +/** + * Strip {{preserve:...}} markers from text and return protected character ranges. + * The inner content is kept verbatim; only the markers are removed. + * Returns { text: strippedText, protectedRanges: [{start, end}] } + */ +function stripProtectedZones(input) { + const protectedRanges = [] + let out = "" + let lastEnd = 0 + let offset = 0 // tracks how much shorter `out` is vs `input` + + PRESERVE_RE.lastIndex = 0 + for (const m of input.matchAll(PRESERVE_RE)) { + const matchStart = m.index + const inner = m[1] + // Copy text before this marker + out += input.slice(lastEnd, matchStart) + // The inner content starts at this position in the output + const innerStart = out.length + out += inner + const innerEnd = out.length + protectedRanges.push({ start: innerStart, end: innerEnd }) + lastEnd = matchStart + m[0].length + } + out += input.slice(lastEnd) + + return { text: out, protectedRanges } +} + +/** + * Check if a span overlaps any protected range. + */ +function isProtected(span, protectedRanges) { + for (const zone of protectedRanges) { + // Any overlap means protected + if (span.start < zone.end && span.end > zone.start) return true + if (zone.start >= span.end) break // ranges are sorted + } + return false +} + function subtractCovered(start, end, covered) { if (start >= end) return [] const out = [] @@ -42,16 +92,10 @@ function insertCovered(covered, span) { } /** - * 对输入文本进行脱敏替换,返回替换后的文本与命中信息。 - * 设计与 VibeGuard 的 redact 引擎一致:处理重叠命中,确保不会把占位符切碎。 - * @param {string} input - * @param {{ keywords: Array<{value:string,category:string}>, regex: Array<{pattern:string,flags:string,category:string}>, exclude: Set }} patterns - * @param {{ getOrCreatePlaceholder(original: string, category: string): string }} session + * Collect regex/keyword spans from the given text (synchronous, fast). + * Shared by both redactText and redactTextWithAI. */ -export function redactText(input, patterns, session) { - const text = String(input ?? "") - if (!text) return { text, matches: [] } - +function findRegexSpans(text, patterns) { const found = [] for (const rule of patterns.keywords) { @@ -71,9 +115,10 @@ export function redactText(input, patterns, session) { } for (const rule of patterns.regex) { - const baseFlags = String(rule.flags ?? "") - const flags = baseFlags.includes("g") ? baseFlags : `${baseFlags}g` - const re = new RegExp(rule.pattern, flags) + // Use pre-compiled regex if available, otherwise compile on the fly + const re = rule.compiled + ? (rule.compiled.lastIndex = 0, rule.compiled) + : new RegExp(rule.pattern, (rule.flags ?? "").includes("g") ? rule.flags : `${rule.flags ?? ""}g`) for (const m of text.matchAll(re)) { if (!m[0]) continue const start = m.index ?? -1 @@ -85,9 +130,17 @@ export function redactText(input, patterns, session) { } } + return found +} + +/** + * Given a set of found spans, resolve overlaps and apply placeholder replacements. + * Shared by both sync and async redaction paths. + */ +function applySpans(text, found, session) { if (found.length === 0) return { text, matches: [] } - // 右侧优先;同起点优先更长,便于把左侧大范围命中拆掉 + // Right-first; same start -> prefer longer span found.sort((a, b) => { if (a.start !== b.start) return b.start - a.start return b.end - a.end @@ -121,3 +174,74 @@ export function redactText(input, patterns, session) { return { text: out, matches: planned } } +/** + * Redact text using regex/keyword patterns only (synchronous, fast). + * Supports {{preserve:...}} bypass markers — wrapped content is never redacted. + * Returns { text, matches }. + */ +export function redactText(input, patterns, session) { + const raw = String(input ?? "") + if (!raw) return { text: raw, matches: [] } + + // Strip bypass markers and get protected zones + const { text, protectedRanges } = stripProtectedZones(raw) + if (!text) return { text, matches: [] } + + let found = findRegexSpans(text, patterns) + + // Filter out spans that overlap protected zones + if (protectedRanges.length > 0) { + found = found.filter((span) => !isProtected(span, protectedRanges)) + } + + return applySpans(text, found, session) +} + +/** + * Redact text using both regex/keyword patterns AND the AI Privacy Filter. + * Async because the AI inference is async. The hook awaits this before + * proceeding, so redaction is guaranteed complete before the LLM sees the text. + * Supports {{preserve:...}} bypass markers — wrapped content is never redacted. + * + * @param {string} input + * @param {object} patterns + * @param {object} session + * @param {object} aiConfig + * @param {boolean} debug + * @param {Function} [_detectFn] - Optional override for detectWithAI (testing only) + */ +export async function redactTextWithAI(input, patterns, session, aiConfig, debug, _detectFn) { + const raw = String(input ?? "") + if (!raw) return { text: raw, matches: [] } + + // Strip bypass markers and get protected zones + const { text, protectedRanges } = stripProtectedZones(raw) + if (!text) return { text, matches: [] } + + // 1. Regex/keyword detection (fast, synchronous) + let found = findRegexSpans(text, patterns) + + // 2. AI-based detection (async, local model inference) + const detect = _detectFn ?? await getDetectWithAI() + const aiSpans = await detect(text, aiConfig, debug) + for (const span of aiSpans) { + if (patterns.exclude.has(span.original)) continue + found.push(span) + } + + // 3. Filter out spans that overlap protected zones + if (protectedRanges.length > 0) { + found = found.filter((span) => !isProtected(span, protectedRanges)) + } + + return applySpans(text, found, session) +} + +/** Lazily resolve the real detectWithAI function from ai-detect.js */ +async function getDetectWithAI() { + if (!_detectWithAI) { + const mod = await import("./ai-detect.js") + _detectWithAI = mod.detectWithAI + } + return _detectWithAI +} diff --git a/src/engine.test.js b/src/engine.test.js new file mode 100644 index 0000000..6313835 --- /dev/null +++ b/src/engine.test.js @@ -0,0 +1,321 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { redactText, redactTextWithAI } from "./engine.js" +import { buildPatternSet } from "./patterns.js" +import { PlaceholderSession } from "./session.js" +import { restoreText } from "./restore.js" + +describe("redactText (regex-only)", () => { + it("redacts email addresses", () => { + const patterns = buildPatternSet({ builtin: ["email"], exclude: ["example.com"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Contact me at alice@corp.io for details", patterns, session) + assert.ok(!result.text.includes("alice@corp.io"), "email should be redacted") + assert.ok(result.text.includes("__VG_EMAIL_"), "should contain placeholder") + assert.equal(result.matches.length, 1) + assert.equal(result.matches[0].category, "EMAIL") + }) + + it("redacts keyword values", () => { + const patterns = buildPatternSet({ + keywords: [{ value: "sk-abc123secret", category: "API_KEY" }], + }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Key is sk-abc123secret here", patterns, session) + assert.ok(!result.text.includes("sk-abc123secret")) + assert.ok(result.text.includes("__VG_API_KEY_")) + }) + + it("respects exclude list", () => { + const patterns = buildPatternSet({ + builtin: ["email"], + exclude: ["test@example.com"], + }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Email: test@example.com", patterns, session) + assert.ok(result.text.includes("test@example.com"), "excluded email should remain") + }) + + it("handles overlapping spans correctly", () => { + const patterns = buildPatternSet({ + keywords: [ + { value: "secret-key-abc", category: "KEY" }, + { value: "key-abc", category: "KEY_PART" }, + ], + }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Found secret-key-abc here", patterns, session) + assert.ok(!result.text.includes("secret-key-abc")) + assert.ok(!result.text.includes("key-abc")) + }) + + it("returns unchanged text when nothing matches", () => { + const patterns = buildPatternSet({ builtin: ["email"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Nothing sensitive here", patterns, session) + assert.equal(result.text, "Nothing sensitive here") + assert.equal(result.matches.length, 0) + }) +}) + +describe("new builtin patterns", () => { + it("redacts US phone numbers", () => { + const patterns = buildPatternSet({ builtin: ["phone_us"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Call me at (555) 123-4567 please", patterns, session) + assert.ok(!result.text.includes("(555) 123-4567")) + assert.ok(result.text.includes("__VG_PHONE_US_")) + }) + + it("redacts SSNs", () => { + const patterns = buildPatternSet({ builtin: ["ssn"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("SSN: 123-45-6789", patterns, session) + assert.ok(!result.text.includes("123-45-6789")) + assert.ok(result.text.includes("__VG_SSN_")) + }) + + it("redacts credit card numbers", () => { + const patterns = buildPatternSet({ builtin: ["credit_card"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Card: 4111-1111-1111-1111", patterns, session) + assert.ok(!result.text.includes("4111-1111-1111-1111")) + assert.ok(result.text.includes("__VG_CREDIT_CARD_")) + }) + + it("redacts Amex credit card numbers (15 digits)", () => { + const patterns = buildPatternSet({ builtin: ["credit_card"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Amex: 3782 822463 10005", patterns, session) + assert.ok(!result.text.includes("3782 822463 10005")) + assert.ok(result.text.includes("__VG_CREDIT_CARD_")) + }) + + it("redacts private key headers", () => { + const patterns = buildPatternSet({ builtin: ["private_key_header"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("-----BEGIN RSA PRIVATE KEY-----\nfoo", patterns, session) + assert.ok(!result.text.includes("-----BEGIN RSA PRIVATE KEY-----")) + assert.ok(result.text.includes("__VG_PRIVATE_KEY_")) + }) + + it("redacts AWS access keys", () => { + const patterns = buildPatternSet({ builtin: ["aws_access_key"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("key: AKIAIOSFODNN7EXAMPLE", patterns, session) + assert.ok(!result.text.includes("AKIAIOSFODNN7EXAMPLE")) + assert.ok(result.text.includes("__VG_AWS_ACCESS_KEY_")) + }) + + it("redacts bearer tokens", () => { + const patterns = buildPatternSet({ builtin: ["generic_bearer"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.xyz", patterns, session) + assert.ok(!result.text.includes("eyJhbGciOiJIUzI1NiJ9")) + assert.ok(result.text.includes("__VG_BEARER_TOKEN_")) + }) + + it("redacts new-format OpenAI keys (sk-proj-)", () => { + const patterns = buildPatternSet({ builtin: ["openai_key"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const result = redactText("key: sk-proj-abcdefghij1234567890klmnopqr", patterns, session) + assert.ok(!result.text.includes("sk-proj-abcdefghij1234567890klmnopqr")) + assert.ok(result.text.includes("__VG_OPENAI_KEY_")) + }) + + it("does not false-positive phone_us on bare digit sequences", () => { + const patterns = buildPatternSet({ builtin: ["phone_us"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + // Timestamp-like and ID-like digit strings should NOT match + const result = redactText("timestamp: 1715000000000 and id: 9876543", patterns, session) + assert.equal(result.matches.length, 0, "bare digits should not match phone_us") + }) +}) + +describe("redactTextWithAI (graceful fallback)", () => { + // Stub that simulates AI being unavailable (returns no spans) + const noopDetect = async () => [] + + it("falls back to regex-only when AI returns no spans", async () => { + const patterns = buildPatternSet({ builtin: ["email"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + + const aiConfig = { + enabled: true, + model: "openai/privacy-filter", + dtype: "q4", + device: "cpu", + categories: [], + silentFallback: true, + } + + const result = await redactTextWithAI( + "Email alice@corp.io from Alice Smith", + patterns, + session, + aiConfig, + false, + noopDetect // inject stub — avoids real model download + ) + + // Email should be redacted by regex even when AI is unavailable + assert.ok(!result.text.includes("alice@corp.io")) + assert.ok(result.text.includes("__VG_EMAIL_")) + // "Alice Smith" won't be redacted without AI, which is expected fallback + assert.ok(result.text.includes("Alice Smith")) + }) + + it("merges AI spans with regex spans", async () => { + const patterns = buildPatternSet({ builtin: ["email"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + + const aiConfig = { + enabled: true, + model: "openai/privacy-filter", + dtype: "q4", + device: "cpu", + categories: [], + silentFallback: true, + } + + // Simulate AI detecting "Alice Smith" as a person name + const input = "Email alice@corp.io from Alice Smith" + const nameStart = input.indexOf("Alice Smith") + const fakeDetect = async () => [ + { start: nameStart, end: nameStart + 11, original: "Alice Smith", category: "PRIVATE_PERSON" }, + ] + + const result = await redactTextWithAI(input, patterns, session, aiConfig, false, fakeDetect) + + assert.ok(!result.text.includes("alice@corp.io"), "email should be redacted by regex") + assert.ok(!result.text.includes("Alice Smith"), "name should be redacted by AI") + assert.ok(result.text.includes("__VG_EMAIL_")) + assert.ok(result.text.includes("__VG_PRIVATE_PERSON_")) + }) + + it("handles empty text", async () => { + const patterns = buildPatternSet({ builtin: ["email"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const aiConfig = { enabled: true, model: "openai/privacy-filter", dtype: "q4", device: "cpu", categories: [], silentFallback: true } + + const result = await redactTextWithAI("", patterns, session, aiConfig, false, noopDetect) + assert.equal(result.text, "") + assert.equal(result.matches.length, 0) + }) +}) + +describe("PlaceholderSession", () => { + it("produces stable placeholders for same input", () => { + const session = new PlaceholderSession({ prefix: "__VG_" }) + const ph1 = session.getOrCreatePlaceholder("secret123", "API_KEY") + const ph2 = session.getOrCreatePlaceholder("secret123", "API_KEY") + assert.equal(ph1, ph2) + }) + + it("supports bidirectional lookup", () => { + const session = new PlaceholderSession({ prefix: "__VG_" }) + const ph = session.getOrCreatePlaceholder("myvalue", "TEXT") + assert.equal(session.lookup(ph), "myvalue") + assert.equal(session.lookupReverse("myvalue"), ph) + }) +}) + +describe("restoreText", () => { + it("restores placeholders to original values", () => { + const session = new PlaceholderSession({ prefix: "__VG_" }) + const ph = session.getOrCreatePlaceholder("alice@corp.io", "EMAIL") + const restored = restoreText(`Contact ${ph} for details`, session) + assert.equal(restored, "Contact alice@corp.io for details") + }) + + it("leaves unknown placeholders unchanged", () => { + const session = new PlaceholderSession({ prefix: "__VG_" }) + const text = "Some __VG_UNKNOWN_abcdef012345__ here" + const restored = restoreText(text, session) + assert.equal(restored, text) + }) +}) + +describe("{{preserve:...}} bypass markers", () => { + it("does not redact content inside {{preserve:...}}", () => { + const patterns = buildPatternSet({ builtin: ["email", "github_token"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const input = "Check {{preserve:ghp_abc123def456abc123def456abc123def456ab}} now" + const result = redactText(input, patterns, session) + // Markers stripped, content preserved verbatim + assert.ok(result.text.includes("ghp_abc123def456abc123def456abc123def456ab"), "protected content should not be redacted") + assert.ok(!result.text.includes("{{preserve:"), "markers should be stripped") + assert.equal(result.matches.length, 0) + }) + + it("still redacts unprotected content alongside protected content", () => { + const patterns = buildPatternSet({ builtin: ["email", "github_token"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const input = "Safe: {{preserve:ghp_abc123def456abc123def456abc123def456ab}} Unsafe: user@example.org" + const result = redactText(input, patterns, session) + // Token is protected + assert.ok(result.text.includes("ghp_abc123def456abc123def456abc123def456ab")) + // Email is NOT protected — should be redacted + assert.ok(!result.text.includes("user@example.org"), "unprotected email should be redacted") + assert.ok(result.text.includes("__VG_EMAIL_")) + }) + + it("handles multiple bypass markers", () => { + const patterns = buildPatternSet({ builtin: ["email"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const input = "A: {{preserve:a@b.com}} B: {{preserve:c@d.com}} C: x@y.com" + const result = redactText(input, patterns, session) + assert.ok(result.text.includes("a@b.com"), "first protected email preserved") + assert.ok(result.text.includes("c@d.com"), "second protected email preserved") + assert.ok(!result.text.includes("x@y.com"), "unprotected email redacted") + }) + + it("works with redactTextWithAI", async () => { + const patterns = buildPatternSet({ builtin: ["email"] }) + const session = new PlaceholderSession({ prefix: "__VG_" }) + const aiConfig = { enabled: true, model: "test", dtype: "q4", device: "cpu", categories: [], silentFallback: true } + // AI detects a span overlapping the protected zone — should be filtered + const fakeDetect = async (text) => { + const idx = text.indexOf("safe@keep.com") + if (idx >= 0) return [{ start: idx, end: idx + 13, original: "safe@keep.com", category: "PRIVATE_EMAIL" }] + return [] + } + const input = "Keep {{preserve:safe@keep.com}} but redact other@leak.com" + const result = await redactTextWithAI(input, patterns, session, aiConfig, false, fakeDetect) + assert.ok(result.text.includes("safe@keep.com"), "AI-detected span in protected zone should be filtered") + assert.ok(!result.text.includes("other@leak.com"), "unprotected email should be redacted") + }) +}) + +describe("config normalizeAiConfig", async () => { + // Import config module to test normalization + const { loadConfig } = await import("./config.js") + const { writeFileSync, unlinkSync, mkdirSync } = await import("node:fs") + const { join } = await import("node:path") + + it("defaults ai to disabled", async () => { + // Create a minimal config with enabled=false to override the real one. + // The env override is added to the candidate list but global config still + // gets checked too, so we provide a real file that wins first. + const tmpDir = `/tmp/vibeguard-test-${Date.now()}` + mkdirSync(tmpDir, { recursive: true }) + const tmpCfg = join(tmpDir, "vibeguard.config.json") + writeFileSync(tmpCfg, JSON.stringify({ enabled: false }), "utf8") + + const prev = process.env.OPENCODE_VIBEGUARD_CONFIG + process.env.OPENCODE_VIBEGUARD_CONFIG = tmpCfg + try { + const cfg = await loadConfig(tmpDir) + assert.equal(cfg.ai.enabled, false) + assert.equal(cfg.ai.model, "openai/privacy-filter") + assert.equal(cfg.ai.dtype, "q4") + assert.equal(cfg.ai.device, "cpu") + assert.equal(cfg.ai.silentFallback, true) + assert.deepEqual(cfg.ai.categories, []) + } finally { + if (prev === undefined) delete process.env.OPENCODE_VIBEGUARD_CONFIG + else process.env.OPENCODE_VIBEGUARD_CONFIG = prev + try { unlinkSync(tmpCfg) } catch {} + } + }) +}) diff --git a/src/index.js b/src/index.js index 9509927..4572a57 100644 --- a/src/index.js +++ b/src/index.js @@ -1,42 +1,100 @@ import { loadConfig } from "./config.js" import { buildPatternSet } from "./patterns.js" import { PlaceholderSession } from "./session.js" -import { redactText } from "./engine.js" +import { redactText, redactTextWithAI } from "./engine.js" import { redactDeep, restoreDeep } from "./deep.js" import { restoreText } from "./restore.js" /** - * OpenCode 插件入口: - * - `experimental.chat.messages.transform`:LLM 请求前对全部消息做脱敏(保证 provider 永远看不到真实值) - * - `tool.execute.before`:工具执行前还原占位符(保证本机执行拿到真实值) + * OpenCode plugin entry point: + * - `experimental.chat.messages.transform`: redact all messages before sending to LLM + * - `tool.execute.before`: restore placeholders before local tool execution + * - `experimental.text.complete`: restore placeholders in completed model output * - * 说明:为了降低误用风险,本插件在“找不到配置文件或 enabled=false”时为 no-op。 + * AI detection is opt-in via the `ai` config section. When AI is enabled but + * @huggingface/transformers is not installed, falls back to regex/keyword only. */ export const VibeGuardPrivacy = async (ctx) => { const config = await loadConfig(ctx.directory) const debug = Boolean(process.env.OPENCODE_VIBEGUARD_DEBUG) || Boolean(config.debug) + // Use OpenCode's structured logging instead of console.log to avoid + // corrupting the TUI. Falls back to no-op if client.app.log is unavailable. + const log = (level, message, extra) => { + try { + ctx.client?.app?.log({ + body: { service: "vibeguard", level, message, ...(extra ? { extra } : {}) }, + }) + } catch { + /* swallow — never crash the plugin over logging */ + } + } + if (debug) { - const from = config.loadedFrom ? config.loadedFrom : "未找到(插件将 no-op)" - console.log(`[opencode-vibeguard] 配置:${from} enabled=${config.enabled}`) + const from = config.loadedFrom ? config.loadedFrom : "not found (plugin will no-op)" + log("debug", `Config: ${from} enabled=${config.enabled}`) } if (!config.enabled) return {} const patterns = buildPatternSet(config.patterns) const sessions = new Map() + const sessionLastAccess = new Map() // track last access time for pruning + const MAX_SESSIONS = 50 // prune oldest when exceeded + const aiConfig = config.ai + const useAI = aiConfig.enabled + + // Check AI availability at startup (non-blocking info) + // Import ai-detect lazily to avoid pulling in Transformers.js when AI disabled + if (useAI) { + const { isAIAvailable, setLogger } = await import("./ai-detect.js") + setLogger(log) + const available = await isAIAvailable() + if (available) { + log("info", `AI detection enabled (model: ${aiConfig.model}, dtype: ${aiConfig.dtype}). Model will be downloaded on first use if not cached.`) + } else { + log("warn", "AI detection enabled in config but @huggingface/transformers is not installed. Falling back to regex/keyword detection only.") + } + // NOTE: We intentionally do NOT register process exit handlers for disposeAI(). + // The ONNX runtime (native N-API addon) crashes if dispose() is called during + // process teardown. The OS reclaims all memory on exit anyway. + } + + if (debug) { + log("debug", `AI detection: ${useAI ? "enabled" : "disabled (opt-in via config)"}`) + log("debug", `Regex patterns: ${patterns.keywords.length} keywords, ${patterns.regex.length} regex rules`) + } const getSession = (sessionID) => { const key = String(sessionID ?? "") if (!key) return null const existing = sessions.get(key) - if (existing) return existing + if (existing) { + sessionLastAccess.set(key, Date.now()) + return existing + } + // Prune oldest sessions if map is too large + if (sessions.size >= MAX_SESSIONS) { + let oldestKey = null + let oldestTime = Infinity + for (const [k, t] of sessionLastAccess) { + if (t < oldestTime) { + oldestTime = t + oldestKey = k + } + } + if (oldestKey) { + sessions.delete(oldestKey) + sessionLastAccess.delete(oldestKey) + } + } const created = new PlaceholderSession({ prefix: config.prefix, ttlMs: config.ttlMs, maxMappings: config.maxMappings, }) sessions.set(key, created) + sessionLastAccess.set(key, Date.now()) return created } @@ -53,60 +111,71 @@ export const VibeGuardPrivacy = async (ctx) => { let changedTextParts = 0 + // Choose redaction function based on AI config + const redactStr = useAI + ? async (text) => { + const result = await redactTextWithAI(text, patterns, session, aiConfig, debug) + return result.text + } + : (text) => { + return Promise.resolve(redactText(text, patterns, session).text) + } + for (const msg of msgs) { const parts = Array.isArray(msg?.parts) ? msg.parts : [] for (const part of parts) { if (!part) continue - // 普通文本(用户/助手) + // Plain text (user/assistant) if (part.type === "text") { if (part.ignored) continue if (!part.text || typeof part.text !== "string") continue const before = part.text - const after = redactText(before, patterns, session).text + const after = await redactStr(before) if (after !== before) changedTextParts++ part.text = after continue } - // 推理文本(部分模型/配置会进入 prompt) + // Reasoning text if (part.type === "reasoning") { if (!part.text || typeof part.text !== "string") continue const before = part.text - const after = redactText(before, patterns, session).text + const after = await redactStr(before) if (after !== before) changedTextParts++ part.text = after continue } - // 工具调用/输出:最常见的泄漏来源(例如读取 .env) + // Tool calls/outputs: most common leak source (e.g., reading .env) if (part.type === "tool") { const state = part.state if (!state || typeof state !== "object") continue - // 统一把工具输入也做深度脱敏:真实执行的 args 会包含明文(由 tool.execute.before 还原), - // 如果不在这里再脱敏一次,后续回合会把明文 args 带给 LLM。 + // Deep-redact tool inputs (args) so they don't leak in later turns. + // Uses sync regex-only for deep object traversal; AI layer covers + // text parts and tool output strings. if (state.input && typeof state.input === "object") { redactDeep(state.input, patterns, session) } if (state.status === "completed" && typeof state.output === "string") { const before = state.output - const after = redactText(before, patterns, session).text + const after = await redactStr(before) if (after !== before) changedTextParts++ state.output = after continue } if (state.status === "error" && typeof state.error === "string") { const before = state.error - const after = redactText(before, patterns, session).text + const after = await redactStr(before) if (after !== before) changedTextParts++ state.error = after continue } if (state.status === "pending" && typeof state.raw === "string") { const before = state.raw - const after = redactText(before, patterns, session).text + const after = await redactStr(before) if (after !== before) changedTextParts++ state.raw = after continue @@ -116,7 +185,7 @@ export const VibeGuardPrivacy = async (ctx) => { } if (debug && changedTextParts > 0) { - console.log(`[opencode-vibeguard] 本次请求前脱敏:已修改 ${changedTextParts} 处文本片段`) + log("debug", `Pre-request redaction: modified ${changedTextParts} text segment(s)`) } }, @@ -130,7 +199,7 @@ export const VibeGuardPrivacy = async (ctx) => { const after = restoreText(before, session) output.text = after if (debug && after !== before) { - console.log("[opencode-vibeguard] 本次响应完成后还原:已修改 1 处文本片段") + log("debug", "Post-response restore: modified 1 text segment") } }, diff --git a/src/model-server.js b/src/model-server.js new file mode 100644 index 0000000..89dc4d1 --- /dev/null +++ b/src/model-server.js @@ -0,0 +1,534 @@ +#!/usr/bin/env node +/** + * vibeguard-model-server — shared Privacy Filter inference daemon. + * + * Runs as a standalone process, serves token-classification inference + * over a Unix domain socket so multiple OpenCode instances share one + * model copy in RAM (~400MB q4). + * + * Lifecycle: + * - Spawned automatically by ai-detect.js on first detectWithAI() call. + * - Exits cleanly after IDLE_TIMEOUT_MS of no requests (default 20min). + * - Signal handlers (SIGINT/SIGTERM) clean up socket + pid file. + * + * Usage (manual): + * node src/model-server.js --model openai/privacy-filter --dtype q4 --device cpu + * + * Protocol (HTTP over Unix socket): + * POST /detect {text, categories?} → {spans: [{start,end,original,category}]} + * GET /health → {status,pid,uptime,model,dtype,device} + */ + +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import os from "node:os" + +// --------------------------------------------------------------------------- +// CLI args / env +// --------------------------------------------------------------------------- +const args = process.argv.slice(2) +function getArg(name, fallback) { + const idx = args.indexOf(`--${name}`) + return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : fallback +} + +const MODEL = getArg("model", process.env.VIBEGUARD_MODEL || "openai/privacy-filter") +const DTYPE = getArg("dtype", process.env.VIBEGUARD_DTYPE || "q4") +const DEVICE = getArg("device", process.env.VIBEGUARD_DEVICE || "cpu") +const IDLE_TIMEOUT_MS = Number( + getArg("idle-timeout", process.env.VIBEGUARD_IDLE_TIMEOUT_MS || "1200000") +) // 20 min + +if (!Number.isFinite(IDLE_TIMEOUT_MS) || IDLE_TIMEOUT_MS <= 0) { + process.stderr.write(`Invalid idle-timeout: ${IDLE_TIMEOUT_MS}. Must be a positive number (ms).\n`) + process.exit(1) +} +const SOCKET_PATH = getArg( + "socket", + process.env.VIBEGUARD_SOCKET || defaultSocketPath() +) +const PID_PATH = SOCKET_PATH.endsWith(".sock") + ? SOCKET_PATH.replace(/\.sock$/, ".pid") + : SOCKET_PATH + ".pid" +const LOG_PATH = SOCKET_PATH.endsWith(".sock") + ? SOCKET_PATH.replace(/\.sock$/, ".log") + : SOCKET_PATH + ".log" +const MAX_BODY_BYTES = 1_048_576 // 1 MB +const MAX_QUEUE = 50 +const REQUEST_TIMEOUT_MS = 30_000 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function defaultSocketPath() { + const dir = process.env.TMPDIR || os.tmpdir() || "/tmp" + const uid = process.getuid?.() ?? process.pid + return path.join(dir, `vibeguard-${uid}.sock`) +} + +function log(msg) { + const ts = new Date().toISOString() + const line = `[${ts}] ${msg}\n` + // Write to stderr only — the spawner redirects stderr to the log file. + // appendFileSync was causing doubled lines when stderr was already redirected. + process.stderr.write(line) +} + +// --------------------------------------------------------------------------- +// Model loading (moved from ai-detect.js) +// --------------------------------------------------------------------------- +const LABEL_TO_CATEGORY = { + // openai/privacy-filter labels + private_person: "PRIVATE_PERSON", + private_address: "PRIVATE_ADDRESS", + private_email: "PRIVATE_EMAIL", + private_phone: "PRIVATE_PHONE", + private_url: "PRIVATE_URL", + private_date: "PRIVATE_DATE", + account_number: "ACCOUNT_NUMBER", + secret: "SECRET", + // broadfield-dev/bert-small labels (BIO prefix already stripped) + email: "PRIVATE_EMAIL", + telephonenum: "PRIVATE_PHONE", + givenname: "PRIVATE_PERSON", + surname: "PRIVATE_PERSON", + username: "PRIVATE_PERSON", + street: "PRIVATE_ADDRESS", + city: "PRIVATE_ADDRESS", + buildingnum: "PRIVATE_ADDRESS", + zipcode: "PRIVATE_ADDRESS", + creditcardnumber: "ACCOUNT_NUMBER", + accountnum: "ACCOUNT_NUMBER", + socialnum: "ACCOUNT_NUMBER", + idcardnum: "ACCOUNT_NUMBER", + driverlicensenum: "ACCOUNT_NUMBER", + taxnum: "ACCOUNT_NUMBER", + dateofbirth: "PRIVATE_DATE", + password: "SECRET", +} + +let _pipeline = null +let _loadError = null +let _loading = null + +async function loadPipeline() { + if (_pipeline) return _pipeline + if (_loading) return _loading + _loading = (async () => { + log(`Loading model: ${MODEL} (dtype=${DTYPE}, device=${DEVICE})`) + const start = Date.now() + try { + const transformers = await import("@huggingface/transformers") + // Limit thread count to prevent CPU-spinning when idle. + // onnxruntime-node uses native threads (not WASM), controlled via + // session_options. The wasm.numThreads setting alone does nothing + // for the native backend. Default is all CPU cores → 100%+ CPU idle. + if (transformers.env?.backends?.onnx?.wasm) { + transformers.env.backends.onnx.wasm.numThreads = 2 + } + _pipeline = await transformers.pipeline("token-classification", MODEL, { + dtype: DTYPE, + device: DEVICE, + session_options: { + intraOpNumThreads: 2, + interOpNumThreads: 1, + }, + }) + const elapsed = ((Date.now() - start) / 1000).toFixed(1) + log(`Model loaded successfully (${elapsed}s)`) + _loadError = null + return _pipeline + } catch (err) { + _loadError = err.message + log(`Model load failed: ${err.message}`) + _pipeline = null + return null + } finally { + _loading = null + } + })() + return _loading +} + +// --------------------------------------------------------------------------- +// Inference (serialized queue) +// --------------------------------------------------------------------------- +let _inferring = false +let _requestCount = 0 +const _queue = [] + +function enqueueInference(text, categories) { + return new Promise((resolve, reject) => { + if (_queue.length >= MAX_QUEUE) { + reject(new Error("Queue full")) + return + } + _queue.push({ text, categories, resolve, reject }) + drainQueue() + }) +} + +async function drainQueue() { + if (_inferring || _queue.length === 0) return + _inferring = true + const job = _queue.shift() + let settled = false + + // Per-request timeout + const timer = setTimeout(() => { + if (!settled) { + settled = true + job.reject(new Error("Inference timed out")) + } + }, REQUEST_TIMEOUT_MS) + + try { + const spans = await runInference(job.text, job.categories) + clearTimeout(timer) + if (!settled) { + settled = true + job.resolve(spans) + } + } catch (err) { + clearTimeout(timer) + if (!settled) { + settled = true + job.reject(err) + } + } finally { + _inferring = false + // Process next in queue — use setImmediate to avoid recursive stack buildup + if (_queue.length > 0) setImmediate(drainQueue) + } +} + +async function runInference(text, categories) { + const pipe = _pipeline + if (!pipe) return [] + + const entities = await pipe(text, { aggregation_strategy: "simple" }) + if (!Array.isArray(entities) || entities.length === 0) return [] + + const allowedCategories = + Array.isArray(categories) && categories.length > 0 + ? new Set(categories.map((c) => c.toLowerCase())) + : null + + const spans = [] + // cursor tracks search position in text to handle repeated words correctly + let cursor = 0 + + for (const entity of entities) { + if (!entity || typeof entity !== "object") continue + const rawLabel = String(entity.entity_group ?? entity.entity ?? "").toLowerCase() + const label = rawLabel.replace(/^[bi]-/, "") + if (!label || label === "o") continue + if (allowedCategories && !allowedCategories.has(label)) continue + + let start = Number(entity.start) + let end = Number(entity.end) + + // Transformers.js may not return start/end character offsets (unlike Python). + // If missing, locate the span by matching the word field against the source text. + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) { + const word = String(entity.word ?? "").trim() + if (!word) continue + const idx = text.indexOf(word, cursor) + if (idx === -1) continue // word not found — skip + start = idx + end = idx + word.length + } + + if (end > text.length) continue + + const original = text.slice(start, end) + const category = LABEL_TO_CATEGORY[label] ?? label.toUpperCase() + spans.push({ start, end, original, category }) + cursor = end // advance cursor past this span + } + return spans +} + +// --------------------------------------------------------------------------- +// Idle timeout + socket watchdog +// --------------------------------------------------------------------------- +let _idleTimer = null +let _socketWatcher = null +const startedAt = Date.now() + +function resetIdleTimer() { + if (_idleTimer) clearTimeout(_idleTimer) + _idleTimer = setTimeout(() => { + log(`Idle for ${IDLE_TIMEOUT_MS / 60_000}min, shutting down.`) + shutdown() + }, IDLE_TIMEOUT_MS) + // Don't let the timer keep the process alive if everything else is done + if (_idleTimer.unref) _idleTimer.unref() +} + +/** + * Watch the socket file for deletion. If it disappears (another instance's + * cleanupStaleFiles, OS cleanup, manual rm), this server is orphaned and + * can never receive requests again — exit immediately. + * The client (ai-detect.js) will spawn a fresh server on next detect() call. + */ +function startSocketWatchdog() { + try { + // Resolve symlinks — macOS $TMPDIR is often a symlink to /private/tmp + const watchDir = fs.realpathSync(path.dirname(SOCKET_PATH)) + const socketBase = path.basename(SOCKET_PATH) + let debounceTimer = null + + _socketWatcher = fs.watch(watchDir, (eventType, filename) => { + if (filename !== socketBase) return + // Debounce to avoid false positives from atomic rename operations + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + debounceTimer = null + if (!fs.existsSync(SOCKET_PATH)) { + log("Socket file deleted externally — exiting orphaned server.") + shutdown() + } + }, 150) + if (debounceTimer.unref) debounceTimer.unref() + }) + _socketWatcher.unref() + } catch { + // fs.watch not supported or dir doesn't exist — not fatal, idle timeout + // will still clean up eventually + } +} + +// --------------------------------------------------------------------------- +// Memory watchdog — kill if RSS exceeds cap (ONNX runtime leaks over time) +// --------------------------------------------------------------------------- +const MAX_RSS_MB = parseInt(process.env.VIBEGUARD_MAX_RSS_MB || "1500", 10) +let _memoryWatchdog = null + +function startMemoryWatchdog() { + _memoryWatchdog = setInterval(() => { + const rssMB = process.memoryUsage.rss() / 1024 / 1024 + if (rssMB > MAX_RSS_MB) { + log(`RSS ${rssMB.toFixed(0)}MB exceeds cap ${MAX_RSS_MB}MB — restarting.`) + shutdown() + } + }, 30_000) // check every 30s + if (_memoryWatchdog.unref) _memoryWatchdog.unref() +} + +// --------------------------------------------------------------------------- +// HTTP server +// --------------------------------------------------------------------------- +const server = http.createServer(async (req, res) => { + resetIdleTimer() + + // Health check + if (req.method === "GET" && req.url === "/health") { + const status = _pipeline ? "ready" : _loading ? "loading" : _loadError ? "error" : "loading" + res.writeHead(200, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + status, + pid: process.pid, + uptime: Math.floor((Date.now() - startedAt) / 1000), + model: MODEL, + dtype: DTYPE, + device: DEVICE, + error: _loadError || undefined, + queueLength: _queue.length, + requestCount: _requestCount, + }) + ) + return + } + + // Detect endpoint + if (req.method === "POST" && req.url === "/detect") { + // Read body with size limit + const chunks = [] + let bodySize = 0 + let aborted = false + + req.on("data", (chunk) => { + if (aborted) return + bodySize += chunk.length + if (bodySize > MAX_BODY_BYTES) { + aborted = true + res.writeHead(413, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Request body too large" })) + req.destroy() + } else { + chunks.push(chunk) + } + }) + + req.on("error", () => { + aborted = true + }) + + req.on("end", async () => { + if (aborted) return + + let body + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf8")) + } catch { + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Invalid JSON" })) + return + } + + const { text, categories, requestedModel } = body + + // Model mismatch check + if (requestedModel && requestedModel !== MODEL) { + res.writeHead(409, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + error: "model mismatch", + loaded: MODEL, + requested: requestedModel, + }) + ) + return + } + + if (!text || typeof text !== "string") { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ spans: [] })) + return + } + + if (!_pipeline) { + res.writeHead(503, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + error: _loadError || "Model still loading", + status: _loading ? "loading" : "error", + }) + ) + return + } + + try { + // NEVER log text — it contains the sensitive data we're protecting + const spans = await enqueueInference(text, categories) + _requestCount++ + log(`detect #${_requestCount}: ${spans.length} span(s) found`) + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ spans })) + } catch (err) { + if (err.message === "Queue full") { + res.writeHead(503, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Server overloaded", spans: [] })) + } else { + // Inference error — return empty spans (client falls back to regex) + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ spans: [], error: err.message })) + } + } + }) + return + } + + // Unknown route + res.writeHead(404, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Not found" })) +}) + +// --------------------------------------------------------------------------- +// Startup & shutdown +// --------------------------------------------------------------------------- +function cleanupFiles() { + try { + fs.unlinkSync(SOCKET_PATH) + } catch { + /* may not exist */ + } + try { + fs.unlinkSync(PID_PATH) + } catch { + /* may not exist */ + } +} + +function shutdown() { + if (_idleTimer) clearTimeout(_idleTimer) + if (_socketWatcher) { try { _socketWatcher.close() } catch { /* ok */ } } + // Reject all queued jobs so their HTTP handlers can respond + while (_queue.length > 0) { + const job = _queue.shift() + job.reject(new Error("Server shutting down")) + } + server.close(() => { + cleanupFiles() + log(`Shutdown complete. Served ${_requestCount} detect request(s).`) + process.exit(0) + }) + // Force exit if server.close hangs (non-zero = abnormal) + setTimeout(() => { + cleanupFiles() + process.exit(1) + }, 3000).unref() +} + +// Clean up stale socket if it exists (only if the owner process is dead) +try { + const staleSocket = fs.existsSync(SOCKET_PATH) + if (staleSocket) { + let ownerAlive = false + try { + const pidStr = fs.readFileSync(PID_PATH, "utf8").trim() + const pid = Number(pidStr) + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 0) + ownerAlive = true + } catch { /* dead */ } + } + } catch { /* no pid file */ } + if (!ownerAlive) { + fs.unlinkSync(SOCKET_PATH) + try { fs.unlinkSync(PID_PATH) } catch { /* ok */ } + } + } +} catch { + /* ok if not found */ +} + +// Signal handlers for clean shutdown +process.on("SIGINT", shutdown) +process.on("SIGTERM", shutdown) +process.on("uncaughtException", (err) => { + log(`Uncaught exception: ${err.message}`) + // After uncaught exception, synchronous cleanup + exit is safest + cleanupFiles() + process.exit(1) +}) + +// Set restrictive umask before socket creation so the socket is never +// world-accessible (avoids TOCTOU race between listen() and chmod). +const prevUmask = process.umask(0o177) // creates files as 0o600 +server.listen(SOCKET_PATH, () => { + process.umask(prevUmask) // restore original umask + try { + fs.chmodSync(SOCKET_PATH, 0o600) + } catch { + /* belt-and-suspenders — umask already handled it */ + } + // Write PID file only after socket is bound (avoids race with other instances) + fs.writeFileSync(PID_PATH, String(process.pid), "utf8") + + log(`Server listening on ${SOCKET_PATH} (pid=${process.pid})`) + log(`Model: ${MODEL}, dtype: ${DTYPE}, device: ${DEVICE}`) + log(`Idle timeout: ${IDLE_TIMEOUT_MS / 60_000}min`) + + // Start idle timer + socket watchdog + memory watchdog + resetIdleTimer() + startSocketWatchdog() + startMemoryWatchdog() + + // Begin loading the model (async, non-blocking) + loadPipeline() +}) diff --git a/src/model-server.test.js b/src/model-server.test.js new file mode 100644 index 0000000..9529a56 --- /dev/null +++ b/src/model-server.test.js @@ -0,0 +1,520 @@ +/** + * Integration tests for vibeguard model-server. + * + * These tests spawn a real model server, send realistic (and tricky) + * PII / secret payloads, and assert the AI detects them. + * + * The model (~400 MB q4) must be pre-downloaded. + * Skip with: VIBEGUARD_SKIP_AI_TESTS=1 node --test src/model-server.test.js + * + * These are intentionally "less obvious" inputs — the kind a regex + * would miss but a language model should catch. + */ + +import { describe, it, before, after } from "node:test" +import assert from "node:assert/strict" +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import os from "node:os" +import { spawn, execSync } from "node:child_process" +import { fileURLToPath } from "node:url" + +// --------------------------------------------------------------------------- +// Skip gate +// --------------------------------------------------------------------------- +const SKIP_AI = process.env.VIBEGUARD_SKIP_AI_TESTS === "1" + +// --------------------------------------------------------------------------- +// Generated test secrets — no literal secrets in source +// --------------------------------------------------------------------------- +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const SECRETS_FILE = path.join(__dirname, "..", "test", ".secrets.json") +if (!fs.existsSync(SECRETS_FILE)) { + execSync( + `${process.execPath} ${path.join(__dirname, "..", "scripts", "generate-test-secrets.js")}`, + { stdio: "pipe" } + ) +} +const fixtures = JSON.parse(fs.readFileSync(SECRETS_FILE, "utf8")) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const SERVER_SCRIPT = path.join(__dirname, "model-server.js") +const uid = process.getuid?.() ?? process.pid +const SOCK = path.join( + os.tmpdir(), + `vibeguard-test-${uid}-${Date.now()}.sock` +) +const PID_FILE = SOCK.replace(/\.sock$/, ".pid") +const LOG_FILE = SOCK.replace(/\.sock$/, ".log") + +let serverProcess = null +let logFd = null + +function request(method, urlPath, body, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + let settled = false + const opts = { + socketPath: SOCK, + path: urlPath, + method, + headers: {}, + timeout: timeoutMs, + } + let payload = null + if (body) { + payload = JSON.stringify(body) + opts.headers["Content-Type"] = "application/json" + opts.headers["Content-Length"] = Buffer.byteLength(payload) + } + const req = http.request(opts, (res) => { + const chunks = [] + res.on("data", (c) => chunks.push(c)) + res.on("end", () => { + if (settled) return + settled = true + try { + resolve({ + status: res.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + }) + } catch { + resolve({ status: res.statusCode, body: null }) + } + }) + }) + req.on("error", (err) => { + if (settled) return + settled = true + reject(err) + }) + req.on("timeout", () => { + if (settled) return + settled = true + req.destroy() + reject(new Error("request timed out")) + }) + if (payload) req.write(payload) + req.end() + }) +} + +function detect(text, categories) { + return request("POST", "/detect", { text, categories }) +} + +/** Extract spans from a detect response, with assertion that they exist. */ +function spans(detectResult) { + assert.ok(detectResult.body, `Expected response body, got null (status=${detectResult.status})`) + assert.ok(Array.isArray(detectResult.body.spans), `Expected spans array, got: ${JSON.stringify(detectResult.body)}`) + return detectResult.body.spans +} + +/** Wait for /health to report "ready", up to timeoutMs. */ +async function waitReady(timeoutMs = 180_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const r = await request("GET", "/health", null, 3000) + if (r.body?.status === "ready") return + if (r.body?.status === "error") throw new Error("server error: " + r.body.error) + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 500)) + } + throw new Error("model-server did not become ready in time") +} + +/** Assert that at least one span covers a substring. */ +function assertDetected(spans, substring, expectedCategory) { + assert.ok(Array.isArray(spans), `Expected spans array, got: ${JSON.stringify(spans)}`) + const match = spans.find((s) => { + // Primary: span original contains the expected substring + if (s.original.includes(substring)) return true + // Reverse: substring contains the span, but only if the span is + // substantial (>= 6 chars) to avoid matching single-char fragments + if (s.original.length >= 6 && substring.includes(s.original)) return true + return false + }) + assert.ok( + match, + `Expected AI to detect "${substring}" but got: ${JSON.stringify(spans.map((s) => s.original))}` + ) + if (expectedCategory) { + assert.equal( + match.category, + expectedCategory, + `Expected category ${expectedCategory} for "${substring}" but got ${match.category}` + ) + } +} + +/** Assert that NO span overlaps with a given region of text. */ +function assertNotDetected(spansArr, text, safeSubstring) { + assert.ok(Array.isArray(spansArr), `Expected spans array, got: ${JSON.stringify(spansArr)}`) + const start = text.indexOf(safeSubstring) + const end = start + safeSubstring.length + const overlapping = spansArr.filter( + (s) => s.start < end && s.end > start + ) + assert.equal( + overlapping.length, + 0, + `"${safeSubstring}" should NOT be flagged but was: ${JSON.stringify(overlapping)}` + ) +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- +describe("model-server (AI integration)", { timeout: 300_000, skip: SKIP_AI }, () => { + before(async () => { + // Clean up any leftover files + for (const f of [SOCK, PID_FILE, LOG_FILE]) { + try { fs.unlinkSync(f) } catch { /* ok */ } + } + + logFd = fs.openSync(LOG_FILE, "a") + serverProcess = spawn( + process.execPath, + [SERVER_SCRIPT, "--socket", SOCK, "--idle-timeout", "300000"], + { detached: true, stdio: ["ignore", logFd, logFd] } + ) + serverProcess.unref() + + // Last-resort cleanup if test process is killed (Ctrl+C, crash) + process.on("exit", () => { + if (serverProcess?.pid) { + try { process.kill(-serverProcess.pid, "SIGKILL") } catch { /* ok */ } + } + }) + + await waitReady() + }) + + after(() => { + // Kill the process group (catches any children) + if (serverProcess?.pid) { + try { process.kill(-serverProcess.pid) } catch { /* ok */ } + } + // Close the log fd + if (logFd !== null) { + try { fs.closeSync(logFd) } catch { /* ok */ } + logFd = null + } + for (const f of [SOCK, PID_FILE, LOG_FILE]) { + try { fs.unlinkSync(f) } catch { /* ok */ } + } + }) + + // ========================================================================= + // Health / protocol + // ========================================================================= + + it("GET /health returns ready with expected fields", async () => { + const r = await request("GET", "/health", null) + assert.equal(r.status, 200) + assert.equal(r.body.status, "ready") + assert.equal(r.body.model, "openai/privacy-filter") + assert.ok(Number.isFinite(r.body.pid)) + assert.ok(Number.isFinite(r.body.uptime)) + }) + + // ========================================================================= + // Subtle secrets — things regex misses + // ========================================================================= + + describe("subtle secrets", () => { + it("detects a password buried in a JDBC connection string", async () => { + const r = await detect(fixtures.jdbc_text) + assert.ok(spans(r).length > 0, "should detect at least one span in JDBC URL") + }) + + it("detects credentials in a MongoDB URI", async () => { + const r = await detect(fixtures.mongo_text) + assert.ok(spans(r).length > 0, "should detect something in Mongo URI") + }) + + it("detects an API key assigned to a variable with a generic name", async () => { + const r = await detect(fixtures.github_text) + assertDetected(spans(r), fixtures.github_pat, "SECRET") + }) + + it("detects a Slack webhook URL (or defers to regex)", async () => { + // NOTE: The Privacy Filter model may not flag webhook URLs as secrets. + // This is expected — the regex layer catches slack webhooks via pattern. + // We test that the server handles it without error; detection is best-effort. + const r = await detect(fixtures.slack_text) + assert.equal(r.status, 200) + // If the model flags it, great; if not, regex handles it. + // Just verify no server errors. + }) + + it("detects a private key block even when indented in YAML", async () => { + const text = `tls: + cert: | + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy5AoC5dNz8mLLMo1mqob + -----END RSA PRIVATE KEY-----` + const r = await detect(text) + assert.ok(spans(r).length > 0, "should detect the private key block") + }) + + it("detects an AWS secret key in an env export", async () => { + const r = await detect(fixtures.aws_text) + assertDetected(spans(r), fixtures.aws_secret_key, "SECRET") + }) + + it("detects a bearer token in an HTTP header literal", async () => { + const r = await detect(fixtures.jwt_text) + assert.ok(spans(r).length > 0, "should detect the JWT / bearer token") + }) + + it("detects a Stripe secret key in JSON config", async () => { + const r = await detect(fixtures.stripe_text) + assertDetected( + spans(r), + fixtures.stripe_key, + "SECRET" + ) + }) + }) + + // ========================================================================= + // Subtle PII — context-dependent, regex-hard + // ========================================================================= + + describe("subtle PII", () => { + it("detects a person's name in a natural sentence without labels", async () => { + const text = + "The quarterly report was prepared by Margaret Thatcherton and reviewed by her manager." + const r = await detect(text) + assertDetected(spans(r), "Margaret Thatcherton", "PRIVATE_PERSON") + }) + + it("detects an email in a markdown link", async () => { + const text = + "For questions, reach out to [the team lead](mailto:sarah.connor@skynet.io) or file a ticket." + const r = await detect(text) + const s = spans(r) + // The model detects this but may split it across multiple spans due to tokenization + // (e.g., "mailto:s", "arah", ".connor@skynet.io"). We verify that at least one span + // overlaps with the email region rather than requiring exact substring match. + const emailStart = text.indexOf("sarah.connor@skynet.io") + const emailEnd = emailStart + "sarah.connor@skynet.io".length + const overlapping = s.filter((sp) => sp.start < emailEnd && sp.end > emailStart) + assert.ok(overlapping.length > 0, `Expected detection overlapping email, got: ${JSON.stringify(s)}`) + }) + + it("detects a phone number written in words-and-digits mix", async () => { + const text = "You can reach our office at +1 (312) 555-0198 during business hours." + const r = await detect(text) + assertDetected(spans(r), "312) 555-0198", "PRIVATE_PHONE") + }) + + it("detects a street address embedded in prose", async () => { + const text = + "Ship the replacement to 742 Evergreen Terrace, Springfield, IL 62704 by next Friday." + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "PRIVATE_ADDRESS" || sp.original.includes("742 Evergreen")), + `Expected address detection, got: ${JSON.stringify(s)}` + ) + }) + + it("detects a date of birth in a sentence", async () => { + const text = + "Patient record: DOB is March 15, 1987, admitted on 2024-01-10 for observation." + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "PRIVATE_DATE"), + `Expected at least one PRIVATE_DATE, got: ${JSON.stringify(s)}` + ) + }) + + it("detects an internal URL with auth token in query string", async () => { + const text = + "Dashboard link: https://grafana.internal.corp/d/abc123?orgId=1&auth_token=eyJrIjoiT2tDN2FBNHciLCJuIjoiZGVwbG95IiwiZCI6MX0" + const r = await detect(text) + assert.ok(spans(r).length > 0, "should detect the URL or embedded token") + }) + + it("detects an account number formatted with spaces", async () => { + const text = "Wire transfer to account 4532 0151 2345 6789, routing 021000021." + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "ACCOUNT_NUMBER"), + `Expected ACCOUNT_NUMBER, got: ${JSON.stringify(s)}` + ) + }) + }) + + // ========================================================================= + // Detection breadth — verify the model catches diverse PII/secret types + // ========================================================================= + + describe("detection breadth", () => { + it("detects an arbitrary password string from context", async () => { + const text = "The database password is hunter2secretpass123" + const r = await detect(text) + assertDetected(spans(r), "hunter2secretpass123", "SECRET") + }) + + it("detects a random API key string", async () => { + const text = "Set API_KEY=xK9mP2vL8nQ4wR7yT3hB5cF6gJ1aD0eU in your env" + const r = await detect(text) + assertDetected(spans(r), "xK9mP2vL8nQ4wR7yT3hB5cF6gJ1aD0eU", "SECRET") + }) + + it("detects a full name", async () => { + const text = "Contact John Smith at the front desk" + const r = await detect(text) + assertDetected(spans(r), "John Smith", "PRIVATE_PERSON") + }) + + it("detects an email address", async () => { + const text = "Send results to john.smith@acme.corp for review" + const r = await detect(text) + assertDetected(spans(r), "john.smith@acme.corp", "PRIVATE_EMAIL") + }) + + it("detects a phone number", async () => { + const text = "Call me at 415-555-0198 after 5pm" + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "PRIVATE_PHONE" && sp.original.includes("415-555-0198")), + `Expected PRIVATE_PHONE with 415-555-0198, got: ${JSON.stringify(s)}` + ) + }) + + it("detects a social security number", async () => { + const text = "SSN on file: 123-45-6789" + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "ACCOUNT_NUMBER"), + `Expected ACCOUNT_NUMBER for SSN, got: ${JSON.stringify(s)}` + ) + }) + + it("detects a full street address", async () => { + const text = "Ship to 742 Evergreen Terrace, Springfield IL 62704" + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "PRIVATE_ADDRESS"), + `Expected PRIVATE_ADDRESS, got: ${JSON.stringify(s)}` + ) + }) + + it("detects a credit card number", async () => { + const text = "Card on file: 4532 0151 2345 6789 exp 12/27" + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "ACCOUNT_NUMBER"), + `Expected ACCOUNT_NUMBER for credit card, got: ${JSON.stringify(s)}` + ) + }) + + it("detects a connection string with embedded password", async () => { + const text = "MONGO_URI=mongodb://admin:p4ssw0rd@db.internal:27017/prod" + const r = await detect(text) + const s = spans(r) + assert.ok(s.length > 0, `Expected at least one detection for connection string, got: ${JSON.stringify(s)}`) + }) + + it("detects a date of birth", async () => { + const text = "Patient DOB: March 15, 1987" + const r = await detect(text) + const s = spans(r) + assert.ok( + s.some((sp) => sp.category === "PRIVATE_DATE"), + `Expected PRIVATE_DATE, got: ${JSON.stringify(s)}` + ) + }) + }) + + // ========================================================================= + // False-positive resistance + // ========================================================================= + + describe("false positive resistance", () => { + it("does NOT flag a semver version string", async () => { + const text = "Upgraded @huggingface/transformers from 4.1.0 to 4.2.0" + const r = await detect(text) + assert.equal(spans(r).length, 0, `Expected no spans, got: ${JSON.stringify(r.body.spans)}`) + }) + + it("does NOT flag a localhost URL with port", async () => { + const text = "Dev server running at http://localhost:3000/api/v1/health" + const r = await detect(text) + assertNotDetected(spans(r), text, "http://localhost:3000") + }) + + it("does NOT flag common programming identifiers", async () => { + const text = "const userId = getUserById(req.params.id)" + const r = await detect(text) + assert.equal(spans(r).length, 0, `Expected no spans, got: ${JSON.stringify(r.body.spans)}`) + }) + }) + + // ========================================================================= + // Category filtering + // ========================================================================= + + describe("category filtering", () => { + it("only returns spans matching requested categories", async () => { + const text = "Contact John Doe at john.doe@acme.com or call (555) 123-4567" + const r = await detect(text, ["private_email"]) + const s = spans(r) + // Should only include email, not person or phone + for (const sp of s) { + assert.equal( + sp.category, + "PRIVATE_EMAIL", + `Unexpected category ${sp.category} when filtering for private_email only` + ) + } + assert.ok(s.length > 0, "should detect at least the email") + }) + }) + + // ========================================================================= + // Edge cases + // ========================================================================= + + describe("edge cases", () => { + it("handles empty text gracefully", async () => { + const r = await detect("") + assert.equal(r.status, 200) + assert.deepEqual(r.body.spans, []) + }) + + it("handles text with no PII", async () => { + const text = "The quick brown fox jumps over the lazy dog." + const r = await detect(text) + assert.equal(r.status, 200) + assert.deepEqual(r.body.spans, []) + }) + + it("returns spans with correct start/end offsets", async () => { + const text = "Email me at test.user@example.org please" + const r = await detect(text) + for (const sp of spans(r)) { + assert.equal( + text.slice(sp.start, sp.end), + sp.original, + `Offset mismatch: text[${sp.start}:${sp.end}] = "${text.slice(sp.start, sp.end)}" but original = "${sp.original}"` + ) + } + }) + }) +}) diff --git a/src/patterns.js b/src/patterns.js index 5238cac..a129f9e 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -1,11 +1,4 @@ -function sanitizeCategory(input) { - const raw = String(input ?? "").trim() - if (!raw) return "TEXT" - const upper = raw.toUpperCase() - const safe = upper.replace(/[^A-Z0-9_]/g, "_").replace(/_+/g, "_") - if (!safe) return "TEXT" - return safe -} +import { sanitizeCategory } from "./util.js" /** * 将 Go 风格的 `(?i)` / `(?m)` 前缀做一个轻量兼容(仅处理“开头连续出现”的情况)。 @@ -34,8 +27,8 @@ function peelInlineFlags(pattern, flags) { } /** - * 内置规则:从 VibeGuard 的 builtin 规则移植(做了 JS 兼容调整)。 - * 目标是“低配置成本 + 尽量覆盖”,不追求 100% 精准。 + * Builtin detection rules (ported from VibeGuard with JS compatibility). + * Goal: low config cost + broad coverage, not 100% precision. */ const BUILTIN = new Map([ [ @@ -49,7 +42,6 @@ const BUILTIN = new Map([ [ "china_phone", { - // 直接匹配手机号本体(用 lookaround 替代 Go 里的捕获组边界保留写法) pattern: String.raw`(? String(x ?? ""))) diff --git a/src/session.js b/src/session.js index eff5c91..f65854a 100644 --- a/src/session.js +++ b/src/session.js @@ -1,13 +1,5 @@ import { createHmac, randomBytes } from "node:crypto" - -function sanitizeCategory(input) { - const raw = String(input ?? "").trim() - if (!raw) return "TEXT" - const upper = raw.toUpperCase() - const safe = upper.replace(/[^A-Z0-9_]/g, "_").replace(/_+/g, "_") - if (!safe) return "TEXT" - return safe -} +import { sanitizeCategory } from "./util.js" function toHexLower(buffer) { return Buffer.from(buffer).toString("hex") @@ -121,7 +113,7 @@ export class PlaceholderSession { // 极低概率:hash12 冲突。追加 _N 后缀保证唯一性(与 VibeGuard 一致的策略)。 const withoutSuffix = base.slice(0, -2) // 去掉末尾 "__" - for (let i = 2; ; i++) { + for (let i = 2; i < 1000; i++) { const candidate = `${withoutSuffix}_${i}__` const prev = this.forward.get(candidate) if (prev === undefined) { @@ -136,11 +128,13 @@ export class PlaceholderSession { return candidate } } + throw new Error(`vibeguard: placeholder collision limit exceeded for category "${category}"`) + } } export function getPlaceholderRegex(prefix) { const escaped = String(prefix).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Pattern: __VG_CATEGORY_HASH12__ or __VG_CATEGORY_HASH12_N__ - return new RegExp(`${escaped}[A-Za-z0-9_]+_[a-f0-9A-F]{12}(?:_\\d+)?__`, "g") + return new RegExp(`${escaped}[A-Za-z0-9_]+_[a-f0-9]{12}(?:_\\d+)?__`, "g") } diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..4f1d9dd --- /dev/null +++ b/src/util.js @@ -0,0 +1,8 @@ +export function sanitizeCategory(input) { + const raw = String(input ?? "").trim() + if (!raw) return "TEXT" + const upper = raw.toUpperCase() + const safe = upper.replace(/[^A-Z0-9_]/g, "_").replace(/_+/g, "_") + if (!safe) return "TEXT" + return safe +} diff --git a/vibeguard.config.json.example b/vibeguard.config.json.example index 9884acc..e97c0cd 100644 --- a/vibeguard.config.json.example +++ b/vibeguard.config.json.example @@ -17,5 +17,13 @@ ], "builtin": ["email", "china_phone", "china_id", "uuid", "ipv4", "mac"], "exclude": ["example.com", "localhost", "127.0.0.1", "0.0.0.0"] + }, + "ai": { + "enabled": false, + "model": "openai/privacy-filter", + "dtype": "q4", + "device": "cpu", + "categories": [], + "silentFallback": true } }