diff --git a/README.md b/README.md index b5df5bf..3c922d8 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,10 @@ omp plugin uninstall @rblaine95/omp-plugins ## Included extensions -| Extension | What it does | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rules-guard` | Enforces the Claude `permissions` allow/deny policy across every omp tool (read/write/edit/find/search/bash/eval/browser), not just `bash`. | -| `usage-status` | Color-coded row above the editor showing remaining usage with reset countdowns for every subscription `/usage` reports (Claude, Codex, Gemini, Grok, OpenCode, Cursor, …), so you don't have to run `/usage`. | +| Extension | What it does | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `rules-guard` | Enforces the Claude `permissions` allow/deny policy across every omp tool (read/write/edit/find/search/bash/eval/browser), not just `bash`. Also redacts secret-shaped text (API tokens, private keys, credential URLs) out of what the model sees — including output from `!` / `$` commands, which bypass tool interception entirely. Your terminal and the saved transcript still show the real values. | +| `usage-status` | Color-coded row above the editor showing remaining usage with reset countdowns for every subscription `/usage` reports (Claude, Codex, Gemini, Grok, OpenCode, Cursor, …), so you don't have to run `/usage`. | ## Local development diff --git a/biome.json b/biome.json index 9510232..662227d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "vcs": { "enabled": false, "clientKind": "git", diff --git a/bun.lock b/bun.lock index e86a009..2334a31 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "@rblaine95/omp-plugins", "devDependencies": { - "@biomejs/biome": "^2.5.4", - "@oh-my-pi/pi-coding-agent": "^17.0.5", + "@biomejs/biome": "^2.5.5", + "@oh-my-pi/pi-coding-agent": "^17.1.3", "@types/bun": "1.3.14", "@types/node": "^24.13.3", "sort-package-json": "^4.0.0", @@ -29,31 +29,41 @@ "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.5", "@biomejs/cli-darwin-x64": "2.5.5", "@biomejs/cli-linux-arm64": "2.5.5", "@biomejs/cli-linux-arm64-musl": "2.5.5", "@biomejs/cli-linux-x64": "2.5.5", "@biomejs/cli-linux-x64-musl": "2.5.5", "@biomejs/cli-win32-arm64": "2.5.5", "@biomejs/cli-win32-x64": "2.5.5" }, "bin": { "biome": "bin/biome" } }, "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.1", "", {}, "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg=="], @@ -137,39 +147,39 @@ "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], - "@oh-my-pi/hashline": ["@oh-my-pi/hashline@17.0.5", "", { "dependencies": { "diff": "^9.0.0", "lru-cache": "11.5.2" } }, "sha512-1aFduPnzUAlnryx7JNttQi0SHstLaO4gPqFbdaSAM+UBeOXRo7W25q6h63xpKuvwBM8XabG3eZnkVLEaQ21InA=="], + "@oh-my-pi/hashline": ["@oh-my-pi/hashline@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.1.3", "lru-cache": "11.5.2" } }, "sha512-12HhWVozlo+pm+gI7avwn5/VtLnllVxGDzFSxwRDJh4sfoonGUuf2xT4Lq6jqInEX+j3xQ85UNPDeM4cJwGAqQ=="], - "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@17.0.5", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.0.5", "@oh-my-pi/pi-catalog": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-ZHyHXC8x1In9dz/Rt4onS0mBC2fVgJLFB2+it8ry0IWX6OiGyzSpg/DinB0T+JiL0YHep1nLGC7d7BBz9vGK4g=="], + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.1.3", "@oh-my-pi/pi-catalog": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-2mMXSt1CSpS49j+dUFssYIFrb7R0pCFIu2Zm4yFXhhmVzGl6YlrPux3RMqo0l+60tpWdAdFM6KPeWi3yMD0jwg=="], - "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@17.0.5", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.0.5", "@oh-my-pi/pi-catalog": "17.0.5", "@oh-my-pi/pi-natives": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "@oh-my-pi/pi-wire": "17.0.5", "@oh-my-pi/snapcompact": "17.0.5", "@opentelemetry/api": "^1.9.1" } }, "sha512-mnYqqr86f4UentSKXJA4nM8XS1YT7VxkG3COfLeH2kD7ri96znRFrEEpD+kjHjhaXLDj/x24jZ8Trtc6y5I1+Q=="], + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.1.3", "@oh-my-pi/pi-catalog": "17.1.3", "@oh-my-pi/pi-natives": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "@oh-my-pi/pi-wire": "17.1.3", "@oh-my-pi/snapcompact": "17.1.3", "@opentelemetry/api": "^1.9.1" } }, "sha512-wH0mEEkfyd23pAIsF06kmVadw9Qiz7cwYovw2jp7NG+mDmTLIk2dH31663/wYuK7hLQkq2wfxocseTzLDy90+A=="], - "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@17.0.5", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/pi-catalog": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "@oh-my-pi/pi-wire": "17.0.5", "arktype": "2.2.3", "zod": "^4" } }, "sha512-6RtjXNDrz2V3IsBr3pw+FqHgBr08P61LeoW7UZE0ffMjHws9A2CYVfTXLsrMJyWOqn06AlNtxY4KORR/nTW/RA=="], + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@17.1.3", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/pi-catalog": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "@oh-my-pi/pi-wire": "17.1.3", "arktype": "2.2.3", "zod": "^4" } }, "sha512-c+FxymmtEMauQ4MNowW82zIJGiLTJkNoPiyRQbHHVxLbXGuuuyjPHNbovmrcAUGjUHAmog2ZxBFjBACtdlvIUA=="], - "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@17.0.5", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/pi-utils": "17.0.5", "arktype": "2.2.3", "zod": "^4" } }, "sha512-OY0/EypQUHuUfR+li1fMAd5mbKD2XsIM7Ewe/DGnTlmwM/TCbkSPauTJTK7ilyCCmeuDKE9Qr9cVLqkB0KlNzQ=="], + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@17.1.3", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/pi-utils": "17.1.3", "arktype": "2.2.3", "zod": "^4" } }, "sha512-idMY/7jGtw7Gn/9cR7XPaVNazgQ2cj2/OC/VZrDLnb8M7Nz7/R61Nl/YKb3gcmpsvuI2mOhZSeUcuKk6jtYqPg=="], - "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@17.0.5", "", { "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@babel/parser": "^7.29.7", "@mozilla/readability": "^0.6.0", "@oh-my-pi/hashline": "17.0.5", "@oh-my-pi/omp-stats": "17.0.5", "@oh-my-pi/pi-agent-core": "17.0.5", "@oh-my-pi/pi-ai": "17.0.5", "@oh-my-pi/pi-catalog": "17.0.5", "@oh-my-pi/pi-mnemopi": "17.0.5", "@oh-my-pi/pi-natives": "17.0.5", "@oh-my-pi/pi-tui": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "@oh-my-pi/pi-wire": "17.0.5", "@oh-my-pi/snapcompact": "17.0.5", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "@puppeteer/browsers": "^3.0.6", "@types/turndown": "5.0.6", "@xterm/headless": "^6.0.0", "arktype": "2.2.3", "chalk": "^5.6.2", "diff": "^9.0.0", "fast-xml-parser": "^5.9.3", "handlebars": "^4.7.9", "header-generator": "^2.1.82", "linkedom": "^0.18.13", "lru-cache": "11.5.2", "mammoth": "^1.12.0", "mupdf": "^1.28.0", "puppeteer-core": "25.3.0", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "zod": "^4" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-SdDYSuc1+zdoufN7xV6pResZXYEN0gpt1mjz7TwQH8LZroRpkA7JiLE48fQp0t3bJYMIKpuTjAXp1bZw2Qep4A=="], + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@17.1.3", "", { "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", "@mozilla/readability": "^0.6.0", "@oh-my-pi/hashline": "17.1.3", "@oh-my-pi/omp-stats": "17.1.3", "@oh-my-pi/pi-agent-core": "17.1.3", "@oh-my-pi/pi-ai": "17.1.3", "@oh-my-pi/pi-catalog": "17.1.3", "@oh-my-pi/pi-mnemopi": "17.1.3", "@oh-my-pi/pi-natives": "17.1.3", "@oh-my-pi/pi-tui": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "@oh-my-pi/pi-wire": "17.1.3", "@oh-my-pi/snapcompact": "17.1.3", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "@puppeteer/browsers": "^3.0.6", "@types/turndown": "5.0.6", "@xterm/headless": "^6.0.0", "arktype": "2.2.3", "chalk": "^5.6.2", "fast-xml-parser": "^5.9.3", "handlebars": "^4.7.9", "header-generator": "^2.1.82", "linkedom": "^0.18.13", "lru-cache": "11.5.2", "mammoth": "^1.12.0", "mupdf": "^1.28.0", "puppeteer-core": "25.3.0", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "zod": "^4" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-g8hJnkwUakIi1bCkOulgORVQQiC0rEsfzo42wpHCVdfNkwHi/cejdCry1P/mERU2LztVgIpByK2SrZzZxo1ZXA=="], - "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@17.0.5", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.0.5", "@oh-my-pi/pi-catalog": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "lru-cache": "11.5.2" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-mR0NMvMWI8x35y00cdGwfaC8LFV7W1MHjS09VclHJE/NVdGo1Cn4OxhOXHDt+ayqfHch3RqWfhPrnzV31zSMog=="], + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.1.3", "@oh-my-pi/pi-catalog": "17.1.3", "@oh-my-pi/pi-natives": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "lru-cache": "11.5.2" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-dtdKKrUjJsVDcbiaCeXWilKlv+4KolssEPNaxsx3FRTbUaNE22Lw12q+UUJcfyUr6V9PN1zji0PS7v647a+FeQ=="], - "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@17.0.5", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "17.0.5", "@oh-my-pi/pi-natives-darwin-x64": "17.0.5", "@oh-my-pi/pi-natives-linux-arm64": "17.0.5", "@oh-my-pi/pi-natives-linux-x64": "17.0.5", "@oh-my-pi/pi-natives-win32-x64": "17.0.5" } }, "sha512-nWuOVgytuiy3xFGDUK0TP0HMe+sRHOhyvXSAlc4aldrE2Z4cN3WM5CJN4OaVMxc+FR1qFBznzvvdP9K3EvHVLA=="], + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@17.1.3", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "17.1.3", "@oh-my-pi/pi-natives-darwin-x64": "17.1.3", "@oh-my-pi/pi-natives-linux-arm64": "17.1.3", "@oh-my-pi/pi-natives-linux-x64": "17.1.3", "@oh-my-pi/pi-natives-win32-x64": "17.1.3" } }, "sha512-t8g19z6VDcYVTeLCR0qyBmy6PgAvAaUTlo6nij5ZnKOm3Wf1T/VDARXOCCVafDVKdIgu+Cz6vn9c99hjS5Wk8A=="], - "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@17.0.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FaKzy1iB2SpbqZ16elvFpctYkJiRPs/Ve0mmpjuosXLim69ir2h+OOrAP1bmXADvK9zt1mElIRr1wQsX0kVk6Q=="], + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@17.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GYOiRpUVKf1pdS5s4rDpXYvCNq4bIODYZq9ylCEqR8Anix3IRGm95jX3SiJTZT1kJCalJz4DTopHuFIqr00qkA=="], - "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@17.0.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ayLls3BRHI0QmHe0pyycv8VOWSYCmm1oyDrdO9YXaSEdjS0doFB4qr+/Fz294Xpt3uSHvuBr1UvGdgvzbO3kNA=="], + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@17.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-3r2GwwdrnUO+PLSOKh2PqcFU9jLCRVFWjmnRo5CIEwbflx5T9dVe4nKT4jT1nOYs3G0WvknxflcYBTyzSKy1yg=="], - "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@17.0.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-2G3KtSNPz8n+005UAqWk/gslDqd0oeS2unDsp5ItMJUTqGL6Yot869wHvdJRx4gVM5fgZJWG/04hTwd8G0gVxg=="], + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@17.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-uGPSRhculfgHJVFF0P7aNFF9KvNwxh7UCMGPDZeyva7o2oQDHsyFUDgtcfxGorKmWQCZvY7BSTRHJncZa1yZng=="], - "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@17.0.5", "", { "os": "linux", "cpu": "x64" }, "sha512-O5HpCTj9dURsO6mhQyBikq0og6iRpCnai2nh0d3HZnD1qW/KYbKuf4Vtge09DsTEQwyvkywpnAuKP+sGYjy5XA=="], + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@17.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LHa3QKUTCie8j0OngixWcs6mj3MZsPTOydmbyJy2tCdXp1Bi19nqxKI61zXoz5ukPWfmi9/0Vcu+ogzjXXqveQ=="], - "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@17.0.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Cev0XspoYBgJ4RbZJdavmUMDb1lJIL8xhWVaDzNMEstUaOHAzKTyYqbMLgV0c4vwSegfbRdxvInD+UOVnDDnAw=="], + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@17.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-CcTM8bDmBnnDEujIsuASmROLtTT74S1UoteCVp1XuYstkGg1Wd2PSuudLg6zajzkiywW1H9lkeBTXIbwnZ0JiQ=="], - "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@17.0.5", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "lru-cache": "11.5.2", "marked": "^18.0.6" } }, "sha512-Z0U+G/SmwUHeXw94YRUmsNOGPMmtyffo/vYvm1blUe2uWBoCh7OSAgK5TcO3AxNPaK1CZAwKDzQ/8TM47is1uA=="], + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "lru-cache": "11.5.2", "marked": "^18.0.6" } }, "sha512-sWfWC+90Edl0CCfB2Tz9xs5euzzrbtNzb+SB4dFbo5U+cuRwKD7zTNuTm3Ps0JiQDklJd2GmxKPzQRxp9/z5BQ=="], - "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@17.0.5", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.0.5", "handlebars": "^4.7.9", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" } }, "sha512-f2ZR4mP7TuNUIQTBStlbDK/abZlvojPWVHTIDj8RRwGszg8k5nojgwC+MJt0MXRBUQmevbVXei008iALGeTSmQ=="], + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.1.3", "handlebars": "^4.7.9", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" } }, "sha512-aY97QprzMR1ZZe3bIQkwhTHisYd0/V7Ea6SshJuwTUpQgD8MCZcaxB4MYam3SMvPbTZiyEt8qg1Cg0yqnFcs5Q=="], - "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@17.0.5", "", {}, "sha512-x6on+RyFEE74a3xQJ2JaHedJPLcvis0Kh+RVTb8kvRPhRqKMrkw9D3QkHyJZbiehk+qawxe0lMn5oahhMMwz2Q=="], + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@17.1.3", "", {}, "sha512-8Ql04tgpBfFVA28PoWUKy+E+8RVZWH2VElXdO4H+/X4IOjnLpQ2qx/CpFMOuD0jzHz3ZI1qfQSAae/H8WZ+v9A=="], - "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@17.0.5", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.0.5", "@oh-my-pi/pi-natives": "17.0.5", "@oh-my-pi/pi-utils": "17.0.5", "@oh-my-pi/pi-wire": "17.0.5" } }, "sha512-5zqq4Ad0Wmy8S9SMuNj+9mCmpgfdNgkFxnF7xJjLeWcjxtiTKOrh0wu+Xk7LyxrVO0W/NWale2B1MAvVyKzIMA=="], + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@17.1.3", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.1.3", "@oh-my-pi/pi-natives": "17.1.3", "@oh-my-pi/pi-utils": "17.1.3", "@oh-my-pi/pi-wire": "17.1.3" } }, "sha512-UPaG2rjv/yXUzwP6ZD1s/0wllUvI/iuJNKzeHhHwuCHSvvT0rNAQXpecz0/epO+5VswUorfbTlr7EzPJA8NWPA=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -343,6 +353,8 @@ "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -357,8 +369,6 @@ "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], @@ -451,6 +461,10 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], diff --git a/extensions/rules-guard/README.md b/extensions/rules-guard/README.md index 4892fb8..69ec586 100644 --- a/extensions/rules-guard/README.md +++ b/extensions/rules-guard/README.md @@ -65,8 +65,10 @@ Denied bash command patterns. `Bash(...)` rules are matched against each command of a shell-command field (`command`, `cmd`, `script`), so `rm -rf *` or `git push --force` is blocked in `bash` and any other tool that carries such a field. -Secret-shaped output redaction on `tool_result`, as defense in depth. Substrings that -look like credentials are replaced with `[REDACTED]`: +Secret-shaped text redaction, as defense in depth. It runs in two passes — `tool_result` +for tool output (patched at record time) and `context` for messages on their way to the +model (transient; your terminal and the saved transcript keep the real bytes). Substrings +that look like credentials are replaced with `[REDACTED]`: - Anthropic (`sk-ant-...`), OpenAI (`sk-...`, `pk-...`) - Stripe (`sk_live_...`, `rk_test_...`) @@ -82,6 +84,25 @@ look like credentials are replaced with `[REDACTED]`: Bare high-entropy strings, git SHAs, and UUIDs are deliberately not redacted, to keep false positives out of normal tool output. +### The `!` / `$` escape hatch + +User-initiated `!cmd` and `$code` run through `AgentSession.executeBash()` and the Python +kernel, which by design skip tool interception entirely — neither `tool_call` nor +`tool_result` fires. Deny rules therefore do **not** apply: `!cat .env` runs, and that is +intentional, since it is your own shell escape and you are not the threat model. What the +`context` pass guarantees is narrower: credential-shaped text in that command and its +output is redacted before the model sees it. + +Coverage is a typed carrier list, not a blind walk over the message tree — user, +developer, custom and tool-result text; `bashExecution` / `pythonExecution` command and +output; `fileMention.files[].content` (`@path` auto-reads); and branch/compaction +summaries. **Adding a message type means adding it to that list.** Two categories are +excluded because rewriting them breaks the provider rather than because they are safe: +signed blocks (assistant text bound to `textSignature`, thinking bound to +`thinkingSignature`) and opaque server-validated replay data (`providerPayload`, +`details`, `redactedThinking`). A secret the model itself echoed into its own reply or +reasoning is therefore not scrubbed. + When a call is blocked the model gets a specific reason instead of a silent failure. The reason names the matched rule, for example `Blocked by deny policy: "..." matches Read(**/.env*)`, and tells the model to ask the user for the file instead of reading, diff --git a/extensions/rules-guard/index.test.ts b/extensions/rules-guard/index.test.ts index 284b620..ac7e583 100644 --- a/extensions/rules-guard/index.test.ts +++ b/extensions/rules-guard/index.test.ts @@ -22,6 +22,7 @@ import rulesGuard, { type Policy, parseRule, pathTokens, + redactMessages, redactText, } from "./index"; @@ -610,13 +611,14 @@ describe("claudeFiles (default settings file locations)", () => { }); describe("rulesGuard wiring — registration & session_start", () => { - test("sets a label of the expected shape and registers three hooks", () => { + test("sets a label of the expected shape and registers four hooks", () => { const { pi, handlers, state } = makeMockPi(); rulesGuard(pi); expect(state.label).toMatch(/^RULES guard \(\d+ deny \/ \d+ allow\)$/); expect(handlers.has("session_start")).toBe(true); expect(handlers.has("tool_call")).toBe(true); expect(handlers.has("tool_result")).toBe(true); + expect(handlers.has("context")).toBe(true); }); test("session_start notifies only when a UI is present", async () => { const { pi, handlers } = makeMockPi(); @@ -753,3 +755,212 @@ describe("fuzzy — redaction idempotence & deny coverage", () => { } }); }); + +// ── context-pass redaction (the `!cat .env` bang hole) ──────────────────────── +// Bang output never reaches tool_call/tool_result: `AgentSession.executeBash()` +// skips tool interception by design. It lands as a `bashExecution` message with +// a top-level `output` string — no `content` array — so a content-only walk +// misses it entirely. These fixtures mirror the real message shapes. +const DSN = "postgres://u:p@h:5432/d"; + +describe("redactMessages — operator-originated carriers", () => { + test("redacts bashExecution output and command (the reported bug)", () => { + const msgs = [ + { + role: "bashExecution", + command: `psql ${DSN} -c 'select 1'`, + output: `SECRET=x\nPOSTGRES=${DSN}\n`, + exitCode: 0, + cancelled: false, + truncated: false, + timestamp: 1, + }, + ]; + const out = must(redactMessages(msgs)) as typeof msgs; + expect(out[0]?.output).toBe("SECRET=x\nPOSTGRES=[REDACTED]\n"); + expect(out[0]?.command).toBe("psql [REDACTED] -c 'select 1'"); + expect(out[0]?.output).not.toContain("postgres://"); + expect(out[0]?.command).not.toContain("postgres://"); + expect(msgs[0]?.output).toContain(DSN); // input untouched (copy-on-write) + expect(msgs[0]?.command).toContain(DSN); + }); + + test("redacts pythonExecution output/code and fileMention contents", () => { + const py = must( + redactMessages([ + { role: "pythonExecution", code: `u="${DSN}"`, output: DSN }, + ]), + ) as Array<{ code: string; output: string }>; + expect(py[0]?.output).toBe("[REDACTED]"); + expect(py[0]?.code).toBe('u="[REDACTED]"'); + + const fm = must( + redactMessages([ + { role: "fileMention", files: [{ path: ".env", content: DSN }] }, + ]), + ) as Array<{ files: Array<{ path: string; content: string }> }>; + expect(fm[0]?.files[0]?.content).toBe("[REDACTED]"); + expect(fm[0]?.files[0]?.path).toBe(".env"); + }); + + test("redacts user string content, block content, and toolResult text", () => { + const out = must( + redactMessages([ + { role: "user", content: `see ${DSN}` }, + { role: "developer", content: [{ type: "text", text: DSN }] }, + { + role: "toolResult", + content: [{ type: "image" }, { type: "text", text: DSN }], + }, + ]), + ) as Array<{ content: string | Array<{ text?: string }> }>; + expect(out[0]?.content).toBe("see [REDACTED]"); + const dev = must(out[1]).content as Array<{ text?: string }>; + expect(dev[0]?.text).toBe("[REDACTED]"); + const tr = must(out[2]).content as Array<{ type?: string; text?: string }>; + expect(tr[0]?.type).toBe("image"); // non-text block passed through + expect(tr[1]?.text).toBe("[REDACTED]"); + }); +}); + +describe("redactMessages — hookMessage content role", () => { + test("redacts hookMessage content", () => { + const out = must( + redactMessages([ + { + role: "hookMessage", + customType: "note", + content: [{ type: "text", text: DSN }], + }, + ]), + ) as Array<{ content: Array<{ text?: string }> }>; + expect(must(out[0]).content[0]?.text).toBe("[REDACTED]"); + }); +}); + +describe("redactMessages — compaction & branch summaries", () => { + test("redacts branch/compaction summaries including snapcompact blocks", () => { + const out = must( + redactMessages([ + { role: "branchSummary", summary: DSN }, + { + role: "compactionSummary", + summary: DSN, + shortSummary: DSN, + blocks: [{ type: "text", text: DSN }], + }, + ]), + ) as Array>; + expect(out[0]?.["summary"]).toBe("[REDACTED]"); + const cs = must(out[1]); + expect(cs["summary"]).toBe("[REDACTED]"); + expect(cs["shortSummary"]).toBe("[REDACTED]"); + const blocks = cs["blocks"] as Array<{ text?: string }>; + expect(blocks[0]?.text).toBe("[REDACTED]"); + }); +}); + +describe("redactMessages — replay-safety invariants", () => { + test("never touches assistant messages (signed text 400s on replay)", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: DSN, textSignature: "sig" }, + { type: "thinking", thinking: DSN, thinkingSignature: "sig" }, + ], + }, + ]; + expect(redactMessages(msgs)).toBeUndefined(); + }); + + test("skips signed text blocks even outside assistant messages", () => { + const msgs = [ + { + role: "user", + content: [{ type: "text", text: DSN, textSignature: "sig" }], + }, + ]; + expect(redactMessages(msgs)).toBeUndefined(); + }); +}); + +describe("redactMessages — opaque payloads are never walked", () => { + test("never descends into providerPayload or details", () => { + // The message MUST be rewritten (its text carries a secret) so this proves + // the opaque payloads survive a copy, not merely that nothing happened. + const details = { nested: DSN }; + const providerPayload = { items: [{ encrypted_content: DSN }] }; + const out = must( + redactMessages([ + { + role: "toolResult", + content: [{ type: "text", text: DSN }], + details, + providerPayload, + }, + ]), + ) as Array>; + const msg = must(out[0]); + expect((msg["content"] as Array<{ text?: string }>)[0]?.text).toBe( + "[REDACTED]", + ); + expect(msg["details"]).toBe(details); // same reference, never walked + expect(msg["providerPayload"]).toBe(providerPayload); + expect(details.nested).toBe(DSN); // and byte-identical inside + expect(providerPayload.items[0]?.encrypted_content).toBe(DSN); + }); +}); + +describe("redactMessages — sharing & malformed input", () => { + test("returns undefined when clean, and shares untouched messages", () => { + const clean = { role: "user", content: "nothing here" }; + const dirty = { role: "bashExecution", output: DSN }; + expect(redactMessages([clean])).toBeUndefined(); + const out = must(redactMessages([clean, dirty])); + expect(out[0]).toBe(clean); // identity preserved — no needless allocation + expect(out[1]).not.toBe(dirty); + }); + + test("tolerates malformed / unknown messages without throwing", () => { + expect(() => + redactMessages([ + null, + 42, + {}, + { role: 7 }, + { role: "user" }, + { role: "user", content: 5 }, + { role: "fileMention", files: "nope" }, + { role: "fileMention", files: [null, { content: 1 }] }, + { role: "bashExecution" }, + { role: "brandNewFutureType", output: DSN }, + // Inherited `Object.prototype` member: not nullish, not iterable. + { role: "toString", content: DSN }, + ]), + ).not.toThrow(); + // ...and stays inert rather than counting as a content role. + expect( + redactMessages([{ role: "toString", content: DSN }]), + ).toBeUndefined(); + }); +}); + +describe("rulesGuard wiring — context", () => { + test("handler redacts a bashExecution message; passes clean history", async () => { + const { pi, handlers } = makeMockPi(); + rulesGuard(pi); + const onContext = must(handlers.get("context")); + const hit = (await onContext( + { + messages: [{ role: "bashExecution", command: "cat .env", output: DSN }], + }, + {}, + )) as { messages?: Array<{ output?: string }> } | undefined; + expect(hit?.messages?.[0]?.output).toBe("[REDACTED]"); + expect( + await onContext({ messages: [{ role: "user", content: "hi" }] }, {}), + ).toBeUndefined(); + expect(await onContext({ messages: "nope" }, {})).toBeUndefined(); + }); +}); diff --git a/extensions/rules-guard/index.ts b/extensions/rules-guard/index.ts index c84c81a..82ac865 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -5,8 +5,28 @@ * omp's `bashInterceptor` only inspects the `bash` tool, so RULES.md is trivially * bypassed by calling `read`/`write`/`edit`/`find`/`search`/`eval` directly. This * `tool_call` guard runs before EVERY tool, fail-closes on any attempt to touch a - * denied path, and enforces the denied bash command patterns too. A `tool_result` - * pass redacts secret-shaped output as defense in depth. + * denied path, and enforces the denied bash command patterns too. Two output + * passes redact secret-shaped text as defense in depth: `tool_result` (tool + * output, at record time) and `context` (messages on their way to the LLM). + * + * Why the `context` pass exists: + * the user execution surfaces bypass tool interception by design — `!cmd` runs + * through `AgentSession.executeBash()` and `$code` through the Python kernel, + * and neither fires `tool_call`/`tool_result`. So `!cat .env` put raw secrets + * straight into the model's context. That output is not a `content` array + * either; it lands as `{ role: "bashExecution", output }`. Blocking these + * surfaces is deliberately NOT attempted: they are the user's own shell escape. + * The pass redacts a TYPED carrier list — see `REDACT_CONTENT_ROLES` / + * `REDACT_STRING_FIELDS` — covering operator-originated bytes: + * user/developer/custom/hookMessage/tool-result text, + * `bashExecution`/`pythonExecution` command+output, + * `fileMention.files[].content` (`@path` auto-reads), and branch/compaction + * summaries. Two categories are excluded because rewriting them BREAKS the + * provider, not because they are safe: signed blocks (`assistant` text bound to + * `textSignature`, thinking bound to `thinkingSignature`) and opaque + * server-validated blobs (`providerPayload`, `details`, `redactedThinking`). + * The list is typed, so ADDING A MESSAGE TYPE REQUIRES ADDING IT HERE — a new + * carrier is not covered automatically. * * Policy source (read at load from ALL Claude settings files): * - ~/.claude/settings.json → permissions.deny + permissions.allow @@ -582,6 +602,157 @@ export function redactText(text: string): string { return out; } +/** + * Roles whose `content` is `string | (TextContent | ImageContent)[]`. + * + * `assistant` is absent BY DESIGN, not by omission: a `text` block carrying a + * `textSignature` is bound to its exact bytes, and rewriting the text while + * keeping the signature makes the provider 400 the replay (see + * `session-persistence.ts` — "never truncate, externalize, or descend"). Model + * output is also not what this pass defends against; it targets operator- + * originated bytes (files, command output, mentions). + * + * Null-prototype: `role` is untrusted, and on a plain object literal an + * `Object.prototype` key (`toString`, `constructor`, …) resolves to an + * inherited member, which reads as a table hit. + */ +const REDACT_CONTENT_ROLES: Record = Object.assign( + Object.create(null), + { + user: true, + developer: true, + toolResult: true, + custom: true, + hookMessage: true, + }, +); + +/** + * Per-role plain-string fields to redact. All unsigned, all operator-facing. + * Null-prototype for the same reason as above, and more sharply: an inherited + * member is not nullish, so `?? []` cannot save the `for...of` from throwing. + */ +const REDACT_STRING_FIELDS: Record = Object.assign( + Object.create(null), + { + // `command`/`code` are redacted too: `!curl -H "Authorization: Bearer …"` is a + // real carrier, and neither field is signed. + bashExecution: ["command", "output"], + pythonExecution: ["code", "output"], + branchSummary: ["summary"], + compactionSummary: ["summary", "shortSummary"], + }, +); + +/** + * Copy-on-write redaction of operator-originated text in a message list. + * Returns a NEW array when anything changed, else `undefined` (the common path). + * + * Copy-on-write, not in-place: `ExtensionRunner.emitContext` deep-clones only on + * a best-effort basis and falls back to `[...messages]` when `structuredClone` + * throws, so in-place writes would corrupt the live session's persisted objects. + * Unchanged nodes are shared by reference — a clean history allocates nothing. + * + * Typed per field rather than a blind deep walk: opaque provider-replay blobs + * (`providerPayload`, `redactedThinking`) and untyped extension payloads + * (`details`) are server-validated atomic data and must never be descended into. + * Exported for tests. + */ +export function redactMessages(messages: readonly T[]): T[] | undefined { + let copy: T[] | undefined; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] as T; + const next = redactMessage(msg) as T; + if (next === msg) continue; + copy ??= [...messages]; + copy[i] = next; + } + return copy; +} + +/** Redact one message; returns the original reference when nothing changed. */ +function redactMessage(msg: unknown): unknown { + if (msg === null || typeof msg !== "object") return msg; + const m = msg as Record; + const role = m["role"]; + if (typeof role !== "string" || role === "assistant") return msg; + + let out: Record | undefined; + const set = (key: string, value: unknown): void => { + out ??= { ...m }; + out[key] = value; + }; + + if (REDACT_CONTENT_ROLES[role]) { + const content = m["content"]; + const next = + typeof content === "string" + ? redactText(content) + : Array.isArray(content) + ? redactBlocks(content) + : content; + if (next !== content) set("content", next); + } + + for (const field of REDACT_STRING_FIELDS[role] ?? []) { + const v = m[field]; + if (typeof v !== "string") continue; + const next = redactText(v); + if (next !== v) set(field, next); + } + + if (role === "compactionSummary") { + const blocks = m["blocks"]; + if (Array.isArray(blocks)) { + const next = redactBlocks(blocks); + if (next !== blocks) set("blocks", next); + } + } + + if (role === "fileMention") { + const files = m["files"]; + if (Array.isArray(files)) { + let copy: unknown[] | undefined; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file === null || typeof file !== "object") continue; + const rec = file as Record; + const content = rec["content"]; + if (typeof content !== "string") continue; + const next = redactText(content); + if (next === content) continue; + copy ??= [...files]; + copy[i] = { ...rec, content: next }; + } + if (copy) set("files", copy); + } + } + + return out ?? msg; +} + +/** + * Redact `text` blocks in a content array, sharing untouched entries. Signed + * blocks are skipped: the signature is bound to the exact bytes. + */ +function redactBlocks(blocks: readonly unknown[]): readonly unknown[] { + let copy: unknown[] | undefined; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (block === null || typeof block !== "object") continue; + const rec = block as Record; + if (rec["type"] !== "text" || typeof rec["textSignature"] === "string") + continue; + const text = rec["text"]; + if (typeof text !== "string") continue; + const next = redactText(text); + if (next === text) continue; + copy ??= [...blocks]; + copy[i] = { ...rec, text: next }; + } + return copy ?? blocks; +} + // ── Compiled policy (read once at load) ──────────────────────────────────────── const LOADED = loadPolicyEntries(); @@ -625,4 +796,14 @@ export default function rulesGuard(pi: ExtensionAPI): void { }); if (changed) return { content }; }); + + // Last line before the model: redact the typed carrier list (see + // `redactMessages`) — notably `!`/`$` output, which reaches context without + // ever passing through `tool_call`/`tool_result`. Signed and opaque replay + // data is excluded. Transient: the on-disk transcript keeps the real bytes. + pi.on("context", async (event) => { + if (!Array.isArray(event.messages)) return; + const messages = redactMessages(event.messages); + if (messages) return { messages }; + }); } diff --git a/package.json b/package.json index 1ac8a65..d275eb8 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@biomejs/biome": "^2.5.4", - "@oh-my-pi/pi-coding-agent": "^17.0.5", + "@biomejs/biome": "^2.5.5", + "@oh-my-pi/pi-coding-agent": "^17.1.3", "@types/bun": "1.3.14", "@types/node": "^24.13.3", "sort-package-json": "^4.0.0", @@ -24,7 +24,7 @@ "omp": { "features": { "rules-guard": { - "description": "Enforce the Claude permissions allow/deny policy across all omp tools (read/write/edit/find/search/bash/eval/browser).", + "description": "Enforce the Claude permissions allow/deny policy across all omp tools (read/write/edit/find/search/bash/eval/browser), and redact secret-shaped text from what the model sees, including output from ! and $ commands.", "default": true, "extensions": [ "./extensions/rules-guard/index.ts"