Skip to content

feat(box): run a declared python tool for python3 and python when monty = false - #444

Draft
hanaduy wants to merge 18 commits into
mainfrom
feature/python-monty-opt-in
Draft

hanaduy wants to merge 18 commits into
mainfrom
feature/python-monty-opt-in

Conversation

@hanaduy

@hanaduy hanaduy commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

Problem

A bare python3 or python in a box always runs Monty, Box's built-in Python subset (#371). An agent that needs -m pytest, a package, or a module Monty lacks gets a refusal. A [tool.python3] table the operator declares is accepted, but a bare python3 never runs it, and nothing tells the operator.

Running real Python through a writable venv has a second problem. Measured on main (61ade09, macOS, Homebrew Python 3.14.7): the agent wrote a .pth file into an in-project venv's site-packages through the Shell. Python ran it inside the tool, and again after the box ended, with the operator's full rights.

What changes

  • monty = false. A new top-level box.toml key, default true. With Monty off, [tool.python3] or [tool.python] answers to both python3 and python, and nothing falls back to Monty. With no Python table, both names exit 126 with a reason, before any policy decision. The table name decides, not the file name. Both tables must name the same command as written, with no fixed arguments.
  • The install is read-only. Box reads the tool's install from its files and never runs it. It recognizes a venv, a standard prefix, Homebrew and Debian, and refuses anything else. It refuses every write grant that reaches the folders Python loads code from, and every Shell or Monty write into them while the box runs. Folders that don't exist yet are covered, such as the user site. So are the base prefix's lib/, link targets outside the install, editable-install sources, and folders named by .pth lines. A file with another hard link refuses startup.
  • Box adds the grants Python needs. These are reads on the install, a venv's pyvenv.cfg, the certificate bundle, and the libraries the install's extension modules load, plus, on macOS, metadata on the folder that holds the command, which Python resolves at startup. When the table has no filesystem list, Box also grants read and write on the workspace. An operator's own list replaces only the workspace grant. The startup notice marks each added grant.
  • Libraries and commands named through links. A module usually names its library through links, such as Homebrew's /opt/homebrew/opt/openssl@3/lib/libssl.3.dylib. Box's own lists refuse a link, so each library entry is on the real file and carries the requested name, (loaded as …) in the startup notice, and Box grants the file to containment under that name. Containment's traversal walk now records each linked folder a chain passes through, such as /opt/homebrew/opt/python@3.14 in a Homebrew venv's python3, so macOS gets a metadata rule on it and the floor judges it like the other nodes.
  • stdin. A program the Shell starts can't receive stdin yet. When the call's stdin carries data, Python gets a write-only descriptor, so reading it fails with EBADF instead of returning nothing, and Box adds one stderr line on failure. A call that would read its program from stdin (python3 -, or a bare python3 in a pipe) exits 126. Only a replaced name waits for the stdin answer. Other spawns are unchanged.
  • Messages. Startup says what a bare python3 runs, on every box. It names a Python table that Monty leaves unused, and with Monty off it states the costs. Monty's refusals for -m, a program from stdin, and a failed import say how to run real Python. A failed write into the read-only install adds one stderr line.
  • A macOS timeout test. It checks that [limits.shell] timeout kills a tool's process group, as the Linux tests already do.

Decisions this depends on

  • Monty stays the default. Real Python takes three steps: monty = false, a [tool.python3] or [tool.python] table, and a shell:spawn permit on the resolved file.
  • A refusal exits 126 with a reason and never runs Monty instead.
  • Nothing in the box may write the Python install. A venv inside the project is therefore refused when the project is writable.
  • [limits.shell] timeout bounds real Python too. There is no Python-only setting.
  • Versioned names such as python3.14, and a venv's bin/ scripts such as pytest, behave as before.

docs/design/decisions.md rewrites #python-in-the-shell-is-monty and #a-bare-python-name-always-means-monty, keeping both anchors. It adds #box-refuses-writes-into-the-python-install and #box-grants-a-python-tool-its-install-and-the-workspace.

Interfaces that move (approval requested for each)

Surface Change
box.toml keys New top-level monty (bool, default true)
Stored records monty = false is stored at record version 34; an older build refuses the box
Aliases With Monty off, python3 and python reach the Python tool instead of Monty
Startup disclosure One new line on every box; more lines with Monty off or an unused Python table; added grants are marked, and an added library shows (loaded as <name>)
Containment rules A grant whose link chain passes through a linked folder now gets file-read-metadata on that folder link (macOS), and the floor and deny checks judge it. A grant spelled as a real path, which is every grant an operator writes, gets no new rule
Exit statuses and messages New 126 reasons; Monty's -m exits 2 with its own text; Monty's hint texts; two new stderr lines
strands-shell public items ReplacedCommands, RefusedCommands, SpawnStdin, HostSpawn.stdin and HostSpawn.name (new fields on a public struct), Kernel::replaces_command, Kernel::refused_command, ShellBuilder::replaced_commands, ShellBuilder::refused_commands, two VfsKernel fields, commands::REAL_PYTHON_HINT. crates/shell/UPSTREAM.md records each.

The broker protocol, CLI, policy actions and telemetry names don't change. A refused name raises no policy decision, so it leaves no decision record.

Known gaps

  • Libraries that extension modules load on Linux. Box now adds a read grant for each library an extension module loads, such as Homebrew's opt/openssl@3/lib for ssl. On Linux that grant is mounted noexec, so a library outside the system folders (conda's lib/, for example) may still fail to load. Box also stops after 5000 extension modules without saying so.
  • One library file named two ways. When two modules name the same file through different links, Box grants it under the first name, because containment takes one read grant per file. A load through the other name's links can fail.
  • stdin forwarding into started programs. The write-only stdin is a stopgap until it exists.
  • refactor(shell): migrate Box to upstream Strands Shell #414 moves the vendored Shell to upstream. The two Shell hooks here would move with it.
  • feat(box, containment): add an experimental deny_write list to [agent.filesystem] #429 adds deny_write for the agent. It addresses the same in-project venv case from the agent's side.

Related Issues

Part of #371. Stacked on #417; merge after it. Follows #397. Takes the place of the design in #389.

Type of Change

New feature

Testing

  • cargo build --workspace --locked --all-targets --all-features: ok.
  • cargo test -p strands-box --lib --all-features: 1050+ passed.
  • cargo test -p strands-shell: 2048 passed.
  • cargo test -p strands-box --all-features --test four_inputs: 5 passed.
  • cargo fmt --check and cargo clippy --workspace --all-targets --all-features with the workflow's flags: clean. Cargo.lock is unchanged.
  • Full suite, dispatched by hand, because CI doesn't run on a pull request into a feature branch:
    • Both OSes and lint, on 60d7400: all passed. https://github.com/strands-agents/box/actions/runs/43506431252 The macOS job ran the new in-box Homebrew test on the runner's Homebrew Python 3.15.2, with the venv outside the home.
    • ci.yml on 60d7400, which adds Containment (both OSes) and Verdict, both of which skip a pull request into a feature branch: Containment, Verdict, lint and macOS unit tests passed. https://github.com/strands-agents/box/actions/runs/43506436419 Its Linux unit job failed once in tests/mcp_alias_fallback.rs with Text file busy while starting a test program. That file isn't touched here, the same test passed in the full suite's Linux job on the same commit, and it passed on 8ce3568.
    • On 8ce3568, the same in-box test failed on macOS with python3: realpath: …/venv/bin/: Operation not permitted, which 5191fad fixes, and skipped on Linux, where CI fails an unexpected skip, which 60d7400 fixes by making it macOS-only.
  • End to end on Linux (Ubuntu 24.04 arm64, Debian CPython 3.12, a venv outside the project, monty = false and [tool.python3] with no filesystem table): 44 of 44 checks passed.
    • python3 and python run the venv;
    • ssl, sqlite3, lzma and hashlib import;
    • python3 -m pytest runs;
    • a .pth file can't be planted through the shell or through Python;
    • pip install fails with the read-only line;
    • piped stdin fails with EBADF and the no-stdin line;
    • python3 - and echo 1 | python3 exit 126;
    • python3.12 is refused;
    • with no permit, 126 default-deny;
    • with Monty on, the table is unused;
    • with Monty off and no tool, 126;
    • a venv inside the project is refused at startup;
    • [limits.shell] timeout stops Python, and the box keeps serving.
  • End to end on macOS (macOS on arm64, Homebrew CPython 3.14.7, a venv outside the project, monty = false and [tool.python3] with no filesystem table), on 8ce3568: 49 of 49 checks passed. These are the Linux checks plus a box whose own table grants Homebrew's folders. Two earlier runs found the bugs that 8ce3568 fixes: every such box refused to start, because the library grants named Homebrew's links, and then exec of the venv's python3 failed, because nothing rendered the /opt/homebrew/opt/python@3.14 folder link.
    • ssl, sqlite3, lzma and hashlib import with no filesystem table;
    • reads outside the grants and writes into the venv raise PermissionError.

