motionloom 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/SKILL.md +122 -0
- package/agent-card.json +161 -0
- package/assets/library/ATTRIBUTION.md +6 -0
- package/assets/library/README.md +20 -0
- package/assets/library/avatar-base.svg +19 -0
- package/assets/library/error-alert.json +1 -0
- package/assets/library/rive/ATTRIBUTION.md +12 -0
- package/assets/library/rive/state-machine-test.riv +0 -0
- package/assets/library/success-check.json +1 -0
- package/bin/motionloom.mjs +81 -0
- package/docs/BROWSER-REVIEW-E2E.md +78 -0
- package/docs/CATEGORIES.md +16 -0
- package/docs/CHECKLIST.md +31 -0
- package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
- package/docs/FRAMEWORK-SELECTION.md +26 -0
- package/docs/PROJECT-MANIFEST.md +37 -0
- package/docs/ROADMAP-INTELLIGENCE.md +224 -0
- package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
- package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
- package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
- package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
- package/docs/releases/1.5.0.md +25 -0
- package/docs/releases/1.6.0.md +27 -0
- package/docs/releases/1.7.0.md +23 -0
- package/docs/releases/1.8.0.md +23 -0
- package/docs/releases/1.9.0.md +21 -0
- package/docs/releases/2.0.0.md +21 -0
- package/docs/releases/npm-publish-from-workstation.md +88 -0
- package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
- package/examples/report-demo/REPORT.md +50 -0
- package/examples/report-demo/artifact-manifest.json +25 -0
- package/examples/report-demo/decision-log.jsonl +0 -0
- package/examples/report-demo/execution-report.json +70 -0
- package/examples/report-demo/handoff.json +22 -0
- package/examples/report-demo/issue-register.json +5 -0
- package/examples/report-demo/task.json +13 -0
- package/package.json +95 -0
- package/project-context.example.json +26 -0
- package/references/browser-review-contract.md +32 -0
- package/references/dotlottie-source-notes.md +21 -0
- package/references/intelligence-core.md +98 -0
- package/references/reporting-contract.md +38 -0
- package/references/runtime-capability.md +12 -0
- package/references/signed-attestation.md +31 -0
- package/schemas/artifact-manifest.schema.json +22 -0
- package/schemas/browser-review-candidate.schema.json +24 -0
- package/schemas/capability-registry.schema.json +61 -0
- package/schemas/continuity-report.schema.json +52 -0
- package/schemas/evidence-verifier-report.schema.json +35 -0
- package/schemas/execution-report.schema.json +35 -0
- package/schemas/fix-plan.schema.json +44 -0
- package/schemas/handoff.schema.json +19 -0
- package/schemas/motion-ir.schema.json +74 -0
- package/schemas/project-graph.schema.json +70 -0
- package/schemas/provenance.schema.json +76 -0
- package/schemas/runtime-evidence.schema.json +48 -0
- package/schemas/runtime-telemetry.schema.json +50 -0
- package/schemas/scene-manifest.schema.json +45 -0
- package/schemas/semantic-benchmark.schema.json +26 -0
- package/schemas/semantic-lint-report.schema.json +48 -0
- package/schemas/signed-attestation.schema.json +95 -0
- package/schemas/task.schema.json +39 -0
- package/schemas/trust-policy.schema.json +53 -0
- package/scripts/analyze.sh +13 -0
- package/scripts/attestation-keygen.py +63 -0
- package/scripts/attestation-verifier.py +178 -0
- package/scripts/attestation.py +288 -0
- package/scripts/capture-runtime-telemetry.sh +37 -0
- package/scripts/devlab.sh +77 -0
- package/scripts/eval-intelligence.py +377 -0
- package/scripts/evidence-verifier.py +222 -0
- package/scripts/fetch-library.sh +57 -0
- package/scripts/intelligence.py +1543 -0
- package/scripts/manifest.py +61 -0
- package/scripts/pr.sh +103 -0
- package/scripts/quality-gate.py +378 -0
- package/scripts/render-node.mjs +53 -0
- package/scripts/render.sh +37 -0
- package/scripts/report-contract.py +181 -0
- package/scripts/report.py +588 -0
- package/scripts/review-hook.py +199 -0
- package/scripts/runtime-adapters.mjs +187 -0
- package/scripts/skill-doctor.py +150 -0
- package/scripts/to-dotlottie.mjs +99 -0
- package/scripts/to-dotlottie.sh +25 -0
- package/scripts/validate-lottie.py +102 -0
- package/src/core/analyzer.py +226 -0
- package/src/core/snapshot.py +124 -0
- package/src/core/spec.py +240 -0
- package/src/output/browser-review-smoke/animation.json +57 -0
- package/src/output/browser-review-smoke/browser-review.json +21 -0
- package/src/output/browser-review-smoke/manifest.json +22 -0
- package/src/output/browser-review-smoke/motion-spec.json +28 -0
- package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
- package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
- package/src/rig/README.md +35 -0
- package/src/rig/cutout_rig.py +211 -0
- package/templates/framer-motion/ui-micro.tsx +50 -0
- package/templates/gsap/scroll-scene.js +54 -0
- package/templates/lottie/README.md +21 -0
- package/templates/lottie/react-component.tsx +82 -0
- package/templates/lottie/scaffold/animation.json +57 -0
- package/templates/lottie/scaffold/character-rig.svg +19 -0
- package/templates/lottie/vanilla.js +68 -0
- package/templates/rive/README.md +36 -0
- package/tests/evals/intelligence-cases.json +131 -0
- package/tests/scripts/run_tests.py +843 -0
- package/tests/scripts/test_attestation.py +172 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/lenhonbp/MotionLoom/blob/main/schemas/task.schema.json",
|
|
4
|
+
"title": "Animation task",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "task_id", "scene", "intent", "state", "created_at", "updated_at"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema_version": {"type": "string"},
|
|
9
|
+
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$"},
|
|
10
|
+
"scene": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$"},
|
|
11
|
+
"intent": {"type": "string", "minLength": 1},
|
|
12
|
+
"project_name": {"type": "string"},
|
|
13
|
+
"context_path": {"type": "string"},
|
|
14
|
+
"context_hash": {"type": "string"},
|
|
15
|
+
"state": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["created", "needs_context", "planning", "sourcing", "generating", "rendering", "review_required", "blocked", "failed", "validated", "ready_for_pr", "confirmed"]
|
|
18
|
+
},
|
|
19
|
+
"owner_agent": {"type": "string"},
|
|
20
|
+
"created_at": {"type": "string", "format": "date-time"},
|
|
21
|
+
"updated_at": {"type": "string", "format": "date-time"},
|
|
22
|
+
"browser_review": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"required": ["required", "status"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"required": {"type": "boolean"},
|
|
27
|
+
"status": {"type": "string", "enum": ["not_prepared", "prepared", "opened", "reviewed", "approved", "changes_requested", "expired"]},
|
|
28
|
+
"candidate_id": {"type": "string"},
|
|
29
|
+
"candidate_path": {"type": "string"},
|
|
30
|
+
"review_artifact": {"type": "string"}
|
|
31
|
+
},
|
|
32
|
+
"additionalProperties": true
|
|
33
|
+
},
|
|
34
|
+
"review_candidate": {"$ref": "browser-review-candidate.schema.json"},
|
|
35
|
+
"commit_sha": {"type": "string"},
|
|
36
|
+
"pr_url": {"type": "string", "format": "uri"}
|
|
37
|
+
},
|
|
38
|
+
"additionalProperties": true
|
|
39
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/lenhonbp/MotionLoom/blob/main/schemas/trust-policy.schema.json",
|
|
4
|
+
"title": "MotionLoom attestation trust-anchor and key lifecycle policy",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schema_version", "policy_id", "trust_domain", "keys", "rotation", "revocation"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": {"const": "1.0"},
|
|
10
|
+
"policy_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$"},
|
|
11
|
+
"trust_domain": {"type": "string", "format": "uri"},
|
|
12
|
+
"keys": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"minItems": 1,
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"required": ["key_id", "algorithm", "public_key_base64", "status", "valid_from"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"key_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{2,127}$"},
|
|
21
|
+
"algorithm": {"const": "ed25519"},
|
|
22
|
+
"public_key_base64": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9+/=_-]+$"},
|
|
23
|
+
"status": {"type": "string", "enum": ["active", "retired", "revoked"]},
|
|
24
|
+
"valid_from": {"type": "string", "format": "date-time"},
|
|
25
|
+
"valid_until": {"type": "string", "format": "date-time"},
|
|
26
|
+
"replaces_key_id": {"type": "string"},
|
|
27
|
+
"revoked_at": {"type": "string", "format": "date-time"},
|
|
28
|
+
"revocation_reason": {"type": "string", "minLength": 1}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"rotation": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"required": ["max_key_age_days", "overlap_days", "require_active_signer"],
|
|
36
|
+
"properties": {
|
|
37
|
+
"max_key_age_days": {"type": "integer", "minimum": 1},
|
|
38
|
+
"overlap_days": {"type": "integer", "minimum": 0},
|
|
39
|
+
"require_active_signer": {"const": true}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"revocation": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"required": ["mode", "fail_closed", "sources"],
|
|
46
|
+
"properties": {
|
|
47
|
+
"mode": {"type": "string", "enum": ["local-policy", "signed-list", "online"]},
|
|
48
|
+
"fail_closed": {"const": true},
|
|
49
|
+
"sources": {"type": "array", "items": {"type": "string", "minLength": 1}}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# analyze.sh — Step 1 of the pipeline: understand the host project.
|
|
3
|
+
# Emits project-context.json inside the analyzed project root.
|
|
4
|
+
#
|
|
5
|
+
# Usage: bash scripts/analyze.sh <project-path>
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
PROJECT="${1:-.}"
|
|
9
|
+
|
|
10
|
+
python3 "$SCRIPT_DIR/../src/core/analyzer.py" "$PROJECT" --output "$(cd "$PROJECT" 2>/dev/null && pwd)/project-context.json"
|
|
11
|
+
echo ""
|
|
12
|
+
echo "== Step 1 complete: the target project's project-context.json is now the binding source =="
|
|
13
|
+
echo " Review inferred values (marked as assumptions) before generating."
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a non-production Ed25519 trust fixture for local/CI attestation tests."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
14
|
+
|
|
15
|
+
from attestation import KEY_ID_RE, reject_symlink_components, validate_policy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def iso_now() -> str:
|
|
19
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(description="Create an ephemeral MotionLoom Ed25519 trust fixture")
|
|
24
|
+
parser.add_argument("--private-key", required=True)
|
|
25
|
+
parser.add_argument("--policy", required=True)
|
|
26
|
+
parser.add_argument("--key-id", required=True)
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
try:
|
|
29
|
+
if not KEY_ID_RE.fullmatch(args.key_id):
|
|
30
|
+
raise ValueError("key-id has an unsafe format")
|
|
31
|
+
private_path = Path(args.private_key).expanduser().absolute()
|
|
32
|
+
policy_path = Path(args.policy).expanduser().absolute()
|
|
33
|
+
reject_symlink_components(private_path.parent)
|
|
34
|
+
reject_symlink_components(policy_path.parent)
|
|
35
|
+
private_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
policy_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
private_key = Ed25519PrivateKey.generate()
|
|
38
|
+
private_path.write_text(base64.b64encode(private_key.private_bytes_raw()).decode("ascii") + "\n", encoding="utf-8")
|
|
39
|
+
policy = {
|
|
40
|
+
"schema_version": "1.0",
|
|
41
|
+
"policy_id": "motionloom-ephemeral-ci-policy",
|
|
42
|
+
"trust_domain": "https://motionloom.dev/trust/ci-ephemeral",
|
|
43
|
+
"keys": [{
|
|
44
|
+
"key_id": args.key_id,
|
|
45
|
+
"algorithm": "ed25519",
|
|
46
|
+
"public_key_base64": base64.b64encode(private_key.public_key().public_bytes_raw()).decode("ascii"),
|
|
47
|
+
"status": "active",
|
|
48
|
+
"valid_from": iso_now(),
|
|
49
|
+
}],
|
|
50
|
+
"rotation": {"max_key_age_days": 1, "overlap_days": 0, "require_active_signer": True},
|
|
51
|
+
"revocation": {"mode": "local-policy", "fail_closed": True, "sources": ["ci-ephemeral"]},
|
|
52
|
+
}
|
|
53
|
+
validate_policy(policy)
|
|
54
|
+
policy_path.write_text(json.dumps(policy, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
55
|
+
print(json.dumps({"generated": True, "key_id": args.key_id, "policy": str(policy_path)}, indent=2))
|
|
56
|
+
return 0
|
|
57
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
58
|
+
print(f"attestation keygen error: {exc}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify MotionLoom signed attestations without granting approval.
|
|
3
|
+
|
|
4
|
+
Exit codes are stable for automation:
|
|
5
|
+
0 verified, 10 malformed envelope, 11 payload mismatch, 12 signature
|
|
6
|
+
failure, 13 trust-policy failure, 14 expected binding mismatch, 2 usage/I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from cryptography.exceptions import InvalidSignature
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
21
|
+
|
|
22
|
+
from attestation import PAYLOAD_TYPE, SCHEMA_VERSION, canonical_json_bytes, dsse_pae, parse_json_file, reject_symlink_components, validate_policy
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_USAGE = 2
|
|
27
|
+
EXIT_ENVELOPE = 10
|
|
28
|
+
EXIT_PAYLOAD = 11
|
|
29
|
+
EXIT_SIGNATURE = 12
|
|
30
|
+
EXIT_TRUST = 13
|
|
31
|
+
EXIT_BINDING = 14
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sha256_bytes(value: bytes) -> str:
|
|
35
|
+
return hashlib.sha256(value).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_time(value: object, label: str) -> datetime:
|
|
39
|
+
try:
|
|
40
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise ValueError(f"{label} is not a valid date-time") from exc
|
|
43
|
+
if parsed.tzinfo is None:
|
|
44
|
+
raise ValueError(f"{label} must include timezone")
|
|
45
|
+
return parsed.astimezone(timezone.utc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_attestation(path_value: str) -> dict:
|
|
49
|
+
path = Path(path_value).expanduser().absolute()
|
|
50
|
+
reject_symlink_components(path)
|
|
51
|
+
value = parse_json_file(str(path), "attestation")
|
|
52
|
+
if value.get("schema_version") != SCHEMA_VERSION:
|
|
53
|
+
raise ValueError("unsupported attestation schema_version")
|
|
54
|
+
if value.get("approval") is not False:
|
|
55
|
+
raise ValueError("attestation approval must be false")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_attestation(attestation_path: str, policy_path: str, expected_task_id: str | None, expected_scene: str | None) -> tuple[dict, int]:
|
|
60
|
+
issues: list[str] = []
|
|
61
|
+
result = {"schema_version": "1.0", "verifier": "motionloom-attestation-verifier/1.0", "verified": False, "approval": False, "issues": [], "signers": [], "bindings": {}}
|
|
62
|
+
try:
|
|
63
|
+
bundle = read_attestation(attestation_path)
|
|
64
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
65
|
+
result["issues"] = [str(exc)]
|
|
66
|
+
return result, EXIT_ENVELOPE
|
|
67
|
+
|
|
68
|
+
envelope = bundle.get("envelope")
|
|
69
|
+
statement = bundle.get("statement")
|
|
70
|
+
if not isinstance(envelope, dict) or not isinstance(statement, dict):
|
|
71
|
+
result["issues"] = ["attestation envelope and statement must be objects"]
|
|
72
|
+
return result, EXIT_ENVELOPE
|
|
73
|
+
if envelope.get("payload_type") != PAYLOAD_TYPE:
|
|
74
|
+
issues.append("unsupported payload_type")
|
|
75
|
+
result["issues"] = issues
|
|
76
|
+
return result, EXIT_ENVELOPE
|
|
77
|
+
try:
|
|
78
|
+
body = base64.b64decode(str(envelope.get("payload_base64") or ""), validate=True)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
result["issues"] = [f"payload_base64 is invalid: {exc}"]
|
|
81
|
+
return result, EXIT_PAYLOAD
|
|
82
|
+
if sha256_bytes(body) != envelope.get("payload_sha256"):
|
|
83
|
+
issues.append("payload sha256 mismatch")
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(body.decode("utf-8"))
|
|
86
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
87
|
+
issues.append(f"payload is not canonical UTF-8 JSON: {exc}")
|
|
88
|
+
result["issues"] = issues
|
|
89
|
+
return result, EXIT_PAYLOAD
|
|
90
|
+
if not isinstance(payload, dict) or payload != statement or canonical_json_bytes(payload) != body:
|
|
91
|
+
issues.append("payload does not exactly match canonical statement")
|
|
92
|
+
predicate = statement.get("predicate") if isinstance(statement, dict) else None
|
|
93
|
+
if not isinstance(predicate, dict):
|
|
94
|
+
issues.append("statement predicate is missing")
|
|
95
|
+
else:
|
|
96
|
+
result["bindings"] = {"task_id": predicate.get("task_id"), "scene": predicate.get("scene"), "context_hash": predicate.get("context_hash"), "source_sha256": predicate.get("source_sha256"), "manifest_sha256": predicate.get("manifest_sha256"), "motion_ir_sha256": predicate.get("motion_ir_sha256")}
|
|
97
|
+
if expected_task_id is not None and predicate.get("task_id") != expected_task_id:
|
|
98
|
+
issues.append("task_id does not match expected binding")
|
|
99
|
+
if expected_scene is not None and predicate.get("scene") != expected_scene:
|
|
100
|
+
issues.append("scene does not match expected binding")
|
|
101
|
+
if issues:
|
|
102
|
+
result["issues"] = issues
|
|
103
|
+
return result, EXIT_BINDING if any("expected binding" in item for item in issues) else EXIT_PAYLOAD
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
policy = parse_json_file(policy_path, "trust policy")
|
|
107
|
+
validate_policy(policy)
|
|
108
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
109
|
+
result["issues"] = [str(exc)]
|
|
110
|
+
return result, EXIT_TRUST
|
|
111
|
+
keys = {str(item["key_id"]): item for item in policy["keys"]}
|
|
112
|
+
signatures = envelope.get("signatures")
|
|
113
|
+
if not isinstance(signatures, list) or not signatures:
|
|
114
|
+
result["issues"] = ["attestation signatures must be a non-empty array"]
|
|
115
|
+
return result, EXIT_SIGNATURE
|
|
116
|
+
now = datetime.now(timezone.utc)
|
|
117
|
+
trust_failures: list[str] = []
|
|
118
|
+
signature_failures: list[str] = []
|
|
119
|
+
for signature in signatures:
|
|
120
|
+
if not isinstance(signature, dict):
|
|
121
|
+
signature_failures.append("signature entry must be an object")
|
|
122
|
+
continue
|
|
123
|
+
key_id = str(signature.get("key_id") or "")
|
|
124
|
+
key = keys.get(key_id)
|
|
125
|
+
if key is None:
|
|
126
|
+
trust_failures.append(f"unknown signer: {key_id}")
|
|
127
|
+
continue
|
|
128
|
+
if signature.get("algorithm") != "ed25519" or key.get("algorithm") != "ed25519":
|
|
129
|
+
trust_failures.append(f"unsupported signature algorithm for signer: {key_id}")
|
|
130
|
+
continue
|
|
131
|
+
if key.get("status") != "active":
|
|
132
|
+
trust_failures.append(f"signer is not active: {key_id}")
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
if now < parse_time(key["valid_from"], f"{key_id}.valid_from") or (key.get("valid_until") and now > parse_time(key["valid_until"], f"{key_id}.valid_until")):
|
|
136
|
+
trust_failures.append(f"signer is outside validity window: {key_id}")
|
|
137
|
+
continue
|
|
138
|
+
public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(str(key["public_key_base64"]), validate=True))
|
|
139
|
+
raw_signature = base64.b64decode(str(signature.get("signature_base64") or ""), validate=True)
|
|
140
|
+
public_key.verify(raw_signature, dsse_pae(PAYLOAD_TYPE, body))
|
|
141
|
+
result["signers"].append({"key_id": key_id, "status": "verified", "algorithm": "ed25519"})
|
|
142
|
+
except InvalidSignature as exc:
|
|
143
|
+
signature_failures.append(f"signature verification failed for {key_id}: {exc}")
|
|
144
|
+
except (ValueError, TypeError) as exc:
|
|
145
|
+
signature_failures.append(f"signature encoding failed for {key_id}: {exc}")
|
|
146
|
+
issues.extend(trust_failures)
|
|
147
|
+
issues.extend(signature_failures)
|
|
148
|
+
if issues:
|
|
149
|
+
result["issues"] = issues
|
|
150
|
+
return result, EXIT_SIGNATURE if signature_failures else EXIT_TRUST
|
|
151
|
+
result["verified"] = True
|
|
152
|
+
return result, EXIT_OK
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main() -> int:
|
|
156
|
+
parser = argparse.ArgumentParser(description="Verify MotionLoom signed attestations without granting approval")
|
|
157
|
+
parser.add_argument("--attestation", required=True)
|
|
158
|
+
parser.add_argument("--trust-policy", required=True)
|
|
159
|
+
parser.add_argument("--expected-task-id")
|
|
160
|
+
parser.add_argument("--expected-scene")
|
|
161
|
+
parser.add_argument("--output")
|
|
162
|
+
args = parser.parse_args()
|
|
163
|
+
try:
|
|
164
|
+
report, code = verify_attestation(args.attestation, args.trust_policy, args.expected_task_id, args.expected_scene)
|
|
165
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
166
|
+
report, code = ({"schema_version": "1.0", "verifier": "motionloom-attestation-verifier/1.0", "verified": False, "approval": False, "issues": [str(exc)]}, EXIT_USAGE)
|
|
167
|
+
rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
168
|
+
if args.output:
|
|
169
|
+
output = Path(args.output).expanduser().absolute()
|
|
170
|
+
reject_symlink_components(output.parent)
|
|
171
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
output.write_text(rendered, encoding="utf-8")
|
|
173
|
+
print(rendered, end="")
|
|
174
|
+
return code
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build MotionLoom DSSE-compatible signed evidence attestations.
|
|
3
|
+
|
|
4
|
+
This builder authenticates an exact canonical statement. It does not verify
|
|
5
|
+
runtime evidence and it never creates approval. Verification is intentionally
|
|
6
|
+
implemented in a separate read-only command in a later phase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from cryptography.hazmat.primitives import serialization
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PAYLOAD_TYPE = "application/vnd.motionloom.attestation+json;version=1"
|
|
26
|
+
SCHEMA_VERSION = "1.0"
|
|
27
|
+
KEY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{2,127}$")
|
|
28
|
+
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
32
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sha256_bytes(value: bytes) -> str:
|
|
36
|
+
return hashlib.sha256(value).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dsse_pae(payload_type: str, body: bytes) -> bytes:
|
|
40
|
+
payload_type_bytes = payload_type.encode("utf-8")
|
|
41
|
+
return b"DSSEv1 " + str(len(payload_type_bytes)).encode("ascii") + b" " + payload_type_bytes + b" " + str(len(body)).encode("ascii") + b" " + body
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reject_symlink_components(path: Path) -> None:
|
|
45
|
+
current = Path(path.anchor) if path.anchor else Path(".")
|
|
46
|
+
for part in path.parts:
|
|
47
|
+
if part in (path.anchor, ""):
|
|
48
|
+
continue
|
|
49
|
+
current = current / part
|
|
50
|
+
if current.is_symlink():
|
|
51
|
+
raise ValueError(f"symlink path component is not allowed: {path}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_regular_file(path_value: str, label: str) -> bytes:
|
|
55
|
+
path = Path(path_value).expanduser().absolute()
|
|
56
|
+
reject_symlink_components(path)
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
raise ValueError(f"{label} must point to an existing regular file")
|
|
59
|
+
return path.read_bytes()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_json_file(path_value: str, label: str) -> dict[str, Any]:
|
|
63
|
+
try:
|
|
64
|
+
value = json.loads(read_regular_file(path_value, label).decode("utf-8"))
|
|
65
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
66
|
+
raise ValueError(f"{label} is not valid UTF-8 JSON: {exc}") from exc
|
|
67
|
+
if not isinstance(value, dict):
|
|
68
|
+
raise ValueError(f"{label} must contain a JSON object")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sha256_file(path: Path, label: str) -> str:
|
|
73
|
+
reject_symlink_components(path)
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
raise ValueError(f"{label} must point to an existing regular file")
|
|
76
|
+
return sha256_bytes(path.read_bytes())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def telemetry_bundle_sha256(task_dir: Path) -> str:
|
|
80
|
+
entries = []
|
|
81
|
+
for path in sorted(task_dir.glob("runtime-adapters/**/runtime-telemetry.json")):
|
|
82
|
+
if not path.is_file() or task_dir.resolve() not in path.resolve().parents:
|
|
83
|
+
raise ValueError("runtime telemetry path must stay inside task directory")
|
|
84
|
+
entries.append({"path": path.resolve().relative_to(task_dir.resolve()).as_posix(), "sha256": sha256_file(path, "runtime telemetry")})
|
|
85
|
+
if not entries:
|
|
86
|
+
raise ValueError("task directory must contain runtime telemetry files")
|
|
87
|
+
return sha256_bytes(canonical_json_bytes(entries))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_private_key(path_value: str) -> Ed25519PrivateKey:
|
|
91
|
+
raw = read_regular_file(path_value, "private key").strip()
|
|
92
|
+
try:
|
|
93
|
+
if raw.startswith(b"-----BEGIN"):
|
|
94
|
+
key = serialization.load_pem_private_key(raw, password=None)
|
|
95
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
96
|
+
raise ValueError("private key must be Ed25519")
|
|
97
|
+
return key
|
|
98
|
+
try:
|
|
99
|
+
decoded = base64.b64decode(raw, validate=True)
|
|
100
|
+
except ValueError:
|
|
101
|
+
decoded = raw
|
|
102
|
+
if len(decoded) != 32:
|
|
103
|
+
raise ValueError("raw Ed25519 private key must be exactly 32 bytes or PEM encoded")
|
|
104
|
+
return Ed25519PrivateKey.from_private_bytes(decoded)
|
|
105
|
+
except (ValueError, TypeError) as exc:
|
|
106
|
+
raise ValueError(f"invalid Ed25519 private key: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_statement(statement: dict[str, Any]) -> None:
|
|
110
|
+
if statement.get("type") != "https://motionloom.dev/attestation/v1":
|
|
111
|
+
raise ValueError("statement.type must be https://motionloom.dev/attestation/v1")
|
|
112
|
+
if statement.get("predicate_type") != "https://motionloom.dev/predicate/animation-evidence/v1":
|
|
113
|
+
raise ValueError("statement.predicate_type is unsupported")
|
|
114
|
+
subjects = statement.get("subject")
|
|
115
|
+
if not isinstance(subjects, list) or not subjects:
|
|
116
|
+
raise ValueError("statement.subject must be a non-empty array")
|
|
117
|
+
for index, subject in enumerate(subjects):
|
|
118
|
+
if not isinstance(subject, dict) or not isinstance(subject.get("name"), str) or not subject["name"].strip():
|
|
119
|
+
raise ValueError(f"statement.subject[{index}].name is required")
|
|
120
|
+
digest = subject.get("digest")
|
|
121
|
+
if not isinstance(digest, dict) or not SHA256_RE.fullmatch(str(digest.get("sha256") or "")):
|
|
122
|
+
raise ValueError(f"statement.subject[{index}].digest.sha256 must be lowercase sha256")
|
|
123
|
+
predicate = statement.get("predicate")
|
|
124
|
+
if not isinstance(predicate, dict):
|
|
125
|
+
raise ValueError("statement.predicate must be an object")
|
|
126
|
+
required = ("task_id", "scene", "context_hash", "source_sha256", "manifest_sha256", "motion_ir_sha256", "evidence", "provenance_chain_hash", "policy_version", "generated_at", "builder")
|
|
127
|
+
missing = [field for field in required if field not in predicate]
|
|
128
|
+
if missing:
|
|
129
|
+
raise ValueError(f"statement.predicate missing fields: {', '.join(missing)}")
|
|
130
|
+
for field in ("context_hash", "source_sha256", "manifest_sha256", "motion_ir_sha256", "provenance_chain_hash"):
|
|
131
|
+
if not SHA256_RE.fullmatch(str(predicate.get(field) or "")):
|
|
132
|
+
raise ValueError(f"statement.predicate.{field} must be lowercase sha256")
|
|
133
|
+
evidence = predicate["evidence"]
|
|
134
|
+
if not isinstance(evidence, dict) or any(not SHA256_RE.fullmatch(str(evidence.get(field) or "")) for field in ("runtime_evidence_sha256", "runtime_telemetry_sha256", "verifier_report_sha256")):
|
|
135
|
+
raise ValueError("statement.predicate.evidence must bind runtime evidence, telemetry and verifier report hashes")
|
|
136
|
+
try:
|
|
137
|
+
datetime.fromisoformat(str(predicate["generated_at"]).replace("Z", "+00:00"))
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise ValueError("statement.predicate.generated_at must be ISO-8601 date-time") from exc
|
|
140
|
+
if not isinstance(predicate["builder"], dict) or not predicate["builder"].get("name") or not predicate["builder"].get("version"):
|
|
141
|
+
raise ValueError("statement.predicate.builder.name and version are required")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_statement(scene_dir_path: str, task_dir_path: str, context_path: str, output_path: str) -> dict[str, Any]:
|
|
145
|
+
scene_dir = Path(scene_dir_path).expanduser().absolute()
|
|
146
|
+
task_dir = Path(task_dir_path).expanduser().absolute()
|
|
147
|
+
context = Path(context_path).expanduser().absolute()
|
|
148
|
+
reject_symlink_components(scene_dir)
|
|
149
|
+
reject_symlink_components(task_dir)
|
|
150
|
+
manifest_path = scene_dir / "manifest.json"
|
|
151
|
+
manifest = parse_json_file(str(manifest_path), "scene manifest")
|
|
152
|
+
task = parse_json_file(str(task_dir / "task.json"), "task")
|
|
153
|
+
source_name = str(manifest.get("file") or "")
|
|
154
|
+
source_path = (scene_dir / source_name).resolve()
|
|
155
|
+
if not source_name or scene_dir.resolve() not in source_path.parents:
|
|
156
|
+
raise ValueError("scene manifest.file must point inside scene directory")
|
|
157
|
+
motion_ir_path = task_dir / "motion-ir.json"
|
|
158
|
+
runtime_evidence_path = task_dir / "runtime-adapters" / "runtime-evidence.json"
|
|
159
|
+
verifier_report_path = task_dir / "evidence-verifier-report.json"
|
|
160
|
+
provenance_path = task_dir / "provenance.json"
|
|
161
|
+
statement = {
|
|
162
|
+
"type": "https://motionloom.dev/attestation/v1",
|
|
163
|
+
"predicate_type": "https://motionloom.dev/predicate/animation-evidence/v1",
|
|
164
|
+
"subject": [{"name": scene_dir.name, "digest": {"sha256": sha256_file(manifest_path, "scene manifest")}}],
|
|
165
|
+
"predicate": {
|
|
166
|
+
"task_id": str(task.get("task_id") or ""),
|
|
167
|
+
"scene": scene_dir.name,
|
|
168
|
+
"context_hash": sha256_file(context, "project context"),
|
|
169
|
+
"source_sha256": sha256_file(source_path, "scene source"),
|
|
170
|
+
"manifest_sha256": sha256_file(manifest_path, "scene manifest"),
|
|
171
|
+
"motion_ir_sha256": sha256_file(motion_ir_path, "Motion IR"),
|
|
172
|
+
"evidence": {
|
|
173
|
+
"runtime_evidence_sha256": sha256_file(runtime_evidence_path, "runtime evidence"),
|
|
174
|
+
"runtime_telemetry_sha256": telemetry_bundle_sha256(task_dir),
|
|
175
|
+
"verifier_report_sha256": sha256_file(verifier_report_path, "evidence verifier report"),
|
|
176
|
+
},
|
|
177
|
+
"provenance_chain_hash": sha256_file(provenance_path, "provenance"),
|
|
178
|
+
"policy_version": "1.0",
|
|
179
|
+
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
180
|
+
"builder": {"name": "motionloom-attestation-builder", "version": "1.0.0"},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
if not statement["predicate"]["task_id"]:
|
|
184
|
+
raise ValueError("task.task_id is required")
|
|
185
|
+
validate_statement(statement)
|
|
186
|
+
output = Path(output_path).expanduser().absolute()
|
|
187
|
+
reject_symlink_components(output.parent)
|
|
188
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
output.write_text(json.dumps(statement, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
190
|
+
return statement
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build(statement_path: str, private_key_path: str, key_id: str, output_path: str) -> dict[str, Any]:
|
|
194
|
+
if not KEY_ID_RE.fullmatch(key_id):
|
|
195
|
+
raise ValueError("key-id has an unsafe format")
|
|
196
|
+
statement = parse_json_file(statement_path, "statement")
|
|
197
|
+
validate_statement(statement)
|
|
198
|
+
body = canonical_json_bytes(statement)
|
|
199
|
+
private_key = load_private_key(private_key_path)
|
|
200
|
+
signature = private_key.sign(dsse_pae(PAYLOAD_TYPE, body))
|
|
201
|
+
bundle = {
|
|
202
|
+
"schema_version": SCHEMA_VERSION,
|
|
203
|
+
"envelope": {
|
|
204
|
+
"payload_type": PAYLOAD_TYPE,
|
|
205
|
+
"payload_sha256": sha256_bytes(body),
|
|
206
|
+
"payload_base64": base64.b64encode(body).decode("ascii"),
|
|
207
|
+
"signatures": [{"key_id": key_id, "algorithm": "ed25519", "signature_base64": base64.b64encode(signature).decode("ascii")}],
|
|
208
|
+
},
|
|
209
|
+
"statement": statement,
|
|
210
|
+
"approval": False,
|
|
211
|
+
}
|
|
212
|
+
output = Path(output_path).expanduser().absolute()
|
|
213
|
+
reject_symlink_components(output.parent)
|
|
214
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
output.write_text(json.dumps(bundle, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
216
|
+
return bundle
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def validate_policy(policy: dict[str, Any]) -> None:
|
|
220
|
+
if policy.get("schema_version") != SCHEMA_VERSION:
|
|
221
|
+
raise ValueError("trust policy schema_version must be 1.0")
|
|
222
|
+
if not isinstance(policy.get("policy_id"), str) or not policy["policy_id"].strip():
|
|
223
|
+
raise ValueError("trust policy policy_id is required")
|
|
224
|
+
keys = policy.get("keys")
|
|
225
|
+
if not isinstance(keys, list) or not keys:
|
|
226
|
+
raise ValueError("trust policy keys must be a non-empty array")
|
|
227
|
+
seen: set[str] = set()
|
|
228
|
+
for index, key in enumerate(keys):
|
|
229
|
+
if not isinstance(key, dict):
|
|
230
|
+
raise ValueError(f"trust policy key {index} must be an object")
|
|
231
|
+
key_id = str(key.get("key_id") or "")
|
|
232
|
+
if not KEY_ID_RE.fullmatch(key_id) or key_id in seen:
|
|
233
|
+
raise ValueError(f"trust policy key {index} has an invalid or duplicate key_id")
|
|
234
|
+
seen.add(key_id)
|
|
235
|
+
if key.get("algorithm") != "ed25519":
|
|
236
|
+
raise ValueError(f"trust policy key {key_id} must use ed25519")
|
|
237
|
+
try:
|
|
238
|
+
public_key = base64.b64decode(str(key.get("public_key_base64") or ""), validate=True)
|
|
239
|
+
if len(public_key) != 32:
|
|
240
|
+
raise ValueError("public key must be 32 bytes")
|
|
241
|
+
except ValueError as exc:
|
|
242
|
+
raise ValueError(f"trust policy key {key_id} has invalid public key: {exc}") from exc
|
|
243
|
+
if key.get("status") == "revoked" and not key.get("revoked_at"):
|
|
244
|
+
raise ValueError(f"revoked key {key_id} must include revoked_at")
|
|
245
|
+
if key.get("status") == "revoked" and not key.get("revocation_reason"):
|
|
246
|
+
raise ValueError(f"revoked key {key_id} must include revocation_reason")
|
|
247
|
+
rotation = policy.get("rotation")
|
|
248
|
+
if not isinstance(rotation, dict) or rotation.get("require_active_signer") is not True:
|
|
249
|
+
raise ValueError("trust policy must require an active signer")
|
|
250
|
+
revocation = policy.get("revocation")
|
|
251
|
+
if not isinstance(revocation, dict) or revocation.get("fail_closed") is not True:
|
|
252
|
+
raise ValueError("trust policy revocation must be fail-closed")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main() -> int:
|
|
256
|
+
parser = argparse.ArgumentParser(description="Build MotionLoom signed evidence attestations")
|
|
257
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
258
|
+
build_parser = subparsers.add_parser("build", help="sign a canonical MotionLoom statement")
|
|
259
|
+
build_parser.add_argument("--statement", required=True)
|
|
260
|
+
build_parser.add_argument("--private-key", required=True)
|
|
261
|
+
build_parser.add_argument("--key-id", required=True)
|
|
262
|
+
build_parser.add_argument("--output", required=True)
|
|
263
|
+
statement_parser = subparsers.add_parser("statement", help="derive a canonical statement from scene and task artifacts")
|
|
264
|
+
statement_parser.add_argument("--scene-dir", required=True)
|
|
265
|
+
statement_parser.add_argument("--task-dir", required=True)
|
|
266
|
+
statement_parser.add_argument("--context", required=True)
|
|
267
|
+
statement_parser.add_argument("--output", required=True)
|
|
268
|
+
policy_parser = subparsers.add_parser("validate-policy", help="validate a trust policy without verifying an attestation")
|
|
269
|
+
policy_parser.add_argument("--policy", required=True)
|
|
270
|
+
args = parser.parse_args()
|
|
271
|
+
try:
|
|
272
|
+
if args.command == "build":
|
|
273
|
+
bundle = build(args.statement, args.private_key, args.key_id, args.output)
|
|
274
|
+
print(json.dumps({"built": True, "approval": bundle["approval"], "key_id": args.key_id, "payload_sha256": bundle["envelope"]["payload_sha256"]}, indent=2))
|
|
275
|
+
elif args.command == "statement":
|
|
276
|
+
statement = build_statement(args.scene_dir, args.task_dir, args.context, args.output)
|
|
277
|
+
print(json.dumps({"built": True, "task_id": statement["predicate"]["task_id"], "scene": statement["predicate"]["scene"]}, indent=2))
|
|
278
|
+
else:
|
|
279
|
+
validate_policy(parse_json_file(args.policy, "trust policy"))
|
|
280
|
+
print(json.dumps({"valid": True, "policy": args.policy}, indent=2))
|
|
281
|
+
return 0
|
|
282
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
283
|
+
print(f"attestation error: {exc}", file=sys.stderr)
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|