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/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..801a4d6 --- /dev/null +++ b/docs/releases/0.3.3.md @@ -0,0 +1,45 @@ +# 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. + +## 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 +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 dada7be..8c800c3 100755 Binary files a/plugins/shim-cli/bin/shim.pyz and b/plugins/shim-cli/bin/shim.pyz differ 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/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) 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" },