From 03b1244bfb6c24882b96806f0d6eacd8764b9b39 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:17:10 +0000 Subject: [PATCH 1/5] fix(cli): confine apply/show writes and scrub the host env from detached runs (#1082) The MCP surface refused a write outside AGENTDESCENT_SURFACE_WRITE_DIRS (#1018), but `agentdescent apply` passed no check, so a host worker that could not write through the tool could still write through the CLI. Pass the same allowlist check at the CLI's write-advertising paths: `apply` (run and `--from`, dry run included) and `show --diff`'s `apply_plan`, which now carries the refusal instead of a path the worker cannot use. Unset means unchanged CLI behaviour; an empty value refuses every write. `runstore.launch` also copied all of `os.environ` into the detached run, so the worker session's `DSH_*`/`CLAUDE*`/`CODEX*`/`MCP_*` variables -- including its MCP server config -- reached the run. Drop those prefixes with `surface_common.scrub_host_env`; the API keys and PATH the run needs still pass through. Refs #1082: the per-Ticket budget-cap row is not in this change. --- agentdescent/cli.py | 39 ++++++++- agentdescent/runstore.py | 4 +- agentdescent/surface_common.py | 20 +++++ changelog.d/1082-cli-write-dirs-host-env.md | 9 ++ docs/dsh-super-agent.md | 10 +++ tests/test_host_env_scrub.py | 69 +++++++++++++++ tests/test_surface_write_dirs_cli.py | 96 +++++++++++++++++++++ 7 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1082-cli-write-dirs-host-env.md create mode 100644 tests/test_host_env_scrub.py create mode 100644 tests/test_surface_write_dirs_cli.py diff --git a/agentdescent/cli.py b/agentdescent/cli.py index de6eacf3..97ce9670 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -2887,7 +2887,9 @@ def _partial_show(rd: "runstore.RunDir", st: Any) -> Dict[str, Any]: def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, - max_chars: int = 40_000) -> Dict[str, Any]: + max_chars: int = 40_000, + check_write: Optional[Callable[[str, str], Any]] = None + ) -> Dict[str, Any]: run_id = resolve_run_id(run_id, store=store) or run_id rd = runstore.get(run_id, store=store) st = rd.status() @@ -2957,8 +2959,14 @@ def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, "chars": len(result.rendered)} return payload if spec.kind == "text": + target = os.path.expanduser(spec.target) payload["rendered"] = result.rendered[:max_chars] - payload["apply_plan"] = _text_plan(os.path.expanduser(spec.target), result.rendered) + try: + if check_write is not None: + check_write(target, "to") + payload["apply_plan"] = _text_plan(target, result.rendered) + except Exception as e: # noqa: BLE001 - the plan carries its own refusal + payload["apply_plan"] = {"error": str(e)} return payload payload["files"] = sorted(result.state) if diff: @@ -2969,14 +2977,17 @@ def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, before, payload["diff_note"] = {}, f"could not read original {spec.target}: {e}" payload["diff"] = _tree_diff(before, result.state)[:max_chars] try: - payload["apply_plan"] = result.write_to(os.path.expanduser(spec.target), dry_run=True) + payload["apply_plan"] = result._write_to( + os.path.expanduser(spec.target), dry_run=True, + check_write=check_write) except Exception as e: # noqa: BLE001 payload["apply_plan"] = {"error": str(e)} return payload def cmd_show(a: argparse.Namespace) -> int: - payload = show_payload(a.run_id, store=a.store, diff=a.diff) + payload = show_payload(a.run_id, store=a.store, diff=a.diff, + check_write=_surface_write_check) if a.json: _out(payload, as_json=True) return 0 @@ -3237,6 +3248,25 @@ def apply_with_target(run_id: Optional[str] = None, *, to: Optional[str] = None, notes, f"run {run_id}") +def _surface_write_check(path: str, name: str) -> None: + """Enforce the operator's write allowlist on a CLI write path (#1082). + + Opt-in like the rest of ``AGENTDESCENT_SURFACE_WRITE_DIRS``: unset leaves the + CLI exactly as it was, and an empty value refuses every write -- the reading + the MCP surface gives it. Raising the store's error keeps ``main``'s exit-2 + envelope, so a worker sees the refusal rather than a traceback. + """ + if "AGENTDESCENT_SURFACE_WRITE_DIRS" not in os.environ: + return + from .surface_common import (SurfaceError, ensure_write_path, + surface_write_dirs_from_env) + + try: + ensure_write_path(path, name=name, allow=surface_write_dirs_from_env()) + except SurfaceError as e: + raise runstore.RunStoreError(str(e)) from None + + def _refuse_dest_link(top: str, path: str) -> None: """Refuse a link planted in the destination, before any backup or write (#977). @@ -3529,6 +3559,7 @@ def cmd_apply(a: argparse.Namespace) -> int: home=getattr(a, "home", None), dry_run=a.dry_run, backup=not a.no_backup, + check_write=_surface_write_check, ) except runstore.RunStoreError as e: if getattr(a, "json", False): diff --git a/agentdescent/runstore.py b/agentdescent/runstore.py index 5c107b92..743c1525 100644 --- a/agentdescent/runstore.py +++ b/agentdescent/runstore.py @@ -51,6 +51,7 @@ from typing import Any, Dict, Iterator, List, Optional, Sequence from .evolution import EvolutionResult, RoundInfo +from .surface_common import scrub_host_env __all__ = [ "STATES", @@ -678,7 +679,8 @@ def launch(rd: RunDir, *, budget_usd: Optional[float] = None, argv += ["--budget", str(budget_usd)] if usd_per_call is not None: argv += ["--usd-per-call", str(usd_per_call)] - child_env = {**os.environ, "PYTHONPATH": _child_pythonpath(), **(env or {})} + child_env = {**scrub_host_env(), "PYTHONPATH": _child_pythonpath(), + **(env or {})} boot = subprocess.run([_python(), "-c", _BOOTSTRAP, rd.log_path, *argv], capture_output=True, text=True, cwd=rd.path, env=child_env) if boot.returncode != 0 or not boot.stdout.strip().isdigit(): diff --git a/agentdescent/surface_common.py b/agentdescent/surface_common.py index 3b3e82da..c3fa46ec 100644 --- a/agentdescent/surface_common.py +++ b/agentdescent/surface_common.py @@ -38,6 +38,7 @@ "resolve_ref", "surface_allow_from_env", "surface_write_dirs_from_env", + "scrub_host_env", ] SURFACE_SCHEMA_VERSION = 1 @@ -200,6 +201,25 @@ def surface_write_dirs_from_env(environ: Optional[Mapping[str, str]] = None return tuple(part.strip() for part in raw.split(",") if part.strip()) +#: Host-session variables a child must not inherit (#1082): the worker's own +#: identity, its MCP server configuration, and the harness markers around them. +_HOST_MARKER_PREFIXES = ("DSH_", "CLAUDE", "CODEX", "MCP_") + + +def scrub_host_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str]: + """Drop the host session's markers and MCP config from a child environment. + + The candidate's gates run under an allowlist (:func:`runners._child_env`), + but a detached run is our own process: it needs the API keys and ``PATH`` + that an allowlist would drop. A denylist of the host-session prefixes keeps + the credentials while the worker's identity -- and any MCP config that would + hand the child a second set of tools -- stays with the host (#1082). + """ + env = dict(os.environ if environ is None else environ) + return {name: value for name, value in env.items() + if not name.upper().startswith(_HOST_MARKER_PREFIXES)} + + def _allowed_module(module_name: str, allow: Optional[Sequence[str]]) -> bool: if allow is None: return True diff --git a/changelog.d/1082-cli-write-dirs-host-env.md b/changelog.d/1082-cli-write-dirs-host-env.md new file mode 100644 index 00000000..70631237 --- /dev/null +++ b/changelog.d/1082-cli-write-dirs-host-env.md @@ -0,0 +1,9 @@ +Fixed +`agentdescent apply` and `show --diff` now obey +`AGENTDESCENT_SURFACE_WRITE_DIRS` instead of only the MCP surface doing so: a +destination outside the operator's allowlist is refused before the first byte, +and the advertised `apply_plan` carries that refusal rather than a path the +worker cannot write. Unset keeps the CLI as it was; an empty value refuses +every write. A detached run also no longer inherits the host session's +`DSH_*`, `CLAUDE*`, `CODEX*` or `MCP_*` variables, while the credentials it +needs still pass through (#1082). diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index d1ba8066..a61f1b1a 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -96,6 +96,12 @@ your tree, under the operator's write allowlist). default is the server's cwd plus `$AGENTDESCENT_HOME` (`agentdescent/surface_common.py:178-190`); an empty value refuses every MCP write path, which is the safest setting for a worker that should only plan. + `agentdescent apply` and `show --diff` obey the same variable from the + release that carries + [`#1082`](https://github.com/ThinkFlowLab/agentdescent/issues/1082): a + destination outside the allowlist is refused before the first byte, and + `show --diff` advertises the refusal instead of a plan the worker cannot + apply. Unset means unchanged CLI behaviour. `algorithm_spec` obeys this allowlist from the release that carries [`#1018`](https://github.com/ThinkFlowLab/agentdescent/issues/1018) (fix in [`#1022`](https://github.com/ThinkFlowLab/agentdescent/pull/1022)). On an older @@ -106,6 +112,10 @@ your tree, under the operator's write allowlist). the Ticket. - `plan` is the budget line: it estimates cost without executing. Read it before `start`, and put the number in the Ticket. +- A detached run no longer inherits the worker session: `launch` drops the + `DSH_*`, `CLAUDE*`, `CODEX*` and `MCP_*` variables before it starts the + grandchild, so the worker's session identity and MCP config stay with the + host while the API keys the run needs are passed through (`#1082`). ## 6. Long runs, Reports and Attention diff --git a/tests/test_host_env_scrub.py b/tests/test_host_env_scrub.py new file mode 100644 index 00000000..9483336e --- /dev/null +++ b/tests/test_host_env_scrub.py @@ -0,0 +1,69 @@ +"""A detached run does not inherit the host session's identity or MCP config (#1082). + +The worker's own gates run under an allowlist (:func:`runners._child_env`), but +the run `launch` detaches is our process, so it needs the API keys and PATH the +allowlist would drop. The cure is a denylist of the host-session variables: +`DSH_*`, `CLAUDE*`, `CODEX*` and `MCP_*` stay with the host, everything else -- +including the credentials the run needs -- is passed through. +""" + +from __future__ import annotations + +import os +import subprocess + +from agentdescent import runstore +from agentdescent.surface_common import scrub_host_env + +HOST_MARKERS = { + "DSH_SESSION_ID": "host-session", + "DSH_WORKSPACE": "/host/workspace", + "CLAUDE_CODE_ENTRYPOINT": "cli", + "CLAUDECODE": "1", + "CODEX_SANDBOX": "1", + "MCP_SERVERS": '{"filesystem": {}}', +} + + +def test_scrub_drops_host_markers_and_keeps_what_the_run_needs(monkeypatch): + for name, value in HOST_MARKERS.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("ARK_API_KEY", "not-a-real-key") + + env = scrub_host_env(os.environ) + + assert [k for k in env if k.upper().startswith(("DSH_", "CLAUDE", "CODEX", "MCP_"))] == [] + assert env["ARK_API_KEY"] == "not-a-real-key" + assert env["PATH"] == os.environ["PATH"] + + +def test_scrub_does_not_mutate_the_caller(monkeypatch): + monkeypatch.setenv("DSH_SESSION_ID", "host-session") + + env = scrub_host_env(os.environ) + + assert env is not os.environ + assert os.environ["DSH_SESSION_ID"] == "host-session" + + +def test_launch_hands_the_child_a_scrubbed_environment(tmp_path, monkeypatch): + monkeypatch.setenv("DSH_SESSION_ID", "host-session") + monkeypatch.setenv("MCP_SERVERS", '{"filesystem": {}}') + monkeypatch.setenv("ARK_API_KEY", "not-a-real-key") + rd = runstore.create({"kind": "text", "target": str(tmp_path / "out.md")}, + store=str(tmp_path / "store")) + seen = {} + + def fake_run(argv, **kwargs): + seen["env"] = kwargs.get("env") + return subprocess.CompletedProcess(argv, 0, stdout="4242\n", stderr="") + + monkeypatch.setattr(runstore.subprocess, "run", fake_run) + + st = runstore.launch(rd) + + assert st.pid == 4242 + env = seen["env"] + assert "DSH_SESSION_ID" not in env and "MCP_SERVERS" not in env + assert env["ARK_API_KEY"] == "not-a-real-key" + assert env["PYTHONPATH"] # the launcher still makes the package importable diff --git a/tests/test_surface_write_dirs_cli.py b/tests/test_surface_write_dirs_cli.py new file mode 100644 index 00000000..991b222b --- /dev/null +++ b/tests/test_surface_write_dirs_cli.py @@ -0,0 +1,96 @@ +"""The CLI obeys ``AGENTDESCENT_SURFACE_WRITE_DIRS`` on its write paths (#1082). + +The MCP surface refused a write outside the operator's allowlist (#1018), but +`agentdescent apply` passed no check, so a host worker that could not write +through the tool could still write through the CLI. Enforcement is opt-in, +like the rest of the variable: unset leaves the CLI as it was, and an empty +value refuses every write -- the reading the MCP surface gives it. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +from agentdescent import cli + + +def _artifact(tmp_path) -> "os.PathLike[str]": + source = tmp_path / "artifact" + source.mkdir() + (source / "rules.md").write_text("evolved\n", encoding="utf-8") + return source + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + monkeypatch.setenv("AGENTDESCENT_HOME", str(tmp_path / "home")) + + +@pytest.mark.parametrize("dry_run", [False, True]) +def test_apply_refuses_a_destination_outside_the_write_dirs( + tmp_path, monkeypatch, capsys, dry_run): + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", str(allowed)) + dest = tmp_path / "outside" + argv = ["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)] + if dry_run: + argv += ["--dry-run", "--json"] + + assert cli.main(argv) == 2 + + captured = capsys.readouterr() + if dry_run: + assert "write allowlist" in json.loads(captured.out)["error"] + else: + assert "write allowlist" in captured.err + assert not dest.exists() + + +def test_an_empty_value_refuses_every_write(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", "") + dest = tmp_path / "anywhere" + + assert cli.main(["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)]) == 2 + + assert "write allowlist" in capsys.readouterr().err + assert not dest.exists() + + +def test_apply_inside_the_write_dirs_still_writes(tmp_path, monkeypatch, capsys): + allowed = tmp_path / "allowed" + monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", str(allowed)) + dest = allowed / "written" + + assert cli.main(["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)]) == 0 + + assert (dest / "rules.md").read_text(encoding="utf-8") == "evolved\n" + + +def test_unset_means_unchanged(tmp_path, monkeypatch): + monkeypatch.delenv("AGENTDESCENT_SURFACE_WRITE_DIRS", raising=False) + dest = tmp_path / "unconstrained" + + assert cli.main(["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)]) == 0 + + assert (dest / "rules.md").read_text(encoding="utf-8") == "evolved\n" + + +def test_show_refuses_to_plan_a_write_outside_the_write_dirs(tmp_path, monkeypatch, capsys): + """`show --diff` must not advertise a plan the host is not allowed to apply.""" + from tests.test_skill_library_apply_install import _make_library_run + + store, run_id = _make_library_run(tmp_path) + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", str(allowed)) + + assert cli.main(["--store", store, "show", run_id, "--diff", "--json"]) == 0 + + payload = json.loads(capsys.readouterr().out) + assert "write allowlist" in payload["apply_plan"]["error"] + # `show` never writes; the refusal is in the plan it advertises. + assert not (allowed / "seed-lib").exists() From b5b66bf87a684612913ee80d6bf0c6f54cc5c6dd Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:50:26 +0000 Subject: [PATCH 2/5] fix(runstore): scrub the host session out of a detached run's env (#1082) Row 3 of #1082: the run `launch` detaches is our own process, so it inherits all of os.environ -- including the worker session's DSH_*/CLAUDE*/CODEX*/MCP_* variables and its MCP server config. Drop those prefixes with surface_common.scrub_host_env; the API keys and PATH the run needs still pass through. Row 1 (the CLI write allowlist) already has its own carrier in #1087, so this change does not touch it. Refs #1082: the per-Ticket budget-cap row is still open. --- agentdescent/cli.py | 39 +-------- changelog.d/1082-cli-write-dirs-host-env.md | 9 -- changelog.d/1082-host-env-scrub.md | 6 ++ docs/dsh-super-agent.md | 6 -- tests/test_surface_write_dirs_cli.py | 96 --------------------- 5 files changed, 10 insertions(+), 146 deletions(-) delete mode 100644 changelog.d/1082-cli-write-dirs-host-env.md create mode 100644 changelog.d/1082-host-env-scrub.md delete mode 100644 tests/test_surface_write_dirs_cli.py diff --git a/agentdescent/cli.py b/agentdescent/cli.py index 97ce9670..de6eacf3 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -2887,9 +2887,7 @@ def _partial_show(rd: "runstore.RunDir", st: Any) -> Dict[str, Any]: def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, - max_chars: int = 40_000, - check_write: Optional[Callable[[str, str], Any]] = None - ) -> Dict[str, Any]: + max_chars: int = 40_000) -> Dict[str, Any]: run_id = resolve_run_id(run_id, store=store) or run_id rd = runstore.get(run_id, store=store) st = rd.status() @@ -2959,14 +2957,8 @@ def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, "chars": len(result.rendered)} return payload if spec.kind == "text": - target = os.path.expanduser(spec.target) payload["rendered"] = result.rendered[:max_chars] - try: - if check_write is not None: - check_write(target, "to") - payload["apply_plan"] = _text_plan(target, result.rendered) - except Exception as e: # noqa: BLE001 - the plan carries its own refusal - payload["apply_plan"] = {"error": str(e)} + payload["apply_plan"] = _text_plan(os.path.expanduser(spec.target), result.rendered) return payload payload["files"] = sorted(result.state) if diff: @@ -2977,17 +2969,14 @@ def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, before, payload["diff_note"] = {}, f"could not read original {spec.target}: {e}" payload["diff"] = _tree_diff(before, result.state)[:max_chars] try: - payload["apply_plan"] = result._write_to( - os.path.expanduser(spec.target), dry_run=True, - check_write=check_write) + payload["apply_plan"] = result.write_to(os.path.expanduser(spec.target), dry_run=True) except Exception as e: # noqa: BLE001 payload["apply_plan"] = {"error": str(e)} return payload def cmd_show(a: argparse.Namespace) -> int: - payload = show_payload(a.run_id, store=a.store, diff=a.diff, - check_write=_surface_write_check) + payload = show_payload(a.run_id, store=a.store, diff=a.diff) if a.json: _out(payload, as_json=True) return 0 @@ -3248,25 +3237,6 @@ def apply_with_target(run_id: Optional[str] = None, *, to: Optional[str] = None, notes, f"run {run_id}") -def _surface_write_check(path: str, name: str) -> None: - """Enforce the operator's write allowlist on a CLI write path (#1082). - - Opt-in like the rest of ``AGENTDESCENT_SURFACE_WRITE_DIRS``: unset leaves the - CLI exactly as it was, and an empty value refuses every write -- the reading - the MCP surface gives it. Raising the store's error keeps ``main``'s exit-2 - envelope, so a worker sees the refusal rather than a traceback. - """ - if "AGENTDESCENT_SURFACE_WRITE_DIRS" not in os.environ: - return - from .surface_common import (SurfaceError, ensure_write_path, - surface_write_dirs_from_env) - - try: - ensure_write_path(path, name=name, allow=surface_write_dirs_from_env()) - except SurfaceError as e: - raise runstore.RunStoreError(str(e)) from None - - def _refuse_dest_link(top: str, path: str) -> None: """Refuse a link planted in the destination, before any backup or write (#977). @@ -3559,7 +3529,6 @@ def cmd_apply(a: argparse.Namespace) -> int: home=getattr(a, "home", None), dry_run=a.dry_run, backup=not a.no_backup, - check_write=_surface_write_check, ) except runstore.RunStoreError as e: if getattr(a, "json", False): diff --git a/changelog.d/1082-cli-write-dirs-host-env.md b/changelog.d/1082-cli-write-dirs-host-env.md deleted file mode 100644 index 70631237..00000000 --- a/changelog.d/1082-cli-write-dirs-host-env.md +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -`agentdescent apply` and `show --diff` now obey -`AGENTDESCENT_SURFACE_WRITE_DIRS` instead of only the MCP surface doing so: a -destination outside the operator's allowlist is refused before the first byte, -and the advertised `apply_plan` carries that refusal rather than a path the -worker cannot write. Unset keeps the CLI as it was; an empty value refuses -every write. A detached run also no longer inherits the host session's -`DSH_*`, `CLAUDE*`, `CODEX*` or `MCP_*` variables, while the credentials it -needs still pass through (#1082). diff --git a/changelog.d/1082-host-env-scrub.md b/changelog.d/1082-host-env-scrub.md new file mode 100644 index 00000000..dce8f014 --- /dev/null +++ b/changelog.d/1082-host-env-scrub.md @@ -0,0 +1,6 @@ +Fixed +A detached run no longer inherits the worker's own session: `runstore.launch` +drops the `DSH_*`, `CLAUDE*`, `CODEX*` and `MCP_*` variables before it starts the +grandchild, so the host worker's session identity and MCP server configuration +stay with the host while the credentials and `PATH` the run needs still pass +through (#1082). diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index a61f1b1a..305337b3 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -96,12 +96,6 @@ your tree, under the operator's write allowlist). default is the server's cwd plus `$AGENTDESCENT_HOME` (`agentdescent/surface_common.py:178-190`); an empty value refuses every MCP write path, which is the safest setting for a worker that should only plan. - `agentdescent apply` and `show --diff` obey the same variable from the - release that carries - [`#1082`](https://github.com/ThinkFlowLab/agentdescent/issues/1082): a - destination outside the allowlist is refused before the first byte, and - `show --diff` advertises the refusal instead of a plan the worker cannot - apply. Unset means unchanged CLI behaviour. `algorithm_spec` obeys this allowlist from the release that carries [`#1018`](https://github.com/ThinkFlowLab/agentdescent/issues/1018) (fix in [`#1022`](https://github.com/ThinkFlowLab/agentdescent/pull/1022)). On an older diff --git a/tests/test_surface_write_dirs_cli.py b/tests/test_surface_write_dirs_cli.py deleted file mode 100644 index 991b222b..00000000 --- a/tests/test_surface_write_dirs_cli.py +++ /dev/null @@ -1,96 +0,0 @@ -"""The CLI obeys ``AGENTDESCENT_SURFACE_WRITE_DIRS`` on its write paths (#1082). - -The MCP surface refused a write outside the operator's allowlist (#1018), but -`agentdescent apply` passed no check, so a host worker that could not write -through the tool could still write through the CLI. Enforcement is opt-in, -like the rest of the variable: unset leaves the CLI as it was, and an empty -value refuses every write -- the reading the MCP surface gives it. -""" - -from __future__ import annotations - -import json -import os - -import pytest - -from agentdescent import cli - - -def _artifact(tmp_path) -> "os.PathLike[str]": - source = tmp_path / "artifact" - source.mkdir() - (source / "rules.md").write_text("evolved\n", encoding="utf-8") - return source - - -@pytest.fixture(autouse=True) -def isolated_home(tmp_path, monkeypatch): - monkeypatch.setenv("AGENTDESCENT_HOME", str(tmp_path / "home")) - - -@pytest.mark.parametrize("dry_run", [False, True]) -def test_apply_refuses_a_destination_outside_the_write_dirs( - tmp_path, monkeypatch, capsys, dry_run): - allowed = tmp_path / "allowed" - allowed.mkdir() - monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", str(allowed)) - dest = tmp_path / "outside" - argv = ["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)] - if dry_run: - argv += ["--dry-run", "--json"] - - assert cli.main(argv) == 2 - - captured = capsys.readouterr() - if dry_run: - assert "write allowlist" in json.loads(captured.out)["error"] - else: - assert "write allowlist" in captured.err - assert not dest.exists() - - -def test_an_empty_value_refuses_every_write(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", "") - dest = tmp_path / "anywhere" - - assert cli.main(["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)]) == 2 - - assert "write allowlist" in capsys.readouterr().err - assert not dest.exists() - - -def test_apply_inside_the_write_dirs_still_writes(tmp_path, monkeypatch, capsys): - allowed = tmp_path / "allowed" - monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", str(allowed)) - dest = allowed / "written" - - assert cli.main(["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)]) == 0 - - assert (dest / "rules.md").read_text(encoding="utf-8") == "evolved\n" - - -def test_unset_means_unchanged(tmp_path, monkeypatch): - monkeypatch.delenv("AGENTDESCENT_SURFACE_WRITE_DIRS", raising=False) - dest = tmp_path / "unconstrained" - - assert cli.main(["apply", "--from", str(_artifact(tmp_path)), "--to", str(dest)]) == 0 - - assert (dest / "rules.md").read_text(encoding="utf-8") == "evolved\n" - - -def test_show_refuses_to_plan_a_write_outside_the_write_dirs(tmp_path, monkeypatch, capsys): - """`show --diff` must not advertise a plan the host is not allowed to apply.""" - from tests.test_skill_library_apply_install import _make_library_run - - store, run_id = _make_library_run(tmp_path) - allowed = tmp_path / "allowed" - allowed.mkdir() - monkeypatch.setenv("AGENTDESCENT_SURFACE_WRITE_DIRS", str(allowed)) - - assert cli.main(["--store", store, "show", run_id, "--diff", "--json"]) == 0 - - payload = json.loads(capsys.readouterr().out) - assert "write allowlist" in payload["apply_plan"]["error"] - # `show` never writes; the refusal is in the plan it advertises. - assert not (allowed / "seed-lib").exists() From c5f021e98c6ce2e6591941a27f18c97efcdb5ec4 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 04:55:59 +0000 Subject: [PATCH 3/5] fix(runstore): share one host-session denylist and re-scrub the merged env (#1082) `scrub_host_env` carried its own four prefixes while the candidate worker (`agents.worker_env`) dropped eleven markers, so five of the eleven crossed the scrub: `OPENAI_AGENT_*`, `OPENCODE_CONFIG`, `OPENCODE_CONFIG_CONTENT`, `OPENCODE_CONFIG_DIR` and `XDG_CONFIG_HOME`. The two OpenCode config carriers are the ones measured to hand a nested process the parent's MCP servers, which is the recursion this guard exists for. The tuple now lives in `surface_common` (the leaf) and `agents` imports it, so there is one list. `launch` also scrubs the merged dict: `env=` is spread last and could put `MCP_*`/`DSH_*` back in after the scrub. The test walks `SESSION_MARKERS` instead of naming variables, so a marker added for a new host is covered the day it is added. --- agentdescent/agents.py | 31 +++----------- agentdescent/runstore.py | 8 +++- agentdescent/surface_common.py | 59 +++++++++++++++++++++++--- changelog.d/1082-host-env-scrub.md | 7 +++- docs/dsh-super-agent.md | 8 ++-- tests/test_host_env_scrub.py | 67 ++++++++++++++++++++++++++++-- 6 files changed, 138 insertions(+), 42 deletions(-) diff --git a/agentdescent/agents.py b/agentdescent/agents.py index ba5603b5..acfe23b5 100644 --- a/agentdescent/agents.py +++ b/agentdescent/agents.py @@ -28,10 +28,14 @@ import urllib.request from dataclasses import dataclass, field from typing import (Any, Callable, Dict, List, Mapping, Optional, Protocol, - Sequence, Tuple, runtime_checkable) + Sequence, runtime_checkable) from .evolvable import ContractError from ._subprocess import decode_output +# The host-session denylist is shared with the detached-run scrub in +# ``surface_common``: it used to be two lists, and the shorter one let +# OpenCode/OpenAI-Agents markers through (#1082). +from .surface_common import SESSION_MARKERS, is_session_marker Completion = Callable[[str], str] @@ -270,28 +274,6 @@ def __call__(self, prompt: str) -> str: ... def in_workspace(self, path: str) -> Completion: ... -#: Environment variables a host agent sets in its own session and its children -#: inherit. A worker that sees them believes it is *inside* that session: -#: Claude Code refuses to start, DSH loads the parent's profile, and a worker -#: whose host runs this package's MCP server would call it recursively. -SESSION_MARKERS: Tuple[str, ...] = ( - "CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_CONFIG_DIR", "CODEX_", "DSH_", - "OPENAI_AGENT_", "MCP_", - # OpenCode reads three of these, and redirecting only the directory is not - # isolation: `OPENCODE_CONFIG` (a file) and `OPENCODE_CONFIG_CONTENT` - # (inline JSON) both win over `OPENCODE_CONFIG_DIR` -- measured, a config - # named by `OPENCODE_CONFIG` still supplied its MCP servers with the dir - # pointed at an empty directory. They are dropped by name rather than by an - # "OPENCODE_" prefix on purpose: `OPENCODE_API_KEY` is a provider - # credential, and a worker needs its keys. - "OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", - # Dropped so the redirect below can take: `setdefault` cannot override a - # value the parent already exported, and this one usually is exported. - # It is broader than the others -- every XDG-respecting tool the worker - # runs sees the workspace copy -- which is the point of an isolated worker. - "XDG_CONFIG_HOME", -) - #: Set for every worker so a tool the worker reaches (this package's own MCP #: server, when the plugin that hosts it is being evolved) can tell it is inside #: a run and refuse to start another. See ``agentdescent.mcp``. @@ -338,8 +320,7 @@ def worker_env(workspace: Optional[str], extra: Optional[Mapping[str, str]] = No the user's setup. """ env = {k: v for k, v in os.environ.items() - if not (isolate and any(k == m.rstrip("_") or k.startswith(m) - for m in SESSION_MARKERS))} + if not (isolate and is_session_marker(k))} env[NESTED_MARKER] = "1" if workspace: # `cwd=` moves the process, not `$PWD`, and a runtime that resolves its diff --git a/agentdescent/runstore.py b/agentdescent/runstore.py index 743c1525..c8984950 100644 --- a/agentdescent/runstore.py +++ b/agentdescent/runstore.py @@ -679,8 +679,12 @@ def launch(rd: RunDir, *, budget_usd: Optional[float] = None, argv += ["--budget", str(budget_usd)] if usd_per_call is not None: argv += ["--usd-per-call", str(usd_per_call)] - child_env = {**scrub_host_env(), "PYTHONPATH": _child_pythonpath(), - **(env or {})} + # Scrub the merged dict, not just the ambient one: a caller-supplied + # ``env`` is spread last and could otherwise put ``MCP_*``/``DSH_*`` back + # into the run after the scrub (#1082). ``PYTHONPATH`` carries no marker + # prefix, so it survives either way. + child_env = scrub_host_env({**os.environ, "PYTHONPATH": _child_pythonpath(), + **(env or {})}) boot = subprocess.run([_python(), "-c", _BOOTSTRAP, rd.log_path, *argv], capture_output=True, text=True, cwd=rd.path, env=child_env) if boot.returncode != 0 or not boot.stdout.strip().isdigit(): diff --git a/agentdescent/surface_common.py b/agentdescent/surface_common.py index c3fa46ec..1a1cc6a0 100644 --- a/agentdescent/surface_common.py +++ b/agentdescent/surface_common.py @@ -17,7 +17,8 @@ import json import os import tempfile -from typing import Any, Dict, Iterator, Mapping, Optional, Sequence, Type, TypeVar +from typing import (Any, Dict, Iterator, Mapping, Optional, Sequence, Tuple, + Type, TypeVar) __all__ = [ "SURFACE_SCHEMA_VERSION", @@ -39,6 +40,8 @@ "surface_allow_from_env", "surface_write_dirs_from_env", "scrub_host_env", + "SESSION_MARKERS", + "is_session_marker", ] SURFACE_SCHEMA_VERSION = 1 @@ -201,9 +204,50 @@ def surface_write_dirs_from_env(environ: Optional[Mapping[str, str]] = None return tuple(part.strip() for part in raw.split(",") if part.strip()) -#: Host-session variables a child must not inherit (#1082): the worker's own -#: identity, its MCP server configuration, and the harness markers around them. -_HOST_MARKER_PREFIXES = ("DSH_", "CLAUDE", "CODEX", "MCP_") +#: Environment variables a host agent sets in its own session and its children +#: inherit. A worker that sees them believes it is *inside* that session: Claude +#: Code refuses to start, DSH loads the parent's profile, and a worker whose +#: host runs this package's MCP server would call it recursively (#1082). +#: +#: One tuple, in this leaf module, because there used to be two lists: the +#: candidate worker (:func:`agentdescent.agents.worker_env`) dropped these +#: eleven names while :func:`scrub_host_env` dropped the four prefixes +#: ``DSH_``/``CLAUDE``/``CODEX``/``MCP_``. Measured on that pair, five of the +#: eleven crossed the scrub -- ``OPENAI_AGENT_*``, ``OPENCODE_CONFIG``, +#: ``OPENCODE_CONFIG_CONTENT``, ``OPENCODE_CONFIG_DIR`` and ``XDG_CONFIG_HOME`` +#: -- and the two OpenCode config carriers are the ones measured to hand a +#: nested process the parent's MCP servers. The tuple lives here rather than in +#: ``agents`` because ``agents`` imports ``socket``/``threading``/``urllib`` +#: and every surface module imports this one. +SESSION_MARKERS: Tuple[str, ...] = ( + "CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_CONFIG_DIR", "CODEX_", "DSH_", + "OPENAI_AGENT_", "MCP_", + # OpenCode reads three of these, and redirecting only the directory is not + # isolation: `OPENCODE_CONFIG` (a file) and `OPENCODE_CONFIG_CONTENT` + # (inline JSON) both win over `OPENCODE_CONFIG_DIR` -- measured, a config + # named by `OPENCODE_CONFIG` still supplied its MCP servers with the dir + # pointed at an empty directory. They are dropped by name rather than by an + # "OPENCODE_" prefix on purpose: `OPENCODE_API_KEY` is a provider + # credential, and a worker needs its keys. + "OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", + # Dropped so a redirect can take: `setdefault` cannot override a value the + # parent already exported, and this one usually is exported. It is broader + # than the others -- every XDG-respecting tool the child runs sees the + # workspace copy -- which is the point of an isolated worker. + "XDG_CONFIG_HOME", +) + + +def is_session_marker(name: str) -> bool: + """Whether ``name`` belongs to a host agent's session rather than to the run. + + ``CLAUDECODE`` is a bare name, not a prefix, so a marker matches when the + variable is the marker without its trailing underscore, or starts with the + marker itself. + """ + upper = name.upper() + return any(upper == marker.rstrip("_") or upper.startswith(marker) + for marker in SESSION_MARKERS) def scrub_host_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str]: @@ -211,13 +255,16 @@ def scrub_host_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str The candidate's gates run under an allowlist (:func:`runners._child_env`), but a detached run is our own process: it needs the API keys and ``PATH`` - that an allowlist would drop. A denylist of the host-session prefixes keeps + that an allowlist would drop. A denylist of the host-session variables keeps the credentials while the worker's identity -- and any MCP config that would hand the child a second set of tools -- stays with the host (#1082). + + The list is :data:`SESSION_MARKERS`, the same one the candidate worker + drops, so the two cannot drift apart again. """ env = dict(os.environ if environ is None else environ) return {name: value for name, value in env.items() - if not name.upper().startswith(_HOST_MARKER_PREFIXES)} + if not is_session_marker(name)} def _allowed_module(module_name: str, allow: Optional[Sequence[str]]) -> bool: diff --git a/changelog.d/1082-host-env-scrub.md b/changelog.d/1082-host-env-scrub.md index dce8f014..74f4687c 100644 --- a/changelog.d/1082-host-env-scrub.md +++ b/changelog.d/1082-host-env-scrub.md @@ -1,6 +1,9 @@ Fixed A detached run no longer inherits the worker's own session: `runstore.launch` -drops the `DSH_*`, `CLAUDE*`, `CODEX*` and `MCP_*` variables before it starts the +drops every `SESSION_MARKERS` variable -- `DSH_*`, `CLAUDE*`, `CODEX*`, `MCP_*`, +the OpenCode config carriers and `XDG_CONFIG_HOME` -- before it starts the grandchild, so the host worker's session identity and MCP server configuration stay with the host while the credentials and `PATH` the run needs still pass -through (#1082). +through (#1082). The list is the one the candidate worker already dropped +(`agentdescent.agents.worker_env`); it used to be a second, shorter copy, and +five of its eleven entries crossed the scrub. diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index 305337b3..dd9587df 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -106,10 +106,12 @@ your tree, under the operator's write allowlist). the Ticket. - `plan` is the budget line: it estimates cost without executing. Read it before `start`, and put the number in the Ticket. -- A detached run no longer inherits the worker session: `launch` drops the - `DSH_*`, `CLAUDE*`, `CODEX*` and `MCP_*` variables before it starts the +- A detached run no longer inherits the worker session: `launch` drops every + host-session marker (`SESSION_MARKERS`: `DSH_*`, `CLAUDE*`, `CODEX*`, `MCP_*`, + the OpenCode config carriers, `XDG_CONFIG_HOME`) before it starts the grandchild, so the worker's session identity and MCP config stay with the - host while the API keys the run needs are passed through (`#1082`). + host while the API keys the run needs are passed through (`#1082`). The list + is the one the candidate worker drops, not a second copy of it. ## 6. Long runs, Reports and Attention diff --git a/tests/test_host_env_scrub.py b/tests/test_host_env_scrub.py index 9483336e..ebd2be3d 100644 --- a/tests/test_host_env_scrub.py +++ b/tests/test_host_env_scrub.py @@ -2,9 +2,13 @@ The worker's own gates run under an allowlist (:func:`runners._child_env`), but the run `launch` detaches is our process, so it needs the API keys and PATH the -allowlist would drop. The cure is a denylist of the host-session variables: -`DSH_*`, `CLAUDE*`, `CODEX*` and `MCP_*` stay with the host, everything else -- -including the credentials the run needs -- is passed through. +allowlist would drop. The cure is a denylist -- ``SESSION_MARKERS``, the same +tuple the candidate worker drops -- so `DSH_*`, `CLAUDE*`, `CODEX*`, `MCP_*`, +the OpenCode config carriers and `XDG_CONFIG_HOME` stay with the host, and +everything else, including the credentials the run needs, is passed through. + +The parametrised row is the one that cannot go stale: it walks the tuple itself, +so a marker added for a new host is covered here the day it is added. """ from __future__ import annotations @@ -12,8 +16,11 @@ import os import subprocess +import pytest + from agentdescent import runstore -from agentdescent.surface_common import scrub_host_env +from agentdescent.agents import SESSION_MARKERS as AGENTS_SESSION_MARKERS +from agentdescent.surface_common import SESSION_MARKERS, scrub_host_env HOST_MARKERS = { "DSH_SESSION_ID": "host-session", @@ -24,6 +31,11 @@ "MCP_SERVERS": '{"filesystem": {}}', } +#: One variable per marker, spelled the way a host would set it: the bare name +#: when the marker ends in ``_`` (``CLAUDECODE``), a suffixed one otherwise. +def _host_variable(marker: str) -> str: + return marker if marker.endswith("_") else marker + "_PROBE" + def test_scrub_drops_host_markers_and_keeps_what_the_run_needs(monkeypatch): for name, value in HOST_MARKERS.items(): @@ -67,3 +79,50 @@ def fake_run(argv, **kwargs): assert "DSH_SESSION_ID" not in env and "MCP_SERVERS" not in env assert env["ARK_API_KEY"] == "not-a-real-key" assert env["PYTHONPATH"] # the launcher still makes the package importable + + +@pytest.mark.parametrize("marker", SESSION_MARKERS) +def test_every_session_marker_is_dropped(marker): + """A marker added for a new host is covered without a second list (#1082). + + The scrub used to carry its own four prefixes, so five of these eleven + crossed it; this row walks the tuple instead of naming them. + """ + env = scrub_host_env({_host_variable(marker): "host-session", "PATH": "/bin"}) + + assert [name for name in env if name != "PATH"] == [] + assert env["PATH"] == "/bin" + + +def test_the_worker_and_the_detached_run_share_one_list(): + """The two consumers of the denylist cannot drift apart again (#1082).""" + assert AGENTS_SESSION_MARKERS is SESSION_MARKERS + + +def test_a_provider_key_that_merely_looks_like_a_marker_is_kept(): + """``OPENCODE_API_KEY`` is a credential, not a config pointer (#1082).""" + env = scrub_host_env({"OPENCODE_API_KEY": "not-a-real-key", "OPENCODE_CONFIG": "/x"}) + + assert env == {"OPENCODE_API_KEY": "not-a-real-key"} + + +def test_launch_re_scrubs_a_caller_supplied_env(tmp_path, monkeypatch): + """A caller cannot put the host session back through ``env=`` (#1082).""" + rd = runstore.create({"kind": "text", "target": str(tmp_path / "out.md")}, + store=str(tmp_path / "store")) + seen = {} + + def fake_run(argv, **kwargs): + seen["env"] = kwargs.get("env") + return subprocess.CompletedProcess(argv, 0, stdout="4242\n", stderr="") + + monkeypatch.setattr(runstore.subprocess, "run", fake_run) + + runstore.launch(rd, env={"MCP_SERVERS": '{"filesystem": {}}', + "XDG_CONFIG_HOME": "/home/someone/.config", + "RUN_TAG": "kept"}) + + env = seen["env"] + assert "MCP_SERVERS" not in env and "XDG_CONFIG_HOME" not in env + assert env["RUN_TAG"] == "kept" + assert env["PYTHONPATH"] From 50bde3c2ff9fecec704d0dbb594cd5ebcba3ed34 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 05:31:12 +0000 Subject: [PATCH 4/5] fix(runstore): keep the host config dirs a detached run reads (#1082) `scrub_host_env` drops `SESSION_MARKERS`, and that list contains `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `DSH_HOME` (the first by name, the other two behind the `CODEX_`/`DSH_` prefixes). They name a host's *configuration*, not its session: the detached run is this package, and `integrations._host_root` resolves a host's home from them, so a worker spec with `isolate: false` -- and every `agentdescent start` from a plain terminal -- was sent to `~/.claude` / `~/.codex`, which for a custom directory is the wrong, often unauthenticated, login (#1082 review, finding 1). `DETACHED_KEEP_VARS` holds the three names and `scrub_host_env` keeps them by exact name; the rest of `SESSION_MARKERS` still goes. The tuple lives in `surface_common` rather than being imported from `agents`, because this module stays a leaf: a test binds the set to `agents.WORKER_CONFIG_DIRS` instead. `is_session_marker` is unchanged, so an isolated worker still redirects all five directories into its own workspace and keeps none of the user's config. Refs #1082. --- agentdescent/surface_common.py | 22 ++++++++- changelog.d/1082-host-env-scrub.md | 5 ++- docs/dsh-super-agent.md | 6 ++- tests/test_host_env_scrub.py | 71 +++++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/agentdescent/surface_common.py b/agentdescent/surface_common.py index 1a1cc6a0..d7ab0ce3 100644 --- a/agentdescent/surface_common.py +++ b/agentdescent/surface_common.py @@ -41,6 +41,7 @@ "surface_write_dirs_from_env", "scrub_host_env", "SESSION_MARKERS", + "DETACHED_KEEP_VARS", "is_session_marker", ] @@ -238,6 +239,21 @@ def surface_write_dirs_from_env(environ: Optional[Mapping[str, str]] = None ) +#: The host config/home directories a *detached run* keeps (#1082 review). +#: +#: The candidate worker redirects these into its own workspace +#: (:func:`agentdescent.agents.worker_env`), but the detached run is this +#: package: ``integrations._host_root`` reads them so a worker spec with +#: ``isolate: false`` -- and every ``agentdescent start`` from a plain terminal +#: -- resolves the user's configured home. Dropping them sent such a run to +#: ``~/.claude`` / ``~/.codex``, which for a custom ``CLAUDE_CONFIG_DIR`` is the +#: wrong, often unauthenticated, login. The names are listed here, not imported +#: from ``agents``, because this module stays a leaf; the test +#: ``test_the_keep_set_cannot_name_a_directory_agents_does_not_know`` binds them +#: to :data:`agentdescent.agents.WORKER_CONFIG_DIRS`. +DETACHED_KEEP_VARS: Tuple[str, ...] = ("CLAUDE_CONFIG_DIR", "CODEX_HOME", "DSH_HOME") + + def is_session_marker(name: str) -> bool: """Whether ``name`` belongs to a host agent's session rather than to the run. @@ -260,11 +276,13 @@ def scrub_host_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str hand the child a second set of tools -- stays with the host (#1082). The list is :data:`SESSION_MARKERS`, the same one the candidate worker - drops, so the two cannot drift apart again. + drops, so the two cannot drift apart again. The exception is + :data:`DETACHED_KEEP_VARS`: those three name the host's *configuration*, not + its session, and this run resolves a host's real home from them. """ env = dict(os.environ if environ is None else environ) return {name: value for name, value in env.items() - if not is_session_marker(name)} + if name.upper() in DETACHED_KEEP_VARS or not is_session_marker(name)} def _allowed_module(module_name: str, allow: Optional[Sequence[str]]) -> bool: diff --git a/changelog.d/1082-host-env-scrub.md b/changelog.d/1082-host-env-scrub.md index 74f4687c..15a0290d 100644 --- a/changelog.d/1082-host-env-scrub.md +++ b/changelog.d/1082-host-env-scrub.md @@ -6,4 +6,7 @@ grandchild, so the host worker's session identity and MCP server configuration stay with the host while the credentials and `PATH` the run needs still pass through (#1082). The list is the one the candidate worker already dropped (`agentdescent.agents.worker_env`); it used to be a second, shorter copy, and -five of its eleven entries crossed the scrub. +five of its eleven entries crossed the scrub. The host's config/home +directories (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `DSH_HOME`) are kept, because +they name configuration rather than session and the run resolves a host's real +home from them (#1082). diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index dd9587df..90659d0c 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -111,7 +111,11 @@ your tree, under the operator's write allowlist). the OpenCode config carriers, `XDG_CONFIG_HOME`) before it starts the grandchild, so the worker's session identity and MCP config stay with the host while the API keys the run needs are passed through (`#1082`). The list - is the one the candidate worker drops, not a second copy of it. + is the one the candidate worker drops, not a second copy of it. The host's + config/home directories (`DETACHED_KEEP_VARS`: `CLAUDE_CONFIG_DIR`, + `CODEX_HOME`, `DSH_HOME`) are kept: they name configuration, not session, and + this run resolves a host's real home from them for `isolate: false` workers + and for `agentdescent start` from a terminal. ## 6. Long runs, Reports and Attention diff --git a/tests/test_host_env_scrub.py b/tests/test_host_env_scrub.py index ebd2be3d..e7746196 100644 --- a/tests/test_host_env_scrub.py +++ b/tests/test_host_env_scrub.py @@ -20,7 +20,8 @@ from agentdescent import runstore from agentdescent.agents import SESSION_MARKERS as AGENTS_SESSION_MARKERS -from agentdescent.surface_common import SESSION_MARKERS, scrub_host_env +from agentdescent.agents import WORKER_CONFIG_DIRS +from agentdescent.surface_common import DETACHED_KEEP_VARS, SESSION_MARKERS, scrub_host_env HOST_MARKERS = { "DSH_SESSION_ID": "host-session", @@ -31,6 +32,16 @@ "MCP_SERVERS": '{"filesystem": {}}', } +#: The host config/home directories the run itself reads +#: (``integrations._host_root``), so a worker spec with ``isolate: false`` -- and +#: every ``agentdescent start`` from a plain terminal -- resolves the user's real +#: setup instead of the default home (#1082 review). +HOST_CONFIG_DIRS = { + "CLAUDE_CONFIG_DIR": "/home/someone/.claude-work", + "CODEX_HOME": "/home/someone/.codex-work", + "DSH_HOME": "/home/someone/.dsh-work", +} + #: One variable per marker, spelled the way a host would set it: the bare name #: when the marker ends in ``_`` (``CLAUDECODE``), a suffixed one otherwise. def _host_variable(marker: str) -> str: @@ -44,11 +55,67 @@ def test_scrub_drops_host_markers_and_keeps_what_the_run_needs(monkeypatch): env = scrub_host_env(os.environ) - assert [k for k in env if k.upper().startswith(("DSH_", "CLAUDE", "CODEX", "MCP_"))] == [] + for name in HOST_MARKERS: + assert name not in env assert env["ARK_API_KEY"] == "not-a-real-key" assert env["PATH"] == os.environ["PATH"] +def test_the_host_config_dirs_survive_and_the_session_does_not(monkeypatch): + """The row the review named: the host's config/home dirs survive `launch`. + + `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `DSH_HOME` are host *configuration*, + which this package reads through `integrations._host_root`, not session + identity: dropping them sent a worker spec with `isolate: false` -- and every + `agentdescent start` from a terminal -- to `~/.claude` / `~/.codex`, which for + a custom directory is the wrong, often unauthenticated, login (#1082 review). + """ + for name, value in HOST_CONFIG_DIRS.items(): + monkeypatch.setenv(name, value) + for name, value in HOST_MARKERS.items(): + monkeypatch.setenv(name, value) + + env = scrub_host_env(os.environ) + + for name, value in HOST_CONFIG_DIRS.items(): + assert env[name] == value, name + for name in HOST_MARKERS: + assert name not in env, name + + +def test_the_keep_set_cannot_name_a_directory_agents_does_not_know(): + """The keep list lives in this leaf module, so a test binds it to `agents`.""" + assert set(DETACHED_KEEP_VARS) == set(HOST_CONFIG_DIRS) + assert set(DETACHED_KEEP_VARS) <= set(WORKER_CONFIG_DIRS) + + +def test_launch_keeps_the_host_config_dirs_and_drops_the_session(tmp_path, monkeypatch): + for name, value in HOST_CONFIG_DIRS.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.setenv("DSH_SESSION_ID", "host-session") + monkeypatch.setenv("MCP_SERVERS", '{"filesystem": {}}') + rd = runstore.create({"kind": "text", "target": str(tmp_path / "out.md")}, + store=str(tmp_path / "store")) + seen = {} + + def fake_run(argv, **kwargs): + seen["env"] = kwargs.get("env") + return subprocess.CompletedProcess(argv, 0, stdout="4242\n", stderr="") + + monkeypatch.setattr(runstore.subprocess, "run", fake_run) + + st = runstore.launch(rd) + + assert st.pid == 4242 + env = seen["env"] + for name, value in HOST_CONFIG_DIRS.items(): + assert env[name] == value, name + assert "CLAUDECODE" not in env + assert "DSH_SESSION_ID" not in env + assert "MCP_SERVERS" not in env + + def test_scrub_does_not_mutate_the_caller(monkeypatch): monkeypatch.setenv("DSH_SESSION_ID", "host-session") From 85b2974c681daeafca074e01ed86f1a0e9486624 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 05:48:02 +0000 Subject: [PATCH 5/5] fix(runstore): keep XDG_CONFIG_HOME for the detached run too (#1082) `integrations._host_root` reads four variables, not three: `DSH_HOME`, `CODEX_HOME`, `XDG_CONFIG_HOME` (for `_opencode_root`) and `CLAUDE_CONFIG_DIR`. `DETACHED_KEEP_VARS` held the other three, so an OpenCode host with a custom `XDG_CONFIG_HOME` still resolved `/.config/opencode` after the scrub -- the same defect as the three already fixed (#1082 review, finding 1). The keep set now holds all four. Isolation is unchanged: `worker_env` filters by `is_session_marker`, not by the keep list, so an `isolate=True` worker is still redirected to `.agentdescent-worker/` inside its workspace, and that row asserts where each value points; `isolate=False` keeps the host values, which is the contract the keep set exists for. The drift row reads the `_host_root` call sites out of `integrations`' own source and checks both directions against `agents.WORKER_CONFIG_DIRS`, so a fifth host root or a renamed one fails it. Refs #1082. --- agentdescent/surface_common.py | 17 ++--- changelog.d/1082-host-env-scrub.md | 6 +- docs/dsh-super-agent.md | 7 ++- tests/test_host_env_scrub.py | 99 ++++++++++++++++++++++++++---- 4 files changed, 105 insertions(+), 24 deletions(-) diff --git a/agentdescent/surface_common.py b/agentdescent/surface_common.py index d7ab0ce3..ec934c61 100644 --- a/agentdescent/surface_common.py +++ b/agentdescent/surface_common.py @@ -243,15 +243,18 @@ def surface_write_dirs_from_env(environ: Optional[Mapping[str, str]] = None #: #: The candidate worker redirects these into its own workspace #: (:func:`agentdescent.agents.worker_env`), but the detached run is this -#: package: ``integrations._host_root`` reads them so a worker spec with +#: package: ``integrations._host_root`` reads all four so a worker spec with #: ``isolate: false`` -- and every ``agentdescent start`` from a plain terminal #: -- resolves the user's configured home. Dropping them sent such a run to -#: ``~/.claude`` / ``~/.codex``, which for a custom ``CLAUDE_CONFIG_DIR`` is the -#: wrong, often unauthenticated, login. The names are listed here, not imported -#: from ``agents``, because this module stays a leaf; the test -#: ``test_the_keep_set_cannot_name_a_directory_agents_does_not_know`` binds them -#: to :data:`agentdescent.agents.WORKER_CONFIG_DIRS`. -DETACHED_KEEP_VARS: Tuple[str, ...] = ("CLAUDE_CONFIG_DIR", "CODEX_HOME", "DSH_HOME") +#: ``~/.claude`` / ``~/.codex`` / ``~/.config``, which for a custom directory is +#: the wrong, often unauthenticated, login. ``XDG_CONFIG_HOME`` is the one +#: OpenCode actually follows, which is why it is here too. The names are listed +#: here, not imported from ``agents``, because this module stays a leaf; the test +#: ``test_the_keep_set_covers_every_variable_host_root_reads`` reads the names +#: out of ``integrations``' own source, so a fifth host root fails it. +DETACHED_KEEP_VARS: Tuple[str, ...] = ( + "CLAUDE_CONFIG_DIR", "CODEX_HOME", "DSH_HOME", "XDG_CONFIG_HOME", +) def is_session_marker(name: str) -> bool: diff --git a/changelog.d/1082-host-env-scrub.md b/changelog.d/1082-host-env-scrub.md index 15a0290d..a15f05e1 100644 --- a/changelog.d/1082-host-env-scrub.md +++ b/changelog.d/1082-host-env-scrub.md @@ -7,6 +7,6 @@ stay with the host while the credentials and `PATH` the run needs still pass through (#1082). The list is the one the candidate worker already dropped (`agentdescent.agents.worker_env`); it used to be a second, shorter copy, and five of its eleven entries crossed the scrub. The host's config/home -directories (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `DSH_HOME`) are kept, because -they name configuration rather than session and the run resolves a host's real -home from them (#1082). +directories (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `DSH_HOME`, `XDG_CONFIG_HOME`) +are kept, because they name configuration rather than session and the run +resolves a host's real home from them (#1082). diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index 90659d0c..b149906f 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -113,9 +113,10 @@ your tree, under the operator's write allowlist). host while the API keys the run needs are passed through (`#1082`). The list is the one the candidate worker drops, not a second copy of it. The host's config/home directories (`DETACHED_KEEP_VARS`: `CLAUDE_CONFIG_DIR`, - `CODEX_HOME`, `DSH_HOME`) are kept: they name configuration, not session, and - this run resolves a host's real home from them for `isolate: false` workers - and for `agentdescent start` from a terminal. + `CODEX_HOME`, `DSH_HOME`, `XDG_CONFIG_HOME`) are kept: they name + configuration, not session, and this run resolves a host's real home from + them for `isolate: false` workers and for `agentdescent start` from a + terminal. ## 6. Long runs, Reports and Attention diff --git a/tests/test_host_env_scrub.py b/tests/test_host_env_scrub.py index e7746196..30b646c9 100644 --- a/tests/test_host_env_scrub.py +++ b/tests/test_host_env_scrub.py @@ -13,14 +13,16 @@ from __future__ import annotations +import ast +import inspect import os import subprocess +from unittest import mock import pytest -from agentdescent import runstore +from agentdescent import integrations, runstore from agentdescent.agents import SESSION_MARKERS as AGENTS_SESSION_MARKERS -from agentdescent.agents import WORKER_CONFIG_DIRS from agentdescent.surface_common import DETACHED_KEEP_VARS, SESSION_MARKERS, scrub_host_env HOST_MARKERS = { @@ -35,11 +37,14 @@ #: The host config/home directories the run itself reads #: (``integrations._host_root``), so a worker spec with ``isolate: false`` -- and #: every ``agentdescent start`` from a plain terminal -- resolves the user's real -#: setup instead of the default home (#1082 review). +#: setup instead of the default home (#1082 review). The fourth is +#: ``XDG_CONFIG_HOME``: OpenCode resolves its config under XDG, which is the +#: variable that actually moves it. HOST_CONFIG_DIRS = { "CLAUDE_CONFIG_DIR": "/home/someone/.claude-work", "CODEX_HOME": "/home/someone/.codex-work", "DSH_HOME": "/home/someone/.dsh-work", + "XDG_CONFIG_HOME": "/home/someone/.config-work", } #: One variable per marker, spelled the way a host would set it: the bare name @@ -61,6 +66,84 @@ def test_scrub_drops_host_markers_and_keeps_what_the_run_needs(monkeypatch): assert env["PATH"] == os.environ["PATH"] +def _host_root_variables() -> set: + """The names ``integrations._host_root`` is actually called with. + + Read from the module's own source, so a fifth host root added there fails the + drift row below instead of silently resolving to its default directory. + """ + tree = ast.parse(inspect.getsource(integrations)) + names = set() + for node in ast.walk(tree): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == "_host_root" and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str)): + names.add(node.args[0].value) + return names + + +def test_the_keep_set_covers_every_variable_host_root_reads(): + """The keep list is in the leaf module, so a test binds it to `integrations`. + + Both directions: a fifth host root fails the first assert, and a name the + worker's own map does not know fails the second -- so neither list can move + alone. + """ + from agentdescent.agents import WORKER_CONFIG_DIRS + + roots = _host_root_variables() + assert roots == {"DSH_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR"} + assert roots <= set(DETACHED_KEEP_VARS) + assert set(DETACHED_KEEP_VARS) <= set(WORKER_CONFIG_DIRS) + + +def test_every_host_root_resolves_under_the_scrubbed_environment(monkeypatch): + """After the scrub each host still finds the directory it was configured with.""" + for name, value in HOST_CONFIG_DIRS.items(): + monkeypatch.setenv(name, value) + + env = scrub_host_env(os.environ) + + with mock.patch.dict(os.environ, env, clear=True): + assert integrations._dsh_root("/home/someone", True) == HOST_CONFIG_DIRS["DSH_HOME"] + assert integrations._codex_root("/home/someone", True) == HOST_CONFIG_DIRS["CODEX_HOME"] + assert (integrations._claude_root("/home/someone", True) + == HOST_CONFIG_DIRS["CLAUDE_CONFIG_DIR"]) + assert (integrations._opencode_root("/home/someone", True) + == os.path.join(HOST_CONFIG_DIRS["XDG_CONFIG_HOME"], "opencode")) + + +def test_an_isolated_worker_still_loses_and_redirects_the_config_dirs(tmp_path, monkeypatch): + """The keep set is the detached run's; a candidate worker keeps none of it. + + Asserted as where each value points, not merely as absence: an absence-only + row would pass while the worker still pointed at the host's directory if the + redirect ever stopped taking (#1082 review). + """ + from agentdescent.agents import worker_env + + for name, value in HOST_CONFIG_DIRS.items(): + monkeypatch.setenv(name, value) + + env = worker_env(str(tmp_path), isolate=True) + expected = { + "CLAUDE_CONFIG_DIR": "claude", + "CODEX_HOME": "codex", + "DSH_HOME": "dsh", + "XDG_CONFIG_HOME": "xdg", + } + + for name, leaf in expected.items(): + assert env[name] == os.path.join(str(tmp_path), ".agentdescent-worker", leaf), name + + # `isolate=False` is the opposite contract: the worker sees the user's setup, + # which is why the detached run keeps these four in the first place. + loose = worker_env(str(tmp_path), isolate=False) + for name, value in HOST_CONFIG_DIRS.items(): + assert loose[name] == value, name + + def test_the_host_config_dirs_survive_and_the_session_does_not(monkeypatch): """The row the review named: the host's config/home dirs survive `launch`. @@ -83,12 +166,6 @@ def test_the_host_config_dirs_survive_and_the_session_does_not(monkeypatch): assert name not in env, name -def test_the_keep_set_cannot_name_a_directory_agents_does_not_know(): - """The keep list lives in this leaf module, so a test binds it to `agents`.""" - assert set(DETACHED_KEEP_VARS) == set(HOST_CONFIG_DIRS) - assert set(DETACHED_KEEP_VARS) <= set(WORKER_CONFIG_DIRS) - - def test_launch_keeps_the_host_config_dirs_and_drops_the_session(tmp_path, monkeypatch): for name, value in HOST_CONFIG_DIRS.items(): monkeypatch.setenv(name, value) @@ -186,10 +263,10 @@ def fake_run(argv, **kwargs): monkeypatch.setattr(runstore.subprocess, "run", fake_run) runstore.launch(rd, env={"MCP_SERVERS": '{"filesystem": {}}', - "XDG_CONFIG_HOME": "/home/someone/.config", + "DSH_SESSION_ID": "host-session", "RUN_TAG": "kept"}) env = seen["env"] - assert "MCP_SERVERS" not in env and "XDG_CONFIG_HOME" not in env + assert "MCP_SERVERS" not in env and "DSH_SESSION_ID" not in env assert env["RUN_TAG"] == "kept" assert env["PYTHONPATH"]