warrant-cli 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.
- warrant/__init__.py +8 -0
- warrant/__main__.py +3 -0
- warrant/checks.py +120 -0
- warrant/cli.py +321 -0
- warrant/config.py +192 -0
- warrant/errors.py +2 -0
- warrant/gate.py +151 -0
- warrant/hashing.py +85 -0
- warrant/intent.py +104 -0
- warrant/ledger.py +106 -0
- warrant/project.py +81 -0
- warrant/report.py +261 -0
- warrant/scaffold.py +112 -0
- warrant/verify.py +120 -0
- warrant_cli-0.1.0.dist-info/METADATA +189 -0
- warrant_cli-0.1.0.dist-info/RECORD +20 -0
- warrant_cli-0.1.0.dist-info/WHEEL +5 -0
- warrant_cli-0.1.0.dist-info/entry_points.txt +2 -0
- warrant_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- warrant_cli-0.1.0.dist-info/top_level.txt +1 -0
warrant/gate.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The gatekeeper: deterministic rules that turn evidence into warrants or denials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from warrant import __version__
|
|
8
|
+
from warrant.config import STRENGTHS
|
|
9
|
+
from warrant.errors import WarrantError
|
|
10
|
+
from warrant.hashing import short
|
|
11
|
+
|
|
12
|
+
RANK = {strength: rank for rank, strength in enumerate(STRENGTHS)}
|
|
13
|
+
GATEKEEPER = f"gatekeeper:warrant-cli/{__version__}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Decision:
|
|
18
|
+
action: str
|
|
19
|
+
workspace: str
|
|
20
|
+
baseline: str
|
|
21
|
+
approval: dict | None
|
|
22
|
+
granted: bool = False
|
|
23
|
+
qualifier: str | None = None
|
|
24
|
+
reasons: list[str] = field(default_factory=list)
|
|
25
|
+
builder_reasons: list[str] = field(default_factory=list) # the same, naming hidden checks only by promise
|
|
26
|
+
rebuttals: list[str] = field(default_factory=list)
|
|
27
|
+
grounds: list[str] = field(default_factory=list) # evidence digests
|
|
28
|
+
passed: list[str] = field(default_factory=list) # the checks that evidence came from
|
|
29
|
+
checks: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
def deny(self, reason: str, builder_reason: str | None = None) -> None:
|
|
32
|
+
if reason not in self.reasons:
|
|
33
|
+
self.reasons.append(reason)
|
|
34
|
+
builder_reason = builder_reason or reason
|
|
35
|
+
if builder_reason not in self.builder_reasons:
|
|
36
|
+
self.builder_reasons.append(builder_reason)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def latest_results(project, workspace: str) -> dict[str, dict]:
|
|
40
|
+
"""The most recent result for each current check on exactly this workspace."""
|
|
41
|
+
current = {check.id: check.digest for check in project.checks}
|
|
42
|
+
results = {}
|
|
43
|
+
for entry in project.ledger.entries():
|
|
44
|
+
if entry["type"] != "verify" or entry["data"]["workspace"] != workspace:
|
|
45
|
+
continue
|
|
46
|
+
for result in entry["data"]["results"]:
|
|
47
|
+
if current.get(result["check"]) == result["digest"]:
|
|
48
|
+
results[result["check"]] = result
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def evaluate(project, action: str, workspace: str) -> Decision:
|
|
53
|
+
gate = project.config.gates.get(action)
|
|
54
|
+
if gate is None:
|
|
55
|
+
raise WarrantError(f"no gate named {action!r}; warrant.toml defines {', '.join(project.config.gates)}")
|
|
56
|
+
state, approval = project.approval_state()
|
|
57
|
+
decision = Decision(action, workspace, project.baseline_digest(), approval)
|
|
58
|
+
|
|
59
|
+
if state == "missing":
|
|
60
|
+
decision.deny("nobody has approved the intent and checks yet")
|
|
61
|
+
elif state == "stale":
|
|
62
|
+
decision.deny("changed since approval: " + "; ".join(project.changes_since(approval)))
|
|
63
|
+
|
|
64
|
+
for prerequisite in gate.after:
|
|
65
|
+
if not evaluate(project, prerequisite, workspace).granted:
|
|
66
|
+
decision.deny(f"needs a {prerequisite} warrant first")
|
|
67
|
+
|
|
68
|
+
results = latest_results(project, workspace)
|
|
69
|
+
selected = [check for check in project.checks if any(check.matches(s) for s in gate.checks)]
|
|
70
|
+
decision.checks = [check.id for check in selected]
|
|
71
|
+
if not selected:
|
|
72
|
+
decision.deny("this gate selects no checks")
|
|
73
|
+
for check in selected:
|
|
74
|
+
_require_pass(decision, check, results.get(check.id))
|
|
75
|
+
if RANK[check.strength] < RANK[gate.min_strength]:
|
|
76
|
+
needs = f"is only {check.strength}; {action} needs {gate.min_strength} evidence"
|
|
77
|
+
decision.deny(f"{check.id} {needs}", f"{_named(check)} {needs}")
|
|
78
|
+
|
|
79
|
+
covered = {clause for check in selected for clause in check.covers}
|
|
80
|
+
uncovered = [clause for clause in project.intent.ids if clause not in covered]
|
|
81
|
+
if uncovered and gate.cover_all_intent:
|
|
82
|
+
decision.deny("no check covers " + ", ".join(uncovered))
|
|
83
|
+
elif uncovered:
|
|
84
|
+
decision.rebuttals.append(f"the {action} gate does not check " + ", ".join(uncovered))
|
|
85
|
+
|
|
86
|
+
if gate.require_regressions:
|
|
87
|
+
for incident in project.incidents():
|
|
88
|
+
regressions = [check for check in project.checks if incident["id"] in check.regression_for]
|
|
89
|
+
if not regressions:
|
|
90
|
+
decision.deny(f"incident {incident['id']} has no regression check")
|
|
91
|
+
for check in regressions:
|
|
92
|
+
_require_pass(decision, check, results.get(check.id))
|
|
93
|
+
|
|
94
|
+
def only(predicate) -> list[str]:
|
|
95
|
+
return [
|
|
96
|
+
clause for clause in project.intent.ids
|
|
97
|
+
if clause in covered and all(predicate(check) for check in selected if clause in check.covers)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
visible_only = only(lambda check: check.visibility == "public")
|
|
101
|
+
if visible_only:
|
|
102
|
+
decision.rebuttals.append("only checks the builder could see cover " + ", ".join(visible_only))
|
|
103
|
+
judged_only = only(lambda check: check.strength == "judged")
|
|
104
|
+
if judged_only:
|
|
105
|
+
decision.rebuttals.append("only judged, non-reproducible evidence covers " + ", ".join(judged_only))
|
|
106
|
+
|
|
107
|
+
if selected:
|
|
108
|
+
decision.qualifier = min((check.strength for check in selected), key=RANK.__getitem__)
|
|
109
|
+
decision.granted = not decision.reasons
|
|
110
|
+
return decision
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def record(project, decision: Decision, label: str) -> dict:
|
|
114
|
+
"""Write the decision to the ledger, structured the way Toulmin structures an argument."""
|
|
115
|
+
approval = decision.approval
|
|
116
|
+
verb = "keeps" if decision.granted else "has not shown that it keeps"
|
|
117
|
+
data = {
|
|
118
|
+
"action": decision.action,
|
|
119
|
+
"granted": decision.granted,
|
|
120
|
+
"claim": f"Workspace {short(decision.workspace)} ({label}) {verb} every promise the {decision.action} gate requires.",
|
|
121
|
+
"workspace": decision.workspace,
|
|
122
|
+
"label": label,
|
|
123
|
+
"grounds": decision.grounds,
|
|
124
|
+
"rule": {"gate": decision.action, **project.config.gates[decision.action].policy()},
|
|
125
|
+
"backing": {
|
|
126
|
+
"baseline": decision.baseline,
|
|
127
|
+
"approvedBy": approval["data"]["by"] if approval else None,
|
|
128
|
+
"approvalEntry": approval["seq"] if approval else None,
|
|
129
|
+
"gatekeeper": f"warrant-cli/{__version__}",
|
|
130
|
+
},
|
|
131
|
+
"qualifier": decision.qualifier if decision.granted else None,
|
|
132
|
+
"rebuttals": decision.rebuttals,
|
|
133
|
+
"reasons": decision.reasons,
|
|
134
|
+
}
|
|
135
|
+
return project.ledger.append("warrant" if decision.granted else "denial", GATEKEEPER, data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _require_pass(decision: Decision, check, result: dict | None) -> None:
|
|
139
|
+
if result is None:
|
|
140
|
+
decision.deny(f"{check.id} has not been run on this workspace", f"{_named(check)} has not been run on this workspace")
|
|
141
|
+
elif result["result"] != "pass":
|
|
142
|
+
verb = "failed" if result["result"] == "fail" else "could not run"
|
|
143
|
+
decision.deny(f"{check.id} {verb}", f"{_named(check)} {verb}")
|
|
144
|
+
elif result["evidence"] not in decision.grounds:
|
|
145
|
+
decision.grounds.append(result["evidence"])
|
|
146
|
+
decision.passed.append(check.id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _named(check) -> str:
|
|
150
|
+
"""How a builder hears about a check: hidden ones only by the promises they cover."""
|
|
151
|
+
return check.id if check.visibility == "public" else "a hidden check on " + ", ".join(check.covers)
|
warrant/hashing.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Content addressing: digests of JSON values and of directory trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from warrant.errors import WarrantError
|
|
13
|
+
|
|
14
|
+
# Never part of a snapshot: version control, caches, local environments and Warrant's own state.
|
|
15
|
+
EXCLUDED_NAMES = frozenset({
|
|
16
|
+
".git", ".hg", ".svn", ".warrant", "__pycache__", ".pytest_cache", ".mypy_cache",
|
|
17
|
+
".ruff_cache", ".venv", "venv", "node_modules", ".DS_Store",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def canonical_json(value) -> bytes:
|
|
22
|
+
"""The single byte encoding of a JSON value that every digest is computed over."""
|
|
23
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def digest_bytes(data: bytes) -> str:
|
|
27
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def digest_json(value) -> str:
|
|
31
|
+
return digest_bytes(canonical_json(value))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def hex_part(digest: str) -> str:
|
|
35
|
+
return digest.partition(":")[2]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def short(digest: str | None) -> str:
|
|
39
|
+
return f"sha256:{hex_part(digest)[:12]}" if digest else "none"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tree_digest(root: Path) -> str:
|
|
43
|
+
"""Digest of every file's path, content and executable bit under root."""
|
|
44
|
+
if not root.is_dir():
|
|
45
|
+
raise WarrantError(f"{root} is not a directory")
|
|
46
|
+
listing = "".join(f"{kind} {relative}\n" for relative, kind in sorted(_entries(root)))
|
|
47
|
+
return digest_bytes(listing.encode("utf-8"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def copy_tree(source: Path, destination: Path) -> None:
|
|
51
|
+
shutil.copytree(source, destination, symlinks=True, ignore=shutil.ignore_patterns(*EXCLUDED_NAMES))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _entries(root: Path):
|
|
55
|
+
for current, dirs, files in os.walk(root):
|
|
56
|
+
base = Path(current)
|
|
57
|
+
descend = []
|
|
58
|
+
for name in sorted(dirs):
|
|
59
|
+
if name in EXCLUDED_NAMES:
|
|
60
|
+
continue
|
|
61
|
+
path = base / name
|
|
62
|
+
if path.is_symlink():
|
|
63
|
+
yield _relative(root, path), _link(path)
|
|
64
|
+
else:
|
|
65
|
+
descend.append(name)
|
|
66
|
+
dirs[:] = descend
|
|
67
|
+
for name in files:
|
|
68
|
+
if name in EXCLUDED_NAMES:
|
|
69
|
+
continue
|
|
70
|
+
path = base / name
|
|
71
|
+
if path.is_symlink():
|
|
72
|
+
yield _relative(root, path), _link(path)
|
|
73
|
+
continue
|
|
74
|
+
kind = "exec" if path.stat().st_mode & stat.S_IXUSR else "file"
|
|
75
|
+
with path.open("rb") as handle:
|
|
76
|
+
content = hashlib.file_digest(handle, "sha256").hexdigest()
|
|
77
|
+
yield _relative(root, path), f"{kind} {content}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _relative(root: Path, path: Path) -> str:
|
|
81
|
+
return path.relative_to(root).as_posix()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _link(path: Path) -> str:
|
|
85
|
+
return "link " + hashlib.sha256(os.readlink(path).encode("utf-8")).hexdigest()
|
warrant/intent.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Intent documents: Markdown in which every promise is a list item that starts with an ID."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from warrant.errors import WarrantError
|
|
10
|
+
from warrant.hashing import digest_bytes
|
|
11
|
+
|
|
12
|
+
CLAUSE = re.compile(r"^[-*] +([A-Z]{1,3}[0-9]{1,4}): +(\S.*)$")
|
|
13
|
+
INFORMATIONAL_SECTIONS = ("out of scope", "non-goals", "non goals")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Clause:
|
|
18
|
+
id: str
|
|
19
|
+
text: str
|
|
20
|
+
section: str
|
|
21
|
+
line: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Intent:
|
|
26
|
+
path: Path
|
|
27
|
+
title: str
|
|
28
|
+
meta: dict[str, str]
|
|
29
|
+
clauses: list[Clause]
|
|
30
|
+
digest: str
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ids(self) -> list[str]:
|
|
34
|
+
return [clause.id for clause in self.clauses]
|
|
35
|
+
|
|
36
|
+
def clause(self, clause_id: str) -> Clause | None:
|
|
37
|
+
return next((clause for clause in self.clauses if clause.id == clause_id), None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_intent(path: Path) -> Intent:
|
|
41
|
+
try:
|
|
42
|
+
raw = path.read_bytes()
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise WarrantError(f"intent file not found: {path}") from None
|
|
45
|
+
lines = raw.decode("utf-8").split("\n")
|
|
46
|
+
meta, start = _front_matter(lines, path)
|
|
47
|
+
title = meta.get("title", "")
|
|
48
|
+
section = ""
|
|
49
|
+
clauses: list[Clause] = []
|
|
50
|
+
defined_on: dict[str, int] = {}
|
|
51
|
+
current: Clause | None = None
|
|
52
|
+
fenced = False
|
|
53
|
+
for number, line in enumerate(lines[start:], start=start + 1):
|
|
54
|
+
line = line.rstrip("\r")
|
|
55
|
+
if line.lstrip().startswith(("```", "~~~")):
|
|
56
|
+
fenced, current = not fenced, None
|
|
57
|
+
continue
|
|
58
|
+
if fenced:
|
|
59
|
+
continue
|
|
60
|
+
if line.startswith("#"):
|
|
61
|
+
current = None
|
|
62
|
+
level = len(line) - len(line.lstrip("#"))
|
|
63
|
+
heading = line[level:].strip()
|
|
64
|
+
if level == 1 and not title:
|
|
65
|
+
title = heading
|
|
66
|
+
elif level == 2:
|
|
67
|
+
section = heading
|
|
68
|
+
continue
|
|
69
|
+
match = CLAUSE.match(line)
|
|
70
|
+
if match:
|
|
71
|
+
current = None
|
|
72
|
+
if section.lower().startswith(INFORMATIONAL_SECTIONS):
|
|
73
|
+
continue
|
|
74
|
+
clause_id, text = match.groups()
|
|
75
|
+
if clause_id in defined_on:
|
|
76
|
+
raise WarrantError(
|
|
77
|
+
f"{path.name}:{number}: clause {clause_id} is already defined on line {defined_on[clause_id]}"
|
|
78
|
+
)
|
|
79
|
+
defined_on[clause_id] = number
|
|
80
|
+
current = Clause(clause_id, text.strip(), section, number)
|
|
81
|
+
clauses.append(current)
|
|
82
|
+
elif current is not None and line[:1] in (" ", "\t") and line.strip():
|
|
83
|
+
current.text += " " + line.strip()
|
|
84
|
+
else:
|
|
85
|
+
current = None
|
|
86
|
+
if not clauses:
|
|
87
|
+
raise WarrantError(
|
|
88
|
+
f"{path.name} has no promises. Write each one as a list item that starts with an ID, like '- G1: ...'"
|
|
89
|
+
)
|
|
90
|
+
return Intent(path, title or path.stem, meta, clauses, digest_bytes(raw))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _front_matter(lines: list[str], path: Path) -> tuple[dict[str, str], int]:
|
|
94
|
+
if not lines or lines[0].strip() != "---":
|
|
95
|
+
return {}, 0
|
|
96
|
+
for index in range(1, len(lines)):
|
|
97
|
+
if lines[index].strip() == "---":
|
|
98
|
+
meta = {}
|
|
99
|
+
for entry in lines[1:index]:
|
|
100
|
+
key, separator, value = entry.partition(":")
|
|
101
|
+
if separator and key.strip():
|
|
102
|
+
meta[key.strip()] = value.strip()
|
|
103
|
+
return meta, index + 1
|
|
104
|
+
raise WarrantError(f"{path.name}: front matter opens with '---' but never closes")
|
warrant/ledger.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The ledger: an append-only, hash-chained record that principals read instead of code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from warrant.errors import WarrantError
|
|
10
|
+
from warrant.hashing import canonical_json, digest_bytes, digest_json, hex_part, short
|
|
11
|
+
|
|
12
|
+
GENESIS = "sha256:" + "0" * 64
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def utc_now() -> str:
|
|
16
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Ledger:
|
|
20
|
+
def __init__(self, directory: Path):
|
|
21
|
+
self.directory = directory
|
|
22
|
+
self.path = directory / "ledger.jsonl"
|
|
23
|
+
self.evidence_dir = directory / "evidence"
|
|
24
|
+
|
|
25
|
+
def entries(self) -> list[dict]:
|
|
26
|
+
entries = []
|
|
27
|
+
for number, line in enumerate(self._lines(), start=1):
|
|
28
|
+
try:
|
|
29
|
+
entries.append(json.loads(line))
|
|
30
|
+
except json.JSONDecodeError as error:
|
|
31
|
+
raise WarrantError(f"{self.path}: entry {number} is not valid JSON ({error.msg})") from None
|
|
32
|
+
return entries
|
|
33
|
+
|
|
34
|
+
def head(self) -> str:
|
|
35
|
+
lines = self._lines()
|
|
36
|
+
return digest_bytes(lines[-1].encode("utf-8")) if lines else GENESIS
|
|
37
|
+
|
|
38
|
+
def append(self, kind: str, actor: str, data: dict) -> dict:
|
|
39
|
+
lines = self._lines()
|
|
40
|
+
entry = {
|
|
41
|
+
"seq": len(lines) + 1,
|
|
42
|
+
"time": utc_now(),
|
|
43
|
+
"type": kind,
|
|
44
|
+
"actor": actor,
|
|
45
|
+
"prev": digest_bytes(lines[-1].encode("utf-8")) if lines else GENESIS,
|
|
46
|
+
"data": data,
|
|
47
|
+
}
|
|
48
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
50
|
+
handle.write(canonical_json(entry).decode("utf-8") + "\n")
|
|
51
|
+
return entry
|
|
52
|
+
|
|
53
|
+
def put_evidence(self, statement: dict) -> str:
|
|
54
|
+
digest = digest_json(statement)
|
|
55
|
+
path = self._evidence_path(digest)
|
|
56
|
+
if not path.exists():
|
|
57
|
+
self.evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
text = json.dumps(statement, indent=2, sort_keys=True, ensure_ascii=False)
|
|
59
|
+
path.write_text(text + "\n", encoding="utf-8")
|
|
60
|
+
return digest
|
|
61
|
+
|
|
62
|
+
def get_evidence(self, digest: str) -> dict:
|
|
63
|
+
try:
|
|
64
|
+
statement = json.loads(self._evidence_path(digest).read_text(encoding="utf-8"))
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
raise WarrantError(f"evidence {short(digest)} is missing") from None
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
raise WarrantError(f"evidence {short(digest)} is not valid JSON") from None
|
|
69
|
+
if digest_json(statement) != digest:
|
|
70
|
+
raise WarrantError(f"evidence {short(digest)} no longer matches its digest, so it was changed")
|
|
71
|
+
return statement
|
|
72
|
+
|
|
73
|
+
def problems(self) -> list[str]:
|
|
74
|
+
"""Everything that shows the ledger or its evidence was altered outside Warrant."""
|
|
75
|
+
problems = []
|
|
76
|
+
previous = GENESIS
|
|
77
|
+
for number, line in enumerate(self._lines(), start=1):
|
|
78
|
+
try:
|
|
79
|
+
entry = json.loads(line)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
problems.append(f"entry {number} is not valid JSON")
|
|
82
|
+
previous = digest_bytes(line.encode("utf-8"))
|
|
83
|
+
continue
|
|
84
|
+
if entry.get("seq") != number:
|
|
85
|
+
problems.append(f"entry {number} carries sequence number {entry.get('seq')}")
|
|
86
|
+
if entry.get("prev") != previous:
|
|
87
|
+
problems.append(f"entry {number} does not chain to the entry before it")
|
|
88
|
+
if canonical_json(entry).decode("utf-8") != line:
|
|
89
|
+
problems.append(f"entry {number} is not in canonical form, so it was edited outside Warrant")
|
|
90
|
+
previous = digest_bytes(line.encode("utf-8"))
|
|
91
|
+
if entry.get("type") == "verify":
|
|
92
|
+
for result in entry.get("data", {}).get("results", []):
|
|
93
|
+
try:
|
|
94
|
+
self.get_evidence(result.get("evidence", ""))
|
|
95
|
+
except WarrantError as error:
|
|
96
|
+
problems.append(f"entry {number}: {error}")
|
|
97
|
+
return problems
|
|
98
|
+
|
|
99
|
+
def _lines(self) -> list[str]:
|
|
100
|
+
if not self.path.exists():
|
|
101
|
+
return []
|
|
102
|
+
# Split on "\n" only: str.splitlines() also breaks on characters that canonical JSON leaves unescaped.
|
|
103
|
+
return [line for line in self.path.read_text(encoding="utf-8").split("\n") if line]
|
|
104
|
+
|
|
105
|
+
def _evidence_path(self, digest: str) -> Path:
|
|
106
|
+
return self.evidence_dir / f"{hex_part(digest)}.json"
|
warrant/project.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A loaded project: configuration, intent, checks and ledger, and the baseline they form."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from warrant.checks import Check, load_checks
|
|
9
|
+
from warrant.config import CONFIG_NAME, Config, load_config
|
|
10
|
+
from warrant.errors import WarrantError
|
|
11
|
+
from warrant.hashing import digest_json
|
|
12
|
+
from warrant.intent import Intent, parse_intent
|
|
13
|
+
from warrant.ledger import Ledger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Project:
|
|
18
|
+
config: Config
|
|
19
|
+
intent: Intent
|
|
20
|
+
checks: list[Check]
|
|
21
|
+
ledger: Ledger
|
|
22
|
+
|
|
23
|
+
def baseline(self) -> dict:
|
|
24
|
+
"""Everything the principal approves: the intent, each check, and the gate policy."""
|
|
25
|
+
return {
|
|
26
|
+
"intent": self.intent.digest,
|
|
27
|
+
"checks": {check.id: check.digest for check in self.checks},
|
|
28
|
+
"policy": self.config.policy_digest(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def baseline_digest(self) -> str:
|
|
32
|
+
return digest_json(self.baseline())
|
|
33
|
+
|
|
34
|
+
def approval_state(self) -> tuple[str, dict | None]:
|
|
35
|
+
"""("missing" | "stale" | "current", the latest approval entry). Only the latest approval counts."""
|
|
36
|
+
approval = next((e for e in reversed(self.ledger.entries()) if e["type"] == "approve"), None)
|
|
37
|
+
if approval is None:
|
|
38
|
+
return "missing", None
|
|
39
|
+
if approval["data"]["baseline"] != self.baseline_digest():
|
|
40
|
+
return "stale", approval
|
|
41
|
+
return "current", approval
|
|
42
|
+
|
|
43
|
+
def changes_since(self, approval: dict) -> list[str]:
|
|
44
|
+
approved, current = approval["data"], self.baseline()
|
|
45
|
+
changes = []
|
|
46
|
+
if approved["intent"] != current["intent"]:
|
|
47
|
+
changes.append(f"{self.intent.path.name} changed")
|
|
48
|
+
for check_id in sorted(set(approved["checks"]) | set(current["checks"])):
|
|
49
|
+
before, after = approved["checks"].get(check_id), current["checks"].get(check_id)
|
|
50
|
+
if before is None:
|
|
51
|
+
changes.append(f"check {check_id} was added")
|
|
52
|
+
elif after is None:
|
|
53
|
+
changes.append(f"check {check_id} was removed")
|
|
54
|
+
elif before != after:
|
|
55
|
+
changes.append(f"check {check_id} changed")
|
|
56
|
+
if approved["policy"] != current["policy"]:
|
|
57
|
+
changes.append("the gate policy changed")
|
|
58
|
+
return changes
|
|
59
|
+
|
|
60
|
+
def coverage(self) -> dict[str, list[Check]]:
|
|
61
|
+
return {clause.id: [c for c in self.checks if clause.id in c.covers] for clause in self.intent.clauses}
|
|
62
|
+
|
|
63
|
+
def incidents(self) -> list[dict]:
|
|
64
|
+
return [
|
|
65
|
+
{**entry["data"], "time": entry["time"], "seq": entry["seq"]}
|
|
66
|
+
for entry in self.ledger.entries()
|
|
67
|
+
if entry["type"] == "incident"
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def find_root(start: Path) -> Path:
|
|
72
|
+
for candidate in (start, *start.parents):
|
|
73
|
+
if (candidate / CONFIG_NAME).is_file():
|
|
74
|
+
return candidate
|
|
75
|
+
raise WarrantError(f"no {CONFIG_NAME} in {start} or any directory above it; run 'warrant init' first")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_project(root: Path) -> Project:
|
|
79
|
+
config = load_config(root)
|
|
80
|
+
intent = parse_intent(config.intent_path)
|
|
81
|
+
return Project(config, intent, load_checks(config, intent), Ledger(config.ledger_dir))
|