feat: deliver owner-bound credentials into bwrap commands
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
parent
0196f083c4
commit
17d4160b6e
10 changed files with 505 additions and 6 deletions
68
docs/bwrap-credentials.md
Normal file
68
docs/bwrap-credentials.md
Normal 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.
|
||||||
|
|
@ -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
|
Bwrap refuses `network.default: allow` and setup secret references. Network
|
||||||
egress is opt-in through the owner allowlist described in bwrap-egress.md;
|
egress is opt-in through the owner allowlist described in bwrap-egress.md;
|
||||||
empty-egress profiles retain loopback-only networking. There is still no
|
empty-egress profiles retain loopback-only networking. Credential exec delivery is now opt-in through the owner route contract
|
||||||
credential-delivery mechanism. Returning a
|
in bwrap-credentials.md; no production route is active. Returning a
|
||||||
declared egress list as evidence would not make that list enforced or usable.
|
declared egress list as evidence would not make that list enforced or usable.
|
||||||
Exec credential route references continue to be labels, not credential values
|
Exec credential route references contain no values. Nonempty references now
|
||||||
or delivery grants.
|
require an owner-configured, consumer-bound provider; references alone grant nothing.
|
||||||
|
|
||||||
On 2026-09-05, `warden route find anthropic --json` and `warden route find
|
On 2026-09-05, `warden route find anthropic --json` and `warden route find
|
||||||
claude-code --json` returned no matching workload routes. The generic OpenBao
|
claude-code --json` returned no matching workload routes. The generic OpenBao
|
||||||
|
|
|
||||||
103
scripts/smoke-bwrap-credentials.py
Normal file
103
scripts/smoke-bwrap-credentials.py
Normal 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()
|
||||||
|
|
@ -276,6 +276,10 @@ class BwrapExtension(SandboxExtension):
|
||||||
"max_output_bytes": max_output_bytes,
|
"max_output_bytes": max_output_bytes,
|
||||||
"stdin_text": stdin_text,
|
"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
|
response_limit = max_output_bytes * 2 + 65_536
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
size = 0
|
size = 0
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,20 @@ def _run(
|
||||||
if proxy_port is not None:
|
if proxy_port is not None:
|
||||||
child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
|
child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
|
||||||
child_env["https_proxy"] = child_env["HTTPS_PROXY"]
|
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
|
timed_out = False
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
|
|
@ -69,6 +83,10 @@ def _run(
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
stdout_raw, stderr_raw = process.communicate()
|
stdout_raw, stderr_raw = process.communicate()
|
||||||
exit_code = 124
|
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)
|
stdout, stdout_truncated = _bounded_output(stdout_raw, max_output_bytes)
|
||||||
stderr, stderr_truncated = _bounded_output(stderr_raw, max_output_bytes)
|
stderr, stderr_truncated = _bounded_output(stderr_raw, max_output_bytes)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
120
src/sandboxer/extensions/credential_delivery.py
Normal file
120
src/sandboxer/extensions/credential_delivery.py
Normal 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()
|
||||||
48
src/sandboxer/extensions/credential_exec.py
Normal file
48
src/sandboxer/extensions/credential_exec.py
Normal 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())
|
||||||
|
|
@ -251,7 +251,7 @@ def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None:
|
||||||
result = ext.execute(
|
result = ext.execute(
|
||||||
handle,
|
handle,
|
||||||
["python3", "-V"],
|
["python3", "-V"],
|
||||||
credential_route_refs=["rein-openweights-openrouter-approle"],
|
credential_route_refs=[],
|
||||||
execution_context={"actor": "agt", "run_id": "run-1"},
|
execution_context={"actor": "agt", "run_id": "run-1"},
|
||||||
timeout_seconds=30,
|
timeout_seconds=30,
|
||||||
max_output_bytes=1024,
|
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["command"] == ["python3", "-V"]
|
||||||
assert received["execution_context"] == {"actor": "agt", "run_id": "run-1"}
|
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["timeout_seconds"] == 30
|
||||||
assert received["max_output_bytes"] == 1024
|
assert received["max_output_bytes"] == 1024
|
||||||
assert received["stdin_text"] == "task payload"
|
assert received["stdin_text"] == "task payload"
|
||||||
|
|
|
||||||
126
tests/test_credential_delivery.py
Normal file
126
tests/test_credential_delivery.py
Normal 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}
|
||||||
|
|
@ -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
|
proxy while retaining an isolated network namespace. Non-secret live smoke
|
||||||
sandbox e290e788 verified provider TLS response, undeclared destination and
|
sandbox e290e788 verified provider TLS response, undeclared destination and
|
||||||
direct-IP denial, and proxy/workspace teardown. See docs/bwrap-egress.md.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue