Skip to content

fix(ui-backend-pi): run the bash tool without a login shell - #2985

Merged
schlessera merged 1 commit into
mainfrom
fix/2960-pi-bash-no-login-044
Oct 26, 2026
Merged

schlessera merged 1 commit into
mainfrom
fix/2960-pi-bash-no-login-044

Conversation

@schlessera

@schlessera schlessera commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #2960

What changed

The pi backend's bash tool no longer runs commands in a login shell. Before this change it spawned bash -lc, so /etc/profile and the first of ~/.bash_profile, ~/.bash_login or ~/.profile under the child's HOME ran before every command. Their output landed in the model-visible result, and every variable or PATH entry they exported overrode the allowlisted subprocessEnv. Commands now run as bash --noprofile --norc -c <cmd> (packages/ui-backend-pi/src/tools.ts, at the spawn site, with a comment explaining why). BASH_ENV and ENV are removed from the command's environment even when an operator admits them, so neither the command nor any shell it starts sources a startup file. The command sees exactly the filtered environment. The tool's result shape (text plus { exitCode }) is unchanged.

Why this shape

  • I used --noprofile --norc -c rather than a bare -c. Non-interactive bash -c already skips profiles and .bashrc, with one exception: Debian/Ubuntu builds source ~/.bashrc when they detect an ssh parent. The flags make "reads no startup file" explicit and independent of how bash was built. They are belt and braces, so no test goes red if only they are removed (receipts below).
  • BASH_ENV/ENV scrubbing: a non-interactive bash sources $BASH_ENV in this command and in every nested bash it starts. $ENV is the sh/posix equivalent. Neither name is on the agent allowlist, so they could only arrive through BRAIN_UI_SUBPROCESS_ENV_EXTRA. They name startup files, not values a command uses. The README and changeset say so.
  • No test sets a fixture HOME on the four existing "bash returns output without a command echo" cases. Those cases now pass whatever the developer's profile does, which is the acceptance criterion; the new tests carry the fixture HOME.

Open questions from the issue, answered

  1. Can the agent plant a profile in a HOME that outlives the turn? No. Every production pi turn goes through the worker launcher (session-runtime.ts; only the internal sessionFactory test double runs tools in-process). Every non-envelope worker binds the host root read-only (--ro-bind / /, worker-launcher.ts). A probe of that mount shape answered touch: cannot touch '$HOME/…': Read-only file system. Profile (governed/strict) workers also mask /home and /root with empty tmpfs and never bind the home back. With the restricted envelope, HOME is WORKER_SCRATCH, which is writable. It is that worker's private turn-temp tmpfs and is gone when the per-turn worker is killed, so a planted file could only have affected later commands in the same turn. That is now moot, because nothing is sourced. Residual edge: before this change, a governed worker whose host HOME sat inside the writable brain could have persisted one. Removing -l closes that edge too.
  2. Is -l load-bearing? No evidence that it is. It dates from the initial public release (ee888dec3) with no rationale. The only doc mention was the argv shape in packages/ui-backend-pi/README.md, updated here. No doc, decision record or hosting-template file relies on a profile for PATH: the hosting template's container-privilege decision synthesizes a fixed PATH, and the restricted envelope sets PATH=/usr/bin:/bin. With -l, distro /etc/profile could replace the allowlisted PATH. Ubuntu's appends /snap/bin (observed here); Debian's resets it outright (distro default, not checked here). A changeset covers hosts that relied on a profile for PATH.
  3. Does the Claude backend have the same shape? Partly. Filed as ui-backend-claude: the Claude CLI's shell snapshot sources the worker HOME's rc and login profile #2984 (the issue scopes other backends out). The Claude CLI (0.3.292 native binary) builds a shell snapshot once per session with <shell> -c -l sourcing ~/.bashrc. It then runs each command as <shell> -c "source <snapshot> && … eval <cmd>" and drops -l only when a snapshot exists. Profile output and exported variables do not reach commands, and the snapshot's PATH is the CLI's own. Functions and aliases from the host rc do reach commands. Without a usable snapshot, the CLI falls back to -c -l per command, which is the exact pi shape.

Proof

Base: origin/main @ c670449d3. Head: see the PR head SHA.

