diff --git a/crates/eryx-python/python/eryx/__main__.py b/crates/eryx-python/python/eryx/__main__.py index adf4cbf6..765ce95b 100644 --- a/crates/eryx-python/python/eryx/__main__.py +++ b/crates/eryx-python/python/eryx/__main__.py @@ -22,7 +22,7 @@ import eryx -from eryx._cli import add_sandbox_args, make_mcp_manager, make_net_config, make_resource_limits +from eryx._cli import add_sandbox_args, make_mcp_manager, make_net_config, make_resource_limits, make_secrets def _build_parser() -> argparse.ArgumentParser: @@ -90,6 +90,9 @@ def _run_once(code: str, args: argparse.Namespace, mcp_manager: object | None = kwargs["resource_limits"] = limits if net is not None: kwargs["network"] = net + secrets = make_secrets(args) + if secrets is not None: + kwargs["secrets"] = secrets if args.volume: kwargs["volumes"] = args.volume if mcp_manager is not None: @@ -127,6 +130,9 @@ def _repl(args: argparse.Namespace, mcp_manager: object | None = None) -> int: net = make_net_config(args) if net is not None: kwargs["network"] = net + secrets = make_secrets(args) + if secrets is not None: + kwargs["secrets"] = secrets if args.volume: kwargs["volumes"] = args.volume if mcp_manager is not None: diff --git a/crates/eryx-python/python/eryx/_cli.py b/crates/eryx-python/python/eryx/_cli.py index d4b41025..45322e97 100644 --- a/crates/eryx-python/python/eryx/_cli.py +++ b/crates/eryx-python/python/eryx/_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import os import eryx @@ -73,6 +74,17 @@ def add_sandbox_args(parser: argparse.ArgumentParser) -> None: help="path to MCP config file (implies --mcp, can be repeated)", ) + env = parser.add_argument_group("environment") + env.add_argument( + "-e", + "--env", + action="append", + default=[], + metavar="KEY[=VALUE]", + help="pass environment variable to sandbox (scrubbed from output). " + "Use KEY=VALUE to set explicitly, or KEY to inherit from host", + ) + fs = parser.add_argument_group("filesystem") fs.add_argument( "-v", @@ -107,6 +119,29 @@ def make_net_config(args: argparse.Namespace) -> eryx.NetConfig | None: return config +def make_secrets(args: argparse.Namespace) -> dict | None: + """Build secrets dict from -e/--env CLI args, or None if no env vars. + + Each -e spec is either KEY=VALUE (explicit) or KEY (inherit from host). + Values are passed as scrubbed secrets so they're redacted in output. + """ + if not args.env: + return None + secrets = {} + for spec in args.env: + if "=" in spec: + key, value = spec.split("=", 1) + else: + key = spec + value = os.environ.get(key) + if value is None: + raise argparse.ArgumentTypeError( + f"environment variable '{key}' is not set" + ) + secrets[key] = {"value": value} + return secrets + + def make_mcp_manager(args: argparse.Namespace) -> object | None: """Create an MCP manager if --mcp or --mcp-config is specified.""" if not args.mcp and not args.mcp_config: diff --git a/crates/eryx-python/tests/test_cli.py b/crates/eryx-python/tests/test_cli.py index f1f069a6..1c2d4a09 100644 --- a/crates/eryx-python/tests/test_cli.py +++ b/crates/eryx-python/tests/test_cli.py @@ -1,5 +1,6 @@ """Tests for the eryx CLI (__main__.py).""" +import os import subprocess import sys import textwrap @@ -8,6 +9,7 @@ import pytest from eryx.__main__ import main +from eryx._cli import make_secrets class TestCliCommandExecution: @@ -197,3 +199,99 @@ def test_piped_stdin_without_dash(self, capsys): assert result == 0 captured = capsys.readouterr() assert "piped" in captured.out + + +class TestMakeSecrets: + """Tests for the make_secrets helper.""" + + def test_no_env_returns_none(self): + args = _make_args(env=[]) + assert make_secrets(args) is None + + def test_explicit_key_value(self): + args = _make_args(env=["MY_KEY=my_value"]) + secrets = make_secrets(args) + assert secrets == {"MY_KEY": {"value": "my_value"}} + + def test_value_with_equals(self): + args = _make_args(env=["DSN=postgres://user:pass@host/db"]) + secrets = make_secrets(args) + assert secrets == {"DSN": {"value": "postgres://user:pass@host/db"}} + + def test_empty_value(self): + args = _make_args(env=["EMPTY="]) + secrets = make_secrets(args) + assert secrets == {"EMPTY": {"value": ""}} + + def test_inherit_from_host(self): + with patch.dict(os.environ, {"HOST_VAR": "host_value"}): + args = _make_args(env=["HOST_VAR"]) + secrets = make_secrets(args) + assert secrets == {"HOST_VAR": {"value": "host_value"}} + + def test_inherit_missing_var_raises(self): + # Ensure the var is not set + env = os.environ.copy() + env.pop("DEFINITELY_NOT_SET_12345", None) + with patch.dict(os.environ, env, clear=True): + args = _make_args(env=["DEFINITELY_NOT_SET_12345"]) + with pytest.raises(Exception, match="not set"): + make_secrets(args) + + def test_multiple_env_vars(self): + args = _make_args(env=["A=1", "B=2"]) + secrets = make_secrets(args) + assert secrets == {"A": {"value": "1"}, "B": {"value": "2"}} + + +class TestCliEnvFlag: + """Integration tests for -e/--env CLI flag.""" + + def test_env_var_available_in_sandbox(self, capsys): + result = main(["-e", "TEST_VAR=hello_sandbox", "-c", "import os; print(os.environ['TEST_VAR'])"]) + assert result == 0 + captured = capsys.readouterr() + # Value should be scrubbed (it's a secret) + assert "hello_sandbox" not in captured.out + assert "[REDACTED]" in captured.out + + def test_env_var_value_is_scrubbed(self, capsys): + result = main(["-e", "SECRET=super_secret_42", "-c", "import os; print(os.environ['SECRET'])"]) + assert result == 0 + captured = capsys.readouterr() + assert "super_secret_42" not in captured.out + assert "[REDACTED]" in captured.out + + def test_multiple_env_vars(self, capsys): + result = main([ + "-e", "VAR_A=aaa", + "-e", "VAR_B=bbb", + "-c", "import os; print(os.environ['VAR_A'], os.environ['VAR_B'])", + ]) + assert result == 0 + captured = capsys.readouterr() + assert "aaa" not in captured.out + assert "bbb" not in captured.out + + def test_env_inherit_from_host(self, capsys): + with patch.dict(os.environ, {"MY_HOST_VAR": "from_host"}): + result = main(["-e", "MY_HOST_VAR", "-c", "import os; print(os.environ['MY_HOST_VAR'])"]) + assert result == 0 + captured = capsys.readouterr() + assert "from_host" not in captured.out + assert "[REDACTED]" in captured.out + + def test_long_form_env_flag(self, capsys): + result = main(["--env", "LONGFORM=works", "-c", "import os; print(os.environ['LONGFORM'])"]) + assert result == 0 + captured = capsys.readouterr() + assert "works" not in captured.out + assert "[REDACTED]" in captured.out + + +def _make_args(**kwargs): + """Create a minimal argparse.Namespace for testing.""" + import argparse + defaults = {"env": []} + defaults.update(kwargs) + return argparse.Namespace(**defaults)