From 4376e7fe344f1cd628ecba94b820022767f96a21 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 28 Jun 2026 13:20:40 -0400 Subject: [PATCH] shell: add env and hook support --- AGENTS.md | 7 +++ agent.nix | 11 +++++ lib/agents.nix | 75 +++++++++++++++++++++++++++---- tartarus/bundle.py | 22 ++++----- tartarus/cli.py | 6 ++- tartarus/constants.py | 9 ++++ tartarus/jail.py | 40 +++++++++++++---- tartarus/manifest.py | 51 ++++++++++++++++++++- tartarus/manifest_loader.py | 13 ++++++ templates/default/agent.nix | 11 +++++ tests/test_bundle.py | 13 ++++++ tests/test_jail.py | 51 ++++++++++++++++++++- tests/test_manifest.py | 19 ++++++++ tests/test_manifest_loader.py | 84 +++++++++++++++++++++++++++++++++++ 14 files changed, 380 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d18a6a9..75113b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,13 @@ From `tartarus/jail.py` and `PLAN.md §8`: `name` in the capability body; the attrset key is the identity. - The agent's `shell` is the baseline PATH baked into the manifest; keep it minimal. Tool-specific programs go in that capability's `grants.packages`. + `shell.env` adds env vars to every call (reserved names rejected at build + time); `shell.hook` is a bash script sourced before every call via `BASH_ENV`. +- Every jailed command runs under `bash --noprofile --norc -c`, so runner + templates get full shell semantics (pipes, globbing, `$VAR`). Model-supplied + values are `shlex.quote`d by `interpolate`, so they stay confined to their + argument position. A declared `shell.hook` must be idempotent — a runner that + is itself `bash -c …` re-sources it via `BASH_ENV`. - `kind = "background"` launches detached (handle returned immediately); `kind = "control"` capabilities (`bg_status`/`bg_output`/`bg_stop`) carry no runner and no grants — they act on the background registry, not the jail. diff --git a/agent.nix b/agent.nix index 23fe354..f7e70f8 100644 --- a/agent.nix +++ b/agent.nix @@ -11,6 +11,17 @@ name = "default"; + # Example shell configuration. Uncomment to customize the baseline environment + # reachable by every jailed call. Heavier tools should stay in per-capability + # grants so they do not bloat every shell closure. + # shell = { + # packages = [ pkgs.bash pkgs.coreutils pkgs.gnugrep pkgs.findutils ]; + # env = { GIT_PAGER = "cat"; PAGER = "cat"; }; + # hook = '' + # echo "Hello Agent!" + # ''; + # }; + model = { baseUrl = "https://opencode.ai/zen/v1"; name = "glm-5.2"; diff --git a/lib/agents.nix b/lib/agents.nix index aa75ca5..6b76010 100644 --- a/lib/agents.nix +++ b/lib/agents.nix @@ -297,12 +297,36 @@ let type = types.nullOr modelType; default = null; }; - shell.packages = lib.mkOption { - type = types.listOf types.package; - default = with pkgs; [ - bash - coreutils - ]; + shell = { + packages = lib.mkOption { + type = types.listOf types.package; + default = with pkgs; [ + bash + coreutils + ]; + description = "Packages whose /bin directories form the agent's baseline PATH."; + }; + env = lib.mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Extra environment variables exposed to every jailed call. + Reserved names (PATH, HOME, locale vars, cert vars, proxy vars, + BASH_ENV, and names beginning with TARTARUS_) are rejected at + build time. + ''; + }; + hook = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Optional bash script sourced by every jailed call via BASH_ENV. + It runs after jail setup — the closure is bound, PATH is composed + (shell PATH plus the call's grant bins), and shell.env is exported — + so it can reach any tool the command itself can. It must be + idempotent: a command that is itself `bash -c …` re-sources it. + ''; + }; }; capabilities = lib.mkOption { type = types.attrsOf (types.submodule capabilityType); @@ -354,7 +378,8 @@ let ) capabilities; shellBinPackages = config.shell.packages ++ [ pkgs.bashInteractive ]; shellRootList = map packageBinRoot shellBinPackages; - shellRoots = shellRootList ++ [ pkgs.cacert ]; + hookDrv = lib.mapNullable (pkgs.writeText "tartarus-shell-hook") config.shell.hook; + shellRoots = shellRootList ++ [ pkgs.cacert ] ++ lib.optional (hookDrv != null) hookDrv; shellClosureDrv = pkgs.closureInfo { rootPaths = shellRoots; }; compiledManifest = compileManifest grantInfo capabilities @@ -362,9 +387,34 @@ let caBundle = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; shellClosure = "${shellClosureDrv}/store-paths"; shellPath = lib.concatStringsSep ":" (lib.unique (map (root: "${root}/bin") shellRootList)); + shellEnv = config.shell.env; } // lib.optionalAttrs (config.systemPrompt != null) { inherit (config) systemPrompt; } - // lib.optionalAttrs (config.model != null) { inherit (config) model; }; + // lib.optionalAttrs (config.model != null) { inherit (config) model; } + // lib.optionalAttrs (hookDrv != null) { shellHook = "${hookDrv}"; }; + # Mirrored in tartarus/manifest.py (_RESERVED_SHELL_ENV_NAMES); the two + # must stay in sync. Drift is silent except for the Python pin test + # test_reserved_shell_env_names_canonical — update both when editing this. + shellEnvReservedNames = [ + "BASH_ENV" + "HOME" + "LANG" + "LC_ALL" + "PATH" + "SSL_CERT_FILE" + "NIX_SSL_CERT_FILE" + "CURL_CA_BUNDLE" + "REQUESTS_CA_BUNDLE" + ]; + isValidShellEnvName = + name: + let + upper = lib.toUpper name; + in + builtins.match "^[A-Za-z_][A-Za-z0-9_]*$" name != null + && !(lib.elem upper shellEnvReservedNames) + && !(lib.hasSuffix "_PROXY" upper) + && !(lib.hasPrefix "TARTARUS_" upper); assertWarn = result: let @@ -394,12 +444,21 @@ let }; }; + config.assertions = [ + { + assertion = lib.all isValidShellEnvName (lib.attrNames config.shell.env); + message = "Tartarus shell.env contains invalid or reserved variable names."; + } + ]; + config.build = { manifest = assertWarn compiledManifest; shell = assertWarn ( pkgs.mkShellNoCC { packages = config.shell.packages; + env = config.shell.env; + shellHook = lib.optionalString (config.shell.hook != null) config.shell.hook; } ); diff --git a/tartarus/bundle.py b/tartarus/bundle.py index 4754dc7..42a7a4d 100644 --- a/tartarus/bundle.py +++ b/tartarus/bundle.py @@ -14,6 +14,7 @@ import json import os from tartarus.config import Config +from tartarus.constants import CERT_ENV_VARS from tartarus.manifest import Manifest from tartarus.manifest_loader import ( ManifestError, @@ -74,29 +75,24 @@ def load_bundle(bundle_path: str) -> Manifest: raise BundleError(f"invalid bundle manifest: {error}") from error -# Certificate environment variables every TLS client in the jail consults; all -# point at the manifest's CA bundle (whose store root the shell closure binds). -_CERT_ENV_VARS = ( - "SSL_CERT_FILE", - "NIX_SSL_CERT_FILE", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", -) - - -def base_env_from(ca_bundle_file: str) -> dict[str, str]: +def base_env_from( + ca_bundle_file: str, + shell_env: dict[str, str] | None = None, +) -> dict[str, str]: """The jail's baseline environment: CA bundle cert vars + a fixed locale. Fails closed if the bundle declares no CA bundle, rather than leaving TLS to an unset/unbound certificate. `C.UTF-8` is built into glibc, so it needs no - locale package in the closure. + locale package in the closure. Agent-declared ``shell_env`` is merged on + top (the manifest validator rejects reserved names). """ if not ca_bundle_file: raise BundleError( "bundle declares no CA bundle (caBundle); refusing to run with an " "unset certificate" ) - base_env = {var: ca_bundle_file for var in _CERT_ENV_VARS} + base_env = {var: ca_bundle_file for var in CERT_ENV_VARS} base_env["LC_ALL"] = "C.UTF-8" base_env["LANG"] = "C.UTF-8" + base_env.update(shell_env or {}) return base_env diff --git a/tartarus/cli.py b/tartarus/cli.py index 2f633e8..3dd5fdf 100644 --- a/tartarus/cli.py +++ b/tartarus/cli.py @@ -360,7 +360,10 @@ async def _async_main(argv: list[str]) -> int: try: bundle_path = resolve_bundle(config) manifest = load_bundle(bundle_path) - base_env = {**base_env_from(manifest.ca_bundle_file), "HOME": "/work"} + base_env = { + **base_env_from(manifest.ca_bundle_file, manifest.shell_env), + "HOME": "/work", + } except BundleError as error: print(f"startup error: {error}", file=sys.stderr) return 1 @@ -379,6 +382,7 @@ async def _async_main(argv: list[str]) -> int: manifest.shell_path, base_env=base_env, shell_closure=manifest.shell_closure, + shell_hook=manifest.shell_hook, ) policy = PolicyEngine(headless=config.headless) # Background tasks: the registry monitors detached runs on this async loop. diff --git a/tartarus/constants.py b/tartarus/constants.py index 6e6b7a8..f713cdb 100644 --- a/tartarus/constants.py +++ b/tartarus/constants.py @@ -9,3 +9,12 @@ from pydantic import ConfigDict STRICT_CONFIG = ConfigDict(frozen=True, extra="forbid", strict=True) DEFAULT_OUTPUT_TRUNCATE_CHARS = 10_000 + +# Cert-bundle env var names every jailed call points at the manifest's CA bundle. +# Also reserved against agent shell.env overrides (see manifest.py). +CERT_ENV_VARS = ( + "SSL_CERT_FILE", + "NIX_SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", +) diff --git a/tartarus/jail.py b/tartarus/jail.py index 7cc4ce6..1534e11 100644 --- a/tartarus/jail.py +++ b/tartarus/jail.py @@ -14,7 +14,6 @@ import asyncio import codecs import os import signal -import shlex import shutil import subprocess import uuid @@ -72,13 +71,13 @@ class JailSpec: writable: list[str] = field(default_factory=list) extra_path: list[str] = field(default_factory=list) # granted package bin dirs # The store paths bound read-only into the jail: the agent's baseline closure - # (shell PATH + CA bundle) plus this grant's package closure. The jail sees - # exactly these store paths and nothing else, so a capability reaches only its - # declared closure (PLAN.md §13). + # (shell PATH + CA bundle closure), before this call's own grant closure is added. bind_paths: list[str] = field(default_factory=list) allowed_hosts: list[str] = field(default_factory=list) network: Literal["none", "proxy"] = "none" unrestricted: bool = False + # Optional store path of a bash hook sourced via BASH_ENV before the command. + shell_hook: str = "" @field_validator("writable") @classmethod @@ -102,6 +101,7 @@ class JailBuilder: base_env: dict[str, str] | None = None, proxy_factory: Callable[[list[str]], FilteringProxy] | None = None, shell_closure: list[str] | None = None, + shell_hook: str = "", ): self._work_tree = os.path.abspath(work_tree) self._shell_path = shell_path @@ -111,6 +111,7 @@ class JailBuilder: # The baseline store paths every jailed call binds (shell PATH + CA # bundle closure), before this call's own grant closure is added. self._shell_closure = list(shell_closure or []) + self._shell_hook = shell_hook def build(self, grant: Grant) -> JailSpec: try: @@ -124,6 +125,7 @@ class JailBuilder: allowed_hosts=list(grant.allowed_hosts), network="proxy" if grant.allowed_hosts else "none", unrestricted=grant.unrestricted, + shell_hook=self._shell_hook, ) except ValidationError as error: raise JailError(str(error)) from error @@ -235,10 +237,13 @@ class JailBuilder: timeout: int | None, output_callback: Callable[[str], None] | None = None, ) -> ExecResult: - env = {"PATH": self._compose_path(spec), **spec.base_env} + env = { + "PATH": self._compose_path(spec), + **self._bash_hook_env(spec), + } try: proc = await asyncio.create_subprocess_exec( - *shlex.split(command), + *self._shell_argv(command), cwd=spec.work_tree, env=env, stdout=asyncio.subprocess.PIPE, @@ -260,7 +265,7 @@ class JailBuilder: ) -> list[str]: env_args = ["--setenv", "PATH", self._compose_path(spec)] env_args += self._network_env_args(proxy_url) - for key, value in spec.base_env.items(): + for key, value in self._bash_hook_env(spec).items(): env_args += ["--setenv", key, value] return [ @@ -283,7 +288,7 @@ class JailBuilder: "--clearenv", # start from empty env, then set PATH explicitly *env_args, "--", - *shlex.split(command), + *self._shell_argv(command), ] @staticmethod @@ -345,6 +350,25 @@ class JailBuilder: return spec.shell_path return ":".join([spec.shell_path, *spec.extra_path]) + @staticmethod + def _bash_hook_env(spec: JailSpec) -> dict[str, str]: + """Return base_env plus BASH_ENV when the agent declared a shell hook.""" + env = dict(spec.base_env) + if spec.shell_hook: + env["BASH_ENV"] = spec.shell_hook + return env + + @staticmethod + def _shell_argv(command: str) -> list[str]: + """Final argv after `--`. Every command runs under a fresh non-interactive + bash (`--noprofile --norc`), so runner templates get consistent shell + semantics (pipes, globbing, `$VAR`) whether or not a hook is set. + Model-supplied values are already shlex.quoted by `interpolate`, so they + stay confined to their argument position. When a hook is declared it is + sourced via BASH_ENV before the command; the hook must be idempotent + because a command that is itself `bash -c …` re-sources BASH_ENV.""" + return ["bash", "--noprofile", "--norc", "-c", command] + async def _wait_for_process( proc: asyncio.subprocess.Process, diff --git a/tartarus/manifest.py b/tartarus/manifest.py index 9150bdc..5d8a281 100644 --- a/tartarus/manifest.py +++ b/tartarus/manifest.py @@ -7,15 +7,30 @@ tool. from __future__ import annotations +import re import string from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator -from tartarus.constants import STRICT_CONFIG +from tartarus.constants import CERT_ENV_VARS, STRICT_CONFIG from typing_extensions import Self +# Environment variable names the harness owns and will not let an agent override. +# This set is mirrored in lib/agents.nix (shellEnvReservedNames); the two must +# stay in sync. test_reserved_shell_env_names_canonical pins this side. +_RESERVED_SHELL_ENV_NAMES = frozenset(CERT_ENV_VARS) | { + "BASH_ENV", + "HOME", + "LANG", + "LC_ALL", + "PATH", +} + +_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + # ── Grant ──────────────────────────────────────────────────────────────────── @@ -270,6 +285,12 @@ class Manifest(BaseModel): # contents. `shell_closure` is filled after realization (manifest_loader). shell_closure_file: str = "" shell_closure: list[str] = Field(default_factory=list) + # Agent-declared extra env vars injected into every jailed call. Keys are + # validated against reserved names the harness owns (PATH, HOME, cert vars, + # proxy-like suffixes, and anything starting with TARTARUS_). + shell_env: dict[str, str] = Field(default_factory=dict) + # Optional store path of a bash hook sourced via BASH_ENV before every call. + shell_hook: str = "" @field_validator("ca_bundle_file") @classmethod @@ -301,6 +322,34 @@ class Manifest(BaseModel): raise ValueError(f"shellPath entry '{entry}' must end with /bin") return v + @field_validator("shell_env") + @classmethod + def _validate_shell_env(cls, v: dict[str, str]) -> dict[str, str]: + for key in v: + if not _ENV_NAME_RE.fullmatch(key): + raise ValueError( + f"shellEnv key '{key}' is not a valid environment variable name" + ) + upper = key.upper() + if upper in _RESERVED_SHELL_ENV_NAMES: + raise ValueError(f"shellEnv key '{key}' is reserved and cannot be overridden") + if upper.endswith("_PROXY"): + raise ValueError( + f"shellEnv key '{key}' matches the reserved *_PROXY suffix" + ) + if upper.startswith("TARTARUS_"): + raise ValueError( + f"shellEnv key '{key}' matches the reserved TARTARUS_ prefix" + ) + return v + + @field_validator("shell_hook") + @classmethod + def _validate_shell_hook(cls, v: str) -> str: + if v and not v.startswith("/nix/store/"): + raise ValueError("shellHook must be under /nix/store") + return v + @model_validator(mode="after") def _validate_tools_consistency(self) -> Self: for tool in self.tools: diff --git a/tartarus/manifest_loader.py b/tartarus/manifest_loader.py index 46335de..439f085 100644 --- a/tartarus/manifest_loader.py +++ b/tartarus/manifest_loader.py @@ -70,6 +70,16 @@ def validate_realized_package_bins(manifest: Manifest) -> None: ) +def validate_realized_shell_hook(manifest: Manifest) -> None: + hook = manifest.shell_hook + if not hook: + return + if not os.path.isfile(hook): + raise ManifestError( + f"shell hook '{hook}' is missing; the bundle is not fully realized" + ) + + def resolve_realized_closures(manifest: Manifest) -> Manifest: """Read each emitted `store-paths` file into its grant's `closure_paths`. @@ -79,6 +89,7 @@ def resolve_realized_closures(manifest: Manifest) -> Manifest: missing or malformed closure file refuses to start (mirrors `validate_realized_package_bins`). """ + validate_realized_shell_hook(manifest) shell_closure = _read_closure_file("agent shell", manifest.shell_closure_file) capabilities = { name: capability.model_copy( @@ -122,6 +133,8 @@ def _map_manifest_raw(raw: dict[str, Any]) -> dict[str, Any]: "ca_bundle_file": raw.get("caBundle", ""), "shell_closure_file": raw.get("shellClosure", ""), "shell_path": raw.get("shellPath", ""), + "shell_env": raw.get("shellEnv", {}), + "shell_hook": raw.get("shellHook", ""), } if "systemPrompt" in raw: mapped["system_prompt"] = raw["systemPrompt"] diff --git a/templates/default/agent.nix b/templates/default/agent.nix index 1db7fc8..791887e 100644 --- a/templates/default/agent.nix +++ b/templates/default/agent.nix @@ -11,6 +11,17 @@ name = "default"; + # Example shell configuration. Uncomment to customize the baseline environment + # reachable by every jailed call. Heavier tools should stay in per-capability + # grants so they do not bloat every shell closure. + # shell = { + # packages = [ pkgs.bash pkgs.coreutils pkgs.gnugrep pkgs.findutils ]; + # env = { GIT_PAGER = "cat"; PAGER = "cat"; }; + # hook = '' + # echo "Hello Agent!" + # ''; + # }; + systemPrompt = '' You are a careful coding agent running inside Tartarus. Use only the tools you have been granted, and prefer the narrowest capability that diff --git a/tests/test_bundle.py b/tests/test_bundle.py index cd24a29..9fdb6cd 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -59,6 +59,8 @@ def _minimal_manifest(shell_closure_file: str) -> dict: }, "shellClosure": shell_closure_file, "shellPath": "", + "shellEnv": {}, + "shellHook": "", "caBundle": "/nix/store/cacert/etc/ssl/certs/ca-bundle.crt", } @@ -143,6 +145,17 @@ def test_base_env_from_fails_closed_without_bundle(): base_env_from("") +def test_base_env_from_merges_shell_env(): + cert = "/nix/store/cacert/etc/ssl/certs/ca-bundle.crt" + + env = base_env_from(cert, {"EDITOR": "vi", "GIT_PAGER": "cat"}) + + assert env["EDITOR"] == "vi" + assert env["GIT_PAGER"] == "cat" + assert env["SSL_CERT_FILE"] == cert + assert env["LC_ALL"] == "C.UTF-8" + + # --- resolve_bundle --------------------------------------------------------- diff --git a/tests/test_jail.py b/tests/test_jail.py index cb035bb..4e526a2 100644 --- a/tests/test_jail.py +++ b/tests/test_jail.py @@ -15,7 +15,7 @@ import tartarus.jail import pytest from tartarus.shell import ShellError, resolve_minimal_shell_path -from tartarus.jail import JailBuilder, JailError +from tartarus.jail import JailBuilder, JailError, JailSpec from tartarus.manifest import Grant from tests.helpers import HttpServer @@ -71,6 +71,55 @@ def test_echo_runs_confined(tmp_path, shell_path, shell_closure): assert "banana" in result.stdout +def test_bwrap_argv_wraps_command_with_shell_hook(tmp_path): + jail = JailBuilder(str(tmp_path), "/nix/store/bash/bin") + spec = JailSpec( + work_tree=str(tmp_path), + shell_path="/nix/store/bash/bin", + base_env={"LC_ALL": "C.UTF-8"}, + shell_hook="/nix/store/hook", + ) + + argv = jail._bwrap_argv(spec, "echo hi") + + bash_env_idx = argv.index("BASH_ENV") + assert argv[bash_env_idx + 1] == "/nix/store/hook" + assert argv[-5:] == ["bash", "--noprofile", "--norc", "-c", "echo hi"] + + +def test_bwrap_argv_wraps_command_without_hook(tmp_path): + jail = JailBuilder(str(tmp_path), "/nix/store/bash/bin") + spec = JailSpec( + work_tree=str(tmp_path), + shell_path="/nix/store/bash/bin", + base_env={"LC_ALL": "C.UTF-8"}, + ) + + argv = jail._bwrap_argv(spec, "echo hi") + + assert "BASH_ENV" not in argv + assert argv[-5:] == ["bash", "--noprofile", "--norc", "-c", "echo hi"] + + +@_NEEDS_SANDBOX +def test_shell_hook_runs_before_unrestricted_command(tmp_path, shell_path): + hook = tmp_path / "hook" + hook.write_text('export HOOK_FLAG=ran\n') + jail = JailBuilder(str(tmp_path), shell_path) + spec = JailSpec( + work_tree=str(tmp_path), + shell_path=shell_path, + base_env={}, + shell_hook=str(hook), + unrestricted=True, + ) + + result = _exec(jail, spec, "bash -c 'echo $HOOK_FLAG'") + + assert result.code == 0 + assert "ran" in result.stdout + + @_NEEDS_SANDBOX def test_bwrap_parent_environment_does_not_leak_into_proc( tmp_path, shell_path, shell_closure, monkeypatch diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 6782353..7946206 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,12 +2,31 @@ from tartarus.manifest import ( Capability, Grant, Param, + _RESERVED_SHELL_ENV_NAMES, build_manifest, tool_from_capability, ) from tests.manifest_fixtures import echo_manifest +def test_reserved_shell_env_names_canonical(): + # Pins the Python side of the reserved-name set. lib/agents.nix + # (shellEnvReservedNames) mirrors this list; if you change one, change both. + assert _RESERVED_SHELL_ENV_NAMES == frozenset( + { + "BASH_ENV", + "CURL_CA_BUNDLE", + "HOME", + "LANG", + "LC_ALL", + "NIX_SSL_CERT_FILE", + "PATH", + "REQUESTS_CA_BUNDLE", + "SSL_CERT_FILE", + } + ) + + def test_echo_fixture_manifest_exposes_echo_tool(): manifest = echo_manifest() diff --git a/tests/test_manifest_loader.py b/tests/test_manifest_loader.py index bafb2e6..a3fd448 100644 --- a/tests/test_manifest_loader.py +++ b/tests/test_manifest_loader.py @@ -705,3 +705,87 @@ def test_shell_path_entry_without_bin_suffix_is_rejected(): with pytest.raises(ManifestError, match="must end with /bin"): build_manifest_from_raw(raw) + + +def test_shell_env_is_mapped(): + raw = _valid_raw() + raw["shellEnv"] = {"EDITOR": "vi"} + + manifest = build_manifest_from_raw(raw) + + assert manifest.shell_env == {"EDITOR": "vi"} + + +def test_shell_env_rejects_reserved_name(): + raw = _valid_raw() + raw["shellEnv"] = {"PATH": "/tmp"} + + with pytest.raises(ManifestError, match="reserved"): + build_manifest_from_raw(raw) + + +def test_shell_env_rejects_proxy_like_name(): + raw = _valid_raw() + raw["shellEnv"] = {"HTTPS_PROXY": "x"} + + with pytest.raises(ManifestError, match=r".*_PROXY"): + build_manifest_from_raw(raw) + + +def test_shell_env_rejects_tartarus_prefix(): + raw = _valid_raw() + raw["shellEnv"] = {"TARTARUS_FOO": "x"} + + with pytest.raises(ManifestError, match="TARTARUS_"): + build_manifest_from_raw(raw) + + +def test_shell_env_rejects_invalid_variable_name(): + raw = _valid_raw() + raw["shellEnv"] = {"123_FOO": "x"} + + with pytest.raises(ManifestError, match="not a valid environment"): + build_manifest_from_raw(raw) + + +def test_shell_hook_must_be_under_nix_store(): + raw = _valid_raw() + raw["shellHook"] = "/tmp/hook.sh" + + with pytest.raises(ManifestError, match="under /nix/store"): + build_manifest_from_raw(raw) + + +def test_resolve_realized_closures_requires_existing_hook_file(tmp_path): + shell_file = tmp_path / "shell-store-paths" + shell_file.write_text("/nix/store/bash\n") + hook_file = tmp_path / "hook" + hook_file.write_text('echo "hi"\n') + + manifest = build_manifest_from_raw(_valid_raw()) + manifest = manifest.model_copy( + update={ + "shell_closure_file": str(shell_file), + "shell_hook": str(hook_file), + } + ) + + resolved = resolve_realized_closures(manifest) + + assert resolved.shell_hook == str(hook_file) + + +def test_resolve_realized_closures_rejects_missing_hook_file(tmp_path): + shell_file = tmp_path / "shell-store-paths" + shell_file.write_text("/nix/store/bash\n") + + manifest = build_manifest_from_raw(_valid_raw()) + manifest = manifest.model_copy( + update={ + "shell_closure_file": str(shell_file), + "shell_hook": "/nix/store/missing-hook", + } + ) + + with pytest.raises(ManifestError, match="shell hook"): + resolve_realized_closures(manifest) -- 2.51.2