From a19df5c2826fa5c9ea5aee360f760ba97b71b355 Mon Sep 17 00:00:00 2001 From: mertcan Date: Sun, 20 Sep 2026 12:31:46 +0300 Subject: [PATCH 1/2] VS Code support, and the Claude install that was already running there The submission checks read plugin.json, .plugin/plugin.json or .github/plugin/plugin.json, and this repository carried only the Claude and Codex manifests. Add the Agent Plugins v1 manifest at the plugin root, where github/awesome-copilot resolves it from the listing's source.path, with hooks in com.github.copilot/hooks/hooks.json. What VS Code grants was measured against 1.137.0 with Copilot Chat 0.65.0 rather than read out of its documentation, and it is narrower than the documentation suggests. A prompt can be stopped before it is sent and a tool call denied before it runs. After a tool, nothing can be withheld: a probe answering decision: "block" on a terminal result was followed by the model quoting the secret out of that result, and continue: false did not stop the turn either. A read_file result never reaches the hook at all, arriving as an empty tool_response, so a file read is covered by its path before the read. So Adapter carries a third field, power, and every adapter states which of rewrite, refuse or report-only its client grants at that event. _decide caps the action at it: a mask becomes a refusal where only refusal exists, and a refusal becomes a report where the model already holds the data. The session record follows, saying found rather than blocked, because reporting a block the model read through is worse than reporting the finding plainly. The same run uncovered an older fault. VS Code reads ~/.claude/settings.json by default, so shim install claude has been registering shim in two clients at once, and in VS Code it spoke Claude's protocol: decision: "block" is a field VS Code ignores. Anyone running user-prompt = "enforce" who used VS Code had prompts sent that shim reported as blocked. The two are now told apart by what each always sends, permission_mode against timestamp, and answered in their own protocol. The environment cannot be used for this: the VS Code capture carried CLAUDE_CODE_* variables, inherited from the terminal that started it. A hook asked for a client this build does not know now inspects nothing and says so on stderr, rather than answering with another client's refusal; an older package meeting this newer plugin would otherwise have blocked every VS Code prompt with a message naming Codex. Copilot CLI and the Copilot app read the same hook file, where shim install copilot is the supported route, so the command stands down on COPILOT_CLI. Live: the shipped plugin in VS Code 1.137.0 (prompt, terminal result and session summary), Claude Code 2.1.273 masking a .env through the committed archive, Copilot CLI 1.0.85 standing down, and plugin detection unchanged on Claude Code and Codex 0.151.0. Captures are fixtures under tests/fixtures/probe/vscode/. 2,032 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 38 ++++ docs/compatibility.md | 64 +++++- docs/releases/1.0.1.md | 81 +++++++ plugins/shim-cli/.claude-plugin/plugin.json | 2 +- plugins/shim-cli/.codex-plugin/plugin.json | 2 +- plugins/shim-cli/README.md | 13 +- plugins/shim-cli/bin/shim.pyz | Bin 397609 -> 402602 bytes .../com.github.copilot/hooks/hooks.json | 32 +++ plugins/shim-cli/plugin.json | 17 ++ pyproject.toml | 2 +- scripts/build_zipapp.py | 3 + src/shim_cli/__init__.py | 2 +- src/shim_cli/clients/claude/tool_events.py | 4 +- src/shim_cli/clients/user_prompt_hook.py | 25 ++- src/shim_cli/clients/vscode/__init__.py | 0 src/shim_cli/clients/vscode/hook.py | 51 +++++ src/shim_cli/clients/vscode/tool_events.py | 142 +++++++++++++ src/shim_cli/events/pipeline.py | 38 +++- src/shim_cli/hook.py | 83 +++++++- src/shim_cli/policy.py | 10 + .../test_claude_install_under_vscode.py | 121 +++++++++++ tests/clients/vscode/test_vscode_codec.py | 199 ++++++++++++++++++ tests/contracts/test_import_hygiene.py | 1 + tests/contracts/test_naming.py | 5 + .../contracts/vscode/test_vscode_protocol.py | 113 ++++++++++ .../probe/vscode/PostToolUse-read_file-1.json | 15 ++ .../vscode/PostToolUse-run_in_terminal-1.json | 17 ++ .../probe/vscode/PreToolUse-read_file-1.json | 14 ++ .../vscode/PreToolUse-run_in_terminal-1.json | 16 ++ .../probe/vscode/SessionStart-none-1.json | 9 + tests/fixtures/probe/vscode/Stop-none-1.json | 8 + .../probe/vscode/UserPromptSubmit-none-1.json | 8 + tests/plugins/test_agent_plugin.py | 175 +++++++++++++++ uv.lock | 2 +- 34 files changed, 1284 insertions(+), 28 deletions(-) create mode 100644 docs/releases/1.0.1.md create mode 100644 plugins/shim-cli/com.github.copilot/hooks/hooks.json create mode 100644 plugins/shim-cli/plugin.json create mode 100644 src/shim_cli/clients/vscode/__init__.py create mode 100644 src/shim_cli/clients/vscode/hook.py create mode 100644 src/shim_cli/clients/vscode/tool_events.py create mode 100644 tests/clients/vscode/test_claude_install_under_vscode.py create mode 100644 tests/clients/vscode/test_vscode_codec.py create mode 100644 tests/contracts/vscode/test_vscode_protocol.py create mode 100644 tests/fixtures/probe/vscode/PostToolUse-read_file-1.json create mode 100644 tests/fixtures/probe/vscode/PostToolUse-run_in_terminal-1.json create mode 100644 tests/fixtures/probe/vscode/PreToolUse-read_file-1.json create mode 100644 tests/fixtures/probe/vscode/PreToolUse-run_in_terminal-1.json create mode 100644 tests/fixtures/probe/vscode/SessionStart-none-1.json create mode 100644 tests/fixtures/probe/vscode/Stop-none-1.json create mode 100644 tests/fixtures/probe/vscode/UserPromptSubmit-none-1.json create mode 100644 tests/plugins/test_agent_plugin.py diff --git a/README.md b/README.md index f818c36..7c6aabf 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ removes GitHub authentication altogether, so there is nothing to watch. | [Claude Code](https://github.com/anthropics/claude-code) | Reports what it found and lets it through; blocks under `enforce` | Eligible structured arguments and inbound results are masked; commands and local writes are report-or-deny only | | [Codex CLI](https://github.com/openai/codex) | Reports what it found and lets it through; blocks under `enforce` | Not installed — no verified native tool-event adapter | | [GitHub Copilot CLI](https://github.com/github/copilot-cli) | Replaces the model-facing prompt with the redacted text | Not installed — no verified native tool-event adapter | +| [VS Code](https://code.visualstudio.com/docs/agent-customization/agent-plugins) | Reports what it found and lets it through; stops the prompt before it is sent under `enforce` | A call is reported, and denied under `enforce`, before it runs. A **result** can only be reported: by then the model has it, and nothing takes it back. Never masked | Tool coverage is verified against a running client, not derived from documentation. `shim doctor ` prints exactly which events are installed @@ -181,6 +182,43 @@ prompts reach the model uninspected. Open `/hooks` in Codex, review the shim entry, and enable it. `shim doctor codex` ends by reminding you, because that record lives in Codex and shim cannot read it. +### VS Code + +VS Code is reached through the plugin only; there is no `shim install vscode`. +Point VS Code at the plugin folder, or install it from a plugin marketplace +that carries this repository: + +```json +"chat.pluginLocations": { "/path/to/shim-cli/plugins/shim-cli": true } +``` + +**In VS Code shim reports, and refuses only where refusing works.** This was +measured against VS Code 1.137.0 rather than read out of its documentation: + +| Moment | What shim can do | +| --- | --- | +| Your prompt | Report. Under `enforce` the prompt is stopped before it is sent. | +| Before a tool runs | Report. Under `enforce` the call is denied and never runs. | +| After a tool has run | **Report only.** A `block` there was read straight through by the model, and so was `continue: false`. | + +Nothing is ever masked: no VS Code hook output replaces a prompt, a tool input +or a tool result. And a `read_file` result reaches the hook as an empty +`tool_response`, so a file read is protected by its path before the read, not +by its contents afterwards. A terminal result does arrive in full and is +inspected. + +Because a refusal is the only enforcement available, tool events report by +default there, and refusing waits until you ask for it: + +```toml +[mode] +inbound = "enforce" +``` + +The same plugin file is read by GitHub Copilot CLI and the Copilot app, where +`shim install copilot` is the supported route. The hook stands down in those +clients so nothing is inspected twice. + ### Marketplace plugins Codex and Claude Code users can install the repository's marketplace plugin: diff --git a/docs/compatibility.md b/docs/compatibility.md index 41a4488..64006af 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -6,8 +6,8 @@ | --- | --- | | Python | CPython 3.10 through 3.13 for the package; the plugin archive runs on 3.9 through 3.13 | | Operating systems | macOS and Linux target | -| Prompt hooks | Codex CLI, Claude Code, and GitHub Copilot CLI | -| Tool hooks | Claude Code `PreToolUse` and `PostToolUse` only | +| Prompt hooks | Codex CLI, Claude Code, GitHub Copilot CLI, and VS Code | +| Tool hooks | Claude Code `PreToolUse` and `PostToolUse`, masked; VS Code `PreToolUse` reports and denies, `PostToolUse` reports only | | `shim watch` | Claude Code only. Codex is refused: it reads its endpoint from its own configuration, so the proxy is bypassed and the session measured as empty ([probe](probe-2026-09-codex-watch.md)). Copilot out of scope because a custom endpoint removes GitHub authentication | ## Install @@ -72,6 +72,53 @@ arguments and native structured tool responses. Copilot uses `userPromptTransformed` to replace the model-facing prompt; the original can remain visible in its timeline. +**VS Code refuses before a tool and reports after it.** Measured on 20 +September 2026 against VS Code 1.137.0 with Copilot Chat 0.65.0, through a +capture-only hook. Captures: `tests/fixtures/probe/vscode/`. + +| Event | Tried | Result | +| --- | --- | --- | +| `UserPromptSubmit` | `continue: false` | **Stops it.** "A hook prevented chat from continuing", with the hook's reason, and no answer. | +| `PreToolUse` | `permissionDecision: "deny"` | **Denies it.** The command never ran and no `PostToolUse` followed. | +| `PostToolUse` | `decision: "block"` | **Does nothing to the result.** The model quoted the secret out of the blocked terminal output. | +| `PostToolUse` | `continue: false` | **Does not stop the turn.** The model answered from the result anyway. | + +So `events/pipeline.py` carries a third field on `Adapter`, `power`, and every +adapter states which of `rewrite`, `refuse` or `report-only` its client grants +at that event. `_decide` caps the action at it: a mask becomes a refusal where +only refusal exists, and a refusal becomes a report where the model already +holds the data. The session record then says `found` rather than `blocked`, +because reporting a block that the model read through would be worse than +saying nothing. + +Two further limits from the same run. A `read_file` result arrives as +`tool_response: ""`, so a file read can only be inspected by its path at +`PreToolUse`; the transcript records `success: true` and no content either. A +`run_in_terminal` result does arrive in full. Tool names are VS Code's own +(`read_file`, `run_in_terminal`) and inputs are camelCase (`filePath`), so a +Claude-shaped adapter would not match them. + +The shipped default for tool traffic is `enforce`, chosen when masking was +free. In VS Code that would mean denying calls nobody asked to have denied, so +a mode that was never written down reports instead; `[mode] outbound = +"enforce"` turns denial on. + +**The Agent Plugins manifest is what the submission checks read.** `plugin.json` +at the plugin root declares the `agent-plugins.org` v1 schema, whose fields are +closed: `displayName` and a `hooks` path, which the Claude manifest carries, +fail the gate. github/awesome-copilot resolves the manifest relative to the +listing's `source.path`, which is why the file sits in `plugins/shim-cli/` +rather than at the repository root, and it accepts only a tag or a full commit +SHA as `source.ref`. + +**The same file reaches three clients.** VS Code, GitHub Copilot CLI and the +Copilot app all read `com.github.copilot/hooks/hooks.json`. Copilot CLI has its +own hook route through `shim install copilot`, and its `postToolUse` can replace +a result with `modifiedResult` where VS Code cannot, so the plugin command +stands down there rather than inspecting every prompt twice. Copilot CLI 1.0.85 +sets `COPILOT_CLI=1` on every hook process, which is the guard; `VSCODE_PID` is +not usable for this, because it is also set in any terminal inside VS Code. + **The plugin ships two hook files, and Claude Code reads both.** `plugin.json` declares `hooks/claude.json`, but Claude Code 2.1.263 also loads `hooks/hooks.json` by convention — the file Codex finds the same way, because @@ -97,6 +144,19 @@ cannot read that record and does not write it; a diagnosis that claimed to would be guessing. `codex exec --dangerously-bypass-hook-trust` runs enabled hooks without it, which is useful to confirm an install and wrong as a habit. +## 1.0.1 release evidence + +Recorded 20 September 2026 on macOS 26.4 arm64, CPython 3.13.5, uv 0.12.5. + +| Evidence | Recorded result | +| --- | --- | +| Local gate | `python scripts/check.py` green: 2,032 tests, lint, format, types, build. | +| VS Code | tested: 1.137.0, Copilot Chat 0.65.0. Hook protocol captured from a running client through a capture-only hook; fixtures in `tests/fixtures/probe/vscode/`. `continue: false` on `UserPromptSubmit` stopped the turn (`A hook prevented chat from continuing`, with the hook's reason, and no answer). `permissionDecision: "deny"` on `PreToolUse` refused `cat blocked.txt`; no `PostToolUse` followed. `decision: "block"` on a `PostToolUse` terminal result did **not** withhold it: the model answered with the secret from the blocked output. `continue: false` at the same event did not stop the turn either. `read_file` results arrive as `tool_response: ""`; `run_in_terminal` results arrive in full. Then the shipped plugin itself, through `hooks/run-shim vscode` and the committed archive: a prompt carrying a synthetic address answered `{"systemMessage":"shim: found EMAIL (1) in your prompt. Not modified."}` in 483 ms; `cat secrets.env` through the terminal tool answered `shim: found DB_URI (1), EMAIL (1), SECRET (1) in run_in_terminal. Not modified.` with `additionalContext` telling the model not to repeat the values, and the model described the file without quoting one; `Stop` rendered `shim — this session / warned 2 EMAIL (run_in_terminal, your prompt) / 1 DB_URI / 1 SECRET / overhead 168 ms median, 218 ms p95`. The archive path costs more than the package: 483 ms on the first event, 168 ms median after. | +| GitHub Copilot CLI | tested: 1.0.83, run on 1.0.85. The plugin loaded with `--plugin-dir` and the `com.github.copilot` hook stood down: a prompt carrying a synthetic AWS key produced no shim line and no error. `COPILOT_CLI=1` is set on every hook process, which is what the guard keys on; `VSCODE_PID` is not usable, because a terminal inside VS Code sets it too. | +| Claude Code | tested: 2.1.263, detection re-checked on 2.1.273. The new root `plugin.json` does not disturb Claude Code's own manifest: `claude plugin install shim-cli@shim-cli` into an isolated `CLAUDE_CONFIG_DIR` reported the plugin enabled at its version. Live on this build through the committed archive, with the hook registered in a scratch workspace's own `.claude/settings.json` rather than in any real user file: a `Read` of a synthetic `.env` reached the model as `AWS_ACCESS_KEY_ID=` and `SUPPORT_EMAIL=`, and the model answered that a masking layer had replaced the values and it would not route around it. The same fixture masks identically through the package and the archive. | +| Codex CLI | tested: 0.151.0. `codex plugin add shim-cli@shim-cli` into a separate `CODEX_HOME` listed the plugin installed and enabled from `plugins/shim-cli`; the root manifest did not change its detection. No live prompt was run for this build. | +| A Claude install under VS Code | `~/.claude/settings.json` is read by VS Code by default. With `user-prompt = "enforce"`, the hook now answers a VS Code payload with `continue: false` and a Claude payload with `decision: "block"` plus `suppressOriginalPrompt`, from the same installed hook line. Covered by `tests/clients/vscode/test_claude_install_under_vscode.py`. | + ## 1.0.0 release evidence Recorded 15 September 2026 on macOS 26.4 arm64, CPython 3.13.5, uv 0.12.5, on the diff --git a/docs/releases/1.0.1.md b/docs/releases/1.0.1.md new file mode 100644 index 0000000..53233af --- /dev/null +++ b/docs/releases/1.0.1.md @@ -0,0 +1,81 @@ +# shim-cli 1.0.1 + +VS Code is a supported client, through the plugin. The same release fixes a +worse problem it uncovered: a Claude Code install has been running inside VS +Code all along, and its prompt block was being ignored there. + +Upgrading is `uv tool upgrade shim`, or `/plugin update shim-cli@shim-cli` in +Claude Code. There is nothing to migrate. + +## If you use VS Code + +Point VS Code at the plugin folder: + +```json +"chat.pluginLocations": { "/path/to/shim-cli/plugins/shim-cli": true } +``` + +There is no `shim install vscode`; the plugin is the whole route. What you get, +measured against VS Code 1.137.0 with Copilot Chat 0.65.0 rather than read out +of its documentation: + +| Moment | What shim does | +| --- | --- | +| Your prompt | Reports the finding. Under `enforce`, the prompt is stopped before it is sent, and VS Code shows the reason. | +| Before a tool runs | Reports. Under `enforce`, the call is denied and never runs. | +| After a tool has run | **Reports only.** | + +**Nothing is masked in VS Code**, and after a tool nothing is blocked either. +Both were measured. A hook answering `decision: "block"` on a terminal result +was followed by the model quoting the secret out of that result, and +`continue: false` at the same event did not stop the turn: by then the model +has been handed the output. Where Claude Code silently replaces a value with +``, VS Code tells you and leaves it. + +One consequence worth knowing: VS Code sends a `read_file` result to hooks as +an empty `tool_response`, so shim cannot see what a file read returned. A file +read is covered by its path before the read. A terminal result does arrive in +full and is inspected. + +Because a refusal is the only enforcement available, tool events report by +default there. Ask for the refusal when you want it: + +```toml +[mode] +outbound = "enforce" +``` + +## If you use Claude Code, read this even if you never open VS Code + +VS Code loads hooks from `~/.claude/settings.json` by default. So `shim install +claude` has been registering shim in two clients at once, and in VS Code it +spoke Claude's protocol: the prompt block, `decision: "block"`, is a field VS +Code ignores. Anyone running `user-prompt = "enforce"` who used VS Code had +prompts sent that shim reported as blocked. + +1.0.1 tells the two apart by what each always sends — every Claude Code event +carries `permission_mode` and never `timestamp`, and every VS Code event is the +reverse — and answers each in its own protocol. A VS Code prompt is now stopped +with `continue: false`, which VS Code obeys. Nothing changes for Claude Code +itself. + +The environment cannot be used for this and is not: the VS Code capture carried +`CLAUDE_CODE_*` variables, because the editor inherited them from the terminal +that started it. + +## Also in this release + +- The shared event pipeline carries what each client grants at each event — + `rewrite`, `refuse` or `report-only` — and caps the action at it. A mask + becomes a refusal where only refusal exists, and a refusal becomes a report + where the model already holds the data. The session summary follows: it says + `found`, not `blocked`, when nothing was withheld. +- A hook asked for a client this build does not know now inspects nothing and + says so on stderr, instead of answering with another client's refusal. An + older package meeting a newer plugin used to block every VS Code prompt with + a message naming Codex. +- The plugin carries an Agent Plugins v1 manifest, `plugins/shim-cli/plugin.json`, + alongside the Claude and Codex ones. Its hooks live in + `com.github.copilot/hooks/hooks.json`, which GitHub Copilot CLI and the + Copilot app read as well; there the command stands down, because `shim + install copilot` is their route and inspecting a prompt twice helps nobody. diff --git a/plugins/shim-cli/.claude-plugin/plugin.json b/plugins/shim-cli/.claude-plugin/plugin.json index ce01b26..5fb17fd 100644 --- a/plugins/shim-cli/.claude-plugin/plugin.json +++ b/plugins/shim-cli/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "shim-cli", "displayName": "shim-cli", - "version": "1.0.0", + "version": "1.0.1", "description": "Masks secrets and personal data in the tool results Claude Code reads, before the model sees them; reports what was in your prompts; and, with `shim watch`, shows what a session sent and what it cost. Runs locally with no account, network destination or telemetry.", "author": { "name": "shim Engineering" diff --git a/plugins/shim-cli/.codex-plugin/plugin.json b/plugins/shim-cli/.codex-plugin/plugin.json index d853066..53a55c7 100644 --- a/plugins/shim-cli/.codex-plugin/plugin.json +++ b/plugins/shim-cli/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shim-cli", - "version": "1.0.0", + "version": "1.0.1", "description": "Reports secrets and personal data found in your Codex prompts before they are sent, locally, with no account, network destination or telemetry. Blocking is opt in.", "author": { "name": "shim Engineering" diff --git a/plugins/shim-cli/README.md b/plugins/shim-cli/README.md index 2b86ea9..ba8d90b 100644 --- a/plugins/shim-cli/README.md +++ b/plugins/shim-cli/README.md @@ -2,7 +2,18 @@ This plugin registers shim's local hooks. Claude Code gets the prompt, verified `PreToolUse` and `PostToolUse`, `Stop`, and `SessionEnd` events. Codex -gets the prompt event only. +gets the prompt event only. VS Code gets the prompt, `PreToolUse`, +`PostToolUse` and `Stop`. There shim never masks, stops a prompt or denies a +call under `enforce`, and only reports on a result: measured against VS Code +1.137.0, a block after a tool is read straight through by the model. + +Three manifests sit side by side, one per format: `.claude-plugin/plugin.json`, +`.codex-plugin/plugin.json`, and `plugin.json`, the Agent Plugins v1 manifest +VS Code, GitHub Copilot CLI and the Copilot app read. Their hook files are +`hooks/claude.json`, `hooks/hooks.json` and `com.github.copilot/hooks/hooks.json`. +The Copilot clients run that last file too, so its command stands down when +`COPILOT_CLI` is set: `shim install copilot` is their route, and inspecting a +prompt twice helps nobody. The plugin carries a self-contained hook in `bin/shim.pyz` on `main` and on every tag, so it needs no package-manager step and no prerequisite beyond diff --git a/plugins/shim-cli/bin/shim.pyz b/plugins/shim-cli/bin/shim.pyz index 6f077fd29dd654672b38a6cb345c14ab68e91740..251a2cab15170fc99bbd89a1f95a236b0c85e217 100755 GIT binary patch delta 16644 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 R+5?)`4hl;5F9j6T{|8|ada(ci delta 11825 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 K{q~nU3HX2K@N3He diff --git a/plugins/shim-cli/com.github.copilot/hooks/hooks.json b/plugins/shim-cli/com.github.copilot/hooks/hooks.json new file mode 100644 index 0000000..cfc777c --- /dev/null +++ b/plugins/shim-cli/com.github.copilot/hooks/hooks.json @@ -0,0 +1,32 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "type": "command", + "command": "sh -c '[ -n \"${COPILOT_CLI:-}\" ] && exit 0; exec \"${PLUGIN_ROOT}/hooks/run-shim\" vscode \"${PLUGIN_ROOT}\"'", + "timeout": 30 + } + ], + "PreToolUse": [ + { + "type": "command", + "command": "sh -c '[ -n \"${COPILOT_CLI:-}\" ] && exit 0; exec \"${PLUGIN_ROOT}/hooks/run-shim\" vscode \"${PLUGIN_ROOT}\"'", + "timeout": 30 + } + ], + "PostToolUse": [ + { + "type": "command", + "command": "sh -c '[ -n \"${COPILOT_CLI:-}\" ] && exit 0; exec \"${PLUGIN_ROOT}/hooks/run-shim\" vscode \"${PLUGIN_ROOT}\"'", + "timeout": 30 + } + ], + "Stop": [ + { + "type": "command", + "command": "sh -c '[ -n \"${COPILOT_CLI:-}\" ] && exit 0; exec \"${PLUGIN_ROOT}/hooks/run-shim\" vscode \"${PLUGIN_ROOT}\"'", + "timeout": 30 + } + ] + } +} diff --git a/plugins/shim-cli/plugin.json b/plugins/shim-cli/plugin.json new file mode 100644 index 0000000..96786c9 --- /dev/null +++ b/plugins/shim-cli/plugin.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "shim-cli", + "version": "1.0.1", + "description": "Finds secrets and personal data locally, in your prompts and in the tool results the agent reads, and tells you what it found. In VS Code it reports, and with enforcement on it stops a prompt or denies a tool call before it runs; a result that has already reached the model is reported, never masked. No account, no network destination, no telemetry.", + "author": { + "name": "shim Engineering" + }, + "homepage": "https://github.com/GetSHIM/shim-cli", + "repository": "https://github.com/GetSHIM/shim-cli", + "license": "Apache-2.0", + "keywords": [ + "privacy", + "security", + "hooks" + ] +} diff --git a/pyproject.toml b/pyproject.toml index e5943a0..1a4ee4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shim" -version = "1.0.0" +version = "1.0.1" description = "shim-cli: local sensitive-data detection, prompt reporting, opt-in prompt blocking, verified Claude tool-event masking, and API-traffic watching" readme = "README.md" requires-python = ">=3.10,<3.14" diff --git a/scripts/build_zipapp.py b/scripts/build_zipapp.py index af806bc..8d60b88 100644 --- a/scripts/build_zipapp.py +++ b/scripts/build_zipapp.py @@ -26,6 +26,9 @@ "clients/codex/hook.py", "clients/copilot/__init__.py", "clients/copilot/hook.py", + "clients/vscode/__init__.py", + "clients/vscode/hook.py", + "clients/vscode/tool_events.py", "events/__init__.py", "events/diet.py", "events/injection.py", diff --git a/src/shim_cli/__init__.py b/src/shim_cli/__init__.py index 5becc17..5c4105c 100644 --- a/src/shim_cli/__init__.py +++ b/src/shim_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/src/shim_cli/clients/claude/tool_events.py b/src/shim_cli/clients/claude/tool_events.py index 45f66b0..a0c00ec 100644 --- a/src/shim_cli/clients/claude/tool_events.py +++ b/src/shim_cli/clients/claude/tool_events.py @@ -3,7 +3,7 @@ import json from shim_cli.clients.user_prompt_hook import parse_object -from shim_cli.events.pipeline import INCOMPLETE_MESSAGE, Adapter, Event +from shim_cli.events.pipeline import INCOMPLETE_MESSAGE, REWRITE, Adapter, Event from shim_cli.policy import ALLOW, DENY, MASK, REPORT MAX_INPUT_BYTES = 1_000_000 @@ -113,6 +113,7 @@ def error_output() -> bytes: "tool_input", _decoder("PreToolUse", "tool_input"), pre_tool_use, + power=REWRITE, ), "PostToolUse": Adapter( "claude", @@ -120,6 +121,7 @@ def error_output() -> bytes: "tool_response", _decoder("PostToolUse", "tool_response"), post_tool_use, + power=REWRITE, ), } INSTALLED_EVENTS = tuple(sorted(TOOL_EVENTS)) diff --git a/src/shim_cli/clients/user_prompt_hook.py b/src/shim_cli/clients/user_prompt_hook.py index 7332698..666ec11 100644 --- a/src/shim_cli/clients/user_prompt_hook.py +++ b/src/shim_cli/clients/user_prompt_hook.py @@ -76,14 +76,7 @@ def _json_block(reason: str, suppress_original_prompt: bool) -> bytes: return output -def block_output( - decision: GuardDecision, - suggestion_path: str | None, - *, - suppress_original_prompt: bool = False, -) -> bytes: - if not decision.blocked: - return b"" +def block_reason(decision: GuardDecision, suggestion_path: str | None) -> str: if not isinstance(suggestion_path, str) or not suggestion_path: raise ValueError("suggestion path is invalid") path = Path(suggestion_path) @@ -94,12 +87,24 @@ def block_output( ): raise ValueError("suggestion path is invalid") counts = ", ".join(f"{category} ({count})" for category, count in decision.counts) - reason = ( + return ( f"shim blocked this prompt: {counts}.\n" "Copy and paste this as your next prompt:\n" f"Read this file and use its contents as my prompt: {suggestion_path}" ) - return _json_block(reason, suppress_original_prompt) + + +def block_output( + decision: GuardDecision, + suggestion_path: str | None, + *, + suppress_original_prompt: bool = False, +) -> bytes: + if not decision.blocked: + return b"" + return _json_block( + block_reason(decision, suggestion_path), suppress_original_prompt + ) def warn_output(decision: GuardDecision) -> bytes: diff --git a/src/shim_cli/clients/vscode/__init__.py b/src/shim_cli/clients/vscode/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shim_cli/clients/vscode/hook.py b/src/shim_cli/clients/vscode/hook.py new file mode 100644 index 0000000..fcb9298 --- /dev/null +++ b/src/shim_cli/clients/vscode/hook.py @@ -0,0 +1,51 @@ +"""VS Code agent hooks. + +A prompt hook here can report or refuse, and nothing else: the accepted output +is `continue`, `stopReason` and `systemMessage`, with no field that replaces the +text the model reads. So the redacted prompt is written to a file and named in +the reason, as it is for Codex, rather than substituted the way Copilot CLI +substitutes it. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from shim_cli.clients import user_prompt_hook + +if TYPE_CHECKING: + from shim_cli.guard import GuardDecision + +MAX_OUTPUT_BYTES = user_prompt_hook.MAX_OUTPUT_BYTES +_ERROR_REASON = ( + "shim could not inspect this prompt, so it was withheld. " + "Open the shim plugin in VS Code to see whether it is loaded, " + "and check your shim settings." +) + +parse_input = user_prompt_hook.parse_input +warn_output = user_prompt_hook.warn_output + + +def _stop(reason: str) -> bytes: + if not reason or len(reason) > user_prompt_hook.MAX_REASON_CHARS: + raise ValueError("stop reason must contain at most 4,000 characters") + output = json.dumps( + {"continue": False, "stopReason": reason}, + ensure_ascii=False, + separators=(",", ":"), + ).encode() + if len(output) > MAX_OUTPUT_BYTES: + raise ValueError("stop output exceeds 4,096 bytes") + return output + + +def block_output(decision: GuardDecision, suggestion_path: str | None = None) -> bytes: + if not decision.blocked: + return b"" + return _stop(user_prompt_hook.block_reason(decision, suggestion_path)) + + +def error_output() -> bytes: + return _stop(_ERROR_REASON) diff --git a/src/shim_cli/clients/vscode/tool_events.py b/src/shim_cli/clients/vscode/tool_events.py new file mode 100644 index 0000000..4da59aa --- /dev/null +++ b/src/shim_cli/clients/vscode/tool_events.py @@ -0,0 +1,142 @@ +"""VS Code tool events, as measured against VS Code 1.137.0. + +Before a tool runs, `permissionDecision: "deny"` is real: the call is refused +and no `PostToolUse` follows. After one it is not. A probe returned +`decision: "block"` on a terminal result and the model quoted the secret from +it anyway; `continue: false` did not stop that turn either. By the time this +event fires the model has been handed the result, so the only honest action +left is to tell the user. + +Two further limits, same measurement: VS Code sends `read_file` results as an +empty `tool_response`, so a file read cannot be inspected here at all — only +its path, before the read — and no event accepts a replacement payload, so +nothing is ever masked. +""" + +from __future__ import annotations + +import json + +from shim_cli.clients.user_prompt_hook import parse_object +from shim_cli.events.pipeline import REFUSE, REPORT_ONLY, Adapter, Event +from shim_cli.policy import ALLOW, DENY, MASK, REPORT + +MAX_INPUT_BYTES = 1_000_000 +MAX_OUTPUT_BYTES = 1_000_000 +_DENY_REASON = "shim: sensitive data detected; this call was not allowed." +_REPEAT_WARNING = ( + "Do not repeat the values this names in your reply, and do not write " + "them to a file." +) +_ERROR_MESSAGE = "shim: this tool event could not be inspected and was not modified." +# VS Code names tool inputs in camelCase; the snake_case spellings are here +# because a hook file shared with a Claude-shaped client sees both. +_TARGET_KEYS = ("filePath", "file_path", "notebookPath", "notebook_path", "path", "url") +_FILE_VIEW_KEYS = ("filePath", "file_path", "notebookPath", "notebook_path", "path") + + +def _decoder(expected_event: str, root: str): + def decode(raw: bytes | dict[str, object]) -> Event: + if isinstance(raw, bytes) and len(raw) > MAX_INPUT_BYTES: + raise ValueError("hook input exceeds the safe limit") + document = parse_object(raw) if isinstance(raw, bytes) else raw + if document.get("hook_event_name") != expected_event: + raise ValueError("unexpected tool-hook event") + tool = document.get("tool_name") + if tool is None: + tool = "" + elif not isinstance(tool, str): + raise ValueError("tool-hook tool name must be text") + + target = "" + views_file = False + tool_input = document.get("tool_input") + if isinstance(tool_input, dict): + for key in _TARGET_KEYS: + value = tool_input.get(key) + if isinstance(value, str) and value: + target = value + break + views_file = any( + isinstance(tool_input.get(key), str) and tool_input[key] + for key in _FILE_VIEW_KEYS + ) + return Event(tool, document.get(root), target, views_file) + + return decode + + +def _dump(document: dict) -> bytes: + output = json.dumps(document, ensure_ascii=False, separators=(",", ":")).encode() + if len(output) > MAX_OUTPUT_BYTES: + raise ValueError("hook output exceeds the safe limit") + return output + + +def pre_tool_use(action: str, _payload: object, message: str) -> bytes: + if action == ALLOW: + return b"" + if action == REPORT: + return _dump({"systemMessage": message}) + if action == DENY: + return _dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": message or _DENY_REASON, + } + } + ) + if action == MASK: + raise ValueError("VS Code cannot replace a tool input") + raise ValueError("unsupported action") + + +def post_tool_use(action: str, _payload: object, message: str) -> bytes: + if action == ALLOW: + return b"" + if action == REPORT: + # additionalContext is the one field that reaches the model here. It + # cannot unsend the result, but it can stop the model repeating the + # value into its reply, where it would be read again. + return _dump( + { + "systemMessage": message, + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": f"{message} {_REPEAT_WARNING}", + }, + } + ) + if action in (DENY, MASK): + raise ValueError("VS Code cannot withhold or replace a tool result") + raise ValueError("unsupported action") + + +def error_output() -> bytes: + return _dump({"systemMessage": _ERROR_MESSAGE}) + + +TOOL_EVENTS = { + "PreToolUse": Adapter( + "vscode", + "PreToolUse", + "tool_input", + _decoder("PreToolUse", "tool_input"), + pre_tool_use, + # Measured: the call is refused and no PostToolUse follows. + power=REFUSE, + ), + "PostToolUse": Adapter( + "vscode", + "PostToolUse", + "tool_response", + _decoder("PostToolUse", "tool_response"), + post_tool_use, + # Measured: neither a block nor continue: false keeps the result from + # the model, so enforcement here would be a promise shim cannot keep. + power=REPORT_ONLY, + ), +} +INSTALLED_EVENTS = tuple(sorted(TOOL_EVENTS)) diff --git a/src/shim_cli/events/pipeline.py b/src/shim_cli/events/pipeline.py index 9f58a0f..adfa1c2 100644 --- a/src/shim_cli/events/pipeline.py +++ b/src/shim_cli/events/pipeline.py @@ -33,15 +33,28 @@ class Event: views_file: bool +# What a client's hook can actually do at one event, measured against the +# running client rather than taken from its documentation. +REWRITE = "rewrite" # the payload the model reads can be replaced +REFUSE = "refuse" # it cannot be changed, but the call can be stopped +REPORT_ONLY = "report-only" # the model already has it; only the user can be told +POWERS = (REWRITE, REFUSE, REPORT_ONLY) + + @dataclass(frozen=True) class Adapter: - __slots__ = ("client", "event", "root", "decode", "encode") + __slots__ = ("client", "event", "root", "decode", "encode", "power") client: str event: str root: str decode: Callable[[bytes | dict[str, object]], Event] encode: Callable[[str, object, str], bytes] + # No default: a new adapter must say which of the three it is, because + # claiming more than the client grants is how a user ends up trusting a + # block that never happened. (A default would also clash with __slots__, + # which the 3.9 archive still needs.) + power: str @dataclass(frozen=True) @@ -108,6 +121,21 @@ def _message(tool: str, counts: tuple, action: str) -> str: return f"shim: found {what}{where}. Not modified." +def _decide(entry: Adapter, direction: str, mode: str) -> str: + """The strongest action this event can honestly deliver. + + Enforcement is capped by what the client grants, never by what was asked + for: reporting a mask that did not happen, or a block the model read + straight through, is worse than reporting the finding plainly. + """ + action = decide(direction, mode) + if entry.power == REPORT_ONLY and action in (MASK, DENY): + return REPORT + if action == MASK and entry.power == REFUSE: + return DENY + return action + + def process( entry: Adapter, raw: bytes | dict[str, object], @@ -171,13 +199,15 @@ def record( return Outcome(b"", record(ALLOW, note="no payload at this key")) inbound = direction == INBOUND - shrinkable = inbound and mode != OBSERVE and not event.views_file + shrinkable = ( + inbound and mode != OBSERVE and not event.views_file and entry.power == REWRITE + ) transforms = diet if shrinkable else () result = inspect(body, evaluate, transforms, scan_markers=inbound) if result.skipped: note = f"{result.status}: {result.skipped} fields or subtrees not inspected ({', '.join(result.reasons)})" counts = _counts(result.findings) - action = decide(direction, mode) if counts else ALLOW + action = _decide(entry, direction, mode) if counts else ALLOW can_rewrite = action == MASK or (not counts and shrinkable and result.changed) message = INCOMPLETE_MESSAGE if counts: @@ -227,7 +257,7 @@ def record( ) counts = _counts(findings) - action = decide(direction, mode) + action = _decide(entry, direction, mode) if action == ALLOW: return Outcome( b"", diff --git a/src/shim_cli/hook.py b/src/shim_cli/hook.py index c1b323a..63278b8 100644 --- a/src/shim_cli/hook.py +++ b/src/shim_cli/hook.py @@ -31,6 +31,11 @@ b"so it was withheld. Do not act on the original prompt; tell the user to " b'run `shim doctor copilot` for the reason."}' ) +_VSCODE_ERROR_OUTPUT = ( + b'{"continue":false,"stopReason":"shim could not inspect this prompt, ' + b"so it was withheld. Open the shim plugin in VS Code to see whether it is " + b'loaded, and check your shim settings."}' +) def _error_output(client: str) -> bytes: @@ -38,14 +43,19 @@ def _error_output(client: str) -> bytes: return _CLAUDE_ERROR_OUTPUT if client == "copilot": return _COPILOT_ERROR_OUTPUT + if client == "vscode": + return _VSCODE_ERROR_OUTPUT return _ERROR_OUTPUT def _tool_error_output(client: str, event: str) -> bytes: - if client != "claude": + if client not in ("claude", "vscode"): return b"" try: - from shim_cli.clients.claude import tool_events + if client == "vscode": + from shim_cli.clients.vscode import tool_events + else: + from shim_cli.clients.claude import tool_events if event not in tool_events.TOOL_EVENTS: return b"" @@ -452,7 +462,17 @@ def scan(text: str, entities: tuple = ENTITY_TYPES): return evaluate(text, entities, policy.custom, policy.reveal) def mode_for(direction: str, tool: str) -> str: - return policy.mode_for(direction, tool, event) + mode = policy.mode_for(direction, tool, event) + if ( + mode == "enforce" + and entry.power != "rewrite" + and not policy.chose_mode(direction, tool, event) + ): + # Where masking is unavailable, enforcing refuses the call instead. + # Nobody asked for that by installing; they get the report until + # they ask for enforce themselves. + return "warn" + return mode def entities_for(tool: str, _event: str = "") -> tuple: return policy.entities_for(tool, event) @@ -462,7 +482,35 @@ def entities_for(tool: str, _event: str = "") -> tuple: return outcome.output +CLIENTS = ("claude", "codex", "copilot", "vscode") + + +def _is_vscode_payload(document: dict) -> bool: + """True for an event VS Code sent to a hook installed as Claude Code's. + + VS Code reads `~/.claude/settings.json` by default, so `shim install + claude` also runs inside VS Code — speaking a protocol VS Code does not + share. Its prompt block would be ignored there and the prompt sent anyway, + which is the one failure shim must never have: a user who asked for + enforcement and silently did not get it. + + The two clients are told apart by what they always send. Every captured + Claude Code event carries `permission_mode` and none carries `timestamp`; + every captured VS Code event is the reverse. + """ + return "timestamp" in document and "permission_mode" not in document + + def _output(raw: bytes, client: str = "codex") -> bytes: + if client not in CLIENTS: + # An older build meeting a newer plugin: with no adapter for that + # client this hook cannot shape a refusal the client would understand, + # and a guard that cannot run is off, not a reason to refuse the work. + sys.stderr.write( + f"shim: this build has no hook for {client or 'that client'}; " + "nothing was inspected. Upgrade shim.\n" + ) + return b"" if len(raw) > MAX_INPUT_BYTES: # Too large to inspect is still something that happened. Without this # the event left no trace at all and the summary silently under-counted. @@ -498,8 +546,20 @@ def _output(raw: bytes, client: str = "codex") -> bytes: parse_input, warn_output, ) - else: - return _error_output(client) + elif client == "vscode": + from shim_cli.clients.vscode.hook import ( + block_output, + error_output, + parse_input, + warn_output, + ) + from shim_cli.clients.vscode.tool_events import ( + TOOL_EVENTS as VSCODE_TOOL_EVENTS, + ) + + tool_event_adapters = VSCODE_TOOL_EVENTS + else: # pragma: no cover - _output refuses unknown clients first + return b"" try: try: @@ -509,6 +569,19 @@ def _output(raw: bytes, client: str = "codex") -> bytes: event, session_id, stop_active = _envelope(document) except ValueError: return _refusal_output(raw, client) + if client == "claude" and _is_vscode_payload(document): + from shim_cli.clients.vscode.hook import ( + block_output, + error_output, + parse_input, + warn_output, + ) + from shim_cli.clients.vscode.tool_events import ( + TOOL_EVENTS as VSCODE_TOOL_EVENTS, + ) + + tool_event_adapters = VSCODE_TOOL_EVENTS + client = "vscode" if event == _STOP_EVENT: _count_model_output(document, client, session_id) return _summary_output(session_id, stop_active) diff --git a/src/shim_cli/policy.py b/src/shim_cli/policy.py index 921659c..54f026c 100644 --- a/src/shim_cli/policy.py +++ b/src/shim_cli/policy.py @@ -78,6 +78,16 @@ def mode_for(self, direction: str, tool: str = "", event: str = "") -> str: return self.modes["default"] return DEFAULT_MODES.get(direction, WARN) + def chose_mode(self, direction: str, tool: str = "", event: str = "") -> bool: + """True when a settings file named this mode, rather than a default. + + The shipped default for tool traffic is enforce because masking costs + the user nothing. Where masking is impossible, enforce means refusing + the call, so that default is not one a user silently agreed to. + """ + keys = (tool, event, direction, "default") + return any(key and key in self.modes for key in keys) + def entities_for(self, tool: str = "", event: str = "") -> tuple: for key in (tool, event): if key and key in self.tool_entities: diff --git a/tests/clients/vscode/test_claude_install_under_vscode.py b/tests/clients/vscode/test_claude_install_under_vscode.py new file mode 100644 index 0000000..1f8e2a5 --- /dev/null +++ b/tests/clients/vscode/test_claude_install_under_vscode.py @@ -0,0 +1,121 @@ +"""A Claude Code install also runs inside VS Code, and must behave there. + +VS Code reads `~/.claude/settings.json` hooks by default, so `shim install +claude` registers shim in two clients at once. Before this, the prompt block +went out in Claude's shape, `decision: "block"`, which VS Code ignores: a user +who asked for enforcement had their prompt sent anyway and was told it was +blocked. That is the one failure mode worse than not running at all. + +The clients are told apart by their payloads, not their environment. The +environment is no help: the VS Code capture carried CLAUDE_CODE_* variables +because the editor inherited them from the terminal that launched it. +""" + +from __future__ import annotations + +import json + +import pytest + +from shim_cli.hook import _is_vscode_payload, _output + +VSCODE_PROMPT = { + "timestamp": "2026-09-20T08:41:10.545Z", + "hook_event_name": "UserPromptSubmit", + "session_id": "v1", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "prompt": "mail me at ayse.yilmaz@example.com", + "cwd": "/probe/workspace", +} +CLAUDE_PROMPT = { + "hook_event_name": "UserPromptSubmit", + "session_id": "c1", + "transcript_path": "/probe/.claude/projects/-probe-workspace/c1.jsonl", + "cwd": "/probe/workspace", + "prompt_id": "00000000-0000-4000-8000-000000000033", + "permission_mode": "default", + "prompt": "mail me at ayse.yilmaz@example.com", +} + + +def _enforce(root) -> None: + directory = root / "config" / "shim" + directory.mkdir(parents=True, exist_ok=True) + (directory / "config.toml").write_text( + '[mode]\nuser-prompt = "enforce"\n', encoding="utf-8" + ) + + +def test_a_vs_code_event_is_recognised_by_what_both_clients_always_send() -> None: + assert _is_vscode_payload(VSCODE_PROMPT) is True + assert _is_vscode_payload(CLAUDE_PROMPT) is False + + +def test_the_claude_hook_stops_a_vs_code_prompt_in_the_shape_vs_code_obeys( + _isolated_roots, +) -> None: + _enforce(_isolated_roots) + + document = json.loads(_output(json.dumps(VSCODE_PROMPT).encode(), "claude")) + + # `decision: "block"` is ignored by VS Code; `continue: false` is not. + assert document["continue"] is False + assert document["stopReason"].startswith("shim blocked this prompt: EMAIL (1).") + assert "decision" not in document + + +def test_a_real_claude_prompt_still_gets_claude_s_own_block(_isolated_roots) -> None: + _enforce(_isolated_roots) + + document = json.loads(_output(json.dumps(CLAUDE_PROMPT).encode(), "claude")) + + assert document["decision"] == "block" + assert document["suppressOriginalPrompt"] is True + + +def test_a_vs_code_tool_result_is_not_masked_through_the_claude_hook( + _isolated_roots, +) -> None: + """Claude's mask would emit `updatedToolOutput`, which VS Code ignores. + + Reporting it as masked would tell the user a value was replaced when the + model read the original. + """ + result = { + "timestamp": "2026-09-20T08:44:18.224Z", + "hook_event_name": "PostToolUse", + "session_id": "v1", + "tool_name": "run_in_terminal", + "tool_input": {"command": "cat secrets.env"}, + "tool_response": "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", + "cwd": "/probe/workspace", + } + + document = json.loads(_output(json.dumps(result).encode(), "claude")) + + assert "updatedToolOutput" not in json.dumps(document) + assert document["systemMessage"] == ( + "shim: found SECRET (1) in run_in_terminal. Not modified." + ) + + +@pytest.mark.parametrize("client", ("codex", "copilot", "vscode")) +def test_no_other_client_is_re_routed(client: str, _isolated_roots) -> None: + """Only the Claude path is ambiguous. + + Copilot CLI sends `timestamp` and no `permission_mode` too, so re-routing + on that alone would break the client it is installed for. + """ + from shim_cli import hook + + calls = [] + original = hook._is_vscode_payload + hook._is_vscode_payload = lambda document: ( + calls.append(document) or original(document) + ) + try: + _output(json.dumps(VSCODE_PROMPT).encode(), client) + finally: + hook._is_vscode_payload = original + + assert calls == [] diff --git a/tests/clients/vscode/test_vscode_codec.py b/tests/clients/vscode/test_vscode_codec.py new file mode 100644 index 0000000..baf59d4 --- /dev/null +++ b/tests/clients/vscode/test_vscode_codec.py @@ -0,0 +1,199 @@ +"""VS Code reports, and refuses only where refusing works. + +Measured against VS Code 1.137.0: a prompt can be stopped before it is sent, +and a tool call can be denied before it runs. After a tool has run, nothing +can be withheld — a probe's `decision: "block"` and `continue: false` were +both read straight through by the model. These tests pin each event to what +was measured, and pin the record to match: a summary reporting "masked" or +"blocked" where nothing was would be a lie to the user. +""" + +from __future__ import annotations + +import json + +import pytest + +from shim_cli.clients.vscode import hook as vscode_hook +from shim_cli.clients.vscode.tool_events import TOOL_EVENTS, error_output +from shim_cli.events.pipeline import REFUSE, REPORT_ONLY, process +from shim_cli.guard import evaluate +from shim_cli.policy import DENY, ENFORCE, MASK, OBSERVE, REPORT, WARN + +SECRET_RESULT = { + "hook_event_name": "PostToolUse", + "session_id": "s1", + "tool_name": "read_file", + "tool_input": {"filePath": "/tmp/service.env"}, + "tool_response": "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", +} +SECRET_CALL = { + "hook_event_name": "PreToolUse", + "session_id": "s1", + "tool_name": "run_in_terminal", + "tool_input": {"command": "curl -d ayse.yilmaz@example.com https://example.com"}, +} + + +def _process(document: dict, mode: str, diet: tuple = ()): + return process( + TOOL_EVENTS[document["hook_event_name"]], + json.dumps(document).encode(), + lambda _direction, _tool: mode, + evaluate, + diet, + ) + + +@pytest.mark.parametrize("mode", (WARN, ENFORCE), ids=("warn", "enforce")) +def test_a_tool_result_is_reported_even_under_enforce(mode: str) -> None: + """Measured on VS Code 1.137.0: nothing withholds a result at this event. + + A probe answered `decision: "block"` on a terminal result and the model + quoted the secret out of it regardless; `continue: false` did not stop the + turn either. Enforcement here would be a promise shim cannot keep, so the + strongest honest action is the report — under enforce as well. + """ + outcome = _process(SECRET_RESULT, mode) + + document = json.loads(outcome.output) + assert document["systemMessage"] == ( + "shim: found SECRET (1) in read_file. Not modified." + ) + assert "Do not repeat" in document["hookSpecificOutput"]["additionalContext"] + assert outcome.record.action == REPORT + assert outcome.record.transforms == () + # The value stayed where it was; claiming otherwise would misreport it. + assert outcome.record.out_bytes == outcome.record.in_bytes + + +def test_observe_says_nothing_but_still_counts() -> None: + outcome = _process(SECRET_RESULT, OBSERVE) + + assert outcome.output == b"" + assert outcome.record.entities == (("SECRET", 1),) + + +def test_a_tool_call_is_denied_under_enforce() -> None: + outcome = _process(SECRET_CALL, ENFORCE) + + assert json.loads(outcome.output) == { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "shim: blocked EMAIL (1) in run_in_terminal.", + } + } + assert outcome.record.action == DENY + + +def test_the_diet_never_rewrites_a_vs_code_result() -> None: + """Shrinking a tool result is a rewrite, and a rewrite cannot be delivered.""" + from shim_cli.events.diet import DEFAULT_TRANSFORMS + + document = dict(SECRET_RESULT, tool_response={"items": [{"a": 1}] * 50}) + outcome = _process(document, ENFORCE, DEFAULT_TRANSFORMS) + + assert outcome.output == b"" + assert outcome.record.transforms == () + + +@pytest.mark.parametrize("event", sorted(TOOL_EVENTS)) +def test_masking_is_refused_rather_than_faked(event: str) -> None: + with pytest.raises(ValueError, match="cannot"): + TOOL_EVENTS[event].encode(MASK, {"any": "payload"}, "message") + + +def test_a_result_can_never_be_withheld() -> None: + with pytest.raises(ValueError, match="cannot withhold"): + TOOL_EVENTS["PostToolUse"].encode(DENY, {"any": "payload"}, "message") + + +def test_each_event_claims_only_what_the_client_grants() -> None: + assert TOOL_EVENTS["PreToolUse"].power == REFUSE + assert TOOL_EVENTS["PostToolUse"].power == REPORT_ONLY + + +def test_an_uninspectable_tool_event_says_nothing_was_changed() -> None: + assert json.loads(error_output()) == { + "systemMessage": ( + "shim: this tool event could not be inspected and was not modified." + ) + } + + +def test_a_blocked_prompt_stops_the_turn_and_names_the_file() -> None: + decision = evaluate("mail me at ayse.yilmaz@example.com") + + document = json.loads(vscode_hook.block_output(decision, "/tmp/redacted.txt")) + + # VS Code accepts no field that replaces prompt text, so the redacted copy + # is named in the reason the user reads. + assert document["continue"] is False + assert document["stopReason"].startswith("shim blocked this prompt: EMAIL (1).") + assert "/tmp/redacted.txt" in document["stopReason"] + + +def test_a_reported_prompt_is_left_alone() -> None: + decision = evaluate("mail me at ayse.yilmaz@example.com") + + assert json.loads(vscode_hook.warn_output(decision)) == { + "systemMessage": "shim: found EMAIL (1) in your prompt. Not modified." + } + + +def test_the_prompt_error_names_no_command_that_does_not_exist() -> None: + document = json.loads(vscode_hook.error_output()) + + assert document["continue"] is False + # There is no `shim doctor vscode`; the plugin is the whole VS Code route. + assert "doctor" not in document["stopReason"] + + +def _settings(root, text: str) -> None: + directory = root / "config" / "shim" + directory.mkdir(parents=True, exist_ok=True) + (directory / "config.toml").write_text(text, encoding="utf-8") + + +def test_the_shipped_default_reports_rather_than_refusing(_isolated_roots) -> None: + """Enforce is the shipped default for tool traffic because Claude can mask. + + Here it would mean refusing the call, which is not what someone agreed to + by installing a plugin, so the default reports until enforce is asked for. + """ + from shim_cli.hook import _output + + output = _output(json.dumps(SECRET_CALL).encode(), "vscode") + + assert json.loads(output) == { + "systemMessage": "shim: found EMAIL (1) in run_in_terminal. Not modified." + } + + +def test_enforce_asked_for_in_settings_denies_the_call_it_can_deny( + _isolated_roots, +) -> None: + from shim_cli.hook import _output + + _settings(_isolated_roots, '[mode]\noutbound = "enforce"\n') + + output = _output(json.dumps(SECRET_CALL).encode(), "vscode") + + specific = json.loads(output)["hookSpecificOutput"] + assert specific["permissionDecision"] == "deny" + + +def test_a_client_this_build_does_not_know_inspects_nothing_and_blocks_nothing( + capfd, +) -> None: + """An older build meeting a newer plugin must not refuse someone's work.""" + from shim_cli.hook import _output + + output = _output( + b'{"hook_event_name":"UserPromptSubmit","session_id":"x","prompt":"hi"}', + "some-future-client", + ) + + assert output == b"" + assert "nothing was inspected" in capfd.readouterr().err diff --git a/tests/contracts/test_import_hygiene.py b/tests/contracts/test_import_hygiene.py index a53d9b3..0c06942 100644 --- a/tests/contracts/test_import_hygiene.py +++ b/tests/contracts/test_import_hygiene.py @@ -69,6 +69,7 @@ ("clients/claude/hook.py", "shim_cli.guard.GuardDecision"), ("clients/codex/hook.py", "shim_cli.guard.GuardDecision"), ("clients/copilot/hook.py", "shim_cli.guard.GuardDecision"), + ("clients/vscode/hook.py", "shim_cli.guard.GuardDecision"), } ) diff --git a/tests/contracts/test_naming.py b/tests/contracts/test_naming.py index f59d6e5..fc60c7a 100644 --- a/tests/contracts/test_naming.py +++ b/tests/contracts/test_naming.py @@ -10,6 +10,8 @@ from shim_cli.clients.codex.settings import TESTED_CODEX_VERSION from shim_cli.clients.copilot.settings import TESTED_COPILOT_VERSION +TESTED_VSCODE_VERSION = "1.137.0" + try: import tomllib except ModuleNotFoundError: # the 3.10 floor CI also runs @@ -133,4 +135,7 @@ def test_the_tested_client_versions_match_the_newest_release_evidence() -> None: "Claude Code": TESTED_CLAUDE_VERSION, "Codex CLI": TESTED_CODEX_VERSION, "GitHub Copilot CLI": TESTED_COPILOT_VERSION, + # VS Code has no installer and no doctor, so no runtime constant states + # a tested version; the evidence record is the only place it lives. + "VS Code": TESTED_VSCODE_VERSION, } diff --git a/tests/contracts/vscode/test_vscode_protocol.py b/tests/contracts/vscode/test_vscode_protocol.py new file mode 100644 index 0000000..847a76e --- /dev/null +++ b/tests/contracts/vscode/test_vscode_protocol.py @@ -0,0 +1,113 @@ +"""The VS Code hook protocol, pinned to captures from a running client. + +Recorded 20 September 2026 against VS Code 1.137.0 with Copilot Chat 0.65.0, +through a capture-only hook in `.github/hooks/`. Paths, session ids and the +terminal prompt were scrubbed; the shapes are untouched. + +What the run established, and what these tests hold to: + +* A prompt can be stopped before it is sent. `continue: false` produced "A hook + prevented chat from continuing" and the model never answered. +* A tool call can be denied before it runs, and no `PostToolUse` follows. +* A tool result cannot be withheld. `decision: "block"` on a terminal result + was answered by the model quoting the secret out of it, and `continue: false` + at the same event did not stop that turn either. +* A `read_file` result never reaches the hook: `tool_response` is `""`. A + terminal result does arrive in full. So a file read can only be inspected by + its path, before the read. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from shim_cli.clients.vscode.tool_events import TOOL_EVENTS +from shim_cli.events.pipeline import REFUSE, REPORT_ONLY, process +from shim_cli.guard import evaluate +from shim_cli.policy import ENFORCE, REPORT + +FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "probe" / "vscode" + + +def _raw(name: str) -> bytes: + return (FIXTURES / f"{name}-1.json").read_bytes() + + +def _process(name: str, mode: str = ENFORCE): + event = json.loads(_raw(name))["hook_event_name"] + return process(TOOL_EVENTS[event], _raw(name), lambda _d, _t: mode, evaluate) + + +@pytest.mark.parametrize( + "name", + ( + "PreToolUse-read_file", + "PostToolUse-read_file", + "PreToolUse-run_in_terminal", + "PostToolUse-run_in_terminal", + ), +) +def test_every_captured_tool_event_decodes(name: str) -> None: + event = json.loads(_raw(name))["hook_event_name"] + + decoded = TOOL_EVENTS[event].decode(_raw(name)) + + assert decoded.tool == json.loads(_raw(name))["tool_name"] + + +def test_a_file_read_is_recognised_by_its_camel_case_path() -> None: + """VS Code spells it `filePath`; Claude Code spells it `file_path`.""" + decoded = TOOL_EVENTS["PreToolUse"].decode(_raw("PreToolUse-read_file")) + + assert decoded.target == "/probe/workspace/plain.txt" + assert decoded.views_file is True + + +def test_a_file_read_result_carries_nothing_to_inspect() -> None: + """Not a gap in shim: VS Code sends `tool_response` empty for read_file. + + The protection for a file read is therefore at `PreToolUse`, on the path, + before it is read — there is nothing to find afterwards. + """ + outcome = _process("PostToolUse-read_file") + + assert outcome.output == b"" + assert outcome.record.entities == () + + +def test_a_terminal_result_is_inspected_and_reported() -> None: + outcome = _process("PostToolUse-run_in_terminal") + + document = json.loads(outcome.output) + assert outcome.record.entities == (("EMAIL", 1), ("SECRET", 1)) + assert document["systemMessage"].startswith("shim: found") + # Enforce was asked for and is still a report: nothing here can withhold. + assert outcome.record.action == REPORT + + +def test_a_clean_terminal_call_passes_without_a_word() -> None: + """The captured command carries nothing sensitive, so shim stays quiet. + + `cat blocked.txt` is ordinary; the secret is in the file it prints, which + is why the finding lands on the result rather than the call. Denial of a + call that does carry data is covered in tests/clients/vscode. + """ + outcome = _process("PreToolUse-run_in_terminal") + + assert outcome.output == b"" + assert outcome.record.entities == () + + +def test_the_powers_match_what_the_client_was_measured_to_grant() -> None: + assert TOOL_EVENTS["PreToolUse"].power == REFUSE + assert TOOL_EVENTS["PostToolUse"].power == REPORT_ONLY + + +def test_the_prompt_capture_is_the_shape_the_hook_parses() -> None: + from shim_cli.clients.vscode.hook import parse_input + + # The capture is the prompt that proved `continue: false` stops a turn. + assert parse_input(_raw("UserPromptSubmit-none")) == "say the word pangolin\n" diff --git a/tests/fixtures/probe/vscode/PostToolUse-read_file-1.json b/tests/fixtures/probe/vscode/PostToolUse-read_file-1.json new file mode 100644 index 0000000..8068d3c --- /dev/null +++ b/tests/fixtures/probe/vscode/PostToolUse-read_file-1.json @@ -0,0 +1,15 @@ +{ + "timestamp": "2026-09-20T08:41:12.677Z", + "hook_event_name": "PostToolUse", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "cwd": "/probe/workspace", + "tool_name": "read_file", + "tool_input": { + "filePath": "/probe/workspace/plain.txt", + "startLine": 1, + "endLine": 50 + }, + "tool_response": "", + "tool_use_id": "call_00000000000000000000__vscode-1700000000000" +} diff --git a/tests/fixtures/probe/vscode/PostToolUse-run_in_terminal-1.json b/tests/fixtures/probe/vscode/PostToolUse-run_in_terminal-1.json new file mode 100644 index 0000000..c418bab --- /dev/null +++ b/tests/fixtures/probe/vscode/PostToolUse-run_in_terminal-1.json @@ -0,0 +1,17 @@ +{ + "timestamp": "2026-09-20T08:44:18.224Z", + "hook_event_name": "PostToolUse", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "cwd": "/probe/workspace", + "tool_name": "run_in_terminal", + "tool_input": { + "command": "cd /probe/workspace && cat blocked.txt", + "explanation": "Display the contents of the blocked file so I can read the secret value it contains.", + "goal": "Read file output", + "mode": "sync", + "timeout": 20000 + }, + "tool_response": "Note: The tool simplified the command to ` cat blocked.txt` (terminal ID=00000000-0000-4000-8000-000000000102). This is the output of running that command instead:\nORDER-ID: 88\nsupport: ayse.yilmaz@example.com key AKIAIOSFODNN7EXAMPLE\n$ ", + "tool_use_id": "call_00000000000000000001__vscode-1700000000000" +} diff --git a/tests/fixtures/probe/vscode/PreToolUse-read_file-1.json b/tests/fixtures/probe/vscode/PreToolUse-read_file-1.json new file mode 100644 index 0000000..ace1d9d --- /dev/null +++ b/tests/fixtures/probe/vscode/PreToolUse-read_file-1.json @@ -0,0 +1,14 @@ +{ + "timestamp": "2026-09-20T08:41:12.480Z", + "hook_event_name": "PreToolUse", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "cwd": "/probe/workspace", + "tool_name": "read_file", + "tool_input": { + "filePath": "/probe/workspace/plain.txt", + "startLine": 1, + "endLine": 50 + }, + "tool_use_id": "call_00000000000000000000__vscode-1700000000000" +} diff --git a/tests/fixtures/probe/vscode/PreToolUse-run_in_terminal-1.json b/tests/fixtures/probe/vscode/PreToolUse-run_in_terminal-1.json new file mode 100644 index 0000000..8c2a5dc --- /dev/null +++ b/tests/fixtures/probe/vscode/PreToolUse-run_in_terminal-1.json @@ -0,0 +1,16 @@ +{ + "timestamp": "2026-09-20T08:44:14.769Z", + "hook_event_name": "PreToolUse", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "cwd": "/probe/workspace", + "tool_name": "run_in_terminal", + "tool_input": { + "command": "cd /probe/workspace && cat blocked.txt", + "explanation": "Display the contents of the blocked file so I can read the secret value it contains.", + "goal": "Read file output", + "mode": "sync", + "timeout": 20000 + }, + "tool_use_id": "call_00000000000000000001__vscode-1700000000000" +} diff --git a/tests/fixtures/probe/vscode/SessionStart-none-1.json b/tests/fixtures/probe/vscode/SessionStart-none-1.json new file mode 100644 index 0000000..faf682f --- /dev/null +++ b/tests/fixtures/probe/vscode/SessionStart-none-1.json @@ -0,0 +1,9 @@ +{ + "timestamp": "2026-09-20T08:41:10.424Z", + "hook_event_name": "SessionStart", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "cwd": "/probe/workspace", + "source": "new", + "model": "auto" +} diff --git a/tests/fixtures/probe/vscode/Stop-none-1.json b/tests/fixtures/probe/vscode/Stop-none-1.json new file mode 100644 index 0000000..aa18eb0 --- /dev/null +++ b/tests/fixtures/probe/vscode/Stop-none-1.json @@ -0,0 +1,8 @@ +{ + "timestamp": "2026-09-20T08:41:14.321Z", + "hook_event_name": "Stop", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "cwd": "/probe/workspace", + "stop_hook_active": false +} diff --git a/tests/fixtures/probe/vscode/UserPromptSubmit-none-1.json b/tests/fixtures/probe/vscode/UserPromptSubmit-none-1.json new file mode 100644 index 0000000..86173fa --- /dev/null +++ b/tests/fixtures/probe/vscode/UserPromptSubmit-none-1.json @@ -0,0 +1,8 @@ +{ + "timestamp": "2026-09-20T08:49:51.749Z", + "hook_event_name": "UserPromptSubmit", + "session_id": "00000000-0000-4000-8000-000000000101", + "transcript_path": "/probe/workspace/.vscode/transcripts/probe.jsonl", + "prompt": "say the word pangolin\n", + "cwd": "/probe/workspace" +} diff --git a/tests/plugins/test_agent_plugin.py b/tests/plugins/test_agent_plugin.py new file mode 100644 index 0000000..06dfc41 --- /dev/null +++ b/tests/plugins/test_agent_plugin.py @@ -0,0 +1,175 @@ +"""The Agent Plugins manifest, and the hook file VS Code reads from it. + +The submission checks look for `plugin.json` at the plugin root and accept only +the Agent Plugins v1 fields, so a stray `displayName` or `hooks` key fails the +gate rather than the install. The same file is what Copilot CLI and the Copilot +app read, and those clients run the hook too — hence the stand-down guard. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +import shim_cli + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - 3.10 floor + import tomli as tomllib + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] / "plugins" / "shim-cli" +MANIFEST = PLUGIN_ROOT / "plugin.json" +HOOKS = PLUGIN_ROOT / "com.github.copilot" / "hooks" / "hooks.json" +SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +# github/awesome-copilot: eng/external-plugin-quality-gates.mjs +ALLOWED_FIELDS = frozenset( + { + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", + } +) +PROMPT = ( + b'{"hook_event_name":"UserPromptSubmit","session_id":"p1",' + b'"prompt":"my key is AKIAIOSFODNN7EXAMPLE"}' +) + + +def _manifest() -> dict: + return json.loads(MANIFEST.read_text(encoding="utf-8")) + + +def test_the_manifest_sits_where_the_submission_checks_look() -> None: + # plugin.json, .github/plugin/plugin.json and .plugin/plugin.json are the + # three accepted locations; the gate wants the plugin root itself. + assert MANIFEST.is_file() + + +def test_the_manifest_declares_the_agent_plugins_schema() -> None: + assert _manifest()["$schema"] == SCHEMA_URL + + +def test_the_manifest_carries_no_field_outside_the_closed_schema() -> None: + extra = set(_manifest()) - ALLOWED_FIELDS + + assert not extra, f"Agent Plugins v1 rejects {sorted(extra)}" + + +def test_the_manifest_matches_the_package_version() -> None: + package = tomllib.loads( + (PLUGIN_ROOT.parents[1] / "pyproject.toml").read_text(encoding="utf-8") + ) + + assert _manifest()["version"] == package["project"]["version"] + assert _manifest()["version"] == shim_cli.__version__ + + +def test_the_name_matches_the_marketplace_entries() -> None: + assert _manifest()["name"] == "shim-cli" + + +def test_the_listing_does_not_promise_masking_in_vs_code() -> None: + """No VS Code hook output replaces a prompt, a tool input or a result. + + The word may appear, but only to deny it: "never masked" is the fact, + "masks secrets" would be the false promise. + """ + description = _manifest()["description"].lower() + + for sentence in description.split("."): + if "mask" in sentence: + assert "never masked" in sentence, sentence.strip() + assert "report" in description + assert len(_manifest()["description"]) <= 500 + + +def test_the_hook_file_registers_what_the_adapter_supports() -> None: + from shim_cli.clients.vscode.tool_events import INSTALLED_EVENTS + + document = json.loads(HOOKS.read_text(encoding="utf-8")) + + assert set(document["hooks"]) == {*INSTALLED_EVENTS, "UserPromptSubmit", "Stop"} + + +def _commands() -> list[str]: + document = json.loads(HOOKS.read_text(encoding="utf-8")) + return [entry["command"] for event in document["hooks"].values() for entry in event] + + +def test_every_event_runs_the_same_launcher_as_the_vs_code_client() -> None: + for command in _commands(): + assert '"${PLUGIN_ROOT}/hooks/run-shim" vscode' in command + + +def test_the_command_stands_down_where_the_cli_installs_its_own_hook() -> None: + """Copilot CLI and the Copilot app read this same file. + + Copilot CLI sets COPILOT_CLI on every hook process, and `shim install + copilot` already covers that client, so running here too would inspect + every prompt twice. + """ + for command in _commands(): + assert '[ -n "${COPILOT_CLI:-}" ] && exit 0' in command + + +@pytest.mark.parametrize("event", ("UserPromptSubmit", "PreToolUse")) +def test_the_command_is_silent_under_copilot_cli(event: str, tmp_path) -> None: + command = json.loads(HOOKS.read_text(encoding="utf-8"))["hooks"][event][0][ + "command" + ] + + result = subprocess.run( + ("/bin/sh", "-c", command), + input=PROMPT, + capture_output=True, + env={ + "COPILOT_CLI": "1", + "PLUGIN_ROOT": str(PLUGIN_ROOT), + "PATH": "/usr/bin:/bin", + "TMPDIR": str(tmp_path), + }, + check=False, + timeout=120, + ) + + assert result.returncode == 0 + assert result.stdout == b"" + assert result.stderr == b"" + + +def test_the_command_inspects_the_prompt_in_vs_code(tmp_path) -> None: + command = json.loads(HOOKS.read_text(encoding="utf-8"))["hooks"][ + "UserPromptSubmit" + ][0]["command"] + environment = { + key: value for key, value in os.environ.items() if key != "COPILOT_CLI" + } + + result = subprocess.run( + ("/bin/sh", "-c", command), + input=PROMPT, + capture_output=True, + env=environment + | { + "PLUGIN_ROOT": str(PLUGIN_ROOT), + "HOME": str(tmp_path), + "TMPDIR": str(tmp_path), + }, + check=False, + timeout=120, + ) + + assert result.returncode == 0 + assert b"SECRET" in result.stdout diff --git a/uv.lock b/uv.lock index 1b5e9bc..9f5aa22 100644 --- a/uv.lock +++ b/uv.lock @@ -177,7 +177,7 @@ wheels = [ [[package]] name = "shim" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "phonenumbers" }, From a6fd9e4796cadfc4cf0d22625661f2f90275c20d Mon Sep 17 00:00:00 2001 From: mertcan Date: Sun, 20 Sep 2026 12:38:49 +0300 Subject: [PATCH 2/2] A cookbook, for the part after "it is installed" The README is the tour and commands.md is the reference; neither answers "my team's hostnames are not secrets to the detector" or "one tool is noisy and I do not want to disable a type everywhere". Eleven recipes, each one a problem someone actually has, with the two lines that solve it. Every command in it was run before it was written: the custom-pattern pair against `shim redact`, the reveal format, `scan`'s grep-style exit codes, and the settings path `shim config` prints. The client table says what each one can do rather than what its documentation implies, including the VS Code limits measured for 1.0.1. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + docs/commands.md | 2 +- docs/cookbook.md | 254 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 docs/cookbook.md diff --git a/README.md b/README.md index 7c6aabf..3942b41 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,7 @@ reinstalling later finds your entity choices and custom patterns still there. ## Project documentation - [Command reference](https://github.com/GetSHIM/shim-cli/blob/main/docs/commands.md) — every command, flag and exit code +- [Cookbook](https://github.com/GetSHIM/shim-cli/blob/main/docs/cookbook.md) — recipes for getting more out of it once it runs - [Architecture](https://github.com/GetSHIM/shim-cli/blob/main/docs/architecture.md) - [Compatibility](https://github.com/GetSHIM/shim-cli/blob/main/docs/compatibility.md) - [Privacy](https://github.com/GetSHIM/shim-cli/blob/main/docs/privacy.md) diff --git a/docs/commands.md b/docs/commands.md index c587b36..194269f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,7 +1,7 @@ # Command reference Every command shim has, what it prints, and what it exits with. The README is -the tour; this is the map. +the tour, this is the map, and the [cookbook](cookbook.md) is the recipes. Everything here runs locally. No command in this document sends anything anywhere, with one exception that is marked as such: `shim watch` forwards your diff --git a/docs/cookbook.md b/docs/cookbook.md new file mode 100644 index 0000000..2d02478 --- /dev/null +++ b/docs/cookbook.md @@ -0,0 +1,254 @@ +# Cookbook + +Recipes for getting more out of shim once it is installed. The README is the +tour and [`commands.md`](commands.md) is the map; this is the part where you +have a specific problem and want the two lines that solve it. + +Everything here runs locally. Nothing in this document sends anything anywhere. + +- [Start here: make it visible](#start-here-make-it-visible) +- [Stop a secret before it leaves](#stop-a-secret-before-it-leaves) +- [Teach it your project's own secrets](#teach-it-your-projects-own-secrets) +- [Quieten a noisy tool](#quieten-a-noisy-tool) +- [Keep the last four digits](#keep-the-last-four-digits) +- [Be strict about shell commands](#be-strict-about-shell-commands) +- [See what a whole session sent](#see-what-a-whole-session-sent) +- [Use it in CI and in scripts](#use-it-in-ci-and-in-scripts) +- [Know what your client can actually do](#know-what-your-client-can-actually-do) +- [When something looks wrong](#when-something-looks-wrong) +- [Leaving](#leaving) + +## Start here: make it visible + +The first thing worth doing is watching shim catch something, on purpose, with +a value that is not yours: + +```console +shim demo claude +``` + +Then use your agent normally for an hour and ask what it saw: + +```console +shim report +``` + +The session report is the habit to build. It names what was found, where, and +what happened to it — masked, blocked, or only reported — and it ends with the +overhead shim added. If the report is empty, nothing sensitive crossed the line +that hour, which is also worth knowing. + +Records live for the session only. To keep them for 30 days: + +```console +shim config --ledger --yes +shim ledger show +``` + +The ledger holds entity *names and counts* and a scrubbed path. It never holds +the value that produced them, so it is safe to keep and useless to steal. + +## Stop a secret before it leaves + +Shim ships deliberately asymmetric defaults. Your prompt is reported and sent, +because rewriting what you typed under you is worse than telling you. Tool +results are masked, because that is content you never read. + +To turn your own prompts into a hard stop instead, edit the settings file — +the flags cover entity types, not per-direction rules. `shim config` prints its +path. Add: + +```toml +[mode] +user-prompt = "enforce" +``` + +Now a prompt with a finding is withheld, and shim writes the redacted version +to a `0600` file in your temporary directory and tells you the path. Paste the +line it gives you and carry on — your prompt reaches the model with +`` where the key was. + +The three modes are `observe` (count it, say nothing), `warn` (say it, change +nothing) and `enforce` (mask or refuse). They apply per direction, per event or +per tool, most specific first. + +## Teach it your project's own secrets + +The detector knows emails, cards, IBANs, keys and the rest. It does not know +your internal hostnames or your ticket codenames. Tell it: + +```console +shim config --custom 'PROJECT_CODENAME=\bATLAS-[0-9]{4}\b' --yes +shim config --custom-literal 'INTERNAL_HOST=db-core-01' --yes +``` + +Use `--custom-literal` whenever the value contains regex characters — a dot in +a hostname matches any character otherwise, and a pattern that is wider than +you meant is a pattern that masks half your file. + +Check it before you trust it: + +```console +printf 'deploy ATLAS-1874 to db-core-01' | shim redact +``` + +A pattern that backtracks badly is refused when you add it, not when it hangs +your hook. + +## Quieten a noisy tool + +If one tool produces findings you do not care about, narrow that tool instead +of turning a whole type off everywhere: + +```toml +[entities] +Read = ["SECRET", "DB_URI", "CREDIT_CARD"] +``` + +Now a `Read` only reports those three, while every other tool still checks +everything. This is almost always better than `shim config --disable PHONE`, +which switches phone numbers off for your prompts too. + +Bare numbers are the usual complaint — an order id that looks like a phone +number. Shim counts those separately and says so in the report rather than +masking them; if a specific tool is still noisy, narrow it here. + +## Keep the last four digits + +Support work often needs to know *which* card, without knowing the card: + +```console +shim config --reveal IBAN=4 --yes +``` + +`` instead of ``. Only `IBAN`, `CREDIT_CARD` and `PHONE` +can do this, at most four digits. `SECRET` cannot, and that refusal is +deliberate: a partial key is worth guessing at. + +## Be strict about shell commands + +A command is not a document: text going into a shell can act. Tighten that one +direction without touching the rest: + +```toml +[mode] +Bash = "enforce" +``` + +For a command or a local write, `enforce` means the call is refused rather than +rewritten, because silently editing a command the model is about to run would +change what it does. Shim tells you what it found and stops there. + +## See what a whole session sent + +The hook sees events. To see the whole conversation — bytes, tokens, and what +it would have cost — run the client through the measuring proxy: + +```console +shim watch -- claude -p "Read calc.py and explain it in one sentence." +``` + +It binds to loopback, forwards bytes unchanged, invents no request of its own, +and writes no request or response body to disk. It reports both directions +separately and reads usage off the wire rather than guessing. + +**Claude Code only.** Codex is refused, with the reason measured rather than +assumed: it takes its endpoint from its own configuration, so the proxy would +be bypassed and the session reported as empty. Copilot is out of scope, because +a custom endpoint there removes GitHub authentication altogether. + +## Use it in CI and in scripts + +`scan` follows grep's convention — exit `1` means it found something: + +```console +shim scan < notes.md || echo "clean" +``` + +```yaml +- name: No secrets in the changelog + run: git diff --name-only origin/main | xargs cat | shim scan +``` + +`redact` always exits `0` and writes the rewritten text, so it composes: + +```console +kubectl logs api-7f4 | shim redact | pbcopy +``` + +That one is worth keeping in your shell history. Pasting logs into a chat is +how most values escape, and this makes the safe version the easy version. + +Add `--json` to most commands when a script is reading the output. The text +layout is not a stable interface; the JSON is. + +## Know what your client can actually do + +Shim can only do what the client grants its hooks, and the clients differ more +than their documentation suggests. `shim doctor ` prints exactly which +events are installed and what shim can change at each one. + +| Client | Your prompt | Tool input and results | +| --- | --- | --- | +| Claude Code | Reported; withheld under `enforce` | Masked before the model reads them | +| Codex CLI | Reported; blocked under `enforce` | Not installed | +| GitHub Copilot CLI | Replaced with the redacted text | Not installed | +| VS Code | Reported; stopped before sending under `enforce` | A call is reported and denied under `enforce`; a **result is only reported** | + +**VS Code deserves a sentence of its own.** Nothing is masked there, and after +a tool has run nothing can be withheld either: by then the model has the +result, and a hook that answers `block` is read straight through. That is +measured, not assumed. So in VS Code the protection that matters is the one +that happens *before* the data moves — the prompt that is stopped, the call +that is denied. A `read_file` result never reaches the hook at all, so a file +read is covered by its path, before the read. Terminal output is inspected in +full. + +Because refusing is the only enforcement available there, tool events report by +default. Ask for the refusal when you want it: + +```toml +[mode] +outbound = "enforce" +``` + +## When something looks wrong + +```console +shim doctor claude +``` + +Doctor is the first command to run and usually the last one you need. It checks +the hook line, the events installed, the launcher in use, and the version the +archive and the package disagree about, if they do. The common answers: + +**Nothing happens at all.** In Codex, a hook does not run until you trust it: +open `/hooks`, review the shim entry, enable it. Shim cannot read that record +and does not pretend to. + +**Everything is inspected twice.** Both the marketplace plugin and `shim +install` are registered for the same client. Pick one; doctor names the command +that removes the other. + +**`No module named shim_guard`.** A hook written by 0.2.0. `shim install +` rewrites the line. + +**Your settings file is refused.** Shim will not read settings that anything +else can rewrite — a symlink, another user's file, a group-writable location. +Anything that can edit your settings can turn detection off. `shim config +--reset --yes` starts over. + +**A prompt was withheld and you do not know why.** The reason names the file +holding the redacted copy. Read it; it shows what was found and where. + +## Leaving + +```console +shim revert claude +shim ledger purge +``` + +Revert removes only shim's own hook groups and keeps everything else in the +file byte for byte, including hooks you added. What stays behind is your +settings file and, if you turned it on, the ledger — both listed above so you +can delete them yourself.