/api/analyze decided 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 called.
Control
Module
Refusal
Container sniffed from magic bytes; mismatch refused
src/image-ingress.js
400
Container walked to its own end marker, trailing payload refused (polyglot)
src/image-ingress.js
400
JPEG APP1/APP13/COM and PNG eXIf/tEXt/zTXt/iTXt/tIME refused
src/image-ingress.js
400
Dimensions read from the header; >4096px edge or >16MP refused
6MB ceiling and 15s body deadline, half-read socket dropped
src/http-body.js
413 / 408
No bytes, hash, or address in any log line or error body
server.mjs + tests
—
The app's own upload is a canvas re-encode (JPEG q0.7, longest edge 1200px) and carries no EXIF, PNG, or WebP metadata, so the accepted path is already metadata-free. A file the browser cannot decode now has its own state: "That file isn’t a photo Timmy can read" + "Nothing was uploaded", instead of the failed-analysis copy.
Evidence
npm test — 225 pass, 0 fail (32 new).
tests/analyze-ingress.acceptance.test.js runs the shipping server against synthetic bytes with a counting provider stub: non-image, polyglot, flood (429 + retry-after), and stalled body (408) all refused with provider calls unmoved; a clean re-encoded image still reaches the provider exactly once; the log carries no base64.
tests/ingress-capture.acceptance.mjs (new browser suite, wired into CI and the release builder): the app refuses the fake file, never posts it, keeps manual entry one tap away, then asks the running server to refuse the same bytes (400) and still reaches consent with a real synthetic photo.
Release demo re-recorded end to end: 63/63 captions measured as painted bars, including the new refusal scene (A file that only claims to be a photo is refused on the device — nothing is uploaded); the builder now requires that caption and records image_ingress_refusal: passed.
Notes
Supersedes the unmerged free/hy3-timmy-16 branch (PR #63): that branch has 6 conflicts against current main and 31 commits of drift, so this is a fresh, tested implementation of the same acceptance criteria on today's tree.
No real medical images anywhere: every fixture is synthetic bytes built in the test file, plus a committed text file that claims to be a .jpg.
Residual risks stated in the threat model, not glossed: no server-side transcode (unusual-but-valid containers are refused, not repaired), PNG CRCs not validated, budget is per process.
Not merged, per the release job's contract.
Closes #16
## What this does
`/api/analyze` decided 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 called.
| Control | Module | Refusal |
|---|---|---|
| Container sniffed from magic bytes; mismatch refused | `src/image-ingress.js` | 400 |
| Container walked to its own end marker, trailing payload refused (polyglot) | `src/image-ingress.js` | 400 |
| JPEG APP1/APP13/COM and PNG eXIf/tEXt/zTXt/iTXt/tIME refused | `src/image-ingress.js` | 400 |
| Dimensions read from the header; >4096px edge or >16MP refused | `src/image-ingress.js` | 400 |
| Per-caller sliding-window budget, 12/60s default, retry-after | `src/rate-limit.js` | 429 |
| 6MB ceiling and 15s body deadline, half-read socket dropped | `src/http-body.js` | 413 / 408 |
| No bytes, hash, or address in any log line or error body | `server.mjs` + tests | — |
The app's own upload is a canvas re-encode (JPEG q0.7, longest edge 1200px) and carries no EXIF, PNG, or WebP metadata, so the accepted path is already metadata-free. A file the browser cannot decode now has its own state: "That file isn’t a photo Timmy can read" + "Nothing was uploaded", instead of the failed-analysis copy.
## Evidence
- `npm test` — 225 pass, 0 fail (32 new).
- `tests/analyze-ingress.acceptance.test.js` runs the shipping server against synthetic bytes with a counting provider stub: non-image, polyglot, flood (429 + retry-after), and stalled body (408) all refused with provider calls unmoved; a clean re-encoded image still reaches the provider exactly once; the log carries no base64.
- `tests/ingress-capture.acceptance.mjs` (new browser suite, wired into CI and the release builder): the app refuses the fake file, never posts it, keeps manual entry one tap away, then asks the running server to refuse the same bytes (400) and still reaches consent with a real synthetic photo.
- Browser gates re-run green: ui, photo, mobile-capture, sleek, manual-fallback, provisional, followup, vision-refusal, worker-readiness, pattern-summary, grounded-chat, photo-lifecycle.
- Release demo re-recorded end to end: 63/63 captions measured as painted bars, including the new refusal scene (`A file that only claims to be a photo is refused on the device — nothing is uploaded`); the builder now requires that caption and records `image_ingress_refusal: passed`.
## Notes
- Supersedes the unmerged `free/hy3-timmy-16` branch (PR #63): that branch has 6 conflicts against current `main` and 31 commits of drift, so this is a fresh, tested implementation of the same acceptance criteria on today's tree.
- No real medical images anywhere: every fixture is synthetic bytes built in the test file, plus a committed text file that claims to be a `.jpg`.
- Residual risks stated in the threat model, not glossed: no server-side transcode (unusual-but-valid containers are refused, not repaired), PNG CRCs not validated, budget is per process.
- Not merged, per the release job's contract.
The declared data-URL MIME was a claim the boundary believed. Every image decision now comes from
the bytes: the container is sniffed, walked to its own end marker, refused when metadata segments
remain, and refused above a 4096px edge or 16MP. The request path adds a per-caller sliding-window
budget (429 + Retry-After) and a body deadline (408), so a flood, a stalled body, or a decode bomb
fails before a provider call.
Log hygiene is part of the contract: refusal copy is fixed prose, the bucket key is a salted hash
kept in memory only, and the acceptance test asserts no image bytes reach the process log.
Selecting a file the browser cannot decode used to render "Timmy couldn't analyze that safely",
which described an upload that never happened. It now states that the file isn't a photo Timmy can
read and that nothing was uploaded, with manual entry and the picker both one tap away. The state is
asserted in a new browser suite that also asks the running server to refuse the same bytes, and the
existing mobile capture suite asserts the new copy across both viewports.
The demo now picks a committed text fixture that only claims to be a photo, asserts on camera that
nothing was posted to /api/analyze, and keeps manual entry in the same scene. The builder requires
that caption to have painted and records image_ingress_refusal in the manifest; the threat model
names each implemented control, its module, and the tests that enforce it.
Release-blocking finding on the release-builder gate this PR adds (not merged today).
scripts/build_release.py starts the acceptance server with TIMMY_VISION_ENABLED=false, and server.mjs returns 503 from the !visionConfig.enabled branch beforeingressGuard runs. tests/ingress-capture.acceptance.mjs then asserts 400 for the same bytes and fails:
$ HOST=127.0.0.1 PORT=4173 TIMMY_VISION_ENABLED=false node server.mjs &
$ curl -s -X POST localhost:4173/api/analyze -H 'content-type: application/json' -d '{"consent":true,"imageDataUrl":"data:image/jpeg;base64,PGh0bWw+..."}'
{"error":"AI analysis is disabled. Continue manually."} # HTTP 503
$ npm run test:ingress-capture
AssertionError: the server must refuse non-image bytes, saw 503
CI is green only because .gitea/workflows/quality.yml does not set TIMMY_VISION_ENABLED=false, so the endpoint reaches ingress there. The builder's own new gate is therefore red on this branch: merging as-is makes npm run test:ingress-capture fail the release build.
Suggested fix in this branch: move the per-caller budget and ingressGuard(payload) ahead of the visionConfig.enabled early return, so invalid bytes are refused with 400 in every configuration while a well-formed photo still gets 503 when the provider is off. Then re-run the suite against a server started with TIMMY_VISION_ENABLED=false. The rest of the diff (byte sniffing, polyglot/trailing-data refusal, metadata refusal, header-bounded dimensions, 6MB/15s body limits, no-bytes-in-logs) tested 225/225 locally and read correctly.
Release-blocking finding on the release-builder gate this PR adds (not merged today).
`scripts/build_release.py` starts the acceptance server with `TIMMY_VISION_ENABLED=false`, and `server.mjs` returns 503 from the `!visionConfig.enabled` branch *before* `ingressGuard` runs. `tests/ingress-capture.acceptance.mjs` then asserts 400 for the same bytes and fails:
```
$ HOST=127.0.0.1 PORT=4173 TIMMY_VISION_ENABLED=false node server.mjs &
$ curl -s -X POST localhost:4173/api/analyze -H 'content-type: application/json' -d '{"consent":true,"imageDataUrl":"data:image/jpeg;base64,PGh0bWw+..."}'
{"error":"AI analysis is disabled. Continue manually."} # HTTP 503
$ npm run test:ingress-capture
AssertionError: the server must refuse non-image bytes, saw 503
```
CI is green only because `.gitea/workflows/quality.yml` does not set `TIMMY_VISION_ENABLED=false`, so the endpoint reaches ingress there. The builder's own new gate is therefore red on this branch: merging as-is makes `npm run test:ingress-capture` fail the release build.
Suggested fix in this branch: move the per-caller budget and `ingressGuard(payload)` ahead of the `visionConfig.enabled` early return, so invalid bytes are refused with 400 in every configuration while a well-formed photo still gets 503 when the provider is off. Then re-run the suite against a server started with `TIMMY_VISION_ENABLED=false`. The rest of the diff (byte sniffing, polyglot/trailing-data refusal, metadata refusal, header-bounded dimensions, 6MB/15s body limits, no-bytes-in-logs) tested 225/225 locally and read correctly.
Brings #82 up to today's `main` (1bbcc10) and resolves the two conflicts as ordered
unions: `package.json` `test`/`check:syntax` and the release builder's syntax allow-list
and evidence keys. Both sides only appended, so nothing was dropped.
Verifying the merge against main's RC attack suite (#40) found two real defects, each
fixed test-first:
- An oversized body destroyed the socket mid-upload, so the caller saw `fetch failed`
instead of the 413 it had already earned, and `security_attack_suite.mjs` could not
complete at all. New test in `tests/analyze-ingress.acceptance.test.js` failed with
`fetch failed` before the fix; `src/http-body.js` now keeps draining without buffering
and refuses on `end`, so the ceiling still holds and the refusal is readable.
- #16's per-caller budget turned the suite's own load phase into 8/8 429s, which would
have published refusals as analysis latency. The load phase now runs against a second
permissive target and the suite refuses to publish a load figure if any sample was
refused. `buildReport` also publishes `evidenceVerified`, so the verdict is
re-derivable from the report — asserted by #40's acceptance test, which only trips once
the verdict is GO.
Evidence on the merged tree: `npm test` 277/277, `test:security-attack` 8/8, the RC attack
suite returns VERDICT: GO with both P0 ingress cases passing (main is BLOCKED on exactly
those two), 14 browser acceptance suites green, and the release demo records end to end
(8:13, 97 captions) including the on-camera refusal scene.
Brought this PR up to main (1bbcc10) and closed what verifying it caught. It is mergeable again; still not merged, per the release contract.
Conflicts — both were pure appends, resolved as ordered unions: package.jsontest/check:syntax, and the release builder's syntax allow-list plus evidence keys. Nothing from either side was dropped.
Two defects found by running main's own RC attack suite (#40) against the merged tree, each fixed test-first:
Defect
Failing test first
Fix
An oversized body destroyed the socket mid-upload, so the caller got fetch failed instead of the 413, and the attack suite could not complete
tests/analyze-ingress.acceptance.test.js — refuses an oversized body with a refusal the caller can read (failed with fetch failed)
src/http-body.js drains without buffering and refuses on end; the 6 MB ceiling still holds
The per-caller budget turned the suite's own load phase into 8/8 429s, i.e. refusals published as analysis latency
scripts/security_attack_suite.mjs — load phase now throws if any sample was refused (failed with 8/8 refusals)
Load latency is measured against a second target with the budget opened up
The report dropped evidenceVerified, so the verdict was not re-derivable from what it published
#40's existing acceptance test (test:security-attack)
npm test — 277/277, 0 fail, 0 skipped (run on the committed tree, clean status).
npm run test:security-attack — 8/8.
node scripts/security_attack_suite.mjs — VERDICT: GO, ingress-oversized-body, ingress-polyglot-html, ingress-garbage-bytes all pass with providerCalls 0; load 8 requests, p50 39 ms, p95 169 ms, errors 0, abstentions 8. On main the same command is BLOCKED on exactly those two ingress cases.
14 browser acceptance suites green against a live server on this tree (ui, photo, mobile-capture, ingress-capture, sleek, manual-fallback, provisional, followup, vision-refusal, worker-readiness, pattern-summary, photo-lifecycle, training-contribution, portability).
scripts/record_release_demo.mjs records end to end on this tree — 8:13, 97 captions — including "A file that only claims to be a photo is refused on the device — nothing is uploaded" and the security scene that previously aborted at the attack suite.
docs/RC-SECURITY-EVIDENCE.md no longer predicts BLOCKED for #16; it states the run and leaves queue bounding (#17) as the open P1. No real medical image, secret, generated artifact, or node_modules entry is in the diff.
**Brought this PR up to `main` (1bbcc10) and closed what verifying it caught.** It is mergeable again; still not merged, per the release contract.
**Conflicts** — both were pure appends, resolved as ordered unions: `package.json` `test`/`check:syntax`, and the release builder's syntax allow-list plus evidence keys. Nothing from either side was dropped.
**Two defects found by running `main`'s own RC attack suite (#40) against the merged tree**, each fixed test-first:
| Defect | Failing test first | Fix |
|---|---|---|
| An oversized body destroyed the socket mid-upload, so the caller got `fetch failed` instead of the 413, and the attack suite could not complete | `tests/analyze-ingress.acceptance.test.js` — *refuses an oversized body with a refusal the caller can read* (failed with `fetch failed`) | `src/http-body.js` drains without buffering and refuses on `end`; the 6 MB ceiling still holds |
| The per-caller budget turned the suite's own load phase into 8/8 429s, i.e. refusals published as analysis latency | `scripts/security_attack_suite.mjs` — load phase now throws if any sample was refused (failed with `8/8 refusals`) | Load latency is measured against a second target with the budget opened up |
| The report dropped `evidenceVerified`, so the verdict was not re-derivable from what it published | #40's existing acceptance test (`test:security-attack`) | `buildReport` publishes the flag |
**Evidence on this head (bc64055):**
- `npm test` — 277/277, 0 fail, 0 skipped (run on the committed tree, clean status).
- `npm run test:security-attack` — 8/8.
- `node scripts/security_attack_suite.mjs` — **VERDICT: GO**, `ingress-oversized-body`, `ingress-polyglot-html`, `ingress-garbage-bytes` all pass with `providerCalls 0`; load 8 requests, p50 39 ms, p95 169 ms, **errors 0**, abstentions 8. On `main` the same command is BLOCKED on exactly those two ingress cases.
- 14 browser acceptance suites green against a live server on this tree (ui, photo, mobile-capture, ingress-capture, sleek, manual-fallback, provisional, followup, vision-refusal, worker-readiness, pattern-summary, photo-lifecycle, training-contribution, portability).
- `scripts/record_release_demo.mjs` records end to end on this tree — 8:13, 97 captions — including *"A file that only claims to be a photo is refused on the device — nothing is uploaded"* and the security scene that previously aborted at the attack suite.
`docs/RC-SECURITY-EVIDENCE.md` no longer predicts BLOCKED for #16; it states the run and leaves queue bounding (#17) as the open P1. No real medical image, secret, generated artifact, or `node_modules` entry is in the diff.
The release builder runs its browser gates with TIMMY_VISION_ENABLED=false, and
the 503 "AI analysis is disabled" branch ran before the request budget, body
limits, and byte-level ingress guard. Under builder conditions every hostile
upload answered 503, so the refusal boundary was never exercised and the
ingress-capture gate this same PR added failed:
AssertionError: the server must refuse non-image bytes, saw 503
Refusals now run in cost order regardless of model availability: budget, body,
bytes, then the disabled switch. Verified with vision disabled: hostile bytes
-> 400 not_an_image, a valid synthetic photo -> 503 with the existing copy.
npm test 277 pass; ingress-capture and the other browser gates green under the
builder's env.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #16
What this does
/api/analyzedecided 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 called.src/image-ingress.jssrc/image-ingress.jssrc/image-ingress.jssrc/image-ingress.jssrc/rate-limit.jssrc/http-body.jsserver.mjs+ testsThe app's own upload is a canvas re-encode (JPEG q0.7, longest edge 1200px) and carries no EXIF, PNG, or WebP metadata, so the accepted path is already metadata-free. A file the browser cannot decode now has its own state: "That file isn’t a photo Timmy can read" + "Nothing was uploaded", instead of the failed-analysis copy.
Evidence
npm test— 225 pass, 0 fail (32 new).tests/analyze-ingress.acceptance.test.jsruns the shipping server against synthetic bytes with a counting provider stub: non-image, polyglot, flood (429 + retry-after), and stalled body (408) all refused with provider calls unmoved; a clean re-encoded image still reaches the provider exactly once; the log carries no base64.tests/ingress-capture.acceptance.mjs(new browser suite, wired into CI and the release builder): the app refuses the fake file, never posts it, keeps manual entry one tap away, then asks the running server to refuse the same bytes (400) and still reaches consent with a real synthetic photo.A file that only claims to be a photo is refused on the device — nothing is uploaded); the builder now requires that caption and recordsimage_ingress_refusal: passed.Notes
free/hy3-timmy-16branch (PR #63): that branch has 6 conflicts against currentmainand 31 commits of drift, so this is a fresh, tested implementation of the same acceptance criteria on today's tree..jpg.Release-blocking finding on the release-builder gate this PR adds (not merged today).
scripts/build_release.pystarts the acceptance server withTIMMY_VISION_ENABLED=false, andserver.mjsreturns 503 from the!visionConfig.enabledbranch beforeingressGuardruns.tests/ingress-capture.acceptance.mjsthen asserts 400 for the same bytes and fails:CI is green only because
.gitea/workflows/quality.ymldoes not setTIMMY_VISION_ENABLED=false, so the endpoint reaches ingress there. The builder's own new gate is therefore red on this branch: merging as-is makesnpm run test:ingress-capturefail the release build.Suggested fix in this branch: move the per-caller budget and
ingressGuard(payload)ahead of thevisionConfig.enabledearly return, so invalid bytes are refused with 400 in every configuration while a well-formed photo still gets 503 when the provider is off. Then re-run the suite against a server started withTIMMY_VISION_ENABLED=false. The rest of the diff (byte sniffing, polyglot/trailing-data refusal, metadata refusal, header-bounded dimensions, 6MB/15s body limits, no-bytes-in-logs) tested 225/225 locally and read correctly.Brought this PR up to
main(1bbcc10) and closed what verifying it caught. It is mergeable again; still not merged, per the release contract.Conflicts — both were pure appends, resolved as ordered unions:
package.jsontest/check:syntax, and the release builder's syntax allow-list plus evidence keys. Nothing from either side was dropped.Two defects found by running
main's own RC attack suite (#40) against the merged tree, each fixed test-first:fetch failedinstead of the 413, and the attack suite could not completetests/analyze-ingress.acceptance.test.js— refuses an oversized body with a refusal the caller can read (failed withfetch failed)src/http-body.jsdrains without buffering and refuses onend; the 6 MB ceiling still holdsscripts/security_attack_suite.mjs— load phase now throws if any sample was refused (failed with8/8 refusals)evidenceVerified, so the verdict was not re-derivable from what it publishedtest:security-attack)buildReportpublishes the flagEvidence on this head (
bc64055):npm test— 277/277, 0 fail, 0 skipped (run on the committed tree, clean status).npm run test:security-attack— 8/8.node scripts/security_attack_suite.mjs— VERDICT: GO,ingress-oversized-body,ingress-polyglot-html,ingress-garbage-bytesall pass withproviderCalls 0; load 8 requests, p50 39 ms, p95 169 ms, errors 0, abstentions 8. Onmainthe same command is BLOCKED on exactly those two ingress cases.scripts/record_release_demo.mjsrecords end to end on this tree — 8:13, 97 captions — including "A file that only claims to be a photo is refused on the device — nothing is uploaded" and the security scene that previously aborted at the attack suite.docs/RC-SECURITY-EVIDENCE.mdno longer predicts BLOCKED for #16; it states the run and leaves queue bounding (#17) as the open P1. No real medical image, secret, generated artifact, ornode_modulesentry is in the diff.