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 ADDED
@@ -0,0 +1,46 @@
1
+ """FleetProof — independent, out-of-band verification for agent fleets.
2
+
3
+ The agent may author the checks; it never executes and grades its own work.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .runlog import (
11
+ RunRecord,
12
+ SubInvocation,
13
+ child_run_id,
14
+ current_run_id,
15
+ list_run_records,
16
+ load_run,
17
+ project_root,
18
+ record,
19
+ recorded,
20
+ runs_dir,
21
+ set_runs_dir,
22
+ )
23
+ from .checks import Check, CheckSpecError, load_checks
24
+ from .checker import CheckReport, CheckResult, run_check, run_checks
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "recorded",
29
+ "record",
30
+ "current_run_id",
31
+ "child_run_id",
32
+ "project_root",
33
+ "runs_dir",
34
+ "set_runs_dir",
35
+ "list_run_records",
36
+ "load_run",
37
+ "RunRecord",
38
+ "SubInvocation",
39
+ "Check",
40
+ "CheckSpecError",
41
+ "load_checks",
42
+ "CheckReport",
43
+ "CheckResult",
44
+ "run_check",
45
+ "run_checks",
46
+ ]
fleetproof/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m fleetproof`` as an alternative to the console script."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
fleetproof/checker.py ADDED
@@ -0,0 +1,280 @@
1
+ """The independent checker.
2
+
3
+ This is the part of FleetProof that no agent runs against itself. The agent that
4
+ did the work authored ``checks.json``; *this* module executes those checks — each
5
+ declared command in its own OS subprocess — grades the results deterministically,
6
+ and appends the verdict to the run log. It is invoked from the Stop hook, i.e.
7
+ from a process the working agent does not control.
8
+
9
+ The separation is load-bearing:
10
+ - the working agent writes the run records and the check spec;
11
+ - the checker (a different process) executes and grades them.
12
+
13
+ No LLM is in this path. Grading is pure comparison.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import asdict, dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .checks import Check, default_checks_path, load_checks, short_spec_hash, spec_hash
26
+ from .runlog import list_run_records, record, runs_dir
27
+
28
+ # Per-check wall-clock ceiling. A check that hangs is a failed check, not a hung fleet.
29
+ DEFAULT_TIMEOUT_S = 600
30
+
31
+
32
+ @dataclass
33
+ class CheckResult:
34
+ id: str
35
+ expectation: str
36
+ passed: bool
37
+ blocking: bool
38
+ returncode: int | None
39
+ detail: str
40
+ duration_ms: float
41
+ stdout_tail: str = ""
42
+ stderr_tail: str = ""
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return asdict(self)
46
+
47
+
48
+ @dataclass
49
+ class CheckReport:
50
+ results: list[CheckResult] = field(default_factory=list)
51
+ run_id: str | None = None
52
+ spec_sha256: str | None = None
53
+
54
+ @property
55
+ def total(self) -> int:
56
+ return len(self.results)
57
+
58
+ @property
59
+ def passed(self) -> int:
60
+ return sum(1 for r in self.results if r.passed)
61
+
62
+ @property
63
+ def failed(self) -> int:
64
+ return sum(1 for r in self.results if not r.passed)
65
+
66
+ @property
67
+ def blocking_failures(self) -> list[CheckResult]:
68
+ return [r for r in self.results if not r.passed and r.blocking]
69
+
70
+ @property
71
+ def verdict(self) -> str:
72
+ """'pass' unless a blocking check failed. Non-blocking failures do not gate."""
73
+ return "fail" if self.blocking_failures else "pass"
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ return {
77
+ "verdict": self.verdict,
78
+ # SHA-256 of the checks.json that governed this verdict. Additive:
79
+ # records written before this field existed simply omit it, and every
80
+ # reader treats a missing value as None (no hash on record).
81
+ "spec_sha256": self.spec_sha256,
82
+ "summary": {
83
+ "total": self.total,
84
+ "passed": self.passed,
85
+ "failed": self.failed,
86
+ "blocking_failed": len(self.blocking_failures),
87
+ },
88
+ "checks": [r.to_dict() for r in self.results],
89
+ }
90
+
91
+
92
+ def _tail(text: str, limit: int = 2000) -> str:
93
+ if len(text) <= limit:
94
+ return text
95
+ return "…(truncated)…\n" + text[-limit:]
96
+
97
+
98
+ def _run_command(command: str, cwd: Path, timeout: int) -> tuple[int | None, str, str]:
99
+ """Execute a shell command in a separate process. Returns (returncode, stdout, stderr).
100
+
101
+ A timeout or spawn failure yields a None returncode, which every grader treats
102
+ as a failure — the tool never lets an unrunnable check silently pass.
103
+ """
104
+ try:
105
+ proc = subprocess.run(
106
+ command,
107
+ shell=True,
108
+ cwd=str(cwd),
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=timeout,
112
+ )
113
+ return proc.returncode, proc.stdout or "", proc.stderr or ""
114
+ except subprocess.TimeoutExpired as e:
115
+ out = e.stdout or ""
116
+ err = e.stderr or ""
117
+ if isinstance(out, bytes):
118
+ out = out.decode(errors="replace")
119
+ if isinstance(err, bytes):
120
+ err = err.decode(errors="replace")
121
+ return None, out, (err + f"\n[fleetproof] command timed out after {timeout}s")
122
+ except OSError as e:
123
+ return None, "", f"[fleetproof] could not launch command: {e}"
124
+
125
+
126
+ def _grade(check: Check, returncode: int | None, stdout: str, stderr: str, cwd: Path) -> tuple[bool, str]:
127
+ """Return (passed, human-readable detail) for one check. Pure comparison."""
128
+ kind = check.expect["kind"]
129
+
130
+ if kind in ("exit0", "exit"):
131
+ want = 0 if kind == "exit0" else check.expect["code"]
132
+ if returncode is None:
133
+ return False, f"command did not complete (expected exit {want})"
134
+ ok = returncode == want
135
+ return ok, f"exit {returncode} (expected {want})"
136
+
137
+ if kind == "regex":
138
+ if returncode is None:
139
+ return False, "command did not complete; cannot match output"
140
+ pattern = check.expect["pattern"]
141
+ combined = f"{stdout}\n{stderr}"
142
+ ok = re.search(pattern, combined) is not None
143
+ return ok, ("matched" if ok else "no match") + f" for /{pattern}/"
144
+
145
+ if kind == "file_exists":
146
+ target = (cwd / check.expect["path"]).resolve()
147
+ ok = target.exists()
148
+ return ok, ("present" if ok else "missing") + f": {check.expect['path']}"
149
+
150
+ return False, f"unknown expectation kind: {kind}"
151
+
152
+
153
+ def run_check(check: Check, cwd: Path, timeout: int = DEFAULT_TIMEOUT_S) -> CheckResult:
154
+ """Execute and grade a single check in its own subprocess."""
155
+ import time as _time
156
+ started = _time.perf_counter()
157
+ returncode: int | None = None
158
+ stdout = stderr = ""
159
+ if check.run is not None:
160
+ returncode, stdout, stderr = _run_command(check.run, cwd, timeout)
161
+ passed, detail = _grade(check, returncode, stdout, stderr, cwd)
162
+ duration_ms = (_time.perf_counter() - started) * 1000.0
163
+ return CheckResult(
164
+ id=check.id,
165
+ expectation=check.describe_expectation(),
166
+ passed=passed,
167
+ blocking=check.block,
168
+ returncode=returncode,
169
+ detail=detail,
170
+ duration_ms=round(duration_ms, 3),
171
+ stdout_tail=_tail(stdout),
172
+ stderr_tail=_tail(stderr),
173
+ )
174
+
175
+
176
+ def run_checks(
177
+ checks: list[Check] | None = None,
178
+ *,
179
+ cwd: Path | None = None,
180
+ timeout: int = DEFAULT_TIMEOUT_S,
181
+ record_to_log: bool = True,
182
+ spec_path: Path | None = None,
183
+ ) -> CheckReport:
184
+ """Run every check and (by default) append the verdict to the run log.
185
+
186
+ ``checks`` defaults to the loaded ``.fleetproof/checks.json``. ``cwd`` defaults
187
+ to the current working directory — the directory the fleet actually worked in.
188
+ ``spec_path`` is the check-spec file whose bytes get hashed onto the verdict;
189
+ when omitted it is resolved from ``cwd`` the same way the checker itself finds
190
+ the spec, so the recorded hash always describes the spec that governed the run.
191
+ """
192
+ if checks is None:
193
+ checks = load_checks(spec_path)
194
+ work_dir = Path(cwd) if cwd is not None else Path.cwd()
195
+ resolved_spec = Path(spec_path) if spec_path is not None else default_checks_path(work_dir)
196
+ sha = spec_hash(resolved_spec)
197
+
198
+ report = CheckReport(spec_sha256=sha)
199
+ if not record_to_log:
200
+ for check in checks:
201
+ report.results.append(run_check(check, work_dir, timeout))
202
+ return report
203
+
204
+ with record("fleetproof", "check", {"check_count": len(checks)}) as handle:
205
+ report.run_id = handle.run_id
206
+ for check in checks:
207
+ report.results.append(run_check(check, work_dir, timeout))
208
+ payload = report.to_dict()
209
+ payload["recorded_from_pid"] = _self_pid()
210
+ handle.set_output(payload)
211
+ return report
212
+
213
+
214
+ def session_spec_baseline(session_id: str | None) -> str | None:
215
+ """The spec hash of the EARLIEST recorded checker verdict in ``session_id``.
216
+
217
+ This is the baseline every later verdict in the same session is compared
218
+ against: if a verdict's own spec hash differs from this, the checks.json was
219
+ edited mid-session (spec drift). Legacy verdicts with no hash on record are
220
+ skipped, so the baseline is the earliest verdict that actually carries one.
221
+ Returns None when there is no session context or no hashed verdict yet — in
222
+ which case there is nothing to drift *from*, so callers treat it as no drift.
223
+ """
224
+ if not session_id:
225
+ return None
226
+ hashed: list[tuple[str, str]] = []
227
+ for run in list_run_records():
228
+ if (run.session_id or None) != session_id:
229
+ continue
230
+ for sub in run.sub_invocations:
231
+ if sub.tool != "fleetproof" or sub.subcmd != "check":
232
+ continue
233
+ payload = sub.load_output()
234
+ if not isinstance(payload, dict):
235
+ continue
236
+ sha = payload.get("spec_sha256")
237
+ if sha:
238
+ order_key = sub.started_at or run.started_at or ""
239
+ hashed.append((order_key, sha))
240
+ if not hashed:
241
+ return None
242
+ hashed.sort(key=lambda item: item[0])
243
+ return hashed[0][1]
244
+
245
+
246
+ def spec_drifted(current_hash: str | None, session_id: str | None) -> tuple[bool, str | None]:
247
+ """Return ``(drifted, baseline_hash)`` for a verdict's hash within a session.
248
+
249
+ ``drifted`` is True only when a session baseline exists and the current hash
250
+ differs from it — a v0.1 pass-with-drift still passes, this just flags it.
251
+ """
252
+ baseline = session_spec_baseline(session_id)
253
+ drifted = bool(baseline and current_hash and baseline != current_hash)
254
+ return drifted, baseline
255
+
256
+
257
+ def _self_pid() -> int:
258
+ import os
259
+ return os.getpid()
260
+
261
+
262
+ def format_report_text(report: CheckReport) -> str:
263
+ """A compact plain-text rendering for the CLI and hook feedback."""
264
+ lines = []
265
+ for r in report.results:
266
+ mark = "PASS" if r.passed else ("FAIL" if r.blocking else "warn")
267
+ lines.append(f" [{mark}] {r.id}: {r.detail}")
268
+ s = report.to_dict()["summary"]
269
+ lines.append(
270
+ f"{report.verdict.upper()} - {s['passed']}/{s['total']} passed, "
271
+ f"{s['blocking_failed']} blocking failure(s)."
272
+ )
273
+ lines.append(f"spec: {short_spec_hash(report.spec_sha256)}")
274
+ return "\n".join(lines)
275
+
276
+
277
+ if __name__ == "__main__": # pragma: no cover - manual smoke entry
278
+ rep = run_checks()
279
+ print(format_report_text(rep))
280
+ sys.exit(1 if rep.verdict == "fail" else 0)
fleetproof/checks.py ADDED
@@ -0,0 +1,218 @@
1
+ """Check-spec loader.
2
+
3
+ A check spec is a JSON file — ``.fleetproof/checks.json`` by default — that
4
+ declares, up front, what "done" is supposed to mean for this repo. An agent is
5
+ allowed to *author* this file. It is never allowed to grade itself against it:
6
+ that job belongs to :mod:`fleetproof.checker`, which runs in a separate process.
7
+
8
+ Spec shape::
9
+
10
+ {
11
+ "checks": [
12
+ {
13
+ "id": "tests-pass",
14
+ "run": "python -m pytest -q",
15
+ "expect": "exit0",
16
+ "block": true,
17
+ "description": "Unit tests must pass."
18
+ }
19
+ ]
20
+ }
21
+
22
+ ``expect`` is one of:
23
+ "exit0" command must exit 0 (default if omitted)
24
+ {"exit": N} command must exit with code N
25
+ {"regex": "pattern"} combined stdout+stderr must match the regex
26
+ {"file_exists": "path"} path (relative to the run cwd) must exist
27
+
28
+ JSON, not YAML, on purpose: the runtime is stdlib-only, so there is no third-
29
+ party parser to trust in a tool whose entire job is being trustworthy.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import json
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from .runlog import PROJECT_MARKER, project_root
41
+
42
+ DEFAULT_CHECKS_FILENAME = "checks.json"
43
+
44
+ _VALID_EXPECT_KEYS = {"exit", "regex", "file_exists"}
45
+
46
+ # The line a drifted verdict carries everywhere it surfaces (gate output, report,
47
+ # CLI). One canonical string so the three surfaces never disagree about wording.
48
+ SPEC_DRIFT_NOTE = (
49
+ "NOTE: .fleetproof/checks.json was modified during this session "
50
+ "(spec drift). Review the diff before trusting this verdict."
51
+ )
52
+
53
+
54
+ class CheckSpecError(Exception):
55
+ """Raised when a check spec is missing or malformed."""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Check:
60
+ """One declared check. Immutable — the spec is a contract, not a scratchpad."""
61
+ id: str
62
+ run: str | None
63
+ expect: dict[str, Any] # normalized: {"kind": ..., ...}
64
+ block: bool
65
+ description: str = ""
66
+
67
+ def describe_expectation(self) -> str:
68
+ kind = self.expect["kind"]
69
+ if kind == "exit0":
70
+ return "exit code 0"
71
+ if kind == "exit":
72
+ return f"exit code {self.expect['code']}"
73
+ if kind == "regex":
74
+ return f"output matches /{self.expect['pattern']}/"
75
+ if kind == "file_exists":
76
+ return f"file exists: {self.expect['path']}"
77
+ return kind
78
+
79
+
80
+ def default_checks_path(start: Path | None = None) -> Path:
81
+ """Return the default check-spec location for the project rooted at ``start``."""
82
+ return project_root(start) / PROJECT_MARKER / DEFAULT_CHECKS_FILENAME
83
+
84
+
85
+ def spec_hash(path: Path | None = None) -> str | None:
86
+ """SHA-256 (hex) of the check-spec bytes, or ``None`` if it can't be read.
87
+
88
+ Hashed over the raw file bytes, not the parsed spec: the point is to notice
89
+ that *the file the agent authored changed* mid-session, which a byte hash
90
+ catches even for edits (whitespace, key reordering) that parse identically.
91
+ Returns None rather than raising so a missing/unreadable spec degrades to
92
+ "no hash on record" instead of breaking a gate run.
93
+ """
94
+ spec_path = Path(path) if path is not None else default_checks_path()
95
+ try:
96
+ return hashlib.sha256(spec_path.read_bytes()).hexdigest()
97
+ except OSError:
98
+ return None
99
+
100
+
101
+ def short_spec_hash(full: str | None, length: int = 12) -> str:
102
+ """A git-style short form of a spec hash for display; 'unknown' when absent."""
103
+ if not full:
104
+ return "unknown"
105
+ return full[:length]
106
+
107
+
108
+ def load_checks(path: Path | None = None) -> list[Check]:
109
+ """Load and validate the check spec. Raises CheckSpecError on any problem."""
110
+ spec_path = Path(path) if path is not None else default_checks_path()
111
+ if not spec_path.exists():
112
+ raise CheckSpecError(
113
+ f"No check spec found at {spec_path}. Run `fleetproof init` to create one."
114
+ )
115
+ try:
116
+ raw = json.loads(spec_path.read_text(encoding="utf-8"))
117
+ except json.JSONDecodeError as e:
118
+ raise CheckSpecError(f"Check spec {spec_path} is not valid JSON: {e}") from e
119
+
120
+ if not isinstance(raw, dict) or "checks" not in raw:
121
+ raise CheckSpecError("Check spec must be an object with a top-level 'checks' array.")
122
+ entries = raw["checks"]
123
+ if not isinstance(entries, list):
124
+ raise CheckSpecError("'checks' must be an array.")
125
+
126
+ checks: list[Check] = []
127
+ seen_ids: set[str] = set()
128
+ for i, entry in enumerate(entries):
129
+ check = _parse_check(entry, i)
130
+ if check.id in seen_ids:
131
+ raise CheckSpecError(f"Duplicate check id: {check.id!r}")
132
+ seen_ids.add(check.id)
133
+ checks.append(check)
134
+ return checks
135
+
136
+
137
+ def _parse_check(entry: Any, index: int) -> Check:
138
+ where = f"checks[{index}]"
139
+ if not isinstance(entry, dict):
140
+ raise CheckSpecError(f"{where} must be an object.")
141
+
142
+ cid = entry.get("id")
143
+ if not cid or not isinstance(cid, str):
144
+ raise CheckSpecError(f"{where} is missing a string 'id'.")
145
+
146
+ run = entry.get("run")
147
+ if run is not None and not isinstance(run, str):
148
+ raise CheckSpecError(f"{where} ({cid}): 'run' must be a string or omitted.")
149
+
150
+ block = entry.get("block", True)
151
+ if not isinstance(block, bool):
152
+ raise CheckSpecError(f"{where} ({cid}): 'block' must be true or false.")
153
+
154
+ description = entry.get("description", "")
155
+ if not isinstance(description, str):
156
+ raise CheckSpecError(f"{where} ({cid}): 'description' must be a string.")
157
+
158
+ expect = _normalize_expect(entry.get("expect", "exit0"), cid, where)
159
+
160
+ # A file_exists check may have no command; every other kind needs one.
161
+ if run is None and expect["kind"] != "file_exists":
162
+ raise CheckSpecError(
163
+ f"{where} ({cid}): a '{expect['kind']}' check requires a 'run' command."
164
+ )
165
+
166
+ return Check(id=cid, run=run, expect=expect, block=block, description=description)
167
+
168
+
169
+ def _normalize_expect(expect: Any, cid: str, where: str) -> dict[str, Any]:
170
+ if expect == "exit0":
171
+ return {"kind": "exit0"}
172
+ if not isinstance(expect, dict) or len(expect) != 1:
173
+ raise CheckSpecError(
174
+ f"{where} ({cid}): 'expect' must be \"exit0\" or a single-key object "
175
+ f"({sorted(_VALID_EXPECT_KEYS)})."
176
+ )
177
+ key, val = next(iter(expect.items()))
178
+ if key not in _VALID_EXPECT_KEYS:
179
+ raise CheckSpecError(f"{where} ({cid}): unknown expect kind {key!r}.")
180
+ if key == "exit":
181
+ if not isinstance(val, int) or isinstance(val, bool):
182
+ raise CheckSpecError(f"{where} ({cid}): expect.exit must be an integer.")
183
+ return {"kind": "exit", "code": val}
184
+ if key == "regex":
185
+ if not isinstance(val, str):
186
+ raise CheckSpecError(f"{where} ({cid}): expect.regex must be a string.")
187
+ return {"kind": "regex", "pattern": val}
188
+ # file_exists
189
+ if not isinstance(val, str):
190
+ raise CheckSpecError(f"{where} ({cid}): expect.file_exists must be a path string.")
191
+ return {"kind": "file_exists", "path": val}
192
+
193
+
194
+ STARTER_SPEC: dict[str, Any] = {
195
+ "checks": [
196
+ {
197
+ "id": "tests-pass",
198
+ "run": "python -m pytest -q",
199
+ "expect": "exit0",
200
+ "block": True,
201
+ "description": "Unit tests must pass before a task can be called done.",
202
+ },
203
+ {
204
+ "id": "build-succeeds",
205
+ "run": "python -m build",
206
+ "expect": "exit0",
207
+ "block": True,
208
+ "description": "The package must build. Edit or delete if you do not ship a package.",
209
+ },
210
+ {
211
+ "id": "changelog-updated",
212
+ "run": None,
213
+ "expect": {"file_exists": "CHANGELOG.md"},
214
+ "block": False,
215
+ "description": "Advisory reminder — non-blocking example of a file_exists check.",
216
+ },
217
+ ]
218
+ }