feat: deliver owner-bound credentials into bwrap commands
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
tegwick 2026-09-06 00:25:07 +02:00
parent 0196f083c4
commit 17d4160b6e
10 changed files with 505 additions and 6 deletions

68
docs/bwrap-credentials.md Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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