From 1cc9a3180986dab5ff694fe5d6d2862dabd09ba8 Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Tue, 10 Feb 2026 17:33:38 +0000 Subject: [PATCH] feat(cli): add -e/--env flag for passing env vars as scrubbed secrets Prior to this commit, there was no way to pass environment variables to the sandbox via the CLI. This commit adds Docker-style `-e KEY=VALUE` and `-e KEY` (inherit from host) flags. Values are passed through the existing secrets API so they are automatically scrubbed from stdout/stderr/file output. Co-Authored-By: Claude Opus 4.6 --- crates/eryx-python/python/eryx/__main__.py | 8 +- crates/eryx-python/python/eryx/_cli.py | 35 ++++++++ crates/eryx-python/tests/test_cli.py | 98 ++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) 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)