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/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))