Skip to content

feat(exec): drop the host directories from a target's PATH - #446

Open
raphaelvigee wants to merge 1 commit into
raphaelvigee/coreutils-default-onfrom
raphaelvigee/coreutils-drop-host-path
Open

raphaelvigee wants to merge 1 commit into
raphaelvigee/coreutils-default-onfrom
raphaelvigee/coreutils-drop-host-path

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Aug 29, 2026

Copy link
Copy Markdown
Member

The host directories — /usr/local/bin:/usr/bin:/bin — come off every target's PATH. Breaking, deliberately.

This is what makes the toolbox worth more than convenience. It turns the sandbox from isolated files, ambient tools into isolated files, declared tools, and closes the largest remaining under-hashing hole in the cache key: the version of every host binary a recipe ever invoked was never in it.

A target's PATH becomes exactly [its tools] : [what it declared] : [heph's builtins]. Nothing else.

This removes discovery, not visibility

The sandbox already exposes the host filesystem; dropping the entries only stops bare-name resolution. /usr/bin/foo still runs. foo stops resolving. The failure is always "not found on PATH" with the program and the searched path named — never a silent behaviour change.

What stops resolving

Worth being blunt: this is larger than shadowing cp. Shadowing changes which cp runs; this changes whether cc runs at all.

  • #!/usr/bin/env python3env is exec'd by absolute path, then searches PATH and does not find the interpreter. Declare it.
  • cc/clang/ld/pkg-config — any recipe shelling out to a toolchain it never declared. The largest migration cost in practice.
  • git, xcrun, codesign — usually undeclared and usually already non-hermetic.

The driver's path: option names directories back for a workspace that needs them.

Two different PATHs, and that is the trick

Resolving the driver's own program and what the recipe sees are separate things:

  • The shell resolves on the ambient PATH — the environment heph itself was launched in — to an absolute path, once. Only for a local spawn: under a runner the shell has to come from the environment the target actually runs in.
  • The child's PATH is the sandbox one above. Nothing ambient.

An absolute program path spawns regardless of the child's PATH, so the shell starts and the recipe inside it still cannot reach an undeclared host binary.

Known cost

Which bash you get now depends on how heph was launched, where it used to be a hardcoded list. More convenient, less deterministic; the shell stays out of the cache key, because folding the resolved path in would split the remote cache across every machine whose bash lives elsewhere. An acknowledged hole that a provisioned shell would close properly.


The stack

Merge bottom-up, and gh stack sync after each one lands — master is squash-only, so the rebase will conflict and the resolution rule in CLAUDE.md applies.

PR What
6 #446 drop the host directories from a target's PATHbreaking
5 #445 the toolbox on by default
4 #440 the template rule and the tmpl applet
3 #453 grep, find, xargs, sed, tar, gzip, zstd
2 #438 the crate, the entry points, the shim directory
1 #451 the runner PATH seam ← base, targets master

Only #451 builds automatically: since #449 a stacked PR is skipped unless it carries ci/force-ci. Every layer was checked locally on its own — cargo build --workspace --all-targets, cargo clippy --workspace --all-targets, and its unit tests — not just at the top of the stack.

🤖 Generated with Claude Code

https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q

@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/coreutils-drop-host-path branch 2 times, most recently from 04defbd to cebd657 Compare August 29, 2026 23:03
@raphaelvigee

Copy link
Copy Markdown
Member Author

Pushed a fix for the CI failure, which was this PR's own.

Dropping the host directories broke every in-process harness that runs a real cat/find/wc in a recipe — crates/e2e, crates/plugingo-e2e and crates/bench all build the driver directly with Driver::new_bash(), where an empty search path used to mean host defaults and now means nothing. That silent flip in meaning is on me.

Two changes:

  • with_search_path() / with_host_path() are now public on the driver — the equivalent of the path: option for an embedder that builds it directly. All 19 harness construction sites say .with_host_path() explicitly, so anything depending on the host now declares that it does.
  • One test needed more than that: runner_json_never_enters_the_sandbox runs under a wrap runner with an empty config, and under a runner the driver's search path is deliberately not injected at all (feat(execrunner): run targets in a described environment #425). It was silently relying on PathPolicy::fallback, which this PR removes. Its runner now names a PATH, which is the runner's job.

That second one is a user-visible consequence worth calling out in review: a wrap runner that supplies no PATH now gives its targets only their declared tools, where it previously inherited the host's directories through the driver's fallback. Documented in docs/COREUTILS.md.

Verified locally across the e2e suite — engine_sanity, deps, group, tool_env, execrunner, clean, codegen_in_place, engine_core, group_relocate, http_fetch, plugin_buildfile, query all pass, plus plugin-exec 108. Lint clean.

@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/coreutils-drop-host-path branch from cebd657 to 7a8f19d Compare August 29, 2026 23:23
@raphaelvigee

Copy link
Copy Markdown
Member Author

Second CI failure on this PR, and a genuine bug I introduced — worth recording rather than just fixing.

e2e::oci_runner::a_target_runs_inside_the_container failed on both Linux targets (it is docker-gated, so it never ran on my macOS box).

The cause: I was resolving the shell on the host's ambient PATH even for targets running under a runner. run_inner computes argv — including my new resolution — before the runner rewrites the spec. So a container target got argv[0] = /nix/store/…/bash-interactive-5.3p9/bin/bash, a host path that does not exist inside the image. The container could not start its own shell.

Fixed by gating the resolution on def.runner.is_none(). The reasoning was already in the design and I applied only half of it: the shell is the driver's implementation detail for a local spawn, but under a runner the environment is the runner's, and that is precisely what naming a runner asks for. A container has its own filesystem; a path resolved from heph's PATH is meaningless there.

Added a unit test pinning that a local spawn does get an absolute shell, with a pointer to oci_runner as the end-to-end cover for the runner case — that one genuinely needs a container, so it belongs there rather than mocked.

plugin-exec 109 and e2e --test execrunner 9 pass locally; lint clean. docs/COREUTILS.md now states the local-only rule instead of implying it always resolves.

@raphaelvigee

Copy link
Copy Markdown
Member Author

Third round on this PR. Two more things it broke, both real, both found only by CI.

1. crates/engine's own test harnesses (12 failures). I updated e2e, plugingo-e2e and bench last round but missed the engine's, which build Driver::new_exec()/new_bash() in 15 places across result.rs, expand.rs, deppath.rs, validate.rs, gitignore.rs and revdeps.rs. Same root cause as before — an empty search path used to mean host defaults. All now say .with_host_path(); 554 engine tests pass. I have also swept the workspace: there are no remaining bare constructions outside plugin-exec's own file.

2. A production hole, not a test one: --shell. default_exec_shell_fallback() builds its own Driver::new_exec(), so heph run --shell on a driver with no shell mode of its own (go, oci, http_fetch) would have dropped a human into a session with an empty PATH — no ls, no cat. Nothing in the test suite covers that path, and I would not have found it without going looking after the engine failures.

It now gets the host's directories, deliberately and unlike a build target: its entire purpose is poking around, and it builds nothing and reaches no cache key, so the argument for keeping the host out does not apply. Documented in docs/COREUTILS.md alongside the runner exception.

Running tally of what this PR has broken and fixed: harness PATH assumptions (19 sites), container shell resolution, engine harnesses (15 sites), and the interactive shell fallback. That is a fair reflection of how many seams "drop the host PATH" actually touches, and why it is worth keeping as its own revert line.

@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/coreutils-drop-host-path branch from 7a8f19d to 85605bd Compare August 30, 2026 00:02
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/coreutils-drop-host-path branch from 85605bd to 41b464e Compare September 3, 2026 16:26
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/coreutils-drop-host-path branch from 41b464e to 2853ee9 Compare September 3, 2026 16:57
This is what makes the toolbox worth more than convenience. A target now
sees what it declares plus heph's builtins, and nothing else — which turns
the sandbox from "isolated files, ambient tools" into "isolated files,
declared tools", and closes the largest remaining under-hashing hole: the
version of every host binary a recipe ever invoked was never in its cache
key.

A recipe reaching for an undeclared host binary now fails with `command
not found` rather than succeeding on whichever machine happens to have it.
`path:` on the driver names directories back for a workspace that needs
them.

Half of this already shipped: #425 made the driver skip its host-directory
injection for any target that names a runner, on the reasoning that
putting /usr/bin ahead of the environment lets a host tool silently shadow
the one the target asked to run beside, inside a cache key claiming that
environment. That argument does not stop being true when the environment
is the local host. What is left is the local spawn: the `def.runner.is_none()`
injection and `PathPolicy::fallback` both go.

The shell is the exception, deliberately. `bash` is the *driver's*
implementation detail, not something the target declared, so it is
resolved on the ambient PATH — the environment heph was launched in — and
spawned by absolute path. An absolute program spawns fine regardless of
the child's PATH, so the shell starts and the recipe inside it still
cannot reach an undeclared host binary. That must never extend to the
`exec` driver's argv[0], which *is* the target's command: resolving that
ambiently would hand back the whole hole this closes. The split follows
the line the code already draws between `wrap_run` and `wrap_run_shell`.

The shell stays out of the cache key. Folding the resolved path in would
split the remote cache between every machine whose bash lives somewhere
different, for a hole a provisioned shell closes properly.

Nine existing tests were relying on the host PATH for `cat`/`sleep`/
`printf` while testing process plumbing. They now say so, via a
`with_host_path()` test helper, rather than being defaulted into it.

Verified end to end with no configuration at all: `cp --version` reports
`cp (uutils coreutils) 0.10.0` and resolves to the shim directory, `git`
fails with 127, and the shell that ran both is an absolute /nix/store path
the target's own PATH could never have found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
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