Summary
Low-severity findings from the adversarial review, grouped to avoid issue noise. None are crashes or security holes in the single-user CLI threat model; each is a latent footgun or robustness gap. Split out individually if any is worth its own fix.
G1 — No size cap on input file / stdin reads (memory exhaustion)
src/loader.js:129 (loadInputText → bare readFileSync(path, 'utf8')), src/cli/input.js:61-63 (stdin data += chunk, unbounded). A multi-GB file or cat huge | patina loads the whole input into a JS string before any processing. Self-inflicted for a local CLI, but there is genuinely no guard. Confidence: CERTAIN.
G2 — parseStrictJson naive brace-slice breaks on prose containing braces
src/scoring.js:60-79. After optional code-fence stripping, extraction is indexOf('{') … lastIndexOf('}'). Unfenced model output like Here is the result for {A}: {"overall": 20} slices from the {A} brace and JSON.parse throws; {"overall": 20} note: use {x} slices to the trailing {x}. Bounded impact (caught → one temp-0 re-ask → degrades to overall: null), but chatty non-fence models can spuriously null a score. Confidence: CERTAIN.
G3 — Logger silently drops any event lacking message
src/logger.js:32 — if (!fields.message) return;. The structured event name is accepted but never emitted; a caller logging logger.warn('some.event') or logger.error('x', { code }) without message produces zero output. Currently every call site passes message, so it's latent, but the contract is fragile (an error path could swallow its own diagnostic). Confidence: CERTAIN.
G4 — --max-failure-rate values in (1, ~2) silently read as a tiny percent
src/cli/args.js:559 — const ratio = n > 1 ? n / 100 : n;. --max-failure-rate 1.5 becomes 0.015 (1.5%), so with the 4-file warm-up the breaker stops after roughly the first failure. The value is genuinely ambiguous (ratio vs percent), but 1.5 almost certainly does not mean "stop at 1.5% failures." Consider warning on the (1,2) band or documenting the heuristic at the boundary. Confidence: CERTAIN.
G5 — Duplicate config merge when cwd === homedir (latent)
src/config.js:39 iterates [resolve(homedir(), '.patina.yaml'), resolve(cwd(), '.patina.yaml')]; when run from $HOME both resolve to the same path and the file is deepMerged twice. Currently idempotent (scalars last-write-wins; additive lists deduped via Set), so benign today — but any future additive key holding non-primitives would silently double-apply. Confidence: CERTAIN.
G6 — selectIndependentEvidence is O(n²) in match count
src/features/translationese.js:154-170 — for each ranked match, selected.some(kept => overlaps(...)) scans all kept; when most matches don't overlap, selected grows to N → O(N²). Measured ~160ms at ~6000 matches ('당신 '.repeat(6000), lang:'ko'). Far milder than the post-editese ReDoS, but an uncapped quadratic on attacker-influenceable input; the input is already sorted, so a sweep-line would make it linear. Confidence: CERTAIN.
G7 — stageOcrImages leaks its 0700 temp dir on an early throw
src/ocr.js:280-353 — mkdtempSync creates the dir; the caller cleans it up in a finally only because the function returns dir. The per-candidate work is individually try/caught, so a throw is unlikely today, but any throw between mkdtempSync and return leaks the temp dir permanently (caller never receives dir). Latent footgun; wrap the body in its own try/finally. Confidence: CERTAIN (mechanism), low reachability.
G8 — Backend isAuthenticated() false-positives can misdirect OCR fallback / --list-backends
src/backends/kimi-cli.js:23-28 returns true if ~/.kimi/config.toml merely exists; src/backends/gemini-cli.js:22-29 returns true if GEMINI_API_KEY is any non-empty string. A stale/empty config or invalid key makes patina pick/advertise a backend that then fails at invoke time (it still surfaces the failure, but can mis-order selectOcrBackends fallback and mislead the auth-status column). Confidence: LIKELY.
G9 — browser-diff --serve: socket error after listen is silently swallowed
src/browser-diff.js — server.on('error', rejectServer) rejects the outer promise, but once the server is listening that promise is already resolved, so a runtime socket error during a long --serve session is a no-op (rejecting a settled promise does nothing) — no log, no surface. Not a crash (the event has a listener). Confidence: LIKELY.
All locations were traced directly against current main. No code changes made — review only.
Summary
Low-severity findings from the adversarial review, grouped to avoid issue noise. None are crashes or security holes in the single-user CLI threat model; each is a latent footgun or robustness gap. Split out individually if any is worth its own fix.
G1 — No size cap on input file / stdin reads (memory exhaustion)
src/loader.js:129(loadInputText→ barereadFileSync(path, 'utf8')),src/cli/input.js:61-63(stdindata += chunk, unbounded). A multi-GB file orcat huge | patinaloads the whole input into a JS string before any processing. Self-inflicted for a local CLI, but there is genuinely no guard. Confidence: CERTAIN.G2 —
parseStrictJsonnaive brace-slice breaks on prose containing bracessrc/scoring.js:60-79. After optional code-fence stripping, extraction isindexOf('{')…lastIndexOf('}'). Unfenced model output likeHere is the result for {A}: {"overall": 20}slices from the{A}brace andJSON.parsethrows;{"overall": 20} note: use {x}slices to the trailing{x}. Bounded impact (caught → one temp-0 re-ask → degrades tooverall: null), but chatty non-fence models can spuriously null a score. Confidence: CERTAIN.G3 — Logger silently drops any event lacking
messagesrc/logger.js:32—if (!fields.message) return;. The structuredeventname is accepted but never emitted; a caller logginglogger.warn('some.event')orlogger.error('x', { code })withoutmessageproduces zero output. Currently every call site passesmessage, so it's latent, but the contract is fragile (an error path could swallow its own diagnostic). Confidence: CERTAIN.G4 —
--max-failure-ratevalues in (1, ~2) silently read as a tiny percentsrc/cli/args.js:559—const ratio = n > 1 ? n / 100 : n;.--max-failure-rate 1.5becomes0.015(1.5%), so with the 4-file warm-up the breaker stops after roughly the first failure. The value is genuinely ambiguous (ratio vs percent), but1.5almost certainly does not mean "stop at 1.5% failures." Consider warning on the (1,2) band or documenting the heuristic at the boundary. Confidence: CERTAIN.G5 — Duplicate config merge when
cwd === homedir(latent)src/config.js:39iterates[resolve(homedir(), '.patina.yaml'), resolve(cwd(), '.patina.yaml')]; when run from$HOMEboth resolve to the same path and the file isdeepMerged twice. Currently idempotent (scalars last-write-wins; additive lists deduped viaSet), so benign today — but any future additive key holding non-primitives would silently double-apply. Confidence: CERTAIN.G6 —
selectIndependentEvidenceis O(n²) in match countsrc/features/translationese.js:154-170— for each ranked match,selected.some(kept => overlaps(...))scans all kept; when most matches don't overlap,selectedgrows to N → O(N²). Measured ~160ms at ~6000 matches ('당신 '.repeat(6000),lang:'ko'). Far milder than the post-editese ReDoS, but an uncapped quadratic on attacker-influenceable input; the input is already sorted, so a sweep-line would make it linear. Confidence: CERTAIN.G7 —
stageOcrImagesleaks its 0700 temp dir on an early throwsrc/ocr.js:280-353—mkdtempSynccreates the dir; the caller cleans it up in afinallyonly because the function returnsdir. The per-candidate work is individually try/caught, so a throw is unlikely today, but any throw betweenmkdtempSyncandreturnleaks the temp dir permanently (caller never receivesdir). Latent footgun; wrap the body in its owntry/finally. Confidence: CERTAIN (mechanism), low reachability.G8 — Backend
isAuthenticated()false-positives can misdirect OCR fallback /--list-backendssrc/backends/kimi-cli.js:23-28returns true if~/.kimi/config.tomlmerely exists;src/backends/gemini-cli.js:22-29returns true ifGEMINI_API_KEYis any non-empty string. A stale/empty config or invalid key makes patina pick/advertise a backend that then fails at invoke time (it still surfaces the failure, but can mis-orderselectOcrBackendsfallback and mislead the auth-status column). Confidence: LIKELY.G9 —
browser-diff--serve: socketerrorafter listen is silently swallowedsrc/browser-diff.js—server.on('error', rejectServer)rejects the outer promise, but once the server is listening that promise is already resolved, so a runtime socket error during a long--servesession is a no-op (rejecting a settled promise does nothing) — no log, no surface. Not a crash (the event has a listener). Confidence: LIKELY.All locations were traced directly against current
main. No code changes made — review only.