Files
skills/plugins/ctxguard/scripts/ctxguard/transcript.py
T
dev 278ecca018 Add ctxguard: hook-enforced context sanitization
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.
2026-09-16 11:33:09 +03:00

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]