feat(run): InputFileStager seam — stage non-image attachments into the prompt #18

Merged
steve merged 3 commits from feat/input-file-stager into main 2026-06-28 18:19:29 +00:00
Owner

What

Adds the missing input-file staging path to the run kernel. tool.Invocation already carried InputFiles (audio/PDF/binary attachments), but Executor.Run only folded Images into the run — non-image attachments were silently dropped. This is the host seam mort's chat-API + chatbot surfaces need for audio-input parity with agentexec before they can migrate onto executus.

Changes

  • run.Ports.InputFiles InputFileStager (nil-safe) — StageInputFile(ctx, runID, agentID, name, mime, content) → file_id. Mirrors mort's skill FileStorage; nil = input files ignored (run still proceeds text-only).
  • run/input_files.go (ported from mort agentexec/input_files.go) — stageInputFiles persists each file under run scope and appends an [ATTACHED FILES] descriptor block so the agent can reach them by file_id (e.g. code_exec files_in/workspace/<name>). Bytes are never inlined into model context (the LLM can't read raw audio/binary). Best-effort: empty / oversized (>50 MB) / save-error files are skipped; colliding base names are disambiguated (name-2, name-3).
  • Executor.Run calls it after the model/toolbox build, before the loop — the descriptor rides the first user turn, alongside the existing Images folding.

Tests

run/input_files_test.go: stages + builds the block (with file_id/mime/runID/agentID asserted); nil stager and no-files leave the prompt intact; dedup of colliding names; empty + save-error skipping. Full executus suite green (-race).

Follow-up (mort side, separate PR)

Bump executus, bridge inv.InputFiles → exInv.InputFiles in executushost.toExtoolInvocation, and wire mort's skill FileStorage as the InputFileStager — unblocking the chatbot-@mention (#14) and chat-API (#15) executus migrations.

🤖 Generated with Claude Code

## What Adds the missing input-file staging path to the run kernel. `tool.Invocation` already carried `InputFiles` (audio/PDF/binary attachments), but `Executor.Run` only folded `Images` into the run — non-image attachments were silently dropped. This is the host seam mort's chat-API + chatbot surfaces need for **audio-input parity with agentexec** before they can migrate onto executus. ## Changes - **`run.Ports.InputFiles InputFileStager`** (nil-safe) — `StageInputFile(ctx, runID, agentID, name, mime, content) → file_id`. Mirrors mort's skill `FileStorage`; nil = input files ignored (run still proceeds text-only). - **`run/input_files.go`** (ported from mort `agentexec/input_files.go`) — `stageInputFiles` persists each file under run scope and appends an `[ATTACHED FILES]` descriptor block so the agent can reach them by `file_id` (e.g. `code_exec` `files_in` → `/workspace/<name>`). Bytes are **never** inlined into model context (the LLM can't read raw audio/binary). Best-effort: empty / oversized (>50 MB) / save-error files are skipped; colliding base names are disambiguated (`name-2`, `name-3`). - **`Executor.Run`** calls it after the model/toolbox build, before the loop — the descriptor rides the first user turn, alongside the existing `Images` folding. ## Tests `run/input_files_test.go`: stages + builds the block (with file_id/mime/runID/agentID asserted); nil stager and no-files leave the prompt intact; dedup of colliding names; empty + save-error skipping. Full executus suite green (`-race`). ## Follow-up (mort side, separate PR) Bump executus, bridge `inv.InputFiles → exInv.InputFiles` in `executushost.toExtoolInvocation`, and wire mort's skill `FileStorage` as the `InputFileStager` — unblocking the chatbot-@mention (#14) and chat-API (#15) executus migrations. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 1 commit 2026-06-28 17:03:13 +00:00
feat(run): InputFileStager seam — stage non-image attachments into the prompt
Adversarial Review (Gadfly) / review (pull_request) Has been cancelled
executus CI / test (pull_request) Successful in 2m21s
2ef88f2a73
executus's tool.Invocation already carried InputFiles (audio/PDF/binary), but the
executor never staged them — only Images were folded into the run. This adds the
host seam mort's chat/chatbot surfaces need for audio-input parity with agentexec.

- run.Ports gains InputFiles InputFileStager (nil-safe; nil = input files silently
  ignored, run still proceeds text-only). The interface mirrors mort's skill
  FileStorage: StageInputFile(ctx, runID, agentID, name, mime, content) → file_id.
- run/input_files.go (ported from mort agentexec/input_files.go): stageInputFiles
  persists each file under run scope and appends an [ATTACHED FILES] descriptor
  block to the prompt so the agent can reach them by file_id (e.g. code_exec
  files_in → /workspace/<name>). Bytes are NEVER inlined into model context.
  Best-effort: empty/oversized(>50MB)/save-error files are skipped; colliding
  base names are disambiguated (name-2, name-3) so they don't clobber at
  /workspace/<name>.
- Executor.Run calls it after the model/toolbox build, before the loop, so the
  descriptor rides the first user turn (alongside the existing Images folding).

Tests: stages + builds the block; nil stager / no files leave the prompt intact;
dedup; empty/save-error skipping. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

🪰 Gadfly — live review status

7/7 reviewers finished · updated 2026-06-28 17:54:51Z

claude-code/opus · claude-code — done

  • security — Minor issues
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

claude-code/opus:max · claude-code — done

  • security — Minor issues
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

claude-code/sonnet · claude-code — done

  • security — Blocking issues found
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

deepseek-v4-pro:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

glm-5.2:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

minimax-m3:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

ragnaros/qwen3.6-27b · ragnaros — done

  • ⚠️ security — could not complete
  • ⚠️ correctness — could not complete
  • ⚠️ maintainability — could not complete
  • ⚠️ performance — could not complete
  • ⚠️ error-handling — could not complete

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 7/7 reviewers finished · updated 2026-06-28 17:54:51Z #### `claude-code/opus` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `claude-code/opus:max` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — Blocking issues found - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `deepseek-v4-pro:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `minimax-m3:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `ragnaros/qwen3.6-27b` · ragnaros — ✅ done - ⚠️ **security** — could not complete - ⚠️ **correctness** — could not complete - ⚠️ **maintainability** — could not complete - ⚠️ **performance** — could not complete - ⚠️ **error-handling** — could not complete <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>

🪰 Gadfly review — ragnaros/qwen3.6-27b (ragnaros)

Review incomplete — all lenses errored — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: ragnaros/qwen3.6-27b: HTTP 502: Bad Gateway

🎯 Correctness⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658)

🧹 Code cleanliness & maintainability⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658)

Performance⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658)

🧯 Error handling & edge cases⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658)

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 14s

<!-- gadfly-review:ollama:ragnaros/qwen3.6-27b --> ### 🪰 Gadfly review — `ragnaros/qwen3.6-27b` (ragnaros) **Review incomplete — all lenses errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: ragnaros/qwen3.6-27b: HTTP 502: Bad Gateway </details> <details><summary><b>🎯 Correctness</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658) </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658) </details> <details><summary><b>⚡ Performance</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658) </details> <details><summary><b>🧯 Error handling & edge cases</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: skipped (backed off until 17:43:32.658) </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 14s</sub>

