brainiac-cli 0.16.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.
Files changed (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/anchor.py ADDED
@@ -0,0 +1,121 @@
1
+ """Off-host anchoring of the Ed25519 audit-chain head (SEC-03).
2
+
3
+ The audit chain (brain.audit) is only as strong as the single key that signs it:
4
+ whoever holds the key can rewrite history and re-sign it silently. Nothing
5
+ EXTERNAL attests the chain head. This module periodically publishes the signed
6
+ chain-head hash to an INDEPENDENT, append-only store so a post-hoc rewrite
7
+ becomes DETECTABLE — a later ``verify`` recomputes the head as-of each anchored
8
+ entry-count and compares it to what was independently recorded; a rewritten
9
+ prefix yields a head that no longer matches the anchor.
10
+
11
+ The anchor store MUST live somewhere an attacker who compromised the host + key
12
+ cannot also rewrite: a separate private repo, a different machine/volume, or
13
+ (strongest) an RFC-3161 timestamp token from a third-party TSA. Anchoring into
14
+ the vault tree itself buys nothing — keep it OFF-HOST.
15
+
16
+ The head value matches exactly what brain.audit would use as the next entry's
17
+ ``prev_hash``: sha256(last stripped chain-entry line). We reuse AuditChain's own
18
+ helpers so the two can never diverge.
19
+
20
+ Ported FROM SCRATCH from the owner vault's ``_chain_anchor.py`` *pattern* — no
21
+ import of vault code.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import socket
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from .audit import NULL_PREV_HASH, AuditChain, _sha256
32
+
33
+ ANCHOR_LOG_NAME = "chain_anchor.log" # JSONL, append-only, in the OFF-HOST dir
34
+
35
+
36
+ def _entry_lines(chain: AuditChain) -> list[str]:
37
+ """Ordered, stripped chain-entry lines — the SAME predicate the chain uses."""
38
+ return [s for line in chain._lines() if (s := line.strip()) and chain._is_entry(s)]
39
+
40
+
41
+ def head_as_of(lines: list[str], count: int) -> str:
42
+ """sha256 of the ``count``-th entry line (1-based) — i.e. the head after
43
+ ``count`` entries, == the prev_hash the (count+1)-th entry would carry.
44
+ ``count==0`` is the empty-chain head."""
45
+ if count <= 0:
46
+ return NULL_PREV_HASH
47
+ if count > len(lines):
48
+ raise ValueError(f"anchor records {count} entries but live chain has only {len(lines)}")
49
+ return _sha256(lines[count - 1])
50
+
51
+
52
+ def current_head(chain: AuditChain) -> tuple[str, int]:
53
+ """(head_hash, entry_count) of the live chain right now."""
54
+ lines = _entry_lines(chain)
55
+ return (head_as_of(lines, len(lines)), len(lines))
56
+
57
+
58
+ def anchor(chain_log: Path, anchor_dir: Path, *, tsa_token: bytes | None = None) -> dict[str, Any]:
59
+ """Append the current signed chain-head to the OFF-HOST anchor log.
60
+
61
+ Re-anchoring an unchanged head simply appends a fresh dated record — the
62
+ timeline of attestations is itself the evidence. Returns the appended record.
63
+ """
64
+ chain = AuditChain(Path(chain_log))
65
+ head, count = current_head(chain)
66
+ anchor_dir = Path(anchor_dir)
67
+ anchor_dir.mkdir(parents=True, exist_ok=True)
68
+ rec = {
69
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
70
+ "host": socket.gethostname(),
71
+ "chain_log": str(Path(chain_log).resolve()),
72
+ "entry_count": count,
73
+ "head": head,
74
+ "tsa": bool(tsa_token),
75
+ }
76
+ log = anchor_dir / ANCHOR_LOG_NAME
77
+ with log.open("a", encoding="utf-8") as f:
78
+ f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")
79
+ if tsa_token: # pragma: no cover - external TSA round-trip
80
+ (anchor_dir / f"head-{count}.tsr").write_bytes(tsa_token)
81
+ return {"anchored": True, "record": rec, "anchor_log": str(log)}
82
+
83
+
84
+ def verify_against_anchor(chain_log: Path, anchor_dir: Path) -> dict[str, Any]:
85
+ """Recompute the head as-of every anchored entry-count and compare.
86
+
87
+ A DIVERGENCE means the live chain's prefix differs from what was
88
+ independently recorded off-host — a possible silent history rewrite. Returns
89
+ a verdict dict; ``status`` is one of: ok | divergence | no-anchor.
90
+ """
91
+ chain = AuditChain(Path(chain_log))
92
+ lines = _entry_lines(chain)
93
+ log = Path(anchor_dir) / ANCHOR_LOG_NAME
94
+ if not log.is_file():
95
+ return {"status": "no-anchor", "anchor_log": str(log), "checked": 0, "divergences": []}
96
+
97
+ checked = 0
98
+ divergences: list[dict[str, Any]] = []
99
+ for raw in log.read_text(encoding="utf-8").splitlines():
100
+ raw = raw.strip()
101
+ if not raw:
102
+ continue
103
+ rec = json.loads(raw)
104
+ count = int(rec["entry_count"])
105
+ checked += 1
106
+ if count > len(lines):
107
+ divergences.append({"entry_count": count, "error": "chain_shorter_than_anchor",
108
+ "live_entries": len(lines)})
109
+ continue
110
+ recomputed = head_as_of(lines, count)
111
+ if recomputed != rec["head"]:
112
+ divergences.append({"entry_count": count, "error": "head_mismatch",
113
+ "anchored": rec["head"][:16], "recomputed": recomputed[:16],
114
+ "anchored_ts": rec.get("ts")})
115
+ return {
116
+ "status": "divergence" if divergences else "ok",
117
+ "anchor_log": str(log),
118
+ "checked": checked,
119
+ "live_entries": len(lines),
120
+ "divergences": divergences,
121
+ }
brain/audit.py ADDED
@@ -0,0 +1,422 @@
1
+ """Ed25519 signed hash-chain audit log on the write path (CORE-03).
2
+
3
+ Ported FROM SCRATCH from the owner vault's ``_audit_chain.py`` *pattern* (JSONL
4
+ entries, sha256 prev_hash linkage, domain-separated canonical payload, byte
5
+ authentication of the last entry). It is independent code — no vault import.
6
+
7
+ KEY CUSTODY (CORE-03 hardening): the signing key is resolved from the OS secret
8
+ store — macOS **Keychain** / Windows **Credential Manager** — or an injected env
9
+ PEM for unattended/sandbox runs. There is **no file fallback**: if no key
10
+ resolves, signing FAILS CLOSED (a write is refused rather than written unsigned).
11
+
12
+ Resolution precedence (first hit wins, all yield identical PEM bytes):
13
+ 1. ``$BRAIN_AUDIT_KEY_PEM`` — literal PEM (unattended / Cowork sandbox / tests)
14
+ 2. ``$BRAIN_AUDIT_KEY_CMD`` — shell command whose stdout is the PEM
15
+ 3. macOS Keychain — ``security find-generic-password -s <svc> -a <acct> -w``
16
+ 4. Windows Credential Manager — via the ``keyring`` package if installed
17
+ (NO bare-file fallback — by design, unlike the vault's transition shim.)
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import contextlib
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Iterator, Optional
33
+
34
+ JSONL_FORMAT = "brain-audit-chain-jsonl-v1"
35
+ NULL_PREV_HASH = "0" * 64
36
+ _REQUIRED_KEYS = frozenset({"format", "ts", "verb", "path", "reason", "prev_hash", "sig"})
37
+ KEYCHAIN_SERVICE_DEFAULT = "profile-a-brain-audit-key"
38
+
39
+
40
+ class AuditError(RuntimeError):
41
+ pass
42
+
43
+
44
+ class KeyUnavailable(AuditError):
45
+ """No signing key resolved from any OS-secret-store path. Fail closed."""
46
+
47
+
48
+ # --------------------------------------------------------------------------
49
+ # crypto
50
+ # --------------------------------------------------------------------------
51
+ def _require_crypto():
52
+ try:
53
+ from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: F401
54
+ except ImportError as exc: # pragma: no cover
55
+ raise AuditError(
56
+ "'cryptography' is required for the audit chain "
57
+ "(pip install 'brainiac-cli[audit]')"
58
+ ) from exc
59
+
60
+
61
+ def _sha256(text: str) -> str:
62
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
63
+
64
+
65
+ def _canonical(obj: dict) -> str:
66
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
67
+
68
+
69
+ def generate_key_pem() -> tuple[bytes, bytes]:
70
+ """Return (private_pem, public_pem) for a fresh Ed25519 keypair (setup/tests)."""
71
+ _require_crypto()
72
+ from cryptography.hazmat.primitives import serialization
73
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
74
+
75
+ priv = Ed25519PrivateKey.generate()
76
+ priv_pem = priv.private_bytes(
77
+ serialization.Encoding.PEM,
78
+ serialization.PrivateFormat.PKCS8,
79
+ serialization.NoEncryption(),
80
+ )
81
+ pub_pem = priv.public_key().public_bytes(
82
+ serialization.Encoding.PEM,
83
+ serialization.PublicFormat.SubjectPublicKeyInfo,
84
+ )
85
+ return priv_pem, pub_pem
86
+
87
+
88
+ # --------------------------------------------------------------------------
89
+ # key custody — OS secret store, NO file fallback
90
+ # --------------------------------------------------------------------------
91
+ def _pem_from_keychain(service: str, account: str) -> Optional[bytes]:
92
+ if sys.platform != "darwin" or not shutil.which("security"):
93
+ return None
94
+ try:
95
+ out = subprocess.run(
96
+ ["security", "find-generic-password", "-s", service, "-a", account, "-w"],
97
+ capture_output=True, timeout=10,
98
+ )
99
+ except (OSError, subprocess.SubprocessError):
100
+ return None
101
+ if out.returncode != 0:
102
+ return None
103
+ text = out.stdout.decode("utf-8", "replace").strip()
104
+ if "BEGIN" not in text and text and re.fullmatch(r"[0-9A-Fa-f]+", text):
105
+ try:
106
+ return bytes.fromhex(text)
107
+ except ValueError:
108
+ return out.stdout
109
+ return out.stdout
110
+
111
+
112
+ def _pem_from_credential_manager(service: str, account: str) -> Optional[bytes]:
113
+ # Windows Credential Manager via the optional `keyring` package.
114
+ if not sys.platform.startswith("win"):
115
+ return None
116
+ try:
117
+ import keyring # type: ignore
118
+
119
+ secret = keyring.get_password(service, account)
120
+ return secret.encode("utf-8") if secret else None
121
+ except Exception:
122
+ return None
123
+
124
+
125
+ def _pem_from_cmd(cmd: str) -> bytes:
126
+ # NOTE: `cmd` is an OPERATOR-controlled env var (BRAIN_AUDIT_KEY_CMD), not
127
+ # untrusted input. shell=True is intentional so custody backends can use a
128
+ # pipeline (e.g. `age -d -i id key.pem.age`). Anyone able to set this env
129
+ # var already has code execution, so shell=True adds no attack surface.
130
+ try:
131
+ # nosec B602 - operator-set env var (BRAIN_AUDIT_KEY_CMD), never untrusted
132
+ # input; see the NOTE above and docs/SECURITY_NOTES.md.
133
+ # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
134
+ out = subprocess.run(cmd, shell=True, capture_output=True, timeout=20) # noqa: S602
135
+ except (OSError, subprocess.SubprocessError) as exc:
136
+ raise KeyUnavailable(f"BRAIN_AUDIT_KEY_CMD failed to run: {exc}") from exc
137
+ if out.returncode != 0 or not out.stdout.strip():
138
+ raise KeyUnavailable(
139
+ f"BRAIN_AUDIT_KEY_CMD failed (exit {out.returncode})"
140
+ )
141
+ return out.stdout
142
+
143
+
144
+ def provision_signing_key() -> dict:
145
+ """Create-if-absent the audit signing key in the OS secret store (host setup).
146
+
147
+ Idempotent: an existing resolvable key is NEVER touched — rotation would
148
+ make the whole signed chain read as tampered. The PEM is stored hex-encoded
149
+ (single line; the read path in ``_pem_from_keychain`` hex-decodes). Returns
150
+ ``{"status": "present", "source": ...}`` or
151
+ ``{"status": "created", "store": ...}``. Raises KeyUnavailable when no OS
152
+ secret store is available to write to.
153
+ """
154
+ try:
155
+ _, source = resolve_signing_key()
156
+ return {"status": "present", "source": source}
157
+ except KeyUnavailable:
158
+ pass
159
+
160
+ service = os.environ.get("BRAIN_AUDIT_KEYCHAIN_SERVICE", KEYCHAIN_SERVICE_DEFAULT)
161
+ account = (
162
+ os.environ.get("BRAIN_AUDIT_KEYCHAIN_ACCOUNT")
163
+ or os.environ.get("USER")
164
+ or os.environ.get("USERNAME")
165
+ or "default"
166
+ )
167
+ priv_pem, _pub = generate_key_pem()
168
+
169
+ if sys.platform == "darwin" and shutil.which("security"):
170
+ try:
171
+ out = subprocess.run(
172
+ ["security", "add-generic-password",
173
+ "-s", service, "-a", account, "-w", priv_pem.hex()],
174
+ capture_output=True, timeout=10,
175
+ )
176
+ except (OSError, subprocess.SubprocessError) as exc:
177
+ raise KeyUnavailable(f"keychain store failed to run: {exc}") from exc
178
+ if out.returncode != 0:
179
+ # exit 45 = item already exists (raced/ACL-hidden) — never overwrite.
180
+ raise KeyUnavailable(
181
+ f"keychain store failed (exit {out.returncode}): "
182
+ f"{out.stderr.decode('utf-8', 'replace').strip()}"
183
+ )
184
+ return {"status": "created", "store": f"keychain:{service}"}
185
+
186
+ if sys.platform.startswith("win"):
187
+ try:
188
+ import keyring # type: ignore
189
+
190
+ keyring.set_password(service, account, priv_pem.decode("utf-8"))
191
+ return {"status": "created", "store": f"credential-manager:{service}"}
192
+ except Exception as exc:
193
+ raise KeyUnavailable(f"Credential Manager store failed: {exc}") from exc
194
+
195
+ raise KeyUnavailable(
196
+ "no OS secret store available to write to (need macOS Keychain or "
197
+ "Windows Credential Manager). For a custom custody backend set "
198
+ "BRAIN_AUDIT_KEY_PEM or BRAIN_AUDIT_KEY_CMD instead."
199
+ )
200
+
201
+
202
+ def resolve_signing_key():
203
+ """Resolve the Ed25519 private key from the OS secret store. Fail closed.
204
+
205
+ Returns (private_key_obj, source_label). Raises KeyUnavailable if nothing
206
+ resolves — there is intentionally NO bare-key-file fallback.
207
+ """
208
+ _require_crypto()
209
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
210
+
211
+ service = os.environ.get("BRAIN_AUDIT_KEYCHAIN_SERVICE", KEYCHAIN_SERVICE_DEFAULT)
212
+ account = (
213
+ os.environ.get("BRAIN_AUDIT_KEYCHAIN_ACCOUNT")
214
+ or os.environ.get("USER")
215
+ or os.environ.get("USERNAME")
216
+ or "default"
217
+ )
218
+
219
+ pem: Optional[bytes] = None
220
+ source: Optional[str] = None
221
+
222
+ env_pem = os.environ.get("BRAIN_AUDIT_KEY_PEM")
223
+ if env_pem:
224
+ pem, source = env_pem.encode("utf-8"), "env:BRAIN_AUDIT_KEY_PEM"
225
+
226
+ if pem is None and os.environ.get("BRAIN_AUDIT_KEY_CMD"):
227
+ pem, source = _pem_from_cmd(os.environ["BRAIN_AUDIT_KEY_CMD"]), "env:BRAIN_AUDIT_KEY_CMD"
228
+
229
+ if pem is None:
230
+ kc = _pem_from_keychain(service, account)
231
+ if kc:
232
+ pem, source = kc, f"keychain:{service}"
233
+
234
+ if pem is None:
235
+ cm = _pem_from_credential_manager(service, account)
236
+ if cm:
237
+ pem, source = cm, f"credential-manager:{service}"
238
+
239
+ if pem is None:
240
+ raise KeyUnavailable(
241
+ "No audit signing key resolved. Tried, in order: $BRAIN_AUDIT_KEY_PEM, "
242
+ "$BRAIN_AUDIT_KEY_CMD, macOS Keychain, Windows Credential Manager. "
243
+ "There is NO file fallback by design — the write path fails closed. "
244
+ "Store the key in the OS secret store (or inject the PEM for unattended runs)."
245
+ )
246
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
247
+
248
+ try:
249
+ key = load_pem_private_key(pem, password=None)
250
+ except Exception as exc:
251
+ raise KeyUnavailable(f"resolved key [{source}] is not a valid PEM private key: {exc}") from exc
252
+ # F-08: assert the key TYPE. load_pem_private_key accepts RSA/ECDSA/Ed25519;
253
+ # a non-Ed25519 key would load here and only fail with a confusing TypeError
254
+ # at sign() time. Fail closed with a clear message instead.
255
+ if not isinstance(key, Ed25519PrivateKey):
256
+ raise KeyUnavailable(
257
+ f"resolved key [{source}] is {type(key).__name__}, expected Ed25519PrivateKey"
258
+ )
259
+ return key, source
260
+
261
+
262
+ @contextlib.contextmanager
263
+ def _exclusive_lock(log_path: Path) -> Iterator[None]:
264
+ """Cross-process exclusive lock for the append read-modify-write (F-07).
265
+
266
+ Without this, two concurrent writers can both read the same last entry and
267
+ emit two entries with an identical prev_hash, breaking the chain (the exact
268
+ race that hit the owner vault on 2026-06-21). Holds a lock on a sidecar
269
+ ``<log>.lock`` for the whole compute-prev-hash + append section.
270
+
271
+ Best-effort on exotic platforms with neither fcntl nor msvcrt (documented),
272
+ but covered on macOS/Linux (host) and Windows.
273
+ """
274
+ lock_path = log_path.with_name(log_path.name + ".lock")
275
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
276
+ f = open(lock_path, "w")
277
+ locked_with = None
278
+ try:
279
+ try:
280
+ import fcntl
281
+
282
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
283
+ locked_with = "fcntl"
284
+ except ImportError:
285
+ try:
286
+ import msvcrt
287
+
288
+ msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
289
+ locked_with = "msvcrt"
290
+ except Exception:
291
+ locked_with = None # best-effort fallback
292
+ yield
293
+ finally:
294
+ if locked_with == "fcntl":
295
+ import fcntl
296
+
297
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
298
+ elif locked_with == "msvcrt":
299
+ try:
300
+ import msvcrt
301
+
302
+ f.seek(0)
303
+ msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
304
+ except Exception:
305
+ pass
306
+ f.close()
307
+
308
+
309
+ def public_key_pem() -> bytes:
310
+ """Public PEM derived from the resolved signing key (for verify/distribution)."""
311
+ from cryptography.hazmat.primitives import serialization
312
+
313
+ key, _ = resolve_signing_key()
314
+ return key.public_key().public_bytes(
315
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
316
+ )
317
+
318
+
319
+ # --------------------------------------------------------------------------
320
+ # chain
321
+ # --------------------------------------------------------------------------
322
+ class AuditChain:
323
+ """Append-only Ed25519 hash chain over write events."""
324
+
325
+ def __init__(self, log_path: Path) -> None:
326
+ self.log_path = Path(log_path)
327
+
328
+ def _lines(self) -> list[str]:
329
+ if not self.log_path.exists():
330
+ return []
331
+ return self.log_path.read_text(encoding="utf-8").splitlines()
332
+
333
+ @staticmethod
334
+ def _is_entry(line: str) -> bool:
335
+ return line.lstrip().startswith("{")
336
+
337
+ def _last_entry(self) -> Optional[str]:
338
+ # F-09 (known, deferred to scale-hardening): O(n) — reads the whole log
339
+ # on every append. Fine for S02 volumes; tail-seek/cache before cutover.
340
+ for line in reversed(self._lines()):
341
+ s = line.strip()
342
+ if self._is_entry(s):
343
+ return s
344
+ return None
345
+
346
+ def append(self, verb: str, path: str, reason: str, ts: str | None = None) -> dict:
347
+ """Sign + append one entry. Raises KeyUnavailable (fail closed) if no key.
348
+
349
+ The compute-prev_hash + write section runs under an exclusive cross-process
350
+ lock (F-07) so concurrent writers cannot fork the chain.
351
+ """
352
+ key, source = resolve_signing_key()
353
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
354
+ with _exclusive_lock(self.log_path):
355
+ prev = self._last_entry()
356
+ prev_hash = _sha256(prev) if prev else NULL_PREV_HASH
357
+ ts = ts or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
358
+ payload = {
359
+ "format": JSONL_FORMAT,
360
+ "ts": ts,
361
+ "verb": verb.strip(),
362
+ "path": path.strip(),
363
+ "reason": reason.strip(),
364
+ "prev_hash": prev_hash,
365
+ }
366
+ sig = base64.urlsafe_b64encode(
367
+ key.sign(_canonical(payload).encode("utf-8"))
368
+ ).decode("ascii")
369
+ full = _canonical({**payload, "sig": sig})
370
+ with self.log_path.open("a", encoding="utf-8") as f:
371
+ f.write(full + "\n")
372
+ return {"appended": True, "ts": ts, "verb": verb, "path": path, "source": source}
373
+
374
+ def verify(self, public_key_pem_bytes: bytes | None = None) -> dict:
375
+ """Walk the chain; verify prev_hash linkage, signatures, byte-canonicality."""
376
+ _require_crypto()
377
+ from cryptography.exceptions import InvalidSignature
378
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
379
+
380
+ if public_key_pem_bytes is None:
381
+ public_key_pem_bytes = public_key_pem()
382
+ pub = load_pem_public_key(public_key_pem_bytes)
383
+
384
+ errors: list[dict] = []
385
+ prev_hash = NULL_PREV_HASH
386
+ checked = 0
387
+ for raw in self._lines():
388
+ s = raw.strip()
389
+ if not self._is_entry(s):
390
+ continue
391
+ idx = checked
392
+ checked += 1
393
+ try:
394
+ obj = json.loads(s)
395
+ except (json.JSONDecodeError, ValueError):
396
+ errors.append({"idx": idx, "error": "parse_failure"})
397
+ prev_hash = _sha256(s)
398
+ continue
399
+ if not isinstance(obj, dict) or set(obj) != set(_REQUIRED_KEYS):
400
+ errors.append({"idx": idx, "error": "unexpected_keys"})
401
+ prev_hash = _sha256(s)
402
+ continue
403
+ if obj.get("format") != JSONL_FORMAT:
404
+ errors.append({"idx": idx, "error": "bad_format"})
405
+ if _canonical(obj) != s:
406
+ errors.append({"idx": idx, "error": "not_canonical"})
407
+ if obj["prev_hash"] != prev_hash:
408
+ errors.append({"idx": idx, "error": "prev_hash_mismatch",
409
+ "expected": prev_hash[:16], "got": obj["prev_hash"][:16]})
410
+ signed = {k: v for k, v in obj.items() if k != "sig"}
411
+ try:
412
+ pub.verify(base64.urlsafe_b64decode(obj["sig"] + "=="),
413
+ _canonical(signed).encode("utf-8"))
414
+ except (InvalidSignature, Exception):
415
+ errors.append({"idx": idx, "error": "invalid_signature"})
416
+ prev_hash = _sha256(s)
417
+
418
+ return {
419
+ "status": "ok" if not errors and checked else ("empty" if not checked else "tampered"),
420
+ "entries_checked": checked,
421
+ "errors": errors,
422
+ }