From 7520685d6e6b22422913d26ed092e29d084e91b7 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Fri, 16 Oct 2026 19:23:48 -0400 Subject: [PATCH 01/11] Import partial candidate patch from 2026-10-16 handoff (unverified) Applied once from cloud-handoff/diffs/task-handoff-check.patch onto the audited base. Not compiled, tested or reviewed at this commit; later commits review and complete it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 18 ++++++++++++++--- CHANGELOG.md | 7 +++++-- README.md | 27 ++++++++++++++++--------- task_handoff_check/__main__.py | 3 ++- task_handoff_check/core.py | 37 +++++++++++++++++++++++++++------- 5 files changed, 70 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eb9f0e7..7d8c717 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,12 +1,24 @@ name: Tests -# Single light job, pull requests only (keeps Actions minutes low). +# Supported minimum and representative versions/platforms; pull requests only. on: pull_request: permissions: contents: read jobs: test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + python: '3.10' + - os: ubuntu-latest + python: '3.12' + - os: ubuntu-latest + python: '3.14' + - os: macos-latest + python: '3.14' + runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -14,7 +26,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.12' + python-version: ${{ matrix.python }} - run: python -m unittest discover -s tests -v - run: python -m pip install --no-deps . - name: Check installed entry point outside the source tree diff --git a/CHANGELOG.md b/CHANGELOG.md index a25173a..b135a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## Unreleased +- Reject exponent-overflow JSON numbers, including positive and negative `1e999`, with `HandoffError`. +- Bound freshness options to 0..87,840,000 hours without converting arbitrary-size integers to float; invalid options consistently raise `HandoffError`. +- Report JSON parse locations without repeating input contents or filenames. +- Restore the documented pull-request Python/platform matrix; distinguish configured jobs from executed verification. +- Correct the published-release installation note; these changes remain local and unreleased. - PyPI metadata (keywords, classifiers, homepage and changelog URLs), README pitch, badge placeholders and install-from-PyPI note. -- CI trimmed to one light Ubuntu job on pull requests to save Actions minutes. -- No behavior changes. ## 0.1.0 diff --git a/README.md b/README.md index f4b91fd..564e5b9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ > Lint a task handoff (human or AI agent) for a missing owner, next step, blockers, done criteria or evidence before someone else picks it up. - - [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -32,7 +30,9 @@ The first two checks pass. The third exits 1 because it claims `complete` while listing an open blocker. All examples, people and references are fictional; referenced paths deliberately need not exist. The tool never opens them. -After the first PyPI release: `pipx install task-handoff-check` (or `pip install task-handoff-check`). +The published 0.1.0 release is available with `pipx install task-handoff-check==0.1.0` +(or `pip install task-handoff-check==0.1.0`). Use the source installation below +for unreleased changes in this checkout. To install from source in a virtual environment: @@ -119,8 +119,10 @@ python -m task_handoff_check examples/in-progress.json \ ``` This example passes exactly at the 24-hour boundary. One second later it is -`stale`. `--max-age-hours` must be finite, nonnegative and accompanied by -`--as-of`. Supplying `--as-of` requires a valid `updated_at` and rejects a future +`stale`. `--max-age-hours` must be finite, from 0 through 87,840,000 inclusive, +and accompanied by `--as-of`. The broad upper bound covers the full supported +timestamp calendar; it does not establish a meaningful freshness policy for you. +Supplying `--as-of` requires a valid `updated_at` and rejects a future update. `--as-of` alone compares chronology, without a maximum-age policy. Accepted timestamps use `YYYY-MM-DDTHH:MM:SS`, optional 1–6 fractional digits, @@ -161,8 +163,12 @@ report = validate_handoff( ``` `validate_handoff` returns issues for invalid documents and raises `HandoffError` -for invalid freshness options. `load_handoff` raises `HandoffError` for input -errors. Validation does not mutate the document. +for invalid freshness options. The library accepts `max_age_hours` as an integer +or float in the same inclusive range as the CLI; booleans and nonfinite values +are rejected. `load_handoff` raises `HandoffError` for input errors, including +numbers such as `1e999` that overflow Python's finite floating-point range. +Validation does not mutate the document. Parser errors identify a location +without repeating JSON content or the input filename. ## Limits and development @@ -179,8 +185,11 @@ It never changes a task's state. See [SECURITY.md](SECURITY.md). python -m unittest discover -s tests -v ``` -Tests use synthetic inputs and temporary directories. CI covers Python 3.10, -3.12 and 3.14 on Linux, plus Python 3.14 on macOS. +Tests use synthetic inputs and temporary directories. The pull-request workflow +is configured for Python 3.10, 3.12 and 3.14 on Linux, plus Python 3.14 on macOS. +Configuration describes intended coverage; inspect the workflow run results for +executed coverage. Local unreleased-fix verification used Python 3.14 on macOS; +the other configured jobs have not been run for these fixes. See [CONTRIBUTING.md](CONTRIBUTING.md). ## Contributing diff --git a/task_handoff_check/__main__.py b/task_handoff_check/__main__.py index e7ba38e..c6e6b8a 100644 --- a/task_handoff_check/__main__.py +++ b/task_handoff_check/__main__.py @@ -11,7 +11,8 @@ def main(argv=None): parser.add_argument('handoff', type=Path) parser.add_argument('--json', action='store_true', help='print a machine-readable report') parser.add_argument('--as-of', help='explicit timestamp for comparison; no implicit current time') - parser.add_argument('--max-age-hours', type=float, help='maximum age; requires --as-of') + parser.add_argument('--max-age-hours', type=float, + help='maximum age from 0 to 87840000 hours; requires --as-of') args = parser.parse_args(argv) options = {'as_of': args.as_of, 'max_age_hours': args.max_age_hours} try: diff --git a/task_handoff_check/core.py b/task_handoff_check/core.py index adce2d9..54c67d7 100644 --- a/task_handoff_check/core.py +++ b/task_handoff_check/core.py @@ -10,6 +10,9 @@ from urllib.parse import urlsplit MAX_HANDOFF_BYTES = 1024 * 1024 +# A deliberately broad cap covers the entire supported datetime calendar while +# rejecting nonsensical option magnitudes before any int-to-float conversion. +MAX_AGE_HOURS = 24 * 366 * 10000 PLACEHOLDERS = {'tbd', 'todo', 'unknown', 'unassigned', 'n/a', 'none', '-', '?'} REQUIRED = {'schema_version', 'objective', 'owner', 'status', 'next_action', 'inputs', 'evidence', 'blockers', 'completion_criteria'} @@ -32,6 +35,13 @@ def _constant(value): raise HandoffError('nonfinite JSON numbers are not supported') +def _finite_float(value): + number = float(value) + if not math.isfinite(number): + raise HandoffError('nonfinite JSON numbers are not supported') + return number + + def load_handoff(path): """Read one regular UTF-8 JSON file, up to 1 MiB. Reject final symlinks.""" path = Path(path) @@ -48,9 +58,20 @@ def load_handoff(path): contents = stream.read(MAX_HANDOFF_BYTES + 1) if len(contents) > MAX_HANDOFF_BYTES: raise HandoffError('handoff exceeds 1 MiB') - return json.loads(contents.decode('utf-8'), object_pairs_hook=_object, parse_constant=_constant) - except (OSError, ValueError, UnicodeError, RecursionError) as exc: - raise HandoffError('cannot read handoff JSON: ' + str(exc)) from exc + return json.loads(contents.decode('utf-8'), object_pairs_hook=_object, + parse_constant=_constant, parse_float=_finite_float) + except HandoffError: + raise + except UnicodeDecodeError as exc: + raise HandoffError('handoff must contain valid UTF-8 JSON') from exc + except json.JSONDecodeError as exc: + raise HandoffError(f'invalid JSON at line {exc.lineno}, column {exc.colno}') from exc + except RecursionError as exc: + raise HandoffError('handoff JSON nesting is too deep') from exc + except OSError as exc: + raise HandoffError('cannot read handoff JSON: ' + (exc.strerror or 'file unavailable')) from exc + except ValueError as exc: + raise HandoffError('handoff contains an unsupported JSON value') from exc finally: if fd is not None: os.close(fd) @@ -95,15 +116,17 @@ def _http_url(value): def validate_handoff(data, *, as_of=None, max_age_hours=None): """Return a JSON-compatible report. Optional freshness uses only supplied time. - ``as_of`` is a timestamp string. ``max_age_hours`` is a finite nonnegative - number and requires ``as_of``. Bad options raise HandoffError; invalid + ``as_of`` is a timestamp string. ``max_age_hours`` is an int or float in + the inclusive range 0..87,840,000 and requires ``as_of``. Booleans and + nonfinite floats are not accepted. Bad options raise HandoffError; invalid document fields appear in the returned issues list. """ clock = _timestamp(as_of) if as_of is not None else None if max_age_hours is not None: if (clock is None or type(max_age_hours) not in (int, float) - or not math.isfinite(max_age_hours) or max_age_hours < 0): - raise HandoffError('max_age_hours requires as_of and a finite nonnegative number') + or not 0 <= max_age_hours <= MAX_AGE_HOURS + or (type(max_age_hours) is float and not math.isfinite(max_age_hours))): + raise HandoffError('max_age_hours requires as_of and a number from 0 to 87840000 hours') issues = [] freshness = 'not_evaluated' From 616a14596a43d759ac83e15e9209b2bda40da0da Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Fri, 16 Oct 2026 20:15:11 -0400 Subject: [PATCH 02/11] Finish finite-number and freshness fixes; honest coverage (0.1.1rc1) Reviews the partial patch: keeps finite parse_float rejection and location-only diagnostics; replaces its 87,840,000-hour cap with exact int/float comparison (the OverflowError came from math.isfinite on a huge int). Regression tests for overflow, huge options, diagnostics, forged and contradictory handoffs, exit codes and the README/CI claim. setuptools>=83 build floor (CVE-2026-59890), hash-pinned build inputs in CI. Not published. Local candidate for independent audit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 4 +- CHANGELOG.md | 4 +- README.md | 25 +++--- pyproject.toml | 4 +- requirements/build.in | 2 + requirements/build.txt | 95 ++++++++++++++++++++++ task_handoff_check/__init__.py | 2 +- task_handoff_check/__main__.py | 2 +- task_handoff_check/core.py | 19 +++-- tests/test_cli.py | 5 +- tests/test_regressions.py | 142 +++++++++++++++++++++++++++++++++ 11 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 requirements/build.in create mode 100644 requirements/build.txt create mode 100644 tests/test_regressions.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7d8c717..5899604 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,6 @@ name: Tests # Supported minimum and representative versions/platforms; pull requests only. +# Build inputs are hash-pinned (requirements/build.txt). on: pull_request: permissions: @@ -28,7 +29,8 @@ jobs: with: python-version: ${{ matrix.python }} - run: python -m unittest discover -s tests -v - - run: python -m pip install --no-deps . + - run: python -m pip install --require-hashes -r requirements/build.txt + - run: python -m pip install --no-deps --no-build-isolation . - name: Check installed entry point outside the source tree working-directory: ${{ runner.temp }} run: task-handoff-check --version diff --git a/CHANGELOG.md b/CHANGELOG.md index b135a3c..63d5ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## Unreleased - Reject exponent-overflow JSON numbers, including positive and negative `1e999`, with `HandoffError`. -- Bound freshness options to 0..87,840,000 hours without converting arbitrary-size integers to float; invalid options consistently raise `HandoffError`. +- Freshness options no longer raise an undocumented `OverflowError`: integers of any size are compared exactly (never converted to float), and every invalid option raises `HandoffError`. - Report JSON parse locations without repeating input contents or filenames. - Restore the documented pull-request Python/platform matrix; distinguish configured jobs from executed verification. -- Correct the published-release installation note; these changes remain local and unreleased. +- Correct the installation note; it no longer describes the first release as future. - PyPI metadata (keywords, classifiers, homepage and changelog URLs), README pitch, badge placeholders and install-from-PyPI note. ## 0.1.0 diff --git a/README.md b/README.md index 564e5b9..1dca1e3 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ The first two checks pass. The third exits 1 because it claims `complete` while listing an open blocker. All examples, people and references are fictional; referenced paths deliberately need not exist. The tool never opens them. -The published 0.1.0 release is available with `pipx install task-handoff-check==0.1.0` -(or `pip install task-handoff-check==0.1.0`). Use the source installation below -for unreleased changes in this checkout. +Install the latest published release with `pipx install task-handoff-check` +(or `pip install task-handoff-check`). This README describes the source tree, +which can contain unreleased changes; see [CHANGELOG.md](CHANGELOG.md) and use +the source installation below to get them. To install from source in a virtual environment: @@ -119,10 +120,8 @@ python -m task_handoff_check examples/in-progress.json \ ``` This example passes exactly at the 24-hour boundary. One second later it is -`stale`. `--max-age-hours` must be finite, from 0 through 87,840,000 inclusive, -and accompanied by `--as-of`. The broad upper bound covers the full supported -timestamp calendar; it does not establish a meaningful freshness policy for you. -Supplying `--as-of` requires a valid `updated_at` and rejects a future +`stale`. `--max-age-hours` must be a finite, nonnegative number and accompanied +by `--as-of`; choosing a meaningful freshness policy is up to you. Supplying `--as-of` requires a valid `updated_at` and rejects a future update. `--as-of` alone compares chronology, without a maximum-age policy. Accepted timestamps use `YYYY-MM-DDTHH:MM:SS`, optional 1–6 fractional digits, @@ -163,9 +162,9 @@ report = validate_handoff( ``` `validate_handoff` returns issues for invalid documents and raises `HandoffError` -for invalid freshness options. The library accepts `max_age_hours` as an integer -or float in the same inclusive range as the CLI; booleans and nonfinite values -are rejected. `load_handoff` raises `HandoffError` for input errors, including +for invalid freshness options. The library accepts `max_age_hours` as a finite, +nonnegative integer or float; integers of any size are compared exactly, and +booleans and nonfinite values are rejected. `load_handoff` raises `HandoffError` for input errors, including numbers such as `1e999` that overflow Python's finite floating-point range. Validation does not mutate the document. Parser errors identify a location without repeating JSON content or the input filename. @@ -187,9 +186,9 @@ python -m unittest discover -s tests -v Tests use synthetic inputs and temporary directories. The pull-request workflow is configured for Python 3.10, 3.12 and 3.14 on Linux, plus Python 3.14 on macOS. -Configuration describes intended coverage; inspect the workflow run results for -executed coverage. Local unreleased-fix verification used Python 3.14 on macOS; -the other configured jobs have not been run for these fixes. +Configured jobs are not executed by this change; workflow run results are the +evidence of executed coverage. This candidate was verified locally with CPython +3.10, 3.11, 3.12 and 3.14 on macOS (arm64), from source and from the built wheel. See [CONTRIBUTING.md](CONTRIBUTING.md). ## Contributing diff --git a/pyproject.toml b/pyproject.toml index cda4bc7..538dd92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=83.0.0"] # CVE-2026-59890 (sdist MANIFEST exclusion bypass) fixed in 83.0.0 build-backend = "setuptools.build_meta" [project] name = "task-handoff-check" -version = "0.1.0" +version = "0.1.1rc1" description = "Check structured task handoffs for missing and contradictory claims." readme = "README.md" requires-python = ">=3.10" diff --git a/requirements/build.in b/requirements/build.in new file mode 100644 index 0000000..eb4a837 --- /dev/null +++ b/requirements/build.in @@ -0,0 +1,2 @@ +build==1.6.1 +setuptools==84.0.0 diff --git a/requirements/build.txt b/requirements/build.txt new file mode 100644 index 0000000..f219d23 --- /dev/null +++ b/requirements/build.txt @@ -0,0 +1,95 @@ +build==1.6.1 \ + --hash=sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53 \ + --hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7 + # via -r task-handoff-check/requirements/build.in +colorama==0.4.6 ; os_name == 'nt' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via build +importlib-metadata==9.0.1 ; python_full_version < '3.10.2' \ + --hash=sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99 \ + --hash=sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0 + # via build +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via build +pyproject-hooks==1.3.3 \ + --hash=sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b \ + --hash=sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43 + # via build +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ + --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 + # via -r task-handoff-check/requirements/build.in +tomli==2.5.0 ; python_full_version < '3.11' \ + --hash=sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea \ + --hash=sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd \ + --hash=sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0 \ + --hash=sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391 \ + --hash=sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df \ + --hash=sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9 \ + --hash=sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066 \ + --hash=sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f \ + --hash=sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57 \ + --hash=sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6 \ + --hash=sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b \ + --hash=sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3 \ + --hash=sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043 \ + --hash=sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01 \ + --hash=sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646 \ + --hash=sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859 \ + --hash=sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b \ + --hash=sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e \ + --hash=sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc \ + --hash=sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5 \ + --hash=sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0 \ + --hash=sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb \ + --hash=sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84 \ + --hash=sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6 \ + --hash=sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b \ + --hash=sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b \ + --hash=sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52 \ + --hash=sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd \ + --hash=sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75 \ + --hash=sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1 \ + --hash=sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b \ + --hash=sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142 \ + --hash=sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03 \ + --hash=sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea \ + --hash=sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885 \ + --hash=sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374 \ + --hash=sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3 \ + --hash=sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276 \ + --hash=sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b \ + --hash=sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc \ + --hash=sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68 \ + --hash=sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a \ + --hash=sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f \ + --hash=sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b \ + --hash=sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7 \ + --hash=sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0 \ + --hash=sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb \ + --hash=sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7 \ + --hash=sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545 \ + --hash=sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8 \ + --hash=sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980 \ + --hash=sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7 \ + --hash=sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105 \ + --hash=sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5 \ + --hash=sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56 \ + --hash=sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d \ + --hash=sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2 \ + --hash=sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4 \ + --hash=sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7 \ + --hash=sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef \ + --hash=sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1 \ + --hash=sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571 \ + --hash=sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a \ + --hash=sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442 \ + --hash=sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc + # via build +zipp==4.1.1 ; python_full_version < '3.10.2' \ + --hash=sha256:7ebb7a44c021b29fd8dbd7cce6812d0d7b5b454521f93cc71af6ccd155aaa70b \ + --hash=sha256:8979f52d874162f485ff2981e3891f3a3317b7a3dd43ff1e1775b9304f307a9c + # via importlib-metadata diff --git a/task_handoff_check/__init__.py b/task_handoff_check/__init__.py index aba12c8..37f49e1 100644 --- a/task_handoff_check/__init__.py +++ b/task_handoff_check/__init__.py @@ -1,5 +1,5 @@ """Structural checks for explicit, human-readable task handoffs.""" from .core import HandoffError, load_handoff, validate_handoff -__version__ = '0.1.0' +__version__ = '0.1.1rc1' __all__ = ['HandoffError', 'load_handoff', 'validate_handoff'] diff --git a/task_handoff_check/__main__.py b/task_handoff_check/__main__.py index c6e6b8a..2f61984 100644 --- a/task_handoff_check/__main__.py +++ b/task_handoff_check/__main__.py @@ -12,7 +12,7 @@ def main(argv=None): parser.add_argument('--json', action='store_true', help='print a machine-readable report') parser.add_argument('--as-of', help='explicit timestamp for comparison; no implicit current time') parser.add_argument('--max-age-hours', type=float, - help='maximum age from 0 to 87840000 hours; requires --as-of') + help='maximum age in hours (finite, nonnegative); requires --as-of') args = parser.parse_args(argv) options = {'as_of': args.as_of, 'max_age_hours': args.max_age_hours} try: diff --git a/task_handoff_check/core.py b/task_handoff_check/core.py index 54c67d7..ed6bd3b 100644 --- a/task_handoff_check/core.py +++ b/task_handoff_check/core.py @@ -10,9 +10,6 @@ from urllib.parse import urlsplit MAX_HANDOFF_BYTES = 1024 * 1024 -# A deliberately broad cap covers the entire supported datetime calendar while -# rejecting nonsensical option magnitudes before any int-to-float conversion. -MAX_AGE_HOURS = 24 * 366 * 10000 PLACEHOLDERS = {'tbd', 'todo', 'unknown', 'unassigned', 'n/a', 'none', '-', '?'} REQUIRED = {'schema_version', 'objective', 'owner', 'status', 'next_action', 'inputs', 'evidence', 'blockers', 'completion_criteria'} @@ -116,17 +113,19 @@ def _http_url(value): def validate_handoff(data, *, as_of=None, max_age_hours=None): """Return a JSON-compatible report. Optional freshness uses only supplied time. - ``as_of`` is a timestamp string. ``max_age_hours`` is an int or float in - the inclusive range 0..87,840,000 and requires ``as_of``. Booleans and - nonfinite floats are not accepted. Bad options raise HandoffError; invalid - document fields appear in the returned issues list. + ``as_of`` is a timestamp string. ``max_age_hours`` is a finite nonnegative + int or float and requires ``as_of``; booleans are not numbers here. Integers + of any size are compared exactly, never converted to float. Bad options + raise HandoffError; invalid document fields appear in the returned issues list. """ clock = _timestamp(as_of) if as_of is not None else None if max_age_hours is not None: + # ponytail: no upper cap. The old OverflowError came from math.isfinite() on a huge int; + # Python compares int and float exactly, and any age beyond the timestamp range means "no limit". if (clock is None or type(max_age_hours) not in (int, float) - or not 0 <= max_age_hours <= MAX_AGE_HOURS - or (type(max_age_hours) is float and not math.isfinite(max_age_hours))): - raise HandoffError('max_age_hours requires as_of and a number from 0 to 87840000 hours') + or (type(max_age_hours) is float and not math.isfinite(max_age_hours)) + or max_age_hours < 0): + raise HandoffError('max_age_hours requires as_of and a finite nonnegative number') issues = [] freshness = 'not_evaluated' diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d595a5..00e3440 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -180,7 +180,10 @@ def test_human_output_and_version(self): result = self.run_cli(self.path) self.assertEqual(result.returncode, 0) self.assertIn('consistency', result.stdout.lower()) - self.assertIn('0.1.0', self.run_cli('--version').stdout) + # Release-candidate versions differ from 0.1.0: compare with the package metadata, not a literal. + from task_handoff_check import __version__ + self.assertIn(f'version = "{__version__}"', (ROOT / 'pyproject.toml').read_text()) + self.assertEqual(self.run_cli('--version').stdout.strip(), __version__) if __name__ == '__main__': diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..6cff919 --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,142 @@ +"""Regressions for THC-1..THC-3 and the boundary between consistency and truth. Synthetic inputs only.""" +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest + +from task_handoff_check import HandoffError, load_handoff, validate_handoff +from test_cli import handoff + +ROOT = Path(__file__).resolve().parents[1] +AS_OF = '2026-01-02T00:00:00Z' + + +class Reader(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def load(self, raw, name='handoff.json'): + path = Path(self.tmp.name) / name + path.write_bytes(raw.encode() if isinstance(raw, str) else raw) + return load_handoff(path) + + def test_thc2_exponent_overflow_is_rejected_everywhere(self): + for raw in ('{"x": 1e999}', '{"x": -1e999}', '{"x": 1E+400}', '{"x": [0, {"y": 9e9999}]}'): + with self.subTest(raw=raw), self.assertRaisesRegex(HandoffError, 'nonfinite'): + self.load(raw) + for raw in ('{"x": NaN}', '{"x": Infinity}', '{"x": -Infinity}'): + with self.subTest(raw=raw), self.assertRaises(HandoffError): + self.load(raw) + + def test_finite_numbers_still_load(self): + self.assertEqual(self.load('{"a": 1.5e308, "b": -0.0, "c": 1e-400, "d": 12345678901234567890}'), + {'a': 1.5e308, 'b': -0.0, 'c': 0.0, 'd': 12345678901234567890}) + + def test_oversized_integer_literal_is_a_handoff_error_not_a_crash(self): + with self.assertRaises(HandoffError): + self.load('{"x": ' + '9' * 5000 + '}') + + def test_diagnostics_give_a_location_without_content_or_filename(self): + with self.assertRaises(HandoffError) as caught: + self.load('{"x": "SYNTHETIC_SECRET_SENTINEL" !}', name='SENTINEL_NAME.json') + message = str(caught.exception) + self.assertRegex(message, r'line 1, column \d+') + self.assertNotIn('SENTINEL', message) + with self.assertRaises(HandoffError) as caught: + load_handoff(Path(self.tmp.name) / 'MISSING_SENTINEL.json') + self.assertNotIn('SENTINEL', str(caught.exception)) + with self.assertRaisesRegex(HandoffError, 'UTF-8'): + self.load(b'{"x": "\xff"}') + with self.assertRaisesRegex(HandoffError, 'too deep|unsupported|invalid'): + self.load('[' * 100000 + ']' * 100000) + + +class Freshness(unittest.TestCase): + def doc(self, updated='2026-01-01T00:00:00Z'): + data = handoff() + data['updated_at'] = updated + return data + + def test_thc3_huge_finite_integers_are_accepted_exactly(self): + for value in (10 ** 400, 2 ** 1024, 10 ** 20): + with self.subTest(value=value): + report = validate_handoff(self.doc(), as_of=AS_OF, max_age_hours=value) + self.assertEqual(report['freshness'], 'within_limit') + self.assertEqual(validate_handoff(self.doc(), as_of=AS_OF, max_age_hours=23)['freshness'], 'stale') + self.assertEqual(validate_handoff(self.doc(), as_of=AS_OF, max_age_hours=24)['freshness'], 'within_limit') + + def test_thc3_invalid_options_raise_only_the_documented_error(self): + for value in (float('inf'), float('-inf'), float('nan'), -1, -10 ** 400, True, False, '24', None.__class__): + with self.subTest(value=value), self.assertRaises(HandoffError): + validate_handoff(self.doc(), as_of=AS_OF, max_age_hours=value) + with self.assertRaises(HandoffError): + validate_handoff(self.doc(), max_age_hours=24) # requires as_of + + +class CLIContract(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) / 'handoff.json' + + def run_cli(self, data, *args, raw=None): + self.path.write_text(raw if raw is not None else json.dumps(data)) + return subprocess.run([sys.executable, '-m', 'task_handoff_check', str(self.path), *args], + cwd=ROOT, text=True, capture_output=True, timeout=10) + + def test_exit_codes(self): + self.assertEqual(self.run_cli(handoff()).returncode, 0) + self.assertEqual(self.run_cli(None, raw='{"x": 1e999}').returncode, 1) + for bad in ('1e400', 'nan', 'inf', '-1'): + with self.subTest(option=bad): + p = self.run_cli(handoff(), '--as-of', AS_OF, '--max-age-hours', bad) + self.assertEqual(p.returncode, 2) + self.assertNotIn('Traceback', p.stderr) + self.assertEqual(self.run_cli(handoff(), '--max-age-hours', '24').returncode, 2) # missing --as-of + + def test_fabricated_but_consistent_handoff_passes_with_the_scope_disclaimer(self): + data = handoff() + data.update(status='complete', next_action=None, owner='Fictional Person', + evidence=[{'label': 'review', 'path': 'never-created/review.txt'}], + completion_criteria=[{'description': 'Everything done', 'met': True, 'evidence': ['review']}]) + p = self.run_cli(data) + self.assertEqual(p.returncode, 0, p.stdout) + self.assertIn('This checks assertions, not truth, authorization, file contents, or task completion.', p.stdout) + report = json.loads(self.run_cli(data, '--json').stdout) + self.assertEqual((report['ok'], report['scope']), (True, 'handoff_consistency')) + + def test_incomplete_and_contradictory_handoffs_fail(self): + cases = { + 'missing owner': lambda d: d.pop('owner'), + 'complete with blocker': lambda d: d.update(status='complete', next_action=None, + blockers=[{'description': 'x waits', 'owner': 'y'}]), + 'complete with unmet criterion': lambda d: d.update(status='complete', next_action=None), + 'met criterion without evidence': lambda d: d['completion_criteria'][0].update(met=True), + 'unknown evidence label': lambda d: d['completion_criteria'][0].update(evidence=['nope']), + 'placeholder owner': lambda d: d.update(owner='TBD'), + } + for name, mutate in cases.items(): + with self.subTest(case=name): + data = handoff() + mutate(data) + self.assertEqual(self.run_cli(data).returncode, 1) + + +class DocumentedCoverage(unittest.TestCase): + def test_thc1_readme_matches_the_configured_ci_matrix(self): + workflow = (ROOT / '.github/workflows/tests.yml').read_text() + readme = (ROOT / 'README.md').read_text() + configured = sorted(set(re.findall(r"python: '([0-9.]+)'", workflow))) + self.assertGreaterEqual(len(configured), 2) + claim = re.search(r'pull-request workflow is configured for (.+?macOS)\.', readme.replace('\n', ' ')).group(1) + for version in configured: + self.assertIn(version, claim) + self.assertIn('not executed by this change', readme.replace('\n', ' ')) + + +if __name__ == '__main__': + unittest.main() From d50f28a5a00c823915e22af0b7569c1f8594d107 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Fri, 16 Oct 2026 20:18:02 -0400 Subject: [PATCH 03/11] Use SPDX license metadata (PEP 639) setuptools deprecates the license table and license classifiers and will reject them after 2027-02-18; the >=83 build floor supports license = "MIT" with license-files. Metadata only. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 538dd92..f09aad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ version = "0.1.1rc1" description = "Check structured task handoffs for missing and contradictory claims." readme = "README.md" requires-python = ">=3.10" -license = {file = "LICENSE"} +license = "MIT" +license-files = ["LICENSE"] authors = [{name = "IT Custom Solution LLC"}] keywords = ["handoff", "ai-agents", "workflow", "validation", "cli", "json"] dependencies = [] @@ -17,7 +18,6 @@ classifiers = [ "Programming Language :: Python :: 3", "Environment :: Console", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Quality Assurance", From acd01fef757b1daa6fab9469d3af77da9ab62a31 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Fri, 16 Oct 2026 20:56:50 -0400 Subject: [PATCH 04/11] Close independent-review gaps: two-way CI claim test, NUL path - THC-R1: README claim and workflow matrix must match in both directions as (platform, Python) pairs. - THC-R2: a NUL in the handoff path is "handoff path is invalid". - THC-R3: point-in-time local verification moved to the CHANGELOG. - Deep-nesting test no longer assumes a RecursionError (CPython 3.14.6 parses it; the schema check refuses it). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ README.md | 4 +--- task_handoff_check/core.py | 2 ++ tests/test_regressions.py | 33 ++++++++++++++++++++++++--------- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d5ef8..32ba844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Restore the documented pull-request Python/platform matrix; distinguish configured jobs from executed verification. - Correct the installation note; it no longer describes the first release as future. - PyPI metadata (keywords, classifiers, homepage and changelog URLs), README pitch, badge placeholders and install-from-PyPI note. +- A handoff path containing a NUL character raises `HandoffError('handoff path is invalid ...')` instead of a misleading JSON message. +- Local verification of the 0.1.1rc1 candidate (2026-10-16): CPython 3.10, 3.11, 3.12 and 3.14 on macOS (arm64), from source and from the built wheel. Python 3.13, Linux and Windows were not run; no hosted CI run exists for it. ## 0.1.0 diff --git a/README.md b/README.md index 1dca1e3..5d1cdfa 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,7 @@ python -m unittest discover -s tests -v Tests use synthetic inputs and temporary directories. The pull-request workflow is configured for Python 3.10, 3.12 and 3.14 on Linux, plus Python 3.14 on macOS. Configured jobs are not executed by this change; workflow run results are the -evidence of executed coverage. This candidate was verified locally with CPython -3.10, 3.11, 3.12 and 3.14 on macOS (arm64), from source and from the built wheel. -See [CONTRIBUTING.md](CONTRIBUTING.md). +evidence of executed coverage. See [CONTRIBUTING.md](CONTRIBUTING.md). ## Contributing diff --git a/task_handoff_check/core.py b/task_handoff_check/core.py index ed6bd3b..dbefbe6 100644 --- a/task_handoff_check/core.py +++ b/task_handoff_check/core.py @@ -42,6 +42,8 @@ def _finite_float(value): def load_handoff(path): """Read one regular UTF-8 JSON file, up to 1 MiB. Reject final symlinks.""" path = Path(path) + if '\x00' in str(path): + raise HandoffError('handoff path is invalid (contains a NUL character)') fd = None try: if not stat.S_ISREG(path.lstat().st_mode): diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 6cff919..f1e8e4b 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -51,8 +51,18 @@ def test_diagnostics_give_a_location_without_content_or_filename(self): self.assertNotIn('SENTINEL', str(caught.exception)) with self.assertRaisesRegex(HandoffError, 'UTF-8'): self.load(b'{"x": "\xff"}') - with self.assertRaisesRegex(HandoffError, 'too deep|unsupported|invalid'): - self.load('[' * 100000 + ']' * 100000) + # Deep nesting never escapes as RecursionError. Some interpreters (CPython 3.14.6 here) parse it + # without a recursion limit; the schema check then refuses it. + try: + data = self.load('[' * 100000 + ']' * 100000) + except HandoffError as exc: + self.assertRegex(str(exc), 'too deep|unsupported|invalid') + else: + self.assertFalse(validate_handoff(data)['ok']) + + def test_thc_r2_nul_in_path_is_reported_as_an_invalid_path(self): + with self.assertRaisesRegex(HandoffError, 'path is invalid'): + load_handoff(Path(self.tmp.name) / 'a\x00b.json') class Freshness(unittest.TestCase): @@ -127,15 +137,20 @@ def test_incomplete_and_contradictory_handoffs_fail(self): class DocumentedCoverage(unittest.TestCase): - def test_thc1_readme_matches_the_configured_ci_matrix(self): + OS_NAMES = {'ubuntu-latest': 'Linux', 'macos-latest': 'macOS', 'windows-latest': 'Windows'} + + def test_thc1_readme_and_ci_matrix_agree_both_ways(self): workflow = (ROOT / '.github/workflows/tests.yml').read_text() - readme = (ROOT / 'README.md').read_text() - configured = sorted(set(re.findall(r"python: '([0-9.]+)'", workflow))) + readme = ' '.join((ROOT / 'README.md').read_text().split()) + configured = {(self.OS_NAMES[os_], v) for os_, v in re.findall(r"- os: (\S+)\s+python: '([0-9.]+)'", workflow)} self.assertGreaterEqual(len(configured), 2) - claim = re.search(r'pull-request workflow is configured for (.+?macOS)\.', readme.replace('\n', ' ')).group(1) - for version in configured: - self.assertIn(version, claim) - self.assertIn('not executed by this change', readme.replace('\n', ' ')) + claim = re.search(r'pull-request workflow is configured for (.+?(?:Linux|macOS|Windows))[.;]', readme).group(1) + claimed = set() + for part in claim.split(', plus '): + platform = re.search(r' on (\w+)$', part).group(1) + claimed |= {(platform, v) for v in re.findall(r'\d+\.\d+', part)} + self.assertEqual(claimed, configured) # every claimed job exists, and every configured job is claimed + self.assertIn('not executed by this change', readme) if __name__ == '__main__': From 0eb9b34aadce564a66b2d4e9787e025d05295414 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Fri, 16 Oct 2026 23:31:20 -0400 Subject: [PATCH 05/11] Compare freshness exactly; drop the stale README version (RH-01, RH-02) - RH-01: elapsed time is integer microseconds and the limit an exact Fraction, so a centuries-long interval one microsecond over the limit is stale (library and CLI). - RH-02: README said "Version 0.1.0" for the 0.1.1rc1 candidate; the sentence is removed and a test keeps any README version in sync. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ README.md | 2 +- task_handoff_check/core.py | 11 ++++++++--- tests/test_regressions.py | 23 +++++++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ba844..607987c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - Correct the installation note; it no longer describes the first release as future. - PyPI metadata (keywords, classifiers, homepage and changelog URLs), README pitch, badge placeholders and install-from-PyPI note. - A handoff path containing a NUL character raises `HandoffError('handoff path is invalid ...')` instead of a misleading JSON message. +- Freshness compares the elapsed time in exact integer microseconds; a long interval just over the limit is now `stale` (independent re-audit RH-01). +- The README no longer states a version number (it said 0.1.0 while the package is 0.1.1rc1; RH-02). - Local verification of the 0.1.1rc1 candidate (2026-10-16): CPython 3.10, 3.11, 3.12 and 3.14 on macOS (arm64), from source and from the built wheel. Python 3.13, Linux and Windows were not run; no hosted CI run exists for it. ## 0.1.0 diff --git a/README.md b/README.md index 5d1cdfa..e0dd5b6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ are true, that its references exist, or that anyone is authorized to act.** An open-source engineering utility from [IT Custom Solution](https://github.com/itcustomsolution). Python 3.10+, no runtime dependencies, no network access, no commands executed. -Version 0.1.0. MIT licensed. +MIT licensed. ## Try it in one minute diff --git a/task_handoff_check/core.py b/task_handoff_check/core.py index dbefbe6..8ed0001 100644 --- a/task_handoff_check/core.py +++ b/task_handoff_check/core.py @@ -1,5 +1,6 @@ """Pure consistency validation; references are never opened or fetched.""" from datetime import datetime +from fractions import Fraction import json import math import os @@ -244,13 +245,17 @@ def result(): freshness = 'unavailable' issue('missing_timestamp', 'updated_at', 'a valid updated_at is required when as_of is supplied') else: - age_hours = (clock - updated).total_seconds() / 3600 + # Exact integer microseconds: a float age loses precision over long intervals. + elapsed = clock - updated + elapsed_us = (elapsed.days * 86400 + elapsed.seconds) * 1_000_000 + elapsed.microseconds freshness = 'timestamp_compared' - if age_hours < 0: + if elapsed_us < 0: freshness = 'future' issue('future_timestamp', 'updated_at', 'updated_at is after the supplied as_of') elif max_age_hours is not None: - freshness = 'within_limit' if age_hours <= max_age_hours else 'stale' + # Fraction is exact for int and float limits alike. + limit_us = Fraction(max_age_hours) * 3_600_000_000 + freshness = 'within_limit' if elapsed_us <= limit_us else 'stale' if freshness == 'stale': issue('stale', 'updated_at', 'updated_at exceeds the supplied maximum age') return result() diff --git a/tests/test_regressions.py b/tests/test_regressions.py index f1e8e4b..01f9dda 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -79,6 +79,17 @@ def test_thc3_huge_finite_integers_are_accepted_exactly(self): self.assertEqual(validate_handoff(self.doc(), as_of=AS_OF, max_age_hours=23)['freshness'], 'stale') self.assertEqual(validate_handoff(self.doc(), as_of=AS_OF, max_age_hours=24)['freshness'], 'within_limit') + def test_rh01_long_intervals_are_compared_exactly(self): + data = self.doc('1000-01-01T00:00:00Z') + hours = 8993736 # exactly 1000-01-01 to 2026-01-01 + self.assertEqual(validate_handoff(data, as_of='2026-01-01T00:00:00Z', max_age_hours=hours)['freshness'], 'within_limit') + report = validate_handoff(data, as_of='2026-01-01T00:00:00.000001Z', max_age_hours=hours) + self.assertEqual((report['ok'], report['freshness']), (False, 'stale')) + self.assertEqual(validate_handoff(data, as_of='2026-01-01T00:00:00.000001Z', max_age_hours=hours + 1)['freshness'], 'within_limit') + short = self.doc('2026-01-01T00:00:00Z') + self.assertEqual(validate_handoff(short, as_of='2026-01-01T00:30:00Z', max_age_hours=0.5)['freshness'], 'within_limit') + self.assertEqual(validate_handoff(short, as_of='2026-01-01T00:30:00.000001Z', max_age_hours=0.5)['freshness'], 'stale') + def test_thc3_invalid_options_raise_only_the_documented_error(self): for value in (float('inf'), float('-inf'), float('nan'), -1, -10 ** 400, True, False, '24', None.__class__): with self.subTest(value=value), self.assertRaises(HandoffError): @@ -108,6 +119,12 @@ def test_exit_codes(self): self.assertNotIn('Traceback', p.stderr) self.assertEqual(self.run_cli(handoff(), '--max-age-hours', '24').returncode, 2) # missing --as-of + def test_rh01_cli_long_interval_one_microsecond_over_is_stale(self): + data = handoff() + data['updated_at'] = '1000-01-01T00:00:00Z' + p = self.run_cli(data, '--as-of', '2026-01-01T00:00:00.000001Z', '--max-age-hours', '8993736') + self.assertEqual(p.returncode, 1, p.stdout + p.stderr) + def test_fabricated_but_consistent_handoff_passes_with_the_scope_disclaimer(self): data = handoff() data.update(status='complete', next_action=None, owner='Fictional Person', @@ -137,6 +154,12 @@ def test_incomplete_and_contradictory_handoffs_fail(self): class DocumentedCoverage(unittest.TestCase): + def test_rh02_readme_states_no_version_other_than_the_package_version(self): + from task_handoff_check import __version__ + readme = (ROOT / 'README.md').read_text() + for version in re.findall(r'\bVersion (\d+\.\d+\.\d+\S*?)[.\s]', readme): + self.assertEqual(version, __version__) + OS_NAMES = {'ubuntu-latest': 'Linux', 'macos-latest': 'macOS', 'windows-latest': 'Windows'} def test_thc1_readme_and_ci_matrix_agree_both_ways(self): From f49b90311d7b265b6aa651326c70995d404e2e2d Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Fri, 16 Oct 2026 23:45:34 -0400 Subject: [PATCH 06/11] Release candidate 0.1.1rc2 (unreleased; follows the audited previous candidate) Version only. Gives the re-audit fixes their own version so the rebuilt artifacts cannot be confused with the independently audited ones. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- task_handoff_check/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 607987c..fd59a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## Unreleased (0.1.1rc2 candidate, 2026-10-17; not published; 0.1.1rc1 of 2026-10-16 was the independently audited candidate) - Reject exponent-overflow JSON numbers, including positive and negative `1e999`, with `HandoffError`. - Freshness options no longer raise an undocumented `OverflowError`: integers of any size are compared exactly (never converted to float), and every invalid option raises `HandoffError`. diff --git a/pyproject.toml b/pyproject.toml index f09aad0..5251de6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "task-handoff-check" -version = "0.1.1rc1" +version = "0.1.1rc2" description = "Check structured task handoffs for missing and contradictory claims." readme = "README.md" requires-python = ">=3.10" diff --git a/task_handoff_check/__init__.py b/task_handoff_check/__init__.py index 37f49e1..55750b7 100644 --- a/task_handoff_check/__init__.py +++ b/task_handoff_check/__init__.py @@ -1,5 +1,5 @@ """Structural checks for explicit, human-readable task handoffs.""" from .core import HandoffError, load_handoff, validate_handoff -__version__ = '0.1.1rc1' +__version__ = '0.1.1rc2' __all__ = ['HandoffError', 'load_handoff', 'validate_handoff'] From 0e0c26ae26ef00873310c61507815685aa84233f Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Sat, 17 Oct 2026 00:41:35 -0400 Subject: [PATCH 07/11] State the float freshness policy in the README (KR2-10) A float limit is compared exactly as the binary value it holds. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e0dd5b6..2cee8a2 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,8 @@ report = validate_handoff( `validate_handoff` returns issues for invalid documents and raises `HandoffError` for invalid freshness options. The library accepts `max_age_hours` as a finite, -nonnegative integer or float; integers of any size are compared exactly, and +nonnegative integer or float; integers of any size are compared exactly, a float is compared exactly as the +binary value it holds (so `0.1` is very slightly more than one tenth), and booleans and nonfinite values are rejected. `load_handoff` raises `HandoffError` for input errors, including numbers such as `1e999` that overflow Python's finite floating-point range. Validation does not mutate the document. Parser errors identify a location From 7fee4106836953f15cb2f21e2625d584569b73d0 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Sat, 17 Oct 2026 07:47:30 -0400 Subject: [PATCH 08/11] Raise HandoffError, not TypeError, for a path that is not text load_handoff(None), bytes, a number or a path-like object returning bytes raised TypeError from pathlib; it now raises the documented HandoffError, as delivery-evidence-check does since r2. Inherited direct-library misuse noted by the r2 audit (not a severity finding). Co-Authored-By: Claude Opus 5.5 --- task_handoff_check/core.py | 5 ++++- tests/test_regressions.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/task_handoff_check/core.py b/task_handoff_check/core.py index 8ed0001..3564cb3 100644 --- a/task_handoff_check/core.py +++ b/task_handoff_check/core.py @@ -42,7 +42,10 @@ def _finite_float(value): def load_handoff(path): """Read one regular UTF-8 JSON file, up to 1 MiB. Reject final symlinks.""" - path = Path(path) + try: + path = Path(path) + except TypeError as exc: # None, bytes, a number: a documented error, not a TypeError + raise HandoffError('handoff path must be a string or path-like object') from exc if '\x00' in str(path): raise HandoffError('handoff path is invalid (contains a NUL character)') fd = None diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 01f9dda..68f0b3d 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -176,5 +176,18 @@ def test_thc1_readme_and_ci_matrix_agree_both_ways(self): self.assertIn('not executed by this change', readme) + +class WrongPathTypes(unittest.TestCase): + # r2 audit observation: load_handoff(None) raised TypeError instead of the documented HandoffError. + def test_a_path_that_is_not_text_or_path_like_is_a_handoff_error(self): + class BytesPath: + def __fspath__(self): + return b'handoff.json' + for bad in (None, b'handoff.json', 3, object(), BytesPath()): + with self.subTest(bad=type(bad).__name__): + with self.assertRaises(HandoffError): + load_handoff(bad) + + if __name__ == '__main__': unittest.main() From 7c5caad727a71c160d8e8943ea49b9c914ec6a3f Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Sat, 17 Oct 2026 07:47:35 -0400 Subject: [PATCH 09/11] Changelog for the path-type fix; version 0.1.1rc3 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- task_handoff_check/__init__.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd59a5a..636375a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -## Unreleased (0.1.1rc2 candidate, 2026-10-17; not published; 0.1.1rc1 of 2026-10-16 was the independently audited candidate) +## Unreleased (0.1.1rc3 candidate, 2026-10-17; not published; 0.1.1rc1 of 2026-10-16 and 0.1.1rc2 of 2026-10-17 were the independently audited candidates) +- A handoff path that is not a string or path-like object (`None`, bytes, a number) raises `HandoffError` instead of `TypeError` (r2 audit observation). - Reject exponent-overflow JSON numbers, including positive and negative `1e999`, with `HandoffError`. - Freshness options no longer raise an undocumented `OverflowError`: integers of any size are compared exactly (never converted to float), and every invalid option raises `HandoffError`. - Report JSON parse locations without repeating input contents or filenames. diff --git a/pyproject.toml b/pyproject.toml index 5251de6..cc46acb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "task-handoff-check" -version = "0.1.1rc2" +version = "0.1.1rc3" description = "Check structured task handoffs for missing and contradictory claims." readme = "README.md" requires-python = ">=3.10" diff --git a/task_handoff_check/__init__.py b/task_handoff_check/__init__.py index 55750b7..7d904e8 100644 --- a/task_handoff_check/__init__.py +++ b/task_handoff_check/__init__.py @@ -1,5 +1,5 @@ """Structural checks for explicit, human-readable task handoffs.""" from .core import HandoffError, load_handoff, validate_handoff -__version__ = '0.1.1rc2' +__version__ = '0.1.1rc3' __all__ = ['HandoffError', 'load_handoff', 'validate_handoff'] From af4624f0101d7ec8083846c8fba6bc747ff66f2f Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (ITC local repair workspace)" Date: Sat, 17 Oct 2026 08:41:44 -0400 Subject: [PATCH 10/11] Accurate messages for a directory or an unencodable handoff path kofi's review of r3 (minor): an empty path or a directory raised "must be a regular file, not a symlink", and a path with a lone surrogate raised "unsupported JSON value". Both are HandoffError still, with messages that say what is wrong. A path object whose own __fspath__ raises a ValueError still propagates that error, as before. Co-Authored-By: Claude Opus 5.5 --- task_handoff_check/core.py | 4 +++- tests/test_regressions.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/task_handoff_check/core.py b/task_handoff_check/core.py index 3564cb3..179e78e 100644 --- a/task_handoff_check/core.py +++ b/task_handoff_check/core.py @@ -51,7 +51,7 @@ def load_handoff(path): fd = None try: if not stat.S_ISREG(path.lstat().st_mode): - raise HandoffError('handoff must be a regular file, not a symlink') + raise HandoffError('handoff must be a regular file (not a directory, symlink or device)') fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NONBLOCK', 0) | getattr(os, 'O_NOFOLLOW', 0)) if not stat.S_ISREG(os.fstat(fd).st_mode): raise HandoffError('handoff must be a regular file') @@ -67,6 +67,8 @@ def load_handoff(path): raise except UnicodeDecodeError as exc: raise HandoffError('handoff must contain valid UTF-8 JSON') from exc + except UnicodeEncodeError as exc: # only the path is ever encoded + raise HandoffError('handoff path is invalid (it cannot be encoded for this system)') from exc except json.JSONDecodeError as exc: raise HandoffError(f'invalid JSON at line {exc.lineno}, column {exc.colno}') from exc except RecursionError as exc: diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 68f0b3d..d8d6862 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -188,6 +188,15 @@ def __fspath__(self): with self.assertRaises(HandoffError): load_handoff(bad) + # kofi r3 review: an empty path or a directory named a symlink, and an unencodable path named a JSON value. + def test_a_directory_or_unencodable_path_gets_an_accurate_message(self): + with tempfile.TemporaryDirectory() as d: + for bad in ('', d): + with self.subTest(bad=bad), self.assertRaisesRegex(HandoffError, 'regular file .*directory'): + load_handoff(bad) + with self.assertRaisesRegex(HandoffError, 'path is invalid'): + load_handoff('handoff-\ud800.json') # not encodable even with surrogateescape + if __name__ == '__main__': unittest.main() From ae1651b39e202e6ad591d65e28a767666d523765 Mon Sep 17 00:00:00 2001 From: "Olufela \"Lu\" Fagbure" Date: Thu, 22 Oct 2026 00:15:41 -0400 Subject: [PATCH 11/11] Release 0.1.1 Promote the tested release candidate to the stable version. Only the version literals and the changelog heading change; the package payload is byte-identical to the audited candidate. --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- task_handoff_check/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 636375a..da16be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased (0.1.1rc3 candidate, 2026-10-17; not published; 0.1.1rc1 of 2026-10-16 and 0.1.1rc2 of 2026-10-17 were the independently audited candidates) +## 0.1.1 (2026-10-22) - A handoff path that is not a string or path-like object (`None`, bytes, a number) raises `HandoffError` instead of `TypeError` (r2 audit observation). - Reject exponent-overflow JSON numbers, including positive and negative `1e999`, with `HandoffError`. diff --git a/pyproject.toml b/pyproject.toml index cc46acb..65154c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "task-handoff-check" -version = "0.1.1rc3" +version = "0.1.1" description = "Check structured task handoffs for missing and contradictory claims." readme = "README.md" requires-python = ">=3.10" diff --git a/task_handoff_check/__init__.py b/task_handoff_check/__init__.py index 7d904e8..b7ce196 100644 --- a/task_handoff_check/__init__.py +++ b/task_handoff_check/__init__.py @@ -1,5 +1,5 @@ """Structural checks for explicit, human-readable task handoffs.""" from .core import HandoffError, load_handoff, validate_handoff -__version__ = '0.1.1rc3' +__version__ = '0.1.1' __all__ = ['HandoffError', 'load_handoff', 'validate_handoff']