From aa6dbec89b0ded9b795a2ba3b2e24069e4167bf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 09:46:41 +0900 Subject: [PATCH 01/93] fix(ci): redact sandboxed verification output --- scripts/ci/sandboxed_verify.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..3650893c3 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -14,6 +14,9 @@ from collections.abc import Sequence from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import redact_text DEFAULT_IGNORE = ( ".git", @@ -169,8 +172,8 @@ def timeout_output_text(value: str | bytes | None) -> str: if value is None: return "" if isinstance(value, bytes): - return value.decode(errors="replace") - return value + return redact_text(value.decode(errors="replace")) + return redact_text(value) def emit_result( @@ -219,9 +222,9 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_command(args.command, copied_repo, env, args.timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode except subprocess.TimeoutExpired as exc: stdout = timeout_output_text(exc.stdout) From 06f1dcdb18015034f0182604fad1fd494584efdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 09:47:45 +0900 Subject: [PATCH 02/93] fix(ci): redact sandboxed web E2E output --- scripts/ci/sandboxed_web_e2e.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..39e2fed16 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -22,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import sandboxed_verify - +from scripts.ci.redact_sensitive_log import redact_text RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT" @@ -232,9 +232,9 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode return exit_code except subprocess.TimeoutExpired as exc: From df52aa3c58c26a97689cc403a77582e13e854777 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 09:48:25 +0900 Subject: [PATCH 03/93] test(ci): cover secret-safe sandbox output --- tests/test_sandboxed_output_redaction.py | 131 +++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/test_sandboxed_output_redaction.py diff --git a/tests/test_sandboxed_output_redaction.py b/tests/test_sandboxed_output_redaction.py new file mode 100644 index 000000000..4373a496f --- /dev/null +++ b/tests/test_sandboxed_output_redaction.py @@ -0,0 +1,131 @@ +"""Regression tests for secret-safe sandboxed command output.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from scripts.ci import sandboxed_verify, sandboxed_web_e2e + + +def _fake_personal_access_token() -> str: + """Build a PAT-shaped fixture without storing a scanner-triggering literal.""" + + return "gh" + "p_" + "123456789012345678901234567890123456" + + +def test_timeout_output_redacts_text_and_bytes() -> None: + """Timeout normalization must redact secrets in both subprocess payload types.""" + + token = _fake_personal_access_token() + + assert sandboxed_verify.timeout_output_text(f"text {token}\n") == "text [REDACTED]\n" + assert sandboxed_verify.timeout_output_text(f"bytes {token}\n".encode()) == ( + "bytes [REDACTED]\n" + ) + + +def test_sandboxed_verify_redacts_completed_output( + monkeypatch, tmp_path: Path, capsys +) -> None: + """Ordinary verification stdout and stderr must not disclose captured secrets.""" + + repo_root = tmp_path / "repo" + repo_root.mkdir() + token = _fake_personal_access_token() + + monkeypatch.setattr( + sandboxed_verify, + "run_command", + lambda command, cwd, env, timeout: subprocess.CompletedProcess( + command, + 0, + stdout=f"verify-out {token}\n", + stderr=f"verify-err {token}\n", + ), + ) + + assert ( + sandboxed_verify.main( + ["--repo-root", str(repo_root), "--timeout", "5", "--", "true"] + ) + == 0 + ) + captured = capsys.readouterr() + + assert token not in captured.out + assert token not in captured.err + assert "verify-out [REDACTED]" in captured.out + assert "verify-err [REDACTED]" in captured.err + + +def test_sandboxed_web_e2e_redacts_completed_output( + monkeypatch, tmp_path: Path, capsys +) -> None: + """Web E2E command output must be redacted before reaching Actions logs.""" + + repo_root = tmp_path / "repo" + repo_root.mkdir() + token = _fake_personal_access_token() + + class FinishedProcess: + """Minimal completed-service process used by the orchestration test.""" + + def poll(self) -> int: + """Report that the synthetic service has already exited cleanly.""" + + return 0 + + def fake_start_service(label, command, cwd, env, logs_dir): + """Return a bounded service record without starting a real subprocess.""" + + return sandboxed_web_e2e.Service( + label=label, + command=command, + process=FinishedProcess(), + log_path=logs_dir / f"{label}.log", + ) + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start_service) + monkeypatch.setattr( + sandboxed_web_e2e, + "wait_for_url", + lambda url, timeout, service: True, + ) + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda command, cwd, env, timeout: subprocess.CompletedProcess( + command, + 0, + stdout=f"e2e-out {token}\n", + stderr=f"e2e-err {token}\n", + ), + ) + monkeypatch.setattr(sandboxed_web_e2e, "stop_service", lambda service: None) + + assert ( + sandboxed_web_e2e.main( + [ + "--repo-root", + str(repo_root), + "--backend-cmd", + "backend", + "--frontend-cmd", + "frontend", + "--e2e-cmd", + "e2e", + "--startup-timeout", + "5", + "--e2e-timeout", + "5", + ] + ) + == 0 + ) + captured = capsys.readouterr() + + assert token not in captured.out + assert token not in captured.err + assert "e2e-out [REDACTED]" in captured.out + assert "e2e-err [REDACTED]" in captured.err From 014f30ce8ad0276576f1ff914d2b7d64a62049a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 09:49:45 +0900 Subject: [PATCH 04/93] docs(doctoring): record sandbox log redaction boundary --- .../sandboxed-command-log-redaction.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/doctoring/sandboxed-command-log-redaction.md diff --git a/docs/doctoring/sandboxed-command-log-redaction.md b/docs/doctoring/sandboxed-command-log-redaction.md new file mode 100644 index 000000000..1563f4d9c --- /dev/null +++ b/docs/doctoring/sandboxed-command-log-redaction.md @@ -0,0 +1,38 @@ +# Sandboxed command log redaction + +## Decision + +Captured `stdout` and `stderr` from repository verification and web end-to-end commands are untrusted evidence. Before those streams are written to GitHub Actions logs, they pass through the existing central `redact_text` boundary. The same rule applies to ordinary process completion and `TimeoutExpired` payloads represented as either text or bytes. + +This change preserves the command exit status, line boundaries, non-sensitive diagnostics, machine-readable result envelope, isolated workspace, scrubbed environment, bounded timeouts, and service cleanup. It does not synthesize success, discard stderr, widen an environment allowlist, or alter the review gate. + +## Threat model + +A tool, test, package manager, browser runner, or application process can print credentials obtained from an explicitly allowed environment variable, configuration file, exception, dependency-manager diagnostic, or echoed request. CI logs are durable review evidence and can have a broader readership than the secret itself. Redaction therefore occurs at the final central output sink rather than relying on every child process to behave correctly. + +MITRE classifies writing sensitive information to a log as CWE-532 and recommends not writing secrets to log files. OWASP likewise identifies access tokens, passwords, connection strings, encryption keys, and other primary secrets as data that should be removed, masked, sanitized, hashed, or encrypted before logging. The Python subprocess API does not implicitly select a system shell when `shell=False`; structured argument execution is retained independently of the log-redaction control. + +## Verification contract + +Permanent regression tests must prove that: + +1. text and byte timeout payloads redact a PAT-shaped fixture; +2. normal sandbox verification stdout and stderr redact the same fixture; +3. normal sandboxed web E2E stdout and stderr redact the same fixture; +4. the raw fixture never appears in captured output; +5. ordinary non-sensitive text and process exit codes remain visible; +6. the test fixture is assembled from separate fragments so repository secret scanning does not mistake it for a live credential. + +Exact-head repository tests, statement/branch coverage, docstring checks, Secret Scan, Semgrep, CodeQL, Security Scan, OpenCode, Noema, and branch protection remain authoritative. + +## Modular boundary + +Both wrappers remain independently executable scripts and reusable Python modules. They depend only on the central redaction utility and standard-library process primitives, so product repositories can consume the same behavior through the organization workflow without copying a repository-local implementation. + +## References + +MITRE. (2026, April 30). *CWE-532: Insertion of sensitive information into log file*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/532.html + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 5, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Python Software Foundation. (2026). *subprocess—Subprocess management* (Python 3.14.6 documentation). https://docs.python.org/3/library/subprocess.html From e1658340e16e1562d92bd96525aa873849692104 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:05:49 +0900 Subject: [PATCH 05/93] test(ci): require complete sandbox output redaction --- tests/test_sandboxed_output_redaction.py | 213 ++++++++++++++--------- 1 file changed, 130 insertions(+), 83 deletions(-) diff --git a/tests/test_sandboxed_output_redaction.py b/tests/test_sandboxed_output_redaction.py index 4373a496f..38611c217 100644 --- a/tests/test_sandboxed_output_redaction.py +++ b/tests/test_sandboxed_output_redaction.py @@ -1,131 +1,178 @@ -"""Regression tests for secret-safe sandboxed command output.""" +"""Regression tests for secret-safe sandbox subprocess evidence.""" from __future__ import annotations import subprocess from pathlib import Path +from typing import cast from scripts.ci import sandboxed_verify, sandboxed_web_e2e +from scripts.ci.redact_sensitive_log import ( + REDACTED, + redact_command_arguments, + redact_shell_command, +) -def _fake_personal_access_token() -> str: - """Build a PAT-shaped fixture without storing a scanner-triggering literal.""" +def _provider_token() -> str: + """Build a credential-shaped fixture without committing a scanner secret.""" + return "gh" + "p_" + ("A" * 36) - return "gh" + "p_" + "123456789012345678901234567890123456" +def test_redact_command_arguments_covers_separate_equals_and_direct_tokens() -> None: + """Redact option values, assignments, and provider-shaped standalone values.""" + token = _provider_token() -def test_timeout_output_redacts_text_and_bytes() -> None: - """Timeout normalization must redact secrets in both subprocess payload types.""" + assert redact_command_arguments( + ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] + ) == [ + "tool", + "--api-key", + REDACTED, + f"TOKEN={REDACTED}", + REDACTED, + "plain", + ] - token = _fake_personal_access_token() - assert sandboxed_verify.timeout_output_text(f"text {token}\n") == "text [REDACTED]\n" - assert sandboxed_verify.timeout_output_text(f"bytes {token}\n".encode()) == ( - "bytes [REDACTED]\n" - ) +def test_redact_shell_command_handles_parsed_and_malformed_input() -> None: + """Redact parsed commands and fall back to line redaction for bad quoting.""" + token = _provider_token() + parsed = redact_shell_command(f"tool --password {token} --name safe") + malformed = redact_shell_command(f"api_key={token}'") + + assert token not in parsed + assert "--password '[REDACTED]'" in parsed + assert token not in malformed + assert REDACTED in malformed + +def test_timeout_output_text_redacts_strings_bytes_and_none() -> None: + """Normalize every TimeoutExpired payload form without leaking credentials.""" + token = _provider_token() -def test_sandboxed_verify_redacts_completed_output( + assert sandboxed_verify.timeout_output_text(None) == "" + assert sandboxed_verify.timeout_output_text(f"token={token}") == f"token={REDACTED}" + assert sandboxed_verify.timeout_output_text(f"token={token}".encode()) == f"token={REDACTED}" + + +def test_sandboxed_verify_redacts_completed_output_command_and_note( monkeypatch, tmp_path: Path, capsys ) -> None: - """Ordinary verification stdout and stderr must not disclose captured secrets.""" - - repo_root = tmp_path / "repo" - repo_root.mkdir() - token = _fake_personal_access_token() + """Keep ordinary command completion evidence secret-free end to end.""" + token = _provider_token() + repository = tmp_path / "repository" + repository.mkdir() - monkeypatch.setattr( - sandboxed_verify, - "run_command", - lambda command, cwd, env, timeout: subprocess.CompletedProcess( + def fake_run_command(command, cwd, env, timeout): + return subprocess.CompletedProcess( command, 0, - stdout=f"verify-out {token}\n", - stderr=f"verify-err {token}\n", - ), - ) - - assert ( - sandboxed_verify.main( - ["--repo-root", str(repo_root), "--timeout", "5", "--", "true"] + stdout=f"token={token}\n", + stderr=f"Authorization: Bearer {token}\n", ) - == 0 + + monkeypatch.setattr(sandboxed_verify, "run_command", fake_run_command) + + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(repository), + "--evidence-note", + f"api_key={token}", + "--", + "tool", + "--api-key", + token, + ] ) captured = capsys.readouterr() + assert exit_code == 0 assert token not in captured.out assert token not in captured.err - assert "verify-out [REDACTED]" in captured.out - assert "verify-err [REDACTED]" in captured.err + assert REDACTED in captured.out + assert REDACTED in captured.err -def test_sandboxed_web_e2e_redacts_completed_output( - monkeypatch, tmp_path: Path, capsys -) -> None: - """Web E2E command output must be redacted before reaching Actions logs.""" +class _DoneProcess: + """Minimal completed-process double accepted by the service cleanup path.""" - repo_root = tmp_path / "repo" - repo_root.mkdir() - token = _fake_personal_access_token() + pid = 12345 - class FinishedProcess: - """Minimal completed-service process used by the orchestration test.""" + def poll(self) -> int: + """Report that the fake service has already exited.""" + return 0 - def poll(self) -> int: - """Report that the synthetic service has already exited cleanly.""" + def wait(self, timeout: int) -> int: + """Return immediately for interface compatibility.""" + del timeout + return 0 - return 0 - def fake_start_service(label, command, cwd, env, logs_dir): - """Return a bounded service record without starting a real subprocess.""" +def test_sandboxed_web_e2e_redacts_commands_output_and_service_logs( + monkeypatch, tmp_path: Path, capsys +) -> None: + """Keep web E2E output, JSON evidence, and service log tails secret-free.""" + token = _provider_token() + repository = tmp_path / "repository" + repository.mkdir() + def fake_start_service(label, command, cwd, env, logs_dir): + del cwd, env + log_path = logs_dir / f"{label}.log" + log_path.write_text(f"secret={token}\n", encoding="utf-8") return sandboxed_web_e2e.Service( label=label, command=command, - process=FinishedProcess(), - log_path=logs_dir / f"{label}.log", + process=cast(subprocess.Popen[str], _DoneProcess()), + log_path=log_path, ) - monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start_service) - monkeypatch.setattr( - sandboxed_web_e2e, - "wait_for_url", - lambda url, timeout, service: True, - ) - monkeypatch.setattr( - sandboxed_web_e2e, - "run_shell", - lambda command, cwd, env, timeout: subprocess.CompletedProcess( + def fake_run_shell(command, cwd, env, timeout): + del cwd, env, timeout + return subprocess.CompletedProcess( command, 0, - stdout=f"e2e-out {token}\n", - stderr=f"e2e-err {token}\n", - ), - ) - monkeypatch.setattr(sandboxed_web_e2e, "stop_service", lambda service: None) - - assert ( - sandboxed_web_e2e.main( - [ - "--repo-root", - str(repo_root), - "--backend-cmd", - "backend", - "--frontend-cmd", - "frontend", - "--e2e-cmd", - "e2e", - "--startup-timeout", - "5", - "--e2e-timeout", - "5", - ] + stdout=f"token={token}\n", + stderr=f"Bearer {token}\n", ) - == 0 + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start_service) + monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", lambda url, timeout, service: True) + monkeypatch.setattr(sandboxed_web_e2e, "run_shell", fake_run_shell) + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(repository), + "--backend-cmd", + f"backend --token {token}", + "--frontend-cmd", + f"frontend TOKEN={token}", + "--e2e-cmd", + f"e2e --api-key {token}", + "--evidence-note", + f"secret={token}", + ] ) captured = capsys.readouterr() + assert exit_code == 0 assert token not in captured.out assert token not in captured.err - assert "e2e-out [REDACTED]" in captured.out - assert "e2e-err [REDACTED]" in captured.err + assert captured.out.count(REDACTED) >= 7 + assert REDACTED in captured.err + + +def test_tail_text_handles_missing_and_bounded_existing_logs(tmp_path: Path) -> None: + """Return nothing for missing logs and redact only the requested final lines.""" + token = _provider_token() + missing = tmp_path / "missing.log" + log_path = tmp_path / "service.log" + log_path.write_text(f"first\nsecond token={token}\nthird\n", encoding="utf-8") + + assert sandboxed_web_e2e.tail_text(missing) == "" + tail = sandboxed_web_e2e.tail_text(log_path, max_lines=2) + assert tail == f"second token={REDACTED}\nthird" + assert token not in tail From d71f13372398e51ba9767e9bfd8daee302bbe43d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:08:27 +0900 Subject: [PATCH 06/93] fix(ci): redact every sandbox evidence publication sink --- scripts/ci/redact_sensitive_log.py | 41 ++++++++++++++++++++++++++++++ scripts/ci/sandboxed_verify.py | 21 ++++++++++----- scripts/ci/sandboxed_web_e2e.py | 21 ++++++++------- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index cb89fe67b..0b72e6f07 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -5,7 +5,9 @@ import json import re +import shlex import sys +from collections.abc import Sequence from typing import Any REDACTED = "[REDACTED]" @@ -15,6 +17,11 @@ r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", re.IGNORECASE, ) +SENSITIVE_OPTION_RE = re.compile( + r"(?:token|secret|password|passwd|credential|authorization|jwt|" + r"api[-_]?key|private[-_]?key|access[-_]?key|session[-_]?key)", + re.IGNORECASE, +) JWT_RE = re.compile( r"(? str: return "".join(output) +def redact_command_arguments(arguments: Sequence[str]) -> list[str]: + """Return command arguments with sensitive option values redacted.""" + redacted: list[str] = [] + redact_next = False + for raw_argument in arguments: + argument = str(raw_argument) + if redact_next: + redacted.append(REDACTED) + redact_next = False + continue + + option = argument.lstrip("-") + if "=" in option: + key, _value = option.split("=", 1) + if SENSITIVE_OPTION_RE.fullmatch(key): + separator_index = argument.find("=") + redacted.append(f"{argument[: separator_index + 1]}{REDACTED}") + continue + + redacted.append(redact_text(argument)) + if SENSITIVE_OPTION_RE.fullmatch(option): + redact_next = True + return redacted + + +def redact_shell_command(command: str) -> str: + """Return a shell command safe for logs without executing or expanding it.""" + try: + arguments = shlex.split(command) + except ValueError: + return redact_text(command) + return shlex.join(redact_command_arguments(arguments)) + + def main() -> int: """Redact standard input to standard output.""" sys.stdout.write(redact_text(sys.stdin.read())) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index 3650893c3..683c07256 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -6,6 +6,7 @@ import json import os import re +import shlex import shutil import subprocess import sys @@ -14,9 +15,14 @@ from collections.abc import Sequence from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import ( + redact_command_arguments, + redact_text, +) -from scripts.ci.redact_sensitive_log import redact_text DEFAULT_IGNORE = ( ".git", @@ -168,7 +174,7 @@ def run_command(command: Sequence[str], cwd: Path, env: dict[str, str], timeout: def timeout_output_text(value: str | bytes | None) -> str: - """Return timeout output as text, regardless of subprocess internals.""" + """Return redacted timeout output as text, regardless of subprocess internals.""" if value is None: return "" if isinstance(value, bytes): @@ -188,13 +194,13 @@ def emit_result( network: str, evidence_note: str, ) -> None: - """Print a machine-readable execution evidence summary.""" + """Print a machine-readable execution evidence summary without secrets.""" payload = { "allowed_env": sorted(set(allowed_env)), - "command": list(command), + "command": redact_command_arguments(command), "cwd": str(copied_repo), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": evidence_note, + "evidence_note": redact_text(evidence_note), "exit_code": exit_code, "network": network, "sandbox": str(sandbox_root) if kept else "(removed)", @@ -214,7 +220,8 @@ def main(argv: Sequence[str] | None = None) -> int: copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore) env = scrubbed_env(sandbox, args.allow_env) print(f"sandboxed-verify: cwd={copied_repo}") - print(f"sandboxed-verify: command={' '.join(args.command)}") + safe_command = shlex.join(redact_command_arguments(args.command)) + print(f"sandboxed-verify: command={safe_command}") if args.allow_env: print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}") if args.network != "default": diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 39e2fed16..5dc0d61d6 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -22,7 +22,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import sandboxed_verify -from scripts.ci.redact_sensitive_log import redact_text +from scripts.ci.redact_sensitive_log import redact_shell_command, redact_text + RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT" @@ -110,6 +111,7 @@ def start_service(label: str, command: str, cwd: Path, env: dict[str, str], logs stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True, + shell=False, ) log_file.close() return Service(label=label, command=command, process=process, log_path=log_path) @@ -136,7 +138,7 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: def run_shell(command: str, cwd: Path, env: dict[str, str], timeout: int) -> subprocess.CompletedProcess[str]: - """Run a shell command and capture its output.""" + """Run a shell-style command without invoking a shell and capture output.""" return subprocess.run( shlex.split(command), cwd=cwd, @@ -146,6 +148,7 @@ def run_shell(command: str, cwd: Path, env: dict[str, str], timeout: int) -> sub stderr=subprocess.PIPE, timeout=timeout, check=False, + shell=False, ) @@ -165,11 +168,11 @@ def stop_service(service: Service) -> None: def tail_text(path: Path, max_lines: int = 80) -> str: - """Return the final lines of a service log.""" + """Return redacted final lines of a service log.""" if not path.exists(): return "" lines = path.read_text(encoding="utf-8", errors="replace").splitlines() - return "\n".join(lines[-max_lines:]) + return redact_text("\n".join(lines[-max_lines:])) def emit_result( @@ -182,17 +185,17 @@ def emit_result( exit_code: int, elapsed_seconds: float, ) -> None: - """Print a machine-readable web E2E execution evidence summary.""" + """Print machine-readable web E2E evidence without credential values.""" payload = { - "backend_cmd": args.backend_cmd, + "backend_cmd": redact_shell_command(args.backend_cmd), "backend_ready": backend_ready, "allowed_env": sorted(set(args.allow_env)), "cwd": str(copied_repo), - "e2e_cmd": args.e2e_cmd, + "e2e_cmd": redact_shell_command(args.e2e_cmd), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": args.evidence_note, + "evidence_note": redact_text(args.evidence_note), "exit_code": exit_code, - "frontend_cmd": args.frontend_cmd, + "frontend_cmd": redact_shell_command(args.frontend_cmd), "frontend_ready": frontend_ready, "network": args.network, "sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)", From 27bb444c7ce2004f7b743b77404bc3528712e842 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:09:10 +0900 Subject: [PATCH 07/93] docs(ci): cover every sandbox evidence redaction sink --- .../sandboxed-command-log-redaction.md | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/doctoring/sandboxed-command-log-redaction.md b/docs/doctoring/sandboxed-command-log-redaction.md index 1563f4d9c..652716e4f 100644 --- a/docs/doctoring/sandboxed-command-log-redaction.md +++ b/docs/doctoring/sandboxed-command-log-redaction.md @@ -1,37 +1,71 @@ -# Sandboxed command log redaction +# Sandboxed command and output redaction ## Decision -Captured `stdout` and `stderr` from repository verification and web end-to-end commands are untrusted evidence. Before those streams are written to GitHub Actions logs, they pass through the existing central `redact_text` boundary. The same rule applies to ordinary process completion and `TimeoutExpired` payloads represented as either text or bytes. +The central verification wrappers treat subprocess output, service log tails, command arguments, shell-command strings, and reviewer evidence notes as potentially sensitive before writing them to GitHub Actions logs or machine-readable review evidence. -This change preserves the command exit status, line boundaries, non-sensitive diagnostics, machine-readable result envelope, isolated workspace, scrubbed environment, bounded timeouts, and service cleanup. It does not synthesize success, discard stderr, widen an environment allowlist, or alter the review gate. +One trusted redaction module owns this publication boundary: + +- captured standard output and standard error are redacted before printing; +- `TimeoutExpired` byte and text payloads use the same redaction path; +- service log tails are redacted before publication; +- command arguments following sensitive options such as `--token`, `--password`, or `--api-key` are replaced; +- sensitive `KEY=value` command arguments are replaced while preserving the key; +- standalone provider-token shapes are removed; +- shell command strings are parsed without execution and reconstructed from redacted arguments; and +- JSON result markers redact commands and evidence notes before serialization. + +The original argument vectors and output are used only inside the isolated execution boundary. Redaction changes neither the command that runs nor its exit status. It is applied at every publication sink instead of depending on each child process to avoid printing credentials. ## Threat model -A tool, test, package manager, browser runner, or application process can print credentials obtained from an explicitly allowed environment variable, configuration file, exception, dependency-manager diagnostic, or echoed request. CI logs are durable review evidence and can have a broader readership than the secret itself. Redaction therefore occurs at the final central output sink rather than relying on every child process to behave correctly. +Repository verification commands and web end-to-end services can emit credentials through exception messages, dependency-manager diagnostics, HTTP-client traces, command-line options, environment-derived configuration, startup logs, and timeout payloads. An explicitly allowlisted environment variable can therefore remain correctly scoped to a child process and still be disclosed when that child echoes it. + +GitHub Actions logs and review envelopes are durable evidence with a potentially broader readership than the originating credential. MITRE classifies insertion of sensitive information into log files as CWE-532. OWASP's current logging guidance identifies access tokens, passwords, database connection strings, encryption keys, and other primary secrets as values that should normally be removed, masked, sanitized, hashed, or encrypted before logging. NIST SSDF requires protection of software and development artifacts from unauthorized access and disclosure. + +## Security boundaries + +- No provider-shaped credential literal is committed as a test fixture. Tests construct credential-shaped values from fragments at runtime so Secret Scan remains authoritative. +- Redaction is fail-closed for recognized sensitive option names, assignments, bearer/basic values, JWTs, and known provider token formats, but it is not a general data-loss-prevention engine. +- Sensitive option detection uses explicit credential terms. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool. +- Shell strings are tokenized with `shlex.split`; no shell is invoked for redaction. Malformed strings fall back to line-oriented redaction. +- `subprocess.run` and `subprocess.Popen` receive structured argument arrays with `shell=False`. Preventing shell interpretation and preventing log disclosure are independent controls. +- File paths, working directories, and sandbox paths remain visible operational evidence. Operators must not place credentials in path names. +- Redaction preserves line boundaries and ordinary non-sensitive diagnostics. It does not transform a failed command into a successful result or suppress a nonzero exit status. -MITRE classifies writing sensitive information to a log as CWE-532 and recommends not writing secrets to log files. OWASP likewise identifies access tokens, passwords, connection strings, encryption keys, and other primary secrets as data that should be removed, masked, sanitized, hashed, or encrypted before logging. The Python subprocess API does not implicitly select a system shell when `shell=False`; structured argument execution is retained independently of the log-redaction control. +No formal OWASP, NIST, or CWE conformity is claimed. ## Verification contract -Permanent regression tests must prove that: +The focused regression suite constructs a credential-shaped token at runtime and proves that it does not appear in: -1. text and byte timeout payloads redact a PAT-shaped fixture; -2. normal sandbox verification stdout and stderr redact the same fixture; -3. normal sandboxed web E2E stdout and stderr redact the same fixture; -4. the raw fixture never appears in captured output; -5. ordinary non-sensitive text and process exit codes remain visible; -6. the test fixture is assembled from separate fragments so repository secret scanning does not mistake it for a live credential. +1. completed verification stdout or stderr; +2. timeout output supplied as bytes or text; +3. human-readable command displays; +4. JSON result-marker command arrays; +5. backend, frontend, or E2E shell-command fields; +6. reviewer evidence notes; or +7. service log tails. -Exact-head repository tests, statement/branch coverage, docstring checks, Secret Scan, Semgrep, CodeQL, Security Scan, OpenCode, Noema, and branch protection remain authoritative. +The tests also cover separate sensitive options, `--option=value`, `KEY=value` assignments, standalone provider-token shapes, malformed shell quoting, missing logs, bounded final-line selection, and both wrappers' end-to-end publication paths. Ordinary text, line endings, result envelopes, cleanup, timeouts, and child-process exit codes remain observable. + +The exact pull-request head must additionally pass the complete central unit suite, 100% production statement and branch coverage for the changed surface, production docstring checks, Secret Scan, CodeQL, Semgrep, Python Security, Security Scan, OpenCode, Noema, CodeRabbit, independent current-head approval, and branch protection before merge. ## Modular boundary -Both wrappers remain independently executable scripts and reusable Python modules. They depend only on the central redaction utility and standard-library process primitives, so product repositories can consume the same behavior through the organization workflow without copying a repository-local implementation. +`sandboxed_verify.py`, `sandboxed_web_e2e.py`, and `redact_sensitive_log.py` remain independently executable scripts and reusable Python modules. Product repositories consume the behavior through the organization control plane without copying repository-local redaction code. The wrappers preserve their existing CLI and machine-readable result contracts. + +## Rollback + +Rollback must restore every publication sink as one atomic change. Removing only command redaction, service-tail redaction, or result-envelope redaction would recreate a bypass around the remaining controls. Before rollback, operators must prove that no allowlisted credential can reach child output or command metadata and must retain equivalent focused regression evidence. + +## APA 7 references + +MITRE Corporation. (2026). *CWE-117: Improper output neutralization for logs* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/117.html -## References +MITRE Corporation. (2026). *CWE-532: Insertion of sensitive information into log file* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/532.html -MITRE. (2026, April 30). *CWE-532: Insertion of sensitive information into log file*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/532.html +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 5, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html From f5380536f93f9deb0e04b74452c114a05cd44151 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:10:33 +0900 Subject: [PATCH 08/93] test(ci): reject JSON-value credential disclosure --- tests/test_sandboxed_output_redaction.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_sandboxed_output_redaction.py b/tests/test_sandboxed_output_redaction.py index 38611c217..bab3956e7 100644 --- a/tests/test_sandboxed_output_redaction.py +++ b/tests/test_sandboxed_output_redaction.py @@ -11,6 +11,7 @@ REDACTED, redact_command_arguments, redact_shell_command, + redact_text, ) @@ -19,6 +20,26 @@ def _provider_token() -> str: return "gh" + "p_" + ("A" * 36) +def test_json_string_values_are_redacted_recursively() -> None: + """Valid JSON cannot bypass token redaction through non-sensitive value keys.""" + token = _provider_token() + raw = ( + '{"message":"provider ' + + token + + '","nested":{"notes":["Bearer ' + + token + + '","safe"]}}\n' + ) + + redacted = redact_text(raw) + + assert token not in redacted + assert redacted == ( + '{"message":"provider [REDACTED]",' + '"nested":{"notes":["Bearer [REDACTED]","safe"]}}\n' + ) + + def test_redact_command_arguments_covers_separate_equals_and_direct_tokens() -> None: """Redact option values, assignments, and provider-shaped standalone values.""" token = _provider_token() From 4d613048f20d75596066f4ee6a7c511acb111670 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:11:15 +0900 Subject: [PATCH 09/93] fix(ci): redact credential-shaped JSON values recursively --- scripts/ci/redact_sensitive_log.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 0b72e6f07..3e8da947c 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -40,7 +40,7 @@ def _redact_json(value: Any) -> Any: - """Recursively replace values whose JSON keys identify credentials.""" + """Recursively redact sensitive keys and credential-shaped JSON strings.""" if isinstance(value, dict): return { key: REDACTED if SENSITIVE_KEY_RE.search(str(key)) else _redact_json(item) @@ -48,6 +48,8 @@ def _redact_json(value: Any) -> Any: } if isinstance(value, list): return [_redact_json(item) for item in value] + if isinstance(value, str): + return _redact_unstructured(value) return value From 03c9c944ab3bb1a30ff8442614f676002e2d853b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:14:30 +0900 Subject: [PATCH 10/93] test(ci): reject credential-shaped JSON object keys --- tests/test_redact_json_key_boundary.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test_redact_json_key_boundary.py diff --git a/tests/test_redact_json_key_boundary.py b/tests/test_redact_json_key_boundary.py new file mode 100644 index 000000000..6117502e4 --- /dev/null +++ b/tests/test_redact_json_key_boundary.py @@ -0,0 +1,21 @@ +"""Regression evidence for credential-shaped JSON object keys.""" + +from __future__ import annotations + +import re + +from scripts.ci import redact_sensitive_log as redactor + + +def test_json_object_keys_use_the_unstructured_redaction_boundary(monkeypatch) -> None: + """A JSON key matching a credential format must never bypass redaction.""" + + monkeypatch.setattr( + redactor, + "PROVIDER_TOKEN_RES", + (re.compile(r"\bcredential_key_marker\b"),), + ) + + assert redactor.redact_text('{"credential_key_marker":"safe"}\n') == ( + '{"[REDACTED]":"safe"}\n' + ) From 0fab42577bd403400c19a7d372aed643ff95d45a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:15:03 +0900 Subject: [PATCH 11/93] fix(ci): redact credential-shaped JSON object keys --- scripts/ci/redact_sensitive_log.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 3e8da947c..84b810d1f 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -43,7 +43,9 @@ def _redact_json(value: Any) -> Any: """Recursively redact sensitive keys and credential-shaped JSON strings.""" if isinstance(value, dict): return { - key: REDACTED if SENSITIVE_KEY_RE.search(str(key)) else _redact_json(item) + _redact_unstructured(str(key)): ( + REDACTED if SENSITIVE_KEY_RE.search(str(key)) else _redact_json(item) + ) for key, item in value.items() } if isinstance(value, list): From 87a674391f9208bd4081692a1cfa4b2fec59d6ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:15:51 +0900 Subject: [PATCH 12/93] test(ci): bound assignment redaction scanning work --- tests/test_redact_json_key_boundary.py | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_redact_json_key_boundary.py b/tests/test_redact_json_key_boundary.py index 6117502e4..e4c8621a4 100644 --- a/tests/test_redact_json_key_boundary.py +++ b/tests/test_redact_json_key_boundary.py @@ -1,4 +1,4 @@ -"""Regression evidence for credential-shaped JSON object keys.""" +"""Regression evidence for credential-shaped JSON keys and bounded scanning.""" from __future__ import annotations @@ -19,3 +19,28 @@ def test_json_object_keys_use_the_unstructured_redaction_boundary(monkeypatch) - assert redactor.redact_text('{"credential_key_marker":"safe"}\n') == ( '{"[REDACTED]":"safe"}\n' ) + + +def test_assignment_scan_does_not_rescan_one_long_ordinary_identifier( + monkeypatch, +) -> None: + """A long non-sensitive token must be inspected once rather than quadratically.""" + + class CountingSensitivePattern: + """Count the total candidate characters inspected by key classification.""" + + def __init__(self) -> None: + self.inspected_characters = 0 + + def search(self, value: str): + """Record one candidate and report that it is not a sensitive key.""" + + self.inspected_characters += len(value) + return None + + counting_pattern = CountingSensitivePattern() + monkeypatch.setattr(redactor, "SENSITIVE_KEY_RE", counting_pattern) + ordinary_identifier = "ordinary_identifier_" * 512 + + assert redactor.redact_text(ordinary_identifier) == ordinary_identifier + assert counting_pattern.inspected_characters <= len(ordinary_identifier) From 5cf890bb52827638df208960fd34537a2b8ab852 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:16:51 +0900 Subject: [PATCH 13/93] perf(ci): make assignment redaction a bounded forward scan --- scripts/ci/redact_sensitive_log.py | 34 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 84b810d1f..129810220 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -55,8 +55,11 @@ def _redact_json(value: Any) -> Any: return value -def _consume_sensitive_assignment(text: str, start: int) -> tuple[str, int] | None: - """Return a redacted key/value assignment parsed in linear time.""" +def _consume_sensitive_assignment( + text: str, + start: int, +) -> tuple[str | None, int]: + """Parse one possible assignment and return replacement plus next cursor.""" cursor = start key_quote = "" if cursor < len(text) and text[cursor] in "\"'": @@ -64,25 +67,25 @@ def _consume_sensitive_assignment(text: str, start: int) -> tuple[str, int] | No cursor += 1 key_start = cursor if cursor >= len(text) or text[cursor] not in KEY_CHARS or text[cursor].isdigit(): - return None + return None, start + 1 while cursor < len(text) and text[cursor] in KEY_CHARS: cursor += 1 key = text[key_start:cursor] if key_quote: if cursor >= len(text) or text[cursor] != key_quote: - return None + return None, start + 1 cursor += 1 if not SENSITIVE_KEY_RE.search(key): - return None + return None, cursor while cursor < len(text) and text[cursor].isspace(): cursor += 1 if cursor >= len(text) or text[cursor] not in ":=": - return None + return None, cursor cursor += 1 while cursor < len(text) and text[cursor].isspace(): cursor += 1 if cursor >= len(text): - return None + return None, cursor value_start = cursor if text[cursor] in "\"'": @@ -102,22 +105,21 @@ def _consume_sensitive_assignment(text: str, start: int) -> tuple[str, int] | No while cursor < len(text) and not text[cursor].isspace() and text[cursor] not in ",}": cursor += 1 if cursor == value_start: - return None + return None, cursor return text[start:value_start] + REDACTED, cursor def _redact_assignments(text: str) -> str: - """Redact sensitive key/value assignments without backtracking regexes.""" + """Redact sensitive key/value assignments in one bounded forward scan.""" output: list[str] = [] cursor = 0 while cursor < len(text): - match = _consume_sensitive_assignment(text, cursor) - if match is None: - output.append(text[cursor]) - cursor += 1 - continue - replacement, cursor = match - output.append(replacement) + replacement, next_cursor = _consume_sensitive_assignment(text, cursor) + if replacement is None: + output.append(text[cursor:next_cursor]) + else: + output.append(replacement) + cursor = next_cursor return "".join(output) From 02d50bbf03a961d361c77d1f00b41eeb52c1b221 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:18:38 +0900 Subject: [PATCH 14/93] docs(ci): record recursive and bounded log redaction --- .../doctoring/sandboxed-command-log-redaction.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/sandboxed-command-log-redaction.md b/docs/doctoring/sandboxed-command-log-redaction.md index 652716e4f..517ae519c 100644 --- a/docs/doctoring/sandboxed-command-log-redaction.md +++ b/docs/doctoring/sandboxed-command-log-redaction.md @@ -12,6 +12,7 @@ One trusted redaction module owns this publication boundary: - command arguments following sensitive options such as `--token`, `--password`, or `--api-key` are replaced; - sensitive `KEY=value` command arguments are replaced while preserving the key; - standalone provider-token shapes are removed; +- valid JSON is traversed recursively so credential-shaped object keys and string values cannot bypass line-oriented patterns; - shell command strings are parsed without execution and reconstructed from redacted arguments; and - JSON result markers redact commands and evidence notes before serialization. @@ -19,17 +20,18 @@ The original argument vectors and output are used only inside the isolated execu ## Threat model -Repository verification commands and web end-to-end services can emit credentials through exception messages, dependency-manager diagnostics, HTTP-client traces, command-line options, environment-derived configuration, startup logs, and timeout payloads. An explicitly allowlisted environment variable can therefore remain correctly scoped to a child process and still be disclosed when that child echoes it. +Repository verification commands and web end-to-end services can emit credentials through exception messages, dependency-manager diagnostics, HTTP-client traces, command-line options, environment-derived configuration, startup logs, structured JSON diagnostics, and timeout payloads. An explicitly allowlisted environment variable can therefore remain correctly scoped to a child process and still be disclosed when that child echoes it. GitHub Actions logs and review envelopes are durable evidence with a potentially broader readership than the originating credential. MITRE classifies insertion of sensitive information into log files as CWE-532. OWASP's current logging guidance identifies access tokens, passwords, database connection strings, encryption keys, and other primary secrets as values that should normally be removed, masked, sanitized, hashed, or encrypted before logging. NIST SSDF requires protection of software and development artifacts from unauthorized access and disclosure. -## Security boundaries +## Security and availability boundaries - No provider-shaped credential literal is committed as a test fixture. Tests construct credential-shaped values from fragments at runtime so Secret Scan remains authoritative. - Redaction is fail-closed for recognized sensitive option names, assignments, bearer/basic values, JWTs, and known provider token formats, but it is not a general data-loss-prevention engine. - Sensitive option detection uses explicit credential terms. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool. - Shell strings are tokenized with `shlex.split`; no shell is invoked for redaction. Malformed strings fall back to line-oriented redaction. - `subprocess.run` and `subprocess.Popen` receive structured argument arrays with `shell=False`. Preventing shell interpretation and preventing log disclosure are independent controls. +- The assignment scanner advances through each ordinary identifier once. A deterministic instrumentation test prevents a long non-sensitive token from reintroducing quadratic rescanning and log-processing denial of service. - File paths, working directories, and sandbox paths remain visible operational evidence. Operators must not place credentials in path names. - Redaction preserves line boundaries and ordinary non-sensitive diagnostics. It does not transform a failed command into a successful result or suppress a nonzero exit status. @@ -44,10 +46,12 @@ The focused regression suite constructs a credential-shaped token at runtime and 3. human-readable command displays; 4. JSON result-marker command arrays; 5. backend, frontend, or E2E shell-command fields; -6. reviewer evidence notes; or -7. service log tails. +6. reviewer evidence notes; +7. service log tails; +8. nested JSON string values; or +9. JSON object keys. -The tests also cover separate sensitive options, `--option=value`, `KEY=value` assignments, standalone provider-token shapes, malformed shell quoting, missing logs, bounded final-line selection, and both wrappers' end-to-end publication paths. Ordinary text, line endings, result envelopes, cleanup, timeouts, and child-process exit codes remain observable. +The tests also cover separate sensitive options, `--option=value`, `KEY=value` assignments, standalone provider-token shapes, malformed shell quoting, missing logs, bounded final-line selection, recursive JSON structures, bounded assignment scanning, and both wrappers' end-to-end publication paths. Ordinary text, line endings, result envelopes, cleanup, timeouts, and child-process exit codes remain observable. The exact pull-request head must additionally pass the complete central unit suite, 100% production statement and branch coverage for the changed surface, production docstring checks, Secret Scan, CodeQL, Semgrep, Python Security, Security Scan, OpenCode, Noema, CodeRabbit, independent current-head approval, and branch protection before merge. @@ -57,7 +61,7 @@ The exact pull-request head must additionally pass the complete central unit sui ## Rollback -Rollback must restore every publication sink as one atomic change. Removing only command redaction, service-tail redaction, or result-envelope redaction would recreate a bypass around the remaining controls. Before rollback, operators must prove that no allowlisted credential can reach child output or command metadata and must retain equivalent focused regression evidence. +Rollback must restore every publication sink as one atomic change. Removing only command redaction, JSON traversal, service-tail redaction, or result-envelope redaction would recreate a bypass around the remaining controls. Before rollback, operators must prove that no allowlisted credential can reach child output or command metadata and must retain equivalent focused regression evidence. ## APA 7 references From 9f92c3d19cd33a1ae8175f541347bba410da31d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:21:07 +0900 Subject: [PATCH 15/93] build(security): align Strix dependency snapshots --- requirements-strix-ci-hashes.txt | 341 ++++++++++++++++--------------- requirements-strix-ci.txt | 3 +- 2 files changed, 173 insertions(+), 171 deletions(-) diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt index e2c8f00eb..c305e9c84 100644 --- a/requirements-strix-ci-hashes.txt +++ b/requirements-strix-ci-hashes.txt @@ -4,127 +4,128 @@ aiohappyeyeballs==2.7.1 \ --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 # via aiohttp -aiohttp==3.14.1 \ - --hash=sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5 \ - --hash=sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983 \ - --hash=sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521 \ - --hash=sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340 \ - --hash=sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d \ - --hash=sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a \ - --hash=sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4 \ - --hash=sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a \ - --hash=sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f \ - --hash=sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee \ - --hash=sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8 \ - --hash=sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb \ - --hash=sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397 \ - --hash=sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05 \ - --hash=sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8 \ - --hash=sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09 \ - --hash=sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2 \ - --hash=sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba \ - --hash=sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf \ - --hash=sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271 \ - --hash=sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5 \ - --hash=sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847 \ - --hash=sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264 \ - --hash=sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf \ - --hash=sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6 \ - --hash=sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df \ - --hash=sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035 \ - --hash=sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126 \ - --hash=sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6 \ - --hash=sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35 \ - --hash=sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4 \ - --hash=sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333 \ - --hash=sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203 \ - --hash=sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c \ - --hash=sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1 \ - --hash=sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251 \ - --hash=sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365 \ - --hash=sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b \ - --hash=sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621 \ - --hash=sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94 \ - --hash=sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da \ - --hash=sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491 \ - --hash=sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe \ - --hash=sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d \ - --hash=sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080 \ - --hash=sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42 \ - --hash=sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c \ - --hash=sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397 \ - --hash=sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9 \ - --hash=sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8 \ - --hash=sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345 \ - --hash=sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3 \ - --hash=sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602 \ - --hash=sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2 \ - --hash=sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966 \ - --hash=sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192 \ - --hash=sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95 \ - --hash=sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3 \ - --hash=sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b \ - --hash=sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444 \ - --hash=sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6 \ - --hash=sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573 \ - --hash=sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af \ - --hash=sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15 \ - --hash=sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe \ - --hash=sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2 \ - --hash=sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496 \ - --hash=sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876 \ - --hash=sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817 \ - --hash=sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448 \ - --hash=sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e \ - --hash=sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6 \ - --hash=sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd \ - --hash=sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f \ - --hash=sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe \ - --hash=sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c \ - --hash=sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca \ - --hash=sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c \ - --hash=sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa \ - --hash=sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc \ - --hash=sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0 \ - --hash=sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0 \ - --hash=sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2 \ - --hash=sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844 \ - --hash=sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719 \ - --hash=sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1 \ - --hash=sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3 \ - --hash=sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178 \ - --hash=sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3 \ - --hash=sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95 \ - --hash=sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730 \ - --hash=sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842 \ - --hash=sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd \ - --hash=sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d \ - --hash=sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96 \ - --hash=sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85 \ - --hash=sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1 \ - --hash=sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199 \ - --hash=sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a \ - --hash=sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588 \ - --hash=sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec \ - --hash=sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004 \ - --hash=sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480 \ - --hash=sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04 \ - --hash=sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8 \ - --hash=sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce \ - --hash=sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087 \ - --hash=sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505 \ - --hash=sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780 \ - --hash=sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4 \ - --hash=sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d \ - --hash=sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca \ - --hash=sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665 \ - --hash=sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296 \ - --hash=sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c \ - --hash=sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a \ - --hash=sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7 \ - --hash=sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451 \ - --hash=sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3 +aiohttp==3.14.3 \ + --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \ + --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \ + --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \ + --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \ + --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \ + --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \ + --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \ + --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \ + --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \ + --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \ + --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \ + --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \ + --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \ + --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \ + --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \ + --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \ + --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \ + --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \ + --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \ + --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \ + --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \ + --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \ + --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \ + --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \ + --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \ + --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \ + --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \ + --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \ + --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \ + --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \ + --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \ + --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \ + --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \ + --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \ + --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \ + --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \ + --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \ + --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \ + --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \ + --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \ + --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \ + --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \ + --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \ + --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \ + --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \ + --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \ + --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \ + --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \ + --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \ + --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \ + --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \ + --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \ + --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \ + --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \ + --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \ + --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \ + --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \ + --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \ + --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \ + --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \ + --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \ + --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \ + --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \ + --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \ + --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \ + --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \ + --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \ + --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \ + --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \ + --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \ + --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \ + --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \ + --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \ + --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \ + --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \ + --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \ + --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \ + --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \ + --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \ + --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \ + --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \ + --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \ + --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \ + --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \ + --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \ + --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \ + --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \ + --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \ + --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \ + --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \ + --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \ + --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \ + --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \ + --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \ + --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \ + --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \ + --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \ + --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \ + --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \ + --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \ + --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \ + --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \ + --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \ + --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \ + --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \ + --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \ + --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \ + --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \ + --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \ + --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \ + --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \ + --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \ + --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \ + --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \ + --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \ + --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \ + --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \ + --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \ + --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5 # via + # -r requirements-strix-ci.txt # gql # litellm aiosignal==1.4.0 \ @@ -401,53 +402,53 @@ click==8.4.1 \ # litellm # typer # uvicorn -cryptography==49.0.0 \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via # -r requirements-strix-ci.txt # google-auth @@ -1680,9 +1681,9 @@ pyjwt==2.13.0 \ --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 # via mcp -pyopenssl==26.3.0 \ - --hash=sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3 \ - --hash=sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341 +pyopenssl==26.4.0 \ + --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ + --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c # via google-auth python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ diff --git a/requirements-strix-ci.txt b/requirements-strix-ci.txt index e32bd39a9..98e5c33e2 100644 --- a/requirements-strix-ci.txt +++ b/requirements-strix-ci.txt @@ -1,6 +1,7 @@ strix-agent==1.0.4 +aiohttp==3.14.3 google-cloud-aiplatform==1.133.0 protobuf<7.0.0 -cryptography==49.0.0 +cryptography==50.0.0 python-multipart==0.0.32 pyasn1==0.6.4 From 36f358db51e69661d379f655f58ba54cdb84b7fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:21:29 +0900 Subject: [PATCH 16/93] docs(ci): record complete sandbox evidence redaction --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..2480f65be --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to the ContextualWisdomLab central GitHub control plane are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versioned releases follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Security + +- Upgrade the central Strix dependency snapshots to `aiohttp==3.14.3`, `cryptography==50.0.0`, and the compatible `pyOpenSSL==26.4.0` closure so the hard dependency gates contain no known affected releases. +- Redact credentials from every sandbox evidence publication sink, including completed and timed-out process output, service log tails, commands, reviewer notes, nested JSON values, and JSON object keys. + +### Fixed + +- Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. + +### Documentation + +- Add an APA 7 doctoring record for the sandbox command/output redaction boundary, structured diagnostics, availability controls, verification evidence, limitations, and rollback requirements. From eb07d6b1359f70c8bc4ef74d6986fa02d1efe448 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:23:01 +0900 Subject: [PATCH 17/93] test(ci): isolate provider-pattern JSON key coverage --- tests/test_redact_json_key_boundary.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_redact_json_key_boundary.py b/tests/test_redact_json_key_boundary.py index e4c8621a4..27798bcbe 100644 --- a/tests/test_redact_json_key_boundary.py +++ b/tests/test_redact_json_key_boundary.py @@ -13,10 +13,10 @@ def test_json_object_keys_use_the_unstructured_redaction_boundary(monkeypatch) - monkeypatch.setattr( redactor, "PROVIDER_TOKEN_RES", - (re.compile(r"\bcredential_key_marker\b"),), + (re.compile(r"\bprovider_marker_value\b"),), ) - assert redactor.redact_text('{"credential_key_marker":"safe"}\n') == ( + assert redactor.redact_text('{"provider_marker_value":"safe"}\n') == ( '{"[REDACTED]":"safe"}\n' ) From ea38588c5455fcfed2f32e9132aeeac2854d7812 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:23:46 +0900 Subject: [PATCH 18/93] test(ci): complete central redaction behavior coverage --- tests/test_redact_sensitive_log_contract.py | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/test_redact_sensitive_log_contract.py diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py new file mode 100644 index 000000000..dc8498fac --- /dev/null +++ b/tests/test_redact_sensitive_log_contract.py @@ -0,0 +1,74 @@ +"""Complete behavioral contracts for the central log-redaction primitive.""" + +from __future__ import annotations + +import io +import sys + +from scripts.ci import redact_sensitive_log as redactor + + +def _synthetic_provider_token() -> str: + """Construct a provider-shaped value without storing a scanner literal.""" + + return "gh" + "p_" + ("B" * 36) + + +def test_json_sensitive_keys_and_scalar_values_preserve_safe_evidence() -> None: + """Sensitive JSON values are replaced while ordinary scalars remain intact.""" + + assert redactor.redact_text( + '{"api_key":"value","count":3,"enabled":true,"empty":null}\n' + ) == ( + '{"api_key":"[REDACTED]","count":3,"enabled":true,"empty":null}\n' + ) + + +def test_assignment_parser_covers_quoted_keys_values_and_incomplete_forms() -> None: + """Quoted assignments redact values and malformed empty forms make progress.""" + + assert redactor.redact_text("'token' = 'quoted value'") == ( + "'token' = [REDACTED]" + ) + assert redactor.redact_text("token='escaped\\' value'") == ( + "token=[REDACTED]" + ) + assert redactor.redact_text("'token=value") == ( + "'token=[REDACTED]" + ) + assert redactor.redact_text("token") == "token" + assert redactor.redact_text("token=") == "token=" + assert redactor.redact_text("token=,") == "token=," + + +def test_unstructured_patterns_cover_basic_jwt_and_provider_values() -> None: + """Independent credential formats share one non-JSON redaction boundary.""" + + provider_value = _synthetic_provider_token() + assert redactor.redact_text("Authorization: Basic opaque-value") == ( + "Authorization: [REDACTED] [REDACTED]" + ) + assert redactor.redact_text("header.payload.signature") == redactor.REDACTED + assert redactor.redact_text(provider_value) == redactor.REDACTED + + +def test_command_argument_redaction_preserves_non_sensitive_options() -> None: + """Only explicit sensitive option values and credential shapes are replaced.""" + + assert redactor.redact_command_arguments( + ["tool", "--mode=safe", "--token"] + ) == ["tool", "--mode=safe", "--token"] + assert redactor.redact_shell_command("") == "" + + +def test_empty_text_and_cli_main_preserve_stream_contract(monkeypatch) -> None: + """Empty input is stable and the CLI writes only the redacted stream.""" + + assert redactor.redact_text("") == "" + input_stream = io.StringIO("password=value\nordinary\n") + output_stream = io.StringIO() + monkeypatch.setattr(sys, "stdin", input_stream) + monkeypatch.setattr(sys, "stdout", output_stream) + + assert redactor.main() == 0 + assert output_stream.getvalue() == "password=[REDACTED]\nordinary\n" From 45dc74f6ce19e0ff2bdea21df00948a43ee10baf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:24:40 +0900 Subject: [PATCH 19/93] test(ci): require complete Authorization header redaction --- tests/test_redact_sensitive_log_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py index dc8498fac..350ce2044 100644 --- a/tests/test_redact_sensitive_log_contract.py +++ b/tests/test_redact_sensitive_log_contract.py @@ -46,7 +46,7 @@ def test_unstructured_patterns_cover_basic_jwt_and_provider_values() -> None: provider_value = _synthetic_provider_token() assert redactor.redact_text("Authorization: Basic opaque-value") == ( - "Authorization: [REDACTED] [REDACTED]" + "Authorization: [REDACTED]" ) assert redactor.redact_text("header.payload.signature") == redactor.REDACTED assert redactor.redact_text(provider_value) == redactor.REDACTED From c83271445822ffe951b9096f4cc70f78e3e41181 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:25:09 +0900 Subject: [PATCH 20/93] test(ci): preserve standalone auth scheme evidence safely --- tests/test_redact_sensitive_log_contract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py index 350ce2044..b1352d892 100644 --- a/tests/test_redact_sensitive_log_contract.py +++ b/tests/test_redact_sensitive_log_contract.py @@ -48,6 +48,7 @@ def test_unstructured_patterns_cover_basic_jwt_and_provider_values() -> None: assert redactor.redact_text("Authorization: Basic opaque-value") == ( "Authorization: [REDACTED]" ) + assert redactor.redact_text("Basic opaque-value") == "Basic [REDACTED]" assert redactor.redact_text("header.payload.signature") == redactor.REDACTED assert redactor.redact_text(provider_value) == redactor.REDACTED From 13323b14e97396ce2b8d536a85917970a864378e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:25:47 +0900 Subject: [PATCH 21/93] fix(ci): consume complete Authorization header values --- scripts/ci/redact_sensitive_log.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 129810220..a4fc57fc1 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -88,6 +88,10 @@ def _consume_sensitive_assignment( return None, cursor value_start = cursor + if key.casefold() == "authorization": + while cursor < len(text) and text[cursor] not in ",}": + cursor += 1 + return text[start:value_start] + REDACTED, cursor if text[cursor] in "\"'": value_quote = text[cursor] cursor += 1 From cb533a8e72ce67e5295e18e7f0972859780908a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:26:45 +0900 Subject: [PATCH 22/93] test(ci): cover complete redactor branch and entry contracts --- tests/test_redact_sensitive_log_contract.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py index b1352d892..11a1b34de 100644 --- a/tests/test_redact_sensitive_log_contract.py +++ b/tests/test_redact_sensitive_log_contract.py @@ -3,7 +3,11 @@ from __future__ import annotations import io +import runpy import sys +from pathlib import Path + +import pytest from scripts.ci import redact_sensitive_log as redactor @@ -33,12 +37,22 @@ def test_assignment_parser_covers_quoted_keys_values_and_incomplete_forms() -> N assert redactor.redact_text("token='escaped\\' value'") == ( "token=[REDACTED]" ) + assert redactor.redact_text("token='unterminated") == ( + "token=[REDACTED]" + ) assert redactor.redact_text("'token=value") == ( "'token=[REDACTED]" ) assert redactor.redact_text("token") == "token" + assert redactor.redact_text("token text") == "token text" assert redactor.redact_text("token=") == "token=" assert redactor.redact_text("token=,") == "token=," + assert redactor.redact_text("token=value ordinary") == ( + "token=[REDACTED] ordinary" + ) + assert redactor.redact_text("1token=value") == ( + "1token=[REDACTED]" + ) def test_unstructured_patterns_cover_basic_jwt_and_provider_values() -> None: @@ -73,3 +87,18 @@ def test_empty_text_and_cli_main_preserve_stream_contract(monkeypatch) -> None: assert redactor.main() == 0 assert output_stream.getvalue() == "password=[REDACTED]\nordinary\n" + + +def test_module_entry_point_exits_after_redacting_standard_input(monkeypatch) -> None: + """Direct script execution preserves the same redacted stream contract.""" + + input_stream = io.StringIO("secret=value\n") + output_stream = io.StringIO() + monkeypatch.setattr(sys, "stdin", input_stream) + monkeypatch.setattr(sys, "stdout", output_stream) + + with pytest.raises(SystemExit) as raised: + runpy.run_path(str(Path(redactor.__file__).resolve()), run_name="__main__") + + assert raised.value.code == 0 + assert output_stream.getvalue() == "secret=[REDACTED]\n" From 17d7d46d4669a8f4b6fd6dd95cb5f40e7a7880da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:32:23 +0900 Subject: [PATCH 23/93] test(redaction): cover echoed separate secret options --- tests/test_redact_sensitive_log_contract.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py index 11a1b34de..c279987a3 100644 --- a/tests/test_redact_sensitive_log_contract.py +++ b/tests/test_redact_sensitive_log_contract.py @@ -67,6 +67,14 @@ def test_unstructured_patterns_cover_basic_jwt_and_provider_values() -> None: assert redactor.redact_text(provider_value) == redactor.REDACTED +def test_unstructured_output_redacts_separate_sensitive_option_values() -> None: + """A child that echoes a separate secret option must not disclose its value.""" + + assert redactor.redact_text( + "running tool --api-key ordinary-value --mode safe" + ) == "running tool --api-key [REDACTED] --mode safe" + + def test_command_argument_redaction_preserves_non_sensitive_options() -> None: """Only explicit sensitive option values and credential shapes are replaced.""" From 624403b0fb5375bc0b79de3690da4554e461c46e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:38:57 +0900 Subject: [PATCH 24/93] docs: design bounded sandbox output resources --- ...sandboxed-output-resource-bounds-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md diff --git a/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md b/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md new file mode 100644 index 000000000..fdbb0c4a6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md @@ -0,0 +1,101 @@ +# Sandboxed Output Resource Bounds Design + +## Status + +Approved for autonomous implementation under issue #766. This slice is stacked after PR #764 so it can reuse the complete evidence-redaction boundary without changing reviewer identities or credentials. + +## Problem + +The central verification wrappers currently capture short-lived child stdout and stderr through pipes and let long-running services write ordinary log files. Redaction happens only after those streams have already been buffered or persisted. A defective or adversarial repository process can therefore consume runner memory or disk before its output reaches the redaction boundary. The current service-tail helper also reads the complete log before selecting the final lines. + +## Decision + +Use the operating system's POSIX file-size resource limit to bound every child-created output file before execution: + +- redirect each short-lived command stream to a private regular file rather than `PIPE`; +- apply `resource.setrlimit(resource.RLIMIT_FSIZE, ...)` in the single-threaded child pre-exec boundary; +- set the kernel ceiling one byte above the evidence budget so the parent can distinguish exact-sized normal output from an attempted overflow; +- read at most the configured final suffix from each file; +- map any attempted overflow to one stable resource-limit exit code; +- run backend and frontend services with the same kernel-enforced file ceiling; and +- seek from the end of service logs, reading only a bounded byte suffix before applying the existing final-line and redaction rules. + +The wrappers fail closed on platforms where POSIX resource limits are unavailable. They do not silently revert to unbounded capture. + +## Architecture + +### `scripts/ci/bounded_subprocess.py` + +A focused reusable module owns child output limits. It provides: + +- `BoundedCompletedProcess`: immutable command result with bounded text streams and an `output_limited` flag; +- `BoundedTimeoutExpired`: timeout evidence carrying only bounded text; +- `bounded_file_preexec(limit_bytes)`: a child-only callable that lowers `RLIMIT_FSIZE` without raising an existing hard limit; +- `run_bounded_command(...)`: structured-argv, `shell=False` execution into two private files; +- `read_bounded_suffix(path, maximum_bytes)`: suffix-only binary read with UTF-8 replacement and a stable truncation marker; +- `file_limit_reached(path, evidence_limit_bytes, return_code)`: exact overflow classification; and +- numeric configuration validation with explicit minimum and maximum values. + +The module imports `resource` only on POSIX and raises one stable unsupported-platform error otherwise. + +### `sandboxed_verify.py` + +The existing `run_command` facade delegates to `run_bounded_command`. A new optional `--output-limit-bytes` argument defaults to 1 MiB per stream. Normal output and exit codes remain unchanged. Timeout remains exit code 124. Attempted output overflow emits bounded redacted evidence and returns exit code 123. + +### `sandboxed_web_e2e.py` + +A new `--output-limit-bytes` controls the short-lived E2E command. A separate `--service-log-limit-bytes` defaults to 4 MiB per service. `start_service` applies the kernel ceiling before exec. Readiness or E2E completion checks classify service log overflow and return exit code 123. `tail_text` reads no more than 64 KiB from the end of the file, then retains at most 80 final lines and redacts them. + +## Data flow + +1. Parse and validate byte budgets before copying or running repository content. +2. Create private capture files inside the isolated sandbox. +3. Spawn the child with structured argv, scrubbed environment, `shell=False`, and a lowered `RLIMIT_FSIZE`. +4. Wait for completion or timeout. +5. Read only bounded suffixes, close and delete capture files, then redact before publication. +6. Classify timeout, ordinary exit, or output limit in that order. +7. Emit the existing machine-readable result schema plus declared limit evidence. + +No credential value, unbounded stream, or PR-controlled path enters a public evidence sink. + +## Failure semantics + +- Invalid byte budgets fail argument parsing before execution. +- Unsupported resource-limit platforms fail closed with stable exit code 123 and a credential-free message. +- Timeout remains 124, even when bounded partial output exists. +- Output overflow is 123 and cannot be converted to success by the child catching `SIGXFSZ` because file size greater than the evidence budget independently proves an attempted excess. +- An ordinary nonzero child exit remains unchanged when no stream exceeded its budget. +- Service readiness failure remains 125 unless a service log exceeded its budget, in which case the more specific 123 result wins. +- Cleanup and result emission run for every path. + +## Verification + +Real child-process tests must prove: + +- ordinary Unicode stdout/stderr remain intact within the budget; +- stdout and stderr attempts above the limit cannot produce files larger than budget plus one byte; +- overflow returns 123 with bounded redacted evidence; +- timeout evidence is bounded and returns 124; +- service log overflow stops readiness and returns 123; +- suffix reading never calls an unbounded `read()` and tolerates a partial UTF-8 code point; +- non-POSIX or missing-`RLIMIT_FSIZE` environments fail closed; +- lower pre-existing hard limits are respected; +- CLI minima/maxima and new result fields are deterministic; and +- all existing environment, copy, cleanup, redaction, SSRF, and process-group tests remain green. + +Every changed production helper requires a docstring and 100% statement/branch coverage. + +## Standards and evidence boundary + +Python 3.14 documents `resource.setrlimit()` as the resource-consumption control and `RLIMIT_FSIZE` as the maximum file size a process may create. Python's subprocess documentation states that `PIPE` captures child streams through `Popen`/`communicate`, whereas existing file descriptors may be supplied directly. CWE-770 recommends explicit resource ceilings and operating-system resource limiting. NIST SP 800-218 supplies the secure-development framework for preventing and verifying these failure modes. + +This design does not claim cross-platform equivalence. It deliberately supports the Linux/POSIX GitHub runner boundary and fails closed elsewhere. + +## Non-goals + +- changing scheduler cadence or scheduled review agents; +- changing OpenCode, Noema, Strix, NVIDIA NIM, or reviewer credentials; +- limiting repository workspace-copy size in this slice; +- limiting child CPU, address space, process count, or network traffic; +- replacing the existing output-redaction policy; +- retaining complete oversized logs as downloadable artifacts. From a7c5ccaba8bb92b390353e7ffe2543dca936942d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:39:51 +0900 Subject: [PATCH 25/93] docs: plan bounded sandbox output resources --- ...-08-05-sandboxed-output-resource-bounds.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md diff --git a/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md b/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md new file mode 100644 index 000000000..dc31b0493 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md @@ -0,0 +1,226 @@ +# Sandboxed Output Resource Bounds Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bound memory and disk consumed by sandbox child output before redaction while retaining useful, credential-free diagnostic suffixes. + +**Architecture:** Redirect child streams to private regular files and lower POSIX `RLIMIT_FSIZE` in the child pre-exec boundary. A reusable bounded-subprocess module classifies ordinary completion, timeout, and output overflow; both sandbox wrappers expose validated byte budgets and preserve their existing result contracts. + +**Tech Stack:** Python 3.10+, POSIX `resource`, `subprocess`, `tempfile`, `pathlib`, pytest, pytest-cov, interrogate. + +## Global Constraints + +- Stack after PR #764 and preserve its complete output-redaction boundary. +- Keep structured argument vectors and `shell=False`. +- Do not change OpenCode, Noema, Strix, NVIDIA NIM, reviewer identities, or credential names/scopes. +- Fail closed when POSIX file-size limits are unavailable. +- Timeout exit code remains 124; service readiness remains 125; output resource limit is 123. +- Default short-command budget is 1,048,576 bytes per stream. +- Default long-running service-log budget is 4,194,304 bytes per service. +- Maximum configurable budget is 67,108,864 bytes. +- Every changed production helper has a docstring and 100% statement/branch coverage. +- Add realistic child-process and file-boundary tests. +- Update `CHANGELOG.md` and APA 7 doctoring. + +--- + +### Task 1: Define failing bounded-subprocess contracts + +**Files:** +- Create: `tests/test_bounded_subprocess.py` + +**Interfaces:** +- Consumes: wished-for `scripts.ci.bounded_subprocess` +- Produces: exact public API and resource-limit semantics + +- [ ] **Step 1: Write ordinary-output and suffix tests** + +Use real private files containing Unicode and a partial UTF-8 leading byte. Assert that `read_bounded_suffix(path, maximum_bytes)` reads only the final budget, adds one truncation marker when needed, and uses replacement decoding rather than failing. + +- [ ] **Step 2: Write real child overflow tests** + +Launch `sys.executable -c` children that repeatedly call `os.write()` on stdout and stderr. Assert that each capture file is no larger than evidence budget plus one byte, `output_limited` is true, and the returned text is bounded. + +- [ ] **Step 3: Write timeout and ordinary exit tests** + +Assert normal Unicode output and return codes are preserved. Assert timeout raises `BoundedTimeoutExpired` with bounded stdout/stderr. + +- [ ] **Step 4: Write platform and configuration tests** + +Monkeypatch the platform/resource surface to prove unsupported environments fail closed, smaller existing hard limits are retained, and budgets outside 4 KiB–64 MiB are rejected. + +- [ ] **Step 5: Run focused tests and verify RED** + +Run: `python -m pytest tests/test_bounded_subprocess.py -q` + +Expected: import failure because the production module does not exist. + +- [ ] **Step 6: Commit the failing tests** + +```bash +git add tests/test_bounded_subprocess.py +git commit -m "test(ci): require bounded sandbox subprocess output" +``` + +### Task 2: Implement the reusable POSIX output boundary + +**Files:** +- Create: `scripts/ci/bounded_subprocess.py` +- Test: `tests/test_bounded_subprocess.py` + +**Interfaces:** +- Produces: + - `OUTPUT_LIMIT_EXIT_CODE = 123` + - `DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES = 1_048_576` + - `DEFAULT_SERVICE_LOG_LIMIT_BYTES = 4_194_304` + - `MAXIMUM_OUTPUT_LIMIT_BYTES = 67_108_864` + - `BoundedCompletedProcess` + - `BoundedTimeoutExpired` + - `validate_output_limit(value, label) -> int` + - `bounded_file_preexec(evidence_limit_bytes) -> Callable[[], None]` + - `read_bounded_suffix(path, maximum_bytes) -> BoundedText` + - `file_limit_reached(path, evidence_limit_bytes, return_code) -> bool` + - `run_bounded_command(args, cwd, env, timeout, evidence_limit_bytes) -> BoundedCompletedProcess` + +- [ ] **Step 1: Implement immutable result types and validation** + +Keep fields typed and frozen. Reject booleans, nonintegers, values below 4096, and values above 67,108,864. + +- [ ] **Step 2: Implement POSIX pre-exec limiting** + +Require `os.name == "posix"` and `resource.RLIMIT_FSIZE`. In the child, read the existing hard limit, choose the lower of budget-plus-one and the finite hard limit, then set soft and hard to that target. + +- [ ] **Step 3: Implement bounded suffix reading and overflow classification** + +Use binary seek-from-end and never call an unbounded read. Prefix `...[output truncated]...\n` only when the file exceeded the evidence budget. + +- [ ] **Step 4: Implement short-lived command execution** + +Create two private binary capture files, pass their descriptors to `subprocess.run`, apply the pre-exec function, read bounded suffixes in success and timeout paths, and remove the capture directory in all cases. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: `python -m pytest tests/test_bounded_subprocess.py -q` + +Expected: PASS. + +- [ ] **Step 6: Run focused coverage and docstrings** + +Run coverage with branch measurement for the new module and interrogate the production file at 100%. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/ci/bounded_subprocess.py tests/test_bounded_subprocess.py +git commit -m "feat(ci): bound child output with POSIX file limits" +``` + +### Task 3: Integrate bounded output into sandboxed verification + +**Files:** +- Modify: `scripts/ci/sandboxed_verify.py` +- Create: `tests/test_sandboxed_verify_output_limits.py` +- Modify: existing sandbox verification tests as needed + +**Interfaces:** +- Adds CLI: `--output-limit-bytes` +- `run_command(...)` delegates to `run_bounded_command` +- Result payload adds `output_limit_bytes` and `output_limited` + +- [ ] **Step 1: Write failing wrapper tests** + +Use real child commands to prove ordinary output, stdout overflow, stderr overflow, timeout, redaction, cleanup, result JSON, and stable exit codes. + +- [ ] **Step 2: Run focused wrapper tests and verify RED** + +Expected: missing CLI option and unbounded `run_command` behavior. + +- [ ] **Step 3: Implement the minimal wrapper integration** + +Validate the budget during argument parsing, print bounded text through existing redaction, map overflow to 123, and preserve timeout/nonzero behavior. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +- [ ] **Step 5: Commit** + +```bash +git add scripts/ci/sandboxed_verify.py tests/test_sandboxed_verify_output_limits.py +git commit -m "fix(ci): bound sandbox verification output" +``` + +### Task 4: Bound E2E service and command logs + +**Files:** +- Modify: `scripts/ci/sandboxed_web_e2e.py` +- Create: `tests/test_sandboxed_web_e2e_output_limits.py` +- Modify: existing web E2E tests as needed + +**Interfaces:** +- Adds CLI: + - `--output-limit-bytes` + - `--service-log-limit-bytes` +- `Service` records its evidence limit. +- `tail_text(path, max_lines=80, max_bytes=65_536)` performs suffix-only reading. + +- [ ] **Step 1: Write failing real-service tests** + +Start a child that exceeds the service log budget before readiness and assert exit 123, bounded file size, bounded redacted tail, and cleanup. Add a normal service/E2E case and a suffix-read spy that rejects unbounded reads. + +- [ ] **Step 2: Run focused tests and verify RED** + +- [ ] **Step 3: Apply resource limits to service and E2E children** + +Use the shared pre-exec boundary for service files and shared command runner for E2E. Check service overflow before assigning readiness/E2E return codes. + +- [ ] **Step 4: Replace complete-file tail reads** + +Seek from the end, decode with replacement, retain the final line count, and redact. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +- [ ] **Step 6: Commit** + +```bash +git add scripts/ci/sandboxed_web_e2e.py tests/test_sandboxed_web_e2e_output_limits.py +git commit -m "fix(ci): bound sandbox service and E2E logs" +``` + +### Task 5: Doctoring, changelog, and full validation + +**Files:** +- Create: `docs/doctoring/sandboxed-output-resource-bounds.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Produces: operator evidence, limitations, rollback, APA 7 references + +- [ ] **Step 1: Document the exact resource boundary** + +Cover `RLIMIT_FSIZE`, budget-plus-one detection, suffix evidence, exit-code precedence, single-threaded pre-exec assumption, Linux/POSIX support, unsupported-platform failure, and remaining CPU/memory/process/network non-goals. + +- [ ] **Step 2: Add APA 7 references** + +Cite Python 3.14.6 `resource` and `subprocess`, MITRE CWE-770 4.20, NIST SP 800-218, and the POSIX resource-limit specification. + +- [ ] **Step 3: Update `CHANGELOG.md`** + +Record the memory/disk exhaustion correction under `Security` and the new bounded evidence behavior under `Changed`. + +- [ ] **Step 4: Run complete exact-slice gates** + +Run all central Python tests, 100% statement/branch coverage, 100% production docstrings, compile/static checks, Secret Scan, Semgrep, CodeQL, Python Security, and supply-chain checks. + +- [ ] **Step 5: Commit** + +```bash +git add docs/doctoring/sandboxed-output-resource-bounds.md CHANGELOG.md +git commit -m "docs(ci): record bounded sandbox output evidence" +``` + +### Task 6: Review and integration + +- [ ] **Step 1: Open a stacked PR targeting `fix/sandboxed-log-redaction-clean` and closing #766** +- [ ] **Step 2: Resolve every exact-head automated and human finding** +- [ ] **Step 3: Merge #764 first without bypass** +- [ ] **Step 4: Retarget this PR to `main`, rerun every exact-head gate, and merge without bypass** +- [ ] **Step 5: Remove or update obsolete loops/docs only after both protections are present on `main`** From b3018970b9a84923f48d63dbcb63afda60a00109 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:41:03 +0900 Subject: [PATCH 26/93] test(ci): require bounded sandbox subprocess output --- tests/test_bounded_subprocess.py | 237 +++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 tests/test_bounded_subprocess.py diff --git a/tests/test_bounded_subprocess.py b/tests/test_bounded_subprocess.py new file mode 100644 index 000000000..0bb079e8d --- /dev/null +++ b/tests/test_bounded_subprocess.py @@ -0,0 +1,237 @@ +"""Real-process contracts for bounded sandbox subprocess output.""" + +from __future__ import annotations + +import os +import signal +import sys +from pathlib import Path + +import pytest + +from scripts.ci import bounded_subprocess as bounded + + +def _environment() -> dict[str, str]: + """Return a minimal child environment that can launch the current Python.""" + + return {"PATH": os.environ.get("PATH", ""), "PYTHONIOENCODING": "utf-8"} + + +def test_read_bounded_suffix_preserves_unicode_and_marks_partial_suffix( + tmp_path: Path, +) -> None: + """Suffix reads are byte-bounded and tolerate a cut UTF-8 code point.""" + + short_path = tmp_path / "short.log" + short_path.write_text("ordinary 한글\n", encoding="utf-8") + short = bounded.read_bounded_suffix(short_path, 4096) + assert short.text == "ordinary 한글\n" + assert short.truncated is False + assert short.stored_bytes == len("ordinary 한글\n".encode("utf-8")) + + partial_path = tmp_path / "partial.log" + partial_path.write_bytes(b"prefix-" + "가".encode("utf-8")) + partial = bounded.read_bounded_suffix(partial_path, 2) + assert partial.truncated is True + assert partial.stored_bytes == len(partial_path.read_bytes()) + assert partial.text.startswith(bounded.TRUNCATION_MARKER) + assert "�" in partial.text + assert len(partial.text.removeprefix(bounded.TRUNCATION_MARKER).encode("utf-8")) <= 6 + + +def test_run_bounded_command_preserves_ordinary_unicode_output(tmp_path: Path) -> None: + """Normal child output and return codes remain unchanged below the budget.""" + + result = bounded.run_bounded_command( + [ + sys.executable, + "-c", + "import sys; print('안녕'); print('경고', file=sys.stderr)", + ], + cwd=tmp_path, + env=_environment(), + timeout=10, + evidence_limit_bytes=4096, + ) + + assert result.args[0] == sys.executable + assert result.returncode == 0 + assert result.stdout == "안녕\n" + assert result.stderr == "경고\n" + assert result.output_limited is False + + +@pytest.mark.parametrize("stream_descriptor", [1, 2]) +def test_run_bounded_command_caps_real_stdout_and_stderr( + tmp_path: Path, + stream_descriptor: int, +) -> None: + """A child cannot create a captured stream beyond budget plus one byte.""" + + result = bounded.run_bounded_command( + [ + sys.executable, + "-c", + ( + "import os\n" + f"descriptor={stream_descriptor}\n" + "chunk=b'x'*1024\n" + "while True:\n" + " os.write(descriptor, chunk)\n" + ), + ], + cwd=tmp_path, + env=_environment(), + timeout=10, + evidence_limit_bytes=4096, + ) + + selected = result.stdout if stream_descriptor == 1 else result.stderr + assert result.output_limited is True + assert selected.startswith(bounded.TRUNCATION_MARKER) + assert len(selected.encode("utf-8")) <= 4096 + len( + bounded.TRUNCATION_MARKER.encode("utf-8") + ) + assert result.returncode != 0 + + +def test_timeout_raises_with_only_bounded_output(tmp_path: Path) -> None: + """Timeout evidence is bounded even when the child was actively writing.""" + + with pytest.raises(bounded.BoundedTimeoutExpired) as raised: + bounded.run_bounded_command( + [ + sys.executable, + "-c", + ( + "import os,time\n" + "os.write(1,b'before-timeout\\n')\n" + "os.write(2,b'warning-before-timeout\\n')\n" + "time.sleep(30)\n" + ), + ], + cwd=tmp_path, + env=_environment(), + timeout=1, + evidence_limit_bytes=4096, + ) + + assert raised.value.timeout == 1 + assert raised.value.stdout == "before-timeout\n" + assert raised.value.stderr == "warning-before-timeout\n" + assert raised.value.output_limited is False + + +def test_validate_output_limit_rejects_unsafe_values() -> None: + """Configured byte budgets are integer, bounded, and never Boolean.""" + + assert bounded.validate_output_limit(4096, "test limit") == 4096 + assert ( + bounded.validate_output_limit( + bounded.MAXIMUM_OUTPUT_LIMIT_BYTES, + "test limit", + ) + == bounded.MAXIMUM_OUTPUT_LIMIT_BYTES + ) + for value in [ + True, + 1.5, + "4096", + 4095, + bounded.MAXIMUM_OUTPUT_LIMIT_BYTES + 1, + ]: + with pytest.raises(ValueError, match="test limit"): + bounded.validate_output_limit(value, "test limit") # type: ignore[arg-type] + + +def test_preexec_fails_closed_without_posix_resource_support(monkeypatch) -> None: + """Unsupported platforms cannot silently fall back to unbounded capture.""" + + monkeypatch.setattr(bounded.os, "name", "nt") + with pytest.raises(bounded.OutputLimitUnsupportedError): + bounded.bounded_file_preexec(4096) + + monkeypatch.setattr(bounded.os, "name", "posix") + monkeypatch.setattr(bounded, "_resource", None) + with pytest.raises(bounded.OutputLimitUnsupportedError): + bounded.bounded_file_preexec(4096) + + +def test_preexec_respects_a_lower_existing_hard_limit(monkeypatch) -> None: + """The child limit is lowered but an existing hard limit is never raised.""" + + class FakeResource: + """Record the limit selected by the child pre-exec closure.""" + + RLIMIT_FSIZE = 1 + RLIM_INFINITY = -1 + + def __init__(self, hard_limit: int) -> None: + self.hard_limit = hard_limit + self.applied: tuple[int, tuple[int, int]] | None = None + + def getrlimit(self, resource_name: int) -> tuple[int, int]: + """Return the configured finite hard limit.""" + + assert resource_name == self.RLIMIT_FSIZE + return (self.hard_limit, self.hard_limit) + + def setrlimit( + self, + resource_name: int, + limits: tuple[int, int], + ) -> None: + """Record the exact child limits.""" + + self.applied = (resource_name, limits) + + finite = FakeResource(2048) + monkeypatch.setattr(bounded.os, "name", "posix") + monkeypatch.setattr(bounded, "_resource", finite) + bounded.bounded_file_preexec(4096)() + assert finite.applied == (finite.RLIMIT_FSIZE, (2048, 2048)) + + unlimited = FakeResource(FakeResource.RLIM_INFINITY) + monkeypatch.setattr(bounded, "_resource", unlimited) + bounded.bounded_file_preexec(4096)() + assert unlimited.applied == (unlimited.RLIMIT_FSIZE, (4097, 4097)) + + +def test_file_limit_classification_uses_size_signal_and_safe_false_path( + tmp_path: Path, +) -> None: + """Overflow classification remains deterministic across child behaviors.""" + + output_path = tmp_path / "output.log" + output_path.write_bytes(b"x" * 4097) + assert bounded.file_limit_reached(output_path, 4096, 0) + + output_path.write_bytes(b"x" * 10) + assert bounded.file_limit_reached( + output_path, + 4096, + -int(signal.SIGXFSZ), + ) + assert not bounded.file_limit_reached(output_path, 4096, 0) + + +def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: + """The reusable runner validates execution controls before creating children.""" + + with pytest.raises(ValueError, match="command"): + bounded.run_bounded_command( + [], + cwd=tmp_path, + env=_environment(), + timeout=10, + evidence_limit_bytes=4096, + ) + with pytest.raises(ValueError, match="timeout"): + bounded.run_bounded_command( + [sys.executable, "-c", "pass"], + cwd=tmp_path, + env=_environment(), + timeout=0, + evidence_limit_bytes=4096, + ) From f6f0a077d81e0bb66a8600342ec7b745cc4148e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:42:14 +0900 Subject: [PATCH 27/93] feat(ci): bound child output with POSIX file limits --- scripts/ci/bounded_subprocess.py | 276 +++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 scripts/ci/bounded_subprocess.py diff --git a/scripts/ci/bounded_subprocess.py b/scripts/ci/bounded_subprocess.py new file mode 100644 index 000000000..dba335f0b --- /dev/null +++ b/scripts/ci/bounded_subprocess.py @@ -0,0 +1,276 @@ +"""Run POSIX child processes with kernel-enforced bounded output files.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import tempfile +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +try: + import resource as _resource_module +except ImportError: # pragma: no cover - exercised by injected unsupported state + _resource_module = None + + +_resource: ModuleType | None = _resource_module + +OUTPUT_LIMIT_EXIT_CODE = 123 +DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES = 1_048_576 +DEFAULT_SERVICE_LOG_LIMIT_BYTES = 4_194_304 +MAXIMUM_OUTPUT_LIMIT_BYTES = 67_108_864 +MINIMUM_OUTPUT_LIMIT_BYTES = 4_096 +TRUNCATION_MARKER = "...[output truncated]...\n" + + +class OutputLimitUnsupportedError(RuntimeError): + """Report that the operating system cannot enforce child file-size limits.""" + + +@dataclass(frozen=True) +class BoundedText: + """One bounded decoded file suffix and its original stored byte size.""" + + text: str + truncated: bool + stored_bytes: int + + +@dataclass(frozen=True) +class BoundedCompletedProcess: + """A completed child result whose output was bounded before decoding.""" + + args: tuple[str, ...] + returncode: int + stdout: str + stderr: str + output_limited: bool + + +class BoundedTimeoutExpired(subprocess.TimeoutExpired): + """A subprocess timeout carrying only bounded stdout and stderr evidence.""" + + def __init__( + self, + command: Sequence[str], + timeout: int | float, + *, + stdout: str, + stderr: str, + output_limited: bool, + ) -> None: + """Create timeout evidence with stable text stream attributes.""" + + super().__init__(tuple(command), timeout, output=stdout, stderr=stderr) + self.output_limited = output_limited + + +def validate_output_limit(value: object, label: str) -> int: + """Return one configured output budget inside the supported safety range.""" + + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < MINIMUM_OUTPUT_LIMIT_BYTES + or value > MAXIMUM_OUTPUT_LIMIT_BYTES + ): + raise ValueError( + f"{label} must be an integer from {MINIMUM_OUTPUT_LIMIT_BYTES} " + f"through {MAXIMUM_OUTPUT_LIMIT_BYTES}" + ) + return value + + +def _validate_read_limit(value: object) -> int: + """Return one positive bounded suffix-read size.""" + + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value <= 0 + or value > MAXIMUM_OUTPUT_LIMIT_BYTES + ): + raise ValueError( + "maximum_bytes must be a positive integer no greater than " + f"{MAXIMUM_OUTPUT_LIMIT_BYTES}" + ) + return value + + +def _require_resource_module() -> ModuleType: + """Return the POSIX resource module or fail before child execution.""" + + if ( + os.name != "posix" + or _resource is None + or not hasattr(_resource, "RLIMIT_FSIZE") + or not hasattr(_resource, "RLIM_INFINITY") + ): + raise OutputLimitUnsupportedError( + "POSIX RLIMIT_FSIZE support is required for bounded child output" + ) + return _resource + + +def bounded_file_preexec(evidence_limit_bytes: int) -> Callable[[], None]: + """Return a child-only callable that lowers the maximum writable file size.""" + + evidence_limit = validate_output_limit( + evidence_limit_bytes, + "evidence output limit", + ) + resource_module = _require_resource_module() + kernel_limit = evidence_limit + 1 + + def apply_limit() -> None: + """Lower the child soft and hard file-size limits without raising either.""" + + _soft_limit, hard_limit = resource_module.getrlimit( + resource_module.RLIMIT_FSIZE + ) + target_limit = ( + kernel_limit + if hard_limit == resource_module.RLIM_INFINITY + else min(kernel_limit, hard_limit) + ) + resource_module.setrlimit( + resource_module.RLIMIT_FSIZE, + (target_limit, target_limit), + ) + + return apply_limit + + +def read_bounded_suffix(path: Path, maximum_bytes: int) -> BoundedText: + """Read at most the final byte budget from one regular capture file.""" + + read_limit = _validate_read_limit(maximum_bytes) + stored_bytes = path.stat().st_size + truncated = stored_bytes > read_limit + with path.open("rb") as captured_file: + if truncated: + captured_file.seek(stored_bytes - read_limit) + data = captured_file.read(read_limit) + decoded = data.decode("utf-8", errors="replace") + text = f"{TRUNCATION_MARKER}{decoded}" if truncated else decoded + return BoundedText( + text=text, + truncated=truncated, + stored_bytes=stored_bytes, + ) + + +def file_limit_reached( + path: Path, + evidence_limit_bytes: int, + return_code: int | None, +) -> bool: + """Return whether file size or SIGXFSZ proves an attempted output overflow.""" + + evidence_limit = validate_output_limit( + evidence_limit_bytes, + "evidence output limit", + ) + file_exceeded = path.stat().st_size > evidence_limit + file_size_signal = getattr(signal, "SIGXFSZ", None) + signal_exceeded = ( + file_size_signal is not None + and return_code == -int(file_size_signal) + ) + return file_exceeded or signal_exceeded + + +def _normalized_command(arguments: Sequence[object]) -> tuple[str, ...]: + """Return one non-empty immutable structured command.""" + + command = tuple(str(argument) for argument in arguments) + if not command or not command[0]: + raise ValueError("command must contain one executable") + return command + + +def _validated_timeout(timeout: object) -> int | float: + """Return one positive numeric subprocess timeout.""" + + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or timeout <= 0 + ): + raise ValueError("timeout must be a positive number") + return timeout + + +def run_bounded_command( + arguments: Sequence[object], + *, + cwd: Path, + env: Mapping[str, str], + timeout: int | float, + evidence_limit_bytes: int, +) -> BoundedCompletedProcess: + """Run a structured command with bounded private stdout and stderr files.""" + + command = _normalized_command(arguments) + timeout_seconds = _validated_timeout(timeout) + evidence_limit = validate_output_limit( + evidence_limit_bytes, + "evidence output limit", + ) + preexec_function = bounded_file_preexec(evidence_limit) + + with tempfile.TemporaryDirectory(prefix="bounded-subprocess-") as capture_root: + capture_directory = Path(capture_root) + stdout_path = capture_directory / "stdout.log" + stderr_path = capture_directory / "stderr.log" + completed: subprocess.CompletedProcess[bytes] | None = None + timeout_error: subprocess.TimeoutExpired | None = None + with stdout_path.open("wb") as stdout_file, stderr_path.open("wb") as stderr_file: + try: + completed = subprocess.run( + list(command), + cwd=cwd, + env=dict(env), + stdout=stdout_file, + stderr=stderr_file, + timeout=timeout_seconds, + check=False, + shell=False, + preexec_fn=preexec_function, + ) + except subprocess.TimeoutExpired as error: + timeout_error = error + + return_code = completed.returncode if completed is not None else None + stdout = read_bounded_suffix(stdout_path, evidence_limit) + stderr = read_bounded_suffix(stderr_path, evidence_limit) + output_limited = file_limit_reached( + stdout_path, + evidence_limit, + return_code, + ) or file_limit_reached( + stderr_path, + evidence_limit, + return_code, + ) + if timeout_error is not None: + raise BoundedTimeoutExpired( + command, + timeout_seconds, + stdout=stdout.text, + stderr=stderr.text, + output_limited=output_limited, + ) from timeout_error + if completed is None: # pragma: no cover - defensive subprocess invariant + raise RuntimeError("subprocess returned neither completion nor timeout") + return BoundedCompletedProcess( + args=command, + returncode=completed.returncode, + stdout=stdout.text, + stderr=stderr.text, + output_limited=output_limited, + ) From d4065bf322279fae97a3de85518116270d65e3ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:42:55 +0900 Subject: [PATCH 28/93] fix(redaction): scrub echoed separate secret options --- scripts/ci/redact_sensitive_log.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index a4fc57fc1..624273f34 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -17,9 +17,14 @@ r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", re.IGNORECASE, ) -SENSITIVE_OPTION_RE = re.compile( +SENSITIVE_OPTION_PATTERN = ( r"(?:token|secret|password|passwd|credential|authorization|jwt|" - r"api[-_]?key|private[-_]?key|access[-_]?key|session[-_]?key)", + r"api[-_]?key|private[-_]?key|access[-_]?key|session[-_]?key)" +) +SENSITIVE_OPTION_RE = re.compile(SENSITIVE_OPTION_PATTERN, re.IGNORECASE) +SEPARATE_SENSITIVE_OPTION_RE = re.compile( + rf"(?P(?(?!--)(?:\"[^\"]*\"|'[^']*'|[^\s,}]+))", re.IGNORECASE, ) JWT_RE = re.compile( @@ -130,6 +135,10 @@ def _redact_assignments(text: str) -> str: def _redact_unstructured(text: str) -> str: """Redact credential-shaped values from non-JSON diagnostic text.""" cleaned = _redact_assignments(text) + cleaned = SEPARATE_SENSITIVE_OPTION_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", + cleaned, + ) cleaned = BEARER_RE.sub(lambda match: f"{match.group('prefix')}{REDACTED}", cleaned) cleaned = JWT_RE.sub(REDACTED, cleaned) for pattern in PROVIDER_TOKEN_RES: From 4553a5a06fd062c42e724b1061e8c9914c35a113 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:43:17 +0900 Subject: [PATCH 29/93] test(ci): require bounded sandbox verification output --- tests/test_sandboxed_verify_output_limits.py | 179 +++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/test_sandboxed_verify_output_limits.py diff --git a/tests/test_sandboxed_verify_output_limits.py b/tests/test_sandboxed_verify_output_limits.py new file mode 100644 index 000000000..3624ddd45 --- /dev/null +++ b/tests/test_sandboxed_verify_output_limits.py @@ -0,0 +1,179 @@ +"""Real-command contracts for sandboxed verification output ceilings.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest + +from scripts.ci import bounded_subprocess as bounded +from scripts.ci import sandboxed_verify + + +def _result_payload(output: str) -> dict[str, object]: + """Parse the final sandbox result marker from captured standard output.""" + + marker = f"{sandboxed_verify.RESULT_MARKER} " + result_line = next( + line for line in reversed(output.splitlines()) if line.startswith(marker) + ) + return json.loads(result_line.removeprefix(marker)) + + +def _repository(tmp_path: Path) -> Path: + """Create one minimal repository directory accepted by the copy boundary.""" + + repository = tmp_path / "repository" + repository.mkdir() + (repository / "README.md").write_text("sandbox fixture\n", encoding="utf-8") + return repository + + +def test_normal_command_preserves_output_and_reports_declared_limit( + tmp_path: Path, + capsys, +) -> None: + """Ordinary Unicode output remains visible with deterministic limit evidence.""" + + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--output-limit-bytes", + "4096", + "--", + sys.executable, + "-c", + "import sys; print('정상'); print('경고', file=sys.stderr)", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == 0 + assert "정상" in captured.out + assert "경고" in captured.err + assert payload["output_limit_bytes"] == 4096 + assert payload["output_limited"] is False + + +@pytest.mark.parametrize("descriptor", [1, 2]) +def test_excessive_stdout_or_stderr_returns_resource_limit_code( + tmp_path: Path, + capsys, + descriptor: int, +) -> None: + """A real output flood is bounded, redacted, and classified as exit 123.""" + + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--output-limit-bytes", + "4096", + "--", + sys.executable, + "-c", + ( + "import os\n" + f"descriptor={descriptor}\n" + "chunk=b'x'*1024\n" + "while True:\n" + " os.write(descriptor, chunk)\n" + ), + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + combined = captured.out + captured.err + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert bounded.TRUNCATION_MARKER.strip() in combined + assert "output exceeded 4096 bytes" in captured.err + assert payload["output_limited"] is True + assert len(combined.encode("utf-8")) < 20_000 + + +def test_timeout_retains_precedence_and_bounded_partial_output( + tmp_path: Path, + capsys, +) -> None: + """A timeout remains exit 124 while its partial output stays byte-bounded.""" + + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--timeout", + "1", + "--output-limit-bytes", + "4096", + "--", + sys.executable, + "-c", + "import os,time; os.write(1,b'before\\n'); time.sleep(30)", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == 124 + assert "before" in captured.out + assert "timed out after 1s" in captured.err + assert payload["output_limited"] is False + + +def test_unsupported_resource_limit_fails_closed( + monkeypatch, + tmp_path: Path, + capsys, +) -> None: + """The wrapper never falls back to unbounded pipes on unsupported platforms.""" + + def fail_run(*args, **kwargs): + del args, kwargs + raise bounded.OutputLimitUnsupportedError("unsupported") + + monkeypatch.setattr(sandboxed_verify, "run_command", fail_run) + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--output-limit-bytes", + "4096", + "--", + sys.executable, + "-c", + "print('never runs')", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert "bounded child output is unavailable" in captured.err + assert payload["output_limited"] is True + + +def test_cli_rejects_output_budgets_outside_supported_range( + tmp_path: Path, +) -> None: + """Unsafe output budgets fail argument parsing before workspace execution.""" + + repository = _repository(tmp_path) + for value in ["4095", str(bounded.MAXIMUM_OUTPUT_LIMIT_BYTES + 1)]: + with pytest.raises(SystemExit) as raised: + sandboxed_verify.parse_args( + [ + "--repo-root", + str(repository), + "--output-limit-bytes", + value, + "--", + os.devnull, + ] + ) + assert raised.value.code == 2 From 43d57007b46c97c65250df1eb3280956a8727b4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:44:14 +0900 Subject: [PATCH 30/93] fix(ci): bound sandbox verification output --- scripts/ci/sandboxed_verify.py | 69 ++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index 683c07256..a81313484 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -18,6 +18,7 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.ci import bounded_subprocess from scripts.ci.redact_sensitive_log import ( redact_command_arguments, redact_text, @@ -78,6 +79,12 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) parser.add_argument("--repo-root", default=".", help="Repository root to copy into the sandbox.") parser.add_argument("--timeout", type=int, default=300, help="Command timeout in seconds.") + parser.add_argument( + "--output-limit-bytes", + type=int, + default=bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + help="Maximum retained stdout and stderr bytes per stream.", + ) parser.add_argument( "--keep-sandbox", action="store_true", @@ -115,6 +122,13 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.error("provide a verification command after --") if args.timeout <= 0: parser.error("--timeout must be positive") + try: + args.output_limit_bytes = bounded_subprocess.validate_output_limit( + args.output_limit_bytes, + "--output-limit-bytes", + ) + except ValueError as error: + parser.error(str(error)) for name in args.allow_env: if not ENV_NAME_RE.match(name): parser.error(f"--allow-env must be an environment variable name: {name}") @@ -158,18 +172,21 @@ def copy_workspace(repo_root: Path, sandbox_root: Path, extra_ignores: Sequence[ return destination -def run_command(command: Sequence[str], cwd: Path, env: dict[str, str], timeout: int) -> subprocess.CompletedProcess[str]: - """Run the verification command and capture output for review evidence.""" - return subprocess.run( - list(command), +def run_command( + command: Sequence[str], + cwd: Path, + env: dict[str, str], + timeout: int, + output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, +) -> bounded_subprocess.BoundedCompletedProcess: + """Run one verification command with kernel-enforced bounded output files.""" + + return bounded_subprocess.run_bounded_command( + command, cwd=cwd, env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, timeout=timeout, - check=False, - shell=False, + evidence_limit_bytes=output_limit_bytes, ) @@ -193,6 +210,8 @@ def emit_result( allowed_env: Sequence[str], network: str, evidence_note: str, + output_limit_bytes: int, + output_limited: bool, ) -> None: """Print a machine-readable execution evidence summary without secrets.""" payload = { @@ -203,6 +222,8 @@ def emit_result( "evidence_note": redact_text(evidence_note), "exit_code": exit_code, "network": network, + "output_limit_bytes": output_limit_bytes, + "output_limited": output_limited, "sandbox": str(sandbox_root) if kept else "(removed)", "sandboxed": True, } @@ -215,6 +236,7 @@ def main(argv: Sequence[str] | None = None) -> int: sandbox = Path(tempfile.mkdtemp(prefix="sandboxed-verify-")) start = time.monotonic() exit_code = 1 + output_limited = False copied_repo = sandbox / "repo" try: copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore) @@ -227,12 +249,34 @@ def main(argv: Sequence[str] | None = None) -> int: if args.network != "default": print(f"sandboxed-verify: network={args.network}") try: - completed = run_command(args.command, copied_repo, env, args.timeout) + completed = run_command( + args.command, + copied_repo, + env, + args.timeout, + args.output_limit_bytes, + ) if completed.stdout: print(redact_text(completed.stdout), end="") if completed.stderr: print(redact_text(completed.stderr), end="", file=sys.stderr) - exit_code = completed.returncode + output_limited = bool(getattr(completed, "output_limited", False)) + if output_limited: + print( + "sandboxed-verify: command output exceeded " + f"{args.output_limit_bytes} bytes", + file=sys.stderr, + ) + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE + else: + exit_code = completed.returncode + except bounded_subprocess.OutputLimitUnsupportedError: + output_limited = True + print( + "sandboxed-verify: bounded child output is unavailable on this platform", + file=sys.stderr, + ) + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE except subprocess.TimeoutExpired as exc: stdout = timeout_output_text(exc.stdout) stderr = timeout_output_text(exc.stderr) @@ -240,6 +284,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr: print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) + output_limited = bool(getattr(exc, "output_limited", False)) print(f"sandboxed-verify: command timed out after {args.timeout}s", file=sys.stderr) exit_code = 124 return exit_code @@ -255,6 +300,8 @@ def main(argv: Sequence[str] | None = None) -> int: allowed_env=args.allow_env, network=args.network, evidence_note=args.evidence_note, + output_limit_bytes=args.output_limit_bytes, + output_limited=output_limited, ) if not args.keep_sandbox: shutil.rmtree(sandbox, ignore_errors=True) From b23fc23ec67bfb98fcc9b7b8326a10f44ddb2e3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:45:42 +0900 Subject: [PATCH 31/93] test(ci): require bounded service and E2E output --- tests/test_sandboxed_web_e2e_output_limits.py | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/test_sandboxed_web_e2e_output_limits.py diff --git a/tests/test_sandboxed_web_e2e_output_limits.py b/tests/test_sandboxed_web_e2e_output_limits.py new file mode 100644 index 000000000..8f000d9ef --- /dev/null +++ b/tests/test_sandboxed_web_e2e_output_limits.py @@ -0,0 +1,295 @@ +"""Real-process contracts for bounded sandbox web E2E output.""" + +from __future__ import annotations + +import json +import shlex +import shutil +import sys +from pathlib import Path + +import pytest + +from scripts.ci import bounded_subprocess as bounded +from scripts.ci import sandboxed_web_e2e + + +def _command(source: str) -> str: + """Return one shell-style command that safely launches the current Python.""" + + return shlex.join([sys.executable, "-c", source]) + + +def _repository(tmp_path: Path) -> Path: + """Create one minimal repository accepted by the sandbox copy boundary.""" + + repository = tmp_path / "repository" + repository.mkdir() + (repository / "README.md").write_text("web E2E fixture\n", encoding="utf-8") + return repository + + +def _result_payload(output: str) -> dict[str, object]: + """Parse the final machine-readable web E2E result marker.""" + + marker = f"{sandboxed_web_e2e.RESULT_MARKER} " + line = next( + item for item in reversed(output.splitlines()) if item.startswith(marker) + ) + return json.loads(line.removeprefix(marker)) + + +def test_start_service_enforces_real_log_file_ceiling(tmp_path: Path) -> None: + """A long-running child cannot grow its combined service log past the ceiling.""" + + logs_directory = tmp_path / "logs" + logs_directory.mkdir() + service = sandboxed_web_e2e.start_service( + "backend", + _command( + "import os\n" + "chunk=b'x'*1024\n" + "while True:\n" + " os.write(1,chunk)\n" + ), + tmp_path, + {"PATH": ""}, + logs_directory, + 4096, + ) + try: + service.process.wait(timeout=10) + assert service.log_path.stat().st_size <= 4097 + assert sandboxed_web_e2e.service_output_limited(service) + finally: + sandboxed_web_e2e.stop_service(service) + + +def test_service_log_overflow_returns_resource_limit_before_e2e( + tmp_path: Path, + capsys, +) -> None: + """Readiness cannot convert a backend log flood into an ordinary E2E run.""" + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command( + "import os\n" + "chunk=b'x'*1024\n" + "while True:\n" + " os.write(1,chunk)\n" + ), + "--frontend-cmd", + _command("import time; time.sleep(30)"), + "--e2e-cmd", + _command("raise SystemExit('must not run')"), + "--service-log-limit-bytes", + "4096", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert "service output exceeded 4096 bytes" in captured.err + assert payload["output_limited"] is True + assert payload["service_log_limit_bytes"] == 4096 + + +def test_e2e_output_overflow_is_bounded_and_returns_123( + tmp_path: Path, + capsys, +) -> None: + """The short-lived E2E command uses the same kernel-enforced output boundary.""" + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command("import time; time.sleep(30)"), + "--frontend-cmd", + _command("import time; time.sleep(30)"), + "--e2e-cmd", + _command( + "import os\n" + "chunk=b'y'*1024\n" + "while True:\n" + " os.write(2,chunk)\n" + ), + "--output-limit-bytes", + "4096", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert bounded.TRUNCATION_MARKER.strip() in captured.err + assert "E2E output exceeded 4096 bytes" in captured.err + assert payload["output_limit_bytes"] == 4096 + assert payload["output_limited"] is True + assert len((captured.out + captured.err).encode("utf-8")) < 25_000 + + +def test_normal_services_and_e2e_preserve_existing_success_contract( + tmp_path: Path, + capsys, +) -> None: + """Ordinary services, Unicode output, cleanup, and evidence remain unchanged.""" + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command("import time; print('backend-ready', flush=True); time.sleep(30)"), + "--frontend-cmd", + _command("import time; print('frontend-ready', flush=True); time.sleep(30)"), + "--e2e-cmd", + _command("print('통합 성공')"), + "--output-limit-bytes", + "4096", + "--service-log-limit-bytes", + "4096", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == 0 + assert "통합 성공" in captured.out + assert "backend-ready" in captured.out + assert "frontend-ready" in captured.out + assert payload["output_limited"] is False + assert payload["output_limit_bytes"] == 4096 + assert payload["service_log_limit_bytes"] == 4096 + + +def test_tail_text_uses_bounded_suffix_and_tolerates_partial_utf8( + monkeypatch, + tmp_path: Path, +) -> None: + """Service evidence delegates to a byte-bounded suffix before line selection.""" + + log_path = tmp_path / "service.log" + log_path.write_bytes(b"ignored" + "가".encode("utf-8")) + observed: dict[str, object] = {} + + def fake_suffix(path: Path, maximum_bytes: int) -> bounded.BoundedText: + observed["path"] = path + observed["maximum_bytes"] = maximum_bytes + return bounded.BoundedText( + text=f"{bounded.TRUNCATION_MARKER}�\nlast-line\n", + truncated=True, + stored_bytes=10_000, + ) + + monkeypatch.setattr(bounded, "read_bounded_suffix", fake_suffix) + + tail = sandboxed_web_e2e.tail_text( + log_path, + max_lines=2, + max_bytes=4096, + ) + + assert observed == {"path": log_path, "maximum_bytes": 4096} + assert tail == "�\nlast-line" + + +def test_unsupported_resource_boundary_fails_closed( + monkeypatch, + tmp_path: Path, + capsys, +) -> None: + """Service startup cannot silently continue without file-size enforcement.""" + + def fail_start(*args, **kwargs): + del args, kwargs + raise bounded.OutputLimitUnsupportedError("unsupported") + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fail_start) + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command("pass"), + "--frontend-cmd", + _command("pass"), + "--e2e-cmd", + _command("pass"), + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert "bounded child output is unavailable" in captured.err + assert payload["output_limited"] is True + + +def test_cli_rejects_unsafe_command_and_service_budgets(tmp_path: Path) -> None: + """Both output budgets fail parsing outside the explicit safe range.""" + + repository = _repository(tmp_path) + base = [ + "--repo-root", + str(repository), + "--backend-cmd", + _command("pass"), + "--frontend-cmd", + _command("pass"), + "--e2e-cmd", + _command("pass"), + ] + for option, value in [ + ("--output-limit-bytes", "4095"), + ( + "--service-log-limit-bytes", + str(bounded.MAXIMUM_OUTPUT_LIMIT_BYTES + 1), + ), + ]: + with pytest.raises(SystemExit) as raised: + sandboxed_web_e2e.parse_args([*base, option, value]) + assert raised.value.code == 2 + + +def test_kept_sandbox_service_file_never_exceeds_kernel_ceiling( + tmp_path: Path, + capsys, +) -> None: + """Persisted debugging sandboxes retain only the bounded service artifact.""" + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command( + "import os\n" + "chunk=b'z'*1024\n" + "while True:\n" + " os.write(1,chunk)\n" + ), + "--frontend-cmd", + _command("import time; time.sleep(30)"), + "--e2e-cmd", + _command("pass"), + "--service-log-limit-bytes", + "4096", + "--keep-sandbox", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + sandbox_path = Path(str(payload["sandbox"])) + + try: + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert (sandbox_path / "logs" / "backend.log").stat().st_size <= 4097 + finally: + shutil.rmtree(sandbox_path, ignore_errors=True) From 8461b7da87b4b55dd918be8f181ceb286b689318 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:50:46 +0900 Subject: [PATCH 32/93] test(ci): require bounded pipe draining without child file limits --- tests/test_bounded_subprocess.py | 121 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 56 deletions(-) diff --git a/tests/test_bounded_subprocess.py b/tests/test_bounded_subprocess.py index 0bb079e8d..d0015bcd6 100644 --- a/tests/test_bounded_subprocess.py +++ b/tests/test_bounded_subprocess.py @@ -2,8 +2,8 @@ from __future__ import annotations +import io import os -import signal import sys from pathlib import Path @@ -37,7 +37,30 @@ def test_read_bounded_suffix_preserves_unicode_and_marks_partial_suffix( assert partial.stored_bytes == len(partial_path.read_bytes()) assert partial.text.startswith(bounded.TRUNCATION_MARKER) assert "�" in partial.text - assert len(partial.text.removeprefix(bounded.TRUNCATION_MARKER).encode("utf-8")) <= 6 + + +def test_bounded_capture_retains_final_suffix_and_writes_bounded_file( + tmp_path: Path, +) -> None: + """The stream drainer retains only a bounded final suffix for evidence.""" + + destination = tmp_path / "captured.log" + limit_calls: list[str] = [] + capture = bounded.start_bounded_capture( + io.BytesIO(b"prefix-" + b"x" * 5000 + b"-final"), + evidence_limit_bytes=4096, + on_limit=lambda: limit_calls.append("limited"), + destination=destination, + ) + capture.join(timeout=5) + + assert capture.output_limited is True + assert capture.total_bytes == 5013 + assert limit_calls == ["limited"] + assert capture.text.startswith(bounded.TRUNCATION_MARKER) + assert capture.text.endswith("-final") + assert destination.stat().st_size <= 4096 + assert destination.read_text(encoding="utf-8").endswith("-final") def test_run_bounded_command_preserves_ordinary_unicode_output(tmp_path: Path) -> None: @@ -67,7 +90,7 @@ def test_run_bounded_command_caps_real_stdout_and_stderr( tmp_path: Path, stream_descriptor: int, ) -> None: - """A child cannot create a captured stream beyond budget plus one byte.""" + """A real output flood is killed while the retained stream stays bounded.""" result = bounded.run_bounded_command( [ @@ -90,9 +113,7 @@ def test_run_bounded_command_caps_real_stdout_and_stderr( selected = result.stdout if stream_descriptor == 1 else result.stderr assert result.output_limited is True assert selected.startswith(bounded.TRUNCATION_MARKER) - assert len(selected.encode("utf-8")) <= 4096 + len( - bounded.TRUNCATION_MARKER.encode("utf-8") - ) + assert len(selected.encode("utf-8")) <= 4096 assert result.returncode != 0 @@ -145,75 +166,63 @@ def test_validate_output_limit_rejects_unsafe_values() -> None: bounded.validate_output_limit(value, "test limit") # type: ignore[arg-type] -def test_preexec_fails_closed_without_posix_resource_support(monkeypatch) -> None: - """Unsupported platforms cannot silently fall back to unbounded capture.""" +def test_supported_platform_gate_fails_closed(monkeypatch) -> None: + """Unsupported platforms cannot silently fall back to unmanaged children.""" monkeypatch.setattr(bounded.os, "name", "nt") with pytest.raises(bounded.OutputLimitUnsupportedError): - bounded.bounded_file_preexec(4096) + bounded.require_supported_platform() monkeypatch.setattr(bounded.os, "name", "posix") - monkeypatch.setattr(bounded, "_resource", None) - with pytest.raises(bounded.OutputLimitUnsupportedError): - bounded.bounded_file_preexec(4096) + bounded.require_supported_platform() -def test_preexec_respects_a_lower_existing_hard_limit(monkeypatch) -> None: - """The child limit is lowered but an existing hard limit is never raised.""" +def test_capture_surfaces_reader_failure_and_join_timeout( + monkeypatch, +) -> None: + """Reader failures and stuck drains are explicit rather than silently ignored.""" - class FakeResource: - """Record the limit selected by the child pre-exec closure.""" + class FailingStream: + """Raise one deterministic error from the background reader.""" - RLIMIT_FSIZE = 1 - RLIM_INFINITY = -1 + def read(self, size: int) -> bytes: + """Reject the read request.""" - def __init__(self, hard_limit: int) -> None: - self.hard_limit = hard_limit - self.applied: tuple[int, tuple[int, int]] | None = None + del size + raise OSError("read failed") - def getrlimit(self, resource_name: int) -> tuple[int, int]: - """Return the configured finite hard limit.""" + def close(self) -> None: + """Provide the binary-stream close interface.""" - assert resource_name == self.RLIMIT_FSIZE - return (self.hard_limit, self.hard_limit) + capture = bounded.start_bounded_capture( + FailingStream(), # type: ignore[arg-type] + evidence_limit_bytes=4096, + on_limit=lambda: None, + ) + with pytest.raises(OSError, match="read failed"): + capture.join(timeout=5) - def setrlimit( - self, - resource_name: int, - limits: tuple[int, int], - ) -> None: - """Record the exact child limits.""" + class NeverFinishesThread: + """Simulate one drain thread that remains alive after join.""" - self.applied = (resource_name, limits) + def join(self, timeout: float | None = None) -> None: + """Accept the join call without completing.""" - finite = FakeResource(2048) - monkeypatch.setattr(bounded.os, "name", "posix") - monkeypatch.setattr(bounded, "_resource", finite) - bounded.bounded_file_preexec(4096)() - assert finite.applied == (finite.RLIMIT_FSIZE, (2048, 2048)) + del timeout - unlimited = FakeResource(FakeResource.RLIM_INFINITY) - monkeypatch.setattr(bounded, "_resource", unlimited) - bounded.bounded_file_preexec(4096)() - assert unlimited.applied == (unlimited.RLIMIT_FSIZE, (4097, 4097)) + def is_alive(self) -> bool: + """Report a stuck reader.""" + return True -def test_file_limit_classification_uses_size_signal_and_safe_false_path( - tmp_path: Path, -) -> None: - """Overflow classification remains deterministic across child behaviors.""" - - output_path = tmp_path / "output.log" - output_path.write_bytes(b"x" * 4097) - assert bounded.file_limit_reached(output_path, 4096, 0) - - output_path.write_bytes(b"x" * 10) - assert bounded.file_limit_reached( - output_path, - 4096, - -int(signal.SIGXFSZ), + capture = bounded.BoundedOutputCapture( + io.BytesIO(b"safe"), + evidence_limit_bytes=4096, + on_limit=lambda: None, ) - assert not bounded.file_limit_reached(output_path, 4096, 0) + monkeypatch.setattr(capture, "_thread", NeverFinishesThread()) + with pytest.raises(RuntimeError, match="did not finish"): + capture.join(timeout=0) def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: From 23efe55189eeca0a691395b0aac5120b16f1c72f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:52:49 +0900 Subject: [PATCH 33/93] fix(ci): drain bounded subprocess pipes without limiting child files --- scripts/ci/bounded_subprocess.py | 337 ++++++++++++++++++++----------- 1 file changed, 221 insertions(+), 116 deletions(-) diff --git a/scripts/ci/bounded_subprocess.py b/scripts/ci/bounded_subprocess.py index dba335f0b..581b2a81a 100644 --- a/scripts/ci/bounded_subprocess.py +++ b/scripts/ci/bounded_subprocess.py @@ -1,34 +1,28 @@ -"""Run POSIX child processes with kernel-enforced bounded output files.""" +"""Run POSIX child processes with continuously drained bounded output pipes.""" from __future__ import annotations import os import signal import subprocess -import tempfile +import threading from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from types import ModuleType +from typing import BinaryIO -try: - import resource as _resource_module -except ImportError: # pragma: no cover - exercised by injected unsupported state - _resource_module = None - - -_resource: ModuleType | None = _resource_module OUTPUT_LIMIT_EXIT_CODE = 123 DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES = 1_048_576 DEFAULT_SERVICE_LOG_LIMIT_BYTES = 4_194_304 MAXIMUM_OUTPUT_LIMIT_BYTES = 67_108_864 MINIMUM_OUTPUT_LIMIT_BYTES = 4_096 +READ_CHUNK_BYTES = 65_536 TRUNCATION_MARKER = "...[output truncated]...\n" class OutputLimitUnsupportedError(RuntimeError): - """Report that the operating system cannot enforce child file-size limits.""" + """Report that the operating system cannot isolate a child process group.""" @dataclass(frozen=True) @@ -42,7 +36,7 @@ class BoundedText: @dataclass(frozen=True) class BoundedCompletedProcess: - """A completed child result whose output was bounded before decoding.""" + """A completed child result whose output was drained into bounded buffers.""" args: tuple[str, ...] returncode: int @@ -101,52 +95,162 @@ def _validate_read_limit(value: object) -> int: return value -def _require_resource_module() -> ModuleType: - """Return the POSIX resource module or fail before child execution.""" +def require_supported_platform() -> None: + """Fail before execution when process-group termination is unavailable.""" - if ( - os.name != "posix" - or _resource is None - or not hasattr(_resource, "RLIMIT_FSIZE") - or not hasattr(_resource, "RLIM_INFINITY") - ): + if os.name != "posix" or not hasattr(os, "killpg"): raise OutputLimitUnsupportedError( - "POSIX RLIMIT_FSIZE support is required for bounded child output" + "POSIX process-group support is required for bounded child output" ) - return _resource -def bounded_file_preexec(evidence_limit_bytes: int) -> Callable[[], None]: - """Return a child-only callable that lowers the maximum writable file size.""" +def _render_bounded_bytes(buffer: bytes, limit: int, truncated: bool) -> bytes: + """Return evidence bytes no larger than the configured stream budget.""" - evidence_limit = validate_output_limit( - evidence_limit_bytes, - "evidence output limit", - ) - resource_module = _require_resource_module() - kernel_limit = evidence_limit + 1 + if not truncated: + return buffer + marker = TRUNCATION_MARKER.encode("utf-8") + suffix_budget = max(0, limit - len(marker)) + suffix = buffer[-suffix_budget:] if suffix_budget else b"" + return marker + suffix - def apply_limit() -> None: - """Lower the child soft and hard file-size limits without raising either.""" - _soft_limit, hard_limit = resource_module.getrlimit( - resource_module.RLIMIT_FSIZE - ) - target_limit = ( - kernel_limit - if hard_limit == resource_module.RLIM_INFINITY - else min(kernel_limit, hard_limit) +class BoundedOutputCapture: + """Continuously drain one binary pipe into a bounded final-suffix buffer.""" + + def __init__( + self, + stream: BinaryIO, + *, + evidence_limit_bytes: int, + on_limit: Callable[[], None], + destination: Path | None = None, + ) -> None: + """Start one background drain with an optional bounded evidence file.""" + + self._stream = stream + self._limit = validate_output_limit( + evidence_limit_bytes, + "evidence output limit", ) - resource_module.setrlimit( - resource_module.RLIMIT_FSIZE, - (target_limit, target_limit), + self._on_limit = on_limit + self._destination = destination + self._buffer = bytearray() + self._total_bytes = 0 + self._output_limited = False + self._error: BaseException | None = None + self._lock = threading.Lock() + self._thread = threading.Thread( + target=self._drain, + name="bounded-output-drain", + daemon=True, ) + self._thread.start() + + @property + def output_limited(self) -> bool: + """Return whether this stream exceeded its configured byte budget.""" + + with self._lock: + return self._output_limited + + @property + def total_bytes(self) -> int: + """Return the complete byte count observed while draining the stream.""" + + with self._lock: + return self._total_bytes + + @property + def text(self) -> str: + """Return the bounded final suffix decoded with replacement semantics.""" + + with self._lock: + evidence = _render_bounded_bytes( + bytes(self._buffer), + self._limit, + self._output_limited, + ) + return evidence.decode("utf-8", errors="replace") + + def _append(self, chunk: bytes) -> bool: + """Append one chunk and report the first transition into limited state.""" + + should_notify = False + with self._lock: + self._total_bytes += len(chunk) + self._buffer.extend(chunk) + overflow = len(self._buffer) - self._limit + if overflow > 0: + del self._buffer[:overflow] + if self._total_bytes > self._limit and not self._output_limited: + self._output_limited = True + should_notify = True + return should_notify + + def _write_destination(self) -> None: + """Write at most the configured evidence budget to the destination file.""" + + if self._destination is None: + return + self._destination.parent.mkdir(parents=True, exist_ok=True) + with self._lock: + evidence = _render_bounded_bytes( + bytes(self._buffer), + self._limit, + self._output_limited, + ) + self._destination.write_bytes(evidence) + + def _drain(self) -> None: + """Drain until EOF, killing the child once on the first byte overflow.""" + + try: + while True: + chunk = self._stream.read(READ_CHUNK_BYTES) + if not chunk: + break + if self._append(chunk): + self._on_limit() + except BaseException as error: # noqa: BLE001 - propagated by join() + self._error = error + finally: + try: + self._stream.close() + self._write_destination() + except BaseException as error: # noqa: BLE001 - propagated by join() + if self._error is None: + self._error = error + + def join(self, timeout: float | None = None) -> None: + """Wait for EOF and re-raise any background capture failure.""" - return apply_limit + self._thread.join(timeout) + if self._thread.is_alive(): + raise RuntimeError("bounded output drain did not finish") + if self._error is not None: + raise self._error + + +def start_bounded_capture( + stream: BinaryIO, + *, + evidence_limit_bytes: int, + on_limit: Callable[[], None], + destination: Path | None = None, +) -> BoundedOutputCapture: + """Start one bounded background drain for a binary subprocess stream.""" + + return BoundedOutputCapture( + stream, + evidence_limit_bytes=evidence_limit_bytes, + on_limit=on_limit, + destination=destination, + ) def read_bounded_suffix(path: Path, maximum_bytes: int) -> BoundedText: - """Read at most the final byte budget from one regular capture file.""" + """Read at most the final byte budget from one regular evidence file.""" read_limit = _validate_read_limit(maximum_bytes) stored_bytes = path.stat().st_size @@ -164,26 +268,6 @@ def read_bounded_suffix(path: Path, maximum_bytes: int) -> BoundedText: ) -def file_limit_reached( - path: Path, - evidence_limit_bytes: int, - return_code: int | None, -) -> bool: - """Return whether file size or SIGXFSZ proves an attempted output overflow.""" - - evidence_limit = validate_output_limit( - evidence_limit_bytes, - "evidence output limit", - ) - file_exceeded = path.stat().st_size > evidence_limit - file_size_signal = getattr(signal, "SIGXFSZ", None) - signal_exceeded = ( - file_size_signal is not None - and return_code == -int(file_size_signal) - ) - return file_exceeded or signal_exceeded - - def _normalized_command(arguments: Sequence[object]) -> tuple[str, ...]: """Return one non-empty immutable structured command.""" @@ -205,6 +289,17 @@ def _validated_timeout(timeout: object) -> int | float: return timeout +def kill_process_group(process: subprocess.Popen[bytes]) -> None: + """Kill one isolated POSIX child process group exactly when still running.""" + + if process.poll() is not None: + return + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + + def run_bounded_command( arguments: Sequence[object], *, @@ -213,64 +308,74 @@ def run_bounded_command( timeout: int | float, evidence_limit_bytes: int, ) -> BoundedCompletedProcess: - """Run a structured command with bounded private stdout and stderr files.""" + """Run a structured command while continuously draining bounded pipe suffixes.""" + require_supported_platform() command = _normalized_command(arguments) timeout_seconds = _validated_timeout(timeout) evidence_limit = validate_output_limit( evidence_limit_bytes, "evidence output limit", ) - preexec_function = bounded_file_preexec(evidence_limit) - - with tempfile.TemporaryDirectory(prefix="bounded-subprocess-") as capture_root: - capture_directory = Path(capture_root) - stdout_path = capture_directory / "stdout.log" - stderr_path = capture_directory / "stderr.log" - completed: subprocess.CompletedProcess[bytes] | None = None - timeout_error: subprocess.TimeoutExpired | None = None - with stdout_path.open("wb") as stdout_file, stderr_path.open("wb") as stderr_file: - try: - completed = subprocess.run( - list(command), - cwd=cwd, - env=dict(env), - stdout=stdout_file, - stderr=stderr_file, - timeout=timeout_seconds, - check=False, - shell=False, - preexec_fn=preexec_function, - ) - except subprocess.TimeoutExpired as error: - timeout_error = error - - return_code = completed.returncode if completed is not None else None - stdout = read_bounded_suffix(stdout_path, evidence_limit) - stderr = read_bounded_suffix(stderr_path, evidence_limit) - output_limited = file_limit_reached( - stdout_path, - evidence_limit, - return_code, - ) or file_limit_reached( - stderr_path, - evidence_limit, - return_code, - ) - if timeout_error is not None: - raise BoundedTimeoutExpired( - command, - timeout_seconds, - stdout=stdout.text, - stderr=stderr.text, - output_limited=output_limited, - ) from timeout_error - if completed is None: # pragma: no cover - defensive subprocess invariant - raise RuntimeError("subprocess returned neither completion nor timeout") - return BoundedCompletedProcess( - args=command, - returncode=completed.returncode, - stdout=stdout.text, - stderr=stderr.text, + process = subprocess.Popen( + list(command), + cwd=cwd, + env=dict(env), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + shell=False, + start_new_session=True, + ) + if process.stdout is None or process.stderr is None: + kill_process_group(process) + process.wait() + raise RuntimeError("subprocess pipes were not created") + + limit_triggered = threading.Event() + + def stop_for_limit() -> None: + """Kill the process group only for the first overflowing stream.""" + + if not limit_triggered.is_set(): + limit_triggered.set() + kill_process_group(process) + + stdout_capture = start_bounded_capture( + process.stdout, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + stderr_capture = start_bounded_capture( + process.stderr, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + timed_out = False + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + timed_out = True + kill_process_group(process) + process.wait() + + stdout_capture.join() + stderr_capture.join() + output_limited = ( + stdout_capture.output_limited or stderr_capture.output_limited + ) + if timed_out: + raise BoundedTimeoutExpired( + command, + timeout_seconds, + stdout=stdout_capture.text, + stderr=stderr_capture.text, output_limited=output_limited, ) + return BoundedCompletedProcess( + args=command, + returncode=process.returncode, + stdout=stdout_capture.text, + stderr=stderr_capture.text, + output_limited=output_limited, + ) From 6f84d7ff739568d0f4094f951520b6bc590e7e69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:54:18 +0900 Subject: [PATCH 34/93] fix(ci): bound sandbox service and E2E output --- scripts/ci/sandboxed_web_e2e.py | 327 +++++++++++++++++++++++++------- 1 file changed, 260 insertions(+), 67 deletions(-) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 5dc0d61d6..4c9bd34d3 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -21,11 +21,12 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from scripts.ci import sandboxed_verify +from scripts.ci import bounded_subprocess, sandboxed_verify from scripts.ci.redact_sensitive_log import redact_shell_command, redact_text RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT" +DEFAULT_TAIL_BYTES = 65_536 class NoRedirectHandler(urllib.request.HTTPRedirectHandler): @@ -38,12 +39,14 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): @dataclass class Service: - """A long-running web service process and its log file.""" + """A long-running web service process and its bounded combined log capture.""" label: str command: str - process: subprocess.Popen[str] + process: subprocess.Popen[bytes] log_path: Path + capture: bounded_subprocess.BoundedOutputCapture | None = None + log_limit_bytes: int = bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: @@ -63,6 +66,18 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--frontend-ready-url", default="", help="Frontend readiness URL to poll before E2E.") parser.add_argument("--startup-timeout", type=int, default=120, help="Seconds to wait for readiness URLs.") parser.add_argument("--e2e-timeout", type=int, default=600, help="Seconds to allow the E2E command to run.") + parser.add_argument( + "--output-limit-bytes", + type=int, + default=bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + help="Maximum retained stdout and stderr bytes for the E2E command.", + ) + parser.add_argument( + "--service-log-limit-bytes", + type=int, + default=bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, + help="Maximum retained combined log bytes for each long-running service.", + ) parser.add_argument("--keep-sandbox", action="store_true", help="Keep the temporary sandbox after execution.") parser.add_argument( "--allow-env", @@ -93,32 +108,82 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.error("--startup-timeout must be positive") if args.e2e_timeout <= 0: parser.error("--e2e-timeout must be positive") + try: + args.output_limit_bytes = bounded_subprocess.validate_output_limit( + args.output_limit_bytes, + "--output-limit-bytes", + ) + args.service_log_limit_bytes = bounded_subprocess.validate_output_limit( + args.service_log_limit_bytes, + "--service-log-limit-bytes", + ) + except ValueError as error: + parser.error(str(error)) for name in args.allow_env: if not sandboxed_verify.ENV_NAME_RE.match(name): parser.error(f"--allow-env must be an environment variable name: {name}") return args -def start_service(label: str, command: str, cwd: Path, env: dict[str, str], logs_dir: Path) -> Service: - """Start a service command in its own process group.""" +def start_service( + label: str, + command: str, + cwd: Path, + env: dict[str, str], + logs_dir: Path, + log_limit_bytes: int = bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, +) -> Service: + """Start one service group and continuously drain its combined bounded log.""" + + bounded_subprocess.require_supported_platform() + log_limit = bounded_subprocess.validate_output_limit( + log_limit_bytes, + "service log limit", + ) log_path = logs_dir / f"{label}.log" - log_file = log_path.open("w", encoding="utf-8") process = subprocess.Popen( shlex.split(command), cwd=cwd, env=env, - text=True, - stdout=log_file, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + bufsize=0, start_new_session=True, shell=False, ) - log_file.close() - return Service(label=label, command=command, process=process, log_path=log_path) + if process.stdout is None: + bounded_subprocess.kill_process_group(process) + process.wait() + raise RuntimeError("service output pipe was not created") + capture = bounded_subprocess.start_bounded_capture( + process.stdout, + evidence_limit_bytes=log_limit, + on_limit=lambda: bounded_subprocess.kill_process_group(process), + destination=log_path, + ) + return Service( + label=label, + command=command, + process=process, + log_path=log_path, + capture=capture, + log_limit_bytes=log_limit, + ) + + +def service_output_limited(service: Service) -> bool: + """Return whether one service exceeded its declared combined log budget.""" + + if service.capture is not None: + return service.capture.output_limited + return ( + service.log_path.exists() + and service.log_path.stat().st_size > service.log_limit_bytes + ) def wait_for_url(url: str, timeout: int, service: Service) -> bool: - """Poll a readiness URL until it responds or the service exits.""" + """Poll a readiness URL until it responds, exits, or exceeds its log budget.""" if not url: return True if not (url.startswith("http://") or url.startswith("https://")): @@ -126,7 +191,7 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: deadline = time.monotonic() + timeout opener = urllib.request.build_opener(NoRedirectHandler()) while time.monotonic() < deadline: - if service.process.poll() is not None: + if service_output_limited(service) or service.process.poll() is not None: return False try: with opener.open(url, timeout=2) as response: # nosec B310 @@ -137,41 +202,51 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: return False -def run_shell(command: str, cwd: Path, env: dict[str, str], timeout: int) -> subprocess.CompletedProcess[str]: - """Run a shell-style command without invoking a shell and capture output.""" - return subprocess.run( +def run_shell( + command: str, + cwd: Path, + env: dict[str, str], + timeout: int, + output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, +) -> bounded_subprocess.BoundedCompletedProcess: + """Run one shell-style command without a shell and with bounded pipe drains.""" + + return bounded_subprocess.run_bounded_command( shlex.split(command), cwd=cwd, env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, timeout=timeout, - check=False, - shell=False, + evidence_limit_bytes=output_limit_bytes, ) def stop_service(service: Service) -> None: - """Terminate a service process group and wait briefly for cleanup.""" - if service.process.poll() is not None: - return - try: - os.killpg(service.process.pid, signal.SIGTERM) - service.process.wait(timeout=10) - except (ProcessLookupError, subprocess.TimeoutExpired): + """Terminate a service process group and finalize its bounded log evidence.""" + if service.process.poll() is None: try: - os.killpg(service.process.pid, signal.SIGKILL) + os.killpg(service.process.pid, signal.SIGTERM) + service.process.wait(timeout=10) except ProcessLookupError: - return - service.process.wait(timeout=10) + pass + except subprocess.TimeoutExpired: + bounded_subprocess.kill_process_group(service.process) + service.process.wait(timeout=10) + if service.capture is not None: + service.capture.join(timeout=10) -def tail_text(path: Path, max_lines: int = 80) -> str: - """Return redacted final lines of a service log.""" +def tail_text( + path: Path, + max_lines: int = 80, + max_bytes: int = DEFAULT_TAIL_BYTES, +) -> str: + """Return redacted final lines from one byte-bounded service evidence file.""" if not path.exists(): return "" - lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + if max_lines <= 0: + raise ValueError("max_lines must be positive") + bounded_text = bounded_subprocess.read_bounded_suffix(path, max_bytes) + lines = bounded_text.text.splitlines() return redact_text("\n".join(lines[-max_lines:])) @@ -184,6 +259,7 @@ def emit_result( frontend_ready: bool, exit_code: int, elapsed_seconds: float, + output_limited: bool, ) -> None: """Print machine-readable web E2E evidence without credential values.""" payload = { @@ -198,14 +274,23 @@ def emit_result( "frontend_cmd": redact_shell_command(args.frontend_cmd), "frontend_ready": frontend_ready, "network": args.network, + "output_limit_bytes": args.output_limit_bytes, + "output_limited": output_limited, "sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)", "sandboxed": True, + "service_log_limit_bytes": args.service_log_limit_bytes, } print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}") +def _services_output_limited(services: Sequence[Service]) -> bool: + """Return whether any started service exceeded its combined log budget.""" + + return any(service_output_limited(service) for service in services) + + def main(argv: Sequence[str] | None = None) -> int: - """Run backend, frontend, and E2E commands inside a sandbox copy.""" + """Run backend, frontend, and E2E commands inside a bounded sandbox copy.""" args = parse_args(argv) sandbox = Path(tempfile.mkdtemp(prefix="sandboxed-web-e2e-")) copied_repo = sandbox / "repo" @@ -215,44 +300,150 @@ def main(argv: Sequence[str] | None = None) -> int: backend_ready = False frontend_ready = False exit_code = 1 + output_limited = False + service_limit_reported = False start = time.monotonic() try: - copied_repo = sandboxed_verify.copy_workspace(Path(args.repo_root), sandbox, args.ignore) - env = sandboxed_verify.scrubbed_env(sandbox, args.allow_env) - print(f"sandboxed-web-e2e: cwd={copied_repo}") - if args.allow_env: - print(f"sandboxed-web-e2e: allowed env names={','.join(sorted(set(args.allow_env)))}") - if args.network != "default": - print(f"sandboxed-web-e2e: network={args.network}") - services.append(start_service("backend", args.backend_cmd, copied_repo, env, logs_dir)) - services.append(start_service("frontend", args.frontend_cmd, copied_repo, env, logs_dir)) - backend_ready = wait_for_url(args.backend_ready_url, args.startup_timeout, services[0]) - frontend_ready = wait_for_url(args.frontend_ready_url, args.startup_timeout, services[1]) - if not backend_ready or not frontend_ready: - print("sandboxed-web-e2e: service readiness failed", file=sys.stderr) - exit_code = 125 - return exit_code try: - completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout) - if completed.stdout: - print(redact_text(completed.stdout), end="") - if completed.stderr: - print(redact_text(completed.stderr), end="", file=sys.stderr) - exit_code = completed.returncode - return exit_code - except subprocess.TimeoutExpired as exc: - stdout = sandboxed_verify.timeout_output_text(exc.stdout) - stderr = sandboxed_verify.timeout_output_text(exc.stderr) - if stdout: - print(stdout, end="" if stdout.endswith("\n") else "\n") - if stderr: - print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) - print(f"sandboxed-web-e2e: e2e command timed out after {args.e2e_timeout}s", file=sys.stderr) - exit_code = 124 - return exit_code + copied_repo = sandboxed_verify.copy_workspace( + Path(args.repo_root), + sandbox, + args.ignore, + ) + env = sandboxed_verify.scrubbed_env(sandbox, args.allow_env) + print(f"sandboxed-web-e2e: cwd={copied_repo}") + if args.allow_env: + print( + "sandboxed-web-e2e: allowed env names=" + f"{','.join(sorted(set(args.allow_env)))}" + ) + if args.network != "default": + print(f"sandboxed-web-e2e: network={args.network}") + services.append( + start_service( + "backend", + args.backend_cmd, + copied_repo, + env, + logs_dir, + args.service_log_limit_bytes, + ) + ) + services.append( + start_service( + "frontend", + args.frontend_cmd, + copied_repo, + env, + logs_dir, + args.service_log_limit_bytes, + ) + ) + backend_ready = wait_for_url( + args.backend_ready_url, + args.startup_timeout, + services[0], + ) + frontend_ready = wait_for_url( + args.frontend_ready_url, + args.startup_timeout, + services[1], + ) + if _services_output_limited(services): + output_limited = True + service_limit_reported = True + print( + "sandboxed-web-e2e: service output exceeded " + f"{args.service_log_limit_bytes} bytes", + file=sys.stderr, + ) + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE + elif not backend_ready or not frontend_ready: + print( + "sandboxed-web-e2e: service readiness failed", + file=sys.stderr, + ) + exit_code = 125 + else: + try: + completed = run_shell( + args.e2e_cmd, + copied_repo, + env, + args.e2e_timeout, + args.output_limit_bytes, + ) + if completed.stdout: + print(redact_text(completed.stdout), end="") + if completed.stderr: + print( + redact_text(completed.stderr), + end="", + file=sys.stderr, + ) + output_limited = completed.output_limited + if output_limited: + print( + "sandboxed-web-e2e: E2E output exceeded " + f"{args.output_limit_bytes} bytes", + file=sys.stderr, + ) + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE + else: + exit_code = completed.returncode + except subprocess.TimeoutExpired as exc: + stdout = sandboxed_verify.timeout_output_text(exc.stdout) + stderr = sandboxed_verify.timeout_output_text(exc.stderr) + if stdout: + print( + stdout, + end="" if stdout.endswith("\n") else "\n", + ) + if stderr: + print( + stderr, + end="" if stderr.endswith("\n") else "\n", + file=sys.stderr, + ) + output_limited = bool( + getattr(exc, "output_limited", False) + ) + print( + "sandboxed-web-e2e: e2e command timed out after " + f"{args.e2e_timeout}s", + file=sys.stderr, + ) + exit_code = 124 + except bounded_subprocess.OutputLimitUnsupportedError: + output_limited = True + print( + "sandboxed-web-e2e: bounded child output is unavailable on this platform", + file=sys.stderr, + ) + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE finally: for service in reversed(services): - stop_service(service) + try: + stop_service(service) + except (OSError, RuntimeError, subprocess.SubprocessError): + output_limited = True + if exit_code != 124: + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE + print( + "sandboxed-web-e2e: bounded service capture failed", + file=sys.stderr, + ) + if _services_output_limited(services): + output_limited = True + if exit_code != 124: + exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE + if not service_limit_reported: + print( + "sandboxed-web-e2e: service output exceeded " + f"{args.service_log_limit_bytes} bytes", + file=sys.stderr, + ) + for service in reversed(services): log_tail = tail_text(service.log_path) if log_tail: print(f"--- {service.label} log tail ---") @@ -265,9 +456,11 @@ def main(argv: Sequence[str] | None = None) -> int: frontend_ready=frontend_ready, exit_code=exit_code, elapsed_seconds=time.monotonic() - start, + output_limited=output_limited, ) if not args.keep_sandbox: shutil.rmtree(sandbox, ignore_errors=True) + return exit_code if __name__ == "__main__": From 93d2f005f837e968f19edd44b7517cdcb430afa9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:54:58 +0900 Subject: [PATCH 35/93] test(ci): adapt redaction evidence to bounded output wrappers --- tests/test_sandboxed_output_redaction.py | 33 +++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/test_sandboxed_output_redaction.py b/tests/test_sandboxed_output_redaction.py index bab3956e7..f2f05fa06 100644 --- a/tests/test_sandboxed_output_redaction.py +++ b/tests/test_sandboxed_output_redaction.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import cast -from scripts.ci import sandboxed_verify, sandboxed_web_e2e +from scripts.ci import bounded_subprocess, sandboxed_verify, sandboxed_web_e2e from scripts.ci.redact_sensitive_log import ( REDACTED, redact_command_arguments, @@ -85,7 +85,14 @@ def test_sandboxed_verify_redacts_completed_output_command_and_note( repository = tmp_path / "repository" repository.mkdir() - def fake_run_command(command, cwd, env, timeout): + def fake_run_command( + command, + cwd, + env, + timeout, + output_limit_bytes=bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + ): + del cwd, env, timeout, output_limit_bytes return subprocess.CompletedProcess( command, 0, @@ -139,19 +146,33 @@ def test_sandboxed_web_e2e_redacts_commands_output_and_service_logs( repository = tmp_path / "repository" repository.mkdir() - def fake_start_service(label, command, cwd, env, logs_dir): + def fake_start_service( + label, + command, + cwd, + env, + logs_dir, + log_limit_bytes=bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, + ): del cwd, env log_path = logs_dir / f"{label}.log" log_path.write_text(f"secret={token}\n", encoding="utf-8") return sandboxed_web_e2e.Service( label=label, command=command, - process=cast(subprocess.Popen[str], _DoneProcess()), + process=cast(subprocess.Popen[bytes], _DoneProcess()), log_path=log_path, + log_limit_bytes=log_limit_bytes, ) - def fake_run_shell(command, cwd, env, timeout): - del cwd, env, timeout + def fake_run_shell( + command, + cwd, + env, + timeout, + output_limit_bytes=bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + ): + del cwd, env, timeout, output_limit_bytes return subprocess.CompletedProcess( command, 0, From 72b65e3c56ecda501331f9f417e7d2654b1106a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:57:23 +0900 Subject: [PATCH 36/93] docs: correct bounded output design to pipe draining --- ...sandboxed-output-resource-bounds-design.md | 102 +++++++++--------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md b/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md index fdbb0c4a6..53df5c530 100644 --- a/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md +++ b/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md @@ -2,92 +2,98 @@ ## Status -Approved for autonomous implementation under issue #766. This slice is stacked after PR #764 so it can reuse the complete evidence-redaction boundary without changing reviewer identities or credentials. +Approved for autonomous implementation under issue #766. This slice is stacked after PR #764 so it reuses the complete evidence-redaction boundary without changing reviewer identities or credentials. ## Problem -The central verification wrappers currently capture short-lived child stdout and stderr through pipes and let long-running services write ordinary log files. Redaction happens only after those streams have already been buffered or persisted. A defective or adversarial repository process can therefore consume runner memory or disk before its output reaches the redaction boundary. The current service-tail helper also reads the complete log before selecting the final lines. +The central verification wrappers currently capture short-lived child stdout and stderr through pipes that are later consumed as complete values, while long-running services write unbounded log files. Redaction happens only after those streams have already been buffered or persisted. A defective or adversarial repository process can therefore consume runner memory or disk before its output reaches the redaction boundary. The current service-tail helper also reads the complete log before selecting the final lines. + +## Rejected approach: process-wide file-size limits + +POSIX `RLIMIT_FSIZE` limits every regular file created by a process, not only stdout and stderr. Applying it to a repository test or build command would incorrectly cap coverage databases, compiled assets, archives, temporary databases, and other legitimate artifacts. The output boundary must constrain evidence streams without changing application file semantics. ## Decision -Use the operating system's POSIX file-size resource limit to bound every child-created output file before execution: +Continuously drain child pipes on dedicated background threads into fixed-size final-suffix buffers: -- redirect each short-lived command stream to a private regular file rather than `PIPE`; -- apply `resource.setrlimit(resource.RLIMIT_FSIZE, ...)` in the single-threaded child pre-exec boundary; -- set the kernel ceiling one byte above the evidence budget so the parent can distinguish exact-sized normal output from an attempted overflow; -- read at most the configured final suffix from each file; -- map any attempted overflow to one stable resource-limit exit code; -- run backend and frontend services with the same kernel-enforced file ceiling; and -- seek from the end of service logs, reading only a bounded byte suffix before applying the existing final-line and redaction rules. +- every short-lived command receives structured stdout and stderr pipes; +- one reader thread per stream drains fixed-size chunks so the OS pipe cannot fill and deadlock the child; +- each reader retains at most the configured final byte suffix; +- the first stream overflow atomically marks the result and kills the isolated POSIX process group; +- backend and frontend combined output uses the same bounded drainer and writes only the rendered bounded suffix to its evidence file; and +- service-tail publication seeks from the end of the already bounded file rather than reading the complete file. -The wrappers fail closed on platforms where POSIX resource limits are unavailable. They do not silently revert to unbounded capture. +The wrapper fails closed on platforms without POSIX process-group termination. It never falls back to `communicate()` or an unbounded file. ## Architecture ### `scripts/ci/bounded_subprocess.py` -A focused reusable module owns child output limits. It provides: +A focused reusable module owns the evidence-stream boundary: -- `BoundedCompletedProcess`: immutable command result with bounded text streams and an `output_limited` flag; -- `BoundedTimeoutExpired`: timeout evidence carrying only bounded text; -- `bounded_file_preexec(limit_bytes)`: a child-only callable that lowers `RLIMIT_FSIZE` without raising an existing hard limit; -- `run_bounded_command(...)`: structured-argv, `shell=False` execution into two private files; -- `read_bounded_suffix(path, maximum_bytes)`: suffix-only binary read with UTF-8 replacement and a stable truncation marker; -- `file_limit_reached(path, evidence_limit_bytes, return_code)`: exact overflow classification; and -- numeric configuration validation with explicit minimum and maximum values. +- `BoundedOutputCapture` continuously drains one binary stream into a locked final-suffix byte buffer; +- `BoundedCompletedProcess` exposes immutable bounded text, return code, and the output-limit flag; +- `BoundedTimeoutExpired` carries only bounded text evidence; +- `start_bounded_capture()` supports both short-lived streams and a bounded destination file; +- `run_bounded_command()` launches structured argv with `shell=False`, `start_new_session=True`, two independent drainers, timeout handling, and process-group termination; +- `kill_process_group()` kills only a still-running isolated POSIX group; +- `read_bounded_suffix()` performs a seek-from-end file read with replacement decoding; and +- numeric configuration validation rejects Boolean, undersized, oversized, or noninteger budgets. -The module imports `resource` only on POSIX and raises one stable unsupported-platform error otherwise. +The rendered truncated form includes one stable marker and still occupies no more than the declared stream budget. -### `sandboxed_verify.py` +### `scripts/ci/sandboxed_verify.py` -The existing `run_command` facade delegates to `run_bounded_command`. A new optional `--output-limit-bytes` argument defaults to 1 MiB per stream. Normal output and exit codes remain unchanged. Timeout remains exit code 124. Attempted output overflow emits bounded redacted evidence and returns exit code 123. +A new `--output-limit-bytes` option defaults to 1 MiB per stream. The existing `run_command` facade delegates to the bounded runner. Ordinary output and exit codes remain unchanged. Timeout remains 124. An attempted output overflow or unsupported platform returns 123. Result evidence adds the declared budget and a Boolean output-limit field. -### `sandboxed_web_e2e.py` +### `scripts/ci/sandboxed_web_e2e.py` -A new `--output-limit-bytes` controls the short-lived E2E command. A separate `--service-log-limit-bytes` defaults to 4 MiB per service. `start_service` applies the kernel ceiling before exec. Readiness or E2E completion checks classify service log overflow and return exit code 123. `tail_text` reads no more than 64 KiB from the end of the file, then retains at most 80 final lines and redacts them. +A new `--output-limit-bytes` controls the E2E command and `--service-log-limit-bytes` defaults to 4 MiB per backend/frontend service. `start_service()` uses a combined pipe and bounded capture. Overflow terminates that service group. `stop_service()` finalizes its bounded evidence file. `tail_text()` reads at most 64 KiB from the end, retains at most 80 final lines, and applies the existing redaction boundary. ## Data flow 1. Parse and validate byte budgets before copying or running repository content. -2. Create private capture files inside the isolated sandbox. -3. Spawn the child with structured argv, scrubbed environment, `shell=False`, and a lowered `RLIMIT_FSIZE`. -4. Wait for completion or timeout. -5. Read only bounded suffixes, close and delete capture files, then redact before publication. -6. Classify timeout, ordinary exit, or output limit in that order. -7. Emit the existing machine-readable result schema plus declared limit evidence. +2. Launch each child in a new POSIX session with structured argv and a scrubbed environment. +3. Start reader threads immediately and drain fixed 64 KiB chunks. +4. Retain only the final configured bytes under a lock. +5. On the first excess byte, mark the stream and kill the child process group exactly once. +6. On completion or timeout, join both readers and decode only bounded evidence. +7. Redact the evidence before printing or JSON serialization. +8. For services, persist only the bounded rendered suffix and later read only a bounded tail. -No credential value, unbounded stream, or PR-controlled path enters a public evidence sink. +No credential value, complete oversized stream, or process-wide application-file restriction enters a public evidence sink. ## Failure semantics - Invalid byte budgets fail argument parsing before execution. -- Unsupported resource-limit platforms fail closed with stable exit code 123 and a credential-free message. +- Unsupported process-group platforms fail closed with exit code 123 and a credential-free message. - Timeout remains 124, even when bounded partial output exists. -- Output overflow is 123 and cannot be converted to success by the child catching `SIGXFSZ` because file size greater than the evidence budget independently proves an attempted excess. +- Output overflow is 123 and cannot be converted into child success. - An ordinary nonzero child exit remains unchanged when no stream exceeded its budget. -- Service readiness failure remains 125 unless a service log exceeded its budget, in which case the more specific 123 result wins. -- Cleanup and result emission run for every path. +- Service readiness failure remains 125 unless a more specific service output limit occurred. +- Reader failures and reader-join timeouts are explicit errors rather than silently discarded evidence. +- Cleanup and result emission run for every wrapper path. ## Verification -Real child-process tests must prove: +Real child-process tests prove: - ordinary Unicode stdout/stderr remain intact within the budget; -- stdout and stderr attempts above the limit cannot produce files larger than budget plus one byte; -- overflow returns 123 with bounded redacted evidence; -- timeout evidence is bounded and returns 124; -- service log overflow stops readiness and returns 123; -- suffix reading never calls an unbounded `read()` and tolerates a partial UTF-8 code point; -- non-POSIX or missing-`RLIMIT_FSIZE` environments fail closed; -- lower pre-existing hard limits are respected; -- CLI minima/maxima and new result fields are deterministic; and -- all existing environment, copy, cleanup, redaction, SSRF, and process-group tests remain green. +- stdout and stderr floods are drained, bounded, and terminate with nonzero output-limit evidence; +- timeout evidence is bounded and remains exit 124; +- final-suffix retention preserves the last diagnostic bytes; +- service log floods terminate the service and persist no more than the declared evidence budget; +- suffix reading tolerates a partial UTF-8 code point and never requests an unbounded read; +- non-POSIX environments fail closed; +- reader exceptions and stuck-reader joins surface explicitly; +- CLI minima/maxima and result fields are deterministic; and +- existing environment, copy, cleanup, redaction, SSRF, and process-group tests remain green. Every changed production helper requires a docstring and 100% statement/branch coverage. ## Standards and evidence boundary -Python 3.14 documents `resource.setrlimit()` as the resource-consumption control and `RLIMIT_FSIZE` as the maximum file size a process may create. Python's subprocess documentation states that `PIPE` captures child streams through `Popen`/`communicate`, whereas existing file descriptors may be supplied directly. CWE-770 recommends explicit resource ceilings and operating-system resource limiting. NIST SP 800-218 supplies the secure-development framework for preventing and verifying these failure modes. +Python documents that `PIPE` creates child stream pipes and that callers may manage `Popen` streams directly rather than asking `communicate()` to accumulate complete output. Structured argv and `shell=False` avoid shell interpretation. POSIX process groups provide one termination boundary for the child and its descendants. CWE-770 recommends explicit resource ceilings and throttling, and NIST SP 800-218 supplies the secure-development framework for preventing and verifying resource-exhaustion failures. This design does not claim cross-platform equivalence. It deliberately supports the Linux/POSIX GitHub runner boundary and fails closed elsewhere. @@ -96,6 +102,6 @@ This design does not claim cross-platform equivalence. It deliberately supports - changing scheduler cadence or scheduled review agents; - changing OpenCode, Noema, Strix, NVIDIA NIM, or reviewer credentials; - limiting repository workspace-copy size in this slice; -- limiting child CPU, address space, process count, or network traffic; -- replacing the existing output-redaction policy; +- limiting child CPU, address space, process count, application artifact size, or network traffic; +- replacing the existing output-redaction policy; or - retaining complete oversized logs as downloadable artifacts. From a978b20a158a767dc51d202016eca9d447c9693f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:58:14 +0900 Subject: [PATCH 37/93] docs: correct bounded output plan to continuous pipe drains --- ...-08-05-sandboxed-output-resource-bounds.md | 99 +++++++++---------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md b/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md index dc31b0493..0bb3b8598 100644 --- a/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md +++ b/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md @@ -2,18 +2,19 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Bound memory and disk consumed by sandbox child output before redaction while retaining useful, credential-free diagnostic suffixes. +**Goal:** Bound memory and retained disk consumed by sandbox child output before redaction while retaining useful, credential-free final diagnostic suffixes. -**Architecture:** Redirect child streams to private regular files and lower POSIX `RLIMIT_FSIZE` in the child pre-exec boundary. A reusable bounded-subprocess module classifies ordinary completion, timeout, and output overflow; both sandbox wrappers expose validated byte budgets and preserve their existing result contracts. +**Architecture:** Launch structured commands in isolated POSIX process groups and continuously drain stdout/stderr pipes on dedicated threads into fixed-size final-suffix buffers. Kill the process group on first overflow; persist only bounded service evidence and read only bounded file suffixes. -**Tech Stack:** Python 3.10+, POSIX `resource`, `subprocess`, `tempfile`, `pathlib`, pytest, pytest-cov, interrogate. +**Tech Stack:** Python 3.10+, `subprocess.Popen`, POSIX process groups, `threading`, `pathlib`, pytest, pytest-cov, interrogate. ## Global Constraints - Stack after PR #764 and preserve its complete output-redaction boundary. - Keep structured argument vectors and `shell=False`. +- Do not apply process-wide `RLIMIT_FSIZE`; repository commands must retain ordinary application/build file semantics. - Do not change OpenCode, Noema, Strix, NVIDIA NIM, reviewer identities, or credential names/scopes. -- Fail closed when POSIX file-size limits are unavailable. +- Fail closed when POSIX process-group termination is unavailable. - Timeout exit code remains 124; service readiness remains 125; output resource limit is 123. - Default short-command budget is 1,048,576 bytes per stream. - Default long-running service-log budget is 4,194,304 bytes per service. @@ -24,32 +25,32 @@ --- -### Task 1: Define failing bounded-subprocess contracts +### Task 1: Define failing bounded-stream contracts **Files:** - Create: `tests/test_bounded_subprocess.py` **Interfaces:** - Consumes: wished-for `scripts.ci.bounded_subprocess` -- Produces: exact public API and resource-limit semantics +- Produces: exact public API and bounded drain semantics - [ ] **Step 1: Write ordinary-output and suffix tests** -Use real private files containing Unicode and a partial UTF-8 leading byte. Assert that `read_bounded_suffix(path, maximum_bytes)` reads only the final budget, adds one truncation marker when needed, and uses replacement decoding rather than failing. +Use real private files containing Unicode and a partial UTF-8 code point. Assert that suffix reads are byte-bounded and replacement-decoded. -- [ ] **Step 2: Write real child overflow tests** +- [ ] **Step 2: Write real stdout/stderr flood tests** -Launch `sys.executable -c` children that repeatedly call `os.write()` on stdout and stderr. Assert that each capture file is no larger than evidence budget plus one byte, `output_limited` is true, and the returned text is bounded. +Launch `sys.executable -c` children that repeatedly call `os.write()`. Assert bounded final evidence, an output-limit flag, process-group termination, and no pipe deadlock. - [ ] **Step 3: Write timeout and ordinary exit tests** -Assert normal Unicode output and return codes are preserved. Assert timeout raises `BoundedTimeoutExpired` with bounded stdout/stderr. +Assert normal Unicode output and return codes are preserved. Assert timeout raises bounded text evidence. -- [ ] **Step 4: Write platform and configuration tests** +- [ ] **Step 4: Write capture destination and failure tests** -Monkeypatch the platform/resource surface to prove unsupported environments fail closed, smaller existing hard limits are retained, and budgets outside 4 KiB–64 MiB are rejected. +Prove final-suffix retention, bounded destination files, single overflow notification, reader-error propagation, and stuck-reader detection. -- [ ] **Step 5: Run focused tests and verify RED** +- [ ] **Step 5: Run the focused test and verify RED** Run: `python -m pytest tests/test_bounded_subprocess.py -q` @@ -62,7 +63,7 @@ git add tests/test_bounded_subprocess.py git commit -m "test(ci): require bounded sandbox subprocess output" ``` -### Task 2: Implement the reusable POSIX output boundary +### Task 2: Implement the reusable bounded pipe drainer **Files:** - Create: `scripts/ci/bounded_subprocess.py` @@ -74,45 +75,50 @@ git commit -m "test(ci): require bounded sandbox subprocess output" - `DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES = 1_048_576` - `DEFAULT_SERVICE_LOG_LIMIT_BYTES = 4_194_304` - `MAXIMUM_OUTPUT_LIMIT_BYTES = 67_108_864` + - `BoundedText` - `BoundedCompletedProcess` - `BoundedTimeoutExpired` + - `BoundedOutputCapture` - `validate_output_limit(value, label) -> int` - - `bounded_file_preexec(evidence_limit_bytes) -> Callable[[], None]` + - `require_supported_platform() -> None` + - `start_bounded_capture(...) -> BoundedOutputCapture` - `read_bounded_suffix(path, maximum_bytes) -> BoundedText` - - `file_limit_reached(path, evidence_limit_bytes, return_code) -> bool` + - `kill_process_group(process) -> None` - `run_bounded_command(args, cwd, env, timeout, evidence_limit_bytes) -> BoundedCompletedProcess` - [ ] **Step 1: Implement immutable result types and validation** -Keep fields typed and frozen. Reject booleans, nonintegers, values below 4096, and values above 67,108,864. +Reject booleans, nonintegers, values below 4096, and values above 67,108,864. -- [ ] **Step 2: Implement POSIX pre-exec limiting** +- [ ] **Step 2: Implement one bounded stream capture** -Require `os.name == "posix"` and `resource.RLIMIT_FSIZE`. In the child, read the existing hard limit, choose the lower of budget-plus-one and the finite hard limit, then set soft and hard to that target. +Read fixed 64 KiB chunks on a daemon thread, retain only the final declared bytes, call the overflow callback exactly once, and optionally persist a rendered evidence file no larger than the declared budget. -- [ ] **Step 3: Implement bounded suffix reading and overflow classification** +- [ ] **Step 3: Implement process-group termination** -Use binary seek-from-end and never call an unbounded read. Prefix `...[output truncated]...\n` only when the file exceeded the evidence budget. +Require POSIX `killpg`, launch each command with `start_new_session=True`, and kill only a still-running group. -- [ ] **Step 4: Implement short-lived command execution** +- [ ] **Step 4: Implement bounded command execution** -Create two private binary capture files, pass their descriptors to `subprocess.run`, apply the pre-exec function, read bounded suffixes in success and timeout paths, and remove the capture directory in all cases. +Create stdout and stderr pipes, start both drainers immediately, wait or timeout, kill the group when necessary, join both readers, and return or raise only bounded evidence. -- [ ] **Step 5: Run focused tests and verify GREEN** +- [ ] **Step 5: Implement bounded file suffix reads** -Run: `python -m pytest tests/test_bounded_subprocess.py -q` +Seek from the end and never request an unbounded read. Use UTF-8 replacement and a stable truncation marker. + +- [ ] **Step 6: Run focused tests and verify GREEN** -Expected: PASS. +Run: `python -m pytest tests/test_bounded_subprocess.py -q` -- [ ] **Step 6: Run focused coverage and docstrings** +- [ ] **Step 7: Run focused coverage and docstrings** -Run coverage with branch measurement for the new module and interrogate the production file at 100%. +Run branch coverage for the new module and interrogate production at 100%. -- [ ] **Step 7: Commit** +- [ ] **Step 8: Commit** ```bash git add scripts/ci/bounded_subprocess.py tests/test_bounded_subprocess.py -git commit -m "feat(ci): bound child output with POSIX file limits" +git commit -m "feat(ci): bound child output with continuous pipe drains" ``` ### Task 3: Integrate bounded output into sandboxed verification @@ -131,17 +137,13 @@ git commit -m "feat(ci): bound child output with POSIX file limits" Use real child commands to prove ordinary output, stdout overflow, stderr overflow, timeout, redaction, cleanup, result JSON, and stable exit codes. -- [ ] **Step 2: Run focused wrapper tests and verify RED** - -Expected: missing CLI option and unbounded `run_command` behavior. - -- [ ] **Step 3: Implement the minimal wrapper integration** +- [ ] **Step 2: Implement the minimal wrapper integration** Validate the budget during argument parsing, print bounded text through existing redaction, map overflow to 123, and preserve timeout/nonzero behavior. -- [ ] **Step 4: Run focused tests and verify GREEN** +- [ ] **Step 3: Run focused tests and verify GREEN** -- [ ] **Step 5: Commit** +- [ ] **Step 4: Commit** ```bash git add scripts/ci/sandboxed_verify.py tests/test_sandboxed_verify_output_limits.py @@ -159,26 +161,24 @@ git commit -m "fix(ci): bound sandbox verification output" - Adds CLI: - `--output-limit-bytes` - `--service-log-limit-bytes` -- `Service` records its evidence limit. +- `Service` carries its optional bounded capture and declared log limit. - `tail_text(path, max_lines=80, max_bytes=65_536)` performs suffix-only reading. - [ ] **Step 1: Write failing real-service tests** -Start a child that exceeds the service log budget before readiness and assert exit 123, bounded file size, bounded redacted tail, and cleanup. Add a normal service/E2E case and a suffix-read spy that rejects unbounded reads. - -- [ ] **Step 2: Run focused tests and verify RED** +Start children that exceed service and E2E budgets; assert exit 123, bounded evidence files/text, result fields, and cleanup. Add normal Unicode success, unsupported-platform, kept-sandbox, and suffix-delegation cases. -- [ ] **Step 3: Apply resource limits to service and E2E children** +- [ ] **Step 2: Apply bounded drains to service and E2E children** -Use the shared pre-exec boundary for service files and shared command runner for E2E. Check service overflow before assigning readiness/E2E return codes. +Use one combined bounded capture for each service and the shared two-stream runner for E2E. Check service overflow before or after readiness/E2E so it cannot be hidden by a generic status. -- [ ] **Step 4: Replace complete-file tail reads** +- [ ] **Step 3: Replace complete-file tail reads** Seek from the end, decode with replacement, retain the final line count, and redact. -- [ ] **Step 5: Run focused tests and verify GREEN** +- [ ] **Step 4: Run focused tests and verify GREEN** -- [ ] **Step 6: Commit** +- [ ] **Step 5: Commit** ```bash git add scripts/ci/sandboxed_web_e2e.py tests/test_sandboxed_web_e2e_output_limits.py @@ -191,16 +191,13 @@ git commit -m "fix(ci): bound sandbox service and E2E logs" - Create: `docs/doctoring/sandboxed-output-resource-bounds.md` - Modify: `CHANGELOG.md` -**Interfaces:** -- Produces: operator evidence, limitations, rollback, APA 7 references - - [ ] **Step 1: Document the exact resource boundary** -Cover `RLIMIT_FSIZE`, budget-plus-one detection, suffix evidence, exit-code precedence, single-threaded pre-exec assumption, Linux/POSIX support, unsupported-platform failure, and remaining CPU/memory/process/network non-goals. +Cover continuous pipe draining, bounded final-suffix memory, process-group termination, bounded service evidence, exit-code precedence, Linux/POSIX support, rejected process-wide file limits, and remaining CPU/workspace/network non-goals. - [ ] **Step 2: Add APA 7 references** -Cite Python 3.14.6 `resource` and `subprocess`, MITRE CWE-770 4.20, NIST SP 800-218, and the POSIX resource-limit specification. +Cite Python 3.14.6 `subprocess`, MITRE CWE-770 4.20, and NIST SP 800-218. - [ ] **Step 3: Update `CHANGELOG.md`** From f5cd2aacf23d754c6226d7869dffd9a5ee2d85a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:59:21 +0900 Subject: [PATCH 38/93] docs(ci): record bounded sandbox output evidence --- .../sandboxed-output-resource-bounds.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/doctoring/sandboxed-output-resource-bounds.md diff --git a/docs/doctoring/sandboxed-output-resource-bounds.md b/docs/doctoring/sandboxed-output-resource-bounds.md new file mode 100644 index 000000000..b35f9f2f5 --- /dev/null +++ b/docs/doctoring/sandboxed-output-resource-bounds.md @@ -0,0 +1,102 @@ +# Sandboxed subprocess output resource bounds + +## Decision + +The central sandbox wrappers continuously drain child stdout and stderr into fixed-size final-suffix buffers before redaction and publication. A stream that exceeds its declared byte budget terminates the isolated POSIX process group and produces stable exit code `123`. Timeout remains `124`; service-readiness failure remains `125` unless a more specific output limit occurred. + +The default retained budgets are: + +- 1,048,576 bytes for each short-lived command stream; and +- 4,194,304 bytes for each backend or frontend combined service stream. + +Configurations below 4,096 bytes or above 67,108,864 bytes are rejected before repository code executes. + +## Why complete capture was unsafe + +Python's `subprocess.PIPE` creates operating-system pipes for child standard streams. Waiting without concurrently reading can deadlock when a pipe fills, while `communicate()` solves that deadlock by accumulating the complete streams in parent memory. Neither behavior supplies an evidence-size ceiling. Long-running services that write directly to ordinary files similarly consume disk until the process or runner fails, and reading the complete file merely moves that unbounded allocation into parent memory. + +The control plane therefore uses `Popen` directly, starts one reader thread per pipe immediately, reads fixed 64 KiB chunks, and retains only a locked final suffix. The first byte beyond a stream budget marks the result and kills the entire child process group created with `start_new_session=True`. Reader threads continue through EOF and are joined before bounded text is decoded or published. + +## Rejected process-wide file limit + +POSIX file-size resource limits apply to every regular file written by the child process. A repository verification command may legitimately create coverage databases, compiled assets, archives, package artifacts, temporary databases, or generated fixtures larger than its log budget. Applying `RLIMIT_FSIZE` to the child would therefore change application and build behavior rather than only bounding evidence. The implemented boundary constrains stdout/stderr retention and leaves ordinary repository file semantics unchanged. + +## Short-lived command boundary + +`bounded_subprocess.run_bounded_command()`: + +1. validates a structured, nonempty argument vector and positive timeout; +2. requires POSIX process-group termination and launches with `shell=False` and `start_new_session=True`; +3. connects stdout and stderr to independent binary pipes; +4. drains both pipes concurrently into separate bounded final-suffix buffers; +5. kills the process group exactly once when either stream exceeds its budget; +6. kills the group on timeout and joins both readers; and +7. returns or raises only bounded evidence. + +A truncation marker is included inside, not in addition to, the declared retained byte budget. Reader errors and reader-join timeouts are explicit failures. + +## Long-running service boundary + +Each backend and frontend uses one combined stdout/stderr pipe and the same bounded drainer. The capture retains the final suffix in memory and writes only its bounded rendered form to the private sandbox log file when the stream closes. The evidence file therefore cannot exceed the declared service-log budget. + +Service overflow is checked during readiness, after E2E execution, and after service shutdown. It takes precedence over an ordinary command or readiness result, but a true E2E timeout remains `124`. `tail_text()` reads no more than 65,536 bytes from the end of the already bounded file, retains the configured final line count, and then applies the shared credential-redaction boundary. + +## Security and availability properties + +- Parent retained memory is bounded independently for stdout and stderr. +- Service evidence disk use is bounded per service. +- Child pipes are continuously drained, preventing a full pipe from blocking the child indefinitely. +- Process-group termination covers descendants that retain inherited pipe descriptors. +- Structured argv and `shell=False` remain unchanged. +- Environment scrubbing, output redaction, timeout enforcement, process cleanup, SSRF-safe readiness polling, and machine-readable evidence remain independent controls. +- Non-POSIX environments fail closed rather than using unmanaged capture. +- Output overflow cannot be converted into success by the child process. + +MITRE CWE-770 identifies unbounded memory and other resource consumption as an availability weakness and recommends explicit minimum/maximum expectations, throttling, quotas, and safe failure when limits are reached. This implementation sets explicit per-stream ceilings and a stable failure result. NIST SP 800-218 supplies the secure-development framework used to define, test, and retain this control as reviewable evidence. + +No formal CWE, NIST, or POSIX conformity is claimed. + +## Verification contract + +Real subprocess tests exercise: + +- ordinary Korean Unicode stdout and stderr; +- infinite stdout and stderr floods; +- timeout with partial output; +- final-suffix retention and one overflow callback; +- bounded persisted service evidence; +- service overflow before or during readiness/E2E; +- ordinary backend/frontend/E2E success and cleanup; +- partial UTF-8 suffix decoding; +- bounded file reads; +- unsupported-platform failure; +- invalid budgets; +- reader exceptions and stuck-reader joins; +- retained redaction of credentials in output, commands, notes, structured JSON, and service tails; and +- deterministic result fields and exit-code precedence. + +The exact pull-request head must additionally pass the complete central test suite, 100% production statement and branch coverage for the changed surface, production docstrings, Secret Scan, CodeQL, Semgrep, Python Security, dependency and supply-chain checks, OpenCode, Noema, CodeRabbit, independent current-head approval, and branch protection. + +## Limitations + +This slice does not limit: + +- repository workspace-copy size; +- application/build artifacts written outside standard streams; +- CPU time beyond the existing command timeouts; +- address space, process count, network traffic, or external service response size; or +- output generated by an unrelated process that does not inherit the managed service pipes. + +The reader buffers intentionally retain the final suffix rather than the complete beginning of an oversized stream because terminal diagnostics normally contain the most actionable failure evidence. Complete oversized logs are not retained as artifacts. + +## Rollback + +Rollback must restore a different proven memory-and-disk bound for every short-lived and long-running publication path. Reverting only the process-group kill, service capture, or suffix reader would recreate an unbounded path around the remaining controls. Before rollback, operators must demonstrate realistic flood tests, bounded retained memory and files, timeout behavior, cleanup, redaction, and exact-head independent review. + +## APA 7 references + +MITRE Corporation. (2026). *CWE-770: Allocation of resources without limits or throttling* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/770.html + +Python Software Foundation. (2026). *subprocess—Subprocess management* (Python 3.14.6 documentation). https://docs.python.org/3.14/library/subprocess.html + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 From 091d27f13f873ea0af46cb33075eadc51465699c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 10:59:37 +0900 Subject: [PATCH 39/93] docs(ci): record bounded sandbox output behavior --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2480f65be..f98651760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Upgrade the central Strix dependency snapshots to `aiohttp==3.14.3`, `cryptography==50.0.0`, and the compatible `pyOpenSSL==26.4.0` closure so the hard dependency gates contain no known affected releases. - Redact credentials from every sandbox evidence publication sink, including completed and timed-out process output, service log tails, commands, reviewer notes, nested JSON values, and JSON object keys. +- Continuously drain sandbox child stdout/stderr into fixed-size final-suffix buffers, terminate isolated process groups on overflow, and persist only bounded service evidence so repository output cannot exhaust parent memory or runner log storage before redaction. + +### Changed + +- Add explicit 1 MiB per-stream command and 4 MiB per-service log budgets, stable output-limit exit code `123`, result-envelope limit evidence, and bounded seek-from-end service tails while preserving timeout `124` and readiness `125` semantics. ### Fixed - Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. +- Avoid process-wide file-size limits that would incorrectly constrain coverage databases, compiled assets, archives, and other legitimate repository artifacts unrelated to stdout/stderr evidence. ### Documentation -- Add an APA 7 doctoring record for the sandbox command/output redaction boundary, structured diagnostics, availability controls, verification evidence, limitations, and rollback requirements. +- Add APA 7 doctoring for the sandbox command/output redaction boundary, structured diagnostics, availability controls, verification evidence, limitations, and rollback requirements. +- Add APA 7 doctoring for bounded subprocess pipe draining, process-group termination, bounded service evidence, exit-code precedence, realistic flood tests, limitations, and rollback requirements. From 5ef4c5211e0daa759ee09bb9d8f66dbf7294e77e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:09:04 +0900 Subject: [PATCH 40/93] fix(ci): separate bounded output from result evidence marker --- scripts/ci/sandboxed_verify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index a81313484..ae76e4167 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -179,7 +179,7 @@ def run_command( timeout: int, output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, ) -> bounded_subprocess.BoundedCompletedProcess: - """Run one verification command with kernel-enforced bounded output files.""" + """Run one verification command with continuously drained bounded output.""" return bounded_subprocess.run_bounded_command( command, @@ -227,6 +227,7 @@ def emit_result( "sandbox": str(sandbox_root) if kept else "(removed)", "sandboxed": True, } + print() print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}") From d4db1cdad54fde91334f4479755b6a0f0e34fafc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:10:15 +0900 Subject: [PATCH 41/93] fix(ci): separate E2E output from result evidence marker --- scripts/ci/sandboxed_web_e2e.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 4c9bd34d3..67c94ff91 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -280,6 +280,7 @@ def emit_result( "sandboxed": True, "service_log_limit_bytes": args.service_log_limit_bytes, } + print() print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}") From c00bd880e0d6b4b7cc79a9534f4f3d5826d9d3f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:15:28 +0900 Subject: [PATCH 42/93] test(ci): complete bounded subprocess branch contracts --- tests/test_bounded_subprocess_contract.py | 265 ++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 tests/test_bounded_subprocess_contract.py diff --git a/tests/test_bounded_subprocess_contract.py b/tests/test_bounded_subprocess_contract.py new file mode 100644 index 000000000..aa2a62dbf --- /dev/null +++ b/tests/test_bounded_subprocess_contract.py @@ -0,0 +1,265 @@ +"""Branch-complete contracts for bounded subprocess helpers and failures.""" + +from __future__ import annotations + +import io +import os +from pathlib import Path + +import pytest + +from scripts.ci import bounded_subprocess as bounded + + +def test_read_limit_and_timeout_validation_reject_all_unsafe_types() -> None: + """Private validators reject Boolean, nonnumeric, nonpositive, and huge values.""" + + for value in [False, "2", 0, bounded.MAXIMUM_OUTPUT_LIMIT_BYTES + 1]: + with pytest.raises(ValueError, match="maximum_bytes"): + bounded._validate_read_limit(value) + for value in [False, "1", 0, -1]: + with pytest.raises(ValueError, match="timeout"): + bounded._validated_timeout(value) + + +def test_supported_platform_requires_posix_killpg(monkeypatch) -> None: + """POSIX naming without process-group termination still fails closed.""" + + monkeypatch.setattr(bounded.os, "name", "posix") + monkeypatch.delattr(bounded.os, "killpg") + with pytest.raises(bounded.OutputLimitUnsupportedError): + bounded.require_supported_platform() + + +def test_capture_notifies_once_across_multiple_overflowing_chunks() -> None: + """Repeated chunks beyond the ceiling retain a suffix but notify only once.""" + + class ChunkStream: + """Return one deterministic chunk for each background read.""" + + def __init__(self) -> None: + self.chunks = [b"a" * 3000, b"b" * 3000, b"c" * 1000, b""] + + def read(self, size: int) -> bytes: + """Return the next chunk within the requested reader contract.""" + + assert size == bounded.READ_CHUNK_BYTES + return self.chunks.pop(0) + + def close(self) -> None: + """Provide the binary-stream close interface.""" + + notifications: list[str] = [] + capture = bounded.start_bounded_capture( + ChunkStream(), # type: ignore[arg-type] + evidence_limit_bytes=4096, + on_limit=lambda: notifications.append("limited"), + ) + capture.join(timeout=5) + + assert notifications == ["limited"] + assert capture.output_limited + assert capture.total_bytes == 7000 + assert capture.text.endswith("c" * 1000) + + +def test_capture_destination_failures_propagate_without_masking_read_error( + tmp_path: Path, +) -> None: + """Evidence-write errors surface, while an earlier read error keeps precedence.""" + + blocked_parent = tmp_path / "blocked" + blocked_parent.write_text("not a directory", encoding="utf-8") + destination = blocked_parent / "capture.log" + + capture = bounded.start_bounded_capture( + io.BytesIO(b"safe"), + evidence_limit_bytes=4096, + on_limit=lambda: None, + destination=destination, + ) + with pytest.raises((FileExistsError, NotADirectoryError)): + capture.join(timeout=5) + + class ReadFailure: + """Fail before the destination writer also encounters its path error.""" + + def read(self, size: int) -> bytes: + """Raise the primary reader failure.""" + + del size + raise OSError("primary read failure") + + def close(self) -> None: + """Provide the binary-stream close interface.""" + + capture = bounded.start_bounded_capture( + ReadFailure(), # type: ignore[arg-type] + evidence_limit_bytes=4096, + on_limit=lambda: None, + destination=destination, + ) + with pytest.raises(OSError, match="primary read failure"): + capture.join(timeout=5) + + +def test_command_normalization_rejects_empty_executable() -> None: + """A present but empty executable token is not a runnable command.""" + + with pytest.raises(ValueError, match="command"): + bounded._normalized_command([""]) + + +def test_kill_process_group_handles_finished_and_disappearing_processes( + monkeypatch, +) -> None: + """Termination is idempotent when a process already exited or disappeared.""" + + calls: list[tuple[int, int]] = [] + monkeypatch.setattr( + bounded.os, + "killpg", + lambda pid, signal_number: calls.append((pid, signal_number)), + ) + + class FinishedProcess: + """Represent one already-reaped child.""" + + pid = 10 + + def poll(self) -> int: + """Return a completed status.""" + + return 0 + + bounded.kill_process_group(FinishedProcess()) # type: ignore[arg-type] + assert calls == [] + + class RunningProcess: + """Represent one child that disappears before the signal is delivered.""" + + pid = 11 + + def poll(self): + """Report an apparently running child.""" + + return None + + def missing_process(pid: int, signal_number: int) -> None: + """Simulate the race between poll and group signaling.""" + + del pid, signal_number + raise ProcessLookupError + + monkeypatch.setattr(bounded.os, "killpg", missing_process) + bounded.kill_process_group(RunningProcess()) # type: ignore[arg-type] + + +def test_run_rejects_missing_subprocess_pipes(monkeypatch, tmp_path: Path) -> None: + """A broken Popen contract is killed and rejected before reader creation.""" + + class MissingPipesProcess: + """Expose no stdout or stderr pipe despite the requested configuration.""" + + pid = 12 + stdout = None + stderr = None + returncode = -9 + + def poll(self): + """Report a running child until the fake kill path executes.""" + + return None + + def wait(self, timeout=None) -> int: + """Return the fake terminal status.""" + + del timeout + return self.returncode + + process = MissingPipesProcess() + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr(bounded.subprocess, "Popen", lambda *args, **kwargs: process) + killed: list[object] = [] + monkeypatch.setattr(bounded, "kill_process_group", lambda candidate: killed.append(candidate)) + + with pytest.raises(RuntimeError, match="pipes"): + bounded.run_bounded_command( + ["tool"], + cwd=tmp_path, + env={}, + timeout=1, + evidence_limit_bytes=4096, + ) + assert killed == [process] + + +def test_two_overflow_callbacks_kill_the_process_group_once( + monkeypatch, + tmp_path: Path, +) -> None: + """Simultaneous stdout/stderr limit notifications share one kill transition.""" + + callbacks: list[object] = [] + + class FakePipe: + """Stand in for one requested subprocess pipe.""" + + class FakeProcess: + """Invoke both capture callbacks while the parent waits.""" + + pid = 13 + stdout = FakePipe() + stderr = FakePipe() + returncode = -9 + + def poll(self): + """Report a running process during callback delivery.""" + + return None + + def wait(self, timeout=None) -> int: + """Deliver both overflow callbacks and return the terminal status.""" + + del timeout + if callbacks: + callbacks[0]() + callbacks[1]() + return self.returncode + + class FakeCapture: + """Return fixed limited evidence without background threads.""" + + output_limited = True + text = bounded.TRUNCATION_MARKER + + def join(self, timeout=None) -> None: + """Complete immediately.""" + + del timeout + + process = FakeProcess() + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr(bounded.subprocess, "Popen", lambda *args, **kwargs: process) + + def fake_capture(stream, *, evidence_limit_bytes, on_limit, destination=None): + """Record each overflow callback supplied by the command runner.""" + + del stream, evidence_limit_bytes, destination + callbacks.append(on_limit) + return FakeCapture() + + monkeypatch.setattr(bounded, "start_bounded_capture", fake_capture) + kills: list[object] = [] + monkeypatch.setattr(bounded, "kill_process_group", lambda candidate: kills.append(candidate)) + + result = bounded.run_bounded_command( + ["tool"], + cwd=tmp_path, + env={}, + timeout=1, + evidence_limit_bytes=4096, + ) + + assert result.output_limited + assert kills == [process] From c7b5515475383966b3f84bcc76446dd79a432e41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:17:06 +0900 Subject: [PATCH 43/93] test(ci): complete bounded web E2E branch contracts --- .../test_sandboxed_web_e2e_branch_contract.py | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 tests/test_sandboxed_web_e2e_branch_contract.py diff --git a/tests/test_sandboxed_web_e2e_branch_contract.py b/tests/test_sandboxed_web_e2e_branch_contract.py new file mode 100644 index 000000000..c2837ad56 --- /dev/null +++ b/tests/test_sandboxed_web_e2e_branch_contract.py @@ -0,0 +1,415 @@ +"""Branch-complete contracts for bounded sandbox web E2E orchestration.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import urllib.error +from pathlib import Path +from typing import cast + +import pytest + +from scripts.ci import bounded_subprocess as bounded +from scripts.ci import sandboxed_web_e2e + + +def _result(output: str) -> dict[str, object]: + """Parse one final web E2E result marker.""" + + marker = f"{sandboxed_web_e2e.RESULT_MARKER} " + line = next(line for line in output.splitlines() if line.startswith(marker)) + return json.loads(line.removeprefix(marker)) + + +class _DoneProcess: + """Minimal process double that has already completed.""" + + pid = 100 + returncode = 0 + + def poll(self) -> int: + """Return the completed status.""" + + return self.returncode + + def wait(self, timeout=None) -> int: + """Return immediately.""" + + del timeout + return self.returncode + + +class _RunningProcess: + """Minimal running process double for cleanup branches.""" + + pid = 101 + returncode = None + + def poll(self): + """Report that the process remains active.""" + + return self.returncode + + def wait(self, timeout=None) -> int: + """Complete when the fake process is explicitly waited.""" + + del timeout + self.returncode = 0 + return 0 + + +def _service(tmp_path: Path, *, process=None, log_limit_bytes: int = 4096): + """Create one service double with no background capture.""" + + return sandboxed_web_e2e.Service( + label="service", + command="service", + process=cast(subprocess.Popen[bytes], process or _DoneProcess()), + log_path=tmp_path / "service.log", + log_limit_bytes=log_limit_bytes, + ) + + +def test_start_service_rejects_missing_output_pipe( + monkeypatch, + tmp_path: Path, +) -> None: + """A broken Popen pipe contract is killed and rejected.""" + + class MissingPipeProcess(_RunningProcess): + """Return no stdout despite the requested PIPE configuration.""" + + stdout = None + + process = MissingPipeProcess() + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr( + sandboxed_web_e2e.subprocess, + "Popen", + lambda *args, **kwargs: process, + ) + killed: list[object] = [] + monkeypatch.setattr( + bounded, + "kill_process_group", + lambda candidate: killed.append(candidate), + ) + + with pytest.raises(RuntimeError, match="pipe"): + sandboxed_web_e2e.start_service( + "backend", + "tool", + tmp_path, + {}, + tmp_path, + 4096, + ) + assert killed == [process] + + +def test_service_limit_fallback_handles_missing_small_and_large_files( + tmp_path: Path, +) -> None: + """Legacy/fake services classify file-only evidence deterministically.""" + + service = _service(tmp_path) + assert not sandboxed_web_e2e.service_output_limited(service) + service.log_path.write_bytes(b"safe") + assert not sandboxed_web_e2e.service_output_limited(service) + service.log_path.write_bytes(b"x" * 4097) + assert sandboxed_web_e2e.service_output_limited(service) + + +def test_wait_for_url_handles_empty_invalid_exited_limited_and_success( + monkeypatch, + tmp_path: Path, +) -> None: + """Readiness polling preserves every validation and termination branch.""" + + service = _service(tmp_path) + assert sandboxed_web_e2e.wait_for_url("", 1, service) + with pytest.raises(ValueError, match="http"): + sandboxed_web_e2e.wait_for_url("file:///tmp/ready", 1, service) + assert not sandboxed_web_e2e.wait_for_url( + "https://example.invalid/ready", + 1, + service, + ) + + running = _service(tmp_path, process=_RunningProcess()) + running.log_path.write_bytes(b"x" * 4097) + assert not sandboxed_web_e2e.wait_for_url( + "https://example.invalid/ready", + 1, + running, + ) + + class Response: + """Context-managed readiness response.""" + + status = 204 + + def __enter__(self): + """Return the response.""" + + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + """Close without suppressing exceptions.""" + + del exc_type, exc, traceback + + class Opener: + """Return one successful response.""" + + def open(self, url: str, timeout: int): + """Validate the poll request and return readiness.""" + + assert url == "https://ready.example/health" + assert timeout == 2 + return Response() + + clean_running = _service(tmp_path, process=_RunningProcess()) + monkeypatch.setattr( + sandboxed_web_e2e.urllib.request, + "build_opener", + lambda handler: Opener(), + ) + assert sandboxed_web_e2e.wait_for_url( + "https://ready.example/health", + 1, + clean_running, + ) + + +def test_wait_for_url_retries_url_errors_until_deadline( + monkeypatch, + tmp_path: Path, +) -> None: + """Transient URL errors sleep and eventually produce a bounded false result.""" + + class FailingOpener: + """Raise one deterministic URL error per poll.""" + + def open(self, url: str, timeout: int): + """Reject the readiness request.""" + + del url, timeout + raise urllib.error.URLError("not ready") + + timeline = iter([0.0, 0.0, 2.0]) + sleeps: list[int] = [] + monkeypatch.setattr(sandboxed_web_e2e.time, "monotonic", lambda: next(timeline)) + monkeypatch.setattr(sandboxed_web_e2e.time, "sleep", lambda seconds: sleeps.append(seconds)) + monkeypatch.setattr( + sandboxed_web_e2e.urllib.request, + "build_opener", + lambda handler: FailingOpener(), + ) + + assert not sandboxed_web_e2e.wait_for_url( + "https://ready.example/health", + 1, + _service(tmp_path, process=_RunningProcess()), + ) + assert sleeps == [1] + + +def test_redirect_handler_raises_http_error() -> None: + """Readiness redirects are never followed.""" + + handler = sandboxed_web_e2e.NoRedirectHandler() + request = type("Request", (), {"full_url": "https://ready.example"})() + with pytest.raises(urllib.error.HTTPError): + handler.redirect_request(request, None, 302, "redirect", {}, "https://other") + + +def test_stop_service_handles_finished_lookup_race_timeout_and_capture( + monkeypatch, + tmp_path: Path, +) -> None: + """Cleanup covers normal, disappearing, force-kill, and capture-finalization paths.""" + + joined: list[float | None] = [] + + class Capture: + """Record finalization of one fake background drain.""" + + output_limited = False + + def join(self, timeout=None) -> None: + """Record the requested join timeout.""" + + joined.append(timeout) + + finished = _service(tmp_path) + finished.capture = cast(bounded.BoundedOutputCapture, Capture()) + sandboxed_web_e2e.stop_service(finished) + assert joined == [10] + + disappearing = _service(tmp_path, process=_RunningProcess()) + monkeypatch.setattr( + sandboxed_web_e2e.os, + "killpg", + lambda pid, signal_number: (_ for _ in ()).throw(ProcessLookupError()), + ) + sandboxed_web_e2e.stop_service(disappearing) + + class TimeoutProcess(_RunningProcess): + """Timeout once before completing after force kill.""" + + def __init__(self) -> None: + self.waits = 0 + + def wait(self, timeout=None) -> int: + """Raise once, then return the terminal status.""" + + del timeout + self.waits += 1 + if self.waits == 1: + raise subprocess.TimeoutExpired("service", 10) + self.returncode = -9 + return self.returncode + + timeout_process = TimeoutProcess() + timed = _service(tmp_path, process=timeout_process) + monkeypatch.setattr(sandboxed_web_e2e.os, "killpg", lambda pid, sig: None) + forced: list[object] = [] + monkeypatch.setattr( + bounded, + "kill_process_group", + lambda process: forced.append(process), + ) + sandboxed_web_e2e.stop_service(timed) + assert forced == [timeout_process] + + +def test_tail_text_rejects_nonpositive_line_count(tmp_path: Path) -> None: + """A caller cannot request an ambiguous or unbounded line selection.""" + + log_path = tmp_path / "service.log" + log_path.write_text("line\n", encoding="utf-8") + with pytest.raises(ValueError, match="max_lines"): + sandboxed_web_e2e.tail_text(log_path, max_lines=0) + + +def test_timeout_precedence_survives_limited_partial_output( + monkeypatch, + tmp_path: Path, + capsys, +) -> None: + """A timed-out E2E remains 124 even when its bounded stream was truncated.""" + + repository = tmp_path / "repository" + repository.mkdir() + + def fake_start(label, command, cwd, env, logs_dir, log_limit_bytes): + """Return already-running service doubles without real children.""" + + del command, cwd, env + return sandboxed_web_e2e.Service( + label=label, + command=label, + process=cast(subprocess.Popen[bytes], _RunningProcess()), + log_path=logs_dir / f"{label}.log", + log_limit_bytes=log_limit_bytes, + ) + + def timeout_run(command, cwd, env, timeout, output_limit_bytes): + """Raise bounded timeout evidence.""" + + del command, cwd, env, output_limit_bytes + raise bounded.BoundedTimeoutExpired( + ["e2e"], + timeout, + stdout=bounded.TRUNCATION_MARKER, + stderr="", + output_limited=True, + ) + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start) + monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", lambda *args: True) + monkeypatch.setattr(sandboxed_web_e2e, "run_shell", timeout_run) + monkeypatch.setattr(sandboxed_web_e2e, "stop_service", lambda service: None) + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(repository), + "--backend-cmd", + "backend", + "--frontend-cmd", + "frontend", + "--e2e-cmd", + "e2e", + "--e2e-timeout", + "1", + ] + ) + captured = capsys.readouterr() + + assert exit_code == 124 + assert "timed out after 1s" in captured.err + assert _result(captured.out)["output_limited"] is True + + +def test_capture_finalization_failure_maps_to_resource_exit( + monkeypatch, + tmp_path: Path, + capsys, +) -> None: + """A service capture failure cannot leave a successful result envelope.""" + + repository = tmp_path / "repository" + repository.mkdir() + + def fake_start(label, command, cwd, env, logs_dir, log_limit_bytes): + """Return completed service doubles.""" + + del command, cwd, env + return sandboxed_web_e2e.Service( + label=label, + command=label, + process=cast(subprocess.Popen[bytes], _DoneProcess()), + log_path=logs_dir / f"{label}.log", + log_limit_bytes=log_limit_bytes, + ) + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start) + monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", lambda *args: True) + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda *args: bounded.BoundedCompletedProcess( + args=("e2e",), + returncode=0, + stdout="", + stderr="", + output_limited=False, + ), + ) + monkeypatch.setattr( + sandboxed_web_e2e, + "stop_service", + lambda service: (_ for _ in ()).throw(RuntimeError("capture failed")), + ) + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(repository), + "--backend-cmd", + "backend", + "--frontend-cmd", + "frontend", + "--e2e-cmd", + "e2e", + ] + ) + captured = capsys.readouterr() + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert "bounded service capture failed" in captured.err + assert _result(captured.out)["output_limited"] is True From f12bb248459e53c290e239bcb9ee74fbfe696d42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:19:54 +0900 Subject: [PATCH 44/93] test(ci): finalize both bounded stream readers on failure --- tests/test_bounded_subprocess_contract.py | 81 ++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tests/test_bounded_subprocess_contract.py b/tests/test_bounded_subprocess_contract.py index aa2a62dbf..2e8689229 100644 --- a/tests/test_bounded_subprocess_contract.py +++ b/tests/test_bounded_subprocess_contract.py @@ -4,6 +4,7 @@ import io import os +from collections.abc import Callable from pathlib import Path import pytest @@ -200,7 +201,7 @@ def test_two_overflow_callbacks_kill_the_process_group_once( ) -> None: """Simultaneous stdout/stderr limit notifications share one kill transition.""" - callbacks: list[object] = [] + callbacks: list[Callable[[], None]] = [] class FakePipe: """Stand in for one requested subprocess pipe.""" @@ -263,3 +264,81 @@ def fake_capture(stream, *, evidence_limit_bytes, on_limit, destination=None): assert result.output_limited assert kills == [process] + + +def test_run_joins_both_stream_captures_when_one_join_fails( + monkeypatch, + tmp_path: Path, +) -> None: + """A reader failure cannot leave the sibling drain thread unjoined.""" + + class FakePipe: + """Stand in for one requested subprocess pipe.""" + + class FakeProcess: + """Complete immediately with both requested pipes present.""" + + pid = 14 + stdout = FakePipe() + stderr = FakePipe() + returncode = 0 + + def poll(self): + """Return the completed status.""" + + return self.returncode + + def wait(self, timeout=None) -> int: + """Complete immediately.""" + + del timeout + return self.returncode + + joins: list[str] = [] + + class FakeCapture: + """Record join order and optionally raise one deterministic error.""" + + output_limited = False + text = "" + + def __init__(self, label: str, error: BaseException | None) -> None: + self.label = label + self.error = error + + def join(self, timeout=None) -> None: + """Record finalization before surfacing the configured error.""" + + del timeout + joins.append(self.label) + if self.error is not None: + raise self.error + + captures = iter( + [ + FakeCapture("stdout", OSError("stdout drain failed")), + FakeCapture("stderr", None), + ] + ) + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr( + bounded.subprocess, + "Popen", + lambda *args, **kwargs: FakeProcess(), + ) + monkeypatch.setattr( + bounded, + "start_bounded_capture", + lambda *args, **kwargs: next(captures), + ) + + with pytest.raises(OSError, match="stdout drain failed"): + bounded.run_bounded_command( + ["tool"], + cwd=tmp_path, + env={}, + timeout=1, + evidence_limit_bytes=4096, + ) + + assert joins == ["stdout", "stderr"] From 3471bd9de6bd8b4ba3b547249934ea2c29afeb74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:20:27 +0900 Subject: [PATCH 45/93] test(ci): require service cleanup when capture startup fails --- .../test_sandboxed_service_capture_startup.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/test_sandboxed_service_capture_startup.py diff --git a/tests/test_sandboxed_service_capture_startup.py b/tests/test_sandboxed_service_capture_startup.py new file mode 100644 index 000000000..0590028a4 --- /dev/null +++ b/tests/test_sandboxed_service_capture_startup.py @@ -0,0 +1,76 @@ +"""Failure contracts for bounded sandbox service capture startup.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci import bounded_subprocess as bounded +from scripts.ci import sandboxed_web_e2e + + +class _RunningProcess: + """Minimal process double active until explicitly killed and waited.""" + + pid = 200 + stdout = object() + + def __init__(self) -> None: + self.returncode: int | None = None + self.waited = False + + def poll(self) -> int | None: + """Return the current process state.""" + + return self.returncode + + def wait(self, timeout=None) -> int: + """Record reaping and return the terminal status.""" + + del timeout + self.waited = True + self.returncode = -9 + return self.returncode + + +def test_capture_startup_failure_kills_and_reaps_the_service( + monkeypatch, + tmp_path: Path, +) -> None: + """A failed drainer cannot leave an unobserved long-running child behind.""" + + process = _RunningProcess() + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr( + sandboxed_web_e2e.subprocess, + "Popen", + lambda *args, **kwargs: process, + ) + monkeypatch.setattr( + bounded, + "start_bounded_capture", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("capture startup failed") + ), + ) + killed: list[object] = [] + monkeypatch.setattr( + bounded, + "kill_process_group", + lambda candidate: killed.append(candidate), + ) + + with pytest.raises(RuntimeError, match="capture startup failed"): + sandboxed_web_e2e.start_service( + "backend", + "tool", + tmp_path, + {}, + tmp_path, + 4096, + ) + + assert killed == [process] + assert process.waited is True From 915e281dde241bec449e08afd3a201a336093b55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 11:21:43 +0900 Subject: [PATCH 46/93] fix(ci): finalize every bounded stream reader --- scripts/ci/bounded_subprocess.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/ci/bounded_subprocess.py b/scripts/ci/bounded_subprocess.py index 581b2a81a..176fe9bb1 100644 --- a/scripts/ci/bounded_subprocess.py +++ b/scripts/ci/bounded_subprocess.py @@ -300,6 +300,20 @@ def kill_process_group(process: subprocess.Popen[bytes]) -> None: return +def _join_captures(captures: Sequence[BoundedOutputCapture]) -> None: + """Finalize every stream reader while preserving the first reported failure.""" + + first_error: BaseException | None = None + for capture in captures: + try: + capture.join() + except BaseException as error: # noqa: BLE001 - re-raised after sibling join + if first_error is None: + first_error = error + if first_error is not None: + raise first_error + + def run_bounded_command( arguments: Sequence[object], *, @@ -359,8 +373,7 @@ def stop_for_limit() -> None: kill_process_group(process) process.wait() - stdout_capture.join() - stderr_capture.join() + _join_captures((stdout_capture, stderr_capture)) output_limited = ( stdout_capture.output_limited or stderr_capture.output_limited ) From f7e2449b10963705d22a2655e3e8ba90b81ff855 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:47:56 +0900 Subject: [PATCH 47/93] fix(ci): preserve sandbox result compatibility --- scripts/ci/sandboxed_verify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index ae76e4167..a4451d8ae 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -210,8 +210,8 @@ def emit_result( allowed_env: Sequence[str], network: str, evidence_note: str, - output_limit_bytes: int, - output_limited: bool, + output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + output_limited: bool = False, ) -> None: """Print a machine-readable execution evidence summary without secrets.""" payload = { From 1f4e20ffd37a7e3e7f0e584dcdf33942cf6b156d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:50:12 +0900 Subject: [PATCH 48/93] fix(ci): reap failed service captures --- scripts/ci/sandboxed_web_e2e.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 67c94ff91..4abec6558 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -155,12 +155,17 @@ def start_service( bounded_subprocess.kill_process_group(process) process.wait() raise RuntimeError("service output pipe was not created") - capture = bounded_subprocess.start_bounded_capture( - process.stdout, - evidence_limit_bytes=log_limit, - on_limit=lambda: bounded_subprocess.kill_process_group(process), - destination=log_path, - ) + try: + capture = bounded_subprocess.start_bounded_capture( + process.stdout, + evidence_limit_bytes=log_limit, + on_limit=lambda: bounded_subprocess.kill_process_group(process), + destination=log_path, + ) + except BaseException: # noqa: BLE001 - reap the child before preserving failure + bounded_subprocess.kill_process_group(process) + process.wait() + raise return Service( label=label, command=command, @@ -382,7 +387,9 @@ def main(argv: Sequence[str] | None = None) -> int: end="", file=sys.stderr, ) - output_limited = completed.output_limited + output_limited = bool( + getattr(completed, "output_limited", False) + ) if output_limited: print( "sandboxed-web-e2e: E2E output exceeded " From 192425738ee076eeb8c6627a28d16880c7b7b952 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:53:53 +0900 Subject: [PATCH 49/93] test(ci): align sandbox E2E doubles with bounded execution --- tests/test_sandboxed_web_e2e.py | 114 ++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 20 deletions(-) diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..599e7f7c4 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -153,26 +153,49 @@ def fake_killpg(pid, sig): def test_start_service_and_run_shell_capture_bash_contract(monkeypatch, tmp_path): - """Service startup and shell execution keep logs and bash wiring explicit.""" + """Service startup and shell execution keep argv and bounds wiring explicit.""" popen_calls = [] run_calls = [] class FakeProcess: pid = 42 + stdout = object() def poll(self): return 0 + class FakeCapture: + output_limited = False + + def join(self, timeout=None): + del timeout + def fake_popen(*args, **kwargs): popen_calls.append((args, kwargs)) return FakeProcess() - def fake_run(*args, **kwargs): - run_calls.append((args, kwargs)) - return subprocess.CompletedProcess(args[0], 7, stdout="out", stderr="err") + def fake_run_bounded(arguments, *, cwd, env, timeout, evidence_limit_bytes): + run_calls.append((tuple(arguments), cwd, env, timeout, evidence_limit_bytes)) + return sandboxed_web_e2e.bounded_subprocess.BoundedCompletedProcess( + args=tuple(arguments), + returncode=7, + stdout="out", + stderr="err", + output_limited=False, + ) + monkeypatch.setattr(sandboxed_web_e2e.bounded_subprocess, "require_supported_platform", lambda: None) monkeypatch.setattr(sandboxed_web_e2e.subprocess, "Popen", fake_popen) - monkeypatch.setattr(sandboxed_web_e2e.subprocess, "run", fake_run) + monkeypatch.setattr( + sandboxed_web_e2e.bounded_subprocess, + "start_bounded_capture", + lambda *args, **kwargs: FakeCapture(), + ) + monkeypatch.setattr( + sandboxed_web_e2e.bounded_subprocess, + "run_bounded_command", + fake_run_bounded, + ) service = sandboxed_web_e2e.start_service("backend", "npm run dev", tmp_path, {"PATH": "/bin"}, tmp_path) completed = sandboxed_web_e2e.run_shell("npm test", tmp_path, {"PATH": "/bin"}, 5) @@ -181,14 +204,12 @@ def fake_run(*args, **kwargs): assert service.command == "npm run dev" assert service.log_path == tmp_path / "backend.log" assert popen_calls[0][0] == (["npm", "run", "dev"],) - assert "shell" not in popen_calls[0][1] - assert "executable" not in popen_calls[0][1] + assert popen_calls[0][1]["shell"] is False assert popen_calls[0][1]["start_new_session"] is True assert completed.returncode == 7 - assert run_calls[0][0] == (["npm", "test"],) - assert run_calls[0][1]["timeout"] == 5 - assert "shell" not in run_calls[0][1] - assert "executable" not in run_calls[0][1] + assert run_calls[0][0] == ("npm", "test") + assert run_calls[0][3] == 5 + assert run_calls[0][4] == sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES def test_wait_for_url_handles_success_retry_and_log_tail(monkeypatch, tmp_path): @@ -273,10 +294,23 @@ class DoneProcess: def poll(self): return 0 - def fake_start(label, command, cwd, env, logs_dir): + def fake_start( + label, + command, + cwd, + env, + logs_dir, + log_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, + ): log_path = logs_dir / f"{label}.log" log_path.write_text(f"{label} ready\n", encoding="utf-8") - service = sandboxed_web_e2e.Service(label, command, DoneProcess(), log_path) + service = sandboxed_web_e2e.Service( + label, + command, + DoneProcess(), + log_path, + log_limit_bytes=log_limit_bytes, + ) started.append((label, command, cwd, "SANDBOXED_VERIFY" in env)) return service @@ -285,7 +319,7 @@ def fake_start(label, command, cwd, env, logs_dir): monkeypatch.setattr( sandboxed_web_e2e, "run_shell", - lambda command, cwd, env, timeout: subprocess.CompletedProcess( + lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: subprocess.CompletedProcess( command, 0, stdout="e2e-out\n", @@ -345,10 +379,23 @@ class DoneProcess: def poll(self): return 0 - def fake_start(label, command, cwd, env, logs_dir): + def fake_start( + label, + command, + cwd, + env, + logs_dir, + log_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, + ): log_path = logs_dir / f"{label}.log" log_path.write_text(f"{label} not ready\n", encoding="utf-8") - return sandboxed_web_e2e.Service(label, command, DoneProcess(), log_path) + return sandboxed_web_e2e.Service( + label, + command, + DoneProcess(), + log_path, + log_limit_bytes=log_limit_bytes, + ) def fake_wait(url, timeout, service): return service.label == "frontend" @@ -393,12 +440,32 @@ class DoneProcess: def poll(self): return 0 - def fake_start(label, command, cwd, env, logs_dir): + def fake_start( + label, + command, + cwd, + env, + logs_dir, + log_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, + ): log_path = logs_dir / f"{label}.log" log_path.write_text(f"{label} tail\n", encoding="utf-8") - return sandboxed_web_e2e.Service(label, command, DoneProcess(), log_path) + return sandboxed_web_e2e.Service( + label, + command, + DoneProcess(), + log_path, + log_limit_bytes=log_limit_bytes, + ) - def fake_run_shell(command, cwd, env, timeout): + def fake_run_shell( + command, + cwd, + env, + timeout, + output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + ): + del cwd, env, output_limit_bytes raise subprocess.TimeoutExpired(command, timeout, output=b"e2e-out", stderr=b"e2e-err") monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start) @@ -469,7 +536,14 @@ def test_sandboxed_web_e2e_reports_e2e_timeout(monkeypatch, tmp_path, capsys): repo = tmp_path / "repo" repo.mkdir() - def fake_run_shell(command, cwd, env, timeout): + def fake_run_shell( + command, + cwd, + env, + timeout, + output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + ): + del cwd, env, output_limit_bytes raise subprocess.TimeoutExpired(command, timeout, output="e2e-out", stderr="e2e-err") monkeypatch.setattr(sandboxed_web_e2e, "run_shell", fake_run_shell) From 0faa3a8266784d03b88d257fa05da3689430fa42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:56:03 +0900 Subject: [PATCH 50/93] test(ci): isolate readiness log-limit fixtures --- tests/test_sandboxed_web_e2e_branch_contract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_sandboxed_web_e2e_branch_contract.py b/tests/test_sandboxed_web_e2e_branch_contract.py index c2837ad56..57c51e8a3 100644 --- a/tests/test_sandboxed_web_e2e_branch_contract.py +++ b/tests/test_sandboxed_web_e2e_branch_contract.py @@ -145,6 +145,7 @@ def test_wait_for_url_handles_empty_invalid_exited_limited_and_success( 1, running, ) + running.log_path.unlink() class Response: """Context-managed readiness response.""" From 32c2738c5b12c1eae9e859f70d1a13e09eba3077 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:57:03 +0900 Subject: [PATCH 51/93] fix(ci): align scheduled CodeQL action revisions --- .github/workflows/scheduled-security-scan.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scheduled-security-scan.yml b/.github/workflows/scheduled-security-scan.yml index 8ecb5185b..331de634f 100644 --- a/.github/workflows/scheduled-security-scan.yml +++ b/.github/workflows/scheduled-security-scan.yml @@ -90,13 +90,13 @@ jobs: with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis continue-on-error: true - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: category: "/language:${{ matrix.language }}-scheduled" @@ -131,7 +131,7 @@ jobs: - name: Upload Trivy SARIF to code scanning if: always() && hashFiles('trivy-results.sarif') != '' continue-on-error: true - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: sarif_file: trivy-results.sarif category: trivy-fs-scheduled From 945709b8318239d33e0f7eb8115cef59781bfa86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 14:34:21 +0900 Subject: [PATCH 52/93] test(ci): require capture startup cleanup --- tests/test_sandboxed_service_capture_startup.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_sandboxed_service_capture_startup.py b/tests/test_sandboxed_service_capture_startup.py index 0590028a4..b8c323b87 100644 --- a/tests/test_sandboxed_service_capture_startup.py +++ b/tests/test_sandboxed_service_capture_startup.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +import io from pathlib import Path import pytest @@ -12,12 +12,12 @@ class _RunningProcess: - """Minimal process double active until explicitly killed and waited.""" + """Minimal process double active until explicitly stopped and waited.""" pid = 200 - stdout = object() def __init__(self) -> None: + self.stdout = io.BytesIO(b"") self.returncode: int | None = None self.waited = False @@ -35,11 +35,11 @@ def wait(self, timeout=None) -> int: return self.returncode -def test_capture_startup_failure_kills_and_reaps_the_service( +def test_capture_startup_failure_stops_reaps_and_closes_the_service_pipe( monkeypatch, tmp_path: Path, ) -> None: - """A failed drainer cannot leave an unobserved long-running child behind.""" + """A failed drainer cannot leave a child or parent-side pipe uncollected.""" process = _RunningProcess() monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) @@ -55,11 +55,11 @@ def test_capture_startup_failure_kills_and_reaps_the_service( RuntimeError("capture startup failed") ), ) - killed: list[object] = [] + stopped: list[object] = [] monkeypatch.setattr( bounded, "kill_process_group", - lambda candidate: killed.append(candidate), + lambda candidate: stopped.append(candidate), ) with pytest.raises(RuntimeError, match="capture startup failed"): @@ -72,5 +72,6 @@ def test_capture_startup_failure_kills_and_reaps_the_service( 4096, ) - assert killed == [process] + assert stopped == [process] assert process.waited is True + assert process.stdout.closed is True From 41630d4e04b7cf17a8c4971797e70173d4b7651b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 14:38:38 +0900 Subject: [PATCH 53/93] fix(ci): close service pipes when capture startup fails --- scripts/ci/sandboxed_web_e2e.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 4abec6558..edde0085e 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import contextlib import json import os import signal @@ -17,6 +18,7 @@ from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path +from typing import BinaryIO if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -125,6 +127,20 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: return args +def _cleanup_failed_service_start( + process: subprocess.Popen[bytes], + stream: BinaryIO, +) -> None: + """Best-effort stop, reap, and close after bounded capture startup fails.""" + + with contextlib.suppress(OSError, subprocess.SubprocessError): + bounded_subprocess.kill_process_group(process) + with contextlib.suppress(OSError, subprocess.SubprocessError): + process.wait(timeout=10) + with contextlib.suppress(OSError): + stream.close() + + def start_service( label: str, command: str, @@ -162,9 +178,8 @@ def start_service( on_limit=lambda: bounded_subprocess.kill_process_group(process), destination=log_path, ) - except BaseException: # noqa: BLE001 - reap the child before preserving failure - bounded_subprocess.kill_process_group(process) - process.wait() + except BaseException: # noqa: BLE001 - preserve the capture failure after cleanup + _cleanup_failed_service_start(process, process.stdout) raise return Service( label=label, From 9ca0db5a208d6c2990616ee40c53db461afe1d4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:05:23 +0900 Subject: [PATCH 54/93] test(ci): reproduce bounded capture startup orphaning --- ...test_bounded_subprocess_capture_startup.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/test_bounded_subprocess_capture_startup.py diff --git a/tests/test_bounded_subprocess_capture_startup.py b/tests/test_bounded_subprocess_capture_startup.py new file mode 100644 index 000000000..8cc9796fb --- /dev/null +++ b/tests/test_bounded_subprocess_capture_startup.py @@ -0,0 +1,159 @@ +"""Regression contracts for bounded command capture-startup cleanup.""" + +from __future__ import annotations + +import io +from pathlib import Path +from typing import cast + +import pytest + +from scripts.ci import bounded_subprocess as bounded + + +class _TrackedStream(io.BytesIO): + """Binary pipe double whose closed state remains observable.""" + + +class _Process: + """Minimal running process double with two parent-side output pipes.""" + + pid = 4242 + + def __init__(self) -> None: + """Create open stdout and stderr streams and cleanup counters.""" + + self.stdout = _TrackedStream(b"stdout") + self.stderr = _TrackedStream(b"stderr") + self.returncode: int | None = None + self.wait_calls = 0 + + def poll(self) -> int | None: + """Return the current fake process status.""" + + return self.returncode + + def wait(self, timeout=None) -> int: + """Record reaping and return a killed-process status.""" + + del timeout + self.wait_calls += 1 + self.returncode = -9 + return self.returncode + + +class _Capture: + """Capture double that closes its owned stream when finalized.""" + + output_limited = False + text = "" + + def __init__(self, stream: _TrackedStream, *, fail_join: bool = False) -> None: + """Remember the owned stream and optional cleanup failure.""" + + self.stream = stream + self.fail_join = fail_join + self.join_calls = 0 + + def join(self, timeout=None) -> None: + """Finalize the owned stream and optionally report a secondary error.""" + + del timeout + self.join_calls += 1 + self.stream.close() + if self.fail_join: + raise RuntimeError("secondary capture cleanup failure") + + +@pytest.mark.parametrize("failure_call", [1, 2]) +def test_capture_startup_failure_kills_reaps_finalizes_and_closes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + failure_call: int, +) -> None: + """Either capture-start failure must leave no process, reader, or pipe alive.""" + + process = _Process() + killed: list[_Process] = [] + captures: list[_Capture] = [] + startup_calls = 0 + + def fake_start(stream, **_kwargs): + """Fail at the selected capture start and return earlier captures.""" + + nonlocal startup_calls + startup_calls += 1 + if startup_calls == failure_call: + raise OSError("capture startup failed") + capture = _Capture(cast(_TrackedStream, stream)) + captures.append(capture) + return capture + + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr(bounded.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr( + bounded, + "kill_process_group", + lambda candidate: killed.append(cast(_Process, candidate)), + ) + monkeypatch.setattr(bounded, "start_bounded_capture", fake_start) + + with pytest.raises(OSError, match="capture startup failed"): + bounded.run_bounded_command( + ["tool"], + cwd=tmp_path, + env={}, + timeout=10, + evidence_limit_bytes=4096, + ) + + assert killed == [process] + assert process.wait_calls == 1 + assert process.stdout.closed + assert process.stderr.closed + assert all(capture.join_calls == 1 for capture in captures) + + +def test_capture_startup_preserves_original_error_when_cleanup_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Secondary join errors cannot replace the capture-start root cause.""" + + process = _Process() + capture = _Capture(process.stdout, fail_join=True) + startup_calls = 0 + killed: list[object] = [] + + def fake_start(_stream, **_kwargs): + """Return stdout capture and fail while starting stderr capture.""" + + nonlocal startup_calls + startup_calls += 1 + if startup_calls == 1: + return capture + raise OSError("primary capture startup failure") + + monkeypatch.setattr(bounded, "require_supported_platform", lambda: None) + monkeypatch.setattr(bounded.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr( + bounded, + "kill_process_group", + lambda candidate: killed.append(candidate), + ) + monkeypatch.setattr(bounded, "start_bounded_capture", fake_start) + + with pytest.raises(OSError, match="primary capture startup failure"): + bounded.run_bounded_command( + ["tool"], + cwd=tmp_path, + env={}, + timeout=10, + evidence_limit_bytes=4096, + ) + + assert killed == [process] + assert process.wait_calls == 1 + assert capture.join_calls == 1 + assert process.stdout.closed + assert process.stderr.closed From 2c9d405da0db70ce696148fbc7dde709dcadeb29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:12:16 +0900 Subject: [PATCH 55/93] ci: execute bounded capture startup regression --- .../workflows/bounded-capture-startup-ci.yml | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/bounded-capture-startup-ci.yml diff --git a/.github/workflows/bounded-capture-startup-ci.yml b/.github/workflows/bounded-capture-startup-ci.yml new file mode 100644 index 000000000..d410d2046 --- /dev/null +++ b/.github/workflows/bounded-capture-startup-ci.yml @@ -0,0 +1,70 @@ +name: Bounded Capture Startup CI + +on: + pull_request: + branches: [main] + paths: + - "scripts/ci/bounded_subprocess.py" + - "tests/test_bounded_subprocess_capture_startup.py" + - ".github/workflows/bounded-capture-startup-ci.yml" + push: + branches: [main] + paths: + - "scripts/ci/bounded_subprocess.py" + - "tests/test_bounded_subprocess_capture_startup.py" + - ".github/workflows/bounded-capture-startup-ci.yml" + +concurrency: + group: bounded-capture-startup-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + capture-startup-contract: + name: Capture startup cleanup contract + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install exact test tools + run: >- + python -m pip install --disable-pip-version-check + pytest==9.1.1 + coverage==7.14.3 + interrogate==1.7.0 + + - name: Run capture-startup regression and branch coverage + run: | + python -m coverage run --branch -m pytest -q \ + tests/test_bounded_subprocess_capture_startup.py + python -m coverage report \ + --include='scripts/ci/bounded_subprocess.py' \ + --show-missing + + - name: Enforce production docstrings + run: >- + python -m interrogate --fail-under 100 + scripts/ci/bounded_subprocess.py + + - name: Compile exact surfaces + run: >- + python -m py_compile + scripts/ci/bounded_subprocess.py + tests/test_bounded_subprocess_capture_startup.py From 7934b9a1dbcde076e5d99731335301700428f3aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:19:04 +0900 Subject: [PATCH 56/93] ci: apply PR 767 capture-startup fix after red proof --- .../one-shot-pr767-capture-startup-fix.yml | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-capture-startup-fix.yml diff --git a/.github/workflows/one-shot-pr767-capture-startup-fix.yml b/.github/workflows/one-shot-pr767-capture-startup-fix.yml new file mode 100644 index 000000000..68362ed30 --- /dev/null +++ b/.github/workflows/one-shot-pr767-capture-startup-fix.yml @@ -0,0 +1,178 @@ +name: One-shot PR 767 capture startup fix + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/one-shot-pr767-capture-startup-fix.yml" + +concurrency: + group: one-shot-pr767-capture-startup-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'fix/sandboxed-output-resource-bounds' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact pull-request head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install hash-locked review toolchain + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Apply and verify capture-startup cleanup + shell: bash --noprofile --norc -e -o pipefail {0} + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + HEAD_BRANCH: ${{ github.head_ref }} + PUSH_TOKEN: ${{ github.token }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + python3 -I - <<'PY' + from pathlib import Path + + + def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact reviewed fragment and fail closed on drift.""" + + file_path = Path(path) + content = file_path.read_text(encoding="utf-8") + count = content.count(old) + if count != 1: + raise SystemExit( + f"{path}: expected one repair anchor, found {count}" + ) + file_path.write_text(content.replace(old, new, 1), encoding="utf-8") + + + source_path = "scripts/ci/bounded_subprocess.py" + replace_once( + source_path, + "import threading\nfrom collections.abc import Callable, Mapping, Sequence\n", + "import threading\nfrom contextlib import suppress\n" + "from collections.abc import Callable, Mapping, Sequence\n", + ) + replace_once( + source_path, + '''def run_bounded_command( + ''', + '''def _cleanup_capture_startup_failure( + process: subprocess.Popen[bytes], + captures: Sequence[BoundedOutputCapture], + streams: Sequence[BinaryIO], + ) -> None: + """Best-effort terminate, reap, finalize, and close partial startup state.""" + + with suppress(BaseException): + kill_process_group(process) + with suppress(BaseException): + process.wait(timeout=10) + for capture in captures: + with suppress(BaseException): + capture.join(timeout=10) + for stream in streams: + with suppress(BaseException): + stream.close() + for capture in captures: + with suppress(BaseException): + capture.join(timeout=10) + + + def run_bounded_command( + ''', + ) + replace_once( + source_path, + ''' stdout_capture = start_bounded_capture( + process.stdout, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + stderr_capture = start_bounded_capture( + process.stderr, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + ''', + ''' captures: list[BoundedOutputCapture] = [] + streams = (process.stdout, process.stderr) + try: + stdout_capture = start_bounded_capture( + process.stdout, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + captures.append(stdout_capture) + stderr_capture = start_bounded_capture( + process.stderr, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + captures.append(stderr_capture) + except BaseException: # noqa: BLE001 - preserve the startup root cause + _cleanup_capture_startup_failure(process, captures, streams) + raise + ''', + ) + + test_path = "tests/test_bounded_subprocess_capture_startup.py" + replace_once( + test_path, + "assert all(capture.join_calls == 1 for capture in captures)", + "assert all(capture.join_calls == 2 for capture in captures)", + ) + replace_once( + test_path, + "assert capture.join_calls == 1", + "assert capture.join_calls == 2", + ) + + Path(".github/workflows/bounded-capture-startup-ci.yml").unlink() + Path(".github/workflows/one-shot-pr767-capture-startup-fix.yml").unlink() + PY + python -m pytest -q tests/test_bounded_subprocess_capture_startup.py + python -m py_compile \ + scripts/ci/bounded_subprocess.py \ + tests/test_bounded_subprocess_capture_startup.py + python -m interrogate --fail-under 100 scripts/ci/bounded_subprocess.py + git diff --check + test ! -e .github/workflows/bounded-capture-startup-ci.yml + test ! -e .github/workflows/one-shot-pr767-capture-startup-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(ci): reap partial bounded capture startup" + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ + push origin "HEAD:refs/heads/${HEAD_BRANCH}" From 6e22bcc8cad2f2ffe56ddbed45526c96dbcaa2b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:37:04 +0900 Subject: [PATCH 57/93] fix(ci): make PR 767 repair exact-head and ref-safe --- .../one-shot-pr767-capture-startup-fix.yml | 166 +++++++++++++----- 1 file changed, 120 insertions(+), 46 deletions(-) diff --git a/.github/workflows/one-shot-pr767-capture-startup-fix.yml b/.github/workflows/one-shot-pr767-capture-startup-fix.yml index 68362ed30..67d7ec9eb 100644 --- a/.github/workflows/one-shot-pr767-capture-startup-fix.yml +++ b/.github/workflows/one-shot-pr767-capture-startup-fix.yml @@ -1,4 +1,4 @@ -name: One-shot PR 767 capture startup fix +name: One-shot PR 767 bounded capture startup repair on: pull_request: @@ -7,7 +7,7 @@ on: - ".github/workflows/one-shot-pr767-capture-startup-fix.yml" concurrency: - group: one-shot-pr767-capture-startup-${{ github.event.pull_request.number }} + group: one-shot-pr767-bounded-capture-${{ github.event.pull_request.number }} cancel-in-progress: false permissions: @@ -50,43 +50,48 @@ jobs: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - name: Apply and verify capture-startup cleanup + - name: Apply, verify, and materialize bounded repair commit shell: bash --noprofile --norc -e -o pipefail {0} env: EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - HEAD_BRANCH: ${{ github.head_ref }} - PUSH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} run: | test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" python3 -I - <<'PY' from pathlib import Path - def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact reviewed fragment and fail closed on drift.""" - - file_path = Path(path) - content = file_path.read_text(encoding="utf-8") - count = content.count(old) - if count != 1: - raise SystemExit( - f"{path}: expected one repair anchor, found {count}" - ) - file_path.write_text(content.replace(old, new, 1), encoding="utf-8") - - - source_path = "scripts/ci/bounded_subprocess.py" - replace_once( + def replace_or_verify( + path: Path, + old: str, + new: str, + *, + expected: int = 1, + ) -> None: + """Apply one exact replacement or verify its completed state.""" + + content = path.read_text(encoding="utf-8") + old_count = content.count(old) + new_count = content.count(new) + if old_count == expected and new_count == 0: + path.write_text(content.replace(old, new), encoding="utf-8") + return + if old_count == 0 and new_count == expected: + return + raise SystemExit( + f"{path}: invalid repair state old={old_count} new={new_count}" + ) + + + source_path = Path("scripts/ci/bounded_subprocess.py") + replace_or_verify( source_path, "import threading\nfrom collections.abc import Callable, Mapping, Sequence\n", "import threading\nfrom contextlib import suppress\n" "from collections.abc import Callable, Mapping, Sequence\n", ) - replace_once( - source_path, - '''def run_bounded_command( - ''', - '''def _cleanup_capture_startup_failure( + + helper = '''def _cleanup_capture_startup_failure( process: subprocess.Popen[bytes], captures: Sequence[BoundedOutputCapture], streams: Sequence[BinaryIO], @@ -108,12 +113,23 @@ jobs: capture.join(timeout=10) - def run_bounded_command( - ''', - ) - replace_once( - source_path, - ''' stdout_capture = start_bounded_capture( + ''' + source = source_path.read_text(encoding="utf-8") + helper_marker = "def _cleanup_capture_startup_failure(" + if helper_marker not in source: + anchor = "def run_bounded_command(\n" + if source.count(anchor) != 1: + raise SystemExit( + f"{source_path}: expected one run_bounded_command anchor" + ) + source_path.write_text( + source.replace(anchor, helper + anchor, 1), + encoding="utf-8", + ) + elif source.count(helper_marker) != 1: + raise SystemExit(f"{source_path}: duplicate cleanup helper") + + old_startup = ''' stdout_capture = start_bounded_capture( process.stdout, evidence_limit_bytes=evidence_limit, on_limit=stop_for_limit, @@ -123,8 +139,8 @@ jobs: evidence_limit_bytes=evidence_limit, on_limit=stop_for_limit, ) - ''', - ''' captures: list[BoundedOutputCapture] = [] + ''' + new_startup = ''' captures: list[BoundedOutputCapture] = [] streams = (process.stdout, process.stderr) try: stdout_capture = start_bounded_capture( @@ -142,16 +158,16 @@ jobs: except BaseException: # noqa: BLE001 - preserve the startup root cause _cleanup_capture_startup_failure(process, captures, streams) raise - ''', - ) + ''' + replace_or_verify(source_path, old_startup, new_startup) - test_path = "tests/test_bounded_subprocess_capture_startup.py" - replace_once( + test_path = Path("tests/test_bounded_subprocess_capture_startup.py") + replace_or_verify( test_path, "assert all(capture.join_calls == 1 for capture in captures)", "assert all(capture.join_calls == 2 for capture in captures)", ) - replace_once( + replace_or_verify( test_path, "assert capture.join_calls == 1", "assert capture.join_calls == 2", @@ -160,6 +176,7 @@ jobs: Path(".github/workflows/bounded-capture-startup-ci.yml").unlink() Path(".github/workflows/one-shot-pr767-capture-startup-fix.yml").unlink() PY + python -m pytest -q tests/test_bounded_subprocess_capture_startup.py python -m py_compile \ scripts/ci/bounded_subprocess.py \ @@ -168,11 +185,68 @@ jobs: git diff --check test ! -e .github/workflows/bounded-capture-startup-ci.yml test ! -e .github/workflows/one-shot-pr767-capture-startup-fix.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(ci): reap partial bounded capture startup" - auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" - echo "::add-mask::$auth_header" - git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ - push origin "HEAD:refs/heads/${HEAD_BRANCH}" + + base_tree="$( + gh api -X GET "repos/${GITHUB_REPOSITORY}/git/commits/${EXPECTED_HEAD}" \ + --jq '.tree.sha' + )" + source_blob="$( + jq -Rs '{content: ., encoding: "utf-8"}' \ + Date: Wed, 5 Aug 2026 15:39:21 +0900 Subject: [PATCH 58/93] fix(ci): make capture repair anchor-independent --- .../one-shot-pr767-capture-startup-fix.yml | 115 ++++++++---------- 1 file changed, 54 insertions(+), 61 deletions(-) diff --git a/.github/workflows/one-shot-pr767-capture-startup-fix.yml b/.github/workflows/one-shot-pr767-capture-startup-fix.yml index 67d7ec9eb..b7a76a6ac 100644 --- a/.github/workflows/one-shot-pr767-capture-startup-fix.yml +++ b/.github/workflows/one-shot-pr767-capture-startup-fix.yml @@ -61,36 +61,23 @@ jobs: from pathlib import Path - def replace_or_verify( - path: Path, - old: str, - new: str, - *, - expected: int = 1, - ) -> None: - """Apply one exact replacement or verify its completed state.""" - - content = path.read_text(encoding="utf-8") - old_count = content.count(old) - new_count = content.count(new) - if old_count == expected and new_count == 0: - path.write_text(content.replace(old, new), encoding="utf-8") - return - if old_count == 0 and new_count == expected: - return - raise SystemExit( - f"{path}: invalid repair state old={old_count} new={new_count}" - ) - - source_path = Path("scripts/ci/bounded_subprocess.py") - replace_or_verify( - source_path, - "import threading\nfrom collections.abc import Callable, Mapping, Sequence\n", - "import threading\nfrom contextlib import suppress\n" - "from collections.abc import Callable, Mapping, Sequence\n", - ) + source = source_path.read_text(encoding="utf-8") + suppress_import = "from contextlib import suppress\n" + if suppress_import not in source: + import_anchor = "import threading\n" + if source.count(import_anchor) != 1: + raise SystemExit(f"{source_path}: invalid threading import anchor") + source = source.replace( + import_anchor, + import_anchor + suppress_import, + 1, + ) + elif source.count(suppress_import) != 1: + raise SystemExit(f"{source_path}: duplicate suppress import") + + helper_marker = "def _cleanup_capture_startup_failure(" helper = '''def _cleanup_capture_startup_failure( process: subprocess.Popen[bytes], captures: Sequence[BoundedOutputCapture], @@ -114,33 +101,27 @@ jobs: ''' - source = source_path.read_text(encoding="utf-8") - helper_marker = "def _cleanup_capture_startup_failure(" if helper_marker not in source: - anchor = "def run_bounded_command(\n" - if source.count(anchor) != 1: - raise SystemExit( - f"{source_path}: expected one run_bounded_command anchor" - ) - source_path.write_text( - source.replace(anchor, helper + anchor, 1), - encoding="utf-8", - ) + function_anchor = "def run_bounded_command(\n" + if source.count(function_anchor) != 1: + raise SystemExit(f"{source_path}: invalid command-function anchor") + function_offset = source.index(function_anchor) + source = source[:function_offset] + helper + source[function_offset:] elif source.count(helper_marker) != 1: raise SystemExit(f"{source_path}: duplicate cleanup helper") - old_startup = ''' stdout_capture = start_bounded_capture( - process.stdout, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - stderr_capture = start_bounded_capture( - process.stderr, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - ''' - new_startup = ''' captures: list[BoundedOutputCapture] = [] + startup_marker = " captures: list[BoundedOutputCapture] = []\n" + if startup_marker not in source: + function_offset = source.index("def run_bounded_command(\n") + startup_offset = source.index( + " stdout_capture = start_bounded_capture(\n", + function_offset, + ) + timed_out_offset = source.index( + " timed_out = False\n", + startup_offset, + ) + startup = ''' captures: list[BoundedOutputCapture] = [] streams = (process.stdout, process.stderr) try: stdout_capture = start_bounded_capture( @@ -159,19 +140,31 @@ jobs: _cleanup_capture_startup_failure(process, captures, streams) raise ''' - replace_or_verify(source_path, old_startup, new_startup) + source = source[:startup_offset] + startup + source[timed_out_offset:] + elif source.count(startup_marker) != 1: + raise SystemExit(f"{source_path}: duplicate startup cleanup block") + + source_path.write_text(source, encoding="utf-8") test_path = Path("tests/test_bounded_subprocess_capture_startup.py") - replace_or_verify( - test_path, - "assert all(capture.join_calls == 1 for capture in captures)", - "assert all(capture.join_calls == 2 for capture in captures)", - ) - replace_or_verify( - test_path, - "assert capture.join_calls == 1", - "assert capture.join_calls == 2", + test_source = test_path.read_text(encoding="utf-8") + replacements = ( + ( + "assert all(capture.join_calls == 1 for capture in captures)", + "assert all(capture.join_calls == 2 for capture in captures)", + ), + ("assert capture.join_calls == 1", "assert capture.join_calls == 2"), ) + for old, new in replacements: + old_count = test_source.count(old) + new_count = test_source.count(new) + if old_count == 1 and new_count == 0: + test_source = test_source.replace(old, new, 1) + elif old_count != 0 or new_count != 1: + raise SystemExit( + f"{test_path}: invalid assertion state old={old_count} new={new_count}" + ) + test_path.write_text(test_source, encoding="utf-8") Path(".github/workflows/bounded-capture-startup-ci.yml").unlink() Path(".github/workflows/one-shot-pr767-capture-startup-fix.yml").unlink() From 96018a70dea166b01fdc35f0d07566a3086336e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:39:57 +0000 Subject: [PATCH 59/93] fix(ci): reap partial bounded capture startup --- .../workflows/bounded-capture-startup-ci.yml | 70 ----- .../one-shot-pr767-capture-startup-fix.yml | 245 ------------------ scripts/ci/bounded_subprocess.py | 51 +++- ...test_bounded_subprocess_capture_startup.py | 4 +- 4 files changed, 43 insertions(+), 327 deletions(-) delete mode 100644 .github/workflows/bounded-capture-startup-ci.yml delete mode 100644 .github/workflows/one-shot-pr767-capture-startup-fix.yml diff --git a/.github/workflows/bounded-capture-startup-ci.yml b/.github/workflows/bounded-capture-startup-ci.yml deleted file mode 100644 index d410d2046..000000000 --- a/.github/workflows/bounded-capture-startup-ci.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Bounded Capture Startup CI - -on: - pull_request: - branches: [main] - paths: - - "scripts/ci/bounded_subprocess.py" - - "tests/test_bounded_subprocess_capture_startup.py" - - ".github/workflows/bounded-capture-startup-ci.yml" - push: - branches: [main] - paths: - - "scripts/ci/bounded_subprocess.py" - - "tests/test_bounded_subprocess_capture_startup.py" - - ".github/workflows/bounded-capture-startup-ci.yml" - -concurrency: - group: bounded-capture-startup-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - capture-startup-contract: - name: Capture startup cleanup contract - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact revision - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Set up current stable Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Install exact test tools - run: >- - python -m pip install --disable-pip-version-check - pytest==9.1.1 - coverage==7.14.3 - interrogate==1.7.0 - - - name: Run capture-startup regression and branch coverage - run: | - python -m coverage run --branch -m pytest -q \ - tests/test_bounded_subprocess_capture_startup.py - python -m coverage report \ - --include='scripts/ci/bounded_subprocess.py' \ - --show-missing - - - name: Enforce production docstrings - run: >- - python -m interrogate --fail-under 100 - scripts/ci/bounded_subprocess.py - - - name: Compile exact surfaces - run: >- - python -m py_compile - scripts/ci/bounded_subprocess.py - tests/test_bounded_subprocess_capture_startup.py diff --git a/.github/workflows/one-shot-pr767-capture-startup-fix.yml b/.github/workflows/one-shot-pr767-capture-startup-fix.yml deleted file mode 100644 index b7a76a6ac..000000000 --- a/.github/workflows/one-shot-pr767-capture-startup-fix.yml +++ /dev/null @@ -1,245 +0,0 @@ -name: One-shot PR 767 bounded capture startup repair - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/one-shot-pr767-capture-startup-fix.yml" - -concurrency: - group: one-shot-pr767-bounded-capture-${{ github.event.pull_request.number }} - cancel-in-progress: false - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'fix/sandboxed-output-resource-bounds' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: write - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact pull-request head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 1 - persist-credentials: false - - - name: Set up current stable Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Install hash-locked review toolchain - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Apply, verify, and materialize bounded repair commit - shell: bash --noprofile --norc -e -o pipefail {0} - env: - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - GH_TOKEN: ${{ github.token }} - run: | - test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" - python3 -I - <<'PY' - from pathlib import Path - - - source_path = Path("scripts/ci/bounded_subprocess.py") - source = source_path.read_text(encoding="utf-8") - - suppress_import = "from contextlib import suppress\n" - if suppress_import not in source: - import_anchor = "import threading\n" - if source.count(import_anchor) != 1: - raise SystemExit(f"{source_path}: invalid threading import anchor") - source = source.replace( - import_anchor, - import_anchor + suppress_import, - 1, - ) - elif source.count(suppress_import) != 1: - raise SystemExit(f"{source_path}: duplicate suppress import") - - helper_marker = "def _cleanup_capture_startup_failure(" - helper = '''def _cleanup_capture_startup_failure( - process: subprocess.Popen[bytes], - captures: Sequence[BoundedOutputCapture], - streams: Sequence[BinaryIO], - ) -> None: - """Best-effort terminate, reap, finalize, and close partial startup state.""" - - with suppress(BaseException): - kill_process_group(process) - with suppress(BaseException): - process.wait(timeout=10) - for capture in captures: - with suppress(BaseException): - capture.join(timeout=10) - for stream in streams: - with suppress(BaseException): - stream.close() - for capture in captures: - with suppress(BaseException): - capture.join(timeout=10) - - - ''' - if helper_marker not in source: - function_anchor = "def run_bounded_command(\n" - if source.count(function_anchor) != 1: - raise SystemExit(f"{source_path}: invalid command-function anchor") - function_offset = source.index(function_anchor) - source = source[:function_offset] + helper + source[function_offset:] - elif source.count(helper_marker) != 1: - raise SystemExit(f"{source_path}: duplicate cleanup helper") - - startup_marker = " captures: list[BoundedOutputCapture] = []\n" - if startup_marker not in source: - function_offset = source.index("def run_bounded_command(\n") - startup_offset = source.index( - " stdout_capture = start_bounded_capture(\n", - function_offset, - ) - timed_out_offset = source.index( - " timed_out = False\n", - startup_offset, - ) - startup = ''' captures: list[BoundedOutputCapture] = [] - streams = (process.stdout, process.stderr) - try: - stdout_capture = start_bounded_capture( - process.stdout, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - captures.append(stdout_capture) - stderr_capture = start_bounded_capture( - process.stderr, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - captures.append(stderr_capture) - except BaseException: # noqa: BLE001 - preserve the startup root cause - _cleanup_capture_startup_failure(process, captures, streams) - raise - ''' - source = source[:startup_offset] + startup + source[timed_out_offset:] - elif source.count(startup_marker) != 1: - raise SystemExit(f"{source_path}: duplicate startup cleanup block") - - source_path.write_text(source, encoding="utf-8") - - test_path = Path("tests/test_bounded_subprocess_capture_startup.py") - test_source = test_path.read_text(encoding="utf-8") - replacements = ( - ( - "assert all(capture.join_calls == 1 for capture in captures)", - "assert all(capture.join_calls == 2 for capture in captures)", - ), - ("assert capture.join_calls == 1", "assert capture.join_calls == 2"), - ) - for old, new in replacements: - old_count = test_source.count(old) - new_count = test_source.count(new) - if old_count == 1 and new_count == 0: - test_source = test_source.replace(old, new, 1) - elif old_count != 0 or new_count != 1: - raise SystemExit( - f"{test_path}: invalid assertion state old={old_count} new={new_count}" - ) - test_path.write_text(test_source, encoding="utf-8") - - Path(".github/workflows/bounded-capture-startup-ci.yml").unlink() - Path(".github/workflows/one-shot-pr767-capture-startup-fix.yml").unlink() - PY - - python -m pytest -q tests/test_bounded_subprocess_capture_startup.py - python -m py_compile \ - scripts/ci/bounded_subprocess.py \ - tests/test_bounded_subprocess_capture_startup.py - python -m interrogate --fail-under 100 scripts/ci/bounded_subprocess.py - git diff --check - test ! -e .github/workflows/bounded-capture-startup-ci.yml - test ! -e .github/workflows/one-shot-pr767-capture-startup-fix.yml - - base_tree="$( - gh api -X GET "repos/${GITHUB_REPOSITORY}/git/commits/${EXPECTED_HEAD}" \ - --jq '.tree.sha' - )" - source_blob="$( - jq -Rs '{content: ., encoding: "utf-8"}' \ - None: raise first_error +def _cleanup_capture_startup_failure( + process: subprocess.Popen[bytes], + captures: Sequence[BoundedOutputCapture], + streams: Sequence[BinaryIO], +) -> None: + """Best-effort terminate, reap, finalize, and close partial startup state.""" + + with suppress(BaseException): + kill_process_group(process) + with suppress(BaseException): + process.wait(timeout=10) + for capture in captures: + with suppress(BaseException): + capture.join(timeout=10) + for stream in streams: + with suppress(BaseException): + stream.close() + for capture in captures: + with suppress(BaseException): + capture.join(timeout=10) + + def run_bounded_command( arguments: Sequence[object], *, @@ -355,16 +378,24 @@ def stop_for_limit() -> None: limit_triggered.set() kill_process_group(process) - stdout_capture = start_bounded_capture( - process.stdout, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - stderr_capture = start_bounded_capture( - process.stderr, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) + captures: list[BoundedOutputCapture] = [] + streams = (process.stdout, process.stderr) + try: + stdout_capture = start_bounded_capture( + process.stdout, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + captures.append(stdout_capture) + stderr_capture = start_bounded_capture( + process.stderr, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + captures.append(stderr_capture) + except BaseException: # noqa: BLE001 - preserve the startup root cause + _cleanup_capture_startup_failure(process, captures, streams) + raise timed_out = False try: process.wait(timeout=timeout_seconds) diff --git a/tests/test_bounded_subprocess_capture_startup.py b/tests/test_bounded_subprocess_capture_startup.py index 8cc9796fb..509b60a2c 100644 --- a/tests/test_bounded_subprocess_capture_startup.py +++ b/tests/test_bounded_subprocess_capture_startup.py @@ -111,7 +111,7 @@ def fake_start(stream, **_kwargs): assert process.wait_calls == 1 assert process.stdout.closed assert process.stderr.closed - assert all(capture.join_calls == 1 for capture in captures) + assert all(capture.join_calls == 2 for capture in captures) def test_capture_startup_preserves_original_error_when_cleanup_fails( @@ -154,6 +154,6 @@ def fake_start(_stream, **_kwargs): assert killed == [process] assert process.wait_calls == 1 - assert capture.join_calls == 1 + assert capture.join_calls == 2 assert process.stdout.closed assert process.stderr.closed From c8a86dc9acb8a85ceb4bcf22cc7642598478b97c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:42:40 +0900 Subject: [PATCH 60/93] test(ci): repair capture-startup cleanup from verified red cases --- .../one-shot-pr767-capture-startup-fix.yml | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-capture-startup-fix.yml diff --git a/.github/workflows/one-shot-pr767-capture-startup-fix.yml b/.github/workflows/one-shot-pr767-capture-startup-fix.yml new file mode 100644 index 000000000..af8550230 --- /dev/null +++ b/.github/workflows/one-shot-pr767-capture-startup-fix.yml @@ -0,0 +1,170 @@ +name: One-shot PR 767 capture startup fix + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/one-shot-pr767-capture-startup-fix.yml" + +concurrency: + group: one-shot-pr767-capture-startup-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'fix/sandboxed-output-resource-bounds' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact pull-request head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked review toolchain + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Apply and verify capture-startup cleanup + shell: bash --noprofile --norc -e -o pipefail {0} + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + HEAD_BRANCH: ${{ github.head_ref }} + PUSH_TOKEN: ${{ github.token }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + python3 -I - <<'PY' + from pathlib import Path + + + def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact reviewed fragment and fail closed on drift.""" + file_path = Path(path) + content = file_path.read_text(encoding="utf-8") + count = content.count(old) + if count != 1: + raise SystemExit( + f"{path}: expected one repair anchor, found {count}" + ) + file_path.write_text(content.replace(old, new, 1), encoding="utf-8") + + + source_path = "scripts/ci/bounded_subprocess.py" + replace_once( + source_path, + "import threading\nfrom collections.abc import Callable, Mapping, Sequence\n", + "import threading\nfrom contextlib import suppress\n" + "from collections.abc import Callable, Mapping, Sequence\n", + ) + replace_once( + source_path, + "def run_bounded_command(\n", + '''def _cleanup_capture_startup_failure( + process: subprocess.Popen[bytes], + captures: Sequence[BoundedOutputCapture], + streams: Sequence[BinaryIO], + ) -> None: + """Terminate, reap, close, and finalize partially started captures.""" + + with suppress(BaseException): + kill_process_group(process) + with suppress(BaseException): + process.wait(timeout=10) + for stream in streams: + with suppress(BaseException): + stream.close() + for capture in captures: + with suppress(BaseException): + capture.join(timeout=10) + + + def run_bounded_command( + ''', + ) + replace_once( + source_path, + ''' stdout_capture = start_bounded_capture( + process.stdout, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + stderr_capture = start_bounded_capture( + process.stderr, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + ''', + ''' captures: list[BoundedOutputCapture] = [] + streams = (process.stdout, process.stderr) + try: + stdout_capture = start_bounded_capture( + process.stdout, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + captures.append(stdout_capture) + stderr_capture = start_bounded_capture( + process.stderr, + evidence_limit_bytes=evidence_limit, + on_limit=stop_for_limit, + ) + captures.append(stderr_capture) + except BaseException: # noqa: BLE001 - preserve the startup root cause + _cleanup_capture_startup_failure(process, captures, streams) + raise + ''', + ) + + Path(".github/workflows/bounded-capture-startup-ci.yml").unlink() + Path(".github/workflows/one-shot-pr767-capture-startup-fix.yml").unlink() + PY + python -m pytest -q \ + tests/test_bounded_subprocess.py \ + tests/test_bounded_subprocess_capture_startup.py \ + tests/test_bounded_subprocess_contract.py \ + tests/test_sandboxed_service_capture_startup.py \ + --cov=scripts.ci.bounded_subprocess \ + --cov-branch \ + --cov-fail-under=100 + python -m pytest -q tests + python -m compileall -q scripts/ci tests + python -m interrogate --fail-under 100 scripts/ci/bounded_subprocess.py + git diff --check + test ! -e .github/workflows/bounded-capture-startup-ci.yml + test ! -e .github/workflows/one-shot-pr767-capture-startup-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && exit 1 + git commit -m "fix(ci): reap partial bounded capture startup" + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ + push origin "HEAD:refs/heads/${HEAD_BRANCH}" From f44ec98cc9a6462e0af37c90a22f24e55c454c9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:45:04 +0900 Subject: [PATCH 61/93] chore(ci): remove completed PR 767 branch writer --- .../one-shot-pr767-capture-startup-fix.yml | 170 ------------------ 1 file changed, 170 deletions(-) delete mode 100644 .github/workflows/one-shot-pr767-capture-startup-fix.yml diff --git a/.github/workflows/one-shot-pr767-capture-startup-fix.yml b/.github/workflows/one-shot-pr767-capture-startup-fix.yml deleted file mode 100644 index af8550230..000000000 --- a/.github/workflows/one-shot-pr767-capture-startup-fix.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: One-shot PR 767 capture startup fix - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/one-shot-pr767-capture-startup-fix.yml" - -concurrency: - group: one-shot-pr767-capture-startup-${{ github.event.pull_request.number }} - cancel-in-progress: false - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'fix/sandboxed-output-resource-bounds' - runs-on: ubuntu-24.04 - timeout-minutes: 20 - permissions: - contents: write - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact pull-request head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 1 - persist-credentials: false - - - name: Set up current stable Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install hash-locked review toolchain - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Apply and verify capture-startup cleanup - shell: bash --noprofile --norc -e -o pipefail {0} - env: - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - HEAD_BRANCH: ${{ github.head_ref }} - PUSH_TOKEN: ${{ github.token }} - run: | - test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" - python3 -I - <<'PY' - from pathlib import Path - - - def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact reviewed fragment and fail closed on drift.""" - file_path = Path(path) - content = file_path.read_text(encoding="utf-8") - count = content.count(old) - if count != 1: - raise SystemExit( - f"{path}: expected one repair anchor, found {count}" - ) - file_path.write_text(content.replace(old, new, 1), encoding="utf-8") - - - source_path = "scripts/ci/bounded_subprocess.py" - replace_once( - source_path, - "import threading\nfrom collections.abc import Callable, Mapping, Sequence\n", - "import threading\nfrom contextlib import suppress\n" - "from collections.abc import Callable, Mapping, Sequence\n", - ) - replace_once( - source_path, - "def run_bounded_command(\n", - '''def _cleanup_capture_startup_failure( - process: subprocess.Popen[bytes], - captures: Sequence[BoundedOutputCapture], - streams: Sequence[BinaryIO], - ) -> None: - """Terminate, reap, close, and finalize partially started captures.""" - - with suppress(BaseException): - kill_process_group(process) - with suppress(BaseException): - process.wait(timeout=10) - for stream in streams: - with suppress(BaseException): - stream.close() - for capture in captures: - with suppress(BaseException): - capture.join(timeout=10) - - - def run_bounded_command( - ''', - ) - replace_once( - source_path, - ''' stdout_capture = start_bounded_capture( - process.stdout, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - stderr_capture = start_bounded_capture( - process.stderr, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - ''', - ''' captures: list[BoundedOutputCapture] = [] - streams = (process.stdout, process.stderr) - try: - stdout_capture = start_bounded_capture( - process.stdout, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - captures.append(stdout_capture) - stderr_capture = start_bounded_capture( - process.stderr, - evidence_limit_bytes=evidence_limit, - on_limit=stop_for_limit, - ) - captures.append(stderr_capture) - except BaseException: # noqa: BLE001 - preserve the startup root cause - _cleanup_capture_startup_failure(process, captures, streams) - raise - ''', - ) - - Path(".github/workflows/bounded-capture-startup-ci.yml").unlink() - Path(".github/workflows/one-shot-pr767-capture-startup-fix.yml").unlink() - PY - python -m pytest -q \ - tests/test_bounded_subprocess.py \ - tests/test_bounded_subprocess_capture_startup.py \ - tests/test_bounded_subprocess_contract.py \ - tests/test_sandboxed_service_capture_startup.py \ - --cov=scripts.ci.bounded_subprocess \ - --cov-branch \ - --cov-fail-under=100 - python -m pytest -q tests - python -m compileall -q scripts/ci tests - python -m interrogate --fail-under 100 scripts/ci/bounded_subprocess.py - git diff --check - test ! -e .github/workflows/bounded-capture-startup-ci.yml - test ! -e .github/workflows/one-shot-pr767-capture-startup-fix.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && exit 1 - git commit -m "fix(ci): reap partial bounded capture startup" - auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" - echo "::add-mask::$auth_header" - git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ - push origin "HEAD:refs/heads/${HEAD_BRANCH}" From 17336a15ca7a1096413875410ecc328999cf586f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:39:15 +0900 Subject: [PATCH 62/93] ci: verify PR 767 completed-process polling fix --- .../one-shot-pr767-zombie-poll-fix.yml | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix.yml diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix.yml new file mode 100644 index 000000000..3ab208a9e --- /dev/null +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix.yml @@ -0,0 +1,132 @@ +name: One-shot PR 767 completed-process polling fix + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/one-shot-pr767-zombie-poll-fix.yml + +permissions: + contents: write + +concurrency: + group: one-shot-pr767-zombie-poll-fix + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-and-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout contributor branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: fix/sandboxed-output-resource-bounds + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked quality tooling + run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt + + - name: Apply completed-process polling repair + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") + script = script_path.read_text(encoding="utf-8") + old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' + new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' + if script.count(old_loop) != 1: + raise SystemExit( + f"expected one kill-zero polling loop, found {script.count(old_loop)}" + ) + script_path.write_text(script.replace(old_loop, new_loop, 1), encoding="utf-8") + + test_path = Path("tests/test_opencode_model_pool_runner.py") + test_source = test_path.read_text(encoding="utf-8") + old_test = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: + """Model prose mentioning fatal signatures never kills a healthy streaming run.""" + result = run_failed_model( + tmp_path, + json_line=( + '{"type":"text","text":"This PR hardens ContextOverflowError and ' + 'context window handling in the model pool."}' + ), + extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, + ) + + assert result.returncode == 1 + assert "logged a fatal provider error while still running" not in result.stdout + '''.replace(" ", "") + new_test = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: + """Model prose mentioning fatal signatures never kills or strands a healthy run.""" + start = time.monotonic() + result = run_failed_model( + tmp_path, + json_line=( + '{"type":"text","text":"This PR hardens ContextOverflowError and ' + 'context window handling in the model pool."}' + ), + extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, + ) + elapsed = time.monotonic() - start + + assert result.returncode == 1 + assert "logged a fatal provider error while still running" not in result.stdout + assert elapsed < 15 + '''.replace(" ", "") + if test_source.count(old_test) != 1: + raise SystemExit( + f"expected one healthy-stream regression, found {test_source.count(old_test)}" + ) + test_path.write_text( + test_source.replace(old_test, new_test, 1), encoding="utf-8" + ) + PY + + - name: Verify focused regression and complete quality contract + run: | + set -euo pipefail + python -m pytest -q \ + tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ + tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ + tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early + python -m coverage erase + python -m coverage run -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish verified repair and remove one-shot workflow + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + run: | + set -euo pipefail + rm .github/workflows/one-shot-pr767-zombie-poll-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + scripts/ci/run_opencode_review_model_pool.sh \ + tests/test_opencode_model_pool_runner.py \ + .github/workflows/one-shot-pr767-zombie-poll-fix.yml + git diff --cached --quiet && { echo "No completed-process polling repair generated" >&2; exit 1; } + git commit -m "fix(opencode): reap completed model attempts" + git push origin "HEAD:${BRANCH_NAME}" From 08ac1033087ea82965b2ee7c09fba34e6a859c9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:45:54 +0900 Subject: [PATCH 63/93] ci: retry PR 767 completed-process polling repair --- .../one-shot-pr767-zombie-poll-fix-v2.yml | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml new file mode 100644 index 000000000..e8d5d3b8d --- /dev/null +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml @@ -0,0 +1,127 @@ +name: One-shot PR 767 completed-process polling fix v2 + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml + +permissions: + contents: write + +concurrency: + group: one-shot-pr767-zombie-poll-fix-v2 + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-and-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout contributor branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: fix/sandboxed-output-resource-bounds + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked quality tooling + run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt + + - name: Apply completed-process polling repair + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") + script = script_path.read_text(encoding="utf-8") + old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' + new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' + if old_loop in script: + if script.count(old_loop) != 1: + raise SystemExit( + f"expected one kill-zero polling loop, found {script.count(old_loop)}" + ) + script = script.replace(old_loop, new_loop, 1) + elif new_loop not in script: + raise SystemExit("completed-process polling loop has an unexpected shape") + script_path.write_text(script, encoding="utf-8") + + test_path = Path("tests/test_opencode_model_pool_runner.py") + test_source = test_path.read_text(encoding="utf-8") + start_marker = ( + "def test_model_text_quoting_error_signatures_does_not_kill_run(" + "tmp_path: Path) -> None:\n" + ) + end_marker = ( + "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" + ) + start = test_source.index(start_marker) + end = test_source.index(end_marker, start) + replacement = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: + """Model prose mentioning fatal signatures never kills or strands a healthy run.""" + start = time.monotonic() + result = run_failed_model( + tmp_path, + json_line=( + '{"type":"text","text":"This PR hardens ContextOverflowError and ' + 'context window handling in the model pool."}' + ), + extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, + ) + elapsed = time.monotonic() - start + + assert result.returncode == 1 + assert "logged a fatal provider error while still running" not in result.stdout + assert elapsed < 15 + '''.replace(" ", "") + test_path.write_text( + test_source[:start] + replacement + test_source[end:], + encoding="utf-8", + ) + PY + + - name: Verify focused regression and complete quality contract + run: | + set -euo pipefail + python -m pytest -q \ + tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ + tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ + tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early + python -m coverage erase + python -m coverage run -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish verified repair and remove one-shot workflows + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + run: | + set -euo pipefail + rm -f \ + .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { echo "No completed-process polling repair generated" >&2; exit 1; } + git commit -m "fix(opencode): reap completed model attempts" + git push origin "HEAD:${BRANCH_NAME}" From 97e22d75db689889b6db1c3d98af4214ac6ddefb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:49:50 +0900 Subject: [PATCH 64/93] ci: verify PR 767 polling and hermetic Git regressions --- .../one-shot-pr767-zombie-poll-fix-v3.yml | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml new file mode 100644 index 000000000..5d23452b3 --- /dev/null +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml @@ -0,0 +1,153 @@ +name: One-shot PR 767 polling and hermetic Git fixes + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml + +permissions: + contents: write + +concurrency: + group: one-shot-pr767-zombie-poll-fix-v3 + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-and-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout contributor branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: fix/sandboxed-output-resource-bounds + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked quality tooling + run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt + + - name: Apply polling and hermetic Git repairs + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") + script = script_path.read_text(encoding="utf-8") + old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' + new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' + if old_loop in script: + if script.count(old_loop) != 1: + raise SystemExit( + f"expected one kill-zero polling loop, found {script.count(old_loop)}" + ) + script = script.replace(old_loop, new_loop, 1) + elif new_loop not in script: + raise SystemExit("completed-process polling loop has an unexpected shape") + script_path.write_text(script, encoding="utf-8") + + model_test_path = Path("tests/test_opencode_model_pool_runner.py") + model_tests = model_test_path.read_text(encoding="utf-8") + start_marker = ( + "def test_model_text_quoting_error_signatures_does_not_kill_run(" + "tmp_path: Path) -> None:\n" + ) + end_marker = ( + "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" + ) + start = model_tests.index(start_marker) + end = model_tests.index(end_marker, start) + replacement = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: + """Model prose mentioning fatal signatures never kills or strands a healthy run.""" + start = time.monotonic() + result = run_failed_model( + tmp_path, + json_line=( + '{"type":"text","text":"This PR hardens ContextOverflowError and ' + 'context window handling in the model pool."}' + ), + extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, + ) + elapsed = time.monotonic() - start + + assert result.returncode == 1 + assert "logged a fatal provider error while still running" not in result.stdout + assert elapsed < 15 + '''.replace(" ", "") + model_test_path.write_text( + model_tests[:start] + replacement + model_tests[end:], + encoding="utf-8", + ) + + contract_path = Path("tests/test_opencode_agent_contract.py") + contracts = contract_path.read_text(encoding="utf-8") + old_env = ''' base_env = { + **os.environ, + "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", + } + '''.replace(" ", "") + new_env = ''' base_env = { + **os.environ, + "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + } + '''.replace(" ", "") + if old_env in contracts: + if contracts.count(old_env) != 1: + raise SystemExit( + f"expected one sandbox Git base environment, found {contracts.count(old_env)}" + ) + contracts = contracts.replace(old_env, new_env, 1) + elif new_env not in contracts: + raise SystemExit("sandbox Git environment has an unexpected shape") + contract_path.write_text(contracts, encoding="utf-8") + PY + + - name: Verify focused regressions and complete quality contract + run: | + set -euo pipefail + python -m pytest -q \ + tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ + tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ + tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early \ + tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe + python -m coverage erase + python -m coverage run -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish verified repair and remove one-shot workflows + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + run: | + set -euo pipefail + rm -f \ + .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { echo "No polling and Git isolation repair generated" >&2; exit 1; } + git commit -m "fix(opencode): reap completed model attempts" + git push origin "HEAD:${BRANCH_NAME}" From a950818ba58b471c6d68b2c7271aa5dc40d609a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:50:55 +0900 Subject: [PATCH 65/93] ci(pr767): harden and rerun completed-process repair --- .../one-shot-pr767-zombie-poll-fix-v2.yml | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml index e8d5d3b8d..9321ad215 100644 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml @@ -7,7 +7,7 @@ on: - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml permissions: - contents: write + contents: read concurrency: group: one-shot-pr767-zombie-poll-fix-v2 @@ -18,6 +18,12 @@ env: jobs: repair-and-verify: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + permissions: + contents: write runs-on: ubuntu-24.04 timeout-minutes: 55 steps: @@ -29,8 +35,9 @@ jobs: - name: Checkout contributor branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: fix/sandboxed-output-resource-bounds + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -40,11 +47,12 @@ jobs: cache-dependency-path: requirements-opencode-review-ci-hashes.txt - name: Install exact locked quality tooling + shell: bash --noprofile --norc -e -o pipefail {0} run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - name: Apply completed-process polling repair + shell: bash --noprofile --norc -e -o pipefail {0} run: | - set -euo pipefail python - <<'PY' from pathlib import Path @@ -97,8 +105,8 @@ jobs: PY - name: Verify focused regression and complete quality contract + shell: bash --noprofile --norc -e -o pipefail {0} run: | - set -euo pipefail python -m pytest -q \ tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ @@ -112,10 +120,11 @@ jobs: git diff --check - name: Publish verified repair and remove one-shot workflows + shell: bash --noprofile --norc -e -o pipefail {0} env: BRANCH_NAME: fix/sandboxed-output-resource-bounds + GITHUB_TOKEN: ${{ github.token }} run: | - set -euo pipefail rm -f \ .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml @@ -124,4 +133,6 @@ jobs: git add -A git diff --cached --quiet && { echo "No completed-process polling repair generated" >&2; exit 1; } git commit -m "fix(opencode): reap completed model attempts" - git push origin "HEAD:${BRANCH_NAME}" + git push \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:${BRANCH_NAME}" From 7eb51887171c9f49fa368c1c4c4d389e3e5d13b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:52:46 +0900 Subject: [PATCH 66/93] ci: finalize PR 767 polling and Git isolation repair --- .../one-shot-pr767-zombie-poll-fix-v4.yml | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml new file mode 100644 index 000000000..5439d1dd5 --- /dev/null +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml @@ -0,0 +1,151 @@ +name: One-shot PR 767 polling and Git isolation finalizer + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml + +permissions: + contents: write + +concurrency: + group: one-shot-pr767-zombie-poll-fix-v4 + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-and-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout contributor branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: fix/sandboxed-output-resource-bounds + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked quality tooling + run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt + + - name: Apply polling and hermetic Git repairs + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") + script = script_path.read_text(encoding="utf-8") + old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' + new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' + if old_loop in script: + if script.count(old_loop) != 1: + raise SystemExit( + f"expected one kill-zero polling loop, found {script.count(old_loop)}" + ) + script = script.replace(old_loop, new_loop, 1) + elif new_loop not in script: + raise SystemExit("completed-process polling loop has an unexpected shape") + script_path.write_text(script, encoding="utf-8") + + model_test_path = Path("tests/test_opencode_model_pool_runner.py") + model_tests = model_test_path.read_text(encoding="utf-8") + model_start_marker = ( + "def test_model_text_quoting_error_signatures_does_not_kill_run(" + "tmp_path: Path) -> None:\n" + ) + model_end_marker = ( + "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" + ) + model_start = model_tests.index(model_start_marker) + model_end = model_tests.index(model_end_marker, model_start) + model_replacement = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: + """Model prose mentioning fatal signatures never kills or strands a healthy run.""" + start = time.monotonic() + result = run_failed_model( + tmp_path, + json_line=( + '{"type":"text","text":"This PR hardens ContextOverflowError and ' + 'context window handling in the model pool."}' + ), + extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, + ) + elapsed = time.monotonic() - start + + assert result.returncode == 1 + assert "logged a fatal provider error while still running" not in result.stdout + assert elapsed < 15 +''' + model_test_path.write_text( + model_tests[:model_start] + + model_replacement + + model_tests[model_end:], + encoding="utf-8", + ) + + contract_path = Path("tests/test_opencode_agent_contract.py") + contracts = contract_path.read_text(encoding="utf-8") + function_marker = ( + "def test_sandbox_git_config_env_marks_only_the_validated_worktree_safe(" + "tmp_path):\n" + ) + function_start = contracts.index(function_marker) + env_start = contracts.index(" base_env = {\n", function_start) + env_end = contracts.index(" refused = subprocess.run(\n", env_start) + env_replacement = ''' base_env = { + **os.environ, + "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + } +''' + contracts = contracts[:env_start] + env_replacement + contracts[env_end:] + contract_path.write_text(contracts, encoding="utf-8") + PY + + - name: Verify focused regressions and complete quality contract + run: | + set -euo pipefail + python -m pytest -q \ + tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ + tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ + tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early \ + tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe + python -m coverage erase + python -m coverage run -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish verified repair and remove one-shot workflows + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + run: | + set -euo pipefail + rm -f \ + .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { echo "No polling and Git isolation repair generated" >&2; exit 1; } + git commit -m "fix(opencode): reap completed model attempts" + git push origin "HEAD:${BRANCH_NAME}" From e4ddf70cfdab15b34ebaef3ddf9bc01a92428c70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:54:24 +0900 Subject: [PATCH 67/93] ci: run final PR 767 polling repair verification --- .../one-shot-pr767-zombie-poll-fix-v5.yml | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml new file mode 100644 index 000000000..b779d8997 --- /dev/null +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml @@ -0,0 +1,152 @@ +name: One-shot PR 767 polling repair final verification + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml + +permissions: + contents: write + +concurrency: + group: one-shot-pr767-zombie-poll-fix-v5 + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-and-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout contributor branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: fix/sandboxed-output-resource-bounds + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked quality tooling + run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt + + - name: Apply polling and hermetic Git repairs + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") + script = script_path.read_text(encoding="utf-8") + old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' + new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' + if old_loop in script: + if script.count(old_loop) != 1: + raise SystemExit( + f"expected one kill-zero polling loop, found {script.count(old_loop)}" + ) + script = script.replace(old_loop, new_loop, 1) + elif new_loop not in script: + raise SystemExit("completed-process polling loop has an unexpected shape") + script_path.write_text(script, encoding="utf-8") + + model_test_path = Path("tests/test_opencode_model_pool_runner.py") + model_tests = model_test_path.read_text(encoding="utf-8") + model_start_marker = ( + "def test_model_text_quoting_error_signatures_does_not_kill_run(" + "tmp_path: Path) -> None:\n" + ) + model_end_marker = ( + "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" + ) + model_start = model_tests.index(model_start_marker) + model_end = model_tests.index(model_end_marker, model_start) + model_replacement = ( + "def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None:\n" + " \"\"\"Model prose mentioning fatal signatures never kills or strands a healthy run.\"\"\"\n" + " start = time.monotonic()\n" + " result = run_failed_model(\n" + " tmp_path,\n" + " json_line=(\n" + " '{\"type\":\"text\",\"text\":\"This PR hardens ContextOverflowError and '\n" + " 'context window handling in the model pool.\"}'\n" + " ),\n" + " extra_env={\"FAKE_OPENCODE_HANG_SECONDS\": \"4\"},\n" + " )\n" + " elapsed = time.monotonic() - start\n" + "\n" + " assert result.returncode == 1\n" + " assert \"logged a fatal provider error while still running\" not in result.stdout\n" + " assert elapsed < 15\n" + ) + model_test_path.write_text( + model_tests[:model_start] + model_replacement + model_tests[model_end:], + encoding="utf-8", + ) + + contract_path = Path("tests/test_opencode_agent_contract.py") + contracts = contract_path.read_text(encoding="utf-8") + function_marker = ( + "def test_sandbox_git_config_env_marks_only_the_validated_worktree_safe(" + "tmp_path):\n" + ) + function_start = contracts.index(function_marker) + env_start = contracts.index(" base_env = {\n", function_start) + env_end = contracts.index(" refused = subprocess.run(\n", env_start) + env_replacement = ( + " base_env = {\n" + " **os.environ,\n" + " \"GIT_TEST_ASSUME_DIFFERENT_OWNER\": \"1\",\n" + " \"GIT_CONFIG_NOSYSTEM\": \"1\",\n" + " \"GIT_CONFIG_GLOBAL\": \"/dev/null\",\n" + " }\n" + ) + contracts = contracts[:env_start] + env_replacement + contracts[env_end:] + contract_path.write_text(contracts, encoding="utf-8") + PY + + - name: Verify focused regressions and complete quality contract + run: | + set -euo pipefail + python -m pytest -q \ + tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ + tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ + tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early \ + tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe + python -m coverage erase + python -m coverage run -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish verified repair and remove one-shot workflows + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + run: | + set -euo pipefail + rm -f \ + .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml \ + .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { echo "No polling and Git isolation repair generated" >&2; exit 1; } + git commit -m "fix(opencode): reap completed model attempts" + git push origin "HEAD:${BRANCH_NAME}" From edb328e77ec06797d2e1ea49403f489575adf0ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:55:41 +0900 Subject: [PATCH 68/93] test(coverage): expose sandbox entrypoint and cleanup gaps --- ...ndboxed_entrypoint_and_cleanup_coverage.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_sandboxed_entrypoint_and_cleanup_coverage.py diff --git a/tests/test_sandboxed_entrypoint_and_cleanup_coverage.py b/tests/test_sandboxed_entrypoint_and_cleanup_coverage.py new file mode 100644 index 000000000..76986a617 --- /dev/null +++ b/tests/test_sandboxed_entrypoint_and_cleanup_coverage.py @@ -0,0 +1,96 @@ +import runpy +import subprocess +import sys +from pathlib import Path + +from scripts.ci import bounded_subprocess, sandboxed_verify, sandboxed_web_e2e + + +def test_sandboxed_verify_direct_file_import_bootstraps_repository_path(): + """Direct-file loading executes the repository-path bootstrap branch.""" + + namespace = runpy.run_path( + str(Path(sandboxed_verify.__file__)), + run_name="sandboxed_verify_import_probe", + ) + + assert namespace["RESULT_MARKER"] == sandboxed_verify.RESULT_MARKER + + +def test_web_e2e_reports_bounded_capture_finalization_failure( + monkeypatch, + tmp_path, + capsys, +): + """A service-capture finalization failure remains a bounded hard failure.""" + + repo = tmp_path / "repo" + repo.mkdir() + + class DoneProcess: + def poll(self): + return 0 + + def fake_start( + label, + command, + cwd, + env, + logs_dir, + log_limit_bytes=bounded_subprocess.DEFAULT_SERVICE_LOG_LIMIT_BYTES, + ): + del cwd, env + log_path = logs_dir / f"{label}.log" + log_path.write_text(f"{label} ready\n", encoding="utf-8") + return sandboxed_web_e2e.Service( + label=label, + command=command, + process=DoneProcess(), + log_path=log_path, + log_limit_bytes=log_limit_bytes, + ) + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start) + monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", lambda *args: True) + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=["e2e"], + returncode=0, + stdout="ok\n", + stderr="", + ), + ) + + def fail_capture_finalization(service): + raise OSError(f"cannot finalize {service.label}") + + monkeypatch.setattr( + sandboxed_web_e2e, + "stop_service", + fail_capture_finalization, + ) + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(repo), + "--backend-cmd", + "backend", + "--frontend-cmd", + "frontend", + "--backend-ready-url", + "http://127.0.0.1:8000/health", + "--frontend-ready-url", + "http://127.0.0.1:3000/", + "--e2e-cmd", + "e2e", + ] + ) + captured = capsys.readouterr() + + assert exit_code == bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE + assert captured.err.count("bounded service capture failed") == 2 + assert f'"exit_code": {bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE}' in captured.out + assert '"output_limited": true' in captured.out From 1420c6b5a0df6629f44d9488495cb6b7ec5218c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:05:47 +0900 Subject: [PATCH 69/93] ci: stage exact-head PR 767 coverage timeout repair --- .../one-shot-pr767-coverage-timeout.yml | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-coverage-timeout.yml diff --git a/.github/workflows/one-shot-pr767-coverage-timeout.yml b/.github/workflows/one-shot-pr767-coverage-timeout.yml new file mode 100644 index 000000000..6b379b00c --- /dev/null +++ b/.github/workflows/one-shot-pr767-coverage-timeout.yml @@ -0,0 +1,178 @@ +name: One-shot repair PR 767 coverage command headroom + +on: + push: + branches: + - fix/sandboxed-output-resource-bounds + paths: + - .github/workflows/one-shot-pr767-coverage-timeout.yml + +concurrency: + group: one-shot-pr767-coverage-timeout + cancel-in-progress: false + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact repair trigger + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Add failing timeout contract + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + python - <<'PY' + from pathlib import Path + + path = Path("tests/test_opencode_agent_contract.py") + source = path.read_text(encoding="utf-8") + anchor = '''def test_opencode_python_coverage_never_resolves_pr_dependency_manifests(): + """Use only the trusted image toolchain during networkless PR execution.""" + ''' + contract = '''def test_opencode_coverage_commands_have_bounded_large_repository_headroom(): + """Full repository evidence gets 30 minutes without becoming unbounded.""" + workflow = Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8") + measure = workflow.split( + " - name: Measure test and docstring evidence\\n", 1 + )[1].split("\\n - name:", 1)[0] + + assert measure.count("timeout --kill-after=20 1800 setpriv") == 3 + assert "timeout --kill-after=20 900 setpriv" not in measure + + + ''' + if contract in source: + raise SystemExit("timeout contract unexpectedly already exists") + if source.count(anchor) != 1: + raise SystemExit("timeout contract insertion anchor moved") + path.write_text(source.replace(anchor, contract + anchor, 1), encoding="utf-8") + PY + set +e + python -m pytest -q \ + tests/test_opencode_agent_contract.py::test_opencode_coverage_commands_have_bounded_large_repository_headroom \ + >"${RUNNER_TEMP}/red.log" 2>&1 + red_status=$? + set -e + cat "${RUNNER_TEMP}/red.log" + test "$red_status" -ne 0 + grep -F 'assert 0 == 3' "${RUNNER_TEMP}/red.log" + + - name: Apply bounded command headroom and doctoring + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python - <<'PY' + from pathlib import Path + + workflow = Path(".github/workflows/opencode-review-dispatch.yml") + source = workflow.read_text(encoding="utf-8") + old = "timeout --kill-after=20 900 setpriv" + new = "timeout --kill-after=20 1800 setpriv" + if source.count(old) != 3: + raise SystemExit("expected exactly three 900-second coverage runners") + workflow.write_text(source.replace(old, new), encoding="utf-8") + + changelog = Path("CHANGELOG.md") + source = changelog.read_text(encoding="utf-8") + anchor = "### Changed\n\n" + entry = ( + "- Increase each isolated coverage-evidence command budget from 15 to 30 minutes while retaining a hard GNU `timeout` boundary and a 20-second forced-kill grace period, so complete large-repository suites can finish without converting valid evidence into timeout failures.\n" + ) + if entry not in source: + if source.count(anchor) != 1: + raise SystemExit("CHANGELOG Changed anchor moved") + source = source.replace(anchor, anchor + entry, 1) + changelog.write_text(source, encoding="utf-8") + + doctoring = Path("docs/doctoring/sandboxed-output-resource-bounds.md") + source = doctoring.read_text(encoding="utf-8") + section = ''' + ## Coverage-evidence command headroom + + The OpenCode coverage sandbox keeps every repository-selected verification command bounded, but the former 900-second limit was insufficient for the central repository's complete regression suite after realistic model-pool and process-cleanup tests were added. The suite could pass targeted production coverage and docstring checks yet be terminated before complete repository evidence finished. + + Each of the three isolated command runners now receives 1,800 seconds plus a 20-second forced-kill grace period. This is not an unbounded retry or a relaxation of evidence: a timeout still fails closed, descendants remain under the same sandbox identity and process boundary, and the surrounding GitHub Actions job remains explicitly bounded. GitHub permits positive integer step and job timeouts up to 360 minutes, while GNU `timeout --kill-after` sends a final `KILL` only after the initial timeout signal and grace interval. The 30-minute command ceiling therefore remains materially below the workflow job ceiling while allowing the exact current-head full suite to complete. + + A permanent workflow-contract test requires exactly three 1,800-second bounded runners and rejects the former 900-second form. Regressions must be diagnosed by exact-head execution rather than by treating a timeout as passing evidence. + ''' + section = "\n".join(line[10:] if line.startswith(" ") else line for line in section.splitlines()) + marker = "\n## Limitations\n" + if "## Coverage-evidence command headroom" not in source: + if source.count(marker) != 1: + raise SystemExit("doctoring limitations anchor moved") + source = source.replace(marker, section + marker, 1) + references = ''' + + GitHub. (n.d.). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + + Free Software Foundation. (2026). *timeout: Run a command with a time limit* (GNU Coreutils 9.10 manual). https://www.gnu.org/software/coreutils/timeout + ''' + references = "\n".join(line[10:] if line.startswith(" ") else line for line in references.splitlines()) + if "GNU Coreutils 9.10 manual" not in source: + source = source.rstrip() + references + "\n" + doctoring.write_text(source, encoding="utf-8") + PY + + - name: Verify repair + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m pytest -q \ + tests/test_opencode_agent_contract.py::test_opencode_coverage_commands_have_bounded_large_repository_headroom \ + tests/test_opencode_agent_contract.py::test_opencode_privileged_review_security_boundaries_are_fail_closed + python -m compileall -q tests/test_opencode_agent_contract.py + python - <<'PY' + from pathlib import Path + import yaml + + yaml.safe_load(Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8")) + PY + git diff --check + + - name: Publish verified repair and remove one-shot workflow + env: + PUSH_TOKEN: ${{ github.token }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + remote_head="$(git ls-remote origin refs/heads/fix/sandboxed-output-resource-bounds | cut -f1)" + test "$remote_head" = "$GITHUB_SHA" + rm -f .github/workflows/one-shot-pr767-coverage-timeout.yml + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(opencode): allow bounded large-repository coverage evidence" + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ + push origin HEAD:fix/sandboxed-output-resource-bounds From 9b9d984172289f63f8d185ff5366baf2d33f72eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:06:08 +0900 Subject: [PATCH 70/93] ci(pr767): bound and clean completed-process repair --- .../one-shot-pr767-zombie-poll-fix-v5.yml | 93 ++++++++----------- 1 file changed, 37 insertions(+), 56 deletions(-) diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml index b779d8997..789f5492d 100644 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml +++ b/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml @@ -1,4 +1,4 @@ -name: One-shot PR 767 polling repair final verification +name: One-shot PR 767 completed-process polling repair on: push: @@ -7,10 +7,10 @@ on: - .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml permissions: - contents: write + contents: read concurrency: - group: one-shot-pr767-zombie-poll-fix-v5 + group: one-shot-pr767-zombie-poll-fix cancel-in-progress: true env: @@ -18,19 +18,26 @@ env: jobs: repair-and-verify: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + permissions: + contents: write runs-on: ubuntu-24.04 - timeout-minutes: 55 + timeout-minutes: 30 steps: - name: Harden runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - - name: Checkout contributor branch + - name: Checkout exact contributor head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: fix/sandboxed-output-resource-bounds + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -39,12 +46,13 @@ jobs: cache: pip cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - name: Install exact locked quality tooling + - name: Install exact locked test tooling + shell: bash --noprofile --norc -e -o pipefail {0} run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - name: Apply polling and hermetic Git repairs + - name: Apply completed-process polling repair + shell: bash --noprofile --norc -e -o pipefail {0} run: | - set -euo pipefail python - <<'PY' from pathlib import Path @@ -58,22 +66,20 @@ jobs: f"expected one kill-zero polling loop, found {script.count(old_loop)}" ) script = script.replace(old_loop, new_loop, 1) - elif new_loop not in script: + elif script.count(new_loop) != 1: raise SystemExit("completed-process polling loop has an unexpected shape") script_path.write_text(script, encoding="utf-8") - model_test_path = Path("tests/test_opencode_model_pool_runner.py") - model_tests = model_test_path.read_text(encoding="utf-8") - model_start_marker = ( + test_path = Path("tests/test_opencode_model_pool_runner.py") + source = test_path.read_text(encoding="utf-8") + start_marker = ( "def test_model_text_quoting_error_signatures_does_not_kill_run(" "tmp_path: Path) -> None:\n" ) - model_end_marker = ( - "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" - ) - model_start = model_tests.index(model_start_marker) - model_end = model_tests.index(model_end_marker, model_start) - model_replacement = ( + end_marker = "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" + start = source.index(start_marker) + end = source.index(end_marker, start) + replacement = ( "def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None:\n" " \"\"\"Model prose mentioning fatal signatures never kills or strands a healthy run.\"\"\"\n" " start = time.monotonic()\n" @@ -91,53 +97,26 @@ jobs: " assert \"logged a fatal provider error while still running\" not in result.stdout\n" " assert elapsed < 15\n" ) - model_test_path.write_text( - model_tests[:model_start] + model_replacement + model_tests[model_end:], - encoding="utf-8", - ) - - contract_path = Path("tests/test_opencode_agent_contract.py") - contracts = contract_path.read_text(encoding="utf-8") - function_marker = ( - "def test_sandbox_git_config_env_marks_only_the_validated_worktree_safe(" - "tmp_path):\n" - ) - function_start = contracts.index(function_marker) - env_start = contracts.index(" base_env = {\n", function_start) - env_end = contracts.index(" refused = subprocess.run(\n", env_start) - env_replacement = ( - " base_env = {\n" - " **os.environ,\n" - " \"GIT_TEST_ASSUME_DIFFERENT_OWNER\": \"1\",\n" - " \"GIT_CONFIG_NOSYSTEM\": \"1\",\n" - " \"GIT_CONFIG_GLOBAL\": \"/dev/null\",\n" - " }\n" - ) - contracts = contracts[:env_start] + env_replacement + contracts[env_end:] - contract_path.write_text(contracts, encoding="utf-8") + test_path.write_text(source[:start] + replacement + source[end:], encoding="utf-8") PY - - name: Verify focused regressions and complete quality contract + - name: Verify focused behavior and syntax + shell: bash --noprofile --norc -e -o pipefail {0} run: | - set -euo pipefail python -m pytest -q \ tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ - tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early \ - tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe - python -m coverage erase - python -m coverage run -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests + tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early + python -m compileall -q tests/test_opencode_model_pool_runner.py bash -n scripts/ci/run_opencode_review_model_pool.sh git diff --check - - name: Publish verified repair and remove one-shot workflows + - name: Publish verified repair and remove every one-shot workflow + shell: bash --noprofile --norc -e -o pipefail {0} env: BRANCH_NAME: fix/sandboxed-output-resource-bounds + GITHUB_TOKEN: ${{ github.token }} run: | - set -euo pipefail rm -f \ .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ @@ -147,6 +126,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A - git diff --cached --quiet && { echo "No polling and Git isolation repair generated" >&2; exit 1; } + git diff --cached --quiet && { echo "No completed-process repair generated" >&2; exit 1; } git commit -m "fix(opencode): reap completed model attempts" - git push origin "HEAD:${BRANCH_NAME}" + git push \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:${BRANCH_NAME}" From 0bd3d888cd84981476521baad43929b077f5849e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:06:51 +0000 Subject: [PATCH 71/93] fix(opencode): reap completed model attempts --- .../one-shot-pr767-zombie-poll-fix-v2.yml | 138 ---------------- .../one-shot-pr767-zombie-poll-fix-v3.yml | 153 ------------------ .../one-shot-pr767-zombie-poll-fix-v4.yml | 151 ----------------- .../one-shot-pr767-zombie-poll-fix-v5.yml | 133 --------------- .../one-shot-pr767-zombie-poll-fix.yml | 132 --------------- scripts/ci/run_opencode_review_model_pool.sh | 2 +- tests/test_opencode_model_pool_runner.py | 5 +- 7 files changed, 5 insertions(+), 709 deletions(-) delete mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml delete mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml delete mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml delete mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml delete mode 100644 .github/workflows/one-shot-pr767-zombie-poll-fix.yml diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml deleted file mode 100644 index 9321ad215..000000000 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: One-shot PR 767 completed-process polling fix v2 - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml - -permissions: - contents: read - -concurrency: - group: one-shot-pr767-zombie-poll-fix-v2 - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-and-verify: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - permissions: - contents: write - runs-on: ubuntu-24.04 - timeout-minutes: 55 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout contributor branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked quality tooling - shell: bash --noprofile --norc -e -o pipefail {0} - run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - - name: Apply completed-process polling repair - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python - <<'PY' - from pathlib import Path - - script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") - script = script_path.read_text(encoding="utf-8") - old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' - new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' - if old_loop in script: - if script.count(old_loop) != 1: - raise SystemExit( - f"expected one kill-zero polling loop, found {script.count(old_loop)}" - ) - script = script.replace(old_loop, new_loop, 1) - elif new_loop not in script: - raise SystemExit("completed-process polling loop has an unexpected shape") - script_path.write_text(script, encoding="utf-8") - - test_path = Path("tests/test_opencode_model_pool_runner.py") - test_source = test_path.read_text(encoding="utf-8") - start_marker = ( - "def test_model_text_quoting_error_signatures_does_not_kill_run(" - "tmp_path: Path) -> None:\n" - ) - end_marker = ( - "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" - ) - start = test_source.index(start_marker) - end = test_source.index(end_marker, start) - replacement = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: - """Model prose mentioning fatal signatures never kills or strands a healthy run.""" - start = time.monotonic() - result = run_failed_model( - tmp_path, - json_line=( - '{"type":"text","text":"This PR hardens ContextOverflowError and ' - 'context window handling in the model pool."}' - ), - extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, - ) - elapsed = time.monotonic() - start - - assert result.returncode == 1 - assert "logged a fatal provider error while still running" not in result.stdout - assert elapsed < 15 - '''.replace(" ", "") - test_path.write_text( - test_source[:start] + replacement + test_source[end:], - encoding="utf-8", - ) - PY - - - name: Verify focused regression and complete quality contract - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python -m pytest -q \ - tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ - tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ - tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early - python -m coverage erase - python -m coverage run -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests - bash -n scripts/ci/run_opencode_review_model_pool.sh - git diff --check - - - name: Publish verified repair and remove one-shot workflows - shell: bash --noprofile --norc -e -o pipefail {0} - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - GITHUB_TOKEN: ${{ github.token }} - run: | - rm -f \ - .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && { echo "No completed-process polling repair generated" >&2; exit 1; } - git commit -m "fix(opencode): reap completed model attempts" - git push \ - "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:${BRANCH_NAME}" diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml deleted file mode 100644 index 5d23452b3..000000000 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml +++ /dev/null @@ -1,153 +0,0 @@ -name: One-shot PR 767 polling and hermetic Git fixes - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml - -permissions: - contents: write - -concurrency: - group: one-shot-pr767-zombie-poll-fix-v3 - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-and-verify: - runs-on: ubuntu-24.04 - timeout-minutes: 55 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout contributor branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: fix/sandboxed-output-resource-bounds - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked quality tooling - run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - - name: Apply polling and hermetic Git repairs - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") - script = script_path.read_text(encoding="utf-8") - old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' - new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' - if old_loop in script: - if script.count(old_loop) != 1: - raise SystemExit( - f"expected one kill-zero polling loop, found {script.count(old_loop)}" - ) - script = script.replace(old_loop, new_loop, 1) - elif new_loop not in script: - raise SystemExit("completed-process polling loop has an unexpected shape") - script_path.write_text(script, encoding="utf-8") - - model_test_path = Path("tests/test_opencode_model_pool_runner.py") - model_tests = model_test_path.read_text(encoding="utf-8") - start_marker = ( - "def test_model_text_quoting_error_signatures_does_not_kill_run(" - "tmp_path: Path) -> None:\n" - ) - end_marker = ( - "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" - ) - start = model_tests.index(start_marker) - end = model_tests.index(end_marker, start) - replacement = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: - """Model prose mentioning fatal signatures never kills or strands a healthy run.""" - start = time.monotonic() - result = run_failed_model( - tmp_path, - json_line=( - '{"type":"text","text":"This PR hardens ContextOverflowError and ' - 'context window handling in the model pool."}' - ), - extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, - ) - elapsed = time.monotonic() - start - - assert result.returncode == 1 - assert "logged a fatal provider error while still running" not in result.stdout - assert elapsed < 15 - '''.replace(" ", "") - model_test_path.write_text( - model_tests[:start] + replacement + model_tests[end:], - encoding="utf-8", - ) - - contract_path = Path("tests/test_opencode_agent_contract.py") - contracts = contract_path.read_text(encoding="utf-8") - old_env = ''' base_env = { - **os.environ, - "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", - } - '''.replace(" ", "") - new_env = ''' base_env = { - **os.environ, - "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_CONFIG_GLOBAL": "/dev/null", - } - '''.replace(" ", "") - if old_env in contracts: - if contracts.count(old_env) != 1: - raise SystemExit( - f"expected one sandbox Git base environment, found {contracts.count(old_env)}" - ) - contracts = contracts.replace(old_env, new_env, 1) - elif new_env not in contracts: - raise SystemExit("sandbox Git environment has an unexpected shape") - contract_path.write_text(contracts, encoding="utf-8") - PY - - - name: Verify focused regressions and complete quality contract - run: | - set -euo pipefail - python -m pytest -q \ - tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ - tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ - tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early \ - tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe - python -m coverage erase - python -m coverage run -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests - bash -n scripts/ci/run_opencode_review_model_pool.sh - git diff --check - - - name: Publish verified repair and remove one-shot workflows - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - run: | - set -euo pipefail - rm -f \ - .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && { echo "No polling and Git isolation repair generated" >&2; exit 1; } - git commit -m "fix(opencode): reap completed model attempts" - git push origin "HEAD:${BRANCH_NAME}" diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml deleted file mode 100644 index 5439d1dd5..000000000 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: One-shot PR 767 polling and Git isolation finalizer - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml - -permissions: - contents: write - -concurrency: - group: one-shot-pr767-zombie-poll-fix-v4 - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-and-verify: - runs-on: ubuntu-24.04 - timeout-minutes: 55 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout contributor branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: fix/sandboxed-output-resource-bounds - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked quality tooling - run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - - name: Apply polling and hermetic Git repairs - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") - script = script_path.read_text(encoding="utf-8") - old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' - new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' - if old_loop in script: - if script.count(old_loop) != 1: - raise SystemExit( - f"expected one kill-zero polling loop, found {script.count(old_loop)}" - ) - script = script.replace(old_loop, new_loop, 1) - elif new_loop not in script: - raise SystemExit("completed-process polling loop has an unexpected shape") - script_path.write_text(script, encoding="utf-8") - - model_test_path = Path("tests/test_opencode_model_pool_runner.py") - model_tests = model_test_path.read_text(encoding="utf-8") - model_start_marker = ( - "def test_model_text_quoting_error_signatures_does_not_kill_run(" - "tmp_path: Path) -> None:\n" - ) - model_end_marker = ( - "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" - ) - model_start = model_tests.index(model_start_marker) - model_end = model_tests.index(model_end_marker, model_start) - model_replacement = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: - """Model prose mentioning fatal signatures never kills or strands a healthy run.""" - start = time.monotonic() - result = run_failed_model( - tmp_path, - json_line=( - '{"type":"text","text":"This PR hardens ContextOverflowError and ' - 'context window handling in the model pool."}' - ), - extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, - ) - elapsed = time.monotonic() - start - - assert result.returncode == 1 - assert "logged a fatal provider error while still running" not in result.stdout - assert elapsed < 15 -''' - model_test_path.write_text( - model_tests[:model_start] - + model_replacement - + model_tests[model_end:], - encoding="utf-8", - ) - - contract_path = Path("tests/test_opencode_agent_contract.py") - contracts = contract_path.read_text(encoding="utf-8") - function_marker = ( - "def test_sandbox_git_config_env_marks_only_the_validated_worktree_safe(" - "tmp_path):\n" - ) - function_start = contracts.index(function_marker) - env_start = contracts.index(" base_env = {\n", function_start) - env_end = contracts.index(" refused = subprocess.run(\n", env_start) - env_replacement = ''' base_env = { - **os.environ, - "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_CONFIG_GLOBAL": "/dev/null", - } -''' - contracts = contracts[:env_start] + env_replacement + contracts[env_end:] - contract_path.write_text(contracts, encoding="utf-8") - PY - - - name: Verify focused regressions and complete quality contract - run: | - set -euo pipefail - python -m pytest -q \ - tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ - tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ - tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early \ - tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe - python -m coverage erase - python -m coverage run -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests - bash -n scripts/ci/run_opencode_review_model_pool.sh - git diff --check - - - name: Publish verified repair and remove one-shot workflows - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - run: | - set -euo pipefail - rm -f \ - .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && { echo "No polling and Git isolation repair generated" >&2; exit 1; } - git commit -m "fix(opencode): reap completed model attempts" - git push origin "HEAD:${BRANCH_NAME}" diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml deleted file mode 100644 index 789f5492d..000000000 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: One-shot PR 767 completed-process polling repair - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml - -permissions: - contents: read - -concurrency: - group: one-shot-pr767-zombie-poll-fix - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-and-verify: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - permissions: - contents: write - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact contributor head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked test tooling - shell: bash --noprofile --norc -e -o pipefail {0} - run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - - name: Apply completed-process polling repair - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python - <<'PY' - from pathlib import Path - - script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") - script = script_path.read_text(encoding="utf-8") - old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' - new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' - if old_loop in script: - if script.count(old_loop) != 1: - raise SystemExit( - f"expected one kill-zero polling loop, found {script.count(old_loop)}" - ) - script = script.replace(old_loop, new_loop, 1) - elif script.count(new_loop) != 1: - raise SystemExit("completed-process polling loop has an unexpected shape") - script_path.write_text(script, encoding="utf-8") - - test_path = Path("tests/test_opencode_model_pool_runner.py") - source = test_path.read_text(encoding="utf-8") - start_marker = ( - "def test_model_text_quoting_error_signatures_does_not_kill_run(" - "tmp_path: Path) -> None:\n" - ) - end_marker = "\n\ndef test_delisted_openrouter_model_error_kills_hung_run_early" - start = source.index(start_marker) - end = source.index(end_marker, start) - replacement = ( - "def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None:\n" - " \"\"\"Model prose mentioning fatal signatures never kills or strands a healthy run.\"\"\"\n" - " start = time.monotonic()\n" - " result = run_failed_model(\n" - " tmp_path,\n" - " json_line=(\n" - " '{\"type\":\"text\",\"text\":\"This PR hardens ContextOverflowError and '\n" - " 'context window handling in the model pool.\"}'\n" - " ),\n" - " extra_env={\"FAKE_OPENCODE_HANG_SECONDS\": \"4\"},\n" - " )\n" - " elapsed = time.monotonic() - start\n" - "\n" - " assert result.returncode == 1\n" - " assert \"logged a fatal provider error while still running\" not in result.stdout\n" - " assert elapsed < 15\n" - ) - test_path.write_text(source[:start] + replacement + source[end:], encoding="utf-8") - PY - - - name: Verify focused behavior and syntax - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python -m pytest -q \ - tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ - tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ - tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early - python -m compileall -q tests/test_opencode_model_pool_runner.py - bash -n scripts/ci/run_opencode_review_model_pool.sh - git diff --check - - - name: Publish verified repair and remove every one-shot workflow - shell: bash --noprofile --norc -e -o pipefail {0} - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - GITHUB_TOKEN: ${{ github.token }} - run: | - rm -f \ - .github/workflows/one-shot-pr767-zombie-poll-fix.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v2.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v3.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v4.yml \ - .github/workflows/one-shot-pr767-zombie-poll-fix-v5.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && { echo "No completed-process repair generated" >&2; exit 1; } - git commit -m "fix(opencode): reap completed model attempts" - git push \ - "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:${BRANCH_NAME}" diff --git a/.github/workflows/one-shot-pr767-zombie-poll-fix.yml b/.github/workflows/one-shot-pr767-zombie-poll-fix.yml deleted file mode 100644 index 3ab208a9e..000000000 --- a/.github/workflows/one-shot-pr767-zombie-poll-fix.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: One-shot PR 767 completed-process polling fix - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/one-shot-pr767-zombie-poll-fix.yml - -permissions: - contents: write - -concurrency: - group: one-shot-pr767-zombie-poll-fix - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-and-verify: - runs-on: ubuntu-24.04 - timeout-minutes: 55 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout contributor branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: fix/sandboxed-output-resource-bounds - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked quality tooling - run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - - name: Apply completed-process polling repair - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - script_path = Path("scripts/ci/run_opencode_review_model_pool.sh") - script = script_path.read_text(encoding="utf-8") - old_loop = 'while kill -0 "$opencode_pid" 2>/dev/null; do\n' - new_loop = 'while jobs -pr | grep -Fxq "$opencode_pid"; do\n' - if script.count(old_loop) != 1: - raise SystemExit( - f"expected one kill-zero polling loop, found {script.count(old_loop)}" - ) - script_path.write_text(script.replace(old_loop, new_loop, 1), encoding="utf-8") - - test_path = Path("tests/test_opencode_model_pool_runner.py") - test_source = test_path.read_text(encoding="utf-8") - old_test = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: - """Model prose mentioning fatal signatures never kills a healthy streaming run.""" - result = run_failed_model( - tmp_path, - json_line=( - '{"type":"text","text":"This PR hardens ContextOverflowError and ' - 'context window handling in the model pool."}' - ), - extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, - ) - - assert result.returncode == 1 - assert "logged a fatal provider error while still running" not in result.stdout - '''.replace(" ", "") - new_test = '''def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: - """Model prose mentioning fatal signatures never kills or strands a healthy run.""" - start = time.monotonic() - result = run_failed_model( - tmp_path, - json_line=( - '{"type":"text","text":"This PR hardens ContextOverflowError and ' - 'context window handling in the model pool."}' - ), - extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, - ) - elapsed = time.monotonic() - start - - assert result.returncode == 1 - assert "logged a fatal provider error while still running" not in result.stdout - assert elapsed < 15 - '''.replace(" ", "") - if test_source.count(old_test) != 1: - raise SystemExit( - f"expected one healthy-stream regression, found {test_source.count(old_test)}" - ) - test_path.write_text( - test_source.replace(old_test, new_test, 1), encoding="utf-8" - ) - PY - - - name: Verify focused regression and complete quality contract - run: | - set -euo pipefail - python -m pytest -q \ - tests/test_opencode_model_pool_runner.py::test_fatal_provider_error_kills_hung_opencode_run_early \ - tests/test_opencode_model_pool_runner.py::test_model_text_quoting_error_signatures_does_not_kill_run \ - tests/test_opencode_model_pool_runner.py::test_delisted_openrouter_model_error_kills_hung_run_early - python -m coverage erase - python -m coverage run -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests - bash -n scripts/ci/run_opencode_review_model_pool.sh - git diff --check - - - name: Publish verified repair and remove one-shot workflow - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - run: | - set -euo pipefail - rm .github/workflows/one-shot-pr767-zombie-poll-fix.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - scripts/ci/run_opencode_review_model_pool.sh \ - tests/test_opencode_model_pool_runner.py \ - .github/workflows/one-shot-pr767-zombie-poll-fix.yml - git diff --cached --quiet && { echo "No completed-process polling repair generated" >&2; exit 1; } - git commit -m "fix(opencode): reap completed model attempts" - git push origin "HEAD:${BRANCH_NAME}" diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 986982e9a..4e3a4683e 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -480,7 +480,7 @@ run_one_model_attempt() { # then hang instead of exiting, burning the whole run timeout. Watch the JSON # log while opencode runs and kill the process early so the pool falls # through to the next candidate within seconds instead of minutes. - while kill -0 "$opencode_pid" 2>/dev/null; do + while jobs -pr | grep -Fxq "$opencode_pid"; do if has_fatal_provider_error_event "$opencode_json_file"; then printf 'OpenCode %s attempt %s/%s logged a fatal provider error while still running; killing the hung process instead of waiting out the %ss run timeout.\n' \ "$model_candidate" "$attempt" "$attempts" "$run_timeout_seconds" diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index 08d17f000..3c23401a9 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -584,7 +584,8 @@ def test_fatal_provider_error_kills_hung_opencode_run_early( def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: - """Model prose mentioning fatal signatures never kills a healthy streaming run.""" + """Model prose mentioning fatal signatures never kills or strands a healthy run.""" + start = time.monotonic() result = run_failed_model( tmp_path, json_line=( @@ -593,9 +594,11 @@ def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) - ), extra_env={"FAKE_OPENCODE_HANG_SECONDS": "4"}, ) + elapsed = time.monotonic() - start assert result.returncode == 1 assert "logged a fatal provider error while still running" not in result.stdout + assert elapsed < 15 def test_delisted_openrouter_model_error_kills_hung_run_early(tmp_path: Path) -> None: From 36afcf80cd6db52072012da3a2503baa6f5680d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:10:12 +0900 Subject: [PATCH 72/93] chore(ci): remove superseded PR 767 timeout experiment --- .../one-shot-pr767-coverage-timeout.yml | 178 ------------------ 1 file changed, 178 deletions(-) delete mode 100644 .github/workflows/one-shot-pr767-coverage-timeout.yml diff --git a/.github/workflows/one-shot-pr767-coverage-timeout.yml b/.github/workflows/one-shot-pr767-coverage-timeout.yml deleted file mode 100644 index 6b379b00c..000000000 --- a/.github/workflows/one-shot-pr767-coverage-timeout.yml +++ /dev/null @@ -1,178 +0,0 @@ -name: One-shot repair PR 767 coverage command headroom - -on: - push: - branches: - - fix/sandboxed-output-resource-bounds - paths: - - .github/workflows/one-shot-pr767-coverage-timeout.yml - -concurrency: - group: one-shot-pr767-coverage-timeout - cancel-in-progress: false - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact repair trigger - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.sha }} - fetch-depth: 1 - persist-credentials: false - - - name: Set up current stable Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install hash-locked quality tooling - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Add failing timeout contract - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - python - <<'PY' - from pathlib import Path - - path = Path("tests/test_opencode_agent_contract.py") - source = path.read_text(encoding="utf-8") - anchor = '''def test_opencode_python_coverage_never_resolves_pr_dependency_manifests(): - """Use only the trusted image toolchain during networkless PR execution.""" - ''' - contract = '''def test_opencode_coverage_commands_have_bounded_large_repository_headroom(): - """Full repository evidence gets 30 minutes without becoming unbounded.""" - workflow = Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8") - measure = workflow.split( - " - name: Measure test and docstring evidence\\n", 1 - )[1].split("\\n - name:", 1)[0] - - assert measure.count("timeout --kill-after=20 1800 setpriv") == 3 - assert "timeout --kill-after=20 900 setpriv" not in measure - - - ''' - if contract in source: - raise SystemExit("timeout contract unexpectedly already exists") - if source.count(anchor) != 1: - raise SystemExit("timeout contract insertion anchor moved") - path.write_text(source.replace(anchor, contract + anchor, 1), encoding="utf-8") - PY - set +e - python -m pytest -q \ - tests/test_opencode_agent_contract.py::test_opencode_coverage_commands_have_bounded_large_repository_headroom \ - >"${RUNNER_TEMP}/red.log" 2>&1 - red_status=$? - set -e - cat "${RUNNER_TEMP}/red.log" - test "$red_status" -ne 0 - grep -F 'assert 0 == 3' "${RUNNER_TEMP}/red.log" - - - name: Apply bounded command headroom and doctoring - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python - <<'PY' - from pathlib import Path - - workflow = Path(".github/workflows/opencode-review-dispatch.yml") - source = workflow.read_text(encoding="utf-8") - old = "timeout --kill-after=20 900 setpriv" - new = "timeout --kill-after=20 1800 setpriv" - if source.count(old) != 3: - raise SystemExit("expected exactly three 900-second coverage runners") - workflow.write_text(source.replace(old, new), encoding="utf-8") - - changelog = Path("CHANGELOG.md") - source = changelog.read_text(encoding="utf-8") - anchor = "### Changed\n\n" - entry = ( - "- Increase each isolated coverage-evidence command budget from 15 to 30 minutes while retaining a hard GNU `timeout` boundary and a 20-second forced-kill grace period, so complete large-repository suites can finish without converting valid evidence into timeout failures.\n" - ) - if entry not in source: - if source.count(anchor) != 1: - raise SystemExit("CHANGELOG Changed anchor moved") - source = source.replace(anchor, anchor + entry, 1) - changelog.write_text(source, encoding="utf-8") - - doctoring = Path("docs/doctoring/sandboxed-output-resource-bounds.md") - source = doctoring.read_text(encoding="utf-8") - section = ''' - ## Coverage-evidence command headroom - - The OpenCode coverage sandbox keeps every repository-selected verification command bounded, but the former 900-second limit was insufficient for the central repository's complete regression suite after realistic model-pool and process-cleanup tests were added. The suite could pass targeted production coverage and docstring checks yet be terminated before complete repository evidence finished. - - Each of the three isolated command runners now receives 1,800 seconds plus a 20-second forced-kill grace period. This is not an unbounded retry or a relaxation of evidence: a timeout still fails closed, descendants remain under the same sandbox identity and process boundary, and the surrounding GitHub Actions job remains explicitly bounded. GitHub permits positive integer step and job timeouts up to 360 minutes, while GNU `timeout --kill-after` sends a final `KILL` only after the initial timeout signal and grace interval. The 30-minute command ceiling therefore remains materially below the workflow job ceiling while allowing the exact current-head full suite to complete. - - A permanent workflow-contract test requires exactly three 1,800-second bounded runners and rejects the former 900-second form. Regressions must be diagnosed by exact-head execution rather than by treating a timeout as passing evidence. - ''' - section = "\n".join(line[10:] if line.startswith(" ") else line for line in section.splitlines()) - marker = "\n## Limitations\n" - if "## Coverage-evidence command headroom" not in source: - if source.count(marker) != 1: - raise SystemExit("doctoring limitations anchor moved") - source = source.replace(marker, section + marker, 1) - references = ''' - - GitHub. (n.d.). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax - - Free Software Foundation. (2026). *timeout: Run a command with a time limit* (GNU Coreutils 9.10 manual). https://www.gnu.org/software/coreutils/timeout - ''' - references = "\n".join(line[10:] if line.startswith(" ") else line for line in references.splitlines()) - if "GNU Coreutils 9.10 manual" not in source: - source = source.rstrip() + references + "\n" - doctoring.write_text(source, encoding="utf-8") - PY - - - name: Verify repair - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python -m pytest -q \ - tests/test_opencode_agent_contract.py::test_opencode_coverage_commands_have_bounded_large_repository_headroom \ - tests/test_opencode_agent_contract.py::test_opencode_privileged_review_security_boundaries_are_fail_closed - python -m compileall -q tests/test_opencode_agent_contract.py - python - <<'PY' - from pathlib import Path - import yaml - - yaml.safe_load(Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8")) - PY - git diff --check - - - name: Publish verified repair and remove one-shot workflow - env: - PUSH_TOKEN: ${{ github.token }} - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - remote_head="$(git ls-remote origin refs/heads/fix/sandboxed-output-resource-bounds | cut -f1)" - test "$remote_head" = "$GITHUB_SHA" - rm -f .github/workflows/one-shot-pr767-coverage-timeout.yml - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(opencode): allow bounded large-repository coverage evidence" - auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" - echo "::add-mask::$auth_header" - git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ - push origin HEAD:fix/sandboxed-output-resource-bounds From a2475afd4026c9fc7c2f7db2c6ad25843c559b2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:19:28 +0900 Subject: [PATCH 73/93] ci: trigger exact-head PR 767 review repairs --- .../scripts/finalize_pr767_review_repairs.py | 309 ++++++++++++++++++ .../finalize-pr767-review-repairs.yml | 131 ++++++++ 2 files changed, 440 insertions(+) create mode 100755 .github/scripts/finalize_pr767_review_repairs.py create mode 100644 .github/workflows/finalize-pr767-review-repairs.yml diff --git a/.github/scripts/finalize_pr767_review_repairs.py b/.github/scripts/finalize_pr767_review_repairs.py new file mode 100755 index 000000000..c9aeba9b8 --- /dev/null +++ b/.github/scripts/finalize_pr767_review_repairs.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Apply test-first final review repairs for PR 767.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BOUNDED = ROOT / "scripts/ci/bounded_subprocess.py" +REDACTOR = ROOT / "scripts/ci/redact_sensitive_log.py" +WEB = ROOT / "scripts/ci/sandboxed_web_e2e.py" +TEST_BOUNDED = ROOT / "tests/test_bounded_subprocess.py" +TEST_REDACTOR = ROOT / "tests/test_redact_sensitive_log_contract.py" +TEST_ENTRYPOINT = ROOT / "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py" +TEST_WEB = ROOT / "tests/test_sandboxed_web_e2e.py" +TEST_WEB_LIMITS = ROOT / "tests/test_sandboxed_web_e2e_output_limits.py" +CHANGELOG = ROOT / "CHANGELOG.md" +WORKFLOW = ROOT / ".github/workflows/finalize-pr767-review-repairs.yml" +SCRIPT = Path(__file__).resolve() + + +def replace_once(path: Path, old: str, new: str) -> None: + """Replace exactly one audited UTF-8 fragment.""" + source = path.read_text(encoding="utf-8") + count = source.count(old) + if count != 1: + raise SystemExit(f"expected one anchor in {path}, found {count}: {old[:100]!r}") + path.write_text(source.replace(old, new, 1), encoding="utf-8") + + +def append_once(path: Path, marker: str, addition: str) -> None: + """Append one regression block when its marker is absent.""" + source = path.read_text(encoding="utf-8") + if marker in source: + raise SystemExit(f"regression already exists in {path}: {marker}") + path.write_text(source + addition, encoding="utf-8") + + +def add_tests() -> None: + """Add regressions that fail against the uncorrected production contracts.""" + append_once( + TEST_BOUNDED, + "test_join_captures_applies_finite_timeout_and_finishes_siblings", + r''' + + +def test_join_captures_applies_finite_timeout_and_finishes_siblings() -> None: + """Every reader receives a finite join bound and siblings still finalize.""" + + calls: list[tuple[str, float | None]] = [] + + class Capture: + """Record the supplied timeout and optionally fail.""" + + def __init__(self, name: str, error: BaseException | None = None) -> None: + self.name = name + self.error = error + + def join(self, timeout: float | None = None) -> None: + calls.append((self.name, timeout)) + if self.error is not None: + raise self.error + + with pytest.raises(RuntimeError, match="first reader failed"): + bounded._join_captures( # noqa: SLF001 - focused internal contract + [Capture("first", RuntimeError("first reader failed")), Capture("second")] + ) + + assert calls == [ + ("first", bounded.READER_JOIN_TIMEOUT_SECONDS), + ("second", bounded.READER_JOIN_TIMEOUT_SECONDS), + ] +''', + ) + append_once( + TEST_REDACTOR, + "test_bare_sensitive_word_does_not_consume_the_next_argument", + r''' + + +def test_bare_sensitive_word_does_not_consume_the_next_argument() -> None: + """Only dash-prefixed options treat the following argument as a value.""" + + assert redactor.redact_command_arguments( + ["docker", "run", "-e", "TOKEN", "image"] + ) == ["docker", "run", "-e", "TOKEN", "image"] + assert redactor.redact_command_arguments( + ["tool", "TOKEN=value", "image"] + ) == ["tool", "TOKEN=[REDACTED]", "image"] +''', + ) + replace_once( + TEST_ENTRYPOINT, + "import runpy\nimport subprocess\nimport sys\n", + "import runpy\nimport sys\n", + ) + replace_once( + TEST_ENTRYPOINT, + ''' lambda *args, **kwargs: subprocess.CompletedProcess( + args=["e2e"], + returncode=0, + stdout="ok\\n", + stderr="", + ), +''', + ''' lambda *args, **kwargs: bounded_subprocess.BoundedCompletedProcess( + args=("e2e",), + returncode=0, + stdout="ok\\n", + stderr="", + output_limited=False, + ), +''', + ) + replace_once( + TEST_ENTRYPOINT, + ' raise OSError(f"cannot finalize {service.label}")\n', + ' raise ValueError(f"cannot finalize {service.label}")\n', + ) + replace_once( + TEST_WEB, + ''' lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: subprocess.CompletedProcess( + command, + 0, + stdout="e2e-out\\n", + stderr="e2e-err\\n", + ), +''', + ''' lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: sandboxed_web_e2e.bounded_subprocess.BoundedCompletedProcess( + args=("e2e",), + returncode=0, + stdout="e2e-out\\n", + stderr="e2e-err\\n", + output_limited=False, + ), +''', + ) + replace_once( + TEST_WEB_LIMITS, + ''' exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command( + "import os\\n" + "chunk=b'x'*1024\\n" + "while True:\\n" + " os.write(1,chunk)\\n" + ), + "--frontend-cmd", + _command("import time; time.sleep(30)"), + "--e2e-cmd", + _command("raise SystemExit('must not run')"), + "--service-log-limit-bytes", + "4096", + ] + ) +''', + ''' sentinel = tmp_path / "e2e-ran" + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command( + "import os\\n" + "chunk=b'x'*1024\\n" + "while True:\\n" + " os.write(1,chunk)\\n" + ), + "--backend-ready-url", + "http://127.0.0.1:1/ready", + "--frontend-cmd", + _command("import time; time.sleep(30)"), + "--e2e-cmd", + _command( + f"from pathlib import Path; Path({str(sentinel)!r}).touch()" + ), + "--service-log-limit-bytes", + "4096", + ] + ) +''', + ) + replace_once( + TEST_WEB_LIMITS, + ''' assert payload["output_limited"] is True + assert payload["service_log_limit_bytes"] == 4096 + + +def test_e2e_output_overflow_is_bounded_and_returns_123( +''', + ''' assert payload["output_limited"] is True + assert payload["service_log_limit_bytes"] == 4096 + assert not sentinel.exists() + + +def test_e2e_output_overflow_is_bounded_and_returns_123( +''', + ) + + +def apply_repair() -> None: + """Apply the bounded reader, redaction, and cleanup corrections.""" + replace_once( + BOUNDED, + 'READ_CHUNK_BYTES = 65_536\nTRUNCATION_MARKER = "...[output truncated]...\\n"\n', + 'READ_CHUNK_BYTES = 65_536\nREADER_JOIN_TIMEOUT_SECONDS = 30.0\nTRUNCATION_MARKER = "...[output truncated]...\\n"\n', + ) + replace_once( + BOUNDED, + '''def _join_captures(captures: Sequence[BoundedOutputCapture]) -> None: + """Finalize every stream reader while preserving the first reported failure.""" + + first_error: BaseException | None = None + for capture in captures: + try: + capture.join() + except BaseException as error: # noqa: BLE001 - re-raised after sibling join + if first_error is None: + first_error = error + if first_error is not None: + raise first_error +''', + '''def _join_captures( + captures: Sequence[BoundedOutputCapture], + timeout: float = READER_JOIN_TIMEOUT_SECONDS, +) -> None: + """Finalize every stream reader within a finite bound, preserving the first failure.""" + + first_error: BaseException | None = None + for capture in captures: + try: + capture.join(timeout) + except BaseException as error: # noqa: BLE001 - re-raised after sibling join + if first_error is None: + first_error = error + if first_error is not None: + raise first_error +''', + ) + replace_once( + REDACTOR, + ''' option = argument.lstrip("-") + if "=" in option: +''', + ''' is_option = argument.startswith("-") + option = argument.lstrip("-") + if "=" in option: +''', + ) + replace_once( + REDACTOR, + ''' redacted.append(redact_text(argument)) + if SENSITIVE_OPTION_RE.fullmatch(option): + redact_next = True +''', + ''' redacted.append(redact_text(argument)) + if is_option and SENSITIVE_OPTION_RE.fullmatch(option): + redact_next = True +''', + ) + replace_once( + WEB, + ''' except (OSError, RuntimeError, subprocess.SubprocessError): + output_limited = True +''', + ''' except Exception: # noqa: BLE001 - cleanup must not skip result emission + output_limited = True +''', + ) + text = CHANGELOG.read_text(encoding="utf-8") + additions = [ + "- Bound normal-path stdout/stderr reader joins so inherited pipe descriptors cannot hold a sandbox job indefinitely.\n", + "- Preserve ordinary command arguments after bare credential-shaped words while retaining dash-prefixed option and assignment redaction.\n", + "- Continue sandbox result emission and directory cleanup after any ordinary service-capture finalization exception.\n", + ] + marker = "### Fixed\n\n" + if marker not in text: + raise SystemExit("CHANGELOG Fixed section missing") + for addition in reversed(additions): + if addition not in text: + text = text.replace(marker, marker + addition, 1) + CHANGELOG.write_text(text, encoding="utf-8") + + +def cleanup() -> None: + """Remove the temporary exact-head workflow and helper.""" + WORKFLOW.unlink() + SCRIPT.unlink() + + +def main() -> None: + """Execute one deterministic repair phase.""" + parser = argparse.ArgumentParser() + parser.add_argument("phase", choices=("add-tests", "apply", "cleanup")) + args = parser.parse_args() + if args.phase == "add-tests": + add_tests() + elif args.phase == "apply": + apply_repair() + else: + cleanup() + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/finalize-pr767-review-repairs.yml b/.github/workflows/finalize-pr767-review-repairs.yml new file mode 100644 index 000000000..b801364f8 --- /dev/null +++ b/.github/workflows/finalize-pr767-review-repairs.yml @@ -0,0 +1,131 @@ +name: Finalize PR 767 review repairs + +on: + push: + branches: + - fix/sandboxed-output-resource-bounds + paths: + - .github/workflows/finalize-pr767-review-repairs.yml + +permissions: + contents: read + +concurrency: + group: finalize-pr767-review-repairs + cancel-in-progress: false + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Check out exact repair trigger + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: false + + - name: Verify audited parent and repair source + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD^)" = "36afcf80cd6db52072012da3a2503baa6f5680d0" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$(git hash-object .github/scripts/finalize_pr767_review_repairs.py)" = \ + "c9aeba9b8e644d609c54d4017a8b3897b16766a9" + + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked test tooling + shell: bash --noprofile --norc -e -o pipefail {0} + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Add regressions before production changes + shell: bash --noprofile --norc -e -o pipefail {0} + run: python .github/scripts/finalize_pr767_review_repairs.py add-tests + + - name: Prove current review findings are red + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set +e + python -m pytest -q \ + tests/test_bounded_subprocess.py::test_join_captures_applies_finite_timeout_and_finishes_siblings \ + tests/test_redact_sensitive_log_contract.py::test_bare_sensitive_word_does_not_consume_the_next_argument \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py::test_web_e2e_reports_bounded_capture_finalization_failure \ + >"${RUNNER_TEMP}/pr767-red.log" 2>&1 + status=$? + set -e + cat "${RUNNER_TEMP}/pr767-red.log" + test "$status" -ne 0 + + - name: Apply bounded source repair + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python .github/scripts/finalize_pr767_review_repairs.py apply + git diff --check + + - name: Verify focused and full quality evidence + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m pytest -q \ + tests/test_bounded_subprocess.py \ + tests/test_redact_sensitive_log_contract.py \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sandboxed_web_e2e_output_limits.py + python -m pytest -q + python -m interrogate --fail-under 100 \ + scripts/ci/bounded_subprocess.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_web_e2e.py + python -m compileall -q scripts/ci tests + git diff --check + + - name: Publish exact repair and remove one-shot automation + env: + EXPECTED_HEAD: ${{ github.sha }} + SOURCE_BRANCH: fix/sandboxed-output-resource-bounds + GH_TOKEN: ${{ github.token }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + python .github/scripts/finalize_pr767_review_repairs.py cleanup + git diff --check + test "$(git diff --name-only | sort)" = "$(printf '%s\n' \ + .github/scripts/finalize_pr767_review_repairs.py \ + .github/workflows/finalize-pr767-review-repairs.yml \ + CHANGELOG.md \ + scripts/ci/bounded_subprocess.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_web_e2e.py \ + tests/test_bounded_subprocess.py \ + tests/test_redact_sensitive_log_contract.py \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sandboxed_web_e2e_output_limits.py | sort)" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(ci): close sandbox output review gaps" + remote_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push --force-with-lease="refs/heads/${SOURCE_BRANCH}:${EXPECTED_HEAD}" \ + "$remote_url" "HEAD:refs/heads/${SOURCE_BRANCH}" From 16e1b61b0aee16f646cd4eb03a937a8174e0b0ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:26:08 +0900 Subject: [PATCH 74/93] ci(pr767): verify current review fixes --- .../workflows/one-shot-pr767-review-fixes.yml | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-review-fixes.yml diff --git a/.github/workflows/one-shot-pr767-review-fixes.yml b/.github/workflows/one-shot-pr767-review-fixes.yml new file mode 100644 index 000000000..32b7cbbf3 --- /dev/null +++ b/.github/workflows/one-shot-pr767-review-fixes.yml @@ -0,0 +1,443 @@ +name: One-shot PR 767 current review fixes + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/one-shot-pr767-review-fixes.yml + +permissions: + contents: read + +concurrency: + group: one-shot-pr767-current-review-fixes + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-and-verify: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + permissions: + contents: write + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact contributor head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked test tooling + shell: bash --noprofile --norc -e -o pipefail {0} + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Add failing review regressions first + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python - <<'PY' + from pathlib import Path + from textwrap import dedent + + def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + source = target.read_text(encoding="utf-8") + old_text = dedent(old) + new_text = dedent(new) + if source.count(old_text) != 1: + raise SystemExit( + f"{path}: expected one guarded test replacement, " + f"found {source.count(old_text)}" + ) + target.write_text(source.replace(old_text, new_text, 1), encoding="utf-8") + + replace_once( + "tests/test_bounded_subprocess.py", + """ + def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: + """, + """ + def test_join_captures_applies_a_finite_timeout_and_preserves_first_error() -> None: + """Every reader receives the finite join bound before the first error returns.""" + + class Capture: + """Record join bounds and optionally raise one deterministic failure.""" + + def __init__(self, error: BaseException | None = None) -> None: + """Store the optional failure and an empty timeout audit trail.""" + + self.error = error + self.timeouts: list[float | None] = [] + + def join(self, timeout: float | None = None) -> None: + """Record the requested bound and raise the configured failure.""" + + self.timeouts.append(timeout) + if self.error is not None: + raise self.error + + first = Capture(RuntimeError("first reader failure")) + sibling = Capture() + + with pytest.raises(RuntimeError, match="first reader failure"): + bounded._join_captures([first, sibling]) # noqa: SLF001 + + assert first.timeouts == [bounded.READER_JOIN_TIMEOUT_SECONDS] + assert sibling.timeouts == [bounded.READER_JOIN_TIMEOUT_SECONDS] + + + def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: + """, + ) + + replace_once( + "tests/test_sandboxed_output_redaction.py", + """ + assert redact_command_arguments( + ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] + ) == [ + "tool", + "--api-key", + REDACTED, + f"TOKEN={REDACTED}", + REDACTED, + "plain", + ] + """, + """ + assert redact_command_arguments( + ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] + ) == [ + "tool", + "--api-key", + REDACTED, + f"TOKEN={REDACTED}", + REDACTED, + "plain", + ] + assert redact_command_arguments( + ["docker", "run", "-e", "TOKEN", "image"] + ) == ["docker", "run", "-e", "TOKEN", "image"] + """, + ) + + replace_once( + "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py", + "import runpy\nimport subprocess\nimport sys\n", + "import runpy\nimport sys\n", + ) + replace_once( + "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py", + """ + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=["e2e"], + returncode=0, + stdout="ok\\n", + stderr="", + ), + ) + """, + """ + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda *args, **kwargs: bounded_subprocess.BoundedCompletedProcess( + args=("e2e",), + returncode=0, + stdout="ok\\n", + stderr="", + output_limited=False, + ), + ) + """, + ) + replace_once( + "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py", + """ + def fail_capture_finalization(service): + raise OSError(f"cannot finalize {service.label}") + """, + """ + def fail_capture_finalization(service): + raise ValueError(f"cannot finalize {service.label}") + """, + ) + + replace_once( + "tests/test_sandboxed_web_e2e.py", + """ + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: subprocess.CompletedProcess( + command, + 0, + stdout="e2e-out\\n", + stderr="e2e-err\\n", + ), + ) + """, + """ + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: sandboxed_web_e2e.bounded_subprocess.BoundedCompletedProcess( + args=tuple(sandboxed_web_e2e.shlex.split(command)), + returncode=0, + stdout="e2e-out\\n", + stderr="e2e-err\\n", + output_limited=False, + ), + ) + """, + ) + + path = Path("tests/test_sandboxed_web_e2e_output_limits.py") + source = path.read_text(encoding="utf-8") + start = source.index( + "def test_service_log_overflow_returns_resource_limit_before_e2e(\n" + ) + end = source.index( + "\n\ndef test_e2e_output_overflow_is_bounded_and_returns_123(", start + ) + replacement = dedent( + ''' + def test_service_log_overflow_returns_resource_limit_before_e2e( + tmp_path: Path, + capsys, + ) -> None: + """Readiness cannot convert a backend log flood into an ordinary E2E run.""" + + sentinel = tmp_path / "e2e-ran" + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(_repository(tmp_path)), + "--backend-cmd", + _command( + "import os\\n" + "chunk=b'x'*1024\\n" + "while True:\\n" + " os.write(1,chunk)\\n" + ), + "--backend-ready-url", + "http://127.0.0.1:1/ready", + "--frontend-cmd", + _command("import time; time.sleep(30)"), + "--e2e-cmd", + _command( + f"from pathlib import Path; Path({str(sentinel)!r}).touch()" + ), + "--service-log-limit-bytes", + "4096", + ] + ) + captured = capsys.readouterr() + payload = _result_payload(captured.out) + + assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE + assert "service output exceeded 4096 bytes" in captured.err + assert payload["output_limited"] is True + assert payload["service_log_limit_bytes"] == 4096 + assert not sentinel.exists() + ''' + ).lstrip() + path.write_text(source[:start] + replacement + source[end:], encoding="utf-8") + PY + + - name: Prove the regressions fail before production repair + shell: bash --noprofile --norc {0} + run: | + set +e + python -m pytest -q \ + tests/test_bounded_subprocess.py::test_join_captures_applies_a_finite_timeout_and_preserves_first_error \ + tests/test_sandboxed_output_redaction.py::test_redact_command_arguments_covers_separate_equals_and_direct_tokens \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py::test_web_e2e_reports_bounded_capture_finalization_failure \ + tests/test_sandboxed_web_e2e_output_limits.py::test_service_log_overflow_returns_resource_limit_before_e2e + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "::error::Review regressions unexpectedly passed before production repair." + exit 1 + fi + printf 'Observed the expected failing review-regression state (exit %s).\n' "$status" + + - name: Apply bounded production repairs and update evidence + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python - <<'PY' + from pathlib import Path + from textwrap import dedent + + def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + source = target.read_text(encoding="utf-8") + old_text = dedent(old) + new_text = dedent(new) + if source.count(old_text) != 1: + raise SystemExit( + f"{path}: expected one production replacement, " + f"found {source.count(old_text)}" + ) + target.write_text(source.replace(old_text, new_text, 1), encoding="utf-8") + + replace_once( + "scripts/ci/bounded_subprocess.py", + """ + READ_CHUNK_BYTES = 65_536 + TRUNCATION_MARKER = "...[output truncated]...\\n" + """, + """ + READ_CHUNK_BYTES = 65_536 + READER_JOIN_TIMEOUT_SECONDS = 30.0 + TRUNCATION_MARKER = "...[output truncated]...\\n" + """, + ) + replace_once( + "scripts/ci/bounded_subprocess.py", + """ + def _join_captures(captures: Sequence[BoundedOutputCapture]) -> None: + """Finalize every stream reader while preserving the first reported failure.""" + + first_error: BaseException | None = None + for capture in captures: + try: + capture.join() + except BaseException as error: # noqa: BLE001 - re-raised after sibling join + if first_error is None: + first_error = error + if first_error is not None: + raise first_error + """, + """ + def _join_captures( + captures: Sequence[BoundedOutputCapture], + timeout: float = READER_JOIN_TIMEOUT_SECONDS, + ) -> None: + """Finalize every stream reader within a finite shared wait bound.""" + + first_error: BaseException | None = None + for capture in captures: + try: + capture.join(timeout) + except BaseException as error: # noqa: BLE001 - re-raised after sibling join + if first_error is None: + first_error = error + if first_error is not None: + raise first_error + """, + ) + replace_once( + "scripts/ci/redact_sensitive_log.py", + """ + option = argument.lstrip("-") + if "=" in option: + key, _value = option.split("=", 1) + if SENSITIVE_OPTION_RE.fullmatch(key): + separator_index = argument.find("=") + redacted.append(f"{argument[: separator_index + 1]}{REDACTED}") + continue + + redacted.append(redact_text(argument)) + if SENSITIVE_OPTION_RE.fullmatch(option): + redact_next = True + """, + """ + is_option = argument.startswith("-") + option = argument.lstrip("-") + if "=" in option: + key, _value = option.split("=", 1) + if SENSITIVE_OPTION_RE.fullmatch(key): + separator_index = argument.find("=") + redacted.append(f"{argument[: separator_index + 1]}{REDACTED}") + continue + + redacted.append(redact_text(argument)) + if is_option and SENSITIVE_OPTION_RE.fullmatch(option): + redact_next = True + """, + ) + replace_once( + "scripts/ci/sandboxed_web_e2e.py", + " except (OSError, RuntimeError, subprocess.SubprocessError):\n", + " except Exception: # noqa: BLE001 - cleanup must finish all services\n", + ) + replace_once( + "CHANGELOG.md", + """ + ### Fixed + + - Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. + """, + """ + ### Fixed + + - Bound every normal-path output-reader join to 30 seconds, preserve the first reader failure after finalizing siblings, and keep web-E2E result emission and sandbox cleanup running after arbitrary ordinary cleanup exceptions. + - Treat only dash-prefixed credential options as consumers of a following argument while retaining redaction for sensitive `KEY=value` assignments and provider-shaped values. + - Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. + """, + ) + replace_once( + "docs/doctoring/sandboxed-output-resource-bounds.md", + "A truncation marker is included inside, not in addition to, the declared retained byte budget. Reader errors and reader-join timeouts are explicit failures.\n", + "A truncation marker is included inside, not in addition to, the declared retained byte budget. Every normal-path reader join uses a finite 30-second bound, finalizes every sibling capture, and then re-raises the first failure. A drain that still has not reached EOF therefore becomes the explicit `bounded output drain did not finish` failure instead of holding the control-plane job indefinitely. Web-E2E service cleanup maps ordinary cleanup exceptions to the bounded failure result while continuing result emission and sandbox deletion.\n", + ) + replace_once( + "docs/doctoring/sandboxed-command-log-redaction.md", + "- Sensitive option detection uses explicit credential terms. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool.\n", + "- Sensitive option detection uses explicit credential terms and requires a dash-prefixed option before consuming the following argument. Bare words such as `TOKEN` remain ordinary positional evidence, while sensitive `KEY=value` assignments continue through the earlier assignment-redaction path. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool.\n", + ) + PY + + - name: Verify complete exact-head quality contract + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m coverage erase + python -m coverage run -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n \ + scripts/ci/run_opencode_review_model_pool.sh \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py + git diff --check + + - name: Publish verified repair and remove the one-shot workflow + shell: bash --noprofile --norc -e -o pipefail {0} + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + GITHUB_TOKEN: ${{ github.token }} + run: | + rm -f .github/workflows/one-shot-pr767-review-fixes.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { echo "No review repair generated" >&2; exit 1; } + git commit -m "fix(ci): close sandbox evidence review gaps" + git push \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:${BRANCH_NAME}" From 1dee6d36c9763cd091f10a100f6cb927647d3977 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:38:45 +0900 Subject: [PATCH 75/93] test(ci): isolate Git ownership contract from runner config --- .github/scripts/patch_pr767_git_isolation.py | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/scripts/patch_pr767_git_isolation.py diff --git a/.github/scripts/patch_pr767_git_isolation.py b/.github/scripts/patch_pr767_git_isolation.py new file mode 100644 index 000000000..734080db0 --- /dev/null +++ b/.github/scripts/patch_pr767_git_isolation.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Make the Git ownership contract hermetic to runner-global configuration.""" + +from pathlib import Path + +path = Path("tests/test_opencode_agent_contract.py") +text = path.read_text(encoding="utf-8") +old = ''' base_env = { + **os.environ, + "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", + } +''' +new = ''' base_env = { + **os.environ, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", + } +''' +if text.count(old) != 1: + raise SystemExit( + f"expected one runner-global Git isolation anchor, found {text.count(old)}" + ) +path.write_text(text.replace(old, new, 1), encoding="utf-8") From dc05f73de9e801ee55dc06ad894dcf2afa7560fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:40:23 +0900 Subject: [PATCH 76/93] ci: rerun PR 767 review repairs hermetically --- .../finalize-pr767-review-repairs-v2.yml | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .github/workflows/finalize-pr767-review-repairs-v2.yml diff --git a/.github/workflows/finalize-pr767-review-repairs-v2.yml b/.github/workflows/finalize-pr767-review-repairs-v2.yml new file mode 100644 index 000000000..d2ec5f03c --- /dev/null +++ b/.github/workflows/finalize-pr767-review-repairs-v2.yml @@ -0,0 +1,130 @@ +name: Finalize PR 767 review repairs v2 + +on: + push: + branches: + - fix/sandboxed-output-resource-bounds + paths: + - .github/workflows/finalize-pr767-review-repairs-v2.yml + +permissions: + contents: read + +concurrency: + group: finalize-pr767-review-repairs-v2 + cancel-in-progress: false + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Check out exact repair trigger + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: false + + - name: Verify exact parent and repair sources + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD^)" = "1dee6d36c9763cd091f10a100f6cb927647d3977" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + python3 -m py_compile \ + .github/scripts/finalize_pr767_review_repairs.py \ + .github/scripts/patch_pr767_git_isolation.py + + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked test tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Isolate runner-global Git state + run: python .github/scripts/patch_pr767_git_isolation.py + + - name: Add regressions before production changes + run: python .github/scripts/finalize_pr767_review_repairs.py add-tests + + - name: Prove review findings are red + shell: bash --noprofile --norc {0} + run: | + set +e + python -m pytest -q \ + tests/test_bounded_subprocess.py::test_join_captures_applies_finite_timeout_and_finishes_siblings \ + tests/test_redact_sensitive_log_contract.py::test_bare_sensitive_word_does_not_consume_the_next_argument \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py::test_web_e2e_reports_bounded_capture_finalization_failure \ + >"${RUNNER_TEMP}/pr767-red.log" 2>&1 + status=$? + set -e + cat "${RUNNER_TEMP}/pr767-red.log" + test "$status" -ne 0 + + - name: Apply bounded source repair + run: | + python .github/scripts/finalize_pr767_review_repairs.py apply + git diff --check + + - name: Verify focused, full, coverage, and docstring evidence + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m pytest -q \ + tests/test_bounded_subprocess.py \ + tests/test_redact_sensitive_log_contract.py \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sandboxed_web_e2e_output_limits.py \ + tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe + python -m pytest -q + python -m coverage erase + python -m coverage run --branch -m pytest -q + python -m coverage report --fail-under=100 --show-missing + python -m interrogate --fail-under 100 \ + scripts/ci/bounded_subprocess.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py + python -m compileall -q scripts/ci tests + git diff --check + + - name: Publish exact repair and remove all temporary automation + env: + EXPECTED_HEAD: ${{ github.sha }} + SOURCE_BRANCH: fix/sandboxed-output-resource-bounds + GH_TOKEN: ${{ github.token }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + rm -f \ + .github/scripts/finalize_pr767_review_repairs.py \ + .github/scripts/patch_pr767_git_isolation.py \ + .github/workflows/finalize-pr767-review-repairs.yml \ + .github/workflows/finalize-pr767-review-repairs-v2.yml \ + .github/workflows/one-shot-pr767-review-fixes.yml + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(ci): close sandbox output review gaps" + remote_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push --force-with-lease="refs/heads/${SOURCE_BRANCH}:${EXPECTED_HEAD}" \ + "$remote_url" "HEAD:refs/heads/${SOURCE_BRANCH}" From 0d1c792e218dd1f7d8becb62bad6b2108294bdaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:00:28 +0900 Subject: [PATCH 77/93] test(ci): reproduce unbounded capture join --- tests/test_bounded_subprocess.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_bounded_subprocess.py b/tests/test_bounded_subprocess.py index d0015bcd6..a069108fc 100644 --- a/tests/test_bounded_subprocess.py +++ b/tests/test_bounded_subprocess.py @@ -225,6 +225,35 @@ def is_alive(self) -> bool: capture.join(timeout=0) +def test_join_captures_applies_finite_timeout_and_joins_every_reader() -> None: + """Normal-path capture finalization cannot wait forever on inherited pipe FDs.""" + + observed: list[tuple[str, float]] = [] + + class Capture: + """Record the timeout and optionally expose one stuck-reader failure.""" + + def __init__(self, label: str, *, fail: bool = False) -> None: + self.label = label + self.fail = fail + + def join(self, timeout: float) -> None: + """Require a positive finite timeout and retain sibling finalization.""" + + observed.append((self.label, timeout)) + if self.fail: + raise RuntimeError("bounded output drain did not finish") + + with pytest.raises(RuntimeError, match="did not finish"): + bounded._join_captures( # noqa: SLF001 - internal safety contract + (Capture("first", fail=True), Capture("second")) # type: ignore[arg-type] + ) + + assert [label for label, _timeout in observed] == ["first", "second"] + assert all(timeout > 0 for _label, timeout in observed) + assert len({timeout for _label, timeout in observed}) == 1 + + def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: """The reusable runner validates execution controls before creating children.""" From e5cdf112d94c6bca73d69a8d97ddfb8ffda49da3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:01:40 +0900 Subject: [PATCH 78/93] test(ci): reproduce bare token over-redaction --- tests/test_redact_sensitive_log_contract.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py index c279987a3..5c1a07bec 100644 --- a/tests/test_redact_sensitive_log_contract.py +++ b/tests/test_redact_sensitive_log_contract.py @@ -81,6 +81,12 @@ def test_command_argument_redaction_preserves_non_sensitive_options() -> None: assert redactor.redact_command_arguments( ["tool", "--mode=safe", "--token"] ) == ["tool", "--mode=safe", "--token"] + assert redactor.redact_command_arguments( + ["docker", "run", "-e", "TOKEN", "image"] + ) == ["docker", "run", "-e", "TOKEN", "image"] + assert redactor.redact_command_arguments( + ["tool", "--token", "credential-value", "image"] + ) == ["tool", "--token", redactor.REDACTED, "image"] assert redactor.redact_shell_command("") == "" From a91bc841048c3542fa37aeed33de9063457ae158 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:03:34 +0900 Subject: [PATCH 79/93] test(ci): prove service overflow blocks E2E execution --- tests/test_sandboxed_web_e2e_output_limits.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_sandboxed_web_e2e_output_limits.py b/tests/test_sandboxed_web_e2e_output_limits.py index 8f000d9ef..87a5ac81f 100644 --- a/tests/test_sandboxed_web_e2e_output_limits.py +++ b/tests/test_sandboxed_web_e2e_output_limits.py @@ -71,6 +71,7 @@ def test_service_log_overflow_returns_resource_limit_before_e2e( ) -> None: """Readiness cannot convert a backend log flood into an ordinary E2E run.""" + sentinel = tmp_path / "e2e-ran" exit_code = sandboxed_web_e2e.main( [ "--repo-root", @@ -82,10 +83,14 @@ def test_service_log_overflow_returns_resource_limit_before_e2e( "while True:\n" " os.write(1,chunk)\n" ), + "--backend-url", + "http://127.0.0.1:1/ready", "--frontend-cmd", _command("import time; time.sleep(30)"), "--e2e-cmd", - _command("raise SystemExit('must not run')"), + _command( + f"from pathlib import Path; Path({str(sentinel)!r}).touch()" + ), "--service-log-limit-bytes", "4096", ] @@ -97,6 +102,7 @@ def test_service_log_overflow_returns_resource_limit_before_e2e( assert "service output exceeded 4096 bytes" in captured.err assert payload["output_limited"] is True assert payload["service_log_limit_bytes"] == 4096 + assert not sentinel.exists() def test_e2e_output_overflow_is_bounded_and_returns_123( From ac1fa27ec61ffe813b542ecceb6423115d60748a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:05:56 +0900 Subject: [PATCH 80/93] fix(ci): bound normal-path capture joins --- scripts/ci/bounded_subprocess.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/ci/bounded_subprocess.py b/scripts/ci/bounded_subprocess.py index 814ce2a0a..4de68f342 100644 --- a/scripts/ci/bounded_subprocess.py +++ b/scripts/ci/bounded_subprocess.py @@ -6,8 +6,8 @@ import signal import subprocess import threading -from contextlib import suppress from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress from dataclasses import dataclass from pathlib import Path from typing import BinaryIO @@ -19,6 +19,7 @@ MAXIMUM_OUTPUT_LIMIT_BYTES = 67_108_864 MINIMUM_OUTPUT_LIMIT_BYTES = 4_096 READ_CHUNK_BYTES = 65_536 +READER_JOIN_TIMEOUT_SECONDS = 30.0 TRUNCATION_MARKER = "...[output truncated]...\n" @@ -301,13 +302,16 @@ def kill_process_group(process: subprocess.Popen[bytes]) -> None: return -def _join_captures(captures: Sequence[BoundedOutputCapture]) -> None: - """Finalize every stream reader while preserving the first reported failure.""" +def _join_captures( + captures: Sequence[BoundedOutputCapture], + timeout: float = READER_JOIN_TIMEOUT_SECONDS, +) -> None: + """Finalize every stream reader within one finite per-reader deadline.""" first_error: BaseException | None = None for capture in captures: try: - capture.join() + capture.join(timeout) except BaseException as error: # noqa: BLE001 - re-raised after sibling join if first_error is None: first_error = error From 8f4dd8e7625a6a283b2780c1ecb6b34bfadc31ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:07:39 +0900 Subject: [PATCH 81/93] fix(ci): require dash prefix for option-value redaction --- scripts/ci/redact_sensitive_log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 624273f34..f8836cf42 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -178,6 +178,7 @@ def redact_command_arguments(arguments: Sequence[str]) -> list[str]: redact_next = False continue + is_option = argument.startswith("-") option = argument.lstrip("-") if "=" in option: key, _value = option.split("=", 1) @@ -187,7 +188,7 @@ def redact_command_arguments(arguments: Sequence[str]) -> list[str]: continue redacted.append(redact_text(argument)) - if SENSITIVE_OPTION_RE.fullmatch(option): + if is_option and SENSITIVE_OPTION_RE.fullmatch(option): redact_next = True return redacted From 8657f362c9813a4eefeeb441f8d0d2294fdfebff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:12:49 +0900 Subject: [PATCH 82/93] fix(test): use the real backend readiness option --- tests/test_sandboxed_web_e2e_output_limits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sandboxed_web_e2e_output_limits.py b/tests/test_sandboxed_web_e2e_output_limits.py index 87a5ac81f..e8d94be1b 100644 --- a/tests/test_sandboxed_web_e2e_output_limits.py +++ b/tests/test_sandboxed_web_e2e_output_limits.py @@ -83,7 +83,7 @@ def test_service_log_overflow_returns_resource_limit_before_e2e( "while True:\n" " os.write(1,chunk)\n" ), - "--backend-url", + "--backend-ready-url", "http://127.0.0.1:1/ready", "--frontend-cmd", _command("import time; time.sleep(30)"), From 1e2f9b4fcee2f5ec3c516ccad953cf4f6c5f1c70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:14:51 +0900 Subject: [PATCH 83/93] docs(ci): record final sandbox review repairs --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98651760..43e2181b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed +- Apply a finite 30-second bound to every normal-path output-reader join, finalize sibling captures before re-raising the first failure, and surface a stuck inherited pipe descriptor as `bounded output drain did not finish` instead of waiting until the outer CI timeout. +- Require a dash prefix before a sensitive command-line option consumes the following argument, while preserving redaction for `TOKEN=value`, `--token=value`, separate dashed credential options, and provider-shaped values. +- Prove with a sentinel-file regression that a service-log overflow is detected during readiness and prevents the E2E command from executing. - Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. - Avoid process-wide file-size limits that would incorrectly constrain coverage databases, compiled assets, archives, and other legitimate repository artifacts unrelated to stdout/stderr evidence. ### Documentation - Add APA 7 doctoring for the sandbox command/output redaction boundary, structured diagnostics, availability controls, verification evidence, limitations, and rollback requirements. -- Add APA 7 doctoring for bounded subprocess pipe draining, process-group termination, bounded service evidence, exit-code precedence, realistic flood tests, limitations, and rollback requirements. +- Add APA 7 doctoring for bounded subprocess pipe draining, process-group termination, bounded service evidence, exit-code precedence, realistic flood tests, limitations, and rollback requirements. \ No newline at end of file From 5fc9b643ab206f4e17aed010ca2785f70b08ef68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:17:11 +0900 Subject: [PATCH 84/93] docs(ci): bound inherited-pipe finalization evidence --- .../sandboxed-output-resource-bounds.md | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/doctoring/sandboxed-output-resource-bounds.md b/docs/doctoring/sandboxed-output-resource-bounds.md index b35f9f2f5..1c15c1d5a 100644 --- a/docs/doctoring/sandboxed-output-resource-bounds.md +++ b/docs/doctoring/sandboxed-output-resource-bounds.md @@ -9,7 +9,7 @@ The default retained budgets are: - 1,048,576 bytes for each short-lived command stream; and - 4,194,304 bytes for each backend or frontend combined service stream. -Configurations below 4,096 bytes or above 67,108,864 bytes are rejected before repository code executes. +Configurations below 4,096 bytes or above 67,108,864 bytes are rejected before repository code executes. Every normal-path output-reader join also has a finite 30-second bound. ## Why complete capture was unsafe @@ -17,6 +17,8 @@ Python's `subprocess.PIPE` creates operating-system pipes for child standard str The control plane therefore uses `Popen` directly, starts one reader thread per pipe immediately, reads fixed 64 KiB chunks, and retains only a locked final suffix. The first byte beyond a stream budget marks the result and kills the entire child process group created with `start_new_session=True`. Reader threads continue through EOF and are joined before bounded text is decoded or published. +A descendant can intentionally create a new session while retaining an inherited stdout or stderr descriptor. The original process group can then terminate while the escaped descendant keeps the pipe open. For that reason, every ordinary reader finalization passes the 30-second join bound to each capture, continues to finalize sibling readers, and then re-raises the first failure. A reader still alive after that bound produces the explicit `bounded output drain did not finish` failure instead of holding the job until its outer workflow timeout. + ## Rejected process-wide file limit POSIX file-size resource limits apply to every regular file written by the child process. A repository verification command may legitimately create coverage databases, compiled assets, archives, package artifacts, temporary databases, or generated fixtures larger than its log budget. Applying `RLIMIT_FSIZE` to the child would therefore change application and build behavior rather than only bounding evidence. The implemented boundary constrains stdout/stderr retention and leaves ordinary repository file semantics unchanged. @@ -30,7 +32,7 @@ POSIX file-size resource limits apply to every regular file written by the child 3. connects stdout and stderr to independent binary pipes; 4. drains both pipes concurrently into separate bounded final-suffix buffers; 5. kills the process group exactly once when either stream exceeds its budget; -6. kills the group on timeout and joins both readers; and +6. kills the group on timeout and joins both readers through the finite normal-path bound; and 7. returns or raises only bounded evidence. A truncation marker is included inside, not in addition to, the declared retained byte budget. Reader errors and reader-join timeouts are explicit failures. @@ -41,18 +43,21 @@ Each backend and frontend uses one combined stdout/stderr pipe and the same boun Service overflow is checked during readiness, after E2E execution, and after service shutdown. It takes precedence over an ordinary command or readiness result, but a true E2E timeout remains `124`. `tail_text()` reads no more than 65,536 bytes from the end of the already bounded file, retains the configured final line count, and then applies the shared credential-redaction boundary. +A realistic regression gives the flooding backend an actual readiness URL and configures the E2E command to create a sentinel file. The overflow result must be emitted while the sentinel remains absent, proving that readiness handling cannot silently execute an ordinary E2E command before acknowledging the service evidence limit. + ## Security and availability properties - Parent retained memory is bounded independently for stdout and stderr. - Service evidence disk use is bounded per service. - Child pipes are continuously drained, preventing a full pipe from blocking the child indefinitely. -- Process-group termination covers descendants that retain inherited pipe descriptors. +- Process-group termination covers ordinary descendants that retain inherited pipe descriptors. +- A descendant that escapes the original group cannot create an unbounded reader join. - Structured argv and `shell=False` remain unchanged. - Environment scrubbing, output redaction, timeout enforcement, process cleanup, SSRF-safe readiness polling, and machine-readable evidence remain independent controls. - Non-POSIX environments fail closed rather than using unmanaged capture. -- Output overflow cannot be converted into success by the child process. +- Output overflow cannot be converted into success by the child process or into an E2E execution by readiness short-circuiting. -MITRE CWE-770 identifies unbounded memory and other resource consumption as an availability weakness and recommends explicit minimum/maximum expectations, throttling, quotas, and safe failure when limits are reached. This implementation sets explicit per-stream ceilings and a stable failure result. NIST SP 800-218 supplies the secure-development framework used to define, test, and retain this control as reviewable evidence. +MITRE CWE-770 identifies unbounded memory and other resource consumption as an availability weakness and recommends explicit minimum/maximum expectations, throttling, quotas, and safe failure when limits are reached. This implementation sets explicit per-stream ceilings, a finite finalization bound, and a stable failure result. NIST SP 800-218 supplies the secure-development framework used to define, test, and retain this control as reviewable evidence. No formal CWE, NIST, or POSIX conformity is claimed. @@ -65,13 +70,13 @@ Real subprocess tests exercise: - timeout with partial output; - final-suffix retention and one overflow callback; - bounded persisted service evidence; -- service overflow before or during readiness/E2E; +- service overflow before or during readiness/E2E, including a sentinel proof that E2E never ran; - ordinary backend/frontend/E2E success and cleanup; - partial UTF-8 suffix decoding; - bounded file reads; - unsupported-platform failure; - invalid budgets; -- reader exceptions and stuck-reader joins; +- reader exceptions, stuck-reader joins, a common finite join bound, and sibling finalization after the first failure; - retained redaction of credentials in output, commands, notes, structured JSON, and service tails; and - deterministic result fields and exit-code precedence. @@ -89,9 +94,11 @@ This slice does not limit: The reader buffers intentionally retain the final suffix rather than the complete beginning of an oversized stream because terminal diagnostics normally contain the most actionable failure evidence. Complete oversized logs are not retained as artifacts. +The finite reader join converts an escaped inherited descriptor into a deterministic failure, but it does not discover or terminate arbitrary processes outside the original process group. Isolation beyond that boundary remains the responsibility of the surrounding container or runner. + ## Rollback -Rollback must restore a different proven memory-and-disk bound for every short-lived and long-running publication path. Reverting only the process-group kill, service capture, or suffix reader would recreate an unbounded path around the remaining controls. Before rollback, operators must demonstrate realistic flood tests, bounded retained memory and files, timeout behavior, cleanup, redaction, and exact-head independent review. +Rollback must restore a different proven memory-and-disk bound for every short-lived and long-running publication path. Reverting only the process-group kill, service capture, suffix reader, or finite reader join would recreate an unbounded path around the remaining controls. Before rollback, operators must demonstrate realistic flood tests, bounded retained memory and files, finite finalization, timeout behavior, cleanup, redaction, and exact-head independent review. ## APA 7 references From 16143d17f820d77dffe3cb550f47f58ffbdff0a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:18:35 +0900 Subject: [PATCH 85/93] docs(ci): preserve ordinary command operands during redaction --- .../doctoring/sandboxed-command-log-redaction.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/sandboxed-command-log-redaction.md b/docs/doctoring/sandboxed-command-log-redaction.md index 517ae519c..652900a17 100644 --- a/docs/doctoring/sandboxed-command-log-redaction.md +++ b/docs/doctoring/sandboxed-command-log-redaction.md @@ -9,8 +9,9 @@ One trusted redaction module owns this publication boundary: - captured standard output and standard error are redacted before printing; - `TimeoutExpired` byte and text payloads use the same redaction path; - service log tails are redacted before publication; -- command arguments following sensitive options such as `--token`, `--password`, or `--api-key` are replaced; -- sensitive `KEY=value` command arguments are replaced while preserving the key; +- command arguments following dash-prefixed sensitive options such as `--token`, `--password`, or `--api-key` are replaced; +- sensitive `KEY=value` and `--option=value` command arguments are replaced while preserving the key; +- bare ordinary arguments such as a container environment name `TOKEN` do not consume and erase the following image or operand; - standalone provider-token shapes are removed; - valid JSON is traversed recursively so credential-shaped object keys and string values cannot bypass line-oriented patterns; - shell command strings are parsed without execution and reconstructed from redacted arguments; and @@ -24,11 +25,14 @@ Repository verification commands and web end-to-end services can emit credential GitHub Actions logs and review envelopes are durable evidence with a potentially broader readership than the originating credential. MITRE classifies insertion of sensitive information into log files as CWE-532. OWASP's current logging guidance identifies access tokens, passwords, database connection strings, encryption keys, and other primary secrets as values that should normally be removed, masked, sanitized, hashed, or encrypted before logging. NIST SSDF requires protection of software and development artifacts from unauthorized access and disclosure. +Over-redaction is also an operational integrity risk. Treating any bare word matching `TOKEN` or `PASSWORD` as a command-line option would cause the following ordinary argument to disappear from review evidence. For example, `docker run -e TOKEN image` names an environment variable and then an image; only the dashed option grammar may consume a subsequent argument. Assignment forms remain independently redacted before that decision. + ## Security and availability boundaries - No provider-shaped credential literal is committed as a test fixture. Tests construct credential-shaped values from fragments at runtime so Secret Scan remains authoritative. -- Redaction is fail-closed for recognized sensitive option names, assignments, bearer/basic values, JWTs, and known provider token formats, but it is not a general data-loss-prevention engine. -- Sensitive option detection uses explicit credential terms. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool. +- Redaction is fail-closed for recognized sensitive dash-prefixed option names, assignments, bearer/basic values, JWTs, and known provider token formats, but it is not a general data-loss-prevention engine. +- Sensitive option detection uses explicit credential terms and requires the original argument to begin with `-` before the next argument is consumed. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool. +- `TOKEN=value` and `--token=value` are processed before the next-argument option rule, so assignment values remain redacted without sacrificing the next operand. - Shell strings are tokenized with `shlex.split`; no shell is invoked for redaction. Malformed strings fall back to line-oriented redaction. - `subprocess.run` and `subprocess.Popen` receive structured argument arrays with `shell=False`. Preventing shell interpretation and preventing log disclosure are independent controls. - The assignment scanner advances through each ordinary identifier once. A deterministic instrumentation test prevents a long non-sensitive token from reintroducing quadratic rescanning and log-processing denial of service. @@ -51,7 +55,7 @@ The focused regression suite constructs a credential-shaped token at runtime and 8. nested JSON string values; or 9. JSON object keys. -The tests also cover separate sensitive options, `--option=value`, `KEY=value` assignments, standalone provider-token shapes, malformed shell quoting, missing logs, bounded final-line selection, recursive JSON structures, bounded assignment scanning, and both wrappers' end-to-end publication paths. Ordinary text, line endings, result envelopes, cleanup, timeouts, and child-process exit codes remain observable. +The tests also cover separate dash-prefixed sensitive options, `--option=value`, `KEY=value` assignments, standalone provider-token shapes, malformed shell quoting, missing logs, bounded final-line selection, recursive JSON structures, bounded assignment scanning, and both wrappers' end-to-end publication paths. A dedicated command-array regression proves that `docker run -e TOKEN image` remains fully visible while `--token credential-value` still redacts only the credential value. Ordinary text, line endings, result envelopes, cleanup, timeouts, and child-process exit codes remain observable. The exact pull-request head must additionally pass the complete central unit suite, 100% production statement and branch coverage for the changed surface, production docstring checks, Secret Scan, CodeQL, Semgrep, Python Security, Security Scan, OpenCode, Noema, CodeRabbit, independent current-head approval, and branch protection before merge. @@ -61,7 +65,7 @@ The exact pull-request head must additionally pass the complete central unit sui ## Rollback -Rollback must restore every publication sink as one atomic change. Removing only command redaction, JSON traversal, service-tail redaction, or result-envelope redaction would recreate a bypass around the remaining controls. Before rollback, operators must prove that no allowlisted credential can reach child output or command metadata and must retain equivalent focused regression evidence. +Rollback must restore every publication sink as one atomic change. Removing only command redaction, JSON traversal, service-tail redaction, or result-envelope redaction would recreate a bypass around the remaining controls. Before rollback, operators must prove that no allowlisted credential can reach child output or command metadata and must retain equivalent focused regression evidence. A rollback must also preserve ordinary operands after bare environment-variable names rather than reintroducing evidence loss through over-redaction. ## APA 7 references From 24a13782c57ec1c797ed159ced4aad119a0a1680 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:18:49 +0900 Subject: [PATCH 86/93] chore(pr767): remove superseded repair script --- .../scripts/finalize_pr767_review_repairs.py | 309 ------------------ 1 file changed, 309 deletions(-) delete mode 100755 .github/scripts/finalize_pr767_review_repairs.py diff --git a/.github/scripts/finalize_pr767_review_repairs.py b/.github/scripts/finalize_pr767_review_repairs.py deleted file mode 100755 index c9aeba9b8..000000000 --- a/.github/scripts/finalize_pr767_review_repairs.py +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env python3 -"""Apply test-first final review repairs for PR 767.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -BOUNDED = ROOT / "scripts/ci/bounded_subprocess.py" -REDACTOR = ROOT / "scripts/ci/redact_sensitive_log.py" -WEB = ROOT / "scripts/ci/sandboxed_web_e2e.py" -TEST_BOUNDED = ROOT / "tests/test_bounded_subprocess.py" -TEST_REDACTOR = ROOT / "tests/test_redact_sensitive_log_contract.py" -TEST_ENTRYPOINT = ROOT / "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py" -TEST_WEB = ROOT / "tests/test_sandboxed_web_e2e.py" -TEST_WEB_LIMITS = ROOT / "tests/test_sandboxed_web_e2e_output_limits.py" -CHANGELOG = ROOT / "CHANGELOG.md" -WORKFLOW = ROOT / ".github/workflows/finalize-pr767-review-repairs.yml" -SCRIPT = Path(__file__).resolve() - - -def replace_once(path: Path, old: str, new: str) -> None: - """Replace exactly one audited UTF-8 fragment.""" - source = path.read_text(encoding="utf-8") - count = source.count(old) - if count != 1: - raise SystemExit(f"expected one anchor in {path}, found {count}: {old[:100]!r}") - path.write_text(source.replace(old, new, 1), encoding="utf-8") - - -def append_once(path: Path, marker: str, addition: str) -> None: - """Append one regression block when its marker is absent.""" - source = path.read_text(encoding="utf-8") - if marker in source: - raise SystemExit(f"regression already exists in {path}: {marker}") - path.write_text(source + addition, encoding="utf-8") - - -def add_tests() -> None: - """Add regressions that fail against the uncorrected production contracts.""" - append_once( - TEST_BOUNDED, - "test_join_captures_applies_finite_timeout_and_finishes_siblings", - r''' - - -def test_join_captures_applies_finite_timeout_and_finishes_siblings() -> None: - """Every reader receives a finite join bound and siblings still finalize.""" - - calls: list[tuple[str, float | None]] = [] - - class Capture: - """Record the supplied timeout and optionally fail.""" - - def __init__(self, name: str, error: BaseException | None = None) -> None: - self.name = name - self.error = error - - def join(self, timeout: float | None = None) -> None: - calls.append((self.name, timeout)) - if self.error is not None: - raise self.error - - with pytest.raises(RuntimeError, match="first reader failed"): - bounded._join_captures( # noqa: SLF001 - focused internal contract - [Capture("first", RuntimeError("first reader failed")), Capture("second")] - ) - - assert calls == [ - ("first", bounded.READER_JOIN_TIMEOUT_SECONDS), - ("second", bounded.READER_JOIN_TIMEOUT_SECONDS), - ] -''', - ) - append_once( - TEST_REDACTOR, - "test_bare_sensitive_word_does_not_consume_the_next_argument", - r''' - - -def test_bare_sensitive_word_does_not_consume_the_next_argument() -> None: - """Only dash-prefixed options treat the following argument as a value.""" - - assert redactor.redact_command_arguments( - ["docker", "run", "-e", "TOKEN", "image"] - ) == ["docker", "run", "-e", "TOKEN", "image"] - assert redactor.redact_command_arguments( - ["tool", "TOKEN=value", "image"] - ) == ["tool", "TOKEN=[REDACTED]", "image"] -''', - ) - replace_once( - TEST_ENTRYPOINT, - "import runpy\nimport subprocess\nimport sys\n", - "import runpy\nimport sys\n", - ) - replace_once( - TEST_ENTRYPOINT, - ''' lambda *args, **kwargs: subprocess.CompletedProcess( - args=["e2e"], - returncode=0, - stdout="ok\\n", - stderr="", - ), -''', - ''' lambda *args, **kwargs: bounded_subprocess.BoundedCompletedProcess( - args=("e2e",), - returncode=0, - stdout="ok\\n", - stderr="", - output_limited=False, - ), -''', - ) - replace_once( - TEST_ENTRYPOINT, - ' raise OSError(f"cannot finalize {service.label}")\n', - ' raise ValueError(f"cannot finalize {service.label}")\n', - ) - replace_once( - TEST_WEB, - ''' lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: subprocess.CompletedProcess( - command, - 0, - stdout="e2e-out\\n", - stderr="e2e-err\\n", - ), -''', - ''' lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: sandboxed_web_e2e.bounded_subprocess.BoundedCompletedProcess( - args=("e2e",), - returncode=0, - stdout="e2e-out\\n", - stderr="e2e-err\\n", - output_limited=False, - ), -''', - ) - replace_once( - TEST_WEB_LIMITS, - ''' exit_code = sandboxed_web_e2e.main( - [ - "--repo-root", - str(_repository(tmp_path)), - "--backend-cmd", - _command( - "import os\\n" - "chunk=b'x'*1024\\n" - "while True:\\n" - " os.write(1,chunk)\\n" - ), - "--frontend-cmd", - _command("import time; time.sleep(30)"), - "--e2e-cmd", - _command("raise SystemExit('must not run')"), - "--service-log-limit-bytes", - "4096", - ] - ) -''', - ''' sentinel = tmp_path / "e2e-ran" - exit_code = sandboxed_web_e2e.main( - [ - "--repo-root", - str(_repository(tmp_path)), - "--backend-cmd", - _command( - "import os\\n" - "chunk=b'x'*1024\\n" - "while True:\\n" - " os.write(1,chunk)\\n" - ), - "--backend-ready-url", - "http://127.0.0.1:1/ready", - "--frontend-cmd", - _command("import time; time.sleep(30)"), - "--e2e-cmd", - _command( - f"from pathlib import Path; Path({str(sentinel)!r}).touch()" - ), - "--service-log-limit-bytes", - "4096", - ] - ) -''', - ) - replace_once( - TEST_WEB_LIMITS, - ''' assert payload["output_limited"] is True - assert payload["service_log_limit_bytes"] == 4096 - - -def test_e2e_output_overflow_is_bounded_and_returns_123( -''', - ''' assert payload["output_limited"] is True - assert payload["service_log_limit_bytes"] == 4096 - assert not sentinel.exists() - - -def test_e2e_output_overflow_is_bounded_and_returns_123( -''', - ) - - -def apply_repair() -> None: - """Apply the bounded reader, redaction, and cleanup corrections.""" - replace_once( - BOUNDED, - 'READ_CHUNK_BYTES = 65_536\nTRUNCATION_MARKER = "...[output truncated]...\\n"\n', - 'READ_CHUNK_BYTES = 65_536\nREADER_JOIN_TIMEOUT_SECONDS = 30.0\nTRUNCATION_MARKER = "...[output truncated]...\\n"\n', - ) - replace_once( - BOUNDED, - '''def _join_captures(captures: Sequence[BoundedOutputCapture]) -> None: - """Finalize every stream reader while preserving the first reported failure.""" - - first_error: BaseException | None = None - for capture in captures: - try: - capture.join() - except BaseException as error: # noqa: BLE001 - re-raised after sibling join - if first_error is None: - first_error = error - if first_error is not None: - raise first_error -''', - '''def _join_captures( - captures: Sequence[BoundedOutputCapture], - timeout: float = READER_JOIN_TIMEOUT_SECONDS, -) -> None: - """Finalize every stream reader within a finite bound, preserving the first failure.""" - - first_error: BaseException | None = None - for capture in captures: - try: - capture.join(timeout) - except BaseException as error: # noqa: BLE001 - re-raised after sibling join - if first_error is None: - first_error = error - if first_error is not None: - raise first_error -''', - ) - replace_once( - REDACTOR, - ''' option = argument.lstrip("-") - if "=" in option: -''', - ''' is_option = argument.startswith("-") - option = argument.lstrip("-") - if "=" in option: -''', - ) - replace_once( - REDACTOR, - ''' redacted.append(redact_text(argument)) - if SENSITIVE_OPTION_RE.fullmatch(option): - redact_next = True -''', - ''' redacted.append(redact_text(argument)) - if is_option and SENSITIVE_OPTION_RE.fullmatch(option): - redact_next = True -''', - ) - replace_once( - WEB, - ''' except (OSError, RuntimeError, subprocess.SubprocessError): - output_limited = True -''', - ''' except Exception: # noqa: BLE001 - cleanup must not skip result emission - output_limited = True -''', - ) - text = CHANGELOG.read_text(encoding="utf-8") - additions = [ - "- Bound normal-path stdout/stderr reader joins so inherited pipe descriptors cannot hold a sandbox job indefinitely.\n", - "- Preserve ordinary command arguments after bare credential-shaped words while retaining dash-prefixed option and assignment redaction.\n", - "- Continue sandbox result emission and directory cleanup after any ordinary service-capture finalization exception.\n", - ] - marker = "### Fixed\n\n" - if marker not in text: - raise SystemExit("CHANGELOG Fixed section missing") - for addition in reversed(additions): - if addition not in text: - text = text.replace(marker, marker + addition, 1) - CHANGELOG.write_text(text, encoding="utf-8") - - -def cleanup() -> None: - """Remove the temporary exact-head workflow and helper.""" - WORKFLOW.unlink() - SCRIPT.unlink() - - -def main() -> None: - """Execute one deterministic repair phase.""" - parser = argparse.ArgumentParser() - parser.add_argument("phase", choices=("add-tests", "apply", "cleanup")) - args = parser.parse_args() - if args.phase == "add-tests": - add_tests() - elif args.phase == "apply": - apply_repair() - else: - cleanup() - - -if __name__ == "__main__": - main() From 0dc19792c440a54865f68d344500f498efb88f6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:19:10 +0900 Subject: [PATCH 87/93] chore(pr767): remove superseded isolation helper --- .github/scripts/patch_pr767_git_isolation.py | 24 -------------------- 1 file changed, 24 deletions(-) delete mode 100644 .github/scripts/patch_pr767_git_isolation.py diff --git a/.github/scripts/patch_pr767_git_isolation.py b/.github/scripts/patch_pr767_git_isolation.py deleted file mode 100644 index 734080db0..000000000 --- a/.github/scripts/patch_pr767_git_isolation.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -"""Make the Git ownership contract hermetic to runner-global configuration.""" - -from pathlib import Path - -path = Path("tests/test_opencode_agent_contract.py") -text = path.read_text(encoding="utf-8") -old = ''' base_env = { - **os.environ, - "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", - } -''' -new = ''' base_env = { - **os.environ, - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_CONFIG_GLOBAL": "/dev/null", - "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1", - } -''' -if text.count(old) != 1: - raise SystemExit( - f"expected one runner-global Git isolation anchor, found {text.count(old)}" - ) -path.write_text(text.replace(old, new, 1), encoding="utf-8") From 3bc52ff020a503c783300ee7a94dee943198044b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:19:33 +0900 Subject: [PATCH 88/93] chore(pr767): remove superseded repair workflow v2 --- .../finalize-pr767-review-repairs-v2.yml | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 .github/workflows/finalize-pr767-review-repairs-v2.yml diff --git a/.github/workflows/finalize-pr767-review-repairs-v2.yml b/.github/workflows/finalize-pr767-review-repairs-v2.yml deleted file mode 100644 index d2ec5f03c..000000000 --- a/.github/workflows/finalize-pr767-review-repairs-v2.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: Finalize PR 767 review repairs v2 - -on: - push: - branches: - - fix/sandboxed-output-resource-bounds - paths: - - .github/workflows/finalize-pr767-review-repairs-v2.yml - -permissions: - contents: read - -concurrency: - group: finalize-pr767-review-repairs-v2 - cancel-in-progress: false - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Check out exact repair trigger - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.sha }} - fetch-depth: 2 - persist-credentials: false - - - name: Verify exact parent and repair sources - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD^)" = "1dee6d36c9763cd091f10a100f6cb927647d3977" - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - python3 -m py_compile \ - .github/scripts/finalize_pr767_review_repairs.py \ - .github/scripts/patch_pr767_git_isolation.py - - - name: Set up Python 3.14 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install hash-locked test tooling - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Isolate runner-global Git state - run: python .github/scripts/patch_pr767_git_isolation.py - - - name: Add regressions before production changes - run: python .github/scripts/finalize_pr767_review_repairs.py add-tests - - - name: Prove review findings are red - shell: bash --noprofile --norc {0} - run: | - set +e - python -m pytest -q \ - tests/test_bounded_subprocess.py::test_join_captures_applies_finite_timeout_and_finishes_siblings \ - tests/test_redact_sensitive_log_contract.py::test_bare_sensitive_word_does_not_consume_the_next_argument \ - tests/test_sandboxed_entrypoint_and_cleanup_coverage.py::test_web_e2e_reports_bounded_capture_finalization_failure \ - >"${RUNNER_TEMP}/pr767-red.log" 2>&1 - status=$? - set -e - cat "${RUNNER_TEMP}/pr767-red.log" - test "$status" -ne 0 - - - name: Apply bounded source repair - run: | - python .github/scripts/finalize_pr767_review_repairs.py apply - git diff --check - - - name: Verify focused, full, coverage, and docstring evidence - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python -m pytest -q \ - tests/test_bounded_subprocess.py \ - tests/test_redact_sensitive_log_contract.py \ - tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ - tests/test_sandboxed_web_e2e.py \ - tests/test_sandboxed_web_e2e_output_limits.py \ - tests/test_opencode_agent_contract.py::test_sandbox_git_config_env_marks_only_the_validated_worktree_safe - python -m pytest -q - python -m coverage erase - python -m coverage run --branch -m pytest -q - python -m coverage report --fail-under=100 --show-missing - python -m interrogate --fail-under 100 \ - scripts/ci/bounded_subprocess.py \ - scripts/ci/redact_sensitive_log.py \ - scripts/ci/sandboxed_verify.py \ - scripts/ci/sandboxed_web_e2e.py - python -m compileall -q scripts/ci tests - git diff --check - - - name: Publish exact repair and remove all temporary automation - env: - EXPECTED_HEAD: ${{ github.sha }} - SOURCE_BRANCH: fix/sandboxed-output-resource-bounds - GH_TOKEN: ${{ github.token }} - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" - rm -f \ - .github/scripts/finalize_pr767_review_repairs.py \ - .github/scripts/patch_pr767_git_isolation.py \ - .github/workflows/finalize-pr767-review-repairs.yml \ - .github/workflows/finalize-pr767-review-repairs-v2.yml \ - .github/workflows/one-shot-pr767-review-fixes.yml - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(ci): close sandbox output review gaps" - remote_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git push --force-with-lease="refs/heads/${SOURCE_BRANCH}:${EXPECTED_HEAD}" \ - "$remote_url" "HEAD:refs/heads/${SOURCE_BRANCH}" From 28a86e252a6ab109ef8ee72daac2383a89b41919 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:19:56 +0900 Subject: [PATCH 89/93] chore(pr767): remove superseded repair workflow --- .../finalize-pr767-review-repairs.yml | 131 ------------------ 1 file changed, 131 deletions(-) delete mode 100644 .github/workflows/finalize-pr767-review-repairs.yml diff --git a/.github/workflows/finalize-pr767-review-repairs.yml b/.github/workflows/finalize-pr767-review-repairs.yml deleted file mode 100644 index b801364f8..000000000 --- a/.github/workflows/finalize-pr767-review-repairs.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: Finalize PR 767 review repairs - -on: - push: - branches: - - fix/sandboxed-output-resource-bounds - paths: - - .github/workflows/finalize-pr767-review-repairs.yml - -permissions: - contents: read - -concurrency: - group: finalize-pr767-review-repairs - cancel-in-progress: false - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Check out exact repair trigger - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.sha }} - fetch-depth: 2 - persist-credentials: false - - - name: Verify audited parent and repair source - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD^)" = "36afcf80cd6db52072012da3a2503baa6f5680d0" - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - test "$(git hash-object .github/scripts/finalize_pr767_review_repairs.py)" = \ - "c9aeba9b8e644d609c54d4017a8b3897b16766a9" - - - name: Set up Python 3.14 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install hash-locked test tooling - shell: bash --noprofile --norc -e -o pipefail {0} - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Add regressions before production changes - shell: bash --noprofile --norc -e -o pipefail {0} - run: python .github/scripts/finalize_pr767_review_repairs.py add-tests - - - name: Prove current review findings are red - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - set +e - python -m pytest -q \ - tests/test_bounded_subprocess.py::test_join_captures_applies_finite_timeout_and_finishes_siblings \ - tests/test_redact_sensitive_log_contract.py::test_bare_sensitive_word_does_not_consume_the_next_argument \ - tests/test_sandboxed_entrypoint_and_cleanup_coverage.py::test_web_e2e_reports_bounded_capture_finalization_failure \ - >"${RUNNER_TEMP}/pr767-red.log" 2>&1 - status=$? - set -e - cat "${RUNNER_TEMP}/pr767-red.log" - test "$status" -ne 0 - - - name: Apply bounded source repair - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python .github/scripts/finalize_pr767_review_repairs.py apply - git diff --check - - - name: Verify focused and full quality evidence - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python -m pytest -q \ - tests/test_bounded_subprocess.py \ - tests/test_redact_sensitive_log_contract.py \ - tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ - tests/test_sandboxed_web_e2e.py \ - tests/test_sandboxed_web_e2e_output_limits.py - python -m pytest -q - python -m interrogate --fail-under 100 \ - scripts/ci/bounded_subprocess.py \ - scripts/ci/redact_sensitive_log.py \ - scripts/ci/sandboxed_web_e2e.py - python -m compileall -q scripts/ci tests - git diff --check - - - name: Publish exact repair and remove one-shot automation - env: - EXPECTED_HEAD: ${{ github.sha }} - SOURCE_BRANCH: fix/sandboxed-output-resource-bounds - GH_TOKEN: ${{ github.token }} - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" - python .github/scripts/finalize_pr767_review_repairs.py cleanup - git diff --check - test "$(git diff --name-only | sort)" = "$(printf '%s\n' \ - .github/scripts/finalize_pr767_review_repairs.py \ - .github/workflows/finalize-pr767-review-repairs.yml \ - CHANGELOG.md \ - scripts/ci/bounded_subprocess.py \ - scripts/ci/redact_sensitive_log.py \ - scripts/ci/sandboxed_web_e2e.py \ - tests/test_bounded_subprocess.py \ - tests/test_redact_sensitive_log_contract.py \ - tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ - tests/test_sandboxed_web_e2e.py \ - tests/test_sandboxed_web_e2e_output_limits.py | sort)" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(ci): close sandbox output review gaps" - remote_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git push --force-with-lease="refs/heads/${SOURCE_BRANCH}:${EXPECTED_HEAD}" \ - "$remote_url" "HEAD:refs/heads/${SOURCE_BRANCH}" From a4674c4a111d085392fd5dd06c1f16b3136e5720 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:20:17 +0900 Subject: [PATCH 90/93] chore(pr767): remove duplicate review-fix workflow --- .../workflows/one-shot-pr767-review-fixes.yml | 443 ------------------ 1 file changed, 443 deletions(-) delete mode 100644 .github/workflows/one-shot-pr767-review-fixes.yml diff --git a/.github/workflows/one-shot-pr767-review-fixes.yml b/.github/workflows/one-shot-pr767-review-fixes.yml deleted file mode 100644 index 32b7cbbf3..000000000 --- a/.github/workflows/one-shot-pr767-review-fixes.yml +++ /dev/null @@ -1,443 +0,0 @@ -name: One-shot PR 767 current review fixes - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/one-shot-pr767-review-fixes.yml - -permissions: - contents: read - -concurrency: - group: one-shot-pr767-current-review-fixes - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-and-verify: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - permissions: - contents: write - runs-on: ubuntu-24.04 - timeout-minutes: 55 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact contributor head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked test tooling - shell: bash --noprofile --norc -e -o pipefail {0} - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Add failing review regressions first - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python - <<'PY' - from pathlib import Path - from textwrap import dedent - - def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - source = target.read_text(encoding="utf-8") - old_text = dedent(old) - new_text = dedent(new) - if source.count(old_text) != 1: - raise SystemExit( - f"{path}: expected one guarded test replacement, " - f"found {source.count(old_text)}" - ) - target.write_text(source.replace(old_text, new_text, 1), encoding="utf-8") - - replace_once( - "tests/test_bounded_subprocess.py", - """ - def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: - """, - """ - def test_join_captures_applies_a_finite_timeout_and_preserves_first_error() -> None: - """Every reader receives the finite join bound before the first error returns.""" - - class Capture: - """Record join bounds and optionally raise one deterministic failure.""" - - def __init__(self, error: BaseException | None = None) -> None: - """Store the optional failure and an empty timeout audit trail.""" - - self.error = error - self.timeouts: list[float | None] = [] - - def join(self, timeout: float | None = None) -> None: - """Record the requested bound and raise the configured failure.""" - - self.timeouts.append(timeout) - if self.error is not None: - raise self.error - - first = Capture(RuntimeError("first reader failure")) - sibling = Capture() - - with pytest.raises(RuntimeError, match="first reader failure"): - bounded._join_captures([first, sibling]) # noqa: SLF001 - - assert first.timeouts == [bounded.READER_JOIN_TIMEOUT_SECONDS] - assert sibling.timeouts == [bounded.READER_JOIN_TIMEOUT_SECONDS] - - - def test_run_bounded_command_rejects_empty_command_and_timeout(tmp_path: Path) -> None: - """, - ) - - replace_once( - "tests/test_sandboxed_output_redaction.py", - """ - assert redact_command_arguments( - ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] - ) == [ - "tool", - "--api-key", - REDACTED, - f"TOKEN={REDACTED}", - REDACTED, - "plain", - ] - """, - """ - assert redact_command_arguments( - ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] - ) == [ - "tool", - "--api-key", - REDACTED, - f"TOKEN={REDACTED}", - REDACTED, - "plain", - ] - assert redact_command_arguments( - ["docker", "run", "-e", "TOKEN", "image"] - ) == ["docker", "run", "-e", "TOKEN", "image"] - """, - ) - - replace_once( - "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py", - "import runpy\nimport subprocess\nimport sys\n", - "import runpy\nimport sys\n", - ) - replace_once( - "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py", - """ - monkeypatch.setattr( - sandboxed_web_e2e, - "run_shell", - lambda *args, **kwargs: subprocess.CompletedProcess( - args=["e2e"], - returncode=0, - stdout="ok\\n", - stderr="", - ), - ) - """, - """ - monkeypatch.setattr( - sandboxed_web_e2e, - "run_shell", - lambda *args, **kwargs: bounded_subprocess.BoundedCompletedProcess( - args=("e2e",), - returncode=0, - stdout="ok\\n", - stderr="", - output_limited=False, - ), - ) - """, - ) - replace_once( - "tests/test_sandboxed_entrypoint_and_cleanup_coverage.py", - """ - def fail_capture_finalization(service): - raise OSError(f"cannot finalize {service.label}") - """, - """ - def fail_capture_finalization(service): - raise ValueError(f"cannot finalize {service.label}") - """, - ) - - replace_once( - "tests/test_sandboxed_web_e2e.py", - """ - monkeypatch.setattr( - sandboxed_web_e2e, - "run_shell", - lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: subprocess.CompletedProcess( - command, - 0, - stdout="e2e-out\\n", - stderr="e2e-err\\n", - ), - ) - """, - """ - monkeypatch.setattr( - sandboxed_web_e2e, - "run_shell", - lambda command, cwd, env, timeout, output_limit_bytes=sandboxed_web_e2e.bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES: sandboxed_web_e2e.bounded_subprocess.BoundedCompletedProcess( - args=tuple(sandboxed_web_e2e.shlex.split(command)), - returncode=0, - stdout="e2e-out\\n", - stderr="e2e-err\\n", - output_limited=False, - ), - ) - """, - ) - - path = Path("tests/test_sandboxed_web_e2e_output_limits.py") - source = path.read_text(encoding="utf-8") - start = source.index( - "def test_service_log_overflow_returns_resource_limit_before_e2e(\n" - ) - end = source.index( - "\n\ndef test_e2e_output_overflow_is_bounded_and_returns_123(", start - ) - replacement = dedent( - ''' - def test_service_log_overflow_returns_resource_limit_before_e2e( - tmp_path: Path, - capsys, - ) -> None: - """Readiness cannot convert a backend log flood into an ordinary E2E run.""" - - sentinel = tmp_path / "e2e-ran" - exit_code = sandboxed_web_e2e.main( - [ - "--repo-root", - str(_repository(tmp_path)), - "--backend-cmd", - _command( - "import os\\n" - "chunk=b'x'*1024\\n" - "while True:\\n" - " os.write(1,chunk)\\n" - ), - "--backend-ready-url", - "http://127.0.0.1:1/ready", - "--frontend-cmd", - _command("import time; time.sleep(30)"), - "--e2e-cmd", - _command( - f"from pathlib import Path; Path({str(sentinel)!r}).touch()" - ), - "--service-log-limit-bytes", - "4096", - ] - ) - captured = capsys.readouterr() - payload = _result_payload(captured.out) - - assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE - assert "service output exceeded 4096 bytes" in captured.err - assert payload["output_limited"] is True - assert payload["service_log_limit_bytes"] == 4096 - assert not sentinel.exists() - ''' - ).lstrip() - path.write_text(source[:start] + replacement + source[end:], encoding="utf-8") - PY - - - name: Prove the regressions fail before production repair - shell: bash --noprofile --norc {0} - run: | - set +e - python -m pytest -q \ - tests/test_bounded_subprocess.py::test_join_captures_applies_a_finite_timeout_and_preserves_first_error \ - tests/test_sandboxed_output_redaction.py::test_redact_command_arguments_covers_separate_equals_and_direct_tokens \ - tests/test_sandboxed_entrypoint_and_cleanup_coverage.py::test_web_e2e_reports_bounded_capture_finalization_failure \ - tests/test_sandboxed_web_e2e_output_limits.py::test_service_log_overflow_returns_resource_limit_before_e2e - status=$? - set -e - if [ "$status" -eq 0 ]; then - echo "::error::Review regressions unexpectedly passed before production repair." - exit 1 - fi - printf 'Observed the expected failing review-regression state (exit %s).\n' "$status" - - - name: Apply bounded production repairs and update evidence - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python - <<'PY' - from pathlib import Path - from textwrap import dedent - - def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - source = target.read_text(encoding="utf-8") - old_text = dedent(old) - new_text = dedent(new) - if source.count(old_text) != 1: - raise SystemExit( - f"{path}: expected one production replacement, " - f"found {source.count(old_text)}" - ) - target.write_text(source.replace(old_text, new_text, 1), encoding="utf-8") - - replace_once( - "scripts/ci/bounded_subprocess.py", - """ - READ_CHUNK_BYTES = 65_536 - TRUNCATION_MARKER = "...[output truncated]...\\n" - """, - """ - READ_CHUNK_BYTES = 65_536 - READER_JOIN_TIMEOUT_SECONDS = 30.0 - TRUNCATION_MARKER = "...[output truncated]...\\n" - """, - ) - replace_once( - "scripts/ci/bounded_subprocess.py", - """ - def _join_captures(captures: Sequence[BoundedOutputCapture]) -> None: - """Finalize every stream reader while preserving the first reported failure.""" - - first_error: BaseException | None = None - for capture in captures: - try: - capture.join() - except BaseException as error: # noqa: BLE001 - re-raised after sibling join - if first_error is None: - first_error = error - if first_error is not None: - raise first_error - """, - """ - def _join_captures( - captures: Sequence[BoundedOutputCapture], - timeout: float = READER_JOIN_TIMEOUT_SECONDS, - ) -> None: - """Finalize every stream reader within a finite shared wait bound.""" - - first_error: BaseException | None = None - for capture in captures: - try: - capture.join(timeout) - except BaseException as error: # noqa: BLE001 - re-raised after sibling join - if first_error is None: - first_error = error - if first_error is not None: - raise first_error - """, - ) - replace_once( - "scripts/ci/redact_sensitive_log.py", - """ - option = argument.lstrip("-") - if "=" in option: - key, _value = option.split("=", 1) - if SENSITIVE_OPTION_RE.fullmatch(key): - separator_index = argument.find("=") - redacted.append(f"{argument[: separator_index + 1]}{REDACTED}") - continue - - redacted.append(redact_text(argument)) - if SENSITIVE_OPTION_RE.fullmatch(option): - redact_next = True - """, - """ - is_option = argument.startswith("-") - option = argument.lstrip("-") - if "=" in option: - key, _value = option.split("=", 1) - if SENSITIVE_OPTION_RE.fullmatch(key): - separator_index = argument.find("=") - redacted.append(f"{argument[: separator_index + 1]}{REDACTED}") - continue - - redacted.append(redact_text(argument)) - if is_option and SENSITIVE_OPTION_RE.fullmatch(option): - redact_next = True - """, - ) - replace_once( - "scripts/ci/sandboxed_web_e2e.py", - " except (OSError, RuntimeError, subprocess.SubprocessError):\n", - " except Exception: # noqa: BLE001 - cleanup must finish all services\n", - ) - replace_once( - "CHANGELOG.md", - """ - ### Fixed - - - Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. - """, - """ - ### Fixed - - - Bound every normal-path output-reader join to 30 seconds, preserve the first reader failure after finalizing siblings, and keep web-E2E result emission and sandbox cleanup running after arbitrary ordinary cleanup exceptions. - - Treat only dash-prefixed credential options as consumers of a following argument while retaining redaction for sensitive `KEY=value` assignments and provider-shaped values. - - Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. - """, - ) - replace_once( - "docs/doctoring/sandboxed-output-resource-bounds.md", - "A truncation marker is included inside, not in addition to, the declared retained byte budget. Reader errors and reader-join timeouts are explicit failures.\n", - "A truncation marker is included inside, not in addition to, the declared retained byte budget. Every normal-path reader join uses a finite 30-second bound, finalizes every sibling capture, and then re-raises the first failure. A drain that still has not reached EOF therefore becomes the explicit `bounded output drain did not finish` failure instead of holding the control-plane job indefinitely. Web-E2E service cleanup maps ordinary cleanup exceptions to the bounded failure result while continuing result emission and sandbox deletion.\n", - ) - replace_once( - "docs/doctoring/sandboxed-command-log-redaction.md", - "- Sensitive option detection uses explicit credential terms. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool.\n", - "- Sensitive option detection uses explicit credential terms and requires a dash-prefixed option before consuming the following argument. Bare words such as `TOKEN` remain ordinary positional evidence, while sensitive `KEY=value` assignments continue through the earlier assignment-redaction path. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool.\n", - ) - PY - - - name: Verify complete exact-head quality contract - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - python -m coverage erase - python -m coverage run -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests - bash -n \ - scripts/ci/run_opencode_review_model_pool.sh \ - scripts/ci/sandboxed_verify.py \ - scripts/ci/sandboxed_web_e2e.py - git diff --check - - - name: Publish verified repair and remove the one-shot workflow - shell: bash --noprofile --norc -e -o pipefail {0} - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - GITHUB_TOKEN: ${{ github.token }} - run: | - rm -f .github/workflows/one-shot-pr767-review-fixes.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && { echo "No review repair generated" >&2; exit 1; } - git commit -m "fix(ci): close sandbox evidence review gaps" - git push \ - "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:${BRANCH_NAME}" From 3a04c8301e5e000b65d1c86a5a8d079d2b84ec90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:26:59 +0900 Subject: [PATCH 91/93] ci(pr767): verify final sandbox evidence repairs --- .github/workflows/pr767-final-cleanup.yml | 91 +++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/pr767-final-cleanup.yml diff --git a/.github/workflows/pr767-final-cleanup.yml b/.github/workflows/pr767-final-cleanup.yml new file mode 100644 index 000000000..6cc773cac --- /dev/null +++ b/.github/workflows/pr767-final-cleanup.yml @@ -0,0 +1,91 @@ +name: Finalize PR 767 sandbox evidence repairs + +on: + push: + branches: [fix/sandboxed-output-resource-bounds] + paths: + - .github/workflows/pr767-final-cleanup.yml + +permissions: + contents: read + +concurrency: + group: pr767-final-cleanup + cancel-in-progress: false + +defaults: + run: + shell: bash --noprofile --norc -e -o pipefail {0} + +jobs: + repair-and-verify: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.actor == 'seonghobae' && + github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + permissions: + contents: write + runs-on: ubuntu-24.04 + timeout-minutes: 55 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact contributor head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Apply bounded review repairs + env: + PATCH_SCRIPT_B64: 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgoKZGVmIHJlcGxhY2Vfb25jZShwYXRoOiBzdHIsIG9sZDogc3RyLCBuZXc6IHN0cikgLT4gTm9uZToKICAgIHRhcmdldCA9IFBhdGgocGF0aCkKICAgIHNvdXJjZSA9IHRhcmdldC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKICAgIGNvdW50ID0gc291cmNlLmNvdW50KG9sZCkKICAgIGlmIGNvdW50ICE9IDE6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIntwYXRofTogZXhwZWN0ZWQgZXhhY3RseSBvbmUgcmVwbGFjZW1lbnQsIGZvdW5kIHtjb3VudH0iKQogICAgdGFyZ2V0LndyaXRlX3RleHQoc291cmNlLnJlcGxhY2Uob2xkLCBuZXcsIDEpLCBlbmNvZGluZz0idXRmLTgiKQoKCnJlcGxhY2Vfb25jZSgKICAgICJzY3JpcHRzL2NpL3NhbmRib3hlZF93ZWJfZTJlLnB5IiwKICAgICcnJyAgICAgICAgZm9yIHNlcnZpY2UgaW4gcmV2ZXJzZWQoc2VydmljZXMpOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBzdG9wX3NlcnZpY2Uoc2VydmljZSkKICAgICAgICAgICAgZXhjZXB0IChPU0Vycm9yLCBSdW50aW1lRXJyb3IsIHN1YnByb2Nlc3MuU3VicHJvY2Vzc0Vycm9yKToKICAgICAgICAgICAgICAgIG91dHB1dF9saW1pdGVkID0gVHJ1ZQogICAgICAgICAgICAgICAgaWYgZXhpdF9jb2RlICE9IDEyNDoKICAgICAgICAgICAgICAgICAgICBleGl0X2NvZGUgPSBib3VuZGVkX3N1YnByb2Nlc3MuT1VUUFVUX0xJTUlUX0VYSVRfQ09ERQogICAgICAgICAgICAgICAgcHJpbnQoCiAgICAgICAgICAgICAgICAgICAgInNhbmRib3hlZC13ZWItZTJlOiBib3VuZGVkIHNlcnZpY2UgY2FwdHVyZSBmYWlsZWQiLAogICAgICAgICAgICAgICAgICAgIGZpbGU9c3lzLnN0ZGVyciwKICAgICAgICAgICAgICAgICkKJycnLAogICAgJycnICAgICAgICB1bmV4cGVjdGVkX2NsZWFudXBfZXJyb3IgPSBGYWxzZQogICAgICAgIGZvciBzZXJ2aWNlIGluIHJldmVyc2VkKHNlcnZpY2VzKToKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgc3RvcF9zZXJ2aWNlKHNlcnZpY2UpCiAgICAgICAgICAgIGV4Y2VwdCAoT1NFcnJvciwgUnVudGltZUVycm9yLCBzdWJwcm9jZXNzLlN1YnByb2Nlc3NFcnJvcik6CiAgICAgICAgICAgICAgICBvdXRwdXRfbGltaXRlZCA9IFRydWUKICAgICAgICAgICAgICAgIGlmIGV4aXRfY29kZSAhPSAxMjQ6CiAgICAgICAgICAgICAgICAgICAgZXhpdF9jb2RlID0gYm91bmRlZF9zdWJwcm9jZXNzLk9VVFBVVF9MSU1JVF9FWElUX0NPREUKICAgICAgICAgICAgICAgIHByaW50KAogICAgICAgICAgICAgICAgICAgICJzYW5kYm94ZWQtd2ViLWUyZTogYm91bmRlZCBzZXJ2aWNlIGNhcHR1cmUgZmFpbGVkIiwKICAgICAgICAgICAgICAgICAgICBmaWxlPXN5cy5zdGRlcnIsCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZXJyb3I6ICAjIG5vcWE6IEJMRTAwMSAtIGNsZWFudXAgbXVzdCBjb250aW51ZQogICAgICAgICAgICAgICAgdW5leHBlY3RlZF9jbGVhbnVwX2Vycm9yID0gVHJ1ZQogICAgICAgICAgICAgICAgcHJpbnQoCiAgICAgICAgICAgICAgICAgICAgInNhbmRib3hlZC13ZWItZTJlOiB1bmV4cGVjdGVkIHNlcnZpY2UgY2xlYW51cCBmYWlsdXJlICIKICAgICAgICAgICAgICAgICAgICBmIih7dHlwZShlcnJvcikuX19uYW1lX199KSIsCiAgICAgICAgICAgICAgICAgICAgZmlsZT1zeXMuc3RkZXJyLAogICAgICAgICAgICAgICAgKQogICAgICAgIGlmIHVuZXhwZWN0ZWRfY2xlYW51cF9lcnJvciBhbmQgZXhpdF9jb2RlIG5vdCBpbiAoCiAgICAgICAgICAgIDEyNCwKICAgICAgICAgICAgYm91bmRlZF9zdWJwcm9jZXNzLk9VVFBVVF9MSU1JVF9FWElUX0NPREUsCiAgICAgICAgKToKICAgICAgICAgICAgZXhpdF9jb2RlID0gMQonJycsCikKCnJlcGxhY2Vfb25jZSgKICAgICJ0ZXN0cy90ZXN0X3NhbmRib3hlZF9lbnRyeXBvaW50X2FuZF9jbGVhbnVwX2NvdmVyYWdlLnB5IiwKICAgICJpbXBvcnQgcnVucHlcbmltcG9ydCBzdWJwcm9jZXNzXG5pbXBvcnQgc3lzXG4iLAogICAgImltcG9ydCBydW5weVxuaW1wb3J0IHN5c1xuIiwKKQpyZXBsYWNlX29uY2UoCiAgICAidGVzdHMvdGVzdF9zYW5kYm94ZWRfZW50cnlwb2ludF9hbmRfY2xlYW51cF9jb3ZlcmFnZS5weSIsCiAgICAnJycgICAgbW9ua2V5cGF0Y2guc2V0YXR0cigKICAgICAgICBzYW5kYm94ZWRfd2ViX2UyZSwKICAgICAgICAicnVuX3NoZWxsIiwKICAgICAgICBsYW1iZGEgKmFyZ3MsICoqa3dhcmdzOiBzdWJwcm9jZXNzLkNvbXBsZXRlZFByb2Nlc3MoCiAgICAgICAgICAgIGFyZ3M9WyJlMmUiXSwKICAgICAgICAgICAgcmV0dXJuY29kZT0wLAogICAgICAgICAgICBzdGRvdXQ9Im9rXFxuIiwKICAgICAgICAgICAgc3RkZXJyPSIiLAogICAgICAgICksCiAgICApCicnJywKICAgICcnJyAgICBtb25rZXlwYXRjaC5zZXRhdHRyKAogICAgICAgIHNhbmRib3hlZF93ZWJfZTJlLAogICAgICAgICJydW5fc2hlbGwiLAogICAgICAgIGxhbWJkYSAqYXJncywgKiprd2FyZ3M6IGJvdW5kZWRfc3VicHJvY2Vzcy5Cb3VuZGVkQ29tcGxldGVkUHJvY2VzcygKICAgICAgICAgICAgYXJncz0oImUyZSIsKSwKICAgICAgICAgICAgcmV0dXJuY29kZT0wLAogICAgICAgICAgICBzdGRvdXQ9Im9rXFxuIiwKICAgICAgICAgICAgc3RkZXJyPSIiLAogICAgICAgICAgICBvdXRwdXRfbGltaXRlZD1GYWxzZSwKICAgICAgICApLAogICAgKQonJycsCikKcmVwbGFjZV9vbmNlKAogICAgInRlc3RzL3Rlc3Rfc2FuZGJveGVkX2VudHJ5cG9pbnRfYW5kX2NsZWFudXBfY292ZXJhZ2UucHkiLAogICAgJycnICAgIGRlZiBmYWlsX2NhcHR1cmVfZmluYWxpemF0aW9uKHNlcnZpY2UpOgogICAgICAgIHJhaXNlIE9TRXJyb3IoZiJjYW5ub3QgZmluYWxpemUge3NlcnZpY2UubGFiZWx9IikKJycnLAogICAgJycnICAgIGRlZiBmYWlsX2NhcHR1cmVfZmluYWxpemF0aW9uKHNlcnZpY2UpOgogICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoZiJjYW5ub3QgZmluYWxpemUge3NlcnZpY2UubGFiZWx9IikKJycnLAopCnJlcGxhY2Vfb25jZSgKICAgICJ0ZXN0cy90ZXN0X3NhbmRib3hlZF9lbnRyeXBvaW50X2FuZF9jbGVhbnVwX2NvdmVyYWdlLnB5IiwKICAgICcnJyAgICBhc3NlcnQgZXhpdF9jb2RlID09IGJvdW5kZWRfc3VicHJvY2Vzcy5PVVRQVVRfTElNSVRfRVhJVF9DT0RFCiAgICBhc3NlcnQgY2FwdHVyZWQuZXJyLmNvdW50KCJib3VuZGVkIHNlcnZpY2UgY2FwdHVyZSBmYWlsZWQiKSA9PSAyCiAgICBhc3NlcnQgZiciZXhpdF9jb2RlIjoge2JvdW5kZWRfc3VicHJvY2Vzcy5PVVRQVVRfTElNSVRfRVhJVF9DT0RFfScgaW4gY2FwdHVyZWQub3V0CiAgICBhc3NlcnQgJyJvdXRwdXRfbGltaXRlZCI6IHRydWUnIGluIGNhcHR1cmVkLm91dAonJycsCiAgICAnJycgICAgYXNzZXJ0IGV4aXRfY29kZSA9PSAxCiAgICBhc3NlcnQgY2FwdHVyZWQuZXJyLmNvdW50KCJ1bmV4cGVjdGVkIHNlcnZpY2UgY2xlYW51cCBmYWlsdXJlIChWYWx1ZUVycm9yKSIpID09IDIKICAgIGFzc2VydCAnImV4aXRfY29kZSI6IDEnIGluIGNhcHR1cmVkLm91dAogICAgYXNzZXJ0ICcib3V0cHV0X2xpbWl0ZWQiOiBmYWxzZScgaW4gY2FwdHVyZWQub3V0CicnJywKKQoKcmVwbGFjZV9vbmNlKAogICAgInRlc3RzL3Rlc3Rfc2FuZGJveGVkX3dlYl9lMmUucHkiLAogICAgJycnICAgICAgICBsYW1iZGEgY29tbWFuZCwgY3dkLCBlbnYsIHRpbWVvdXQsIG91dHB1dF9saW1pdF9ieXRlcz1zYW5kYm94ZWRfd2ViX2UyZS5ib3VuZGVkX3N1YnByb2Nlc3MuREVGQVVMVF9DT01NQU5EX09VVFBVVF9MSU1JVF9CWVRFUzogc3VicHJvY2Vzcy5Db21wbGV0ZWRQcm9jZXNzKAogICAgICAgICAgICBjb21tYW5kLAogICAgICAgICAgICAwLAogICAgICAgICAgICBzdGRvdXQ9ImUyZS1vdXRcXG4iLAogICAgICAgICAgICBzdGRlcnI9ImUyZS1lcnJcXG4iLAogICAgICAgICksCicnJywKICAgICcnJyAgICAgICAgbGFtYmRhIGNvbW1hbmQsIGN3ZCwgZW52LCB0aW1lb3V0LCBvdXRwdXRfbGltaXRfYnl0ZXM9c2FuZGJveGVkX3dlYl9lMmUuYm91bmRlZF9zdWJwcm9jZXNzLkRFRkFVTFRfQ09NTUFORF9PVVRQVVRfTElNSVRfQllURVM6IHNhbmRib3hlZF93ZWJfZTJlLmJvdW5kZWRfc3VicHJvY2Vzcy5Cb3VuZGVkQ29tcGxldGVkUHJvY2VzcygKICAgICAgICAgICAgYXJncz10dXBsZShzYW5kYm94ZWRfd2ViX2UyZS5zaGxleC5zcGxpdChjb21tYW5kKSksCiAgICAgICAgICAgIHJldHVybmNvZGU9MCwKICAgICAgICAgICAgc3Rkb3V0PSJlMmUtb3V0XFxuIiwKICAgICAgICAgICAgc3RkZXJyPSJlMmUtZXJyXFxuIiwKICAgICAgICAgICAgb3V0cHV0X2xpbWl0ZWQ9RmFsc2UsCiAgICAgICAgKSwKJycnLAopCgpyZXBsYWNlX29uY2UoCiAgICAidGVzdHMvdGVzdF9zYW5kYm94ZWRfd2ViX2UyZV9vdXRwdXRfbGltaXRzLnB5IiwKICAgICcgICAgICAgICAgICAiLS1iYWNrZW5kLXVybCIsXG4nLAogICAgJyAgICAgICAgICAgICItLWJhY2tlbmQtcmVhZHktdXJsIixcbicsCikKCnJlcGxhY2Vfb25jZSgKICAgICJ0ZXN0cy90ZXN0X3NhbmRib3hlZF9vdXRwdXRfcmVkYWN0aW9uLnB5IiwKICAgICcnJyAgICBdCgoKZGVmIHRlc3RfcmVkYWN0X3NoZWxsX2NvbW1hbmRfaGFuZGxlc19wYXJzZWRfYW5kX21hbGZvcm1lZF9pbnB1dCgpIC0+IE5vbmU6CicnJywKICAgICcnJyAgICBdCiAgICBhc3NlcnQgcmVkYWN0X2NvbW1hbmRfYXJndW1lbnRzKAogICAgICAgIFsiZG9ja2VyIiwgInJ1biIsICItZSIsICJUT0tFTiIsICJpbWFnZSJdCiAgICApID09IFsiZG9ja2VyIiwgInJ1biIsICItZSIsICJUT0tFTiIsICJpbWFnZSJdCgoKZGVmIHRlc3RfcmVkYWN0X3NoZWxsX2NvbW1hbmRfaGFuZGxlc19wYXJzZWRfYW5kX21hbGZvcm1lZF9pbnB1dCgpIC0+IE5vbmU6CicnJywKKQoKcmVwbGFjZV9vbmNlKAogICAgIkNIQU5HRUxPRy5tZCIsCiAgICAiIyMjIEZpeGVkXG5cbiIsCiAgICAiIyMjIEZpeGVkXG5cbiIKICAgICItIFByZXNlcnZlIHJlc3VsdCBlbWlzc2lvbiBhbmQgc2FuZGJveCBkZWxldGlvbiBhZnRlciB1bmV4cGVjdGVkIG9yZGluYXJ5IHNlcnZpY2UtY2xlYW51cCBleGNlcHRpb25zLCB3aGlsZSBrZWVwaW5nIHRoZW0gZGlzdGluY3QgZnJvbSBib3VuZGVkLW91dHB1dCBleGhhdXN0aW9uLlxuIgogICAgIi0gUmVxdWlyZSBkYXNoLXByZWZpeGVkIGNyZWRlbnRpYWwgb3B0aW9ucyBiZWZvcmUgcmVkYWN0aW5nIGEgZm9sbG93aW5nIGFyZ3VtZW50LCBhbmQgdXNlIHRoZSBib3VuZGVkIGNvbXBsZXRlZC1wcm9jZXNzIGNvbnRyYWN0IGluIHdlYi1FMkUgdGVzdCBkb3VibGVzLlxuIiwKKQoKcmVwbGFjZV9vbmNlKAogICAgImRvY3MvZG9jdG9yaW5nL3NhbmRib3hlZC1vdXRwdXQtcmVzb3VyY2UtYm91bmRzLm1kIiwKICAgICJBIHRydW5jYXRpb24gbWFya2VyIGlzIGluY2x1ZGVkIGluc2lkZSwgbm90IGluIGFkZGl0aW9uIHRvLCB0aGUgZGVjbGFyZWQgcmV0YWluZWQgYnl0ZSBidWRnZXQuIFJlYWRlciBlcnJvcnMgYW5kIHJlYWRlci1qb2luIHRpbWVvdXRzIGFyZSBleHBsaWNpdCBmYWlsdXJlcy5cbiIsCiAgICAiQSB0cnVuY2F0aW9uIG1hcmtlciBpcyBpbmNsdWRlZCBpbnNpZGUsIG5vdCBpbiBhZGRpdGlvbiB0bywgdGhlIGRlY2xhcmVkIHJldGFpbmVkIGJ5dGUgYnVkZ2V0LiBSZWFkZXIgZXJyb3JzIGFuZCByZWFkZXItam9pbiB0aW1lb3V0cyBhcmUgZXhwbGljaXQgZmFpbHVyZXMuIEV4cGVjdGVkIGJvdW5kZWQtY2FwdHVyZSBjbGVhbnVwIGZhaWx1cmVzIHJldGFpbiBvdXRwdXQtbGltaXQgZXhpdCAxMjM7IHVuZXhwZWN0ZWQgb3JkaW5hcnkgY2xlYW51cCBleGNlcHRpb25zIGFyZSByZXBvcnRlZCBzZXBhcmF0ZWx5LCBwcmVzZXJ2ZSBhbnkgcHJpb3IgdGltZW91dCBvciBvdXRwdXQtbGltaXQgcmVzdWx0LCBhbmQgb3RoZXJ3aXNlIHByb2R1Y2UgZXhpdCAxIHdpdGhvdXQgc2tpcHBpbmcgcmVzdWx0IGVtaXNzaW9uIG9yIHNhbmRib3ggZGVsZXRpb24uXG4iLAopCg==' + run: | + printf '%s' "$PATCH_SCRIPT_B64" | base64 --decode > "$RUNNER_TEMP/pr767_patch.py" + python "$RUNNER_TEMP/pr767_patch.py" + git diff --check + + - name: Verify exact-head quality contract + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish verified repair and remove finalizer + env: + BRANCH_NAME: fix/sandboxed-output-resource-bounds + GITHUB_TOKEN: ${{ github.token }} + EXPECTED_HEAD: ${{ github.sha }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + remote_url="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + remote_head="$(git ls-remote "$remote_url" "refs/heads/$BRANCH_NAME" | cut -f1)" + test "$remote_head" = "$EXPECTED_HEAD" + rm -f .github/workflows/pr767-final-cleanup.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git diff --cached --quiet && { echo 'No final repair generated' >&2; exit 1; } + git commit -m 'fix(ci): close sandbox evidence review gaps' + git push \ + --force-with-lease="refs/heads/${BRANCH_NAME}:${EXPECTED_HEAD}" \ + "$remote_url" "HEAD:refs/heads/$BRANCH_NAME" From 45e4929e1fc473211168b2d6360c9da2516e5341 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:41:52 +0900 Subject: [PATCH 92/93] chore(ci): remove completed PR 767 cleanup workflow --- .github/workflows/pr767-final-cleanup.yml | 91 ----------------------- 1 file changed, 91 deletions(-) delete mode 100644 .github/workflows/pr767-final-cleanup.yml diff --git a/.github/workflows/pr767-final-cleanup.yml b/.github/workflows/pr767-final-cleanup.yml deleted file mode 100644 index 6cc773cac..000000000 --- a/.github/workflows/pr767-final-cleanup.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Finalize PR 767 sandbox evidence repairs - -on: - push: - branches: [fix/sandboxed-output-resource-bounds] - paths: - - .github/workflows/pr767-final-cleanup.yml - -permissions: - contents: read - -concurrency: - group: pr767-final-cleanup - cancel-in-progress: false - -defaults: - run: - shell: bash --noprofile --norc -e -o pipefail {0} - -jobs: - repair-and-verify: - if: >- - github.repository == 'ContextualWisdomLab/.github' && - github.actor == 'seonghobae' && - github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' - permissions: - contents: write - runs-on: ubuntu-24.04 - timeout-minutes: 55 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact contributor head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python 3.14 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.14' - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact locked quality tooling - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Apply bounded review repairs - env: - PATCH_SCRIPT_B64: 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgoKZGVmIHJlcGxhY2Vfb25jZShwYXRoOiBzdHIsIG9sZDogc3RyLCBuZXc6IHN0cikgLT4gTm9uZToKICAgIHRhcmdldCA9IFBhdGgocGF0aCkKICAgIHNvdXJjZSA9IHRhcmdldC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKICAgIGNvdW50ID0gc291cmNlLmNvdW50KG9sZCkKICAgIGlmIGNvdW50ICE9IDE6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIntwYXRofTogZXhwZWN0ZWQgZXhhY3RseSBvbmUgcmVwbGFjZW1lbnQsIGZvdW5kIHtjb3VudH0iKQogICAgdGFyZ2V0LndyaXRlX3RleHQoc291cmNlLnJlcGxhY2Uob2xkLCBuZXcsIDEpLCBlbmNvZGluZz0idXRmLTgiKQoKCnJlcGxhY2Vfb25jZSgKICAgICJzY3JpcHRzL2NpL3NhbmRib3hlZF93ZWJfZTJlLnB5IiwKICAgICcnJyAgICAgICAgZm9yIHNlcnZpY2UgaW4gcmV2ZXJzZWQoc2VydmljZXMpOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBzdG9wX3NlcnZpY2Uoc2VydmljZSkKICAgICAgICAgICAgZXhjZXB0IChPU0Vycm9yLCBSdW50aW1lRXJyb3IsIHN1YnByb2Nlc3MuU3VicHJvY2Vzc0Vycm9yKToKICAgICAgICAgICAgICAgIG91dHB1dF9saW1pdGVkID0gVHJ1ZQogICAgICAgICAgICAgICAgaWYgZXhpdF9jb2RlICE9IDEyNDoKICAgICAgICAgICAgICAgICAgICBleGl0X2NvZGUgPSBib3VuZGVkX3N1YnByb2Nlc3MuT1VUUFVUX0xJTUlUX0VYSVRfQ09ERQogICAgICAgICAgICAgICAgcHJpbnQoCiAgICAgICAgICAgICAgICAgICAgInNhbmRib3hlZC13ZWItZTJlOiBib3VuZGVkIHNlcnZpY2UgY2FwdHVyZSBmYWlsZWQiLAogICAgICAgICAgICAgICAgICAgIGZpbGU9c3lzLnN0ZGVyciwKICAgICAgICAgICAgICAgICkKJycnLAogICAgJycnICAgICAgICB1bmV4cGVjdGVkX2NsZWFudXBfZXJyb3IgPSBGYWxzZQogICAgICAgIGZvciBzZXJ2aWNlIGluIHJldmVyc2VkKHNlcnZpY2VzKToKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgc3RvcF9zZXJ2aWNlKHNlcnZpY2UpCiAgICAgICAgICAgIGV4Y2VwdCAoT1NFcnJvciwgUnVudGltZUVycm9yLCBzdWJwcm9jZXNzLlN1YnByb2Nlc3NFcnJvcik6CiAgICAgICAgICAgICAgICBvdXRwdXRfbGltaXRlZCA9IFRydWUKICAgICAgICAgICAgICAgIGlmIGV4aXRfY29kZSAhPSAxMjQ6CiAgICAgICAgICAgICAgICAgICAgZXhpdF9jb2RlID0gYm91bmRlZF9zdWJwcm9jZXNzLk9VVFBVVF9MSU1JVF9FWElUX0NPREUKICAgICAgICAgICAgICAgIHByaW50KAogICAgICAgICAgICAgICAgICAgICJzYW5kYm94ZWQtd2ViLWUyZTogYm91bmRlZCBzZXJ2aWNlIGNhcHR1cmUgZmFpbGVkIiwKICAgICAgICAgICAgICAgICAgICBmaWxlPXN5cy5zdGRlcnIsCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZXJyb3I6ICAjIG5vcWE6IEJMRTAwMSAtIGNsZWFudXAgbXVzdCBjb250aW51ZQogICAgICAgICAgICAgICAgdW5leHBlY3RlZF9jbGVhbnVwX2Vycm9yID0gVHJ1ZQogICAgICAgICAgICAgICAgcHJpbnQoCiAgICAgICAgICAgICAgICAgICAgInNhbmRib3hlZC13ZWItZTJlOiB1bmV4cGVjdGVkIHNlcnZpY2UgY2xlYW51cCBmYWlsdXJlICIKICAgICAgICAgICAgICAgICAgICBmIih7dHlwZShlcnJvcikuX19uYW1lX199KSIsCiAgICAgICAgICAgICAgICAgICAgZmlsZT1zeXMuc3RkZXJyLAogICAgICAgICAgICAgICAgKQogICAgICAgIGlmIHVuZXhwZWN0ZWRfY2xlYW51cF9lcnJvciBhbmQgZXhpdF9jb2RlIG5vdCBpbiAoCiAgICAgICAgICAgIDEyNCwKICAgICAgICAgICAgYm91bmRlZF9zdWJwcm9jZXNzLk9VVFBVVF9MSU1JVF9FWElUX0NPREUsCiAgICAgICAgKToKICAgICAgICAgICAgZXhpdF9jb2RlID0gMQonJycsCikKCnJlcGxhY2Vfb25jZSgKICAgICJ0ZXN0cy90ZXN0X3NhbmRib3hlZF9lbnRyeXBvaW50X2FuZF9jbGVhbnVwX2NvdmVyYWdlLnB5IiwKICAgICJpbXBvcnQgcnVucHlcbmltcG9ydCBzdWJwcm9jZXNzXG5pbXBvcnQgc3lzXG4iLAogICAgImltcG9ydCBydW5weVxuaW1wb3J0IHN5c1xuIiwKKQpyZXBsYWNlX29uY2UoCiAgICAidGVzdHMvdGVzdF9zYW5kYm94ZWRfZW50cnlwb2ludF9hbmRfY2xlYW51cF9jb3ZlcmFnZS5weSIsCiAgICAnJycgICAgbW9ua2V5cGF0Y2guc2V0YXR0cigKICAgICAgICBzYW5kYm94ZWRfd2ViX2UyZSwKICAgICAgICAicnVuX3NoZWxsIiwKICAgICAgICBsYW1iZGEgKmFyZ3MsICoqa3dhcmdzOiBzdWJwcm9jZXNzLkNvbXBsZXRlZFByb2Nlc3MoCiAgICAgICAgICAgIGFyZ3M9WyJlMmUiXSwKICAgICAgICAgICAgcmV0dXJuY29kZT0wLAogICAgICAgICAgICBzdGRvdXQ9Im9rXFxuIiwKICAgICAgICAgICAgc3RkZXJyPSIiLAogICAgICAgICksCiAgICApCicnJywKICAgICcnJyAgICBtb25rZXlwYXRjaC5zZXRhdHRyKAogICAgICAgIHNhbmRib3hlZF93ZWJfZTJlLAogICAgICAgICJydW5fc2hlbGwiLAogICAgICAgIGxhbWJkYSAqYXJncywgKiprd2FyZ3M6IGJvdW5kZWRfc3VicHJvY2Vzcy5Cb3VuZGVkQ29tcGxldGVkUHJvY2VzcygKICAgICAgICAgICAgYXJncz0oImUyZSIsKSwKICAgICAgICAgICAgcmV0dXJuY29kZT0wLAogICAgICAgICAgICBzdGRvdXQ9Im9rXFxuIiwKICAgICAgICAgICAgc3RkZXJyPSIiLAogICAgICAgICAgICBvdXRwdXRfbGltaXRlZD1GYWxzZSwKICAgICAgICApLAogICAgKQonJycsCikKcmVwbGFjZV9vbmNlKAogICAgInRlc3RzL3Rlc3Rfc2FuZGJveGVkX2VudHJ5cG9pbnRfYW5kX2NsZWFudXBfY292ZXJhZ2UucHkiLAogICAgJycnICAgIGRlZiBmYWlsX2NhcHR1cmVfZmluYWxpemF0aW9uKHNlcnZpY2UpOgogICAgICAgIHJhaXNlIE9TRXJyb3IoZiJjYW5ub3QgZmluYWxpemUge3NlcnZpY2UubGFiZWx9IikKJycnLAogICAgJycnICAgIGRlZiBmYWlsX2NhcHR1cmVfZmluYWxpemF0aW9uKHNlcnZpY2UpOgogICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoZiJjYW5ub3QgZmluYWxpemUge3NlcnZpY2UubGFiZWx9IikKJycnLAopCnJlcGxhY2Vfb25jZSgKICAgICJ0ZXN0cy90ZXN0X3NhbmRib3hlZF9lbnRyeXBvaW50X2FuZF9jbGVhbnVwX2NvdmVyYWdlLnB5IiwKICAgICcnJyAgICBhc3NlcnQgZXhpdF9jb2RlID09IGJvdW5kZWRfc3VicHJvY2Vzcy5PVVRQVVRfTElNSVRfRVhJVF9DT0RFCiAgICBhc3NlcnQgY2FwdHVyZWQuZXJyLmNvdW50KCJib3VuZGVkIHNlcnZpY2UgY2FwdHVyZSBmYWlsZWQiKSA9PSAyCiAgICBhc3NlcnQgZiciZXhpdF9jb2RlIjoge2JvdW5kZWRfc3VicHJvY2Vzcy5PVVRQVVRfTElNSVRfRVhJVF9DT0RFfScgaW4gY2FwdHVyZWQub3V0CiAgICBhc3NlcnQgJyJvdXRwdXRfbGltaXRlZCI6IHRydWUnIGluIGNhcHR1cmVkLm91dAonJycsCiAgICAnJycgICAgYXNzZXJ0IGV4aXRfY29kZSA9PSAxCiAgICBhc3NlcnQgY2FwdHVyZWQuZXJyLmNvdW50KCJ1bmV4cGVjdGVkIHNlcnZpY2UgY2xlYW51cCBmYWlsdXJlIChWYWx1ZUVycm9yKSIpID09IDIKICAgIGFzc2VydCAnImV4aXRfY29kZSI6IDEnIGluIGNhcHR1cmVkLm91dAogICAgYXNzZXJ0ICcib3V0cHV0X2xpbWl0ZWQiOiBmYWxzZScgaW4gY2FwdHVyZWQub3V0CicnJywKKQoKcmVwbGFjZV9vbmNlKAogICAgInRlc3RzL3Rlc3Rfc2FuZGJveGVkX3dlYl9lMmUucHkiLAogICAgJycnICAgICAgICBsYW1iZGEgY29tbWFuZCwgY3dkLCBlbnYsIHRpbWVvdXQsIG91dHB1dF9saW1pdF9ieXRlcz1zYW5kYm94ZWRfd2ViX2UyZS5ib3VuZGVkX3N1YnByb2Nlc3MuREVGQVVMVF9DT01NQU5EX09VVFBVVF9MSU1JVF9CWVRFUzogc3VicHJvY2Vzcy5Db21wbGV0ZWRQcm9jZXNzKAogICAgICAgICAgICBjb21tYW5kLAogICAgICAgICAgICAwLAogICAgICAgICAgICBzdGRvdXQ9ImUyZS1vdXRcXG4iLAogICAgICAgICAgICBzdGRlcnI9ImUyZS1lcnJcXG4iLAogICAgICAgICksCicnJywKICAgICcnJyAgICAgICAgbGFtYmRhIGNvbW1hbmQsIGN3ZCwgZW52LCB0aW1lb3V0LCBvdXRwdXRfbGltaXRfYnl0ZXM9c2FuZGJveGVkX3dlYl9lMmUuYm91bmRlZF9zdWJwcm9jZXNzLkRFRkFVTFRfQ09NTUFORF9PVVRQVVRfTElNSVRfQllURVM6IHNhbmRib3hlZF93ZWJfZTJlLmJvdW5kZWRfc3VicHJvY2Vzcy5Cb3VuZGVkQ29tcGxldGVkUHJvY2VzcygKICAgICAgICAgICAgYXJncz10dXBsZShzYW5kYm94ZWRfd2ViX2UyZS5zaGxleC5zcGxpdChjb21tYW5kKSksCiAgICAgICAgICAgIHJldHVybmNvZGU9MCwKICAgICAgICAgICAgc3Rkb3V0PSJlMmUtb3V0XFxuIiwKICAgICAgICAgICAgc3RkZXJyPSJlMmUtZXJyXFxuIiwKICAgICAgICAgICAgb3V0cHV0X2xpbWl0ZWQ9RmFsc2UsCiAgICAgICAgKSwKJycnLAopCgpyZXBsYWNlX29uY2UoCiAgICAidGVzdHMvdGVzdF9zYW5kYm94ZWRfd2ViX2UyZV9vdXRwdXRfbGltaXRzLnB5IiwKICAgICcgICAgICAgICAgICAiLS1iYWNrZW5kLXVybCIsXG4nLAogICAgJyAgICAgICAgICAgICItLWJhY2tlbmQtcmVhZHktdXJsIixcbicsCikKCnJlcGxhY2Vfb25jZSgKICAgICJ0ZXN0cy90ZXN0X3NhbmRib3hlZF9vdXRwdXRfcmVkYWN0aW9uLnB5IiwKICAgICcnJyAgICBdCgoKZGVmIHRlc3RfcmVkYWN0X3NoZWxsX2NvbW1hbmRfaGFuZGxlc19wYXJzZWRfYW5kX21hbGZvcm1lZF9pbnB1dCgpIC0+IE5vbmU6CicnJywKICAgICcnJyAgICBdCiAgICBhc3NlcnQgcmVkYWN0X2NvbW1hbmRfYXJndW1lbnRzKAogICAgICAgIFsiZG9ja2VyIiwgInJ1biIsICItZSIsICJUT0tFTiIsICJpbWFnZSJdCiAgICApID09IFsiZG9ja2VyIiwgInJ1biIsICItZSIsICJUT0tFTiIsICJpbWFnZSJdCgoKZGVmIHRlc3RfcmVkYWN0X3NoZWxsX2NvbW1hbmRfaGFuZGxlc19wYXJzZWRfYW5kX21hbGZvcm1lZF9pbnB1dCgpIC0+IE5vbmU6CicnJywKKQoKcmVwbGFjZV9vbmNlKAogICAgIkNIQU5HRUxPRy5tZCIsCiAgICAiIyMjIEZpeGVkXG5cbiIsCiAgICAiIyMjIEZpeGVkXG5cbiIKICAgICItIFByZXNlcnZlIHJlc3VsdCBlbWlzc2lvbiBhbmQgc2FuZGJveCBkZWxldGlvbiBhZnRlciB1bmV4cGVjdGVkIG9yZGluYXJ5IHNlcnZpY2UtY2xlYW51cCBleGNlcHRpb25zLCB3aGlsZSBrZWVwaW5nIHRoZW0gZGlzdGluY3QgZnJvbSBib3VuZGVkLW91dHB1dCBleGhhdXN0aW9uLlxuIgogICAgIi0gUmVxdWlyZSBkYXNoLXByZWZpeGVkIGNyZWRlbnRpYWwgb3B0aW9ucyBiZWZvcmUgcmVkYWN0aW5nIGEgZm9sbG93aW5nIGFyZ3VtZW50LCBhbmQgdXNlIHRoZSBib3VuZGVkIGNvbXBsZXRlZC1wcm9jZXNzIGNvbnRyYWN0IGluIHdlYi1FMkUgdGVzdCBkb3VibGVzLlxuIiwKKQoKcmVwbGFjZV9vbmNlKAogICAgImRvY3MvZG9jdG9yaW5nL3NhbmRib3hlZC1vdXRwdXQtcmVzb3VyY2UtYm91bmRzLm1kIiwKICAgICJBIHRydW5jYXRpb24gbWFya2VyIGlzIGluY2x1ZGVkIGluc2lkZSwgbm90IGluIGFkZGl0aW9uIHRvLCB0aGUgZGVjbGFyZWQgcmV0YWluZWQgYnl0ZSBidWRnZXQuIFJlYWRlciBlcnJvcnMgYW5kIHJlYWRlci1qb2luIHRpbWVvdXRzIGFyZSBleHBsaWNpdCBmYWlsdXJlcy5cbiIsCiAgICAiQSB0cnVuY2F0aW9uIG1hcmtlciBpcyBpbmNsdWRlZCBpbnNpZGUsIG5vdCBpbiBhZGRpdGlvbiB0bywgdGhlIGRlY2xhcmVkIHJldGFpbmVkIGJ5dGUgYnVkZ2V0LiBSZWFkZXIgZXJyb3JzIGFuZCByZWFkZXItam9pbiB0aW1lb3V0cyBhcmUgZXhwbGljaXQgZmFpbHVyZXMuIEV4cGVjdGVkIGJvdW5kZWQtY2FwdHVyZSBjbGVhbnVwIGZhaWx1cmVzIHJldGFpbiBvdXRwdXQtbGltaXQgZXhpdCAxMjM7IHVuZXhwZWN0ZWQgb3JkaW5hcnkgY2xlYW51cCBleGNlcHRpb25zIGFyZSByZXBvcnRlZCBzZXBhcmF0ZWx5LCBwcmVzZXJ2ZSBhbnkgcHJpb3IgdGltZW91dCBvciBvdXRwdXQtbGltaXQgcmVzdWx0LCBhbmQgb3RoZXJ3aXNlIHByb2R1Y2UgZXhpdCAxIHdpdGhvdXQgc2tpcHBpbmcgcmVzdWx0IGVtaXNzaW9uIG9yIHNhbmRib3ggZGVsZXRpb24uXG4iLAopCg==' - run: | - printf '%s' "$PATCH_SCRIPT_B64" | base64 --decode > "$RUNNER_TEMP/pr767_patch.py" - python "$RUNNER_TEMP/pr767_patch.py" - git diff --check - - - name: Verify exact-head quality contract - run: | - python -m coverage erase - python -m coverage run --branch -m pytest -q - python -m coverage report --show-missing --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci - python -m compileall -q scripts tests - bash -n scripts/ci/run_opencode_review_model_pool.sh - git diff --check - - - name: Publish verified repair and remove finalizer - env: - BRANCH_NAME: fix/sandboxed-output-resource-bounds - GITHUB_TOKEN: ${{ github.token }} - EXPECTED_HEAD: ${{ github.sha }} - run: | - test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" - remote_url="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - remote_head="$(git ls-remote "$remote_url" "refs/heads/$BRANCH_NAME" | cut -f1)" - test "$remote_head" = "$EXPECTED_HEAD" - rm -f .github/workflows/pr767-final-cleanup.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git diff --cached --quiet && { echo 'No final repair generated' >&2; exit 1; } - git commit -m 'fix(ci): close sandbox evidence review gaps' - git push \ - --force-with-lease="refs/heads/${BRANCH_NAME}:${EXPECTED_HEAD}" \ - "$remote_url" "HEAD:refs/heads/$BRANCH_NAME" From 37e6f613233130fd67c9bf95319e2fdff6036434 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:13:38 +0900 Subject: [PATCH 93/93] ci(pr767): focus sandbox evidence bounds on current main --- .../one-shot-pr767-focus-current-main.yml | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 .github/workflows/one-shot-pr767-focus-current-main.yml diff --git a/.github/workflows/one-shot-pr767-focus-current-main.yml b/.github/workflows/one-shot-pr767-focus-current-main.yml new file mode 100644 index 000000000..51728b756 --- /dev/null +++ b/.github/workflows/one-shot-pr767-focus-current-main.yml @@ -0,0 +1,221 @@ +name: One-shot PR 767 focused current-main rebuild + +on: + push: + branches: + - fix/sandboxed-output-resource-bounds + paths: + - .github/workflows/one-shot-pr767-focus-current-main.yml + +permissions: + contents: read + +concurrency: + group: one-shot-pr767-focused-current-main + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focus-and-verify: + if: >- + github.repository == 'ContextualWisdomLab/.github' + && github.ref == 'refs/heads/fix/sandboxed-output-resource-bounds' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact legacy head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Rebuild focused sandbox security slice on protected main + env: + EXPECTED_HEAD: ${{ github.sha }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + git fetch --no-tags origin main + legacy_tree="$EXPECTED_HEAD" + git checkout --detach origin/main + git checkout "$legacy_tree" -- \ + docs/doctoring/sandboxed-command-log-redaction.md \ + docs/doctoring/sandboxed-output-resource-bounds.md \ + docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md \ + docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md \ + scripts/ci/bounded_subprocess.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/run_opencode_review_model_pool.sh \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py \ + tests/test_bounded_subprocess.py \ + tests/test_bounded_subprocess_capture_startup.py \ + tests/test_bounded_subprocess_contract.py \ + tests/test_opencode_model_pool_runner.py \ + tests/test_redact_json_key_boundary.py \ + tests/test_redact_sensitive_log_contract.py \ + tests/test_sandboxed_entrypoint_and_cleanup_coverage.py \ + tests/test_sandboxed_output_redaction.py \ + tests/test_sandboxed_service_capture_startup.py \ + tests/test_sandboxed_verify_output_limits.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sandboxed_web_e2e_branch_contract.py \ + tests/test_sandboxed_web_e2e_output_limits.py + + cat >.github/workflows/sandboxed-evidence-quality-ci.yml <<'YAML' + name: Sandboxed Evidence Quality CI + + on: + pull_request: + branches: [main] + paths: + - ".github/workflows/sandboxed-evidence-quality-ci.yml" + - "scripts/ci/bounded_subprocess.py" + - "scripts/ci/redact_sensitive_log.py" + - "scripts/ci/run_opencode_review_model_pool.sh" + - "scripts/ci/sandboxed_verify.py" + - "scripts/ci/sandboxed_web_e2e.py" + - "tests/test_bounded_subprocess*.py" + - "tests/test_opencode_model_pool_runner.py" + - "tests/test_redact*.py" + - "tests/test_sandboxed*.py" + - "requirements-opencode-review-ci-hashes.txt" + push: + branches: [main] + paths: + - ".github/workflows/sandboxed-evidence-quality-ci.yml" + - "scripts/ci/bounded_subprocess.py" + - "scripts/ci/redact_sensitive_log.py" + - "scripts/ci/run_opencode_review_model_pool.sh" + - "scripts/ci/sandboxed_verify.py" + - "scripts/ci/sandboxed_web_e2e.py" + - "tests/test_bounded_subprocess*.py" + - "tests/test_opencode_model_pool_runner.py" + - "tests/test_redact*.py" + - "tests/test_sandboxed*.py" + - "requirements-opencode-review-ci-hashes.txt" + + concurrency: + group: sandboxed-evidence-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + + permissions: + contents: read + + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + jobs: + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout exact head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + - name: Install exact hash-locked tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + - name: Run focused and repository-wide quality contracts + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + YAML + + python3 - <<'PY' + from pathlib import Path + + changelog = Path("CHANGELOG.md") + source = changelog.read_text(encoding="utf-8") + entries = ( + "- Redact credentials from every sandbox command, timeout, service-tail, " + "JSON, and review-evidence sink while preserving ordinary diagnostics.\n", + "- Bound command and long-running service output, terminate isolated process " + "groups on overflow, and emit deterministic resource-limit evidence without " + "capping unrelated repository artifacts.\n", + "- Add a permanent exact-head quality workflow enforcing 100% production " + "statement, branch, and docstring coverage for sandbox evidence controls.\n", + ) + marker = "## [Unreleased]\n" + if marker not in source: + raise SystemExit("CHANGELOG is missing the Unreleased section") + missing = "".join(entry for entry in entries if entry not in source) + if missing: + source = source.replace(marker, marker + "\n" + missing, 1) + changelog.write_text(source, encoding="utf-8") + PY + rm -f .github/workflows/one-shot-pr767-focus-current-main.yml + git diff --check + + - name: Install exact hash-locked tooling + shell: bash --noprofile --norc -e -o pipefail {0} + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Verify complete integrated quality contract + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q + python -m coverage report --show-missing --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci + python -m compileall -q scripts tests + bash -n scripts/ci/run_opencode_review_model_pool.sh + git diff --check + + - name: Publish focused current-main replacement + env: + EXPECTED_HEAD: ${{ github.sha }} + HEAD_BRANCH: ${{ github.ref_name }} + PUSH_TOKEN: ${{ github.token }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + remote_head="$(git ls-remote origin "refs/heads/${HEAD_BRANCH}" | cut -f1)" + test "$remote_head" = "$EXPECTED_HEAD" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add --all + git diff --cached --quiet && { echo "No focused replacement generated" >&2; exit 1; } + git commit -m "fix(ci): secure and bound sandbox evidence streams" + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.https://github.com/.extraheader="AUTHORIZATION: basic ${auth_header}" \ + push --force-with-lease="refs/heads/${HEAD_BRANCH}:${EXPECTED_HEAD}" \ + origin "HEAD:refs/heads/${HEAD_BRANCH}"