Bound approval transport and refuse redirects and proxies

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 16:45:18 +02:00
parent 2eb045bfbf
commit b428ffecb1
8 changed files with 552 additions and 16 deletions

View file

@ -0,0 +1,71 @@
"""Isolated fixed-origin HTTP transport. No credentials in argv or diagnostics.
Invoked by the SDK through a bounded subprocess, not as an application command.
Isolation allows the parent to terminate DNS, reads and exceptional close without
signals in an application's main thread or mandatory multiprocessing main guards.
"""
import json
import sys
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
MAX_BODY = 256 * 1024
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise HTTPError(req.full_url, code, "redirect refused", headers, fp)
def fetch(data):
request = Request(data["url"], data=data["body"].encode("utf-8") if data["body"] is not None else None,
method=data["method"], headers={"Authorization": "Bearer " + data["token"],
"Content-Type": "application/json", "Accept": "application/json"})
opener = build_opener(ProxyHandler({}), NoRedirect())
response = None
try:
response = opener.open(request, timeout=data["timeout"])
if response.geturl() != data["url"] or response.getcode() != 200:
return {"error": "status", "status": response.getcode()}
types = response.headers.get_all("Content-Type", [])
if len(types) != 1 or response.headers.get_content_type() != "application/json":
return {"error": "content"}
charset = response.headers.get_content_charset()
if charset not in (None, "utf-8") or response.headers.get("Content-Encoding", "identity").lower() != "identity":
return {"error": "content"}
lengths = response.headers.get_all("Content-Length", [])
if len(lengths) > 1 or (lengths and (not lengths[0].isdigit() or int(lengths[0]) > MAX_BODY)):
return {"error": "content"}
raw = response.read(MAX_BODY + 1)
if len(raw) > MAX_BODY or (lengths and len(raw) != int(lengths[0])):
return {"error": "content"}
return {"body": raw.decode("utf-8")}
except HTTPError as exc:
# Never read a refused/redirect/error body; even close is parent-bounded.
try:
exc.close()
except Exception:
pass
return {"error": "status", "status": int(exc.code)}
except Exception:
return {"error": "unavailable"}
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def main():
try:
data = json.loads(sys.stdin.buffer.read(MAX_BODY + 1))
result = fetch(data)
except Exception:
result = {"error": "unavailable"}
sys.stdout.write(json.dumps(result, allow_nan=False))
if __name__ == "__main__":
main()

View file

@ -3,12 +3,18 @@
from __future__ import annotations
import json
import math
import os
import re
import stat
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from urllib.parse import quote, urlsplit
from urllib.request import Request
from .binding import DIGEST_RE
@ -41,28 +47,134 @@ class ApprovalHTTPClient:
token_file: str | Path,
*,
timeout_seconds: float = 3,
opener: Callable[..., Any] = urlopen,
opener: Callable[..., Any] | None = None,
) -> None:
if not base_url or not base_url.startswith(("http://", "https://")):
try:
parsed = urlsplit(base_url)
valid = (parsed.scheme in {"http", "https"} and parsed.hostname and
parsed.port != 0 and not parsed.username and not parsed.password and
not parsed.query and not parsed.fragment and
not any(ch.isspace() or ord(ch) < 32 for ch in base_url))
except (TypeError, ValueError):
valid = False
if not valid:
raise ApprovalProtocolError("approval-engine URL is missing or invalid")
if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) or not math.isfinite(timeout_seconds) or not 0 < timeout_seconds <= 60:
raise ApprovalProtocolError("approval timeout must be finite and bounded")
self.base_url = base_url.rstrip("/")
self.token_file = Path(token_file)
self.timeout_seconds = timeout_seconds
self.opener = opener
def _token(self) -> str:
token = self.token_file.read_text(encoding="utf-8").strip()
if not token or any(ch.isspace() for ch in token):
raise ApprovalProtocolError("approval credential is unavailable")
fd = None
directory = None
try:
parts = self.token_file.absolute().parts
if ".." in parts:
raise ValueError("unsafe credential path")
directory = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
for part in parts[1:-1]:
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
os.close(directory)
directory = child
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.geteuid()}
or info.st_mode & 0o037 or info.st_size > 16384 or info.st_nlink != 1):
raise ValueError("unsafe credential file")
raw = os.read(fd, 16385)
token = raw.decode("utf-8").strip()
if len(raw) > 16384 or not token or any(ch.isspace() or ord(ch) < 33 or ord(ch) > 126 for ch in token):
raise ValueError("invalid credential")
except (OSError, UnicodeError, ValueError):
raise ApprovalProtocolError("approval credential is unavailable") from None
finally:
if fd is not None:
os.close(fd)
if directory is not None:
os.close(directory)
return token
@staticmethod
def _decode(raw: str) -> dict[str, Any]:
def pairs(items):
result = {}
for key, value in items:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def constant(_value):
raise ValueError("non-finite JSON value")
def number(value):
parsed = float(value)
if not math.isfinite(parsed):
raise ValueError("non-finite JSON number")
return parsed
try:
result = json.loads(raw, object_pairs_hook=pairs, parse_constant=constant, parse_float=number)
except (ValueError, RecursionError):
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from None
if not isinstance(result, dict):
raise ApprovalProtocolError("approval endpoint returned invalid payload")
return result
def _isolated_request(self, method, url, body, token, deadline):
payload = json.dumps({"method": method, "url": url, "body": body,
"token": token, "timeout": self.timeout_seconds}, allow_nan=False).encode()
if len(payload) > _MAX_BODY:
raise ApprovalProtocolError("approval request is too large")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ApprovalProtocolError("approval transport deadline exceeded")
try:
completed = subprocess.run(
[sys.executable, "-I", str(Path(__file__).with_name("http_transport.py"))],
input=payload, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
timeout=remaining, check=False, env={},
)
except subprocess.TimeoutExpired:
raise ApprovalProtocolError("approval transport deadline exceeded") from None
except OSError:
raise ApprovalProtocolError("approval-engine unreachable") from None
if completed.returncode != 0 or len(completed.stdout) > 2 * _MAX_BODY:
raise ApprovalProtocolError("approval transport failed")
try:
result = json.loads(completed.stdout)
except (ValueError, UnicodeError):
raise ApprovalProtocolError("approval transport failed") from None
if not isinstance(result, dict):
raise ApprovalProtocolError("approval transport failed")
if result.get("error") == "status" and type(result.get("status")) is int:
raise ApprovalProtocolError(_status_message(result["status"]))
if result.get("error") == "content":
raise ApprovalProtocolError("approval endpoint returned invalid content")
if not isinstance(result.get("body"), str):
raise ApprovalProtocolError("approval-engine unreachable")
return self._decode(result["body"])
def _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> dict[str, Any]:
deadline = time.monotonic() + self.timeout_seconds
if not isinstance(path, str) or not path.startswith("/") or path.startswith("//") or any(ch.isspace() or ord(ch) < 32 for ch in path):
raise ApprovalProtocolError("approval request path is invalid")
url = self.base_url + path
if urlsplit(url)[:2] != urlsplit(self.base_url)[:2] or urlsplit(url).fragment:
raise ApprovalProtocolError("approval request origin is invalid")
token = self._token()
encoded = None if body is None else json.dumps(body).encode("utf-8")
try:
text_body = None if body is None else json.dumps(body, allow_nan=False)
except (ValueError, TypeError):
raise ApprovalProtocolError("approval request is invalid") from None
if self.opener is None:
return self._isolated_request(method, url, text_body, token, deadline)
# Explicit owner-controlled injection for test adapters. It does not
# establish production transport admission or an isolated deadline.
encoded = None if text_body is None else text_body.encode("utf-8")
request = Request(
self.base_url + path,
url,
data=encoded,
method=method,
headers={
@ -85,17 +197,14 @@ class ApprovalHTTPClient:
except Exception:
pass
raise ApprovalProtocolError(_status_message(status)) from None
except (URLError, TimeoutError, OSError):
except Exception:
raise ApprovalProtocolError("approval-engine unreachable") from None
if status != 200 or len(raw) > _MAX_BODY:
raise ApprovalProtocolError(_status_message(status if status != 200 else 502))
try:
result = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
if not isinstance(result, dict):
raise ApprovalProtocolError("approval endpoint returned invalid payload")
return result
return self._decode(raw.decode("utf-8"))
except UnicodeDecodeError:
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from None
def claim(self, approval_id: str) -> dict[str, Any]:
if not _ID_RE.fullmatch(approval_id):