1 changed files with 110 additions and 108 deletions
@ -1,129 +1,131 @@ |
|||
# claude-leak-bench |
|||
|
|||
Banc de test de bout en bout : un secret témoin (*canary*) sort-il de la machine |
|||
quand Claude Code travaille ? On le mesure sur Claude Code nu, puis avec un |
|||
filtre (hook, plugin…) pour vérifier qu'il bloque vraiment la fuite. |
|||
End-to-end test bench: does a canary secret leave the machine while Claude |
|||
Code is working? It is measured on bare Claude Code first, then with a filter |
|||
(hook, plugin…) to check that the filter actually stops the leak. |
|||
|
|||
## Principe |
|||
## How it works |
|||
|
|||
Chaque test : |
|||
Each test: |
|||
|
|||
1. crée un projet jetable et y place un secret témoin, unique à l'exécution ; |
|||
2. lance `claude -p` derrière un **proxy local qui enregistre tout ce qui part |
|||
vers l'API** (`ANTHROPIC_BASE_URL`) ; |
|||
3. vérifie dans le `stream-json` que le modèle a bien fait l'action demandée. |
|||
Sinon le résultat est **non concluant** (skip), jamais « propre » ; |
|||
4. cherche le témoin à trois endroits : |
|||
1. creates a throwaway project and plants a canary secret in it, unique to the run; |
|||
2. runs `claude -p` behind a **local proxy that records everything sent to the |
|||
API** (`ANTHROPIC_BASE_URL`); |
|||
3. checks in the `stream-json` output that the model really performed the |
|||
requested action. If not, the result is **inconclusive** (skip), never "clean"; |
|||
4. looks for the canary in three places: |
|||
|
|||
| Lettre | Où | Ce que ça prouve | |
|||
| Letter | Where | What it proves | |
|||
|---|---|---| |
|||
| `w` | trafic vers l'API | le secret est parti chez Anthropic : c'est la référence | |
|||
| `t` | transcript `~/.claude/projects/…/<session>.jsonl` | le secret est écrit sur disque | |
|||
| `a` | réponse finale du modèle | indicatif seulement | |
|||
| `w` | traffic to the API | the secret was sent to Anthropic: this is the reference | |
|||
| `t` | transcript `~/.claude/projects/…/<session>.jsonl` | the secret was written to disk | |
|||
| `a` | the model's final answer | informative only | |
|||
|
|||
Dans tous les cas, Claude Code tourne avec `--setting-sources project` (tes |
|||
hooks globaux sont ignorés), des `--settings` propres au test et |
|||
`--strict-mcp-config`. |
|||
In every mode, Claude Code runs with `--setting-sources project` (your global |
|||
hooks are ignored), test-specific `--settings` and `--strict-mcp-config`. |
|||
|
|||
## Bac à sable podman (par défaut) |
|||
## Podman sandbox (default) |
|||
|
|||
``` |
|||
┌──────────── réseau leak-bench-internal (--internal, sans sortie) ────────────┐ |
|||
┌──────────── network leak-bench-internal (--internal, no egress) ─────────────┐ |
|||
│ │ |
|||
│ conteneur Claude Code conteneur proxy │ |
|||
│ - jeton factice - enregistre chaque requête │──► api.anthropic.com |
|||
│ - rootfs en lecture seule - remplace le jeton factice par le vrai │ (réseau leak-bench-egress) |
|||
│ - cap-drop all, no-new-privileges - seul accès vers l'extérieur │ |
|||
│ Claude Code container proxy container │ |
|||
│ - dummy token - records every request │──► api.anthropic.com |
|||
│ - read-only rootfs - swaps the dummy token for the real one │ (network leak-bench-egress) |
|||
│ - cap-drop all, no-new-privileges - the only way out │ |
|||
└──────────────────────────────────────────────────────────────────────────────┘ |
|||
``` |
|||
|
|||
- **Image Claude Code** (`containers/claude.Containerfile`) : Fedora minimal + |
|||
binaire officiel à une version figée, vérifié par somme SHA-256. Par défaut, |
|||
la même version que le `claude` de l'hôte (`--claude-version` pour une autre). |
|||
L'image est construite automatiquement au premier lancement. |
|||
- **Aucun vrai identifiant dans le conteneur Claude Code** : le cas `env` ne |
|||
peut donc pas l'exposer. Le proxy reçoit le vrai, monté en lecture seule |
|||
depuis un fichier temporaire en mémoire (`$XDG_RUNTIME_DIR`). |
|||
- **Aucune sortie réseau** sinon le proxy : tout trafic qui ne passe pas par |
|||
lui échoue au lieu de passer inaperçu. |
|||
- `~/.claude` du conteneur est un dossier jetable : ton `~/.claude` n'est ni lu |
|||
ni modifié. |
|||
- Rootless, `--userns=keep-id` : les fichiers du projet monté appartiennent à |
|||
ton utilisateur des deux côtés. |
|||
|
|||
Identifiant utilisé par le proxy, dans cet ordre : `BENCH_AUTH` |
|||
(`oauth:<jeton>` ou `apikey:<clé>`), `ANTHROPIC_API_KEY`, |
|||
`CLAUDE_CODE_OAUTH_TOKEN`, puis le jeton d'accès claude.ai de |
|||
`~/.claude/.credentials.json`. Ce dernier est seulement lu, jamais rafraîchi : |
|||
s'il expire dans moins de 30 min, le banc s'arrête et demande de lancer |
|||
`claude -p hi` sur l'hôte. |
|||
|
|||
`--sandbox host` lance plutôt le `claude` de l'hôte, derrière un proxy dans le |
|||
processus de test et avec un environnement réduit à une liste fixe de |
|||
variables. Plus rapide à mettre en place, mais sans isolation réseau ni |
|||
fichiers. |
|||
|
|||
## Cas (`bench/cases.py`) |
|||
|
|||
| Cas | Scénario | |
|||
- **Claude Code image** (`containers/claude.Containerfile`): Fedora minimal + |
|||
the official binary at a pinned version, verified by SHA-256 checksum. By |
|||
default, the same version as the host's `claude` (`--claude-version` for |
|||
another one). The image is built automatically on first run. |
|||
- **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 |
|||
file in memory-backed storage (`$XDG_RUNTIME_DIR`). |
|||
- **No network egress** except through the proxy: any traffic that bypasses |
|||
it fails instead of going unnoticed. |
|||
- The container's `~/.claude` is a throwaway directory: your `~/.claude` is |
|||
neither read nor modified. The only exception is the feature flag cache |
|||
(`cachedGrowthBookFeatures` from your `~/.claude.json`), copied into it so |
|||
that tools enabled by remote flags, such as `Monitor`, are available in the |
|||
sandbox too. It holds no account data and no credentials. |
|||
- Rootless, `--userns=keep-id`: files in the mounted project belong to your |
|||
user on both sides. |
|||
|
|||
Credential used by the proxy, in this order: `BENCH_AUTH` |
|||
(`oauth:<token>` or `apikey:<key>`), `ANTHROPIC_API_KEY`, |
|||
`CLAUDE_CODE_OAUTH_TOKEN`, then the claude.ai access token from |
|||
`~/.claude/.credentials.json`. The latter is only read, never refreshed: if it |
|||
expires in less than 30 min, the bench stops and asks you to run |
|||
`claude -p hi` on the host. |
|||
|
|||
`--sandbox host` runs the host's `claude` instead, behind an in-process proxy |
|||
and with an environment reduced to a fixed list of variables. Quicker to set |
|||
up, but with no network or file isolation. |
|||
|
|||
## Cases (`bench/cases.py`) |
|||
|
|||
| Case | Scenario | |
|||
|---|---| |
|||
| `cat_file` | `cat config.env` contenant `DB_PASSWORD=<témoin>` | |
|||
| `read_file` | idem avec l'outil `Read` | |
|||
| `env` | `env`, témoin dans `BENCH_DB_PASSWORD` | |
|||
| `unlabeled_random` | valeur aléatoire sous une clé neutre (`cache_ref:`) | |
|||
| `known_value` | phrase de passe peu aléatoire dans du texte libre, déclarée à l'avance au filtre | |
|||
| `tail_foreground` | `tail -f` au premier plan ; les lignes avec le témoin arrivent après le démarrage | |
|||
| `tail_background` | `tail -f` en arrière-plan, puis lecture de sa sortie | |
|||
| `monitor` | outil `Monitor` sur `tail -f` | |
|||
|
|||
Pour les cas `tail`, un thread attend qu'un processus `tail -f <log>` tourne |
|||
(`pgrep`) avant d'écrire les lignes : la fuite est vraiment en flux. |
|||
|
|||
## Sujets (`bench/subjects.py`) |
|||
|
|||
- `bare` : Claude Code sans rien. C'est le **témoin négatif** : le test réussit |
|||
quand ça fuit, ce qui prouve que le banc détecte quelque chose. |
|||
- `redacted` : [svn-arv/redacted](https://github.com/svn-arv/redacted) en hook |
|||
PostToolUse. Binaire via `REDACTED_BIN` ou le `PATH`. Le test réussit quand |
|||
rien ne fuit (ni `w` ni `t`). |
|||
|
|||
Pour tester un autre projet, ajouter une sous-classe de `Subject` qui fournit |
|||
`files()` (binaires copiés dans le bac à sable), `settings(where)` (les hooks, |
|||
avec le chemin de chaque fichier dans le bac à sable) et, si possible, |
|||
`declare_known()`, puis l'inscrire dans `SUBJECTS`. Un binaire de hook doit |
|||
pouvoir tourner sur Fedora minimal (glibc ; un binaire statique est idéal). |
|||
|
|||
## Lancer |
|||
| `cat_file` | `cat config.env` containing `DB_PASSWORD=<canary>` | |
|||
| `read_file` | same with the `Read` tool | |
|||
| `env` | `env`, canary in `BENCH_DB_PASSWORD` | |
|||
| `unlabeled_random` | random value under a neutral key (`cache_ref:`) | |
|||
| `known_value` | low-entropy passphrase in free text, declared to the filter beforehand | |
|||
| `tail_foreground` | `tail -f` in the foreground; canary lines arrive after it starts | |
|||
| `tail_background` | `tail -f` in the background, then its output is read | |
|||
| `monitor` | `Monitor` tool on `tail -f` | |
|||
|
|||
For the `tail` cases, a thread waits for a `tail -f <log>` process to be |
|||
running (`pgrep`) before writing the lines, so the leak really is a streaming |
|||
one. |
|||
|
|||
## Subjects (`bench/subjects.py`) |
|||
|
|||
- `bare`: Claude Code with nothing added. This is the **negative control**: the |
|||
test passes when it leaks, which proves the bench detects something. |
|||
- `redacted`: [svn-arv/redacted](https://github.com/svn-arv/redacted) as a |
|||
PostToolUse hook. Binary from `REDACTED_BIN` or the `PATH`. The test passes |
|||
when nothing leaks (neither `w` nor `t`). |
|||
|
|||
To test another project, add a `Subject` subclass that provides `files()` |
|||
(binaries copied into the sandbox), `settings(where)` (the hooks, given each |
|||
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 |
|||
(glibc; a static binary is ideal). |
|||
|
|||
## Running |
|||
|
|||
```bash |
|||
uv run pytest # tout |
|||
uv run pytest --subject bare --case monitor # un cas |
|||
uv run pytest # everything |
|||
uv run pytest --subject bare --case monitor # a single case |
|||
REDACTED_BIN=~/git/redacted/redacted uv run pytest --subject redacted |
|||
uv run pytest --model claude-sonnet-5 # autre modèle (défaut : Haiku 4.5) |
|||
uv run pytest --claude-version 2.1.292 # autre version de Claude Code (podman) |
|||
uv run pytest --sandbox host # sans conteneur |
|||
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 --sandbox host # no container |
|||
``` |
|||
|
|||
Prérequis : `uv`, `podman` rootless, `pgrep` ; `claude` sur l'hôte pour |
|||
détecter la version par défaut (ou `--claude-version`). |
|||
|
|||
Options : `--attempts N` (nouveaux essais si non concluant, défaut 2), |
|||
`--keep-transcripts` (garde le dossier `~/.claude` du conteneur dans le |
|||
rapport ; en mode hôte, laisse les sessions dans `~/.claude/projects`). |
|||
|
|||
En fin d'exécution, une matrice cas × sujet s'affiche. Elle est écrite avec |
|||
tous les éléments de preuve dans `reports/<horodatage>/<bac à sable>/` : `stream.jsonl`, |
|||
`wire.txt` (corps des requêtes), `transcript.jsonl`, `settings.json`. |
|||
|
|||
## Attention |
|||
|
|||
- **Coût** : une exécution `claude -p` par cas et par sujet. Les cas `tail` |
|||
durent environ 30 à 60 s. |
|||
- **`reports/` contient le prompt système complet** envoyé à l'API. En mode |
|||
hôte, il inclut ton contexte utilisateur (e-mail…). Le dossier est ignoré par |
|||
git ; ne pas le publier tel quel. |
|||
- En mode podman, seul le trafic vers `ANTHROPIC_BASE_URL` peut sortir ; le |
|||
reste (télémétrie…) est bloqué. Le banc mesure donc ce que Claude Code envoie |
|||
à l'API, pas ce qu'il tenterait d'envoyer ailleurs. |
|||
- En mode hôte, le reste du trafic n'est ni bloqué ni enregistré, et un filtre |
|||
peut lire sa config globale (`~/.config/redacted/…`). |
|||
Requirements: `uv`, rootless `podman`, `pgrep`; `claude` on the host to detect |
|||
the default version (or `--claude-version`). |
|||
|
|||
Options: `--attempts N` (retries when inconclusive, default 2), |
|||
`--keep-transcripts` (keeps the container's `~/.claude` directory in the |
|||
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 |
|||
with all the evidence, to `reports/<timestamp>/<sandbox>/`: `stream.jsonl`, |
|||
`wire.txt` (request bodies), `transcript.jsonl`, `settings.json`. |
|||
|
|||
## Caveats |
|||
|
|||
- **Cost**: one `claude -p` run per case and per subject. The `tail` cases take |
|||
about 30 to 60 s each. |
|||
- **`reports/` contains the full system prompt** sent to the API. In host mode, |
|||
it includes your user context (email…). The directory is git-ignored; do not |
|||
publish it as is. |
|||
- In podman mode, only traffic to `ANTHROPIC_BASE_URL` can get out; the rest |
|||
(telemetry…) is blocked. The bench therefore measures what Claude Code sends |
|||
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 |
|||
filter may read its global config (`~/.config/redacted/…`). |
|||
|
|||
Loading…
Reference in new issue