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
|
||||
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
|
||||
|
|
|
|||
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,
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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(
|
||||
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"
|
||||
|
|
|
|||
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
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue