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