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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/SKILL.md +122 -0
  4. package/agent-card.json +161 -0
  5. package/assets/library/ATTRIBUTION.md +6 -0
  6. package/assets/library/README.md +20 -0
  7. package/assets/library/avatar-base.svg +19 -0
  8. package/assets/library/error-alert.json +1 -0
  9. package/assets/library/rive/ATTRIBUTION.md +12 -0
  10. package/assets/library/rive/state-machine-test.riv +0 -0
  11. package/assets/library/success-check.json +1 -0
  12. package/bin/motionloom.mjs +81 -0
  13. package/docs/BROWSER-REVIEW-E2E.md +78 -0
  14. package/docs/CATEGORIES.md +16 -0
  15. package/docs/CHECKLIST.md +31 -0
  16. package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
  17. package/docs/FRAMEWORK-SELECTION.md +26 -0
  18. package/docs/PROJECT-MANIFEST.md +37 -0
  19. package/docs/ROADMAP-INTELLIGENCE.md +224 -0
  20. package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
  21. package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
  22. package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
  23. package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
  24. package/docs/releases/1.5.0.md +25 -0
  25. package/docs/releases/1.6.0.md +27 -0
  26. package/docs/releases/1.7.0.md +23 -0
  27. package/docs/releases/1.8.0.md +23 -0
  28. package/docs/releases/1.9.0.md +21 -0
  29. package/docs/releases/2.0.0.md +21 -0
  30. package/docs/releases/npm-publish-from-workstation.md +88 -0
  31. package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
  32. package/examples/report-demo/REPORT.md +50 -0
  33. package/examples/report-demo/artifact-manifest.json +25 -0
  34. package/examples/report-demo/decision-log.jsonl +0 -0
  35. package/examples/report-demo/execution-report.json +70 -0
  36. package/examples/report-demo/handoff.json +22 -0
  37. package/examples/report-demo/issue-register.json +5 -0
  38. package/examples/report-demo/task.json +13 -0
  39. package/package.json +95 -0
  40. package/project-context.example.json +26 -0
  41. package/references/browser-review-contract.md +32 -0
  42. package/references/dotlottie-source-notes.md +21 -0
  43. package/references/intelligence-core.md +98 -0
  44. package/references/reporting-contract.md +38 -0
  45. package/references/runtime-capability.md +12 -0
  46. package/references/signed-attestation.md +31 -0
  47. package/schemas/artifact-manifest.schema.json +22 -0
  48. package/schemas/browser-review-candidate.schema.json +24 -0
  49. package/schemas/capability-registry.schema.json +61 -0
  50. package/schemas/continuity-report.schema.json +52 -0
  51. package/schemas/evidence-verifier-report.schema.json +35 -0
  52. package/schemas/execution-report.schema.json +35 -0
  53. package/schemas/fix-plan.schema.json +44 -0
  54. package/schemas/handoff.schema.json +19 -0
  55. package/schemas/motion-ir.schema.json +74 -0
  56. package/schemas/project-graph.schema.json +70 -0
  57. package/schemas/provenance.schema.json +76 -0
  58. package/schemas/runtime-evidence.schema.json +48 -0
  59. package/schemas/runtime-telemetry.schema.json +50 -0
  60. package/schemas/scene-manifest.schema.json +45 -0
  61. package/schemas/semantic-benchmark.schema.json +26 -0
  62. package/schemas/semantic-lint-report.schema.json +48 -0
  63. package/schemas/signed-attestation.schema.json +95 -0
  64. package/schemas/task.schema.json +39 -0
  65. package/schemas/trust-policy.schema.json +53 -0
  66. package/scripts/analyze.sh +13 -0
  67. package/scripts/attestation-keygen.py +63 -0
  68. package/scripts/attestation-verifier.py +178 -0
  69. package/scripts/attestation.py +288 -0
  70. package/scripts/capture-runtime-telemetry.sh +37 -0
  71. package/scripts/devlab.sh +77 -0
  72. package/scripts/eval-intelligence.py +377 -0
  73. package/scripts/evidence-verifier.py +222 -0
  74. package/scripts/fetch-library.sh +57 -0
  75. package/scripts/intelligence.py +1543 -0
  76. package/scripts/manifest.py +61 -0
  77. package/scripts/pr.sh +103 -0
  78. package/scripts/quality-gate.py +378 -0
  79. package/scripts/render-node.mjs +53 -0
  80. package/scripts/render.sh +37 -0
  81. package/scripts/report-contract.py +181 -0
  82. package/scripts/report.py +588 -0
  83. package/scripts/review-hook.py +199 -0
  84. package/scripts/runtime-adapters.mjs +187 -0
  85. package/scripts/skill-doctor.py +150 -0
  86. package/scripts/to-dotlottie.mjs +99 -0
  87. package/scripts/to-dotlottie.sh +25 -0
  88. package/scripts/validate-lottie.py +102 -0
  89. package/src/core/analyzer.py +226 -0
  90. package/src/core/snapshot.py +124 -0
  91. package/src/core/spec.py +240 -0
  92. package/src/output/browser-review-smoke/animation.json +57 -0
  93. package/src/output/browser-review-smoke/browser-review.json +21 -0
  94. package/src/output/browser-review-smoke/manifest.json +22 -0
  95. package/src/output/browser-review-smoke/motion-spec.json +28 -0
  96. package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
  97. package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
  98. package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
  99. package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
  100. package/src/rig/README.md +35 -0
  101. package/src/rig/cutout_rig.py +211 -0
  102. package/templates/framer-motion/ui-micro.tsx +50 -0
  103. package/templates/gsap/scroll-scene.js +54 -0
  104. package/templates/lottie/README.md +21 -0
  105. package/templates/lottie/react-component.tsx +82 -0
  106. package/templates/lottie/scaffold/animation.json +57 -0
  107. package/templates/lottie/scaffold/character-rig.svg +19 -0
  108. package/templates/lottie/vanilla.js +68 -0
  109. package/templates/rive/README.md +36 -0
  110. package/tests/evals/intelligence-cases.json +131 -0
  111. package/tests/scripts/run_tests.py +843 -0
  112. 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())