Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 7 additions & 3 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions docs/releases/0.3.3.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion plugins/shim-cli/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/shim-cli/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Binary file modified plugins/shim-cli/bin/shim.pyz
Binary file not shown.
2 changes: 1 addition & 1 deletion plugins/shim-cli/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/shim_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.2"
__version__ = "0.3.3"
3 changes: 3 additions & 0 deletions src/shim_cli/cli/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
56 changes: 31 additions & 25 deletions tests/plugins/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,50 +121,56 @@ 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
assert result.stdout == b""
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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.