Files
skills/plugins/ctxguard/scripts/ctxguard/paths.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

123 lines
3.4 KiB
Python

"""Filesystem layout. Everything lives outside any git repository on purpose."""
from __future__ import annotations
import hashlib
import os
import re
from pathlib import Path
ROOT_ENV = "CTXGUARD_HOME"
def home() -> Path:
"""Root of ctxguard state. Overridable for tests via CTXGUARD_HOME."""
override = os.environ.get(ROOT_ENV)
if override:
return Path(override)
return Path.home() / ".claude" / "ctx-guard"
def policy_file() -> Path:
return home() / "policy.json"
def audit_file() -> Path:
return home() / "audit.jsonl"
_TOPLEVEL_CACHE: dict[str, str] = {}
def repo_root(project_dir: str | os.PathLike[str]) -> str:
"""The git working-tree root containing `project_dir`, or the directory itself.
The dictionary is keyed by this rather than by the current directory. Keying by
cwd meant that running from a subdirectory silently produced a *different, empty*
dictionary -- so a session started in `src/` looked protected while none of the
registered entities applied. One dictionary per repository is also what makes a
handover land where the recipient expects it.
"""
import subprocess
real = str(Path(project_dir).resolve())
if real in _TOPLEVEL_CACHE:
return _TOPLEVEL_CACHE[real]
root = real
try:
r = subprocess.run(
["git", "-C", real, "rev-parse", "--show-toplevel"],
capture_output=True, timeout=5,
)
if r.returncode == 0:
candidate = r.stdout.decode("utf-8", "replace").strip()
if candidate:
root = str(Path(candidate).resolve())
except (OSError, subprocess.SubprocessError):
pass
_TOPLEVEL_CACHE[real] = root
return root
def slug(project_dir: str | os.PathLike[str]) -> str:
"""Stable, human-recognisable id for a project.
Anchored to the repository root, so every directory inside one repository shares a
single dictionary.
"""
real = repo_root(project_dir)
tail = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(real)).strip("-").lower()
digest = hashlib.sha256(real.encode("utf-8")).hexdigest()[:10]
return f"{tail or 'root'}-{digest}"
def project_dir(slug_: str) -> Path:
return home() / "projects" / slug_
def entities_file(slug_: str) -> Path:
return project_dir(slug_) / "entities.json"
def index_file(slug_: str) -> Path:
return project_dir(slug_) / "index.json"
def cache_root(slug_: str) -> Path:
return home() / "cache" / slug_
def ensure_private_dir(path: Path, mode: int = 0o700) -> Path:
path.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path, mode)
except OSError:
pass
return path
def write_private(path: Path, text: str, mode: int = 0o600) -> None:
"""Write a file that only the owner may read, without a world-readable window."""
ensure_private_dir(path.parent)
tmp = path.with_name(path.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
except BaseException:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, path)
try:
os.chmod(path, mode)
except OSError:
pass
def is_inside(path: str | os.PathLike[str], parent: Path) -> bool:
try:
Path(path).resolve().relative_to(parent.resolve())
return True
except (ValueError, OSError):
return False