Summary
Agent worker processes (codex, opencode, claude, …) inherit the daemon's full os.Environ() with no sanitization. Per-project/role env config only layers on top of the inherited environment (role wins on collision) rather than replacing it, so a user cannot remove an inherited variable from a worker — only override it.
How this was found. A codex worker spawned into a worktree failed to connect to its API (stream disconnected / request timed out), while the same codex run from the user's own shell worked. The host runs a forward HTTP proxy on 127.0.0.1:8118 (exported via HTTP_PROXY/HTTPS_PROXY). Tracing showed the worker process was picking up the host/desktop proxy env rather than the user's shell setup — which led into the env-forwarding code below, and from there to the broader concern described at the end.
Reproduction (codex + local HTTP proxy):
- Host has a forward HTTP proxy on
127.0.0.1:8118 exported via HTTP_PROXY/HTTPS_PROXY in the environment that launches AO.
codex launched from the user's own shell (with a working proxy) — works.
ao spawn --agent codex … into a worktree — fails to connect: stream disconnected / request timed out.
Code path & feature detail (current main). The environment a worker runs in is assembled across several layers, all additive — nothing filters by content:
- Daemon reconstructs a login-shell env at startup.
frontend/src/main.ts (daemonEnv, ~:465-486) + frontend/src/shared/shell-env.ts (:81-90) run zsh -ilc 'env -0', parse the full block, and use it as the daemon's base env — so anything exported in ~/.zshrc/~/.zprofile reaches the daemon even when the GUI was launched from Finder/Dock.
- Workers inherit
os.Environ() of the daemon. backend/internal/adapters/runtime/tmux/tmux.go:68 (cmd.Env = append(os.Environ(), env...)) and :295 (attachEnv(os.Environ())). attachEnv (:324) only rewrites TERM; every other var passes through unchanged. On Windows, backend/internal/adapters/runtime/conpty/spawn_windows.go:83 merges os.Environ() + cfg env the same way.
- Config env is a free-form, unvalidated map layered on top.
backend/internal/domain/projectconfig.go:28 (ProjectConfig.Env) and backend/internal/domain/agentconfig.go:46 (AgentConfig.Env) are map[string]string; Validate() (agentconfig.go:82-102) checks Permissions/MCP/PluginDirs but not env keys. backend/internal/adapters/runtime/tmux/tmux.go:484-507 (validateEnvKeys) only enforces key syntax ([A-Za-z_][A-Za-z0-9_]*), not content.
- Merge semantics: layer, not replace.
backend/internal/session_manager/manager.go:539,2170-2179 (mergeEnv) overlays role→project env on top of the inherited env; a user can override an inherited value but cannot delete an inherited variable from a worker. Only AO-internal keys (AO_SESSION_ID, AO_PROJECT_ID, …) and PATH/GH_PATH are pinned against override.
Net effect: every worker sees the daemon's full reconstructed environment, workers of different projects are not isolated by environment, and per-project/role env can only add/override — never narrow.
Architectural note / severity. I don't think this is a flaw introduced by AO — it reads as a property of this whole class of orchestrator designs, where the shell environment is forwarded down to workers so a GUI-launched daemon can find tmux/git/credentials (docs/daemon-environment.md frames forwarding as correct for exactly that UX reason). That said, I personally consider this CRITICAL. The combination "a worker inherits the daemon's environment wholesale and a worker's behavior is driven by config files (env / MCP / plugins)" is, in my assessment, a high-severity security concern — not a cosmetic env-hygiene issue — and it's worth a deliberate threat-model decision (env-key allowlist / per-worker secret scoping / replace-instead-of-merge) rather than leaving it as an incidental side effect of env forwarding. I'm intentionally not posting the specifics of why I rate it critical in this public issue. I'm ready to share the details privately with a maintainer if there's a private channel (a SECURITY.md / responsible-disclosure process), or to set one up if none exists yet.
Environment: macOS, current main (post #2838, #2847); reproduces conceptually for any proxy-aware agent.
Summary
Agent worker processes (codex, opencode, claude, …) inherit the daemon's full
os.Environ()with no sanitization. Per-project/roleenvconfig only layers on top of the inherited environment (role wins on collision) rather than replacing it, so a user cannot remove an inherited variable from a worker — only override it.How this was found. A
codexworker spawned into a worktree failed to connect to its API (stream disconnected/request timed out), while the samecodexrun from the user's own shell worked. The host runs a forward HTTP proxy on127.0.0.1:8118(exported viaHTTP_PROXY/HTTPS_PROXY). Tracing showed the worker process was picking up the host/desktop proxy env rather than the user's shell setup — which led into the env-forwarding code below, and from there to the broader concern described at the end.Reproduction (codex + local HTTP proxy):
127.0.0.1:8118exported viaHTTP_PROXY/HTTPS_PROXYin the environment that launches AO.codexlaunched from the user's own shell (with a working proxy) — works.ao spawn --agent codex …into a worktree — fails to connect:stream disconnected/request timed out.Code path & feature detail (current
main). The environment a worker runs in is assembled across several layers, all additive — nothing filters by content:frontend/src/main.ts(daemonEnv, ~:465-486) +frontend/src/shared/shell-env.ts(:81-90) runzsh -ilc 'env -0', parse the full block, and use it as the daemon's base env — so anything exported in~/.zshrc/~/.zprofilereaches the daemon even when the GUI was launched from Finder/Dock.os.Environ()of the daemon.backend/internal/adapters/runtime/tmux/tmux.go:68(cmd.Env = append(os.Environ(), env...)) and:295(attachEnv(os.Environ())).attachEnv(:324) only rewritesTERM; every other var passes through unchanged. On Windows,backend/internal/adapters/runtime/conpty/spawn_windows.go:83mergesos.Environ()+ cfg env the same way.backend/internal/domain/projectconfig.go:28(ProjectConfig.Env) andbackend/internal/domain/agentconfig.go:46(AgentConfig.Env) aremap[string]string;Validate()(agentconfig.go:82-102) checksPermissions/MCP/PluginDirsbut not env keys.backend/internal/adapters/runtime/tmux/tmux.go:484-507(validateEnvKeys) only enforces key syntax ([A-Za-z_][A-Za-z0-9_]*), not content.backend/internal/session_manager/manager.go:539,2170-2179(mergeEnv) overlays role→project env on top of the inherited env; a user can override an inherited value but cannot delete an inherited variable from a worker. Only AO-internal keys (AO_SESSION_ID,AO_PROJECT_ID, …) andPATH/GH_PATHare pinned against override.Net effect: every worker sees the daemon's full reconstructed environment, workers of different projects are not isolated by environment, and per-project/role
envcan only add/override — never narrow.Architectural note / severity. I don't think this is a flaw introduced by AO — it reads as a property of this whole class of orchestrator designs, where the shell environment is forwarded down to workers so a GUI-launched daemon can find
tmux/git/credentials (docs/daemon-environment.mdframes forwarding as correct for exactly that UX reason). That said, I personally consider this CRITICAL. The combination "a worker inherits the daemon's environment wholesale and a worker's behavior is driven by config files (env / MCP / plugins)" is, in my assessment, a high-severity security concern — not a cosmetic env-hygiene issue — and it's worth a deliberate threat-model decision (env-key allowlist / per-worker secret scoping / replace-instead-of-merge) rather than leaving it as an incidental side effect of env forwarding. I'm intentionally not posting the specifics of why I rate it critical in this public issue. I'm ready to share the details privately with a maintainer if there's a private channel (aSECURITY.md/ responsible-disclosure process), or to set one up if none exists yet.Environment: macOS, current
main(post #2838, #2847); reproduces conceptually for any proxy-aware agent.