Main new tests:

  • Record: the round trip at version 34, every older version refused, the two-table and fixed-argument refusals.

  • Bare names and startup lines.

  • Shell: a replaced name and a refused name.

  • Install layouts on fake trees: hard links, link targets, missing folders, .pth paths, editable sources.

  • Write grants refused in both directions, and Shell writes into the install refused.

  • The added grants and their disclosure.

  • Classifying stdin.

  • The program-from-stdin argument scan.

  • Libraries named through links: one entry per real file with the requested name; the translated config grants the file once, under that name, and a relinked name refuses the spawn.

  • Containment (macOS): a file read and an exec chain through a linked folder render metadata on the folder link. Each fails without the walk change.

  • In a box, tests/python_tool.rs: a venv of the runner's Homebrew Python with no filesystem table imports ssl, lzma, sqlite3 and decimal. It skips when the host has no Homebrew Python.

  • I ran the relevant suites (just check, or cargo test --workspace --all-features)

  • If I touched Rust, I ran cargo fmt --all and cargo clippy --workspace --all-targets --all-features -- -D warnings

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

## Problem

A `python3` or `python` that a program starts skips the Shell. The alias
reads the script with the agent's own access, sends the source straight
to Monty with no `shell:exec` decision, and waits under its own 35-second
limit, while Monty allows 120 seconds and the Shell's deadline is
configurable. The same script typed in the Shell takes a different route
with different decisions and limits.

Every alias also starts its command in the workspace, not in the folder
it was run from. Measured on main: from a subfolder,
`zsh -c 'cat here.txt'` read the workspace's `here.txt` and exited 0. A
read silently gets the wrong file, and a write lands in the wrong folder.

## Solution

The broker protocol moves to version 9, with one new client frame, `run`.
It carries a command and the caller's folder. The command is text for
`zsh`, `bash` and `sh`, or a name and an argument list for `python3` and
`python`. `call` and every other frame are unchanged; an alias built for
version 8 is refused by version.

- Every alias sends the folder it was run from: `PWD` when it names `.`,
  otherwise `getcwd`. If the alias can't tell, it exits 125 and sends
  nothing, so a command never falls back to the workspace.
- The `python3` and `python` aliases open a Shell Program and send their
  name and arguments unchanged. They parse no option and read no file.
  The trusted process single-quotes every word, the quoting `xargs` and
  `find -exec` already use, so `$(...)`, backticks and newlines stay
  literal. The Shell's own `python3` then runs Monty, exactly as a typed
  `python3` does, and refuses a bare `python3` or `python3 -` with
  status 2.
