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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
}
|