Browse Source

Add rdx-proxy subject: a redacting API proxy as an interceptor

Subjects can now provide a sidecar, built as an image from their checkout,
used for setup steps in the project (prepare) and/or as a proxy between
Claude Code and the recording proxy (interceptor). rdx-proxy benches
paroque28/claude-code-redact in proxy mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZY2T82aEHnuVC4ALzRcPh
main
Nicolas Massé 4 weeks ago
parent
commit
ef508b43a4
  1. 69
      README.md
  2. 94
      bench/runner.py
  3. 91
      bench/subjects.py
  4. 9
      containers/rdx.Containerfile
  5. 3
      tests/test_leaks.py

69
README.md

@ -36,18 +36,20 @@ Run on 2026-10-11, podman sandbox, Claude Code 2.1.283, model Haiku 4.5,
`redacted` at commit `0b4dd23` (`main`, Go version), `redacted-pr47` at `redacted` at commit `0b4dd23` (`main`, Go version), `redacted-pr47` at
commit `b88d507` ([svn-arv/redacted#47](https://github.com/svn-arv/redacted/pull/47), commit `b88d507` ([svn-arv/redacted#47](https://github.com/svn-arv/redacted/pull/47),
Rust rewrite 1.0.0), as is and with the entropy heuristic on Rust rewrite 1.0.0), as is and with the entropy heuristic on
(`redacted-pr47-heuristic`). (`redacted-pr47-heuristic`). `rdx-proxy`:
[paroque28/claude-code-redact](https://github.com/paroque28/claude-code-redact)
| Case | bare | redacted | redacted-pr47 | redacted-pr47-heuristic | at `d9c57fa` plus two local fixes (see below), 3 runs, one dot per run.
|---|---|---|---|---|
| `cat_file` | 🔴 | 🔴 | 🟢 | 🟢 | | Case | bare | redacted | redacted-pr47 | redacted-pr47-heuristic | rdx-proxy |
| `read_file` | 🔴 | 🔴 | 🟢 | 🟢 | |---|---|---|---|---|---|
| `env` | 🔴 | 🔴 | 🟢 | 🟢 | | `cat_file` | 🔴 | 🔴 | 🟢 | 🟢 | 🟠🔴🟠 |
| `unlabeled_random` | 🔴 | 🔴 | 🔴 | 🟢 | | `read_file` | 🔴 | 🔴 | 🟢 | 🟢 | 🔴🟠🔴 |
| `known_value` | 🔴 | 🔴 | 🟢 | 🟢 | | `env` | 🔴 | 🔴 | 🟢 | 🟢 | 🟠🔴🟠 |
| `tail_foreground` | 🔴 | 🔴 | 🟢 | 🟢 | | `unlabeled_random` | 🔴 | 🔴 | 🔴 | 🟢 | 🔴🔴🔴 |
| `tail_background` | 🔴 | 🔴 | 🟢 | 🟢 | | `known_value` | 🔴 | 🔴 | 🟢 | 🟢 | 🟠🟠🟠 |
| `monitor` | 🔴 | 🔴 | 🔴 | 🔴 | | `tail_foreground` | 🔴 | 🔴 | 🟢 | 🟢 | 🟠🔴🟠 |
| `tail_background` | 🔴 | 🔴 | 🟢 | 🟢 | 🟠🟠🟠 |
| `monitor` | 🔴 | 🔴 | 🔴 | 🔴 | 🟠🔴🔴 |
🔴 sent to the Anthropic API · 🟠 not sent to the API, but written to the 🔴 sent to the Anthropic API · 🟠 not sent to the API, but written to the
transcript on disk · 🟢 neither transcript on disk · 🟢 neither
@ -70,8 +72,24 @@ transcript on disk · 🟢 neither
heuristic (off by default). heuristic (off by default).
- **redacted-pr47-heuristic**: with the heuristic on, `unlabeled_random` is - **redacted-pr47-heuristic**: with the heuristic on, `unlabeled_random` is
caught too (`cache_ref: [REDACTED ...B95I]`, pattern `secret_value`). caught too (`cache_ref: [REDACTED ...B95I]`, pattern `secret_value`).
- **rdx-proxy**: as an API proxy, it sees everything sent to the API,
`Monitor` notifications included, but the transcript always keeps the
secret, so 🟠 is its best result. Apart from the declared `known_value`,
only its entropy detector catches the canaries: Shannon entropy of at least
4.5 bits per character, on a value after `=` or in quotes. A random
32-character value stays under that threshold about one time in three
(68/200 in a direct test of its redactor), and every 🔴 above is a canary
under 4.5 bits, every 🟠 one at 4.5 or more. `unlabeled_random`
(`cache_ref: <value>`) matches no detector. Its context detector only knows
quoted values and `export` lines.
At `d9c57fa`, the proxy answers 500 to every request of a project with
`.redaction_rules` (a `NameError` on `_cache` since `b6c4606`), so Claude
Code cannot work at all, and its streaming responses are cut off because the
HTTP client is closed before the stream is read. The bench ran a local
branch with both fixed (`_cache` → `cache`, a client owned by the stream).
- **`monitor`**: `Monitor` events reach the model as notifications that never - **`monitor`**: `Monitor` events reach the model as notifications that never
go through PostToolUse hooks, so a PostToolUse filter cannot see them at all. go through PostToolUse hooks, so a PostToolUse filter cannot see them at all.
Only an API proxy such as `rdx-proxy` can catch them.
- **`tail_foreground`**: when the Bash timeout expires, Claude Code moves the - **`tail_foreground`**: when the Bash timeout expires, Claude Code moves the
command to the background and the model reads its output file with `Read`, command to the background and the model reads its output file with `Read`,
so this case ends up like `tail_background`. so this case ends up like `tail_background`.
@ -98,6 +116,11 @@ re-run the bench rather than relying on this table.
- **No real credential in the Claude Code container**, so the `env` case cannot - **No real credential in the Claude Code container**, so the `env` case cannot
expose it. The proxy gets the real one, mounted read-only from a temporary expose it. The proxy gets the real one, mounted read-only from a temporary
file in memory-backed storage (`$XDG_RUNTIME_DIR`). file in memory-backed storage (`$XDG_RUNTIME_DIR`).
- **Interceptor**: a subject that is a redacting API proxy (`rdx-proxy`) runs
in a third container on the internal network, between Claude Code and the
recording proxy, with the same hardening. Its image is built from the
subject's checkout and tagged with its commit. The project is mounted
read-only at the same path (`/work`) as in the Claude Code container.
- **No network egress** except through the proxy: any traffic that bypasses - **No network egress** except through the proxy: any traffic that bypasses
it fails instead of going unnoticed. it fails instead of going unnoticed.
- The container's `~/.claude` is a throwaway directory: your `~/.claude` is - The container's `~/.claude` is a throwaway directory: your `~/.claude` is
@ -152,11 +175,23 @@ one.
The heuristic is read from the global config only, so the hook runs with a The heuristic is read from the global config only, so the hook runs with a
`HOME` of its own holding `.config/redacted/config.yaml`. `HOME` of its own holding `.config/redacted/config.yaml`.
- `rdx-proxy`: [paroque28/claude-code-redact](https://github.com/paroque28/claude-code-redact)
in proxy mode, its recommended mode. Checkout from `RDX_SRC`. It redacts
requests on their way to the API and puts the real values back in the
responses, so the transcript on disk always holds the secret: its best
possible result is 🟠. rdx redacts nothing for a project without
`.redaction_rules`, so the subject writes an empty one (built-in detectors
only), then performs the file steps of `rdx setup --proxy` with rdx's own
code (deny permissions in `.claude/settings.json`, `RDX.md`, `CLAUDE.md`).
To test another project, add a `Subject` subclass that provides `files()` To test another project, add a `Subject` subclass that provides `files()`
(binaries copied into the sandbox), `settings(where)` (the hooks, given each (binaries copied into the sandbox), `settings(where)` (the hooks, given each
file's path inside the sandbox) and, if possible, `declare_known()`, then file's path inside the sandbox) and, if possible, `declare_known()`, then
register it in `SUBJECTS`. A hook binary must be able to run on Fedora minimal register it in `SUBJECTS`. A hook binary must be able to run on Fedora minimal
(glibc; a static binary is ideal). (glibc; a static binary is ideal). A subject that is not a hook provides a
`Sidecar` (its checkout and a Containerfile) and uses it in `prepare()` (setup
steps run in the project) and/or `interceptor()` (a proxy inserted before the
recording proxy).
## Running ## Running
@ -165,6 +200,7 @@ uv run pytest # everything
uv run pytest --subject bare --case monitor # a single case uv run pytest --subject bare --case monitor # a single case
REDACTED_BIN=~/git/redacted/redacted uv run pytest --subject redacted REDACTED_BIN=~/git/redacted/redacted uv run pytest --subject redacted
REDACTED_PR47_BIN=~/git/redacted-pr47/target/release/redacted uv run pytest --subject redacted-pr47 --subject redacted-pr47-heuristic REDACTED_PR47_BIN=~/git/redacted-pr47/target/release/redacted uv run pytest --subject redacted-pr47 --subject redacted-pr47-heuristic
RDX_SRC=~/git/claude-code-redact uv run pytest --subject rdx-proxy
uv run pytest --model claude-sonnet-5 # another model (default: Haiku 4.5) uv run pytest --model claude-sonnet-5 # another model (default: Haiku 4.5)
uv run pytest --claude-version 2.1.292 # another Claude Code version (podman) uv run pytest --claude-version 2.1.292 # another Claude Code version (podman)
uv run pytest --sandbox host # no container uv run pytest --sandbox host # no container
@ -179,7 +215,8 @@ report; in host mode, leaves the sessions in `~/.claude/projects`).
At the end of a run, a case × subject matrix is printed. It is written, along At the end of a run, a case × subject matrix is printed. It is written, along
with all the evidence, to `reports/<timestamp>/<sandbox>/`: `stream.jsonl`, with all the evidence, to `reports/<timestamp>/<sandbox>/`: `stream.jsonl`,
`wire.txt` (request bodies), `transcript.jsonl`, `settings.json`. `wire.txt` (request bodies), `transcript.jsonl`, `settings.json` and, for an
interceptor, `interceptor.log`.
## Caveats ## Caveats
@ -192,4 +229,6 @@ with all the evidence, to `reports/<timestamp>/<sandbox>/`: `stream.jsonl`,
(telemetry…) is blocked. The bench therefore measures what Claude Code sends (telemetry…) is blocked. The bench therefore measures what Claude Code sends
to the API, not what it would try to send elsewhere. to the API, not what it would try to send elsewhere.
- In host mode, the rest of the traffic is neither blocked nor recorded, and a - In host mode, the rest of the traffic is neither blocked nor recorded, and a
filter may read its global config (`~/.config/redacted/…`). filter may read its global config (`~/.config/redacted/…`). The `rdx-proxy`
interceptor runs from its checkout with `uv run`, which creates a `.venv`
there.

94
bench/runner.py

@ -5,6 +5,9 @@ Two sandboxes:
network with no egress and a dummy credential. A proxy container is the network with no egress and a dummy credential. A proxy container is the
only way out; it records every request and swaps in the real credential. only way out; it records every request and swaps in the real credential.
- HostSandbox: Claude Code on the host, behind an in-process recording proxy. - HostSandbox: Claude Code on the host, behind an in-process recording proxy.
A subject's interceptor (a redacting proxy) sits between Claude Code and the
recording proxy, so what is recorded is what the interceptor let through.
""" """
from __future__ import annotations from __future__ import annotations
@ -12,6 +15,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil import shutil
import socket
import subprocess import subprocess
import tempfile import tempfile
import time import time
@ -20,6 +24,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from .proxy import RecordingProxy from .proxy import RecordingProxy
from .subjects import Interceptor, Sidecar
CLAUDE_BIN = os.environ.get("BENCH_CLAUDE_BIN", "claude") CLAUDE_BIN = os.environ.get("BENCH_CLAUDE_BIN", "claude")
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@ -104,18 +109,29 @@ class HostSandbox:
config_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) config_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
self.projects = config_dir / "projects" self.projects = config_dir / "projects"
def exec(self, sidecar: Sidecar, argv: list[str], project: Path) -> None:
subprocess.run([*sidecar.host_cmd, *argv], cwd=project, check=True, capture_output=True,
env=clean_env(), timeout=300)
def run(self, prompt, *, session_id, model, project, settings, subject_files, env, artifacts, timeout, def run(self, prompt, *, session_id, model, project, settings, subject_files, env, artifacts, timeout,
keep_transcripts) -> RawRun: keep_transcripts, interceptor: Interceptor | None = None) -> RawRun:
settings_path = artifacts / "settings.json" settings_path = artifacts / "settings.json"
settings_path.write_text(json.dumps(settings, indent=2)) settings_path.write_text(json.dumps(settings, indent=2))
proxy = RecordingProxy().start() proxy = RecordingProxy().start()
sidecar = None
try: try:
base_url = proxy.base_url
if interceptor:
sidecar, base_url = self._start_interceptor(interceptor, proxy.base_url, project, artifacts)
proc = subprocess.run( proc = subprocess.run(
[CLAUDE_BIN, *claude_args(prompt, session_id, model, str(settings_path))], [CLAUDE_BIN, *claude_args(prompt, session_id, model, str(settings_path))],
cwd=project, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL, cwd=project, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL,
env=clean_env({"ANTHROPIC_BASE_URL": proxy.base_url, **env}), env=clean_env({"ANTHROPIC_BASE_URL": base_url, **env}),
) )
finally: finally:
if sidecar:
sidecar.terminate()
sidecar.wait(timeout=10)
proxy.stop() proxy.stop()
paths = list(self.projects.glob(f"*/{session_id}*")) paths = list(self.projects.glob(f"*/{session_id}*"))
transcript = "\n".join(_read_tree(p) if p.is_dir() else p.read_text(errors="replace") for p in paths) transcript = "\n".join(_read_tree(p) if p.is_dir() else p.read_text(errors="replace") for p in paths)
@ -124,6 +140,28 @@ class HostSandbox:
shutil.rmtree(p) if p.is_dir() else p.unlink() shutil.rmtree(p) if p.is_dir() else p.unlink()
return RawRun(proc.returncode, proc.stdout, proc.stderr, proxy.wire_text(), transcript) return RawRun(proc.returncode, proc.stdout, proc.stderr, proxy.wire_text(), transcript)
@staticmethod
def _start_interceptor(icp: Interceptor, upstream: str, project: Path,
artifacts: Path) -> tuple[subprocess.Popen, str]:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
argv = [a.format(host="127.0.0.1", port=port) for a in icp.argv]
proc = subprocess.Popen([*icp.sidecar.host_cmd, *argv], cwd=project, stdin=subprocess.DEVNULL,
stdout=(artifacts / "interceptor.log").open("w"), stderr=subprocess.STDOUT,
env=clean_env({icp.upstream_env: upstream}))
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"interceptor exited: {(artifacts / 'interceptor.log').read_text()[-2000:]}")
try:
socket.create_connection(("127.0.0.1", port), timeout=1).close()
return proc, f"http://127.0.0.1:{port}"
except OSError:
time.sleep(0.2)
proc.kill()
raise RuntimeError("interceptor did not start listening")
def close(self) -> None: def close(self) -> None:
pass pass
@ -202,18 +240,31 @@ class PodmanSandbox:
self.auth_file.touch(mode=0o600) self.auth_file.touch(mode=0o600)
self.auth_file.write_text(auth) self.auth_file.write_text(auth)
def _build(self, tag: str, containerfile: str, **build_args: str) -> None: def _build(self, tag: str, containerfile: str, context: Path = ROOT, **build_args: str) -> None:
if _podman("image", "exists", tag, check=False).returncode == 0: if _podman("image", "exists", tag, check=False).returncode == 0:
return return
args = [f"--build-arg={k}={v}" for k, v in build_args.items()] args = [f"--build-arg={k}={v}" for k, v in build_args.items()]
_podman("build", "-q", "-f", str(ROOT / "containers" / containerfile), *args, "-t", tag, str(ROOT), _podman("build", "-q", "-f", str(ROOT / "containers" / containerfile), *args, "-t", tag, str(context),
timeout=1200) timeout=1200)
def _sidecar_image(self, sidecar: Sidecar) -> str:
image = f"leak-bench-{sidecar.name}:{sidecar.tag}"
self._build(image, sidecar.containerfile, context=sidecar.checkout)
return image
def _sidecar_args(self) -> list[str]:
return [f"--userns=keep-id:uid={self.UID},gid={self.UID}", "--network", self.INTERNAL,
"--cap-drop=all", "--security-opt=no-new-privileges", "--read-only", "--tmpfs", "/tmp:rw,U"]
def exec(self, sidecar: Sidecar, argv: list[str], project: Path) -> None:
_podman("run", "--rm", *self._sidecar_args(), "-v", f"{project}:/work:z", "-w", "/work",
self._sidecar_image(sidecar), *argv, timeout=300)
def close(self) -> None: def close(self) -> None:
shutil.rmtree(self._secret_dir, ignore_errors=True) shutil.rmtree(self._secret_dir, ignore_errors=True)
def run(self, prompt, *, session_id, model, project, settings, subject_files, env, artifacts, timeout, def run(self, prompt, *, session_id, model, project, settings, subject_files, env, artifacts, timeout,
keep_transcripts) -> RawRun: keep_transcripts, interceptor: Interceptor | None = None) -> RawRun:
work = Path(tempfile.mkdtemp(prefix="lb-", dir=artifacts.parent)) work = Path(tempfile.mkdtemp(prefix="lb-", dir=artifacts.parent))
config, records, staged = work / "claude-config", work / "records", work / "subject" config, records, staged = work / "claude-config", work / "records", work / "subject"
for d in (config, records, staged): for d in (config, records, staged):
@ -226,24 +277,34 @@ class PodmanSandbox:
shutil.copy2(staged / "settings.json", artifacts / "settings.json") shutil.copy2(staged / "settings.json", artifacts / "settings.json")
tag = session_id[:8] tag = session_id[:8]
proxy_name, claude_name = f"lb-proxy-{tag}", f"lb-claude-{tag}" proxy_name, claude_name, icp_name = f"lb-proxy-{tag}", f"lb-claude-{tag}", f"lb-icp-{tag}"
userns = f"--userns=keep-id:uid={self.UID},gid={self.UID}" userns = f"--userns=keep-id:uid={self.UID},gid={self.UID}"
_podman("run", "-d", "--rm", "--init", "--name", proxy_name, userns, _podman("run", "-d", "--rm", "--init", "--name", proxy_name, userns,
"--network", f"{self.INTERNAL},{self.EGRESS}", "--network", f"{self.INTERNAL},{self.EGRESS}",
"--cap-drop=all", "--security-opt=no-new-privileges", "--read-only", "--cap-drop=all", "--security-opt=no-new-privileges", "--read-only",
"-v", f"{self.auth_file}:/run/secrets/bench-auth:ro,z", "-v", f"{self.auth_file}:/run/secrets/bench-auth:ro,z",
"-v", f"{records}:/records:Z", self.PROXY_IMAGE) "-v", f"{records}:/records:Z", self.PROXY_IMAGE)
base_url = f"http://{proxy_name}:8080"
try: try:
self._wait_ready(proxy_name) self._wait_ready(proxy_name, "listening")
if interceptor:
argv = [a.format(host="0.0.0.0", port=8080) for a in interceptor.argv]
# Same project path as Claude Code: it may locate the project from the system prompt.
_podman("run", "-d", "--rm", "--init", "--name", icp_name, *self._sidecar_args(),
"-v", f"{project}:/work:ro,z", "-w", "/work",
"-e", f"{interceptor.upstream_env}={base_url}",
self._sidecar_image(interceptor.sidecar), *argv)
self._wait_ready(icp_name, interceptor.ready)
base_url = f"http://{icp_name}:8080"
cmd = ["podman", "run", "--rm", "-i", "--name", claude_name, userns, cmd = ["podman", "run", "--rm", "-i", "--name", claude_name, userns,
"--network", self.INTERNAL, "--network", self.INTERNAL,
"--cap-drop=all", "--security-opt=no-new-privileges", "--read-only", "--cap-drop=all", "--security-opt=no-new-privileges", "--read-only",
"--pids-limit=1024", "--memory=4g", "--pids-limit=1024", "--memory=4g",
"--tmpfs", "/tmp:rw,U", "--tmpfs", "/home/bench:rw,U", "--tmpfs", "/tmp:rw,U", "--tmpfs", "/home/bench:rw,U",
"-v", f"{config}:/home/bench/.claude:Z", "-v", f"{config}:/home/bench/.claude:Z",
"-v", f"{project}:/work:Z", "-v", f"{project}:/work:z",
"-v", f"{staged}:{self.subject_dir}:ro,Z", "-v", f"{staged}:{self.subject_dir}:ro,Z",
"-e", f"ANTHROPIC_BASE_URL=http://{proxy_name}:8080"] "-e", f"ANTHROPIC_BASE_URL={base_url}"]
for k, v in {**self.dummy_env, **env}.items(): for k, v in {**self.dummy_env, **env}.items():
cmd += ["-e", f"{k}={v}"] cmd += ["-e", f"{k}={v}"]
cmd += [self.claude_image, *claude_args(prompt, session_id, model, f"{self.subject_dir}/settings.json")] cmd += [self.claude_image, *claude_args(prompt, session_id, model, f"{self.subject_dir}/settings.json")]
@ -254,6 +315,10 @@ class PodmanSandbox:
_podman("kill", claude_name, check=False) _podman("kill", claude_name, check=False)
raise raise
finally: finally:
if interceptor:
logs = _podman("logs", icp_name, check=False)
(artifacts / "interceptor.log").write_text(logs.stdout + logs.stderr)
_podman("kill", icp_name, check=False)
_podman("kill", proxy_name, check=False) _podman("kill", proxy_name, check=False)
wire_file = records / "wire.jsonl" wire_file = records / "wire.jsonl"
@ -266,23 +331,24 @@ class PodmanSandbox:
return RawRun(proc.returncode, proc.stdout, proc.stderr, wire, transcript) return RawRun(proc.returncode, proc.stdout, proc.stderr, wire, transcript)
@staticmethod @staticmethod
def _wait_ready(name: str, timeout: float = 20) -> None: def _wait_ready(name: str, line: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
if "listening" in _podman("logs", name, check=False).stdout: logs = _podman("logs", name, check=False)
if line in logs.stdout + logs.stderr:
return return
time.sleep(0.2) time.sleep(0.2)
raise RuntimeError(f"proxy container {name} did not start: {_podman('logs', name, check=False).stderr}") raise RuntimeError(f"container {name} did not start: {_podman('logs', name, check=False).stderr}")
def run_claude(prompt: str, *, sandbox, project: Path, settings: dict, subject_files: dict[str, Path], def run_claude(prompt: str, *, sandbox, project: Path, settings: dict, subject_files: dict[str, Path],
artifacts: Path, model: str, env: dict[str, str] | None = None, timeout: int = 600, artifacts: Path, model: str, env: dict[str, str] | None = None, timeout: int = 600,
keep_transcripts: bool = False) -> Run: keep_transcripts: bool = False, interceptor: Interceptor | None = None) -> Run:
artifacts.mkdir(parents=True, exist_ok=True) artifacts.mkdir(parents=True, exist_ok=True)
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
raw = sandbox.run(prompt, session_id=session_id, model=model, project=project, settings=settings, raw = sandbox.run(prompt, session_id=session_id, model=model, project=project, settings=settings,
subject_files=subject_files, env=env or {}, artifacts=artifacts, timeout=timeout, subject_files=subject_files, env=env or {}, artifacts=artifacts, timeout=timeout,
keep_transcripts=keep_transcripts) keep_transcripts=keep_transcripts, interceptor=interceptor)
events = [] events = []
for line in raw.stdout.splitlines(): for line in raw.stdout.splitlines():

91
bench/subjects.py

@ -9,6 +9,11 @@ Files are copied into the sandbox: `settings(where)` receives, for each
`files()` name, the path the file is visible at (a container path under podman). `files()` name, the path the file is visible at (a container path under podman).
A name may contain `/` to lay out a directory tree. A name may contain `/` to lay out a directory tree.
Hook binaries must run in the sandbox image (Fedora minimal, glibc). Hook binaries must run in the sandbox image (Fedora minimal, glibc).
A subject that is not a hook (e.g. a redacting API proxy) runs as a sidecar:
an image built from its checkout under podman, a command from that checkout on
the host. `prepare()` may run it once in the project (setup steps), and
`interceptor()` inserts it between Claude Code and the recording proxy.
""" """
from __future__ import annotations from __future__ import annotations
@ -16,9 +21,41 @@ from __future__ import annotations
import os import os
import re import re
import shutil import shutil
import subprocess
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable
# Runs a sidecar command with the project as working directory.
RunSidecar = Callable[["Sidecar", list[str]], None]
@dataclass
class Sidecar:
"""A program the subject runs next to Claude Code, from a local checkout."""
name: str # podman image: leak-bench-<name>:<tag>
checkout: Path # build context under podman, project for `host_cmd` on the host
containerfile: str # in containers/
host_cmd: list[str] # prefix that runs a command from the checkout on the host
@property
def tag(self) -> str:
"""The checkout's commit, or a stash commit when it has local changes."""
git = ["git", "-C", str(self.checkout)]
rev = subprocess.run([*git, "stash", "create"], capture_output=True, text=True).stdout.strip()
rev = rev or subprocess.run([*git, "rev-parse", "HEAD"], capture_output=True, text=True,
check=True).stdout.strip()
return rev[:12]
@dataclass
class Interceptor:
"""A sidecar proxy between Claude Code and the recording proxy."""
sidecar: Sidecar
argv: list[str] # `{host}` and `{port}` are filled in by the sandbox
upstream_env: str # env var that receives the recording proxy's base URL
ready: str # log line printed once it listens
@dataclass @dataclass
@ -42,6 +79,12 @@ class Subject:
"""Tell the subject about values to always redact. False if unsupported.""" """Tell the subject about values to always redact. False if unsupported."""
return False return False
def prepare(self, project: Path, run: RunSidecar) -> None:
"""Set the project up, after the case and `declare_known` wrote theirs."""
def interceptor(self) -> Interceptor | None:
return None
class Bare(Subject): class Bare(Subject):
def __init__(self) -> None: def __init__(self) -> None:
@ -114,9 +157,57 @@ class RedactedHeuristic(Redacted):
return {"hooks": {"PostToolUse": [{"hooks": [{"type": "command", "command": cmd}]}]}} return {"hooks": {"PostToolUse": [{"hooks": [{"type": "command", "command": cmd}]}]}}
class RdxProxy(Subject):
"""github.com/paroque28/claude-code-redact (`rdx`) in proxy mode.
An API proxy: it redacts requests on their way to the API and restores the
real values in responses, so the transcript on disk keeps the secret. It
only redacts for projects with a .redaction_rules file, found through the
"Primary working directory" of the system prompt. `prepare` performs the
file steps of `rdx setup --proxy` (deny permissions in
.claude/settings.json, RDX.md, CLAUDE.md) with rdx's own code. Checkout from
RDX_SRC.
"""
SETUP = ("from pathlib import Path; from rdx.setup import setup as s; p = Path.cwd(); "
"f = p / s.SETTINGS_FILE; c = s._read_settings(f); s._remove_rdx_hooks(c); "
"s._merge_deny_permissions(c); s._write_settings(f, c); "
"s.write_rdx_md(p, mode='proxy'); s._ensure_claude_md_includes_rdx(p)")
def __init__(self) -> None:
super().__init__("rdx-proxy")
src = os.environ.get("RDX_SRC")
self.sidecar = Sidecar("rdx", Path(src).expanduser() if src else Path(), "rdx.Containerfile",
["uv", "run", "--quiet", "--project", src or ""])
def available(self) -> str | None:
if not os.environ.get("RDX_SRC") or not (self.sidecar.checkout / "src/rdx").is_dir():
return "claude-code-redact checkout not found (set RDX_SRC)"
return None
def declare_known(self, project: Path, values: list[str]) -> bool:
assert not any("'" in v for v in values), "value cannot be quoted literally"
rules = "".join(f" - id: bench-known-{i}\n pattern: '{v}'\n is_regex: false\n category: KEY\n"
for i, v in enumerate(values))
(project / ".redaction_rules").write_text("rules:\n" + rules)
return True
def prepare(self, project: Path, run: RunSidecar) -> None:
rules = project / ".redaction_rules"
if not rules.exists():
rules.write_text("rules: []\n") # without it, rdx passes everything through
run(self.sidecar, ["python", "-c", self.SETUP])
def interceptor(self) -> Interceptor:
return Interceptor(self.sidecar,
["python", "-m", "uvicorn", "rdx.proxy.server:app", "--host", "{host}", "--port", "{port}"],
upstream_env="RDX_UPSTREAM_URL", ready="Uvicorn running")
SUBJECTS: dict[str, Subject] = {s.name: s for s in [ SUBJECTS: dict[str, Subject] = {s.name: s for s in [
Bare(), Bare(),
Redacted(), Redacted(),
Redacted("redacted-pr47", env_var="REDACTED_PR47_BIN", which=False), Redacted("redacted-pr47", env_var="REDACTED_PR47_BIN", which=False),
RedactedHeuristic(), RedactedHeuristic(),
RdxProxy(),
]} ]}

9
containers/rdx.Containerfile

@ -0,0 +1,9 @@
# claude-code-redact (rdx), built from a local checkout used as the build context.
# podman build -f containers/rdx.Containerfile -t leak-bench-rdx:<commit> ~/git/claude-code-redact
ARG FEDORA=44
FROM registry.fedoraproject.org/fedora-minimal:${FEDORA}
RUN microdnf -y install --setopt=install_weak_deps=0 python3 && microdnf clean all
COPY . /src
RUN python3 -m venv /app && /app/bin/pip install --no-cache-dir /src && rm -rf /src
ENV PATH=/app/bin:$PATH HOME=/tmp
USER 1000:1000

3
tests/test_leaks.py

@ -36,6 +36,7 @@ def test_leak(subject, case_name, tmp_path, request, report_dir, record, sandbox
if scenario.known and not subject.declare_known(project, [scenario.secret]): if scenario.known and not subject.declare_known(project, [scenario.secret]):
record(**row, cell="n/a") record(**row, cell="n/a")
pytest.skip(f"{subject.name} cannot declare known values") pytest.skip(f"{subject.name} cannot declare known values")
subject.prepare(project, lambda sidecar, argv: sandbox.exec(sidecar, argv, project))
if scenario.writer: if scenario.writer:
scenario.writer.start() scenario.writer.start()
@ -44,7 +45,7 @@ def test_leak(subject, case_name, tmp_path, request, report_dir, record, sandbox
scenario.prompt, sandbox=sandbox, project=project, settings=subject.settings(where), scenario.prompt, sandbox=sandbox, project=project, settings=subject.settings(where),
subject_files=files, model=opt("model"), env=scenario.env, subject_files=files, model=opt("model"), env=scenario.env,
artifacts=report_dir / sandbox.name / subject.name / case_name / f"attempt{attempt}", artifacts=report_dir / sandbox.name / subject.name / case_name / f"attempt{attempt}",
keep_transcripts=opt("keep_transcripts"), keep_transcripts=opt("keep_transcripts"), interceptor=subject.interceptor(),
) )
finally: finally:
if scenario.writer: if scenario.writer:

Loading…
Cancel
Save