corrlog-core 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.
- corrlog_autogen/__init__.py +112 -0
- corrlog_claude_code/__init__.py +8 -0
- corrlog_claude_code/cli.py +147 -0
- corrlog_claude_code/hooks.json +16 -0
- corrlog_core/__init__.py +385 -0
- corrlog_core/schema/acr-v1.json +97 -0
- corrlog_core-0.1.0.dist-info/METADATA +136 -0
- corrlog_core-0.1.0.dist-info/RECORD +18 -0
- corrlog_core-0.1.0.dist-info/WHEEL +5 -0
- corrlog_core-0.1.0.dist-info/entry_points.txt +2 -0
- corrlog_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- corrlog_core-0.1.0.dist-info/top_level.txt +7 -0
- corrlog_crewai/__init__.py +125 -0
- corrlog_inspect/__init__.py +15 -0
- corrlog_inspect/_corrlog.py +121 -0
- corrlog_inspect/hooks.py +110 -0
- corrlog_langchain/__init__.py +155 -0
- corrlog_proofagent/__init__.py +202 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""corrlog-autogen — drop-in correction log for AutoGen agents.
|
|
2
|
+
|
|
3
|
+
AutoGen has NO merged tool-call middleware as of this build (verified against the
|
|
4
|
+
cloned source). The seams we need — `BaseTool.run_json()` / `Workbench.call_tool()` —
|
|
5
|
+
are exactly what the still-open proposals target (#7405 GuardrailProvider, #7353 AAR
|
|
6
|
+
receipts). So this adapter wraps `run_json` rather than relying on a shipped hook.
|
|
7
|
+
|
|
8
|
+
Import-safe: if autogen is not installed, `is_available()` returns False and the
|
|
9
|
+
wrapper class still imports.
|
|
10
|
+
|
|
11
|
+
Usage (when autogen is installed):
|
|
12
|
+
from corrlog_autogen import GuardedTool
|
|
13
|
+
tool = GuardedTool(my_autogen_tool, sink, private_key, agent_id="agent-1")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from corrlog_core import record, retract
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from autogen_core.tools import BaseTool # type: ignore
|
|
24
|
+
_AUTOGEN_AVAILABLE = True
|
|
25
|
+
except Exception: # pragma: no cover
|
|
26
|
+
_AUTOGEN_AVAILABLE = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_available() -> bool:
|
|
30
|
+
return _AUTOGEN_AVAILABLE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GuardedTool:
|
|
34
|
+
"""Wraps an AutoGen tool, adding before/after correction-logging around `run_json`.
|
|
35
|
+
|
|
36
|
+
Structured to match the proposed GuardrailProvider signature from #7405, so it
|
|
37
|
+
upgrades cleanly when that lands upstream.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, tool, sink, private_key, agent_id: str, principal_id: str = "organization") -> None:
|
|
41
|
+
self._tool = tool
|
|
42
|
+
self.sink = sink
|
|
43
|
+
self.private_key = private_key
|
|
44
|
+
self.agent_id = agent_id
|
|
45
|
+
self.principal_id = principal_id
|
|
46
|
+
|
|
47
|
+
def __getattr__(self, name: str):
|
|
48
|
+
# Delegate everything else to the wrapped tool.
|
|
49
|
+
return getattr(self._tool, name)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
return getattr(self._tool, "name", "unknown_tool")
|
|
54
|
+
|
|
55
|
+
async def run_json(self, args, cancellation_token=None, call_id=None):
|
|
56
|
+
tool_name = self.name
|
|
57
|
+
rec = record(
|
|
58
|
+
agent_id=self.agent_id,
|
|
59
|
+
principal_id=self.principal_id,
|
|
60
|
+
action_type=f"autogen.tool.{tool_name}",
|
|
61
|
+
action_args=args if isinstance(args, dict) else {"args": args},
|
|
62
|
+
private_key=self.private_key,
|
|
63
|
+
metadata={"call_id": call_id} if call_id else None,
|
|
64
|
+
)
|
|
65
|
+
self.sink.append(rec)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
result = await self._tool.run_json(args, cancellation_token, call_id)
|
|
69
|
+
except Exception as e: # noqa: BLE001
|
|
70
|
+
corr = retract(
|
|
71
|
+
prior_record=rec,
|
|
72
|
+
reason=f"tool raised: {e}",
|
|
73
|
+
trigger="check_failed",
|
|
74
|
+
agent_id=self.agent_id,
|
|
75
|
+
private_key=self.private_key,
|
|
76
|
+
principal_id=self.principal_id,
|
|
77
|
+
fix_type="replace",
|
|
78
|
+
fix_note=f"autogen tool {tool_name} raised",
|
|
79
|
+
)
|
|
80
|
+
self.sink.append(corr)
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
# Result-level error detection (best-effort, framework-dependent shape).
|
|
84
|
+
if isinstance(result, dict) and result.get("error"):
|
|
85
|
+
corr = retract(
|
|
86
|
+
prior_record=rec,
|
|
87
|
+
reason=str(result["error"]),
|
|
88
|
+
trigger="check_failed",
|
|
89
|
+
agent_id=self.agent_id,
|
|
90
|
+
private_key=self.private_key,
|
|
91
|
+
principal_id=self.principal_id,
|
|
92
|
+
fix_type="replace",
|
|
93
|
+
fix_note=f"autogen tool {tool_name} errored",
|
|
94
|
+
)
|
|
95
|
+
self.sink.append(corr)
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
def mark_wrong(self, prior_record: dict[str, Any], reason: str, fix_note: str | None = None) -> dict[str, Any]:
|
|
100
|
+
"""Human-flag a prior record as wrong -> signed correction."""
|
|
101
|
+
corr = retract(
|
|
102
|
+
prior_record=prior_record,
|
|
103
|
+
reason=reason,
|
|
104
|
+
trigger="human_flagged",
|
|
105
|
+
agent_id=self.agent_id,
|
|
106
|
+
private_key=self.private_key,
|
|
107
|
+
principal_id=self.principal_id,
|
|
108
|
+
fix_type="replace",
|
|
109
|
+
fix_note=fix_note,
|
|
110
|
+
)
|
|
111
|
+
self.sink.append(corr)
|
|
112
|
+
return corr
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""corrlog-claude-code — CLI for Claude Code hooks.
|
|
2
|
+
|
|
3
|
+
Invoked by hooks/hooks.json as an external process. Reads the hook's stdin JSON,
|
|
4
|
+
emits a signed correction-log record.
|
|
5
|
+
|
|
6
|
+
Modes:
|
|
7
|
+
- PreToolUse : hash the tool input, mint an action id (emit to stderr-free stdout)
|
|
8
|
+
- PostToolUse : hash the tool result, sign the action record
|
|
9
|
+
- human-flag : `corrlog human-flag --receipt <id> --reason "..."` to flag wrong
|
|
10
|
+
|
|
11
|
+
The key lives in $CORRLOG_KEY_PATH (a PEM/raw Ed25519 private key file).
|
|
12
|
+
Records append to $CORRLOG_SINK (JSONL path) or ./corrections.jsonl.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
23
|
+
|
|
24
|
+
from corrlog_core import generate_keypair, record, retract, JsonlSink
|
|
25
|
+
from cryptography.hazmat.primitives import serialization
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_or_create_key(path: str):
|
|
30
|
+
if path and os.path.exists(path):
|
|
31
|
+
with open(path, "rb") as f:
|
|
32
|
+
return serialization.load_pem_private_key(f.read(), password=None)
|
|
33
|
+
priv = ed25519.Ed25519PrivateKey.generate()
|
|
34
|
+
if path:
|
|
35
|
+
with open(path, "wb") as f:
|
|
36
|
+
f.write(priv.private_bytes(
|
|
37
|
+
serialization.Encoding.PEM,
|
|
38
|
+
serialization.PrivateFormat.PKCS8,
|
|
39
|
+
serialization.NoEncryption(),
|
|
40
|
+
))
|
|
41
|
+
return priv
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _sink_path() -> str:
|
|
45
|
+
return os.environ.get("CORRLOG_SINK", os.path.join(os.getcwd(), "corrections.jsonl"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _key_path() -> str:
|
|
49
|
+
return os.environ.get("CORRLOG_KEY_PATH", os.path.join(os.getcwd(), "corrlog_key.pem"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _agent_id() -> str:
|
|
53
|
+
return os.environ.get("CORRLOG_AGENT_ID", "claude-code-agent")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
58
|
+
|
|
59
|
+
# Subcommand: human-flag
|
|
60
|
+
if argv and argv[0] == "human-flag":
|
|
61
|
+
p = argparse.ArgumentParser(prog="corrlog human-flag")
|
|
62
|
+
p.add_argument("--receipt", required=True)
|
|
63
|
+
p.add_argument("--reason", required=True)
|
|
64
|
+
p.add_argument("--fix-note")
|
|
65
|
+
args = p.parse_args(argv[1:])
|
|
66
|
+
priv = _load_or_create_key(_key_path())
|
|
67
|
+
sink = JsonlSink(_sink_path())
|
|
68
|
+
# Find the prior record.
|
|
69
|
+
prior = sink.get(args.receipt)
|
|
70
|
+
if prior is None:
|
|
71
|
+
print(json.dumps({"ok": False, "error": f"receipt {args.receipt} not found"}), file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
corr = retract(
|
|
74
|
+
prior_record=prior, reason=args.reason, trigger="human_flagged",
|
|
75
|
+
agent_id=_agent_id(), private_key=priv, fix_type="replace", fix_note=args.fix_note,
|
|
76
|
+
)
|
|
77
|
+
sink.append(corr)
|
|
78
|
+
print(json.dumps({"ok": True, "correctionId": corr["correctionId"]}))
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
# Hook mode: read stdin JSON (Claude Code hook contract).
|
|
82
|
+
try:
|
|
83
|
+
data = json.load(sys.stdin)
|
|
84
|
+
except Exception:
|
|
85
|
+
data = {}
|
|
86
|
+
|
|
87
|
+
hook = data.get("hook_event_name", "")
|
|
88
|
+
tool_name = data.get("tool_name", "")
|
|
89
|
+
tool_input = data.get("tool_input", {})
|
|
90
|
+
|
|
91
|
+
priv = _load_or_create_key(_key_path())
|
|
92
|
+
sink = JsonlSink(_sink_path())
|
|
93
|
+
|
|
94
|
+
if hook == "PreToolUse":
|
|
95
|
+
rec = record(
|
|
96
|
+
agent_id=_agent_id(), action_type=f"claude.tool.{tool_name}",
|
|
97
|
+
action_args=tool_input if isinstance(tool_input, dict) else {"input": tool_input},
|
|
98
|
+
private_key=priv,
|
|
99
|
+
)
|
|
100
|
+
sink.append(rec)
|
|
101
|
+
# Allow the call; stash the action id in a sidecar file for PostToolUse.
|
|
102
|
+
_sidecar = os.path.join(os.getcwd(), ".corrlog_inflight.json")
|
|
103
|
+
try:
|
|
104
|
+
inflight = json.load(open(_sidecar)) if os.path.exists(_sidecar) else {}
|
|
105
|
+
inflight[tool_name] = rec["correctionId"]
|
|
106
|
+
json.dump(inflight, open(_sidecar, "w"))
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
print(json.dumps({"continue": True}))
|
|
110
|
+
|
|
111
|
+
elif hook == "PostToolUse":
|
|
112
|
+
tool_result = data.get("tool_result", "")
|
|
113
|
+
_sidecar = os.path.join(os.getcwd(), ".corrlog_inflight.json")
|
|
114
|
+
prior_id = None
|
|
115
|
+
try:
|
|
116
|
+
inflight = json.load(open(_sidecar)) if os.path.exists(_sidecar) else {}
|
|
117
|
+
prior_id = inflight.pop(tool_name, None)
|
|
118
|
+
json.dump(inflight, open(_sidecar, "w"))
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
rec = record(
|
|
122
|
+
agent_id=_agent_id(), action_type=f"claude.tool.{tool_name}",
|
|
123
|
+
action_args=tool_input if isinstance(tool_input, dict) else {"input": tool_input},
|
|
124
|
+
action_result=tool_result, private_key=priv,
|
|
125
|
+
)
|
|
126
|
+
sink.append(rec)
|
|
127
|
+
# Detect error in result -> check_failed correction.
|
|
128
|
+
if isinstance(tool_result, str) and any(
|
|
129
|
+
m in tool_result.lower() for m in ("error", "failed", "exception", "denied")
|
|
130
|
+
):
|
|
131
|
+
corr = retract(
|
|
132
|
+
prior_record=rec, reason="tool returned error", trigger="check_failed",
|
|
133
|
+
agent_id=_agent_id(), private_key=priv, fix_type="replace",
|
|
134
|
+
fix_note=f"claude tool {tool_name} errored",
|
|
135
|
+
)
|
|
136
|
+
sink.append(corr)
|
|
137
|
+
print(json.dumps({"continue": True}))
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
# Unhandled hook event: no-op, allow.
|
|
141
|
+
print(json.dumps({"continue": True}))
|
|
142
|
+
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
corrlog_core/__init__.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""corrlog-core — the Agent Correction Record (ACR) core.
|
|
2
|
+
|
|
3
|
+
Framework-independent implementation of the ACR v1.0 spec (see SPEC.md):
|
|
4
|
+
sign, verify, record, retract. Ed25519 over RFC 8785 (JCS) canonical JSON.
|
|
5
|
+
|
|
6
|
+
The only runtime dependency is `cryptography` (for Ed25519).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
from cryptography.exceptions import InvalidSignature
|
|
19
|
+
from cryptography.hazmat.primitives import serialization
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
23
|
+
|
|
24
|
+
CANONICALIZATION = "RFC8785"
|
|
25
|
+
VALID_TRIGGERS = ("supersede", "check_failed", "human_flagged", "self_correction")
|
|
26
|
+
VALID_FIX_TYPES = ("replace", "delete", "rollback", "noop", "other")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Canonical JSON per RFC 8785 (JCS — JSON Canonicalization Scheme)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
def canonical_json(obj: dict[str, Any]) -> bytes:
|
|
33
|
+
"""Return RFC 8785 (JCS) canonical bytes.
|
|
34
|
+
|
|
35
|
+
RFC 8785 requires: recursive key sort by UTF-16 code unit order, no
|
|
36
|
+
insignificant whitespace, and specific string escaping — control
|
|
37
|
+
characters and non-ASCII MUST be escaped as ``\\uXXXX``/surrogate pairs so
|
|
38
|
+
every conforming implementation (Python, Rust, Go, TS) produces
|
|
39
|
+
byte-identical output. Python's ``json.dumps`` with ``ensure_ascii=True``
|
|
40
|
+
and ``separators=(",", ":")`` satisfies this for strings; numbers MUST be
|
|
41
|
+
serialized per RFC 8785 §3.2.3 (ES6 Number::toString) — this reference
|
|
42
|
+
avoids the float ambiguity by representing non-integer values as strings
|
|
43
|
+
(see `record`/`retract`), matching AAR's practice.
|
|
44
|
+
"""
|
|
45
|
+
def _sorted(o: Any) -> Any:
|
|
46
|
+
if isinstance(o, dict):
|
|
47
|
+
# sort_keys sorts by Unicode code point, which matches UTF-16
|
|
48
|
+
# code unit order for the BMP (all keys here are ASCII-safe).
|
|
49
|
+
return {k: _sorted(v) for k, v in sorted(o.items())}
|
|
50
|
+
if isinstance(o, list):
|
|
51
|
+
return [_sorted(v) for v in o]
|
|
52
|
+
return o
|
|
53
|
+
|
|
54
|
+
return json.dumps(
|
|
55
|
+
_sorted(obj), separators=(",", ":"), ensure_ascii=True, sort_keys=True
|
|
56
|
+
).encode("utf-8")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Key helpers
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
def generate_keypair() -> tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
|
|
63
|
+
"""Return (private_key, public_key) as cryptography Ed25519 objects."""
|
|
64
|
+
priv = ed25519.Ed25519PrivateKey.generate()
|
|
65
|
+
return priv, priv.public_key()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def public_key_b64url(pub: ed25519.Ed25519PublicKey) -> str:
|
|
69
|
+
"""Base64url (no padding) encoding of the raw Ed25519 public key bytes."""
|
|
70
|
+
raw = pub.public_bytes(
|
|
71
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
72
|
+
)
|
|
73
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_private_key(seed: str) -> ed25519.Ed25519PrivateKey:
|
|
77
|
+
"""Load an Ed25519 private key from a 32-byte seed string.
|
|
78
|
+
|
|
79
|
+
Accepts the seed as base64url (no padding) or hex. This is the loading
|
|
80
|
+
counterpart to ``generate_keypair`` — adapters use it to turn an operator's
|
|
81
|
+
secret (from an env var) into a signing key without inventing their own
|
|
82
|
+
serialization.
|
|
83
|
+
"""
|
|
84
|
+
s = seed.strip()
|
|
85
|
+
if s.startswith(("sk_", "priv_", "ed25519:")):
|
|
86
|
+
# allow a small prefix for readability; strip it
|
|
87
|
+
s = s.split(":", 1)[-1] if ":" in s else s[2:] if s.startswith("sk_") else s[5:]
|
|
88
|
+
try:
|
|
89
|
+
raw = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
90
|
+
except Exception:
|
|
91
|
+
raw = b""
|
|
92
|
+
if len(raw) != 32:
|
|
93
|
+
# fall back to hex
|
|
94
|
+
try:
|
|
95
|
+
raw = bytes.fromhex(s)
|
|
96
|
+
except Exception:
|
|
97
|
+
raw = b""
|
|
98
|
+
if len(raw) != 32:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"seed must decode to exactly 32 bytes (base64url or hex)"
|
|
101
|
+
)
|
|
102
|
+
return ed25519.Ed25519PrivateKey.from_private_bytes(raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Record construction
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
def _hash_object(data: bytes) -> dict[str, str]:
|
|
109
|
+
return {"alg": "sha256", "digest": _b64url(hashlib.sha256(data).digest())}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _b64url(data: bytes) -> str:
|
|
113
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def record(
|
|
117
|
+
*,
|
|
118
|
+
agent_id: str,
|
|
119
|
+
agent_name: str | None = None,
|
|
120
|
+
principal_id: str = "unknown",
|
|
121
|
+
principal_type: str = "organization",
|
|
122
|
+
action_type: str,
|
|
123
|
+
action_target: str | None = None,
|
|
124
|
+
action_args: Any = None,
|
|
125
|
+
action_result: Any = None,
|
|
126
|
+
private_key: ed25519.Ed25519PrivateKey,
|
|
127
|
+
kid: str | None = None,
|
|
128
|
+
metadata: dict[str, Any] | None = None,
|
|
129
|
+
correction_id: str | None = None,
|
|
130
|
+
timestamp: str | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Build a signed ACR-compatible *action* record (the receipt a correction can supersede).
|
|
133
|
+
|
|
134
|
+
This is the lightweight action record; corrections are built with `retract`.
|
|
135
|
+
|
|
136
|
+
``correction_id`` and ``timestamp`` are OPTIONAL and exist for deterministic
|
|
137
|
+
test vectors (reproducible records, cross-implementation verification).
|
|
138
|
+
When omitted they default to a fresh UUID / current UTC time.
|
|
139
|
+
"""
|
|
140
|
+
ts = timestamp if timestamp is not None else datetime.now(timezone.utc).isoformat()
|
|
141
|
+
public_key = private_key.public_key()
|
|
142
|
+
pk_b64 = public_key_b64url(public_key)
|
|
143
|
+
if kid is None:
|
|
144
|
+
kid = pk_b64
|
|
145
|
+
|
|
146
|
+
body: dict[str, Any] = {
|
|
147
|
+
"correctionId": correction_id if correction_id is not None else str(uuid.uuid4()),
|
|
148
|
+
"agent": {"id": agent_id, "publicKey": pk_b64},
|
|
149
|
+
"principal": {"id": principal_id, "type": principal_type},
|
|
150
|
+
"action": {"type": action_type},
|
|
151
|
+
"reason": "",
|
|
152
|
+
"trigger": "supersede", # placeholder; corrected on retract
|
|
153
|
+
"fix": {"type": "noop"},
|
|
154
|
+
"timestamp": ts,
|
|
155
|
+
"metadata": metadata or {},
|
|
156
|
+
}
|
|
157
|
+
if agent_name:
|
|
158
|
+
body["agent"]["name"] = agent_name
|
|
159
|
+
if action_target:
|
|
160
|
+
body["action"]["target"] = action_target
|
|
161
|
+
|
|
162
|
+
# Store hashes of args/result (privacy-preserving) in metadata.
|
|
163
|
+
if action_args is not None:
|
|
164
|
+
body["metadata"]["inputHash"] = _hash_object(
|
|
165
|
+
canonical_json(action_args) if isinstance(action_args, dict) else str(action_args).encode()
|
|
166
|
+
)
|
|
167
|
+
if action_result is not None:
|
|
168
|
+
body["metadata"]["outputHash"] = _hash_object(
|
|
169
|
+
canonical_json(action_result) if isinstance(action_result, dict) else str(action_result).encode()
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
_sign(body, private_key, kid)
|
|
173
|
+
return body
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def retract(
|
|
177
|
+
*,
|
|
178
|
+
prior_record: dict[str, Any],
|
|
179
|
+
reason: str,
|
|
180
|
+
trigger: str,
|
|
181
|
+
agent_id: str,
|
|
182
|
+
private_key: ed25519.Ed25519PrivateKey,
|
|
183
|
+
principal_id: str = "unknown",
|
|
184
|
+
principal_type: str = "organization",
|
|
185
|
+
fix_type: str = "replace",
|
|
186
|
+
fix_note: str | None = None,
|
|
187
|
+
corrected_content: Any = None,
|
|
188
|
+
kid: str | None = None,
|
|
189
|
+
metadata: dict[str, Any] | None = None,
|
|
190
|
+
correction_id: str | None = None,
|
|
191
|
+
timestamp: str | None = None,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Build a signed correction record that supersedes `prior_record`.
|
|
194
|
+
|
|
195
|
+
`prior_record` is the action record (or prior correction) being amended.
|
|
196
|
+
The supersedes pointer is a SHA-256 content hash of the prior record's
|
|
197
|
+
canonical bytes — not just an id — so the chain is verifiable offline.
|
|
198
|
+
|
|
199
|
+
``correction_id`` and ``timestamp`` are OPTIONAL and exist for deterministic
|
|
200
|
+
test vectors (reproducible records, cross-implementation verification).
|
|
201
|
+
"""
|
|
202
|
+
if trigger not in VALID_TRIGGERS:
|
|
203
|
+
raise ValueError(f"trigger must be one of {VALID_TRIGGERS}, got {trigger!r}")
|
|
204
|
+
if fix_type not in VALID_FIX_TYPES:
|
|
205
|
+
raise ValueError(f"fix.type must be one of {VALID_FIX_TYPES}, got {fix_type!r}")
|
|
206
|
+
|
|
207
|
+
ts = timestamp if timestamp is not None else datetime.now(timezone.utc).isoformat()
|
|
208
|
+
public_key = private_key.public_key()
|
|
209
|
+
pk_b64 = public_key_b64url(public_key)
|
|
210
|
+
if kid is None:
|
|
211
|
+
kid = pk_b64
|
|
212
|
+
|
|
213
|
+
prior_digest = _hash_object(canonical_json(prior_record))
|
|
214
|
+
|
|
215
|
+
body: dict[str, Any] = {
|
|
216
|
+
"correctionId": correction_id if correction_id is not None else str(uuid.uuid4()),
|
|
217
|
+
"agent": {"id": agent_id, "publicKey": pk_b64},
|
|
218
|
+
"principal": {"id": principal_id, "type": principal_type},
|
|
219
|
+
"supersedes": {
|
|
220
|
+
"receiptId": prior_record.get("correctionId") or prior_record.get("receiptId"),
|
|
221
|
+
"digest": prior_digest,
|
|
222
|
+
},
|
|
223
|
+
"action": {"type": prior_record.get("action", {}).get("type", "unknown")},
|
|
224
|
+
"reason": reason,
|
|
225
|
+
"trigger": trigger,
|
|
226
|
+
"fix": {"type": fix_type},
|
|
227
|
+
"timestamp": ts,
|
|
228
|
+
"metadata": metadata or {},
|
|
229
|
+
}
|
|
230
|
+
if fix_note:
|
|
231
|
+
body["fix"]["note"] = fix_note
|
|
232
|
+
if corrected_content is not None:
|
|
233
|
+
body["fix"]["contentHash"] = _hash_object(
|
|
234
|
+
canonical_json(corrected_content)
|
|
235
|
+
if isinstance(corrected_content, dict)
|
|
236
|
+
else str(corrected_content).encode()
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
_sign(body, private_key, kid)
|
|
240
|
+
return body
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _sign(body: dict[str, Any], private_key: ed25519.Ed25519PrivateKey, kid: str) -> None:
|
|
244
|
+
body["signature"] = {
|
|
245
|
+
"alg": "Ed25519",
|
|
246
|
+
"kid": kid,
|
|
247
|
+
"publicKey": public_key_b64url(private_key.public_key()),
|
|
248
|
+
"canonicalization": CANONICALIZATION,
|
|
249
|
+
"sig": "",
|
|
250
|
+
}
|
|
251
|
+
# Canonicalize with sig removed (empty), then sign.
|
|
252
|
+
sig_bytes = private_key.sign(canonical_json(body))
|
|
253
|
+
body["signature"]["sig"] = _b64url(sig_bytes)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Verification
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
def verify(record: dict[str, Any], public_key: ed25519.Ed25519PublicKey | None = None) -> bool:
|
|
260
|
+
"""Verify an ACR record's Ed25519 signature.
|
|
261
|
+
|
|
262
|
+
If `public_key` is None, the key embedded in `record["signature"]["publicKey"]`
|
|
263
|
+
is used (self-authenticating). Pass a key explicitly to pin trust to a known key.
|
|
264
|
+
"""
|
|
265
|
+
sig_meta = record.get("signature") or {}
|
|
266
|
+
if sig_meta.get("alg") != "Ed25519":
|
|
267
|
+
return False
|
|
268
|
+
if sig_meta.get("canonicalization") != CANONICALIZATION:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
sig_b64 = sig_meta.get("sig") or ""
|
|
272
|
+
if not sig_b64:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
sig_bytes = base64.urlsafe_b64decode(sig_b64 + "=" * (-len(sig_b64) % 4))
|
|
277
|
+
except Exception:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
if public_key is None:
|
|
281
|
+
pk_b64 = sig_meta.get("publicKey") or record.get("agent", {}).get("publicKey")
|
|
282
|
+
if not pk_b64:
|
|
283
|
+
return False
|
|
284
|
+
try:
|
|
285
|
+
raw = base64.urlsafe_b64decode(pk_b64 + "=" * (-len(pk_b64) % 4))
|
|
286
|
+
public_key = ed25519.Ed25519PublicKey.from_public_bytes(raw)
|
|
287
|
+
except Exception:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
# Re-canonicalize with sig removed.
|
|
291
|
+
payload = {k: v for k, v in record.items() if k != "signature"}
|
|
292
|
+
payload["signature"] = {**sig_meta, "sig": ""}
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
public_key.verify(sig_bytes, canonical_json(payload))
|
|
296
|
+
return True
|
|
297
|
+
except InvalidSignature:
|
|
298
|
+
return False
|
|
299
|
+
except Exception:
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def verify_chain(records: list[dict[str, Any]], public_key: ed25519.Ed25519PublicKey | None = None) -> bool:
|
|
304
|
+
"""Verify a chain of records: each signature valid, and each correction's
|
|
305
|
+
`supersedes.digest` matches the canonical bytes of the prior record."""
|
|
306
|
+
for i, rec in enumerate(records):
|
|
307
|
+
if not verify(rec, public_key):
|
|
308
|
+
return False
|
|
309
|
+
if i == 0:
|
|
310
|
+
continue
|
|
311
|
+
sup = rec.get("supersedes")
|
|
312
|
+
if not sup:
|
|
313
|
+
# A chain where a later record doesn't supersede is broken.
|
|
314
|
+
return False
|
|
315
|
+
expected_digest = sup.get("digest", {}).get("digest")
|
|
316
|
+
if not expected_digest:
|
|
317
|
+
return False
|
|
318
|
+
actual_digest = _b64url(hashlib.sha256(canonical_json(records[i - 1])).digest())
|
|
319
|
+
if actual_digest != expected_digest:
|
|
320
|
+
return False
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def tamper_evident(record: dict[str, Any]) -> bool:
|
|
325
|
+
"""True if any mutation of the signed body invalidates the signature."""
|
|
326
|
+
# A record is tamper-evident by construction; this checks the signature
|
|
327
|
+
# still holds for the current body (i.e. it has NOT been mutated).
|
|
328
|
+
return verify(record)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
# Pluggable sink
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
class Sink(Protocol):
|
|
335
|
+
def append(self, record: dict[str, Any]) -> None: ...
|
|
336
|
+
def get(self, correction_id: str) -> dict[str, Any] | None: ...
|
|
337
|
+
def all(self) -> list[dict[str, Any]]: ...
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class MemorySink:
|
|
341
|
+
"""In-memory append-only sink (for demos/tests)."""
|
|
342
|
+
|
|
343
|
+
def __init__(self) -> None:
|
|
344
|
+
self._records: list[dict[str, Any]] = []
|
|
345
|
+
|
|
346
|
+
def append(self, record: dict[str, Any]) -> None:
|
|
347
|
+
self._records.append(record)
|
|
348
|
+
|
|
349
|
+
def get(self, correction_id: str) -> dict[str, Any] | None:
|
|
350
|
+
for r in self._records:
|
|
351
|
+
if r.get("correctionId") == correction_id:
|
|
352
|
+
return r
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def all(self) -> list[dict[str, Any]]:
|
|
356
|
+
return list(self._records)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class JsonlSink:
|
|
360
|
+
"""Append-only JSONL file sink — durable, hash-chained by file order."""
|
|
361
|
+
|
|
362
|
+
def __init__(self, path: str) -> None:
|
|
363
|
+
self.path = path
|
|
364
|
+
|
|
365
|
+
def append(self, record: dict[str, Any]) -> None:
|
|
366
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
367
|
+
f.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
368
|
+
|
|
369
|
+
def get(self, correction_id: str) -> dict[str, Any] | None:
|
|
370
|
+
for r in self.all():
|
|
371
|
+
if r.get("correctionId") == correction_id:
|
|
372
|
+
return r
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
def all(self) -> list[dict[str, Any]]:
|
|
376
|
+
out: list[dict[str, Any]] = []
|
|
377
|
+
try:
|
|
378
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
379
|
+
for line in f:
|
|
380
|
+
line = line.strip()
|
|
381
|
+
if line:
|
|
382
|
+
out.append(json.loads(line))
|
|
383
|
+
except FileNotFoundError:
|
|
384
|
+
pass
|
|
385
|
+
return out
|