scpe-protocol 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reference/producer.py ADDED
@@ -0,0 +1,647 @@
1
+ #!/usr/bin/env python3
2
+ """SCPE scpe/0.1 reference PRODUCER — the signing side of the reference impl.
3
+
4
+ Companion to reference/standalone/verify_envelope.py (the verifier). Everything this
5
+ module emits MUST verify, byte for byte, through that file. Like the verifier this is
6
+ a small, self-contained module: stdlib only, the only external binaries are
7
+ `ssh-keygen` (OpenSSH >= 8.2), `git`, and — for `submit` — `gh`.
8
+
9
+ Subcommands (see main()):
10
+ pack compute a diff, normalize (SPEC §6), sign the manifest (SPEC §7),
11
+ zip -> envelope (manifest.json + manifest.sig + diff.patch) (SPEC §4)
12
+ pack-artifact seal a file as an `artifact` subject (SPEC §6.2): sign the manifest,
13
+ zip -> standalone envelope (manifest.json + manifest.sig + artifact.bin)
14
+ attest re-wrap an envelope as the compact PR-body attestation (SPEC §9):
15
+ manifest + sig WITHOUT the diff, base64, in an SCPE-ATTESTATION-v1 block
16
+ verify run the standalone reference verifier on an envelope/attestation
17
+ submit open a native PR via `gh`, attestation in the body + diff applied
18
+
19
+ The manifest is signed as EXACT bytes (SPEC §4/§7): the same bytes are written to the
20
+ zip and fed to `ssh-keygen -Y sign`, and the verifier checks those bytes without any
21
+ re-serialization. No JSON canonicalization is involved anywhere.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import base64
27
+ import hashlib
28
+ import io
29
+ import json
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ import tempfile
34
+ import zipfile
35
+ from pathlib import Path
36
+
37
+ NAMESPACE = "scpe/0.1" # SSHSIG namespace (SPEC §7); MUST match the verifier
38
+ SPEC_VERSION = "scpe/0.1"
39
+ VERIFIER = Path(__file__).resolve().parent / "standalone" / "verify_envelope.py"
40
+
41
+ # Profile registry (SPEC §13.1). A profile is a LABEL + domain conventions layered on
42
+ # the artifact-agnostic core — nothing more. It adds NO verification logic: the verifier
43
+ # always checks integrity by subject.type (SPEC §6, §8 step 7), and surfaces the stamped
44
+ # label verbatim (SPEC §13.2). Here the producer uses each profile for two conventions:
45
+ # which subject.type it rides, and — for `artifact` subjects — a default `media_type` to
46
+ # stamp when the caller does not pass an explicit one (media_type is itself informational
47
+ # and unverified, SPEC §6.2). `SCPE-C` rides `code-change`, whose subject is a diff with
48
+ # no media_type. Stamping a profile is optional; an omitted profile means unstamped.
49
+ PROFILE_REGISTRY: dict[str, dict] = {
50
+ "SCPE-C": {"subject_type": "code-change", "media_type": None},
51
+ "SCPE-I": {"subject_type": "artifact", "media_type": "image/png"},
52
+ "SCPE-V": {"subject_type": "artifact", "media_type": "video/mp4"},
53
+ "SCPE-A": {"subject_type": "artifact", "media_type": "audio/mpeg"},
54
+ "SCPE-M": {"subject_type": "artifact", "media_type": "application/octet-stream"},
55
+ "SCPE-DATA": {"subject_type": "artifact", "media_type": "application/octet-stream"},
56
+ "SCPE-D": {"subject_type": "artifact", "media_type": "application/pdf"},
57
+ "SCPE-AR": {"subject_type": "artifact", "media_type": "application/octet-stream"},
58
+ }
59
+ PROFILES = tuple(PROFILE_REGISTRY)
60
+ ATTESTATION_RE = re.compile(
61
+ r"<!--\s*SCPE-ATTESTATION-v1\s*\n(.*?)\n\s*-->", re.DOTALL)
62
+
63
+
64
+ class ProducerError(RuntimeError):
65
+ """A precondition failed (git/ssh-keygen missing, bad input) — never a silent default."""
66
+
67
+
68
+ def _run(args: list[str], *, cwd: str | None = None,
69
+ stdin: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess:
70
+ try:
71
+ proc = subprocess.run(args, cwd=cwd, input=stdin, capture_output=True)
72
+ except FileNotFoundError as exc:
73
+ raise ProducerError(f"{args[0]} not found — install it and retry") from exc
74
+ if check and proc.returncode != 0:
75
+ raise ProducerError(
76
+ f"{' '.join(args[:3])} failed: {proc.stderr.decode('utf-8', 'replace').strip()}")
77
+ return proc
78
+
79
+
80
+ # ---------------------------------------------------------- normalization (SPEC §6)
81
+
82
+ def normalize_diff(raw: bytes) -> bytes:
83
+ """SPEC §6: CRLF/CR -> LF, exactly one trailing newline, at the BYTE level.
84
+ Byte-identical to the verifier's normalize_diff — the two MUST agree or nothing
85
+ round-trips. Operates on raw bytes (never decodes) so the anchor is well-defined
86
+ even for a diff that is not valid UTF-8."""
87
+ text = raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
88
+ return text.rstrip(b"\n") + b"\n"
89
+
90
+
91
+ def diff_sha256(diff_normalized: bytes) -> str:
92
+ return hashlib.sha256(diff_normalized).hexdigest()
93
+
94
+
95
+ # ---------------------------------------------------------------- diff from git (§6)
96
+
97
+ def compute_diff(repo: Path, base: str, head: str | None) -> tuple[bytes, list[str], dict, str, str]:
98
+ """Return (normalized_diff_bytes, files_changed, stats, base_sha, head_sha).
99
+
100
+ base...head (three-dot, matching the verifier's `git diff <base_sha>...<head>`) when
101
+ `head` is given; otherwise base -> working tree. The diff is normalized per SPEC §6
102
+ before it is ever hashed or written, so the enclosed diff.patch and change.diff_sha256
103
+ are derived from the same bytes.
104
+ """
105
+ if not (repo / ".git").exists():
106
+ raise ProducerError(f"{repo} is not a git repository")
107
+ spec = f"{base}...{head}" if head else base
108
+ diff_raw = _run(["git", "-C", str(repo), "diff", spec]).stdout
109
+ numstat = _run(["git", "-C", str(repo), "diff", "--numstat", spec]).stdout
110
+ files: list[str] = []
111
+ insertions = deletions = 0
112
+ for line in numstat.decode("utf-8", "replace").splitlines():
113
+ parts = line.split("\t")
114
+ if len(parts) != 3:
115
+ continue
116
+ add, rem, path = parts
117
+ insertions += int(add) if add.isdigit() else 0
118
+ deletions += int(rem) if rem.isdigit() else 0
119
+ files.append(path)
120
+ base_sha = _run(["git", "-C", str(repo), "rev-parse", base]).stdout.decode().strip()
121
+ head_ref = head if head else "HEAD"
122
+ head_sha = _run(["git", "-C", str(repo), "rev-parse", head_ref]).stdout.decode().strip()
123
+ return (normalize_diff(diff_raw), files,
124
+ {"insertions": insertions, "deletions": deletions}, base_sha, head_sha)
125
+
126
+
127
+ # ------------------------------------------------------------------ identity (§7-8)
128
+
129
+ def key_fingerprint(key_path: Path) -> str:
130
+ """The `SHA256:...` fingerprint of the signing key, for change.contributor. Reads the
131
+ public half (<key>.pub) when present, else the key itself."""
132
+ pub = Path(str(key_path) + ".pub")
133
+ target = pub if pub.exists() else key_path
134
+ out = _run(["ssh-keygen", "-lf", str(target)]).stdout.decode("utf-8", "replace")
135
+ return out.split()[1] # "256 SHA256:... comment" -> "SHA256:..."
136
+
137
+
138
+ def resolve_login_and_key(login: str | None, key: str | None) -> tuple[str, Path]:
139
+ """Explicit --login/--key run fully offline (tests, CI, air-gapped signing). When
140
+ omitted, fall back to the well-tested gh-CLI resolver in the legacy package, which
141
+ confirms the key is actually published on the account before it lets you sign."""
142
+ if login and key:
143
+ return login, Path(key)
144
+ try:
145
+ from scpe.identity import resolve_local_identity # lazy: offline path never imports it
146
+ except Exception as exc: # pragma: no cover - only when scpe unavailable
147
+ raise ProducerError(
148
+ "pass --login and --key for offline signing (gh-based resolution unavailable: "
149
+ f"{exc})") from exc
150
+ ident = resolve_local_identity(key_path=key)
151
+ return ident.login, Path(ident.key_path)
152
+
153
+
154
+ # ------------------------------------------------------------------- manifest (§4)
155
+
156
+ def _build_envelope_manifest(*, login: str, fingerprint: str, provider: str,
157
+ subject: dict, ai_mode: str, ai_notes: str | None,
158
+ created_at: str,
159
+ attestations: list[dict] | None = None,
160
+ profile: str | None = None) -> dict:
161
+ # The manifest is a signed EVIDENCE CONTAINER (SPEC §4). Identity is a
162
+ # (provider, subject-username) pair (SPEC §8): `login` is the identity username and
163
+ # `provider` names the key-resolution method from the fixed registry (default
164
+ # `github`). The top-level `subject` BLOCK (SPEC §6) says WHAT is attested and is
165
+ # type-dispatched by the verifier (`code-change` or `artifact` in scpe/0.1).
166
+ # `attestations` is a list of typed, signed claims (SPEC §5); omitted when empty.
167
+ m: dict = {
168
+ "spec_version": SPEC_VERSION,
169
+ "created_at": created_at,
170
+ "contributor": {
171
+ "identity": {"provider": provider, "subject": login},
172
+ "key_fingerprint": fingerprint,
173
+ },
174
+ "subject": subject,
175
+ "ai_disclosure": {"mode": ai_mode},
176
+ }
177
+ if ai_notes is not None:
178
+ m["ai_disclosure"]["notes"] = ai_notes
179
+ # `profile` (SPEC §4/§13) is an advisory domain-convention LABEL. It carries no
180
+ # integrity path: the producer stamps it, the verifier surfaces it but verifies by
181
+ # subject.type. Omitted -> unstamped. Placed after ai_disclosure, before attestations,
182
+ # matching the SPEC §4 field order.
183
+ if profile is not None:
184
+ m["profile"] = profile
185
+ if attestations:
186
+ m["attestations"] = attestations
187
+ return m
188
+
189
+
190
+ def build_manifest(*, login: str, fingerprint: str, repo: str, base_sha: str,
191
+ dsha: str, head_sha: str, files: list[str], stats: dict,
192
+ ai_mode: str, ai_notes: str | None, created_at: str,
193
+ attestations: list[dict] | None = None,
194
+ provider: str = "github", profile: str | None = None) -> dict:
195
+ # A `code-change` subject (SPEC §6.1): the implemented default. Nests today's
196
+ # target + change verbatim under the typed `subject` block.
197
+ subject = {
198
+ "type": "code-change",
199
+ "target": {"repo": repo, "base_sha": base_sha},
200
+ "change": {
201
+ "diff_sha256": dsha,
202
+ "head_sha": head_sha,
203
+ "files_changed": files,
204
+ "stats": stats,
205
+ },
206
+ }
207
+ return _build_envelope_manifest(
208
+ login=login, fingerprint=fingerprint, provider=provider, subject=subject,
209
+ ai_mode=ai_mode, ai_notes=ai_notes, created_at=created_at,
210
+ attestations=attestations, profile=profile)
211
+
212
+
213
+ def build_artifact_manifest(*, login: str, fingerprint: str, digest_sha256: str,
214
+ media_type: str, ai_mode: str, ai_notes: str | None,
215
+ created_at: str, attestations: list[dict] | None = None,
216
+ provider: str = "github", profile: str | None = None) -> dict:
217
+ # An `artifact` subject (SPEC §6.2): a hash-addressed digital artifact. The
218
+ # standalone envelope carries the bytes as `artifact.bin`; the verifier hashes them
219
+ # RAW and compares to subject.digest.sha256.
220
+ subject = {
221
+ "type": "artifact",
222
+ "digest": {"sha256": digest_sha256},
223
+ "media_type": media_type,
224
+ }
225
+ return _build_envelope_manifest(
226
+ login=login, fingerprint=fingerprint, provider=provider, subject=subject,
227
+ ai_mode=ai_mode, ai_notes=ai_notes, created_at=created_at,
228
+ attestations=attestations, profile=profile)
229
+
230
+
231
+ def serialize_manifest(m: dict) -> bytes:
232
+ """The EXACT bytes that get signed AND zipped (SPEC §4). Any stable serialization is
233
+ conformant; we mirror the test vectors (indent=2, UTF-8, no trailing newline)."""
234
+ return json.dumps(m, indent=2, ensure_ascii=False).encode("utf-8")
235
+
236
+
237
+ def sign_manifest(manifest_bytes: bytes, key_path: Path) -> bytes:
238
+ """SSHSIG over the exact manifest bytes, namespace scpe/0.1 (SPEC §7). Returns the
239
+ armored .sig bytes. Signs the very bytes passed in — never a re-serialization."""
240
+ with tempfile.TemporaryDirectory(prefix="scpe-sign-") as td:
241
+ mp = Path(td) / "manifest.json"
242
+ mp.write_bytes(manifest_bytes)
243
+ _run(["ssh-keygen", "-Y", "sign", "-f", str(key_path), "-n", NAMESPACE, str(mp)])
244
+ sig = Path(str(mp) + ".sig")
245
+ if not sig.is_file():
246
+ raise ProducerError("ssh-keygen produced no signature")
247
+ return sig.read_bytes()
248
+
249
+
250
+ def _zip_bytes(members: list[tuple[str, bytes]]) -> bytes:
251
+ """Deterministic zip of exactly `members` (name, bytes) — flat, no directories, so it
252
+ passes the verifier's strict membership check (SPEC §4)."""
253
+ buf = io.BytesIO()
254
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
255
+ for name, data in members:
256
+ zi = zipfile.ZipInfo(name)
257
+ zi.compress_type = zipfile.ZIP_DEFLATED
258
+ zf.writestr(zi, data)
259
+ return buf.getvalue()
260
+
261
+
262
+ # -------------------------------------------------------------------------- pack (§4)
263
+
264
+ def pack(*, repo: Path, base: str, head: str | None, out: Path,
265
+ login: str | None, key: str | None, ai_mode: str = "none",
266
+ ai_notes: str | None = None, created_at: str,
267
+ repo_name: str | None = None, attestations: list[dict] | None = None,
268
+ provider: str = "github", profile: str | None = None) -> Path:
269
+ login, key_path = resolve_login_and_key(login, key)
270
+ fpr = key_fingerprint(key_path)
271
+ diff_bytes, files, stats, base_sha, head_sha = compute_diff(repo, base, head)
272
+ dsha = diff_sha256(diff_bytes)
273
+ target_repo = repo_name or _origin_slug(repo) or repo.name
274
+ m = build_manifest(login=login, fingerprint=fpr, repo=target_repo, base_sha=base_sha,
275
+ dsha=dsha, head_sha=head_sha, files=files, stats=stats,
276
+ ai_mode=ai_mode, ai_notes=ai_notes, created_at=created_at,
277
+ attestations=attestations, provider=provider, profile=profile)
278
+ manifest_bytes = serialize_manifest(m)
279
+ sig_bytes = sign_manifest(manifest_bytes, key_path)
280
+ envelope = _zip_bytes([
281
+ ("manifest.json", manifest_bytes),
282
+ ("manifest.sig", sig_bytes),
283
+ ("diff.patch", diff_bytes),
284
+ ])
285
+ out.write_bytes(envelope)
286
+ return out
287
+
288
+
289
+ def resolve_media_type(media_type: str | None, profile: str | None) -> str:
290
+ """Resolve the `media_type` to stamp on an `artifact` subject. An explicit
291
+ --media-type always wins; otherwise a stamped profile supplies its convention default
292
+ (SPEC §13.1). media_type is informational and unverified (SPEC §6.2) — this only
293
+ decides what advisory label is recorded, never the verify decision."""
294
+ if media_type:
295
+ return media_type
296
+ conv = PROFILE_REGISTRY.get(profile or "", {}).get("media_type")
297
+ if conv:
298
+ return conv
299
+ raise ProducerError(
300
+ "no media type: pass --media-type, or a --profile whose convention supplies one")
301
+
302
+
303
+ def pack_artifact(*, artifact: Path, media_type: str | None = None, out: Path,
304
+ login: str | None, key: str | None, ai_mode: str = "none",
305
+ ai_notes: str | None = None, created_at: str,
306
+ attestations: list[dict] | None = None,
307
+ provider: str = "github", profile: str | None = None) -> Path:
308
+ """Pack an `artifact` subject (SPEC §6.2) into a standalone envelope: the raw
309
+ artifact bytes ride as `artifact.bin`, and subject.digest.sha256 binds them. Verifies
310
+ through the standalone verifier exactly like a code-change envelope, only the payload
311
+ member differs. Artifact subjects are standalone-only (no PR-transport form).
312
+
313
+ `profile` (SPEC §13) is an advisory label stamped verbatim; when `media_type` is not
314
+ passed, the profile's convention (SPEC §13.1) supplies the default media_type. Neither
315
+ the profile nor the media_type affects verification — integrity is the raw-bytes digest
316
+ check of SPEC §6.2, unchanged."""
317
+ login, key_path = resolve_login_and_key(login, key)
318
+ fpr = key_fingerprint(key_path)
319
+ resolved_media = resolve_media_type(media_type, profile)
320
+ data = artifact.read_bytes()
321
+ digest = hashlib.sha256(data).hexdigest()
322
+ m = build_artifact_manifest(login=login, fingerprint=fpr, digest_sha256=digest,
323
+ media_type=resolved_media, ai_mode=ai_mode, ai_notes=ai_notes,
324
+ created_at=created_at, attestations=attestations,
325
+ provider=provider, profile=profile)
326
+ manifest_bytes = serialize_manifest(m)
327
+ sig_bytes = sign_manifest(manifest_bytes, key_path)
328
+ envelope = _zip_bytes([
329
+ ("manifest.json", manifest_bytes),
330
+ ("manifest.sig", sig_bytes),
331
+ ("artifact.bin", data),
332
+ ])
333
+ out.write_bytes(envelope)
334
+ return out
335
+
336
+
337
+ def _origin_slug(repo: Path) -> str | None:
338
+ """owner/name from `git remote get-url origin`, or None (local-only fixture repos)."""
339
+ proc = _run(["git", "-C", str(repo), "remote", "get-url", "origin"], check=False)
340
+ if proc.returncode != 0:
341
+ return None
342
+ return _repo_slug(proc.stdout.decode("utf-8", "replace").strip())
343
+
344
+
345
+ def _repo_slug(repo_url: str) -> str:
346
+ s = repo_url.strip().replace("https://", "").replace("http://", "")
347
+ s = s.replace("git@github.com:", "github.com/").rstrip("/")
348
+ if s.endswith(".git"):
349
+ s = s[:-4]
350
+ if s.startswith("github.com/"):
351
+ s = s[len("github.com/"):]
352
+ parts = s.split("/")
353
+ return f"{parts[0]}/{parts[1]}" if len(parts) == 2 and all(parts) else ""
354
+
355
+
356
+ # ------------------------------------------------------------------ attestation (§9)
357
+
358
+ def _read_envelope(path: Path) -> tuple[bytes, bytes, bytes | None]:
359
+ """(manifest_bytes, sig_bytes, diff_bytes|None) from an envelope zip."""
360
+ with zipfile.ZipFile(path) as zf:
361
+ names = set(zf.namelist())
362
+ if not {"manifest.json", "manifest.sig"} <= names:
363
+ raise ProducerError(f"envelope missing manifest.json/manifest.sig: {sorted(names)}")
364
+ return (zf.read("manifest.json"), zf.read("manifest.sig"),
365
+ zf.read("diff.patch") if "diff.patch" in names else None)
366
+
367
+
368
+ def attestation_block(manifest_bytes: bytes, sig_bytes: bytes) -> str:
369
+ """The SPEC §9 attestation: the envelope zip WITHOUT diff.patch, base64, wrapped in
370
+ exactly one SCPE-ATTESTATION-v1 HTML comment. Verifies identically to the standalone
371
+ envelope; only the diff (step 6) has to come from elsewhere (the PR / --diff)."""
372
+ inner = _zip_bytes([("manifest.json", manifest_bytes), ("manifest.sig", sig_bytes)])
373
+ b64 = base64.b64encode(inner).decode("ascii")
374
+ return f"<!-- SCPE-ATTESTATION-v1\n{b64}\n-->\n"
375
+
376
+
377
+ def attest(*, envelope: Path, out: Path | None) -> str:
378
+ manifest_bytes, sig_bytes, _ = _read_envelope(envelope)
379
+ block = attestation_block(manifest_bytes, sig_bytes)
380
+ if out is not None:
381
+ out.write_text(block, encoding="utf-8", newline="\n")
382
+ return block
383
+
384
+
385
+ # ----------------------------------------------------------------------- verify (§8)
386
+
387
+ def verify(path: Path, *, keys: Path | None = None, diff: Path | None = None) -> dict:
388
+ """Run the standalone reference verifier as a subprocess (exactly how an auditor runs
389
+ it) and return its JSON result. This is the SAME verifier the test vectors use — the
390
+ producer never re-implements verification, it defers to the one auditable file."""
391
+ args = [sys.executable, str(VERIFIER), str(path), "--json"]
392
+ if keys is not None:
393
+ args += ["--keys", str(keys)]
394
+ if diff is not None:
395
+ args += ["--diff", str(diff)]
396
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=120)
397
+ if not proc.stdout.strip():
398
+ raise ProducerError(f"verifier produced no output; stderr: {proc.stderr[-500:]}")
399
+ return json.loads(proc.stdout)
400
+
401
+
402
+ # ----------------------------------------------------------------------- submit (§9)
403
+
404
+ def submit(*, envelope: Path, repo: str | None, gh: str = "gh") -> str:
405
+ """Open a native PR: the diff applied to a fresh branch (a real, reviewable PR) and the
406
+ SPEC §9 attestation embedded in the PR body. Mirrors scpe.cli._cmd_submit's clean-merge
407
+ approach — the envelope never lands in the tree, so merging leaves the repo clean.
408
+
409
+ Returns the PR URL. Every git/gh call goes through subprocess.run, so tests mock it.
410
+ """
411
+ import shutil
412
+ if shutil.which(gh) is None:
413
+ raise ProducerError(
414
+ f"{gh} CLI not found — install GitHub CLI (https://cli.github.com) and "
415
+ "`gh auth login`, then retry.")
416
+ manifest_bytes, sig_bytes, diff_bytes = _read_envelope(envelope)
417
+ if diff_bytes is None:
418
+ raise ProducerError("envelope has no diff.patch to apply")
419
+ m = json.loads(manifest_bytes.decode("utf-8"))
420
+ identity = (m.get("contributor") or {}).get("identity") or {}
421
+ login = identity.get("subject") or "" # GitHub PR transport: subject == login
422
+ subject = m.get("subject") or {} # SPEC §6 evidence-container subject block
423
+ target = repo or _repo_slug((subject.get("target") or {}).get("repo") or "")
424
+ if not target:
425
+ raise ProducerError(
426
+ "could not determine the target repo from the envelope — pass --repo owner/name")
427
+ env_sha = hashlib.sha256(envelope.read_bytes()).hexdigest()
428
+ branch = f"scpe/{login}-{env_sha[:8]}" if login else f"scpe/{env_sha[:8]}"
429
+ files = (subject.get("change") or {}).get("files_changed") or []
430
+ title = f"scpe: contribution to {', '.join(files) or target}"[:100]
431
+ block = attestation_block(manifest_bytes, sig_bytes)
432
+ body = (
433
+ "The change is in **Files changed** above, like any PR.\n\n"
434
+ "It carries a signed SCPE scpe/0.1 attestation (below): portable proof of who "
435
+ f"authored it. Re-verify offline against github.com/{login}.keys with the "
436
+ "standalone `verify_envelope.py`. Nothing lands until you merge.\n\n"
437
+ + block
438
+ )
439
+ with tempfile.TemporaryDirectory(prefix="scpe-submit-") as td:
440
+ work = Path(td) / "repo"
441
+ _run([gh, "repo", "clone", target, str(work)])
442
+ _run(["git", "-C", str(work), "checkout", "-b", branch])
443
+ patch = Path(td) / "contribution.patch"
444
+ patch.write_bytes(normalize_diff(diff_bytes)) # LF, exactly as signed
445
+ ap = _run(["git", "-C", str(work), "apply", "--index", str(patch)], check=False)
446
+ if ap.returncode != 0:
447
+ raise ProducerError(
448
+ f"the contribution does not apply cleanly onto {target}: "
449
+ f"{ap.stderr.decode('utf-8', 'replace').strip()}")
450
+ _run(["git", "-C", str(work), "commit", "-m", title])
451
+ same_owner = bool(login) and target.split("/")[0].lower() == login.lower()
452
+ if same_owner:
453
+ _run(["git", "-C", str(work), "push", "--force", "origin", branch])
454
+ head = branch
455
+ else:
456
+ _run([gh, "repo", "fork", target, "--remote", "--remote-name", "fork"], cwd=str(work))
457
+ _run(["git", "-C", str(work), "push", "--force", "fork", branch])
458
+ head = f"{login}:{branch}" if login else branch
459
+ proc = _run([gh, "pr", "create", "--repo", target, "--head", head,
460
+ "--title", title, "--body", body], cwd=str(work), check=False)
461
+ if proc.returncode != 0:
462
+ raise ProducerError(
463
+ f"gh pr create failed: {proc.stderr.decode('utf-8', 'replace').strip()}")
464
+ return proc.stdout.decode("utf-8", "replace").strip()
465
+
466
+
467
+ # -------------------------------------------------------------------------------- cli
468
+
469
+ def _iso_now() -> str:
470
+ from datetime import datetime, timezone
471
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
472
+
473
+
474
+ def _load_attestations(attestations_path: str | None,
475
+ agent_trace_path: str | None) -> list[dict] | None:
476
+ """Assemble the signed `attestations[]` list (SPEC §5) from the CLI.
477
+
478
+ `--attestations FILE` carries a JSON array of ready-made attestation objects (each
479
+ with its own `type`). `--agent-trace FILE` is convenience for the common case: a
480
+ single agent-trace `{format, data}` record, wrapped here as an `agent-trace`
481
+ attestation. Both may be given; the results concatenate. Empty -> None (omit)."""
482
+ out: list[dict] = []
483
+ if attestations_path:
484
+ data = json.loads(Path(attestations_path).read_text(encoding="utf-8"))
485
+ if not isinstance(data, list) or not all(isinstance(a, dict) for a in data):
486
+ raise ProducerError(
487
+ "--attestations file must contain a JSON array of attestation objects")
488
+ out.extend(data)
489
+ if agent_trace_path:
490
+ obj = json.loads(Path(agent_trace_path).read_text(encoding="utf-8"))
491
+ if not isinstance(obj, dict):
492
+ raise ProducerError("--agent-trace file must contain a JSON object")
493
+ out.append(obj if obj.get("type") == "agent-trace"
494
+ else {"type": "agent-trace", **obj})
495
+ return out or None
496
+
497
+
498
+ def _cmd_pack(args) -> int:
499
+ out = pack(repo=Path(args.repo), base=args.base, head=args.head, out=Path(args.out),
500
+ login=args.login, key=args.key, ai_mode=args.mode, ai_notes=args.notes,
501
+ created_at=args.created_at or _iso_now(), repo_name=args.repo_name,
502
+ attestations=_load_attestations(args.attestations, args.agent_trace),
503
+ provider=args.provider, profile=args.profile)
504
+ print(f"envelope written: {out}", file=sys.stderr)
505
+ return 0
506
+
507
+
508
+ def _cmd_pack_artifact(args) -> int:
509
+ out = pack_artifact(
510
+ artifact=Path(args.artifact), media_type=args.media_type, out=Path(args.out),
511
+ login=args.login, key=args.key, ai_mode=args.mode, ai_notes=args.notes,
512
+ created_at=args.created_at or _iso_now(),
513
+ attestations=_load_attestations(args.attestations, args.agent_trace),
514
+ provider=args.provider, profile=args.profile)
515
+ print(f"artifact envelope written: {out}", file=sys.stderr)
516
+ return 0
517
+
518
+
519
+ def _cmd_attest(args) -> int:
520
+ block = attest(envelope=Path(args.envelope),
521
+ out=Path(args.out) if args.out else None)
522
+ if args.out:
523
+ print(f"attestation written: {args.out}", file=sys.stderr)
524
+ else:
525
+ sys.stdout.write(block)
526
+ return 0
527
+
528
+
529
+ def _cmd_verify(args) -> int:
530
+ res = verify(Path(args.path),
531
+ keys=Path(args.keys) if args.keys else None,
532
+ diff=Path(args.diff) if args.diff else None)
533
+ if args.json:
534
+ print(json.dumps(res))
535
+ else:
536
+ mark = "OK" if res["status"] == "verified" else "NO"
537
+ line = f"[{mark}] {res['status']}"
538
+ if res["status"] == "verified":
539
+ atts = res.get("attestations") or []
540
+ summ = ", ".join(f"{a['type']}={a['status']}" for a in atts) or "none"
541
+ line += f" (attestations: {summ})"
542
+ if res.get("profile"):
543
+ line += f" [profile: {res['profile']}]"
544
+ if res.get("detail"):
545
+ line += f" — {res['detail']}"
546
+ print(line)
547
+ return 0 if res["status"] == "verified" else 1
548
+
549
+
550
+ def _cmd_submit(args) -> int:
551
+ url = submit(envelope=Path(args.envelope), repo=args.repo)
552
+ print(f"opened PR: {url}" if url else "opened PR")
553
+ return 0
554
+
555
+
556
+ def main(argv: list[str] | None = None) -> int:
557
+ ap = argparse.ArgumentParser(prog="scpe-producer",
558
+ description="SCPE scpe/0.1 reference producer (pack / attest / verify / submit)")
559
+ sub = ap.add_subparsers(dest="cmd", required=True)
560
+
561
+ pk = sub.add_parser("pack", help="compute a diff, sign the manifest, emit an envelope zip")
562
+ pk.add_argument("--repo", default=".", help="git working copy (default .)")
563
+ pk.add_argument("--base", required=True, help="base commit the diff applies to")
564
+ pk.add_argument("--head", default=None,
565
+ help="head commit (base...head); omit to diff base -> working tree")
566
+ pk.add_argument("--out", default="envelope.zip")
567
+ pk.add_argument("--login", default=None,
568
+ help="subject/username within the provider (offline; else resolved via gh)")
569
+ pk.add_argument("--provider", default="github",
570
+ choices=["github", "gitlab", "codeberg", "local"],
571
+ help="identity provider from the fixed registry (SPEC §8; default github)")
572
+ pk.add_argument("--key", default=None, help="SSH signing private key")
573
+ pk.add_argument("--repo-name", default=None,
574
+ help="target repo slug for the manifest (default: origin, else dir name)")
575
+ pk.add_argument("--profile", default=None, choices=PROFILES,
576
+ help="stamp an advisory domain-convention label (SPEC §13; e.g. SCPE-C "
577
+ "for code). Surfaced by the verifier, never changes the verdict. "
578
+ "Omit to leave the manifest unstamped.")
579
+ pk.add_argument("--ai-disclosure", "--mode", dest="mode", default="none",
580
+ choices=["none", "assisted", "generated"],
581
+ help="ai_disclosure.mode (SPEC §4); --mode is a back-compat alias")
582
+ pk.add_argument("--notes", default=None, help="ai_disclosure.notes")
583
+ pk.add_argument("--attestations", default=None,
584
+ help="JSON file: array of signed attestation objects (SPEC §5)")
585
+ pk.add_argument("--agent-trace", default=None,
586
+ help="JSON file: a single agent-trace {format,data} record, wrapped "
587
+ "as an agent-trace attestation (convenience for --attestations)")
588
+ pk.add_argument("--created-at", default=None, help="RFC3339 timestamp (default: now)")
589
+ pk.set_defaults(fn=_cmd_pack)
590
+
591
+ pa = sub.add_parser("pack-artifact",
592
+ help="pack an artifact (a file + media type) as a standalone artifact-subject envelope")
593
+ pa.add_argument("--artifact", required=True, help="path to the artifact file to seal")
594
+ pa.add_argument("--media-type", default=None,
595
+ help="the artifact's media (MIME) type, e.g. application/zip. Optional "
596
+ "when --profile is given (the profile's convention supplies a "
597
+ "default); required otherwise.")
598
+ pa.add_argument("--profile", default=None, choices=PROFILES,
599
+ help="stamp an advisory domain-convention label (SPEC §13; e.g. SCPE-I "
600
+ "image, SCPE-M model, SCPE-DATA dataset, SCPE-D document, SCPE-AR "
601
+ "catch-all). Surfaced by the verifier, never changes the verdict; "
602
+ "also supplies the default --media-type when that is omitted.")
603
+ pa.add_argument("--out", default="envelope.zip")
604
+ pa.add_argument("--login", default=None,
605
+ help="subject/username within the provider (offline; else resolved via gh)")
606
+ pa.add_argument("--provider", default="github",
607
+ choices=["github", "gitlab", "codeberg", "local"],
608
+ help="identity provider from the fixed registry (SPEC §8; default github)")
609
+ pa.add_argument("--key", default=None, help="SSH signing private key")
610
+ pa.add_argument("--ai-disclosure", "--mode", dest="mode", default="none",
611
+ choices=["none", "assisted", "generated"],
612
+ help="ai_disclosure.mode (SPEC §4); --mode is a back-compat alias")
613
+ pa.add_argument("--notes", default=None, help="ai_disclosure.notes")
614
+ pa.add_argument("--attestations", default=None,
615
+ help="JSON file: array of signed attestation objects (SPEC §5)")
616
+ pa.add_argument("--agent-trace", default=None,
617
+ help="JSON file: a single agent-trace {format,data} record")
618
+ pa.add_argument("--created-at", default=None, help="RFC3339 timestamp (default: now)")
619
+ pa.set_defaults(fn=_cmd_pack_artifact)
620
+
621
+ at = sub.add_parser("attest", help="re-wrap an envelope as the compact PR-body attestation")
622
+ at.add_argument("envelope")
623
+ at.add_argument("--out", default=None, help="write the block to a file (default: stdout)")
624
+ at.set_defaults(fn=_cmd_attest)
625
+
626
+ vf = sub.add_parser("verify", help="run the standalone reference verifier and surface status")
627
+ vf.add_argument("path", help="envelope zip, vector dir, or a file with an attestation block")
628
+ vf.add_argument("--keys", default=None, help="offline .keys body (else network fetch)")
629
+ vf.add_argument("--diff", default=None, help="diff to check integrity against (attestation)")
630
+ vf.add_argument("--json", action="store_true")
631
+ vf.set_defaults(fn=_cmd_verify)
632
+
633
+ sm = sub.add_parser("submit", help="open a native PR (attestation in body + diff applied)")
634
+ sm.add_argument("envelope")
635
+ sm.add_argument("--repo", default=None, help="target owner/name (default: from envelope)")
636
+ sm.set_defaults(fn=_cmd_submit)
637
+
638
+ args = ap.parse_args(argv)
639
+ try:
640
+ return args.fn(args)
641
+ except ProducerError as exc:
642
+ print(f"error: {exc}", file=sys.stderr)
643
+ return 1
644
+
645
+
646
+ if __name__ == "__main__":
647
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """The SCPE standalone reference verifier — one file, stdlib only.
2
+
3
+ `verify_envelope.py` keeps its own `if __name__ == "__main__"` guard, so it stays
4
+ runnable as a plain file exactly as the spec/test-vectors invoke it; this
5
+ `__init__` only makes it a discoverable subpackage so the build ships it.
6
+ """