mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(egress): address maxpetrusenko P1/P2 review — fail-closed secrets, NODE_OPTIONS conflict, GPG verify, threat-model scope
Follow-up to the #30179 security review (maxpetrusenko). P0s 1-3 are being handled separately (kuangmi-bit); this covers the three P1s + the P2. P1 #4 — secrets replace rules now emit `require: true`. Verified replaceConfig.Require EXISTS in the pinned iron-proxy v0.39.0 secrets transform (KnownFields(true) strict decode would otherwise reject it) and is enforced in TransformRequest: a request to an allowlisted upstream that arrives WITHOUT the proxy token in a matched location is rejected (ActionReject) rather than forwarded with whatever credential it carried. Closes the leak where a real provider key sent directly to an allowed host passed the proxy boundary. P1 #5 — NODE_OPTIONS append-merge now resolves CA-mode conflicts. A docker_env `--use-bundled-ca` would previously survive alongside the egress-required `--use-openssl-ca`, leaving Node's trust behavior dependent on option order. Egress flag now wins deterministically (conflicting CA-mode flags stripped + warning); unrelated operator tuning preserved. P1 #6 — install now GPG-verifies the release. checksums.txt is verified against checksums.txt.asc using the bundled public-key.asc in an ephemeral keyring. Best-effort: degrades with a warning when gpg/sig assets are unavailable (SHA-256 still enforced); a PRESENT-but-invalid signature is a tamper signal and hard-fails the install. P2 #7 — threat-model wording scoped to the 'configured trusted proxy boundary' across the module docstring, config.py, and the egress docs, with a new security-model bullet on CA-key / endpoint-integrity loss (MITRE T1588.004 AiTM). Tests: +2 NODE_OPTIONS conflict/preserve, +5 GPG verify (skip/missing/bad/ good/install-abort), +1 require assertion. iron_proxy 94 + docker 74 green; ruff clean.
This commit is contained in:
parent
a697b4b86a
commit
45da4b2363
6 changed files with 259 additions and 10 deletions
|
|
@ -12,7 +12,10 @@ iron-proxy is a TLS-intercepting egress firewall (Apache-2.0, Go binary, by
|
|||
ironsh). It sits between the sandbox and the internet, enforces a default-deny
|
||||
allowlist on outbound hosts, and *swaps proxy tokens for real credentials*
|
||||
on the way out. The sandbox only ever holds opaque proxy tokens — leaking
|
||||
them is useless, since they only work from behind the proxy.
|
||||
them is useless, since they only work behind the configured trusted proxy
|
||||
boundary (the CA private key and proxy endpoint integrity are part of that
|
||||
boundary: if traffic can be redirected to attacker-controlled proxy
|
||||
infrastructure, the guarantee no longer holds).
|
||||
|
||||
Design summary
|
||||
--------------
|
||||
|
|
@ -90,6 +93,12 @@ _IRON_PROXY_RELEASE_BASE = (
|
|||
f"https://github.com/ironsh/iron-proxy/releases/download/v{_IRON_PROXY_VERSION}"
|
||||
)
|
||||
_IRON_PROXY_CHECKSUM_NAME = "checksums.txt"
|
||||
# Detached signature for checksums.txt + the signing public key, both shipped on
|
||||
# the release. Used for optional GPG verification of the release channel
|
||||
# (maxpetrusenko P1): SHA-256 only protects the archive if checksums.txt itself
|
||||
# came from an uncompromised channel; verifying its signature closes that gap.
|
||||
_IRON_PROXY_CHECKSUM_SIG_NAME = "checksums.txt.asc"
|
||||
_IRON_PROXY_PUBKEY_NAME = "public-key.asc"
|
||||
|
||||
# How long to wait for HTTP downloads and subprocess interactions, in seconds.
|
||||
_DOWNLOAD_TIMEOUT = 120 # binary is ~16MB
|
||||
|
|
@ -436,6 +445,15 @@ def install_iron_proxy(*, force: bool = False) -> Path:
|
|||
_http_download(asset_url, archive_path)
|
||||
_http_download(checksum_url, checksum_path)
|
||||
|
||||
# Defense-in-depth (maxpetrusenko P1): verify the GPG signature of
|
||||
# checksums.txt before trusting it. The archive download honors ambient
|
||||
# proxy env (urllib), so a compromised channel could serve a matching
|
||||
# binary + checksums pair; the detached signature + pinned public key
|
||||
# close that release-channel tamper gap. Best-effort: if gpg or the
|
||||
# signature assets aren't available we log and fall back to the SHA-256
|
||||
# check alone rather than hard-failing offline installs.
|
||||
_verify_checksums_signature(tmp, checksum_path)
|
||||
|
||||
expected = _expected_sha256(checksum_path, asset_name)
|
||||
actual = _sha256_file(archive_path)
|
||||
if expected.lower() != actual.lower():
|
||||
|
|
@ -493,6 +511,79 @@ def _http_download(url: str, dest: Path) -> None:
|
|||
raise RuntimeError(f"Failed to download {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool:
|
||||
"""Best-effort GPG verification of ``checksums.txt`` (maxpetrusenko P1).
|
||||
|
||||
Downloads the detached signature (``checksums.txt.asc``) and the release
|
||||
signing key (``public-key.asc``), imports the key into an ephemeral
|
||||
keyring, and verifies the signature over ``checksum_path``.
|
||||
|
||||
Returns True when the signature is verified. Returns False (with a warning)
|
||||
when verification is unavailable — ``gpg`` not installed, or the signature /
|
||||
public-key assets are missing from the release. Raises RuntimeError ONLY
|
||||
when verification actively FAILS (a present-but-bad signature), which is a
|
||||
tamper signal we must not ignore.
|
||||
|
||||
Rationale for graceful degradation on "unavailable": the SHA-256 check
|
||||
against ``checksums.txt`` remains in force regardless, and many install
|
||||
hosts (CI, minimal containers) won't have gpg. We harden when we can and
|
||||
never make gpg a hard dependency for a working install.
|
||||
"""
|
||||
gpg = shutil.which("gpg")
|
||||
if not gpg:
|
||||
logger.warning(
|
||||
"gpg not found on PATH — skipping iron-proxy release-signature "
|
||||
"verification (SHA-256 checksum check still enforced)."
|
||||
)
|
||||
return False
|
||||
|
||||
sig_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_CHECKSUM_SIG_NAME}"
|
||||
pubkey_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_PUBKEY_NAME}"
|
||||
sig_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME
|
||||
pubkey_path = tmp / _IRON_PROXY_PUBKEY_NAME
|
||||
|
||||
try:
|
||||
_http_download(sig_url, sig_path)
|
||||
_http_download(pubkey_url, pubkey_path)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(
|
||||
"iron-proxy release signature assets unavailable (%s) — skipping "
|
||||
"GPG verification (SHA-256 checksum check still enforced).", exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Ephemeral keyring so we never touch the user's real GPG home.
|
||||
gnupg_home = tmp / "gnupg"
|
||||
gnupg_home.mkdir(mode=0o700, exist_ok=True)
|
||||
base_cmd = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"]
|
||||
|
||||
imp = subprocess.run( # noqa: S603 — gpg path from trusted PATH lookup
|
||||
[*base_cmd, "--import", str(pubkey_path)],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
if imp.returncode != 0:
|
||||
logger.warning(
|
||||
"Could not import iron-proxy signing key — skipping GPG "
|
||||
"verification (SHA-256 still enforced): %s",
|
||||
imp.stderr.decode("utf-8", "replace")[:200],
|
||||
)
|
||||
return False
|
||||
|
||||
verify = subprocess.run( # noqa: S603
|
||||
[*base_cmd, "--verify", str(sig_path), str(checksum_path)],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
if verify.returncode != 0:
|
||||
# A present signature that does NOT verify is a tamper signal — fail hard.
|
||||
raise RuntimeError(
|
||||
"iron-proxy checksums.txt failed GPG signature verification — "
|
||||
"refusing to install (possible release-channel tampering). "
|
||||
f"gpg: {verify.stderr.decode('utf-8', 'replace')[:300]}"
|
||||
)
|
||||
logger.info("Verified iron-proxy checksums.txt GPG signature.")
|
||||
return True
|
||||
|
||||
|
||||
def _expected_sha256(checksum_file: Path, asset_name: str) -> str:
|
||||
"""Parse the standard ``sha256sum`` output: ``<hex> <filename>``."""
|
||||
|
||||
|
|
@ -874,6 +965,16 @@ def build_proxy_config(
|
|||
# don't want body inspection forced for every request.
|
||||
"match_query": True,
|
||||
"match_body": False,
|
||||
# Fail closed (maxpetrusenko P1): when a request reaches an
|
||||
# allowlisted upstream WITHOUT the proxy token present in a
|
||||
# matched location, reject it instead of forwarding as-is.
|
||||
# Without this, a real provider key that a sandbox process
|
||||
# sent directly (not via the minted token) would still pass
|
||||
# the proxy boundary to the allowed host. With require=true,
|
||||
# iron-proxy returns ActionReject when no token swap fired
|
||||
# (v0.39 secrets transform: replaceConfig.Require, enforced in
|
||||
# TransformRequest — verified present in the pinned version).
|
||||
"require": True,
|
||||
},
|
||||
"rules": [{"host": h} for h in m.upstream_hosts],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2545,7 +2545,8 @@ DEFAULT_CONFIG = {
|
|||
# today; Modal/SSH in follow-ups) is routed through a managed iron-proxy
|
||||
# subprocess. The sandbox sees opaque proxy tokens; iron-proxy swaps in
|
||||
# real API credentials at the egress boundary. Compromising the sandbox
|
||||
# leaks tokens that only work from behind the proxy.
|
||||
# leaks tokens that only work behind the configured trusted proxy boundary
|
||||
# (CA private key + proxy endpoint integrity are part of that boundary).
|
||||
#
|
||||
# Configure with `hermes egress setup`. Disabled by default — the rest of
|
||||
# Hermes works exactly as before with `enabled: false`.
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ def test_build_proxy_config_shape(tmp_path):
|
|||
# The proxy token is the replacement target.
|
||||
assert rule["replace"]["proxy_value"] == m.proxy_token
|
||||
assert "Authorization" in rule["replace"]["match_headers"]
|
||||
# Fail-closed: a request to a mapped host without the proxy token must be
|
||||
# rejected, not forwarded with whatever credential it carried
|
||||
# (maxpetrusenko P1). iron-proxy's replaceConfig.Require enforces this.
|
||||
assert rule["replace"]["require"] is True
|
||||
# Rules list contains one entry per upstream host.
|
||||
rule_hosts = {r["host"] for r in rule["rules"]}
|
||||
assert rule_hosts == set(m.upstream_hosts)
|
||||
|
|
@ -566,6 +570,86 @@ def test_install_iron_proxy_rejects_missing_checksum_entry(hermes_home, monkeypa
|
|||
ip.install_iron_proxy()
|
||||
|
||||
|
||||
# ── GPG release-signature verification (maxpetrusenko P1) ────────────────────
|
||||
|
||||
def test_verify_checksums_signature_skips_without_gpg(hermes_home, monkeypatch, tmp_path):
|
||||
"""No gpg on PATH → degrade gracefully (return False), do not raise."""
|
||||
monkeypatch.setattr(ip.shutil, "which", lambda name: None)
|
||||
cks = tmp_path / "checksums.txt"
|
||||
cks.write_text("abc iron-proxy.tar.gz\n")
|
||||
assert ip._verify_checksums_signature(tmp_path, cks) is False
|
||||
|
||||
|
||||
def test_verify_checksums_signature_skips_when_sig_assets_missing(hermes_home, monkeypatch, tmp_path):
|
||||
"""gpg present but the release ships no .asc assets → degrade, don't raise."""
|
||||
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
|
||||
|
||||
def fail_download(url: str, dest: Path) -> None:
|
||||
raise RuntimeError("404 not found")
|
||||
monkeypatch.setattr(ip, "_http_download", fail_download)
|
||||
cks = tmp_path / "checksums.txt"
|
||||
cks.write_text("abc iron-proxy.tar.gz\n")
|
||||
assert ip._verify_checksums_signature(tmp_path, cks) is False
|
||||
|
||||
|
||||
def test_verify_checksums_signature_raises_on_bad_signature(hermes_home, monkeypatch, tmp_path):
|
||||
"""A present-but-INVALID signature is a tamper signal → must raise."""
|
||||
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
|
||||
monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc"))
|
||||
|
||||
class _R:
|
||||
def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD signature"
|
||||
def fake_run(cmd, **kw):
|
||||
# import succeeds (rc 0), verify fails (rc 1)
|
||||
return _R(0) if "--import" in cmd else _R(1)
|
||||
monkeypatch.setattr(ip.subprocess, "run", fake_run)
|
||||
|
||||
cks = tmp_path / "checksums.txt"
|
||||
cks.write_text("abc iron-proxy.tar.gz\n")
|
||||
with pytest.raises(RuntimeError, match="failed GPG signature verification"):
|
||||
ip._verify_checksums_signature(tmp_path, cks)
|
||||
|
||||
|
||||
def test_verify_checksums_signature_passes_on_good_signature(hermes_home, monkeypatch, tmp_path):
|
||||
"""Valid signature → returns True."""
|
||||
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
|
||||
monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc"))
|
||||
|
||||
class _R:
|
||||
def __init__(self, rc): self.returncode = rc; self.stderr = b""
|
||||
monkeypatch.setattr(ip.subprocess, "run", lambda cmd, **kw: _R(0))
|
||||
|
||||
cks = tmp_path / "checksums.txt"
|
||||
cks.write_text("abc iron-proxy.tar.gz\n")
|
||||
assert ip._verify_checksums_signature(tmp_path, cks) is True
|
||||
|
||||
|
||||
def test_install_aborts_on_bad_release_signature(hermes_home, monkeypatch):
|
||||
"""End-to-end: a tampered (bad-signature) release must abort install."""
|
||||
fake_payload = _make_fake_tar(ip._platform_binary_name())
|
||||
import hashlib
|
||||
sha = hashlib.sha256(fake_payload).hexdigest()
|
||||
asset_name = ip._platform_asset_name()
|
||||
|
||||
def fake_download(url: str, dest: Path) -> None:
|
||||
if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME):
|
||||
dest.write_text(f"{sha} {asset_name}\n")
|
||||
elif url.endswith(".asc"):
|
||||
dest.write_bytes(b"-----BEGIN PGP-----\n")
|
||||
else:
|
||||
dest.write_bytes(fake_payload)
|
||||
monkeypatch.setattr(ip, "_http_download", fake_download)
|
||||
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
|
||||
|
||||
class _R:
|
||||
def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD"
|
||||
monkeypatch.setattr(ip.subprocess, "run",
|
||||
lambda cmd, **kw: _R(0) if "--import" in cmd else _R(1))
|
||||
|
||||
with pytest.raises(RuntimeError, match="GPG signature verification"):
|
||||
ip.install_iron_proxy()
|
||||
|
||||
|
||||
def test_pick_tar_member_rejects_path_traversal():
|
||||
"""A malicious tar that escapes via '..' must be refused."""
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,52 @@ def test_docker_env_appears_in_run_command(monkeypatch):
|
|||
assert "GNUPGHOME=/root/.gnupg" in run_args_str
|
||||
|
||||
|
||||
def _node_options_from_run(calls):
|
||||
run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"]
|
||||
assert run_calls, "docker run should have been called"
|
||||
args = run_calls[0][0]
|
||||
for i, a in enumerate(args):
|
||||
if a == "-e" and i + 1 < len(args) and args[i + 1].startswith("NODE_OPTIONS="):
|
||||
return args[i + 1].split("=", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def test_egress_node_options_overrides_conflicting_ca_flag(monkeypatch):
|
||||
"""maxpetrusenko P1: a conflicting docker_env NODE_OPTIONS CA-mode flag
|
||||
(--use-bundled-ca) must be replaced by the egress-required --use-openssl-ca,
|
||||
not left to survive alongside it (final Node trust would depend on order)."""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setattr(
|
||||
docker_env, "_egress_proxy_args_for_docker",
|
||||
lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []),
|
||||
)
|
||||
calls = _mock_subprocess_run(monkeypatch)
|
||||
|
||||
_make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=8192 --use-bundled-ca"})
|
||||
|
||||
node_opts = (_node_options_from_run(calls) or "").split()
|
||||
assert "--use-openssl-ca" in node_opts, "egress CA flag must be present"
|
||||
assert "--use-bundled-ca" not in node_opts, "conflicting CA flag must be stripped"
|
||||
# Operator's unrelated tuning must be preserved.
|
||||
assert "--max-old-space-size=8192" in node_opts
|
||||
|
||||
|
||||
def test_egress_node_options_preserves_operator_tuning(monkeypatch):
|
||||
"""Non-conflicting operator NODE_OPTIONS survive the egress append-merge."""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setattr(
|
||||
docker_env, "_egress_proxy_args_for_docker",
|
||||
lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []),
|
||||
)
|
||||
calls = _mock_subprocess_run(monkeypatch)
|
||||
|
||||
_make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=4096"})
|
||||
|
||||
node_opts = (_node_options_from_run(calls) or "").split()
|
||||
assert "--use-openssl-ca" in node_opts
|
||||
assert "--max-old-space-size=4096" in node_opts
|
||||
|
||||
|
||||
def test_docker_env_appears_in_init_env_args(monkeypatch):
|
||||
"""Explicit docker_env values should appear in _build_init_env_args."""
|
||||
env = _make_execute_only_env()
|
||||
|
|
|
|||
|
|
@ -1114,15 +1114,31 @@ class DockerEnvironment(BaseEnvironment):
|
|||
)
|
||||
if _egress_node_append:
|
||||
existing_node = merged_env.get("NODE_OPTIONS", "")
|
||||
existing_tokens = existing_node.split()
|
||||
# maxpetrusenko P1: dedupe is not enough — the operator may have set
|
||||
# a CONFLICTING CA-mode flag (e.g. --use-bundled-ca) that would
|
||||
# otherwise survive alongside our --use-openssl-ca, leaving Node's
|
||||
# final trust behavior dependent on option order / Node parsing.
|
||||
# Egress isolation requires our flag to win deterministically, so
|
||||
# strip any known-conflicting CA-mode flags before appending.
|
||||
_CA_MODE_FLAGS = {"--use-openssl-ca", "--use-bundled-ca"}
|
||||
append_token = _egress_node_append.strip()
|
||||
if append_token in _CA_MODE_FLAGS:
|
||||
dropped = [t for t in existing_tokens if t in _CA_MODE_FLAGS and t != append_token]
|
||||
if dropped:
|
||||
logger.warning(
|
||||
"Overriding conflicting NODE_OPTIONS CA-mode flag(s) %s "
|
||||
"with egress-required %s to keep Node routed through the "
|
||||
"egress CA store.", dropped, append_token,
|
||||
)
|
||||
existing_tokens = [t for t in existing_tokens if t not in _CA_MODE_FLAGS or t == append_token]
|
||||
# De-dup: only add if not already present (the operator may
|
||||
# have set the same flag themselves).
|
||||
if _egress_node_append.strip() not in existing_node.split():
|
||||
if existing_node.strip():
|
||||
merged_env["NODE_OPTIONS"] = (
|
||||
f"{existing_node} {_egress_node_append}".strip()
|
||||
)
|
||||
else:
|
||||
merged_env["NODE_OPTIONS"] = _egress_node_append
|
||||
if append_token not in existing_tokens:
|
||||
existing_tokens.append(append_token)
|
||||
merged_env["NODE_OPTIONS"] = " ".join(existing_tokens).strip()
|
||||
if not merged_env["NODE_OPTIONS"]:
|
||||
merged_env.pop("NODE_OPTIONS", None)
|
||||
|
||||
env_args = []
|
||||
for key in sorted(merged_env):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
When Hermes runs your agent inside a Docker terminal sandbox, that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them.
|
||||
|
||||
The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work from behind the proxy.
|
||||
The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work behind the **configured trusted proxy boundary** — the CA private key and the proxy endpoint integrity are part of that boundary. If traffic can be redirected to attacker-controlled proxy infrastructure (e.g. a stolen CA private key or a hijacked proxy endpoint), the token guarantee no longer holds.
|
||||
|
||||
This release wires the egress proxy into the Docker backend only. Modal, Daytona, SSH, and Singularity do **not** receive proxy env vars or CA mounts yet.
|
||||
|
||||
|
|
@ -437,6 +437,7 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
|
|||
**What it does NOT protect against:**
|
||||
|
||||
- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise.
|
||||
- **Loss of the trusted-proxy boundary itself.** The token-swap guarantee assumes the sandbox trusts the mounted CA cert (`/etc/ssl/certs/hermes-egress-ca.crt`) and that traffic actually reaches *our* iron-proxy. If the CA private key is stolen, or sandbox egress is redirected to attacker-controlled proxy infrastructure, an adversary-in-the-middle can present a valid leaf cert and the proxy tokens are no longer a meaningful boundary (cf. [MITRE ATT&CK T1588.004](https://attack.mitre.org/techniques/T1588/004/) — obtained TLS certificate material enabling AiTM). Protect the CA key (it's `0600`, host-only) and the proxy endpoint accordingly.
|
||||
- Sandbox processes that bypass `HTTPS_PROXY` by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via `NODE_OPTIONS=--use-openssl-ca` (see caveat above).
|
||||
- Credential files explicitly mounted into Docker (`terminal.credential_files` or skill-registered mounts). Egress protects provider env vars; it does not inspect arbitrary mounted files. Do not mount real provider credentials into an enforced egress sandbox.
|
||||
- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue