From f02dcdbbe8e8f9865418dbb77e09140665408168 Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Wed, 30 Sep 2026 01:04:30 -0700 Subject: [PATCH 1/4] rl harness: run trusted process argv and workspace probes without a login shell --- breadboard/rl/harness/sandbox.py | 10 ++- .../test_sandbox_process_integration.py | 88 ++++++++++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/breadboard/rl/harness/sandbox.py b/breadboard/rl/harness/sandbox.py index 0ddb79992..259e5a556 100644 --- a/breadboard/rl/harness/sandbox.py +++ b/breadboard/rl/harness/sandbox.py @@ -2682,7 +2682,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", 'exec "$@"', "breadboard-execute", *execution_argv, @@ -3005,7 +3007,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, @@ -3046,7 +3050,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, diff --git a/tests/rl/harness/test_sandbox_process_integration.py b/tests/rl/harness/test_sandbox_process_integration.py index b48542080..b393ede94 100644 --- a/tests/rl/harness/test_sandbox_process_integration.py +++ b/tests/rl/harness/test_sandbox_process_integration.py @@ -1711,7 +1711,7 @@ async def scripted_pinned_argv( ( ( "/proc/self/fd/71", - "-lc", + "-c", 'exec "$@"', "breadboard-execute", "/bin/echo", @@ -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", @@ -2075,6 +2075,90 @@ 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 + + + @requires_sealed_execution async def test_symlinked_runtime_ancestor_is_rejected_before_child_creation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch From 71f2e2093567b275ad0e7444ec70f94f8446263b Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Wed, 30 Sep 2026 01:57:19 -0700 Subject: [PATCH 2/4] docs: ACR for trusted process argv without a login shell --- .../acr/ACR-20260930-trusted-argv-nonlogin.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md diff --git a/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md b/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md new file mode 100644 index 000000000..78df3dfc3 --- /dev/null +++ b/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md @@ -0,0 +1,66 @@ +# 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 (` -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. +- 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. + +## 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. From 0ed8ed63076439dac35a2af5af85e195f4c185cf Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Wed, 30 Sep 2026 02:05:24 -0700 Subject: [PATCH 3/4] rl harness: reject shell startup hooks in trusted runtime environments --- breadboard/rl/harness/sandbox.py | 6 ++++++ .../acr/ACR-20260930-trusted-argv-nonlogin.md | 7 +++++++ .../harness/test_sandbox_process_integration.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/breadboard/rl/harness/sandbox.py b/breadboard/rl/harness/sandbox.py index 259e5a556..1fcabf31e 100644 --- a/breadboard/rl/harness/sandbox.py +++ b/breadboard/rl/harness/sandbox.py @@ -830,6 +830,12 @@ 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 launches run the pinned shell non-interactively (-c), which + # sources $BASH_ENV and imports BASH_FUNC_%% functions (able to + # override exec/printf) before the requested argv. ENV is read only by + # interactive shells. + if 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) diff --git a/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md b/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md index 78df3dfc3..602e7fb84 100644 --- a/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md +++ b/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md @@ -26,6 +26,11 @@ The graded patch did not depend on these probes. It comes from the shell-free se - 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. +- `InstalledRuntime` rejects a `fixed_environment` that carries `BASH_ENV` or any `BASH_FUNC_*` key (`fixed environment cannot carry shell startup hooks`). + - Every trusted launch runs the pinned 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. + - 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. @@ -51,6 +56,8 @@ The graded patch did not depend on these probes. It comes from the shell-free se - 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_runtime_authority_rejects_shell_startup_hooks` (`BASH_ENV` and `BASH_FUNC_exec%%`) fails before the validation and passes after, on macOS. The sandbox runtime, 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 diff --git a/tests/rl/harness/test_sandbox_process_integration.py b/tests/rl/harness/test_sandbox_process_integration.py index b393ede94..677cef3f0 100644 --- a/tests/rl/harness/test_sandbox_process_integration.py +++ b/tests/rl/harness/test_sandbox_process_integration.py @@ -2158,6 +2158,21 @@ def git(*arguments: str) -> str: assert (await primary.close()).state is CleanupState.RELEASED +@pytest.mark.parametrize( + ("key", "value"), + (("BASH_ENV", "/tmp/policy-hook"), ("BASH_FUNC_exec%%", "() { :; }")), +) +def test_runtime_authority_rejects_shell_startup_hooks(key: str, value: str) -> None: + # Trusted launches run the pinned shell with -c, which sources $BASH_ENV + # and imports exported functions before the requested argv. + fixture = make_runtime_fixture(with_writable_mount=True) + runtime = fixture.authorities.runtimes[0] + with pytest.raises(ValueError, match="shell startup hooks"): + replace( + runtime, + fixed_environment=tuple(sorted((*runtime.fixed_environment, (key, value)))), + ) + @requires_sealed_execution async def test_symlinked_runtime_ancestor_is_rejected_before_child_creation( From 1a09d72bf7f4a97477e3592e322ad03a63d45775 Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Wed, 30 Sep 2026 02:11:21 -0700 Subject: [PATCH 4/4] rl harness: limit shell startup hook rejection to trusted-process runtimes --- breadboard/rl/harness/sandbox.py | 14 +++++--- .../acr/ACR-20260930-trusted-argv-nonlogin.md | 10 ++++-- .../test_sandbox_process_integration.py | 35 +++++++++++++------ 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/breadboard/rl/harness/sandbox.py b/breadboard/rl/harness/sandbox.py index 1fcabf31e..6eafce7d0 100644 --- a/breadboard/rl/harness/sandbox.py +++ b/breadboard/rl/harness/sandbox.py @@ -830,11 +830,15 @@ 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 launches run the pinned shell non-interactively (-c), which - # sources $BASH_ENV and imports BASH_FUNC_%% functions (able to - # override exec/printf) before the requested argv. ENV is read only by - # interactive shells. - if any(key == "BASH_ENV" or key.startswith("BASH_FUNC_") for key, _ in self.fixed_environment): + # Trusted-process launches run the pinned host shell non-interactively + # (-c), which sources $BASH_ENV and imports BASH_FUNC_%% 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") diff --git a/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md b/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md index 602e7fb84..c69c907f3 100644 --- a/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md +++ b/docs/contracts/policies/acr/ACR-20260930-trusted-argv-nonlogin.md @@ -26,10 +26,11 @@ The graded patch did not depend on these probes. It comes from the shell-free se - 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. -- `InstalledRuntime` rejects a `fixed_environment` that carries `BASH_ENV` or any `BASH_FUNC_*` key (`fixed environment cannot carry shell startup hooks`). - - Every trusted launch runs the pinned shell non-interactively with `-c`: the stopped-process bootstrap, the native worker, and the three executions above. +- 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: @@ -56,7 +57,10 @@ The graded patch did not depend on these probes. It comes from the shell-free se - 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_runtime_authority_rejects_shell_startup_hooks` (`BASH_ENV` and `BASH_FUNC_exec%%`) fails before the validation and passes after, on macOS. The sandbox runtime, native phase admission, v2 service and verifier snapshot suites have identical results otherwise. +- `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 diff --git a/tests/rl/harness/test_sandbox_process_integration.py b/tests/rl/harness/test_sandbox_process_integration.py index 677cef3f0..db261ccb1 100644 --- a/tests/rl/harness/test_sandbox_process_integration.py +++ b/tests/rl/harness/test_sandbox_process_integration.py @@ -2162,16 +2162,31 @@ def git(*arguments: str) -> str: ("key", "value"), (("BASH_ENV", "/tmp/policy-hook"), ("BASH_FUNC_exec%%", "() { :; }")), ) -def test_runtime_authority_rejects_shell_startup_hooks(key: str, value: str) -> None: - # Trusted launches run the pinned shell with -c, which sources $BASH_ENV - # and imports exported functions before the requested argv. - fixture = make_runtime_fixture(with_writable_mount=True) - runtime = fixture.authorities.runtimes[0] - with pytest.raises(ValueError, match="shell startup hooks"): - replace( - runtime, - fixed_environment=tuple(sorted((*runtime.fixed_environment, (key, value)))), - ) +@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