From ba93e4c63502785933bd2680c02c2de056751e55 Mon Sep 17 00:00:00 2001 From: mertcan Date: Tue, 15 Sep 2026 19:40:37 +0300 Subject: [PATCH 1/2] release: 0.3.3, the Codex plugin hook inspects prompts again The Codex command in hooks/hooks.json stood down when CLAUDE_PLUGIN_ROOT was set, to keep Claude Code from running it. Codex 0.151.0 sets that variable for its own plugin hooks, so from 0.3.1 the Codex plugin hook exited before inspecting anything and Codex reported it Completed. The guard now keys on PLUGIN_ROOT, which Claude Code leaves unset and Codex sets; the failure mode becomes double inspection instead of none. The tests that run the Codex command now set both variables as Codex does and fail on the 0.3.2 command. Seen live on Codex 0.151.0: Blocked under enforce. Co-Authored-By: Claude Opus 5 (1M context) --- docs/compatibility.md | 10 ++-- docs/releases/0.3.3.md | 37 +++++++++++++ plugins/shim-cli/.claude-plugin/plugin.json | 2 +- plugins/shim-cli/.codex-plugin/plugin.json | 2 +- plugins/shim-cli/bin/shim.pyz | Bin 397807 -> 397807 bytes plugins/shim-cli/hooks/hooks.json | 2 +- pyproject.toml | 2 +- src/shim_cli/__init__.py | 2 +- tests/plugins/test_plugin.py | 56 +++++++++++--------- uv.lock | 2 +- 10 files changed, 81 insertions(+), 34 deletions(-) create mode 100644 docs/releases/0.3.3.md diff --git a/docs/compatibility.md b/docs/compatibility.md index ded9ae5..e1d3b82 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -75,9 +75,13 @@ declares `hooks/claude.json`, but Claude Code 2.1.263 also loads `.codex-plugin/plugin.json` has no field that names a hooks path. Claude Code does not expand Codex's `${PLUGIN_ROOT}`, so every prompt ran `/hooks/run-shim` and logged exit 127 beside the real hook's output. The Codex command therefore -opens with `[ -n "$CLAUDE_PLUGIN_ROOT" ] && exit 0`: Claude Code sets that -variable and the command stands down silently, while Codex does not set it and -substitutes `${PLUGIN_ROOT}` as before. If a future Codex manifest accepts a +opens with `[ -z "${PLUGIN_ROOT}" ] && exit 0`: Claude Code leaves `PLUGIN_ROOT` +unset and the command stands down silently, while Codex sets it. 0.3.1 and +0.3.2 keyed the guard on `CLAUDE_PLUGIN_ROOT` instead, which Codex 0.151.0 +sets too, so the Codex plugin hook exited before inspecting anything and +reported the prompt as `Completed`; the package route was unaffected. The +guard now fails the safe way: a Claude Code that set `PLUGIN_ROOT` would +inspect a prompt twice, not zero times. If a future Codex manifest accepts a hooks path, the file becomes `hooks/codex.json` and the guard is dropped. **A Codex hook does not run until it is trusted.** From 0.151.0 Codex holds a diff --git a/docs/releases/0.3.3.md b/docs/releases/0.3.3.md new file mode 100644 index 0000000..2f20c64 --- /dev/null +++ b/docs/releases/0.3.3.md @@ -0,0 +1,37 @@ +# shim-cli 0.3.3 + +A fix for Codex plugin installs, which inspected nothing in 0.3.1 and 0.3.2. + +## The Codex plugin hook ran again + +The plugin ships one hook file for Codex and one for Claude Code, and Claude +Code reads both. 0.3.1 made the Codex command stand down when +`CLAUDE_PLUGIN_ROOT` was set, so that Claude Code would not run it. Codex +0.151.0 sets that variable for its own plugin hooks too. The command exited +before inspecting the prompt, and Codex reported the hook as `Completed`: no +warning, no finding, and under `enforce` nothing was blocked. + +The command now stands down when `PLUGIN_ROOT` is empty, which is true under +Claude Code and false under Codex. If Claude Code ever set that variable, a +prompt would be inspected twice rather than not at all. + +Seen on Codex CLI 0.151.0 with the plugin installed from this release's tree +and `user-prompt = "enforce"`: + +``` +hook: UserPromptSubmit +hook: UserPromptSubmit Blocked +``` + +0.3.2's plugin, same prompt and settings, printed `hook: UserPromptSubmit +Completed`. Installs made with `shim install codex` were never affected. Claude +Code still reports a prompt once. + +Update with `codex plugin marketplace upgrade` and `codex plugin add +shim-cli@shim-cli`, or `uv tool upgrade shim` for the package. + +## Verified + +`python scripts/check.py` green on 3.13; CI covers 3.10. The tests that +exercise the Codex command now set both variables, as Codex does, and fail on +0.3.2's command. diff --git a/plugins/shim-cli/.claude-plugin/plugin.json b/plugins/shim-cli/.claude-plugin/plugin.json index 524a751..6af0e12 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": "0.3.2", + "version": "0.3.3", "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 879c1e4..16e7cf9 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": "0.3.2", + "version": "0.3.3", "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/bin/shim.pyz b/plugins/shim-cli/bin/shim.pyz index dada7befaf82ce2bda7f1b50a5786c659ec9eb19..8c800c3dec6b450ee118333adb0db289cb581511 100755 GIT binary patch delta 50 zcmaFASmOO+iG~)&7N!>FEi4g!EVE=2mQ0WFV=-Y2YoF-HvVEc-tLs*<==NL8Y@&Ps DFEi4g!ENjabEu9|Y$6~@5)IQOVW&1=wR@bdy(e1aG*+lsO D06-Hf diff --git a/plugins/shim-cli/hooks/hooks.json b/plugins/shim-cli/hooks/hooks.json index a3eea32..b60cac7 100644 --- a/plugins/shim-cli/hooks/hooks.json +++ b/plugins/shim-cli/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "sh -c '[ -n \"$CLAUDE_PLUGIN_ROOT\" ] && exit 0; exec \"${PLUGIN_ROOT}/hooks/run-shim\" codex \"${PLUGIN_ROOT}\"'", + "command": "sh -c '[ -z \"${PLUGIN_ROOT}\" ] && exit 0; exec \"${PLUGIN_ROOT}/hooks/run-shim\" codex \"${PLUGIN_ROOT}\"'", "timeout": 30 } ] diff --git a/pyproject.toml b/pyproject.toml index ba19e26..449c79d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shim" -version = "0.3.2" +version = "0.3.3" 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/src/shim_cli/__init__.py b/src/shim_cli/__init__.py index f9aa3e1..e19434e 100644 --- a/src/shim_cli/__init__.py +++ b/src/shim_cli/__init__.py @@ -1 +1 @@ -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index f493102..d0b1b19 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -121,25 +121,34 @@ def _codex_command() -> str: return document["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] -def test_the_codex_command_stands_down_under_claude_code() -> None: +def test_the_codex_command_keys_on_the_variable_only_codex_sets() -> None: """Claude Code loads hooks/hooks.json by convention, on top of the - hooks/claude.json its plugin.json declares, and does not expand - ${PLUGIN_ROOT}. Without this guard every Claude prompt ran `/hooks/run-shim` - and logged exit 127 next to the real hook's output. + hooks/claude.json its plugin.json declares, and sets no PLUGIN_ROOT. Codex + 0.151.0 sets PLUGIN_ROOT and CLAUDE_PLUGIN_ROOT both, so a guard on the + Claude variable turned the Codex hook into a silent no-op. """ - assert '[ -n "$CLAUDE_PLUGIN_ROOT" ] && exit 0' in _codex_command() + assert '[ -z "${PLUGIN_ROOT}" ] && exit 0' in _codex_command() + assert "CLAUDE_PLUGIN_ROOT" not in _codex_command() -def test_the_codex_command_exits_silently_when_claude_code_runs_it() -> None: +def _run_codex_command(command: str, environment: dict, tmp_path) -> object: import subprocess - result = subprocess.run( - ("/bin/sh", "-c", _codex_command()), + return subprocess.run( + ("/bin/sh", "-c", command), input=b'{"hook_event_name":"UserPromptSubmit","prompt":"AKIAIOSFODNN7EXAMPLE"}', capture_output=True, - env={"CLAUDE_PLUGIN_ROOT": "/x", "PATH": "/usr/bin:/bin"}, + env=environment | {"TMPDIR": str(tmp_path)}, check=False, - timeout=60, + timeout=120, + ) + + +def test_the_codex_command_exits_silently_when_claude_code_runs_it(tmp_path) -> None: + result = _run_codex_command( + _codex_command(), + {"CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT), "PATH": "/usr/bin:/bin"}, + tmp_path, ) assert result.returncode == 0 @@ -147,24 +156,21 @@ def test_the_codex_command_exits_silently_when_claude_code_runs_it() -> None: assert result.stderr == b"" -def test_the_codex_command_still_runs_the_launcher_under_codex(tmp_path) -> None: - """Codex substitutes ${PLUGIN_ROOT} itself and sets no CLAUDE_PLUGIN_ROOT.""" +@pytest.mark.parametrize("substituted", (True, False), ids=("text", "environment")) +def test_the_codex_command_inspects_the_prompt_under_codex( + substituted: bool, tmp_path +) -> None: import os - import subprocess - command = _codex_command().replace("${PLUGIN_ROOT}", str(PLUGIN_ROOT)) - environment = { - key: value for key, value in os.environ.items() if key != "CLAUDE_PLUGIN_ROOT" - } | {"TMPDIR": str(tmp_path)} + command = _codex_command() + if substituted: + command = command.replace("${PLUGIN_ROOT}", str(PLUGIN_ROOT)) + environment = dict(os.environ) | { + "PLUGIN_ROOT": str(PLUGIN_ROOT), + "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT), + } - result = subprocess.run( - ("/bin/sh", "-c", command), - input=b'{"hook_event_name":"UserPromptSubmit","prompt":"AKIAIOSFODNN7EXAMPLE"}', - capture_output=True, - env=environment, - check=False, - timeout=120, - ) + result = _run_codex_command(command, environment, tmp_path) assert result.returncode == 0 assert b"SECRET" in result.stdout diff --git a/uv.lock b/uv.lock index 8664894..15e6645 100644 --- a/uv.lock +++ b/uv.lock @@ -177,7 +177,7 @@ wheels = [ [[package]] name = "shim" -version = "0.3.2" +version = "0.3.3" source = { editable = "." } dependencies = [ { name = "phonenumbers" }, From 26f8dfbb2da0ee1d3632b88cd144e961da345a3b Mon Sep 17 00:00:00 2001 From: mertcan Date: Tue, 15 Sep 2026 19:46:14 +0300 Subject: [PATCH 2/2] A custom pattern added on a 0.2.0 config applies 0.2.0 wrote its default entity list by name, and the list predates CUSTOM. On an upgraded machine `shim config --custom` saved the pattern, said PASS, and it never matched. Adding a pattern now enables CUSTOM unless the same command disables it. Found in the 1.0 smoke pass (J3 then J4 on one home). Co-Authored-By: Claude Opus 5 (1M context) --- docs/commands.md | 3 ++- docs/releases/0.3.3.md | 8 ++++++++ src/shim_cli/cli/configuration.py | 3 +++ tests/cli/test_cli.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index f1ced48..5e1f36c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -275,7 +275,8 @@ shim config --remove-custom PROJECT_CODENAME --yes `--custom` takes a regular expression, `--custom-literal` takes text to match exactly — use the literal form when the value contains regex characters. Names -are `UPPER_CASE` letters, digits and underscores, up to 32 characters. +are `UPPER_CASE` letters, digits and underscores, up to 32 characters. Adding +a pattern turns on `CUSTOM` unless the same command disables it. A pattern that backtracks badly is refused before it is saved: diff --git a/docs/releases/0.3.3.md b/docs/releases/0.3.3.md index 2f20c64..801a4d6 100644 --- a/docs/releases/0.3.3.md +++ b/docs/releases/0.3.3.md @@ -30,6 +30,14 @@ Code still reports a prompt once. Update with `codex plugin marketplace upgrade` and `codex plugin add shim-cli@shim-cli`, or `uv tool upgrade shim` for the package. +## A custom pattern added on an upgraded machine applies + +0.2.0 wrote its default entity list into `config.toml` by name, and that list +predates `CUSTOM`. On a machine upgraded from 0.2.0, `shim config --custom` +saved the pattern, printed `PASS Entity settings saved.`, and the pattern never +matched. Adding a pattern now turns `CUSTOM` on, unless the same command says +`--disable CUSTOM`. + ## Verified `python scripts/check.py` green on 3.13; CI covers 3.10. The tests that diff --git a/src/shim_cli/cli/configuration.py b/src/shim_cli/cli/configuration.py index 742664a..7a606cc 100644 --- a/src/shim_cli/cli/configuration.py +++ b/src/shim_cli/cli/configuration.py @@ -18,6 +18,7 @@ from shim_cli.events.diet import DEFAULT_TRANSFORMS from shim_cli.guard import DEFAULT_ENTITIES, ENTITY_TYPES, normalize_entities from shim_cli.guard.entities import ( + CUSTOM, compile_custom, entry_source, normalize_reveal, @@ -241,6 +242,8 @@ def configure( assert policy is not None selected = set(policy.entities) selected.update(enable) + if custom or custom_literal: + selected.add(CUSTOM) selected.difference_update(disable) enabled = normalize_entities(selected) except ValueError as error: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 4098b15..a185188 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1046,6 +1046,36 @@ def test_custom_patterns_are_written_read_and_removed(monkeypatch, tmp_path) -> assert "custom" not in target.read_text(encoding="utf-8") +def test_adding_a_pattern_enables_custom_on_a_0_2_0_entity_list( + monkeypatch, tmp_path +) -> None: + from shim_cli.guard import BUILT_IN_TYPES + + target = _guard_config(monkeypatch, tmp_path) + target.parent.mkdir(parents=True) + listed = ", ".join(f'"{name}"' for name in BUILT_IN_TYPES) + target.write_text(f"enabled_entities = [{listed}]\n", encoding="utf-8") + target.chmod(0o600) + + runner.invoke(app, ["config", "--custom", r"CODENAME=\bATLAS-[0-9]{4}\b", "--yes"]) + scanned = runner.invoke(app, ["scan", "--json"], input="ship ATLAS-0042") + runner.invoke( + app, + [ + "config", + "--disable", + "CUSTOM", + "--custom-literal", + "HOST=db-core-01", + "--yes", + ], + ) + disabled = runner.invoke(app, ["scan", "--json"], input="ship ATLAS-0042") + + assert json.loads(scanned.output)["counts"] == {"CUSTOM": 1} + assert json.loads(disabled.output)["counts"] == {} + + def test_a_literal_is_written_and_matched_whole(monkeypatch, tmp_path) -> None: _guard_config(monkeypatch, tmp_path)