From 74c5ea91884a09e7e59720fda986e7d6970c9f7c Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Sun, 26 Jul 2026 12:34:24 +0200 Subject: [PATCH 1/3] fix(rules-guard): Redact secrets in bang output The guard only redacted tool output, through the `tool_result` hook. User initiated `!cmd` and `$code` never reach that hook: they run via `AgentSession.executeBash()` and the Python kernel, which skip tool interception by design. A `!cat .env` therefore put raw credentials straight into the model context, with nothing to scrub them. Add a `context` hook that redacts secret shaped text from messages on their way to the model. Coverage is a typed carrier list rather than a blind tree walk, because two categories must pass through byte for byte or the provider rejects the replay: - signed blocks, where assistant text is bound to `textSignature` and thinking to `thinkingSignature` - opaque server validated data, namely `providerPayload`, `details` and `redactedThinking` The pass is copy on write. `ExtensionRunner.emitContext` deep clones only on a best effort basis and falls back to a shallow array copy when `structuredClone` throws, so mutating in place would corrupt the live session objects. Unchanged messages are shared by reference, and a clean history allocates nothing. Deny rules still do not apply to `!` and `$`. That is deliberate: the shell escape belongs to the user, who is not the threat model. Only delivery to the model is filtered; the terminal and the saved transcript keep the real bytes. A secret the model has already echoed into its own reply is not scrubbed either, since assistant text is excluded. Also bump `@oh-my-pi/pi-coding-agent` to ^17.1.3 and `@biomejs/biome` to ^2.5.5. The Machine God guards what the flesh would spill --- README.md | 8 +- biome.json | 2 +- bun.lock | 74 +++++++----- extensions/rules-guard/README.md | 25 +++- extensions/rules-guard/index.test.ts | 172 ++++++++++++++++++++++++++- extensions/rules-guard/index.ts | 170 +++++++++++++++++++++++++- package.json | 6 +- 7 files changed, 414 insertions(+), 43 deletions(-) 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..488f001 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,171 @@ 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: "cat .env", + 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]?.output).not.toContain("postgres://"); + expect(msgs[0]?.output).toContain(DSN); // input untouched (copy-on-write) + }); + + 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 — 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(); + }); + + test("never descends into providerPayload or details", () => { + const msgs = [ + { + role: "toolResult", + content: [{ type: "text", text: "clean" }], + details: { nested: DSN }, + providerPayload: { items: [{ encrypted_content: DSN }] }, + }, + ]; + expect(redactMessages(msgs)).toBeUndefined(); + }); + + 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 }, + ]), + ).not.toThrow(); + }); +}); + +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..7fe3737 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -5,8 +5,27 @@ * 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/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 +601,143 @@ 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). + */ +const REDACT_CONTENT_ROLES: Record = { + user: true, + developer: true, + toolResult: true, + custom: true, + hookMessage: true, +}; + +/** Per-role plain-string fields to redact. All unsigned, all operator-facing. */ +const REDACT_STRING_FIELDS: Record = { + // `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 +781,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" From a9035bcac30143dbab7bbbd9648457e7119c11ce Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Sun, 26 Jul 2026 12:51:54 +0200 Subject: [PATCH 2/3] fix(rules-guard): Harden role lookups against inherited keys The redaction carrier tables were plain object literals indexed by an untrusted `role`. An `Object.prototype` key resolved to an inherited member instead of missing: `REDACT_CONTENT_ROLES["toString"]` read as a table hit, and `REDACT_STRING_FIELDS["toString"]` returned a function, which is not nullish, so `?? []` could not save it and `for...of` threw a `TypeError` inside the `context` handler. Give both tables a null prototype so an inherited key cannot resolve. The lookups themselves are unchanged. Also record `hookMessage` in the carrier list documented at the top of the file. It was already present in `REDACT_CONTENT_ROLES` and redacted correctly; only the prose omitted it. Tests: a `toString` role in the malformed message fixture, asserting both that it does not throw and that it stays inert, plus focused coverage that `hookMessage` content is redacted. From the weakness of the mind, Omnissiah save us --- extensions/rules-guard/index.test.ts | 21 ++++++++++++ extensions/rules-guard/index.ts | 51 ++++++++++++++++++---------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/extensions/rules-guard/index.test.ts b/extensions/rules-guard/index.test.ts index 488f001..edcdbd0 100644 --- a/extensions/rules-guard/index.test.ts +++ b/extensions/rules-guard/index.test.ts @@ -820,6 +820,21 @@ describe("redactMessages — operator-originated carriers", () => { }); }); +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( @@ -900,8 +915,14 @@ describe("redactMessages — replay-safety invariants", () => { { 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(); }); }); diff --git a/extensions/rules-guard/index.ts b/extensions/rules-guard/index.ts index 7fe3737..82ac865 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -17,8 +17,9 @@ * 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/tool-result text, `bashExecution`/`pythonExecution` command+output, + * `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 @@ -610,24 +611,38 @@ export function redactText(text: string): string { * `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 = { - user: true, - developer: true, - toolResult: true, - custom: true, - hookMessage: true, -}; +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. */ -const REDACT_STRING_FIELDS: Record = { - // `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"], -}; +/** + * 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. From 8aeb8f4efdbc1c9d3b01c1fc711db410e750dd90 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Sun, 26 Jul 2026 13:03:27 +0200 Subject: [PATCH 3/3] test(rules-guard): Assert command and opaque-payload contracts Two gaps found by review, both in coverage rather than behaviour. The `bashExecution` fixture ran a command with no secret in it, so `command` redaction executed but nothing asserted the result. Put a credential URL in the command and assert it is redacted, and that the caller's copy keeps the original bytes. The opaque-payload test asserted only that a clean message returned `undefined`, which would still pass if `details` and `providerPayload` were rebuilt rather than shared. Make the message redactable through its text so it is genuinely rewritten, then assert both payloads come back as the same object references with their contents byte-identical. Split two describes that crossed the per-function line cap. No source change. The Machine God sees what the test did not --- extensions/rules-guard/index.test.ts | 40 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/extensions/rules-guard/index.test.ts b/extensions/rules-guard/index.test.ts index edcdbd0..ac7e583 100644 --- a/extensions/rules-guard/index.test.ts +++ b/extensions/rules-guard/index.test.ts @@ -768,7 +768,7 @@ describe("redactMessages — operator-originated carriers", () => { const msgs = [ { role: "bashExecution", - command: "cat .env", + command: `psql ${DSN} -c 'select 1'`, output: `SECRET=x\nPOSTGRES=${DSN}\n`, exitCode: 0, cancelled: false, @@ -778,8 +778,11 @@ describe("redactMessages — operator-originated carriers", () => { ]; 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", () => { @@ -880,19 +883,36 @@ describe("redactMessages — replay-safety invariants", () => { ]; expect(redactMessages(msgs)).toBeUndefined(); }); +}); +describe("redactMessages — opaque payloads are never walked", () => { test("never descends into providerPayload or details", () => { - const msgs = [ - { - role: "toolResult", - content: [{ type: "text", text: "clean" }], - details: { nested: DSN }, - providerPayload: { items: [{ encrypted_content: DSN }] }, - }, - ]; - expect(redactMessages(msgs)).toBeUndefined(); + // 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 };