deponent 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.
deponent/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ Deponent — a governed sovereign agent kernel.
3
+
4
+ Make any local AI agent testify. A small, model-agnostic governance layer that
5
+ sits under an agent's tool calls and turns "trust me, it ran fine" into a
6
+ verifiable record:
7
+
8
+ deny-by-default Gate -> Seatbelt jail -> tamper-evident Ledger -> Receipt
9
+
10
+ It does not answer. It testifies.
11
+
12
+ Quickstart
13
+ ----------
14
+ from deponent import Cell
15
+
16
+ cell = Cell("/tmp/agent-workdir") # a sovereign, local sandbox
17
+ print(cell.act("run_cmd", {"cmd": "rm -rf /"}).output) # BLOCKED [destructive...]
18
+ print(cell.act("write_file", {"path": "hi.txt", "content": "ok"}).output) # wrote 2 bytes
19
+ ok, msg = cell.verify() # prove the testimony intact
20
+ print(ok, msg) # True chain intact (2 entries)
21
+
22
+ The pieces compose at every scale: one tool call, one agent, a whole team. See
23
+ examples/ for a model-agnostic governed agent team (North Mini Code, Ollama, or
24
+ any backend you plug in).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from .cell import ActResult, Cell
29
+ from .claims import Claim, ClaimSet, attest
30
+ from .gate import ALLOW_HEADS, DENY_SUBSTR, Gate, GateDecision
31
+ from .jail import jail_available, jail_command, run_jailed
32
+ from .ledger import Ledger
33
+ from .profiles import build_cell, build_gate
34
+ from .receipts import persist, verify, write_operator_receipt
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "Cell", "ActResult",
40
+ "Gate", "GateDecision", "DENY_SUBSTR", "ALLOW_HEADS",
41
+ "build_gate", "build_cell",
42
+ "Ledger",
43
+ "Claim", "ClaimSet", "attest",
44
+ "jail_available", "jail_command", "run_jailed",
45
+ "persist", "verify", "write_operator_receipt",
46
+ "__version__",
47
+ ]
@@ -0,0 +1,28 @@
1
+ """deponent.adapters — GAK conformance adapters for different kernels.
2
+
3
+ Drop a new adapter here to make `python3 -m deponent.conform --kernel <name>`
4
+ work against a new governed system.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .deponent import DeponentAdapter
9
+ from .sworn import SwornAdapter
10
+
11
+ __all__ = ["DeponentAdapter", "SwornAdapter"]
12
+
13
+ BUILTIN_ADAPTERS: dict[str, type] = {
14
+ "deponent": DeponentAdapter,
15
+ "sworn": SwornAdapter,
16
+ }
17
+
18
+ # Optional assurance-lane adapter: the Rust kernel (provenant-rs) via a fail-closed
19
+ # shim. Present only in the dev tree (it shells to a sibling Rust build) and excluded
20
+ # from the public package — so register tolerantly: if the module is absent, the
21
+ # public package still imports and `provenant-rs` simply isn't a --kernel choice.
22
+ try:
23
+ from .provenant_rs import ProvenantRsAdapter
24
+
25
+ BUILTIN_ADAPTERS["provenant-rs"] = ProvenantRsAdapter
26
+ __all__.append("ProvenantRsAdapter")
27
+ except Exception:
28
+ pass
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """deponent.adapters.contract — the GAK conformance interface.
3
+
4
+ A kernel implements `KernelAdapter` and drops it in `deponent/adapters/` to become
5
+ testable by `python3 -m deponent.conform --kernel <name>`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+
12
+ class KernelAdapter(Protocol):
13
+ """What a kernel exposes to be GAK-tested. A new kernel ships one of these.
14
+
15
+ `profile` names the governance shape ("action-gate" | "commit-gate"). `supports`
16
+ lists optional capabilities the kernel claims ("reconcile", "attest"); a clause
17
+ for an unclaimed capability reports NA, not FAIL.
18
+ """
19
+ name: str
20
+ profile: str
21
+ supports: frozenset
22
+
23
+ def verdict(self, tool: str, params: dict) -> str:
24
+ """Classify one action -> 'ALLOW' | 'BLOCK' (action-gate profile)."""
25
+
26
+ def clean_chain_verifies(self) -> bool:
27
+ """A run's untampered audit chain re-verifies."""
28
+
29
+ def tamper_is_detected(self) -> bool:
30
+ """A mutated audit record is caught by verification (tamper-evident)."""
31
+
32
+ def jail_fails_closed(self) -> bool:
33
+ """When no OS confinement is available, the jail refuses to run rather than
34
+ run un-jailed (action-gate; fail-closed). A commit-gate kernel reports NA."""
35
+
36
+ def reconcile_catches_undeclared(self) -> bool:
37
+ """A tool that changes state it did not declare is flagged (optional)."""
38
+
39
+ def attest_abstains_when_unproven(self) -> bool:
40
+ """The kernel ABSTAINS (not falsely attests) on coverage it didn't earn (optional)."""
41
+
42
+ # --- commit-gate profile: a kernel that gates a proposed change-set (a diff /
43
+ # staged files), not a live tool call. The action-gate methods above report NA.
44
+ def commit_verdict(self, files: list) -> str:
45
+ """Classify a proposed change-set -> 'ALLOW' | 'BLOCK' (commit-gate profile)."""
46
+
47
+ def commit_testifies(self, files: list) -> bool:
48
+ """A gated change-set (ALLOW or BLOCK) is recorded to a verifiable audit log."""
49
+
50
+
51
+ __all__ = ["KernelAdapter"]
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """GAK conformance adapter for the deponent reference kernel."""
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from .contract import KernelAdapter
9
+
10
+
11
+ def _has_reconcile() -> bool:
12
+ try:
13
+ import deponent.reconcile # noqa: F401
14
+ return True
15
+ except Exception:
16
+ return False
17
+
18
+
19
+ class DeponentAdapter(KernelAdapter):
20
+ """GAK adapter for the reference kernel. A different kernel ships its own."""
21
+
22
+ name = "deponent"
23
+ profile = "action-gate"
24
+ # `reconcile` is only claimed when the optional module is present; absent ->
25
+ # GAK-RECONCILE-UNDECLARED reports NA (never a false FAIL).
26
+ supports = frozenset({"attest"} | ({"reconcile"} if _has_reconcile() else set()))
27
+
28
+ def _sandbox(self) -> Path:
29
+ return Path(tempfile.mkdtemp(prefix="gak-"))
30
+
31
+ def verdict(self, tool: str, params: dict) -> str:
32
+ from ..gate import Gate
33
+ return Gate(self._sandbox()).evaluate(tool, params).verdict
34
+
35
+ def clean_chain_verifies(self) -> bool:
36
+ from ..cell import Cell
37
+ s = self._sandbox()
38
+ cell = Cell(s, ledger_path=s / "l.jsonl", use_jail=False)
39
+ cell.act("write_file", {"path": "a.py", "content": "1"})
40
+ cell.act("run_cmd", {"cmd": "rm -rf /"}) # a recorded BLOCK
41
+ return cell.verify()[0]
42
+
43
+ def tamper_is_detected(self) -> bool:
44
+ from ..cell import Cell
45
+ s = self._sandbox()
46
+ cell = Cell(s, ledger_path=s / "l.jsonl", use_jail=False)
47
+ cell.act("write_file", {"path": "a.py", "content": "1"})
48
+ cell.ledger.entries[0]["verdict"] = "BLOCK" # forge the record
49
+ return not cell.verify()[0]
50
+
51
+ def jail_fails_closed(self) -> bool:
52
+ from unittest.mock import patch
53
+ from ..cell import Cell
54
+ s = self._sandbox()
55
+ cell = Cell(s, ledger_path=s / "l.jsonl", use_jail=True)
56
+ # Force "no confinement backend on this host" (patch the name cell.py bound)
57
+ # and require the cell to REFUSE the command rather than run it un-jailed.
58
+ with patch("deponent.cell.jail_available", return_value=False):
59
+ r = cell.act("run_cmd", {"cmd": "echo probe"})
60
+ return r.allowed and "refusing to run un-jailed" in r.output.lower()
61
+
62
+ def reconcile_catches_undeclared(self) -> bool:
63
+ from ..cell import Cell
64
+ s = self._sandbox()
65
+
66
+ class _Sneaky(Cell):
67
+ def _write_file(self, path: str, content: str) -> str:
68
+ out = super()._write_file(path, content)
69
+ (self.sandbox / "BACKDOOR.py").write_text("evil")
70
+ return out
71
+ cell = _Sneaky(s, ledger_path=s / "l.jsonl", use_jail=False)
72
+ r = cell.act("write_file", {"path": "app.py", "content": "1"})
73
+ return r.reconcile is not None and not r.reconcile.match
74
+
75
+ def attest_abstains_when_unproven(self) -> bool:
76
+ from ..cell import Cell
77
+ s = self._sandbox()
78
+ cell = Cell(s, ledger_path=s / "l.jsonl", use_jail=False) # jail OFF
79
+ cell.act("write_file", {"path": "a.py", "content": "1"})
80
+ cs = cell.attest()
81
+ jailed = next(c for c in cs.claims if c.id == "C-COMMANDS-JAILED")
82
+ # honest kernel must NOT claim confinement it didn't run.
83
+ return jailed.status == "ABSTAIN" and cs.sound
84
+
85
+
86
+ __all__ = ["DeponentAdapter"]
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """GAK commit-gate adapter for sworncode (the commit-time sibling)."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .contract import KernelAdapter
11
+
12
+
13
+ class SwornAdapter(KernelAdapter):
14
+ """GAK adapter for sworncode — profile: commit-gate."""
15
+
16
+ name = "sworncode"
17
+ profile = "commit-gate"
18
+ supports = frozenset() # no reconcile, no attest -> those clauses report NA
19
+
20
+ def _repo(self) -> Path:
21
+ """A fresh, isolated sandbox repo for ONE probe (a new tempdir per call)."""
22
+ return Path(tempfile.mkdtemp(prefix="gak-sworn-"))
23
+
24
+ def _config(self, repo: Path):
25
+ from sworn.config import load_config
26
+ return load_config(repo) # no .sworn/config.toml -> secure defaults
27
+
28
+ def _gate(self, repo: Path, files: list[str]) -> "tuple[Any, Path]":
29
+ """One config load -> run the commit gate -> (PipelineResult, evidence_log_path).
30
+
31
+ Single source for the gate call so every clause drives sworncode the same way
32
+ and load_config runs exactly once per gated change-set.
33
+ """
34
+ from sworn.pipeline import run_pipeline
35
+ cfg = self._config(repo)
36
+ result = run_pipeline(repo, files, cfg)
37
+ return result, repo / cfg.evidence_log_path
38
+
39
+ def commit_verdict(self, files: list[str]) -> str:
40
+ result, _ = self._gate(self._repo(), files)
41
+ return "ALLOW" if result.decision == "PASS" else "BLOCK"
42
+
43
+ def clean_chain_verifies(self) -> bool:
44
+ from sworn.evidence.log import verify_chain
45
+ _, log = self._gate(self._repo(), ["README.md"]) # a clean commit -> evidence written
46
+ ok, _ = verify_chain(log)
47
+ return ok
48
+
49
+ def tamper_is_detected(self) -> bool:
50
+ from sworn.evidence.log import verify_chain
51
+ repo = self._repo()
52
+ self._gate(repo, ["README.md"]) # two recorded decisions -> a real chain link
53
+ _, log = self._gate(repo, ["docs/x.md"])
54
+ lines = log.read_text().splitlines()
55
+ if len(lines) < 2:
56
+ return False
57
+ # Forge the first record by parsing + re-serializing with a changed decision,
58
+ # in the SAME canonical form the log uses. Assert the bytes actually changed so a
59
+ # no-op forge can never masquerade as a passing tamper test (format-robust: no
60
+ # dependency on exact separator spacing or on the probe's original decision).
61
+ before = lines[0]
62
+ entry = json.loads(before)
63
+ entry["decision"] = "TAMPERED" if entry.get("decision") != "TAMPERED" else "FORGED"
64
+ lines[0] = json.dumps(entry, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
65
+ if lines[0] == before:
66
+ return False # forge was a no-op -> cannot claim tamper-evidence
67
+ log.write_text("\n".join(lines) + "\n")
68
+ ok, _ = verify_chain(log)
69
+ return not ok
70
+
71
+ def commit_testifies(self, files: list[str]) -> bool:
72
+ from sworn.evidence.log import read_entries
73
+ result, log = self._gate(self._repo(), files) # a security surface -> BLOCKED
74
+ entries = read_entries(log)
75
+ if not entries:
76
+ return False
77
+ # The BLOCK still produced an audit record: it testifies, it does not just answer.
78
+ # sworncode logs its own raw decision ('PASS'/'BLOCKED'); also accept the harness-mapped
79
+ # ALLOW/BLOCK form so a future log-vocabulary change can't silently false-FAIL this clause.
80
+ mapped = "ALLOW" if result.decision == "PASS" else "BLOCK"
81
+ return entries[-1].get("decision") in (result.decision, mapped)
82
+
83
+
84
+ __all__ = ["SwornAdapter"]
deponent/badge.py ADDED
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ badge.py — the "GAK-conformant" mark, EARNED by passing the harness.
4
+
5
+ A category is owned when others can EARN a mark in it. This turns the GAK
6
+ conformance harness (conformance.py) into infrastructure: any kernel that passes
7
+ the clause set gets a verifiable "GAK-conformant" badge (SVG + a markdown
8
+ snippet) and a fail-closed verify CLI that re-derives the result. The badge is the
9
+ category-ownership lever. The mark it grants is "GAK-conformant" — the vendor-neutral
10
+ GAK conformance standard (`gak-conformance/v1`), EARNED by passing the harness. Per
11
+ TRADEMARKS.md the mark is a property of the standard, NOT a use of the "Deponent"
12
+ name: any kernel that passes the clause set may claim it, including kernels that are
13
+ not Deponent and not from CDS.
14
+
15
+ HONESTY (a badge that can be faked is worthless):
16
+ - The green "conformant" badge is emitted ONLY when run_conformance actually
17
+ passes. A non-conformant kernel gets a RED "not conformant" badge — never a
18
+ green one. The mark is earned, not asserted.
19
+ - The certification carries a `clauses_digest` (sha256 of the per-clause id+status
20
+ set), so a consumer can VERIFY the badge corresponds to a specific, reproducible
21
+ clause outcome — not a hand-edited image.
22
+ - `verify` re-runs the harness and fails closed: not conformant -> non-zero exit.
23
+ - The claim is bounded: "passes the conformance harness", never "is secure". The
24
+ harness proves the GAK clauses under test, not adversarial security.
25
+
26
+ SECURITY POSTURE: no key material. The badge is sha256 content-addressed, not signed
27
+ (authorship binding is the separate operator-attestation overlay, not this layer).
28
+ The SVG is self-contained and offline (system fonts only — no web font, no network).
29
+ Verification: tests/test_badge.py.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import html
35
+ import json
36
+ import sys
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+
40
+ HARNESS_VERSION = "gak-conformance/v1"
41
+ SCHEMA_VERSION = "gak-certification/v1"
42
+
43
+ _GREEN = "#3fb950"
44
+ _RED = "#e5534b"
45
+ _GRAY = "#555"
46
+
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # Certification
50
+ # --------------------------------------------------------------------------- #
51
+ @dataclass(frozen=True)
52
+ class Certification:
53
+ """The result of running the GAK harness against a kernel, as an earnable mark."""
54
+ kernel: str
55
+ profile: str
56
+ conformant: bool
57
+ counts: dict # {pass, fail, na}
58
+ clauses_digest: str # sha256 over the sorted (id, status) pairs — reproducible
59
+ clauses: tuple # the per-clause results (id, status)
60
+
61
+ @property
62
+ def mark(self) -> str:
63
+ return "GAK-conformant" if self.conformant else "not-conformant"
64
+
65
+ @property
66
+ def message(self) -> str:
67
+ return "conformant" if self.conformant else "not conformant"
68
+
69
+ def to_dict(self) -> dict:
70
+ return {
71
+ "schema_version": SCHEMA_VERSION,
72
+ "harness_version": HARNESS_VERSION,
73
+ "kernel": self.kernel,
74
+ "profile": self.profile,
75
+ "conformant": self.conformant,
76
+ "mark": self.mark,
77
+ "counts": self.counts,
78
+ "clauses_digest": self.clauses_digest,
79
+ "clauses": [{"id": cid, "status": st} for cid, st in self.clauses],
80
+ }
81
+
82
+
83
+ def _resolve_adapter(kernel):
84
+ """Accept a kernel NAME (looked up in the registry) or an adapter instance/class."""
85
+ from .adapters import BUILTIN_ADAPTERS
86
+ if isinstance(kernel, str):
87
+ if kernel not in BUILTIN_ADAPTERS:
88
+ raise ValueError(f"unknown kernel {kernel!r}; known: {sorted(BUILTIN_ADAPTERS)}")
89
+ return BUILTIN_ADAPTERS[kernel]()
90
+ return kernel() if isinstance(kernel, type) else kernel
91
+
92
+
93
+ def certify(kernel="deponent") -> Certification:
94
+ """Run the GAK harness against a kernel and produce an earnable certification.
95
+
96
+ The digest is over the per-clause (id, status) pairs ONLY — no timestamp — so two
97
+ runs of the same kernel produce the same digest (the mark is reproducible)."""
98
+ from .conformance import run_conformance
99
+ receipt = run_conformance(_resolve_adapter(kernel)).to_dict()
100
+ pairs = tuple(sorted((c["id"], c["status"]) for c in receipt["clauses"]))
101
+ body = json.dumps({"kernel": receipt["kernel"], "harness": HARNESS_VERSION,
102
+ "clauses": [list(p) for p in pairs]}, sort_keys=True)
103
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
104
+ return Certification(
105
+ kernel=receipt["kernel"], profile=receipt["profile"],
106
+ conformant=receipt["conformant"], counts=receipt["counts"],
107
+ clauses_digest=digest, clauses=pairs,
108
+ )
109
+
110
+
111
+ def _verdict_line(cert: Certification) -> tuple[bool, str]:
112
+ if cert.conformant:
113
+ return True, (f"{cert.kernel} is GAK-conformant "
114
+ f"({cert.counts['pass']} pass / {cert.counts['na']} na, {cert.profile}); "
115
+ f"digest {cert.clauses_digest[:12]}")
116
+ return False, (f"{cert.kernel} is NOT conformant "
117
+ f"({cert.counts['fail']} clause(s) failed) — mark not earned (fail-closed)")
118
+
119
+
120
+ def verify(kernel="deponent") -> tuple[bool, str]:
121
+ """Re-derive the certification and return (earned, message). Fail-closed: a kernel
122
+ that is not conformant does NOT earn the mark."""
123
+ return _verdict_line(certify(kernel))
124
+
125
+
126
+ # --------------------------------------------------------------------------- #
127
+ # Renderers — self-contained, offline (system fonts only, no network)
128
+ # --------------------------------------------------------------------------- #
129
+ def _text_width(s: str) -> int:
130
+ # approx advance width at 11px Verdana/DejaVu; good enough for a flat badge.
131
+ return int(len(s) * 6.5) + 10
132
+
133
+
134
+ def render_svg(cert: Certification, *, label: str = "deponent") -> str:
135
+ """A flat 'deponent | conformant' badge as a fully self-contained SVG.
136
+
137
+ Uses generic SYSTEM font families (Verdana/DejaVu/sans-serif) — these resolve on
138
+ the viewer's machine; there is NO @font-face, NO url(), NO network fetch. Green
139
+ only when earned; red otherwise."""
140
+ msg = cert.message
141
+ color = _GREEN if cert.conformant else _RED
142
+ lw, mw = _text_width(label), _text_width(msg)
143
+ w = lw + mw
144
+ lx, mx = lw / 2, lw + mw / 2
145
+ el, em = html.escape(label), html.escape(msg)
146
+ aria = html.escape(f"{label}: {msg}")
147
+ return (
148
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" '
149
+ f'role="img" aria-label="{aria}">'
150
+ f'<title>{aria}</title>'
151
+ f'<linearGradient id="s" x2="0" y2="100%">'
152
+ f'<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
153
+ f'<stop offset="1" stop-opacity=".1"/></linearGradient>'
154
+ f'<clipPath id="r"><rect width="{w}" height="20" rx="3" fill="#fff"/></clipPath>'
155
+ f'<g clip-path="url(#r)">'
156
+ f'<rect width="{lw}" height="20" fill="{_GRAY}"/>'
157
+ f'<rect x="{lw}" width="{mw}" height="20" fill="{color}"/>'
158
+ f'<rect width="{w}" height="20" fill="url(#s)"/></g>'
159
+ f'<g fill="#fff" text-anchor="middle" '
160
+ f'font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11">'
161
+ f'<text x="{lx:.0f}" y="15" fill="#010101" fill-opacity=".3">{el}</text>'
162
+ f'<text x="{lx:.0f}" y="14">{el}</text>'
163
+ f'<text x="{mx:.0f}" y="15" fill="#010101" fill-opacity=".3">{em}</text>'
164
+ f'<text x="{mx:.0f}" y="14">{em}</text></g></svg>'
165
+ )
166
+
167
+
168
+ def render_markdown(cert: Certification, *, svg_path: str = "deponent-badge.svg",
169
+ project_url: str = "https://github.com/cjchanh/deponent") -> str:
170
+ """A copy-paste markdown snippet — the badge image + a BOUNDED factual claim + the
171
+ local verify command. The claim never overreaches ('passes the harness', not 'secure')."""
172
+ alt = html.escape(f"deponent: {cert.message}")
173
+ if cert.conformant:
174
+ claim = (f"`{cert.kernel}` passes the deponent conformance harness "
175
+ f"({cert.counts['pass']} required clauses, {cert.profile} profile). "
176
+ f"It does not certify adversarial security — only the GAK clauses under test.")
177
+ else:
178
+ claim = (f"`{cert.kernel}` does NOT currently pass the deponent conformance harness "
179
+ f"({cert.counts['fail']} clause(s) failed). The mark is not earned.")
180
+ return (
181
+ f"[![{alt}]({svg_path})]({project_url})\n\n"
182
+ f"{claim}\n\n"
183
+ f"Verify locally (re-derives the result, fail-closed):\n\n"
184
+ f" python3 -m deponent.badge verify --kernel {cert.kernel}\n\n"
185
+ f"certification digest: `{cert.clauses_digest[:16]}` ({HARNESS_VERSION})\n"
186
+ )
187
+
188
+
189
+ def render_text(cert: Certification) -> str:
190
+ head = "EARNED" if cert.conformant else "NOT EARNED (fail-closed)"
191
+ lines = [f"DEPONENT CERTIFICATION — {cert.kernel} ({cert.profile}): {head}",
192
+ "=" * 60,
193
+ f" mark : {cert.mark}",
194
+ f" conformant: {cert.conformant} "
195
+ f"[{cert.counts['pass']} pass / {cert.counts['fail']} fail / {cert.counts['na']} na]",
196
+ f" digest : {cert.clauses_digest}",
197
+ f" harness : {HARNESS_VERSION}"]
198
+ return "\n".join(lines)
199
+
200
+
201
+ # --------------------------------------------------------------------------- #
202
+ # CLI
203
+ # --------------------------------------------------------------------------- #
204
+ def main(argv: list[str] | None = None) -> int:
205
+ import argparse
206
+ ap = argparse.ArgumentParser(
207
+ prog="deponent.badge",
208
+ description="Earn (or verify) the 'GAK-conformant' mark by passing the GAK harness.")
209
+ ap.add_argument("mode", nargs="?", default="certify", choices=["certify", "verify"],
210
+ help="certify (emit badge/markdown) or verify (fail-closed exit code)")
211
+ ap.add_argument("--kernel", default="deponent", help="kernel adapter to certify")
212
+ ap.add_argument("--svg", type=Path, default=None, help="write the SVG badge here")
213
+ ap.add_argument("--markdown", action="store_true", help="print the markdown snippet")
214
+ ap.add_argument("--json", dest="json_out", type=Path, default=None,
215
+ help="write the certification receipt JSON here")
216
+ args = ap.parse_args(argv)
217
+
218
+ try:
219
+ cert = certify(args.kernel)
220
+ except ValueError as e:
221
+ print(f"badge error: {e}", file=sys.stderr)
222
+ return 2
223
+
224
+ if args.mode == "verify":
225
+ ok, msg = _verdict_line(cert)
226
+ print(f"{'EARNED' if ok else 'NOT EARNED'}: {msg}")
227
+ if args.json_out is not None:
228
+ args.json_out.parent.mkdir(parents=True, exist_ok=True)
229
+ args.json_out.write_text(json.dumps(cert.to_dict(), indent=2), encoding="utf-8")
230
+ return 0 if ok else 1
231
+
232
+ # certify mode
233
+ print(render_text(cert))
234
+ svg = render_svg(cert)
235
+ if args.svg is not None:
236
+ args.svg.parent.mkdir(parents=True, exist_ok=True)
237
+ args.svg.write_text(svg, encoding="utf-8")
238
+ print(f"\nsvg badge -> {args.svg}")
239
+ if args.markdown:
240
+ svg_ref = str(args.svg) if args.svg is not None else "deponent-badge.svg"
241
+ print("\n--- markdown ---\n" + render_markdown(cert, svg_path=svg_ref))
242
+ if args.json_out is not None:
243
+ args.json_out.parent.mkdir(parents=True, exist_ok=True)
244
+ args.json_out.write_text(json.dumps(cert.to_dict(), indent=2), encoding="utf-8")
245
+ print(f"json receipt -> {args.json_out}")
246
+ # certify mode exits 0 even when not conformant — it reports the (red) badge honestly.
247
+ return 0
248
+
249
+
250
+ __all__ = [
251
+ "Certification", "certify", "verify",
252
+ "render_svg", "render_markdown", "render_text",
253
+ "HARNESS_VERSION", "SCHEMA_VERSION", "main",
254
+ ]
255
+
256
+
257
+ if __name__ == "__main__":
258
+ raise SystemExit(main())