hakodesh 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.
- hakodesh/__init__.py +27 -0
- hakodesh/__main__.py +4 -0
- hakodesh/akashic.py +449 -0
- hakodesh/catalog.py +103 -0
- hakodesh/cli.py +169 -0
- hakodesh/combo.py +110 -0
- hakodesh/contracts/hakodesh-event-v1.json +78 -0
- hakodesh/dispatch.py +81 -0
- hakodesh/enchant.py +76 -0
- hakodesh/events.py +358 -0
- hakodesh/grimoire.py +239 -0
- hakodesh/home.py +63 -0
- hakodesh/keys.py +47 -0
- hakodesh/mint.py +125 -0
- hakodesh/templates/combo.json.tmpl +9 -0
- hakodesh/templates/enchantment.json.tmpl +8 -0
- hakodesh/templates/spell.py.tmpl +19 -0
- hakodesh/templates/ward.json.tmpl +12 -0
- hakodesh/ward.py +107 -0
- hakodesh-0.1.0.dist-info/METADATA +78 -0
- hakodesh-0.1.0.dist-info/RECORD +25 -0
- hakodesh-0.1.0.dist-info/WHEEL +5 -0
- hakodesh-0.1.0.dist-info/entry_points.txt +2 -0
- hakodesh-0.1.0.dist-info/licenses/LICENSE +7 -0
- hakodesh-0.1.0.dist-info/top_level.txt +1 -0
hakodesh/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""hakodesh — empty book, mint as you go."""
|
|
2
|
+
|
|
3
|
+
from .akashic import inscribe, verify_chain
|
|
4
|
+
from .catalog import catalog as list_catalog
|
|
5
|
+
from .catalog import route
|
|
6
|
+
from .combo import cast as combo_cast
|
|
7
|
+
from .combo import seal as combo_seal
|
|
8
|
+
from .enchant import bind, lift
|
|
9
|
+
from .events import make_event, validate_event
|
|
10
|
+
from .grimoire import seal as grimoire_seal
|
|
11
|
+
from .ward import unlock
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"list_catalog",
|
|
16
|
+
"route",
|
|
17
|
+
"make_event",
|
|
18
|
+
"validate_event",
|
|
19
|
+
"inscribe",
|
|
20
|
+
"verify_chain",
|
|
21
|
+
"grimoire_seal",
|
|
22
|
+
"bind",
|
|
23
|
+
"lift",
|
|
24
|
+
"unlock",
|
|
25
|
+
"combo_seal",
|
|
26
|
+
"combo_cast",
|
|
27
|
+
]
|
hakodesh/__main__.py
ADDED
hakodesh/akashic.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Akashic HMAC chain under HAKODESH_HOME/akashic."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fcntl
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import events, home as paths, keys
|
|
14
|
+
|
|
15
|
+
SCHEMA = "1.0.0"
|
|
16
|
+
CHAIN_ALG = "hmac-sha256"
|
|
17
|
+
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
18
|
+
|
|
19
|
+
_SENSITIVE_RECEIPT_KEY = re.compile(
|
|
20
|
+
r"(?:^|_)(?:token|secret|password|authorization|api_?key|private_?key|"
|
|
21
|
+
r"webhook_?url|credential)(?:$|_)",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
_SENSITIVE_RECEIPT_VALUE = (
|
|
25
|
+
re.compile(r"https://(?:canary\.)?discord(?:app)?\.com/api/webhooks/", re.I),
|
|
26
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{12,}", re.I),
|
|
27
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
|
28
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
29
|
+
re.compile(r"\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)\s*="),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe(name: str) -> str:
|
|
34
|
+
return _SAFE.sub("-", str(name))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def events_path(root: Path | None = None) -> Path:
|
|
38
|
+
return paths.akashic_dir(root) / "events.jsonl"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def events_head_path(root: Path | None = None) -> Path:
|
|
42
|
+
return paths.akashic_dir(root) / "events_head.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def record_path(root: Path | None = None) -> Path:
|
|
46
|
+
return paths.akashic_dir(root) / "akashic_record.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def receipt_dir(name: str, root: Path | None = None) -> Path:
|
|
50
|
+
return paths.receipts_dir(root) / _safe(name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def now_iso() -> str:
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def new_run_id() -> str:
|
|
60
|
+
return uuid.uuid4().hex[:12]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def key_fp(key: bytes) -> str:
|
|
64
|
+
return hashlib.sha256(key).hexdigest()[:16]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def redact_sensitive(value, key=None, depth=0):
|
|
68
|
+
if key and _SENSITIVE_RECEIPT_KEY.search(str(key)):
|
|
69
|
+
return "[REDACTED]"
|
|
70
|
+
if depth > 12:
|
|
71
|
+
return "[REDACTED:DEPTH]"
|
|
72
|
+
if isinstance(value, dict):
|
|
73
|
+
return {
|
|
74
|
+
str(item_key): redact_sensitive(item, key=str(item_key), depth=depth + 1)
|
|
75
|
+
for item_key, item in value.items()
|
|
76
|
+
}
|
|
77
|
+
if isinstance(value, (list, tuple)):
|
|
78
|
+
return [redact_sensitive(item, depth=depth + 1) for item in value]
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
if any(pattern.search(value) for pattern in _SENSITIVE_RECEIPT_VALUE):
|
|
81
|
+
return "[REDACTED]"
|
|
82
|
+
return value
|
|
83
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
84
|
+
return value
|
|
85
|
+
return str(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _event_body(line: dict) -> str:
|
|
89
|
+
return json.dumps(
|
|
90
|
+
{k: v for k, v in line.items() if k != "chain"},
|
|
91
|
+
sort_keys=True,
|
|
92
|
+
separators=(",", ":"),
|
|
93
|
+
default=str,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def event_hash(prev: str, line: dict) -> str:
|
|
98
|
+
return hashlib.sha256((prev + _event_body(line)).encode()).hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def event_sig(prev: str, line: dict, key: bytes) -> str:
|
|
102
|
+
return hmac.new(key, (prev + _event_body(line)).encode(), hashlib.sha256).hexdigest()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _head_mac(pid, lines, tail_self, key):
|
|
106
|
+
return hmac.new(key, f"{pid}:{lines}:{tail_self}".encode(), hashlib.sha256).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _head_mac2(pid, lines, tail_self, key_fp_, seal_offset, legacy_anchor, signed_hwm, key):
|
|
110
|
+
msg = f"{pid}:{lines}:{tail_self}:{key_fp_}:{seal_offset}:{legacy_anchor}:{signed_hwm}"
|
|
111
|
+
return hmac.new(key, msg.encode(), hashlib.sha256).hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _atomic_write(path: Path, obj) -> None:
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
tmp = path.with_name("%s.tmp.%d.%s" % (path.name, os.getpid(), uuid.uuid4().hex[:8]))
|
|
117
|
+
try:
|
|
118
|
+
tmp.write_text(json.dumps(obj, indent=2, default=str))
|
|
119
|
+
os.replace(str(tmp), str(path))
|
|
120
|
+
finally:
|
|
121
|
+
if tmp.exists():
|
|
122
|
+
try:
|
|
123
|
+
tmp.unlink()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_head(root: Path | None = None):
|
|
129
|
+
hp = events_head_path(root)
|
|
130
|
+
if not hp.is_file():
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
head = json.loads(hp.read_text())
|
|
134
|
+
except (OSError, json.JSONDecodeError):
|
|
135
|
+
return None
|
|
136
|
+
return head if isinstance(head, dict) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_head(lines, tail_self, key, seal_offset, legacy_anchor, signed_hwm, root=None):
|
|
140
|
+
pid = "shared"
|
|
141
|
+
fp = key_fp(key)
|
|
142
|
+
doc = {
|
|
143
|
+
"partition": pid,
|
|
144
|
+
"lines": lines,
|
|
145
|
+
"self": tail_self,
|
|
146
|
+
"seal_offset": seal_offset,
|
|
147
|
+
"legacy_anchor": legacy_anchor,
|
|
148
|
+
"key_fp": fp,
|
|
149
|
+
"signed_hwm": signed_hwm,
|
|
150
|
+
"alg": CHAIN_ALG,
|
|
151
|
+
"mac": _head_mac(pid, lines, tail_self, key),
|
|
152
|
+
"mac2": _head_mac2(pid, lines, tail_self, fp, seal_offset, legacy_anchor, signed_hwm, key),
|
|
153
|
+
}
|
|
154
|
+
_atomic_write(events_head_path(root), doc)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _tail_line(path: Path):
|
|
158
|
+
try:
|
|
159
|
+
with open(path, "rb") as handle:
|
|
160
|
+
handle.seek(0, os.SEEK_END)
|
|
161
|
+
end = handle.tell()
|
|
162
|
+
if end == 0:
|
|
163
|
+
return None
|
|
164
|
+
size = min(end, 65536)
|
|
165
|
+
handle.seek(end - size)
|
|
166
|
+
block = handle.read(size)
|
|
167
|
+
for raw in reversed(block.split(b"\n")):
|
|
168
|
+
if raw.strip():
|
|
169
|
+
return raw.decode("utf-8", "replace")
|
|
170
|
+
return None
|
|
171
|
+
except OSError:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _seal_offset_and_anchor(path: Path):
|
|
176
|
+
off = 0
|
|
177
|
+
digest = hashlib.sha256()
|
|
178
|
+
try:
|
|
179
|
+
with open(path, "rb") as handle:
|
|
180
|
+
for raw in handle:
|
|
181
|
+
stripped = raw.strip()
|
|
182
|
+
if not stripped:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
obj = json.loads(stripped)
|
|
186
|
+
except ValueError:
|
|
187
|
+
break
|
|
188
|
+
if isinstance(obj, dict) and isinstance(obj.get("chain"), dict):
|
|
189
|
+
break
|
|
190
|
+
off += 1
|
|
191
|
+
digest.update(raw)
|
|
192
|
+
except OSError:
|
|
193
|
+
return 0, ""
|
|
194
|
+
return off, (digest.hexdigest() if off else "")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _seal_context(ep: Path, root: Path | None = None):
|
|
198
|
+
tail = _tail_line(ep)
|
|
199
|
+
tail_chain = None
|
|
200
|
+
if tail:
|
|
201
|
+
try:
|
|
202
|
+
parsed = json.loads(tail)
|
|
203
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("chain"), dict):
|
|
204
|
+
tail_chain = parsed["chain"]
|
|
205
|
+
except ValueError:
|
|
206
|
+
tail_chain = None
|
|
207
|
+
if root is None:
|
|
208
|
+
root = ep.parent.parent
|
|
209
|
+
head = _read_head(root)
|
|
210
|
+
if tail_chain and isinstance(tail_chain.get("self"), str):
|
|
211
|
+
prev = tail_chain["self"]
|
|
212
|
+
if head:
|
|
213
|
+
return (
|
|
214
|
+
prev,
|
|
215
|
+
int(head.get("seal_offset") or 0),
|
|
216
|
+
str(head.get("legacy_anchor") or ""),
|
|
217
|
+
int(head.get("signed_hwm") or 0),
|
|
218
|
+
int(head.get("lines") or 0),
|
|
219
|
+
)
|
|
220
|
+
off, anchor = _seal_offset_and_anchor(ep)
|
|
221
|
+
n = sum(1 for _ in open(ep)) if ep.is_file() else off
|
|
222
|
+
return prev, off, anchor, 0, n
|
|
223
|
+
off, anchor = _seal_offset_and_anchor(ep)
|
|
224
|
+
prev = anchor if off else "genesis"
|
|
225
|
+
return prev, off, anchor, 0, off
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _acquire_lock(root: Path | None = None):
|
|
229
|
+
fd = None
|
|
230
|
+
try:
|
|
231
|
+
lp = paths.akashic_dir(root) / ".akashic.lock"
|
|
232
|
+
lp.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
fd = os.open(str(lp), os.O_CREAT | os.O_RDWR, 0o600)
|
|
234
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
235
|
+
return fd, None
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
if fd is not None:
|
|
238
|
+
try:
|
|
239
|
+
os.close(fd)
|
|
240
|
+
except OSError:
|
|
241
|
+
pass
|
|
242
|
+
return None, str(exc)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _release_lock(fd):
|
|
246
|
+
if fd is None:
|
|
247
|
+
return
|
|
248
|
+
try:
|
|
249
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
try:
|
|
253
|
+
os.close(fd)
|
|
254
|
+
except OSError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def append_event(event: dict, root: Path | None = None) -> dict:
|
|
259
|
+
"""Validate, HMAC-chain, append. Returns the stored line plus chain."""
|
|
260
|
+
validated = events.validate_event({k: v for k, v in event.items() if k != "chain"})
|
|
261
|
+
paths.ensure_layout(root)
|
|
262
|
+
ep = events_path(root)
|
|
263
|
+
fd, err = _acquire_lock(root)
|
|
264
|
+
out = {"ok": True, "event_id": validated["event_id"], "errors": []}
|
|
265
|
+
if err:
|
|
266
|
+
out["ok"] = False
|
|
267
|
+
out["errors"].append(err)
|
|
268
|
+
return out
|
|
269
|
+
try:
|
|
270
|
+
prev, seal_offset, legacy_anchor, prev_hwm, base_lines = _seal_context(ep, root)
|
|
271
|
+
line = dict(validated)
|
|
272
|
+
self_ = event_hash(prev, line)
|
|
273
|
+
chain = {"prev": prev, "self": self_}
|
|
274
|
+
key = keys.chain_key(create=True, root=root)
|
|
275
|
+
if key:
|
|
276
|
+
chain["alg"] = CHAIN_ALG
|
|
277
|
+
chain["sig"] = event_sig(prev, line, key)
|
|
278
|
+
else:
|
|
279
|
+
out["errors"].append("chain: keystore unusable — event appended UNSIGNED")
|
|
280
|
+
line["chain"] = chain
|
|
281
|
+
ep.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
with open(ep, "a", encoding="utf-8") as handle:
|
|
283
|
+
handle.write(json.dumps(line, separators=(",", ":"), default=str) + "\n")
|
|
284
|
+
handle.flush()
|
|
285
|
+
os.fsync(handle.fileno())
|
|
286
|
+
if key:
|
|
287
|
+
signed = base_lines - seal_offset + 1
|
|
288
|
+
_write_head(
|
|
289
|
+
base_lines + 1,
|
|
290
|
+
self_,
|
|
291
|
+
key,
|
|
292
|
+
seal_offset,
|
|
293
|
+
legacy_anchor,
|
|
294
|
+
max(prev_hwm, signed),
|
|
295
|
+
root=root,
|
|
296
|
+
)
|
|
297
|
+
out["chain"] = chain
|
|
298
|
+
out["event"] = line
|
|
299
|
+
return out
|
|
300
|
+
finally:
|
|
301
|
+
_release_lock(fd)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def write_receipt(name: str, receipt: dict, root: Path | None = None) -> Path:
|
|
305
|
+
folder = receipt_dir(name, root)
|
|
306
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
run_id = receipt.get("run_id") or new_run_id()
|
|
308
|
+
path = folder / ("%s__%s.json" % (now_iso().replace(":", ""), run_id))
|
|
309
|
+
path.write_text(json.dumps(redact_sensitive(receipt), indent=2, default=str))
|
|
310
|
+
return path
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def inscribe(name: str, receipt: dict, *, event: dict | None = None, root: Path | None = None) -> dict:
|
|
314
|
+
receipt = dict(receipt)
|
|
315
|
+
run_id = receipt.get("run_id") or new_run_id()
|
|
316
|
+
receipt["run_id"] = run_id
|
|
317
|
+
path = write_receipt(name, receipt, root)
|
|
318
|
+
out = {"ok": True, "receipt": str(path), "run_id": run_id}
|
|
319
|
+
if event:
|
|
320
|
+
actor = events.default_actor(spell=name)
|
|
321
|
+
built = events.make_event(
|
|
322
|
+
actor=actor,
|
|
323
|
+
kind=event.get("kind") or "spell.cast.completed",
|
|
324
|
+
subject={"product": "hakodesh", "spell": name},
|
|
325
|
+
state=event.get("state") or ("completed" if receipt.get("ok", True) else "failed"),
|
|
326
|
+
summary=event.get("summary") or ("cast %s" % name),
|
|
327
|
+
idempotency_key=event.get("idempotency_key") or ("cast:%s:%s" % (name, run_id)),
|
|
328
|
+
provenance=event.get("provenance") or {"surface": "hakodesh"},
|
|
329
|
+
notification_policy="none",
|
|
330
|
+
run_id=run_id,
|
|
331
|
+
)
|
|
332
|
+
chained = append_event(built, root)
|
|
333
|
+
out["event_id"] = chained.get("event_id")
|
|
334
|
+
out["chain"] = chained.get("chain")
|
|
335
|
+
out["event_ok"] = chained.get("ok")
|
|
336
|
+
if chained.get("errors"):
|
|
337
|
+
out["errors"] = chained["errors"]
|
|
338
|
+
return out
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def verify_chain(strict=False, root: Path | None = None) -> dict:
|
|
342
|
+
ep = events_path(root)
|
|
343
|
+
out = {
|
|
344
|
+
"ok": True,
|
|
345
|
+
"partition": "shared",
|
|
346
|
+
"events": str(ep),
|
|
347
|
+
"lines": 0,
|
|
348
|
+
"legacy": 0,
|
|
349
|
+
"signed": 0,
|
|
350
|
+
"sealed": True,
|
|
351
|
+
"head_anchor": "absent",
|
|
352
|
+
"problems": [],
|
|
353
|
+
}
|
|
354
|
+
try:
|
|
355
|
+
raw = [line for line in ep.read_text().splitlines() if line.strip()] if ep.is_file() else []
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
out["ok"] = False
|
|
358
|
+
out["problems"].append("spine unreadable: %s" % exc)
|
|
359
|
+
return out
|
|
360
|
+
lines = []
|
|
361
|
+
for i, line in enumerate(raw):
|
|
362
|
+
try:
|
|
363
|
+
lines.append(json.loads(line))
|
|
364
|
+
except ValueError:
|
|
365
|
+
out["ok"] = False
|
|
366
|
+
out["problems"].append("line %d: unparseable" % i)
|
|
367
|
+
return out
|
|
368
|
+
out["lines"] = len(lines)
|
|
369
|
+
key = keys.chain_key(create=False, root=root)
|
|
370
|
+
head = _read_head(root)
|
|
371
|
+
off, anchor = _seal_offset_and_anchor(ep)
|
|
372
|
+
out["legacy"] = off
|
|
373
|
+
if head and key is not None and head.get("key_fp") and head.get("key_fp") != key_fp(key):
|
|
374
|
+
out["ok"] = False
|
|
375
|
+
out["problems"].append("chain key rotated")
|
|
376
|
+
return out
|
|
377
|
+
if off == len(lines):
|
|
378
|
+
out["sealed"] = False
|
|
379
|
+
if strict:
|
|
380
|
+
out["ok"] = False
|
|
381
|
+
out["problems"].append("spine is entirely unsealed")
|
|
382
|
+
return out
|
|
383
|
+
prev = anchor if off else "genesis"
|
|
384
|
+
signed = 0
|
|
385
|
+
for idx in range(off, len(lines)):
|
|
386
|
+
entry = lines[idx]
|
|
387
|
+
chain = entry.get("chain")
|
|
388
|
+
if not isinstance(chain, dict) or not isinstance(chain.get("self"), str) or not isinstance(chain.get("prev"), str):
|
|
389
|
+
out["ok"] = False
|
|
390
|
+
out["problems"].append("line %d: missing chain" % idx)
|
|
391
|
+
return out
|
|
392
|
+
if chain["prev"] != prev or chain["self"] != event_hash(prev, entry):
|
|
393
|
+
out["ok"] = False
|
|
394
|
+
out["problems"].append("line %d: hash linkage broken" % idx)
|
|
395
|
+
return out
|
|
396
|
+
sig = chain.get("sig")
|
|
397
|
+
if sig:
|
|
398
|
+
signed += 1
|
|
399
|
+
if key is None:
|
|
400
|
+
out["ok"] = False
|
|
401
|
+
out["problems"].append("line %d: signed but chain key unavailable" % idx)
|
|
402
|
+
return out
|
|
403
|
+
if not hmac.compare_digest(sig, event_sig(prev, entry, key)):
|
|
404
|
+
out["ok"] = False
|
|
405
|
+
out["problems"].append("line %d: keyed MAC mismatch" % idx)
|
|
406
|
+
return out
|
|
407
|
+
prev = chain["self"]
|
|
408
|
+
out["signed"] = signed
|
|
409
|
+
hp = events_head_path(root)
|
|
410
|
+
if hp.is_file():
|
|
411
|
+
pid = "shared"
|
|
412
|
+
tail_self = lines[-1].get("chain", {}).get("self")
|
|
413
|
+
if head is None or key is None:
|
|
414
|
+
out["ok"] = False
|
|
415
|
+
out["problems"].append("head anchor unreadable or chain key unavailable")
|
|
416
|
+
elif not hmac.compare_digest(
|
|
417
|
+
_head_mac(pid, head.get("lines"), head.get("self") or "", key),
|
|
418
|
+
head.get("mac") or "",
|
|
419
|
+
):
|
|
420
|
+
out["ok"] = False
|
|
421
|
+
out["problems"].append("head MAC invalid")
|
|
422
|
+
elif head.get("lines") != len(lines) or head.get("self") != tail_self:
|
|
423
|
+
out["ok"] = False
|
|
424
|
+
out["problems"].append("spine does not match the head anchor")
|
|
425
|
+
else:
|
|
426
|
+
out["head_anchor"] = "verified"
|
|
427
|
+
elif signed or strict:
|
|
428
|
+
out["ok"] = False
|
|
429
|
+
out["problems"].append("events_head.json missing")
|
|
430
|
+
out["tamper_evidence"] = (
|
|
431
|
+
"keyed (HMAC-SHA256)" if out["ok"] and signed else "FAILED" if not out["ok"] else "hash-linkage only"
|
|
432
|
+
)
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def tail_events(n=20, root: Path | None = None):
|
|
437
|
+
ep = events_path(root)
|
|
438
|
+
try:
|
|
439
|
+
with open(ep) as handle:
|
|
440
|
+
lines = handle.readlines()[-int(n) :]
|
|
441
|
+
out = []
|
|
442
|
+
for line in lines:
|
|
443
|
+
try:
|
|
444
|
+
out.append(json.loads(line))
|
|
445
|
+
except json.JSONDecodeError:
|
|
446
|
+
continue
|
|
447
|
+
return out
|
|
448
|
+
except OSError:
|
|
449
|
+
return []
|
hakodesh/catalog.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Discover user-book artifacts. Kernel commands are not catalog entries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import home as paths
|
|
9
|
+
|
|
10
|
+
KERNEL = (
|
|
11
|
+
"mint",
|
|
12
|
+
"catalog",
|
|
13
|
+
"route",
|
|
14
|
+
"invoke",
|
|
15
|
+
"akashic",
|
|
16
|
+
"grimoire",
|
|
17
|
+
"grimoire-seal",
|
|
18
|
+
"enchant",
|
|
19
|
+
"ward",
|
|
20
|
+
"combo",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_spell(path: Path):
|
|
25
|
+
spec = importlib.util.spec_from_file_location("hakodesh_book_%s" % path.stem, path)
|
|
26
|
+
if spec is None or spec.loader is None:
|
|
27
|
+
return None
|
|
28
|
+
mod = importlib.util.module_from_spec(spec)
|
|
29
|
+
spec.loader.exec_module(mod)
|
|
30
|
+
desc = getattr(mod, "SPELL", None)
|
|
31
|
+
run = getattr(mod, "run", None)
|
|
32
|
+
if not (isinstance(desc, dict) and callable(run)):
|
|
33
|
+
return None
|
|
34
|
+
name = desc.get("name") or path.stem.replace("_", "-")
|
|
35
|
+
return {"name": name, "kind": "spell", "path": str(path), "descriptor": desc, "run": run}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_json(path: Path, kind: str):
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(path.read_text())
|
|
41
|
+
except (OSError, json.JSONDecodeError):
|
|
42
|
+
return None
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
return None
|
|
45
|
+
name = data.get("name") or path.stem
|
|
46
|
+
return {"name": name, "kind": kind, "path": str(path), "descriptor": data, "run": None}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def user_artifacts(root: Path | None = None):
|
|
50
|
+
root = paths.ensure_layout(root)
|
|
51
|
+
items = []
|
|
52
|
+
for path in sorted(paths.book_dir("spell", root).glob("*.py")):
|
|
53
|
+
loaded = _load_spell(path)
|
|
54
|
+
if loaded:
|
|
55
|
+
items.append(loaded)
|
|
56
|
+
for kind in ("enchantment", "ward", "combo"):
|
|
57
|
+
for path in sorted(paths.book_dir(kind, root).glob("*.json")):
|
|
58
|
+
loaded = _load_json(path, kind)
|
|
59
|
+
if loaded:
|
|
60
|
+
items.append(loaded)
|
|
61
|
+
return items
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def catalog(root: Path | None = None):
|
|
65
|
+
items = user_artifacts(root)
|
|
66
|
+
return {
|
|
67
|
+
"ok": True,
|
|
68
|
+
"count": len(items),
|
|
69
|
+
"artifacts": [
|
|
70
|
+
{
|
|
71
|
+
"name": item["name"],
|
|
72
|
+
"kind": item["kind"],
|
|
73
|
+
"path": item["path"],
|
|
74
|
+
"purpose": (item.get("descriptor") or {}).get("purpose", ""),
|
|
75
|
+
}
|
|
76
|
+
for item in items
|
|
77
|
+
],
|
|
78
|
+
"kernel": list(KERNEL),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def registry(root: Path | None = None):
|
|
83
|
+
return {item["name"]: item for item in user_artifacts(root)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def route(intent: str, root: Path | None = None):
|
|
87
|
+
intent = (intent or "").lower()
|
|
88
|
+
scored = []
|
|
89
|
+
haystacks = [(name, "kernel command %s" % name, "kernel") for name in KERNEL]
|
|
90
|
+
for item in user_artifacts(root):
|
|
91
|
+
desc = item.get("descriptor") or {}
|
|
92
|
+
hay = "%s %s %s" % (item["name"], desc.get("purpose", ""), " ".join(desc.get("tags") or []))
|
|
93
|
+
haystacks.append((item["name"], hay, item["kind"]))
|
|
94
|
+
for name, hay, kind in haystacks:
|
|
95
|
+
score = sum(1 for word in intent.split() if word and word in hay.lower())
|
|
96
|
+
if score:
|
|
97
|
+
scored.append((score, name, kind))
|
|
98
|
+
scored.sort(reverse=True)
|
|
99
|
+
return {
|
|
100
|
+
"ok": True,
|
|
101
|
+
"intent": intent,
|
|
102
|
+
"matches": [{"name": name, "score": score, "kind": kind} for score, name, kind in scored[:8]],
|
|
103
|
+
}
|