From df3aa459e5ba107913fb6b66826f50c79e9efec2 Mon Sep 17 00:00:00 2001 From: Ismail Rhoulam Date: Sun, 12 Jul 2026 18:09:13 +0100 Subject: [PATCH 1/2] =?UTF-8?q?wip:=20auto-checkpoint=20=E2=80=94=20305=20?= =?UTF-8?q?lines=20/=201=20files=20(2026-07-12T18:09:13+0100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ctxguard/cli.py | 305 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 297 insertions(+), 8 deletions(-) diff --git a/ctxguard/cli.py b/ctxguard/cli.py index f63ec81..d16a5f1 100644 --- a/ctxguard/cli.py +++ b/ctxguard/cli.py @@ -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 run a hook handler (used by Claude Code) ctxguard --version """ @@ -11,6 +12,8 @@ import argparse import json +import shutil +import subprocess import sys from pathlib import Path from typing import List, Optional @@ -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 @@ -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) @@ -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)" ) @@ -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() From 6a70b2843fddab85c1aa30dbc098e09ae34f53e1 Mon Sep 17 00:00:00 2001 From: Ismail Rhoulam Date: Sun, 12 Jul 2026 18:13:13 +0100 Subject: [PATCH 2/2] feat(cli): add ctxguard doctor command Verifies the local install end to end: ctxguard resolves on PATH (what the hooks registered by `ctxguard init` actually invoke at runtime), the resolved binary's version matches what's running the check, .claude/settings.json has both PreToolUse and SessionStart hooks registered with the expected matcher, .ctxguard.toml parses if present, and a real invocation of the PreToolUse hook succeeds end to end. Each check reports ok/warn/fail; only fail affects the exit code (1), so e.g. a missing .ctxguard.toml (defaults apply) or a narrower-than- expected matcher don't block CI while still surfacing as visible warnings. --json for scripting, matching scan's convention. --- README.md | 9 ++++ tests/test_cli.py | 124 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5050196..418ba07 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 5083eb3..2986e32 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,9 @@ -"""Tests for the ctxguard CLI: scan, init, --version, exit codes, masking.""" +"""Tests for the ctxguard CLI: scan, init, doctor, --version, exit codes, masking.""" import json +import os +import sys +from pathlib import Path import pytest @@ -15,6 +18,16 @@ from ctxguard.cli import main +def run_json_doctor(capsys, path): + code = main(["doctor", str(path), "--json"]) + report = json.loads(capsys.readouterr().out) + return code, report + + +def checks_by_name(report): + return {c["name"]: c for c in report["checks"]} + + def run_json_scan(capsys, path): code = main(["scan", str(path), "--json"]) report = json.loads(capsys.readouterr().out) @@ -143,3 +156,112 @@ def test_existing_settings_merged_with_yes(self, tmp_path, capsys): def test_init_on_missing_dir_exits_two(self, capsys): assert main(["init", "/nonexistent/definitely/not/here"]) == 2 + + +# --------------------------------------------------------------------------- +# doctor + + +class TestDoctor: + def test_missing_dir_exits_two(self, capsys): + assert main(["doctor", "/nonexistent/definitely/not/here"]) == 2 + + def test_nothing_set_up_reports_failures(self, tmp_path, capsys, monkeypatch): + monkeypatch.setenv("PATH", str(tmp_path)) # no ctxguard resolvable + code, report = run_json_doctor(capsys, tmp_path) + assert code == 1 + assert report["healthy"] is False + checks = checks_by_name(report) + assert checks["path_resolution"]["status"] == "fail" + assert checks["settings_file"]["status"] == "fail" + assert checks["pretooluse_hook"]["status"] == "fail" + assert checks["sessionstart_hook"]["status"] == "fail" + assert checks["config_file"]["status"] == "warn" # missing config is not fatal + # can't run a subprocess smoke test or version check without a resolvable binary + assert "version_match" not in checks + assert "hook_smoke_test" not in checks + + def test_healthy_project_end_to_end(self, tmp_path, capsys, monkeypatch): + # prepend the dir containing the currently-running interpreter, which + # for a `pip install -e .` venv also contains the `ctxguard` console + # script, so this exercises the real PATH-resolved binary end to end + venv_bin = str(Path(sys.executable).parent) + monkeypatch.setenv("PATH", venv_bin + os.pathsep + os.defpath) + assert main(["init", str(tmp_path)]) == 0 + capsys.readouterr() # drain init's own stdout before capturing doctor's JSON + + code, report = run_json_doctor(capsys, tmp_path) + assert code == 0 + assert report["healthy"] is True + checks = checks_by_name(report) + assert checks["python_version"]["status"] == "ok" + assert checks["path_resolution"]["status"] == "ok" + assert checks["version_match"]["status"] == "ok" + assert checks["settings_file"]["status"] == "ok" + assert checks["pretooluse_hook"]["status"] == "ok" + assert checks["sessionstart_hook"]["status"] == "ok" + assert checks["config_file"]["status"] == "ok" + assert checks["hook_smoke_test"]["status"] == "ok" + + def test_narrow_matcher_warns_but_not_fails(self, tmp_path, capsys, monkeypatch): + # resolvable PATH so path_resolution/version_match/smoke_test don't + # also fail and confound the "warn alone doesn't fail health" check + venv_bin = str(Path(sys.executable).parent) + monkeypatch.setenv("PATH", venv_bin + os.pathsep + os.defpath) + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "ctxguard hook pretooluse", + } + ], + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "ctxguard hook session-start", + } + ] + } + ], + } + } + ), + encoding="utf-8", + ) + code, report = run_json_doctor(capsys, tmp_path) + checks = checks_by_name(report) + assert checks["pretooluse_hook"]["status"] == "warn" + assert "Bash" in checks["pretooluse_hook"]["detail"] + assert checks["sessionstart_hook"]["status"] == "ok" + # a warning alone does not fail the overall health check + assert report["healthy"] is True + assert code == 0 + + def test_malformed_config_fails(self, tmp_path, capsys, monkeypatch): + monkeypatch.setenv("PATH", str(tmp_path)) + (tmp_path / ".ctxguard.toml").write_text("mode = [broken", encoding="utf-8") + code, report = run_json_doctor(capsys, tmp_path) + checks = checks_by_name(report) + assert checks["config_file"]["status"] == "fail" + assert report["healthy"] is False + assert code == 1 + + def test_rich_report_renders(self, tmp_path, capsys, monkeypatch): + monkeypatch.setenv("PATH", str(tmp_path)) + code = main(["doctor", str(tmp_path)]) + out = capsys.readouterr().out + assert code == 1 + assert "ctxguard doctor" in out + assert "not fully set up" in out