🪰 Gadfly review — claude-code/opus (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

I've verified the code against the draft. Let me confirm the one substantive finding holds.

The code at run/input_files.go matches the diff exactly. Confirming the Cf finding:

  • sanitizeField (lines 119-126) drops \n/\r/\t plus unicode.IsControl(r). Go's unicode.IsControl returns true only for category Cc. Format characters (Cf) — U+202E RLO, U+2066–2069 isolates, U+200B ZWSP — are not Cc, so they survive. Confirmed.
  • sanitizeName runs sanitizeField then path.Base after \/ normalization, applied before staging (line 70). Traversal containment confirmed.
  • Line 90 inlines s.fileID (host-generated) without sanitizeField; name/mime are sanitized. Confirmed — accurately framed as a non-exploitable note.

The minor finding is real. Outputting the corrected review.


VERDICT: Minor issues

The change is genuinely security-focused and the core hardening is sound. Verified against run/input_files.go and run/executor.go:

Verified solid (no issue):

  • Path traversalsanitizeName (input_files.go:105) runs path.Base after normalizing \/, applied before StageInputFile (line 70). ../../etc/passwdpasswd, /etc/cron.d/xx, .././""attachment. The sanitized base name is the only value passed to the host store and the only name placed in the /workspace/<name> descriptor. Traversal into the host store and the sandbox is correctly blocked. NUL and other Cc bytes are stripped (NUL is Cc).
  • Descriptor injection via newlinesanitizeField strips \n/\r/\t, so a crafted name/mime cannot open a new - line or a fake instruction line in the [ATTACHED FILES] block. Both attacker-controlled fields fed into the block (name, mime at lines 77/90) are sanitized. This is the meaningful prompt-injection containment and it holds.
  • Resource bounds — per-file 50 MB cap (line 58) and 32-file count cap (line 40) are both at the seam, with empty/oversized/save-error files skipped best-effort.

Minor finding (low severity):

  • run/input_files.go:119-126sanitizeField filters only \n/\r/\t plus unicode.IsControl (Unicode category Cc). Unicode format characters (category Cf) pass through: bidi overrides (U+202E RLO / U+2066–2069 isolates) and zero-width chars (U+200B). A filename can therefore carry these into the descriptor line and into /workspace/<name>. Impact is limited — newline containment is intact, so this cannot inject a new logical instruction line; it can only visually reorder/spoof the displayed filename. Suggested fix: also drop Cf (e.g. unicode.IsControl(r) || unicode.In(r, unicode.Cf)), which closes the RTLO-spoof gap the commit's "prompt-injection hardening" intent is reaching for.

Defense-in-depth note (not a finding):

  • run/input_files.go:90 — the host-returned fileID is inlined into the descriptor without sanitizeField. It's host-generated (trusted), so this is not exploitable today, but if any future stager derives the id from the untrusted filename it would reopen the newline/injection path. A one-line sanitizeField(fileID) would make the boundary uniform.

Nothing here is blocking. The traversal and newline-injection defenses — the substance of the security lens for this change — are correctly implemented and tested.

🎯 Correctness — Minor issues

I've confirmed the finding against the actual code. At run/input_files.go:40-44, the count cap truncates files = files[:maxInputFiles] before the filtering loop at line 52, which independently skips empty (line 53), oversized (line 58), and save-error (line 72) files. The ordering is exactly as the draft describes, and input is reassigned at executor.go:326 then passed to runAgent at line 330.

VERDICT: Minor issues

Findings (Correctness lens):

  • run/input_files.go:40-44 — Count cap is applied before validation, so valid files can be silently dropped to junk. The files = files[:maxInputFiles] truncation runs before the empty/oversized/save-error filtering loop (lines 52-78). A run with 32 empty or oversized attachments followed by 5 real ones stages zero files — the real attachments are truncated away before the loop ever reaches them. The cap counts skipped junk against the budget instead of valid staged files. If the intent is "stage up to 32 valid files," the cap belongs inside/after the filtering loop (e.g. break once len(staged) == maxInputFiles). Low severity — real attachments normally arrive via a bounded host extraction path — but a genuine behavior bug. The drop is also only slog.Warn-logged; neither the agent nor the [ATTACHED FILES] descriptor signals that attachments were omitted, so the model may answer as if it saw everything.
🧹 Code cleanliness & maintainability — Minor issues

Both findings verified against the actual code:

  • README.md:40 lists the seams as Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery — no InputFiles. The PR adds InputFiles InputFileStager to run.Ports, and rg InputFile over README/examples returns nothing. Confirmed.
  • tool/registry.go:157 reads "Name is a safe base name (no path separators) suitable for /workspace/", while the new staging path re-derives a safe base via uniqueName(sanitizeName(f.Name)), treating Name as untrusted. Genuine contradiction. Confirmed.

VERDICT: Minor issues

The new code is clean and self-contained: helpers (sanitizeName/sanitizeField/uniqueName/humanizeBytes) are not duplicated anywhere else in the repo, naming is clear, and the slog.Warn("run: ...", ...) logging matches the established run-package convention (run/session.go, run/runengine.go). Two maintainability findings, both verified by reading the code:

  • README.md:40 — Ports list not updated (docs-sync house rule). The README enumerates the seams as Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery, but this PR adds a seventh public run.Ports seam (InputFiles InputFileStager, run/ports.go:45-59). CLAUDE.md explicitly requires README/CLAUDE.md/examples to be updated in the same commit when you "alter the public API" or "change a seam." Verified: rg for InputFile/InputFileStager across README.md and examples/ returns nothing. Low-churn fix: add InputFiles to the seam list at line 40 (and optionally a line to the run/ tier entry in CLAUDE.md, which currently lists the wired ports).

  • tool/registry.go:157InputFile.Name doc contract contradicts the new handling. The type comment states "Name is a safe base name (no path separators) suitable for /workspace/<name>," implying callers supply an already-safe name. But the new staging path (run/input_files.go:70, comment at :64-69) explicitly treats f.Name as untrusted and re-derives a safe base via uniqueName(sanitizeName(...)). A future caller reading the InputFile.Name doc could reasonably assume no sanitization happens downstream. Suggested fix: reword the comment to describe Name as the caller-supplied (untrusted) filename that the executor reduces to a safe base name when staging — so the documented contract matches the actual trust boundary.

Neither is blocking. The dedup/sanitize/byte-formatting logic itself reads correctly and follows surrounding patterns; I found no dead code, confusing names, or copy-paste within the executus tree.

Performance — No material issues found

I've verified the staging logic and its call site. stageInputFiles is invoked exactly once per run at executor.go:326, before the agent loop — not on any hot path.

VERDICT: No material issues found

Through the performance lens, this change is clean:

  • No hot-loop work. stageInputFiles runs once per Executor.Run, before the agent loop begins (executor.go:326). The per-file loop (input_files.go:52) is bounded by maxInputFiles = 32 via the truncation at line 43, so the iteration count is hard-capped regardless of caller input. Verified by reading both files.
  • No unnecessary copies of the payload. f.Data is passed straight through to StageInputFile (input_files.go:71) — the potentially-large byte slice is never copied or buffered in this layer. The descriptor block holds only metadata (name/mime/size/file_id), never the bytes, which the PR explicitly intends.
  • Allocations are appropriately sized. seenNames is pre-sized with make(map[string]int, len(files)) (line 51), and the descriptor uses a single strings.Builder (line 83) rather than repeated concatenation. The final prompt + "\n\n" + b.String() is one concat.
  • No unbounded growth / missing limits. Both a per-file byte cap (50 MB) and a per-run count cap (32) are enforced, so neither a giant blob nor a flood of tiny files can blow up memory or the descriptor.
  • uniqueName's inner for loop (line 138) is bounded by the number of staged files (≤32) and the pre-sized map, so no quadratic blowup in practice.

Minor non-issues considered and dismissed as micro-optimizations not worth flagging: sanitizeField allocates a fresh string per call via strings.Map, and StageInputFile is called sequentially for up to 32 files (a potentially-blocking I/O write each). Both are bounded, run once per run off the hot path, and don't constitute a justifiable regression.

Outside my lens: none severe to flag.

🧯 Error handling & edge cases — Minor issues

The code matches the diff exactly. I've confirmed the one substantive finding: the count cap at run/input_files.go:40-44 truncates files[:maxInputFiles] against the raw slice, before the empty/oversized/error filtering in the loop at :52-77. The finding is valid by inspection.

VERDICT: Minor issues

  • run/input_files.go:40-44 — the count cap truncates files[:maxInputFiles] against the raw count, before the empty/oversized/error filtering at :52-77. Validity isn't considered when choosing which 32 survive: if the first 32 entries are empty/oversized/error and a valid attachment sits at index 33+, that valid file is silently dropped even though fewer than 32 files actually stage. Impact is small (host-extracted InputFiles rarely lead with junk) and the truncation is logged, but the cap can discard good attachments while staging zero. If the cap is meant to mean "32 staged files," filter first then cap, or break out of the staging loop once len(staged) == maxInputFiles. (TestStageInputFilesCountCap only exercises all-valid files, so this path is unverified by the tests.)

Note on coverage: files dropped by the count cap, and files skipped as empty/oversized, are not reflected in the [ATTACHED FILES] descriptor, so the agent has no signal that an attachment went missing. That is an intentional best-effort design choice (and logged), so it is not a defect — only noted in case "tell the model N files were dropped" is desired behavior.

No blocking error-handling or edge-case issues.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 10s

<!-- gadfly-review:ollama:claude-code/opus --> ### 🪰 Gadfly review — `claude-code/opus` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> I've verified the code against the draft. Let me confirm the one substantive finding holds. The code at `run/input_files.go` matches the diff exactly. Confirming the Cf finding: - `sanitizeField` (lines 119-126) drops `\n`/`\r`/`\t` plus `unicode.IsControl(r)`. Go's `unicode.IsControl` returns true only for category Cc. Format characters (Cf) — U+202E RLO, U+2066–2069 isolates, U+200B ZWSP — are not Cc, so they survive. Confirmed. - `sanitizeName` runs `sanitizeField` then `path.Base` after `\`→`/` normalization, applied before staging (line 70). Traversal containment confirmed. - Line 90 inlines `s.fileID` (host-generated) without `sanitizeField`; `name`/`mime` are sanitized. Confirmed — accurately framed as a non-exploitable note. The minor finding is real. Outputting the corrected review. --- ## VERDICT: Minor issues The change is genuinely security-focused and the core hardening is sound. Verified against `run/input_files.go` and `run/executor.go`: **Verified solid (no issue):** - **Path traversal** — `sanitizeName` (input_files.go:105) runs `path.Base` after normalizing `\`→`/`, applied *before* `StageInputFile` (line 70). `../../etc/passwd`→`passwd`, `/etc/cron.d/x`→`x`, `..`/`.`/`""`→`attachment`. The sanitized base name is the only value passed to the host store and the only name placed in the `/workspace/<name>` descriptor. Traversal into the host store and the sandbox is correctly blocked. NUL and other Cc bytes are stripped (NUL is Cc). - **Descriptor injection via newline** — `sanitizeField` strips `\n`/`\r`/`\t`, so a crafted name/mime cannot open a new `- ` line or a fake instruction line in the `[ATTACHED FILES]` block. Both attacker-controlled fields fed into the block (`name`, `mime` at lines 77/90) are sanitized. This is the meaningful prompt-injection containment and it holds. - **Resource bounds** — per-file 50 MB cap (line 58) and 32-file count cap (line 40) are both at the seam, with empty/oversized/save-error files skipped best-effort. **Minor finding (low severity):** - `run/input_files.go:119-126` — `sanitizeField` filters only `\n`/`\r`/`\t` plus `unicode.IsControl` (Unicode category **Cc**). Unicode **format** characters (category **Cf**) pass through: bidi overrides (U+202E RLO / U+2066–2069 isolates) and zero-width chars (U+200B). A filename can therefore carry these into the descriptor line and into `/workspace/<name>`. Impact is limited — newline containment is intact, so this cannot inject a new logical instruction line; it can only visually reorder/spoof the displayed filename. Suggested fix: also drop Cf (e.g. `unicode.IsControl(r) || unicode.In(r, unicode.Cf)`), which closes the RTLO-spoof gap the commit's "prompt-injection hardening" intent is reaching for. **Defense-in-depth note (not a finding):** - `run/input_files.go:90` — the host-returned `fileID` is inlined into the descriptor without `sanitizeField`. It's host-generated (trusted), so this is not exploitable today, but if any future stager derives the id from the untrusted filename it would reopen the newline/injection path. A one-line `sanitizeField(fileID)` would make the boundary uniform. Nothing here is blocking. The traversal and newline-injection defenses — the substance of the security lens for this change — are correctly implemented and tested. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> I've confirmed the finding against the actual code. At `run/input_files.go:40-44`, the count cap truncates `files = files[:maxInputFiles]` *before* the filtering loop at line 52, which independently skips empty (line 53), oversized (line 58), and save-error (line 72) files. The ordering is exactly as the draft describes, and `input` is reassigned at `executor.go:326` then passed to `runAgent` at line 330. ## VERDICT: Minor issues **Findings (Correctness lens):** - **`run/input_files.go:40-44` — Count cap is applied before validation, so valid files can be silently dropped to junk.** The `files = files[:maxInputFiles]` truncation runs *before* the empty/oversized/save-error filtering loop (lines 52-78). A run with 32 empty or oversized attachments followed by 5 real ones stages **zero** files — the real attachments are truncated away before the loop ever reaches them. The cap counts skipped junk against the budget instead of valid staged files. If the intent is "stage up to 32 *valid* files," the cap belongs inside/after the filtering loop (e.g. `break` once `len(staged) == maxInputFiles`). Low severity — real attachments normally arrive via a bounded host extraction path — but a genuine behavior bug. The drop is also only `slog.Warn`-logged; neither the agent nor the `[ATTACHED FILES]` descriptor signals that attachments were omitted, so the model may answer as if it saw everything. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Both findings verified against the actual code: - README.md:40 lists the seams as `Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery` — no `InputFiles`. The PR adds `InputFiles InputFileStager` to `run.Ports`, and `rg InputFile` over README/examples returns nothing. Confirmed. - tool/registry.go:157 reads "Name is a safe base name (no path separators) suitable for /workspace/<name>", while the new staging path re-derives a safe base via `uniqueName(sanitizeName(f.Name))`, treating `Name` as untrusted. Genuine contradiction. Confirmed. --- VERDICT: Minor issues The new code is clean and self-contained: helpers (`sanitizeName`/`sanitizeField`/`uniqueName`/`humanizeBytes`) are not duplicated anywhere else in the repo, naming is clear, and the `slog.Warn("run: ...", ...)` logging matches the established `run`-package convention (`run/session.go`, `run/runengine.go`). Two maintainability findings, both verified by reading the code: - **`README.md:40` — Ports list not updated (docs-sync house rule).** The README enumerates the seams as `Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery`, but this PR adds a seventh public `run.Ports` seam (`InputFiles InputFileStager`, `run/ports.go:45-59`). CLAUDE.md explicitly requires README/CLAUDE.md/examples to be updated in the *same commit* when you "alter the public API" or "change a seam." Verified: `rg` for `InputFile`/`InputFileStager` across `README.md` and `examples/` returns nothing. Low-churn fix: add `InputFiles` to the seam list at line 40 (and optionally a line to the `run/` tier entry in CLAUDE.md, which currently lists the wired ports). - **`tool/registry.go:157` — `InputFile.Name` doc contract contradicts the new handling.** The type comment states "Name is a safe base name (no path separators) suitable for `/workspace/<name>`," implying callers supply an already-safe name. But the new staging path (`run/input_files.go:70`, comment at :64-69) explicitly treats `f.Name` as *untrusted* and re-derives a safe base via `uniqueName(sanitizeName(...))`. A future caller reading the `InputFile.Name` doc could reasonably assume no sanitization happens downstream. Suggested fix: reword the comment to describe `Name` as the caller-supplied (untrusted) filename that the executor reduces to a safe base name when staging — so the documented contract matches the actual trust boundary. Neither is blocking. The dedup/sanitize/byte-formatting logic itself reads correctly and follows surrounding patterns; I found no dead code, confusing names, or copy-paste within the executus tree. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> I've verified the staging logic and its call site. `stageInputFiles` is invoked exactly once per run at `executor.go:326`, before the agent loop — not on any hot path. ## VERDICT: No material issues found Through the performance lens, this change is clean: - **No hot-loop work.** `stageInputFiles` runs once per `Executor.Run`, before the agent loop begins (`executor.go:326`). The per-file loop (`input_files.go:52`) is bounded by `maxInputFiles = 32` via the truncation at line 43, so the iteration count is hard-capped regardless of caller input. Verified by reading both files. - **No unnecessary copies of the payload.** `f.Data` is passed straight through to `StageInputFile` (`input_files.go:71`) — the potentially-large byte slice is never copied or buffered in this layer. The descriptor block holds only metadata (name/mime/size/file_id), never the bytes, which the PR explicitly intends. - **Allocations are appropriately sized.** `seenNames` is pre-sized with `make(map[string]int, len(files))` (line 51), and the descriptor uses a single `strings.Builder` (line 83) rather than repeated concatenation. The final `prompt + "\n\n" + b.String()` is one concat. - **No unbounded growth / missing limits.** Both a per-file byte cap (50 MB) and a per-run count cap (32) are enforced, so neither a giant blob nor a flood of tiny files can blow up memory or the descriptor. - **`uniqueName`'s inner `for` loop** (line 138) is bounded by the number of staged files (≤32) and the pre-sized map, so no quadratic blowup in practice. Minor non-issues considered and dismissed as micro-optimizations not worth flagging: `sanitizeField` allocates a fresh string per call via `strings.Map`, and `StageInputFile` is called sequentially for up to 32 files (a potentially-blocking I/O write each). Both are bounded, run once per run off the hot path, and don't constitute a justifiable regression. Outside my lens: none severe to flag. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> The code matches the diff exactly. I've confirmed the one substantive finding: the count cap at `run/input_files.go:40-44` truncates `files[:maxInputFiles]` against the raw slice, before the empty/oversized/error filtering in the loop at `:52-77`. The finding is valid by inspection. **VERDICT: Minor issues** - `run/input_files.go:40-44` — the count cap truncates `files[:maxInputFiles]` against the **raw** count, *before* the empty/oversized/error filtering at `:52-77`. Validity isn't considered when choosing which 32 survive: if the first 32 entries are empty/oversized/error and a valid attachment sits at index 33+, that valid file is silently dropped even though fewer than 32 files actually stage. Impact is small (host-extracted `InputFiles` rarely lead with junk) and the truncation is logged, but the cap can discard good attachments while staging zero. If the cap is meant to mean "32 *staged* files," filter first then cap, or break out of the staging loop once `len(staged) == maxInputFiles`. (`TestStageInputFilesCountCap` only exercises all-valid files, so this path is unverified by the tests.) Note on coverage: files dropped by the count cap, and files skipped as empty/oversized, are not reflected in the `[ATTACHED FILES]` descriptor, so the agent has no signal that an attachment went missing. That is an intentional best-effort design choice (and logged), so it is not a defect — only noted in case "tell the model N files were dropped" is desired behavior. No blocking error-handling or edge-case issues. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 10s</sub>

🪰 Gadfly review — glm-5.2:cloud (ollama-cloud)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

VERDICT: Minor issues

  • run/input_files.go:77,90 — stager-returned fileID inlined into the prompt descriptor without sanitization. StageInputFile returns fileID (line 71), which is stored verbatim into the stagedFile struct (line 77: fileID: fileID) and later written directly into the prompt descriptor at line 90 via fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID). Every other caller-supplied field is sanitized before it reaches the descriptor: s.name comes from sanitizeName(f.Name) (line 70) and s.mime comes from sanitizeField(f.MimeType) (line 77), but s.fileID — the one field produced by the host InputFileStager implementation and thus the least under the kernel's control — is passed through raw. A hostile or buggy host stager returning a fileID containing \n or injected instruction text (e.g. \n- system: ignore prior instructions) would inject attacker-controlled content into the first user turn, the exact attack sanitizeField exists to prevent. Confirmed by reading lines 70–90. Suggested fix: apply sanitizeField to fileID when recording it (line 77: fileID: sanitizeField(fileID)), mirroring the treatment of mime. Low severity (requires a hostile/compromised host stager), but it is the one gap in an otherwise consistent sanitization boundary.

  • run/input_files.go:71MimeType passed to the stager unsanitized. f.MimeType is forwarded raw to StageInputFile at line 71; only the descriptor copy is sanitized at line 77 (sanitizeField(f.MimeType)). This is asymmetric with name, which is sanitized before staging via sanitizeName(f.Name) at line 70. If the host store keys on or persists the MIME type verbatim, an attacker-supplied MimeType with control characters/newlines could flow through to downstream storage. Defense-in-depth concern only — the host is trusted to interpret its own store. Confirmed. Minor.

  • run/input_files.go:163"KMGTPE"[exp] index out of range for ≥1 ZiB (1024⁷). The index string has length 6 (indices 0–5); exp reaches 5 at 1 EiB (still valid, 'E') but 6 at 1 ZiB, which would panic. The threshold stated in the draft (≥1 EiB) is off by one unit — the actual out-of-range point is ~1 ZiB — but the conclusion stands: it is unreachable in practice because maxInputFileBytes (50 MB, line 18) caps size far below even 1 GiB. Cosmetic/unreachable; noted for completeness only.

The core security design is sound: traversal and control-character injection into the prompt descriptor is blocked for caller-supplied fields, bytes are never inlined, and size/count caps bound resource use. The one real gap is the unsanitized fileID echo into the prompt (finding 1); the rest are defense-in-depth nits.

🎯 Correctness — No material issues found

VERDICT: No material issues found

Through the correctness lens, I verified the new staging path against the checked-out code:

  • Executor.Run wiring (run/executor.go:326): stageInputFiles is called after the model/toolbox build and before runAgent, and its augmented input is what runAgent receives. In runAgent (run/executor.go:459), the text is placed as llm.Text(input) in the first user turn (with images) or passed directly to ag.Run (without). So the descriptor correctly rides the first user turn — confirmed by reading both functions.
  • Nil/empty guards (run/input_files.go:35): Ports.InputFiles == nil || len(files) == 0 returns the prompt untouched; len(staged) == 0 (line 79) also returns the prompt. Both paths verified against Ports struct and runAgent.
  • uniqueName logic (run/input_files.go:131-146): Re-derived the first-seen → name, repeat → name-2, name-3 sequence against the seen map mutation; matches the test expectations (a.wav, a-2.wav). A pathological ordering (e.g. a.wav, a.wav, then a literally-named a-2.wav) would force the third to a-2-2.wav — cosmetically odd but still collision-free, so not a correctness bug.
  • humanizeBytes (run/input_files.go:150-163): Re-derived the 1024-based divisions for boundary values (1024 → "1.0 KB", 1 MiB → "1.0 MB", 50_000_000 → ~47.7 MB); the "KMGTPE"[exp] index tracks the divisor loop correctly. No off-by-one.
  • Caps: maxInputFileBytes = 50_000_000 and maxInputFiles = 32 are applied before staging (len(f.Data) > maxInputFileBytes skips; files[:maxInputFiles] truncates) — verified the comparisons use > / > respectively, so a file exactly at the cap is staged. Consistent.
  • Sanitization: sanitizeName strips control chars then takes path.Base after normalizing backslashes; sanitizeField strips \n\r\t and control runes. The stager receives the raw f.MimeType (line 71) while the descriptor uses the sanitized mime (line 77) — intentional split, not a bug.

No logic bugs, incorrect thresholds, or semantic errors found in this lens.

🧹 Code cleanliness & maintainability — Minor issues

The draft raises four bullets but explicitly self-drops three of them (stagedFile duplication, humanizeBytes, and the arrow glyph), concluding they are "not a real issue." After verifying the actual source:

  • Lines 46-49: the stagedFile struct is a clean local record — confirmed, no duplication issue.
  • humanizeBytes (150-163): confirmed go-humanize is only in contrib/store's separate module (contrib/store/go.mod), not the root run module — keeping it local is reasonable.
  • The arrow glyph at line 90 is intentional human-readable decoration, not parsed — harmless.
  • The count-cap comment redundancy: confirmed. Line 20-22 (maxInputFiles doc) and line 38-39 (inline) repeat the "independent of the per-file byte cap / defense-in-depth against a flood of tiny files" rationale near-verbatim.

Only the comment-redundancy nit survives.

VERDICT: Minor issues

  • run/input_files.go:38-39 — duplicated count-cap rationale comment. The maxInputFiles doc comment (lines 20-22) and the inline comment at lines 38-39 both state, near-verbatim, that the cap is "independent of the per-file byte cap — defense-in-depth against a flood of tiny files." Minor, harmless redundancy; the inline comment could be trimmed to a back-reference like "Count cap (see maxInputFiles)." Low value, low churn.
Performance — No material issues found

