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 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
@@ -0,0 +1,4 @@
1
+ from hakodesh.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
+ }