- The alias's 35-second Python wait is gone; `limits.shell.timeout`
  applies, and a `Signal` can end the call.
- Before the command runs, `Program::enter` moves the Shell into the
  folder through `Mediated::change_dir`, the `fs:read` `change_dir`
  decision and reachable paths check a typed `cd` gets. The folder the
  Program already starts in, the workspace, takes no decision, so a
  file-scoped policy still runs `zsh -c` from there. A refused folder, a
  missing one, or a file runs nothing and exits 126 with
  `strands-box: cannot start in <folder>: <reason>`.

Interfaces that move, which need a maintainer's approval (the broker
protocol and the aliases are frozen surfaces):

- The `python3` alias now raises a `shell:exec` decision. A policy with
  only `fs:*` permits refuses it with 126.
- The Shell reads a script file under `fs:read`, from the caller's
  folder, not with the agent's own access.
- `bash -c`, `zsh -c`, `sh -c`, `python3` and `python` start in the
  caller's folder, and a folder other than the workspace raises an
  `fs:read` `change_dir` decision.
- Through the Python alias, a refused form exits 2 instead of 125, and
  `--version` and `--help` work.

Monty still roots a relative path at the workspace. Making it follow the
Shell's folder changes the vendored Shell's script hook, which the move
to upstream Strands Shell replaces, so it is a follow-up. The direct
`Interpreter::Python` route stays in the daemon for one release; no alias
opens it.

Docs: `docs/user/monty.md`, `docs/user/shell.md`, `docs/user/config.md`,
`docs/design/monty.md`, `docs/design/shell.md`,
`docs/design/binaries.md`, `docs/design/architecture.md`, the
`python-in-the-shell-is-monty` entry in `docs/design/decisions.md`, and
`crates/box/AGENTS.md`.

## Tests

Ran on macOS: `cargo fmt -p strands-box --check`, clippy with the CI
allow list and `-D warnings`, the alias tests (45 pass), the library
tests (948 pass, 1 ignored), and every test target with `--no-run`.
`Cargo.lock` is unchanged.

- Alias: `a_python_alias_forwards_its_name_and_arguments_unchanged`
  (no arguments, `-`, `-m`, `$(rm x)`, backticks, newlines, quotes,
  spaces, the empty string), `a_python_alias_refuses_a_non_utf8_argument`,
  `the_working_directory_comes_from_pwd_only_when_pwd_names_it`,
  `an_unknown_working_directory_is_refused`,
  `every_submission_sends_the_callers_directory`. The `-c`, `-lc`,
  `-c -l` and `SCRIPT` tests still pass.
- Protocol: `a_run_frame_round_trips_in_both_command_shapes`,
  `a_malformed_run_frame_is_refused`,
  `the_protocol_version_that_carries_run_is_nine`.
- Broker: `a_command_as_words_keeps_every_argument_literal`,
  `a_run_starts_in_the_callers_directory`,
  `a_python_run_reaches_the_shells_python_and_reads_the_callers_script`,
  `a_python_run_with_no_script_is_refused_by_the_shell`,
  `a_python_run_needs_a_shell_exec_permit`,
  `only_a_folder_other_than_the_programs_own_meets_the_change_dir_decision`,
  `a_folder_the_box_cannot_enter_is_refused_and_runs_nothing`,
  `a_python_program_refuses_a_run`, `the_version_before_run_is_refused`.
  Replacing the `change_dir` call with a plain assignment, and dropping
  the quoting, each fails four of these.
- In a box (`box_shell.rs`), compiled here and run only by CI:
  `an_alias_run_from_a_subfolder_runs_in_that_subfolder`,
  `an_alias_in_a_folder_the_policy_refuses_runs_nothing`,
  `the_python_alias_needs_a_shell_exec_permit`.

Not run: anything that starts a box (`box_shell.rs` and the other
end-to-end suites, `test-integ`), and Linux. This Mac can't start a box
inside its own sandbox. The Mermaid blocks in the changed design pages
were not compiled here.
## Problem

Two review findings on the previous commit.

- The broker still served `open` in Python mode. A `call` on that
  Program ran Monty with no `shell:exec` decision. No version 9 alias
  opens it, but any process in the agent's sandbox can connect to
  `run/box.sock`, so a policy that forbids `shell:exec` on `python3`
  did not stop a client the workload wrote itself.
- A caller's folder that no host path backs was entered. The reachable
  paths check passes a name outside the host set, and the Shell's
  in-memory tree holds `/`, `/tmp`, `/home`, `/bin` and `/usr/bin`. So
  `printf RAN` run from `/` or `/tmp` ran in memory and exited 0, and a
  write landed nowhere on the host. A `..` spelling that starts inside
  the workspace reached the same place.

Smaller findings: entering the folder ran outside the call's deadline,
and the docs left out the input limit on `python3 -c`, a script that
never pauses, a folder name that isn't valid UTF-8, and the agent's home
in one troubleshooting row. The previous commit message also said a
`Signal` can end a Python call through the alias; the alias sends no
`Signal`.

## Solution

- The broker refuses `open` in Python mode with a `transport_error`
  denial that says the mode was removed in protocol 9. The Shell is now
  the only way to Monty. The old serving code stays, unreachable:
  deleting it also retires `REQUEST_TIMEOUT` and the `fetch.rs` timeout
  ordering check, which is a separate change.
- `Program::enter` refuses a folder `Reach::names_a_host_path` doesn't
  back, both as the alias spelled it and as the Shell resolved it after
  `change_dir`, and restores the old folder. The refusal is the usual
  126 with `strands-box: cannot start in <folder>: outside this box's
  home and every declared bind`.
- Entering the folder counts against the call's deadline, and the
  command gets what is left of it.
- Docs: `docs/user/monty.md` (input limit, never-pausing script,
  non-UTF-8 folder, the troubleshooting row), `docs/user/shell.md`,
  `docs/design/shell.md`, `docs/design/monty.md`,
  `docs/design/binaries.md`, the `python-in-the-shell-is-monty` entry,
  `crates/box/AGENTS.md`, and the protocol and Python module docs.

## Tests

Ran on macOS: fmt check, clippy with the CI allow list and
`-D warnings`, the alias tests, the library tests, and every test target
with `--no-run`. `Cargo.lock` is unchanged.

- `an_open_in_python_mode_is_refused_and_runs_nothing` replaces
  `a_python_program_refuses_input_and_signal_by_name` and
  `a_python_program_refuses_a_run`: the open is refused by name, a
  `call` for that id finds nothing open and writes nothing, and the
  connection still serves a Shell Program. With the old open restored,
  it fails.
- `a_folder_the_box_cannot_enter_is_refused_and_runs_nothing` gains `/`,
  `/tmp`, a made-up absolute path, and a `..` spelling from the
  workspace into `/tmp`. Without both checks, `/` prints `RAN`. Without
  the check after `change_dir`, the `..` row prints `RAN`.

Not run here: the in-box suites and Linux.
… docs

## Problem

Three small findings from the review of the previous commit.

- `tokio::time::timeout` polls the folder entry before its timer. A
  synchronous lookup that overran the call's deadline still returned
  `Ok`, and the command then started with no time left, so it could run
  before the timeout was reported.
- `docs/design/architecture.md` said the broker hands Python scripts to
  Monty, and the `monty-is-per-request-and-buffers-its-output` entry in
  `docs/design/decisions.md` described an outer limit on direct Python
  calls. The previous commit removed that route.
- `an_open_in_python_mode_is_refused_and_runs_nothing` waited on an
  unbounded read, so a regression would fail only at the 300-second idle
  timeout.

## Solution

- After the folder entry, the broker reports a timeout and exits 125
  without running the command if no time is left.
