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