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/__init__.py +46 -0
- fleetproof/__main__.py +6 -0
- fleetproof/checker.py +280 -0
- fleetproof/checks.py +218 -0
- fleetproof/cli.py +293 -0
- fleetproof/hookgate.py +166 -0
- fleetproof/report.py +384 -0
- fleetproof/runlog.py +500 -0
- fleetproof-0.1.0.dist-info/METADATA +231 -0
- fleetproof-0.1.0.dist-info/RECORD +14 -0
- fleetproof-0.1.0.dist-info/WHEEL +5 -0
- fleetproof-0.1.0.dist-info/entry_points.txt +2 -0
- fleetproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleetproof-0.1.0.dist-info/top_level.txt +1 -0
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
|