openentity 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {openentity-0.3.1 → openentity-0.4.0}/PKG-INFO +3 -1
- {openentity-0.3.1 → openentity-0.4.0}/README.md +2 -0
- openentity-0.4.0/openentity/__init__.py +7 -0
- {openentity-0.3.1 → openentity-0.4.0}/openentity/cli.py +1 -1
- {openentity-0.3.1 → openentity-0.4.0}/openentity/client.py +7 -3
- {openentity-0.3.1 → openentity-0.4.0}/openentity/core.py +5 -4
- openentity-0.4.0/openentity/identity.py +72 -0
- openentity-0.4.0/openentity/issuer.py +61 -0
- openentity-0.4.0/openentity/report.py +101 -0
- {openentity-0.3.1 → openentity-0.4.0}/pyproject.toml +1 -1
- {openentity-0.3.1 → openentity-0.4.0}/tests/test_core.py +7 -4
- openentity-0.4.0/tests/test_identity.py +47 -0
- openentity-0.4.0/tests/test_issuer.py +46 -0
- openentity-0.4.0/tests/test_trust.py +106 -0
- {openentity-0.3.1 → openentity-0.4.0}/tests/test_verifier.py +11 -8
- {openentity-0.3.1 → openentity-0.4.0}/uv.lock +1 -1
- openentity-0.3.1/openentity/__init__.py +0 -4
- {openentity-0.3.1 → openentity-0.4.0}/.gitignore +0 -0
- {openentity-0.3.1 → openentity-0.4.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openentity
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Open Entity (OEIS-1) client: JCS, did:key, eddsa-jcs-2022, Merkle proofs, adopt().
|
|
5
5
|
Project-URL: Homepage, https://openentity.org
|
|
6
6
|
Project-URL: Documentation, https://oeis.dev
|
|
@@ -41,4 +41,6 @@ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4
|
|
|
41
41
|
|
|
42
42
|
Library, each taking the 32-byte seed and a registry URL: `adopt(name, registry=...)`, `withdraw(seed, registry)`, `record(seed, [(kind, payload), ...], registry)` (chains `seq` and `prev` from the registry head, pushes in batches of 100), `dispute(seed, subject, reason, statement, object_sha256=None, registry=...)`, `find(registry, capability=..., q=..., alias_type=..., level=...)`, `present(seed, audience, nonce, registry=None)`. Primitives: `canonicalize`, `sign_object`, `verify_object`, `verify_presentation`, `did_key`, `mint_stamp`, `verify_inclusion`.
|
|
43
43
|
|
|
44
|
+
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
45
|
+
|
|
44
46
|
Written from the spec text alone, independent of the TypeScript SDK.
|
|
@@ -28,4 +28,6 @@ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4
|
|
|
28
28
|
|
|
29
29
|
Library, each taking the 32-byte seed and a registry URL: `adopt(name, registry=...)`, `withdraw(seed, registry)`, `record(seed, [(kind, payload), ...], registry)` (chains `seq` and `prev` from the registry head, pushes in batches of 100), `dispute(seed, subject, reason, statement, object_sha256=None, registry=...)`, `find(registry, capability=..., q=..., alias_type=..., level=...)`, `present(seed, audience, nonce, registry=None)`. Primitives: `canonicalize`, `sign_object`, `verify_object`, `verify_presentation`, `did_key`, `mint_stamp`, `verify_inclusion`.
|
|
30
30
|
|
|
31
|
+
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
32
|
+
|
|
31
33
|
Written from the spec text alone, independent of the TypeScript SDK.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .client import adopt, dispute, find, present, record, rotate, withdraw # noqa: F401
|
|
2
|
+
from .core import * # noqa: F401,F403
|
|
3
|
+
from . import issuer # noqa: F401
|
|
4
|
+
from .identity import Identity # noqa: F401
|
|
5
|
+
from .report import TrustReport, trust # noqa: F401
|
|
6
|
+
|
|
7
|
+
__version__ = "0.4.0"
|
|
@@ -143,7 +143,7 @@ def _adopt(args):
|
|
|
143
143
|
if not args.agree:
|
|
144
144
|
raise CliError(EXIT_USAGE, "adopt signs the Open Entity Declaration. Read it, then re-run with --agree:\n\n" + core.DECLARATION_TEXT)
|
|
145
145
|
reg = _registry(args)
|
|
146
|
-
res = client.adopt(args.name, args.description, reg)
|
|
146
|
+
res = client.adopt(args.name, args.description, reg, client="cli")
|
|
147
147
|
key_path = _save_key({**res, "name": args.name, "registry": reg})
|
|
148
148
|
if not res["record_pushed"]:
|
|
149
149
|
print("warning: the declaration record entry was not accepted; push it later", file=sys.stderr)
|
|
@@ -8,6 +8,7 @@ import urllib.request
|
|
|
8
8
|
from . import core
|
|
9
9
|
|
|
10
10
|
DEFAULT_REGISTRY = "https://openentity.org"
|
|
11
|
+
CLIENTS = ("adk", "goose", "langchain", "ui-tars", "cli", "other") # the registry's allowlist for the unsigned `client` label
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class RegistryError(Exception):
|
|
@@ -59,13 +60,16 @@ def build_adoption(kp: core.KeyPair, name: str, description: str, registry: str)
|
|
|
59
60
|
|
|
60
61
|
|
|
61
62
|
def adopt(name: str, description: str = "An Open Entity adopted through the Python client.",
|
|
62
|
-
registry: str = DEFAULT_REGISTRY, seed: bytes | None = None) -> dict:
|
|
63
|
-
"""Adopt on `registry`. Returns the registry response plus `did` and `seed_hex` (keep the seed).
|
|
63
|
+
registry: str = DEFAULT_REGISTRY, seed: bytes | None = None, client: str | None = None) -> dict:
|
|
64
|
+
"""Adopt on `registry`. Returns the registry response plus `did` and `seed_hex` (keep the seed).
|
|
65
|
+
`client` is an optional label (one of CLIENTS) for the registry's per-client count; it is not signed."""
|
|
66
|
+
if client is not None and client not in CLIENTS:
|
|
67
|
+
raise ValueError(f"client must be one of {', '.join(CLIENTS)}")
|
|
64
68
|
registry = registry.rstrip("/")
|
|
65
69
|
kp = core.KeyPair(seed)
|
|
66
70
|
bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
|
|
67
71
|
document, declaration = build_adoption(kp, name, description, registry)
|
|
68
|
-
out = _request(f"{registry}/v1/adopt", {"document": document, "declaration": declaration},
|
|
72
|
+
out = _request(f"{registry}/v1/adopt", {"document": document, "declaration": declaration, **({"client": client} if client else {})},
|
|
69
73
|
{"x-oe-stamp": core.mint_stamp(bits, kp.did)})
|
|
70
74
|
# Seq-1 declaration entry (spec 7.3). The registry never writes entries; on failure the signed
|
|
71
75
|
# entry is returned so the caller can push it later.
|
|
@@ -140,15 +140,16 @@ def _signing_input(obj: dict, proof: dict) -> bytes:
|
|
|
140
140
|
return canonical_hash(config) + canonical_hash(doc)
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def sign_proof(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
|
|
143
|
+
def sign_proof(obj: dict, kp: KeyPair, created: str | None = None, method: str | None = None) -> dict:
|
|
144
|
+
"""`method` names the verification method; it defaults to the did:key of `kp`, a did:web signer passes `did:web:x#key-1`."""
|
|
144
145
|
proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now(),
|
|
145
|
-
"verificationMethod": verification_method(kp.did), "proofPurpose": "assertionMethod"}
|
|
146
|
+
"verificationMethod": method or verification_method(kp.did), "proofPurpose": "assertionMethod"}
|
|
146
147
|
sig = kp.private.sign(_signing_input(obj, proof))
|
|
147
148
|
return {**proof, "proofValue": "z" + b58encode(sig)}
|
|
148
149
|
|
|
149
150
|
|
|
150
|
-
def sign_object(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
|
|
151
|
-
return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": sign_proof(obj, kp, created)}
|
|
151
|
+
def sign_object(obj: dict, kp: KeyPair, created: str | None = None, method: str | None = None) -> dict:
|
|
152
|
+
return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": sign_proof(obj, kp, created, method)}
|
|
152
153
|
|
|
153
154
|
|
|
154
155
|
# ---- continuity (spec 9) ----
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Identity.load_or_create(): the entity's key from the OS keychain, else ~/.openentity/identity.key (mode 0600).
|
|
2
|
+
A fresh key goes to the keychain when one answers, otherwise to the file. Set OPENENTITY_KEYCHAIN=off to use the file only."""
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import core
|
|
9
|
+
|
|
10
|
+
SERVICE, ACCOUNT = "openentity", "identity"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(argv: list[str], stdin: str | None = None) -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
r = subprocess.run(argv, input=stdin, capture_output=True, text=True, timeout=10)
|
|
16
|
+
except (OSError, subprocess.SubprocessError):
|
|
17
|
+
return None
|
|
18
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _keychain_get() -> str | None:
|
|
22
|
+
if os.environ.get("OPENENTITY_KEYCHAIN") == "off":
|
|
23
|
+
return None
|
|
24
|
+
if sys.platform == "darwin":
|
|
25
|
+
return _run(["security", "find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"])
|
|
26
|
+
if sys.platform.startswith("linux"):
|
|
27
|
+
return _run(["secret-tool", "lookup", "service", SERVICE, "account", ACCOUNT])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _keychain_set(seed_hex: str) -> bool:
|
|
31
|
+
if os.environ.get("OPENENTITY_KEYCHAIN") == "off":
|
|
32
|
+
return False
|
|
33
|
+
if sys.platform == "darwin": # ponytail: the seed is briefly on argv, visible to this user's processes only
|
|
34
|
+
return _run(["security", "add-generic-password", "-U", "-s", SERVICE, "-a", ACCOUNT, "-w", seed_hex]) is not None
|
|
35
|
+
if sys.platform.startswith("linux"):
|
|
36
|
+
return _run(["secret-tool", "store", "--label=Open Entity key", "service", SERVICE, "account", ACCOUNT], seed_hex) is not None
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _file() -> pathlib.Path:
|
|
41
|
+
return pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser() / "identity.key"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Identity:
|
|
45
|
+
def __init__(self, seed: bytes, source: str):
|
|
46
|
+
self.kp, self.source = core.KeyPair(seed), source
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def did(self) -> str:
|
|
50
|
+
return self.kp.did
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def seed(self) -> bytes: # pass to adopt(), present(), record() and the other seed-keyed calls
|
|
54
|
+
return self.kp.seed
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def load_or_create(cls) -> "Identity":
|
|
58
|
+
for source, read in (("keychain", _keychain_get), ("file", lambda: _file().read_text().strip() if _file().exists() else None)):
|
|
59
|
+
if h := read():
|
|
60
|
+
return cls(bytes.fromhex(h), source)
|
|
61
|
+
seed = os.urandom(32)
|
|
62
|
+
if _keychain_set(seed.hex()):
|
|
63
|
+
return cls(seed, "keychain")
|
|
64
|
+
path = _file()
|
|
65
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
66
|
+
try:
|
|
67
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
68
|
+
except FileExistsError: # another process created it first
|
|
69
|
+
return cls(bytes.fromhex(path.read_text().strip()), "file")
|
|
70
|
+
with os.fdopen(fd, "w") as f:
|
|
71
|
+
f.write(seed.hex())
|
|
72
|
+
return cls(seed, "file")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Issuer helpers (ADR 0003): sign attestations and revocations, build a did:web issuer's two well-known files.
|
|
2
|
+
|
|
3
|
+
`issuer` defaults to the signer's did:key. A did:web issuer passes its DID; the proof then names `<issuer>#<key_id>`,
|
|
4
|
+
the key `did_document` publishes. Registry submission stays with `client` (POST /v1/attestations, stamped, spec 10.4)."""
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
from . import core
|
|
8
|
+
|
|
9
|
+
MAX_TTL = timedelta(days=365) # spec 8.4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ts(t: datetime) -> str:
|
|
13
|
+
return t.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _method(kp: core.KeyPair, issuer: str | None, key_id: str) -> str | None:
|
|
17
|
+
return f"{issuer}#{key_id}" if issuer and issuer != kp.did else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def attestation(kp: core.KeyPair, subject: str, claim_type: str, claim: dict, *, issuer: str | None = None,
|
|
21
|
+
key_id: str = "key-1", evidence: dict | None = None, confidence: float | None = None,
|
|
22
|
+
ttl: timedelta = timedelta(days=90), now: datetime | None = None) -> dict:
|
|
23
|
+
"""A signed Attestation (spec 8.2). `ttl` is capped at one year."""
|
|
24
|
+
if not timedelta(0) < ttl <= MAX_TTL:
|
|
25
|
+
raise ValueError("ttl must be positive and at most 1 year")
|
|
26
|
+
t = now or datetime.now(timezone.utc)
|
|
27
|
+
created = _ts(t)
|
|
28
|
+
obj = {"spec": core.SPEC, "type": "Attestation", "issuer": issuer or kp.did, "subject": subject,
|
|
29
|
+
"claim_type": claim_type, "claim": claim, **({"evidence": evidence} if evidence else {}),
|
|
30
|
+
**({"confidence": confidence} if confidence is not None else {}), "created": created, "expires": _ts(t + ttl)}
|
|
31
|
+
return core.sign_object(obj, kp, created, _method(kp, issuer, key_id))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def revocation(kp: core.KeyPair, attested: dict | str, *, issuer: str | None = None, key_id: str = "key-1",
|
|
35
|
+
reason: str | None = None, now: datetime | None = None) -> dict:
|
|
36
|
+
"""A signed Revocation naming the attestation (the object, or its SHA-256 hex) (spec 8.4)."""
|
|
37
|
+
sha = core.canonical_hash(attested).hex() if isinstance(attested, dict) else attested
|
|
38
|
+
created = _ts(now or datetime.now(timezone.utc))
|
|
39
|
+
obj = {"spec": core.SPEC, "type": "Revocation", "issuer": issuer or kp.did, "attestation_sha256": sha,
|
|
40
|
+
**({"reason": reason} if reason else {}), "created": created}
|
|
41
|
+
return core.sign_object(obj, kp, created, _method(kp, issuer, key_id))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def did_document(did: str, public: bytes, key_id: str = "key-1") -> dict:
|
|
45
|
+
"""The body of `https://<domain>/.well-known/did.json` for a did:web issuer."""
|
|
46
|
+
vm = f"{did}#{key_id}"
|
|
47
|
+
return {"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], "id": did,
|
|
48
|
+
"verificationMethod": [{"id": vm, "type": "Multikey", "controller": did,
|
|
49
|
+
"publicKeyMultibase": core.did_key(public).removeprefix("did:key:")}],
|
|
50
|
+
"assertionMethod": [vm], "authentication": [vm]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def openentity_json(did: str, public: bytes, name: str, description: str, *, key_id: str = "key-1",
|
|
54
|
+
operator: dict | None = None, extensions: dict | None = None) -> dict:
|
|
55
|
+
"""The unsigned body of `https://<domain>/.well-known/openentity.json`: the domain alias proof (spec 3.4) and,
|
|
56
|
+
under `extensions["openentity:observer"]`, an observer's methodology (spec 8.4)."""
|
|
57
|
+
return {"spec": core.SPEC, "eid": did, "name": name, "description": description,
|
|
58
|
+
"keys": [{"id": f"{did}#{key_id}", "type": "Multikey",
|
|
59
|
+
"publicKeyMultibase": core.did_key(public).removeprefix("did:key:")}],
|
|
60
|
+
"operator": operator or {"kind": "organization", "name": name, "custody": "operator", "can_change": []},
|
|
61
|
+
**({"extensions": extensions} if extensions else {})}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""trust(eid): one TrustReport from registry reads only (ADR 0002). Every attestation and revocation is verified
|
|
2
|
+
locally against its issuer's key; any failure returns an untrusted report (fail closed). Cached for 5 minutes."""
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from urllib.parse import unquote
|
|
7
|
+
|
|
8
|
+
from . import client, core
|
|
9
|
+
|
|
10
|
+
MOLTBOTDEN, CLAWGRC = "did:web:moltbotden.com", "did:web:api.clawgrc.com"
|
|
11
|
+
SMA = ("did:web:securemyagent.ai", "did:pkh:eip155:8453:0x62fef02E2859726EC1c0B534853Ad2cdd5FdDE1a")
|
|
12
|
+
TIER, POSTURE, VIOLATION = "trust_tier", "custom:securemyagent:security_posture", "custom:clawgrc:violation"
|
|
13
|
+
ACCEPT = {TIER: (MOLTBOTDEN, CLAWGRC), POSTURE: SMA, VIOLATION: (CLAWGRC,)} # claim_type -> issuers read; others are ignored
|
|
14
|
+
CACHE_SECONDS = 300
|
|
15
|
+
_cache: dict = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TrustReport:
|
|
20
|
+
eid: str
|
|
21
|
+
ok: bool # False: a read failed or a signature did not verify. Treat the entity as untrusted.
|
|
22
|
+
error: str | None = None
|
|
23
|
+
level: str | None = None # registry level, "L0" to "L3"
|
|
24
|
+
withdrawn: bool = False
|
|
25
|
+
trust_tier: dict = field(default_factory=dict) # issuer DID -> newest live tier (0 to 4)
|
|
26
|
+
security_posture: dict | None = None # claim of the newest live custom:securemyagent:security_posture
|
|
27
|
+
violations: list = field(default_factory=list) # live custom:clawgrc:violation: issuer, severity, policy, observed_at, sha256
|
|
28
|
+
revocations: list = field(default_factory=list) # verified revocations of the accepted attestations
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def did_web_resolver(fetch=client._request):
|
|
32
|
+
"""did:web through /.well-known/did.json (or <path>/did.json); everything else offline."""
|
|
33
|
+
def resolve(did: str) -> dict:
|
|
34
|
+
if not did.startswith("did:web:"):
|
|
35
|
+
return core.default_resolver(did)
|
|
36
|
+
host, *path = [unquote(p) for p in did[8:].split(":")]
|
|
37
|
+
doc = fetch(f"https://{host}/{'/'.join(path) if path else '.well-known'}/did.json")
|
|
38
|
+
if doc.get("id") != did:
|
|
39
|
+
raise ValueError(f"{did} document names {doc.get('id')}")
|
|
40
|
+
keys = []
|
|
41
|
+
for vm in doc.get("verificationMethod", []):
|
|
42
|
+
try:
|
|
43
|
+
keys.append(core.public_from_did_key("did:key:" + vm["publicKeyMultibase"]))
|
|
44
|
+
except (KeyError, ValueError):
|
|
45
|
+
continue # not an Ed25519 Multikey
|
|
46
|
+
return {"ed25519": keys}
|
|
47
|
+
return resolve
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def trust(eid: str, registry: str = client.DEFAULT_REGISTRY, *, now: datetime | None = None, fetch=client._request,
|
|
51
|
+
resolver=None) -> TrustReport:
|
|
52
|
+
key = (registry, eid)
|
|
53
|
+
hit = _cache.get(key)
|
|
54
|
+
if hit and time.monotonic() - hit[0] < CACHE_SECONDS:
|
|
55
|
+
return hit[1]
|
|
56
|
+
try:
|
|
57
|
+
report = _read(eid, registry.rstrip("/"), now or datetime.now(timezone.utc), fetch, resolver or did_web_resolver(fetch))
|
|
58
|
+
except Exception as e: # fail closed on anything: network, registry, schema, signature
|
|
59
|
+
return TrustReport(eid, False, f"{type(e).__name__}: {e}")
|
|
60
|
+
_cache[key] = (time.monotonic(), report)
|
|
61
|
+
return report
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read(eid, base, now, fetch, resolver) -> TrustReport:
|
|
65
|
+
if not eid.startswith("did:"):
|
|
66
|
+
raise ValueError("eid must be a DID")
|
|
67
|
+
ent = fetch(f"{base}/v1/entities/{eid}")
|
|
68
|
+
if ent["eid"] != eid:
|
|
69
|
+
raise ValueError(f"registry answered for {ent['eid']}")
|
|
70
|
+
rows = fetch(f"{base}/v1/entities/{eid}/attestations")["attestations"]
|
|
71
|
+
tiers, posture, violations, revocations = {}, None, [], []
|
|
72
|
+
for r in sorted(rows, key=lambda r: r["attestation"]["created"], reverse=True):
|
|
73
|
+
a, ct = r["attestation"], r["attestation"].get("claim_type")
|
|
74
|
+
if a.get("issuer", "").lower() not in {i.lower() for i in ACCEPT.get(ct, ())}:
|
|
75
|
+
continue
|
|
76
|
+
if r.get("inherited_from") and ct != VIOLATION:
|
|
77
|
+
continue # a rotation does not carry positive claims; a strike follows the entity
|
|
78
|
+
sha, ended = r["sha256"], core._ts(a["expires"]) <= now
|
|
79
|
+
if a["subject"] != (r.get("inherited_from") or eid) or core.canonical_hash(a).hex() != sha:
|
|
80
|
+
raise ValueError(f"attestation {sha} does not match what the registry served")
|
|
81
|
+
ok, signer = core.verify_object(a, resolver)
|
|
82
|
+
if not ok or signer != a["issuer"]:
|
|
83
|
+
raise ValueError(f"attestation {sha}: {signer}")
|
|
84
|
+
rv = r.get("revocation")
|
|
85
|
+
if rv:
|
|
86
|
+
ok, signer = core.verify_object(rv, resolver)
|
|
87
|
+
if not ok or signer != a["issuer"] or rv["attestation_sha256"] != sha:
|
|
88
|
+
raise ValueError(f"revocation of {sha}: {signer}")
|
|
89
|
+
revocations.append({"sha256": sha, "claim_type": ct, "issuer": a["issuer"], "reason": rv.get("reason"), "at": rv["created"]})
|
|
90
|
+
elif r.get("revoked_at"):
|
|
91
|
+
raise ValueError(f"registry reports a revocation of {sha} it does not carry")
|
|
92
|
+
if ended or rv:
|
|
93
|
+
continue
|
|
94
|
+
c = a["claim"]
|
|
95
|
+
if ct == TIER:
|
|
96
|
+
tiers.setdefault(a["issuer"], c["tier"])
|
|
97
|
+
elif ct == POSTURE:
|
|
98
|
+
posture = posture or c
|
|
99
|
+
else:
|
|
100
|
+
violations.append({"issuer": a["issuer"], "severity": c["severity"], "policy": c["policy"], "observed_at": c["observed_at"], "sha256": sha})
|
|
101
|
+
return TrustReport(eid, True, None, ent["level"], bool(ent.get("withdrawn")), tiers, posture, violations, revocations)
|
|
@@ -155,7 +155,7 @@ class _Stub(BaseHTTPRequestHandler):
|
|
|
155
155
|
return self._send(200, {"accepted": 1})
|
|
156
156
|
doc, decl = body["document"], body["declaration"]
|
|
157
157
|
stamp = self.headers["x-oe-stamp"]
|
|
158
|
-
_Stub.seen = {"stamp": stamp, "doc": doc, "decl": decl}
|
|
158
|
+
_Stub.seen = {"stamp": stamp, "doc": doc, "decl": decl, "client": body.get("client")}
|
|
159
159
|
assert core.check_stamp(stamp, 8, doc["eid"]) is None
|
|
160
160
|
assert core.verify_object(doc)[0] and core.verify_object(decl)[0]
|
|
161
161
|
assert decl["text_sha256"] == core.DECLARATION_SHA256 and doc["eid"] == decl["eid"]
|
|
@@ -168,13 +168,16 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
|
|
|
168
168
|
url = f"http://127.0.0.1:{srv.server_port}"
|
|
169
169
|
try:
|
|
170
170
|
from openentity import adopt
|
|
171
|
-
res = adopt("Tester", registry=url, seed=SEED)
|
|
171
|
+
res = adopt("Tester", registry=url, seed=SEED, client="adk")
|
|
172
|
+
assert _Stub.seen["client"] == "adk" # the unsigned label travels beside the signed objects
|
|
173
|
+
with pytest.raises(ValueError):
|
|
174
|
+
adopt("Tester", registry=url, seed=SEED, client="nope")
|
|
172
175
|
assert res["eid"] == res["did"] == core.KeyPair(SEED).did
|
|
173
176
|
assert res["record_pushed"] and _Stub.seen["entry"]["eid"] == res["eid"]
|
|
174
177
|
monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
|
|
175
178
|
assert cli.main(["adopt", "--name", "T2", "--agree", "--registry", url, "--json"]) == 0
|
|
176
179
|
out = json.loads(capsys.readouterr().out)
|
|
177
|
-
assert "seed_hex" not in out and Path(out["key_file"]).exists()
|
|
180
|
+
assert "seed_hex" not in out and Path(out["key_file"]).exists() and _Stub.seen["client"] == "cli"
|
|
178
181
|
assert cli.main(["adopt", "--name", "T3", "--registry", url]) == 1 # no --agree, nothing signed
|
|
179
182
|
finally:
|
|
180
183
|
srv.shutdown()
|
|
@@ -183,7 +186,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
|
|
|
183
186
|
def test_cli_version(capsys):
|
|
184
187
|
with pytest.raises(SystemExit) as e:
|
|
185
188
|
cli.main(["--version"])
|
|
186
|
-
assert e.value.code == 0 and "0.
|
|
189
|
+
assert e.value.code == 0 and "0.4.0" in capsys.readouterr().out
|
|
187
190
|
|
|
188
191
|
|
|
189
192
|
# ---- shared vectors, checked with the independent verifier's own logic ----
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from openentity import identity
|
|
7
|
+
from openentity.identity import Identity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def home(tmp_path, monkeypatch):
|
|
12
|
+
monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
|
|
13
|
+
monkeypatch.setenv("OPENENTITY_KEYCHAIN", "off")
|
|
14
|
+
return tmp_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_file_fallback_is_0600_and_stable():
|
|
18
|
+
a = Identity.load_or_create()
|
|
19
|
+
f = identity._file()
|
|
20
|
+
assert a.source == "file" and stat.S_IMODE(f.stat().st_mode) == 0o600 and stat.S_IMODE(f.parent.stat().st_mode) == 0o700
|
|
21
|
+
b = Identity.load_or_create()
|
|
22
|
+
assert (b.did, b.source) == (a.did, "file") and b.seed == a.seed
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_keychain_wins_and_holds_the_only_copy(monkeypatch):
|
|
26
|
+
store = {}
|
|
27
|
+
|
|
28
|
+
def run(argv, stdin=None):
|
|
29
|
+
if argv[0] == "security" and argv[1] == "find-generic-password":
|
|
30
|
+
return store.get("k")
|
|
31
|
+
if argv[0] == "security":
|
|
32
|
+
store["k"] = argv[-1]
|
|
33
|
+
return ""
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
monkeypatch.delenv("OPENENTITY_KEYCHAIN")
|
|
37
|
+
monkeypatch.setattr(identity, "_run", run)
|
|
38
|
+
monkeypatch.setattr(identity.sys, "platform", "darwin")
|
|
39
|
+
a = Identity.load_or_create()
|
|
40
|
+
assert a.source == "keychain" and store["k"] == a.seed.hex() and not identity._file().exists()
|
|
41
|
+
assert Identity.load_or_create().did == a.did
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_keychain_failure_falls_back_to_file(monkeypatch):
|
|
45
|
+
monkeypatch.delenv("OPENENTITY_KEYCHAIN")
|
|
46
|
+
monkeypatch.setattr(identity, "_run", lambda argv, stdin=None: None) # no `security`, no `secret-tool`
|
|
47
|
+
assert Identity.load_or_create().source == "file"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Issuer helpers: a did:web issuer signs, anyone verifies against the published did.json."""
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from openentity import core, issuer
|
|
7
|
+
|
|
8
|
+
KP = core.KeyPair(bytes(range(1, 33))) # published throwaway seed from test-vectors
|
|
9
|
+
SUBJECT = core.KeyPair(bytes(range(33, 65))).did
|
|
10
|
+
DID = "did:web:issuer.example"
|
|
11
|
+
NOW = datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolver(did):
|
|
15
|
+
assert did == DID
|
|
16
|
+
return {"ed25519": [core.public_from_did_key("did:key:" + issuer.did_document(DID, KP.public)["verificationMethod"][0]["publicKeyMultibase"])]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_attestation_verifies_against_did_json_and_expires_on_ttl():
|
|
20
|
+
a = issuer.attestation(KP, SUBJECT, "trust_tier", {"tier": 2, "platform": "x"}, issuer=DID, ttl=timedelta(days=30), now=NOW)
|
|
21
|
+
assert (a["issuer"], a["created"], a["expires"]) == (DID, "2026-10-27T12:00:00Z", "2026-11-26T12:00:00Z")
|
|
22
|
+
assert a["proof"]["verificationMethod"] == f"{DID}#key-1"
|
|
23
|
+
assert core.verify_object(a, resolver) == (True, DID)
|
|
24
|
+
assert not core.verify_object({**a, "claim": {"tier": 4, "platform": "x"}}, resolver)[0]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_did_key_issuer_needs_no_key_id_and_ttl_is_capped():
|
|
28
|
+
a = issuer.attestation(KP, SUBJECT, "stage", {"stage": "agent", "profile": "p"}, evidence={"kind": "t"}, confidence=0.5, now=NOW)
|
|
29
|
+
assert a["issuer"] == KP.did and core.verify_object(a) == (True, KP.did)
|
|
30
|
+
for ttl in (timedelta(0), timedelta(days=366)):
|
|
31
|
+
with pytest.raises(ValueError):
|
|
32
|
+
issuer.attestation(KP, SUBJECT, "stage", {}, ttl=ttl)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_revocation_names_the_attestation_hash():
|
|
36
|
+
a = issuer.attestation(KP, SUBJECT, "custom:x:y", {}, issuer=DID, now=NOW)
|
|
37
|
+
r = issuer.revocation(KP, a, issuer=DID, reason="superseded", now=NOW)
|
|
38
|
+
assert r["attestation_sha256"] == core.canonical_hash(a).hex() == issuer.revocation(KP, r["attestation_sha256"], issuer=DID, now=NOW)["attestation_sha256"]
|
|
39
|
+
assert core.verify_object(r, resolver) == (True, DID)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_well_known_files_agree_on_the_key():
|
|
43
|
+
d, o = issuer.did_document(DID, KP.public), issuer.openentity_json(DID, KP.public, "Issuer", "An issuer.", extensions={"openentity:observer": {}})
|
|
44
|
+
assert d["id"] == o["eid"] == DID
|
|
45
|
+
assert d["verificationMethod"][0]["publicKeyMultibase"] == o["keys"][0]["publicKeyMultibase"]
|
|
46
|
+
assert d["assertionMethod"] == [o["keys"][0]["id"]]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""trust(): built from registry reads, every signature checked locally, fail closed."""
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from openentity import core, issuer, report
|
|
7
|
+
from openentity.report import CLAWGRC, MOLTBOTDEN, POSTURE, SMA, TIER, VIOLATION, trust
|
|
8
|
+
|
|
9
|
+
SUBJECT = core.KeyPair(b"\1" * 32).did
|
|
10
|
+
KEYS = {d: core.KeyPair(bytes([i]) * 32) for i, d in enumerate([MOLTBOTDEN, CLAWGRC, SMA[0], "did:web:evil.example"], 10)}
|
|
11
|
+
NOW = datetime.now(timezone.utc)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def att(did, ct, claim, **kw):
|
|
15
|
+
return issuer.attestation(KEYS[did], SUBJECT, ct, claim, issuer=did, now=kw.pop("now", NOW - timedelta(hours=1)), **kw)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def row(a, rev=None, **kw):
|
|
19
|
+
return {"sha256": core.canonical_hash(a).hex(), "attestation": a, "revocation": rev, "revoked_at": rev and rev["created"], **kw}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Registry:
|
|
23
|
+
"""A fake registry plus the issuers' did.json files."""
|
|
24
|
+
def __init__(self, rows, **entity):
|
|
25
|
+
self.rows, self.entity, self.calls = rows, {"eid": SUBJECT, "level": "L2", "withdrawn": False, **entity}, []
|
|
26
|
+
|
|
27
|
+
def __call__(self, url, body=None, headers=None):
|
|
28
|
+
self.calls.append(url)
|
|
29
|
+
if url.endswith("/did.json"):
|
|
30
|
+
host = url.split("/")[2]
|
|
31
|
+
return issuer.did_document(f"did:web:{host}", KEYS[f"did:web:{host}"].public)
|
|
32
|
+
return {"attestations": self.rows} if url.endswith("/attestations") else self.entity
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture(autouse=True)
|
|
36
|
+
def fresh():
|
|
37
|
+
report._cache.clear()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def full():
|
|
41
|
+
tier_old = att(MOLTBOTDEN, TIER, {"tier": 1, "platform": "moltbotden"}, ttl=timedelta(days=60), now=NOW - timedelta(days=2))
|
|
42
|
+
tier_new = att(MOLTBOTDEN, TIER, {"tier": 3, "platform": "moltbotden"})
|
|
43
|
+
grc = att(CLAWGRC, TIER, {"tier": 2, "platform": "clawgrc"})
|
|
44
|
+
posture = att(SMA[0], POSTURE, {"score_band": "A", "checks_passed": [], "scanner_version": "1", "evidence_hash": "0" * 64})
|
|
45
|
+
viol = att(CLAWGRC, VIOLATION, {"policy": "p", "severity": "high", "evidence_hash": "0" * 64, "observed_at": "2026-10-01T00:00:00Z"})
|
|
46
|
+
old_viol = att(CLAWGRC, VIOLATION, {"policy": "q", "severity": "low", "evidence_hash": "0" * 64, "observed_at": "2026-09-01T00:00:00Z"})
|
|
47
|
+
rev = issuer.revocation(KEYS[CLAWGRC], old_viol, issuer=CLAWGRC, reason="dispute upheld", now=NOW)
|
|
48
|
+
return [row(a) for a in (tier_old, tier_new, grc, posture, viol)] + [row(old_viol, rev)], viol, old_viol
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_report_from_registry_reads_only():
|
|
52
|
+
rows, viol, old = full()
|
|
53
|
+
reg = Registry(rows)
|
|
54
|
+
r = trust(SUBJECT, fetch=reg)
|
|
55
|
+
assert r.ok and r.level == "L2" and not r.withdrawn
|
|
56
|
+
assert r.trust_tier == {MOLTBOTDEN: 3, CLAWGRC: 2} # newest per issuer
|
|
57
|
+
assert r.security_posture["score_band"] == "A"
|
|
58
|
+
assert [(v["severity"], v["policy"]) for v in r.violations] == [("high", "p")] # the revoked strike is not active
|
|
59
|
+
assert [(x["sha256"], x["reason"]) for x in r.revocations] == [(core.canonical_hash(old).hex(), "dispute upheld")]
|
|
60
|
+
assert all(u.startswith(("https://openentity.org/v1/", "https://moltbotden.com", "https://api.clawgrc.com", "https://securemyagent.ai")) for u in reg.calls)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_cache_holds_five_minutes():
|
|
64
|
+
reg = Registry(full()[0])
|
|
65
|
+
trust(SUBJECT, fetch=reg)
|
|
66
|
+
n = len(reg.calls)
|
|
67
|
+
assert trust(SUBJECT, fetch=reg) is trust(SUBJECT, fetch=reg) and len(reg.calls) == n
|
|
68
|
+
report._cache[("https://openentity.org", SUBJECT)] = (report.time.monotonic() - 301, None)
|
|
69
|
+
assert trust(SUBJECT, fetch=reg).ok and len(reg.calls) > n
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_fails_closed():
|
|
73
|
+
good = att(MOLTBOTDEN, TIER, {"tier": 4, "platform": "moltbotden"})
|
|
74
|
+
forged = {**good, "claim": {"tier": 4, "platform": "evil"}}
|
|
75
|
+
other_key = issuer.attestation(KEYS["did:web:evil.example"], SUBJECT, TIER, {"tier": 4, "platform": "x"}, issuer=MOLTBOTDEN, now=NOW)
|
|
76
|
+
viol = att(CLAWGRC, VIOLATION, {"policy": "p", "severity": "critical", "evidence_hash": "0" * 64, "observed_at": "2026-10-01T00:00:00Z"})
|
|
77
|
+
fake_rev = issuer.revocation(KEYS[MOLTBOTDEN], viol, issuer=CLAWGRC, now=NOW) # signed by a key that is not the issuer's
|
|
78
|
+
cases = {
|
|
79
|
+
"forged claim": [{**row(good), "attestation": forged}],
|
|
80
|
+
"signed by another key": [row(other_key)],
|
|
81
|
+
"hidden strike (bad revocation)": [row(viol, fake_rev)],
|
|
82
|
+
"revocation not carried": [{**row(viol), "revoked_at": "2026-10-02T00:00:00Z"}],
|
|
83
|
+
"wrong subject": [row(issuer.attestation(KEYS[MOLTBOTDEN], SUBJECT[:-1] + "x", TIER, {"tier": 4}, issuer=MOLTBOTDEN, now=NOW))],
|
|
84
|
+
}
|
|
85
|
+
for name, rows in cases.items():
|
|
86
|
+
r = trust(SUBJECT, fetch=Registry(rows))
|
|
87
|
+
assert not r.ok and r.error and r.trust_tier == {} and r.level is None, name
|
|
88
|
+
report._cache.clear()
|
|
89
|
+
|
|
90
|
+
def down(*a, **k):
|
|
91
|
+
raise OSError("registry down")
|
|
92
|
+
assert not trust(SUBJECT, fetch=down).ok
|
|
93
|
+
assert not trust("not-a-did", fetch=Registry([])).ok
|
|
94
|
+
assert not trust(SUBJECT, fetch=Registry([], eid="did:key:other")).ok # registry answered for someone else
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_expired_and_unlisted_issuers_are_ignored_and_not_fetched():
|
|
98
|
+
expired = att(MOLTBOTDEN, TIER, {"tier": 4, "platform": "m"}, ttl=timedelta(days=1), now=NOW - timedelta(days=3))
|
|
99
|
+
stranger = att("did:web:evil.example", TIER, {"tier": 4, "platform": "x"})
|
|
100
|
+
reg = Registry([row(expired), row(stranger)])
|
|
101
|
+
r = trust(SUBJECT, fetch=reg)
|
|
102
|
+
assert r.ok and r.trust_tier == {} and not any("evil.example" in u for u in reg.calls)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_withdrawn_is_reported():
|
|
106
|
+
assert trust(SUBJECT, fetch=Registry([], withdrawn=True)).withdrawn
|
|
@@ -15,6 +15,9 @@ ROOT = Path(__file__).resolve().parents[3]
|
|
|
15
15
|
spec = importlib.util.spec_from_file_location("iv", ROOT / "scripts/independent_verifier.py")
|
|
16
16
|
iv = importlib.util.module_from_spec(spec)
|
|
17
17
|
spec.loader.exec_module(iv)
|
|
18
|
+
spec = importlib.util.spec_from_file_location("rc", ROOT / "scripts/rebuild_check.py")
|
|
19
|
+
rc = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(rc)
|
|
18
21
|
ORIGIN = "openentity.org/log"
|
|
19
22
|
|
|
20
23
|
|
|
@@ -129,18 +132,18 @@ def _bucket(tmp_path, n=5, tamper=False):
|
|
|
129
132
|
|
|
130
133
|
def test_rebuild_matches_checkpoint(tmp_path):
|
|
131
134
|
n, root = _bucket(tmp_path)
|
|
132
|
-
ok, lines =
|
|
135
|
+
ok, lines = rc.rebuild(tmp_path, (n, root))
|
|
133
136
|
assert ok, lines
|
|
134
|
-
assert
|
|
137
|
+
assert rc.tile_path(1234067) == "x001/x234/067"
|
|
135
138
|
|
|
136
139
|
|
|
137
140
|
def test_rebuild_catches_wrong_root_missing_leaf_and_bad_tile(tmp_path):
|
|
138
141
|
n, root = _bucket(tmp_path)
|
|
139
|
-
assert not
|
|
140
|
-
assert not
|
|
142
|
+
assert not rc.rebuild(tmp_path, (n, b"\0" * 32))[0]
|
|
143
|
+
assert not rc.rebuild(tmp_path, (n + 1, root))[0]
|
|
141
144
|
t = tmp_path / "t"; t.mkdir()
|
|
142
145
|
n, root = _bucket(t, tamper=True)
|
|
143
|
-
assert not
|
|
146
|
+
assert not rc.rebuild(t, (n, root))[0]
|
|
144
147
|
|
|
145
148
|
|
|
146
149
|
def test_rebuild_checks_objects_against_leaves(tmp_path):
|
|
@@ -159,10 +162,10 @@ def test_rebuild_checks_objects_against_leaves(tmp_path):
|
|
|
159
162
|
(tmp_path / "objects").mkdir()
|
|
160
163
|
line = {"idx": 0, "type": "Declaration", "eid": "did:key:z1", "object_sha256": sha, "object": obj}
|
|
161
164
|
(tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps(line) + "\n")
|
|
162
|
-
ok, lines =
|
|
165
|
+
ok, lines = rc.rebuild(tmp_path, (n, root))
|
|
163
166
|
assert ok and any("objects: 1 of" in x for x in lines), lines
|
|
164
167
|
(tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": {**obj, "n": 2}}) + "\n")
|
|
165
|
-
assert not
|
|
168
|
+
assert not rc.rebuild(tmp_path, (n, root))[0]
|
|
166
169
|
red = {"seq": 2, "redacted": True, "hash": sha, "redaction": {}}
|
|
167
170
|
(tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": red}) + "\n")
|
|
168
|
-
assert
|
|
171
|
+
assert rc.rebuild(tmp_path, (n, root))[0]
|
|
File without changes
|
|
File without changes
|