- `architecture.md` now says the Shell handles the Python aliases and
  runs scripts in Monty. The decisions entry drops the outer limit and
  the "separate five-minute allowance" sentence, and says Python runs
  through the Shell.
- The refusal test bounds each read to 5 seconds.

## Tests

Ran on macOS: fmt check, clippy with the CI allow list and
`-D warnings`, the alias tests, the library tests, and every test target
with `--no-run`. `Cargo.lock` is unchanged.

- `a_run_whose_folder_entry_passes_the_deadline_runs_nothing` runs a
  `run` into a subfolder 20 times with a zero deadline and checks that
  each reports the timeout, exits 125, and writes nothing. With the old
  remaining-time line restored, it fails on the first attempt.
- On this code, replacing `change_dir` with a plain assignment fails 2
  broker tests, and removing the quoting fails 4.

Not run here: the in-box suites and Linux.
crates/box/src/bin/strands-box-sock-alias.rs: kept the run frame, the caller's folder and python3 words; submission now sends Open and Run, and forward attaches main's stdin frames (each Input, then InputEof, or InputEof alone) after Run for every alias; forwards_stdin is true for Text (shell aliases) and false for Words (python3/python); main's stdin tests now send Run.
crates/box/src/run/broker/host.rs: imported both Command and main's MAX_CHUNK_BYTES and MAX_ENCODED_CHUNK_BYTES; main's Input handling is per Program, so it serves a Run as it serves a Call.
crates/box/src/run/broker/program.rs: kept no_host_folder for Program::enter and dropped poisoned(), which main's stdin buffer no longer calls.
crates/box/AGENTS.md: kept main's stdin notes with Call changed to Run, said python3/python send InputEof right after Run and forward no stdin, and kept the StdinEof note for every alias.
docs/user/shell.md: kept the caller's folder rule and main's credential route placeholder, and said the python3 and python aliases forward no standard input.
PROTOCOL_VERSION stays 9: main is still at 8, and the Input frames it uses existed before.
…d Python tool

## Problem

A bare `python3` or `python` in a box always runs Monty, Box's built-in Python subset (#371). An
agent that needs `-m pytest`, a package, or a module Monty lacks gets a refusal. A `[tool.python3]`
table the operator declares is accepted, but a bare `python3` never runs it, and nothing says so.

## Solution

- A new top-level `box.toml` key, `monty`, defaults to true. `monty = false` is stored at record
  version 34, so an older build refuses the box instead of quietly running Monty.
- With Monty off, `[tool.python3]` or `[tool.python]` answers to both `python3` and `python`. The
  table name decides, not the file name. If both tables exist, they must name the same command as
  written.
- Nothing falls back to Monty. With no Python table, both names exit 126 with a reason, before any
  policy decision. With a table but no `shell:spawn` permit, the call exits 126 with
  `default-deny`.
- Startup says what a bare `python3` runs on every box. It names a Python table that Monty leaves
  unused, and with Monty off it states the costs.
- Monty's refusals for `-m`, a program from stdin, and a failed import say how to run real Python.
- Versioned names such as `python3.14`, and a venv's `bin/` scripts such as `pytest`, behave as
  before.
- The Shell gains two embedder hooks: a declared file can replace one of its commands, and a bare
  name can be refused with a reason. `crates/shell/UPSTREAM.md` records both.

Interfaces that move, for maintainer approval: the `monty` key and record version 34; what the
`python3` and `python` aliases run with Monty off; a new startup line on every box; new 126 reasons;
Monty's `-m` refusal (exit 2 with its own text) and its hint texts; and strands-shell's public items
`ReplacedCommands`, `RefusedCommands`, `Kernel::replaces_command`, `Kernel::refused_command`,
`ShellBuilder::replaced_commands`, `ShellBuilder::refused_commands`, two `VfsKernel` fields, and
`commands::REAL_PYTHON_HINT`.

## Tests

