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}" 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..43e2181b0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# 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. +- 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 + +- 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. \ No newline at end of file diff --git a/docs/doctoring/sandboxed-command-log-redaction.md b/docs/doctoring/sandboxed-command-log-redaction.md new file mode 100644 index 000000000..652900a17 --- /dev/null +++ b/docs/doctoring/sandboxed-command-log-redaction.md @@ -0,0 +1,80 @@ +# Sandboxed command and output redaction + +## Decision + +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. + +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 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 +- 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 + +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. + +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 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. +- 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. + +No formal OWASP, NIST, or CWE conformity is claimed. + +## Verification contract + +The focused regression suite constructs a credential-shaped token at runtime and proves that it does not appear in: + +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; +7. service log tails; +8. nested JSON string values; or +9. JSON object keys. + +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. + +## Modular boundary + +`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, 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 + +MITRE Corporation. (2026). *CWE-117: Improper output neutralization for logs* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/117.html + +MITRE Corporation. (2026). *CWE-532: Insertion of sensitive information into log file* (CWE Version 4.20). 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 + +Python Software Foundation. (2026). *subprocess—Subprocess management* (Python 3.14.6 documentation). https://docs.python.org/3/library/subprocess.html diff --git a/docs/doctoring/sandboxed-output-resource-bounds.md b/docs/doctoring/sandboxed-output-resource-bounds.md new file mode 100644 index 000000000..1c15c1d5a --- /dev/null +++ b/docs/doctoring/sandboxed-output-resource-bounds.md @@ -0,0 +1,109 @@ +# 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. Every normal-path output-reader join also has a finite 30-second bound. + +## 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. + +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. + +## 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 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. + +## 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. + +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 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 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, 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. + +## 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, 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, 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. + +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. + +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, 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 + +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 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..0bb3b8598 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-sandboxed-output-resource-bounds.md @@ -0,0 +1,223 @@ +# 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 retained disk consumed by sandbox child output before redaction while retaining useful, credential-free final diagnostic suffixes. + +**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+, `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 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. +- 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-stream contracts + +**Files:** +- Create: `tests/test_bounded_subprocess.py` + +**Interfaces:** +- Consumes: wished-for `scripts.ci.bounded_subprocess` +- 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 code point. Assert that suffix reads are byte-bounded and replacement-decoded. + +- [ ] **Step 2: Write real stdout/stderr flood tests** + +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 bounded text evidence. + +- [ ] **Step 4: Write capture destination and failure tests** + +Prove final-suffix retention, bounded destination files, single overflow notification, reader-error propagation, and stuck-reader detection. + +- [ ] **Step 5: Run the focused test 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 bounded pipe drainer + +**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` + - `BoundedText` + - `BoundedCompletedProcess` + - `BoundedTimeoutExpired` + - `BoundedOutputCapture` + - `validate_output_limit(value, label) -> int` + - `require_supported_platform() -> None` + - `start_bounded_capture(...) -> BoundedOutputCapture` + - `read_bounded_suffix(path, maximum_bytes) -> BoundedText` + - `kill_process_group(process) -> None` + - `run_bounded_command(args, cwd, env, timeout, evidence_limit_bytes) -> BoundedCompletedProcess` + +- [ ] **Step 1: Implement immutable result types and validation** + +Reject booleans, nonintegers, values below 4096, and values above 67,108,864. + +- [ ] **Step 2: Implement one bounded stream capture** + +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 process-group termination** + +Require POSIX `killpg`, launch each command with `start_new_session=True`, and kill only a still-running group. + +- [ ] **Step 4: Implement bounded command execution** + +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: Implement bounded file suffix reads** + +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** + +Run: `python -m pytest tests/test_bounded_subprocess.py -q` + +- [ ] **Step 7: Run focused coverage and docstrings** + +Run branch coverage for the new module and interrogate production at 100%. + +- [ ] **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 continuous pipe drains" +``` + +### 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: 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 3: Run focused tests and verify GREEN** + +- [ ] **Step 4: 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` 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 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 2: Apply bounded drains to service and E2E children** + +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 3: Replace complete-file tail reads** + +Seek from the end, decode with replacement, retain the final line count, and redact. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +- [ ] **Step 5: 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` + +- [ ] **Step 1: Document the exact resource boundary** + +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 `subprocess`, MITRE CWE-770 4.20, and NIST SP 800-218. + +- [ ] **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`** 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..53df5c530 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-sandboxed-output-resource-bounds-design.md @@ -0,0 +1,107 @@ +# Sandboxed Output Resource Bounds Design + +## Status + +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 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 + +Continuously drain child pipes on dedicated background threads into fixed-size final-suffix buffers: + +- 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 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 the evidence-stream boundary: + +- `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 rendered truncated form includes one stable marker and still occupies no more than the declared stream budget. + +### `scripts/ci/sandboxed_verify.py` + +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. + +### `scripts/ci/sandboxed_web_e2e.py` + +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. 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, 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 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 into child success. +- An ordinary nonzero child exit remains unchanged when no stream exceeded its budget. +- 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 prove: + +- ordinary Unicode stdout/stderr remain intact within the budget; +- 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 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. + +## 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, application artifact size, or network traffic; +- replacing the existing output-redaction policy; or +- retaining complete oversized logs as downloadable artifacts. 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 diff --git a/scripts/ci/bounded_subprocess.py b/scripts/ci/bounded_subprocess.py new file mode 100644 index 000000000..4de68f342 --- /dev/null +++ b/scripts/ci/bounded_subprocess.py @@ -0,0 +1,429 @@ +"""Run POSIX child processes with continuously drained bounded output pipes.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import threading +from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + + +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 +READER_JOIN_TIMEOUT_SECONDS = 30.0 +TRUNCATION_MARKER = "...[output truncated]...\n" + + +class OutputLimitUnsupportedError(RuntimeError): + """Report that the operating system cannot isolate a child process group.""" + + +@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 drained into bounded buffers.""" + + 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_supported_platform() -> None: + """Fail before execution when process-group termination is unavailable.""" + + if os.name != "posix" or not hasattr(os, "killpg"): + raise OutputLimitUnsupportedError( + "POSIX process-group support is required for bounded child output" + ) + + +def _render_bounded_bytes(buffer: bytes, limit: int, truncated: bool) -> bytes: + """Return evidence bytes no larger than the configured stream budget.""" + + 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 + + +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", + ) + 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.""" + + 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 evidence 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 _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 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 _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(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 + + +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], + *, + cwd: Path, + env: Mapping[str, str], + timeout: int | float, + evidence_limit_bytes: int, +) -> BoundedCompletedProcess: + """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", + ) + 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) + + 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) + except subprocess.TimeoutExpired: + timed_out = True + kill_process_group(process) + process.wait() + + _join_captures((stdout_capture, stderr_capture)) + 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, + ) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index cb89fe67b..f8836cf42 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,16 @@ r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", re.IGNORECASE, ) +SENSITIVE_OPTION_PATTERN = ( + r"(?:token|secret|password|passwd|credential|authorization|jwt|" + 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( r"(? 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) + _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): return [_redact_json(item) for item in value] + if isinstance(value, str): + return _redact_unstructured(value) 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 "\"'": @@ -53,27 +72,31 @@ 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 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 @@ -91,28 +114,31 @@ 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) 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: @@ -141,6 +167,41 @@ def redact_text(text: str) -> 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 + + 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 + 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/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/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..a4451d8ae 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,6 +15,15 @@ from collections.abc import Sequence from pathlib import Path +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, +) + DEFAULT_IGNORE = ( ".git", @@ -69,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", @@ -106,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}") @@ -149,28 +172,31 @@ 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 continuously drained bounded output.""" + + 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, ) 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): - return value.decode(errors="replace") - return value + return redact_text(value.decode(errors="replace")) + return redact_text(value) def emit_result( @@ -184,19 +210,24 @@ def emit_result( allowed_env: Sequence[str], network: str, evidence_note: str, + output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES, + output_limited: bool = False, ) -> 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, + "output_limit_bytes": output_limit_bytes, + "output_limited": output_limited, "sandbox": str(sandbox_root) if kept else "(removed)", "sandboxed": True, } + print() print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}") @@ -206,23 +237,47 @@ 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) 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": 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(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) - exit_code = completed.returncode + print(redact_text(completed.stderr), end="", file=sys.stderr) + 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) @@ -230,6 +285,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 @@ -245,6 +301,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) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..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,14 +18,17 @@ 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])) -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): @@ -37,12 +41,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: @@ -62,6 +68,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", @@ -92,31 +110,100 @@ 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 _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, + 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, + ) + if process.stdout is None: + bounded_subprocess.kill_process_group(process) + process.wait() + raise RuntimeError("service output pipe was not created") + 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 - preserve the capture failure after cleanup + _cleanup_failed_service_start(process, process.stdout) + raise + 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 ) - log_file.close() - return Service(label=label, command=command, process=process, log_path=log_path) 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://")): @@ -124,7 +211,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 @@ -135,41 +222,52 @@ 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 command and capture its 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, + 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 the 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() - return "\n".join(lines[-max_lines:]) + 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:])) def emit_result( @@ -181,28 +279,39 @@ def emit_result( frontend_ready: bool, exit_code: int, elapsed_seconds: float, + output_limited: bool, ) -> 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, + "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() 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" @@ -212,44 +321,152 @@ 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(completed.stdout, end="") - if completed.stderr: - print(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 = bool( + getattr(completed, "output_limited", False) + ) + 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 ---") @@ -262,9 +479,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__": diff --git a/tests/test_bounded_subprocess.py b/tests/test_bounded_subprocess.py new file mode 100644 index 000000000..a069108fc --- /dev/null +++ b/tests/test_bounded_subprocess.py @@ -0,0 +1,275 @@ +"""Real-process contracts for bounded sandbox subprocess output.""" + +from __future__ import annotations + +import io +import os +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 + + +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: + """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 real output flood is killed while the retained stream stays bounded.""" + + 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 + 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_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.require_supported_platform() + + monkeypatch.setattr(bounded.os, "name", "posix") + bounded.require_supported_platform() + + +def test_capture_surfaces_reader_failure_and_join_timeout( + monkeypatch, +) -> None: + """Reader failures and stuck drains are explicit rather than silently ignored.""" + + class FailingStream: + """Raise one deterministic error from the background reader.""" + + def read(self, size: int) -> bytes: + """Reject the read request.""" + + del size + raise OSError("read failed") + + def close(self) -> None: + """Provide the binary-stream close interface.""" + + 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) + + class NeverFinishesThread: + """Simulate one drain thread that remains alive after join.""" + + def join(self, timeout: float | None = None) -> None: + """Accept the join call without completing.""" + + del timeout + + def is_alive(self) -> bool: + """Report a stuck reader.""" + + return True + + capture = bounded.BoundedOutputCapture( + io.BytesIO(b"safe"), + evidence_limit_bytes=4096, + on_limit=lambda: None, + ) + monkeypatch.setattr(capture, "_thread", NeverFinishesThread()) + with pytest.raises(RuntimeError, match="did not finish"): + 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.""" + + 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, + ) diff --git a/tests/test_bounded_subprocess_capture_startup.py b/tests/test_bounded_subprocess_capture_startup.py new file mode 100644 index 000000000..509b60a2c --- /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 == 2 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 == 2 + assert process.stdout.closed + assert process.stderr.closed diff --git a/tests/test_bounded_subprocess_contract.py b/tests/test_bounded_subprocess_contract.py new file mode 100644 index 000000000..2e8689229 --- /dev/null +++ b/tests/test_bounded_subprocess_contract.py @@ -0,0 +1,344 @@ +"""Branch-complete contracts for bounded subprocess helpers and failures.""" + +from __future__ import annotations + +import io +import os +from collections.abc import Callable +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[Callable[[], None]] = [] + + 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] + + +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"] 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: diff --git a/tests/test_redact_json_key_boundary.py b/tests/test_redact_json_key_boundary.py new file mode 100644 index 000000000..27798bcbe --- /dev/null +++ b/tests/test_redact_json_key_boundary.py @@ -0,0 +1,46 @@ +"""Regression evidence for credential-shaped JSON keys and bounded scanning.""" + +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"\bprovider_marker_value\b"),), + ) + + assert redactor.redact_text('{"provider_marker_value":"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) diff --git a/tests/test_redact_sensitive_log_contract.py b/tests/test_redact_sensitive_log_contract.py new file mode 100644 index 000000000..5c1a07bec --- /dev/null +++ b/tests/test_redact_sensitive_log_contract.py @@ -0,0 +1,118 @@ +"""Complete behavioral contracts for the central log-redaction primitive.""" + +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 + + +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='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: + """Independent credential formats share one non-JSON redaction boundary.""" + + provider_value = _synthetic_provider_token() + 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 + + +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.""" + + 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("") == "" + + +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" + + +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" 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 diff --git a/tests/test_sandboxed_output_redaction.py b/tests/test_sandboxed_output_redaction.py new file mode 100644 index 000000000..f2f05fa06 --- /dev/null +++ b/tests/test_sandboxed_output_redaction.py @@ -0,0 +1,220 @@ +"""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 bounded_subprocess, sandboxed_verify, sandboxed_web_e2e +from scripts.ci.redact_sensitive_log import ( + REDACTED, + redact_command_arguments, + redact_shell_command, + redact_text, +) + + +def _provider_token() -> str: + """Build a credential-shaped fixture without committing a scanner secret.""" + 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() + + assert redact_command_arguments( + ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] + ) == [ + "tool", + "--api-key", + REDACTED, + f"TOKEN={REDACTED}", + REDACTED, + "plain", + ] + + +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() + + 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: + """Keep ordinary command completion evidence secret-free end to end.""" + token = _provider_token() + repository = tmp_path / "repository" + repository.mkdir() + + 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, + stdout=f"token={token}\n", + stderr=f"Authorization: Bearer {token}\n", + ) + + 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 REDACTED in captured.out + assert REDACTED in captured.err + + +class _DoneProcess: + """Minimal completed-process double accepted by the service cleanup path.""" + + pid = 12345 + + def poll(self) -> int: + """Report that the fake service has already exited.""" + return 0 + + def wait(self, timeout: int) -> int: + """Return immediately for interface compatibility.""" + del timeout + return 0 + + +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, + 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[bytes], _DoneProcess()), + log_path=log_path, + log_limit_bytes=log_limit_bytes, + ) + + 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, + stdout=f"token={token}\n", + stderr=f"Bearer {token}\n", + ) + + 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 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 diff --git a/tests/test_sandboxed_service_capture_startup.py b/tests/test_sandboxed_service_capture_startup.py new file mode 100644 index 000000000..b8c323b87 --- /dev/null +++ b/tests/test_sandboxed_service_capture_startup.py @@ -0,0 +1,77 @@ +"""Failure contracts for bounded sandbox service capture startup.""" + +from __future__ import annotations + +import io +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 stopped and waited.""" + + pid = 200 + + def __init__(self) -> None: + self.stdout = io.BytesIO(b"") + 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_stops_reaps_and_closes_the_service_pipe( + monkeypatch, + tmp_path: Path, +) -> None: + """A failed drainer cannot leave a child or parent-side pipe uncollected.""" + + 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") + ), + ) + stopped: list[object] = [] + monkeypatch.setattr( + bounded, + "kill_process_group", + lambda candidate: stopped.append(candidate), + ) + + with pytest.raises(RuntimeError, match="capture startup failed"): + sandboxed_web_e2e.start_service( + "backend", + "tool", + tmp_path, + {}, + tmp_path, + 4096, + ) + + assert stopped == [process] + assert process.waited is True + assert process.stdout.closed is True 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 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) 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..57c51e8a3 --- /dev/null +++ b/tests/test_sandboxed_web_e2e_branch_contract.py @@ -0,0 +1,416 @@ +"""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, + ) + running.log_path.unlink() + + 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 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..e8d94be1b --- /dev/null +++ b/tests/test_sandboxed_web_e2e_output_limits.py @@ -0,0 +1,301 @@ +"""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.""" + + 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() + + +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)