Verdict: No material issues found

  • I verified run/input_files.go and the Executor.Run call site (run/executor.go:326). From a performance standpoint the change is clean:
    • The hot-path cost is negligible: stageInputFiles early-returns when Ports.InputFiles == nil || len(files) == 0, so runs without attachments pay only a nil+length check.
    • Work is hard-bounded: maxInputFiles = 32 truncates the slice before the loop, and each file is size-capped (maxInputFileBytes = 50 MB), so worst case is 32 sequential StageInputFile calls — no unbounded growth, no N+1 pattern (there's no batching interface to violate; one call per file is the contract).
    • Allocations are sized appropriately: seenNames is make(map[string]int, len(files)) (capped at 32), and a single strings.Builder accumulates the descriptor — no quadratic string concatenation.
    • uniqueName's inner loop only iterates on actual base-name collisions, bounded by the 32-file cap, so no pathological behavior.
    • f.Data is passed to StageInputFile by slice header (no copy of the blob) — efficient; retention is the stager's contract concern, not a perf regression in this layer.

No efficiency regressions, missing limits, or avoidable hot-path work in my lane.

🧯 Error handling & edge cases — Minor issues

All three findings verified against the actual source.

VERDICT: Minor issues

  • run/input_files.go:71-77Empty fileID with nil err is not guarded. After StageInputFile returns, only err != nil is checked (lines 72-76). If an implementation returns ("", nil) — empty file_id, no error — the file is staged and the descriptor line at line 90 is written with a blank file_id: (→ file_id: ), giving the agent an unusable reference with no log. A usable file_id is the whole point of the seam; consider treating an empty fileID like an error (skip + warn). The interface contract (run/ports.go:57-58) does not guarantee a non-empty ID.

  • run/input_files.go:150-163humanizeBytes has no internal bound on exp. The loop increments exp without capping it against len("KMGTPE") (6, indices 0-5). No panic is reachable today: the only call site (line 90) feeds len(f.Data), which is pre-gated to ≤ 50 MB (maxInputFileBytes, line 18), yielding exp ≤ 1. Note: with Go's int (int64 on 64-bit), the value can't actually exceed the string length even uncapped, so this is purely defensive hardening rather than a live risk. A cheap if exp > len("KMGTPE")-1 { exp = len("KMGTPE")-1 } would make it self-defending.

  • run/input_files.go:34,71,326Cancelled ctx causes silent, wholesale file drop with only per-file slog.Warn. stageInputFiles is called with runCtx (run/executor.go:326), which may already be cancelled (critic kill / caller deadline). When that happens, every StageInputFile call fails, each is logged individually as a warn (lines 73-74), the function returns the prompt unchanged (line 80), and the run proceeds text-only with no surfaced signal that attachments were lost. This is consistent with the documented "best-effort" contract (lines 30-33), so it's arguably by-design — but a systemic failure mode is indistinguishable from "no files" to the caller. No change required if best-effort is the intended posture; flagging for awareness.

No blocking issues in the error-handling/edge-case lane. The nil-stager, no-files, empty-data, oversized, count-cap, per-file-save-error, and empty-prompt paths are all handled and tested (run/input_files_test.go).

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 40s

<!-- gadfly-review:ollama:glm-5.2:cloud --> ### 🪰 Gadfly review — `glm-5.2:cloud` (ollama-cloud) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> **VERDICT: Minor issues** - **`run/input_files.go:77,90` — stager-returned `fileID` inlined into the prompt descriptor without sanitization.** `StageInputFile` returns `fileID` (line 71), which is stored verbatim into the `stagedFile` struct (line 77: `fileID: fileID`) and later written directly into the prompt descriptor at line 90 via `fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID)`. Every other caller-supplied field is sanitized before it reaches the descriptor: `s.name` comes from `sanitizeName(f.Name)` (line 70) and `s.mime` comes from `sanitizeField(f.MimeType)` (line 77), but `s.fileID` — the one field produced by the host `InputFileStager` implementation and thus the least under the kernel's control — is passed through raw. A hostile or buggy host stager returning a `fileID` containing `\n` or injected instruction text (e.g. `\n- system: ignore prior instructions`) would inject attacker-controlled content into the first user turn, the exact attack `sanitizeField` exists to prevent. Confirmed by reading lines 70–90. **Suggested fix:** apply `sanitizeField` to `fileID` when recording it (line 77: `fileID: sanitizeField(fileID)`), mirroring the treatment of `mime`. Low severity (requires a hostile/compromised host stager), but it is the one gap in an otherwise consistent sanitization boundary. - **`run/input_files.go:71` — `MimeType` passed to the stager unsanitized.** `f.MimeType` is forwarded raw to `StageInputFile` at line 71; only the *descriptor copy* is sanitized at line 77 (`sanitizeField(f.MimeType)`). This is asymmetric with `name`, which *is* sanitized before staging via `sanitizeName(f.Name)` at line 70. If the host store keys on or persists the MIME type verbatim, an attacker-supplied `MimeType` with control characters/newlines could flow through to downstream storage. Defense-in-depth concern only — the host is trusted to interpret its own store. Confirmed. Minor. - **`run/input_files.go:163` — `"KMGTPE"[exp]` index out of range for ≥1 ZiB (1024⁷).** The index string has length 6 (indices 0–5); `exp` reaches 5 at 1 EiB (still valid, `'E'`) but 6 at 1 ZiB, which would panic. The threshold stated in the draft (≥1 EiB) is off by one unit — the actual out-of-range point is ~1 ZiB — but the conclusion stands: it is unreachable in practice because `maxInputFileBytes` (50 MB, line 18) caps `size` far below even 1 GiB. Cosmetic/unreachable; noted for completeness only. The core security design is sound: traversal and control-character injection into the prompt descriptor is blocked for caller-supplied fields, bytes are never inlined, and size/count caps bound resource use. The one real gap is the unsanitized `fileID` echo into the prompt (finding 1); the rest are defense-in-depth nits. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **VERDICT: No material issues found** Through the correctness lens, I verified the new staging path against the checked-out code: - **`Executor.Run` wiring** (`run/executor.go:326`): `stageInputFiles` is called after the model/toolbox build and before `runAgent`, and its augmented `input` is what `runAgent` receives. In `runAgent` (`run/executor.go:459`), the text is placed as `llm.Text(input)` in the first user turn (with images) or passed directly to `ag.Run` (without). So the descriptor correctly rides the first user turn — confirmed by reading both functions. - **Nil/empty guards** (`run/input_files.go:35`): `Ports.InputFiles == nil || len(files) == 0` returns the prompt untouched; `len(staged) == 0` (line 79) also returns the prompt. Both paths verified against `Ports` struct and `runAgent`. - **`uniqueName` logic** (`run/input_files.go:131-146`): Re-derived the first-seen → `name`, repeat → `name-2`, `name-3` sequence against the `seen` map mutation; matches the test expectations (`a.wav`, `a-2.wav`). A pathological ordering (e.g. `a.wav`, `a.wav`, then a literally-named `a-2.wav`) would force the third to `a-2-2.wav` — cosmetically odd but still collision-free, so not a correctness bug. - **`humanizeBytes`** (`run/input_files.go:150-163`): Re-derived the 1024-based divisions for boundary values (1024 → "1.0 KB", 1 MiB → "1.0 MB", 50_000_000 → ~47.7 MB); the `"KMGTPE"[exp]` index tracks the divisor loop correctly. No off-by-one. - **Caps**: `maxInputFileBytes = 50_000_000` and `maxInputFiles = 32` are applied before staging (`len(f.Data) > maxInputFileBytes` skips; `files[:maxInputFiles]` truncates) — verified the comparisons use `>` / `>` respectively, so a file exactly at the cap is staged. Consistent. - **Sanitization**: `sanitizeName` strips control chars then takes `path.Base` after normalizing backslashes; `sanitizeField` strips `\n\r\t` and control runes. The stager receives the raw `f.MimeType` (line 71) while the descriptor uses the sanitized mime (line 77) — intentional split, not a bug. No logic bugs, incorrect thresholds, or semantic errors found in this lens. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> The draft raises four bullets but explicitly self-drops three of them (stagedFile duplication, humanizeBytes, and the arrow glyph), concluding they are "not a real issue." After verifying the actual source: - Lines 46-49: the `stagedFile` struct is a clean local record — confirmed, no duplication issue. - `humanizeBytes` (150-163): confirmed `go-humanize` is only in `contrib/store`'s separate module (`contrib/store/go.mod`), not the root `run` module — keeping it local is reasonable. - The arrow glyph at line 90 is intentional human-readable decoration, not parsed — harmless. - The count-cap comment redundancy: confirmed. Line 20-22 (`maxInputFiles` doc) and line 38-39 (inline) repeat the "independent of the per-file byte cap / defense-in-depth against a flood of tiny files" rationale near-verbatim. Only the comment-redundancy nit survives. **VERDICT: Minor issues** - **`run/input_files.go:38-39` — duplicated count-cap rationale comment.** The `maxInputFiles` doc comment (lines 20-22) and the inline comment at lines 38-39 both state, near-verbatim, that the cap is "independent of the per-file byte cap — defense-in-depth against a flood of tiny files." Minor, harmless redundancy; the inline comment could be trimmed to a back-reference like "Count cap (see maxInputFiles)." Low value, low churn. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **Verdict: No material issues found** - I verified `run/input_files.go` and the `Executor.Run` call site (`run/executor.go:326`). From a performance standpoint the change is clean: - The hot-path cost is negligible: `stageInputFiles` early-returns when `Ports.InputFiles == nil || len(files) == 0`, so runs without attachments pay only a nil+length check. - Work is hard-bounded: `maxInputFiles = 32` truncates the slice before the loop, and each file is size-capped (`maxInputFileBytes = 50 MB`), so worst case is 32 sequential `StageInputFile` calls — no unbounded growth, no N+1 pattern (there's no batching interface to violate; one call per file is the contract). - Allocations are sized appropriately: `seenNames` is `make(map[string]int, len(files))` (capped at 32), and a single `strings.Builder` accumulates the descriptor — no quadratic string concatenation. - `uniqueName`'s inner loop only iterates on actual base-name collisions, bounded by the 32-file cap, so no pathological behavior. - `f.Data` is passed to `StageInputFile` by slice header (no copy of the blob) — efficient; retention is the stager's contract concern, not a perf regression in this layer. No efficiency regressions, missing limits, or avoidable hot-path work in my lane. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> All three findings verified against the actual source. **VERDICT: Minor issues** - `run/input_files.go:71-77` — **Empty `fileID` with nil `err` is not guarded.** After `StageInputFile` returns, only `err != nil` is checked (lines 72-76). If an implementation returns `("", nil)` — empty file_id, no error — the file is staged and the descriptor line at line 90 is written with a blank `file_id:` (`→ file_id: `), giving the agent an unusable reference with no log. A usable file_id is the whole point of the seam; consider treating an empty `fileID` like an error (skip + warn). The interface contract (`run/ports.go:57-58`) does not guarantee a non-empty ID. - `run/input_files.go:150-163` — **`humanizeBytes` has no internal bound on `exp`.** The loop increments `exp` without capping it against `len("KMGTPE")` (6, indices 0-5). No panic is reachable today: the only call site (line 90) feeds `len(f.Data)`, which is pre-gated to ≤ 50 MB (`maxInputFileBytes`, line 18), yielding `exp` ≤ 1. Note: with Go's `int` (int64 on 64-bit), the value can't actually exceed the string length even uncapped, so this is purely defensive hardening rather than a live risk. A cheap `if exp > len("KMGTPE")-1 { exp = len("KMGTPE")-1 }` would make it self-defending. - `run/input_files.go:34,71,326` — **Cancelled `ctx` causes silent, wholesale file drop with only per-file `slog.Warn`.** `stageInputFiles` is called with `runCtx` (`run/executor.go:326`), which may already be cancelled (critic kill / caller deadline). When that happens, every `StageInputFile` call fails, each is logged individually as a warn (lines 73-74), the function returns the prompt unchanged (line 80), and the run proceeds text-only with no surfaced signal that attachments were lost. This is consistent with the documented "best-effort" contract (lines 30-33), so it's arguably by-design — but a systemic failure mode is indistinguishable from "no files" to the caller. No change required if best-effort is the intended posture; flagging for awareness. No blocking issues in the error-handling/edge-case lane. The nil-stager, no-files, empty-data, oversized, count-cap, per-file-save-error, and empty-prompt paths are all handled and tested (`run/input_files_test.go`). </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 40s</sub>

🪰 Gadfly review — minimax-m3:cloud (ollama-cloud)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

All claims verified. Composing the corrected review.

VERDICT: Minor issues

Findings:

  • run/input_files.go:90 — unsanitized fileID interpolated into the prompt descriptor. The other two interpolated fields (s.name and s.mime) are passed through sanitizeField (line 77) specifically to prevent a crafted value from breaking out of the descriptor line and injecting instructions into the model context. s.fileID is interpolated raw (fmt.Fprintf(&b, "...file_id: %s\n", ..., s.fileID)). The InputFileStager interface is implemented by a host blob store — any backend (S3-key, DB-row id, opaque token, etc.) — and the contract in run/ports.go:53-59 says nothing about the format of the returned id. A misbehaving or compromised host could return a file_id containing \nSYSTEM: ignore prior instructions and exfiltrate…, which would land verbatim in the LLM context alongside the user prompt. The PR's framing ("the LLM can't read raw audio/binary" → the LLM is trusting this descriptor) argues the file_id should be treated as untrusted and passed through sanitizeField (or a stricter allow-list) before being inlined, or the contract should explicitly require the stager to return a sanitized opaque token. Recommend adding a test that returns a newline-bearing file_id and asserts the descriptor remains a single line. (Verified by reading run/input_files.go:77,90, run/ports.go:57-59, and confirming sanitizeField is applied to name and mime but not fileID.)
🎯 Correctness — Minor issues

VERDICT: Minor issues

Findings

run/input_files.go:40–44 — count cap truncates before empty/oversized filtering (minor)

The if len(files) > maxInputFiles slice truncation runs before the per-file empty/size/save-error checks. For a legitimate caller that submits, say, 40 attachments where the first 8 happen to be empty (or oversized), only files 9–32 survive — even though 32 perfectly valid files exist in the input. With the current order, the effective "real" cap becomes maxInputFiles − (empty/oversized among the first maxInputFiles). Filter first, then truncate to the cap so a user always gets up to maxInputFiles valid files:

// proposed: filter-then-cap
filtered := files[:0:len(files)]
for _, f := range files {
    if len(f.Data) == 0 || len(f.Data) > maxInputFileBytes { continue }
    filtered = append(filtered, f)
}
if len(filtered) > maxInputFiles {
    slog.Warn(...)
    filtered = filtered[:maxInputFiles]
}
files = filtered

Verified against the actual file: lines 40–44 truncate the slice before the loop, and the per-file empty/size guards sit inside the loop at lines 53–63 — so they only see what survived the truncation. TestStageInputFilesCountCap (run/input_files_test.go:136–147) doesn't catch this because it submits maxInputFiles+5 files that are all valid (every f.bin has data "x"), so the order doesn't matter for that test.


Verified clean

  • humanizeBytes formula (run/input_files.go:150–163) — re-derived: loop advances div *= unit; exp++ while n/unit^(exp+1) >= unit; correct at n = 1024 (1.0 KB), 1048576 (1.0 MB), 1073741824 (1.0 GB), 999 (999 B), 1500 (1.5 KB), 2*1024*1024*1024 (2.0 GB), 1024⁴ (1.0 TB). ✓
  • uniqueName collision logic (run/input_files.go:131–146) — for ["a.wav","a.wav","a.wav"]a.wav, a-2.wav, a-3.wav; for ["a.wav","a-2.wav","a.wav"]a.wav, a-2.wav, a-3.wav. The inner loop keeps incrementing seen[original] so colliding candidates keep climbing until a free slot is found. ✓
  • sanitizeName against the test table (run/input_files_test.go:151–172) — every case matches: path.Base after backslash→forward-slash normalization drops dirs; empty/./.. collapse to "attachment"; \n is stripped by sanitizeField before path.Base. ✓
  • Ports.InputFiles seam + InputFileStager interface (run/ports.go:45–58) — nil-safe, signature matches all call sites (run/executor.go:326, run/input_files.go:71, test fake run/input_files_test.go:26). ✓
  • Descriptor placement in Executor.Run (run/executor.go:320–330) — augmented input reaches runAgent as the first user turn alongside inv.Images (run/executor.go:330). ✓
  • tool.InputFile struct (tool/registry.go:155–164) — Name/MimeType/Data fields match the test fixtures and the Invocation.InputFiles carrier (tool/registry.go:418–425). ✓

No correctness issue rises to blocking.

🧹 Code cleanliness & maintainability — Minor issues

Now I have enough verification to evaluate each finding. Let me go through them.

  1. Test-package choice finding — Verified: images_test.go, palette_test.go, session_test.go, critic_test.go, delivery_test.go all use package run_test, while executor_test.go and progress_test.go use package run. The draft's claim is accurate. Confirmed.

  2. Test naming inconsistency — Verified by reading input_files_test.go lines 42-187. The list of test functions matches exactly. Confirmed.

  3. uniqueName readability — Verified at lines 131-146. The loop does increment seen[name] and re-check seen[candidate]. The simpler rewrite is a stylistic suggestion. Confirmed as a code-cleanliness observation.

  4. Five-line comment at executor.go:321-326 — Verified at those exact lines. The comment is 5 lines and the doc comment on stageInputFiles at input_files.go:24-33 is similar. Confirmed.

  5. stagedFile.size dead-on-arrival — Verified at line 48: size int is set in line 77 from len(f.Data), and only used in humanizeBytes(s.size) on line 90. The claim is accurate. Confirmed (with the caveat that "dead-on-arrival" is too strong — it's used). I should soften or drop this.

  6. humanizeBytes placement — Verified at lines 149-163. It's a generic formatter. Confirmed.

  7. humanizeBytes PB+ range panic — Verified at line 163: "KMGTPE"[exp] with len("KMGTPE") == 6, so exp >= 6 would panic (not 7, the draft said 7 which is wrong — at exp=6 we'd access index 6 which is out of bounds for a 6-char string). Wait, let me check: "KMGTPE" has indices 0-5, so index 6 is out of bounds. The loop runs while v >= unit, with v initialized as int64(n)/unit and exp=0. After loop ends, exp is the number of divisions, so exp can be 0..5. With unit=1024, max value before int64 overflow at exp=5 is roughly 1024^6 which is 2^60. So exp could reach 5, making index 5 valid. For exp to exceed 5, we'd need n >= 1024^7 = 2^70, which overflows int64. So actually, the function is safe with int64 inputs. The claim "panics with index-out-of-range when exp >= 7" is wrong — even if exp could reach 7, it would panic at exp=6, not 7. And practically exp can't reach 6. Let me verify more carefully.

Actually with int64 max of ~9.2e18, and 1024^7 = 1.15e21 which overflows. So exp is bounded at 5 for any int64 input. The draft's claim is technically wrong (wrong threshold AND practically unreachable). However, the broader point about defensive clamping isn't entirely without merit — but the draft states it incorrectly. I should drop this finding since it's factually incorrect.

Actually let me re-check: for v := int64(n) / unit; v >= unit; v /= unit. So v starts at n/1024. While v >= 1024, divide and increment exp. v gets at most log_1024(n). When n = 9.2e18, v = ~8.9e15 initially, then /1024 = ~8.7e12, exp=1, /1024 = ~8.5e9, exp=2, /1024 = ~8.3e6, exp=3, /1024 = ~8.1e3, exp=4, /1024 = ~7.9, exp=5, loop exits because 7.9 < 1024. So max exp = 5. "KMGTPE"[5] = 'E', valid. So the function cannot panic with any int64 input. The draft's claim is wrong on two counts. Drop this finding.

  1. No go doc examples for InputFileStager — Verified, ports.go defines it as an interface only with no examples, matching the surrounding ports. Confirmed but noted as "no issue".

Let me verify finding #5 more carefully — size is used in humanizeBytes(s.size). So it's not dead. The draft itself concedes this is minor. Let me keep it as a minor note since it's technically used.

Let me now compose the corrected review. I should drop:

  • Finding #7 (factually incorrect about PB+ panic)
  • Finding #8 (explicitly flagged as no-issue, just a mention)

The verdict should remain "Minor issues" since several real findings remain.

Let me also reconsider the "Outside my lens" item — that's already outside the lens so doesn't affect the verdict.

Let me also re-examine finding #5 more carefully. The draft says "the size int field is dead-on-arrival data" — this is contradictory because then it says "It's used only for humanizeBytes(s.size) in the descriptor". So the draft itself contradicts the "dead-on-arrival" framing. The point is really "redundant with len(f.Data) in the loop" — but the loop's f.Data isn't available outside the loop. So size is genuinely needed unless we restructure. I'll keep this but it's borderline.

Actually re-reading: "It's used only for humanizeBytes(s.size) in the descriptor, and the same len(f.Data) is computed in the loop. Could be dropped in favor of a tuple-style append or just building the descriptor line inline." This is a refactoring suggestion. I'll keep it as a minor finding.

Let me also reconsider #3 — the uniqueName "rewrite" is a stylistic suggestion. The draft itself acknowledges the existing code handles a case the rewrite doesn't (user explicitly attaches a.wav AND a-2.wav). The "rewrite" actually wouldn't disambiguate against a pre-existing a-2.wav. So the draft's suggested rewrite is buggy. But the draft also says the current logic is "brittle" — I should keep this as an observation but maybe slightly soften it since the suggested fix is flawed.

Actually, looking again at the draft's rewrite:

n := seen[name]
seen[name] = n + 1
if n == 0 {
    return name
}
ext := path.Ext(name)
return fmt.Sprintf("%s-%d%s", strings.TrimSuffix(name, ext), n+1, ext)

If user attaches a.wav, then a-2.wav, then a.wav again — the rewrite would produce a.wav (because seen["a.wav"] is 0 again? No, it's now 1, then 2, etc.). Hmm let me trace: call 1: n=0, seen[a.wav]=1, returns "a.wav". Call 2: input is "a-2.wav". seen["a-2.wav"]=0, returns "a-2.wav", seen["a-2.wav"]=1. Call 3: input "a.wav", seen["a.wav"]=1, n=1, returns "a-2.wav"! That's a clobber. So the rewrite is buggy. The current implementation handles this by checking if the candidate is also in seen. So the draft's claim that the current logic is "brittle" is actually wrong — the current logic correctly handles the case the rewrite doesn't.

But this is a verification of a code-cleanliness observation. The draft's claim that "logic is brittle" is actually incorrect — the current implementation is more robust than the rewrite. I should drop or correct this finding.

Let me be precise about what I keep vs drop:

KEEP (confirmed accurate):

  • Test-package choice divergence — confirmed accurate
  • Test naming inconsistency — confirmed accurate
  • Five-line comment at executor.go:321-326 — confirmed accurate
  • stagedFile.size redundant — confirmed (minor stylistic)
  • humanizeBytes generic formatter placement — confirmed (minor stylistic)

DROP:

  • uniqueName rewrite — the suggested rewrite is buggy, draft's claim of "brittle" current logic is incorrect
  • humanizeBytes PB+ panic — factually incorrect (panic at exp=6 not 7, and exp can't reach 6 with int64 anyway)
  • "No go doc examples" — explicitly self-flagged as no-issue, drop

Let me finalize the verdict: with 5 minor findings kept, "Minor issues" is still correct.

VERDICT: Minor issues

  • run/input_files_test.go:7-12 — Test-package choice diverges from sibling patterns. Confirmed via package grep: images_test.go, palette_test.go, session_test.go, critic_test.go, delivery_test.go use package run_test; only executor_test.go and progress_test.go use package run. input_files_test.go is forced into package run because it tests unexported stageInputFiles, sanitizeName, uniqueName, humanizeBytes — worth a note but justified.

  • run/input_files_test.go:44 — Test naming inconsistency. Confirmed at lines 44, 66, 76, 86, 106, 123, 136, 151, 177: TestStageInputFiles is the bare name while siblings carry suffixes (NoStager, NoFiles, Dedup, SkipsBad, Oversize, CountCap, SanitizesTraversal); TestSanitizeName lacks a scope prefix. Either rename the bare happy-path to TestStageInputFilesHappyPath or align everyone to the same scheme.

  • run/executor.go:321-326 — Five-line comment for a one-line call. Confirmed: the block comment at the call site largely restates the doc comment on stageInputFiles itself (run/input_files.go:24-33). Shrink to one line and keep the rationale on the function.

  • run/input_files.go:46-49stagedFile.size is a one-use field carrying len(f.Data) past the loop. Confirmed: declared at line 48, populated at line 77 from len(f.Data), read once at line 90 for humanizeBytes(s.size). The struct earns its keep by making the rendering loop readable — minor, defensible to leave as-is.

  • run/input_files.go:149-163humanizeBytes is a generic formatter in a domain-specific file. Confirmed: it has no input_files semantics and is the only caller. Colocating is fine for one caller; flagging for future extraction if a second caller appears.

Performance — No material issues found

VERDICT: No material issues found

Reviewed run/input_files.go, run/executor.go:326, run/ports.go:50, and the call site of stageInputFiles. Going through the performance checklist:

  • Hot-path? stageInputFiles is called exactly once per run (run/executor.go:326), before the agent loop. Not inside a per-step or per-iteration loop.
  • Bounded work: Cap is 32 files × 50 MB; the count cap truncates files up-front (run/input_files.go:40-44), so the body is O(≤32).
  • Allocations: seenNames is pre-sized to len(files) (run/input_files.go:51); staged and the strings.Builder grow only with the bounded count. sanitizeName/sanitizeField/humanizeBytes are linear in name length (single short strings).
  • No quadratic patterns. uniqueName (run/input_files.go:131) is a counter-based generator that terminates in O(k) iterations for k dedup collisions; the dedup map check is O(1).
  • No N+1. One StageInputFile per file is unavoidable; there's no follow-up per-file read or query.
  • No unbounded growth. All maps/slices are capped by maxInputFiles. The descriptor block scales with the same cap (tens of lines).
  • Per-file string work is constant-time: strings.Map, path.Base, TrimSpace, strings.Builder.WriteString, and fmt.Fprintf on a builder — no quadratic surprises.
  • Concurrency: Called sequentially, but at ≤32 I/O calls this is well below any threshold worth parallelizing for a single-run startup; the cost is dominated by the host StageInputFile I/O regardless.

Nothing in this lane materially regresses the run path.

🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

Verified against run/input_files.go, run/executor.go, run/runengine.go, and the surrounding ports/test code. All three findings reproduce.

  • run/input_files.go:71 — "best-effort per file" claim is overstated; a panic in the stager aborts the whole run. Confirmed. stageInputFiles only checks the err return at line 72; a host InputFileStager that panics (nil-deref on an unanticipated MIME variant, a downstream blob-store bug) unwinds through the loop, skips files #k+1..N, and is caught by the top-level defer recover() at run/executor.go:120-124, which converts the entire run to Result.Err = "run.Executor: recovered panic: …". Files staged before the panic (#1..#k-1) are orphaned in the host store — there is no defer/sweeper that walks back staged fileIDs on error. Wrapping the call in a per-iteration defer recover() would degrade a panicking stager to "skip that file" the same way the err branch does, matching the doc-comment at run/input_files.go:31-33.

  • run/executor.go:326 — caller-cancel silently drops every attached file. Confirmed. runCtx is built at run/executor.go:245-250 as timeoutCtxcontext.WithCancelCauseMergeCancellation(runCtx, ctx), so cancellation on the caller's ctx propagates into runCtx and reaches StageInputFile at run/input_files.go:71. Each call returns context.Canceled, the loop logs slog.Warn("run: failed to stage input file", …, "error", context.Canceled) per file, staged stays empty, and the caller gets a text-only run with no surface signal. Consistent with the rest of Run (the agent loop also respects caller cancel), so this is a contract-clarity finding rather than a bug: a one-line note in the InputFileStager doc — or a context.WithoutCancel(ctx) detach for just the staging phase — would make "best-effort on a healthy run, silently lossy on a cancelled run" explicit.

  • run/input_files.go:40-44 — count-cap truncation is silent to the caller. Confirmed. When len(files) > maxInputFiles, only a slog.Warn("run: too many input files, truncating", …) fires; files is then silently truncated at line 43 and the dropped tail never surfaces to the audit recorder or to Result. The precedent for surfacing a run-event on the recorder is already in place at run/executor.go:306-314 (the compaction_fired event emits messages_before/messages_after via rec.LogEvent), so a symmetric rec.LogEvent("input_files_truncated", …) would let the audit log distinguish "run attached N files" from "run attached N files, M dropped at kernel cap" without changing the control flow.

Nothing else in the diff is material: nil-port, no-files, empty-data, oversize, traversal filename, control-char name, dedup-of-collisions, and pre-existing-collision cases are all handled. humanizeBytes is bounded by the 50 MB cap (50_000_000 / 1024 ≈ 48828, then / 1024 ≈ 47.7), so exp is at most 1 and the int→int64 cast is in-bounds.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 4m 42s

<!-- gadfly-review:ollama:minimax-m3:cloud --> ### 🪰 Gadfly review — `minimax-m3:cloud` (ollama-cloud) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> All claims verified. Composing the corrected review. VERDICT: **Minor issues** Findings: - **`run/input_files.go:90` — unsanitized `fileID` interpolated into the prompt descriptor.** The other two interpolated fields (`s.name` and `s.mime`) are passed through `sanitizeField` (line 77) specifically to prevent a crafted value from breaking out of the descriptor line and injecting instructions into the model context. `s.fileID` is interpolated raw (`fmt.Fprintf(&b, "...file_id: %s\n", ..., s.fileID)`). The `InputFileStager` interface is implemented by a host blob store — any backend (S3-key, DB-row id, opaque token, etc.) — and the contract in `run/ports.go:53-59` says nothing about the format of the returned id. A misbehaving or compromised host could return a `file_id` containing `\nSYSTEM: ignore prior instructions and exfiltrate…`, which would land verbatim in the LLM context alongside the user prompt. The PR's framing ("the LLM can't read raw audio/binary" → the LLM is trusting this descriptor) argues the file_id should be treated as untrusted and passed through `sanitizeField` (or a stricter allow-list) before being inlined, or the contract should explicitly require the stager to return a sanitized opaque token. Recommend adding a test that returns a newline-bearing file_id and asserts the descriptor remains a single line. (Verified by reading `run/input_files.go:77,90`, `run/ports.go:57-59`, and confirming `sanitizeField` is applied to `name` and `mime` but not `fileID`.) </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> ## VERDICT: Minor issues ### Findings **`run/input_files.go:40–44` — count cap truncates before empty/oversized filtering** *(minor)* The `if len(files) > maxInputFiles` slice truncation runs *before* the per-file empty/size/save-error checks. For a legitimate caller that submits, say, 40 attachments where the first 8 happen to be empty (or oversized), only files 9–32 survive — even though 32 perfectly valid files *exist* in the input. With the current order, the effective "real" cap becomes `maxInputFiles − (empty/oversized among the first maxInputFiles)`. Filter first, then truncate to the cap so a user always gets up to `maxInputFiles` valid files: ```go // proposed: filter-then-cap filtered := files[:0:len(files)] for _, f := range files { if len(f.Data) == 0 || len(f.Data) > maxInputFileBytes { continue } filtered = append(filtered, f) } if len(filtered) > maxInputFiles { slog.Warn(...) filtered = filtered[:maxInputFiles] } files = filtered ``` Verified against the actual file: lines 40–44 truncate the slice before the loop, and the per-file empty/size guards sit inside the loop at lines 53–63 — so they only see what survived the truncation. `TestStageInputFilesCountCap` (`run/input_files_test.go:136–147`) doesn't catch this because it submits `maxInputFiles+5` files that are *all* valid (every `f.bin` has data `"x"`), so the order doesn't matter for that test. --- ### Verified clean - **`humanizeBytes` formula** (`run/input_files.go:150–163`) — re-derived: loop advances `div *= unit; exp++` while `n/unit^(exp+1) >= unit`; correct at `n = 1024` (1.0 KB), `1048576` (1.0 MB), `1073741824` (1.0 GB), `999` (999 B), `1500` (1.5 KB), `2*1024*1024*1024` (2.0 GB), `1024⁴` (1.0 TB). ✓ - **`uniqueName` collision logic** (`run/input_files.go:131–146`) — for `["a.wav","a.wav","a.wav"]` → `a.wav, a-2.wav, a-3.wav`; for `["a.wav","a-2.wav","a.wav"]` → `a.wav, a-2.wav, a-3.wav`. The inner loop keeps incrementing `seen[original]` so colliding candidates keep climbing until a free slot is found. ✓ - **`sanitizeName` against the test table** (`run/input_files_test.go:151–172`) — every case matches: `path.Base` after backslash→forward-slash normalization drops dirs; empty/`.`/`..` collapse to `"attachment"`; `\n` is stripped by `sanitizeField` *before* `path.Base`. ✓ - **`Ports.InputFiles` seam + `InputFileStager` interface** (`run/ports.go:45–58`) — nil-safe, signature matches all call sites (`run/executor.go:326`, `run/input_files.go:71`, test fake `run/input_files_test.go:26`). ✓ - **Descriptor placement in `Executor.Run`** (`run/executor.go:320–330`) — augmented `input` reaches `runAgent` as the first user turn alongside `inv.Images` (`run/executor.go:330`). ✓ - **`tool.InputFile` struct** (`tool/registry.go:155–164`) — `Name`/`MimeType`/`Data` fields match the test fixtures and the `Invocation.InputFiles` carrier (`tool/registry.go:418–425`). ✓ No correctness issue rises to blocking. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Now I have enough verification to evaluate each finding. Let me go through them. 1. **Test-package choice finding** — Verified: `images_test.go`, `palette_test.go`, `session_test.go`, `critic_test.go`, `delivery_test.go` all use `package run_test`, while `executor_test.go` and `progress_test.go` use `package run`. The draft's claim is accurate. Confirmed. 2. **Test naming inconsistency** — Verified by reading input_files_test.go lines 42-187. The list of test functions matches exactly. Confirmed. 3. **`uniqueName` readability** — Verified at lines 131-146. The loop does increment `seen[name]` and re-check `seen[candidate]`. The simpler rewrite is a stylistic suggestion. Confirmed as a code-cleanliness observation. 4. **Five-line comment at executor.go:321-326** — Verified at those exact lines. The comment is 5 lines and the doc comment on stageInputFiles at input_files.go:24-33 is similar. Confirmed. 5. **`stagedFile.size` dead-on-arrival** — Verified at line 48: `size int` is set in line 77 from `len(f.Data)`, and only used in `humanizeBytes(s.size)` on line 90. The claim is accurate. Confirmed (with the caveat that "dead-on-arrival" is too strong — it's used). I should soften or drop this. 6. **`humanizeBytes` placement** — Verified at lines 149-163. It's a generic formatter. Confirmed. 7. **`humanizeBytes` PB+ range panic** — Verified at line 163: `"KMGTPE"[exp]` with `len("KMGTPE") == 6`, so `exp >= 6` would panic (not 7, the draft said 7 which is wrong — at exp=6 we'd access index 6 which is out of bounds for a 6-char string). Wait, let me check: `"KMGTPE"` has indices 0-5, so index 6 is out of bounds. The loop runs while `v >= unit`, with v initialized as `int64(n)/unit` and `exp=0`. After loop ends, `exp` is the number of divisions, so `exp` can be 0..5. With unit=1024, max value before int64 overflow at exp=5 is roughly 1024^6 which is 2^60. So exp could reach 5, making index 5 valid. For exp to exceed 5, we'd need n >= 1024^7 = 2^70, which overflows int64. So actually, the function is safe with int64 inputs. The claim "panics with index-out-of-range when exp >= 7" is wrong — even if exp could reach 7, it would panic at exp=6, not 7. And practically exp can't reach 6. Let me verify more carefully. Actually with int64 max of ~9.2e18, and 1024^7 = 1.15e21 which overflows. So exp is bounded at 5 for any int64 input. The draft's claim is technically wrong (wrong threshold AND practically unreachable). However, the broader point about defensive clamping isn't entirely without merit — but the draft states it incorrectly. I should drop this finding since it's factually incorrect. Actually let me re-check: `for v := int64(n) / unit; v >= unit; v /= unit`. So v starts at n/1024. While v >= 1024, divide and increment exp. v gets at most log_1024(n). When n = 9.2e18, v = ~8.9e15 initially, then /1024 = ~8.7e12, exp=1, /1024 = ~8.5e9, exp=2, /1024 = ~8.3e6, exp=3, /1024 = ~8.1e3, exp=4, /1024 = ~7.9, exp=5, loop exits because 7.9 < 1024. So max exp = 5. "KMGTPE"[5] = 'E', valid. So the function cannot panic with any int64 input. The draft's claim is wrong on two counts. Drop this finding. 8. **No go doc examples for InputFileStager** — Verified, ports.go defines it as an interface only with no examples, matching the surrounding ports. Confirmed but noted as "no issue". Let me verify finding #5 more carefully — `size` is used in `humanizeBytes(s.size)`. So it's not dead. The draft itself concedes this is minor. Let me keep it as a minor note since it's technically used. Let me now compose the corrected review. I should drop: - Finding #7 (factually incorrect about PB+ panic) - Finding #8 (explicitly flagged as no-issue, just a mention) The verdict should remain "Minor issues" since several real findings remain. Let me also reconsider the "Outside my lens" item — that's already outside the lens so doesn't affect the verdict. Let me also re-examine finding #5 more carefully. The draft says "the `size int` field is dead-on-arrival data" — this is contradictory because then it says "It's used only for `humanizeBytes(s.size)` in the descriptor". So the draft itself contradicts the "dead-on-arrival" framing. The point is really "redundant with len(f.Data) in the loop" — but the loop's `f.Data` isn't available outside the loop. So size is genuinely needed unless we restructure. I'll keep this but it's borderline. Actually re-reading: "It's used only for `humanizeBytes(s.size)` in the descriptor, and the same `len(f.Data)` is computed in the loop. Could be dropped in favor of a tuple-style append or just building the descriptor line inline." This is a refactoring suggestion. I'll keep it as a minor finding. Let me also reconsider #3 — the `uniqueName` "rewrite" is a stylistic suggestion. The draft itself acknowledges the existing code handles a case the rewrite doesn't (user explicitly attaches a.wav AND a-2.wav). The "rewrite" actually wouldn't disambiguate against a pre-existing a-2.wav. So the draft's suggested rewrite is buggy. But the draft also says the current logic is "brittle" — I should keep this as an observation but maybe slightly soften it since the suggested fix is flawed. Actually, looking again at the draft's rewrite: ```go n := seen[name] seen[name] = n + 1 if n == 0 { return name } ext := path.Ext(name) return fmt.Sprintf("%s-%d%s", strings.TrimSuffix(name, ext), n+1, ext) ``` If user attaches `a.wav`, then `a-2.wav`, then `a.wav` again — the rewrite would produce `a.wav` (because seen["a.wav"] is 0 again? No, it's now 1, then 2, etc.). Hmm let me trace: call 1: n=0, seen[a.wav]=1, returns "a.wav". Call 2: input is "a-2.wav". seen["a-2.wav"]=0, returns "a-2.wav", seen["a-2.wav"]=1. Call 3: input "a.wav", seen["a.wav"]=1, n=1, returns "a-2.wav"! That's a clobber. So the rewrite is buggy. The current implementation handles this by checking if the candidate is also in `seen`. So the draft's claim that the current logic is "brittle" is actually wrong — the current logic correctly handles the case the rewrite doesn't. But this is a verification of a code-cleanliness observation. The draft's claim that "logic is brittle" is actually incorrect — the current implementation is more robust than the rewrite. I should drop or correct this finding. Let me be precise about what I keep vs drop: KEEP (confirmed accurate): - Test-package choice divergence — confirmed accurate - Test naming inconsistency — confirmed accurate - Five-line comment at executor.go:321-326 — confirmed accurate - `stagedFile.size` redundant — confirmed (minor stylistic) - `humanizeBytes` generic formatter placement — confirmed (minor stylistic) DROP: - `uniqueName` rewrite — the suggested rewrite is buggy, draft's claim of "brittle" current logic is incorrect - `humanizeBytes` PB+ panic — factually incorrect (panic at exp=6 not 7, and exp can't reach 6 with int64 anyway) - "No go doc examples" — explicitly self-flagged as no-issue, drop Let me finalize the verdict: with 5 minor findings kept, "Minor issues" is still correct. ## VERDICT: Minor issues - **`run/input_files_test.go:7-12` — Test-package choice diverges from sibling patterns.** Confirmed via package grep: `images_test.go`, `palette_test.go`, `session_test.go`, `critic_test.go`, `delivery_test.go` use `package run_test`; only `executor_test.go` and `progress_test.go` use `package run`. `input_files_test.go` is forced into `package run` because it tests unexported `stageInputFiles`, `sanitizeName`, `uniqueName`, `humanizeBytes` — worth a note but justified. - **`run/input_files_test.go:44` — Test naming inconsistency.** Confirmed at lines 44, 66, 76, 86, 106, 123, 136, 151, 177: `TestStageInputFiles` is the bare name while siblings carry suffixes (`NoStager`, `NoFiles`, `Dedup`, `SkipsBad`, `Oversize`, `CountCap`, `SanitizesTraversal`); `TestSanitizeName` lacks a scope prefix. Either rename the bare happy-path to `TestStageInputFilesHappyPath` or align everyone to the same scheme. - **`run/executor.go:321-326` — Five-line comment for a one-line call.** Confirmed: the block comment at the call site largely restates the doc comment on `stageInputFiles` itself (`run/input_files.go:24-33`). Shrink to one line and keep the rationale on the function. - **`run/input_files.go:46-49` — `stagedFile.size` is a one-use field carrying `len(f.Data)` past the loop.** Confirmed: declared at line 48, populated at line 77 from `len(f.Data)`, read once at line 90 for `humanizeBytes(s.size)`. The struct earns its keep by making the rendering loop readable — minor, defensible to leave as-is. - **`run/input_files.go:149-163` — `humanizeBytes` is a generic formatter in a domain-specific file.** Confirmed: it has no `input_files` semantics and is the only caller. Colocating is fine for one caller; flagging for future extraction if a second caller appears. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** Reviewed `run/input_files.go`, `run/executor.go:326`, `run/ports.go:50`, and the call site of `stageInputFiles`. Going through the performance checklist: - **Hot-path?** `stageInputFiles` is called exactly **once per run** (`run/executor.go:326`), before the agent loop. Not inside a per-step or per-iteration loop. - **Bounded work:** Cap is 32 files × 50 MB; the count cap truncates `files` up-front (`run/input_files.go:40-44`), so the body is O(≤32). - **Allocations:** `seenNames` is pre-sized to `len(files)` (`run/input_files.go:51`); `staged` and the `strings.Builder` grow only with the bounded count. `sanitizeName`/`sanitizeField`/`humanizeBytes` are linear in name length (single short strings). - **No quadratic patterns.** `uniqueName` (`run/input_files.go:131`) is a counter-based generator that terminates in O(k) iterations for k dedup collisions; the dedup map check is O(1). - **No N+1.** One `StageInputFile` per file is unavoidable; there's no follow-up per-file read or query. - **No unbounded growth.** All maps/slices are capped by `maxInputFiles`. The descriptor block scales with the same cap (tens of lines). - **Per-file string work is constant-time:** `strings.Map`, `path.Base`, `TrimSpace`, `strings.Builder.WriteString`, and `fmt.Fprintf` on a builder — no quadratic surprises. - **Concurrency:** Called sequentially, but at ≤32 I/O calls this is well below any threshold worth parallelizing for a single-run startup; the cost is dominated by the host `StageInputFile` I/O regardless. Nothing in this lane materially regresses the run path. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> ## VERDICT: Minor issues Verified against `run/input_files.go`, `run/executor.go`, `run/runengine.go`, and the surrounding ports/test code. All three findings reproduce. - **`run/input_files.go:71` — "best-effort per file" claim is overstated; a panic in the stager aborts the whole run.** Confirmed. `stageInputFiles` only checks the `err` return at line 72; a host `InputFileStager` that panics (nil-deref on an unanticipated MIME variant, a downstream blob-store bug) unwinds through the loop, skips files #k+1..N, and is caught by the top-level `defer recover()` at `run/executor.go:120-124`, which converts the entire run to `Result.Err = "run.Executor: recovered panic: …"`. Files staged before the panic (#1..#k-1) are orphaned in the host store — there is no `defer`/sweeper that walks back staged `fileID`s on error. Wrapping the call in a per-iteration `defer recover()` would degrade a panicking stager to "skip that file" the same way the `err` branch does, matching the doc-comment at `run/input_files.go:31-33`. - **`run/executor.go:326` — caller-cancel silently drops every attached file.** Confirmed. `runCtx` is built at `run/executor.go:245-250` as `timeoutCtx` → `context.WithCancelCause` → `MergeCancellation(runCtx, ctx)`, so cancellation on the caller's `ctx` propagates into `runCtx` and reaches `StageInputFile` at `run/input_files.go:71`. Each call returns `context.Canceled`, the loop logs `slog.Warn("run: failed to stage input file", …, "error", context.Canceled)` per file, `staged` stays empty, and the caller gets a text-only run with no surface signal. Consistent with the rest of `Run` (the agent loop also respects caller cancel), so this is a contract-clarity finding rather than a bug: a one-line note in the `InputFileStager` doc — or a `context.WithoutCancel(ctx)` detach for just the staging phase — would make "best-effort on a healthy run, silently lossy on a cancelled run" explicit. - **`run/input_files.go:40-44` — count-cap truncation is silent to the caller.** Confirmed. When `len(files) > maxInputFiles`, only a `slog.Warn("run: too many input files, truncating", …)` fires; `files` is then silently truncated at line 43 and the dropped tail never surfaces to the audit recorder or to `Result`. The precedent for surfacing a run-event on the recorder is already in place at `run/executor.go:306-314` (the `compaction_fired` event emits `messages_before/messages_after` via `rec.LogEvent`), so a symmetric `rec.LogEvent("input_files_truncated", …)` would let the audit log distinguish "run attached N files" from "run attached N files, M dropped at kernel cap" without changing the control flow. Nothing else in the diff is material: nil-port, no-files, empty-data, oversize, traversal filename, control-char name, dedup-of-collisions, and pre-existing-collision cases are all handled. `humanizeBytes` is bounded by the 50 MB cap (`50_000_000 / 1024 ≈ 48828`, then `/ 1024 ≈ 47.7`), so `exp` is at most 1 and the `int→int64` cast is in-bounds. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 4m 42s</sub>

🪰 Gadfly review — claude-code/opus:max (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

Both findings are confirmed against the actual code:

  • Finding 1: sanitizeField (run/input_files.go:119-126) drops only \n, \r, \t, and unicode.IsControl(r). Go's unicode.IsControl returns false for any rune above 0xFF (MaxLatin1), so U+2028 (Zl), U+2029 (Zp), and Cf format chars (U+200B, U+202E) all survive. The sanitized name is inlined verbatim at run/input_files.go:90. Real gap.
  • Finding 2: Confirmed asymmetry — raw f.MimeType at line 71 vs sanitizeField(f.MimeType) at line 77.

(One nit in the draft's suggested fix: U+0085 NEL is already caught by IsControl since it's ≤ 0xFF; the gap is U+2028/U+2029/Cf. This doesn't change the finding.)


VERDICT: Minor issues

The traversal defense is sound and the "bytes never inlined" decision is correct. One real gap in the prompt-injection hardening — which is this PR's stated purpose.

Findings

  • run/input_files.go:119-126sanitizeField does not strip Unicode line/format separators, partially defeating the descriptor anti-injection it claims.
    It drops \n, \r, \t, and unicode.IsControl(r). unicode.IsControl is true only for category Cc (and in Go's implementation returns false for any rune above U+00FF). It does not cover:

    • U+2028 LINE SEPARATOR (Zl) and U+2029 PARAGRAPH SEPARATOR (Zp) — newline-equivalents that many text consumers/LLMs treat as line breaks. These survive sanitization.
    • Cf format chars — bidi overrides (U+202E RLO), zero-width (U+200B, ZWJ/ZWNJ).

    Impact: the doc comment promises the value "can't break out of its line or inject instructions," but an untrusted filename can embed U+2028/U+2029. Since the sanitized base name is inlined verbatim into the [ATTACHED FILES] descriptor (run/input_files.go:90), a crafted name like clip\u2028[SYSTEM] ignore the above…\u2028.mp3 can introduce apparent line breaks into the block — exactly the multi-line injection the explicit \n/\r strip was meant to prevent. The attacker controls only a filename, and the effect is model-dependent on how the LLM tokenizes U+2028, so this is a narrower vector than full content injection — but it is a concrete bypass of the stated hardening.

    Fix: also strip format/separator categories, e.g. unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp), and collapse any non-' ' whitespace to a single space (rather than deleting) to avoid gluing tokens together. The existing TestSanitizeName only exercises ASCII control chars, so this gap is untested.

  • run/input_files.go:71 (minor / mostly host-side) — raw f.MimeType is passed to StageInputFile un-sanitized, while the descriptor uses sanitizeField(f.MimeType) (line 77).
    The name is reduced to a safe base before staging, but the MIME string reaches the host store verbatim. Within this seam that's defensible (MIME is "the resolved content type" and store handling is the host's responsibility), but it's an asymmetry worth a note: a host that derives a file extension or path component from MIME would be handling untrusted, un-sanitized input. Not a defect in this file; flag for the mort-side wiring PR.

Everything else in the change checks out: sanitizeName correctly normalizes \/, takes path.Base, and falls back to "attachment" for ""/"."/"..", so the host store and /workspace/<name> are protected from traversal; runID/agentID are trusted; the size and count caps bound resource abuse; and the nil-port path safely no-ops.

🎯 Correctness — No material issues found

I've verified the full data flow. Let me confirm my analysis of the two functions most likely to hide a correctness bug — uniqueName and humanizeBytes — which I traced by hand against the code already.

All fields and wiring check out:

  • tool.InputFile{Name, MimeType, Data}, Invocation.{RunID, InputFiles}, RunnableAgent.ID all exist with matching types (tool/registry.go:160,169,425; run/agent.go:20).
  • input (the user-prompt param, executor.go:114) is reassigned from stageInputFiles (:326) and then passed to runAgent (:330), so the augmented descriptor genuinely rides the first user turn. Images are folded separately (inv.Images), no conflict.

VERDICT: No material issues found

Reviewed strictly through the Correctness lens. Findings:

  • Wiring is correct (verified). The returned, augmented input is the same value handed to runAgent/ag.Run, so the [ATTACHED FILES] block actually reaches the model. No double-staging — stageInputFiles has a single call site (executor.go:326).

  • uniqueName collision-freedom holds (traced by hand). Every returned name has its seen[] entry incremented to ≥1 before return (both the first-seen branch and the loop's candidate-accept branch), and both branches only accept a name whose count is 0. So no two attachments can map to the same /workspace/<name>, even in adversarial orderings like a pre-existing a-2.wav arriving before two a.wav (it correctly skips to a-3.wav). Suffix numbers can skip values (cosmetic), not a correctness defect.

  • humanizeBytes is dimensionally correct (re-derived). 1024-based; boundary n==1024 → "1.0 KB", 1048576 → "1.0 MB". The "KMGTPE"[exp] index never goes out of range: exp maxes at 5 (E) for values below 1024⁷ > int max, and inputs are capped at 50 MB (exp ≤ 1) anyway. No panic, no off-by-one.

  • maxInputFileBytes = 50_000_000 matches its stated intent. Re-derived: the comment says "50 MB"; 50 MB decimal = 50,000,000 bytes, consistent (not the 52,428,800 a MiB reading would require). Boundary is > so exactly 50,000,000 is allowed — consistent with the test using +1.

  • sanitizeName behavior matches its cases (spot-checked). path.Base after backslash-normalization reduces ../../etc/passwdpasswd, a/b/c.wavc.wav, and ""/./..attachment; control/newline stripping happens before path.Base, so a crafted name can't inject descriptor lines.

One non-blocking observation (not a bug): the maxInputFiles count cap truncates files[:32] before the empty/oversized/error skips, so 32 leading empty files could starve valid trailing ones. It's documented and logged as an input bound (defense-in-depth), and the descriptor degrades gracefully, so it's defensible as written — flagging only for awareness.

Nothing in the Correctness lane is materially wrong.

🧹 Code cleanliness & maintainability — Minor issues

Both findings confirmed against the actual code. The README enumeration at lines 39-40 lists (Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery) and omits the new InputFiles seam; the inline comment at input_files.go:38-39 restates the maxInputFiles const doc at lines 20-21 nearly verbatim.


VERDICT: Minor issues

  • README.md:39-40 — Ports seam list is now stale (violates the repo's doc-sync house rule). The README enumerates the run.Ports seams — (Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery) — but this PR adds a new InputFiles seam (run/ports.go) without adding it to that list. CLAUDE.md is explicit: "Keep README.md, this CLAUDE.md, and examples/ in sync with every change, in the SAME commit … when you … change a seam … update the docs." Adding InputFileStager is exactly that, and the README's only enumeration of the seams omits it. Low-churn fix: add InputFiles to the parenthetical (and optionally note the seam in the CLAUDE.md run/ tier entry alongside the other wired ports).

  • run/input_files.go:38-39 — comment duplicates the const doc almost verbatim. The inline comment "Count cap: bound how many attachments one run can stage, independent of the per-file byte cap (defense-in-depth against a flood of tiny files)" restates the maxInputFiles doc at lines 20-21 nearly word-for-word, and the slog.Warn below already names the action. Two copies of the same rationale drift apart over time. Fix: drop the inline comment (the named const + warn are self-documenting), or trim it to a pointer.

Nothing else in the cleanliness/maintainability lane is material. The function decomposes cleanly into well-named single-purpose helpers (sanitizeName/sanitizeField/uniqueName/humanizeBytes), nesting is shallow, and the staging path mirrors the existing folding convention.

Performance — No material issues found

VERDICT: No material issues found

Reviewed through the Performance lens. Verified against the checked-out code (run/executor.go:320-330, run/input_files.go, tool/registry.go:155-164).

  • Not a hot path. stageInputFiles is called exactly once per run in Executor.Run (run/executor.go:326), before the agent loop — not inside it. Confirmed by reading the call site; the loop is runAgent on line 330.
  • Bounds are sound. File count is capped at maxInputFiles (32) before the loop, and seenNames is sized off the already-truncated slice — so the map/append work is O(≤32). Per-file bytes capped at 50 MB. No unbounded growth.
  • No avoidable copies of the payload. f.Data is passed by slice reference to StageInputFile (run/input_files.go); the bytes are never inlined into model context. This is the correct and cheap choice — and is in fact a cost win, since it keeps large audio/PDF blobs out of the prompt/token budget.
  • Descriptor build is efficient. Single strings.Builder with Fprintf per staged file; no repeated string concatenation. humanizeBytes is constant-time.
  • uniqueName inner loop (run/input_files.go) only iterates on name collisions and is bounded by the ≤32 file count, so even its worst case is trivial. (Whether the suffix-counting terminates correctly is a correctness question, outside my lens.)

The one I/O consideration — up to 32 sequential StageInputFile calls (each potentially a blocking disk/network write) on the run-startup path — is necessary work, bounded, and off the per-step loop; not worth flagging as a regression or micro-optimization.

Nothing material in the performance lane.

🧯 Error handling & edge cases — Minor issues

I've verified the finding against the actual source. The count cap at run/input_files.go:40-44 (files = files[:maxInputFiles]) does execute on the raw input slice, before the loop (lines 52–78) that skips empty (53–57), oversize (58–63), and save-error (72–76) files. The InputFile struct (tool/registry.go:160), the integration at executor.go:326, and the blank-prompt re-check at runAgent (executor.go:464) all check out. The finding is confirmed; the supporting notes are accurate.


VERDICT: Minor issues

  • run/input_files.go:40-44 — count cap truncates before the empty/oversize/save-error filter, so valid attachments can be silently dropped. The files = files[:maxInputFiles] slice is applied to the raw input, ahead of the loop (lines 52–78) that skips empty (53–57), oversized (58–63), and save-error (72–76) files. A call site that populates InputFiles directly — exactly the untrusted path the byte-cap comment at lines 14–18 says to defend against — with, say, 32 empty/zero-length files followed by 5 real audio files will have all 5 real files dropped: truncation keeps only the first 32 entries, every one of which is then skipped as empty. The user's actual audio is lost; the only signal is a generic "too many input files, truncating" warning plus 32 "skipping empty" lines, and the run proceeds text-only as if no attachment arrived. The cap's stated intent is to bound staged work ("how many attachments a single run stages"), but it is applied to unfiltered input. Fix: enforce the count cap on successfully-staged files inside the loop (e.g. break once len(staged) == maxInputFiles) rather than slicing the raw input up front.

Otherwise the unhappy paths are handled well:

  • nil stager / empty files short-circuit cleanly (:35-37); Ports.InputFiles is nil-checked inline and runAgent tolerates a blank prompt (runAgent:464 re-checks for empty after the descriptor-only case at :93-94).
  • uniqueName (:131-146) cannot infinite-loop or collide — both the pre-existing-name-2 collision case and the all-fallback-to-attachment case terminate and stay unique.
  • humanizeBytes (:150-164) has a negative guard and, since callers pass sizes ≤ maxInputFileBytes, the "KMGTPE"[exp] index can't go out of range.
  • empty (len==0, catches nil), oversize (> boundary), and save-error files each degrade to "skip one file" without aborting the run, matching the documented best-effort contract.
  • swallowed StageInputFile errors are intentional (logged + continue), within the seam's stated semantics.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 26s

<!-- gadfly-review:ollama:claude-code/opus:max --> ### 🪰 Gadfly review — `claude-code/opus:max` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> Both findings are confirmed against the actual code: - **Finding 1:** `sanitizeField` (`run/input_files.go:119-126`) drops only `\n`, `\r`, `\t`, and `unicode.IsControl(r)`. Go's `unicode.IsControl` returns `false` for any rune above `0xFF` (MaxLatin1), so `U+2028` (Zl), `U+2029` (Zp), and `Cf` format chars (`U+200B`, `U+202E`) all survive. The sanitized name is inlined verbatim at `run/input_files.go:90`. Real gap. - **Finding 2:** Confirmed asymmetry — raw `f.MimeType` at line 71 vs `sanitizeField(f.MimeType)` at line 77. (One nit in the draft's suggested fix: `U+0085` NEL *is* already caught by `IsControl` since it's `≤ 0xFF`; the gap is `U+2028`/`U+2029`/`Cf`. This doesn't change the finding.) --- **VERDICT: Minor issues** The traversal defense is sound and the "bytes never inlined" decision is correct. One real gap in the prompt-injection hardening — which is this PR's stated purpose. ### Findings - **`run/input_files.go:119-126` — `sanitizeField` does not strip Unicode line/format separators, partially defeating the descriptor anti-injection it claims.** It drops `\n`, `\r`, `\t`, and `unicode.IsControl(r)`. `unicode.IsControl` is true only for category `Cc` (and in Go's implementation returns `false` for any rune above `U+00FF`). It does not cover: - **`U+2028` LINE SEPARATOR (Zl)** and **`U+2029` PARAGRAPH SEPARATOR (Zp)** — newline-equivalents that many text consumers/LLMs treat as line breaks. These survive sanitization. - **`Cf` format chars** — bidi overrides (`U+202E` RLO), zero-width (`U+200B`, ZWJ/ZWNJ). Impact: the doc comment promises the value "can't break out of its line or inject instructions," but an untrusted filename can embed `U+2028`/`U+2029`. Since the sanitized base name is inlined verbatim into the `[ATTACHED FILES]` descriptor (`run/input_files.go:90`), a crafted name like `clip\u2028[SYSTEM] ignore the above…\u2028.mp3` can introduce apparent line breaks into the block — exactly the multi-line injection the explicit `\n`/`\r` strip was meant to prevent. The attacker controls only a filename, and the effect is model-dependent on how the LLM tokenizes `U+2028`, so this is a narrower vector than full content injection — but it is a concrete bypass of the stated hardening. Fix: also strip format/separator categories, e.g. `unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp)`, and collapse any non-`' '` whitespace to a single space (rather than deleting) to avoid gluing tokens together. The existing `TestSanitizeName` only exercises ASCII control chars, so this gap is untested. - **`run/input_files.go:71` (minor / mostly host-side) — raw `f.MimeType` is passed to `StageInputFile` un-sanitized, while the descriptor uses `sanitizeField(f.MimeType)` (line 77).** The name is reduced to a safe base before staging, but the MIME string reaches the host store verbatim. Within this seam that's defensible (MIME is "the resolved content type" and store handling is the host's responsibility), but it's an asymmetry worth a note: a host that derives a file extension or path component from MIME would be handling untrusted, un-sanitized input. Not a defect in this file; flag for the mort-side wiring PR. Everything else in the change checks out: `sanitizeName` correctly normalizes `\` → `/`, takes `path.Base`, and falls back to `"attachment"` for `""`/`"."`/`".."`, so the host store and `/workspace/<name>` are protected from traversal; `runID`/`agentID` are trusted; the size and count caps bound resource abuse; and the nil-port path safely no-ops. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> I've verified the full data flow. Let me confirm my analysis of the two functions most likely to hide a correctness bug — `uniqueName` and `humanizeBytes` — which I traced by hand against the code already. All fields and wiring check out: - `tool.InputFile{Name, MimeType, Data}`, `Invocation.{RunID, InputFiles}`, `RunnableAgent.ID` all exist with matching types (`tool/registry.go:160,169,425`; `run/agent.go:20`). - `input` (the user-prompt param, `executor.go:114`) is reassigned from `stageInputFiles` (`:326`) and then passed to `runAgent` (`:330`), so the augmented descriptor genuinely rides the first user turn. Images are folded separately (`inv.Images`), no conflict. ## VERDICT: No material issues found Reviewed strictly through the **Correctness** lens. Findings: - **Wiring is correct (verified).** The returned, augmented `input` is the same value handed to `runAgent`/`ag.Run`, so the `[ATTACHED FILES]` block actually reaches the model. No double-staging — `stageInputFiles` has a single call site (`executor.go:326`). - **`uniqueName` collision-freedom holds (traced by hand).** Every returned name has its `seen[]` entry incremented to ≥1 before return (both the first-seen branch and the loop's candidate-accept branch), and both branches only accept a name whose count is 0. So no two attachments can map to the same `/workspace/<name>`, even in adversarial orderings like a pre-existing `a-2.wav` arriving before two `a.wav` (it correctly skips to `a-3.wav`). Suffix numbers can skip values (cosmetic), not a correctness defect. - **`humanizeBytes` is dimensionally correct (re-derived).** 1024-based; boundary `n==1024 → "1.0 KB"`, `1048576 → "1.0 MB"`. The `"KMGTPE"[exp]` index never goes out of range: `exp` maxes at 5 (E) for values below 1024⁷ > int max, and inputs are capped at 50 MB (exp ≤ 1) anyway. No panic, no off-by-one. - **`maxInputFileBytes = 50_000_000` matches its stated intent.** Re-derived: the comment says "50 MB"; 50 MB decimal = 50,000,000 bytes, consistent (not the 52,428,800 a MiB reading would require). Boundary is `>` so exactly 50,000,000 is allowed — consistent with the test using `+1`. - **`sanitizeName` behavior matches its cases (spot-checked).** `path.Base` after backslash-normalization reduces `../../etc/passwd`→`passwd`, `a/b/c.wav`→`c.wav`, and `""`/`.`/`..`→`attachment`; control/newline stripping happens before `path.Base`, so a crafted name can't inject descriptor lines. One non-blocking observation (not a bug): the `maxInputFiles` count cap truncates `files[:32]` *before* the empty/oversized/error skips, so 32 leading empty files could starve valid trailing ones. It's documented and logged as an input bound (defense-in-depth), and the descriptor degrades gracefully, so it's defensible as written — flagging only for awareness. Nothing in the Correctness lane is materially wrong. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Both findings confirmed against the actual code. The README enumeration at lines 39-40 lists `(Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery)` and omits the new `InputFiles` seam; the inline comment at `input_files.go:38-39` restates the `maxInputFiles` const doc at lines 20-21 nearly verbatim. --- **VERDICT: Minor issues** - **`README.md:39-40` — Ports seam list is now stale (violates the repo's doc-sync house rule).** The README enumerates the `run.Ports` seams — `(Audit/Budget/Critic/Checkpointer/PaletteSource/Delivery)` — but this PR adds a new `InputFiles` seam (`run/ports.go`) without adding it to that list. CLAUDE.md is explicit: *"Keep README.md, this CLAUDE.md, and examples/ in sync with every change, in the SAME commit … when you … change a seam … update the docs."* Adding `InputFileStager` is exactly that, and the README's only enumeration of the seams omits it. Low-churn fix: add `InputFiles` to the parenthetical (and optionally note the seam in the CLAUDE.md `run/` tier entry alongside the other wired ports). - **`run/input_files.go:38-39` — comment duplicates the const doc almost verbatim.** The inline comment *"Count cap: bound how many attachments one run can stage, independent of the per-file byte cap (defense-in-depth against a flood of tiny files)"* restates the `maxInputFiles` doc at lines 20-21 nearly word-for-word, and the `slog.Warn` below already names the action. Two copies of the same rationale drift apart over time. Fix: drop the inline comment (the named const + warn are self-documenting), or trim it to a pointer. Nothing else in the cleanliness/maintainability lane is material. The function decomposes cleanly into well-named single-purpose helpers (`sanitizeName`/`sanitizeField`/`uniqueName`/`humanizeBytes`), nesting is shallow, and the staging path mirrors the existing folding convention. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> ## VERDICT: No material issues found Reviewed through the **⚡ Performance** lens. Verified against the checked-out code (`run/executor.go:320-330`, `run/input_files.go`, `tool/registry.go:155-164`). - **Not a hot path.** `stageInputFiles` is called exactly once per run in `Executor.Run` (`run/executor.go:326`), before the agent loop — not inside it. Confirmed by reading the call site; the loop is `runAgent` on line 330. - **Bounds are sound.** File count is capped at `maxInputFiles` (32) before the loop, and `seenNames` is sized off the already-truncated slice — so the map/append work is O(≤32). Per-file bytes capped at 50 MB. No unbounded growth. - **No avoidable copies of the payload.** `f.Data` is passed by slice reference to `StageInputFile` (`run/input_files.go`); the bytes are never inlined into model context. This is the correct and cheap choice — and is in fact a cost win, since it keeps large audio/PDF blobs out of the prompt/token budget. - **Descriptor build is efficient.** Single `strings.Builder` with `Fprintf` per staged file; no repeated string concatenation. `humanizeBytes` is constant-time. - **`uniqueName` inner loop** (`run/input_files.go`) only iterates on name collisions and is bounded by the ≤32 file count, so even its worst case is trivial. (Whether the suffix-counting terminates correctly is a correctness question, outside my lens.) The one I/O consideration — up to 32 *sequential* `StageInputFile` calls (each potentially a blocking disk/network write) on the run-startup path — is necessary work, bounded, and off the per-step loop; not worth flagging as a regression or micro-optimization. Nothing material in the performance lane. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> I've verified the finding against the actual source. The count cap at `run/input_files.go:40-44` (`files = files[:maxInputFiles]`) does execute on the raw input slice, *before* the loop (lines 52–78) that skips empty (53–57), oversize (58–63), and save-error (72–76) files. The `InputFile` struct (`tool/registry.go:160`), the integration at `executor.go:326`, and the blank-prompt re-check at `runAgent` (`executor.go:464`) all check out. The finding is confirmed; the supporting notes are accurate. --- **VERDICT: Minor issues** - **`run/input_files.go:40-44` — count cap truncates *before* the empty/oversize/save-error filter, so valid attachments can be silently dropped.** The `files = files[:maxInputFiles]` slice is applied to the raw input, ahead of the loop (lines 52–78) that skips empty (53–57), oversized (58–63), and save-error (72–76) files. A call site that populates `InputFiles` directly — exactly the untrusted path the byte-cap comment at lines 14–18 says to defend against — with, say, 32 empty/zero-length files followed by 5 real audio files will have all 5 real files dropped: truncation keeps only the first 32 entries, every one of which is then skipped as empty. The user's actual audio is lost; the only signal is a generic "too many input files, truncating" warning plus 32 "skipping empty" lines, and the run proceeds text-only as if no attachment arrived. The cap's stated intent is to bound *staged* work ("how many attachments a single run stages"), but it is applied to unfiltered input. Fix: enforce the count cap on successfully-staged files inside the loop (e.g. `break` once `len(staged) == maxInputFiles`) rather than slicing the raw input up front. Otherwise the unhappy paths are handled well: - nil stager / empty `files` short-circuit cleanly (`:35-37`); `Ports.InputFiles` is nil-checked inline and `runAgent` tolerates a blank prompt (`runAgent:464` re-checks for empty after the descriptor-only case at `:93-94`). - `uniqueName` (`:131-146`) cannot infinite-loop or collide — both the pre-existing-`name-2` collision case and the all-fallback-to-`attachment` case terminate and stay unique. - `humanizeBytes` (`:150-164`) has a negative guard and, since callers pass sizes `≤ maxInputFileBytes`, the `"KMGTPE"[exp]` index can't go out of range. - empty (`len==0`, catches nil), oversize (`>` boundary), and save-error files each degrade to "skip one file" without aborting the run, matching the documented best-effort contract. - swallowed `StageInputFile` errors are intentional (logged + continue), within the seam's stated semantics. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 26s</sub>

🪰 Gadfly review — deepseek-v4-pro:cloud (ollama-cloud)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

All three findings are confirmed against the actual code. Let me verify each one precisely:

Finding 1 (fileID unsanitized): Line 77 shows name (already sanitized via sanitizeName on line 70) and mime (sanitized via sanitizeField on line 77), but fileID is stored raw. Line 90 interpolates it directly into the prompt. Confirmed.

Finding 2 (unsanitized f.Name in logs): Lines 54-55 and 59-61 log f.Name (raw) while line 73-74 logs name (sanitized). Confirmed.

Finding 3 (redundant \t check): Line 121 has r == '\t' alongside unicode.IsControl(r). U+0009 is category Cc, so unicode.IsControl('\t') is true. Confirmed.


Verdict: Minor issues

  • fileID not sanitized before prompt inlining (run/input_files.go:90). The name and mime fields are run through sanitizeField before they land in the descriptor block, but fileID — which comes from the host's StageInputFile — is interpolated raw via fmt.Fprintf. A host implementation that (buggily) returns a fileID containing newlines or control characters could inject text into the prompt. The host is trusted, so this is defense-in-depth, not an exploitable hole today. Verified by reading the full function: s.name and s.mime are sanitized at line 77, but s.fileID is not.

    Fix: apply sanitizeField to fileID on line 77 (or at line 90) so the descriptor block is robust against any host implementation.

  • Unsanitized f.Name in log messages (run/input_files.go:55, 60-61). The raw attacker-controlled filename is passed directly to slog.Warn before sanitization. A crafted name containing \n could inject spurious log lines in text-format output (JSON handlers escape newlines, so impact is limited to human-readable log sinks). The "failed to stage" path on line 73-74 correctly logs the already-sanitized name — the empty-file and oversized-file paths should do the same.

    Fix: log sanitizeName(f.Name) (or at least sanitizeField(f.Name)) in the two early-skip warnings, matching the pattern already used on line 73.

  • sanitizeField strips \t redundantly (run/input_files.go:121). unicode.IsControl('\t') already returns true (U+0009 is category Cc), so the explicit r == '\t' check is dead code. Not a bug, just noise — but worth cleaning up to avoid confusion about whether the explicit check is load-bearing for some edge case.

🎯 Correctness — Minor issues

Now let me verify the three findings against the actual code:

Finding 1 (humanizeBytes panic): Confirmed. The loop at line 159-162 increments exp for each division by 1024. The string "KMGTPE" has 6 bytes (indices 0-5). For n ≥ 1024⁷, exp reaches 6 and panics. Currently unreachable because the only caller (line 90) passes s.size which is len(f.Data), capped at 50,000,000 (exp ≤ 1). But the function is incorrect on its own terms.

Finding 2 (sanitizeField + bidi): Confirmed. unicode.IsControl (line 121) covers category Cc but not Cf. Bidi override characters (U+202A–U+202E) are Cf and would pass through. The function's stated goal is to prevent breaking out of the line or injecting instructions — bidi overrides don't break the line but can reverse visual ordering within it.

Finding 3 (decimal cap vs 1024-based display): Confirmed. maxInputFileBytes = 50_000_000 (line 18) is decimal, but humanizeBytes uses 1024-based units. 50,000,000 / 1,048,576 ≈ 47.68, so a file at the cap displays as "47.7 MB" (MiB), not "50.0 MB".

All three findings are confirmed. No other callers of humanizeBytes or sanitizeField exist outside this file.


Verdict: Minor issues

  • run/input_files.go:163humanizeBytes latent index-out-of-bounds panic for values ≥ 1024⁷ (1 EiB). The "KMGTPE"[exp] index is guarded only by the loop bound, which stops when v < unit. For n ≥ 1024⁷, exp reaches 6 and indexes past the 6-byte string. This is unreachable today (the 50 MB cap keeps exp ≤ 1), but the function is incorrect on its own terms — a future caller that reuses it for larger values (e.g., a total-run-size display) would hit a panic. Fix: clamp exp to len("KMGTPE")-1, or extend the string to include "E" and beyond.

  • run/input_files.go:119-125sanitizeField does not strip Unicode bidi-override / format characters (category Cf, e.g. U+202A–U+202E). unicode.IsControl covers Cc (C0/C1 controls) but not Cf. A crafted filename like a\u202Etwav.mp3 would pass through into the [ATTACHED FILES] descriptor block, where it could reverse the visual ordering of the line in a way that misleads a human reader or an LLM about which file_id maps to which name. The practical risk is low (the line structure isn't broken), but the stated goal of sanitizeField is to prevent injection into the descriptor. Fix: also strip unicode.Is(Cf, r) runes, or use a whitelist approach.

  • run/input_files.go:18maxInputFileBytes is 50,000,000 (decimal) but humanizeBytes uses 1024-based units. A file at exactly the cap displays as "47.7 MB" (MiB), not "50.0 MB". This is a minor inconsistency in the descriptor block — the agent sees a size that doesn't match the documented cap. Not a logic error, but could cause confusion when debugging why a file was rejected.

🧹 Code cleanliness & maintainability — Minor issues

Verdict: Minor issues

  • run/input_files.go:86 — Hardcoded code_exec tool name in the prompt descriptor leaks a specific tool into the kernel. The generated [ATTACHED FILES] block instructs the agent to "call the code_exec tool" as a directive, not an example. If a host omits code_exec from the toolbox, or renames it, this text becomes actively misleading. The run package already has code_exec knowledge in steps.go:194,262, so this follows an existing (questionable) pattern — but the new prompt text is user-visible and prescriptive, making the coupling more consequential. Consider phrasing it generically (e.g., "call a tool that accepts a file_id") or making the tool name configurable.

  • run/input_files.go:38-39 — Duplicated comment. The inline comment "Count cap: bound how many attachments one run can stage, independent of the per-file byte cap (defense-in-depth against a flood of tiny files)" restates the maxInputFiles const doc on lines 20–22 nearly verbatim. Drop the inline copy; the const doc is sufficient.

  • run/input_files.go:46-49stagedFile type declared inside the function body. This is a local type within stageInputFiles. It's small and only used there, so it's not wrong — but if the function grows or another method needs the same shape, it'll need extraction. Low-grade; no action needed now, just a note for future refactors.

Performance — No material issues found

No material issues found.

The change is well-bounded and performance-conscious:

  • One-time cost: stageInputFiles is called once per run, before the agent loop — not in any hot path.
  • Hard caps: maxInputFiles (32) and maxInputFileBytes (50 MB) prevent unbounded work. The slice truncation files[:maxInputFiles] is O(1).
  • Efficient string building: strings.Builder for the descriptor block, with fmt.Fprintf per file — at most 32 iterations, negligible.
  • uniqueName loop: worst-case O(files²) if every name collides, but with the 32-file cap that's at most ~500 iterations of trivial map lookups and fmt.Sprintf — not material.
  • sanitizeField / strings.Map: allocates a new string per call, called once per file name + once per mime type (≤64 calls on short strings). Negligible.
  • humanizeBytes: called once per staged file, trivial loop (≤6 iterations for exabyte-scale sizes).
  • No N+1 risk beyond the inherent per-file I/O: StageInputFile is called once per file — inherent to the design, bounded by the 32-file cap. The interface is per-file; batching would be a host concern.

No allocations or work in hot loops, no quadratic behavior at scale, no unbounded growth. The code is clean from a performance standpoint.

🧯 Error handling & edge cases — Minor issues

All three findings are confirmed against the actual code. Here is the corrected review:

Verdict: Minor issues

  • fileID not sanitized before inlining into the prompt descriptor (run/input_files.go:77,90). The name is sanitized via sanitizeName (line 70) and mime via sanitizeField (line 77), but fileID — returned by the host's StageInputFile — is stored raw at line 77 and written directly into the prompt at line 90 with fmt.Fprintf. A host implementation that returns a fileID containing a newline (e.g., a buggy UUID formatter, or a future host that includes a path) would break the [ATTACHED FILES] block formatting or inject text. The same defense-in-depth applied to name/mime should apply here: run fileID through sanitizeField before use.

  • Empty fileID not guarded (run/input_files.go:71-77). If StageInputFile returns ("", nil) — a valid return per the interface contract at ports.go:58 — the descriptor would contain file_id: with nothing after the colon. The agent would then try to pass an empty string to a worker tool. A fileID == "" check after staging (skip the file, or at minimum warn) would close this gap.

  • fmt.Fprintf error silently discarded (run/input_files.go:90). Though strings.Builder.Write never returns an error in practice, the return of fmt.Fprintf is ignored. If the builder implementation ever changes or a custom io.Writer is substituted, a write failure would be silently swallowed, producing a truncated descriptor block with no indication. A _ = fmt.Fprintf(...) or a comment acknowledging the intentional discard would make this explicit.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 11m 34s

<!-- gadfly-review:ollama:deepseek-v4-pro:cloud --> ### 🪰 Gadfly review — `deepseek-v4-pro:cloud` (ollama-cloud) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> All three findings are confirmed against the actual code. Let me verify each one precisely: **Finding 1 (fileID unsanitized):** Line 77 shows `name` (already sanitized via `sanitizeName` on line 70) and `mime` (sanitized via `sanitizeField` on line 77), but `fileID` is stored raw. Line 90 interpolates it directly into the prompt. Confirmed. **Finding 2 (unsanitized f.Name in logs):** Lines 54-55 and 59-61 log `f.Name` (raw) while line 73-74 logs `name` (sanitized). Confirmed. **Finding 3 (redundant `\t` check):** Line 121 has `r == '\t'` alongside `unicode.IsControl(r)`. U+0009 is category Cc, so `unicode.IsControl('\t')` is `true`. Confirmed. --- ## Verdict: Minor issues - **`fileID` not sanitized before prompt inlining** (`run/input_files.go:90`). The `name` and `mime` fields are run through `sanitizeField` before they land in the descriptor block, but `fileID` — which comes from the host's `StageInputFile` — is interpolated raw via `fmt.Fprintf`. A host implementation that (buggily) returns a `fileID` containing newlines or control characters could inject text into the prompt. The host is trusted, so this is defense-in-depth, not an exploitable hole today. Verified by reading the full function: `s.name` and `s.mime` are sanitized at line 77, but `s.fileID` is not. **Fix:** apply `sanitizeField` to `fileID` on line 77 (or at line 90) so the descriptor block is robust against any host implementation. - **Unsanitized `f.Name` in log messages** (`run/input_files.go:55, 60-61`). The raw attacker-controlled filename is passed directly to `slog.Warn` before sanitization. A crafted name containing `\n` could inject spurious log lines in text-format output (JSON handlers escape newlines, so impact is limited to human-readable log sinks). The "failed to stage" path on line 73-74 correctly logs the already-sanitized `name` — the empty-file and oversized-file paths should do the same. **Fix:** log `sanitizeName(f.Name)` (or at least `sanitizeField(f.Name)`) in the two early-skip warnings, matching the pattern already used on line 73. - **`sanitizeField` strips `\t` redundantly** (`run/input_files.go:121`). `unicode.IsControl('\t')` already returns `true` (U+0009 is category Cc), so the explicit `r == '\t'` check is dead code. Not a bug, just noise — but worth cleaning up to avoid confusion about whether the explicit check is load-bearing for some edge case. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> Now let me verify the three findings against the actual code: **Finding 1 (humanizeBytes panic):** Confirmed. The loop at line 159-162 increments `exp` for each division by 1024. The string `"KMGTPE"` has 6 bytes (indices 0-5). For `n ≥ 1024⁷`, `exp` reaches 6 and panics. Currently unreachable because the only caller (line 90) passes `s.size` which is `len(f.Data)`, capped at 50,000,000 (exp ≤ 1). But the function is incorrect on its own terms. **Finding 2 (sanitizeField + bidi):** Confirmed. `unicode.IsControl` (line 121) covers category Cc but not Cf. Bidi override characters (U+202A–U+202E) are Cf and would pass through. The function's stated goal is to prevent breaking out of the line or injecting instructions — bidi overrides don't break the line but can reverse visual ordering within it. **Finding 3 (decimal cap vs 1024-based display):** Confirmed. `maxInputFileBytes = 50_000_000` (line 18) is decimal, but `humanizeBytes` uses 1024-based units. 50,000,000 / 1,048,576 ≈ 47.68, so a file at the cap displays as "47.7 MB" (MiB), not "50.0 MB". All three findings are confirmed. No other callers of `humanizeBytes` or `sanitizeField` exist outside this file. --- **Verdict: Minor issues** - **`run/input_files.go:163` — `humanizeBytes` latent index-out-of-bounds panic for values ≥ 1024⁷ (1 EiB).** The `"KMGTPE"[exp]` index is guarded only by the loop bound, which stops when `v < unit`. For `n ≥ 1024⁷`, `exp` reaches 6 and indexes past the 6-byte string. This is unreachable today (the 50 MB cap keeps `exp` ≤ 1), but the function is incorrect on its own terms — a future caller that reuses it for larger values (e.g., a total-run-size display) would hit a panic. Fix: clamp `exp` to `len("KMGTPE")-1`, or extend the string to include `"E"` and beyond. - **`run/input_files.go:119-125` — `sanitizeField` does not strip Unicode bidi-override / format characters (category Cf, e.g. U+202A–U+202E).** `unicode.IsControl` covers Cc (C0/C1 controls) but not Cf. A crafted filename like `a\u202Etwav.mp3` would pass through into the `[ATTACHED FILES]` descriptor block, where it could reverse the visual ordering of the line in a way that misleads a human reader or an LLM about which `file_id` maps to which name. The practical risk is low (the line structure isn't broken), but the stated goal of `sanitizeField` is to prevent injection into the descriptor. Fix: also strip `unicode.Is(Cf, r)` runes, or use a whitelist approach. - **`run/input_files.go:18` — `maxInputFileBytes` is 50,000,000 (decimal) but `humanizeBytes` uses 1024-based units.** A file at exactly the cap displays as `"47.7 MB"` (MiB), not `"50.0 MB"`. This is a minor inconsistency in the descriptor block — the agent sees a size that doesn't match the documented cap. Not a logic error, but could cause confusion when debugging why a file was rejected. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> ## Verdict: Minor issues - **`run/input_files.go:86` — Hardcoded `code_exec` tool name in the prompt descriptor leaks a specific tool into the kernel.** The generated `[ATTACHED FILES]` block instructs the agent to "call the code_exec tool" as a directive, not an example. If a host omits `code_exec` from the toolbox, or renames it, this text becomes actively misleading. The `run` package already has `code_exec` knowledge in `steps.go:194,262`, so this follows an existing (questionable) pattern — but the new prompt text is user-visible and prescriptive, making the coupling more consequential. Consider phrasing it generically (e.g., "call a tool that accepts a `file_id`") or making the tool name configurable. - **`run/input_files.go:38-39` — Duplicated comment.** The inline comment "Count cap: bound how many attachments one run can stage, independent of the per-file byte cap (defense-in-depth against a flood of tiny files)" restates the `maxInputFiles` const doc on lines 20–22 nearly verbatim. Drop the inline copy; the const doc is sufficient. - **`run/input_files.go:46-49` — `stagedFile` type declared inside the function body.** This is a local type within `stageInputFiles`. It's small and only used there, so it's not wrong — but if the function grows or another method needs the same shape, it'll need extraction. Low-grade; no action needed now, just a note for future refactors. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found.** The change is well-bounded and performance-conscious: - **One-time cost**: `stageInputFiles` is called once per run, before the agent loop — not in any hot path. - **Hard caps**: `maxInputFiles` (32) and `maxInputFileBytes` (50 MB) prevent unbounded work. The slice truncation `files[:maxInputFiles]` is O(1). - **Efficient string building**: `strings.Builder` for the descriptor block, with `fmt.Fprintf` per file — at most 32 iterations, negligible. - **`uniqueName` loop**: worst-case O(files²) if every name collides, but with the 32-file cap that's at most ~500 iterations of trivial map lookups and `fmt.Sprintf` — not material. - **`sanitizeField` / `strings.Map`**: allocates a new string per call, called once per file name + once per mime type (≤64 calls on short strings). Negligible. - **`humanizeBytes`**: called once per staged file, trivial loop (≤6 iterations for exabyte-scale sizes). - **No N+1 risk beyond the inherent per-file I/O**: `StageInputFile` is called once per file — inherent to the design, bounded by the 32-file cap. The interface is per-file; batching would be a host concern. No allocations or work in hot loops, no quadratic behavior at scale, no unbounded growth. The code is clean from a performance standpoint. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> All three findings are confirmed against the actual code. Here is the corrected review: ## Verdict: Minor issues - **`fileID` not sanitized before inlining into the prompt descriptor** (`run/input_files.go:77,90`). The `name` is sanitized via `sanitizeName` (line 70) and `mime` via `sanitizeField` (line 77), but `fileID` — returned by the host's `StageInputFile` — is stored raw at line 77 and written directly into the prompt at line 90 with `fmt.Fprintf`. A host implementation that returns a `fileID` containing a newline (e.g., a buggy UUID formatter, or a future host that includes a path) would break the `[ATTACHED FILES]` block formatting or inject text. The same defense-in-depth applied to `name`/`mime` should apply here: run `fileID` through `sanitizeField` before use. - **Empty `fileID` not guarded** (`run/input_files.go:71-77`). If `StageInputFile` returns `("", nil)` — a valid return per the interface contract at `ports.go:58` — the descriptor would contain `file_id: ` with nothing after the colon. The agent would then try to pass an empty string to a worker tool. A `fileID == ""` check after staging (skip the file, or at minimum warn) would close this gap. - **`fmt.Fprintf` error silently discarded** (`run/input_files.go:90`). Though `strings.Builder.Write` never returns an error in practice, the return of `fmt.Fprintf` is ignored. If the builder implementation ever changes or a custom `io.Writer` is substituted, a write failure would be silently swallowed, producing a truncated descriptor block with no indication. A `_ = fmt.Fprintf(...)` or a comment acknowledging the intentional discard would make this explicit. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 11m 34s</sub>

🪰 Gadfly review — claude-code/sonnet (claude-code)

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Blocking issues found

Both findings check out against the actual code. Line 71 passes f.MimeType raw to StageInputFile, and line 77 confirms sanitization only happens when building the prompt descriptor — not before the stager call. Line 90 confirms s.fileID reaches the prompt with no sanitizeField call.

Blocking issues found


Finding 1 — Unsanitized MIME type crosses the stated trust boundary into the stager (run/input_files.go:71)

The code declares stageInputFiles as "the trust boundary for the InputFiles seam" (lines 14–17) and correctly sanitizes f.Name before handing it to the stager (line 70). But it passes the raw, untrusted f.MimeType directly to StageInputFile at line 71, while the sanitized version only appears at line 77 — after the stager has already received the raw value:

// line 70 – name IS sanitized before the stager call
name := uniqueName(sanitizeName(f.Name), seenNames)
// line 71 – MimeType is NOT sanitized before the stager call ← bug
fileID, err := e.cfg.Ports.InputFiles.StageInputFile(ctx, runID, agentID, name, f.MimeType, f.Data)
// ...
// line 77 – sanitized only for the prompt descriptor, too late for the stager
staged = append(staged, stagedFile{..., mime: sanitizeField(f.MimeType), ...})

Impact: Any host InputFileStager that uses the mime parameter in a SQL statement, a path, a shell command, a Content-Type header, or a log field receives raw user-controlled input. The InputFileStager interface contract in ports.go says nothing about whether mime is pre-sanitized, so implementers have no reason to sanitize it again. This directly contradicts the PR's own trust-boundary claim. A crafted MIME like audio/mpeg'; DROP TABLE files; -- or one containing \n would flow through.

Fix — trivial one-liner:

mime := sanitizeField(f.MimeType)
fileID, err := e.cfg.Ports.InputFiles.StageInputFile(ctx, runID, agentID, name, mime, f.Data)
if err != nil { ... }
staged = append(staged, stagedFile{name: name, mime: mime, fileID: fileID, size: len(f.Data)})

Also worth adding a note to the InputFileStager interface doc that mime (and name) arrive pre-sanitized, so stager authors know the kernel has done that work.


Finding 2 — Stager-returned fileID injected into the prompt without sanitization (run/input_files.go:90)

fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID)

s.name and s.mime are sanitized. s.fileID is the raw return value from StageInputFile stored directly at line 77 with no sanitizeField call. The interface contract places no constraint on what a stager may return. If a stager implementation derives a file_id from the (still-unsanitized at call-time per Finding 1) MIME or any user-supplied value, or if the backing store can return unexpected data, a \n-containing file_id would inject a new line into the [ATTACHED FILES] block, breaking the expected line-per-file format and potentially adding agent instructions.

This is lower severity than Finding 1 because the stager is a trusted host component, but the interface makes no promise about output format, and the PR sanitizes every other string that touches the descriptor.

Fix:

staged = append(staged, stagedFile{name: name, mime: mime, fileID: sanitizeField(fileID), size: len(f.Data)})
🎯 Correctness — Minor issues

The "/" case is not in the test suite. The finding is confirmed: line 110 checks for "", ".", and ".." but not "/", and path.Base("/") is documented to return "/".


Minor issues


Finding: sanitizeName passes "/" through as a path separator

Location: run/input_files.go:110

Per Go's stdlib docs: "If the path consists entirely of slashes, Base returns '/'." So for a filename of "/", "///", or any string that reduces (after sanitizeField + ReplaceAll) to all forward-slashes:

sanitizeField("/")      → "/"
strings.ReplaceAll      → "/"
path.Base("/")          → "/"     // documented: root stays "/"
strings.TrimSpace("/")  → "/"
check: "" || "." || ".."          // "/" is none of these → NOT caught
return "/"                        // path separator returned as "safe base name"

The function's doc says it "strips every directory component" and lists the fallback cases as (empty, ".", "..")"/" is absent. The same applies to "///" (also returns "/" from path.Base).

Concrete impact: A synthetic InputFile{Name: "/", …} reaches StageInputFile with name="/" and appears in the descriptor as - / (…) → file_id: …. Most host store implementations will reject "/" as an invalid path component and the stager will return an error, causing the file to be skipped — so the blast radius is self-limiting. But the function makes a correctness promise it doesn't keep.

Fix — one-liner at line 110:

if base == "" || base == "." || base == ".." || base == "/" {

(Or, more defensively: || strings.ContainsAny(base, "/\\").)

🧹 Code cleanliness & maintainability — Minor issues

All three findings are confirmed by the actual code. Let me output the corrected review.


Minor issues

  • input_files_test.go:164-168sanitizeName called twice per loop iteration. The if-initializer at line 164 captures got scoped to the if block; line 168 makes a second independent call to get a got for the separator check. The two checks share no variable. Fix: hoist to got := sanitizeName(in) before the if, then reuse it for both assertions.

  • input_files.go:131-145seen map overloaded as membership flag and suffix cursor. seen[name]++ inside the loop body mutates the original-name counter to serve double-duty as "next suffix index to try." It's correct (on re-entry the cursor is already past skipped values), but the dual role is easy to misread — seen looks like a boolean set, not a cursor. A cleaner shape:

    for n := seen[name] + 1; ; n++ {
        candidate := fmt.Sprintf("%s-%d%s", base, n, ext)
        if seen[candidate] == 0 {
            seen[name] = n
            seen[candidate]++
            return candidate
        }
    }
    

    This makes the advance-and-skip intent explicit and keeps seen[name] as a "last suffix used" bookmark, consistent with the first-seen branch's seen[name] = 1 meaning "allocated once."

  • executor.go:321-325 — 5-line comment block for a 1-line call. The comment largely restates what stageInputFiles already expresses through its name and doc comment. Per the project's own convention (// Why: comments where rationale isn't obvious, one short line max), this could be cut to one line: // After model/toolbox build so the descriptor rides the first user turn.

Performance — No material issues found

No material issues found

I read run/input_files.go in full and traced the call site in run/executor.go:326.

Performance analysis:

  • Call frequency: stageInputFiles is called exactly once per Executor.Run invocation, not inside the agent loop. Any latency here is a one-time pre-run setup cost, not a recurring hit.

  • Sequential staging: StageInputFile is called one file at a time in a blocking loop. If the host implementation involves network I/O (e.g. an S3 upload), the wall-clock overhead multiplies with file count. However, the cap of maxInputFiles = 32 bounds this to at most 32 sequential ops. For the typical case (1–3 audio files), this is fine. Parallelising via goroutines would be the fix if a heavy host reports staging latency, but it is not a structural problem at this ceiling.

  • uniqueName loop: seen[name]++ is incremented inside the retry loop, which means the counter grows faster than the number of distinct names inserted. Under worst-case all-colliding input, the loop runs at most O(maxInputFiles) times total across all calls, which is ≤ 32. Not a concern.

  • Allocations: staged []stagedFile is not pre-sized with make(…, 0, len(files)), leaving 4–5 growth rounds for the maximum 32-entry case. Trivial and not worth flagging given the one-shot, bounded nature of this path.

  • String operations: sanitizeField (via strings.Map), strings.ReplaceAll, and fmt.Fprintf in the descriptor loop are all bounded by the file count cap and called once per file. No hot-path concern.

🧯 Error handling & edge cases — Minor issues

The finding is confirmed. At line 77, fileID is stored raw — name is sanitized via sanitizeNamesanitizeField, mime via sanitizeField, but fileID has no sanitization. At line 90 it's emitted verbatim into the prompt with %s. The asymmetry is real and the injection risk is genuine.


Minor issues

  • fileID inlined into the prompt descriptor without sanitization (run/input_files.go:77, 90)

    name is passed through sanitizeName and mime through sanitizeField before being stored in stagedFile, but fileID (the value returned by the StageInputFile seam) is stored and emitted raw:

    // line 77 — fileID not sanitized
    staged = append(staged, stagedFile{name: name, mime: sanitizeField(f.MimeType), fileID: fileID, size: len(f.Data)})
    // line 90 — emitted verbatim into the prompt
    fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID)
    

    If the InputFileStager implementation ever returns a fileID containing \n or \r (e.g. from a storage backend that returns structured blobs, or via a bug/injection in a custom adapter), the character drops to a new line in the [ATTACHED FILES] block and anything after the newline is injected into the prompt as a free-form instruction. This is exactly the class of attack the surrounding sanitizeField calls on name and mime are meant to prevent — there's no reason fileID escapes that treatment.

    Fix: apply sanitizeField(fileID) before appending:

    staged = append(staged, stagedFile{name: name, mime: sanitizeField(f.MimeType), fileID: sanitizeField(fileID), size: len(f.Data)})
    

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 4m 57s

<!-- gadfly-review:ollama:claude-code/sonnet --> ### 🪰 Gadfly review — `claude-code/sonnet` (claude-code) **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Blocking issues found</summary> Both findings check out against the actual code. Line 71 passes `f.MimeType` raw to `StageInputFile`, and line 77 confirms sanitization only happens when building the prompt descriptor — not before the stager call. Line 90 confirms `s.fileID` reaches the prompt with no `sanitizeField` call. **Blocking issues found** --- ### Finding 1 — Unsanitized MIME type crosses the stated trust boundary into the stager (`run/input_files.go:71`) The code declares `stageInputFiles` as "the trust boundary for the InputFiles seam" (lines 14–17) and correctly sanitizes `f.Name` before handing it to the stager (line 70). But it passes the raw, untrusted `f.MimeType` directly to `StageInputFile` at line 71, while the sanitized version only appears at line 77 — after the stager has already received the raw value: ```go // line 70 – name IS sanitized before the stager call name := uniqueName(sanitizeName(f.Name), seenNames) // line 71 – MimeType is NOT sanitized before the stager call ← bug fileID, err := e.cfg.Ports.InputFiles.StageInputFile(ctx, runID, agentID, name, f.MimeType, f.Data) // ... // line 77 – sanitized only for the prompt descriptor, too late for the stager staged = append(staged, stagedFile{..., mime: sanitizeField(f.MimeType), ...}) ``` **Impact:** Any host `InputFileStager` that uses the `mime` parameter in a SQL statement, a path, a shell command, a Content-Type header, or a log field receives raw user-controlled input. The `InputFileStager` interface contract in `ports.go` says nothing about whether `mime` is pre-sanitized, so implementers have no reason to sanitize it again. This directly contradicts the PR's own trust-boundary claim. A crafted MIME like `audio/mpeg'; DROP TABLE files; --` or one containing `\n` would flow through. **Fix — trivial one-liner:** ```go mime := sanitizeField(f.MimeType) fileID, err := e.cfg.Ports.InputFiles.StageInputFile(ctx, runID, agentID, name, mime, f.Data) if err != nil { ... } staged = append(staged, stagedFile{name: name, mime: mime, fileID: fileID, size: len(f.Data)}) ``` Also worth adding a note to the `InputFileStager` interface doc that `mime` (and `name`) arrive pre-sanitized, so stager authors know the kernel has done that work. --- ### Finding 2 — Stager-returned `fileID` injected into the prompt without sanitization (`run/input_files.go:90`) ```go fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID) ``` `s.name` and `s.mime` are sanitized. `s.fileID` is the raw return value from `StageInputFile` stored directly at line 77 with no `sanitizeField` call. The interface contract places no constraint on what a stager may return. If a stager implementation derives a file_id from the (still-unsanitized at call-time per Finding 1) MIME or any user-supplied value, or if the backing store can return unexpected data, a `\n`-containing file_id would inject a new line into the `[ATTACHED FILES]` block, breaking the expected line-per-file format and potentially adding agent instructions. This is lower severity than Finding 1 because the stager is a trusted host component, but the interface makes no promise about output format, and the PR sanitizes every other string that touches the descriptor. **Fix:** ```go staged = append(staged, stagedFile{name: name, mime: mime, fileID: sanitizeField(fileID), size: len(f.Data)}) ``` </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> The `"/"` case is not in the test suite. The finding is confirmed: line 110 checks for `""`, `"."`, and `".."` but not `"/"`, and `path.Base("/")` is documented to return `"/"`. --- **Minor issues** --- ### Finding: `sanitizeName` passes `"/"` through as a path separator **Location:** `run/input_files.go:110` Per Go's stdlib docs: *"If the path consists entirely of slashes, Base returns '/'."* So for a filename of `"/"`, `"///"`, or any string that reduces (after `sanitizeField` + `ReplaceAll`) to all forward-slashes: ``` sanitizeField("/") → "/" strings.ReplaceAll → "/" path.Base("/") → "/" // documented: root stays "/" strings.TrimSpace("/") → "/" check: "" || "." || ".." // "/" is none of these → NOT caught return "/" // path separator returned as "safe base name" ``` The function's doc says it "strips every directory component" and lists the fallback cases as `(empty, ".", "..")` — `"/"` is absent. The same applies to `"///"` (also returns `"/"` from `path.Base`). **Concrete impact:** A synthetic `InputFile{Name: "/", …}` reaches `StageInputFile` with `name="/"` and appears in the descriptor as `- / (…) → file_id: …`. Most host store implementations will reject `"/"` as an invalid path component and the stager will return an error, causing the file to be skipped — so the blast radius is self-limiting. But the function makes a correctness promise it doesn't keep. **Fix — one-liner** at line 110: ```go if base == "" || base == "." || base == ".." || base == "/" { ``` (Or, more defensively: `|| strings.ContainsAny(base, "/\\")`.) </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> All three findings are confirmed by the actual code. Let me output the corrected review. --- **Minor issues** - **`input_files_test.go:164-168` — `sanitizeName` called twice per loop iteration.** The `if`-initializer at line 164 captures `got` scoped to the `if` block; line 168 makes a second independent call to get a `got` for the separator check. The two checks share no variable. Fix: hoist to `got := sanitizeName(in)` before the `if`, then reuse it for both assertions. - **`input_files.go:131-145` — `seen` map overloaded as membership flag *and* suffix cursor.** `seen[name]++` inside the loop body mutates the original-name counter to serve double-duty as "next suffix index to try." It's correct (on re-entry the cursor is already past skipped values), but the dual role is easy to misread — `seen` looks like a boolean set, not a cursor. A cleaner shape: ```go for n := seen[name] + 1; ; n++ { candidate := fmt.Sprintf("%s-%d%s", base, n, ext) if seen[candidate] == 0 { seen[name] = n seen[candidate]++ return candidate } } ``` This makes the advance-and-skip intent explicit and keeps `seen[name]` as a "last suffix used" bookmark, consistent with the first-seen branch's `seen[name] = 1` meaning "allocated once." - **`executor.go:321-325` — 5-line comment block for a 1-line call.** The comment largely restates what `stageInputFiles` already expresses through its name and doc comment. Per the project's own convention (`// Why:` comments where rationale isn't obvious, one short line max), this could be cut to one line: `// After model/toolbox build so the descriptor rides the first user turn.` </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found** I read `run/input_files.go` in full and traced the call site in `run/executor.go:326`. Performance analysis: - **Call frequency**: `stageInputFiles` is called exactly once per `Executor.Run` invocation, not inside the agent loop. Any latency here is a one-time pre-run setup cost, not a recurring hit. - **Sequential staging**: `StageInputFile` is called one file at a time in a blocking loop. If the host implementation involves network I/O (e.g. an S3 upload), the wall-clock overhead multiplies with file count. However, the cap of `maxInputFiles = 32` bounds this to at most 32 sequential ops. For the typical case (1–3 audio files), this is fine. Parallelising via goroutines would be the fix if a heavy host reports staging latency, but it is not a structural problem at this ceiling. - **`uniqueName` loop**: `seen[name]++` is incremented inside the retry loop, which means the counter grows faster than the number of distinct names inserted. Under worst-case all-colliding input, the loop runs at most O(maxInputFiles) times total across all calls, which is ≤ 32. Not a concern. - **Allocations**: `staged []stagedFile` is not pre-sized with `make(…, 0, len(files))`, leaving 4–5 growth rounds for the maximum 32-entry case. Trivial and not worth flagging given the one-shot, bounded nature of this path. - **String operations**: `sanitizeField` (via `strings.Map`), `strings.ReplaceAll`, and `fmt.Fprintf` in the descriptor loop are all bounded by the file count cap and called once per file. No hot-path concern. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> The finding is confirmed. At line 77, `fileID` is stored raw — `name` is sanitized via `sanitizeName`→`sanitizeField`, `mime` via `sanitizeField`, but `fileID` has no sanitization. At line 90 it's emitted verbatim into the prompt with `%s`. The asymmetry is real and the injection risk is genuine. --- **Minor issues** - **`fileID` inlined into the prompt descriptor without sanitization** (`run/input_files.go:77, 90`) `name` is passed through `sanitizeName` and `mime` through `sanitizeField` before being stored in `stagedFile`, but `fileID` (the value returned by the `StageInputFile` seam) is stored and emitted raw: ```go // line 77 — fileID not sanitized staged = append(staged, stagedFile{name: name, mime: sanitizeField(f.MimeType), fileID: fileID, size: len(f.Data)}) // line 90 — emitted verbatim into the prompt fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID) ``` If the `InputFileStager` implementation ever returns a `fileID` containing `\n` or `\r` (e.g. from a storage backend that returns structured blobs, or via a bug/injection in a custom adapter), the character drops to a new line in the `[ATTACHED FILES]` block and anything after the newline is injected into the prompt as a free-form instruction. This is exactly the class of attack the surrounding `sanitizeField` calls on `name` and `mime` are meant to prevent — there's no reason `fileID` escapes that treatment. **Fix:** apply `sanitizeField(fileID)` before appending: ```go staged = append(staged, stagedFile{name: name, mime: sanitizeField(f.MimeType), fileID: sanitizeField(fileID), size: len(f.Data)}) ``` </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 4m 57s</sub>
steve added 1 commit 2026-06-28 17:29:46 +00:00
The full swarm (5-6 models) flagged that stageInputFiles passed the untrusted
attachment filename straight to StageInputFile and inlined it into the
[ATTACHED FILES]/`/workspace/<name>` descriptor with no sanitization — a path
the byte-cap already treats as a trust boundary. A name like ../../etc/passwd or
an absolute/drive path could escape the host store or the sandbox workspace, and
newlines in the name/mime could inject text into the prompt block.

- sanitizeName: strips control chars/newlines, then reduces to a base name
  (path.Base after backslash-normalization) so ../, nested dirs, and absolute /
  drive paths all collapse to their last element; "attachment" fallback for
  empty/"."/"..". Applied BEFORE staging AND inlining.
- sanitizeField: strips control chars from MimeType (also inlined verbatim).
- maxInputFiles (32) count cap — defense-in-depth vs a flood of tiny files,
  independent of the per-file byte cap.

Tests: sanitizeName table (traversal/absolute/backslash/control/fallback, +
no-separator invariant); traversal staged+described under the base name only;
oversize skip; count-cap truncation. Full suite green (-race).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve added 1 commit 2026-06-28 18:08:58 +00:00
fix(run): harden input-file staging per gadfly #18 validation pass
executus CI / test (pull_request) Successful in 48s
df4033f42e
Second-pass findings on the security fix:

- Mime sanitized ONCE and passed to BOTH StageInputFile and the descriptor (was
  passing raw f.MimeType to the host store while only the descriptor sanitized) —
  3 models.
- sanitizeField now also strips Unicode format chars (category Cf, incl. the bidi
  overrides U+202A–U+202E that can reorder how the descriptor renders); IsControl
  already covers \n\r\t so the explicit checks are dropped.
- fileID is sanitized before inlining + an empty file_id drops the file (defense
  vs a misbehaving stager).
- humanizeBytes clamps the prefix index so an absurd size (≥1024^6) can't index
  past "KMGTPE" and panic — a no-panic guarantee independent of the per-file cap.
- Docs sync: README Ports list gains InputFiles; tool.InputFile.Name doc now says
  the executor reduces an untrusted name to a safe base name (was claiming the
  field is already safe).

Tests: bidi/control stripping; mime sanitized in staged value + descriptor; empty
file_id drop; humanizeBytes no-panic across sizes up to 1<<62. Suite green (-race).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve merged commit add8f847a4 into main 2026-06-28 18:19:29 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/executus#18