fleetproof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fleetproof/cli.py ADDED
@@ -0,0 +1,293 @@
1
+ """FleetProof command-line interface.
2
+
3
+ Stdlib only (argparse) — a verification tool should add as little dependency
4
+ surface as it can. Subcommands:
5
+
6
+ init write a starter .fleetproof/checks.json
7
+ check run the independent checker, record the verdict, exit non-zero on block
8
+ list list recorded runs, newest first
9
+ show show one run and its sub-invocations
10
+ report render the self-contained HTML run report
11
+ cleanup delete run records older than N days
12
+ stop-gate Stop-hook entry: emit a Claude Code block decision on a false "done"
13
+ record PostToolUse-hook entry: append an evidence record from hook stdin
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import shutil
22
+ import sys
23
+ from datetime import datetime, timedelta, timezone
24
+ from pathlib import Path
25
+
26
+ # The CLI must not record its own invocations.
27
+ os.environ.setdefault("FLEETPROOF_NO_RECORD", "1")
28
+
29
+ from . import __version__
30
+ from .checker import format_report_text, run_checks, spec_drifted
31
+ from .checks import (
32
+ CheckSpecError,
33
+ SPEC_DRIFT_NOTE,
34
+ STARTER_SPEC,
35
+ default_checks_path,
36
+ load_checks,
37
+ short_spec_hash,
38
+ )
39
+ from .hookgate import record_tool_main, stop_gate_main
40
+ from .report import write_report
41
+ from .runlog import (
42
+ PROJECT_MARKER,
43
+ SESSION_ID_ENV,
44
+ list_run_records,
45
+ load_run,
46
+ project_root,
47
+ runs_dir,
48
+ )
49
+
50
+
51
+ def _cmd_init(args: argparse.Namespace) -> int:
52
+ path = Path(args.path) if args.path else default_checks_path()
53
+ if path.exists() and not args.force:
54
+ print(f"Refusing to overwrite existing {path} (use --force).", file=sys.stderr)
55
+ return 1
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.write_text(json.dumps(STARTER_SPEC, indent=2) + "\n", encoding="utf-8")
58
+ print(f"Wrote starter check spec to {path}")
59
+ print("Edit it to declare what 'done' means for this repo, then run `fleetproof check`.")
60
+ return 0
61
+
62
+
63
+ def _cmd_check(args: argparse.Namespace) -> int:
64
+ spec_path = Path(args.spec) if args.spec else None
65
+ try:
66
+ checks = load_checks(spec_path)
67
+ except CheckSpecError as e:
68
+ _emit_error("check_spec_error", str(e), args.format)
69
+ return 2
70
+ report = run_checks(checks, record_to_log=not args.no_record, spec_path=spec_path)
71
+
72
+ # Best-effort drift note: a bare CLI run usually has no session context (so
73
+ # baseline is unresolvable and nothing is flagged), but when it runs inside a
74
+ # Claude Code session the env carries the id and we can flag drift here too.
75
+ session_id = os.environ.get(SESSION_ID_ENV)
76
+ drifted, baseline = spec_drifted(report.spec_sha256, session_id)
77
+
78
+ if args.format == "json":
79
+ payload = report.to_dict()
80
+ payload["run_id"] = report.run_id
81
+ payload["spec_drift"] = drifted
82
+ payload["session_baseline_sha256"] = baseline
83
+ print(json.dumps(payload, indent=2))
84
+ else:
85
+ print(format_report_text(report))
86
+ if drifted:
87
+ print(SPEC_DRIFT_NOTE)
88
+ print(f" session baseline: {short_spec_hash(baseline)}")
89
+ return 1 if report.verdict == "fail" else 0
90
+
91
+
92
+ def _cmd_list(args: argparse.Namespace) -> int:
93
+ records = list_run_records()
94
+ rows = []
95
+ for r in records:
96
+ failed = r.failed_count
97
+ if args.status == "ok" and failed > 0:
98
+ continue
99
+ if args.status == "fail" and failed == 0:
100
+ continue
101
+ rows.append(r)
102
+ if args.limit and len(rows) >= args.limit:
103
+ break
104
+ if args.format == "json":
105
+ print(json.dumps({
106
+ "runs": [
107
+ {
108
+ "run_id": r.run_id,
109
+ "root_tool": r.root_tool,
110
+ "started_at": r.started_at,
111
+ "session_id": r.session_id,
112
+ "sub_count": len(r.sub_invocations),
113
+ "failed_count": r.failed_count,
114
+ }
115
+ for r in rows
116
+ ]
117
+ }, indent=2))
118
+ return 0
119
+ if not rows:
120
+ print("No runs found.")
121
+ return 0
122
+ print(f"{'run_id':<24} {'root_tool':<16} {'started':<20} {'subs':>5} {'failed':>7} {'session':<14}")
123
+ for r in rows:
124
+ print(f"{r.run_id:<24} {(r.root_tool or '-'):<16} "
125
+ f"{(r.started_at or '-')[:19]:<20} {len(r.sub_invocations):>5} {r.failed_count:>7} "
126
+ f"{_short_session(r.session_id):<14}")
127
+ return 0
128
+
129
+
130
+ def _short_session(session_id: str | None) -> str:
131
+ """A compact session id for the list column; '-' when a run has none."""
132
+ if not session_id:
133
+ return "-"
134
+ return session_id if len(session_id) <= 14 else session_id[:11] + "..."
135
+
136
+
137
+ def _cmd_show(args: argparse.Namespace) -> int:
138
+ r = load_run(args.run_id)
139
+ if r is None:
140
+ _emit_error("run_not_found", f"No run record for {args.run_id!r}", args.format)
141
+ return 1
142
+ if args.format == "json":
143
+ print(json.dumps({
144
+ "run_id": r.run_id,
145
+ "root_tool": r.root_tool,
146
+ "started_at": r.started_at,
147
+ "session_id": r.session_id,
148
+ "sub_invocations": [
149
+ {
150
+ "tool": s.tool,
151
+ "subcmd": s.subcmd,
152
+ "started_at": s.started_at,
153
+ "exit_code": s.exit_code,
154
+ "duration_ms": s.duration_ms,
155
+ "exception_type": s.exception_type,
156
+ "record_dir": str(s.record_dir),
157
+ }
158
+ for s in r.sub_invocations
159
+ ],
160
+ }, indent=2))
161
+ return 0
162
+ print(f"{r.run_id} root_tool={r.root_tool or '-'} started={(r.started_at or '-')[:19]}"
163
+ f" session={r.session_id or '-'}")
164
+ for s in r.sub_invocations:
165
+ if s.exit_code is None:
166
+ badge = "?"
167
+ elif s.exit_code == 0 and not s.exception_type:
168
+ badge = "ok"
169
+ else:
170
+ badge = "fail"
171
+ dur = f"{s.duration_ms:.1f}ms" if s.duration_ms is not None else "?ms"
172
+ print(f" [{badge}] {s.tool} {s.subcmd} ({dur}) {s.record_dir}")
173
+ return 0
174
+
175
+
176
+ def _cmd_report(args: argparse.Namespace) -> int:
177
+ out = Path(args.output) if args.output else project_root() / PROJECT_MARKER / "fleetproof-report.html"
178
+ written = write_report(out)
179
+ print(f"Wrote report to {written}")
180
+ return 0
181
+
182
+
183
+ def _cmd_cleanup(args: argparse.Namespace) -> int:
184
+ rd = runs_dir()
185
+ deleted, skipped = [], []
186
+ if rd.exists():
187
+ cutoff = datetime.now(timezone.utc) - timedelta(days=args.older_than_days)
188
+ for run_dir in sorted(rd.iterdir()):
189
+ if not run_dir.is_dir() or run_dir.name.startswith("."):
190
+ continue
191
+ started = _run_started_at(run_dir)
192
+ if started is None or started >= cutoff:
193
+ skipped.append(run_dir.name)
194
+ continue
195
+ if not args.dry_run:
196
+ shutil.rmtree(run_dir, ignore_errors=True)
197
+ deleted.append(run_dir.name)
198
+ prefix = "DRY RUN " if args.dry_run else ""
199
+ print(f"{prefix}Deleted {len(deleted)} runs, skipped {len(skipped)}.")
200
+ return 0
201
+
202
+
203
+ def _run_started_at(run_dir: Path) -> datetime | None:
204
+ record = load_run(run_dir.name)
205
+ if record and record.started_at:
206
+ try:
207
+ return datetime.fromisoformat(record.started_at)
208
+ except ValueError:
209
+ pass
210
+ try:
211
+ return datetime.fromtimestamp(run_dir.stat().st_mtime, tz=timezone.utc)
212
+ except OSError:
213
+ return None
214
+
215
+
216
+ def _cmd_stop_gate(args: argparse.Namespace) -> int:
217
+ return stop_gate_main()
218
+
219
+
220
+ def _cmd_record(args: argparse.Namespace) -> int:
221
+ # PostToolUse recorder must be allowed to write records.
222
+ os.environ["FLEETPROOF_NO_RECORD"] = "0"
223
+ return record_tool_main()
224
+
225
+
226
+ def _emit_error(code: str, message: str, fmt: str) -> None:
227
+ if fmt == "json":
228
+ print(json.dumps({"ok": False, "error_code": code, "message": message}), file=sys.stderr)
229
+ else:
230
+ print(f"error ({code}): {message}", file=sys.stderr)
231
+
232
+
233
+ def _add_format(p: argparse.ArgumentParser) -> None:
234
+ p.add_argument("--format", choices=["human", "json"], default="human",
235
+ help="Output format. Use json for agent consumption.")
236
+
237
+
238
+ def build_parser() -> argparse.ArgumentParser:
239
+ parser = argparse.ArgumentParser(
240
+ prog="fleetproof",
241
+ description="Independent, out-of-band verification for agent fleets.",
242
+ )
243
+ parser.add_argument("--version", action="version", version=f"fleetproof {__version__}")
244
+ sub = parser.add_subparsers(dest="command", required=True)
245
+
246
+ p_init = sub.add_parser("init", help="Write a starter .fleetproof/checks.json.")
247
+ p_init.add_argument("--path", default=None, help="Where to write the spec.")
248
+ p_init.add_argument("--force", action="store_true", help="Overwrite an existing spec.")
249
+ p_init.set_defaults(func=_cmd_init)
250
+
251
+ p_check = sub.add_parser("check", help="Run the independent checker and record the verdict.")
252
+ p_check.add_argument("--spec", default=None, help="Path to a check spec (default: .fleetproof/checks.json).")
253
+ p_check.add_argument("--no-record", action="store_true", help="Do not write to the run log.")
254
+ _add_format(p_check)
255
+ p_check.set_defaults(func=_cmd_check)
256
+
257
+ p_list = sub.add_parser("list", help="List recorded runs, newest first.")
258
+ p_list.add_argument("--status", choices=["ok", "fail"], default=None)
259
+ p_list.add_argument("--limit", type=int, default=20)
260
+ _add_format(p_list)
261
+ p_list.set_defaults(func=_cmd_list)
262
+
263
+ p_show = sub.add_parser("show", help="Show a run and its sub-invocations.")
264
+ p_show.add_argument("run_id")
265
+ _add_format(p_show)
266
+ p_show.set_defaults(func=_cmd_show)
267
+
268
+ p_report = sub.add_parser("report", help="Render the self-contained HTML run report.")
269
+ p_report.add_argument("-o", "--output", default=None, help="Output HTML path.")
270
+ p_report.set_defaults(func=_cmd_report)
271
+
272
+ p_cleanup = sub.add_parser("cleanup", help="Delete run records older than N days.")
273
+ p_cleanup.add_argument("--older-than-days", type=int, required=True)
274
+ p_cleanup.add_argument("--dry-run", action="store_true")
275
+ p_cleanup.set_defaults(func=_cmd_cleanup)
276
+
277
+ p_stop = sub.add_parser("stop-gate", help="Stop-hook entry: emit a block decision on a false 'done'.")
278
+ p_stop.set_defaults(func=_cmd_stop_gate)
279
+
280
+ p_rec = sub.add_parser("record", help="PostToolUse-hook entry: record a tool call from hook stdin.")
281
+ p_rec.set_defaults(func=_cmd_record)
282
+
283
+ return parser
284
+
285
+
286
+ def main(argv: list[str] | None = None) -> int:
287
+ parser = build_parser()
288
+ args = parser.parse_args(argv)
289
+ return args.func(args)
290
+
291
+
292
+ if __name__ == "__main__":
293
+ sys.exit(main())
fleetproof/hookgate.py ADDED
@@ -0,0 +1,166 @@
1
+ """Claude Code hook entry points.
2
+
3
+ These functions are what the plugin's ``type: "command"`` hooks invoke. They are
4
+ deterministic and run in a process separate from the agent whose "done" claim
5
+ they are judging — which is the whole product.
6
+
7
+ - :func:`stop_gate_main` — the Stop hook. Runs the independent checker and, if a
8
+ blocking check failed, emits ``{"decision": "block", "reason": ...}`` so Claude
9
+ Code refuses to let the agent stop on a false "done".
10
+ - :func:`record_tool_main` — the PostToolUse hook. Appends an evidence record for
11
+ the tool call that just ran. Never blocks (the tool already happened).
12
+
13
+ Both read the hook payload as JSON on stdin, per the Claude Code hooks contract.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from typing import Any
22
+
23
+ from .checker import run_checks, spec_drifted
24
+ from .checks import SPEC_DRIFT_NOTE, CheckSpecError, load_checks, short_spec_hash
25
+ from .runlog import SESSION_ID_ENV, record
26
+
27
+
28
+ def _read_hook_input() -> dict[str, Any]:
29
+ try:
30
+ data = sys.stdin.read()
31
+ except Exception:
32
+ return {}
33
+ # Tolerate a UTF-8 BOM (some shells prepend one when piping) — silently
34
+ # losing the payload would silently lose session grouping and drift
35
+ # detection, which is exactly the quiet degradation this tool exists to avoid.
36
+ data = data.lstrip("\ufeff")
37
+ if not data.strip():
38
+ return {}
39
+ try:
40
+ parsed = json.loads(data)
41
+ return parsed if isinstance(parsed, dict) else {}
42
+ except json.JSONDecodeError:
43
+ return {}
44
+
45
+
46
+ def _apply_session_id(payload: dict[str, Any]) -> None:
47
+ """Thread the Claude Code session id into the run log.
48
+
49
+ Every hook invocation receives a JSON payload on stdin whose ``session_id``
50
+ field identifies the session (see the "hook input" section of
51
+ https://code.claude.com/docs/en/hooks). Each hook fires as its own OS
52
+ process with a fresh run id, so without this the report shows one session as
53
+ several unrelated root runs. Carrying the id via env lets the root record
54
+ (written in this same process) group them back together.
55
+ """
56
+ sid = payload.get("session_id")
57
+ if isinstance(sid, str) and sid:
58
+ os.environ[SESSION_ID_ENV] = sid
59
+
60
+
61
+ def stop_gate() -> tuple[dict[str, Any] | None, int]:
62
+ """Run the checker and decide whether Claude may stop.
63
+
64
+ Returns ``(decision_dict_or_None, exit_code)``. A None decision + exit 0 means
65
+ "let the agent stop"; a block decision + exit 0 means "you claimed done but the
66
+ independent checker disagrees — keep going."
67
+ """
68
+ try:
69
+ checks = load_checks()
70
+ except CheckSpecError:
71
+ # No spec (or a broken one) means nothing to enforce. Fail open, but visibly:
72
+ # a verification tool must never pretend it verified when it did not.
73
+ return None, 0
74
+
75
+ if not checks:
76
+ return None, 0
77
+
78
+ report = run_checks(checks)
79
+
80
+ # Spec-drift check: did the checks.json that just graded this verdict differ
81
+ # from the one the session's first verdict was graded against? An agent is
82
+ # allowed to author checks.json, so a failing agent could quietly weaken it to
83
+ # slip this gate. We don't block on drift alone (v0.1 policy) — we make it loud.
84
+ session_id = os.environ.get(SESSION_ID_ENV)
85
+ drifted, baseline = spec_drifted(report.spec_sha256, session_id)
86
+
87
+ if report.verdict == "pass":
88
+ if drifted:
89
+ # A pass with drift still passes, but must not pass *silently*.
90
+ return {
91
+ "hookSpecificOutput": {
92
+ "hookEventName": "Stop",
93
+ "additionalContext": _drift_context(report.spec_sha256, baseline),
94
+ },
95
+ }, 0
96
+ return None, 0
97
+
98
+ failing = report.blocking_failures
99
+ lines = [f"{r.id} ({r.detail})" for r in failing]
100
+ reason = (
101
+ f"FleetProof: {len(failing)}/{report.total} blocking check(s) failed — "
102
+ + "; ".join(lines)
103
+ + ". The agent reported done; the independent checker disagrees. "
104
+ "Fix the failures and let the checker re-run before stopping."
105
+ )
106
+ if drifted:
107
+ reason += " " + SPEC_DRIFT_NOTE
108
+ decision = {
109
+ "decision": "block",
110
+ "reason": reason,
111
+ "hookSpecificOutput": {
112
+ "hookEventName": "Stop",
113
+ "additionalContext": _evidence_context(report, drifted, baseline),
114
+ },
115
+ }
116
+ return decision, 0
117
+
118
+
119
+ def _drift_context(current_hash: str | None, baseline_hash: str | None) -> str:
120
+ """The unmissable drift annotation, with both hashes for a reviewer to diff."""
121
+ return (
122
+ f"{SPEC_DRIFT_NOTE}\n"
123
+ f"- current spec hash: {short_spec_hash(current_hash)}\n"
124
+ f"- session baseline: {short_spec_hash(baseline_hash)}"
125
+ )
126
+
127
+
128
+ def _evidence_context(report, drifted: bool = False, baseline_hash: str | None = None) -> str:
129
+ parts = []
130
+ if drifted:
131
+ parts.append(_drift_context(report.spec_sha256, baseline_hash))
132
+ for r in report.results:
133
+ status = "pass" if r.passed else ("FAIL" if r.blocking else "warn")
134
+ parts.append(f"- [{status}] {r.id}: {r.detail}")
135
+ parts.append(f"Spec hash: {short_spec_hash(report.spec_sha256)}")
136
+ if report.run_id:
137
+ parts.append(f"Evidence recorded under run {report.run_id} in .fleetproof/runs/.")
138
+ return "\n".join(parts)
139
+
140
+
141
+ def stop_gate_main() -> int:
142
+ # Consume stdin per contract; the verdict is checker-driven, but the payload
143
+ # still carries the session id we group this run's evidence under.
144
+ _apply_session_id(_read_hook_input())
145
+ decision, code = stop_gate()
146
+ if decision is not None:
147
+ sys.stdout.write(json.dumps(decision))
148
+ return code
149
+
150
+
151
+ def record_tool_main() -> int:
152
+ """PostToolUse recorder: accrete an evidence record. Always non-blocking."""
153
+ payload = _read_hook_input()
154
+ _apply_session_id(payload)
155
+ tool_name = str(payload.get("tool_name", "unknown"))
156
+ try:
157
+ with record("claude-tool", tool_name, {"tool_name": tool_name}) as handle:
158
+ handle.set_output({
159
+ "tool_name": tool_name,
160
+ "tool_input": payload.get("tool_input"),
161
+ "tool_response_present": "tool_response" in payload,
162
+ })
163
+ except Exception:
164
+ # An evidence-recorder that crashes must not break the agent's turn.
165
+ return 0
166
+ return 0