diff --git a/docs/bwrap-credentials.md b/docs/bwrap-credentials.md new file mode 100644 index 0000000..1a84bbe --- /dev/null +++ b/docs/bwrap-credentials.md @@ -0,0 +1,68 @@ +# Bwrap owner credential delivery + +Nonempty exec credential references now require an explicit owner-configured +route. Unknown or multiple references refuse. Empty references run without +credential acquisition. The owner binds exact allowed profile IDs, projects, +actors and a nonempty run ID before starting its configured exec provider. +These bindings are additional to manager/API consumer authentication. + +The provider prefix is an absolute executable plus arguments ending in `--`. +It must implement exec-env delivery: consume its own approval and authentication, +inject ANTHROPIC_API_KEY only into the supplied child, and return the child's +exit status. The supplied child is sand-boxer's fixed host helper, not the +caller command. Its request travels on stdin; key values never travel on argv. +Only the helper sends the key over the private owner Unix channel into the +namespace broker. No key or OpenBao token is returned through the public API. + +Owner extension configuration has this shape (proposal only): + +```yaml +credential_routes: + glas-claude-agent-dev-anthropic: + profiles: [profile.claude-agent-dev-proof] + projects: [glas-harness] + actors: [agt] + exec_argv: + - /absolute/owner/venv/bin/python + - -m + - secrets_engine.cli + - exec + - --catalog + - glas-claude-agent-dev-anthropic + - --field + - ANTHROPIC_API_KEY + - --mode + - exec-env + - --auth + - service-jwt + - -- +``` + +Do not install this example until the exact provider runtime, service identity, +approval contract and profile exist and are reviewed. No production route is +configured by this change. Owner config is trusted executable configuration; +API callers cannot change it. The parent shell's ANTHROPIC_API_KEY and +ANTHROPIC_AUTH_TOKEN are removed before provider invocation, so missing owner +acquisition cannot silently become interactive-shell delivery. + +The broker injects only ANTHROPIC_API_KEY, validates nonempty bounded values, +and redacts exact byte sequences from stdout/stderr before truncation. A second +exec without a credential route has no key. Provider output is bounded and +captured; its errors are replaced by fixed messages. Envelope duration is the +command timeout plus 30 seconds of provider/cleanup budget. Provider failure or +invalid JSON refuses; SIGTERM and then SIGKILL bound stuck provider processes. +Engine/backend token revocation remains the provider's responsibility. + +This is a delivery boundary for a trusted workload. It cannot prevent arbitrary +workload code from encoding the key, writing it into an artifact, or using it +against an allowed provider. Exact-output redaction is a backstop, not protection +against hostile code; artifact checks, profile admission and enforced egress +remain necessary. No key is written by the delivery mechanism itself. + +Proof: scripts/smoke-bwrap-credentials.py uses a synthetic provider and a real +bwrap namespace. Sandbox 0e5fb35a proved child-only delivery, output redaction, +no leak into the next exec, wrong-project denial and teardown. No live OpenBao +or provider credential is involved. This does not activate CCR-2026-0016. +Native adoption is tracked in secrets-engine/SECRETS-WP-0009; production exec +currently refuses before OpenBao while exact-action authorization and engine +service authority are unavailable. diff --git a/docs/bwrap-runtime.md b/docs/bwrap-runtime.md index 2ec17cd..43a6ddc 100644 --- a/docs/bwrap-runtime.md +++ b/docs/bwrap-runtime.md @@ -84,11 +84,11 @@ continues to carry exact consumer identity and value-free route references. Bwrap refuses `network.default: allow` and setup secret references. Network egress is opt-in through the owner allowlist described in bwrap-egress.md; -empty-egress profiles retain loopback-only networking. There is still no -credential-delivery mechanism. Returning a +empty-egress profiles retain loopback-only networking. Credential exec delivery is now opt-in through the owner route contract +in bwrap-credentials.md; no production route is active. Returning a declared egress list as evidence would not make that list enforced or usable. -Exec credential route references continue to be labels, not credential values -or delivery grants. +Exec credential route references contain no values. Nonempty references now +require an owner-configured, consumer-bound provider; references alone grant nothing. On 2026-09-05, `warden route find anthropic --json` and `warden route find claude-code --json` returned no matching workload routes. The generic OpenBao diff --git a/scripts/smoke-bwrap-credentials.py b/scripts/smoke-bwrap-credentials.py new file mode 100644 index 0000000..9e74a40 --- /dev/null +++ b/scripts/smoke-bwrap-credentials.py @@ -0,0 +1,103 @@ +"""Synthetic exec-env provider proof. Never fetches a live secret.""" + +import json +import sys +import tempfile +from pathlib import Path + +from sandboxer.extensions.bwrap import BwrapExtension +from sandboxer.models import Profile + + +def main(): + with tempfile.TemporaryDirectory(prefix="sandboxer-credential-proof-") as temporary: + root = Path(temporary) + provider = root / "synthetic-provider.py" + provider.write_text("""import os,subprocess,sys +assert 'ANTHROPIC_API_KEY' not in os.environ +env=os.environ.copy() +env['ANTHROPIC_API_KEY']='synthetic-only-credential-proof' +raise SystemExit(subprocess.call(sys.argv[sys.argv.index('--')+1:],env=env)) +""") + context = { + "actor": "agt", + "project": "credential-proof", + "run_id": "proof-1", + "profile_id": "profile.credential-proof", + } + extension = BwrapExtension( + { + "base_dir": str(root / "sandboxes"), + "credential_routes": { + "synthetic-proof": { + "profiles": ["profile.credential-proof"], + "projects": ["credential-proof"], + "actors": ["agt"], + "exec_argv": [sys.executable, str(provider), "--"], + } + }, + } + ) + profile = Profile(id=context["profile_id"], version="1", extension="ext.bwrap") + handle = extension.provision(profile, {}, "localhost") + try: + extension.wait_ready(handle) + result = extension.execute( + handle, + [ + "python3", + "-c", + "import os; " + "assert os.environ['ANTHROPIC_API_KEY']=='synthetic-only-credential-proof'; " + "assert 'BAO_TOKEN' not in os.environ and 'VAULT_TOKEN' not in os.environ; " + "print(os.environ['ANTHROPIC_API_KEY'])", + ], + credential_route_refs=["synthetic-proof"], + execution_context=context, + timeout_seconds=10, + max_output_bytes=1024, + ) + assert result["exit_code"] == 0 and result["stdout"] == "[REDACTED]\n" + clean = extension.execute( + handle, + ["python3", "-c", "import os; assert 'ANTHROPIC_API_KEY' not in os.environ"], + credential_route_refs=[], + execution_context=context, + timeout_seconds=10, + max_output_bytes=1024, + ) + assert clean["exit_code"] == 0 + try: + extension.execute( + handle, + ["true"], + credential_route_refs=["synthetic-proof"], + execution_context={**context, "project": "wrong"}, + timeout_seconds=10, + max_output_bytes=1024, + ) + except ValueError: + denied = True + else: + raise AssertionError("wrong consumer accepted") + finally: + teardown = extension.teardown(handle) + assert teardown["workspace_removed"] == "True" + print( + json.dumps( + { + "sandbox_id": handle["sandbox_id"], + "synthetic_provider": True, + "child_received_value": True, + "output_redacted": True, + "no_following_exec_leak": True, + "wrong_project_denied": denied, + "workspace_removed": True, + "real_key_read": False, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index 7bdbee8..eb73b4c 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -276,6 +276,10 @@ class BwrapExtension(SandboxExtension): "max_output_bytes": max_output_bytes, "stdin_text": stdin_text, } + if credential_route_refs: + from sandboxer.extensions.credential_delivery import execute + + return execute(self.config, request, workspace / self.control_socket_name) response_limit = max_output_bytes * 2 + 65_536 chunks: list[bytes] = [] size = 0 diff --git a/src/sandboxer/extensions/bwrap_runner.py b/src/sandboxer/extensions/bwrap_runner.py index 5f88415..999c9f8 100644 --- a/src/sandboxer/extensions/bwrap_runner.py +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -50,6 +50,20 @@ def _run( if proxy_port is not None: child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}" child_env["https_proxy"] = child_env["HTTPS_PROXY"] + credential_env = payload.get("credential_env", {}) + if ( + not isinstance(credential_env, dict) + or set(credential_env) - {"ANTHROPIC_API_KEY"} + or any( + not isinstance(value, str) + or not value + or len(value) > 8192 + or any(c.isspace() for c in value) + for value in credential_env.values() + ) + ): + raise ValueError("invalid owner credential envelope") + child_env.update(credential_env) timed_out = False process = subprocess.Popen( command, @@ -69,6 +83,10 @@ def _run( os.killpg(process.pid, signal.SIGKILL) stdout_raw, stderr_raw = process.communicate() exit_code = 124 + # Redact complete raw values before output truncation can leave a prefix. + for value in credential_env.values(): + stdout_raw = stdout_raw.replace(value.encode(), b"[REDACTED]") + stderr_raw = stderr_raw.replace(value.encode(), b"[REDACTED]") stdout, stdout_truncated = _bounded_output(stdout_raw, max_output_bytes) stderr, stderr_truncated = _bounded_output(stderr_raw, max_output_bytes) return { diff --git a/src/sandboxer/extensions/credential_delivery.py b/src/sandboxer/extensions/credential_delivery.py new file mode 100644 index 0000000..9c427fc --- /dev/null +++ b/src/sandboxer/extensions/credential_delivery.py @@ -0,0 +1,120 @@ +"""Owner-configured credential provider envelope; no secret retrieval in Glas.""" + +from __future__ import annotations + +import json +import os +import selectors +import signal +import subprocess +import sys +import time +from contextlib import suppress +from pathlib import Path + + +def provider_argv(config: dict, refs: list[str], context: dict) -> list[str]: + if len(refs) != 1: + raise ValueError("credential execution requires exactly one configured route") + route = config.get("credential_routes", {}).get(refs[0]) + if not isinstance(route, dict): + raise ValueError("credential route is not configured by the owner") + for field in ("profiles", "projects", "actors"): + choices = route.get(field) + if ( + not isinstance(choices, list) + or not choices + or any(not isinstance(item, str) or not item for item in choices) + ): + raise ValueError("credential route requires explicit consumer allowlists") + if ( + context.get("profile_id") not in route.get("profiles", []) + or context.get("project") not in route.get("projects", []) + or context.get("actor") not in route.get("actors", []) + or not context.get("run_id") + ): + raise ValueError("credential route consumer binding denied") + command = route.get("exec_argv") + if ( + not isinstance(command, list) + or not command + or any(not isinstance(arg, str) or not arg or "\0" in arg for arg in command) + or not Path(command[0]).is_absolute() + or command[-1] != "--" + ): + raise ValueError("credential provider requires an absolute executable and trailing --") + return command + + +def execute(config: dict, request: dict, socket_path: Path) -> dict: + prefix = provider_argv(config, request["credential_route_refs"], request["execution_context"]) + argv = [ + *prefix, + sys.executable, + str(Path(__file__).with_name("credential_exec.py")), + str(socket_path), + ] + # Never let an interactive provider key stand in for owner acquisition. + env = os.environ.copy() + env.pop("ANTHROPIC_API_KEY", None) + env.pop("ANTHROPIC_AUTH_TOKEN", None) + proc = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env, + start_new_session=True, + ) + assert proc.stdin is not None and proc.stdout is not None + budget = request["timeout_seconds"] + 30 + deadline = time.monotonic() + budget + limit = request["max_output_bytes"] * 2 + 65536 + output = bytearray() + pending = memoryview(json.dumps(request).encode()) + try: + os.set_blocking(proc.stdin.fileno(), False) + os.set_blocking(proc.stdout.fileno(), False) + with selectors.DefaultSelector() as selector: + selector.register(proc.stdin, selectors.EVENT_WRITE) + selector.register(proc.stdout, selectors.EVENT_READ) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError("credential delivery deadline exceeded") + for key, _ in selector.select(min(remaining, 1)): + if key.fileobj is proc.stdin: + written = os.write(proc.stdin.fileno(), pending[:65536]) + pending = pending[written:] + if not pending: + selector.unregister(proc.stdin) + proc.stdin.close() + else: + chunk = os.read(proc.stdout.fileno(), 65536) + if not chunk: + selector.unregister(proc.stdout) + output.extend(chunk) + if len(output) > limit: + raise RuntimeError("credential delivery output exceeded bound") + if proc.wait(timeout=max(0.1, deadline - time.monotonic())) != 0: + raise RuntimeError("credential provider refused or failed") + response = json.loads(output) + if not isinstance(response, dict) or "boundary_error" in response: + raise RuntimeError("credential broker refused or failed") + return response + except Exception: + # Provider stdout/stderr and parser exception excerpts must never escape. + raise RuntimeError("owner credential delivery failed") from None + finally: + if proc.poll() is None: + with suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + with suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + proc.stdout.close() + if not proc.stdin.closed: + proc.stdin.close() diff --git a/src/sandboxer/extensions/credential_exec.py b/src/sandboxer/extensions/credential_exec.py new file mode 100644 index 0000000..de991db --- /dev/null +++ b/src/sandboxer/extensions/credential_exec.py @@ -0,0 +1,48 @@ +"""Silent-failure child of an owner-approved exec-env credential provider. + +The provider injects only ANTHROPIC_API_KEY. This helper passes it directly to +the namespace broker; it never emits the credential or accepts one in argv. +""" + +from __future__ import annotations + +import json +import os +import socket +import sys + + +def main() -> int: + try: + if len(sys.argv) != 2: + return 1 + value = os.environ.get("ANTHROPIC_API_KEY", "") + if not value or len(value) > 8192 or any(c.isspace() for c in value): + return 1 + raw = sys.stdin.buffer.read(7_000_001) + if len(raw) > 7_000_000: + return 1 + request = json.loads(raw) + request["credential_env"] = {"ANTHROPIC_API_KEY": value} + limit = int(request["max_output_bytes"]) * 2 + 65536 + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(int(request["timeout_seconds"]) + 5) + client.connect(sys.argv[1]) + client.sendall(json.dumps(request).encode()) + client.shutdown(socket.SHUT_WR) + result = bytearray() + while chunk := client.recv(65536): + result.extend(chunk) + if len(result) > limit: + return 1 + response = json.loads(result) + # Backstop, including any future broker diagnostics. No raw exception output. + encoded = json.dumps(response).replace(value, "[REDACTED]") + sys.stdout.write(encoded) + return 0 + except Exception: + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py index 66a876a..3ce0dee 100644 --- a/tests/test_bwrap.py +++ b/tests/test_bwrap.py @@ -251,7 +251,7 @@ def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None: result = ext.execute( handle, ["python3", "-V"], - credential_route_refs=["rein-openweights-openrouter-approle"], + credential_route_refs=[], execution_context={"actor": "agt", "run_id": "run-1"}, timeout_seconds=30, max_output_bytes=1024, @@ -262,7 +262,7 @@ def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None: assert received["command"] == ["python3", "-V"] assert received["execution_context"] == {"actor": "agt", "run_id": "run-1"} - assert received["credential_route_refs"] == ["rein-openweights-openrouter-approle"] + assert received["credential_route_refs"] == [] assert received["timeout_seconds"] == 30 assert received["max_output_bytes"] == 1024 assert received["stdin_text"] == "task payload" diff --git a/tests/test_credential_delivery.py b/tests/test_credential_delivery.py new file mode 100644 index 0000000..b4ee90d --- /dev/null +++ b/tests/test_credential_delivery.py @@ -0,0 +1,126 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sandboxer.extensions.bwrap_runner import _run +from sandboxer.extensions.credential_delivery import execute, provider_argv + +CONTEXT = { + "profile_id": "profile.proof", + "project": "glas-harness", + "actor": "agt", + "run_id": "run-1", +} + + +def config(command=None): + return { + "credential_routes": { + "proof": { + "profiles": ["profile.proof"], + "projects": ["glas-harness"], + "actors": ["agt"], + "exec_argv": command or ["/usr/bin/provider", "--"], + } + } + } + + +@pytest.mark.parametrize( + "field,value", [("profile_id", "wrong"), ("project", "wrong"), ("actor", "usr"), ("run_id", "")] +) +def test_wrong_consumer_refuses_before_provider(field, value): + context = {**CONTEXT, field: value} + with pytest.raises(ValueError, match="binding denied"): + provider_argv(config(), ["proof"], context) + + +@pytest.mark.parametrize("refs", [[], ["unknown"], ["proof", "proof"]]) +def test_unknown_or_multiple_routes_refuse(refs): + with pytest.raises(ValueError): + provider_argv(config(), refs, CONTEXT) + + +def test_provider_requires_absolute_command(): + with pytest.raises(ValueError): + provider_argv(config(["relative-provider", "--"]), ["proof"], CONTEXT) + + +def test_redaction_precedes_truncation_and_environment_is_scoped(tmp_path): + value = "synthetic-value-for-test" + process = MagicMock(returncode=0) + process.communicate.return_value = (value.encode() + b" suffix", value.encode()) + payload = { + "command": ["true"], + "timeout_seconds": 1, + "max_output_bytes": 5, + "credential_env": {"ANTHROPIC_API_KEY": value}, + } + with patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process) as popen: + response = _run(payload, tmp_path) + assert response["stdout"] == "[REDA" and response["stderr"] == "[REDA" + env = popen.call_args.kwargs["env"] + assert env["ANTHROPIC_API_KEY"] == value + assert "BAO_TOKEN" not in env and "VAULT_TOKEN" not in env + + +@pytest.mark.parametrize( + "values", [{"PATH": "bad"}, {"ANTHROPIC_API_KEY": ""}, {"ANTHROPIC_API_KEY": "has newline\n"}] +) +def test_bad_credential_envelope_refuses_before_execution(tmp_path, values): + with patch("sandboxer.extensions.bwrap_runner.subprocess.Popen") as popen: + with pytest.raises(ValueError): + _run( + { + "command": ["true"], + "timeout_seconds": 1, + "max_output_bytes": 8, + "credential_env": values, + }, + tmp_path, + ) + popen.assert_not_called() + + +@pytest.mark.parametrize( + "body", + [ + "print('sensitive-provider-failure'); raise SystemExit(1)", + "print('sensitive-provider-failure')", + "print('x'*100000)", + ], +) +def test_provider_failure_never_escapes(tmp_path, body): + provider = tmp_path / "provider.py" + provider.write_text(body) + request = { + "credential_route_refs": ["proof"], + "execution_context": CONTEXT, + "timeout_seconds": 1, + "max_output_bytes": 8, + "command": ["true"], + } + with pytest.raises(RuntimeError, match="^owner credential delivery failed$"): + execute(config([sys.executable, str(provider), "--"]), request, tmp_path / "unused.sock") + + +def test_parent_api_key_is_not_provider_fallback(tmp_path, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "ambient-secret") + provider = tmp_path / "provider.py" + provider.write_text( + "import os,json; assert 'ANTHROPIC_API_KEY' not in os.environ; " + "print(json.dumps({'exit_code':0}))" + ) + result = execute( + config([sys.executable, str(provider), "--"]), + { + "credential_route_refs": ["proof"], + "execution_context": CONTEXT, + "timeout_seconds": 1, + "max_output_bytes": 8, + }, + Path("/unused"), + ) + assert result == {"exit_code": 0} diff --git a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md index 15db26b..0b1997d 100644 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -117,3 +117,15 @@ HTTPS egress is now implemented as an exact owner/profile allowlisted CONNECT proxy while retaining an isolated network namespace. Non-secret live smoke sandbox e290e788 verified provider TLS response, undeclared destination and direct-IP denial, and proxy/workspace teardown. See docs/bwrap-egress.md. + +## 2026-09-05 credential transport owner return + +Owner-bound exec-env transport is implemented and proved with a synthetic +provider in real sandbox 0e5fb35a. Exact route/profile/project/actor/run binding, +child-only key injection, pre-truncation redaction, next-exec absence and teardown +pass. No real key read or production profile activation. See docs/bwrap-credentials.md. +SECRETS-WP-0009 now owns native AppRole adoption for CCR-2026-0016, including +a data-only consumer read policy. Its production exec refuses before OpenBao +until SECRETS-WP-0007-T04 and SECRETS-WP-0008-T02/T06 deliver canonical +authorization/consume and service authority. T04 remains waiting on that live +owner path, the pinned Claude executable and real model acceptance.