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:
Teknium 2026-03-24 08:19:34 -07:00 committed by GitHub
parent ad1bf16f28
commit 745859babb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 527 additions and 6 deletions

View file

@ -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",

View 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

View 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

View file

@ -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
View 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

View file

@ -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(":"):

View file

@ -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,

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.