Keeps credentials, company names, personal names and PII out of the model's context. Enforcement lives in Claude Code hooks rather than in instructions to the agent: a skill alone cannot protect anything, because by the time the agent reads a rule the surrounding context has already been sent. Credentials are removed irreversibly and marked. Entities from a user-supplied dictionary become stable aliases, rewritten back to real values on their way to disk and to the shell, so code and commands referring to them still work. Published from a clean tree; development history is not included.
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""Ground truth: what actually reached the model.
|
|
|
|
Every other check in this plugin measures intent -- whether a rule would have fired.
|
|
The transcript is the only artefact that records what was really sent. Scanning it
|
|
turns "we think nothing leaked" into a number.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def unprotectable_aliases(store, cwd: str) -> set[str]:
|
|
"""Entities whose value is a component of the project's own path.
|
|
|
|
Such a value cannot be aliased: paths handed to tools have to be real or nothing
|
|
works, so the name is in every absolute path and in the transcript's own metadata.
|
|
Counting it as a leak makes the metric permanently non-zero, and a metric that
|
|
cannot reach zero is a metric nobody acts on.
|
|
"""
|
|
parts = {p.casefold() for p in Path(cwd).resolve().parts}
|
|
out = set()
|
|
for e in store.entities:
|
|
v = e.value.casefold()
|
|
if v in parts or any(v in part for part in parts):
|
|
out.add(e.alias)
|
|
return out
|
|
|
|
|
|
def scan_text(text: str, engine, store) -> dict[str, int]:
|
|
hits: dict[str, int] = {}
|
|
|
|
matcher = store.matcher()
|
|
if matcher is not None:
|
|
for m in matcher.finditer(text):
|
|
entity = store.entity_for_match(m)
|
|
if entity is not None:
|
|
key = f"entity:{entity.alias}"
|
|
hits[key] = hits.get(key, 0) + 1
|
|
|
|
for f in engine.scanner.secrets(text):
|
|
key = f"secret:{f.rule_id}"
|
|
hits[key] = hits.get(key, 0) + 1
|
|
|
|
return hits
|
|
|
|
|
|
def scan_transcript(path: str | Path, engine, store) -> dict[str, Any]:
|
|
"""Count real sensitive values present in a transcript file.
|
|
|
|
Aliases and <SECRET:...> markers are expected in there and are not counted --
|
|
they are the mechanism working. Only real values count as leaks.
|
|
"""
|
|
total = 0
|
|
by_rule: dict[str, int] = {}
|
|
lines = 0
|
|
|
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
for raw in fh:
|
|
if not raw.strip():
|
|
continue
|
|
lines += 1
|
|
# Scan the raw JSON line: a real value is a leak wherever it sits, whether
|
|
# in message content, a tool result, or a file path.
|
|
for key, count in scan_text(raw, engine, store).items():
|
|
by_rule[key] = by_rule.get(key, 0) + count
|
|
total += count
|
|
|
|
return {"total": total, "by_rule": by_rule, "lines": lines, "path": str(path)}
|
|
|
|
|
|
def split_report(by_rule: dict[str, int], unprotectable: set[str]) -> tuple[dict, dict]:
|
|
"""(filterable, structural) -- leaks that should be zero, and leaks that cannot be."""
|
|
filterable: dict[str, int] = {}
|
|
structural: dict[str, int] = {}
|
|
for key, count in by_rule.items():
|
|
alias = key.split(":", 1)[1] if key.startswith("entity:") else ""
|
|
(structural if alias in unprotectable else filterable)[key] = count
|
|
return filterable, structural
|
|
|
|
|
|
def project_transcript_dir(cwd: str) -> Path:
|
|
"""Where Claude Code keeps this project's transcripts.
|
|
|
|
The directory name is the absolute path with every non-alphanumeric character
|
|
replaced by a dash.
|
|
"""
|
|
import re
|
|
encoded = re.sub(r"[^A-Za-z0-9]", "-", str(Path(cwd).resolve()))
|
|
return Path.home() / ".claude" / "projects" / encoded
|
|
|
|
|
|
def find_transcripts(limit: int = 20, cwd: str | None = None,
|
|
all_projects: bool = False) -> list[Path]:
|
|
"""This project's transcripts, most recent first.
|
|
|
|
Scoping matters more than it looks. Scanning every project's transcripts against
|
|
the current project's dictionary reports every unrelated session that merely
|
|
mentioned one of these names -- a live run produced 802 "leaks" that way, almost
|
|
all of them from other projects. An alarming number that means nothing is worse
|
|
than no number, because it is the one measurement the design relies on.
|
|
"""
|
|
root = Path.home() / ".claude" / "projects"
|
|
if not root.is_dir():
|
|
return []
|
|
if all_projects:
|
|
base: list[Path] = list(root.rglob("*.jsonl"))
|
|
else:
|
|
own = project_transcript_dir(cwd or ".")
|
|
if not own.is_dir():
|
|
return []
|
|
base = list(own.rglob("*.jsonl"))
|
|
files = sorted(base, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
return files[:limit]
|