proofed-agent 0.1.0a2__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.
- proofed/__init__.py +4 -0
- proofed/cli.py +451 -0
- proofed/config.py +78 -0
- proofed/evidence.py +135 -0
- proofed/kernel.py +243 -0
- proofed/receipt.py +193 -0
- proofed/subject.py +166 -0
- proofed_agent-0.1.0a2.dist-info/METADATA +90 -0
- proofed_agent-0.1.0a2.dist-info/RECORD +13 -0
- proofed_agent-0.1.0a2.dist-info/WHEEL +5 -0
- proofed_agent-0.1.0a2.dist-info/entry_points.txt +2 -0
- proofed_agent-0.1.0a2.dist-info/licenses/LICENSE +202 -0
- proofed_agent-0.1.0a2.dist-info/top_level.txt +1 -0
proofed/__init__.py
ADDED
proofed/cli.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import ConfigError, config_path, find_root, load_config, write_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
EXIT_REJECT = 2
|
|
16
|
+
EXIT_HOLD = 3
|
|
17
|
+
EXIT_INVALID = 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _root(value: str | None = None) -> Path:
|
|
21
|
+
return find_root(Path(value or os.getcwd()))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _project_id(root: Path) -> str:
|
|
25
|
+
path = root / ".proofed" / "project-id"
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
if path.is_file():
|
|
28
|
+
value = path.read_text(encoding="utf-8").strip()
|
|
29
|
+
if value:
|
|
30
|
+
return value
|
|
31
|
+
value = f"urn:uuid:{uuid.uuid4()}"
|
|
32
|
+
path.write_text(value + "\n", encoding="utf-8")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _print_state(state: dict[str, Any], *, context: bool = False) -> None:
|
|
37
|
+
payload = {
|
|
38
|
+
"intent": state["intent"],
|
|
39
|
+
"intentRevision": state["intentRevision"],
|
|
40
|
+
"phase": state["phase"],
|
|
41
|
+
"next": state["nextLegalAction"],
|
|
42
|
+
"missingEvidence": state["missingEvidence"],
|
|
43
|
+
"failedPathsNotToRepeat": state["failedPathRefs"],
|
|
44
|
+
}
|
|
45
|
+
if context:
|
|
46
|
+
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
47
|
+
return
|
|
48
|
+
print(f"Intent: {payload['intent']}")
|
|
49
|
+
print(f"Phase: {payload['phase']}")
|
|
50
|
+
missing = ", ".join(payload["missingEvidence"]) or "none"
|
|
51
|
+
print(f"Missing evidence: {missing}")
|
|
52
|
+
print(f"Next: {payload['next']}")
|
|
53
|
+
if payload["failedPathsNotToRepeat"]:
|
|
54
|
+
print("Do not repeat unchanged: " + ", ".join(payload["failedPathsNotToRepeat"]))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _merge_claude_hooks(root: Path) -> Path:
|
|
58
|
+
settings_path = root / ".claude" / "settings.json"
|
|
59
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
if settings_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
63
|
+
except json.JSONDecodeError as exc:
|
|
64
|
+
raise ConfigError(f"cannot merge invalid {settings_path}: {exc}") from exc
|
|
65
|
+
else:
|
|
66
|
+
settings = {}
|
|
67
|
+
hooks = settings.setdefault("hooks", {})
|
|
68
|
+
desired = {
|
|
69
|
+
"SessionStart": {
|
|
70
|
+
"matcher": "startup|resume|clear|compact|fork",
|
|
71
|
+
"hooks": [{"type": "command", "command": "proofed hook-session-start"}],
|
|
72
|
+
},
|
|
73
|
+
"Stop": {
|
|
74
|
+
"hooks": [{"type": "command", "command": "proofed hook-stop"}],
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
for event, group in desired.items():
|
|
78
|
+
groups = hooks.setdefault(event, [])
|
|
79
|
+
marker = group["hooks"][0]["command"]
|
|
80
|
+
present = any(
|
|
81
|
+
handler.get("command") == marker
|
|
82
|
+
for existing in groups
|
|
83
|
+
if isinstance(existing, dict)
|
|
84
|
+
for handler in existing.get("hooks", [])
|
|
85
|
+
if isinstance(handler, dict)
|
|
86
|
+
)
|
|
87
|
+
if not present:
|
|
88
|
+
groups.append(group)
|
|
89
|
+
settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
90
|
+
return settings_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
94
|
+
from .evidence import infer_test_commands
|
|
95
|
+
|
|
96
|
+
root = Path(args.target).resolve()
|
|
97
|
+
existing = config_path(root).is_file()
|
|
98
|
+
if existing and args.claude and not args.force:
|
|
99
|
+
config = load_config(root)
|
|
100
|
+
commands = config["tests"].get("commands", [])
|
|
101
|
+
print(f"Kept existing {config_path(root)}")
|
|
102
|
+
else:
|
|
103
|
+
commands = infer_test_commands(root)
|
|
104
|
+
path = write_config(root, commands, force=args.force)
|
|
105
|
+
print(f"Created {path}")
|
|
106
|
+
if commands:
|
|
107
|
+
print(f"Detected {len(commands)} canonical test command(s).")
|
|
108
|
+
else:
|
|
109
|
+
print("HOLD: no canonical test command detected; edit .proofed.yml before verification.")
|
|
110
|
+
if args.claude:
|
|
111
|
+
if not args.accept_hooks:
|
|
112
|
+
print("Hook not installed. Re-run with --claude --accept-hooks after reviewing the project hook command.")
|
|
113
|
+
return EXIT_HOLD
|
|
114
|
+
hook_path = _merge_claude_hooks(root)
|
|
115
|
+
print(f"Installed project-scoped Claude hooks in {hook_path}")
|
|
116
|
+
print("Review them in Claude Code with /hooks; workspace trust is controlled by Claude Code.")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
121
|
+
from .kernel import Kernel
|
|
122
|
+
|
|
123
|
+
root = _root(args.target)
|
|
124
|
+
config = load_config(root)
|
|
125
|
+
state = Kernel(root).start_run(
|
|
126
|
+
project_id=_project_id(root),
|
|
127
|
+
intent=args.intent,
|
|
128
|
+
required_evidence=config["completion"].get("require", []),
|
|
129
|
+
test_commands=config["tests"].get("commands", []),
|
|
130
|
+
)
|
|
131
|
+
print(f"Run active: {state['runId']}")
|
|
132
|
+
_print_state(state)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
137
|
+
from .kernel import Kernel
|
|
138
|
+
|
|
139
|
+
root = _root(args.target)
|
|
140
|
+
load_config(root)
|
|
141
|
+
state = Kernel(root).active_run()
|
|
142
|
+
if state is None:
|
|
143
|
+
if args.context:
|
|
144
|
+
print('{"activeRun":false}')
|
|
145
|
+
else:
|
|
146
|
+
print("No active Proofed run.")
|
|
147
|
+
return 0
|
|
148
|
+
_print_state(state, context=args.context)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
153
|
+
from .evidence import build_test_statement, run_tests
|
|
154
|
+
from .kernel import Kernel
|
|
155
|
+
from .receipt import build_bundle, build_completion_statement, write_bundle
|
|
156
|
+
from .subject import current_subject
|
|
157
|
+
|
|
158
|
+
root = _root(args.target)
|
|
159
|
+
config = load_config(root)
|
|
160
|
+
kernel = Kernel(root)
|
|
161
|
+
state = kernel.active_run()
|
|
162
|
+
if state is None:
|
|
163
|
+
print("HOLD: no active run; run: proofed run .", file=sys.stderr)
|
|
164
|
+
return EXIT_HOLD
|
|
165
|
+
subject = current_subject(root)
|
|
166
|
+
digest = subject["digest"]["sha256"]
|
|
167
|
+
state = kernel.append(state["runId"], "VERIFICATION_REQUESTED", {"subjectDigest": digest})
|
|
168
|
+
|
|
169
|
+
test_result = state["testEvidence"].get(digest)
|
|
170
|
+
if args.run_tests:
|
|
171
|
+
result = run_tests(
|
|
172
|
+
root,
|
|
173
|
+
state["testCommands"],
|
|
174
|
+
config["tests"].get("timeout_seconds", 900),
|
|
175
|
+
)
|
|
176
|
+
subject = result.get("subject", subject)
|
|
177
|
+
digest = subject["digest"]["sha256"]
|
|
178
|
+
evidence = {
|
|
179
|
+
"passed": bool(result.get("passed")),
|
|
180
|
+
"reason": result.get("reason"),
|
|
181
|
+
"runs": result.get("runs", []),
|
|
182
|
+
}
|
|
183
|
+
state = kernel.append(
|
|
184
|
+
state["runId"],
|
|
185
|
+
"TEST_EVIDENCE_RECORDED",
|
|
186
|
+
{"subjectDigest": digest, "evidence": evidence},
|
|
187
|
+
)
|
|
188
|
+
test_result = evidence
|
|
189
|
+
|
|
190
|
+
available = {"git_diff_recorded"}
|
|
191
|
+
if test_result and test_result.get("passed"):
|
|
192
|
+
available.add("tests_passed")
|
|
193
|
+
missing = [item for item in state["requiredEvidence"] if item not in available]
|
|
194
|
+
|
|
195
|
+
if args.ci and os.environ.get("GITHUB_ACTIONS") != "true":
|
|
196
|
+
print("HOLD: --ci is only accepted inside GitHub Actions.", file=sys.stderr)
|
|
197
|
+
return EXIT_HOLD
|
|
198
|
+
trust = "L1" if args.ci else "L0"
|
|
199
|
+
environment = "github-actions" if args.ci else "local"
|
|
200
|
+
test_statement = build_test_statement(subject, test_result) if test_result else None
|
|
201
|
+
|
|
202
|
+
if not state["testCommands"] and "tests_passed" in state["requiredEvidence"]:
|
|
203
|
+
decision, code = "HOLD", "NO_TEST_COMMAND"
|
|
204
|
+
message = "no canonical test command configured"
|
|
205
|
+
next_action = "configure tests.commands in .proofed.yml"
|
|
206
|
+
elif test_result and not test_result.get("passed"):
|
|
207
|
+
decision, code = "REJECT", test_result.get("reason") or "TESTS_FAILED"
|
|
208
|
+
message = "canonical tests did not pass"
|
|
209
|
+
next_action = "inspect local .proofed/logs and fix the failing tests"
|
|
210
|
+
elif missing:
|
|
211
|
+
decision, code = "REJECT", "MISSING_REQUIRED_EVIDENCE"
|
|
212
|
+
message = "missing " + ", ".join(missing)
|
|
213
|
+
next_action = "run: proofed verify --run-tests"
|
|
214
|
+
else:
|
|
215
|
+
decision, code = "PASSED", "ALL_REQUIRED_EVIDENCE_PRESENT"
|
|
216
|
+
message = "all required evidence matches the current subject"
|
|
217
|
+
next_action = "none"
|
|
218
|
+
|
|
219
|
+
completion = build_completion_statement(
|
|
220
|
+
subject=subject,
|
|
221
|
+
run_state=state,
|
|
222
|
+
decision=decision,
|
|
223
|
+
reason_code=code,
|
|
224
|
+
reason_message=message,
|
|
225
|
+
next_action=next_action,
|
|
226
|
+
missing=missing,
|
|
227
|
+
test_statement=test_statement,
|
|
228
|
+
trust_level=trust,
|
|
229
|
+
producer_environment=environment,
|
|
230
|
+
)
|
|
231
|
+
bundle = build_bundle(completion, test_statement)
|
|
232
|
+
receipt_path = root / ".proofed" / "receipts" / f"completion-{state['runId']}-r{state['intentRevision']}.json"
|
|
233
|
+
write_bundle(receipt_path, bundle)
|
|
234
|
+
state = kernel.append(
|
|
235
|
+
state["runId"],
|
|
236
|
+
"VERIFICATION_DECIDED",
|
|
237
|
+
{
|
|
238
|
+
"decision": decision,
|
|
239
|
+
"reason": {"code": code, "message": message},
|
|
240
|
+
"missingEvidence": missing,
|
|
241
|
+
"nextLegalAction": next_action,
|
|
242
|
+
"subjectDigest": digest,
|
|
243
|
+
"receiptPath": str(receipt_path.relative_to(root)),
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
if decision == "PASSED":
|
|
247
|
+
print("PASSED: current code has all required evidence")
|
|
248
|
+
print(f"Receipt: {state['receiptPath']}")
|
|
249
|
+
print(f"Subject: sha256:{digest}")
|
|
250
|
+
return 0
|
|
251
|
+
print(f"{decision}: {message}", file=sys.stderr)
|
|
252
|
+
print(f"Next: {next_action}", file=sys.stderr)
|
|
253
|
+
print(f"Receipt: {state['receiptPath']}", file=sys.stderr)
|
|
254
|
+
return EXIT_HOLD if decision == "HOLD" else EXIT_REJECT
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_check_receipt(args: argparse.Namespace) -> int:
|
|
258
|
+
from .receipt import verify_bundle
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
bundle = json.loads(Path(args.receipt).read_text(encoding="utf-8"))
|
|
262
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
263
|
+
print(f"INVALID: {exc}", file=sys.stderr)
|
|
264
|
+
return EXIT_INVALID
|
|
265
|
+
expected = None
|
|
266
|
+
if args.current:
|
|
267
|
+
from .subject import current_subject
|
|
268
|
+
|
|
269
|
+
expected = current_subject(_root(args.current))["digest"]["sha256"]
|
|
270
|
+
errors = verify_bundle(bundle, expected_subject=expected, require_trust=args.require_trust)
|
|
271
|
+
if errors:
|
|
272
|
+
print("INVALID: " + ", ".join(errors), file=sys.stderr)
|
|
273
|
+
return EXIT_INVALID
|
|
274
|
+
print("VALID: completion receipt passed independent checks")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _read_hook_input() -> dict[str, Any]:
|
|
279
|
+
try:
|
|
280
|
+
value = json.load(sys.stdin)
|
|
281
|
+
return value if isinstance(value, dict) else {}
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
return {}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _hook_root(payload: dict[str, Any]) -> Path:
|
|
287
|
+
cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
288
|
+
return _root(str(cwd))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_hook_session_start(_args: argparse.Namespace) -> int:
|
|
292
|
+
payload = _read_hook_input()
|
|
293
|
+
root = _hook_root(payload)
|
|
294
|
+
if not config_path(root).is_file() or not (root / ".proofed" / "state.db").is_file():
|
|
295
|
+
return 0
|
|
296
|
+
from .kernel import Kernel
|
|
297
|
+
|
|
298
|
+
kernel = Kernel(root)
|
|
299
|
+
state = kernel.active_run()
|
|
300
|
+
if state is None:
|
|
301
|
+
return 0
|
|
302
|
+
state = kernel.append(
|
|
303
|
+
state["runId"],
|
|
304
|
+
"HOOK_HANDSHAKE",
|
|
305
|
+
{"host": "claude-code", "event": "SessionStart", "source": payload.get("source")},
|
|
306
|
+
)
|
|
307
|
+
context = {
|
|
308
|
+
"intent": state["intent"],
|
|
309
|
+
"intentRevision": state["intentRevision"],
|
|
310
|
+
"phase": state["phase"],
|
|
311
|
+
"missingEvidence": state["missingEvidence"],
|
|
312
|
+
"next": state["nextLegalAction"],
|
|
313
|
+
"failedPathsNotToRepeat": state["failedPathRefs"],
|
|
314
|
+
}
|
|
315
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "Proofed: " + json.dumps(context, ensure_ascii=False)}}))
|
|
316
|
+
return 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cmd_hook_stop(_args: argparse.Namespace) -> int:
|
|
320
|
+
payload = _read_hook_input()
|
|
321
|
+
root = _hook_root(payload)
|
|
322
|
+
if not config_path(root).is_file() or not (root / ".proofed" / "state.db").is_file():
|
|
323
|
+
return 0
|
|
324
|
+
from .kernel import Kernel
|
|
325
|
+
from .subject import current_subject
|
|
326
|
+
|
|
327
|
+
config = load_config(root)
|
|
328
|
+
if not config.get("hooks", {}).get("stop_gate", False):
|
|
329
|
+
return 0
|
|
330
|
+
kernel = Kernel(root)
|
|
331
|
+
state = kernel.active_run()
|
|
332
|
+
if state is None:
|
|
333
|
+
return 0
|
|
334
|
+
state = kernel.append(
|
|
335
|
+
state["runId"],
|
|
336
|
+
"HOOK_HANDSHAKE",
|
|
337
|
+
{"host": "claude-code", "event": "Stop", "source": None},
|
|
338
|
+
)
|
|
339
|
+
if payload.get("background_tasks"):
|
|
340
|
+
return 0
|
|
341
|
+
if payload.get("stop_hook_active") is True:
|
|
342
|
+
kernel.append(
|
|
343
|
+
state["runId"],
|
|
344
|
+
"HOLD_RECORDED",
|
|
345
|
+
{"reason": {"code": "HOST_STOP_REENTRY", "message": "host stop hook re-entry"}, "nextLegalAction": "review Proofed status manually"},
|
|
346
|
+
)
|
|
347
|
+
return 0
|
|
348
|
+
if state["phase"] != "VERIFYING" and not state["completionRequested"]:
|
|
349
|
+
return 0
|
|
350
|
+
subject = current_subject(root)["digest"]["sha256"]
|
|
351
|
+
if state.get("decision") == "PASSED" and state.get("subjectDigest") == subject:
|
|
352
|
+
return 0
|
|
353
|
+
reason = (state.get("reason") or {}).get("code") or "MISSING_REQUIRED_EVIDENCE"
|
|
354
|
+
budget_key = "|".join(["claude-code", state["runId"], str(state["intentRevision"]), subject, reason])
|
|
355
|
+
count = int(state.get("hookBlocks", {}).get(budget_key, 0))
|
|
356
|
+
maximum = int(config.get("hooks", {}).get("max_blocks_per_revision", 2))
|
|
357
|
+
if count >= maximum:
|
|
358
|
+
kernel.append(
|
|
359
|
+
state["runId"],
|
|
360
|
+
"HOLD_RECORDED",
|
|
361
|
+
{"reason": {"code": "BLOCK_BUDGET_EXHAUSTED", "message": "automatic stop block budget exhausted"}, "nextLegalAction": "run proofed status and resolve or cancel the run"},
|
|
362
|
+
)
|
|
363
|
+
return 0
|
|
364
|
+
kernel.append(state["runId"], "HOOK_BLOCKED", {"budgetKey": budget_key})
|
|
365
|
+
missing = ", ".join(state.get("missingEvidence", [])) or "current subject evidence"
|
|
366
|
+
print(json.dumps({"decision": "block", "reason": f"Proofed rejected completion: missing {missing}. Next: {state['nextLegalAction']}"}, ensure_ascii=False))
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
371
|
+
root = _root(args.target)
|
|
372
|
+
opted_in = config_path(root).is_file()
|
|
373
|
+
settings = root / ".claude" / "settings.json"
|
|
374
|
+
configured = settings.is_file() and "proofed hook-stop" in settings.read_text(encoding="utf-8", errors="ignore")
|
|
375
|
+
handshake = None
|
|
376
|
+
active = False
|
|
377
|
+
if (root / ".proofed" / "state.db").is_file():
|
|
378
|
+
from .kernel import Kernel
|
|
379
|
+
|
|
380
|
+
state = Kernel(root).active_run()
|
|
381
|
+
if state:
|
|
382
|
+
active = True
|
|
383
|
+
handshake = state.get("lastHookHandshake")
|
|
384
|
+
print(f"Repository opt-in: {'yes' if opted_in else 'no'}")
|
|
385
|
+
print(f"Active run: {'yes' if active else 'no'}")
|
|
386
|
+
print(f"Claude project hook configured: {'yes' if configured else 'no'}")
|
|
387
|
+
print(f"Recent hook handshake: {'yes' if handshake else 'no'}")
|
|
388
|
+
print("Claude workspace trust: unknown (review with /hooks in Claude Code)")
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
393
|
+
parser = argparse.ArgumentParser(prog="proofed")
|
|
394
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
395
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
396
|
+
|
|
397
|
+
init = sub.add_parser("init", help="create repository opt-in configuration")
|
|
398
|
+
init.add_argument("target", nargs="?", default=".")
|
|
399
|
+
init.add_argument("--force", action="store_true")
|
|
400
|
+
init.add_argument("--claude", action="store_true", help="also install project-scoped Claude Code hooks")
|
|
401
|
+
init.add_argument("--accept-hooks", action="store_true", help="confirm review of the hook command")
|
|
402
|
+
init.set_defaults(func=cmd_init)
|
|
403
|
+
|
|
404
|
+
run = sub.add_parser("run", help="start or resume a user-owned completion run")
|
|
405
|
+
run.add_argument("target", nargs="?", default=".")
|
|
406
|
+
run.add_argument("--intent", default="complete the current repository task")
|
|
407
|
+
run.set_defaults(func=cmd_run)
|
|
408
|
+
|
|
409
|
+
status = sub.add_parser("status", help="show the next legal action and missing evidence")
|
|
410
|
+
status.add_argument("target", nargs="?", default=".")
|
|
411
|
+
status.add_argument("--context", action="store_true")
|
|
412
|
+
status.set_defaults(func=cmd_status)
|
|
413
|
+
|
|
414
|
+
verify = sub.add_parser("verify", help="verify required evidence for the current subject")
|
|
415
|
+
verify.add_argument("target", nargs="?", default=".")
|
|
416
|
+
verify.add_argument("--run-tests", action="store_true")
|
|
417
|
+
verify.add_argument("--ci", action="store_true", help=argparse.SUPPRESS)
|
|
418
|
+
verify.set_defaults(func=cmd_verify)
|
|
419
|
+
|
|
420
|
+
check = sub.add_parser("check-receipt", help="verify a receipt without opening the Proofed state database")
|
|
421
|
+
check.add_argument("receipt")
|
|
422
|
+
check.add_argument("--current", metavar="PROJECT")
|
|
423
|
+
check.add_argument("--require-trust", choices=["L0", "L1", "L2"])
|
|
424
|
+
check.set_defaults(func=cmd_check_receipt)
|
|
425
|
+
|
|
426
|
+
doctor = sub.add_parser("doctor", help="diagnose opt-in and hook visibility without changing trust")
|
|
427
|
+
doctor.add_argument("target", nargs="?", default=".")
|
|
428
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
429
|
+
|
|
430
|
+
session = sub.add_parser("hook-session-start", help=argparse.SUPPRESS)
|
|
431
|
+
session.set_defaults(func=cmd_hook_session_start)
|
|
432
|
+
stop = sub.add_parser("hook-stop", help=argparse.SUPPRESS)
|
|
433
|
+
stop.set_defaults(func=cmd_hook_stop)
|
|
434
|
+
return parser
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def main(argv: list[str] | None = None) -> int:
|
|
438
|
+
parser = build_parser()
|
|
439
|
+
args = parser.parse_args(argv)
|
|
440
|
+
try:
|
|
441
|
+
return int(args.func(args))
|
|
442
|
+
except ConfigError as exc:
|
|
443
|
+
print(f"HOLD: {exc}", file=sys.stderr)
|
|
444
|
+
return EXIT_HOLD
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
447
|
+
return 1
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
raise SystemExit(main())
|
proofed/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONFIG_NAME = ".proofed.yml"
|
|
9
|
+
DEFAULT_REQUIRED = ["tests_passed", "git_diff_recorded"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_path(root: Path) -> Path:
|
|
17
|
+
return root / CONFIG_NAME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config(root: Path) -> dict[str, Any]:
|
|
21
|
+
path = config_path(root)
|
|
22
|
+
if not path.is_file():
|
|
23
|
+
raise ConfigError("repository is not opted in; run: proofed init")
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
27
|
+
raise ConfigError(f"{CONFIG_NAME} must be valid JSON-compatible YAML: {exc}") from exc
|
|
28
|
+
if not isinstance(data, dict) or data.get("version") != 1:
|
|
29
|
+
raise ConfigError(f"{CONFIG_NAME} requires version: 1")
|
|
30
|
+
tests = data.get("tests", {})
|
|
31
|
+
completion = data.get("completion", {})
|
|
32
|
+
hooks = data.get("hooks", {})
|
|
33
|
+
if not isinstance(tests, dict) or not isinstance(completion, dict) or not isinstance(hooks, dict):
|
|
34
|
+
raise ConfigError("tests, completion, and hooks must be objects")
|
|
35
|
+
commands = tests.get("commands", [])
|
|
36
|
+
required = completion.get("require", DEFAULT_REQUIRED)
|
|
37
|
+
if not isinstance(commands, list) or not all(isinstance(x, list) for x in commands):
|
|
38
|
+
raise ConfigError("tests.commands must be a list of argv lists")
|
|
39
|
+
if not all(x and all(isinstance(part, str) and part for part in x) for x in commands):
|
|
40
|
+
raise ConfigError("every test command must contain non-empty string argv entries")
|
|
41
|
+
if not isinstance(required, list) or not all(isinstance(x, str) for x in required):
|
|
42
|
+
raise ConfigError("completion.require must be a list of strings")
|
|
43
|
+
unknown = set(required) - {"tests_passed", "git_diff_recorded"}
|
|
44
|
+
if unknown:
|
|
45
|
+
raise ConfigError(f"unsupported required evidence: {', '.join(sorted(unknown))}")
|
|
46
|
+
timeout = tests.get("timeout_seconds", 900)
|
|
47
|
+
if not isinstance(timeout, int) or not 1 <= timeout <= 7200:
|
|
48
|
+
raise ConfigError("tests.timeout_seconds must be an integer from 1 to 7200")
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_config(root: Path, commands: list[list[str]], *, force: bool = False) -> Path:
|
|
53
|
+
path = config_path(root)
|
|
54
|
+
if path.exists() and not force:
|
|
55
|
+
raise ConfigError(f"{CONFIG_NAME} already exists; use --force to replace it")
|
|
56
|
+
data = {
|
|
57
|
+
"version": 1,
|
|
58
|
+
"tests": {
|
|
59
|
+
"auto_inferred": True,
|
|
60
|
+
"commands": commands,
|
|
61
|
+
"timeout_seconds": 900,
|
|
62
|
+
},
|
|
63
|
+
"completion": {"require": list(DEFAULT_REQUIRED)},
|
|
64
|
+
"hooks": {"stop_gate": True, "max_blocks_per_revision": 2},
|
|
65
|
+
}
|
|
66
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_root(start: Path) -> Path:
|
|
71
|
+
current = start.resolve()
|
|
72
|
+
if current.is_file():
|
|
73
|
+
current = current.parent
|
|
74
|
+
for candidate in (current, *current.parents):
|
|
75
|
+
if (candidate / CONFIG_NAME).is_file():
|
|
76
|
+
return candidate
|
|
77
|
+
return current
|
|
78
|
+
|
proofed/evidence.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .subject import canonical_json, current_subject, sha256_bytes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TEST_RESULT_TYPE = "https://in-toto.io/attestation/test-result/v0.1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def infer_test_commands(root: Path) -> list[list[str]]:
|
|
20
|
+
commands: list[list[str]] = []
|
|
21
|
+
python_test_files = list((root / "tests").glob("test_*.py"))
|
|
22
|
+
has_python_tests = (root / "pytest.ini").is_file() or bool(python_test_files)
|
|
23
|
+
pyproject = root / "pyproject.toml"
|
|
24
|
+
mentions_pytest = pyproject.is_file() and "pytest" in pyproject.read_text(
|
|
25
|
+
encoding="utf-8", errors="ignore"
|
|
26
|
+
).lower()
|
|
27
|
+
mentions_unittest = any(
|
|
28
|
+
"unittest" in path.read_text(encoding="utf-8", errors="ignore")
|
|
29
|
+
for path in python_test_files
|
|
30
|
+
)
|
|
31
|
+
if has_python_tests or mentions_pytest:
|
|
32
|
+
if importlib.util.find_spec("pytest") is not None or mentions_pytest:
|
|
33
|
+
commands.append([sys.executable, "-m", "pytest", "-q"])
|
|
34
|
+
elif mentions_unittest:
|
|
35
|
+
commands.append([sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"])
|
|
36
|
+
|
|
37
|
+
package_json = root / "package.json"
|
|
38
|
+
if package_json.is_file():
|
|
39
|
+
try:
|
|
40
|
+
package = json.loads(package_json.read_text(encoding="utf-8"))
|
|
41
|
+
script = package.get("scripts", {}).get("test")
|
|
42
|
+
placeholder = "no test specified" in str(script).lower()
|
|
43
|
+
if script and not placeholder:
|
|
44
|
+
commands.append(["npm", "test"])
|
|
45
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
46
|
+
pass
|
|
47
|
+
return commands
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _configuration_descriptor(argv: list[str]) -> dict[str, Any]:
|
|
51
|
+
command_spec = {"argv": argv, "version": "0.1"}
|
|
52
|
+
return {
|
|
53
|
+
"name": "canonical-test-command",
|
|
54
|
+
"digest": {"sha256": sha256_bytes(canonical_json(command_spec))},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def command_digests(commands: list[list[str]]) -> list[str]:
|
|
59
|
+
return [_configuration_descriptor(command)["digest"]["sha256"] for command in commands]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_tests(root: Path, commands: list[list[str]], timeout_seconds: int) -> dict[str, Any]:
|
|
63
|
+
if not commands:
|
|
64
|
+
return {"passed": False, "reason": "NO_TEST_COMMAND", "runs": []}
|
|
65
|
+
proofed_dir = root / ".proofed"
|
|
66
|
+
logs_dir = proofed_dir / "logs"
|
|
67
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
before = current_subject(root)
|
|
69
|
+
runs: list[dict[str, Any]] = []
|
|
70
|
+
all_passed = True
|
|
71
|
+
for index, argv in enumerate(commands, start=1):
|
|
72
|
+
started = time.monotonic()
|
|
73
|
+
try:
|
|
74
|
+
completed = subprocess.run(
|
|
75
|
+
argv,
|
|
76
|
+
cwd=root,
|
|
77
|
+
stdout=subprocess.PIPE,
|
|
78
|
+
stderr=subprocess.STDOUT,
|
|
79
|
+
timeout=timeout_seconds,
|
|
80
|
+
check=False,
|
|
81
|
+
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
|
82
|
+
)
|
|
83
|
+
output = completed.stdout
|
|
84
|
+
exit_code = completed.returncode
|
|
85
|
+
timed_out = False
|
|
86
|
+
except subprocess.TimeoutExpired as exc:
|
|
87
|
+
output = (exc.stdout or b"") + (exc.stderr or b"")
|
|
88
|
+
exit_code = 124
|
|
89
|
+
timed_out = True
|
|
90
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
91
|
+
log_path = logs_dir / f"test-{index}.log"
|
|
92
|
+
log_path.write_bytes(output)
|
|
93
|
+
try:
|
|
94
|
+
log_path.chmod(0o600)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
run = {
|
|
98
|
+
"configuration": _configuration_descriptor(argv),
|
|
99
|
+
"exitCode": exit_code,
|
|
100
|
+
"timedOut": timed_out,
|
|
101
|
+
"durationMillis": duration_ms,
|
|
102
|
+
"outputSha256": hashlib.sha256(output).hexdigest(),
|
|
103
|
+
}
|
|
104
|
+
runs.append(run)
|
|
105
|
+
if exit_code != 0:
|
|
106
|
+
all_passed = False
|
|
107
|
+
after = current_subject(root)
|
|
108
|
+
subject_stable = before["digest"] == after["digest"]
|
|
109
|
+
return {
|
|
110
|
+
"passed": all_passed and subject_stable,
|
|
111
|
+
"reason": None if all_passed and subject_stable else (
|
|
112
|
+
"SUBJECT_CHANGED_DURING_TESTS" if not subject_stable else "TESTS_FAILED"
|
|
113
|
+
),
|
|
114
|
+
"subject": after,
|
|
115
|
+
"subjectStable": subject_stable,
|
|
116
|
+
"runs": runs,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_test_statement(subject: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
configurations = [run["configuration"] for run in result.get("runs", [])]
|
|
122
|
+
passed_names = [f"canonical-suite-{i}" for i, run in enumerate(result.get("runs", []), 1) if run["exitCode"] == 0]
|
|
123
|
+
failed_names = [f"canonical-suite-{i}" for i, run in enumerate(result.get("runs", []), 1) if run["exitCode"] != 0]
|
|
124
|
+
return {
|
|
125
|
+
"_type": "https://in-toto.io/Statement/v1",
|
|
126
|
+
"subject": [{"name": subject["name"], "digest": subject["digest"]}],
|
|
127
|
+
"predicateType": TEST_RESULT_TYPE,
|
|
128
|
+
"predicate": {
|
|
129
|
+
"result": "PASSED" if result.get("passed") else "FAILED",
|
|
130
|
+
"configuration": configurations,
|
|
131
|
+
"passedTests": passed_names,
|
|
132
|
+
"warnedTests": [],
|
|
133
|
+
"failedTests": failed_names,
|
|
134
|
+
},
|
|
135
|
+
}
|