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.
123 lines
3.4 KiB
Python
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
|