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/__init__.py +10 -0
- reference/disclosure.py +144 -0
- reference/level1_lint.py +97 -0
- reference/producer.py +647 -0
- reference/standalone/__init__.py +6 -0
- reference/standalone/verify_envelope.py +470 -0
- scpe/__init__.py +2 -0
- scpe/analyze.py +67 -0
- scpe/attestation.py +361 -0
- scpe/backends.py +93 -0
- scpe/changes.py +90 -0
- scpe/checks.py +96 -0
- scpe/cli.py +1020 -0
- scpe/contribute.py +142 -0
- scpe/envelope.py +211 -0
- scpe/handshake.py +229 -0
- scpe/identity.py +245 -0
- scpe/inspect.py +73 -0
- scpe/label.py +153 -0
- scpe/mcp_server.py +334 -0
- scpe/optin.py +79 -0
- scpe/prompting.py +33 -0
- scpe/repo_snapshot.py +74 -0
- scpe/sandbox.py +196 -0
- scpe/scrub.py +48 -0
- scpe/seal.py +117 -0
- scpe/signing.py +55 -0
- scpe/workspace.py +189 -0
- scpe_protocol-0.1.2.dist-info/METADATA +221 -0
- scpe_protocol-0.1.2.dist-info/RECORD +35 -0
- scpe_protocol-0.1.2.dist-info/WHEEL +5 -0
- scpe_protocol-0.1.2.dist-info/entry_points.txt +4 -0
- scpe_protocol-0.1.2.dist-info/licenses/LICENSE +202 -0
- scpe_protocol-0.1.2.dist-info/licenses/LICENSE-SPEC +10 -0
- scpe_protocol-0.1.2.dist-info/top_level.txt +2 -0
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
|
+
"""
|