Skip to content

fix(sandbox): keep document and seed values out of host shell code - #394

Merged
earakely-scale merged 7 commits into
mainfrom
edgararakelyan/quote-shell-values
Oct 29, 2026
Merged

earakely-scale merged 7 commits into
mainfrom
edgararakelyan/quote-shell-values

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Places where a value from a stored document became shell code on a sandbox host or in an env container are now quoted. On the local provider that host is the operator's machine. Seed placeholders are also rendered in one pass.

Quoted at the sink (sandbox.py, a2a_agent.py, run_docker_container.py, the unit-tests verifier):

  • Signed URLs in host scripts. The docker-load script (_load_docker_images) put the signed GET URL inside double quotes, where bash still expands $(...) and backticks. A Cloud Storage URL carries the bucket name of a stored DockerImageArtifact as written. upload_vm_file did the same with the signed PUT URL. Both are now shlex.quoted. Every provider's _load_tarballs override goes through this base function.
  • The agent container's run script (A2AAgent._run_container). Env var names, the container name and the image name went in bare, and an agent document's default_env_vars keys are free text. All three are now quoted. The value keeps its existing '...'\''...' form because sail_vm's model-key scrubber matches the key in that encoding. A normal key and value render exactly as before.
  • install_cli's chmod. The CLI's /opt/cli/<command_name>/<entrypoint> path, taken from the stored CliArtifact, went into docker exec ... chmod +x unquoted. It is now quoted. The host-mode branch already quoted it.
  • run_docker_container's failure messages. The missing-Dockerfile and readiness messages put dockerfile_path and container_name inside single quotes in a host script. Each message is now quoted whole.
  • The verifier's timeout_sec is quoted in its timeout call.

Seeds (task.py): substitute_seed_in_steps replaced each <key> key by key, so substituted text was re-scanned. A value of <b> was then rewritten by a later key b. It now renders all placeholders in one left-to-right pass.

A flaky unit test (tst/unit/store/aws/): dynamodb_step_journal_test.py::test_concurrent_completions_all_journal_and_an_undo_replays_the_survivors failed now and then in CI, on this PR and on other branches.

  • Cause: moto checks a conditional put_item's condition and stores the item as two steps, so two threads can both pass the same condition and one completion is lost. DynamoDB evaluates the condition atomically, so the store's code is correct.
  • dynamodb_document_store_test.py already ran its racing cases one botocore call at a time for exactly this reason. That serialization is now a conftest.py fixture, one_aws_call_at_a_time, which both suites use.
  • Measured with a probe that widens moto's gap between reading an item and storing it by 2 ms: the test failed 10 of 10 runs before the fix and passed 20 of 20 after. Both DynamoDB suites pass under the probe.

Not in this PR

  • Seeds bound for shell fields still substitute as written. A seed value with shell syntax in a verifier command, a verify_sandbox bash_cmd or a run_docker_container command still becomes part of that command, as before.
    • An earlier revision refused such values through a per-step shell_fields declaration. It was dropped so step authors don't have a new concept to learn.
    • VULN-3252's first fix criterion is therefore still open. A task that passes data into a command should use the variable the verifier exports, e.g. "$INPUT_URL", not a placeholder.
  • Write-time and signing checks. Validating tar_gz_object_url and CliArtifact names when they're written, refusing foreign buckets in GcsObjectStore, and refusing non-identifier env var names when an agent is read. Each sink now passes the value as data, so these aren't needed to stop the injections fixed here.
  • run_docker_container env var names. They're still pasted bare. They come from the task document, whose author already runs shell on that host by design.

