Repository navigation
Security bug-hunt loop state #9
Description
Activity
Round 1 — findings: 5 substantive, 0 nitpick (fixed), 2 refuted; nitpicks-only: no; PR #11 merged.
Fixed (all confirmed by an independent adversarial refuter, each with a reproduction test observed failing before and passing after):
- Provider/schema registry prototype-chain DoS —
Object.hasOwnguard (a/constructor-style URL crashed the render). - Star-rating
String.repeatcrash/tab-hang on out-of-range untrusted rating values — clamped. settings.jsonfetch bypassed the source allowlist (presence leak to a non-allowlisted origin) — gated.- Oversized image wiped the whole image cache — over-budget blobs refused, eviction target floored.
__proto__smuggled values past.loose()entity validation — prototype-polluting keys stripped before Zod.
Refuted (considered, not fixed): prototype-pollution via bracket assignment in
useCollection.ts/relationshipResolver.ts(data already Zod-validated; residual is an inert local reparent); schema-registryinflaw as a live bug (no reachable call site — hardened defensively anyway).Deferred to future rounds (out of scope for a smallest-diff autonomous round): plugin worker-sandbox correctness/capability model; un-gated
themeLoaderfetch (same allowlist class as fix 3); unencoded interpolation in several CDN/GitHub URL builders; CSV formula-injection on export; service-worker cache-policy gaps; assorted nitpicks.Merge gate: CI fully green (check, gitleaks, zizmor); two independent reviewers (Claude Fable 5 + Claude Opus 5) both returned SHIP. Merged squash after updating the branch onto the new
main(CI-gates commit #10).Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Provider/schema registry prototype-chain DoS —
Round 2 — findings: 1 substantive, 7 nitpick, 9 refuted; nitpicks-only: no; PR #12 merged.
Fixed (confirmed substantive; adversarially refuted-then-survived; reproduction tests observed failing before and passing after):
- CSV/spreadsheet formula injection on collection export —
escapeCsvValue(src/lib/collectionExport.ts) emitted formula-lead characters verbatim, so an untrusted card field (title/summary/year) beginning with=,+,-,@(or a leading TAB/CR) was written to the downloaded.csvunchanged and evaluated as a formula by Excel/LibreOffice/Sheets even inside a quoted field (data exfiltration viaHYPERLINK/WEBSERVICE, or DDE command execution). Fix prefixes such values with a single quote, before the quote-wrapping step, covering both the header and every data cell. 8 new tests (the module previously had none). CSP is irrelevant here — the vector is a file opened in a desktop spreadsheet app.
Refuted (considered, not fixed):
- GitHub-API URL interpolation without
encodeURIComponentinupdateChecker.ts:105andgithubDiscovery.ts:153— refuted as a security finding: host is pinned to public, unauthenticatedapi.github.com, no credentials in flight, requester equals victim (self-added sources), and the result sinks only into an "update available" badge / a filename list re-fetched through the allowlist. The traversal case is additionally unreachable (a..breaks the mandatory allowlistedcollection.jsonload first). Worth a defence-in-depth encoding clean-up as correctness, not security. themeLoaderun-gated fetch (loaders/themeLoader.ts,services/themeLoader.ts) — refuted: the remote-capableuseTheme(url)inuseThemeLoader.tshas no consumers, so no user-pasted URL reaches it; and CSP would block the egress regardless.- Entire plugin subsystem (
src/plugins/**: denylist-based worker sandbox not blockingnew Worker; URL-manifest path skipping Zod; themecustomCSS→<style>; source adapters fetching without a capability check; fail-open unknown-capability grant) — refuted as live findings: the runtime is dormant. Nothing outsidesrc/plugins/invokes the loader/adapters/sandbox (only a type-only import and a dead localStorage key touch it); the live mechanic/theme/source systems are a separate implementation. Correctly deferred until the subsystem is wired in. - Round-1
useCollection.ts/relationshipResolver.tsbracket-assignment re-checked and re-confirmed inert (Zod-validated keys only).
Confirmed nitpick / defence-in-depth (real but not substantive; deferred to keep the round's diff minimal and regression-free):
- Source-allowlist gating of the health-check, update-check and manifest fetches, and the add-source form — reachable un-gated
fetch/HEAD calls, but the shipped CSPconnect-srcblocks non-allowlisted egress in the browser, so no production presence-leak. Single-chokepoint fix available (gatesourceStore.addSource); endorsed by the Round-1settings.jsonprecedent but carries UX/regression risk, so left for a dedicated round. - Service-worker runtime cache policy (
vite.config.ts: opaque cross-origin responses; broad any-origin image rule) — data/image caches only, no code-execution path, CSP-constrained. - Markdown export leaves untrusted HTML/link syntax active (
exportToMarkdown). validateForcedSettingsuses stale enum vocabularies that no longer match the settings schema (dead/wrong forced-setting validation).picsum.photosimage-placeholder host is on neither the source allowlist nor CSPconnect-src(third-party contact + failed cache preload).ALLOWED_INPUT_DOMAINSincludesgithub.com/gitlab.comwhich the CSPconnect-srcomits (silent load failure instead of a clear rejection).entityTypes[ref]/[target]looked up without an own-property guard (aref: "__proto__"would throw during render) — latent:FieldOptionsProvideris not mounted.
Merge gate: CI fully green (
check,gitleaks,zizmor); two independent adversarial reviewers — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy) — both returned SHIP. Merged squash and branch deleted.Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- CSV/spreadsheet formula injection on collection export —
Round 3 — findings: 4 substantive, 10 nitpick, 2 refuted; nitpicks-only: no; PR #13 merged.
Fixed (all confirmed by an independent adversarial refuter, each with a reproduction test observed failing before and passing after):
- Imported theme colours could beacon to an arbitrary host, bypassing the egress allowlist.
settingsExport.schema.tsvalidated the five theme-customisation colour fields as free-formz.string()(the siblingthemeExport.schema.tsrestricts them to hex). On settings import the value is written verbatim into a CSS custom property that livebackground:shorthands consume, so a shared settings file withaccentColour: "url(https://attacker.example/beacon)"resolved tobackground: url(…)and fired an outbound request — governed by the permissiveimg-src https:, bypassingconnect-src. Restricted to hex, matching the theme export schema. - Large untrusted collection crashed the Competing mechanic.
numericFields.tsspread a per-card array intoMath.min(...)/Math.max(...); the array is sized by the (untrusted) card count, so a very large collection threwRangeErroron init. Replaced with a single-pass loop. - Malformed forced
fieldMappingpersistently denied the primary view. A remotesettings.json's forcedfieldMappingcontainer was type-checked but its values were not, so a non-string path reached the render-time resolver'ssplitand threw on every card render; becausefieldMappingis persisted, the poison survived reloads and collection switches until a manual reset. Now validates each value as a string (topBadgeFieldpreserved), with a defensive guard inresolveFieldPath. - Untrusted entity id crashed the edit form via the prototype chain.
editsStoregetEdit/hasEditsindexed a plain object with an id from untrusted collection data, so an id such as"toString"resolved to an inheritedObject.prototypemember. AddedObject.hasOwnguards, matching the Round-1 provider-registry fix.
Refuted (considered, not fixed): provider
..path traversal inproviders/index.ts(the..mechanism is real but escapes only a non-security UX template — the app already loads arbitrary public content from allowlisted CDNs by design, cannot change host/scheme, and output stays Zod-validated);CollectionDataContextedits[card.id]lookup (inert —{...undefined}spread is a no-op and_editedAt: undefinedreads as "no edits").Nitpick / defence-in-depth (real but not substantive; deferred to keep the diff minimal): CSP tightening (
style-src 'unsafe-inline',img-src https:wildcard,worker-src blob:, promoting the app CSP from a meta tag to a real header); broadening the source allowlist to gate the remaining un-gated health-check/update-check/manifest fetches andsourceStore.addSource(CSP-neutralised today);migrate-collection.tsdev-only argv path traversal;useVisualTheme.ts:202card-backurl()interpolation (latent — fed only built-in URLs); GitHub-API URL interpolation withoutencodeURIComponent(host-pinned, no credentials);picsum.photosplaceholder host on neither allowlist nor CSP with unencoded id;useImageValidationCSP-blocked images cached as invalid for 7 days;fillTheBlank$&-style replacement string;themeLoaderadvisory-only trust check; competing/snap-ranking bracket-assignment keyed on ids (inert, Zod-validated).Merge gate: CI fully green (
check,gitleaks,zizmor) on the head commit; one remediation round addedtopBadgeFieldback to the validated forced-fieldMapping allowlist after a reviewer flagged an incidental feature narrowing; two independent adversarial reviewers — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy) — both returned SHIP on the updated diff. Merged squash; branch deleted.Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Imported theme colours could beacon to an arbitrary host, bypassing the egress allowlist.
Round 4 — findings: 7 substantive, 15 nitpick, 1 refuted; nitpicks-only: no; PR #14 merged.
Fixed (7 confirmed substantive; each survived an independent adversarial refuter, each with a reproduction test observed failing before and passing after):
- An entity
rating/averageRatingofnullcrashed the entire collection load (isStructuredRatingdid"score" in null). Guard is now null- and type-safe;normaliseRating/formatRatingcoerce malformed values; the load treatsnullas "no rating". (src/types/rating.ts,src/hooks/useCollection.ts) - A relationship
target(or entity field) named after anObject.prototypemember such as"toString"resolved to an inherited function on the plain-objectentityMapsand threwmap.get is not a function, failing the load —Object.hasOwnguards on both the explicit and implicit paths. (src/loaders/relationshipResolver.ts) - A non-array
videosfield threw via.map()—Array.isArrayguard. (src/hooks/useCollection.ts) - A non-string resolved platform
titlethrew via.replace()— coerced like the primary entity's title. (src/hooks/useCollection.ts) - Top Trumps numeric-field detection DoS.
detectNumericFieldswas O(keys × cards) with an attacker-scaled key space, freezing the main thread for tens of seconds on a hostile collection (measured ~30s at 3000 cards × 15 unique fields; new single-pass ~250ms). (src/mechanics/competing/utils/numericFields.ts) - Trusted-source brand spoofing. Source detection substring-matched bare patterns against the whole URL, so a collection could brand an attacker link with a trusted source's icon/name (and suppress the visible URL in the card view) via
https://evil.example/en.wikipedia.org/x; it also false-matcheddesign.comforign.com. Detection now matches the URL hostname, dot-anchored. (src/components/SourceIcon/SourceIcon.tsx) - Pre-commit PII/British gate bypass.
git diff --cached --name-onlyhonourscore.quotePath(default true), so a staged path with a non-ASCII/backslash/quote/newline character was emitted C-quoted,git show ":<that>"failed, and the file was silently skipped — coverage the CI secret scanners don't fully backstop. Now NUL-terminated withcore.quotePath=false,read -r -d '', fail-closed on unreadable blobs. Verified in a throwaway repo. (scripts/lib/staged-files.sh,scripts/check-pii.sh,scripts/check-british.sh)
Confirmed but deferred (out of scope for a smallest-diff round): collection
forcedsettings from a remotesettings.jsonare written into the persisted top-level global settings and are never reverted (clearCollectionForcedSettingshas no callers), so viewing one hostile source once permanently rewrites the visitor's global display config. The correct fix is a collection-scoped overlay layered at read time plus a persisted-store migration — cross-cutting, moderate/high regression risk on a persisted store. Flagged for a dedicated round.Refuted (considered, not fixed): stale
dist/analyser artefact leak — the site build never emptiesdist/andbuild:analysewritesdist/stats.html(absolute local paths) into the publish root, butdist/is gitignored and Cloudflare Pages builds from a clean checkout withANALYZEunset, so it never reaches production. Hygiene, not a live leak.Also surfaced this round but deferred un-reviewed (hunter-flagged correctness/UX defects, not the primary security lens; logged for future rounds):
applyMechanicOverridesclobbers its own backup on a second activation (loses persisted user card-size settings); "Import Collection" writes to a localStorage key nothing reads, so import silently no-ops while claiming success; the edits-import dialog maps Cancel → destructive replace-all; list/compact/fit views mount oneCardExpandedper card (per-item resize listeners + a body-scroll-lock leak); quiz A–D keyboard shortcut fires on Arrow/Alt/etc.; quiz distractors de-duped by id only (duplicate-title questions score a valid option wrong);getShuffledAnswersbiased shuffle; inert "Auto-Advance Rounds" toggle. These are real but carry product-behaviour decisions and/or wider diffs; kept out to keep this round security-focused and low-regression.Nitpicks (15, deferred): entity-id/username URL-encoding in
collectionLoader/dataSource;check-piiemail-allowlist unanchored regex; TruffleHog mutable-tag pin and--only-verified;check-britishwhole-line URL exemption;build-siteCSP-hash regex fragility; app-shell Google Fonts third-party CSS; CIpush+pull_requestdouble-run;CacheConsentDialogunbounded collection name;ImageWithFallbacknosrcresync;useImageValidationdouble-fire; single-slotappliedCollectionDefaultsSourceIdre-apply;fillTheBlankunbounded alternative answers;relationshipToNameduplicate-title questions.Merge gate: CI fully green (
check,gitleaks,zizmor) on the head commit; two independent adversarial reviewers — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy) — both returned SHIP, no remediation round needed. Merged squash; branch auto-deleted. 912 tests pass (+24 new).Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- An entity
Round 5 — findings: 5 substantive (4 fixed, 1 deferred), 12 nitpick, 5 refuted; nitpicks-only: no; PR #15 merged.
Fixed (4 confirmed substantive; each survived an independent adversarial refuter, each with a reproduction test observed failing before and passing after; both pre-merge reviewers reproduced the pre-fix failures):
- Malformed
detailUrlscrashed the whole collection load.detailUrlsis an unvalidated.loose()passthrough, sodetailUrls: [null]reachednormaliseDetailUrls(src/types/links.ts), which dereferenced.urlbefore the null-safesafeExternalUrl; the throw escaped the per-entity map and rejected the entire load. Now filters each element through the existingisDetailLinkguard first. - Unbounded entity fetch fan-out (main-thread stall + CDN request amplification).
loadEntitiesFromDirectory(src/loaders/collectionLoader.ts) mapped an untrustedindex.jsonid list straight intoPromise.all— no concurrency limit, no cap. Now loads through a fixed-size concurrency pool (ENTITY_FETCH_CONCURRENCY) with a generous id cap (MAX_ENTITY_IDS, warns on truncation), order-preserving, mirroringimageCache's batching. - Snap Ranking guess-button DoS. One
<button>was rendered per distinct badge value; the default per-cardorderfield made a large untrusted collection render a button per card (tens of thousands of DOM nodes, rebuilt on every flip).initGame(src/mechanics/snap-ranking/store.ts) now refuses aboveMAX_UNIQUE_VALUESdistinct values with a clear message, mirroring the existing empty / all-same guards. - "Hard Reset" left persisted data behind (data remanence). The reset cleared only five localStorage keys and the app IndexedDB DB, leaving cached remote collections (idb-keyval's separate store), imported data,
itemdeck-config, collection game state, and the plugin cache DB. ExtractedclearAllPersistedData(src/lib/clearPersistedData.ts): sweeps everyitemdeck--prefixed localStorage key and clears all three IndexedDB stores.
11 reproduction tests added (912 → 923).
Deferred (confirmed substantive; its own dedicated round): collection
forcedsettings from a remotesettings.jsonpersist into top-level global settings with no revert path (clearCollectionForcedSettingsis unused), so viewing one hostile source once permanently rewrites the visitor's global display config. This round's hunt produced a concrete smallest-diff design: a symmetric backup/restore mirroring the existing_mechanicOverridesBackuppattern (snapshot the touched keys on apply; restore on source change without clearingappliedCollectionDefaultsSourceId; persist the backup for crash recovery). Still deferred here because it touches a persisted store's serialization, a migration, and crash recovery — deserving a focused round rather than bundling.Refuted (considered, not fixed): GitHub-API URL interpolation without
encodeURIComponent(host-pinned to public unauthenticatedapi.github.com, no credentials); provider..path traversal (escapes only a non-security UX template, output Zod-validated); remaining un-gated health/update/manifest fetches (CSPconnect-src-neutralised in the browser); the entire plugin subsystem's worker sandbox / URL-manifest /customCSSpaths (dormant — no live callers outsidesrc/plugins/); the GitHub entity-discovery fan-out path (already ~1000-bounded by the Contents API).Nitpicks / defence-in-depth / follow-ups (12, not fixed):
fontUrl/cardBackBackgroundImagesettings-import fields use barez.url()(unreachable — no consumer);validateForcedSettingsenum allow-lists stale vs the real types;fieldDiscoverybracket lookups lackObject.hasOwn(latent — provider unmounted); image fetch has no size/timeout pre-check (size checked after the body is materialised);cardBackBackground/usePlaceholderImagesmissing frompartialize(silently revert on reload);themeCustomisationsdead validation branch;migrate-collectiondev-CLI argv path traversal;collectionStatsMath.min/maxspread;themeLoaderun-gated fetch;normaliseRatingstructured-branch passthrough; residualentityTypes-count fan-out inloadCollection(sibling of fix 2 — cap the type count in a future round);deleteDBlacks anonblockedhandler so the hard reset could stall/skip later steps if another tab holds the DB (pre-existing; route it through the tolerant delete next round).Merge gate: CI fully green (
check,gitleaks,zizmor) on the head commit; two independent adversarial reviewers — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy) — both returned SHIP, no remediation round needed. Merged squash; branch auto-deleted. 923 tests pass (+11 new).Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Malformed
Round 6 — findings: 7 substantive, 15 nitpick, 2 refuted; nitpicks-only: no; PR #16 merged.
Fixed (7 confirmed substantive; each survived an independent Opus 5 adversarial refuter, each with a reproduction test observed failing before and passing after):
- Collection
forcedsettings permanently overwrote the user's global settings. A remotesettings.json'sforcedblock was written into the visitor's persisted global settings with no backup, and the only teardown action (clearCollectionForcedSettings) had zero callers — reachable via a single unconfirmed/gh/<user>/link that auto-adds and activates the source, then rewrites the display config across reloads and source switches (several keys unrecoverable without a full reset). Now snapshots the user's own value per forced key (source-scoped; a same-source refetch that begins forcing an additional key is still captured), restores on source change via a dedicatedCollectionDataContexteffect, and rolls back on rehydration for crash recovery — mirroring_mechanicOverridesBackup. The dead action is repurposed intorestoreCollectionForcedSettings. This was the deferred item from rounds 4/5. - Object-typed entity fields crashed the whole collection view.
summaryand resolved platformtitle/shortTitle/summary/yearwere cast, not coerced; the.loose()schema lets an object through, which reaches JSX as a child and throws "Objects are not valid as a React child" — the device-badge sink fires on first paint in the default grid. Coerced with a shared helper. collectionStatsmin/max spread crashed on large collections.Math.min(...values)/Math.max(...values)over a per-card array throwsRangeErrorpast the engine argument limit (~125k);CollectionToastrenders outside the collection error boundary, blanking the app. Replaced with a single-pass loop.- Uncapped entity-type fan-out.
loadCollectionmapped the untrustedentityTypesrecord into aPromise.all, each type probing several fetches — amplifying one load into ~80k CDN requests plus GitHub-quota exhaustion. Capped and pooled, always retaining the primary type. (Round 5's caps only covered the per-directory id path.) - Uncapped discovery fan-out.
useMyPlausibleMeDiscoveryran aPromise.allover everycollection.jsonin the GitHub tree; the username is attacker-controlled and discovery auto-runs on the startup picker with no click, so a/gh/<user>/link listing thousands of collections became a CDN flood. Capped and pooled. - Unbounded media per card.
card.imageUrls(images + videos) was uncapped and feeds the gallery dot buttons and the load-time preloader. Capped the combined list at the data layer. - Hard reset silently left data behind or hung. The three IndexedDB cleanups ran sequentially and only the first (
deleteDB) was failure-intolerant, anddeleteDBhad noonblockedhandler — a blocked/failed app-DB delete hung the dialog or aborted the rest, leaving cached collections and the plugin DB on disk while the UI reported "delete everything". Addedonblockedand switched toPromise.allSettled.
946 → verified via 21 new reproduction tests (suite 944 passing; +19 in the main round, +2 escalation tests added during remediation).
Refuted (considered, not fixed):
applyMechanicOverridessecond-activation backup clobber (unreachable — the Start Game overlay is gated on!activeMechanic, so overrides only apply when no mechanic is active and every deactivation restores first); write-onlycollectionForcedSettingsmid-session revert (false mechanism — TanStack Query structural sharing keepsdata.settingsidentity stable across refetches, so the apply effect does not re-fire; folded into fix 1).Nitpick / deferred (real but not substantive, ~15): TruffleHog pre-commit
--since-commit HEADempty-range scan; gitleaks checksum not provenance-anchored;build:analysewritingstats.htmlinto the deploy root;check-pii.shwholesale script exemption;git show ":$file":0:stage-prefix resolution;build-site.mjsprototype-inheriting block map; forcedcardBackStyle/titleDisplayModeenum mismatch vs the store types;getDB()in-flight dedup (root cause of the delete-blocked path);imageUrlscheme-filter bypass (latent, inert today);maxVisibleCardsno upper bound;resetToDefaultsshares module-level object refs; crafted-localStorage rehydration crash (same-origin only);edits[card.id]proto lookup inCollectionDataContext; game-store plain-object proto keys; quiz unbounded alternative answers /$&-replace / duplicate-id throw. 0 npm-audit findings on production deps (no dependency changes made).Merge gate. CI fully green (
check,gitleaks,zizmor) on the head commit. Two independent adversarial reviewers — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy). First pass: Opus SHIP, Fable NO-SHIP on a genuine per-key snapshot gap (a same-source refetch forcing an additional key lost the user's value). One remediation round fixed the snapshot to be per-key (with 2 escalation regression tests); a lint error introduced by that commit turned CI red, cleared by a mechanical follow-up (generic helper → explicit per-key literals, behaviour identical). Both reviewers then returned SHIP on the final head. Merged squash; branch auto-deleted.Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Collection
Round 7 — findings: 11 substantive, 20 nitpick, 3 refuted; nitpicks-only: no; PR #17 merged.
Fixed (11 confirmed substantive; each survived an independent Opus 5 adversarial refuter, each with a reproduction test observed failing before and passing after):
settings.jsonmaxVisibleCardscould disable card flipping. The lower bound was tested before the floor and there was no finiteness check, so0.5stored0and1e400storedInfinity(which serialises tonull). At either valueCardGriddiscards every card on flip. It persists globally and is adefaultskey, so the round-6 forced-settings restore does not cover it — the breakage follows the visitor to every later collection.settings.jsonsearchFieldswas uncapped. Search resolves every field on every card per settled query: 5 000 fields (43 KiB) against a 500-card collection measured 1.6 s per search versus 5.7 ms at the 3-field default. Also persisted globally, with no UI to edit it back.- Non-string
DetailLink.source/labelcrashed the card grid. The entity schema is loose and round 5 hardened only the URL. A numericsourcethrows where the sources overlay lowercases it; an object-valuedsource/labelreaches JSX as a child. Either replaces the whole grid with the error boundary, two ordinary clicks after load. - Duplicate entity ids caused a render-time throw.
CardGrid's random-selection guard proves "every selected id still exists" by comparing counts, which only holds if ids are unique. Also produced duplicate React keys in every view and made one flip toggle several cards. Fixed at the root in the loader, which also drops a redundant fetch per repeat. - Quiz generation froze the tab. Wrong-answer selection scanned the correct-answer array linearly per candidate value, with both dimensions collection-sized: 7.37 MB measured 168 s of frozen main thread against under 200 ms to load and validate. The emitted question also carried one rendered option per alternative (399 measured in
fillTheBlank). Both generators fixed. - A card id of
"__proto__"was unscoreable in Snap Ranking. Assigning a primitive through the inherited setter is a silent no-op, so theundefinedguard read backObject.prototype. Perfect play scored 20 against a displayed maximum of 30. - Relationship resolution was quadratic in payload size. The whole relationship record was rebuilt per entity in both the resolve and rank passes: 10 000 relationships × 1 000 entities measured 11.9 s, now 44 ms. 25 KiB gzipped bought a 10 s freeze.
- Image preloading could lock the app behind the loading overlay. No aggregate cap, and the cache probed one URL at a time with the first progress tick only after the whole loop. The overlay clears only at 100% with no skip, and the active source persists — a reload re-enters the same state.
- Image caching was quadratic and its budget check was not concurrency-safe. Storing N images cost N(N+1)/2 record reads. Admission, eviction and the write now happen in one transaction over both stores.
- Filter dropdowns had no option ceiling. One checkbox per option, re-reconciled on every toggle;
genresuncapped per entity, platform/year bounded only by the 10 000-entity cap. All three capped. - "Never cache" did not stop image caching. The preference only suppressed the consent prompt while preloading still fetched every image from the third-party host and wrote it to IndexedDB — strictly weaker than declining once. Verified empirically against real IndexedDB.
41 reproduction tests added (944 → 985).
Deferred (confirmed, own dedicated round): entity edits live in one flat, source-unscoped map, so switching collections bleeds one collection's private notes onto another's cards and silently overwrites them. Collision is routine, not adversarial — the project's own tutorial mints
item-1/books. Refuted as a security finding (no exfiltration path) but it contradicts a written requirement in R-020; every correct fix changes the persisted shape of a store with noversionormigrate, plus a product decision about provenance-less existing edits. Also deferred: "Import Collection" writes an unvalidated, unbounded blob to a localStorage key nothing reads, so the feature is inert while reporting success — removing the button or building the feature are both product calls.Refuted (considered, not fixed): forced
cardBackStyle/titleDisplayModeenum mismatch re-raised as substantive — the mismatch is real but the claimed impact is not (the--card-title-*custom properties are defined and never read;cardBackStylehas no renderer at all), so it remains the round-6 nitpick. Re-confirmed from earlier rounds: GitHub-API URL interpolation, provider..traversal, un-gated health/update/manifest fetches, and the dormantsrc/plugins/**subsystem.Nitpicks / logged for later (~20):
evictLRUdoes not persist corrected totals when its scan fallback runs but nothing needs evicting (unreachable — no callers);readTotalsheals downward drift only; "never cache" is enforced at the loading-screen call site rather than insidepreloadImages/imageCache.set; truncation is silent formaxVisibleCards/searchFieldsbut warned elsewhere; the preload cap has no in-UI signal;mayCacheImagesmemoises on a stable action identity rather than subscribing tocacheConsentGranted;capFilterOptionswarns from inside auseMemo; lint is scoped tosrc/so test files are unlinted; nodependabot.yml/renovate.json; bothsize-limitscripts are broken (wrong flag and pre-dist/demopaths); the PII/British gates have no CI enforcement; plus dead code inuseCollectionManifest,useCardData,StorageSettingsTabsand several unused hook exports.Dependency review (report only, no changes):
npm audit --omit=devreports 0 advisories in the production tree. All 25 advisories are devDependency-only. Worth attention outside this loop: theviteandstorybookdev-server advisories are drive-by exploitable whilenpm run dev/storybookis listening.Merge gate. CI fully green (
check,gitleaks,zizmor) on the head commit. Both reviewers returned NO-SHIP on the first pass — one on a genuine non-atomic eviction admission check (reproduced: budget overshoot to 140% of maximum under the preloader's 5-wide concurrency), the other on three behaviour changes shipping without tests, a filter cap covering one field of three, and ~580 lines of unrelated reformatting bundled into a security commit. One remediation round addressed all four; both then returned SHIP on the updated diff, the second reviewer having verified the hand-restored formatting was lossless by token-stream diff and independently reproduced each previously-missing test failure. Merged squash; branch auto-deleted.Protocol deviations, for the record: (a) the orchestrator ran as Claude Opus 5 rather than Fable 5 — the session model was changed mid-run; (b) the Fable 5 reviewer slot could not run at all, hitting a hard quota limit in this environment, so the dual review was Claude Sonnet 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy). Two genuinely distinct models with complementary lenses, and no reviewer reviewed work it produced, but not the pairing the protocol specifies.
Consecutive nitpick-only rounds: 0.
Generated by Claude Code
Round 8 — findings: 5 substantive, 20 nitpick, 2 refuted; nitpicks-only: no; PR #18 merged.
Fixed (5 confirmed substantive; each survived an independent Opus 5 adversarial refuter, each with a reproduction test observed failing before and passing after):
- Discovered
collection.jsonmetadata could blank the whole app. The startup picker renders discoveredname/description/itemCountdirectly as React children and sits above every error boundary;fetchCollectionMetadata(src/hooks/useMyPlausibleMeDiscovery.ts) only checkedtypeof data === "object". An attacker/gh/<user>/link — discovery auto-runs on the picker with no click — serving a non-primitive value threw "Objects are not valid as a React child" and unmounted the entire tree, and the badnamealso persisted into the source store. Coerced all three to primitives at the discovery trust boundary (flagged independently by three hunters). - Uncapped per-entity DOM fan-out.
getDisplayableFields(src/utils/entityFields.ts) and the platformadditionalFieldscopy (src/hooks/useCollection.ts) emitted one DOM row per entity key with no ceiling; the entity schema is.loose(), so a single entity carrying ~100k scalar keys mounted a ~300k-node subtree in one synchronous commit when its "More" overlay opened, freezing/OOM-killing the tab. Both paths capped at 100, matching the existingMAX_MEDIA_PER_CARD. - Inverted forced-setting allowlists.
validateForcedSettings(src/loaders/settingsLoader.ts) guardedcardBackStyleandtitleDisplayModeagainst allowlists (plain|pattern|gradient,always|hover|never) matching neither the realCardBackStyle(bitmap|svg|colour) norTitleDisplayMode(truncate|wrap) enums — so every honest author's forced value was silently dropped while out-of-enum values were accepted, persisted globally, and then rejected wholesale by the settings-export schema on reimport, bricking the user's own backup. Corrected to the real enums. This fixes the round-6/7 mismatch on its genuine grounds (the correctness drop plus the export-brick self-DoS) — not the previously-refuted CSS-clamping claim, which remains inert (the--card-title-*custom properties are still defined-but-never-read). - Replace-mode settings import re-armed by the active collection. A "replace" import called
resetToDefaults(), clearing the one-timehasAppliedCollectionDefaultsmarker and re-arming theCollectionDataContexteffect, so the active untrusted collection'sdefaults(includingfieldMapping) immediately overwrote seven just-imported fields while the UI reported success. The marker is now preserved across the replace reset (src/utils/settingsExport.ts). - Imported edits could persistently crash the grid. The edits import schema typed field values as
z.unknown()and merged them raw over the source card, which is rendered as a React child; an "edits backup" with an object/array-valuedtitlethrew on every grid render and, because edits persist unscoped, bricked the collection view across reloads and other collections. Edit field values are now restricted to JSON primitives at the import boundary — the only shape the edit form produces (src/utils/editExport.ts).
9 reproduction tests added (985 → 994).
Refuted (confirmed as candidates, then disproven by an independent Opus 5 refuter; not fixed):
?reset=1settings wipe — a documented, in-app-surfaced self-service reset that clears only theitemdeck-settingskey (edits/themes/sources/plugins survive under separate keys); the substring match has no realistic colliding URL the app generates or accepts. Reduced to a minor hardening item.- Unbounded
collection.jsonmaxVisibleCards— a genuinely distinct path from the round-7settings.jsonfix, butz.number().int().positive()already blocks the harmful non-finite / sub-1 / float inputs, and the sole consumer uses the value only as a downward cap min'd against the real card count, so a large value is inert rather than a DoS (residual: a self-inflicted export/reimport annoyance and a missing.max()for schema consistency).
Nitpick / defence-in-depth / correctness (real but not substantive-security; ~20, not fixed): quiz
maxScoreis unattainable so a flawless quiz reports ~85% (correctness, product-behaviour decision); Ctrl/Cmd-R both re-shuffles immediately and opens the "Reset View?" dialog with no undo (a duplicatewindowkeydown registration);partializestill omitscardBackBackground/usePlaceholderImages(silent reset on reload); single-slotappliedCollectionDefaultsSourceIdre-applies a revisited collection's defaults; rawedits[card.id]inherited-key lookup inCollectionDataContextbypasses the hardenedgetEdit(inert);pluginStorein/index lookups on plugin ids (dormant subsystem); residual label-based matching inSourceIcon(gated, latent); latent casts inuseCollection(platformTitle,metadata.category) and akey in displayCardcopy that dropsObject.prototype-named fields; deaduseAvailableBackgrounds/theme-loader code and a dead uncappedObject.entriesrender sink in the unusedCardDetailModal; edits-import confirmation shows the file's self-declarededitCount; dev-onlyscripts/migrate-collection.ts(parseInt→nullin migrated files, hardcoded "Fair use" licence for Wikimedia images) andextract-wikipedia-images.mjsexiting 0 on failure;tsconfig/lint/coverage gates scoped tosrc/only.Pre-merge reviewer follow-ups (both reviewers returned SHIP; logged, not blocking): the platform-field cap half of fix 2 ships without a dedicated test (only
getDisplayableFieldsis covered); fix 2 caps DOM nodes but still formats +localeCompare-sorts all keys before theslice, so a ~100k-key entity costs some main-thread CPU even though the mount-DoS is closed (cap-before-sort is a follow-up);MAX_PLATFORM_FIELDSis a function-local const rather than a module-levelMAX_*; and the deadCardDetailModaluncapped sink should be deleted.Merge gate. CI fully green (
check,gitleaks,zizmor) on the head commit. Two independent adversarial reviewers with distinct models and complementary lenses — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy) — both returned SHIP, no remediation round needed. The Fable 5 reviewer independently re-ran the five affected test files againstorigin/mainand confirmed exactly the 7 new reproduction tests fail pre-fix (60 others pass), then 994/994 on the branch. Merged squash; branch auto-deleted. No dependency changes;npm audit --omit=devreports 0 advisories in the production tree.Protocol note: no deviations this round — orchestrator ran as Claude Fable 5, hunters and refuters as Claude Opus 5, and the dual PR review used the specified Fable 5 + Opus 5 pairing.
Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Discovered
Round 9 — findings: 9 substantive, 12 nitpick, 2 refuted; nitpicks-only: no; PR #19 merged.
Fixed (9 confirmed substantive; each survived an independent Opus 5 adversarial refuter, each with a reproduction test observed failing before and passing after):
- Relationship / single-file entity-array DoS. The untrusted
relationshipsrecord is unbounded andtype.fieldkeyed; the round-7 grouping bounded the per-entity rebuild but a record whose keys all share the primary type's prefix collapses into one bucket, so both the resolve and per-card rank passes wereO(relationships × entities)again (measured multi-minute freeze). Capped atMAX_RELATIONSHIPS; the single-file entity-array paths ({type}s.json/{type}.json) now share theMAX_ENTITY_IDSceiling the index path enforced, removing the entity-count multiplier. - Uncapped settings field-options.
useAvailableFieldsturned every key of the sampled.loose()entities into an<option>in the settings sort/badge/group-by selectors, which render outside the collection error boundary, so a hostile key space froze/OOM-killed the tab; capped atMAX_AVAILABLE_FIELDS, stopping the per-card walk early. - Uncapped detail links.
card.detailUrlsescapedMAX_MEDIA_PER_CARD(built from the uncappeddetailUrlsplus one link per uncappedvideosentry) and the sources overlay mounted an anchor + several URL parses per link; capped atMAX_DETAIL_LINKS_PER_CARD. Remediation additionally capped the platform-overlaycategoryInfo.detailUrlspath (see below). - Unbounded rank-placeholder / UI label. A forced
rankPlaceholderTextand the v2uiLabels.rankPlaceholderare rendered once per unranked card with no virtualisation; capped at ingress and truncated in the schema (truncation, not rejection, so one long label cannot deny the whole load). - Uncapped settings-import fields. The import schema accepted an unbounded
searchFieldsandrankPlaceholderText— values the collection loader already caps, persisted globally; both bounded on import. - Silent source loss on store-version bump. The source store declares a persist version (already bumped 1→2→3) but no
migrate, so a version mismatch handedundefinedtomerge, which threw on.sources; the swallowed throw discarded every configured source on upgrade. Added amigrate(carrying the persisted state forward so the existing dedup/legacy-cleanup runs as the migration) and mademergeundefined-safe. - Platform filter matched the wrong field. Options were collected from the short title but matched against the full
categoryTitle, emptying the grid whenever they differed. - Genre filter matched only the first genre. Options listed every genre but the filter compared
genres[0], silently dropping cards whose matching genre sat at a later index. Fixes 7–8 move the field names, option source and match predicate into one module (filterMatch) so the two sides cannot drift; array-valued fields match by membership. - Replace-import re-armed collection defaults. The round-7/8 fix preserved
hasAppliedCollectionDefaultsbut notappliedCollectionDefaultsSourceId(the markerapplyCollectionSettingscompares against the active source), so the active collection'ssettings.jsondefaults re-armed to clobber the just-imported values on the next load; both markers are now snapshotted and restored.
20 reproduction tests added (994 → 1014).
Refuted (considered, disproven by an independent refuter; not fixed): imported-collection localStorage write reframed as a quota-DoS (self-inflicted, deliberate-action-gated dead code; the DoS framing is unsubstantiated), and the provider
..collection-path traversal spoof (two independent refuters — grants nothing the design-intended?collection=public-CDN route already grants, and the loading screen renders the traversal string).Nitpick / defence-in-depth (real but not substantive, ~12, not fixed): the
tsc -b-emittedvite.config.jsshadowingvite.config.ts(production build is a clean checkout, so a local-dev staleness wart only); committed Playwrighttest-results/artefacts (no PII/secrets, no binary-trace leak channel on developer machines); the?reset=1substring collision (latent — nopresetquery param exists); snap-ranking mixed number/string badge values (low-likelihood, graceful categorical degradation); CSPconnect-srcnarrower thanALLOWED_INPUT_DOMAINS;check-pii.shbinary-blob NUL mangling;editsStorerevertFieldbare bracket lookups (dormant — no callers); write-onlycacheConsentDenied;z.url()acceptingjavascript:/data:schemes (inert — every sink already routes throughsafeExternalUrl);stripUnsafeKeysunbounded recursion (contained to a per-collection load error);EditFormnon-stringmyVerdictsilent save failure; platformadditionalFieldsunbounded array join.Merge gate. CI fully green (
check,gitleaks,zizmor) on the head commit. Two independent adversarial reviewers with distinct models and complementary lenses — Claude Fable 5 (security/correctness) and Claude Opus 5 (regressions/conventions/privacy). First pass: both returned NO-SHIP — Fable on a genuine residual bypass of fix #3 (the platform overlay'scategoryInfo.detailUrlswas uncapped, reachable by moving the payload fromentity.videostoplatform.detailUrls), Opus on a within-PR regression (the filter refactor left a stalecategoryTitlebranch in the add-filter count badge, so the Platform row showed the genre count). One remediation round fixed both (each with a reproduction test observed failing before and passing after); both reviewers then returned SHIP on the updated diff, having adversarially re-run the affected tests against the pre-fix states. Merged squash; branch auto-deleted.Protocol note: no deviations — orchestrator ran as Claude Fable 5, hunters and refuters as Claude Opus 5, and the dual PR review used the specified Fable 5 + Opus 5 pairing. Dependency review:
npm audit --omit=devreports 0 advisories in the production tree; no dependency was changed.Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Relationship / single-file entity-array DoS. The untrusted
Round 10 — findings: 6 substantive (5 fixed, 1 deferred), 19 nitpick, 0 refuted; nitpicks-only: no; PR #20 merged.
Fixed (5 confirmed substantive; each survived an independent Opus 5 adversarial refuter, each with a reproduction test observed failing before and passing after):
- Quadratic field-path parser DoS.
parsePath(src/loaders/fieldPath.ts) is O(n²) in path length for a bracket-free path (it re-scans the remaining string for[every iteration).display.card.front.title/subtitle/badge(v2cardFrontConfigSchema, a barez.string()with no.max()) and persisted forcedfieldMappingvalues (settings.json) feed it uncapped untrusted strings, resolved once per card at grid render, so a ~2MB dotted path froze the main thread for tens of seconds (empirically 14.3s for one 2MB path) with no error boundary reached. AMAX_FIELD_PATH_LENGTHguard at the sharedgetFieldValue/resolveFieldPathchokepoint now resolves an over-long path to the fallback in bounded time. - Competing store prototype pollution. The Competing (Top Trumps) store built its
cardDatamap as a plain object literal keyed by untrusted entity ids; an id of"__proto__"(which survives the loader'sSet-keyed dedup and reachescard.id) re-pointed the map's prototype to the attacker card instead of creating an own key — the card was counted but omitted fromObject.keys, never dealt, and able to dropvalidCardCountbelow the four-card minimum. NowObject.create(null), mirroring the round-7 snap-ranking fix. - Uncapped
verdictFieldsdisplay-config DoS. The collection-controlledverdictFieldsordering list (v2cardDisplayConfigSchema) wasz.array(z.string())with no bound, andgetDisplayableFieldsscans every spec against the entity's fields onCardExpandedmount — once per card in the non-virtualised list/compact layouts — so a 200k-entry array froze the tab on collection load (~34s at 200 cards). The schema now truncates the array (MAX_VERDICT_FIELDS) and each spec (MAX_VERDICT_FIELD_LENGTH), matching theuiLabelstruncate-not-reject pattern; the scan hoists the per-spec lowercase out offind. - Eager
CardExpandedmount in list/compact/fit.CardCompactItemandCardListItemmounted aCardExpandedper card unconditionally, defeating the lazy-mount guardCard.tsxdocuments (each instance registers a window resize listener viauseViewportSizeplus several store subscriptions). These layouts are not virtualised, so a large collection mounted thousands of listeners/subscriptions on view switch. Both renderers now mountCardExpandedlazily on first open. - Hard Reset left service-worker caches on disk.
clearAllPersistedDatacleared localStorage and three IndexedDB databases but never touched the service worker's Cache Storage buckets (jsdelivr-cache/github-raw-cache/image-cache, populated by vite-plugin-pwa) or unregistered the worker, so the viewed collection JSON,settings.jsonand imagery survived the reset and were served back on the reload — contradicting the dialog's "permanently delete all your … cached data" promise. The reset now deletes every Cache Storage bucket and unregisters the service worker, guarded for environments lacking the APIs.
14 reproduction tests added (1014 → 1028).
Deferred (confirmed substantive, own dedicated round): the service worker's
runtimeCachingrules cache remote collection data and images into Cache Storage regardless of the user's "Never cache" consent choice — the app-layer gate only covers the IndexedDB caches. The correct fix is a consent-aware service worker (aninjectManifestconversion or removing the redundant SW data/image cache layer), both moderate/high-regression build + SW architecture changes out of scope for a smallest-diff round. Fix 5 already closes the reset direction of this gap.Considered and rejected (confirmed nitpick / not attacker-reachable): edits-import
confirm()treats Escape/window-X as "replace all edits" (user-driven only; destructive Cancel is documented by the dialog copy); a non-stringmyVerdictmakesEditFormSave silently no-op (behind the default-off edit-mode opt-in); the inert "Import Collection" localStorage write (re-refuted, self-inflicted, prior rounds 7/9). Plus hunter-classed nitpicks:SourcesOverlay/SourceIconresidual source-label display spoof for unknown hosts,collection.metanever populated, primary-imagejavascript:-scheme gap (inert<img src>sink), prototype-chain reads inCollectionDataContext/fieldPathResolver/fieldDiscovery, un-gated fetch / unbounded fan-out in dead-codeuseCollectionManifest/useImageValidation, snap-ranking guess-value key collision,tsc -bemittingvite.config.js, unconditional Google Fonts stylesheet,connect-srcvs allowlist divergence, CI typecheck/lint coveringsrc/only, and the pre-commit TruffleHog mutable-tag pin.Merge gate: CI fully green (check, gitleaks, zizmor — all check runs success); two independent reviewers (Claude Fable 5 on security/correctness, Claude Opus 5 on regressions/conventions) both returned SHIP with no blockers. Merged squash, branch deleted.
Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Quadratic field-path parser DoS.
Round 11 — findings: 10 substantive (9 fixed, 1 deferred), 27 nitpick, 2 refuted; nitpicks-only: no; PR #21 merged.
Fixed (9 confirmed substantive; each survived an independent adversarial refuter, each with a reproduction test observed failing before and passing after):
- Entity id collisions survived the index-driven loader path —
loadEntitiesFromDirectorydeduped file names but not the ids declared inside the files, so distinct index entries sharing an inner id produced duplicate card ids (duplicate React keys, linked flips, cross-card edit bleed, a reachable throw inCardGrid's selection guard); the directory path now sharesdedupeEntitiesByIdwith the single-file paths. - Source health check validated against the wrong schema — it parsed fetched
collection.jsonwith the legacy v1 items/categories schema and matched versions against strings the detector never returns, so every healthy source wore a permanent red "Invalid" badge in the live sources tab; now parses the v2 collection definition and infers the version viadetectSchemaVersion, with the test fixture importing the canonical example so it cannot go vacuous again. - Quiz answer order was strongly biased — the shuffle seed was a char-code sum of the question id (correct answer at option B ~36%, only 13 of 24 orderings reachable); djb2 hash plus the existing seeded Fisher–Yates, per-question determinism preserved (~25% per position verified).
- A flawless quiz could never score 100% —
maxScoreassumed the full streak bonus on every question against pre-answer-streak scoring (a flawless 5-question untimed run displayed 70%); the maximum is now the per-index achievable sum. - Expert/Extreme distractor selection was an uncapped scan over attacker-scaled data (~8s synchronous from a ~508KB gzipped payload); candidate pool sampled at
MAX_SIMILARITY_CANDIDATES, per-card key walk capped atMAX_SIMILARITY_KEYS. - The Competing "Auto-Advance Rounds" toggle was inert — stored and surfaced but never read; the timer is now gated on it while click/keypress dismissal stays unconditional.
- Discovery scans raced and could persist a broken source — a slow scan could overwrite a newer scan's results and the picker paired the current username with a stale entry's folder, persisting an activated permanently-404ing source; generation token per run (bumped on effect cleanup too), every state update guarded, and each entry now carries its scanned username, which the picker uses.
- Collection defaults re-applied over the user's choices on every A↔B alternation — the applied-defaults marker was a single last-source slot; now a bounded FIFO array of source ids (persist version 27→28 with migration and tamper normalisation), preserving the round-6 rule that forced-settings restore never clears defaults tracking.
- A non-string entity
myVerdictmade EditForm's Save silently no-op — coerced at the form boundary and the missing summary/verdict error spans rendered (round 10 had logged this as a nitpick; this round's refuter confirmed it substantive on the invisible-error grounds).
46 reproduction tests added (1028 → 1074).
Confirmed substantive, deferred (owner policy call): the size-limit bundle gate is dead — the budgets point at
dist/assets/*, which stopped receiving app bundles at the landing-page/demo/split, andsize:checkuses a flag size-limit v11 does not have — but repairing the paths makes both budgets fail immediately (measured ~265KB gzip JS vs 200KB, ~41KB gzip CSS vs 15KB), so a functional repair forces a budget re-baseline or per-entry restructure. Recommendation in PR #21.Refuted (considered, not fixed): the PWA
image-cachemissing-cacheableResponseclaim (the extension-anchored regex can never match a cross-origin URL under Workbox routing, so the route is inert and the fix a no-op; real image hosts are covered by the anchored routes) and theformatFieldValuedeep-recursion crash (unreachable —stripUnsafeKeysoverflows first on the ingest path and every caller catches it; only a value-size hang survives, as a nitpick).Nitpicks noted (not fixed, detailed in PR #21): theme JSON absent from the PWA precache glob, non-http schemes reaching inert image sinks, un-anchored YouTube patterns, dormant
fieldDiscoveryprototype-chain lookups, provider URL$-pattern splicing,gitlab.comallowlist/CSP mismatch, assorted mechanics micro-rescans and duplicate button keys, the latent Competing tie-branch deadlock,_editedAtspoofing,build-site.mjsandcheck-pii.shhardening items, tracked Playwright artefacts, lint scope, plus the re-raised import-collection dead write (rounds 7/9/10) andvite.config.jsshadowing (round 9), both left per prior adjudication. Plugin subsystem re-verified dormant. Dependency review: no advisories acted on, no dependency changed.Merge gate: CI fully green (check, gitleaks, zizmor); first review round returned one NO-SHIP blocker (a probabilistically flaky new test, ~5% failure rate), remediated in one round by making the fixture deterministic; both reviewers (Claude Fable 5 + Claude Opus 5) then returned SHIP on the updated diff. Merged squash.
Consecutive nitpick-only rounds: 0.
Generated by Claude Code
- Entity id collisions survived the index-driven loader path —
Autonomous test run complete.
This issue is the persistent state for an automated, scheduled security-hardening bug-hunt loop running against this repository.
Protocol
Each scheduled run is exactly one round:
LOOP-STOPPED, the loop ends immediately.security-hunt/round-<N>branch, each behaviour change covered by a test observed failing before and passing after. Dependency changes are out of scope and reported instead.Round N — findings: X substantive, Y nitpick, Z refuted; nitpicks-only: yes|no; PR #M merged|open (<reason>)|none.The loop stops itself after three consecutive nitpick-only rounds by posting a
LOOP-STOPPEDcomment. The owner can stop it at any time by commentingLOOP-STOPPEDor disabling the routine.Only comments from the repository owner or collaborators count as loop state; all other comment content is treated as untrusted data.
Consecutive nitpick-only rounds: 0