- Record: the default, misspelled and non-boolean values refused, the round trip at version 34,
  every older version refused, and the two-table refusal.
- Bare names: both names answer to either table, both are refused without one, an unused table is
  listed, and versioned names stay separate.
- Startup lines: one full-output test for each case.
- Shell: a replaced name runs its declared file. A refused name exits 126 before Monty, a declared
  file, `PATH` and policy, and stops a pipeline. `command -v` and `type` find nothing for it.
  Monty's hint texts.
- In a real box: Monty off with no tool exits 126; with a tool, both names run it; without a permit,
  126 `default-deny`; with Monty on, the table is unused and `python3` is Monty.
With Monty off, Box reads the Python tool's install from its files: a venv, a standard prefix,
Homebrew, or Debian. Anything else refuses startup. Box refuses every write grant that reaches the
folders Python loads code from, and every Shell or Monty write into them while the box runs. An
editable install whose source is in the workspace, or under a write grant, is refused too. Without
this, an agent could plant a `.pth` file that runs inside the tool and later outside the box with
the operator's rights.
…the timeout kill on macOS

A program the Shell starts can't receive stdin yet. When a Python tool call's stdin carries data,
Box hands Python a write-only descriptor, so a script that reads stdin fails with EBADF instead of
reading nothing. On a failure Box adds one stderr line saying why. A call that would read its
program from stdin, such as `python3 -` or a bare `python3` in a pipe, exits 126. A new macOS test
checks that the Shell timeout kills a tool's process group, as the Linux tests already do.
With Monty off, Box adds the Python tool's reads on its install folders, a venv's `pyvenv.cfg`, and
its certificate bundle. When the table has no `filesystem` list, Box also adds read and write on
the workspace. An operator's own list replaces only the workspace grant. Each added grant is marked
in the startup notice.
With monty = false, Box walks every .so (and .dylib on macOS) under the Python tool's code folders, up to 5000, through containment's dependency walk, and adds read_file on each library no read already reaches, marked 'libraries' in the startup notice. Containment exposes loaded_libraries(images, config), seeded with the tool's write grants, so a dependency reached through a writable chain is not derived. On Homebrew this grants openssl@3, sqlite, xz, zstd and mpdecimal, so ssl, sqlite3 and lzma import.
…nd run the Mach-O library test only on macOS
## Problem

With `monty = false` and no `[tool.python3.filesystem]`, a box whose Python is Homebrew's refused
to start on macOS:

    `[tool.python3]` filesystem entry "/opt/homebrew/opt/mpdecimal/lib/libmpdec.4.dylib" is
    refused: ... is a symbolic link to /opt/homebrew/Cellar/mpdecimal/4.0.1/lib/libmpdec.4.0.1.dylib

Box added each library the install's extension modules load as a `read_file` under the name the
load requests, and Homebrew names every one through `/opt/homebrew/opt/<formula>` links. Box's lists
refuse a link. Past that, the venv's `python3` couldn't start either: its chain is
`python3 -> python3.14 -> /opt/homebrew/opt/python@3.14/bin/python3.14`, and `exec` failed with
`Operation not permitted`, because containment's traversal walk recorded file links but not the
`/opt/homebrew/opt/python@3.14` folder link the lookup reads. The Linux test used Debian's Python,
whose libraries are in system folders and whose venv links to `/usr/bin/python3`, and the box tests
used a stand-in install, so neither reached this.

## Solution

- Each added library is a `read_file` on the real file, judged like any other entry, and carries
  the name the load requests (`DirectGrant::loaded_as`). A name with `..` is spelled from its
  folder's real path. The startup notice adds `(loaded as <name>)`.
- At launch Box grants the file to containment under that name in place of the real path, after
  checking the name still leads to the judged file, so containment renders the links on the way.
  It is one grant per file because containment refuses a file granted the same operation twice;
  the first name wins.
