M3-12: Claude Code runner: answer attachments¶
Status: ready | Depends on: M2-35, M3-07 | Milestone: M3
Goal¶
After this ticket the Claude Code runner of M2-34 and M2-35 sends files with its answers, following 09 18.10 "File rules for reply attachments". Claude announces a file with a line ATTACH: <path> in its answer (the runner prompt asks for it). For every answer the runner collects these lines outside code blocks, removes them from the text, resolves every path with realpath and accepts it only when the real path lies under one of the roots of runner.attach_allowed_roots for this run (the run's project directory, the inbox of the run's thread, the run's own temporary directory), is a regular file without a second hard link, matches no pattern of runner.attach_denied_patterns (secret files such as .env*, *.pem, *.key, id_*, .netrc and the directories .ssh, .claude, .config, .aws, .azure, .gnupg), is not empty and stays within the limits the server publishes (InstanceLimits.message_max_attachments, InstanceLimits.file_max_upload_bytes). Only when every announced file passes does it upload them, each through the upload session of M3-03 (createUpload, uploadPart, completeUpload) from a private copy taken right after the check, inside the run's binding (07 "SR9"), and attach them to the answer with MessageCreate.attachment_file_ids (M3-07). If one file fails a check, an upload fails or the server refuses the attachments, no file is attached and the answer is sent without attachments, with a notice that names each announced file by its base name and the reason: all or none. The heartbeat reports capabilities.files_out = true. Why: a coding agent runs with the rights of the runner's operating system account, so a single symbolic link or an injected instruction in a repository must never turn an answer into a channel for SSH keys, cloud credentials or the runner's own state (T-DEV-01, T-AI-03, SEC-LOG-4); the server only checks rights, limits and content (12 11), so the path rules can only live in the runner. This is the plan row M3-12: a symbolic link escape and the secret patterns are rejected, and an answer carries all of its attachments or none.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket" (point 5: real model calls only behind an explicit switch); section 5 "Test strategy" (row "Test doubles": Fake-Claude, FakeServer); section 9 "M3 Platform services" (Goal, Scope "reply attachments of the runner", Exit criteria, Red team "a file across space boundaries in the context of another agent (SEC-UP-9)", Dependencies "from M2 for M3-07, M3-09, M3-12 and M3-19") and the rows M3-03, M3-04 and M3-07 (what exists on the server), M3-12 (this ticket, the contract), M3-08 (cleanup of never attached files, beside it); section 8 rows M2-29, M2-34 and M2-35 (what exists in the runner and the SDK), M2-14 (run binding), M2-20 (heartbeat capabilities); section 18 row "Dev forum with project binding and runner agent"; section 19 (P-09, P-10, P-11).- Dependencies: the header names M2-35 and M3-07. Implied through them and therefore not in the header: M2-34 (the runner core: configuration, environment, turn process, stream reader, answer path, turn record, Fake-Claude), M2-29 (SDK hand layer,
Client.bind_run, outbox, FakeServer), M2-23 (generated corebothy_sdk), M2-14 (headerInvocation-Token, the mode table of SR9), M2-12, M2-10, M2-03, M3-04 (scan pass), M3-03 (upload sessions,getFile), M3-01, M1-46 (getInstancewithInstanceLimits), M1-56, M1-58 and the rest of their closure. Not in the closure: M3-08 (cleanup anddeleteFile), M3-05 (download), M2-20 (server side of the heartbeat capabilities); each place below says how that is handled. docs/spec/09-agents.md: 1 (points 4 and 5); 2.2 "Features" (row "Sending files":supports_files_out,files_out); 2.3 "Grants of an agent" (uploadneeds a grant); 4.6 "Run context" (files are always data); 4.7 rows "Send reply" and "Send file"; 4.8 (capabilities); 5.3 (/status_line); 15 "Reliability"; 18 completely, above all 18.2 "Process contract" (rows "Environment", "Input", "Recording"), 18.10 "File rules for reply attachments" and 18.11 "Flow"; 21 rowsrunner.state_dir,runner.projects_root,runner.stream_log_retention,runner.attach_allowed_roots,runner.attach_denied_patternsand the list of existing values (message.max_attachments,file.max_upload_bytes).docs/spec/12-files.md: 1 "Goals and limits"; 2 "States of a file"; 3.1 "Creating an upload session"; 3.2 "Transferring parts (modedirect, default)"; 3.3 (whypresignedstays off); 3.4 "Completion and verification pass" step 3 (a completed upload may be attached at once); 3.6 "Error cases"; 6.2 "File names"; 10 "Attaching and agent invocation" points 1 to 3; 11 "Files from agents and from imports" (paragraph "Reply attachments from agents"); 12 row "Never attached"; 14.docs/spec/07-permissions.md: "SR9 Acting-for binding (confused deputy)" (every action that writes into a channel is decided on the intersection; exceptions only for answers in the run's thread and the other listed actions); 4.1 (reasonscapability_missing,acting_for_insufficient).docs/spec/05-actions.md(core contract, read only): 2.3 point 6; 4.2 rowinstance.read; 4.7 rowmessage.create("with attachments additionally+upload"); 4.8 rowfile.upload.docs/spec/19-security.md: SEC-UP-9, SEC-LOG-2, SEC-LOG-4, SEC-AI-1, SEC-AI-3, SEC-T10.docs/spec/06-api.md5 "Errors" and 7 "Idempotency".docs/spec/23-quality.md3 (row "Python project") and "Documentation in code".docs/api/openapi.yaml: operationsgetInstance,createUpload,uploadPart,getUpload,completeUpload,abortUpload,createMessage,patchRun,finishRun,sendHeartbeat; schemasInstanceInfo,InstanceLimits,UploadCreate,UploadSession,UploadPart,HeaderPair,UploadState,UploadPartResult,UploadComplete,File,MessageCreate,Heartbeat,Problem(x-problem-codes),FieldError; parametersIdempotencyKey,ChannelIdPath,FileIdPath,PartNumberPath;components.parameters.InvocationToken(M2-14).- ADRs 0022, 0023, 0026, 0053, 0054.
docs/DECISIONS.md: D-R-RUNNER, D-AG-22, D-FI-07, D-W-100, D-W-104 (R-DM-43: the copies onmessage_attachmentsthat M3-07 builds), section 10 (format of the rowsD-M3-12-<n>, and the rowsD-M2-34-<n>,D-M2-35-<n>,D-M3-03-<n>andD-M3-07-<n>that this ticket builds on).docs/security/threat-model.md: T-DEV-01, T-AI-01, T-AI-02, T-AI-03, T-AI-04, T-UP-01.docs/RISKS.md: RK-14. No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/operations/configuration.md:runner.attach_allowed_roots,runner.attach_denied_patterns,runner.state_dir,runner.projects_root,runner.stream_log_retention,sdk.retry_max_attempts,sdk.http_timeout,agent.run_update_min_interval; server keys read only for documentation:message.max_attachments,file.max_upload_bytes,file.upload_part_bytes,file.upload_session_ttl,file.unattached_ttl,sync.idempotency_key_ttl,message.max_content_bytes.docs/observability/signals.mdnames no runner metric; this ticket adds none.- Briefs (merged names win, D-W-100):
docs/briefs/M2-34-claude-code-runner-core.md(Design notes 2, 5, 7, 9 to 11, 13 to 17: configuration loader, run environment,--add-dirwith the thread inbox, prompt andprompts/runner_prompt.md,attachments.pyfor incoming files, turn process, the answer path with the outbox'sclient_msg_id, the heartbeat withfiles_out: false, the run binding of every request, Fake-Claude, the FakeServer routes),M2-35-claude-code-runner-paths.md(Design notes 5, 6 and 12: one answer perresult, the stop wins, adoption after a crash with the turn record),M2-29-python-sdk.md(Design notes 8 to 15, and "Out of scope": file upload goes throughClient.callwith the generated operations),M2-14-acting-for.md(Design note 5:file.uploadis not an exception, so it is bound; "Out of scope": attachments of an agent reply),M3-03-upload-sessions.md(Design notes 5, 9, 11 to 13 and 19: check order ofcreateUpload,sha256required as lowercase hex,Content-Digest, idempotent parts, answers per session state,abortUploadof a completed session is409),M3-07-attachments-invocation.md(Design notes 4, 5 and 15: attach rules, one409 conflictwithreasonfile_not_attachable, empty content with attachments, the switchsupports_files_outwith403 feature_disabled),M1-46-instance-settings.md(getInstancefor any active principal including agent tokens). - Code on the base branch:
plugins/claude-code-runner(M2-34 and M2-35: packageclaude_code_runner, configuration,environment.py,attachments.py,prompt.py,prompts/runner_prompt.md,turn.py, the answer path,heartbeat.py, the turn record, Fake-Claude and fixtures, tests, README, CHANGELOG),sdk/python(hand layer, generated core,testingFakeServer). Read the merged state withgit logbefore you start.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Note on rule 3: as in M2-34 (D-M2-34-1), the runner is a program; its public Python symbols carry docstrings checked by ruff D with convention google, and its operator interface (the two configuration keys of this ticket) is documented in its README and in 09 21, from which make config-reference generates the reference.
Product requirements implemented here:
- Claude announces an answer attachment with a line
ATTACH: <path>(runner prompt); the runner removes every such line from the answer before it is sent (09 18.10 introduction and point 5). - Every path is resolved with
realpath(symbolic links followed, then checked) and accepted only under a root ofrunner.attach_allowed_roots: the run's project directory, the inbox of the run's thread, the run's temporary directory; never elsewhere, also not through a symbolic link or a hard link (09 18.10 points 1 and 2, 18.1). - A path that matches a pattern of
runner.attach_denied_patternsis rejected; the patterns of 09 18.10 point 3 always apply (09 18.10 point 3). - Count (
message.max_attachments) and size (file.max_upload_bytes) are checked for the whole set before the first upload; then the runner uploads all files or none, and the answer carries all of them or none (09 18.10 point 4, plan row M3-12). - Rejected files are named in the answer with their reason (09 18.10 point 5); the notice never shows a directory or a real path.
- Uploads use the upload session of M3-03 in mode
direct(12 3.1 to 3.4, R-FI-01, R-FI-02) and attach withMessageCreate.attachment_file_idsto the runner's own answer in the run's thread (12 10, 12 11, 09 4.7 row "Send file");uploaded_byis the agent, with the same scan, limits and quota as people (12 11). - Every request of an answer goes through the SDK client inside the run binding (header
Invocation-Token, M2-34 Design note 13, 07 "SR9"), so a file reaches a channel only when the agent and the triggering person may both upload there (SEC-AI-3, T-AI-02). - The heartbeat reports
capabilities.files_out = true(09 2.2, 4.8); the owner's switchsupports_files_outis enforced by the server (403 feature_disabled, M3-07) and answered by the runner without attachments. - File contents are never read into a prompt, a log line, a patch or a request other than the upload parts; logs carry no file name, path or content (SEC-LOG-4, SEC-T10, 12 14).
- No answer, upload or process starts after a stop request; a crash of the runner during an upload neither loses the answer nor uploads a file twice (09 1 points 4 and 5, 15, R-AG-17).
Scope¶
All paths under plugins/claude-code-runner/ unless they start with docs/ or sdk/. <pkg> is the runner's import package that M2-34 created (expected claude_code_runner). Names of M2-34, M2-35 and M2-29 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
src/<pkg>/reply_attachments.py, functions without network access (allowed_rootsandcheck_candidateread the file system withrealpathandstat, the others are pure):split_attach_lines(text) -> tuple[str, list[str]](Design note 2),AllowedRootsandallowed_roots(config, project_dir, thread_inbox, run_temp)(note 3),DeniedPatternsanddenied_match(...)(note 4),Candidate,Rejectionwith the closed set of reason codes of note 8,check_candidate(...)(notes 3 to 5),check_set(candidates, limits) -> SetVerdict(note 6) andbuild_notice(outcomes, limit) -> str(note 8).src/<pkg>/reply_upload.py:stage_files(...)(note 5 c),AttachmentSetRecord(the persisted record of note 10), the asyncupload_set(...)(note 7) and the asyncsend_answer_with_attachments(...)used by the answer path (note 9).- Fake-Claude fixtures in M2-34's fixture directory (expected
tests/fake_claude/fixtures/) for the scenarios of the Tests section, each with M2-34'sfixture_metaline (originconstructed). - Tests:
tests/unit/test_reply_attachments.py,tests/unit/test_reply_upload.py,tests/scenarios/test_reply_attachments.py. Follow M2-34's test layout if it differs.
Change:
- M2-34's configuration loader: the keys
runner.attach_allowed_rootsandrunner.attach_denied_patternswith the forms and load checks of Design notes 3 and 4 (only add them if missing; adjust them to these forms if present);config.example.tomlwith both keys at their defaults. - M2-34's run environment (
environment.py):TMPDIRof a turn is the run's temporary directory of Design note 3 c instead of the runner's ownTMPDIR; the key set of the allow list does not change. - M2-34's turn runner: create the run's temporary directory before the turn starts and remove it when the run ends (note 3 c); its retention sweep also covers
outgoing/andtmp/underrunner.state_dir(note 11). - M2-34's answer path (and M2-35's one answer per
result): after theTITLE:line is removed, every answer goes through Design note 9; an answer whose text is empty but that carries attachments is sent (M3-07 note 5 a); the notice of note 8 is appended where note 9 says. - M2-34's prompt (
prompts/runner_prompt.md, English): one paragraph onATTACH:lines (note 12). - M2-34's heartbeat:
capabilities.files_outbecomestrue(note 13). - M2-35's adoption after a crash and the stop: an answer with a pending attachment set is resumed or ended by note 10; the stop of M2-35 note 6 cancels an upload in progress (note 10 d).
sdk/python, only where this brief needs it, each with a test insdk/python/tests/and a line insdk/python/CHANGELOG.md: (a) if the generated core cannot send a binary body with headers foruploadPartthroughClient.call, a hand-layer functionupload_part(client, file_id, part_number, data, headers)in the module that holds the other call helpers; (b) if the outbox'ssend_messagecannot carryattachment_file_ids, the member; (c) FakeServer routesgetInstance,createUpload,uploadPart,getUpload,completeUpload,abortUploadandcreateMessagewithattachment_file_ids, a capture of every body and header, and injectable answers for the problems of Tests (Design note 14).docs/spec/09-agents.md: 18.2 row "Environment" (TMPDIR), 18.10 and 21 rowsrunner.attach_allowed_rootsandrunner.attach_denied_patterns(Design note 15); thenmake config-referenceregeneratesdocs/operations/configuration.md.docs/spec/12-files.md11 paragraph "Reply attachments from agents": one sentence (note 15).README.mdof the runner: status, answer attachments, the two keys, the roots, the patterns, the notice codes, the residuals of note 16.CHANGELOG.mdunder "Unreleased".plugins/README.mdonly if it lists the runner's status or capabilities.docs/DECISIONS.mdsection 10: rowsD-M3-12-1and following (Design note 17).
This ticket adds no configuration key (both keys exist in 09 21), no operation, no event, no metric, no migration, no server code and no new top-level make target.
Out of scope¶
- The server side of uploads, scan and attaching (M3-03, M3-04, M3-07), the owner's switch
supports_files_out(M3-07), the evaluation of the heartbeat capabilityfiles_out(M2-20), downloads (M3-05). - Deleting uploaded files that ended up unattached:
deleteFileand the jobfiles.cleanup_pendingare M3-08, which is not in this ticket's closure; such files stay readable only by the agent (M3-03: uploader only before attaching) and are removed afterfile.unattached_ttlonce M3-08 is merged (plan gap reported). - Incoming attachments, their download into the thread inbox and their place in the prompt (M2-34 Design note 10); voice transcription (
file.transcript.submit); components and cards. - The Hermes adapter (files of the Hermes adapter are a plan gap of M2-33); the TypeScript SDK; the conformance suite (M2-31).
- Uploads in mode
presigned(12 3.3 keeps it off). - Dev environments and the claim
dev_environment_id(V2-03). - Real logins, real sessions and model calls of any kind.
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-12-runner-attachments; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(claude-code-runner): ...,docs(spec): ...,test(sdk-python): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a package. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0 (P-10); the FakeServer of M2-29 runs in the test process. This ticket needs no database and no container. - Stop only processes you started, by PID or by the process group of a turn the test started. Temporary directories only via pytest
tmp_pathortempfile.mkdtemp, removed at the end; symbolic links, hard links and FIFOs of the tests are created only inside them. - Never read or print secrets. The "secret" files of the tests hold canary values written by the test; no test reads a real file under a home directory,
/etcor any path outside its temporary directories. No network calls except package downloads for pinned tools and Python packages. - Never start a
claudeCLI installed on this machine: every test uses Fake-Claude throughrunner.claude_bin(M2-34). - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M2-34, M2-35, M2-29, M3-03 and M3-07: their names win over the expected names here (modules, the answer path, the turn record, the outbox,
Client.call,Client.bind_run, the FakeServer helpers); list each difference. Never rebuild in the runner what the SDK provides (retries, idempotency of the outbox, the run binding), and never define types the generated core already has (ADR 0026).
Design notes¶
-
Start check. Run
make checkon the fresh worktree; red givesVerdict: BLOCKED base branch red. Confirm: M3-03 is merged (createUpload,uploadPart,completeUploadandgetUploadare enabled inservices/server/api/operations.yaml, andUploadCreate.sha256is required indocs/api/openapi.yaml); M3-07 is merged (createMessageno longer answersnot_enabledforattachment_file_ids); M2-35 is merged (the runner has M2-35's stop and adoption). OtherwiseVerdict: BLOCKED dependency <ticket> missing. -
Where this goes. M2-34 built the answer path (after
result: stripTITLE:, onecreateMessagewithclient_msg_idfrom the outbox,run_id, then the title andfinishRun), the run environment, the thread inbox<runner.state_dir>/files/<thread_id>/for incoming files (attachments.py) and the turn record; M2-35 made everyresultwithout error post one answer, added the stop and the adoption after a crash. This ticket inserts one step into that path for every answer: collect and removeATTACH:lines, check the set, upload it, attach it or fall back. Every decision lives inreply_attachments.py(table tests); onlyallowed_rootsandcheck_candidateread the file system there, and staging and network I/O live inreply_upload.py, which the answer path calls. Where the merged code already does one of these things, extend it instead of building a second path, and list it. -
ATTACH:lines (09 18.10 point 5; Ruling for docs/DECISIONS.md).split_attach_lines(text)runs on the answer text after M2-34 removed theTITLE:line.a. An
ATTACH:line is a line that starts at column 0 with exactlyATTACH:(case sensitive) followed by at least one space; the path is the rest of the line with leading and trailing white space removed. Lines inside a fenced code block (a line starting with three or more backticks or tildes opens it, the next fence of the same character and at least the same length closes it; an unclosed block runs to the end) are text, neverATTACH:lines, so an answer can show the syntax.b. Every
ATTACH:line is removed from the text, together with the line break that ends it; then trailing blank lines are trimmed. The order of the remaining lines is unchanged. AnATTACH:line with an empty path is removed and listed as rejected withnot_foundand the name(empty).c. The paths keep the order of their lines; that order becomes the order of
attachment_file_idsand so thepositionof each attachment (M3-07 note 4 d). -
Allowed roots (09 18.10 point 2, 21 row
runner.attach_allowed_roots; Ruling for docs/DECISIONS.md).a. Form: a TOML list of strings, each one of the names
project,thread_inbox,runner_temp, without duplicates; default["project", "thread_inbox", "runner_temp"]. Any other value, an absolute path included, aborts the start with a message that names the key and the file, never the value (M2-34 note 2). Reason: each name stands for a directory that belongs to exactly this run's forum, thread or run, while an absolute path would be shared by every forum the runner serves, so one forum's answer could carry another forum's files (the reasoning of SEC-UP-9 for the runner's side). An operator can narrow the list, never widen it.b. Per run the names resolve to real paths:
projectis the resolved working directory of the turn (M2-34 note 6, neverrunner.projects_rootitself, so a project never reaches another project);thread_inboxisrealpath(<runner.state_dir>/files/<thread_id>)of the run's thread only (M2-34 note 9; another thread's inbox is never a root);runner_tempis the run's temporary directory of c. A root that does not exist is left out.c. The run's temporary directory is
<runner.state_dir>/tmp/<run_id>/, created with mode0700before the turn starts and passed asTMPDIRof the turn (09 18.2 row "Environment" namesTMPDIRon the allow list; this fixes its value), so files a turn writes as scratch output can be attached. It is removed when the run ends (after its last answer); leftovers after a crash fall to the retention sweep (note 11). The command line of 18.2 does not change.d. Never a root, whatever the configuration says:
runner.state_diritself and every directory under it other than the run'sfiles/<thread_id>andtmp/<run_id>(the inbox database holds invocation tokens,sessions/holds prompts and transcripts), andrunner.projects_rootitself.check_candidateenforces this after the root test, so a misbuilt root cannot widen it. -
Denied patterns (09 18.10 point 3, 21 row
runner.attach_denied_patterns; Ruling for docs/DECISIONS.md).a. Form: a TOML list of strings, each a shell-style pattern (
fnmatchsyntax) without/, not empty, without control or bidi characters. The default is the list of 18.10 point 3:.env*,*.pem,*.key,id_*,.netrc,.ssh,.claude,.config,.aws,.azure,.gnupg. A configured list must contain every default pattern; a list without one of them aborts the start, naming the key and the missing pattern (the patterns are not secret). Reason: the key exists to add secret file names of a project, and a list that drops a default would reopen exactly the files 18.10 names.b. Matching: a candidate is denied when any pattern matches, compared case-insensitively (both sides
casefold; case-insensitive file systems would otherwise let.ENVthrough), (1) any component of its real path below the root it lies in (so a pattern for a directory such as.sshdenies every file inside it, at any depth), or (2) the last component of the path as announced (so a symbolic link namedid_rsais denied even when it points to a harmless file). The components of the root itself are not tested, so a projects root under a directory named.configdoes not deny every file. -
Checks of one candidate (
check_candidate; 09 18.10 points 1 to 3; Ruling for docs/DECISIONS.md). In this order, the first failure gives the reason:a. Resolve: a relative path is joined with the turn's working directory (the resolved project),
~is not expanded;os.path.realpath(path, strict=True); a missing path, a loop or anyOSErrorgivesnot_found. The real path must be a strict descendant of a root of note 3 (os.path.commonpath), elseoutside_allowed_roots; a path inside a forbidden place of note 3 d givesoutside_allowed_rootstoo. Denied patterns (note 4) givedenied_pattern.b. Type:
os.statof the real path; not a regular file (directory, FIFO, socket, device) givesnot_a_regular_file;st_nlinkabove 1 giveshard_link, because a hard link to a file outside the roots passes every path test; size 0 givesempty_file(UploadCreate.size_byteshasminimum: 1).c. Open and stage (in
stage_files, after the whole set passed note 6):os.open(real_path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC),fstatof the descriptor; device, inode and type must equal thestatof b, elsechanged_during_check(a file swapped for a link or another file between check and open). Copy from that descriptor into<runner.state_dir>/outgoing/<run_id>/<answer>/<position>(mode0600, directories0700), computing SHA-256 and the byte count while copying; more bytes thanInstanceLimits.file_max_upload_bytes, or a count different from the size of b, giveschanged_during_check. Every upload reads only the staged copy, so a turn or another process that changes the file during the upload cannot change what is sent, and the SHA-256 ofcreateUploaddescribes the bytes sent.d. The same real path announced twice counts once, at the position and with the announced name of its first line.
-
The set: all or none (09 18.10 point 4, plan row M3-12; Ruling for docs/DECISIONS.md).
check_set(candidates, limits)decides on the whole answer before any upload:a. Limits come from
getInstance(InstanceInfo.limits:message_max_attachments,file_max_upload_bytes; actioninstance.read, allowed for agent tokens, M1-46), read once per answer that has at least oneATTACH:line, inside the run binding. A failed read gives every filelimits_unavailable. Reason: these are server values (03 9,message.max_attachments,file.max_upload_bytes) that the runner cannot configure, andInstanceLimitsis the published place for "limits that clients enforce before sending".b. A candidate above
file_max_upload_bytesgivestoo_large; more distinct candidates thanmessage_max_attachmentsgives every candidatetoo_many.c. If any candidate is rejected (notes 2 b, 5 and 6 b), the set is rejected: no file is staged or uploaded, every rejected candidate keeps its reason and every other candidate gets
set_not_sent. Only a set without any rejection goes to staging (note 5 c), where achanged_during_checkagain rejects the whole set before the firstcreateUpload. -
Upload (
upload_set; 12 3.1 to 3.4, M3-03 notes 5, 9, 11 and 12; Ruling for docs/DECISIONS.md). Files one after the other in position order, parts one after the other; every request through the SDK client insideClient.bind_runof the run (headerInvocation-Token); the SDK's retry rules (sdk.retry_max_attempts,sdk.http_timeout,Retry-Afterof429 rate_limited) apply. While a set uploads, the run document carriesreplace /status_linewithuploading_attachments(09 5.3), removed afterwards, so the box shows why the run still works. Per file:a.
createUploadon the run's channel (channel_idof the invocation) with a fresh UUIDv7 asIdempotency-Key, stored in the record of note 10 before the request; bodyname= the announced last path component (the server cleans it, 12 6.2),size_bytesandsha256(lowercase hex, M3-03D-M3-03-1) of the staged copy; nodeclared_mime_type, because the server detects the type itself (12 6.1). Thefile_idof the201goes into the record.b.
UploadSession.modeother thandirect: the file fails withupload_failed(log codeupload_mode_unsupported), because presigned URLs point outside the server and outside the SDK client (12 3.3).c. For each entry of
UploadSession.parts:uploadPartwithfile_idandpart_number, the headers ofUploadPart.headers, andContent-Digest: sha-256=:<base64 of the part's SHA-256>:(RFC 9530, 12 3.2); the part is the slice of the staged copy at(part_number - 1) * part_size_byteswith the length ofpart_size_bytes(the last part the rest). Theetagof each200goes into the record.d.
completeUploadwithUploadComplete.parts(everypart_numberwith itsetag) and a second fresh UUIDv7 asIdempotency-Key, stored before the request. The file is attachable from the200on, before the scan (12 3.4 step 3); the scan never delays the answer.e. Failure: a final problem on any of these requests (
403 forbidden, for example withreasoncapability_missingoracting_for_insufficient;403 channel_read_only;404 not_found;409 conflictwithreasonquota_exceeded,part_conflictorupload_incomplete;410 gone;413 payload_too_large;415 unsupported_media_typewithreasonfile_type_denied;400 validation_failed;503 unavailablewithreasonstorage_full) gives this fileupload_refusedwith the problem code andreason; retries exhausted or a transport error giveupload_failed. Either way the set fails: no further file is uploaded, every completed file getsset_not_sent, the failing file keeps its reason, files not yet started getset_not_sent. The open session of the failing file (and only an open one) is aborted withabortUpload; an error of the abort is logged as a code and changes nothing. Completed uploads stay unattached (abortUploadanswers409 invalid_state_transitionfor them, M3-03 note 13 c); M3-08's cleanup removes them (Out of scope).f.
401 unauthenticated, or403 invocation_token_invalidwithreasonrun_inactive(M2-14 note 4 c), means the run ended on the server: stop the turn as M2-35 note 6 e does, send no answer and do not finish. -
Notice and reason codes (09 18.10 point 5; Ruling for docs/DECISIONS.md). The closed set of codes:
not_found,outside_allowed_roots,denied_pattern,not_a_regular_file,hard_link,empty_file,changed_during_check,too_large,too_many,limits_unavailable,upload_refused,upload_failed,not_attached,set_not_sent.build_noticeappends to the answer text, after one blank line, the fixed English lineAttachments not sent:and one line per announced file in position order:- `<name>`: <code>and, forupload_refusedandnot_attached,(<problem code>, <reason>)when the problem had areason.<name>is the announced last path component with control and bidi characters removed, backticks removed and cut to 255 UTF-8 bytes, never inside a character (the default offile.name_max_bytesin 12; the notice is display text, so the runner does not read the instance value); never a directory, a real path or a home directory (the runner's file system layout is not for the readers of the channel). At mostmessage_max_attachmentslines name files; further files are summed up in one line- and <n> more. The notice stands only in answers whose set failed; a successful set adds nothing. If the notice makes the text exceedmessage.max_content_bytes, M2-34's400 validation_failedrule ends the runadapter_erroras for any long answer. -
Sending the answer (
send_answer_with_attachments; M2-34 note 14, M2-35 note 5, M3-07 notes 4, 5 and 15; Ruling for docs/DECISIONS.md).a. No
ATTACH:line: M2-34's path unchanged (nogetInstance, no record).b. Set uploaded: one
createMessagein the run's thread through the outbox withclient_msg_idstored before sending,content_mdthe text of note 2 (may be empty, M3-07 note 5 a),run_idandattachment_file_idsin position order. Answers in the run's thread are an exception of SR9, somessage.createuses the agent's rights; its+uploadis the agent's own (05 4.7, M2-14 note 5).c. The server refuses the attachments:
403 feature_disabled(the owner's switchsupports_files_out, M3-07 note 15),403 forbidden(for examplecapability_missing),409 conflictwithreasonfile_not_attachable, or400 validation_failedwhose everyerrors[].pointerstarts with/attachment_file_ids: send the answer again withoutattachment_file_ids, with the notice (every filenot_attachedwith the problem code and reason) and a newclient_msg_idstored before sending, because the same id with another body answers422 idempotency_key_reuse(06 7). These refusals store nothing, so the answer appears once. Every other answer ofcreateMessagefollows M2-34 (retry by the outbox,400 validation_failedendsadapter_error).d. Set rejected or failed (notes 6 and 7): send the answer without
attachment_file_idswith the notice.e. Empty text and no attachment left: M2-34's rule "an empty reply sends nothing" applies only when no
ATTACH:line was present; with a failed set the notice is the text and is sent.f. Then M2-34's title and finish steps as before. A failed set never fails the run: the run finishes as its turn did.
-
Persistence, crash and stop (09 15, 18.2 "Detachment", M2-35 notes 6 and 12; Ruling for docs/DECISIONS.md).
a.
AttachmentSetRecord, written atomically (temporary file and rename, mode0600) to<runner.state_dir>/sessions/<session ref>/<run_id>.attach-<answer>.json(answer = the index of theresultin the run, from 1): the outcome of the checks, the staged paths with size and SHA-256, per file the twoIdempotency-Keyvalues,file_id, the confirmed parts with etags and the state (staged,created,completed,failed), theclient_msg_idof the answer and of a fallback, and the end state of the set. Persist before the request that depends on it: a key before its request, afile_idright after its201. The record never holds a token, file contents or the answer text (the outbox of M2-29 holds the message body as it already does).b. Adoption (M2-35 note 12 d): an adopted run whose stream file has a
resultafter the stored offset goes through the answer path as usual; when a record of that answer exists, the new runner resumes it: a file withoutfile_idrepeatscreateUploadwith the stored key (the server answers the stored201withinsync.idempotency_key_ttl, 06 7), a file withfile_idreadsgetUploadand sends only the parts not confirmed,completeUploadrepeats with its stored key; an answer whoseclient_msg_idis stored is resent by the outbox with that id. So no file is uploaded twice and the answer appears once. A staged copy that is missing gives the setupload_failed.c. Turn records of M2-35 gain the index of the last answer sent, so an answer is never handled twice.
d. Stop (M2-35 note 6): a stop request cancels the set: the request in flight is cancelled, no further request starts, open sessions of the set are aborted with
abortUpload(best effort, codes only in the log), nocreateMessageis sent (M2-35: no answer after a stop), and the run finishescancelledwith the reason of the request once the turn's process group is gone. The stop wins also between the last upload andcreateMessage. -
Files and retention. Staged copies and records are deleted after their answer was sent (attached or fallback) or the run was stopped; the run's temporary directory when the run ends. M2-34's retention sweep (at start and after every turn) also deletes entries under
outgoing/andtmp/older thanrunner.stream_log_retention. Every file this ticket writes has mode0600, every directory0700; the staging directory lies underrunner.state_dir, never in the project or the run's temporary directory, so a turn cannot read or change it through its working directory. -
Runner prompt (
prompts/runner_prompt.md, English, appended to M2-34's text): "To send files with your answer, add one line per file at the very start of a line, outside code blocks:ATTACH: <path>. Only files inside the project directory, the thread's file folder or$TMPDIRcan be sent. Files whose names mark them as secrets (for example.envfiles, keys, certificates,.ssh,.awsor.configfolders) are never sent; do not attach any other file that holds a secret. The ATTACH lines are removed from your answer; if any file cannot be sent, no file is sent and the answer lists why." The prompt is guidance only; every rule is enforced by the checks above, never by the model. -
Heartbeat and capability (09 2.2, 4.8; M2-34 note 15).
capabilities.files_outistruefrom this ticket on, in every heartbeat. The owner's switch is enforced by the server (M3-07 note 15) and handled by note 9 c; the runner does not readoperator_config. Reason: the switch can change between a read and the send, and 09 2.2 makes a capability effective only when both halves allow it, so the send is the one place that decides. -
FakeServer and Fake-Claude (09 16.1). Extend the FakeServer of M2-29 where it lacks a part (Scope):
getInstancewith configurableInstanceLimits;createUpload,uploadPart,getUpload,completeUploadandabortUploadwith the semantics of M3-03 notes 5, 9 and 11 to 13 (a smallpart_size_bytesper test, digest and length check of each part, idempotent parts, replay of a stored answer for a repeatedIdempotency-Key,abortUploadof a completed session answers409 invalid_state_transition), storing the received bytes per file so a test compares them;createMessagewithattachment_file_idsthat checks each id is a completed upload of the agent for that channel and not attached yet (else409 conflictwithreasonfile_not_attachable); injectable problems per operation (403 forbiddenwithreasonacting_for_insufficientorcapability_missing,403 feature_disabled,409 conflictwithreasonquota_exceeded,413 payload_too_large,415 unsupported_media_type, a transport error, "stored, then 502"); a capture of every request with headers. Each route gets a test that validates its answers againstdocs/api/openapi.yaml. Fake-Claude needs no new capability: its fixtures carry the answers withATTACH:lines, and the scenario writes the files, links and FIFOs into the test's project, inbox and run temporary directory before the turn (or through Fake-Claude's pause on a marker file, M2-35 note 13, for the swap and crash cases). -
Specification text (rule 10; new text in English like the chapter, as the owner rules for all repository content (D-W-94); nothing in 00, 03, 04 or 05 changes):
docs/spec/09-agents.md18.10: after the five points, English paragraphs with the line syntax and the code block rule (note 2), the three root names and what they resolve to, the run's temporary directory asTMPDIRand the places that are never roots (note 3), the pattern form, the mandatory defaults and the matching rule (note 4), the regular file, hard link, empty file and staging rules (note 5), the limits fromgetInstanceand the all-or-none rule (note 6), uploads inside the run binding with SR9 and the fallback without attachments (notes 7 and 9), the closed set of notice codes (note 8), persistence and the stop (note 10).- 18.2 row "Environment": an English sentence: "
TMPDIRis the run's temporary directory<runner.state_dir>/tmp/<run_id>(18.10)." - 21: row
runner.attach_allowed_rootswith Default["project", "thread_inbox", "runner_temp"]and the meaning in English ("roots for answer attachments, by name only (18.10)"); rowrunner.attach_denied_patternskeeps Default "list in 18.10" and gets the English meaning "denied patterns for answer attachments; must contain the list of 18.10". Thenmake config-referenceandmake config-doc-check. docs/spec/12-files.md11 paragraph "Reply attachments from agents": one English sentence: "Inside a run the upload is bound to the run (07 SR9): an agent uploads only where the triggering person may upload too; the answer in the run's thread is an exception of SR9."- No change to
docs/api/openapi.yaml.
-
Residuals to state in the README and in 18.10 (no code): the checks protect the paths the runner sends, not what a turn with
hostcan read or copy on its own (T-DEV-01, SR2); a secret file under a name no pattern knows is sent if it lies under a root, so operators add their project's names torunner.attach_denied_patterns; content checks are the server's (scan, 12 5). -
Rulings for
docs/DECISIONS.md(section 10, English, one row each with date, decision, reason and place):D-M3-12-1syntax ofATTACH:lines and the code block rule (note 2);D-M3-12-2roots by name only, the run's temporary directory asTMPDIR, the places never allowed (note 3);D-M3-12-3pattern form, mandatory defaults, case-insensitive matching of real components and the announced name (note 4);D-M3-12-4the check order,O_NOFOLLOWand the inode comparison, hard links rejected, the staged copy (note 5);D-M3-12-5limits fromgetInstanceand the all-or-none set (note 6);D-M3-12-6sequential upload inside the run binding, thestatus_linevalue,presignedrefused, failure handling and aborted open sessions (note 7);D-M3-12-7the notice and its closed codes, base names only (note 8);D-M3-12-8sending, the fallback with a newclient_msg_id, a failed set never fails the run (note 9);D-M3-12-9the persisted record, resumption after a crash and the stop (note 10);D-M3-12-10files_out = truewithout readingoperator_config(note 13).
Tests (write them first)¶
Test names follow the plan row and the reason codes, so pytest -k symlink and pytest -k all_or_none find them. Configuration values in tests come from a test configuration file; production code never hard-codes a window or a limit.
Unit (tests/unit/):
test_reply_attachments.py:test_split_attach_lines: a lineATTACH: report.mdis removed and returnsreport.md; leading spaces beforeATTACH:,attach:andATTACH:report.md(no space) stay text; trailing white space is trimmed from the path; lines inside a fence of three backticks, of four backticks closed by four, of tildes and inside an unclosed fence stay text; an empty path is removed and reported asnot_foundwith the name(empty); order of paths equals order of lines; the text after removal keeps all other lines in order and loses trailing blank lines; run after theTITLE:removal, both lines are gone.test_allowed_roots_config: the default list; a subset; an unknown name, a duplicate and an absolute path abort with a message that names the key and the file and not the value.test_check_candidate_roots: in a temporary layout withprojects_root/website(the run's project),projects_root/other,state_dir/files/<thread>,state_dir/files/<other thread>,state_dir/tmp/<run>,state_dir/tmp/<other run>andstate_dir/sessions/...: files in the project, the run's inbox and the run's temporary directory pass; files inother, the other thread's inbox, the other run's temporary directory,sessions/, the inbox database andprojects_rootitself giveoutside_allowed_roots;website/../other/xgivesoutside_allowed_roots; a missing file givesnot_found; a path with~is not expanded.test_symlink_escape_rejected(plan row): a link in the project to a canary file in a temporary "home" directory outside every root; a link with an absolute target in the test's temporary tree outside every root; a directory linkdocs -> <outside>with the announceddocs/a.txt; a link to the other project; a link tostate_dir/sessions/<ref>/<run>.input.jsonl; a chain of two links ending outside; a link loop: each givesoutside_allowed_roots(the loopnot_found); a link inside the project to another file inside the project passes with the real path of its target.test_secret_patterns_rejected(plan row): every default pattern with an example (.env,.env.local,server.pem,tls.key,id_ed25519,.netrc,.ssh/config,.claude/settings.json,.config/tool/hosts.yml,.aws/credentials,.azure/profile.json,.gnupg/pubring.kbx), case variants.ENVandServer.PEM, a file two levels below.ssh, a linknotes.txt -> .env(real name denied), a linkid_rsa -> README.md(announced name denied): each givesdenied_pattern; a projects root inside a directory named.configstill allowsREADME.md.test_denied_patterns_config: a configured list missing.sshaborts the start naming the key and.ssh; an added pattern*.sqlitedeniesdata.sqlite; a pattern with/or a control character aborts.test_regular_file_and_hard_link: a directory, a FIFO and a Unix socket givenot_a_regular_filewithout blocking; a hard link (second name of a file outside the roots) giveshard_link; a 0-byte file givesempty_file.test_check_set_all_or_none(plan row): three candidates with onedenied_patterngive that one its reason and the two othersset_not_sent; more distinct candidates thanmessage_max_attachmentsgive alltoo_many; one abovefile_max_upload_bytesgivestoo_largeand the restset_not_sent; the same real path announced twice counts once at its first position; limits unavailable give alllimits_unavailable; a set without rejection passes.test_build_notice: base name only for an announced/srv/projects/website/out/report.mdand for../x/y.txt; control, bidi and backtick characters removed; problem code and reason forupload_refusedandnot_attached; at mostmessage_max_attachmentsnamed lines and one- and <n> moreline; no notice for a successful set.
test_reply_upload.py:test_stage_files_detects_swap: the file is replaced by a link to an outside file aftercheck_candidateand before staging:changed_during_check, nothing staged; the file grows beyond its checked size:changed_during_check.test_stage_files_copy: the staged copy has mode0600underoutgoing/<run_id>/<answer>/, the same bytes, the SHA-256 and size thatcreateUploadwill carry; changing the original afterwards does not change the copy.test_part_slices: part boundaries for sizes equal to, one below and one above a multiple ofpart_size_bytes;Content-Digestof each part equals RFC 9530sha-256=:<base64>:.test_record_roundtrip: the record is written atomically with mode0600, holds keys and file ids, and never a token, file content or answer text.
Scenarios (tests/scenarios/test_reply_attachments.py): the real runner code with real Fake-Claude processes and the FakeServer of M2-29 on 127.0.0.1 and port 0; world of M2-34: space Engineering with the forum Projekt-A (binding website, a Git working tree under the test's projects root) served by the agent Runner in mode organization with tool class scoped; Alex triggers in a thread of Projekt-A; Robin is the owner; Sam has no trigger. The FakeServer answers getInstance with message_max_attachments 3 and a small file_max_upload_bytes, and gives UploadSession.part_size_bytes small enough that one file needs three parts. Every scenario asserts that no request body, log record, patch or file outside outgoing/ and the FakeServer's byte store contains a canary file content, except the uploadPart bodies of an announced file that is accepted.
test_reply_with_attachments_uploaded_and_attached: the answer text "Done." plusATTACH: dist/report.md(project) andATTACH: <TMPDIR>/summary.txt(the run's temporary directory, written by the scenario as the turn'sTMPDIR): onegetInstance; per file onecreateUpload(name,size_bytes, lowercase hexsha256, anIdempotency-Key, nodeclared_mime_type), threeuploadPartin order with validContent-Digest, onecompleteUploadwith all etags and its ownIdempotency-Key; the FakeServer's stored bytes equal the files; onecreateMessagewithcontent_md"Done." (noATTACH:line),run_idandattachment_file_idsin announced order; every request of the answer carriesInvocation-Tokenwith the run's token;/status_linewasuploading_attachmentsduring the upload and removed after; the turn's environment hadTMPDIRequal to<runner.state_dir>/tmp/<run_id>, which is gone after the run;finishRuncompleted.test_symlink_escape_rejected_nothing_uploaded(plan row): the answer announcesnotes.md(fine) andlink.txt -> <outside canary file>: nocreateUpload; onecreateMessagewithoutattachment_file_idswhose text ends with the notice listingnotes.mdwithset_not_sentandlink.txtwithoutside_allowed_roots; the canary content and the outside path appear in no request; the run finishescompleted.test_secret_patterns_rejected_nothing_uploaded(plan row): the answer announcesREADME.mdand.env, and in a second runconfig/../.ssh/id_ed25519: nocreateUpload; the notices namedenied_pattern; nothing of the canary secret appears in any request.test_all_or_none_upload_failure_attaches_none(plan row): three files; the FakeServer answers the secondcreateUploadwith409 conflictandreasonquota_exceeded: no upload of the third file, nocreateMessagewithattachment_file_ids; the answer without attachments names the first and third fileset_not_sentand the secondupload_refused (conflict, quota_exceeded). Variant: the third part of the second file fails with a transport error after the retries: its open session is aborted withabortUpload, the first (completed) file is not aborted, the notice saysupload_failed.test_all_or_none_limits_from_instance: four announced files againstmessage_max_attachments3 givetoo_manyfor all and no upload; one file abovefile_max_upload_bytesgivestoo_large;getInstanceanswering503giveslimits_unavailable.test_acting_for_insufficient_refuses_upload(SR9): the FakeServer answerscreateUploadwith403 forbiddenandreasonacting_for_insufficient(Alex lacksupload): no file attached, noticeupload_refused (forbidden, acting_for_insufficient), the run finishescompleted.test_message_refused_falls_back_without_attachments: after a complete upload,createMessageanswers403 feature_disabled, in a second case409 conflictwithreasonfile_not_attachable: exactly one morecreateMessagewith a newclient_msg_id, noattachment_file_ids, the notice withnot_attachedand the problem; the thread shows one answer; the run finishescompleted. A502after storing on the firstcreateMessageis retried by the outbox with the same id and gives one message with the attachments.test_attach_only_answer_sends_empty_text: the answer is onlyATTACH: dist/report.md: onecreateMessagewith emptycontent_mdand one attachment. Without anyATTACH:line an empty answer still sends nothing (M2-34).test_attach_lines_in_code_block_stay_text: an answer that showsATTACH: example.txtinside a fenced block: nogetInstance, no upload, the block is unchanged in the message.test_thread_inbox_file_reattached: a file of the triggering message, stored by M2-34 in the thread inbox, announced with its inbox path: uploaded and attached as a new file; the inbox path of another thread isoutside_allowed_roots.test_followup_results_each_answer_own_attachments(M2-35 note 5): tworesults in one turn, the first with oneATTACH:line, the second with another: two messages, each with its own attachment; recordsattach-1andattach-2.test_stop_during_upload_sends_nothing: Fake-Claude has finished; the FakeServer holds the seconduploadPartuntil the test sendsrun.cancel_requested: no further request of the set, the open session is aborted, nocreateMessage,finishRuncancelledwith the request's reason.test_crash_during_upload_resumes_once(M2-35 adoption): the runner is killed with SIGKILL after the first file'screateUploadanswered and one part was confirmed; a new runner on the samerunner.state_diradopts the turn, repeatscreateUploadwith the stored key (the FakeServer replays the stored answer), readsgetUpload, sends only the missing parts, completes and sends onecreateMessage: the FakeServer has exactly one upload session per announced file and one message.test_run_ended_on_server_during_upload:uploadPartanswers403 invocation_token_invalidwithreasonrun_inactive: the turn's group is stopped, nocreateMessage, nofinishRun(M2-35 note 6 e).test_heartbeat_reports_files_out: every heartbeat body hascapabilities.files_out = true.test_no_names_paths_or_content_in_logs(SEC-LOG-4, SEC-T10 for this path): canary file name, canary directory name and canary content of an announced file appear in no log record of the runner, no patch and no finish body; the name appears only in thecreateUploadbody and the notice.test_state_files_private_and_retention: staging copies and records have mode0600, directories0700; they are gone after the answer; entries underoutgoing/andtmp/older thanrunner.stream_log_retentionare deleted by the next sweep.
SDK (sdk/python/tests/): one test per FakeServer route added (answers valid against docs/api/openapi.yaml, digest and length checks of parts, replay of a stored Idempotency-Key, 409 invalid_state_transition for aborting a completed session, 409 conflict with reason file_not_attachable for a foreign or attached file), and a test for upload_part and the outbox member if they were added.
Red team focus of the milestone (PLAN 9 "Red team": T-UP-01, T-AI-03; SEC-UP-9): test_symlink_escape_rejected, test_secret_patterns_rejected, test_regular_file_and_hard_link, test_stage_files_detects_swap, test_all_or_none_upload_failure_attaches_none and test_acting_for_insufficient_refuses_upload; name them in the red team request, together with: escaping the roots through links, hard links, case, .. or a swap between check and read; reading the runner's own state; another thread's or project's files; a notice that leaks a path.
Commands that must pass, from the repository root:
cd plugins/claude-code-runner && uv run --locked pytestmake fmt-check,make lint,make typecheck,make test,make coverage,make licenses,make osvmake api-lint,make api-checks,make codegen-check,make api-clients(whensdk/pythonchanged)make config-reference(no diff afterwards),make config-doc-check,make docs,make linkcheckmake check
Acceptance criteria¶
- An answer with
ATTACH:lines is sent without them; the announced files are uploaded withcreateUpload,uploadPartandcompleteUploadfrom private copies inside the run binding and attached withattachment_file_idsin announced order. - A symbolic link escape is rejected: no path outside the run's project, the run's thread inbox and the run's temporary directory is ever uploaded, also not through a link, a linked directory,
.., a hard link or a swap between check and read (test_symlink_escape_rejected,test_symlink_escape_rejected_nothing_uploaded). - The secret patterns of 09 18.10 are rejected, case-insensitively, also as directory components and as the announced name of a link; a configuration cannot drop them (
test_secret_patterns_rejected,test_secret_patterns_rejected_nothing_uploaded). - All or none: one rejected file, a count or size above the server's limits, a failed upload or a refused attachment leaves the answer without any attachment and with a notice naming each file by base name and reason (
test_check_set_all_or_none,test_all_or_none_upload_failure_attaches_none,test_message_refused_falls_back_without_attachments). - A refusal under SR9 (
403 forbiddenwithreasonacting_for_insufficient) and the owner's switch (403 feature_disabled) are handled by the fallback; the run still finishes as its turn did. - A stop sends nothing more; a crash during an upload is resumed by the new runner without a second upload session or a second message.
- The heartbeat reports
files_out: true; no file name, path or content appears in a log line, patch or finish body. - 09 18.2, 18.10 and 21, 12 11, the configuration reference, the runner README and CHANGELOG and the rows
D-M3-12-1toD-M3-12-10are written. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- README of
plugins/claude-code-runnerupdated in English (status, answer attachments, the two keys and their forms, the roots, the patterns, the notice codes, the residuals of note 16); README ofsdk/pythonif its FakeServer or hand layer changed. - Rulings of Design note 17 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 09 and 12 of Design note 15 and the regenerated configuration reference made in the same change. plugins/claude-code-runner/CHANGELOG.mdentry under 'Unreleased' (andsdk/python/CHANGELOG.mdif changed).- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.