Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions breadboard/rl/harness/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,16 @@ def __post_init__(self) -> None:
raise ValueError("fixed environment keys must be unique")
if any(not key or "=" in key or "\x00" in key + value for key, value in self.fixed_environment):
raise ValueError("invalid fixed environment")
# Trusted-process launches run the pinned host shell non-interactively
# (-c), which sources $BASH_ENV and imports BASH_FUNC_<name>%% functions
# (able to override exec/printf) before the requested argv. ENV is read
# only by interactive shells. Hardened backends exec argv in the
# container without that wrapper.
if self.runtime_class is RuntimeClass.TRUSTED_PROCESS and any(
key == "BASH_ENV" or key.startswith("BASH_FUNC_")
for key, _ in self.fixed_environment
):
raise ValueError("fixed environment cannot carry shell startup hooks")


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -2682,7 +2692,9 @@ async def run_argv(
return await self._run_pinned_argv(
(
self._executable.proc_fd_path,
"-lc",
# Not a login shell: setup and verifier argv are trusted, and the
# policy can write the profiles a login shell sources.
"-c",
Comment thread
kmccleary3301 marked this conversation as resolved.
'exec "$@"',
"breadboard-execute",
*execution_argv,
Expand Down Expand Up @@ -3005,7 +3017,9 @@ async def measure_repository_base_commit(self) -> str | None:
result = await self._run_pinned_argv(
(
self._executable.proc_fd_path,
"-lc",
# Not a login shell: a policy-written profile would run inside
# this trusted measurement.
"-c",
'exec "$2" -C "$1" rev-parse --verify "HEAD^{commit}"',
"breadboard-workspace-base",
relative_path,
Expand Down Expand Up @@ -3046,7 +3060,7 @@ async def workspace_diff(self) -> Mapping[str, Any]:
result = await self._run_pinned_argv(
(
self._executable.proc_fd_path,
"-lc",
"-c",
'exec "$2" -C "$1" diff --no-ext-diff --binary',
"breadboard-workspace-diff",
repositories[0].target_logical_path,
Expand Down
77 changes: 77 additions & 0 deletions docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ACR-20260930-trusted-argv-nonlogin

- `acr_id`: `ACR-20260930-trusted-argv-nonlogin`
- `title`: Run trusted process argv and workspace probes without a login shell
- `author`: BreadBoard RL implementation
- `date`: 2026-09-30
- `status`: implemented

## 1) Problem Statement

`TrustedProcessHandle` (`breadboard/rl/harness/sandbox.py`) ran three trusted executions through the pinned shell as a **login** shell (`<pinned bash> -lc …`):
- `run_argv`, which serves setup and verifier argv and `ProcessLease.execute`;
- `measure_repository_base_commit`, which runs `git rev-parse --verify HEAD^{commit}` with a 256-byte output limit;
- `workspace_diff`.

A login shell sources `/etc/profile` and the first of `~/.bash_profile`, `~/.bash_login` and `~/.profile`. Those files are in state the policy can write, so a policy command could get its own code run inside the trusted executions. It could print extra output, change the exit status, or change working state before the requested argv ran.

Measured defect on IBM Slurm, 2026-09-30, runtime v11, SMoE SWE training (job 128376), episode hihbg4el:
- The policy ran `cp /tmp/test_fix.py ~/.bash_profile`.
- The verifier's base-commit probe sourced that file and exceeded its 256-byte output limit.
- The episode failed closed with `output_limit_exceeded`.

The graded patch did not depend on these probes. It comes from the shell-free sealed diff (`sealed_workspace_diff`, which runs pinned git by descriptor with a private `HOME` and `GIT_CONFIG_GLOBAL=/dev/null`). The probes could still misreport or fail.

## 2) Scope and Surfaces

- Kernel danger-zone: yes, under `breadboard/**`. This change touches `breadboard/rl/harness/sandbox.py` only.
- `TrustedProcessHandle.run_argv`, `measure_repository_base_commit` and `workspace_diff` pass `-c` instead of `-lc` to the pinned shell. The argv, positional parameters, environment, timeouts and output limits are unchanged.
- A `TRUSTED_PROCESS` `InstalledRuntime` rejects a `fixed_environment` that carries `BASH_ENV` or any `BASH_FUNC_*` key (`fixed environment cannot carry shell startup hooks`).
- Every trusted-process launch runs the pinned host shell non-interactively with `-c`: the stopped-process bootstrap, the native worker, and the three executions above.
- Such a shell sources `$BASH_ENV` and imports exported functions, which can override `exec` and `printf`, before the requested argv runs. If `BASH_ENV` named a policy-writable file, it would reopen the same hole.
- `ENV` is read only by interactive shells.
- Hardened Docker/gVisor runtimes exec argv inside the container without that wrapper, so they keep their admitted environment unchanged.
- The production composer's fixed environment is `PATH`, `HOME`, `LANG` and `LC_ALL`.
- Not changed: policy-facing `run_shell` and `run_native_tool` keep `-lc`, so policy commands still see the image's login environment. The idle process argv is also unchanged.
- This matches the existing backends:
- The Docker backend's `run_argv` executes argv directly.
- The native worker launch already uses `-c` ("Not a login shell: the image's /etc/profile would replace the admitted worker PATH").

## 3) Coupling and Generalization Impact

- Harness- and task-neutral. Trusted executions now run exactly the argv the runtime requested, whatever shell startup files the workload has written. No task or image may rely on login-profile side effects inside setup/verifier argv. Those argv already receive their environment from the admitted runtime's fixed environment.

## 4) Change Classification

- Classification: `behavioral-change`. Trusted argv and workspace probes no longer source shell startup files. There is no schema or wire-format change.

## 5) Evidence and Validation Plan

- New real-execution test `tests/rl/harness/test_sandbox_process_integration.py::test_trusted_argv_ignores_policy_written_login_profile`:
- The policy writes `$HOME/.profile` and `$HOME/.bash_profile` through `run_shell`.
- A sensitivity check asserts that the policy's own login shell runs the profile.
- The trusted base commit must then equal `HEAD`, `workspace_diff` must be unchanged, and `execute` must print only the requested argv's output.
- `test_run_argv_executes_requested_command_through_pinned_shell` and `test_workspace_diff_uses_nested_repository_and_types_missing_git` pin `-c`.
- IBM Linux (bash, `/bin/sh` = dash), on the runtime lineage (`e4src/trusted-argv-nonlogin-v11` on runtime-v11 source `ecd17010`, job 129406):
- Without the change, the three tests fail. The new test fails with `SandboxLaunchError: workspace base commit measurement failed`.
- With the change, all three pass.
- `test_verifier_snapshot`, `test_sandbox_runtime`, `test_v2_service`, `test_production_composition_runtime` and `test_headless_runner` have the same results in both trees, as does the rest of `test_sandbox_process_integration`.
- On `main`, the two pinning tests fail before and pass after (job 129373). The sealed test is skipped as `runtime_unsupported` on hosts without namespace/UID mapping.
- `test_trusted_process_runtime_rejects_shell_startup_hooks` (`BASH_ENV` and `BASH_FUNC_exec%%`, each for trusted-process and hardened-Docker runtimes) runs on macOS:
- Trusted-process cases fail before the validation and pass after.
- Hardened-Docker cases fail under an ungated check and pass with the gate.
- The sandbox runtime, Docker sandbox, native phase admission, v2 service and verifier snapshot suites have identical results otherwise.
- Runtime `bbagent-runtime/v12` carries only the `-c` change, not the hook validation. Its composed fixed environment has neither key.

## 6) Rollout Plan

- RL runtime lineage: `e4src/trusted-argv-nonlogin-v11` builds `bbagent-runtime/v12`.
- Main: this PR.

## 7) Rollback Plan

- Revert the commit. There is no configuration surface.

## 8) Approvals

- This artifact records the implementation decision and its evidence. It does not assert external PR acceptance or BMoE promotion.
118 changes: 116 additions & 2 deletions tests/rl/harness/test_sandbox_process_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1711,7 +1711,7 @@ async def scripted_pinned_argv(
(
(
"/proc/self/fd/71",
"-lc",
"-c",
'exec "$@"',
"breadboard-execute",
"/bin/echo",
Expand Down Expand Up @@ -1777,7 +1777,7 @@ async def scripted_run_argv(
assert calls[0] == (
(
"/proc/self/fd/71",
"-lc",
"-c",
'exec "$2" -C "$1" diff --no-ext-diff --binary',
"breadboard-workspace-diff",
"nested/repository",
Expand Down Expand Up @@ -2075,6 +2075,120 @@ async def test_process_lease_execute_runs_requested_argv(tmp_path: Path) -> None
assert (await primary.close()).state is CleanupState.RELEASED


@requires_sealed_execution
async def test_trusted_argv_ignores_policy_written_login_profile(
tmp_path: Path,
) -> None:
# A policy that overwrites its shell profile must not run code inside the
# trusted base-commit, workspace-diff, or setup/verifier argv executions.
fixture = make_runtime_fixture(
with_writable_mount=True,
runtime_install_root=tmp_path / "runtime",
)
runtime = next(
runtime
for runtime in fixture.authorities.runtimes
if runtime.runtime_id == fixture.plan.sandbox.runtime_id
)
composed = replace(
runtime,
fixed_environment=tuple(sorted((*runtime.fixed_environment, ("HOME", "/tmp")))),
)
authorities = replace(
fixture.authorities,
runtimes=tuple(
composed if candidate.runtime_id == composed.runtime_id else candidate
for candidate in fixture.authorities.runtimes
),
)
(tmp_path / "harness").mkdir()
harness = RuntimeHarness(
tmp_path / "harness", replace(fixture, authorities=authorities)
)
harness.manager.process_backend = TrustedProcessBackend()
primary = await harness.manager.open(fixture.request)
workspace = primary._materialized.workspace_path
repository = workspace / "work"
repository.mkdir(exist_ok=True)

def git(*arguments: str) -> str:
return subprocess.run(
("git", *arguments), cwd=repository, check=True,
capture_output=True, text=True,
).stdout.strip()

git("init", "--quiet", "--template=")
(repository / "module.py").write_text("value = 1\n", encoding="utf-8")
git("add", ".")
git(
"-c", "user.name=BreadBoard",
"-c", "user.email=breadboard@example.invalid",
"commit", "--quiet", "-m", "base",
)
(repository / "module.py").write_text("value = 2\n", encoding="utf-8")
head = git("rev-parse", "HEAD")
handle = primary._runtime
mount = next(
entry
for entry in handle.plan.materialization_plan.entries
if entry.target_logical_path == "work"
)
handle.plan = replace(
handle.plan,
materialization_plan=replace(
handle.plan.materialization_plan,
entries=(replace(mount, role="repository"),),
),
)
expected_diff = (await handle.workspace_diff())["stdout"]
assert "+value = 2" in expected_diff
written = await primary.runner_workspace.run_shell(
"printf 'printf %s policy-profile-ran\\n' > \"$HOME/.profile\""
" && cp \"$HOME/.profile\" \"$HOME/.bash_profile\"",
timeout=2,
)
assert written["returncode"] == 0
login = await primary.runner_workspace.run_shell("true", timeout=2)
assert login["stdout"] == "policy-profile-ran"

assert await handle.measure_repository_base_commit() == head
assert (await handle.workspace_diff())["stdout"] == expected_diff
executed = await primary.execute(("/usr/bin/printf", "requested-argv"))
assert executed["stdout"] == "requested-argv"
assert (await primary.close()).state is CleanupState.RELEASED


@pytest.mark.parametrize(
("key", "value"),
(("BASH_ENV", "/tmp/policy-hook"), ("BASH_FUNC_exec%%", "() { :; }")),
)
@pytest.mark.parametrize(
"runtime_class",
(
sandbox_module.RuntimeClass.TRUSTED_PROCESS,
sandbox_module.RuntimeClass.HARDENED_DOCKER,
),
)
def test_trusted_process_runtime_rejects_shell_startup_hooks(
runtime_class: sandbox_module.RuntimeClass, key: str, value: str
) -> None:
# Trusted-process launches run the pinned host shell with -c, which sources
# $BASH_ENV and imports exported functions before the requested argv.
# Hardened backends exec argv in the container and keep the variable.
fixture = make_runtime_fixture(runtime_class=runtime_class, with_writable_mount=True)
runtime = next(
runtime
for runtime in fixture.authorities.runtimes
if runtime.runtime_id == fixture.plan.sandbox.runtime_id
)
environment = tuple(sorted((*runtime.fixed_environment, (key, value))))
if runtime_class is sandbox_module.RuntimeClass.TRUSTED_PROCESS:
with pytest.raises(ValueError, match="shell startup hooks"):
replace(runtime, fixed_environment=environment)
else:
assert replace(runtime, fixed_environment=environment).fixed_environment == environment


@requires_sealed_execution
async def test_symlinked_runtime_ancestor_is_rejected_before_child_creation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
Expand Down
Loading