Michael Stattelman commited on
Commit
d191420
·
1 Parent(s): 0685a03

Add application file

Browse files
README.md CHANGED
@@ -1,15 +1,75 @@
1
  ---
2
- title: Verifier
3
- emoji: 🐢
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
- short_description: Verification tool to chack the provenance of a model package
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Provenance Verifier — Live Demo
3
+ emoji: ✔
4
+ colorFrom: gray
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 5.44.1
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
 
11
  ---
12
 
13
+ # Provenance Verifier — live demo · FALCONS.AI Model Surgeon
14
+
15
+ A public, no-account demo where anyone can watch a Model Surgeon package prove
16
+ itself. Tap a sample, drop your own `.zip` onto the grid, or point it at a
17
+ Hugging Face repo — the verdict comes straight from the unmodified, audited
18
+ `verify_attestation.py` from
19
+ [Falcons-ai/surgeon-verify](https://github.com/Falcons-ai/surgeon-verify)
20
+ (Apache-2.0), invoked as a subprocess.
21
+
22
+ Product: [surgeon.falcons.ai](https://surgeon.falcons.ai) ·
23
+ [/verify](https://surgeon.falcons.ai/verify)
24
+
25
+ > Signed creation is the product — universal verification is what makes it
26
+ > worth anything. Free, no account, forever.
27
+
28
+ ## Transport: Gradio (refactored 2026-09-03 from the FastAPI/Docker Space)
29
+
30
+ | file | role |
31
+ | --- | --- |
32
+ | `app.py` | the Gradio page and its three handlers (sample · upload · Hub repo), the cancel control, the five-deep verdict stack with receipts |
33
+ | `engine_wrapper.py` | the verification wrapper — **every function byte-identical to the FastAPI original** (`_run_engine_sync`, `_parse_stdout`, `_normalize_container`, `_junk_entry`, `_zip_declared_size`, `_resolve_and_verify_repo_sync`, `_dsse_subject_names`, `_result`) |
34
+ | `engine/verify_attestation.py` | the pinned engine, verbatim, unchanged |
35
+ | `static/style.css` | the product stylesheet, unchanged; `static/gradio.css` holds the few overrides that hide Gradio's own wrappers |
36
+ | `samples/` | SAMPLE_A (genuine) · SAMPLE_B (one byte flipped) |
37
+
38
+ What changed for the reader: sample cards are tapped rather than dragged (Gradio has no cross-component drag; drop **of your own files** onto the grid still works); the keyid chip is plain selectable text (no copy button); "recomputing digests… / fetching from the Hub…" shows as Gradio's progress text; ✕ cancel aborts the in-flight event exactly as the old AbortController did.
39
+
40
+ ## Guarantees (BUILD SPEC section 1) — how each survives the transport
41
+
42
+ - The **only** verification engine is the pinned, verbatim copy in `engine/verify_attestation.py`, invoked as a subprocess. No porting, no patching.
43
+ - **No model-parsing code** anywhere in this Space.
44
+ - **Nothing retained**: per-request temp dirs deleted in `finally`; Gradio's own upload copy is moved into that dir and deleted with it; `delete_cache=(60, 60)` sweeps anything Gradio holds; `analytics_enabled=False`; uvicorn runs with `access_log=False`; this code never logs user input.
45
+ - A missing capability is never reported as tampering.
46
+ - Weights are never re-served — the page has no file output component.
47
+
48
+ ## Configuration
49
+
50
+ | env | default | meaning |
51
+ | --- | --- | --- |
52
+ | `MAX_UPLOAD_MB` | `1024` | per-file cap for uploads and repo files (also passed to Gradio as `max_file_size`) |
53
+ | `MAX_REPO_MB` | `2048` | total cap for attested-repo subject downloads |
54
+ | `HF_TOKEN` | unset | optional, for Hub rate headroom only |
55
+ | `PORT` | `7860` | Spaces contract |
56
+
57
+ ## Local run
58
+
59
+ ```
60
+ pip install -r requirements.txt
61
+ python app.py # http://localhost:7860
62
+ ```
63
+
64
+ ## Verified before delivery (sandbox, 2026-09-03)
65
+
66
+ Handlers executed against the real engine and both samples through the Gradio
67
+ functions (with the `gradio` import stubbed — the package is not installable in
68
+ the build sandbox): SAMPLE_A → ✔ VERIFIED with keyid + receipt · SAMPLE_B →
69
+ ✘ TAMPERED with the ✖ line and signed/actual digests · a Windows-style
70
+ backslash container → repaired, VERIFIED, note shown · a text file → NOT A
71
+ SURGEON PACKAGE · over-cap → LIMIT / ERROR · bad repo id → LIMIT / ERROR · at
72
+ capacity → the same "at capacity" verdict · stack capped at five · no temp
73
+ dirs left behind, Gradio's cached upload deleted. **Not run here:** the Gradio
74
+ UI itself (first render is on the Space); fonts still fall back to system
75
+ monospace until `static/fonts/` is populated.
app.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALCONS.AI "Verify Anything" — Hugging Face Space (Gradio transport)
3
+
4
+ Refactor of the FastAPI Space to the `gradio` SDK. Look and behaviour are
5
+ preserved: the product's dark theme tokens and stylesheet, the sample cards,
6
+ the drop grid, the Hub-repo form, a cancel control, and the verdict stack
7
+ (five newest, each with the engine's receipt). The verification logic is
8
+ NOT here — it lives, byte-identical to the FastAPI original, in
9
+ `engine_wrapper.py`, and still invokes the pinned, unmodified
10
+ `engine/verify_attestation.py` as a subprocess.
11
+
12
+ Standing rules (BUILD SPEC section 1), how each survives the transport:
13
+ 1. Engine as subprocess only -> engine_wrapper._run_engine_sync
14
+ 2. No model-parsing code -> none here; JSON metadata only
15
+ 3. Nothing retained -> per-request temp dirs deleted in
16
+ `finally`; Gradio's own upload copy
17
+ is deleted in the same `finally`;
18
+ analytics off; uvicorn runs with
19
+ access_log=False; nothing logged.
20
+ 4. Missing capability != tampering -> engine_wrapper (unchanged)
21
+ 5. Weights never re-served -> the verdict stack shows text only;
22
+ no file output component exists.
23
+ """
24
+
25
+ import base64
26
+ import html
27
+ import json
28
+ import os
29
+ import shutil
30
+ import tempfile
31
+ import threading
32
+ import zipfile
33
+ from pathlib import Path
34
+
35
+ import gradio as gr
36
+
37
+ from engine_wrapper import (
38
+ BUILD_STAMP, LOCAL_CMD, MANIFEST, MAX_UPLOAD_BYTES, MAX_UPLOAD_MB,
39
+ MAX_REPO_MB, NORMALIZED_NOTE, NOT_A_PACKAGE_COPY, ROOT, SAMPLES_DIR,
40
+ _REPO_ID_RE, _normalize_container, _resolve_and_verify_repo_sync,
41
+ _result, _run_engine_sync, _zip_declared_size,
42
+ )
43
+
44
+ MAX_CONCURRENCY = 4
45
+ STACK_MAX = 5
46
+
47
+ print(f"falcons-verify build {BUILD_STAMP}")
48
+
49
+ # ------------------------------------------------------------- concurrency
50
+ # The FastAPI version counted in-flight requests under an asyncio lock and
51
+ # answered 429 when full. Gradio runs handlers in worker threads, so the same
52
+ # rule is a non-blocking semaphore: full -> the same "at capacity" verdict.
53
+
54
+ _slots = threading.Semaphore(MAX_CONCURRENCY)
55
+
56
+
57
+ def _busy_result():
58
+ return _result(
59
+ "error", None, "",
60
+ detail=("The demo is at capacity right now — give it a few seconds and "
61
+ "try again. " + LOCAL_CMD),
62
+ )
63
+
64
+
65
+ # ---------------------------------------------------------------- verdicts
66
+ # pushVerdict() from static/app.js, ported line for line. Same classes, same
67
+ # headings, same rules (SCAN F3: a TAMPERED card shows the engine's ✖ line and
68
+ # its signed/actual digest pair — never the reassuring signature line).
69
+
70
+ COPY = {
71
+ "not_a_package": NOT_A_PACKAGE_COPY,
72
+ "repo_not_attested": (
73
+ "This repo doesn't carry a Surgeon attestation, so there's nothing to "
74
+ "verify against — that's a statement about provenance, not quality. "
75
+ "Repos published through Model Surgeon's attested push verify here."
76
+ ),
77
+ }
78
+
79
+
80
+ def _e(s):
81
+ return html.escape(str(s), quote=True)
82
+
83
+
84
+ def _first_line_matching(text, needle_lower):
85
+ for ln in (text or "").split("\n"):
86
+ if needle_lower in ln.lower():
87
+ return ln.strip()
88
+ return None
89
+
90
+
91
+ def _first_non_empty(text):
92
+ for ln in (text or "").split("\n"):
93
+ if ln.strip():
94
+ return ln.strip()
95
+ return None
96
+
97
+
98
+ def render_card(res, source_label):
99
+ v = res.get("verdict")
100
+ if v == "verified":
101
+ cls, head = "v-verified", "✔ VERIFIED"
102
+ elif v == "tampered":
103
+ cls, head = "v-tampered", "✘ TAMPERED"
104
+ elif v == "not_a_package":
105
+ cls, head = "v-amber", "NOT A SURGEON PACKAGE"
106
+ elif v == "repo_not_attested":
107
+ cls, head = "v-amber", "REPO NOT ATTESTED"
108
+ else:
109
+ cls, head = "v-error", "LIMIT / ERROR"
110
+ parts = [f'<article class="verdict-card {cls}"><h3 class="vhead">{_e(head)}</h3>']
111
+ if source_label:
112
+ parts.append(f'<p class="vsub">{_e(source_label)}</p>')
113
+
114
+ stdout = res.get("stdout") or ""
115
+ if v == "verified":
116
+ if res.get("keyid"):
117
+ parts.append(
118
+ '<p class="vline"><span class="keyid"><span>'
119
+ + _e(res["keyid"])
120
+ + '</span></span><span class="kcaption">pin this to know who signed</span></p>'
121
+ )
122
+ fc = res.get("files_checked")
123
+ if fc is not None:
124
+ parts.append(f'<p class="vline">{_e(fc)} files checked</p>')
125
+ att = _first_line_matching(stdout, "attestation")
126
+ if att:
127
+ parts.append(f'<p class="vline">{_e(att)}</p>')
128
+
129
+ if v == "tampered":
130
+ fail = [ln.strip() for ln in stdout.split("\n")
131
+ if ln.lstrip().startswith(("✖", "signed ", "actual "))]
132
+ if not fail:
133
+ fail = [_first_non_empty(stdout)]
134
+ for ln in fail:
135
+ if ln:
136
+ parts.append(f'<p class="vline vfail">{_e(ln)}</p>')
137
+
138
+ detail = res.get("detail") or COPY.get(v, "")
139
+ if detail:
140
+ parts.append(f'<p class="vdetail">{_e(detail)}</p>')
141
+
142
+ if stdout.strip():
143
+ parts.append(
144
+ '<details class="receipt"><summary>show the receipt</summary><pre>'
145
+ + _e(stdout) + "</pre></details>"
146
+ )
147
+ parts.append("</article>")
148
+ return "".join(parts)
149
+
150
+
151
+ def render_stack(stack):
152
+ if not stack:
153
+ return '<div id="verdictStack"></div>'
154
+ return '<div id="verdictStack">' + "".join(stack) + "</div>"
155
+
156
+
157
+ def _push(stack, res, source_label):
158
+ stack = [render_card(res, source_label)] + list(stack or [])
159
+ return stack[:STACK_MAX]
160
+
161
+
162
+ # ---------------------------------------------------------------- handlers
163
+ # Each returns (new_stack_state, rendered_html). Every path releases its slot
164
+ # and deletes its temp dir in `finally` (SPEC 1.3).
165
+
166
+ def _samples():
167
+ return json.loads(MANIFEST.read_text()) if MANIFEST.exists() else []
168
+
169
+
170
+ def verify_sample(sample_id, stack, progress=gr.Progress()):
171
+ if not _slots.acquire(blocking=False):
172
+ s = _push(stack, _busy_result(), sample_id)
173
+ return s, render_stack(s)
174
+ try:
175
+ progress(0, desc="recomputing digests…")
176
+ entry = next((e for e in _samples() if e.get("id") == sample_id), None)
177
+ if entry is None:
178
+ res = _result("error", None, "", detail="Unknown sample id.")
179
+ else:
180
+ sample_path = SAMPLES_DIR / Path(entry["filename"]).name
181
+ if not sample_path.exists():
182
+ res = _result("error", None, "", detail=(
183
+ "This sample package hasn't been installed on the Space yet. "
184
+ + LOCAL_CMD))
185
+ else:
186
+ # Server-side copy only — the file never round-trips through
187
+ # the browser.
188
+ res = _run_engine_sync(sample_path)
189
+ s = _push(stack, res, entry["label"] if entry else sample_id)
190
+ return s, render_stack(s)
191
+ finally:
192
+ _slots.release()
193
+
194
+
195
+ def verify_upload(file_path, stack, progress=gr.Progress()):
196
+ if not file_path:
197
+ return stack, render_stack(stack)
198
+ label = Path(file_path).name
199
+ if not _slots.acquire(blocking=False):
200
+ s = _push(stack, _busy_result(), label)
201
+ return s, render_stack(s)
202
+ tmp = tempfile.mkdtemp(prefix="verify-")
203
+ try:
204
+ progress(0, desc="recomputing digests…")
205
+ # Gradio has already streamed the upload to its cache (bounded by
206
+ # launch(max_file_size=…)); it is moved into our per-request dir so
207
+ # exactly one deletion covers everything.
208
+ target = Path(tmp) / "upload.bin"
209
+ shutil.move(file_path, target)
210
+ received = target.stat().st_size
211
+ if received > MAX_UPLOAD_BYTES:
212
+ res = _result("error", None, "", detail=(
213
+ "That file is over this demo's " + str(MAX_UPLOAD_MB)
214
+ + " MB upload cap. " + LOCAL_CMD))
215
+ elif received == 0:
216
+ res = _result("error", None, "", detail="No file was received. " + LOCAL_CMD)
217
+ else:
218
+ res = None
219
+ # Zip-bomb guard: declared uncompressed size, before any extraction.
220
+ if zipfile.is_zipfile(target):
221
+ try:
222
+ if _zip_declared_size(target) > 2 * MAX_UPLOAD_BYTES:
223
+ res = _result("error", None, "", detail=(
224
+ "This archive declares an uncompressed size more than "
225
+ "twice the " + str(MAX_UPLOAD_MB)
226
+ + " MB cap, so the demo won't open it. " + LOCAL_CMD))
227
+ except zipfile.BadZipFile:
228
+ pass # let the engine speak for itself
229
+ if res is None:
230
+ # Any file type is accepted; the engine's own output routes
231
+ # non-zips and attestation-less zips to the polite
232
+ # not-a-package card. Windows/macOS-made zips get their
233
+ # container repaired first.
234
+ target, normalized = _normalize_container(target, tmp)
235
+ res = _run_engine_sync(target)
236
+ if normalized and isinstance(res, dict):
237
+ res["detail"] = NORMALIZED_NOTE + (res.get("detail") or "")
238
+ s = _push(stack, res, label)
239
+ return s, render_stack(s)
240
+ finally:
241
+ shutil.rmtree(tmp, ignore_errors=True) # SPEC 1.3 — nothing retained
242
+ try:
243
+ Path(file_path).unlink(missing_ok=True) # Gradio's cached copy, if any remains
244
+ except Exception:
245
+ pass
246
+ _slots.release()
247
+
248
+
249
+ def verify_repo(repo_id, stack, progress=gr.Progress()):
250
+ repo_id = (repo_id or "").strip()
251
+ if not repo_id:
252
+ return stack, render_stack(stack)
253
+ if not _slots.acquire(blocking=False):
254
+ s = _push(stack, _busy_result(), repo_id)
255
+ return s, render_stack(s)
256
+ tmp = tempfile.mkdtemp(prefix="verify-repo-")
257
+ try:
258
+ progress(0, desc="fetching from the Hub…")
259
+ if not _REPO_ID_RE.match(repo_id):
260
+ res = _result("error", None, "", detail=(
261
+ "That doesn't look like a Hugging Face repo id — the format is "
262
+ "owner/name. " + LOCAL_CMD))
263
+ else:
264
+ # Wall-clock: the download itself is bounded inside the resolver's
265
+ # hf_hub_download calls; the ✕ cancel button aborts the event.
266
+ res = _resolve_and_verify_repo_sync(repo_id, Path(tmp))
267
+ s = _push(stack, res, repo_id)
268
+ return s, render_stack(s)
269
+ finally:
270
+ shutil.rmtree(tmp, ignore_errors=True) # SPEC 1.3 — nothing retained
271
+ _slots.release()
272
+
273
+
274
+ # ---------------------------------------------------------------- the page
275
+
276
+ def _data_uri(path, mime):
277
+ return f"data:{mime};base64," + base64.b64encode(Path(path).read_bytes()).decode()
278
+
279
+
280
+ LOGO = _data_uri(ROOT / "static" / "logo.png", "image/png")
281
+ CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + (ROOT / "static" / "gradio.css").read_text(encoding="utf-8")
282
+
283
+ MASTHEAD = f"""
284
+ <header class="masthead">
285
+ <a class="brandmark" href="https://surgeon.falcons.ai" target="_blank" rel="noopener" aria-label="FALCONS.AI">
286
+ <img src="{LOGO}" alt="FALCONS.AI falcon logo" width="72" height="72">
287
+ </a>
288
+ <div class="masthead-text">
289
+ <h1>PROVENANCE VERIFIER — LIVE DEMO</h1>
290
+ <p class="byline">by <span class="brand-name">FALCONS.AI</span></p>
291
+ <p class="sub">free for everyone · no account needed · <span class="brand">Model Surgeon</span></p>
292
+ </div>
293
+ </header>"""
294
+
295
+ FOOTER = """
296
+ <footer class="footer">
297
+ <nav class="footlinks">
298
+ <a href="https://surgeon.falcons.ai/pricing">Pricing</a> ·
299
+ <a href="https://surgeon.falcons.ai/terms">Terms</a> ·
300
+ <a href="https://surgeon.falcons.ai/eu-ai-act">EU AI Act</a> ·
301
+ <a href="https://surgeon.falcons.ai/privacy">Privacy</a> ·
302
+ <a href="https://surgeon.falcons.ai">surgeon.falcons.ai</a> ·
303
+ <a href="https://github.com/Falcons-ai/surgeon-verify">GitHub: surgeon-verify</a>
304
+ </nav>
305
+ <p class="strapline">Signed creation is the product — universal verification is what makes it worth anything. Free, no account, forever.</p>
306
+ </footer>"""
307
+
308
+ # Force the dark theme regardless of the visitor's OS setting (the product is
309
+ # dark only, SPEC 3) — Gradio otherwise follows prefers-color-scheme.
310
+ FORCE_DARK_JS = """
311
+ () => {
312
+ const u = new URL(window.location);
313
+ if (u.searchParams.get('__theme') !== 'dark') {
314
+ u.searchParams.set('__theme', 'dark');
315
+ window.location.replace(u.toString());
316
+ }
317
+ }
318
+ """
319
+
320
+
321
+ def build():
322
+ samples = _samples()
323
+ with gr.Blocks(title="Provenance Verifier — live demo · FALCONS.AI Model Surgeon",
324
+ css=CSS, js=FORCE_DARK_JS, analytics_enabled=False,
325
+ theme=gr.themes.Base(font=gr.themes.GoogleFont("IBM Plex Mono"),
326
+ font_mono=gr.themes.GoogleFont("IBM Plex Mono"))) as demo:
327
+ stack = gr.State([])
328
+ gr.HTML(MASTHEAD)
329
+
330
+ with gr.Row(elem_classes="workbench"):
331
+ with gr.Column(scale=1, min_width=260, elem_classes="samples"):
332
+ gr.HTML('<h2 class="panel-label">SAMPLE PACKAGES</h2>')
333
+ sample_buttons = []
334
+ for entry in samples:
335
+ b = gr.Button(f"▤ {entry['label']}", elem_classes="sample-card")
336
+ gr.HTML(f'<p class="blurb">{_e(entry["blurb"])}</p>')
337
+ sample_buttons.append((b, entry["id"]))
338
+ gr.HTML('<p class="hint">tap a sample to verify it</p>')
339
+
340
+ with gr.Column(scale=2, elem_classes="verify"):
341
+ gr.HTML('<h2 class="panel-label">THE GRID</h2>')
342
+ grid = gr.File(label="drop any .zip of your own here — or click to choose a file",
343
+ file_count="single", type="filepath",
344
+ elem_id="grid", elem_classes="grid")
345
+ with gr.Row(elem_classes="alt-inputs"):
346
+ repo = gr.Textbox(show_label=False, placeholder="owner/name — e.g. an attested Surgeon push",
347
+ elem_classes="repoinput", scale=3, max_lines=1)
348
+ repo_btn = gr.Button("VERIFY", elem_classes="repobtn", scale=1)
349
+ cancel_btn = gr.Button("✕ cancel", elem_classes="cancel", scale=1)
350
+
351
+ verdicts = gr.HTML(render_stack([]), elem_classes="verdicts")
352
+ gr.HTML(FOOTER)
353
+ gr.HTML(f'<p class="buildtag">build {_e(BUILD_STAMP)} · upload cap {MAX_UPLOAD_MB} MB · repo cap {MAX_REPO_MB} MB</p>')
354
+
355
+ events = []
356
+ for b, sid in sample_buttons:
357
+ events.append(b.click(verify_sample, inputs=[gr.State(sid), stack], outputs=[stack, verdicts],
358
+ concurrency_limit=MAX_CONCURRENCY))
359
+ events.append(grid.upload(verify_upload, inputs=[grid, stack], outputs=[stack, verdicts],
360
+ concurrency_limit=MAX_CONCURRENCY))
361
+ events.append(repo.submit(verify_repo, inputs=[repo, stack], outputs=[stack, verdicts],
362
+ concurrency_limit=MAX_CONCURRENCY))
363
+ events.append(repo_btn.click(verify_repo, inputs=[repo, stack], outputs=[stack, verdicts],
364
+ concurrency_limit=MAX_CONCURRENCY))
365
+ # Same behaviour as the AbortController in the old front end: the
366
+ # in-flight verification is dropped; nothing is recorded for it.
367
+ cancel_btn.click(None, None, None, cancels=events)
368
+ return demo
369
+
370
+
371
+ demo = build()
372
+
373
+ if __name__ == "__main__":
374
+ # Mounted on a bare FastAPI shell so uvicorn can run with access_log=False
375
+ # (SPEC 1.3: no logging of filenames, repo ids, IPs, or verdicts). The
376
+ # `gradio` SDK on Spaces simply runs this file; port 7860 is its contract.
377
+ import uvicorn
378
+ from fastapi import FastAPI
379
+ from fastapi.responses import FileResponse
380
+
381
+ shell = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
382
+
383
+ @shell.get("/favicon.ico")
384
+ async def favicon():
385
+ return FileResponse(ROOT / "static" / "favicon.png", media_type="image/png")
386
+
387
+ demo.queue(default_concurrency_limit=MAX_CONCURRENCY)
388
+ app = gr.mount_gradio_app(
389
+ shell, demo, path="/",
390
+ max_file_size=f"{MAX_UPLOAD_MB}mb", # the streamed upload cap (SPEC 4.2)
391
+ show_api=False,
392
+ delete_cache=(60, 60), # Gradio's cache: sweep every minute, nothing older than a minute
393
+ )
394
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")), access_log=False)
engine/UPSTREAM_NOTE.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Suggested upstream change (NOT applied — shipped engine is verbatim)
2
+
3
+ The attestation covers file digests, so verification should be OS-agnostic.
4
+ Today `verify_attestation.py` exits 2 ("not a Surgeon package") on archives
5
+ re-zipped by Windows Explorer / PowerShell Compress-Archive (backslash entry
6
+ names) and by macOS Finder (`__MACOSX/` twins + `.DS_Store` break single-root
7
+ detection). This Space repairs the container in its wrapper before invoking
8
+ the engine, but local `python verify_attestation.py package.zip` still fails
9
+ on such archives.
10
+
11
+ Minimal engine-side fix (route through Michael):
12
+
13
+ 1. normalize names once: `names = [n.replace("\\", "/") for n in z.namelist()]`
14
+ and read via a map from normalized -> original name;
15
+ 2. ignore archiver junk when detecting the wrapper root and when reading:
16
+ any path whose first segment is `__MACOSX`, or whose leaf is `.DS_Store`,
17
+ `Thumbs.db`, `desktop.ini`, or starts with `._`.
18
+
19
+ Digest comparison logic unchanged; junk entries are never subjects.
engine/verify_attestation.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ FALCONS.AI Model Surgeon — FREE attestation verifier.
4
+
5
+ Anyone can verify a Surgeon package without a subscription:
6
+
7
+ python verify_attestation.py package.zip [--key HEXKEY-OR-STRING]
8
+
9
+ Checks, in order:
10
+ 1. the package carries a lineage.intoto.jsonl attestation;
11
+ 2. every subject artifact's SHA-256 matches the signed digest;
12
+ 3. the DSSE HMAC signature verifies under the given key
13
+ (--key or FALCONSAI_HMAC_KEY; defaults to the public demo key).
14
+
15
+ Exit codes: 0 VERIFIED · 1 TAMPERED/INCOMPLETE · 2 not a Surgeon package.
16
+ Verification is free by design: signed creation is the paid act, universal
17
+ verification is what makes the signature worth anything.
18
+ """
19
+ import argparse
20
+ import base64
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import sys
26
+ import zipfile
27
+
28
+ # SEC-7: this script used to default to a shipped secret — which meant the
29
+ # signing key travelled inside every exported package. Anyone holding a
30
+ # package could mint forgeries that verified. No default now: the verifier
31
+ # requires the key out of band, exactly like any other shared secret.
32
+ DEFAULT_KEY = None
33
+
34
+
35
+ def main():
36
+ ap = argparse.ArgumentParser()
37
+ ap.add_argument("package")
38
+ ap.add_argument("--key", default=os.environ.get("FALCONSAI_HMAC_KEY"))
39
+ a = ap.parse_args()
40
+ try:
41
+ z = zipfile.ZipFile(a.package)
42
+ except Exception as e:
43
+ print(f"✖ cannot open {a.package}: {e}")
44
+ sys.exit(2)
45
+ names = z.namelist()
46
+ prefix = ""
47
+ roots = {n.split("/", 1)[0] for n in names if n.strip("/")}
48
+ if len(roots) == 1 and all("/" in n for n in names if n.strip("/")):
49
+ prefix = next(iter(roots)) + "/" # Explorer-style re-zip: descend
50
+
51
+ def _zread(nm):
52
+ return z.read(prefix + nm)
53
+ try:
54
+ att = json.loads(_zread("lineage.intoto.jsonl"))
55
+ except KeyError:
56
+ print("✖ no lineage.intoto.jsonl — not a Surgeon package "
57
+ "(or the attestation was stripped).")
58
+ sys.exit(2)
59
+ payload = base64.b64decode(att.get("payload", ""))
60
+ want = (att.get("signatures") or [{}])[0].get("sig", "")
61
+ # SEC-1: asymmetric first — the package carries the publisher's PUBLIC
62
+ # key, so verification needs no secret and forgery needs the private
63
+ # key. Free verification, restored properly.
64
+ env_missing = False
65
+ sig_entry = (att.get("signatures") or [{}])[0] or {}
66
+ scheme = sig_entry.get("scheme") or "hmac"
67
+ if scheme == "ed25519" and sig_entry.get("publicKey"):
68
+ try:
69
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
70
+ Ed25519PublicKey)
71
+ Ed25519PublicKey.from_public_bytes(
72
+ bytes.fromhex(sig_entry["publicKey"])).verify(
73
+ bytes.fromhex(want), payload)
74
+ have = want
75
+ print(f"signature : ed25519 OK · publisher {sig_entry.get('keyid')}")
76
+ print(" (pin this fingerprint to establish WHO "
77
+ "signed it)")
78
+ except ImportError:
79
+ # A missing library is an ENVIRONMENT problem, not evidence of
80
+ # tampering — say so, keep checking digests, verdict INCOMPLETE.
81
+ have = None
82
+ env_missing = True
83
+ print("signature : cannot check — install the 'cryptography' "
84
+ "package (pip install cryptography) to verify ed25519")
85
+ except Exception as e:
86
+ have = None
87
+ print(f"signature : ed25519 INVALID ({e})")
88
+ elif a.key:
89
+ have = hmac.new(a.key.encode(), payload, hashlib.sha256).hexdigest()
90
+ print("signature : legacy shared-key (hmac) — anyone with the "
91
+ "key could have produced this package")
92
+ else:
93
+ have = None
94
+ print("signature : legacy shared-key package; supply --key or "
95
+ "FALCONSAI_HMAC_KEY to check it (digests still verified)")
96
+ sig_ok = hmac.compare_digest(want, have)
97
+ stmt = json.loads(payload)
98
+ print(f"attestation : {stmt.get('predicateType')}")
99
+ print(f"tool : {(stmt.get('predicate') or {}).get('tool')}")
100
+ all_ok = True
101
+ for sub in stmt.get("subject", []):
102
+ nm, digest = sub["name"], sub["digest"]["sha256"]
103
+ try:
104
+ actual = hashlib.sha256(_zread(nm)).hexdigest()
105
+ ok = actual == digest
106
+ except KeyError:
107
+ actual, ok = "<missing from zip>", False
108
+ except Exception:
109
+ # e.g. the archive's own CRC rejects the entry — the byte-flip
110
+ # case. Corruption is tampering evidence, not a crash.
111
+ actual, ok = "<unreadable — fails the archive's own checksum>", False
112
+ all_ok &= ok
113
+ print(f" {'✔' if ok else '✖'} {nm}")
114
+ if not ok:
115
+ print(f" signed {digest}\n actual {actual}")
116
+ print(f"signature : {'✔ valid under this key' if sig_ok else '✖ does NOT verify under this key'}")
117
+ merges = (stmt.get("predicate") or {}).get("merges") or []
118
+ if merges:
119
+ print(f"merges : {len(merges)} recorded "
120
+ f"({', '.join(sorted({m.get('method','lerp') for m in merges}))})")
121
+ verdict = "VERIFIED" if sig_ok and all_ok and stmt.get("subject") \
122
+ else "INCOMPLETE" if (env_missing and all_ok) \
123
+ else "TAMPERED" if stmt.get("subject") else "INCOMPLETE"
124
+ print(f"\nVERDICT: {verdict}")
125
+ sys.exit(0 if verdict == "VERIFIED" else 1)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
engine_wrapper.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALCONS.AI "Verify Anything" — engine wrapper (transport-independent).
3
+
4
+ Every function below is the FastAPI-era code, moved VERBATIM out of app.py so
5
+ the Gradio app and any future transport call exactly the same logic. The
6
+ standing rules (BUILD SPEC section 1) live here:
7
+ 1. The engine is invoked ONLY as a subprocess. Never ported/patched/inlined.
8
+ 2. No model-parsing code of any kind in this Space.
9
+ 3. Nothing retained: callers use per-request temp dirs, deleted in `finally`.
10
+ 4. A missing capability is never reported as tampering.
11
+ 5. Weights are never re-served.
12
+ """
13
+
14
+ import base64
15
+ import fnmatch
16
+ import json
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import zipfile
22
+ from pathlib import Path
23
+
24
+ # ---------------------------------------------------------------- constants
25
+
26
+ ROOT = Path(__file__).resolve().parent
27
+ ENGINE = ROOT / "engine" / "verify_attestation.py"
28
+ SAMPLES_DIR = ROOT / "samples"
29
+ MANIFEST = SAMPLES_DIR / "manifest.json"
30
+
31
+ MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "1024"))
32
+ MAX_REPO_MB = int(os.environ.get("MAX_REPO_MB", "2048"))
33
+ MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
34
+ MAX_REPO_BYTES = MAX_REPO_MB * 1024 * 1024
35
+
36
+ ENGINE_TIMEOUT_S = 120
37
+ DOWNLOAD_TIMEOUT_S = 300
38
+ MAX_CONCURRENCY = 4
39
+
40
+ # SPEC 5 — appended verbatim to every error state.
41
+ LOCAL_CMD = (
42
+ "You can always verify locally: python verify_attestation.py package.zip "
43
+ "— github.com/Falcons-ai/surgeon-verify"
44
+ )
45
+
46
+ NOT_A_PACKAGE_COPY = (
47
+ "This doesn't look like a Surgeon package — no signed attestation inside. "
48
+ "Not an error, just not something this tool can vouch for. Export any "
49
+ "model from Model Surgeon and drop it here, or try the two samples."
50
+ )
51
+
52
+ # SPEC 4.3.c — attestation filename patterns at repo root.
53
+ # TODO(SPEC 6.2): confirm the exact filename against the product fixture repo
54
+ # before enabling this path in production; patterns below follow the spec.
55
+ # Pinned per the shipped engine: it looks for exactly this name (SPEC 6.2).
56
+ ATTESTATION_PATTERNS = ("lineage.intoto.jsonl",)
57
+
58
+ # Bumped on every delivered build so a running instance is identifiable.
59
+ BUILD_STAMP = "2026-09-03.1-gradio"
60
+
61
+ # ---------------------------------------------------------------- helpers
62
+
63
+
64
+ def _result(verdict, exit_code, stdout, keyid=None, files_checked=None, detail=""):
65
+ """Common response shape (SPEC 4). Never invent fields the engine
66
+ didn't print."""
67
+ return {
68
+ "verdict": verdict,
69
+ "exit_code": exit_code,
70
+ "stdout": stdout,
71
+ "keyid": keyid,
72
+ "files_checked": files_checked,
73
+ "detail": detail,
74
+ }
75
+
76
+
77
+ # Defensive stdout parsing. If these miss, fields stay null and the raw
78
+ # stdout still shows — the engine's own words are always the receipt.
79
+ _KEYID_RE = re.compile(
80
+ r"publisher\s+(\S+)"
81
+ )
82
+ # fallback for older engine builds that print "keyid: ..."
83
+ _KEYID_FALLBACK_RE = re.compile(
84
+ r"key\s*[- ]?id\s*[:=]?\s*([0-9a-zA-Z:._-]{8,})"
85
+ )
86
+ _FILES_RE = re.compile(
87
+ r"(\d+)\s+files?\s+(?:checked|verified|ok|passed)", re.I
88
+ )
89
+ _SUBJECT_LINE_RE = re.compile(r"^\s*[\u2714\u2716]\s+\S", re.M)
90
+
91
+
92
+ def _parse_stdout(stdout):
93
+ """Best-effort keyid / files_checked from engine stdout (SPEC 4.5).
94
+ Null when absent — never invented."""
95
+ keyid = None
96
+ m = _KEYID_RE.search(stdout)
97
+ if not m:
98
+ m = _KEYID_FALLBACK_RE.search(stdout)
99
+ if m:
100
+ keyid = m.group(1)
101
+ files_checked = None
102
+ m = _FILES_RE.search(stdout)
103
+ if m:
104
+ files_checked = int(m.group(1))
105
+ else:
106
+ n = len(_SUBJECT_LINE_RE.findall(stdout))
107
+ if n:
108
+ files_checked = n
109
+ return keyid, files_checked
110
+
111
+
112
+ def _run_engine_sync(target):
113
+ """Invoke the pinned engine as a subprocess (SPEC 4, all paths)."""
114
+ try:
115
+ proc = subprocess.run(
116
+ [sys.executable, str(ENGINE), str(target)],
117
+ capture_output=True,
118
+ text=True,
119
+ encoding="utf-8",
120
+ errors="replace",
121
+ timeout=ENGINE_TIMEOUT_S,
122
+ )
123
+ except subprocess.TimeoutExpired:
124
+ return _result(
125
+ "error",
126
+ None,
127
+ "",
128
+ detail=(
129
+ "Verification timed out after "
130
+ + str(ENGINE_TIMEOUT_S)
131
+ + " s. "
132
+ + LOCAL_CMD
133
+ ),
134
+ )
135
+
136
+ stdout = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
137
+ keyid, files_checked = _parse_stdout(proc.stdout or "")
138
+
139
+ if proc.returncode == 0:
140
+ return _result("verified", 0, stdout, keyid, files_checked)
141
+ if proc.returncode == 1:
142
+ if "VERDICT:" not in (proc.stdout or ""):
143
+ # SCAN F2: the engine crashed before reaching a verdict (e.g. the
144
+ # legacy-no-key path raises TypeError at the signature compare).
145
+ # A crash is OUR bug — never evidence about the user's package,
146
+ # and never rendered as TAMPERED (SPEC 1.4).
147
+ if "legacy shared-key" in (proc.stdout or ""):
148
+ _d = ("This is an older shared-key Surgeon package — the demo "
149
+ "can't check its signature without the key. Not evidence "
150
+ "of tampering. " + LOCAL_CMD +
151
+ " (add --key or FALCONSAI_HMAC_KEY)")
152
+ else:
153
+ _d = ("The verifier itself hit a bug on this input — that's "
154
+ "on our side, not evidence about your package. "
155
+ + LOCAL_CMD)
156
+ return _result("error", 1, stdout, keyid, files_checked, _d)
157
+ if "VERDICT: INCOMPLETE" in (proc.stdout or ""):
158
+ return _result(
159
+ "error", 1, stdout, keyid, files_checked,
160
+ detail=(
161
+ "The engine could not fully establish the signature in "
162
+ "this environment (verdict INCOMPLETE). That is a missing "
163
+ "capability, not evidence of tampering. " + LOCAL_CMD
164
+ ),
165
+ )
166
+ return _result("tampered", 1, stdout, keyid, files_checked)
167
+
168
+ # Any other exit: classify by the engine's own words (SPEC 4). A missing
169
+ # capability is never reported as tampering (SPEC 1.4).
170
+ lower = (proc.stdout or "").lower() + (proc.stderr or "").lower()
171
+ package_signals = (
172
+ "no signed attestation",
173
+ "no attestation",
174
+ "attestation missing",
175
+ "missing attestation",
176
+ "not a zip",
177
+ "bad zip",
178
+ "not a valid",
179
+ "not a package",
180
+ "not a surgeon package",
181
+ "no lineage.intoto.jsonl",
182
+ "cannot open",
183
+ )
184
+ if any(sig in lower for sig in package_signals):
185
+ return _result(
186
+ "not_a_package", proc.returncode, stdout, keyid, files_checked,
187
+ NOT_A_PACKAGE_COPY,
188
+ )
189
+ return _result(
190
+ "error", proc.returncode, stdout, keyid, files_checked,
191
+ "The verifier couldn't process this input. " + LOCAL_CMD,
192
+ )
193
+
194
+
195
+ def _junk_entry(name):
196
+ """Archiver metadata that no OS's archive tool should make load-bearing:
197
+ macOS Finder resource forks and .DS_Store, Windows Thumbs.db /
198
+ desktop.ini. Matched per path segment, case-insensitively."""
199
+ parts = [seg.lower() for seg in name.replace("\\", "/").split("/") if seg]
200
+ if not parts:
201
+ return True
202
+ if parts[0] == "__macosx":
203
+ return True
204
+ leaf = parts[-1]
205
+ return leaf in (".ds_store", "thumbs.db", "desktop.ini") or leaf.startswith("._")
206
+
207
+
208
+ def _normalize_container(path, workdir):
209
+ r"""Canonicalize a zip CONTAINER before handing it to the engine, so the
210
+ verdict is OS-agnostic (SPEC intent: the signature covers file digests,
211
+ not the archiver's packaging quirks).
212
+
213
+ Repairs exactly two classes of archiver quirk:
214
+ * Windows (Explorer / Compress-Archive): backslash entry names,
215
+ which the ZIP APPNOTE forbids;
216
+ * macOS (Finder "Compress"): __MACOSX/ resource-fork twins and
217
+ .DS_Store entries, which break single-root detection.
218
+
219
+ Entry NAMES are rewritten (\ -> /) and junk entries dropped; every
220
+ remaining content byte is copied exactly as stored, CRC checks bypassed
221
+ so corrupted (tampered) bytes still reach the engine's own digest
222
+ comparison. On any doubt (name collision, unreadable container) the
223
+ original file is returned untouched and the engine speaks for itself.
224
+ Never touches package content (SPEC 1.1 / 1.2).
225
+ """
226
+ try:
227
+ if not zipfile.is_zipfile(path):
228
+ return path, False
229
+ src = zipfile.ZipFile(path)
230
+ names = src.namelist()
231
+ needs = any("\\" in n for n in names) or any(_junk_entry(n) for n in names)
232
+ if not needs:
233
+ return path, False
234
+ mapped = {}
235
+ for info in src.infolist():
236
+ new = info.filename.replace("\\", "/")
237
+ if new.endswith("/") or _junk_entry(new):
238
+ continue
239
+ if new in mapped:
240
+ return path, False # collision -- refuse to guess
241
+ mapped[new] = info
242
+ if not mapped:
243
+ return path, False
244
+ fixed = Path(workdir) / "normalized.zip"
245
+ with zipfile.ZipFile(fixed, "w", zipfile.ZIP_DEFLATED) as out:
246
+ for new, info in mapped.items():
247
+ f = src.open(info)
248
+ f._expected_crc = None # deliver bytes even if the CRC is broken
249
+ out.writestr(new, f.read())
250
+ return fixed, True
251
+ except Exception:
252
+ return path, False
253
+
254
+
255
+ NORMALIZED_NOTE = (
256
+ "Note: the demo repaired this archive's container before verification "
257
+ "(archiver quirks such as Windows path separators or macOS metadata "
258
+ "entries) -- package contents untouched. "
259
+ )
260
+
261
+
262
+ def _zip_declared_size(path):
263
+ """Sum of declared uncompressed sizes (zip-bomb guard, SPEC 4.2).
264
+ Reads only the central directory — no member is extracted here."""
265
+ with zipfile.ZipFile(path) as zf:
266
+ return sum(i.file_size for i in zf.infolist())
267
+
268
+
269
+ _REPO_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*/[A-Za-z0-9._\-]+$")
270
+
271
+
272
+ def _resolve_and_verify_repo_sync(repo_id, workdir):
273
+ """SPEC 4.3 resolver. Runs in a worker thread; wall-clock capped by
274
+ the caller."""
275
+ from huggingface_hub import HfApi, hf_hub_download
276
+ from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError
277
+
278
+ token = os.environ.get("HF_TOKEN") or None
279
+ api = HfApi(token=token)
280
+
281
+ tree = None
282
+ repo_type = None
283
+ last_err = None
284
+ for rt in ("model", "dataset"):
285
+ try:
286
+ tree = list(
287
+ api.list_repo_tree(repo_id, repo_type=rt, recursive=True)
288
+ )
289
+ repo_type = rt
290
+ break
291
+ except GatedRepoError:
292
+ return _result(
293
+ "error",
294
+ None,
295
+ "",
296
+ detail=(
297
+ "this demo verifies public repos — run the verifier "
298
+ "locally on files you have access to. " + LOCAL_CMD
299
+ ),
300
+ )
301
+ except RepositoryNotFoundError as e:
302
+ last_err = e
303
+ continue
304
+ except Exception as e: # network etc.
305
+ last_err = e
306
+ tree = None
307
+ break
308
+
309
+ if tree is None:
310
+ if isinstance(last_err, RepositoryNotFoundError):
311
+ # Private repos also surface as not-found when anonymous.
312
+ return _result(
313
+ "error",
314
+ None,
315
+ "",
316
+ detail=(
317
+ "Couldn't find that repo on the Hub. If it's private or "
318
+ "gated: this demo verifies public repos — run the "
319
+ "verifier locally on files you have access to. "
320
+ + LOCAL_CMD
321
+ ),
322
+ )
323
+ return _result(
324
+ "error",
325
+ None,
326
+ "",
327
+ detail=(
328
+ "Couldn't reach the Hugging Face Hub for this repo. "
329
+ + LOCAL_CMD
330
+ ),
331
+ )
332
+
333
+ files = [t for t in tree if getattr(t, "size", None) is not None]
334
+ names = [t.path for t in files]
335
+ sizes = {t.path: t.size for t in files}
336
+
337
+ def _download(fname):
338
+ p = hf_hub_download(
339
+ repo_id=repo_id,
340
+ filename=fname,
341
+ repo_type=repo_type,
342
+ local_dir=workdir,
343
+ token=token,
344
+ )
345
+ return Path(p)
346
+
347
+ # ---- SPEC 4.3.b — exactly one *.zip in the repo → verify as a package
348
+ zips = [n for n in names if n.lower().endswith(".zip")]
349
+ if len(zips) == 1:
350
+ zname = zips[0]
351
+ if sizes.get(zname, 0) > MAX_UPLOAD_BYTES:
352
+ return _result(
353
+ "error",
354
+ None,
355
+ "",
356
+ detail=(
357
+ "The package in this repo is over the "
358
+ + str(MAX_UPLOAD_MB)
359
+ + " MB demo cap. "
360
+ + LOCAL_CMD
361
+ ),
362
+ )
363
+ try:
364
+ zpath = _download(zname)
365
+ except Exception:
366
+ return _result(
367
+ "error",
368
+ None,
369
+ "",
370
+ detail=(
371
+ "Downloading the package from the Hub failed. "
372
+ + LOCAL_CMD
373
+ ),
374
+ )
375
+ zpath, normalized = _normalize_container(zpath, workdir)
376
+ result = _run_engine_sync(zpath)
377
+ if normalized and isinstance(result, dict):
378
+ result["detail"] = NORMALIZED_NOTE + (result.get("detail") or "")
379
+ return result
380
+
381
+ # ---- SPEC 4.3.c — DSSE attestation at repo root → subjects-only verify
382
+ root_files = [n for n in names if "/" not in n]
383
+ att_name = None
384
+ for n in root_files:
385
+ if any(fnmatch.fnmatch(n, pat) for pat in ATTESTATION_PATTERNS):
386
+ att_name = n
387
+ break
388
+ if att_name:
389
+ try:
390
+ att_path = _download(att_name)
391
+ subjects = _dsse_subject_names(att_path)
392
+ except Exception:
393
+ return _result(
394
+ "error",
395
+ None,
396
+ "",
397
+ detail=(
398
+ "Found an attestation file in this repo but couldn't "
399
+ "fetch or read it. " + LOCAL_CMD
400
+ ),
401
+ )
402
+
403
+ # Download only the files the attestation names as subjects
404
+ # (SPEC 4.3.c).
405
+ total = 0
406
+ for s in subjects:
407
+ if s not in sizes:
408
+ # Missing subject: nothing to download — the engine will
409
+ # flag it, and a missing subject IS tampered (SPEC 4.3.c).
410
+ continue
411
+ if sizes[s] > MAX_UPLOAD_BYTES:
412
+ return _result(
413
+ "error",
414
+ None,
415
+ "",
416
+ detail=(
417
+ "A file named by the attestation is over the "
418
+ + str(MAX_UPLOAD_MB)
419
+ + " MB per-file demo cap. "
420
+ + LOCAL_CMD
421
+ ),
422
+ )
423
+ total += sizes[s]
424
+ if total > MAX_REPO_BYTES:
425
+ return _result(
426
+ "error",
427
+ None,
428
+ "",
429
+ detail=(
430
+ "The attested files in this repo total more than "
431
+ "the "
432
+ + str(MAX_REPO_MB)
433
+ + " MB demo cap. "
434
+ + LOCAL_CMD
435
+ ),
436
+ )
437
+ try:
438
+ _download(s)
439
+ except Exception:
440
+ # Leave it absent; the engine decides what that means.
441
+ pass
442
+
443
+ # Hub-added files not named by the attestation: informational only,
444
+ # never TAMPERED (SPEC 4.3.c).
445
+ uncovered = [n for n in names if n not in subjects and n != att_name]
446
+
447
+ # SCAN F1: the engine verifies ZIP packages only (a directory input
448
+ # exits 2 "cannot open"). Package the attestation + downloaded
449
+ # subjects into a canonical zip; a subject missing from the repo is
450
+ # simply absent, and the engine's own "<missing from zip>" makes
451
+ # that TAMPERED — exactly SPEC 4.3.c.
452
+ pkg = Path(workdir) / "__attested_package.zip"
453
+ with zipfile.ZipFile(pkg, "w", zipfile.ZIP_DEFLATED) as zf:
454
+ zf.write(att_path, att_name)
455
+ for s in subjects:
456
+ sp = Path(workdir) / s
457
+ if sp.is_file():
458
+ zf.write(sp, s)
459
+ res = _run_engine_sync(pkg)
460
+ if uncovered:
461
+ note = (
462
+ "present but not covered by the attestation: "
463
+ + ", ".join(sorted(uncovered))
464
+ )
465
+ res["detail"] = (
466
+ (res["detail"] + " · ") if res["detail"] else ""
467
+ ) + note
468
+ return res
469
+
470
+ # ---- SPEC 4.3.d — nothing to verify against
471
+ return _result(
472
+ "repo_not_attested",
473
+ None,
474
+ "",
475
+ detail=(
476
+ "This repo doesn't carry a Surgeon attestation, so there's "
477
+ "nothing to verify against — that's a statement about "
478
+ "provenance, not quality. Repos published through Model "
479
+ "Surgeon's attested push verify here."
480
+ ),
481
+ )
482
+
483
+
484
+ def _dsse_subject_names(att_path):
485
+ """Read subject filenames out of a DSSE envelope (or a bare in-toto
486
+ statement). This is JSON metadata only — no model content is parsed."""
487
+ data = json.loads(att_path.read_text())
488
+ if isinstance(data, dict) and "payload" in data:
489
+ payload = base64.b64decode(data["payload"])
490
+ data = json.loads(payload)
491
+ subjects = data.get("subject", []) if isinstance(data, dict) else []
492
+ return [
493
+ s.get("name")
494
+ for s in subjects
495
+ if isinstance(s, dict) and s.get("name")
496
+ ]
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.44,<6
2
+ fastapi
3
+ uvicorn
4
+ cryptography
5
+ huggingface_hub
samples/SAMPLE_A.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f9937e98d912ae6ab6608a46d2ee74684fbabc69edf016edea01cd8cd6448c8
3
+ size 654865
samples/SAMPLE_B.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3a5abb2b6e511ba4257db3c8efde187993f6184512e6389e9426e7f319fafc12
3
+ size 654865
samples/manifest.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "SAMPLE_A",
4
+ "filename": "SAMPLE_A.zip",
5
+ "label": "SAMPLE_A",
6
+ "blurb": "a genuine Surgeon export — the model artifacts' SHA-256 signed"
7
+ },
8
+ {
9
+ "id": "SAMPLE_B",
10
+ "filename": "SAMPLE_B.zip",
11
+ "label": "SAMPLE_B",
12
+ "blurb": "the same package with one byte changed — watch it fail"
13
+ }
14
+ ]
static/favicon.png ADDED
static/fonts/PLACE_FONTS_HERE.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Self-hosted fonts (blocker: no CDN allowed, SPEC 2/3)
2
+
3
+ Drop the following files here before publish (from the IBM Plex release,
4
+ OFL-1.1 licensed — include the license file):
5
+
6
+ - IBMPlexMono-Regular.woff2
7
+ - IBMPlexMono-Medium.woff2
8
+ - OFL.txt
9
+
10
+ Until then the CSS falls back to the system monospace stack, so nothing
11
+ breaks — it just isn't pixel-faithful to the product yet.
static/gradio.css ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ /* ---------- Gradio transport overrides (V-Kali, 2026-09-03) ----------
3
+ Everything above is the product stylesheet, untouched. Below: the few
4
+ rules that make Gradio's own wrappers disappear behind it. */
5
+ .gradio-container, .gradio-container .main, body, .dark {
6
+ background: var(--bg) !important; color: var(--ink) !important;
7
+ font-family: var(--mono) !important; max-width: none !important;
8
+ }
9
+ .gradio-container .block, .gradio-container .form, .gradio-container .gap {
10
+ background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important;
11
+ }
12
+ .gradio-container .prose, .gradio-container .prose * { color: inherit; }
13
+ footer.svelte-1rjryqp, .gradio-container > .main > .wrap > footer { display: none !important; } /* Gradio's own footer */
14
+
15
+ /* workbench = the two-column grid from the product page */
16
+ .workbench { max-width: 1080px; margin: 0 auto; padding: 24px 20px; gap: 24px; }
17
+ .workbench > .column { gap: 8px; }
18
+
19
+ /* sample cards: the product's card look on a Gradio button */
20
+ button.sample-card {
21
+ justify-content: flex-start; text-align: left;
22
+ border: 1px solid var(--line) !important; background: var(--panel) !important; color: var(--ink) !important;
23
+ border-radius: 8px !important; padding: 14px !important; margin: 0 !important;
24
+ font-family: var(--mono) !important; font-weight: 500 !important; letter-spacing: .06em; min-height: 44px;
25
+ cursor: pointer;
26
+ }
27
+ button.sample-card:hover, button.sample-card:focus-visible { border-color: var(--cyan) !important; }
28
+ .samples .blurb { margin: 0 0 8px; color: var(--dim); font-size: 13px; }
29
+
30
+ /* the grid: Gradio's file drop zone wearing the product's grid */
31
+ #grid, #grid .wrap, #grid .file-preview, #grid label { min-height: 220px; }
32
+ #grid {
33
+ border: 1px dashed var(--line) !important; border-radius: 10px !important;
34
+ background:
35
+ linear-gradient(var(--line) 1px, transparent 1px) 0 0 / 32px 32px,
36
+ linear-gradient(90deg, var(--line) 1px, transparent 1px) 0 0 / 32px 32px,
37
+ var(--panel) !important;
38
+ }
39
+ #grid .wrap { color: var(--dim); font-family: var(--mono); }
40
+ #grid .wrap .icon-wrap, #grid .wrap svg { color: var(--cyan); }
41
+ #grid.dragging, #grid .wrap.dragging, #grid:has(.dragging) {
42
+ border-color: var(--mint) !important;
43
+ box-shadow: 0 0 0 1px var(--mint), inset 0 0 24px rgba(124, 247, 196, 0.25);
44
+ }
45
+ #grid label > span, #grid .label-wrap { color: var(--dim); font-family: var(--mono); font-size: 13px; }
46
+
47
+ /* alt inputs: repo form + cancel */
48
+ .alt-inputs { margin-top: 14px; gap: 8px; align-items: stretch; }
49
+ .alt-inputs .repoinput textarea, .alt-inputs .repoinput input {
50
+ background: var(--panel) !important; border: 1px solid var(--line) !important; color: var(--ink) !important;
51
+ font-family: var(--mono) !important; font-size: 14px !important; border-radius: 6px !important; min-height: 44px;
52
+ }
53
+ .alt-inputs .repoinput textarea:focus { border-color: var(--cyan) !important; box-shadow: none !important; }
54
+ button.repobtn {
55
+ background: var(--cyan) !important; color: #04222B !important; border: none !important;
56
+ font-family: var(--mono) !important; font-weight: 500 !important; letter-spacing: .08em; border-radius: 6px !important; min-height: 44px;
57
+ }
58
+ button.repobtn:hover { filter: brightness(1.1); }
59
+ button.cancel {
60
+ background: none !important; border: 1px solid var(--line) !important; color: var(--dim) !important;
61
+ font-family: var(--mono) !important; border-radius: 6px !important; min-height: 44px;
62
+ }
63
+ button.cancel:hover { border-color: var(--red) !important; color: var(--red) !important; }
64
+
65
+ /* verdict stack */
66
+ .verdicts { max-width: 1080px; margin: 0 auto; padding: 0 20px 24px; }
67
+ .verdicts .vfail { color: var(--red); }
68
+
69
+ /* progress text ("recomputing digests…" / "fetching from the Hub…") in product colours */
70
+ .gradio-container .progress-text, .gradio-container .meta-text, .gradio-container .eta-bar { color: var(--dim) !important; font-family: var(--mono) !important; }
71
+
72
+ @media (max-width: 720px) {
73
+ .workbench { padding: 16px 12px; }
74
+ #grid, #grid .wrap { min-height: 160px; }
75
+ }
static/logo.png ADDED
static/style.css ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* FALCONS.AI Verify Anything — theme tokens copied from the product legal
2
+ pages (SPEC 3). Dark only. */
3
+
4
+ @font-face {
5
+ font-family: "IBM Plex Mono";
6
+ src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
7
+ font-weight: 400;
8
+ font-display: swap;
9
+ }
10
+ @font-face {
11
+ font-family: "IBM Plex Mono";
12
+ src: url("/static/fonts/IBMPlexMono-Medium.woff2") format("woff2");
13
+ font-weight: 500;
14
+ font-display: swap;
15
+ }
16
+
17
+ :root {
18
+ --bg: #070B14;
19
+ --ink: #E8F0FF;
20
+ --dim: #8FA3C0;
21
+ --cyan: #33E0FF;
22
+ --mint: #7CF7C4;
23
+ --line: #22304A;
24
+ --red: #FF5D6C;
25
+ --red-dim: #5A1F2A;
26
+ --amber: #FFC46B;
27
+ --panel: #0A1120;
28
+ --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
29
+ }
30
+
31
+ * { box-sizing: border-box; }
32
+
33
+ html, body {
34
+ margin: 0;
35
+ background: var(--bg);
36
+ color: var(--ink);
37
+ font-family: var(--mono);
38
+ font-size: 15px;
39
+ line-height: 1.5;
40
+ }
41
+
42
+ a { color: var(--cyan); text-decoration: none; }
43
+ a:hover, a:focus-visible { text-decoration: underline; }
44
+ :focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
45
+
46
+ .visually-hidden {
47
+ position: absolute; width: 1px; height: 1px;
48
+ clip: rect(0 0 0 0); overflow: hidden; white-space: nowrap;
49
+ }
50
+
51
+ /* ---------- masthead ---------- */
52
+ .masthead {
53
+ padding: 28px 20px 18px;
54
+ border-bottom: 1px solid var(--line);
55
+ }
56
+ .masthead h1 {
57
+ margin: 0;
58
+ font-size: 20px;
59
+ font-weight: 500;
60
+ letter-spacing: 0.08em;
61
+ }
62
+ .masthead .sub { margin: 6px 0 0; color: var(--dim); font-size: 13px; }
63
+ .masthead .brand { color: var(--cyan); }
64
+
65
+ /* ---------- workbench ---------- */
66
+ .workbench {
67
+ display: grid;
68
+ grid-template-columns: minmax(260px, 340px) 1fr;
69
+ gap: 24px;
70
+ padding: 24px 20px;
71
+ max-width: 1080px;
72
+ margin: 0 auto;
73
+ }
74
+ .panel-label {
75
+ margin: 0 0 12px;
76
+ font-size: 12px;
77
+ font-weight: 500;
78
+ letter-spacing: 0.14em;
79
+ color: var(--dim);
80
+ }
81
+
82
+ /* sample cards */
83
+ .sample-card {
84
+ border: 1px solid var(--line);
85
+ background: var(--panel);
86
+ border-radius: 8px;
87
+ padding: 14px;
88
+ margin-bottom: 12px;
89
+ cursor: grab;
90
+ user-select: none;
91
+ -webkit-user-select: none;
92
+ transition: border-color 120ms ease;
93
+ min-height: 44px;
94
+ }
95
+ .sample-card:hover, .sample-card:focus-visible { border-color: var(--cyan); }
96
+ .sample-card:active { cursor: grabbing; }
97
+ .sample-card.dragging { opacity: 0.5; }
98
+ .sample-card .card-title {
99
+ display: flex; align-items: center; gap: 8px;
100
+ font-weight: 500; letter-spacing: 0.06em;
101
+ }
102
+ .sample-card .glyph { color: var(--cyan); }
103
+ .sample-card .blurb { margin: 6px 0 0; color: var(--dim); font-size: 13px; }
104
+ .samples .hint { color: var(--dim); font-size: 12px; margin: 4px 0 0; }
105
+
106
+ /* the grid */
107
+ .grid {
108
+ position: relative;
109
+ border: 1px dashed var(--line);
110
+ border-radius: 10px;
111
+ background:
112
+ linear-gradient(var(--line) 1px, transparent 1px) 0 0 / 32px 32px,
113
+ linear-gradient(90deg, var(--line) 1px, transparent 1px) 0 0 / 32px 32px,
114
+ var(--panel);
115
+ min-height: 220px;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ text-align: center;
120
+ padding: 20px;
121
+ transition: border-color 120ms ease, box-shadow 120ms ease;
122
+ }
123
+ .grid.dragover {
124
+ border-color: var(--mint);
125
+ box-shadow: 0 0 0 1px var(--mint), inset 0 0 24px rgba(124, 247, 196, 0.25);
126
+ }
127
+ .grid-idle p { color: var(--dim); margin: 8px 0 0; }
128
+ .grid-glyph { font-size: 28px; color: var(--cyan); }
129
+
130
+ .grid-busy p { margin: 8px 0 12px; }
131
+ .scanline {
132
+ width: 180px; height: 3px; margin: 0 auto;
133
+ background: linear-gradient(90deg, transparent, var(--cyan), transparent);
134
+ animation: scan 1.1s linear infinite;
135
+ border-radius: 2px;
136
+ }
137
+ @keyframes scan {
138
+ 0% { transform: translateX(-40px); opacity: 0.4; }
139
+ 50% { transform: translateX(40px); opacity: 1; }
140
+ 100% { transform: translateX(-40px); opacity: 0.4; }
141
+ }
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .scanline { animation: none; }
144
+ }
145
+ .cancel {
146
+ background: none;
147
+ border: 1px solid var(--line);
148
+ color: var(--dim);
149
+ font-family: var(--mono);
150
+ font-size: 13px;
151
+ border-radius: 6px;
152
+ padding: 10px 16px;
153
+ min-height: 44px;
154
+ cursor: pointer;
155
+ }
156
+ .cancel:hover { border-color: var(--red); color: var(--red); }
157
+
158
+ /* alt inputs */
159
+ .alt-inputs { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
160
+ .filebtn span {
161
+ display: inline-flex; align-items: center;
162
+ border: 1px solid var(--line);
163
+ background: var(--panel);
164
+ color: var(--ink);
165
+ border-radius: 6px;
166
+ padding: 10px 16px;
167
+ min-height: 44px;
168
+ cursor: pointer;
169
+ }
170
+ .filebtn span:hover { border-color: var(--cyan); }
171
+ .repoform { display: flex; gap: 8px; flex: 1; min-width: 260px; }
172
+ .repoform input {
173
+ flex: 1;
174
+ min-width: 0;
175
+ background: var(--panel);
176
+ border: 1px solid var(--line);
177
+ color: var(--ink);
178
+ font-family: var(--mono);
179
+ font-size: 14px;
180
+ border-radius: 6px;
181
+ padding: 10px 12px;
182
+ min-height: 44px;
183
+ }
184
+ .repoform input::placeholder { color: var(--dim); opacity: 0.8; }
185
+ .repoform input:focus { border-color: var(--cyan); outline: none; }
186
+ .repoform button {
187
+ background: var(--cyan);
188
+ color: #04222B;
189
+ border: none;
190
+ font-family: var(--mono);
191
+ font-weight: 500;
192
+ letter-spacing: 0.08em;
193
+ border-radius: 6px;
194
+ padding: 10px 18px;
195
+ min-height: 44px;
196
+ cursor: pointer;
197
+ }
198
+ .repoform button:hover { filter: brightness(1.1); }
199
+ button:disabled, input:disabled, .filebtn.disabled span, .sample-card.disabled {
200
+ opacity: 0.45; pointer-events: none;
201
+ }
202
+
203
+ /* ---------- verdicts ---------- */
204
+ .verdicts { max-width: 1080px; margin: 0 auto; padding: 0 20px 24px; }
205
+ .verdict-card {
206
+ border: 1px solid var(--line);
207
+ border-left-width: 4px;
208
+ border-radius: 8px;
209
+ background: var(--panel);
210
+ padding: 16px;
211
+ margin-bottom: 12px;
212
+ }
213
+ .verdict-card .vhead {
214
+ font-size: 16px;
215
+ font-weight: 500;
216
+ letter-spacing: 0.06em;
217
+ margin: 0 0 8px;
218
+ }
219
+ .verdict-card .vsub { color: var(--dim); font-size: 12px; margin: 0 0 8px; overflow-wrap: anywhere; }
220
+ .verdict-card .vline { margin: 4px 0; font-size: 14px; overflow-wrap: anywhere; }
221
+ .verdict-card .vdetail { font-size: 14px; margin: 8px 0 0; overflow-wrap: anywhere; }
222
+
223
+ .v-verified { border-left-color: var(--mint); }
224
+ .v-verified .vhead { color: var(--mint); }
225
+ .v-tampered {
226
+ border-left-color: var(--red);
227
+ background: linear-gradient(0deg, rgba(90,31,42,.25), rgba(90,31,42,.25)), var(--panel);
228
+ }
229
+ .v-tampered .vhead { color: var(--red); }
230
+ .v-amber { border-left-color: var(--amber); }
231
+ .v-amber .vhead { color: var(--amber); }
232
+ .v-error { border-left-color: var(--dim); }
233
+ .v-error .vhead { color: var(--dim); }
234
+
235
+ .keyid {
236
+ display: inline-flex; align-items: center; gap: 8px;
237
+ background: #060A12;
238
+ border: 1px solid var(--line);
239
+ border-radius: 6px;
240
+ padding: 4px 8px;
241
+ font-size: 13px;
242
+ }
243
+ .keyid button {
244
+ background: none; border: none; color: var(--cyan);
245
+ font-family: var(--mono); font-size: 12px; cursor: pointer; padding: 4px;
246
+ }
247
+ .kcaption { color: var(--dim); font-size: 12px; margin-left: 8px; }
248
+
249
+ details.receipt { margin-top: 10px; }
250
+ details.receipt summary {
251
+ color: var(--cyan); font-size: 13px; cursor: pointer; user-select: none;
252
+ min-height: 32px; display: flex; align-items: center;
253
+ }
254
+ details.receipt pre {
255
+ background: #060A12;
256
+ border: 1px solid var(--line);
257
+ border-radius: 6px;
258
+ padding: 12px;
259
+ font-size: 12px;
260
+ color: var(--dim);
261
+ overflow-x: auto;
262
+ white-space: pre-wrap;
263
+ overflow-wrap: anywhere;
264
+ margin: 8px 0 0;
265
+ }
266
+
267
+ /* ---------- footer ---------- */
268
+ .footer {
269
+ border-top: 1px solid var(--line);
270
+ padding: 20px;
271
+ color: var(--dim);
272
+ font-size: 13px;
273
+ text-align: center;
274
+ }
275
+ .footlinks { margin-bottom: 8px; }
276
+ .strapline { color: var(--dim); margin: 0; }
277
+
278
+ /* ---------- mobile (SPEC 3: iPhone SE, no sideways scroll, 44px taps) ---- */
279
+ @media (max-width: 720px) {
280
+ .workbench { grid-template-columns: 1fr; gap: 16px; padding: 16px 12px; }
281
+ .verdicts { padding: 0 12px 16px; }
282
+ .masthead { padding: 20px 12px 14px; }
283
+ .masthead h1 { font-size: 17px; }
284
+ .grid { min-height: 160px; }
285
+ .repoform { min-width: 0; width: 100%; }
286
+ }
287
+
288
+ /* ---- masthead branding ---- */
289
+ .masthead{display:flex;align-items:center;gap:18px;text-align:left}
290
+ .brandmark{flex:0 0 auto;display:block;line-height:0}
291
+ .brandmark img{width:72px;height:72px;display:block;
292
+ filter:drop-shadow(0 0 14px rgba(217,32,44,.35))}
293
+ .masthead-text{min-width:0}
294
+ .byline{margin:.15rem 0 .1rem;font-size:1.05rem;letter-spacing:.14em;color:var(--dim)}
295
+ .byline .brand-name{color:#E0202C;font-weight:700;letter-spacing:.18em}
296
+ @media (max-width:720px){
297
+ .masthead{flex-direction:column;text-align:center;gap:10px}
298
+ .brandmark img{width:56px;height:56px}
299
+ }
300
+ .buildtag{margin:.2rem 0 0;font-size:.68rem;letter-spacing:.08em;color:var(--line);text-align:center}