diff --git a/.gitignore b/.gitignore index 55d4dba..f2a1e30 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist/ htmlcov/ *.egg-info/ runs/ +.autopilot/ diff --git a/autopilot/project.json b/autopilot/project.json new file mode 100644 index 0000000..80388aa --- /dev/null +++ b/autopilot/project.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-project.v2", + "taskSchemaVersion": "system-design-workbench.autopilot-task.v1", + "taskDirectory": "autopilot/tasks", + "runtimeDirectory": ".autopilot", + "hardLimits": { + "maxMinutes": 45, + "maxChangedFiles": 4, + "maxVerificationAttempts": 2, + "maxPatchBytes": 65536 + }, + "producer": { + "id": "codex-exec-v1", + "cliVersion": "0.144.1", + "executableSha256": "134063e133f0b4244fa3b251acf973d4fe4b4aeeacbdc135211bf480f59f1477", + "maxSeconds": 600, + "maxOutputBytes": 4194304, + "promptContract": "system-design-workbench.codex-producer-prompt.v2" + }, + "protectedPaths": [ + ".git", + ".autopilot", + ".github/workflows", + ".gitignore", + "autopilot", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "pyproject.toml", + "schemas", + "scripts", + "uv.lock" + ], + "requiredStopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required" + ], + "checks": [ + { + "id": "cli-test", + "kind": "argv-v1", + "timeoutMs": 120000, + "argv": [ + "uv", + "run", + "--locked", + "pytest", + "tests/test_cli.py" + ] + }, + { + "id": "crawl-test", + "kind": "argv-v1", + "timeoutMs": 120000, + "argv": [ + "uv", + "run", + "--locked", + "pytest", + "tests/test_crawl.py" + ] + }, + { + "id": "project-check", + "kind": "argv-v1", + "timeoutMs": 300000, + "argv": [ + "make", + "check" + ] + } + ], + "reviewPolicy": { + "kind": "record-before-close-v1" + }, + "controllerSourceManifest": { + "toolVersion": "1.0.0", + "manifestSha256": "0fbb587d6a940ebfdd11f01a1927dee86f169d22c6d5247ec91ad2a2312932f7", + "sourceSetSha256": "fede99b03f5d876ae2128086f674dfe4e2cc687098cfdbf37b8ea9531a38af24" + } +} diff --git a/autopilot/tasks/SDW-AUTO-901.json b/autopilot/tasks/SDW-AUTO-901.json new file mode 100644 index 0000000..758ea8d --- /dev/null +++ b/autopilot/tasks/SDW-AUTO-901.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-task.v1", + "id": "SDW-AUTO-901", + "status": "READY", + "execution": { + "kind": "prepared-bound-patch-v1", + "patchSha256": "78508dd8d7e36e6b172dec2d659130119b5275ae1c9cc96c3e7cc5932c7a8c16", + "patchBytes": 2454, + "preparation": { + "sourceHead": "ad98560072e5938c2814f045ddd2ac680d7dc583", + "project": { + "path": "autopilot/project.json", + "fileSha256": "1687980d1f6ff5c4f330cc385c22138e1e18ea01ccb3628f3ee549b56be4753a", + "gitBlobOid": "a4adf2f2ec76ab0e4297d6da52a85db3ff32809b", + "canonicalSha256": "aa4e0a90a916fd297aac585b19a43ae6feec951b1226424fc8936fe585b6acbf" + }, + "task": { + "path": "autopilot/tasks/SDW-AUTO-901.json", + "fileSha256": "e11baa1d3c0775c9aec5098183a7bb7c8d12ce52124a2a5850559a13254295d5", + "gitBlobOid": "73d4ea0c5d5ef022f146a817c8945f4c54bb16a4", + "canonicalSha256": "3d08463bf3c304c3c0cd163834eaacaf368c443cf6b55051ce8d04b40819909b" + }, + "candidateSnapshotSha256": "be883bbcbf58d84bcccb3c059c27543fc1dcfdd8516b6660bea81c9f17891f49", + "producer": { + "schemaVersion": "system-design-workbench.codex-producer-receipt.v1", + "id": "codex-exec-v1", + "cliVersion": "0.144.1", + "executableSha256": "134063e133f0b4244fa3b251acf973d4fe4b4aeeacbdc135211bf480f59f1477", + "argvLabel": "codex exec fixed-contract-v1", + "sourceBindingSha256": "76b7f0fb9a030699c7414b7bf6e0da880b680954d62702e307fec4f75ce93c07", + "promptSha256": "004e4f069911910c9db5acc36ea123f1e578671375e91e1e6f23088e6668364f", + "exitCode": 0, + "signal": null, + "timedOut": false, + "outputLimitExceeded": false, + "durationMs": 72416, + "stdout": { + "bytes": 44792, + "sha256": "242bbda2d8f0a65d9c8f32f393649e4abdd4eb862c5f655e63ce94671c0c3ffe" + }, + "stderr": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "events": { + "lineCount": 23, + "turnCompletedCount": 1, + "errorCount": 0 + }, + "patchSha256": "78508dd8d7e36e6b172dec2d659130119b5275ae1c9cc96c3e7cc5932c7a8c16", + "patchBytes": 2454, + "candidateSnapshotSha256": "be883bbcbf58d84bcccb3c059c27543fc1dcfdd8516b6660bea81c9f17891f49" + } + } + }, + "objective": "Make `verify_crawl_bundle` reject a crawl receipt whose top-level `status` differs from the deterministic status derived by `_receipt_from_store` from `pages` and `max_pages`. Preserve all valid crawl receipts, reuse the producer's exact precedence for pending, complete, failed, and blocked page combinations, add a regression that changes only a valid complete receipt's top-level status to `failed` and proves verification rejects it with a stable, specific failure, and keep both changed files compliant with the repository's current Ruff formatting contract.", + "problemEvidence": [ + "`_receipt_from_store` deterministically derives the top-level crawl status from page statuses, terminal page count, and max_pages.", + "`verify_crawl_bundle` recomputes failures_by_code and validates the SQLite projection, but it never recomputes or compares the top-level crawl status.", + "On source HEAD c57b1bcb788d6228bbade186667c3bed46ef1996, a valid one-page complete crawl remains `valid: true` after changing only `crawl-receipt.json.status` from `complete` to `failed`.", + "The existing crawl test file passes before the fix, so the new regression must fail on the source behavior and pass only when the verifier enforces the producer's status contract.", + "The fix belongs in verification and its direct tests; changing crawl production, public models, build configuration, or schemas is outside this task.", + "The first isolated candidate passed all 12 crawl tests and Ruff lint, but `ruff format --check` rejected its multiline `processed_count` sum; that candidate is not eligible for TaskSpec installation, and the replacement must satisfy the existing formatter without a later manual patch." + ], + "allowedPaths": [ + "src/tracefetch/verify.py", + "tests/test_crawl.py" + ], + "forbiddenPaths": [ + ".github", + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "autopilot", + "pyproject.toml", + "schemas", + "scripts", + "src/tracefetch/crawl.py", + "src/tracefetch/contracts.py", + "uv.lock" + ], + "baselineChecks": [ + "crawl-test" + ], + "acceptanceChecks": [ + "crawl-test", + "project-check" + ], + "budget": { + "maxMinutes": 30, + "maxChangedFiles": 2, + "maxVerificationAttempts": 1 + }, + "authority": { + "write": true, + "invokeAgent": false, + "applyPatch": true, + "commit": false, + "push": false, + "release": false, + "network": false + }, + "externalOutcome": "An independent reviewer accepts a frozen two-file candidate that makes `verify_crawl_bundle` reject a top-level status inconsistent with the producer's deterministic page projection, while the targeted crawl tests and the full TraceFetch project check pass without changing valid crawl behavior.", + "stopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required", + "crawl-status-contract-requires-behavior-change" + ], + "requiresChanges": true +} diff --git a/autopilot/tasks/SDW-AUTO-902.json b/autopilot/tasks/SDW-AUTO-902.json new file mode 100644 index 0000000..8c010d1 --- /dev/null +++ b/autopilot/tasks/SDW-AUTO-902.json @@ -0,0 +1,107 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-task.v1", + "id": "SDW-AUTO-902", + "status": "READY", + "execution": { + "kind": "prepared-bound-patch-v1", + "patchSha256": "152fd43e9de0efdd87b9b8dc5fcfa154a4616e5f37cc444d4547247ff81edb08", + "patchBytes": 11818, + "preparation": { + "sourceHead": "cff9587db73c7a31d5e864b2792c6440b54af02f", + "project": { + "path": "autopilot/project.json", + "fileSha256": "4729153e18f731cc6c3f1450320eab479d44147140cdd9a1c63b7949b209e412", + "gitBlobOid": "e31b776e55cee1136758764ced3af1f60b6ff04b", + "canonicalSha256": "942ec6ad09ddc509953064ce3be3bea397c818875ad6138cefccf0de778b65d8" + }, + "task": { + "path": "autopilot/tasks/SDW-AUTO-902.json", + "fileSha256": "417f5e634d62b8decbe2df0b911724ef032b8bd67aa863cecaef9de153647fad", + "gitBlobOid": "776e8857d300c2a7afa2d24b268a142c1e145eed", + "canonicalSha256": "e57c2d195c215a940ffa3be19ba92200000ab26993555fec563171d0e3e197a3" + }, + "candidateSnapshotSha256": "cb7dc6e8f230db839bc2f58fa8dc1d114392afafe13d7a6f52870c66a31ef808", + "producer": null + } + }, + "objective": "Make `tracefetch verify ` preserve its JSON error contract for malformed crawl receipts and corrupted crawl state. Use one shared receipt parse result. Map malformed/schema-invalid JSON, invalid UTF-8, and unreadable receipts to the exact fixed failures `crawl receipt failed schema validation`, `crawl receipt is not valid UTF-8`, and `crawl receipt is unreadable` without reflecting load-boundary exception or input text. CLI failures must exit 6 with empty stdout and one `tracefetch.error.v1` stderr object; a 5000-page schema-invalid sentinel case must stay within 4096 bytes and not echo the sentinel. A successfully modeled receipt must retain its crawl ID and complete-page count when state is missing, corrupt, or a symlink. SQLite query and row-projection `sqlite3.Error`, `TypeError`, `ValueError`, or `OverflowError` must become `crawl SQLite state is invalid`, never a traceback. Preserve valid crawl payloads, ordinary evidence verification, and existing downstream page diagnostics. Fix the shared verification layer, keep all changed Python files Ruff-formatted, and leave no ignored producer artifact.", + "problemEvidence": [ + "On committed source HEAD e52d1fd8681323eacd3205677a7b3b54aec70aff, a crawl directory containing only `crawl-receipt.json` with `{}` reaches `verify_crawl_bundle`, then `crawl_verification_payload` reparses the same file and raises an uncaught Pydantic `ValidationError` with 11 missing-field errors.", + "The reproduced CLI process exits 1 and prints a Python traceback, contradicting README's contract that errors are one `tracefetch.error.v1` object on stderr with stable exit codes.", + "`verify_crawl_bundle` already catches `OSError`, `UnicodeError`, and `ValidationError` while reading the crawl receipt and returns an `invalid crawl receipt` failure; the second unguarded parse in `crawl_verification_payload` is the root divergence.", + "The existing `tests/test_cli.py` suite passes 7 tests on the source behavior because it covers tampered ordinary evidence but not malformed crawl receipts.", + "Regression coverage must call the public CLI entry point for malformed JSON, invalid UTF-8, and a valid JSON object missing required receipt fields; each case must assert exit 6, empty stdout, one parseable error envelope, and no `Traceback`, `pydantic_core`, or `ValidationError` text outside the bounded failure details.", + "A direct verification-layer regression must prove `crawl_verification_payload` returns `valid=false` with zero verified pages and the failure list instead of throwing when the receipt cannot be modeled.", + "Valid crawl receipts must retain the existing `tracefetch.crawl-verification.v1` fields, crawl ID, verified-page count, and `valid=true`; ordinary `receipt.json` verification is outside the changed control path and must remain unchanged.", + "The fix must reuse the result of the existing receipt failure boundary or a single shared parse result; adding a broad catch only in `cli.py` would leave `crawl_verification_payload` unsafe for its other caller and is not sufficient.", + "Transaction a2433ac0-fa05-4b99-87a8-3c46b0354cdf produced a semantically plausible three-file candidate whose focused CLI/crawl tests passed 25/25, but `ruff format --check .` rejected multiline expressions in `src/tracefetch/verify.py` and `tests/test_crawl.py`; that candidate was formally rejected as `ruff-format-contract-failed` and must not be installed or repaired manually.", + "Independent review of the rejected candidate showed that directly embedding Pydantic `ValidationError` text is a security and availability failure: a 5000-page invalid receipt produced about 2.4 MB of stderr, a single failure near 2.38 million characters, and reflected `TOP_SECRET_SENTINEL`; fixed portable failure messages and a 4096-byte complete-envelope bound are therefore hard acceptance gates.", + "The rejected helper returned `receipt=None` before parsing whenever `crawl.sqlite3` was a symlink, so a valid one-page receipt's failure payload regressed from its real crawl ID and verified-page count to an empty ID and zero; receipt validity and state validity must remain separate.", + "Transaction eeec5b9e-6560-463d-83ed-54ed5e2b59cd invoked the producer once but candidate audit rejected an ignored generated path; resume correctly ended at `PRODUCER_OUTCOME_UNKNOWN` without invoking the model again. A replacement producer may run checks only if it leaves no `.venv`, `.pytest_cache`, `.coverage`, `build`, `dist`, or other ignored output in the isolated candidate clone.", + "Transaction 451a4c03-f2e6-45b8-a09b-8e1b7f660cc6 produced a formatted candidate that passed focused 27/27 and full 117/117 checks, bounded a 5000-page schema-invalid sentinel case to 242 bytes, and preserved valid receipt summaries for missing, corrupt, and symlinked state. It was formally rejected after a probe placed a sentinel in a schema-valid page URL and bundle path. Independent review showed that probe exercised existing downstream diagnostics rather than the malformed receipt load boundary; broad redaction would change public behavior and exceed this task, so the rejected candidate cannot be reused but the replacement must retain its narrower load-boundary semantics.", + "An explicit input-byte ceiling and open-handle protection against local concurrent replacement are not defined by the current TraceFetch receipt contract. They remain separate follow-up risks; this task must not invent a new size threshold or claim TOCTOU protection while fixing the confirmed exception and output-reflection path.", + "Transaction a6bd4c8a-8fc3-4c88-9c54-503e899e2a00 produced the calibrated load-boundary behavior and passed 29 focused tests, but `ruff format --check .` rejected one multiline call in `tests/test_cli.py`: `(output / \"crawl-receipt.json\").write_text(json.dumps({\"pages\": pages}), encoding=\"utf-8\")` must remain on the formatter's single line. That candidate was formally rejected as `ruff-format-contract-failed`; the replacement must use the exact committed Ruff style without relying on a later formatting patch.", + "Transaction 94c87198-b226-4f21-a7dc-6d333bd0e194 fixed the formatter issue but left `_verify_crawl_state` row conversion outside its `sqlite3.Error` boundary. After updating `pages.depth` to the string `not-an-integer`, the public CLI still exited 1 with an uncaught `ValueError` traceback. That candidate was formally rejected as `corrupt-state-traceback`; regression coverage must tamper a schema-valid crawl SQLite row, assert the internal payload retains crawl ID and complete-page count with `crawl SQLite state is invalid`, and assert the CLI still exits 6 with one JSON error.", + "Transaction 5b1625c6-77da-448e-94a5-d19d1e6da0ad implemented the SQLite conversion boundary and passed 28 focused tests, but Ruff rejected two multiline forms. The replacement must keep `assert json.loads(captured.err)[\"details\"][\"failures\"][-1] == (\"crawl SQLite state is invalid\")` on one line; that candidate was formally rejected as `ruff-format-contract-failed` and cannot be post-formatted or reused.", + "Transaction 8c95c95c-3d1b-4509-8c0f-6f841a7c8afd covered the corrupt-row contract and passed 31 focused tests, but Ruff rejected an inline verified-pages conditional in the result dictionary; the replacement should compute a local `verified_pages` value before constructing the dictionary so the formatter owns layout. It also mapped a receipt symlink to the legacy combined symlink message instead of the required exact `crawl receipt is unreadable`. The candidate was formally rejected as `receipt-symlink-contract-mismatch` plus `ruff-format-contract-failed`.", + "After the seventh rejected batch, the operator paused new TraceFetch transactions and changed the source-owned controller instead of retrying the same producer contract. Pushed controller commit 69f75e573e5b077878152dae75f534ed1615c33d adds prompt contract v2, which requires the producer to run declared baseline checks before editing, rerun every baseline and acceptance recipe before finishing, and remove generated ignored artifacts. The new controller source set 843089a34bb3d0b3a5c2503538c5b02474e6e1b13d039638f57f47545d95f1f5 is a real workflow delta; it authorizes exactly one fresh bounded transaction, not an automatic retry loop.", + "Transaction ac575b5c-4441-4f68-a037-c725097dba7f ran once under prompt contract v2 and returned `candidate-empty`; resume moved the persisted latch to terminal `PRODUCER_OUTCOME_UNKNOWN` without a second model invocation, candidate installation, product write, commit, or push. The operator therefore stopped producer retries and selected the source-owned manual prepared-patch path. The independently authored three-file patch is 11818 bytes with SHA-256 152fd43e9de0efdd87b9b8dc5fcfa154a4616e5f37cc444d4547247ff81edb08; it passed 31 focused tests and full `make check` with 121 tests and 82.74% coverage before binding." + ], + "allowedPaths": [ + "src/tracefetch/verify.py", + "tests/test_cli.py", + "tests/test_crawl.py" + ], + "forbiddenPaths": [ + ".github", + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "autopilot", + "pyproject.toml", + "schemas", + "scripts", + "src/tracefetch/cli.py", + "src/tracefetch/contracts.py", + "src/tracefetch/crawl.py", + "src/tracefetch/errors.py", + "uv.lock" + ], + "baselineChecks": [ + "cli-test", + "crawl-test" + ], + "acceptanceChecks": [ + "cli-test", + "crawl-test", + "project-check" + ], + "budget": { + "maxMinutes": 30, + "maxChangedFiles": 3, + "maxVerificationAttempts": 1 + }, + "authority": { + "write": true, + "invokeAgent": false, + "applyPatch": true, + "commit": false, + "push": false, + "release": false, + "network": false + }, + "externalOutcome": "An independent reviewer accepts a frozen candidate that converts malformed crawl-receipt verification into the documented JSON error and exit-6 contract while valid crawl and ordinary evidence verification remain unchanged; after the frozen lineage closes, the exact product commit is pushed on its lineage branch without claiming PR, merge, release, or production acceptance.", + "stopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required", + "malformed-crawl-receipt-contract-requires-behavior-change" + ], + "requiresChanges": true +} diff --git a/autopilot/tasks/SDW-AUTO-903.json b/autopilot/tasks/SDW-AUTO-903.json new file mode 100644 index 0000000..b79ff9d --- /dev/null +++ b/autopilot/tasks/SDW-AUTO-903.json @@ -0,0 +1,97 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-task.v1", + "id": "SDW-AUTO-903", + "status": "READY", + "execution": { + "kind": "prepared-bound-patch-v1", + "patchSha256": "db891b31290487c4c84aa101517c5d3e4dc89a0926c6cfe5fe5956cbe162f980", + "patchBytes": 7991, + "preparation": { + "sourceHead": "e53c5db7ea0c25c71617e95dfacf89d052fead57", + "project": { + "path": "autopilot/project.json", + "fileSha256": "d2f1e89e8857477de0c0bc0ff146520929ef542ce8d60eb04deffecad2117ba4", + "gitBlobOid": "80388aaf9961622a139398df38326c39d1619b4f", + "canonicalSha256": "75ee3877eee92352025bd212299b68918be915999834e2407d2f2a2f1351d295" + }, + "task": { + "path": "autopilot/tasks/SDW-AUTO-903.json", + "fileSha256": "c064da6df401043cf0218bf56ff3fe933bff6674023338caf241dccf52a1bcda", + "gitBlobOid": "787eb00ce96ad6c57e4de092fbef34bdeb0739ba", + "canonicalSha256": "d9c1638fe487f4a89123f9f0938701d103b26b8c4c9bc07cf647eccccb5561db" + }, + "candidateSnapshotSha256": "7b95b0e62b766074ed49ef063aa620ce1ba3bb545197bb4c000f933706a8cc47", + "producer": null + } + }, + "objective": "Add a bounded open-handle crawl-receipt read so `tracefetch verify ` cannot allocate unbounded memory from `crawl-receipt.json` and cannot parse a locally replaced file. The shared verification layer must open the receipt with `O_NOFOLLOW`, keep one file descriptor, fstat before and after, read in bounded chunks with a short-read check, and require identical device, inode, and byte size. A receipt larger than 16 MiB maps to the exact fixed failure `crawl receipt exceeds the size limit`; a receipt that changes while being read maps to `crawl receipt changed during verification`; symlink, unreadable, invalid UTF-8, and schema-invalid receipts keep the existing exact messages `crawl receipt is unreadable`, `crawl receipt is not valid UTF-8`, and `crawl receipt failed schema validation` without reflecting exception text, receipt content, or local paths. CLI failures must exit 6 with empty stdout and one bounded `tracefetch.error.v1` stderr object under 4096 bytes. A valid receipt must retain crawl ID, complete-page count, and `valid=true`; missing, corrupt, or symlinked SQLite state summaries from SDW-AUTO-902 must not regress. Preserve ordinary `receipt.json` verification and downstream page diagnostics, keep all changed Python files Ruff-formatted, and leave no ignored producer artifact.", + "problemEvidence": [ + "The SDW-AUTO-902 lineage explicitly deferred this risk: an explicit input-byte ceiling and open-handle protection against local concurrent replacement are not defined by the current TraceFetch receipt contract, and the 902 fix was forbidden from inventing them.", + "`_load_crawl_receipt` currently calls `Path.is_symlink()` and then `Path.read_text(encoding=\"utf-8\")`, which fully allocates the decoded file before validation; an arbitrarily large `crawl-receipt.json` therefore drives unbounded memory use with no fixed failure boundary.", + "The symlink check and the open are separate path operations, so a local concurrent writer can replace `crawl-receipt.json` between them and verification parses a different file than the one that was checked.", + "The existing fixed load-boundary messages, the 5000-page sentinel bounds, and the shared single-parse result were accepted in SDW-AUTO-902 and must remain byte-for-byte unchanged for schema-invalid, invalid UTF-8, and unreadable receipts.", + "Regression coverage must call the public CLI entry point for an oversized receipt and for a mid-read truncation, asserting exit 6, empty stdout, one parseable error envelope, total stderr no larger than 4096 bytes, and no receipt-content sentinel reflection.", + "Direct verification-layer regressions must assert the exact failure list for the two new fixed messages and must prove a valid one-page receipt still yields its real crawl ID and complete-page count when the SQLite state is missing, corrupt, or a symlink.", + "The fix must live in the shared verification layer used by both `verify_crawl_bundle` and `crawl_verification_payload`; catching the failure only in `cli.py` leaves the payload caller unsafe.", + "The candidate must keep all changed Python files Ruff-formatted and must not leave `.venv`, `.pytest_cache`, `.coverage`, `build`, `dist`, or other ignored artifacts in the isolated candidate clone.", + "This task authorizes exactly one fresh bounded transaction, not an automatic retry loop; if the producer returns `candidate-empty`, the operator will fail it closed and use the source-owned manual prepared-patch path as in SDW-AUTO-902.", + "Transaction 4ad9cb8b-dd52-41ea-afa6-02398e528229 ran once under prompt contract v2 and failed at the Codex process boundary with `codex-exec-failed` before installing any candidate or receipt; resume moved the persisted latch to terminal `PRODUCER_OUTCOME_UNKNOWN` without a second model invocation, product write, commit, or push. The operator therefore stopped producer retries and selected the source-owned manual prepared-patch path." + ], + "allowedPaths": [ + "src/tracefetch/verify.py", + "tests/test_cli.py", + "tests/test_crawl.py" + ], + "forbiddenPaths": [ + ".github", + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "autopilot", + "pyproject.toml", + "schemas", + "scripts", + "src/tracefetch/cli.py", + "src/tracefetch/contracts.py", + "src/tracefetch/crawl.py", + "src/tracefetch/errors.py", + "uv.lock" + ], + "baselineChecks": [ + "cli-test", + "crawl-test" + ], + "acceptanceChecks": [ + "cli-test", + "crawl-test", + "project-check" + ], + "budget": { + "maxMinutes": 30, + "maxChangedFiles": 3, + "maxVerificationAttempts": 1 + }, + "authority": { + "write": true, + "invokeAgent": false, + "applyPatch": true, + "commit": false, + "push": false, + "release": false, + "network": false + }, + "externalOutcome": "An independent reviewer accepts a frozen candidate that adds the bounded open-handle crawl-receipt read with the two fixed failure messages while valid crawl and ordinary evidence verification remain unchanged; after the frozen lineage closes, the exact product commit is pushed on its lineage branch without claiming PR, merge, release, or production acceptance.", + "stopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required", + "receipt-boundary-contract-requires-behavior-change" + ], + "requiresChanges": true +} diff --git a/autopilot/tasks/SDW-AUTO-904.json b/autopilot/tasks/SDW-AUTO-904.json new file mode 100644 index 0000000..ad84103 --- /dev/null +++ b/autopilot/tasks/SDW-AUTO-904.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-task.v1", + "id": "SDW-AUTO-904", + "status": "READY", + "execution": { + "kind": "prepared-bound-patch-v1", + "patchSha256": "3887749bbeed11f02680d5c5073349b33ec78a83b59caba363781b4a1850eece", + "patchBytes": 10372, + "preparation": { + "sourceHead": "b7e14ee304dfa2859b7cb115533fc22f4dccc35f", + "project": { + "path": "autopilot/project.json", + "fileSha256": "d2f1e89e8857477de0c0bc0ff146520929ef542ce8d60eb04deffecad2117ba4", + "gitBlobOid": "80388aaf9961622a139398df38326c39d1619b4f", + "canonicalSha256": "75ee3877eee92352025bd212299b68918be915999834e2407d2f2a2f1351d295" + }, + "task": { + "path": "autopilot/tasks/SDW-AUTO-904.json", + "fileSha256": "bda6da5ad8bd842d45a5b9af2b91711a53915a20830ea17505fd12691caff13f", + "gitBlobOid": "ac6c7838eb5c9bab2c543eb735a74ce399837a0f", + "canonicalSha256": "c93247bcd2869b7f846617dcb14fc990096dcaee0474e7a0a50fbf7876180fc7" + }, + "candidateSnapshotSha256": "8ea0347a42089ffda61728fb367080ba13be59d3437bb67a57139b7cad00a581", + "producer": null + } + }, + "objective": "Replace the rejected SDW-AUTO-903 crawl-receipt reader with a bounded open-handle implementation that satisfies the full read contract. Keep one O_NOFOLLOW file descriptor, fstat before and after, reject files larger than 16 MiB with `crawl receipt exceeds the size limit`, classify any non-full chunk as `crawl receipt changed during verification`, and map read OSError to the existing fixed `crawl receipt is unreadable` failure. Preserve fixed invalid UTF-8 and schema messages, valid crawl summaries, ordinary receipt verification, SQLite-state diagnostics, CLI exit 6, empty stdout, bounded non-reflective JSON stderr, and Ruff formatting.", + "problemEvidence": [ + "Independent review of frozen SDW-AUTO-903 snapshot 387526abb2a0adc618b4fcc357ba6ddd936b394f0c0b41de521f31dd9a2c4c1d rejected it with `read-error-unhandled` and `short-read-undetected`; the v2 review decision was recorded and that one-shot lineage terminated without product commit or push.", + "A local injected `_read_receipt_chunk` OSError escaped `_load_crawl_receipt` instead of returning `crawl receipt is unreadable`, violating the fixed shared-layer failure contract and allowing a public CLI traceback.", + "A local injected partial read returned fewer bytes than requested but non-empty data; the candidate continued reading and later reported schema validation rather than `crawl receipt changed during verification`.", + "The minimal fix is at the shared chunk loop: catch OSError and require every read to return exactly the requested byte count. Regression tests must exercise both direct payload behavior and public CLI bounded output.", + "The complete candidate also retains the original 903 oversized, mid-read truncation, exact-size, valid-summary, ordinary-receipt, and downstream diagnostics coverage because the rejected patch was never committed as a product change." + ], + "allowedPaths": [ + "src/tracefetch/verify.py", + "tests/test_cli.py", + "tests/test_crawl.py" + ], + "forbiddenPaths": [ + ".github", + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "autopilot", + "pyproject.toml", + "schemas", + "scripts", + "src/tracefetch/cli.py", + "src/tracefetch/contracts.py", + "src/tracefetch/crawl.py", + "src/tracefetch/errors.py", + "uv.lock" + ], + "baselineChecks": [ + "cli-test", + "crawl-test" + ], + "acceptanceChecks": [ + "cli-test", + "crawl-test", + "project-check" + ], + "budget": { + "maxMinutes": 30, + "maxChangedFiles": 3, + "maxVerificationAttempts": 1 + }, + "authority": { + "write": true, + "invokeAgent": false, + "applyPatch": true, + "commit": false, + "push": false, + "release": false, + "network": false + }, + "externalOutcome": "An independent reviewer accepts a frozen successor candidate that closes both concrete SDW-AUTO-903 review findings while preserving the full bounded crawl-receipt contract; after close, the exact product commit is pushed on its lineage branch and a delivery ref-match observation is exported without claiming PR, merge, release, or production acceptance.", + "stopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required", + "receipt-boundary-contract-requires-behavior-change" + ], + "requiresChanges": true +} diff --git a/autopilot/tasks/SDW-AUTO-905.json b/autopilot/tasks/SDW-AUTO-905.json new file mode 100644 index 0000000..0fcde86 --- /dev/null +++ b/autopilot/tasks/SDW-AUTO-905.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-task.v1", + "id": "SDW-AUTO-905", + "status": "READY", + "execution": { + "kind": "prepared-bound-patch-v1", + "patchSha256": "38d9126043109ebf2388139d6841e82d19f17efe884d620d6d3f961de4cf7f77", + "patchBytes": 11144, + "preparation": { + "sourceHead": "e8bb6b4b8635b6566442d11c8e7650d3b6ed2e22", + "project": { + "path": "autopilot/project.json", + "fileSha256": "d2f1e89e8857477de0c0bc0ff146520929ef542ce8d60eb04deffecad2117ba4", + "gitBlobOid": "80388aaf9961622a139398df38326c39d1619b4f", + "canonicalSha256": "75ee3877eee92352025bd212299b68918be915999834e2407d2f2a2f1351d295" + }, + "task": { + "path": "autopilot/tasks/SDW-AUTO-905.json", + "fileSha256": "3438d131269f78304a39dde7fca369e3218509eecdb56e3e49e6da5329631be7", + "gitBlobOid": "15fa9852e72ade6306c358b8914801bcf16503f4", + "canonicalSha256": "8be4bab909f00e3db8102ddf9462e43d1b7ff0948e3065e3b71c3f1e2cfb4216" + }, + "candidateSnapshotSha256": "85c904004f94f2c62ad814760bac480ce8bbfa29312c8f0f4cfa820aac5f4ef9", + "producer": null + } + }, + "objective": "Implement the complete bounded open-handle crawl-receipt read after the rejected 903 candidate and the environment-only failed 904 claim. Keep one O_NOFOLLOW file descriptor; classify open, fstat, read, and close boundary failures without traceback; fstat before and after; reject more than 16 MiB with `crawl receipt exceeds the size limit`; classify every non-full read as `crawl receipt changed during verification`; and map read or metadata OSError to `crawl receipt is unreadable`. Preserve exact invalid UTF-8/schema failures, valid crawl summaries, ordinary receipt verification, SQLite diagnostics, CLI exit 6, empty stdout, bounded non-reflective JSON stderr, and Ruff formatting.", + "problemEvidence": [ + "Independent review of frozen SDW-AUTO-903 snapshot 387526abb2a0adc618b4fcc357ba6ddd936b394f0c0b41de521f31dd9a2c4c1d rejected it with recorded reason codes `read-error-unhandled` and `short-read-undetected`; that lineage terminated without product commit or push.", + "Injected read OSError escaped the shared verification layer, while an injected non-empty partial read was misclassified as schema-invalid instead of changed-during-verification.", + "Additional review showed fstat and close are OSError boundaries too: fstat must map to the fixed unreadable result, while close must never replace the already-determined fixed verification result with an exception.", + "SDW-AUTO-904 never applied a product patch: its one claim terminated at generation 2 because the isolated clone lacked the locked dev extra and both declared baseline commands could not spawn pytest. The failed runtime was archived outside the target, the dev environment was installed from uv.lock, and both unmodified baseline files then passed (11 CLI tests and 20 crawl tests).", + "The successor candidate retains the complete 903 scope and adds direct and public-CLI regressions for short read, read OSError, and metadata OSError. Only the three original product/test paths are required." + ], + "allowedPaths": [ + "src/tracefetch/verify.py", + "tests/test_cli.py", + "tests/test_crawl.py" + ], + "forbiddenPaths": [ + ".github", + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "autopilot", + "pyproject.toml", + "schemas", + "scripts", + "src/tracefetch/cli.py", + "src/tracefetch/contracts.py", + "src/tracefetch/crawl.py", + "src/tracefetch/errors.py", + "uv.lock" + ], + "baselineChecks": [ + "cli-test", + "crawl-test" + ], + "acceptanceChecks": [ + "cli-test", + "crawl-test", + "project-check" + ], + "budget": { + "maxMinutes": 30, + "maxChangedFiles": 3, + "maxVerificationAttempts": 1 + }, + "authority": { + "write": true, + "invokeAgent": false, + "applyPatch": true, + "commit": false, + "push": false, + "release": false, + "network": false + }, + "externalOutcome": "An independent reviewer accepts a frozen successor candidate that closes all concrete 903 review findings and the additional metadata/close error boundary while preserving the full bounded receipt contract; after close, the exact product commit is pushed on its lineage branch and a delivery ref-match observation is exported without claiming PR, merge, release, or production acceptance.", + "stopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required", + "receipt-boundary-contract-requires-behavior-change" + ], + "requiresChanges": true +} diff --git a/autopilot/tasks/SDW-AUTO-906.json b/autopilot/tasks/SDW-AUTO-906.json new file mode 100644 index 0000000..d787ff5 --- /dev/null +++ b/autopilot/tasks/SDW-AUTO-906.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": "system-design-workbench.autopilot-task.v1", + "id": "SDW-AUTO-906", + "status": "READY", + "execution": { + "kind": "prepared-bound-patch-v1", + "patchSha256": "0ae0b5557ae6df2ce43018b62fa88df781d8bf09a4ca87c5ea38e2b2cf944cf8", + "patchBytes": 5016, + "preparation": { + "sourceHead": "cfda888e5ec20e7b37a273849430a2c15233c42b", + "project": { + "path": "autopilot/project.json", + "fileSha256": "d2f1e89e8857477de0c0bc0ff146520929ef542ce8d60eb04deffecad2117ba4", + "gitBlobOid": "80388aaf9961622a139398df38326c39d1619b4f", + "canonicalSha256": "75ee3877eee92352025bd212299b68918be915999834e2407d2f2a2f1351d295" + }, + "task": { + "path": "autopilot/tasks/SDW-AUTO-906.json", + "fileSha256": "39d6f4e707d2f5bdb2aff8cfe954a9bc7728b71d200eef069f2ce230d047277d", + "gitBlobOid": "1d5eac3b0dccf7a3121ca7c12daafd66825bb75d", + "canonicalSha256": "0005df509781c4166992d39b2e6ac44c50407ed9d31276075c050f4b4874f177" + }, + "candidateSnapshotSha256": "cc0dd3c84e2c44d130246b081918377c310116056d8c6deefeba8f8af4cbe31f", + "producer": null + } + }, + "objective": "Make the bounded crawl-receipt open itself non-blocking and fail closed when the required safe-open primitives are unavailable. The shared verification layer must require both `os.O_NOFOLLOW` and `os.O_NONBLOCK`, open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, and map a missing flag or any open/fstat/read boundary failure to the fixed `crawl receipt is unreadable` result without traceback. A FIFO, socket, device, directory, or other non-regular target must never block verification and must remain unreadable. Preserve the accepted SDW-AUTO-905 16 MiB limit, exact full-chunk rule, pre/post fstat identity and size check, valid crawl summaries, malformed UTF-8/schema failures, CLI exit 6/empty stdout/bounded non-reflective JSON stderr, ordinary evidence verification, and all existing diagnostics.", + "problemEvidence": [ + "Post-push review of product commit 726c0f279eeb2029cb3f8600c66478d7a292520c found that `_read_crawl_receipt_bytes` calls `os.open` with `O_RDONLY | O_NOFOLLOW` and only rejects non-regular files after open. Opening a FIFO read-only can block before fstat, so a local special-file replacement can hang `tracefetch verify` despite the later regular-file check.", + "The source-owned Autopilot controller already uses `O_NOFOLLOW | O_NONBLOCK` for untrusted local file reads. The receipt reader should apply the same primitive rather than claiming a bounded verification path while open itself remains unbounded in time.", + "A local capability probe confirmed Darwin exposes both flags. A deterministic monkeypatch deleting `os.O_NOFOLLOW` made the current shared loader escape `AttributeError` instead of returning the fixed unreadable failure. Platforms lacking either primitive must fail closed; silently dropping no-follow or non-blocking semantics would weaken the stated security contract.", + "Regression coverage must exercise the public payload/CLI contract for missing safe-open flags without globally mutating unrelated os users. A POSIX-only real FIFO subprocess probe must call the shared verification layer directly and demonstrate it returns the fixed unreadable payload promptly without traceback; the existing CLI target classifier may reject a FIFO earlier as an invalid path and is outside this three-file task.", + "The final change belongs only in `src/tracefetch/verify.py`, `tests/test_cli.py`, and `tests/test_crawl.py`; no public schema or producer behavior change is required." + ], + "allowedPaths": [ + "src/tracefetch/verify.py", + "tests/test_cli.py", + "tests/test_crawl.py" + ], + "forbiddenPaths": [ + ".github", + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "Makefile", + "README.md", + "autopilot", + "pyproject.toml", + "schemas", + "scripts", + "src/tracefetch/cli.py", + "src/tracefetch/contracts.py", + "src/tracefetch/crawl.py", + "src/tracefetch/errors.py", + "uv.lock" + ], + "baselineChecks": [ + "cli-test", + "crawl-test" + ], + "acceptanceChecks": [ + "cli-test", + "crawl-test", + "project-check" + ], + "budget": { + "maxMinutes": 30, + "maxChangedFiles": 3, + "maxVerificationAttempts": 1 + }, + "authority": { + "write": true, + "invokeAgent": false, + "applyPatch": true, + "commit": false, + "push": false, + "release": false, + "network": false + }, + "externalOutcome": "An independent reviewer accepts a frozen successor candidate proving crawl receipt verification cannot block on a FIFO and cannot weaken safe-open semantics when O_NOFOLLOW or O_NONBLOCK is unavailable; after close, the exact direct-successor product commit is pushed and a fresh delivery ref-match observation is exported without claiming PR, merge, release, or production acceptance.", + "stopConditions": [ + "budget-exhausted", + "verification-attempts-exhausted", + "scope-expansion-required", + "protected-path-change-required", + "dirty-working-tree", + "task-input-changed", + "new-authority-required", + "safe-open-contract-requires-platform-fallback" + ], + "requiresChanges": true +} diff --git a/src/tracefetch/verify.py b/src/tracefetch/verify.py index 59d14e1..a0847c5 100644 --- a/src/tracefetch/verify.py +++ b/src/tracefetch/verify.py @@ -2,8 +2,11 @@ import hashlib import json +import os import sqlite3 +import stat from collections import Counter +from contextlib import suppress from pathlib import Path from typing import Any @@ -18,6 +21,8 @@ LinkRecord, ) +MAX_CRAWL_RECEIPT_BYTES = 16 * 1024 * 1024 + def verify_bundle(bundle_dir: Path) -> list[str]: failures: list[str] = [] @@ -207,17 +212,76 @@ def verification_payload(bundle_dir: Path) -> dict[str, object]: } -def verify_crawl_bundle(root_dir: Path) -> list[str]: +def _read_receipt_chunk(descriptor: int, size: int) -> bytes: + return os.read(descriptor, size) + + +def _read_crawl_receipt_bytes(receipt_path: Path) -> tuple[bytes | None, str | None]: + nofollow = getattr(os, "O_NOFOLLOW", None) + nonblock = getattr(os, "O_NONBLOCK", None) + if not isinstance(nofollow, int) or not isinstance(nonblock, int): + return None, "crawl receipt is unreadable" + try: + descriptor = os.open(receipt_path, os.O_RDONLY | nofollow | nonblock) + except OSError: + return None, "crawl receipt is unreadable" + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + return None, "crawl receipt is unreadable" + if before.st_size > MAX_CRAWL_RECEIPT_BYTES: + return None, "crawl receipt exceeds the size limit" + chunks: list[bytes] = [] + remaining = before.st_size + while remaining > 0: + requested = min(65536, remaining) + try: + chunk = _read_receipt_chunk(descriptor, requested) + except OSError: + return None, "crawl receipt is unreadable" + if len(chunk) != requested: + return None, "crawl receipt changed during verification" + chunks.append(chunk) + remaining -= len(chunk) + after = os.fstat(descriptor) + if (before.st_dev, before.st_ino, before.st_size) != ( + after.st_dev, + after.st_ino, + after.st_size, + ): + return None, "crawl receipt changed during verification" + return b"".join(chunks), None + except OSError: + return None, "crawl receipt is unreadable" + finally: + with suppress(OSError): + os.close(descriptor) + + +def _load_crawl_receipt(receipt_path: Path) -> tuple[CrawlReceipt | None, list[str]]: + received, failure = _read_crawl_receipt_bytes(receipt_path) + if received is None: + return None, [failure or "crawl receipt is unreadable"] + try: + source = received.decode("utf-8") + except UnicodeDecodeError: + return None, ["crawl receipt is not valid UTF-8"] + try: + return CrawlReceipt.model_validate_json(source), [] + except ValidationError: + return None, ["crawl receipt failed schema validation"] + + +def _crawl_verification_result(root_dir: Path) -> tuple[CrawlReceipt | None, list[str]]: failures: list[str] = [] root = root_dir.resolve() receipt_path = root_dir / "crawl-receipt.json" state_path = root_dir / "crawl.sqlite3" - if receipt_path.is_symlink() or state_path.is_symlink(): - return ["crawl receipt and state must not be symlinks"] - try: - receipt = CrawlReceipt.model_validate_json(receipt_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, ValidationError) as exc: - return [f"invalid crawl receipt: {exc}"] + receipt, receipt_failures = _load_crawl_receipt(receipt_path) + if receipt is None: + return None, receipt_failures + if state_path.is_symlink(): + return receipt, ["crawl receipt and state must not be symlinks"] try: state_digest = _sha256_file(state_path) except OSError as exc: @@ -258,26 +322,48 @@ def verify_crawl_bundle(root_dir: Path) -> list[str]: failures.append(f"non-complete crawl page has bundle_path: {page.url}") if dict(sorted(failures_by_code.items())) != dict(sorted(receipt.failures_by_code.items())): failures.append("failures_by_code does not match page records") + statuses = Counter(page.status for page in receipt.pages) + processed_count = statuses["complete"] + statuses["failed"] + statuses["blocked"] + if statuses["pending"] and processed_count >= receipt.max_pages: + expected_status = "partial" + elif statuses["pending"]: + expected_status = "running" + elif statuses["complete"] and (statuses["failed"] or statuses["blocked"]): + expected_status = "partial" + elif statuses["complete"]: + expected_status = "complete" + else: + expected_status = "failed" + if receipt.status != expected_status: + failures.append("crawl status does not match page records") failures.extend(_verify_crawl_state(receipt, state_path)) - return failures + return receipt, failures + + +def verify_crawl_bundle(root_dir: Path) -> list[str]: + return _crawl_verification_result(root_dir)[1] def crawl_verification_payload(root_dir: Path) -> dict[str, object]: - failures = verify_crawl_bundle(root_dir) - receipt = CrawlReceipt.model_validate_json( - (root_dir / "crawl-receipt.json").read_text(encoding="utf-8") + receipt, failures = _crawl_verification_result(root_dir) + verified_pages = ( + 0 if receipt is None else sum(page.status == "complete" for page in receipt.pages) ) return { "schema_version": "tracefetch.crawl-verification.v1", - "crawl_id": receipt.crawl_id, + "crawl_id": "" if receipt is None else receipt.crawl_id, "valid": not failures, - "verified_pages": sum(page.status == "complete" for page in receipt.pages), + "verified_pages": verified_pages, "failures": failures, } def _verify_crawl_state(receipt: CrawlReceipt, state_path: Path) -> list[str]: failures: list[str] = [] + connection: sqlite3.Connection | None = None + metadata: dict[str, str] = {} + state_pages: list[dict[str, object]] = [] + invalid_state = False try: connection = sqlite3.connect(f"file:{state_path}?mode=ro", uri=True) connection.row_factory = sqlite3.Row @@ -291,11 +377,27 @@ def _verify_crawl_state(receipt: CrawlReceipt, state_path: Path) -> list[str]: "from pages order by depth, added_at" ) ) - except sqlite3.Error as exc: - return [f"invalid crawl SQLite state: {exc}"] + state_pages = [ + { + "url": str(row["url"]), + "depth": int(row["depth"]), + "status": "pending" if row["status"] == "running" else str(row["status"]), + "bundle_path": row["bundle_path"], + "error_code": row["error_code"], + "error_message": row["error_message"], + } + for row in rows + ] + except (sqlite3.Error, TypeError, ValueError, OverflowError): + invalid_state = True finally: - if "connection" in locals(): - connection.close() + if connection is not None: + try: + connection.close() + except sqlite3.Error: + invalid_state = True + if invalid_state: + return ["crawl SQLite state is invalid"] expected_metadata = { "crawl_id": receipt.crawl_id, "root_url": receipt.root_url, @@ -307,17 +409,6 @@ def _verify_crawl_state(receipt: CrawlReceipt, state_path: Path) -> list[str]: for key, expected in expected_metadata.items(): if metadata.get(key) != expected: failures.append(f"crawl state metadata mismatch: {key}") - state_pages = [ - { - "url": str(row["url"]), - "depth": int(row["depth"]), - "status": "pending" if row["status"] == "running" else str(row["status"]), - "bundle_path": row["bundle_path"], - "error_code": row["error_code"], - "error_message": row["error_message"], - } - for row in rows - ] receipt_pages = [ page.model_dump( mode="json", diff --git a/tests/test_cli.py b/tests/test_cli.py index a61bec4..cfd376d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,15 +2,18 @@ import json from pathlib import Path +from types import SimpleNamespace import pytest +import tracefetch.verify as verify_module from tracefetch.cli import main from tracefetch.contracts import ( SearchResultAttempt, SearchResultCandidate, SearchResultsEnvelope, ) +from tracefetch.verify import MAX_CRAWL_RECEIPT_BYTES def test_cli_ingest_and_verify_round_trip( @@ -49,6 +52,174 @@ def test_cli_verification_error_has_stable_json_exit( assert error["retryable"] is False +@pytest.mark.parametrize( + ("contents", "failure"), + [ + (b"{", "crawl receipt failed schema validation"), + (b"{}", "crawl receipt failed schema validation"), + (b"\xff", "crawl receipt is not valid UTF-8"), + ], +) +def test_cli_crawl_receipt_load_errors_are_fixed_and_bounded( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + contents: bytes, + failure: str, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(contents) + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + error = json.loads(captured.err) + assert error["schema_version"] == "tracefetch.error.v1" + assert error["error"] == "verification_failed" + assert error["details"]["failures"] == [failure] + assert len(captured.err.encode("utf-8")) <= 4096 + assert "Traceback" not in captured.err + assert "pydantic_core" not in captured.err + assert "ValidationError" not in captured.err + + +def test_cli_large_invalid_crawl_receipt_does_not_reflect_input( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + sentinel = "TOP_SECRET_SENTINEL" + output = tmp_path / "crawl" + output.mkdir() + pages = [{"url": f"https://example.test/{index}/{sentinel}"} for index in range(5000)] + (output / "crawl-receipt.json").write_text(json.dumps({"pages": pages}), encoding="utf-8") + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + assert len(captured.err.encode("utf-8")) <= 4096 + assert sentinel not in captured.err + assert json.loads(captured.err)["details"]["failures"] == [ + "crawl receipt failed schema validation" + ] + + +def test_cli_oversized_crawl_receipt_stays_bounded_and_silent( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + sentinel = "TOP_SECRET_SENTINEL" + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes( + b"x" * (MAX_CRAWL_RECEIPT_BYTES + 1) + sentinel.encode() + ) + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + assert len(captured.err.encode("utf-8")) <= 4096 + assert sentinel not in captured.err + assert json.loads(captured.err)["details"]["failures"] == [ + "crawl receipt exceeds the size limit" + ] + + +def test_cli_crawl_receipt_truncated_mid_read_fails_closed( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"x" * 70000) + real = verify_module._read_receipt_chunk + calls = 0 + + def fake(descriptor: int, size: int) -> bytes: + nonlocal calls + calls += 1 + if calls >= 2: + return b"" + return real(descriptor, size) + + monkeypatch.setattr(verify_module, "_read_receipt_chunk", fake) + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + assert json.loads(captured.err)["details"]["failures"] == [ + "crawl receipt changed during verification" + ] + assert "Traceback" not in captured.err + + +def test_cli_crawl_receipt_read_error_is_fixed_and_bounded( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"TOP_SECRET_SENTINEL") + + def fake(descriptor: int, size: int) -> bytes: + raise OSError("injected read failure") + + monkeypatch.setattr(verify_module, "_read_receipt_chunk", fake) + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + assert len(captured.err.encode("utf-8")) <= 4096 + assert "TOP_SECRET_SENTINEL" not in captured.err + assert json.loads(captured.err)["details"]["failures"] == ["crawl receipt is unreadable"] + assert "Traceback" not in captured.err + + +@pytest.mark.parametrize("missing_flag", ["O_NOFOLLOW", "O_NONBLOCK"]) +def test_cli_crawl_receipt_requires_safe_open_flags( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + missing_flag: str, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"TOP_SECRET_SENTINEL") + available = { + "O_NOFOLLOW": verify_module.os.O_NOFOLLOW, + "O_NONBLOCK": verify_module.os.O_NONBLOCK, + "O_RDONLY": verify_module.os.O_RDONLY, + } + del available[missing_flag] + monkeypatch.setattr(verify_module, "os", SimpleNamespace(**available)) + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + assert len(captured.err.encode("utf-8")) <= 4096 + assert "TOP_SECRET_SENTINEL" not in captured.err + assert json.loads(captured.err)["details"]["failures"] == ["crawl receipt is unreadable"] + assert "Traceback" not in captured.err + + def test_cli_schema_and_doctor_emit_machine_readable_json( capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/tests/test_crawl.py b/tests/test_crawl.py index ee9bdfe..ae003ad 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -1,20 +1,30 @@ from __future__ import annotations import json +import os import sqlite3 +import subprocess +import sys from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace import pytest +import tracefetch.verify as verify_module from tracefetch.adapters.base import ReaderResult from tracefetch.bundle import BundleResult, write_bundle +from tracefetch.cli import main from tracefetch.config import Policy from tracefetch.contracts import Attempt, LinkRecord from tracefetch.crawl import CrawlStore, crawl_site, policy_sha256 from tracefetch.errors import InvalidInputError from tracefetch.normalize import NormalizedDocument -from tracefetch.verify import crawl_verification_payload, verify_crawl_bundle +from tracefetch.verify import ( + MAX_CRAWL_RECEIPT_BYTES, + crawl_verification_payload, + verify_crawl_bundle, +) NOW = datetime(2026, 7, 24, tzinfo=UTC) ROOT = "https://example.test/" @@ -223,6 +233,246 @@ def test_crawl_sqlite_tamper_is_rejected(tmp_path: Path, monkeypatch: pytest.Mon assert "crawl state metadata mismatch: max_depth" in failures +@pytest.mark.parametrize( + ("contents", "failure"), + [ + (b"{", "crawl receipt failed schema validation"), + (b"{}", "crawl receipt failed schema validation"), + (b"\xff", "crawl receipt is not valid UTF-8"), + ], +) +def test_crawl_payload_keeps_receipt_load_failures_inside_the_contract( + tmp_path: Path, contents: bytes, failure: str +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(contents) + + assert crawl_verification_payload(output) == { + "schema_version": "tracefetch.crawl-verification.v1", + "crawl_id": "", + "valid": False, + "verified_pages": 0, + "failures": [failure], + } + + +def test_crawl_receipt_symlink_is_a_fixed_unreadable_failure(tmp_path: Path) -> None: + output = tmp_path / "crawl" + output.mkdir() + outside = tmp_path / "outside-receipt.json" + outside.write_text("{}", encoding="utf-8") + (output / "crawl-receipt.json").symlink_to(outside) + + assert crawl_verification_payload(output)["failures"] == ["crawl receipt is unreadable"] + + +def test_oversized_crawl_receipt_is_a_fixed_bounded_failure(tmp_path: Path) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"x" * (MAX_CRAWL_RECEIPT_BYTES + 1)) + + assert crawl_verification_payload(output) == { + "schema_version": "tracefetch.crawl-verification.v1", + "crawl_id": "", + "valid": False, + "verified_pages": 0, + "failures": ["crawl receipt exceeds the size limit"], + } + + +def test_crawl_receipt_at_the_size_limit_is_not_rejected_as_oversized(tmp_path: Path) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"x" * MAX_CRAWL_RECEIPT_BYTES) + + assert crawl_verification_payload(output)["failures"] == [ + "crawl receipt failed schema validation" + ] + + +def test_crawl_receipt_truncated_mid_read_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"x" * 70000) + real = verify_module._read_receipt_chunk + calls = 0 + + def fake(descriptor: int, size: int) -> bytes: + nonlocal calls + calls += 1 + if calls >= 2: + return b"" + return real(descriptor, size) + + monkeypatch.setattr(verify_module, "_read_receipt_chunk", fake) + + assert crawl_verification_payload(output)["failures"] == [ + "crawl receipt changed during verification" + ] + + +def test_crawl_receipt_short_read_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"{}") + real = verify_module._read_receipt_chunk + + def fake(descriptor: int, size: int) -> bytes: + return real(descriptor, size - 1) + + monkeypatch.setattr(verify_module, "_read_receipt_chunk", fake) + + assert crawl_verification_payload(output)["failures"] == [ + "crawl receipt changed during verification" + ] + + +def test_crawl_receipt_read_error_is_a_fixed_unreadable_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"{}") + + def fake(descriptor: int, size: int) -> bytes: + raise OSError("injected read failure") + + monkeypatch.setattr(verify_module, "_read_receipt_chunk", fake) + + assert crawl_verification_payload(output)["failures"] == ["crawl receipt is unreadable"] + + +def test_crawl_receipt_fstat_error_is_a_fixed_unreadable_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"{}") + + def fake(descriptor: int) -> os.stat_result: + raise OSError("injected fstat failure") + + monkeypatch.setattr(verify_module.os, "fstat", fake) + + assert crawl_verification_payload(output)["failures"] == ["crawl receipt is unreadable"] + + +@pytest.mark.parametrize("missing_flag", ["O_NOFOLLOW", "O_NONBLOCK"]) +def test_crawl_receipt_requires_safe_open_flags( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + missing_flag: str, +) -> None: + output = tmp_path / "crawl" + output.mkdir() + (output / "crawl-receipt.json").write_bytes(b"{}") + available = { + "O_NOFOLLOW": os.O_NOFOLLOW, + "O_NONBLOCK": os.O_NONBLOCK, + "O_RDONLY": os.O_RDONLY, + } + del available[missing_flag] + monkeypatch.setattr(verify_module, "os", SimpleNamespace(**available)) + + assert crawl_verification_payload(output)["failures"] == ["crawl receipt is unreadable"] + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="requires POSIX FIFO support") +def test_crawl_receipt_fifo_fails_without_blocking(tmp_path: Path) -> None: + output = tmp_path / "crawl" + output.mkdir() + os.mkfifo(output / "crawl-receipt.json") + + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json,sys;" + "from pathlib import Path;" + "from tracefetch.verify import crawl_verification_payload;" + "print(json.dumps(crawl_verification_payload(Path(sys.argv[1]))))" + ), + str(output), + ], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert json.loads(completed.stdout)["failures"] == ["crawl receipt is unreadable"] + assert completed.stderr == "" + assert "Traceback" not in completed.stderr + + +@pytest.mark.parametrize("state_kind", ["missing", "corrupt", "symlink"]) +def test_valid_receipt_summary_survives_invalid_crawl_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + state_kind: str, +) -> None: + output = tmp_path / "crawl" + install_fake_fetch(monkeypatch, {ROOT: []}) + receipt = crawl_site(ROOT, output, reader="direct", policy=policy(), resume=False) + state_path = output / "crawl.sqlite3" + if state_kind == "missing": + state_path.unlink() + elif state_kind == "corrupt": + state_path.write_bytes(b"not a SQLite database") + else: + outside = tmp_path / "outside.sqlite3" + outside.write_bytes(state_path.read_bytes()) + state_path.unlink() + state_path.symlink_to(outside) + + payload = crawl_verification_payload(output) + + assert payload["valid"] is False + assert payload["crawl_id"] == receipt.crawl_id + assert payload["verified_pages"] == 1 + if state_kind == "symlink": + assert payload["failures"] == ["crawl receipt and state must not be symlinks"] + + +def test_invalid_sqlite_row_is_fixed_for_payload_and_cli( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + output = tmp_path / "crawl" + install_fake_fetch(monkeypatch, {ROOT: []}) + receipt = crawl_site(ROOT, output, reader="direct", policy=policy(), resume=False) + connection = sqlite3.connect(output / "crawl.sqlite3") + connection.execute("update pages set depth = 'not-an-integer'") + connection.commit() + connection.close() + + payload = crawl_verification_payload(output) + assert payload["crawl_id"] == receipt.crawl_id + assert payload["verified_pages"] == 1 + assert payload["failures"][-1] == "crawl SQLite state is invalid" + + with pytest.raises(SystemExit) as exit_info: + main(["verify", str(output), "--json"]) + + captured = capsys.readouterr() + assert exit_info.value.code == 6 + assert captured.out == "" + assert json.loads(captured.err)["details"]["failures"][-1] == "crawl SQLite state is invalid" + assert "Traceback" not in captured.err + + def test_failed_page_counts_are_recomputed_from_page_records( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -234,3 +484,18 @@ def test_failed_page_counts_are_recomputed_from_page_records( receipt["failures_by_code"] = {"fabricated": 1} receipt_path.write_text(json.dumps(receipt), encoding="utf-8") assert "failures_by_code does not match page records" in verify_crawl_bundle(output) + + +def test_crawl_status_is_recomputed_from_page_records( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + output = tmp_path / "crawl" + install_fake_fetch(monkeypatch, {ROOT: []}) + receipt = crawl_site(ROOT, output, reader="direct", policy=policy(), resume=False) + assert receipt.status == "complete" + receipt_path = output / "crawl-receipt.json" + payload = json.loads(receipt_path.read_text(encoding="utf-8")) + payload["status"] = "failed" + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + + assert verify_crawl_bundle(output) == ["crawl status does not match page records"]