Skip to content

M3-12 Claude Code runner: answer attachments

No Date Ruling Reason
D-M3-12-1 2026-10-25 An ATTACH: line starts at column 0 with exactly ATTACH: followed by at least one space or tab; the path is the rest without outer white space. Lines inside a fenced code block (three or more backticks or tildes, up to three spaces of indent; closed by a fence of the same character and at least the same length, an unclosed block runs to the end) are text. Every ATTACH: line is removed with its line break, trailing blank lines are trimmed, an empty path is listed as (empty) with not_found; the line order is the attachment order. The live box excerpt drops ATTACH: lines too. Place: reply_attachments.split_attach_lines, livebox.py. 09 18.10 point 5; a reply can show the syntax in a code block; the excerpt would otherwise show local paths that the answer itself never shows.
D-M3-12-2 2026-10-25 runner.attach_allowed_roots names roots only (project, thread_inbox, runner_temp, no duplicates, default all three); any other value aborts the start naming key and file, never the value. project is the turn's resolved working directory, thread_inbox the run's own thread inbox, runner_temp <runner.state_dir>/tmp/<run_id> (mode 0700, created before the pre-check, the turn's TMPDIR, removed when the run ends). runner.projects_root and a directory above it are never roots, a project under the state directory is dropped, and every place under runner.state_dir other than the run's inbox and temporary directory is rejected after the root test. Places are compared by device and inode of the file's ancestors, never by their spelling, and an inbox or temporary directory that does not lie under the state (a turn replaced it with a link) is no root. The turn record carries the resolved project, so an adopting runner has the same roots. Place: reply_attachments.allowed_roots, check_candidate, environment.build_environment, app.precheck. An absolute path would be shared by every forum the runner serves (the runner side of SEC-UP-9); the inbox database holds invocation tokens and sessions/ prompts and transcripts. Red team on the first build: on a file system that ignores case, realpath keeps the announced spelling, so a text comparison let RUNNERSTATE/inbox.sqlite3 past the guard when the state lay inside a project.
D-M3-12-3 2026-10-25 runner.attach_denied_patterns is a list of fnmatch patterns without /, not empty, without control or bidi characters, that must contain every pattern of 18.10 point 3 (a missing one is named in the error). A file is denied when a pattern matches, after casefold on both sides, any component of its real path below its root or the last component of the announced path; the components of the root itself are not tested. Place: reply_attachments.check_denied_patterns, denied_match. The key exists to add a project's secret names; dropping a default would reopen the files 18.10 names; case-insensitive file systems would let .ENV through; a link named id_rsa is denied whatever it points to.
D-M3-12-4 2026-10-25 Check order of one file, first failure wins: realpath(strict) (a missing path, a loop or any OSError is not_found), strictly below a root and outside the forbidden places (outside_allowed_roots), denied patterns (denied_pattern), lstat of the real path: not regular (not_a_regular_file, a FIFO is never opened), more than one link (hard_link), zero bytes (empty_file). Staging opens with O_RDONLY, O_NOFOLLOW, O_NONBLOCK, O_CLOEXEC, compares device, inode, type and link count with the check and copies at most the checked size into <runner.state_dir>/outgoing/<run_id>/<answer>/<position> (0600, directories 0700), hashing while copying; any difference is changed_during_check for the whole set. The upload reads only the copy. Place: reply_attachments.check_candidate, reply_upload.stage_files. A hard link to an outside file passes every path test; a swap between check and read must not change what is sent, and the SHA-256 of createUpload describes the bytes sent.
D-M3-12-5 2026-10-25 The limits come from getInstance (message_max_attachments, file_max_upload_bytes), read once per answer with ATTACH: lines inside the run binding. The same real path announced twice counts once at its first line. A file rejected by its own checks keeps that reason; otherwise no limits give limits_unavailable, more distinct files than the limit give too_many, a file above the size too_large; any rejection gives every other file set_not_sent and stages nothing. Place: reply_attachments.check_set. InstanceLimits is the published place for limits that clients enforce before sending; the specific reason of a path rejection is more useful than a limits code.
D-M3-12-6 2026-10-25 Files upload one after the other, parts in order, every request through the SDK client inside the run binding; /status_line is uploading_attachments meanwhile and removed afterwards. createUpload carries name, size_bytes and lowercase hex sha256 (no declared type) and a stored UUIDv7 Idempotency-Key; a mode other than direct is upload_failed (log code upload_mode_unsupported); each part goes with the server's part headers and Content-Digest through the SDK's upload_part; completeUpload carries every etag and a second stored key. A final problem gives upload_refused with code and reason (also 503 unavailable with storage_full), retries exhausted or a transport error upload_failed (also another 5xx, 429 and request_in_progress after the SDK's retries); then no further file starts, the open session of the failing file is aborted (errors only logged as codes), completed files stay unattached for the cleanup of M3-08. 401 or 403 invocation_token_invalid with run_inactive stops the turn without answer and finish. Place: reply_upload.upload_set. 12 3.1 to 3.4, 3.6; M3-03 notes 5, 9, 11 to 13; presigned URLs point outside the server and the SDK client (12 3.3).
D-M3-12-7 2026-10-25 The notice: one blank line, Attachments not sent:, one line per announced file in position order - `<name>`: <code> with (<problem>, <reason>) (or (<problem>)) for upload_refused and not_attached; at most message_max_attachments named lines, then - and <n> more (all lines when the limits are unknown). <name> is the last announced path component without control, bidi and backtick characters, cut to 255 UTF-8 bytes (the default of file.name_max_bytes, display text only), (empty) when nothing is left. The codes are the closed set REASONS. Place: reply_attachments.build_notice. 09 18.10 point 5; the runner's file system layout is not for the readers of the channel.
D-M3-12-8 2026-10-25 An answer without ATTACH: lines goes the M2-34 path unchanged. With them: a complete set is sent once through the outbox under the answer's stored client_msg_id with attachment_file_ids (an empty text is allowed); a rejected or failed set, or a refusal of the attachments (403 feature_disabled, 403 forbidden, 409 conflict with file_not_attachable, 400 validation_failed whose every pointer starts with /attachment_file_ids), sends the answer without them, with the notice and a new client_msg_id stored before it. The SDK outbox gains attachment_file_ids, fails a reply with attachments at once on 403 and returns the refusal on the entry (OutboxEntry.problem, not stored). A failed set never fails the run. Place: reply_upload.send_answer_with_attachments, runturn.RunTurn._deliver_detailed, bothy_sdk.outbox. The same id with another body answers 422 idempotency_key_reuse (06 7); a 403 reply left pending would block the fallback in the thread's FIFO and could later post a second answer.
D-M3-12-9 2026-10-25 AttachmentSetRecord in <runner.state_dir>/sessions/<ref>/<run_id>.attach-<answer>.json (answer = position of the result among the run's planned answers, from 1), written atomically with mode 0600: per file the two keys (stored before their requests), file_id (right after its 201), the part plan, the confirmed etags and the state; the outcomes of a failed set; the client_msg_id of the answer and of a fallback. Never a token, a content or the answer text. An adopting runner resumes it: no file_id repeats createUpload with the stored key, a file_id reads getUpload and sends only the missing parts, completeUpload repeats with its key; a missing staged copy gives upload_failed. A stop cancels the request in flight (the upload is a task the stop cancels), aborts the open sessions, sends no message and the run finishes cancelled; an orderly stop of the runner keeps the record. Staged copies and records go once the answer is settled; the run's temporary and staging directories go at the run's end; the retention sweep removes a run's directory under outgoing/ and tmp/ as a whole once its newest entry is older than runner.stream_log_retention, never while the run is active (a run of this runner or one with a turn record), so a turn's unpacked old file is never deleted under it. The turn record's existing per-answer sent flag is the "last answer sent" of note 10 c. Place: reply_upload.py, runturn.py, statedir.sweep. 09 1 points 4 and 5, 15; R-AG-17: no file uploaded twice, the answer appears once.
D-M3-12-10 2026-10-25 capabilities.files_out is true in every heartbeat; the runner never reads operator_config for supports_files_out, the server's 403 feature_disabled at the send is handled by D-M3-12-8. Place: heartbeat.CAPABILITIES. The switch can change between a read and the send; 09 2.2 makes a capability effective only when both halves allow it.
D-M3-12-11 2026-10-25 Differences to the brief's names: the answer path is runturn.RunTurn._answer (M2-35 moved it from app.py); the hand-layer helper is bothy_sdk.uploads.upload_part (a module of its own next to client.py, because the generated uploadPart cannot carry the server's part headers); getInstance is generated under bothy_sdk.generated.api.admin; the FakeServer keeps upload state in FakeServer.uploads (_fake_uploads.py); Fake-Claude expands {{tmpdir}} and {{add_dir}} and takes meta_result_text, so an answer can name the run's scratch or inbox file without a test race; a second scenario module test_reply_attachments_crash.py holds the crash case (its runner is a process of its own on a real-time clock). D-W-100: merged names win; a held request and a killed process need the module's real-time world.