Testing

  • New tests run each generated script under real bash with stub curl/gunzip/docker/seq commands that only record their arguments (tst/util/exec_scripts.run_with_stubs). The hostile value reaches the command as one literal argument, and no pwned file appears. Covered: the signed GET and PUT URLs, the agent env var name, the CLI chmod, and the Dockerfile and readiness messages. Each fails on main and passes here.
  • tst/unit/task/seed_substitution_test.py: placeholders take their values, including inside double quotes, and substituted text isn't re-scanned.
  • Pinned scripts in vm_sandbox_test.py and vm_file_transfer_test.py lose the double quotes around the signed URL. One AsyncMock sandbox now sets a real container_name.
  • Full unit suite on macOS: 11,757 passed and 66 skipped. Two tests failed, neither related to this change:
    • test_hermes_bridge.py::test_the_event_pipe_is_not_left_open_in_the_server reads /proc/self/fd, which only Linux has.
    • task_run_teardown_test.py::test_keep_still_tears_down_a_run_ctrl_c_cancels passed 3 of 3 times when run alone.
  • ruff check is clean.
  • On the previous head (82880f0e), every CI check passed, including CodeBuild's test_task_steps_e2e[modal_vm]. That test loads S3 image tarballs into Modal VMs through the re-quoted signed-URL download.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge within its stated scope.

Summary

Quotes stored document values before placing them in shell scripts and renders seed placeholders in one pass.

  • Signed object-store URLs reach sandbox commands as single arguments.
  • Agent startup and CLI installation pass stored names as shell arguments.
  • Docker task messages and verifier timeouts stay single shell arguments.
  • Seed values fill only the placeholders present before substitution starts.
  • DynamoDB mock tests share one lock for API calls.

Reviews (6) · Last reviewed commit: "test(store): run the DynamoDB step-journ..." · Reviewed by Greptile

earakely-scale and others added 2 commits October 29, 2026 12:03
…t scripts

A signed GET or PUT URL went into the VM host's script inside double quotes,
where bash still expands $(...) and backticks; a Cloud Storage URL carries the
bucket name of a stored artifact as written. The agent container's run script
pasted env var names, the container name and the image name bare, and
install_cli pasted the CLI's entrypoint path bare into its chmod. Each is now
shell-quoted; a value with no shell syntax renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l fields

substitute_seed_in_steps replaced each <key> raw and key by key, so a seed
row's text became part of a step's command before the step's own quoting
could see a placeholder, and substituted text was re-scanned for other keys.
Placeholders are now rendered in one left-to-right pass. A step declares the
fields it runs as shell text (TaskStep.shell_fields); a seed value bound for
one must be text the shell reads literally, or the run is refused before any
step starts. Every other field takes any value, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/task_step/task_steps/verifiers/verify_sandbox.py Outdated
Comment thread src/agent_env/task_step/task_steps/run_docker_container.py Outdated
earakely-scale and others added 3 commits October 29, 2026 13:03
…e refused seed in its error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…failure messages

verify_sandbox declared all of criteria as shell text, so a seed bound for a
probe_file_contains regex or a path was refused although it never reaches a
shell; shell_fields now names fields by their path in the stored step, and
verify_sandbox declares only criteria.bash_cmd. run_docker_container's
missing-Dockerfile and readiness messages put dockerfile_path and
container_name inside single quotes in a host script; each message is quoted
whole. The verifier's timeout_sec is quoted in its timeout call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tution

The per-step shell_fields declaration and the refusal of shell syntax in seeds
bound for them are removed: they added a concept every step author would have
to know. Seeds are still rendered in one left-to-right pass, so substituted
text is never re-scanned, and every quoting fix at the script sinks stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/task/task.py
…belongs in the env vars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

@greptileai review

moto checks a conditional write's condition and stores the item as two steps,
so two threads can both pass the same condition and one write is lost; DynamoDB
evaluates the condition atomically. The document store's tests already
serialized botocore calls for their racing cases; the step-journal suite's
concurrent-completions test did not, and lost a completion now and then in CI.
The serialization moves to the AWS tests' conftest as a fixture both use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 23f0b23 into main Oct 29, 2026
26 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/quote-shell-values branch October 29, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant