Skip to content

Linux view: reproduce a grant's link chain, so a venv python starts - #99

Open
mufaddalq wants to merge 7 commits into
strands-agents:mainfrom
mufaddalq:fix/30-python-grants
Open

mufaddalq wants to merge 7 commits into
strands-agents:mainfrom
mufaddalq:fix/30-python-grants

Conversation

@mufaddalq

Copy link
Copy Markdown

Description

Bug fix, no interface change. No box.toml key, CLI flag, record field, telemetry name, or pub use changes, and RECORD_VERSION stays 21. The change is internal to the Linux namespace view in containment.

On Linux, a virtualenv python as [agent] command (or [tool.<name>] command) does not start. decisions.md already says Box execs a program through its link, so that CPython finds pyvenv.cfg, and grants the link's target. The Linux view did not keep that promise for a chain of more than one link. A venv's bin/python -> python3 -> /usr/local/bin/python3 -> python3.13 failed in two ways:

  • When the venv is granted: the venv bind brings in its own two links, but the middle hop /usr/local/bin/python3 is in no grant and is not in the view. exec fails with ENOENT.
  • When it is not: the view bound the spelling as a second file. The program then runs as that spelling, so /proc/self/exe and the loader's $ORIGIN name the venv's bin. The python.org build carries RUNPATH $ORIGIN/../lib, so libpython3.13.so.1.0 is not found. Box's closure walk had expanded $ORIGIN against the identity, so the walk and the loader disagreed.

The view now reproduces each link that a grant's lookup traverses, with the host's text, and binds only the identity. The kernel then walks the same chain in the view that it walks on the host.

Load-bearing decisions:

  1. A link node is a host link whose parent is canonical. /bin/sh on merged-/usr lstat's as a link because /usr/bin/sh is one, but /bin is itself a link, so a copy of it in a real /bin would dangle. Such a spelling, reached through a linked ancestor directory, keeps its bind as today. This is the residual, recorded in decisions.md. A command never reaches it, because canonical_route makes its directory canonical first.
  2. A link that a directory bind brings in is left to that bind. Only links outside every bind are created, in the staging tmpfs.
  3. An entry spelled beneath a link is planned at the host's resolution of it. The first version refused such entries. That refused every tool on merged-/usr: a tool's /lib read root is a link there, and the loader's program header names /lib/ld-linux-*.so.1. A mountpoint is never made through a link, because that could lead into a bind and onto the host. Refusals are re-spelled too, so a denial stays in force.
  4. W^X holds across the merge. When a re-spelled entry meets an entry at its resolution, a dependency (loader or library) is dropped, a grant replaces a dependency, and two grants keep their union. A dependency never adds exec to a writable grant.
  5. The link text in the view is the text the grant judged. A link whose text no longer leads to a node that the grant recorded is refused at plan time. A link on the read-only root cannot be changed by the workload. A link under the writable /tmp can be replaced, but only after the trampoline's exec, and the workload can already make links there to anything in its view.

Out of scope (follow-ups):

For maintainers: as an outside contributor, my CI and Auto Strands Review runs wait for approval (action_required / manual-approval). Could someone approve them? Thanks.

Related Issues

Part A of #30 (the issue stays open for part B).
Related: #21 / #40 (x86_64)

Type of Change

Bug fix

Testing

  • Fork CI (run): every job green, including Unit Tests on ubuntu-latest (x86_64) and macos-latest, both Containment / Deterministic legs (ubuntu-24.04-arm, macos-latest), Lint, and Verdict. The first run (38056671462) failed one x86_64 unit test, a_read_root_on_a_loader_directory_is_bound_executable_and_read_only. It expected the loader directory bound at its spelling, but on ubuntu-latest that spelling is the link /lib64 -> usr/lib64, which the view now reproduces as a link. 05f3592 checks the bind at the identity instead. I reproduced the failure on aarch64 by adding a /lib64 link, and the suite passes with and without it.
  • Tests that fail on main and pass here:
    • contains_exec_target_linux.rs::a_program_through_a_link_chain_runs_as_its_identity: on main, /proc/self/exe is the spelling.
    • contains_exec_target_linux.rs::a_link_chain_leaving_a_bound_tree_execs: on main, exec fails with ENOENT.
    • box_direct_filesystem.rs::a_venv_shaped_command_runs_as_its_identity: on main, the run exits 4.
    • The chain in each test is a copy of readlink, so CI needs no Python.
  • Plan tests (view.rs): each hop planned as a link, hops inside a bound tree, a shared hop planned once, a hop under a read-only empty directory, entries and refusals beneath a link, the real /lib on merged-/usr, the four W^X merge cases, a retargeted link, and the linked-ancestor residual.
  • Linux aarch64 locally (finch, rust:1-bookworm, privileged):
    • strands-box-containment: all tests pass.
    • strands-box: 885 pass. The 38 failures are in suites that fail the same way on main in that environment, measured by swapping main's view.rs back in: runtime_mcp_start 4, native_egress_mcp 5, runtime_mcp_policy_staging 25, box_shell signalled binary 1, run::hardening 2, box_direct_filesystem rename EBUSY 1.
  • Manual acceptance in python:3.13-slim (aarch64), which CI cannot show:
    • A python.org venv and a Debian venv python start as [agent] command, with and without a read grant on the venv. With the grant, sys.prefix is the venv.
    • numpy 2.5.3, pandas 3.0.6, and requests import from the venv, with hand-written exec grants on site-packages and lib-dynload (part B removes the need for these).
  • I ran cargo fmt --all and the just clippy gate.
  • Not run locally: the other workspace crates, because none depends on containment and the full build did not fit the local disk (CI runs them). Also not run: Kata.

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • My changes generate no new warnings

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

🤖 Generated with Claude Code

muffadalq and others added 7 commits October 10, 2026 00:02
## Problem

On Linux a virtualenv `python` as `command` does not start (strands-agents#30). Its link chain leaves the venv:
`bin/python -> python3 -> /usr/local/bin/python3 -> python3.13`. The view bound the spelling as a
second file and left out every other hop. When the venv is granted, exec fails with ENOENT at the
missing hop. When it is not, the program runs as the spelling, so the loader expands
`$ORIGIN/../lib` against the venv and does not find libpython. Box's own closure walk expanded
`$ORIGIN` against the identity, so the walk and the loader disagreed.

## Solution

The Linux view now plans each link that a grant's lookup traverses, and that has a canonical
parent, as a link with the host's text (`MountKind::Symlink`, `MountOrigin::Link`). It binds only
the identity. A link that a directory bind brings in is left to the bind. A link under a read-only
empty directory is refused. A link whose text no longer leads to a node the grant judged is refused.
An entry spelled beneath a link, such as the loader `/lib/ld-linux-*.so.1` where `/lib` is a link,
is planned at the host's resolution of it, so no mountpoint is made through a link. A dependency
never adds access to an entry there, which keeps W^X. A spelling through a linked ancestor directory
keeps its bind; decisions.md records this residual. No interface moves; RECORD_VERSION stays 21.

## Tests

- view.rs: a_two_link_chain_plans_each_unenclosed_hop_as_a_link, a_hop_inside_a_bound_tree_plans_nothing,
  two_grants_sharing_a_hop_plan_it_once, a_hop_under_a_read_only_fresh_mount_is_refused,
  an_entry_beneath_a_planned_link_is_planned_at_its_resolution,
  a_refusal_beneath_a_planned_link_refuses_its_resolution,
  a_linked_loader_directory_and_an_interpreter_spelled_through_it_plan,
  a_respelled_library_does_not_make_a_writable_grant_executable,
  a_respelled_writable_grant_replaces_a_library_without_gaining_exec,
  a_respelled_grant_meeting_a_later_grant_is_one_entry,
  a_respelled_entry_meeting_a_planned_link_is_refused,
  a_link_retargeted_after_its_grant_was_built_is_refused,
  a_spelling_through_a_linked_ancestor_keeps_its_bind, and the changed
  a_grant_through_a_symlink_is_reachable_under_both_names.
- contains_exec_target_linux.rs: a_program_through_a_link_chain_runs_as_its_identity (fails on main:
  /proc/self/exe is the spelling) and a_link_chain_leaving_a_bound_tree_execs (fails on main: ENOENT).
- box_direct_filesystem.rs: a_venv_shaped_command_runs_as_its_identity (fails on main: exit 4).
- Ran on Linux aarch64 (finch, rust:1-bookworm): strands-box-containment all pass; strands-box
  885 pass, and the 38 failures are the same suites that fail the same way on main in that
  environment (runtime_mcp_start, native_egress_mcp, runtime_mcp_policy_staging, box_shell
  signalled binary, hardening, box_direct_filesystem rename EBUSY). Manual: a python.org and a
  Debian venv `python` start in python:3.13-slim, and numpy and pandas import from the venv with
  hand-written exec grants.
- Not run: x86_64, the other workspace crates (none depends on containment), and Kata. numpy still
  needs exec grants on site-packages; that is part B of strands-agents#30.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ay be a link

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant