Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,18 @@ expressions are applied alongside built-ins.
ctxguard scan # scan current directory; exit 1 on findings
ctxguard scan path/ --json # machine-readable masked report
ctxguard init # create config and register Claude Code hooks
ctxguard doctor # verify PATH resolution and hook registration
ctxguard --version
```

`ctxguard doctor` checks that the `ctxguard` executable resolves on `PATH`
(what the hooks registered by `ctxguard init` actually invoke), that the
resolved binary's version matches the one running the check, that
`.claude/settings.json` has both hooks registered with the expected matcher,
that `.ctxguard.toml` parses if present, and does an end-to-end smoke test by
actually invoking the `PreToolUse` hook once. Exits 1 if anything is broken
(`--json` for scripting), 0 if warnings only or fully healthy.

## Honest limitations

- Pattern and entropy matching has false positives and false negatives. A
Expand Down
305 changes: 297 additions & 8 deletions ctxguard/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Commands:
ctxguard scan [path] [--json] audit a repo for secrets (exit 1 if found)
ctxguard init [path] [--yes] write .ctxguard.toml and register hooks
ctxguard doctor [path] [--json] verify PATH resolution and hook registration
ctxguard hook <event> run a hook handler (used by Claude Code)
ctxguard --version
"""
Expand All @@ -11,6 +12,8 @@

import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
Expand Down Expand Up @@ -145,20 +148,26 @@ def _cmd_scan(args: argparse.Namespace) -> int:
# init


def _entries_have_ctxguard(entries: object) -> bool:
"""True if a hooks[event] list already contains a ctxguard-invoking entry."""
if not isinstance(entries, list):
return False
return any(
"ctxguard" in hook.get("command", "")
for item in entries
if isinstance(item, dict)
for hook in item.get("hooks", [])
if isinstance(hook, dict)
)


def _register_hooks(settings: dict) -> bool:
"""Merge ctxguard hook entries into a settings dict. True if changed."""
hooks = settings.setdefault("hooks", {})
changed = False
for event, entry in _hook_settings_entries().items():
entries = hooks.setdefault(event, [])
already = any(
"ctxguard" in hook.get("command", "")
for item in entries
if isinstance(item, dict)
for hook in item.get("hooks", [])
if isinstance(hook, dict)
)
if not already:
if not _entries_have_ctxguard(entries):
entries.append(entry)
changed = True
return changed
Expand Down Expand Up @@ -225,6 +234,273 @@ def _cmd_init(args: argparse.Namespace) -> int:
return 0


# ---------------------------------------------------------------------------
# doctor


def _check_python_version() -> dict:
ok = sys.version_info >= (3, 9)
version = ".".join(str(part) for part in sys.version_info[:3])
return {
"name": "python_version",
"status": "ok" if ok else "fail",
"detail": f"Python {version}" + ("" if ok else " (ctxguard requires 3.9+)"),
}


def _check_path_resolution() -> tuple:
"""Returns (check_dict, resolved_path_or_None)."""
resolved = shutil.which("ctxguard")
if resolved is None:
return (
{
"name": "path_resolution",
"status": "fail",
"detail": (
"`ctxguard` was not found on PATH. The standalone hooks "
"registered by `ctxguard init` invoke it by name at "
"session/tool-call time, so Claude Code won't be able to "
"run them. (Not needed if you installed ctxguard as a "
"Claude Code plugin instead - those hooks call python3 "
"directly.) Try `pip install ctxguard` or check your venv "
"is activated in the shell Claude Code launches from."
),
},
None,
)
return (
{"name": "path_resolution", "status": "ok", "detail": resolved},
resolved,
)


def _check_version_match(resolved_path: Optional[str]) -> Optional[dict]:
if resolved_path is None:
return None
try:
out = subprocess.run(
[resolved_path, "--version"],
capture_output=True,
text=True,
timeout=10,
check=False,
).stdout.strip()
except (OSError, subprocess.SubprocessError) as exc:
return {
"name": "version_match",
"status": "fail",
"detail": f"could not run `{resolved_path} --version`: {exc}",
}
running = f"ctxguard {__version__}"
if out == running:
return {"name": "version_match", "status": "ok", "detail": out}
return {
"name": "version_match",
"status": "warn",
"detail": (
f"PATH resolves to {out!r} but this check is running from "
f"{running!r}. Multiple installs may be shadowing each other."
),
}


def _check_settings_and_hooks(root: Path) -> List[dict]:
settings_path = root / ".claude" / "settings.json"
if not settings_path.exists():
msg = (
f"{settings_path} does not exist. Run `ctxguard init` to create it "
"and register the hooks."
)
return [
{"name": "settings_file", "status": "fail", "detail": msg},
{"name": "pretooluse_hook", "status": "fail", "detail": "not registered"},
{"name": "sessionstart_hook", "status": "fail", "detail": "not registered"},
]

try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
msg = f"{settings_path} could not be parsed: {exc}"
return [
{"name": "settings_file", "status": "fail", "detail": msg},
{
"name": "pretooluse_hook",
"status": "fail",
"detail": "unknown (settings file invalid)",
},
{
"name": "sessionstart_hook",
"status": "fail",
"detail": "unknown (settings file invalid)",
},
]

checks = [{"name": "settings_file", "status": "ok", "detail": str(settings_path)}]
hooks = settings.get("hooks", {}) if isinstance(settings, dict) else {}
expected = _hook_settings_entries()

for event, key in (
("PreToolUse", "pretooluse_hook"),
("SessionStart", "sessionstart_hook"),
):
entries = hooks.get(event, []) if isinstance(hooks, dict) else []
if not _entries_have_ctxguard(entries):
checks.append(
{
"name": key,
"status": "fail",
"detail": f"no ctxguard entry under hooks.{event}; run `ctxguard init`",
}
)
continue
if event == "PreToolUse":
expected_matcher = expected["PreToolUse"]["matcher"]
matchers = [
item.get("matcher")
for item in entries
if isinstance(item, dict) and _entries_have_ctxguard([item])
]
if expected_matcher not in matchers:
checks.append(
{
"name": key,
"status": "warn",
"detail": (
f"registered, but matcher is {matchers!r}, expected "
f"{expected_matcher!r}; some tool calls may not be guarded"
),
}
)
continue
checks.append({"name": key, "status": "ok", "detail": "registered"})
return checks


def _check_config_file(root: Path) -> dict:
config_path = root / det.CONFIG_FILENAME
if not config_path.exists():
return {
"name": "config_file",
"status": "warn",
"detail": f"{config_path} not found; using built-in defaults (mode=block, no allowlist)",
}
if det._toml is None:
return {
"name": "config_file",
"status": "warn",
"detail": f"{config_path} exists but no TOML parser is available to validate it",
}
try:
det._toml.loads(config_path.read_text(encoding="utf-8"))
except Exception as exc:
return {
"name": "config_file",
"status": "fail",
"detail": f"{config_path} has invalid TOML syntax: {exc}",
}
return {"name": "config_file", "status": "ok", "detail": str(config_path)}


def _check_hook_smoke_test(resolved_path: Optional[str], root: Path) -> Optional[dict]:
if resolved_path is None:
return None
payload = json.dumps(
{
"cwd": str(root),
"hook_event_name": "PreToolUse",
"tool_name": "Read",
"tool_input": {"file_path": str(root / "ctxguard-doctor-smoke-test.tmp")},
}
)
try:
proc = subprocess.run(
[resolved_path, "hook", "pretooluse"],
input=payload,
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
return {
"name": "hook_smoke_test",
"status": "fail",
"detail": f"`{resolved_path} hook pretooluse` could not be run: {exc}",
}
if proc.returncode != 0:
return {
"name": "hook_smoke_test",
"status": "fail",
"detail": (
f"`{resolved_path} hook pretooluse` on a benign, non-existent "
f"path exited {proc.returncode}: {proc.stderr.strip() or proc.stdout.strip()}"
),
}
return {
"name": "hook_smoke_test",
"status": "ok",
"detail": "end-to-end hook invocation succeeded",
}


def _cmd_doctor(args: argparse.Namespace) -> int:
root = Path(args.path).resolve()
if not root.is_dir():
print(f"ctxguard: not a directory: {root}", file=sys.stderr)
return 2

path_check, resolved = _check_path_resolution()
checks = [_check_python_version(), path_check]
version_check = _check_version_match(resolved)
if version_check is not None:
checks.append(version_check)
checks.extend(_check_settings_and_hooks(root))
checks.append(_check_config_file(root))
smoke_check = _check_hook_smoke_test(resolved, root)
if smoke_check is not None:
checks.append(smoke_check)

failed = any(c["status"] == "fail" for c in checks)

if args.as_json:
print(
json.dumps(
{"path": str(root), "healthy": not failed, "checks": checks}, indent=2
)
)
return 1 if failed else 0

from rich.console import Console
from rich.table import Table

console = Console()
table = Table(
title="ctxguard doctor",
title_justify="left",
show_edge=True,
header_style="bold dim",
)
table.add_column("check")
table.add_column("status")
table.add_column("detail")
icon = {
"ok": "[bold green]ok[/]",
"warn": "[bold yellow]warn[/]",
"fail": "[bold red]fail[/]",
}
for c in checks:
table.add_row(c["name"], icon.get(c["status"], c["status"]), c["detail"])
console.print(table)

if failed:
console.print(
"[bold red]ctxguard is not fully set up.[/] See the failed checks above."
)
else:
console.print("[bold green]ctxguard is set up correctly.[/]")
return 1 if failed else 0


# ---------------------------------------------------------------------------
# hook (internal, used by the settings.json registration)

Expand Down Expand Up @@ -271,6 +547,17 @@ def main(argv: Optional[List[str]] = None) -> int:
help="modify an existing .claude/settings.json without prompting",
)

doctor_parser = sub.add_parser(
"doctor",
help="verify PATH resolution and Claude Code hook registration",
)
doctor_parser.add_argument(
"path", nargs="?", default=".", help="project root (default: .)"
)
doctor_parser.add_argument(
"--json", dest="as_json", action="store_true", help="machine-readable output"
)

hook_parser = sub.add_parser(
"hook", help="run a Claude Code hook handler (internal)"
)
Expand All @@ -281,6 +568,8 @@ def main(argv: Optional[List[str]] = None) -> int:
return _cmd_scan(args)
if args.command == "init":
return _cmd_init(args)
if args.command == "doctor":
return _cmd_doctor(args)
if args.command == "hook":
return _cmd_hook(args)
parser.print_help()
Expand Down
Loading
Loading