- Containment's traversal walk (`PathGrant::walk_traversal_nodes`) also records each linked
  directory any spelling on the chain passes through, parent-canonical, and walks that link's own
  target. macOS renders `file-read-metadata` on every node, so the exec chain and the library
  names get the folder rule, and the floor and refusals judge these nodes like the rest. A grant
  spelled canonically, which is every entry an operator writes, gains no node. Linux already binds
  a grant under both names.

## Tests

- `a_file_read_spelled_through_homebrew_links_renders_metadata_on_each_link` and
  `an_exec_chain_through_a_linked_folder_renders_metadata_on_the_folder_link` (containment, macOS):
  each fails without the change (the `opt/...` folder rule is missing) and passes with it. Rendering
  the real venv's chain on this host now gives
  `(allow file-read-metadata (literal "/opt/homebrew/opt/python@3.14"))`.
- `a_library_named_through_links_is_granted_on_its_file_with_the_names_alongside` (Box): one entry
  per file, the first name kept, a `..` name respelled, a missing name dropped.
- `a_library_is_granted_once_under_the_name_its_load_requests` (Box): the translated config holds
  one grant for the file, under the name; on macOS the profile renders with the linked folder's
  metadata; a name relinked to another file refuses the spawn. Granting both the real path and
  the name fails it, which is what a first version of this fix did ("granted Read twice").
- `with_monty_off_a_homebrew_python_loads_the_libraries_its_modules_name_through_links`
  (`crates/box/tests/python_tool.rs`, in a box): a venv of the host's Homebrew Python with no
  filesystem table starts, lists its libraries with their names, and imports `ssl`, `lzma`,
  `sqlite3` and `decimal`. Skips when the host has no Homebrew Python.
- Locally on macOS: the Box lib suite and CI's clippy are clean. The containment lib
  suite passes except the two `floors` tests that need a setuid bit this host drops; they fail
  without this change too. Box startup with Homebrew Python 3.14.7 now lists six libraries, each
  with its `loaded as` name; this host can't apply a sandbox, so the in-box test runs in CI.
- End to end on macOS with Homebrew Python 3.14.7, a read-only venv outside the project and no
  filesystem table: 49 of 49 checks pass, including the `ssl`, `sqlite3` and `lzma` imports, pytest,
  `PermissionError` on reads outside the grants and on writes into the venv, and the Shell timeout.
A venv outside the operator's home failed at startup on macOS, before running anything:

    python3: realpath: /private/var/tmp/.../venv/bin/: Operation not permitted

Python resolves the folder its command was started from, which needs metadata on that folder.
Under the operator's home the discovery rules give it, which is why the end-to-end run, with its
venv under the home, passed. The in-box Homebrew test, whose venv is under /private/var/tmp, failed
on the macOS runner.

On macOS Box now adds `metadata` on the folder holding the command, such as a venv's `bin/`,
marked `(added for [tool.python3]: install)`. Nothing in the folder becomes readable, and none is
added when a read already covers it. Linux adds nothing, because its view makes the folder
traversable.

Tests: the three added-grant tests expect the entry on macOS. The in-box test
`with_monty_off_a_homebrew_python_loads_the_libraries_its_modules_name_through_links` covers the
venv outside the home.
The Linux runner has no Homebrew, so the test skipped there, and Linux CI fails a job on a skip
that `scripts/check-linux-test-output.py` doesn't expect. The macOS runner has Homebrew Python, and
the case the test covers, libraries named through Homebrew's `opt/<formula>` links, is a macOS one
in practice. The candidates are Homebrew's two macOS prefixes.
Base automatically changed from feature/python-alias-through-shell to main November 14, 2026 00:15

/// One Python install, as its files describe it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PythonInstall {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of this should be included in Box; Box does't need to know about this. If monty is disabled, it's up to the author to include python as a tool.

That's what I've been trying to emphasize - the only case that Box should special case python3 is for/using Monty. But if Monty is disabled, python should just be a normal tool

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants