diff --git a/scripts/live_loop_proof.py b/scripts/live_loop_proof.py index 67cf8a6a..33667d8c 100644 --- a/scripts/live_loop_proof.py +++ b/scripts/live_loop_proof.py @@ -29,10 +29,9 @@ Self-provision only exists on dev-like postures — production-like deployments (staging, preprod, preview, ...) refuse it — so this script is -loopback-only unless ``--allow-remote`` is passed, and then only against a -remote instance that has self-provision enabled. Non-loopback targets must -use ``https://``: the run mints an API key and sends it on every request, -which must never cross the network in the clear. +loopback-only by design. The run mints an API key and sends it on every +request; refusing any non-loopback target keeps that credential on the local +host and off the network entirely. """ from __future__ import annotations @@ -204,9 +203,8 @@ def authenticate(self) -> dict[str, str]: json={"agent_id": f"live-loop-proof-{self.run_id}"}, ) hint = ( - " -- a production-like target (staging/preprod/preview) refuses " - "self-provision by design; point --allow-remote at a remote " - "instance with ENABLE_DEV_KEY_SELF_PROVISION=true" + " -- self-provision is refused on production-like targets by " + "design; run this against a local `make quickstart` server" if minted.status_code == 403 else "" ) @@ -377,7 +375,9 @@ def trust_keys(self) -> Path: # base_url with a trailing slash, and Starlette does not collapse # "//.well-known/..." to the single-slash route. keys_url = self.client.base_url.join(".well-known/trust-keys.json") - keys = httpx.get(str(keys_url), timeout=30) + # trust_env=False for the same reason as the main client: keep every + # request on the loopback host, never through an ambient proxy. + keys = httpx.get(str(keys_url), timeout=30, trust_env=False) require(keys.status_code == 200, "trust-keys.json not served") path = self.output_dir / "trust-keys.json" path.write_text(keys.text, encoding="utf-8") @@ -499,7 +499,12 @@ def run(api_url: str, output_dir: Path) -> None: # partner as a single coherent bundle. for name in ARTIFACTS: (output_dir / name).unlink(missing_ok=True) - with httpx.Client(base_url=api_url, timeout=30) as client: + # trust_env=False: the run mints an API key and sends it in X-API-Key on + # every request. httpx honors ambient HTTP_PROXY/HTTPS_PROXY/ALL_PROXY by + # default, which could route that credential off the loopback host through + # a proxy. Disabling env trust keeps the whole loopback-only guarantee + # airtight regardless of the caller's proxy environment. + with httpx.Client(base_url=api_url, timeout=30, trust_env=False) as client: proof = ProofRun(client, output_dir) print(f"live loop proof {proof.run_id} against {api_url}\n") @@ -569,39 +574,21 @@ def main(argv: list[str] | None = None) -> int: default=DEFAULT_OUTPUT_DIR, help=f"Where to write the handoff bundle (default: {DEFAULT_OUTPUT_DIR}).", ) - parser.add_argument( - "--allow-remote", - action="store_true", - help=( - "Permit a non-loopback --api-url (which must then use https://). " - "Only for a remote instance with self-provision enabled -- " - "production-like hosts (staging, preprod, preview, ...) refuse " - "self-provision by design, so this mode cannot authenticate there." - ), - ) args = parser.parse_args(argv) - parsed = urlparse(args.api_url) - host = parsed.hostname or "" + # Loopback only, by design: the run self-provisions a key and sends it on + # every request, and self-provision exists only on dev-like postures. There + # is no supported remote target, so any non-loopback host is refused before + # a credential is minted. + host = urlparse(args.api_url).hostname or "" if host not in LOOPBACK_HOSTS: - if not args.allow_remote: - print( - f"refusing non-loopback target {args.api_url!r} without " - "--allow-remote", - file=sys.stderr, - ) - return 2 - # The run mints an API key and sends it in X-API-Key on every request; - # a non-loopback hop must be encrypted or that credential (and every - # governed call) crosses the network in the clear. - if parsed.scheme != "https": - print( - f"refusing cleartext {parsed.scheme or 'http'}:// for " - f"non-loopback target {args.api_url!r}; use https:// so the " - "minted key is not sent over the wire unencrypted", - file=sys.stderr, - ) - return 2 + print( + f"refusing non-loopback target {args.api_url!r}: the live-loop " + "proof is loopback-only; run it against a local `make quickstart` " + "server", + file=sys.stderr, + ) + return 2 try: run(args.api_url.rstrip("/"), args.output_dir) diff --git a/tests/test_quickstart_path.py b/tests/test_quickstart_path.py index c75775a9..ca33197d 100644 --- a/tests/test_quickstart_path.py +++ b/tests/test_quickstart_path.py @@ -300,37 +300,30 @@ def test_documented_quickstart_path(quickstart_server, tmp_path): client.close() -def _run_proof_script(*args: str, timeout: int = 30) -> subprocess.CompletedProcess: +def _run_proof_script( + *args: str, timeout: int = 30, extra_env: dict | None = None +) -> subprocess.CompletedProcess: + env = _prepend_pythonpath(REPO_ROOT) + if extra_env: + env.update(extra_env) return subprocess.run( [sys.executable, str(REPO_ROOT / "scripts" / "live_loop_proof.py"), *args], cwd=REPO_ROOT, - env=_prepend_pythonpath(REPO_ROOT), + env=env, capture_output=True, text=True, timeout=timeout, ) -def test_live_loop_proof_refuses_unsafe_remote_targets(tmp_path): - """The guard must fail closed before minting or sending any credential. - - Both rejections happen before any network call, so no server is needed. - """ +def test_live_loop_proof_refuses_non_loopback_target(tmp_path): + """The proof is loopback-only: any non-loopback host is refused before a + credential is minted, over http or https alike. No server is needed.""" out = tmp_path / "out" - # Non-loopback without opting in. - without_optin = _run_proof_script( - "--api-url", "https://example.com", "--output-dir", str(out) - ) - assert without_optin.returncode == 2 - assert "without --allow-remote" in without_optin.stderr - - # Non-loopback over cleartext, even with --allow-remote: the minted key - # would cross the network unencrypted, so it must be refused. - cleartext = _run_proof_script( - "--api-url", "http://example.com", "--allow-remote", "--output-dir", str(out) - ) - assert cleartext.returncode == 2 - assert "cleartext" in cleartext.stderr + for url in ("https://example.com", "http://example.com"): + result = _run_proof_script("--api-url", url, "--output-dir", str(out)) + assert result.returncode == 2, result.stdout + result.stderr + assert "loopback-only" in result.stderr # Fail closed means no handoff directory, not a half-written bundle. assert not out.exists() @@ -348,6 +341,37 @@ def test_live_loop_proof_reports_unreachable_server(tmp_path): assert "make quickstart" in result.stderr +def test_live_loop_proof_ignores_ambient_proxy(quickstart_server, tmp_path): + """The minted key must stay on loopback even under a proxy environment. + + With HTTP(S)_PROXY pointed at a dead port and NO_PROXY cleared, the run + can only succeed if it does NOT honor those proxy vars — i.e. the client + is built with trust_env=False. If proxy inheritance were left on, httpx + would route the X-API-Key requests through the dead proxy and the run + would fail. This is the regression guard for that credential-hygiene + property. + """ + dead_proxy = f"http://127.0.0.1:{_free_port()}" + proxy_env = { + var: dead_proxy + for var in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "http_proxy", "https_proxy", "all_proxy") + } + # Clear any ambient loopback exclusion so the proxy vars would otherwise bite. + proxy_env["NO_PROXY"] = "" + proxy_env["no_proxy"] = "" + + output_dir = tmp_path / "handoff" + result = _run_proof_script( + "--api-url", quickstart_server, + "--output-dir", str(output_dir), + timeout=180, + extra_env=proxy_env, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert (output_dir / "receipt-bundle.json").exists() + + def test_sanitize_url_for_record_strips_credentials(): """The transcript ships to partners; a secret in --api-url must not.""" sys.path.insert(0, str(REPO_ROOT / "scripts"))