deeprem 0.3.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.
- deeprem/__init__.py +20 -0
- deeprem/__main__.py +3 -0
- deeprem/_schema.py +26 -0
- deeprem/_util.py +166 -0
- deeprem/cli.py +210 -0
- deeprem/crypto.py +151 -0
- deeprem/dreaming.py +136 -0
- deeprem/dynamics.py +241 -0
- deeprem/engine.py +686 -0
- deeprem/errors.py +33 -0
- deeprem/memory.py +536 -0
- deeprem/models.py +61 -0
- deeprem/py.typed +0 -0
- deeprem/runtime_cli.py +119 -0
- deeprem/runtime_store.py +91 -0
- deeprem/schemas/approval-request-v1.schema.json +125 -0
- deeprem/schemas/approval-v1.schema.json +141 -0
- deeprem/schemas/autobiography-v1.schema.json +92 -0
- deeprem/schemas/checkpoint-v1.schema.json +36 -0
- deeprem/schemas/context-receipt-v1.schema.json +101 -0
- deeprem/schemas/dream-v1.schema.json +609 -0
- deeprem/schemas/event-v1.schema.json +768 -0
- deeprem/schemas/evidence-v1.schema.json +33 -0
- deeprem/schemas/fragment-v1.schema.json +51 -0
- deeprem/schemas/manifest-v1.schema.json +101 -0
- deeprem/schemas/output-hook-snapshot-v1.schema.json +147 -0
- deeprem/schemas/output-hook-v1.schema.json +131 -0
- deeprem/schemas/proposal-v1.schema.json +160 -0
- deeprem/schemas/relationship-v1.schema.json +25 -0
- deeprem/schemas/runtime-event-v1.schema.json +1266 -0
- deeprem/schemas/runtime-manifest-v1.schema.json +308 -0
- deeprem/schemas/runtime-policy-v1.schema.json +225 -0
- deeprem/schemas/subconscious-event-v1.schema.json +2309 -0
- deeprem/schemas/subconscious-manifest-v1.schema.json +786 -0
- deeprem/source.py +90 -0
- deeprem/store.py +224 -0
- deeprem/subconscious.py +925 -0
- deeprem/subconscious_cli.py +91 -0
- deeprem/subconscious_index.py +62 -0
- deeprem/subconscious_rules.py +186 -0
- deeprem/subconscious_store.py +37 -0
- deeprem-0.3.0.dist-info/METADATA +204 -0
- deeprem-0.3.0.dist-info/RECORD +47 -0
- deeprem-0.3.0.dist-info/WHEEL +5 -0
- deeprem-0.3.0.dist-info/entry_points.txt +4 -0
- deeprem-0.3.0.dist-info/licenses/LICENSE +21 -0
- deeprem-0.3.0.dist-info/top_level.txt +1 -0
deeprem/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .crypto import SigningKey, generate_encryption_key, sign_approval
|
|
2
|
+
from .errors import (ApprovalRequired, ConflictError, DeepRemError, EvidenceError,
|
|
3
|
+
IntegrityError, KeyRequired, NotFoundError, ValidationError)
|
|
4
|
+
from .memory import Memory
|
|
5
|
+
from .models import Hit, Record, Verification
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
__all__ = ["Memory", "Record", "Hit", "Verification", "SigningKey", "generate_encryption_key", "sign_approval",
|
|
9
|
+
"DeepRemError", "ValidationError", "IntegrityError", "EvidenceError", "ApprovalRequired", "ConflictError",
|
|
10
|
+
"NotFoundError", "KeyRequired"]
|
|
11
|
+
|
|
12
|
+
from .dynamics import Policy
|
|
13
|
+
from .engine import Engine
|
|
14
|
+
|
|
15
|
+
__all__ += ["Engine", "Policy"]
|
|
16
|
+
|
|
17
|
+
from .subconscious import Subconscious
|
|
18
|
+
from .subconscious_rules import StreamWeights, SubconsciousPolicy
|
|
19
|
+
|
|
20
|
+
__all__ += ["Subconscious", "StreamWeights", "SubconsciousPolicy"]
|
deeprem/__main__.py
ADDED
deeprem/_schema.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Versioned schemas are shipped as package resources and used at runtime."""
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
7
|
+
from jsonschema.exceptions import ValidationError as SchemaError
|
|
8
|
+
|
|
9
|
+
from ._util import canonical
|
|
10
|
+
from .errors import ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache(maxsize=None)
|
|
14
|
+
def validator(name: str) -> Draft202012Validator:
|
|
15
|
+
schema = json.loads(files("deeprem").joinpath("schemas", name + "-v1.schema.json").read_text(encoding="utf-8"))
|
|
16
|
+
return Draft202012Validator(schema, format_checker=FormatChecker())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate(name: str, value) -> None:
|
|
20
|
+
canonical(value)
|
|
21
|
+
try:
|
|
22
|
+
validator(name).validate(value)
|
|
23
|
+
except SchemaError as exc:
|
|
24
|
+
location = ".".join(str(p) for p in exc.absolute_path) or "root"
|
|
25
|
+
# Do not include secret journal prose in an exception message.
|
|
26
|
+
raise ValidationError(f"invalid {name} at {location}: failed {exc.validator}") from exc
|
deeprem/_util.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import IntegrityError, ValidationError
|
|
14
|
+
|
|
15
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
16
|
+
ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def json_value(value: Any, *, _seen: set[int] | None = None, _depth: int = 0) -> None:
|
|
20
|
+
if _depth > 64:
|
|
21
|
+
raise ValidationError("JSON nesting exceeds the v1 limit of 64")
|
|
22
|
+
if value is None or type(value) in (str, int, bool):
|
|
23
|
+
return
|
|
24
|
+
if type(value) is float and math.isfinite(value):
|
|
25
|
+
return
|
|
26
|
+
if type(value) not in (list, dict) or (type(value) is dict and not all(type(key) is str for key in value)):
|
|
27
|
+
raise ValidationError("values must be finite JSON types with string object keys")
|
|
28
|
+
seen = set() if _seen is None else _seen
|
|
29
|
+
identity = id(value)
|
|
30
|
+
if identity in seen:
|
|
31
|
+
raise ValidationError("cyclic objects are not JSON")
|
|
32
|
+
seen.add(identity)
|
|
33
|
+
try:
|
|
34
|
+
for item in (value.values() if isinstance(value, dict) else value):
|
|
35
|
+
json_value(item, _seen=seen, _depth=_depth + 1)
|
|
36
|
+
finally:
|
|
37
|
+
seen.remove(identity)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonical(value: Any) -> bytes:
|
|
41
|
+
json_value(value)
|
|
42
|
+
try:
|
|
43
|
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("ascii")
|
|
44
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
45
|
+
raise ValidationError("invalid JSON value") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sha256(data: bytes) -> str:
|
|
49
|
+
return hashlib.sha256(data).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pairs(pairs):
|
|
53
|
+
result = {}
|
|
54
|
+
for key, value in pairs:
|
|
55
|
+
if key in result:
|
|
56
|
+
raise ValidationError("duplicate JSON object key")
|
|
57
|
+
result[key] = value
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def loads(raw: bytes | str) -> Any:
|
|
62
|
+
if len(raw) > MAX_BYTES:
|
|
63
|
+
raise ValidationError("JSON document exceeds the 16 MiB v1 limit")
|
|
64
|
+
try:
|
|
65
|
+
value = json.loads(raw, object_pairs_hook=_pairs)
|
|
66
|
+
json_value(value)
|
|
67
|
+
return value
|
|
68
|
+
except (UnicodeError, ValueError, TypeError, RecursionError) as exc:
|
|
69
|
+
raise ValidationError("invalid JSON document") from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def now() -> datetime:
|
|
73
|
+
return datetime.now(timezone.utc)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def iso(value: datetime) -> str:
|
|
77
|
+
return value.astimezone(timezone.utc).isoformat(timespec="microseconds")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def timestamp(value: str) -> datetime:
|
|
81
|
+
if not isinstance(value, str):
|
|
82
|
+
raise ValidationError("timestamp must be an ISO8601 string with a timezone")
|
|
83
|
+
try:
|
|
84
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
85
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
86
|
+
raise ValueError("missing timezone")
|
|
87
|
+
return parsed.astimezone(timezone.utc)
|
|
88
|
+
except (ValueError, OverflowError) as exc:
|
|
89
|
+
raise ValidationError("timestamp must be ISO8601 with a timezone") from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def boundary(value: str | None, *, relative_to: datetime) -> datetime | None:
|
|
93
|
+
if value is None:
|
|
94
|
+
return None
|
|
95
|
+
if isinstance(value, str) and re.fullmatch(r"[1-9][0-9]{0,4}d", value):
|
|
96
|
+
return relative_to - timedelta(days=int(value[:-1]))
|
|
97
|
+
return timestamp(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def safe_id(value: str) -> str:
|
|
101
|
+
if not isinstance(value, str) or re.fullmatch(ID_PATTERN, value) is None:
|
|
102
|
+
raise ValidationError("unsafe or invalid entry id")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def text(value: str, name: str, maximum: int = 2048) -> str:
|
|
107
|
+
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
|
108
|
+
raise ValidationError(f"{name} must be nonblank text of at most {maximum} characters")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def integer(value: int, name: str, minimum: int = 0, maximum: int = 10000) -> int:
|
|
113
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
114
|
+
raise ValidationError(f"{name} must be an integer in [{minimum}, {maximum}]")
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def safe_path(path: Path, root: Path) -> Path:
|
|
119
|
+
if not path.is_relative_to(root):
|
|
120
|
+
raise IntegrityError("path escapes the configured root")
|
|
121
|
+
for item in (path, *path.parents):
|
|
122
|
+
if item == root:
|
|
123
|
+
break
|
|
124
|
+
if item.is_symlink():
|
|
125
|
+
raise IntegrityError("symlinks are not allowed within canonical storage")
|
|
126
|
+
if not path.resolve().is_relative_to(root):
|
|
127
|
+
raise IntegrityError("path escapes the configured root")
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fsync_dir(path: Path) -> None:
|
|
132
|
+
if os.name == "posix":
|
|
133
|
+
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
|
134
|
+
try:
|
|
135
|
+
os.fsync(fd)
|
|
136
|
+
finally:
|
|
137
|
+
os.close(fd)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def atomic_write(path: Path, data: bytes, *, replace: bool = False) -> None:
|
|
141
|
+
"""Publish a fully synced file; never replace canonical history by default.
|
|
142
|
+
|
|
143
|
+
All canonical calls are serialized by an OS-held lock. Hard-link publication
|
|
144
|
+
also prevents overwriting a pre-existing destination after a crash.
|
|
145
|
+
"""
|
|
146
|
+
fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
|
|
147
|
+
temp = Path(name)
|
|
148
|
+
try:
|
|
149
|
+
with os.fdopen(fd, "wb") as handle:
|
|
150
|
+
handle.write(data)
|
|
151
|
+
handle.flush()
|
|
152
|
+
os.fsync(handle.fileno())
|
|
153
|
+
if replace:
|
|
154
|
+
os.replace(temp, path)
|
|
155
|
+
else:
|
|
156
|
+
os.link(temp, path)
|
|
157
|
+
temp.unlink()
|
|
158
|
+
fsync_dir(path.parent)
|
|
159
|
+
finally:
|
|
160
|
+
temp.unlink(missing_ok=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_bytes(path: Path) -> bytes:
|
|
164
|
+
if path.stat().st_size > MAX_BYTES:
|
|
165
|
+
raise IntegrityError("canonical file exceeds the 16 MiB v1 limit")
|
|
166
|
+
return path.read_bytes()
|
deeprem/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""JSON-first CLI; private keys are accepted only by file path, never inline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from filelock import Timeout
|
|
10
|
+
|
|
11
|
+
from ._util import atomic_write, canonical, loads
|
|
12
|
+
from .crypto import SigningKey, generate_encryption_key, sign_approval
|
|
13
|
+
from .errors import DeepRemError, ValidationError
|
|
14
|
+
from .memory import Memory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_json(path: str):
|
|
18
|
+
return loads(sys.stdin.read() if path == "-" else Path(path).read_bytes())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _emit(value):
|
|
22
|
+
if hasattr(value, "to_dict"):
|
|
23
|
+
value = value.to_dict()
|
|
24
|
+
elif isinstance(value, list):
|
|
25
|
+
value = [item.to_dict() if hasattr(item, "to_dict") else item for item in value]
|
|
26
|
+
print(json.dumps(value, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parser() -> argparse.ArgumentParser:
|
|
30
|
+
p = argparse.ArgumentParser(prog="deeprem", description="Local memory. Use subconscious for autobiographical output hooks, runtime for the older Engine, or the legacy Memory commands below.")
|
|
31
|
+
p.add_argument("--root", default="memory")
|
|
32
|
+
p.add_argument("--journal", help="ghostjournal root; not needed for keygen, approve or verify --log-only")
|
|
33
|
+
p.add_argument("--agent", help="required on first initialization")
|
|
34
|
+
p.add_argument("--actor", help="audit label, not an authentication identity")
|
|
35
|
+
p.add_argument("--signing-key-file", help="external writer Ed25519 PEM file")
|
|
36
|
+
p.add_argument("--encryption-key-file", help="external Fernet key file")
|
|
37
|
+
p.add_argument("--reviewer-public-key-file", help="base64 Ed25519 public key, read at store creation")
|
|
38
|
+
p.add_argument("--checkpoint", help="trusted checkpoint kept outside the memory root")
|
|
39
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
40
|
+
sub.add_parser("init")
|
|
41
|
+
kg = sub.add_parser("keygen")
|
|
42
|
+
kg.add_argument("kind", choices=["signing", "encryption"])
|
|
43
|
+
kg.add_argument("--out", required=True, help="new external key file; signing also writes OUT.pub")
|
|
44
|
+
ap = sub.add_parser("approve", help="operator-only signing of a reviewed approval request")
|
|
45
|
+
ap.add_argument("--request", required=True)
|
|
46
|
+
ap.add_argument("--reviewer-key-file", required=True)
|
|
47
|
+
ap.add_argument("--out", required=True)
|
|
48
|
+
cp = sub.add_parser("consolidate")
|
|
49
|
+
cp.add_argument("--since", default="30d")
|
|
50
|
+
cp.add_argument("--until")
|
|
51
|
+
cp.add_argument("--min-entries", type=int, default=3)
|
|
52
|
+
cp.add_argument("--min-days", type=int, default=3)
|
|
53
|
+
cp.add_argument("--dimensions", nargs="+", choices=["theme", "focus", "tag"], default=["theme", "focus"])
|
|
54
|
+
pp = sub.add_parser("propose", help="read a proposal argument object, including supporting entry ids")
|
|
55
|
+
pp.add_argument("--json", default="-", help="file or '-' for stdin")
|
|
56
|
+
for command in ["commit", "reject", "deactivate", "resolve"]:
|
|
57
|
+
sp = sub.add_parser(command)
|
|
58
|
+
sp.add_argument("id")
|
|
59
|
+
sp.add_argument("--reason", required=True)
|
|
60
|
+
sp.add_argument("--approval", help="reviewer-signed approval JSON")
|
|
61
|
+
sp.add_argument("--client-key")
|
|
62
|
+
ev = sub.add_parser("add-evidence")
|
|
63
|
+
ev.add_argument("id")
|
|
64
|
+
ev.add_argument("--support", action="append", default=[])
|
|
65
|
+
ev.add_argument("--contradict", action="append", default=[])
|
|
66
|
+
ev.add_argument("--reason", required=True)
|
|
67
|
+
ev.add_argument("--client-key")
|
|
68
|
+
pa = sub.add_parser("prepare-approval")
|
|
69
|
+
pa.add_argument("action", choices=["commit", "reject", "deactivate", "resolve"])
|
|
70
|
+
pa.add_argument("id")
|
|
71
|
+
pa.add_argument("--reason", required=True)
|
|
72
|
+
pa.add_argument("--ttl-seconds", type=int, default=3600)
|
|
73
|
+
gp = sub.add_parser("get")
|
|
74
|
+
gp.add_argument("id")
|
|
75
|
+
lp = sub.add_parser("list")
|
|
76
|
+
lp.add_argument("--status")
|
|
77
|
+
lp.add_argument("--kind")
|
|
78
|
+
lp.add_argument("--limit", type=int, default=100)
|
|
79
|
+
rp = sub.add_parser("recall")
|
|
80
|
+
rp.add_argument("query")
|
|
81
|
+
rp.add_argument("-k", type=int, default=8)
|
|
82
|
+
rp.add_argument("--include-inactive", action="store_true")
|
|
83
|
+
pc = sub.add_parser("prompt-context")
|
|
84
|
+
pc.add_argument("query")
|
|
85
|
+
pc.add_argument("-k", type=int, default=6)
|
|
86
|
+
pc.add_argument("--max-chars", type=int, default=6000)
|
|
87
|
+
for cmd in ("identity", "open-threads", "seal-journal", "reindex"):
|
|
88
|
+
sub.add_parser(cmd)
|
|
89
|
+
ch = sub.add_parser("changes")
|
|
90
|
+
ch.add_argument("--since", default="90d")
|
|
91
|
+
ch.add_argument("--until")
|
|
92
|
+
dp = sub.add_parser("decay")
|
|
93
|
+
dp.add_argument("--as-of")
|
|
94
|
+
dp.add_argument("--threshold", type=float, default=0.1)
|
|
95
|
+
vp = sub.add_parser("verify")
|
|
96
|
+
vp.add_argument("--log-only", action="store_true", help="explicitly skip journal evidence verification")
|
|
97
|
+
ck = sub.add_parser("checkpoint")
|
|
98
|
+
ck.add_argument("--out", required=True, help="new path outside memory root; protect it with independent permissions")
|
|
99
|
+
return p
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _outside(path: str, root: Path) -> Path:
|
|
103
|
+
result = Path(path).expanduser().resolve()
|
|
104
|
+
if result.is_relative_to(root):
|
|
105
|
+
raise ValidationError("private keys and trusted checkpoints must be stored outside the memory root")
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run(args) -> int:
|
|
110
|
+
root = Path(args.root).expanduser().resolve()
|
|
111
|
+
if args.command == "keygen":
|
|
112
|
+
out = _outside(args.out, root)
|
|
113
|
+
if args.kind == "signing":
|
|
114
|
+
pub_path = Path(str(out) + ".pub")
|
|
115
|
+
if out.exists() or pub_path.exists():
|
|
116
|
+
raise ValidationError("key output already exists")
|
|
117
|
+
key = SigningKey.generate()
|
|
118
|
+
key.save(out)
|
|
119
|
+
atomic_write(pub_path, (key.public_key + "\n").encode("ascii"))
|
|
120
|
+
_emit({"private_key_file": str(out), "public_key_file": str(pub_path), "public_key": key.public_key})
|
|
121
|
+
else:
|
|
122
|
+
out.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
123
|
+
atomic_write(out, generate_encryption_key() + b"\n")
|
|
124
|
+
_emit({"encryption_key_file": str(out)})
|
|
125
|
+
return 0
|
|
126
|
+
if args.command == "approve":
|
|
127
|
+
key = SigningKey.load(_outside(args.reviewer_key_file, root))
|
|
128
|
+
approval = sign_approval(_read_json(args.request), key)
|
|
129
|
+
out = Path(args.out)
|
|
130
|
+
out.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
131
|
+
atomic_write(out, canonical(approval) + b"\n")
|
|
132
|
+
_emit({"approval_file": str(out)})
|
|
133
|
+
return 0
|
|
134
|
+
log_only = args.command == "verify" and args.log_only
|
|
135
|
+
if args.journal is None and not log_only:
|
|
136
|
+
raise ValidationError("--journal is required for memory operations")
|
|
137
|
+
signer = SigningKey.load(_outside(args.signing_key_file, root)) if args.signing_key_file else None
|
|
138
|
+
key = _outside(args.encryption_key_file, root).read_bytes().strip() if args.encryption_key_file else None
|
|
139
|
+
reviewer = Path(args.reviewer_public_key_file).read_text(encoding="ascii").strip() if args.reviewer_public_key_file else None
|
|
140
|
+
checkpoint = _read_json(str(_outside(args.checkpoint, root))) if args.checkpoint else None
|
|
141
|
+
m = Memory(args.root, journal=None if log_only else args.journal, agent=args.agent, signing_key=signer,
|
|
142
|
+
encryption_key=key, reviewer_public_key=reviewer, checkpoint=checkpoint)
|
|
143
|
+
command = args.command
|
|
144
|
+
if command == "init":
|
|
145
|
+
result = {"root": str(m.root), "agent": m.agent, "verification": m.verify().to_dict()}
|
|
146
|
+
elif command == "propose":
|
|
147
|
+
data = _read_json(args.json)
|
|
148
|
+
allowed = {"statement", "supporting_entries", "contradicting_entries", "kind", "theme", "tags", "protected", "supersedes", "half_life_days", "client_key"}
|
|
149
|
+
if not isinstance(data, dict) or set(data) - allowed or "statement" not in data:
|
|
150
|
+
raise ValidationError("proposal JSON has missing or unknown argument fields")
|
|
151
|
+
result = m.propose(**data, actor=args.actor)
|
|
152
|
+
elif command == "consolidate":
|
|
153
|
+
result = m.consolidate(since=args.since, until=args.until, min_entries=args.min_entries, min_days=args.min_days,
|
|
154
|
+
dimensions=tuple(args.dimensions), actor=args.actor)
|
|
155
|
+
elif command in {"commit", "reject", "deactivate", "resolve"}:
|
|
156
|
+
result = getattr(m, command)(args.id, reason=args.reason, approval=_read_json(args.approval) if args.approval else None,
|
|
157
|
+
client_key=args.client_key, actor=args.actor)
|
|
158
|
+
elif command == "add-evidence":
|
|
159
|
+
result = m.add_evidence(args.id, supporting_entries=args.support, contradicting_entries=args.contradict,
|
|
160
|
+
reason=args.reason, client_key=args.client_key, actor=args.actor)
|
|
161
|
+
elif command == "prepare-approval":
|
|
162
|
+
result = m.prepare_approval(args.action, args.id, reason=args.reason, ttl_seconds=args.ttl_seconds)
|
|
163
|
+
elif command == "get":
|
|
164
|
+
result = m.get(args.id)
|
|
165
|
+
elif command == "list":
|
|
166
|
+
result = m.list(status=args.status, kind=args.kind, limit=args.limit)
|
|
167
|
+
elif command == "recall":
|
|
168
|
+
result = m.recall(args.query, k=args.k, include_inactive=args.include_inactive)
|
|
169
|
+
elif command == "prompt-context":
|
|
170
|
+
print(m.prompt_context(args.query, k=args.k, max_chars=args.max_chars))
|
|
171
|
+
return 0
|
|
172
|
+
elif command == "identity":
|
|
173
|
+
result = m.identity()
|
|
174
|
+
elif command == "open-threads":
|
|
175
|
+
result = m.open_threads()
|
|
176
|
+
elif command == "changes":
|
|
177
|
+
result = m.changes(since=args.since, until=args.until)
|
|
178
|
+
elif command == "decay":
|
|
179
|
+
result = m.decay(as_of=args.as_of, threshold=args.threshold, actor=args.actor)
|
|
180
|
+
elif command == "seal-journal":
|
|
181
|
+
result = {"newly_sealed": m.seal_journal(actor=args.actor)}
|
|
182
|
+
elif command == "verify":
|
|
183
|
+
result = m.verify(check_journal=not log_only)
|
|
184
|
+
elif command == "reindex":
|
|
185
|
+
result = m.reindex()
|
|
186
|
+
elif command == "checkpoint":
|
|
187
|
+
out = _outside(args.out, root)
|
|
188
|
+
out.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
189
|
+
result = m.checkpoint()
|
|
190
|
+
atomic_write(out, canonical(result) + b"\n")
|
|
191
|
+
else:
|
|
192
|
+
raise ValidationError("unknown command")
|
|
193
|
+
_emit(result)
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main(argv: list[str] | None = None) -> int:
|
|
198
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
199
|
+
if argv[:1] == ["subconscious"]:
|
|
200
|
+
from .subconscious_cli import main as subconscious_main
|
|
201
|
+
return subconscious_main(argv[1:])
|
|
202
|
+
if argv[:1] == ["runtime"]:
|
|
203
|
+
from .runtime_cli import main as runtime_main
|
|
204
|
+
return runtime_main(argv[1:])
|
|
205
|
+
args = parser().parse_args(argv)
|
|
206
|
+
try:
|
|
207
|
+
return _run(args)
|
|
208
|
+
except (DeepRemError, OSError, Timeout) as exc:
|
|
209
|
+
print(json.dumps({"error": type(exc).__name__, "message": str(exc)}, ensure_ascii=True), file=sys.stderr)
|
|
210
|
+
return 2
|
deeprem/crypto.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Optional cryptography adapter. No custom ciphers, key derivation, or nonce code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._schema import validate
|
|
9
|
+
from ._util import atomic_write, canonical
|
|
10
|
+
from .errors import ApprovalRequired, IntegrityError, KeyRequired, ValidationError
|
|
11
|
+
|
|
12
|
+
APPROVAL_DOMAIN = b"deeprem:review:v1\x00"
|
|
13
|
+
EVENT_DOMAIN = b"deeprem:event:v1\x00"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _imports():
|
|
17
|
+
try:
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
19
|
+
from cryptography.hazmat.primitives import serialization
|
|
20
|
+
return Ed25519PrivateKey, Ed25519PublicKey, serialization
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise KeyRequired("install deeprem[crypto] to use signing, review gates or encryption") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def b64(value: bytes) -> str:
|
|
26
|
+
return base64.urlsafe_b64encode(value).decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unb64(value: str) -> bytes:
|
|
30
|
+
try:
|
|
31
|
+
return base64.b64decode(value.encode("ascii"), altchars=b"-_", validate=True)
|
|
32
|
+
except (ValueError, UnicodeError, AttributeError) as exc:
|
|
33
|
+
raise ValidationError("invalid base64 key or signature") from exc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SigningKey:
|
|
37
|
+
"""Ed25519 private key. Keep reviewer keys outside the agent's OS identity."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, raw: bytes):
|
|
40
|
+
private, _, _ = _imports()
|
|
41
|
+
try:
|
|
42
|
+
self._key = private.from_private_bytes(raw)
|
|
43
|
+
except (TypeError, ValueError) as exc:
|
|
44
|
+
raise ValidationError("Ed25519 private keys must be 32 bytes") from exc
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def generate(cls) -> SigningKey:
|
|
48
|
+
private, _, serialization = _imports()
|
|
49
|
+
raw = private.generate().private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
|
|
50
|
+
return cls(raw)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def load(cls, path: str | Path) -> SigningKey:
|
|
54
|
+
private, _, serialization = _imports()
|
|
55
|
+
try:
|
|
56
|
+
key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
|
|
57
|
+
if not isinstance(key, private):
|
|
58
|
+
raise ValueError("not Ed25519")
|
|
59
|
+
return cls(key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()))
|
|
60
|
+
except (TypeError, ValueError) as exc:
|
|
61
|
+
raise ValidationError("expected an unencrypted Ed25519 PKCS8 PEM key") from exc
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def public_key(self) -> str:
|
|
65
|
+
_, _, serialization = _imports()
|
|
66
|
+
return b64(self._key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
|
|
67
|
+
|
|
68
|
+
def sign(self, data: bytes) -> str:
|
|
69
|
+
return b64(self._key.sign(data))
|
|
70
|
+
|
|
71
|
+
def save(self, path: str | Path) -> None:
|
|
72
|
+
"""Write a NEW 0600 PEM key file; never print private material or overwrite."""
|
|
73
|
+
_, _, serialization = _imports()
|
|
74
|
+
path = Path(path)
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
76
|
+
atomic_write(path, self._key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def verify_signature(public_key: str, signature: str, data: bytes) -> None:
|
|
80
|
+
_, public, _ = _imports()
|
|
81
|
+
try:
|
|
82
|
+
public.from_public_bytes(unb64(public_key)).verify(unb64(signature), data)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
# Import errors are handled before this block, invalid signatures fail closed.
|
|
85
|
+
raise IntegrityError("signature verification failed") from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_public_key(value: str) -> None:
|
|
89
|
+
_, public, _ = _imports()
|
|
90
|
+
try:
|
|
91
|
+
public.from_public_bytes(unb64(value))
|
|
92
|
+
except (ValueError, TypeError) as exc:
|
|
93
|
+
raise ValidationError("invalid Ed25519 public key") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sign_approval(request: dict[str, Any], reviewer: SigningKey) -> dict[str, Any]:
|
|
97
|
+
"""Run in an operator-controlled process, after reviewing the request/evidence."""
|
|
98
|
+
validate("approval-request", request)
|
|
99
|
+
return {"request": request, "signature": reviewer.sign(APPROVAL_DOMAIN + canonical(request))}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify_approval_signature(approval: dict, public_key: str | None) -> None:
|
|
103
|
+
if public_key is None:
|
|
104
|
+
raise ApprovalRequired("this store has no reviewer; protected transitions are disabled")
|
|
105
|
+
try:
|
|
106
|
+
validate("approval", approval)
|
|
107
|
+
verify_signature(public_key, approval["signature"], APPROVAL_DOMAIN + canonical(approval["request"]))
|
|
108
|
+
except (IntegrityError, ValidationError) as exc:
|
|
109
|
+
raise ApprovalRequired("independent reviewer signature is missing or invalid") from exc
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def generate_encryption_key() -> bytes:
|
|
113
|
+
try:
|
|
114
|
+
from cryptography.fernet import Fernet
|
|
115
|
+
return Fernet.generate_key()
|
|
116
|
+
except ImportError as exc:
|
|
117
|
+
raise KeyRequired("install deeprem[crypto] for encryption") from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Codec:
|
|
121
|
+
def __init__(self, encrypted: bool, key: bytes | None):
|
|
122
|
+
self.encrypted = encrypted
|
|
123
|
+
self._cipher = None
|
|
124
|
+
if encrypted:
|
|
125
|
+
if key is None:
|
|
126
|
+
raise KeyRequired("this store requires its external Fernet encryption key")
|
|
127
|
+
try:
|
|
128
|
+
from cryptography.fernet import Fernet
|
|
129
|
+
self._cipher = Fernet(key)
|
|
130
|
+
except ImportError as exc:
|
|
131
|
+
raise KeyRequired("install deeprem[crypto] for encryption") from exc
|
|
132
|
+
except (TypeError, ValueError) as exc:
|
|
133
|
+
raise ValidationError("invalid Fernet key; use generate_encryption_key()") from exc
|
|
134
|
+
elif key is not None:
|
|
135
|
+
raise ValidationError("cannot silently change an existing store's encryption mode")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def suffix(self) -> str:
|
|
139
|
+
return ".fernet" if self.encrypted else ".json"
|
|
140
|
+
|
|
141
|
+
def encode(self, data: bytes) -> bytes:
|
|
142
|
+
return self._cipher.encrypt(data) if self._cipher else data + b"\n"
|
|
143
|
+
|
|
144
|
+
def decode(self, data: bytes) -> bytes:
|
|
145
|
+
if self._cipher is None:
|
|
146
|
+
return data
|
|
147
|
+
from cryptography.fernet import InvalidToken
|
|
148
|
+
try:
|
|
149
|
+
return self._cipher.decrypt(data)
|
|
150
|
+
except InvalidToken as exc:
|
|
151
|
+
raise IntegrityError("encrypted event authentication failed (wrong key or changed ciphertext)") from exc
|