Failing-first. New tests (packages/ui-backend-pi/tests/tools-permission.test.ts) run against the unchanged tree:

  • bash output carries nothing a profile in HOME prints: echo hi: received "profile-stdout\nhi\n\nprofile-stderr" instead of "hi".
  • … : true: received "profile-stdout\n\nprofile-stderr" instead of "(no output)".
  • a profile in HOME sets no variable and no PATH entry the command sees: received tok=[from-profile] leak=[1] probe=[/tmp/pi-bash-home-…/planted-bin/brain-profile-probe] path=[planted] instead of tok=[unset] leak=[unset] probe=[absent] path=[clean]. The host had GH_TOKEN=host-value, which the allowlist removed; the profile put it back.
  • an operator-admitted BASH_ENV or ENV names no startup file …: received startup-file-ran … nested=[1] instead of env=[unset] bash_env=[unset] nested=[unset].

Mutations, each restored afterwards:

  • Put ["bash", "-lc", cmd] back: the two noisy-profile cases and the leak case fail on the same assertions as above; the BASH_ENV case passes (3 fail / 1 pass).
  • Delete the BASH_ENV/ENV scrub: only the BASH_ENV case fails (startup-file-ran … nested=[1]) (1 fail / 3 pass).
  • Drop only --noprofile --norc (bare -c): stays green, as expected for belt-and-braces flags (see above).

The four existing cases (bash returns output without a command echo): with the host HOME, 4 pass. With a noisy developer HOME (.bash_profile/.profile sourcing a missing file), 4 pass on this branch. With -l restored, the same 4 fail with …/.bash_profile: line 2: /nonexistent/env-file: No such file or directory, which is the issue's report reproduced.

Suites on head 9dddf96 (base c670449d3; each heavy run through the host slot wrapper):

Run Result
bun run test packages/ui-backend-pi 506 pass / 0 fail
bun run test packages/ui-backend-claude exit 0
bun run test packages/ui-sdk exit 0
bun run test packages/ui-server 4051 pass / 0 fail
bun run test ./tests 1966 pass / 0 fail, exit 0 (the two (fail) lines in its log are the runner harness's deliberately failing penelope/odysseus fixture children)
bun run check:pr --base origin/main passed: lint/leakage, env docs, changeset, affected fast invariants (13 files, 493 pass / 0 fail)
bun scripts/prove-native-host.ts --backend=pi (native lock, scratch worktree) exit 0 for app UID 1000 and UID 0; pi drives its real SDK session and Bash tool, so the new argv runs in the hosted worker

No base comparison was needed: nothing failed. origin/main has since gained #2916 and #2949, which touch ui-server and docs only. The PR still merges cleanly.

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof.
  • Acceptance criteria from the issue are all met, or the gap is named below.
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included (.changeset/pi-bash-no-login-shell.md, patch).
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • Nothing in the machine surface moved (no --json, MCP or schema change; the tool result shape is unchanged).
  • Docs reflect the change (packages/ui-backend-pi/README.md, exec-wrapper section).
  • Provider history stays append-only: the tool result keeps its shape. It now omits profile noise, and that only changes new tool results.

Anything left open

The bash tool spawned `bash -lc`, so /etc/profile and the first of
~/.bash_profile, ~/.bash_login or ~/.profile under the child's HOME ran
before every command. Their output reached the model-visible result and
anything they exported, PATH included, overrode the allowlisted
subprocess environment (a profile `export GH_TOKEN=...` reached the
command although the allowlist removed the host value).

Run `bash --noprofile --norc -c` instead and remove BASH_ENV and ENV from
the command's environment even when an operator admits them, so neither
the command nor a shell it starts sources a startup file. The command now
sees exactly the filtered environment.

Tests run the real tool with a fixture HOME whose profiles print, export
GH_TOKEN/BRAIN_TEST_PROFILE_LEAK and prepend a planted PATH entry, and
with an admitted BASH_ENV/ENV.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@schlessera schlessera added this to the 0.44.0 milestone Oct 26, 2026
@schlessera schlessera added type: fix Something is broken and must behave the way it is documented. area: backends AgentBackend implementations and the seam they share. security Auth, isolation, egress, secrets, or anything in the threat model. labels Oct 26, 2026
@schlessera
schlessera marked this pull request as ready for review October 26, 2026 06:26
@schlessera
schlessera merged commit 2d4671a into main Oct 26, 2026
15 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backends AgentBackend implementations and the seam they share. security Auth, isolation, egress, secrets, or anything in the threat model. type: fix Something is broken and must behave the way it is documented.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui-backend-pi: the bash tool runs a login shell, so host profile output reaches the model and may override the allowlisted environment

1 participant