-
Timmy review build 2026-10-31 — image ingress hardening (#16)
Pre-ReleaseQuality gates / quality (push) Canceled after 0sreleased this
2026-10-31 12:44:27 +00:00 | 5 commits to main since this releasePrerelease for review — tag
daily-2026-10-31on commit9067ce53067473467d7cc6a0cc681e8ff2054af6(America/New_York release date 2026-10-31, built 2026-10-31T12:36Z).What changed
The upload boundary stops trusting the caller (issue #16, PR #82).
/api/analyzeused to decide whether an upload was a photo from the data-URL MIME type the caller supplied. That claim is now ignored: every decision comes from the bytes, and every limit fails before the provider is asked.Control Module Refusal Container sniffed from magic bytes; a mismatch is refused src/image-ingress.js400 Container walked to its own end marker; trailing payload refused (polyglot) src/image-ingress.js400 JPEG APP1/APP13/COM and PNG eXIf/tEXt/zTXt/iTXt/tIME refused src/image-ingress.js400 Dimensions read from the header; >4096px edge or >16MP refused src/image-ingress.js400 Per-caller sliding-window budget, 12 per 60s, with retry-aftersrc/rate-limit.js429 6MB ceiling and 15s body deadline; a half-read socket is dropped src/http-body.js413 / 408 No bytes, hash, or address in any log line or error body server.mjs+ tests— What a user sees when a file is not a photo is now its own state — “That file isn’t a photo Timmy can read” and “Nothing was uploaded” — instead of the failed-analysis copy. Manual logging stays one tap away. File handling is self-contained enough that this release adds no new dependency.
What the release job had to correct before it could ship
Stated plainly, because each one was a real defect found by a gate rather than by reading:
- PR #82 could not pass the gate it added. The builder runs its browser gates with
TIMMY_VISION_ENABLED=false, and the503 AI analysis is disabledbranch ran before the request budget, body limits, and byte-level guard. Every hostile upload therefore answered 503, the refusal boundary was never exercised, andtest:ingress-capturefailed withexpected 400, saw 503. Fixed by putting refusals in cost order regardless of model availability (commit8ce5bc1on the branch). Verified with vision disabled: hostile bytes →400 not_an_image, a valid synthetic photo →503with the existing copy. - The demo only showed the device guard. The boundary the ticket is actually about — the server deciding from bytes — was never on camera. The recording now lifts its own
/api/analyzeinterceptor for one probe, posts non-image bytes to the running app, and reads the status and copy back out of that response; a 200 fails the recording (commit2019d51). Both new captions are required gates. - That probe then broke the demo's error budget. The recording allows no browser console errors except the ones a scene produces on purpose, and it enumerated only two fail-closed 503s. The budget is now two 503s, one 400, and nothing else (commit
9067ce5). The first full build stopped here instead of shipping a recording that had never exercised the boundary.
The RC attack suite now says GO
Yesterday's release shipped
VERDICT: BLOCKEDon two P0 ingress holds —ingress-polyglot-htmlandingress-garbage-bytesboth reached the model worker where they should have been refused. Re-run against this commit:VERDICT: GO ingress-oversized-body P0 pass ingress-polyglot-html P0 pass ingress-garbage-bytes P0 pass consent-required P0 pass model-output-malformed P0 pass replay-duplicate-request P2 pass flood-concurrency P1 pass log-leak-scan P0 pass deletion-withdrawal P0 covered-elsewhere load: 8 requests · p50 17ms · p95 68ms · errors 0 · abstentions 8Scope note, unchanged from yesterday: the suite is a command line, not a screen in the app, and it is not yet wired into
scripts/build_release.py's mandatory gate list. It is evidence the release job read, and it now reads GO instead of BLOCKED. Wiring it in as a blocking gate belongs with #40, which stays open.Completed ticket / PR
- Issue #16 — Harden image ingress: magic bytes, re-encode, limits, metadata stripping, and rate control — closed by this merge.
- PR #82 merged as
c31d0a5, branch head8ce5bc1. Diff review: 21 files, +1091/−30, code, tests, and docs only — no secrets, model weights, or real medical imagery (the only image-path fixture is a committed text file named.jpg); links a non-human-gate issue (labelsarea/backend,area/privacy,priority/P0,type/security,type/test). - Pre-merge verification on the branch head:
npm test277/277;ingress-capture,ui,photo,mobile-capture,sleek,manual-fallback,provisional,followup,vision-refusal,pattern-summary,grounded-chat,photo-lifecycleall green under the builder's own environment (vision disabled), with served-byte hashes checked against the checkout.
Gates — 29/29 passed
npm test277/277 (up from 244 at the previous release); image ingress refusal; mobile green path; photo-first acceptance; sleek Hermes chat; manual fallback + draft recovery; provisional suggestion state; hard-stool follow-up; vision corpus refusal; abstention quality gate; worker container receipts; worker post-load readiness; longitudinal pattern summary; grounded chat citations; training contribution consent; corpus manifest provenance; reviewer adjudication blind; dataset card; calibration harness; operator backup restore; production runbook commands; portable backup restore; dependency audit (0 high); syntax (node/bash/python); secret scan; forbidden artifacts; demo H.264 720×1280 probe; complete MP4 decode; release delta verified painted on camera (99/99 captions, delta caption 4 lines / padBottom 14 px).Artifacts
- source archive
timmy-talking-turd-2026-10-31-9067ce530674.tar.gz— 371,298 bytes — sha256e84bd1ed3cfb3df7cf51d464d070ab84d4ccce9f8b3977401109a7813d4ce295 - demo video
timmy-talking-turd-2026-10-31-9067ce530674-demo.mp4— 29,041,842 bytes — H.264 720×1280 yuv420p, 25 fps, 457.72 s — sha2562c01253110c2633af549e600ab478035444dacf82f81bdbc7b6c570df457b564
Model / profile
Built and recorded by Hermes (profile
default, modeldeepseek-flash). The demo runs the real app from the built commit withTIMMY_VISION_ENABLED=false; vision replies replay the committed CC0 synthetic corpus through the app's real analysis path, and the configured vision profile remains self-hostedSmolVLM2-2.2B-Instruct(pinned GGUF) with the hosted profile as fallback. The newIMAGE INGRESS, REFUSED BY THE SERVERcard is not a fixture: it is one real HTTP request to the running app, and the card prints the status and copy that response returned. It measures the refusal boundary, not model quality — no live inference appears in the video and no real photo is used anywhere.Demonstrated on camera
A file that only claims to be a photo is refused on the device — nothing is uploaded— the committed text fixture chosen asphoto.jpg, refused before any upload, with manual logging one tap awayThe device guard is not the only one: the same bytes, sent straight to the running server— the demo's own interceptor lifted for one probeIMAGE INGRESS, REFUSED BY THE SERVERcard —status 400 refused,code not_an_image,copy That file is not a JPEG, PNG, or WebP photo.,provider never called,budget counted by the per-device limiter,after the journal, the draft, and manual logging are untouched, and ascoperow naming which cases this probe does not coverRefused at the server too: 400 not_an_image, before any model work
Known accuracy limitation
Unchanged by this release, and not made better by it. Bristol accuracy is not clinically validated; the calibration receipt still reports
BLOCKEDbecause no candidate model clears every gate. User confirmation remains mandatory everywhere: photo analysis may suggest visible Bristol form, broad color, and image quality only, and never symptoms or a diagnosis. Visible color is not blood.On the ingress boundary specifically, the claims are bounded by what was tested: the suite's synthetic attacks and 33 new tests, not every conceivable container or decoder exploit. Residual risks are stated in
docs/PHOTO-DATAFLOW-THREAT-MODEL.mdrather than glossed — no server-side transcode, so unusual-but-valid containers are refused, not repaired; PNG CRCs are not validated; the rate budget is per process, so it does not survive a restart and is not shared across workers.Downloads
- PR #82 could not pass the gate it added. The builder runs its browser gates with