mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: env var passthrough for skills and user config (#2807)
* feat: env var passthrough for skills and user config Skills that declare required_environment_variables now have those vars passed through to sandboxed execution environments (execute_code and terminal). Previously, execute_code stripped all vars containing KEY, TOKEN, SECRET, etc. and the terminal blocklist removed Hermes infrastructure vars — both blocked skill-declared env vars. Two passthrough sources: 1. Skill-scoped (automatic): when a skill is loaded via skill_view and declares required_environment_variables, vars that are present in the environment are registered in a session-scoped passthrough set. 2. Config-based (manual): terminal.env_passthrough in config.yaml lets users explicitly allowlist vars for non-skill use cases. Changes: - New module: tools/env_passthrough.py — shared passthrough registry - hermes_cli/config.py: add terminal.env_passthrough to DEFAULT_CONFIG - tools/skills_tool.py: register available skill env vars on load - tools/code_execution_tool.py: check passthrough before filtering - tools/environments/local.py: check passthrough in _sanitize_subprocess_env and _make_run_env - 19 new tests covering all layers * docs: add environment variable passthrough documentation Document the env var passthrough feature across four docs pages: - security.md: new 'Environment Variable Passthrough' section with full explanation, comparison table, and security considerations - code-execution.md: update security section, add passthrough subsection, fix comparison table - creating-skills.md: add tip about automatic sandbox passthrough - skills.md: add note about passthrough after secure setup docs Live-tested: launched interactive CLI, loaded a skill with required_environment_variables, verified TEST_SKILL_SECRET_KEY was accessible inside execute_code sandbox (value: passthrough-test-value-42).
This commit is contained in:
parent
ad1bf16f28
commit
745859babb
11 changed files with 527 additions and 6 deletions
|
|
@ -119,6 +119,10 @@ DEFAULT_CONFIG = {
|
|||
"backend": "local",
|
||||
"cwd": ".", # Use current directory
|
||||
"timeout": 180,
|
||||
# Environment variables to pass through to sandboxed execution
|
||||
# (terminal and execute_code). Skill-declared required_environment_variables
|
||||
# are passed through automatically; this list is for non-skill use cases.
|
||||
"env_passthrough": [],
|
||||
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"docker_forward_env": [],
|
||||
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
|
|
|
|||
199
tests/tools/test_env_passthrough.py
Normal file
199
tests/tools/test_env_passthrough.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Tests for tools.env_passthrough — skill and config env var passthrough."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tools.env_passthrough import (
|
||||
clear_env_passthrough,
|
||||
get_all_passthrough,
|
||||
is_env_passthrough,
|
||||
register_env_passthrough,
|
||||
reset_config_cache,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_passthrough():
|
||||
"""Ensure a clean passthrough state for every test."""
|
||||
clear_env_passthrough()
|
||||
reset_config_cache()
|
||||
yield
|
||||
clear_env_passthrough()
|
||||
reset_config_cache()
|
||||
|
||||
|
||||
class TestSkillScopedPassthrough:
|
||||
def test_register_and_check(self):
|
||||
assert not is_env_passthrough("TENOR_API_KEY")
|
||||
register_env_passthrough(["TENOR_API_KEY"])
|
||||
assert is_env_passthrough("TENOR_API_KEY")
|
||||
|
||||
def test_register_multiple(self):
|
||||
register_env_passthrough(["FOO_TOKEN", "BAR_SECRET"])
|
||||
assert is_env_passthrough("FOO_TOKEN")
|
||||
assert is_env_passthrough("BAR_SECRET")
|
||||
assert not is_env_passthrough("OTHER_KEY")
|
||||
|
||||
def test_clear(self):
|
||||
register_env_passthrough(["TENOR_API_KEY"])
|
||||
assert is_env_passthrough("TENOR_API_KEY")
|
||||
clear_env_passthrough()
|
||||
assert not is_env_passthrough("TENOR_API_KEY")
|
||||
|
||||
def test_get_all(self):
|
||||
register_env_passthrough(["A_KEY", "B_TOKEN"])
|
||||
result = get_all_passthrough()
|
||||
assert "A_KEY" in result
|
||||
assert "B_TOKEN" in result
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
register_env_passthrough([" SPACED_KEY "])
|
||||
assert is_env_passthrough("SPACED_KEY")
|
||||
|
||||
def test_skips_empty(self):
|
||||
register_env_passthrough(["", " ", "VALID_KEY"])
|
||||
assert is_env_passthrough("VALID_KEY")
|
||||
assert not is_env_passthrough("")
|
||||
|
||||
|
||||
class TestConfigPassthrough:
|
||||
def test_reads_from_config(self, tmp_path, monkeypatch):
|
||||
config = {"terminal": {"env_passthrough": ["MY_CUSTOM_KEY", "ANOTHER_TOKEN"]}}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
reset_config_cache()
|
||||
|
||||
assert is_env_passthrough("MY_CUSTOM_KEY")
|
||||
assert is_env_passthrough("ANOTHER_TOKEN")
|
||||
assert not is_env_passthrough("UNRELATED_VAR")
|
||||
|
||||
def test_empty_config(self, tmp_path, monkeypatch):
|
||||
config = {"terminal": {"env_passthrough": []}}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
reset_config_cache()
|
||||
|
||||
assert not is_env_passthrough("ANYTHING")
|
||||
|
||||
def test_missing_config_key(self, tmp_path, monkeypatch):
|
||||
config = {"terminal": {"backend": "local"}}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
reset_config_cache()
|
||||
|
||||
assert not is_env_passthrough("ANYTHING")
|
||||
|
||||
def test_no_config_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
reset_config_cache()
|
||||
|
||||
assert not is_env_passthrough("ANYTHING")
|
||||
|
||||
def test_union_of_skill_and_config(self, tmp_path, monkeypatch):
|
||||
config = {"terminal": {"env_passthrough": ["CONFIG_KEY"]}}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
reset_config_cache()
|
||||
|
||||
register_env_passthrough(["SKILL_KEY"])
|
||||
all_pt = get_all_passthrough()
|
||||
assert "CONFIG_KEY" in all_pt
|
||||
assert "SKILL_KEY" in all_pt
|
||||
|
||||
|
||||
class TestExecuteCodeIntegration:
|
||||
"""Verify that the passthrough is checked in execute_code's env filtering."""
|
||||
|
||||
def test_secret_substring_blocked_by_default(self):
|
||||
"""TENOR_API_KEY should be blocked without passthrough."""
|
||||
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
|
||||
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
|
||||
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
|
||||
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
|
||||
"PASSWD", "AUTH")
|
||||
|
||||
test_env = {"PATH": "/usr/bin", "TENOR_API_KEY": "test123", "HOME": "/home/user"}
|
||||
child_env = {}
|
||||
for k, v in test_env.items():
|
||||
if is_env_passthrough(k):
|
||||
child_env[k] = v
|
||||
continue
|
||||
if any(s in k.upper() for s in _SECRET_SUBSTRINGS):
|
||||
continue
|
||||
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
|
||||
child_env[k] = v
|
||||
|
||||
assert "PATH" in child_env
|
||||
assert "HOME" in child_env
|
||||
assert "TENOR_API_KEY" not in child_env
|
||||
|
||||
def test_passthrough_allows_secret_through(self):
|
||||
"""TENOR_API_KEY should pass through when registered."""
|
||||
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
|
||||
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
|
||||
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
|
||||
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
|
||||
"PASSWD", "AUTH")
|
||||
|
||||
register_env_passthrough(["TENOR_API_KEY"])
|
||||
|
||||
test_env = {"PATH": "/usr/bin", "TENOR_API_KEY": "test123", "HOME": "/home/user"}
|
||||
child_env = {}
|
||||
for k, v in test_env.items():
|
||||
if is_env_passthrough(k):
|
||||
child_env[k] = v
|
||||
continue
|
||||
if any(s in k.upper() for s in _SECRET_SUBSTRINGS):
|
||||
continue
|
||||
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
|
||||
child_env[k] = v
|
||||
|
||||
assert "PATH" in child_env
|
||||
assert "HOME" in child_env
|
||||
assert "TENOR_API_KEY" in child_env
|
||||
assert child_env["TENOR_API_KEY"] == "test123"
|
||||
|
||||
|
||||
class TestTerminalIntegration:
|
||||
"""Verify that the passthrough is checked in terminal's env sanitizers."""
|
||||
|
||||
def test_blocklisted_var_blocked_by_default(self):
|
||||
from tools.environments.local import _sanitize_subprocess_env, _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
|
||||
# Pick a var we know is in the blocklist
|
||||
blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST))
|
||||
env = {blocked_var: "secret_value", "PATH": "/usr/bin"}
|
||||
result = _sanitize_subprocess_env(env)
|
||||
assert blocked_var not in result
|
||||
assert "PATH" in result
|
||||
|
||||
def test_passthrough_allows_blocklisted_var(self):
|
||||
from tools.environments.local import _sanitize_subprocess_env, _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
|
||||
blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST))
|
||||
register_env_passthrough([blocked_var])
|
||||
|
||||
env = {blocked_var: "secret_value", "PATH": "/usr/bin"}
|
||||
result = _sanitize_subprocess_env(env)
|
||||
assert blocked_var in result
|
||||
assert result[blocked_var] == "secret_value"
|
||||
|
||||
def test_make_run_env_passthrough(self, monkeypatch):
|
||||
from tools.environments.local import _make_run_env, _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
|
||||
blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST))
|
||||
monkeypatch.setenv(blocked_var, "secret_value")
|
||||
|
||||
# Without passthrough — blocked
|
||||
result_before = _make_run_env({})
|
||||
assert blocked_var not in result_before
|
||||
|
||||
# With passthrough — allowed
|
||||
register_env_passthrough([blocked_var])
|
||||
result_after = _make_run_env({})
|
||||
assert blocked_var in result_after
|
||||
105
tests/tools/test_skill_env_passthrough.py
Normal file
105
tests/tools/test_skill_env_passthrough.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Test that skill_view registers required env vars in the passthrough registry."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.env_passthrough import clear_env_passthrough, is_env_passthrough, reset_config_cache
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_passthrough():
|
||||
clear_env_passthrough()
|
||||
reset_config_cache()
|
||||
yield
|
||||
clear_env_passthrough()
|
||||
reset_config_cache()
|
||||
|
||||
|
||||
def _create_skill(tmp_path, name, frontmatter_extra=""):
|
||||
"""Create a minimal skill directory with SKILL.md."""
|
||||
skill_dir = tmp_path / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\n"
|
||||
f"name: {name}\n"
|
||||
f"description: Test skill\n"
|
||||
f"{frontmatter_extra}"
|
||||
f"---\n\n"
|
||||
f"# {name}\n\n"
|
||||
f"Test content.\n"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestSkillViewRegistersPassthrough:
|
||||
def test_available_env_vars_registered(self, tmp_path, monkeypatch):
|
||||
"""When a skill declares required_environment_variables and the var IS set,
|
||||
it should be registered in the passthrough."""
|
||||
_create_skill(
|
||||
tmp_path,
|
||||
"test-skill",
|
||||
frontmatter_extra=(
|
||||
"required_environment_variables:\n"
|
||||
" - name: TENOR_API_KEY\n"
|
||||
" prompt: Enter your Tenor API key\n"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_tool.SKILLS_DIR", tmp_path
|
||||
)
|
||||
# Set the env var so it's "available"
|
||||
monkeypatch.setenv("TENOR_API_KEY", "test-value-123")
|
||||
|
||||
# Patch the secret capture callback to not prompt
|
||||
with patch("tools.skills_tool._secret_capture_callback", None):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
result = json.loads(skill_view(name="test-skill"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert is_env_passthrough("TENOR_API_KEY")
|
||||
|
||||
def test_missing_env_vars_not_registered(self, tmp_path, monkeypatch):
|
||||
"""When a skill declares required_environment_variables but the var is NOT set,
|
||||
it should NOT be registered in the passthrough."""
|
||||
_create_skill(
|
||||
tmp_path,
|
||||
"test-skill",
|
||||
frontmatter_extra=(
|
||||
"required_environment_variables:\n"
|
||||
" - name: NONEXISTENT_SKILL_KEY_XYZ\n"
|
||||
" prompt: Enter your key\n"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_tool.SKILLS_DIR", tmp_path
|
||||
)
|
||||
monkeypatch.delenv("NONEXISTENT_SKILL_KEY_XYZ", raising=False)
|
||||
|
||||
with patch("tools.skills_tool._secret_capture_callback", None):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
result = json.loads(skill_view(name="test-skill"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert not is_env_passthrough("NONEXISTENT_SKILL_KEY_XYZ")
|
||||
|
||||
def test_no_env_vars_skill_no_registration(self, tmp_path, monkeypatch):
|
||||
"""Skills without required_environment_variables shouldn't register anything."""
|
||||
_create_skill(tmp_path, "simple-skill")
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_tool.SKILLS_DIR", tmp_path
|
||||
)
|
||||
|
||||
with patch("tools.skills_tool._secret_capture_callback", None):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
result = json.loads(skill_view(name="simple-skill"))
|
||||
|
||||
assert result["success"] is True
|
||||
from tools.env_passthrough import get_all_passthrough
|
||||
assert len(get_all_passthrough()) == 0
|
||||
|
|
@ -428,15 +428,28 @@ def execute_code(
|
|||
# Build a minimal environment for the child. We intentionally exclude
|
||||
# API keys and tokens to prevent credential exfiltration from LLM-
|
||||
# generated scripts. The child accesses tools via RPC, not direct API.
|
||||
# Exception: env vars declared by loaded skills (via env_passthrough
|
||||
# registry) or explicitly allowed by the user in config.yaml
|
||||
# (terminal.env_passthrough) are passed through.
|
||||
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
|
||||
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
|
||||
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
|
||||
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
|
||||
"PASSWD", "AUTH")
|
||||
try:
|
||||
from tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
except Exception:
|
||||
_is_passthrough = lambda _: False # noqa: E731
|
||||
child_env = {}
|
||||
for k, v in os.environ.items():
|
||||
# Passthrough vars (skill-declared or user-configured) always pass.
|
||||
if _is_passthrough(k):
|
||||
child_env[k] = v
|
||||
continue
|
||||
# Block vars with secret-like names.
|
||||
if any(s in k.upper() for s in _SECRET_SUBSTRINGS):
|
||||
continue
|
||||
# Allow vars with known safe prefixes.
|
||||
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
|
||||
child_env[k] = v
|
||||
child_env["HERMES_RPC_SOCKET"] = sock_path
|
||||
|
|
|
|||
99
tools/env_passthrough.py
Normal file
99
tools/env_passthrough.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Environment variable passthrough registry.
|
||||
|
||||
Skills that declare ``required_environment_variables`` in their frontmatter
|
||||
need those vars available in sandboxed execution environments (execute_code,
|
||||
terminal). By default both sandboxes strip secrets from the child process
|
||||
environment for security. This module provides a session-scoped allowlist
|
||||
so skill-declared vars (and user-configured overrides) pass through.
|
||||
|
||||
Two sources feed the allowlist:
|
||||
|
||||
1. **Skill declarations** — when a skill is loaded via ``skill_view``, its
|
||||
``required_environment_variables`` are registered here automatically.
|
||||
2. **User config** — ``terminal.env_passthrough`` in config.yaml lets users
|
||||
explicitly allowlist vars for non-skill use cases.
|
||||
|
||||
Both ``code_execution_tool.py`` and ``tools/environments/local.py`` consult
|
||||
:func:`is_env_passthrough` before stripping a variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Session-scoped set of env var names that should pass through to sandboxes.
|
||||
_allowed_env_vars: set[str] = set()
|
||||
|
||||
# Cache for the config-based allowlist (loaded once per process).
|
||||
_config_passthrough: frozenset[str] | None = None
|
||||
|
||||
|
||||
def register_env_passthrough(var_names: Iterable[str]) -> None:
|
||||
"""Register environment variable names as allowed in sandboxed environments.
|
||||
|
||||
Typically called when a skill declares ``required_environment_variables``.
|
||||
"""
|
||||
for name in var_names:
|
||||
name = name.strip()
|
||||
if name:
|
||||
_allowed_env_vars.add(name)
|
||||
logger.debug("env passthrough: registered %s", name)
|
||||
|
||||
|
||||
def _load_config_passthrough() -> frozenset[str]:
|
||||
"""Load ``tools.env_passthrough`` from config.yaml (cached)."""
|
||||
global _config_passthrough
|
||||
if _config_passthrough is not None:
|
||||
return _config_passthrough
|
||||
|
||||
result: set[str] = set()
|
||||
try:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
config_path = hermes_home / "config.yaml"
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
passthrough = cfg.get("terminal", {}).get("env_passthrough")
|
||||
if isinstance(passthrough, list):
|
||||
for item in passthrough:
|
||||
if isinstance(item, str) and item.strip():
|
||||
result.add(item.strip())
|
||||
except Exception as e:
|
||||
logger.debug("Could not read tools.env_passthrough from config: %s", e)
|
||||
|
||||
_config_passthrough = frozenset(result)
|
||||
return _config_passthrough
|
||||
|
||||
|
||||
def is_env_passthrough(var_name: str) -> bool:
|
||||
"""Check whether *var_name* is allowed to pass through to sandboxes.
|
||||
|
||||
Returns ``True`` if the variable was registered by a skill or listed in
|
||||
the user's ``tools.env_passthrough`` config.
|
||||
"""
|
||||
if var_name in _allowed_env_vars:
|
||||
return True
|
||||
return var_name in _load_config_passthrough()
|
||||
|
||||
|
||||
def get_all_passthrough() -> frozenset[str]:
|
||||
"""Return the union of skill-registered and config-based passthrough vars."""
|
||||
return frozenset(_allowed_env_vars) | _load_config_passthrough()
|
||||
|
||||
|
||||
def clear_env_passthrough() -> None:
|
||||
"""Reset the skill-scoped allowlist (e.g. on session reset)."""
|
||||
_allowed_env_vars.clear()
|
||||
|
||||
|
||||
def reset_config_cache() -> None:
|
||||
"""Force re-read of config on next access (for testing)."""
|
||||
global _config_passthrough
|
||||
_config_passthrough = None
|
||||
|
|
@ -135,21 +135,28 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
|
|||
"""Filter Hermes-managed secrets from a subprocess environment.
|
||||
|
||||
`_HERMES_FORCE_<VAR>` entries in ``extra_env`` opt a blocked variable back in
|
||||
intentionally for callers that truly need it.
|
||||
intentionally for callers that truly need it. Vars registered via
|
||||
:mod:`tools.env_passthrough` (skill-declared or user-configured) also
|
||||
bypass the blocklist.
|
||||
"""
|
||||
try:
|
||||
from tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
except Exception:
|
||||
_is_passthrough = lambda _: False # noqa: E731
|
||||
|
||||
sanitized: dict[str, str] = {}
|
||||
|
||||
for key, value in (base_env or {}).items():
|
||||
if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):
|
||||
continue
|
||||
if key not in _HERMES_PROVIDER_ENV_BLOCKLIST:
|
||||
if key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key):
|
||||
sanitized[key] = value
|
||||
|
||||
for key, value in (extra_env or {}).items():
|
||||
if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):
|
||||
real_key = key[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):]
|
||||
sanitized[real_key] = value
|
||||
elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST:
|
||||
elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key):
|
||||
sanitized[key] = value
|
||||
|
||||
return sanitized
|
||||
|
|
@ -264,13 +271,18 @@ _SANE_PATH = (
|
|||
|
||||
def _make_run_env(env: dict) -> dict:
|
||||
"""Build a run environment with a sane PATH and provider-var stripping."""
|
||||
try:
|
||||
from tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
except Exception:
|
||||
_is_passthrough = lambda _: False # noqa: E731
|
||||
|
||||
merged = dict(os.environ | env)
|
||||
run_env = {}
|
||||
for k, v in merged.items():
|
||||
if k.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):
|
||||
real_key = k[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):]
|
||||
run_env[real_key] = v
|
||||
elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST:
|
||||
elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(k):
|
||||
run_env[k] = v
|
||||
existing_path = run_env.get("PATH", "")
|
||||
if "/usr/bin" not in existing_path.split(":"):
|
||||
|
|
|
|||
|
|
@ -1146,6 +1146,26 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
|||
)
|
||||
setup_needed = bool(remaining_missing_required_envs)
|
||||
|
||||
# Register available skill env vars so they pass through to sandboxed
|
||||
# execution environments (execute_code, terminal). Only vars that are
|
||||
# actually set get registered — missing ones are reported as setup_needed.
|
||||
available_env_names = [
|
||||
e["name"]
|
||||
for e in required_env_vars
|
||||
if e["name"] not in remaining_missing_required_envs
|
||||
]
|
||||
if available_env_names:
|
||||
try:
|
||||
from tools.env_passthrough import register_env_passthrough
|
||||
|
||||
register_env_passthrough(available_env_names)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not register env passthrough for skill %s",
|
||||
skill_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"name": skill_name,
|
||||
|
|
|
|||
|
|
@ -107,6 +107,10 @@ required_environment_variables:
|
|||
|
||||
The user can skip setup and keep loading the skill. Hermes never exposes the raw secret value to the model. Gateway and messaging sessions show local setup guidance instead of collecting secrets in-band.
|
||||
|
||||
:::tip Sandbox Passthrough
|
||||
When your skill is loaded, any declared `required_environment_variables` that are set are **automatically passed through** to `execute_code` and `terminal` sandboxes. Your skill's scripts can access `$TENOR_API_KEY` (or `os.environ["TENOR_API_KEY"]` in Python) without the user needing to configure anything extra. See [Environment Variable Passthrough](/docs/user-guide/security#environment-variable-passthrough) for details.
|
||||
:::
|
||||
|
||||
Legacy `prerequisites.env_vars` remains supported as a backward-compatible alias.
|
||||
|
||||
## Skill Guidelines
|
||||
|
|
|
|||
|
|
@ -169,11 +169,26 @@ The response always includes `status` (success/error/timeout/interrupted), `outp
|
|||
## Security
|
||||
|
||||
:::danger Security Model
|
||||
The child process runs with a **minimal environment**. API keys, tokens, and credentials are stripped entirely. The script accesses tools exclusively via the RPC channel — it cannot read secrets from environment variables.
|
||||
The child process runs with a **minimal environment**. API keys, tokens, and credentials are stripped by default. The script accesses tools exclusively via the RPC channel — it cannot read secrets from environment variables unless explicitly allowed.
|
||||
:::
|
||||
|
||||
Environment variables containing `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `PASSWD`, or `AUTH` in their names are excluded. Only safe system variables (`PATH`, `HOME`, `LANG`, `SHELL`, `PYTHONPATH`, `VIRTUAL_ENV`, etc.) are passed through.
|
||||
|
||||
### Skill Environment Variable Passthrough
|
||||
|
||||
When a skill declares `required_environment_variables` in its frontmatter, those variables are **automatically passed through** to both `execute_code` and `terminal` sandboxes after the skill is loaded. This lets skills use their declared API keys without weakening the security posture for arbitrary code.
|
||||
|
||||
For non-skill use cases, you can explicitly allowlist variables in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
env_passthrough:
|
||||
- MY_CUSTOM_KEY
|
||||
- ANOTHER_TOKEN
|
||||
```
|
||||
|
||||
See the [Security guide](/docs/user-guide/security#environment-variable-passthrough) for full details.
|
||||
|
||||
The script runs in a temporary directory that is cleaned up after execution. The child process runs in its own process group so it can be cleanly killed on timeout or interruption.
|
||||
|
||||
## execute_code vs terminal
|
||||
|
|
@ -186,7 +201,7 @@ The script runs in a temporary directory that is cleaned up after execution. The
|
|||
| Running a build or test suite | ❌ | ✅ |
|
||||
| Looping over search results | ✅ | ❌ |
|
||||
| Interactive/background processes | ❌ | ✅ |
|
||||
| Needs API keys in environment | ❌ | ✅ |
|
||||
| Needs API keys in environment | ⚠️ Only via [passthrough](/docs/user-guide/security#environment-variable-passthrough) | ✅ (most pass through) |
|
||||
|
||||
**Rule of thumb:** Use `execute_code` when you need to call Hermes tools programmatically with logic between calls. Use `terminal` for running shell commands, builds, and processes.
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ required_environment_variables:
|
|||
|
||||
When a missing value is encountered, Hermes asks for it securely only when the skill is actually loaded in the local CLI. You can skip setup and keep using the skill. Messaging surfaces never ask for secrets in chat — they tell you to use `hermes setup` or `~/.hermes/.env` locally instead.
|
||||
|
||||
Once set, declared env vars are **automatically passed through** to `execute_code` and `terminal` sandboxes — the skill's scripts can use `$TENOR_API_KEY` directly. For non-skill env vars, use the `terminal.env_passthrough` config option. See [Environment Variable Passthrough](/docs/user-guide/security#environment-variable-passthrough) for details.
|
||||
|
||||
## Skill Directory Structure
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -256,6 +256,54 @@ If you add names to `terminal.docker_forward_env`, those variables are intention
|
|||
| **modal** | Cloud sandbox | ❌ Skipped | Scalable cloud isolation |
|
||||
| **daytona** | Cloud sandbox | ❌ Skipped | Persistent cloud workspaces |
|
||||
|
||||
## Environment Variable Passthrough {#environment-variable-passthrough}
|
||||
|
||||
Both `execute_code` and `terminal` strip sensitive environment variables from child processes to prevent credential exfiltration by LLM-generated code. However, skills that declare `required_environment_variables` legitimately need access to those vars.
|
||||
|
||||
### How It Works
|
||||
|
||||
Two mechanisms allow specific variables through the sandbox filters:
|
||||
|
||||
**1. Skill-scoped passthrough (automatic)**
|
||||
|
||||
When a skill is loaded (via `skill_view` or the `/skill` command) and declares `required_environment_variables`, any of those vars that are actually set in the environment are automatically registered as passthrough. Missing vars (still in setup-needed state) are **not** registered.
|
||||
|
||||
```yaml
|
||||
# In a skill's SKILL.md frontmatter
|
||||
required_environment_variables:
|
||||
- name: TENOR_API_KEY
|
||||
prompt: Tenor API key
|
||||
help: Get a key from https://developers.google.com/tenor
|
||||
```
|
||||
|
||||
After loading this skill, `TENOR_API_KEY` passes through to both `execute_code` and `terminal` subprocesses — no manual configuration needed.
|
||||
|
||||
**2. Config-based passthrough (manual)**
|
||||
|
||||
For env vars not declared by any skill, add them to `terminal.env_passthrough` in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
env_passthrough:
|
||||
- MY_CUSTOM_KEY
|
||||
- ANOTHER_TOKEN
|
||||
```
|
||||
|
||||
### What Each Sandbox Filters
|
||||
|
||||
| Sandbox | Default Filter | Passthrough Override |
|
||||
|---------|---------------|---------------------|
|
||||
| **execute_code** | Blocks vars containing `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `PASSWD`, `AUTH` in name; only allows safe-prefix vars through | ✅ Passthrough vars bypass both checks |
|
||||
| **terminal** (local) | Blocks explicit Hermes infrastructure vars (provider keys, gateway tokens, tool API keys) | ✅ Passthrough vars bypass the blocklist |
|
||||
| **MCP** | Blocks everything except safe system vars + explicitly configured `env` | ❌ Not affected by passthrough (use MCP `env` config instead) |
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- The passthrough only affects vars you or your skills explicitly declare — the default security posture is unchanged for arbitrary LLM-generated code
|
||||
- Skills Guard scans skill content for suspicious env access patterns before installation
|
||||
- Missing/unset vars are never registered (you can't leak what doesn't exist)
|
||||
- Hermes infrastructure secrets (provider API keys, gateway tokens) should never be added to `env_passthrough` — they have dedicated mechanisms
|
||||
|
||||
## MCP Credential Handling
|
||||
|
||||
MCP (Model Context Protocol) server subprocesses receive a **filtered environment** to prevent accidental credential leakage.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue