From f0a00cc491ec6be4a208022003dc2b38d3d12647 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:01:42 +0000 Subject: [PATCH 1/6] Prepare pinned format readiness probes, boundary inventory and JSON corpus Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/format-readiness.yml | 75 ++++++++++ .gitignore | 3 +- config/format-tools.toml | 16 +++ docs/format-migration.md | 143 +++++++++++++++++++ docs/ijson-jcs.md | 6 +- scripts/formats/conformance.py | 87 ++++++++++++ scripts/formats/inventory.py | 98 +++++++++++++ scripts/formats/probe_kyq.py | 54 ++++++++ scripts/formats/process.py | 100 +++++++++++++ scripts/tests/test_format_readiness.py | 154 ++++++++++++++++++++ test/fixtures/formats/ijson-jcs.toml | 185 +++++++++++++++++++++++++ 11 files changed, 919 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/format-readiness.yml create mode 100644 config/format-tools.toml create mode 100644 docs/format-migration.md create mode 100644 scripts/formats/conformance.py create mode 100644 scripts/formats/inventory.py create mode 100644 scripts/formats/probe_kyq.py create mode 100644 scripts/formats/process.py create mode 100644 scripts/tests/test_format_readiness.py create mode 100644 test/fixtures/formats/ijson-jcs.toml diff --git a/.github/workflows/format-readiness.yml b/.github/workflows/format-readiness.yml new file mode 100644 index 00000000..17df3f82 --- /dev/null +++ b/.github/workflows/format-readiness.yml @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: MPL-2.0 +# Preparatory evidence only: this does not approve kyq or migrate any runtime IO. +name: Format readiness evidence + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + reference: + name: Format harness and pinned JSON reference + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test harness failure boundaries + env: + PYTHONDONTWRITEBYTECODE: '1' + run: python3 -m unittest discover -s scripts/tests -p 'test_format*.py' -v + - name: Inventory candidates without declaring compliance + run: python3 scripts/formats/inventory.py > "$RUNNER_TEMP/format-boundaries.tsv" + - name: Read immutable JSON reference pins + id: pins + run: | + python3 - <<'PY' + import os, re, tomllib + with open('config/format-tools.toml', 'rb') as stream: + pin = tomllib.load(stream)['ijson_jcs'] + if pin['repository'] != 'https://github.com/hyperpolymath/ijson-jcs': + raise SystemExit('unexpected reference repository') + if not re.fullmatch(r'[0-9a-f]{40}', pin['revision']): + raise SystemExit('reference must be an immutable commit') + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', pin['rust_toolchain']): + raise SystemExit('Rust must be an explicit release') + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: + stream.write(f"revision={pin['revision']}\nrust={pin['rust_toolchain']}\n") + PY + - name: Checkout pinned reference source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/ijson-jcs + ref: ${{ steps.pins.outputs.revision }} + path: .format-reference + persist-credentials: false + - name: Build the real reference CLI with locked dependencies + env: + REFERENCE_REVISION: ${{ steps.pins.outputs.revision }} + RUST_VERSION: ${{ steps.pins.outputs.rust }} + run: | + set -euo pipefail + test "$(git -C .format-reference rev-parse HEAD)" = "$REFERENCE_REVISION" + rustup toolchain install "$RUST_VERSION" --profile minimal + cargo "+${RUST_VERSION}" install --path .format-reference --locked --root "$RUNNER_TEMP/format-reference" + - name: Exercise original bytes against the installed reference + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 scripts/formats/conformance.py --tool "$RUNNER_TEMP/format-reference/bin/ijson-jcs" + { + echo '## Format migration evidence' + echo 'Pinned ijson-jcs raw-byte corpus passed. This is not application-wide conformance.' + echo 'kyq compilation, installed CLI conformance, KYAML comment preservation and runtime migration remain blocked/unverified.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index dcc294bf..181a720a 100644 --- a/.gitignore +++ b/.gitignore @@ -247,10 +247,11 @@ vignettes/*.pdf # R Environment Variables .Renviron -## Documentation kept local except published release notes and cladistics notes +## Documentation kept local except published release, cladistics and format notes docs/* !docs/release-notes/ !docs/cladistics/ +!docs/format-migration.md # translation temp files diff --git a/config/format-tools.toml b/config/format-tools.toml new file mode 100644 index 00000000..11f3efcb --- /dev/null +++ b/config/format-tools.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Source identities, not claims of runtime conformance. See docs/format-migration.md. +[ijson_jcs] +repository = "https://github.com/hyperpolymath/ijson-jcs" +revision = "3a9a4ee326a968a32b2f152b4faec243a2d00765" +rust_toolchain = "1.85.1" + +[kyq] +repository = "https://github.com/metadatastician/kyq" +revision = "d448abd1f6534abf703db8a89e3d2247e16ee179" +just_version = "1.56.0" + +[affinescript] +repository = "https://github.com/hyperpolymath/affinescript" +revision = "26cdb43e141a5fdce5408e00b8baca7df6862cb8" +version = "0.1.1" diff --git a/docs/format-migration.md b/docs/format-migration.md new file mode 100644 index 00000000..87d61f2c --- /dev/null +++ b/docs/format-migration.md @@ -0,0 +1,143 @@ + +# JSON/KYAML migration: staged evidence and blockers + +## Status and order + +This is preparation, not completed adoption. No application parser, persisted +record, configuration file or workflow dialect is migrated by this change. + +| Stage | State | Exit evidence required | +| --- | --- | --- | +| 1. Working kyq | **BLOCKED** | Real compiler checks including imports; executable build/tests; native/WASM IO; isolated-prefix installation | +| 2. Conformance corpus | JSON raw-byte characterization harness prepared; reference execution delegated to CI; KYAML/runtime equivalence not verified | Installed-artifact tests, differential Julia/JS tests, pinned KYAML profile, comment-preservation/mutation proof, resource limits | +| 3. Shared adapters and structural gates | Not started | Explicit schemas/error mapping; replace workflow regex decisions with parser queries | +| 4. Application migration | Not started | Boundary-by-boundary tests; precision, data and old-ID compatibility | +| 5. Workflow conversion | Not started | Comment association/idempotence; actionlint, bot compatibility, real hosted execution | + +Do not treat a passing preparation workflow as permission to skip stage 1. +Kubernetes lint/schema validation is an optional layer, not a prerequisite for +parsing an ordinary MetaManifold configuration document. + +## Reproduced upstream blocker (2026-10-23) + +Source identities are recorded in `config/format-tools.toml`: + +- kyq `d448abd1f6534abf703db8a89e3d2247e16ee179`. +- AffineScript **0.1.1**, source `26cdb43e141a5fdce5408e00b8baca7df6862cb8`. +- ijson-jcs `3a9a4ee326a968a32b2f152b4faec243a2d00765`. +- Source-probe runner `just 1.56.0`. + +On clean kyq source, `just build`, `just wasm`, `just test` and `just install` +each exited **1**, reporting that the work is not implemented. This reproduces +the recipe fencing, **not** the compiler's syntax diagnosis. The available +v0.2.0 AffineScript release has no binary assets. Downloading the v0.1.1 binary +and checksum failed because the sandbox cannot reach GitHub's release-asset +host. OCaml/opam/dune are not installed. Compiler checks and runtime conformance +are **NOT RUN**; no compiler or placeholder artifact was installed. + +The source probe intentionally does **not** run installation: an isolated-prefix +contract has not been established. The manually attempted install recipe above +failed without installing anything. Clone/build dependencies outside the +application checkout; do not vendor upstream source or tool binaries here. + +Upstream implementation and acceptance criteria are tracked in +[kyq issue #11](https://github.com/metadatastician/kyq/issues/11). Fixing the +AffineScript implementation belongs there; this repository does not silently +replace it with another language or publish another tool under the kyq name. + +Reproduce using a clean clone at the pinned commit: + +```sh +python3 scripts/formats/probe_kyq.py --source /path/to/kyq --just /path/to/just +``` + +Probe exit codes: 0 means only the three source recipes returned success; +1 means a recipe failed; 2 means a missing tool, wrong pin, dirty source or +infrastructure failure. Even 0 does not establish parser/CLI/installation +conformance. The tool prints those missing obligations explicitly. + +## Repository boundary coverage matrix + +Generate locations without printing source lines or study/credential values: + +```sh +python3 scripts/formats/inventory.py > /tmp/format-boundaries.tsv +``` + +The inventory includes tracked and non-ignored new files, hidden workflows and +lockfiles. It does not follow symlinks. Its regex hits are **lexical candidates**, +including possible comments/test strings; aliases and dynamic dispatch need +manual inspection. Routing buckets are not ownership decisions or conformance +verdicts. Do not turn this search into a regex-based YAML gate. Generated, +ignored and external files are outside this census, not automatically compliant. + +| Boundary / paths | Classification and current evidence | Migration obligation | +| --- | --- | --- | +| `src/canonical.jl` | First-party strict-input/JCS adapter, its own fixtures; Rust equivalence not established locally | Compare raw-input acceptance and exact bytes against pinned reference; bound depth/size; characterize numeric policy | +| `src/server/server.jl`: `_request_json`, `_read_json_object`, `_request_source` | First-party input; ordinary JSON3 parsing remains, including body inspection outside the route parser | Parse/validate original bytes once; preserve body/empty-body contracts; stable 400/413/422 behavior; do not classify internal faults as client errors | +| `src/server/routes/validated_statistics.jl` | Strict `ijson_read` plus separate exact-count token checks on input; `_statistics_json` still uses ordinary JSON3 output | Preserve integral-count lexical policy and decimal strings; migrate output only after number-field audit | +| Other `src/server/routes/*.jl` | Mixed request parsing, Oxygen responses, persisted documents and imported third-party data | Classify each operation, not only the module; explicit schemas and semantic tests | +| `frontend/src/api/client.ts`, `ijsonJcs.ts` | Canonical first-party request output; responses still use `res.json()` | Validate response text before duplicate keys/numeric tokens are lost; include error/config responses and 204/empty cases | +| `frontend/src/tree/model.ts`, figure/charts and component JSON | Mixed jplace/Plotly, display/cache keys and first-party state | Separate opaque imports, display-only JSON and actual serialization boundaries; preserve array order and external contracts | +| `src/core/doi.jl`, `epistemic.jl`, `provenance.jl` | Legacy identity-sensitive receipts/hashes and YAML provenance | Version encoding changes, retain old verification, never silently recompute old IDs | +| `src/analysis/derived_analyses.jl`, figure/view/report storage | First-party canonicalized storage at selected boundaries | Compare reference bytes; preserve saved-record read compatibility and corruption diagnostics | +| `src/server/jobs.jl`, `src/pipeline/phylogeny*`, QC files | First-party generated JSON/state, some permissive reads | Define schemas, valid numeric/missing-value policy, bounded reads and atomic writes | +| `src/core/config.jl`, `*_library.jl`, `databases.jl`, pipeline YAML calls | Application-owned YAML with config cascades and generated run configs | KYAML emission/validation, legacy YAML import policy, null/type distinctions, deterministic hashing and file discovery | +| `config/**`, sample `data/**`, generated user/study/run configs | First-party configuration plus user-owned legacy material | Review ownership before rewriting; extension support, safe migration/backups, old paths and merge precedence | +| `.github/**`, `codecov.yml` | Workflow/service-consumed YAML; mixed human/bot ownership | Structural readers first; preserve permissions, pin comments, multiline shell and expressions; retain required consumer extensions | +| `scripts/check-governance-contract.sh` and its mutations | Line-oriented workflow assertions; tests assume block YAML | Query parsed structure; block/flow parity, run-body/comment decoys, duplicate-key and missing-parser mutations | +| `bench/**`, `scripts/*.jl`, `docs/occupancy/*.yml` | Benchmarks, baselines, references and tooling YAML/JSON | Preserve independent oracles and measured baselines; errors must name files without silently substituting empty results | +| `test/**` | Positive/negative fixtures and independent reference readers | Preserve invalid raw fixtures; don't canonicalize away the defect a test is meant to exercise | +| `renv.lock`, `renv/settings.json`, `frontend/package.json`, `bun.lock`, `tsconfig.json` | External-tool-owned document contracts | Verify consumer support; do not mass-rewrite or claim arbitrary exact numbers are portable | +| `.gitbot-fleet/**`, K9 reports, CI artifacts | First-party governance evidence with external renderer involvement | Audit actual renderer bytes and schema; no general jq-as-JCS fallback without a constrained, tested equivalence claim | +| Unclassified hits/new files | **Untriaged**, not exempt | Add explicit ownership/schema/compatibility decisions during the relevant implementation stage | + +"Throughout the repository" includes the runtime, generated files, installers, +examples and CI—not just checked-in extensions. Third-party data and invalid test +fixtures stay explicit exceptions, never hidden automatic passes. + +## Raw-byte JSON characterization + +The 34-case corpus is TOML so it can carry invalid JSON tokens and hex-encoded +invalid UTF-8 without a JSON loader normalizing the evidence. It covers decoded +duplicate keys, nested duplicates, UTF-16 sorting, surrogate pairs/noncharacters, +escaped and raw controls, integer boundaries, beyond-uint64 counts, exponent +thresholds, negative zero, array order, trailing input and truncation. + +```sh +python3 scripts/formats/conformance.py --tool /path/to/ijson-jcs +python3 -m unittest discover -s scripts/tests -p 'test_format*.py' -v +``` + +The protocol is the existing ijson-jcs `canon`: original bytes on stdin, exact +canonical bytes with no newline on stdout, exit 0 for valid input, exit 2 and +empty stdout for invalid input. Crashes, arbitrary nonzero exits, timeouts, +missing tools, changed byte order and extra newlines cannot pass. This runner +implements no parser/canonicalizer of its own. A future kyq adapter may implement +this protocol; it is **not** a claim that kyq has a `canon` command today. + +The subprocess runner limits input/output bytes and wall time, keeps diagnostics +out of logs and kills the process group on completion/failure. It currently +supports POSIX hosts. These harness budgets are **not** parser resource limits +in the application. Fake subprocesses in unit tests test the harness only. + +The preparation workflow builds the real pinned Rust CLI with locked dependencies +and an explicit Rust release, then runs the corpus. Local Rust execution is not +claimed (Rust/cargo are absent). Failures remain failures; there is no permissive +parser or sorted-jq fallback. Exit 0 means corpus compatibility at this pin, not +complete RFC coverage, safe installation of kyq or application-wide adoption. + +## Still unresolved before stage 3 + +- Reconcile Julia/JS/reference acceptance of integer-valued binary64 numbers, + including `1e21`, and representation changes such as `1e20` producing an + integer-looking canonical token. The initial one-pass corpus is not a proof + that every reference output can be reparsed under every adapter policy. +- Add installed-artifact Julia/JS differential tests, number-roundtrip/closure + tests, fuzz/property tests, underflow/boundary cases and large scientific data. +- Pin the exact KYAML profile and distinguish it from merely valid YAML or a + formatter's dialect. Prove comment association and idempotence with mutants. +- Establish isolated install, actual WASM/native IO, tool capability reporting, + artifact verification, parser limits and fail-closed dependency handling. +- Only then change application adapters, followed by application files and + finally workflows. Preserve existing licensing, scientific results and IDs. diff --git a/docs/ijson-jcs.md b/docs/ijson-jcs.md index 6ee08e2c..ace363b9 100644 --- a/docs/ijson-jcs.md +++ b/docs/ijson-jcs.md @@ -5,7 +5,7 @@ The reference is [hyperpolymath/ijson-jcs](https://github.com/hyperpolymath/ijso ## First-party boundary implemented here - Julia `MetaManifold.Canonical.ijson_read(raw)` validates UTF-8, repeated object keys (including escaped aliases at every depth), lone surrogates/noncharacters, non-finite numbers and integer tokens outside ±(2^53−1) **on the raw text**. It returns ordinary JSON3 values. `jcs_bytes` validates emitted values (including noncharacters) and sorts keys by UTF-16 code units, formats ECMAScript numbers and emits no newline. The existing `jcs_sha256` hashes those bytes. -- Validated-statistics request bodies and their responses, error responses, derived-analysis records, figure documents, tree **view-state sidecars**, and report basket indices use those helpers. Stored derived analyses must be canonical on read; existing correctly persisted JCS records are unchanged. Invalid first-party requests get 400 (the exact-count endpoint still rejects floating *count tokens* with 422). +- Validated-statistics request bodies use the strict input helper. Error responses, derived-analysis records, figure documents, tree **view-state sidecars**, and report basket indices use the canonical helpers. Successful validated-statistics responses still pass through `_statistics_json`/`JSON3.write`; those outputs are **not yet JCS-migrated**. Stored derived analyses must be canonical on read; existing correctly persisted JCS records are unchanged. Invalid first-party requests get 400 (the exact-count endpoint still rejects floating *count tokens* with 422). - The frontend's first-party API POST/PATCH/PUT payloads use `ijsonJcsStringify`, which sorts object keys by UTF-16, preserves array order and refuses unsafe JS numbers, non-finite values, lone surrogates, noncharacters and sparse arrays. Undefined optional object properties are omitted as before. Exact large counts remain decimal **strings**. `frontend/public/config.json` is canonical (plus the optional one-file newline). Use the upstream `ijson-jcs check frontend/public/config.json` to verify the checked-in config if the Rust CLI is installed. The Julia writer has its own JCS fixture tests, but cross-language equivalence with the Rust binary has **not** been independently verified here. @@ -15,3 +15,7 @@ Use the upstream `ijson-jcs check frontend/public/config.json` to verify the che Other Oxygen `json(...)` route responses, legacy JSON3 inputs/outputs, pipeline job/QC JSON, DOI receipts and epistemic receipt hashes still use their old serialization. The latter two are part of existing checksums/IDs: switching their byte encoding without an explicit versioned migration would make previously stored receipts unverifiable. External JSON (notably jplace, Plotly, renv metadata and imported report `.json` files) must not be rewritten just because its name ends in `.json`; the owning format and compatibility contract take precedence. Existing figure/view/index documents with merely different whitespace or key order still read; they are written canonically on their next save. Documents with duplicate keys or unsafe values need explicit migration. Follow-up: audit all remaining first-party route JSON with representative large result sets and tests; decide whether oversized legacy numeric fields become strings and how existing persisted records migrate, then replace their serializers and add an upstream CLI interoperability check to CI. Do **not** automatically JCS-encode opaque upstream/third-party formats or change an established content hash without versioning. + +## Staged follow-up + +See [format-migration.md](format-migration.md) for pinned tool identities, the reproducible kyq blocker, boundary coverage matrix and raw-byte reference harness. No application-wide or kyq runtime conformance is inferred from preparation tests. diff --git a/scripts/formats/conformance.py b/scripts/formats/conformance.py new file mode 100644 index 00000000..e87afbc8 --- /dev/null +++ b/scripts/formats/conformance.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Characterize an installed ijson-jcs-compatible `canon` adapter. + +Tests original bytes, exact stdout and documented exit codes. No parsing or +canonicalization is implemented in Python. Exit 0 = corpus passed, 1 = mismatch, +2 = infrastructure/configuration failure. This is NOT kyq readiness approval. +""" +import argparse +from pathlib import Path +import re +import sys +import tomllib + +from process import ToolError, run + +ROOT = Path(__file__).resolve().parents[2] + + +def load_cases(path): + with Path(path).open("rb") as stream: + document = tomllib.load(stream) + if document.get("schema") != 1 or set(document) != {"schema", "case"}: + raise ValueError("unsupported corpus schema") + cases = document["case"] + if not isinstance(cases, list) or not cases: + raise ValueError("empty or invalid corpus") + seen = set() + for case in cases: + if not isinstance(case, dict): + raise ValueError("case must be a table") + allowed = {"id", "input", "hex", "valid", "canonical"} + if set(case) - allowed: + raise ValueError("unknown case field") + name = case.get("id", "") + if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9-]+", name) or name in seen: + raise ValueError("invalid or duplicate case id") + seen.add(name) + if type(case.get("valid")) is not bool or (("input" in case) == ("hex" in case)): + raise ValueError("case needs an explicit verdict and exactly one input") + if case["valid"] != ("canonical" in case): + raise ValueError("only valid cases must have expected canonical bytes") + raw = case.get("input", case.get("hex")) + if not isinstance(raw, str): + raise ValueError("input must be a string or hex string") + data = case["input"].encode("utf-8") if "input" in case else bytes.fromhex(case["hex"]) + if case["valid"] and not isinstance(case["canonical"], str): + raise ValueError("canonical bytes must be a UTF-8 string") + expected = case["canonical"].encode("utf-8") if case["valid"] else b"" + if len(data) > 131072 or len(expected) > 131072: + raise ValueError("case exceeds the corpus budget") + yield name, data, case["valid"], expected + + +def check(executable, cases, *, timeout=5.0): + passed = True + for name, data, valid, expected in cases: + result = run([executable, "canon"], data, timeout=timeout) + # Rejection must be the documented invalid-input exit, not a crash, + # missing library, usage failure from a wrapper, or arbitrary nonzero. + wanted = 0 if valid else 2 + ok = result.code == wanted and result.stdout == expected + print(f"{'PASS' if ok else 'FAIL'} {name}") + passed = passed and ok + return passed + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tool", required=True, help="installed binary or canon-protocol adapter") + parser.add_argument("--corpus", type=Path, default=ROOT / "test/fixtures/formats/ijson-jcs.toml") + parser.add_argument("--timeout", type=float, default=5.0) + args = parser.parse_args(argv) + try: + # Validate the entire corpus before invoking the first child. + cases = list(load_cases(args.corpus)) + ok = check(args.tool, cases, timeout=args.timeout) + print(f"Corpus: {len(cases)} cases; runtime migration remains separately gated") + return 0 if ok else 1 + except (ToolError, ValueError, TypeError, OSError) as error: + # Do not echo paths, source text, child stderr or exception values. + print(f"INFRASTRUCTURE: {type(error).__name__}; no conformance pass", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/formats/inventory.py b/scripts/formats/inventory.py new file mode 100644 index 00000000..a5dc339d --- /dev/null +++ b/scripts/formats/inventory.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Inventory lexical JSON/YAML candidates; NEVER a compliance verdict. + +Includes tracked and non-ignored new files, including hidden workflow/hooks. +Search hits can be comments/test strings. Aliases/dynamic dispatch require human +review. Prints locations/operation names, not source lines or document contents. +""" +import argparse +import csv +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +CODE = {".jl", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".R", ".r", ".rs"} +DATA = {".json", ".jsonl", ".yaml", ".yml", ".kyaml"} +PATTERNS = { + "JSON3.read": r"\bJSON3\.read\s*\(", + "JSON3.write": r"\bJSON3\.write\s*\(", + "YAML IO": r"\bYAML\.(?:load_file|load|write_file|write)\s*\(", + "strict/JCS adapter": r"\b(?:ijson_read|jcs_bytes|jcs_sha256|ijsonJcsStringify|_request_json)\s*\(", + "JSON.parse": r"\bJSON\.parse\s*\(", + "JSON.stringify": r"\bJSON\.stringify\s*\(", + "response.json candidate": r"\.json\s*\(", + "Python JSON IO": r"\bjson\.(?:load|loads|dump|dumps)\s*\(", + "Oxygen JSON candidate": r"(? limit: + raise ToolError("invalid process budget or oversized test input") + if os.name != "posix": + raise ToolError("this probe currently supports POSIX pipe IO only") + try: + child = subprocess.Popen( + argv, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, start_new_session=True, + ) + except OSError as error: + raise ToolError("tool could not be started") from error + buffers = {"stdout": bytearray(), "stderr": bytearray()} + deadline = time.monotonic() + timeout + offset = 0 + total = 0 + try: + with selectors.DefaultSelector() as selector: + for stream, label in ((child.stdout, "stdout"), (child.stderr, "stderr")): + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ, label) + if data: + os.set_blocking(child.stdin.fileno(), False) + selector.register(child.stdin, selectors.EVENT_WRITE, "stdin") + else: + child.stdin.close() + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ToolError("tool exceeded its time budget") + for key, _ in selector.select(remaining): + stream = key.fileobj + if key.data == "stdin": + try: + offset += os.write(stream.fileno(), data[offset:offset + 16384]) + except BrokenPipeError: + offset = len(data) + except BlockingIOError: + continue + if offset == len(data): + selector.unregister(stream) + stream.close() + else: + try: + chunk = os.read(stream.fileno(), 16384) + except BlockingIOError: + continue + if not chunk: + selector.unregister(stream) + stream.close() + continue + total += len(chunk) + if total > limit: + raise ToolError("tool exceeded its output budget") + buffers[key.data].extend(chunk) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ToolError("tool exceeded its time budget") + try: + code = child.wait(timeout=remaining) + except subprocess.TimeoutExpired as error: + raise ToolError("tool exceeded its time budget") from error + return Result(code, bytes(buffers["stdout"]), bytes(buffers["stderr"])) + finally: + # Kill the session even if its leader exited but descendants kept a pipe + # open. A hung descendant must not outlive a timed-out probe. + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + child.wait() + for stream in (child.stdin, child.stdout, child.stderr): + if not stream.closed: + stream.close() diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py new file mode 100644 index 00000000..5e046227 --- /dev/null +++ b/scripts/tests/test_format_readiness.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Tests of the harness itself; fake children are NOT parser conformance evidence.""" +import contextlib +import io +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts/formats")) +from conformance import check, load_cases, main as conformance_main +from inventory import inventory +from process import ToolError, run +from probe_kyq import main as probe_main + + +class ProcessTests(unittest.TestCase): + def test_round_trip_binary_io(self): + raw = bytes(range(256)) * 200 + result = run([sys.executable, "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"], raw) + self.assertEqual((result.code, result.stdout, result.stderr), (0, raw, b"")) + + def test_stderr_separate_and_exit_preserved(self): + result = run([sys.executable, "-c", "import sys; sys.stderr.write('private'); sys.exit(7)"]) + self.assertEqual((result.code, result.stdout, result.stderr), (7, b"", b"private")) + + def test_missing_binary_is_infrastructure_not_document_rejection(self): + with self.assertRaises(ToolError): + run(["/nonexistent/format-tool"]) + + def test_timeout(self): + with self.assertRaisesRegex(ToolError, "time budget"): + run([sys.executable, "-c", "import time; time.sleep(10)"], timeout=0.15) + + def test_output_budget_covers_both_streams(self): + with self.assertRaisesRegex(ToolError, "output budget"): + run([sys.executable, "-c", "import sys; sys.stderr.write('x' * 10000)"], limit=100) + + def test_input_and_deadline_budgets(self): + for kwargs in ({"timeout": 0}, {"timeout": float("nan")}, {"timeout": float("inf")}, {"limit": 0}, {"data": b"xx", "limit": 1}): + with self.assertRaises(ToolError): + run([sys.executable, "-c", "pass"], **kwargs) + + def test_descendant_keeping_pipe_open_is_bounded(self): + with self.assertRaisesRegex(ToolError, "time budget"): + run([sys.executable, "-c", "import os,time; p=os.fork(); time.sleep(10) if p==0 else None"], timeout=0.2) + + +class CorpusTests(unittest.TestCase): + def test_raw_fixtures_survive_loading(self): + cases = list(load_cases(ROOT / "test/fixtures/formats/ijson-jcs.toml")) + self.assertGreaterEqual(len(cases), 30) + by_id = {c[0]: c for c in cases} + self.assertEqual(by_id["invalid-utf8"][1], b'["\xff"]') + self.assertEqual(by_id["escaped-equivalent-key"][1], b'{"a":1,"\\u0061":2}') + self.assertEqual(by_id["exponent-thresholds"][3], b'[0.000001,1e-7,1e+21]') + + def test_bad_and_ambiguous_corpora_fail_before_running_tools(self): + for contents in ( + "schema=2\ncase=[]\n", "schema=1\ncase=[]\n", + "schema=1\n[[case]]\nid='a'\ninput='{}'\nvalid=true\n", + "schema=1\n[[case]]\nid='a'\ninput='{}'\nhex='ff'\nvalid=false\n", + "schema=1\n[[case]]\nid='a'\ninput='{}'\nvalid='false'\n", + "schema=1\n[[case]]\nid='a'\ninput='{}'\nvalid=false\nunknown=1\n", + "schema=1\n[[case]]\nid='a'\nhex='not-hex'\nvalid=false\n", + ): + with self.subTest(contents=contents), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "cases.toml" + path.write_text(contents) + with self.assertRaises((ValueError, TypeError)): + list(load_cases(path)) + + def test_duplicate_ids_fail(self): + case = "\n[[case]]\nid='a'\ninput='{}'\nvalid=false\n" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "cases.toml" + path.write_text("schema=1\n" + case * 2) + with self.assertRaises(ValueError): + list(load_cases(path)) + + def fake(self, directory, body): + path = Path(directory) / "tool with spaces" + path.write_text(f"#!{sys.executable}\n{body}\n") + path.chmod(0o700) + return str(path) + + def test_mutants_cannot_pass_as_canonicalizers(self): + cases = [("valid", b' {"a":1} ', True, b'{"a":1}'), + ("invalid", b'{"a":1,"a":2}', False, b'')] + # Always-success, always-invalid, crashes, and passthrough all fail. + for body in ("raise SystemExit(0)", "raise SystemExit(2)", + "raise SystemExit(1)", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"): + with self.subTest(body=body), tempfile.TemporaryDirectory() as tmp: + with contextlib.redirect_stdout(io.StringIO()): + self.assertFalse(check(self.fake(tmp, body), cases)) + + def test_correct_adapter_protocol_passes_and_redacts_diagnostics(self): + cases = [("valid", b"true", True, b"true"), ("invalid", b"broken", False, b"")] + body = """import sys +raw = sys.stdin.buffer.read() +if raw == b'true': + sys.stdout.buffer.write(raw) +else: + sys.stderr.write('private input') + raise SystemExit(2) +""" + with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(io.StringIO()) as out: + self.assertTrue(check(self.fake(tmp, body), cases)) + self.assertNotIn("private input", out.getvalue()) + + def test_exact_bytes_no_newline_or_wrong_order(self): + cases = [("order", b'{}', True, b'{"a":1,"z":2}')] + for output in (b'{"z":2,"a":1}', b'{"a":1,"z":2}\n'): + with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(io.StringIO()): + executable = self.fake(tmp, f"import sys; sys.stdout.buffer.write({output!r})") + self.assertFalse(check(executable, cases)) + + def test_missing_tool_cli_fails_closed_without_echoing_input(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = conformance_main(["--tool", "/nonexistent/format-tool"]) + self.assertEqual(code, 2) + self.assertIn("no conformance pass", err.getvalue()) + self.assertNotIn("9007199254740992", out.getvalue() + err.getvalue()) + + def test_kyq_probe_missing_source_is_not_a_pass(self): + with contextlib.redirect_stderr(io.StringIO()): + code = probe_main(["--source", "/nonexistent/kyq"]) + self.assertEqual(code, 2) + + +class InventoryTests(unittest.TestCase): + def test_inventory_covers_hidden_new_files_and_never_echoes_source(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + subprocess.run(["git", "init", "-q", tmp], check=True) + (root / ".github/workflows").mkdir(parents=True) + (root / ".github/workflows/probe.yml").write_text('name: "private-value"\n') + (root / "client.ts").write_text('res.json();\nJSON.parse("private-value")\n') + (root / "foreign.json").symlink_to("/etc/passwd") + (root / ".gitignore").write_text('ignored.py\n') + (root / "ignored.py").write_text('json.loads("ignore")') + rows = list(inventory(root)) + self.assertTrue(any(r[2] == ".github/workflows/probe.yml" for r in rows)) + self.assertTrue(any(r[0] == "symlink" and r[2] == "foreign.json" for r in rows)) + self.assertTrue(any(r[3] == 2 and r[4] == "JSON.parse" for r in rows)) + self.assertFalse(any(r[2] == "ignored.py" for r in rows)) + self.assertNotIn("private-value", repr(rows)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/fixtures/formats/ijson-jcs.toml b/test/fixtures/formats/ijson-jcs.toml new file mode 100644 index 00000000..0184f096 --- /dev/null +++ b/test/fixtures/formats/ijson-jcs.toml @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: MPL-2.0 +# Raw-text characterization of the pinned ijson-jcs `canon` protocol. +# Deliberately not a JSON file: malformed bytes/tokens must survive fixture loading. +schema = 1 + +[[case]] +id = "object-order" +input = '{"z":0,"a":1}' +valid = true +canonical = '{"a":1,"z":0}' + +[[case]] +id = "nested-order-array-preserved" +input = '{"b":[3,1,2],"a":{"z":false,"a":null}}' +valid = true +canonical = '{"a":{"a":null,"z":false},"b":[3,1,2]}' + +[[case]] +id = "utf16-not-utf8-order" +input = '{"A":1,"😀":2}' +valid = true +canonical = '{"😀":2,"A":1}' + +[[case]] +id = "surrogate-pair" +input = '{"s":"\ud83d\ude00"}' +valid = true +canonical = '{"s":"😀"}' + +[[case]] +id = "escaped-controls-are-valid" +input = '{"s":"\u0001\t\n\r"}' +valid = true +canonical = '{"s":"\u0001\t\n\r"}' + +[[case]] +id = "negative-zero" +input = '[-0.0,0.0]' +valid = true +canonical = '[0,0]' + +[[case]] +id = "exponent-thresholds" +input = '[1e-6,1e-7,1e21]' +valid = true +canonical = '[0.000001,1e-7,1e+21]' + +[[case]] +id = "integer-bounds" +input = '[-9007199254740991,9007199254740991]' +valid = true +canonical = '[-9007199254740991,9007199254740991]' + +[[case]] +id = "exact-large-count-string" +input = '{"count":"18446744073709551617"}' +valid = true +canonical = '{"count":"18446744073709551617"}' + +[[case]] +id = "scalar-is-format-valid" +input = 'true' +valid = true +canonical = 'true' + +[[case]] +id = "insignificant-whitespace" +input = " \t[1]\r\n" +valid = true +canonical = '[1]' + +[[case]] +id = "duplicate-key" +input = '{"a":1,"a":2}' +valid = false + +[[case]] +id = "escaped-equivalent-key" +input = '{"a":1,"\u0061":2}' +valid = false + +[[case]] +id = "nested-duplicate" +input = '{"a":[{"b":1,"b":2}]}' +valid = false + +[[case]] +id = "unsafe-positive-integer" +input = '[9007199254740992]' +valid = false + +[[case]] +id = "unsafe-negative-integer" +input = '[-9007199254740992]' +valid = false + +[[case]] +id = "integer-beyond-uint64" +input = '{"n":18446744073709551617}' +valid = false + +[[case]] +id = "overflow" +input = '[1e400]' +valid = false + +[[case]] +id = "nan" +input = '[NaN]' +valid = false + +[[case]] +id = "infinity" +input = '[Infinity]' +valid = false + +[[case]] +id = "lone-high-surrogate" +input = '["\ud800"]' +valid = false + +[[case]] +id = "lone-low-surrogate" +input = '["\udc00"]' +valid = false + +[[case]] +id = "noncharacter-value" +input = '["\ufdd0"]' +valid = false + +[[case]] +id = "noncharacter-key" +input = '{"\uffff":0}' +valid = false + +[[case]] +id = "invalid-utf8" +hex = '5b22ff225d' +valid = false + +[[case]] +id = "utf8-encoded-surrogate" +hex = '5b22eda080225d' +valid = false + +[[case]] +id = "raw-control" +hex = '5b2201225d' +valid = false + +[[case]] +id = "leading-zero" +input = '[01]' +valid = false + +[[case]] +id = "incomplete-number" +input = '[1.]' +valid = false + +[[case]] +id = "trailing-comma" +input = '[1,]' +valid = false + +[[case]] +id = "trailing-junk" +input = '[1] garbage' +valid = false + +[[case]] +id = "concatenated-documents" +input = '{"a":1}{"b":2}' +valid = false + +[[case]] +id = "truncated-body" +input = '{"a":' +valid = false + +[[case]] +id = "empty-body" +input = '' +valid = false From c923c7c74586ecad68d869f9c20394e096d0e0ed Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:06:05 +0000 Subject: [PATCH 2/6] Constrain probe command protocols and address Sonar security review Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/format-migration.md | 16 ++- scripts/formats/conformance.py | 68 ++++++---- scripts/formats/inventory.py | 48 ++++--- scripts/formats/process.py | 176 ++++++++++++++++--------- scripts/tests/test_format_readiness.py | 39 ++++-- 5 files changed, 222 insertions(+), 125 deletions(-) diff --git a/docs/format-migration.md b/docs/format-migration.md index 87d61f2c..38fbf0ab 100644 --- a/docs/format-migration.md +++ b/docs/format-migration.md @@ -9,7 +9,7 @@ record, configuration file or workflow dialect is migrated by this change. | Stage | State | Exit evidence required | | --- | --- | --- | | 1. Working kyq | **BLOCKED** | Real compiler checks including imports; executable build/tests; native/WASM IO; isolated-prefix installation | -| 2. Conformance corpus | JSON raw-byte characterization harness prepared; reference execution delegated to CI; KYAML/runtime equivalence not verified | Installed-artifact tests, differential Julia/JS tests, pinned KYAML profile, comment-preservation/mutation proof, resource limits | +| 2. Conformance corpus | JSON raw-byte characterization harness prepared; initial pinned reference run passed; KYAML/runtime equivalence not verified | Installed-artifact tests, differential Julia/JS tests, pinned KYAML profile, comment-preservation/mutation proof, resource limits | | 3. Shared adapters and structural gates | Not started | Explicit schemas/error mapping; replace workflow regex decisions with parser queries | | 4. Application migration | Not started | Boundary-by-boundary tests; precision, data and old-ID compatibility | | 5. Workflow conversion | Not started | Comment association/idempotence; actionlint, bot compatibility, real hosted execution | @@ -116,14 +116,22 @@ missing tools, changed byte order and extra newlines cannot pass. This runner implements no parser/canonicalizer of its own. A future kyq adapter may implement this protocol; it is **not** a claim that kyq has a `canon` command today. -The subprocess runner limits input/output bytes and wall time, keeps diagnostics +The subprocess runner accepts only named installed tools (`ijson-jcs`, a future +`kyq-json-adapter`, `git`, `just`) with closed argument protocols. It rejects +arbitrary commands/flags and symlinks to unrelated interpreters. These are trusted +installed executables, not sandboxed plugins. Corpus paths are repository +constants/test fixtures, never CLI-supplied paths. The runner limits input/output +bytes and wall time, keeps diagnostics out of logs and kills the process group on completion/failure. It currently supports POSIX hosts. These harness budgets are **not** parser resource limits in the application. Fake subprocesses in unit tests test the harness only. The preparation workflow builds the real pinned Rust CLI with locked dependencies -and an explicit Rust release, then runs the corpus. Local Rust execution is not -claimed (Rust/cargo are absent). Failures remain failures; there is no permissive +and an explicit Rust release, then runs the corpus. The initial hosted run +[39999978128](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/runs/39999978128) +built the pinned CLI and passed all 34 cases at `f0a00cc`. Subsequent harness +changes must rerun that job. Local Rust execution is not claimed (Rust/cargo are +absent). Failures remain failures; there is no permissive parser or sorted-jq fallback. Exit 0 means corpus compatibility at this pin, not complete RFC coverage, safe installation of kyq or application-wide adoption. diff --git a/scripts/formats/conformance.py b/scripts/formats/conformance.py index e87afbc8..14099643 100644 --- a/scripts/formats/conformance.py +++ b/scripts/formats/conformance.py @@ -17,39 +17,54 @@ ROOT = Path(__file__).resolve().parents[2] +def _input(case): + if ("input" in case) == ("hex" in case): + raise ValueError("case needs exactly one input") + raw = case.get("input", case.get("hex")) + if not isinstance(raw, str): + raise ValueError("input must be a string or hex string") + return raw.encode("utf-8") if "input" in case else bytes.fromhex(raw) + + +def _case(case): + allowed = {"id", "input", "hex", "valid", "canonical"} + if not isinstance(case, dict) or set(case) - allowed: + raise ValueError("invalid case fields") + name = case.get("id", "") + if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9-]+", name): + raise ValueError("invalid case id") + if type(case.get("valid")) is not bool: + raise ValueError("case needs an explicit verdict") + if case["valid"] != ("canonical" in case): + raise ValueError("only valid cases must have canonical bytes") + expected = case.get("canonical", "") + if not isinstance(expected, str): + raise ValueError("canonical bytes must be a UTF-8 string") + data, encoded = _input(case), expected.encode("utf-8") + if len(data) > 131072 or len(encoded) > 131072: + raise ValueError("case exceeds the corpus budget") + return name, data, case["valid"], encoded + + def load_cases(path): + # Paths come from this repo's constant fixture location or test fixtures, + # never a CLI-supplied filesystem path. No arbitrary-file reader CLI. with Path(path).open("rb") as stream: document = tomllib.load(stream) - if document.get("schema") != 1 or set(document) != {"schema", "case"}: + if type(document.get("schema")) is not int or document["schema"] != 1: raise ValueError("unsupported corpus schema") + if set(document) != {"schema", "case"}: + raise ValueError("unsupported corpus fields") cases = document["case"] if not isinstance(cases, list) or not cases: raise ValueError("empty or invalid corpus") seen = set() - for case in cases: - if not isinstance(case, dict): - raise ValueError("case must be a table") - allowed = {"id", "input", "hex", "valid", "canonical"} - if set(case) - allowed: - raise ValueError("unknown case field") - name = case.get("id", "") - if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9-]+", name) or name in seen: - raise ValueError("invalid or duplicate case id") - seen.add(name) - if type(case.get("valid")) is not bool or (("input" in case) == ("hex" in case)): - raise ValueError("case needs an explicit verdict and exactly one input") - if case["valid"] != ("canonical" in case): - raise ValueError("only valid cases must have expected canonical bytes") - raw = case.get("input", case.get("hex")) - if not isinstance(raw, str): - raise ValueError("input must be a string or hex string") - data = case["input"].encode("utf-8") if "input" in case else bytes.fromhex(case["hex"]) - if case["valid"] and not isinstance(case["canonical"], str): - raise ValueError("canonical bytes must be a UTF-8 string") - expected = case["canonical"].encode("utf-8") if case["valid"] else b"" - if len(data) > 131072 or len(expected) > 131072: - raise ValueError("case exceeds the corpus budget") - yield name, data, case["valid"], expected + for raw in cases: + case = _case(raw) + if case[0] in seen: + raise ValueError("duplicate case id") + seen.add(case[0]) + yield case def check(executable, cases, *, timeout=5.0): @@ -68,12 +83,11 @@ def check(executable, cases, *, timeout=5.0): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--tool", required=True, help="installed binary or canon-protocol adapter") - parser.add_argument("--corpus", type=Path, default=ROOT / "test/fixtures/formats/ijson-jcs.toml") parser.add_argument("--timeout", type=float, default=5.0) args = parser.parse_args(argv) try: # Validate the entire corpus before invoking the first child. - cases = list(load_cases(args.corpus)) + cases = list(load_cases(ROOT / "test/fixtures/formats/ijson-jcs.toml")) ok = check(args.tool, cases, timeout=args.timeout) print(f"Corpus: {len(cases)} cases; runtime migration remains separately gated") return 0 if ok else 1 diff --git a/scripts/formats/inventory.py b/scripts/formats/inventory.py index a5dc339d..5ced42bd 100644 --- a/scripts/formats/inventory.py +++ b/scripts/formats/inventory.py @@ -51,33 +51,39 @@ def category(path): return "untriaged" +def _file_rows(root, name): + path = root / name + bucket = category(Path(name)) + if path.is_symlink(): + yield "symlink", bucket, name, 0, "target-not-followed" + return + if not path.is_file(): + yield "missing", bucket, name, 0, "missing-worktree-file" + return + suffix = path.suffix + if suffix in DATA or path.name in ("renv.lock", "bun.lock"): + yield "document", bucket, name, 0, "format-owner-needs-review" + if suffix not in CODE: + return + try: + text = path.read_text(encoding="utf-8") + except UnicodeError: + yield "unreadable", bucket, name, 0, "invalid-utf8-source" + return + for label, pattern in PATTERNS.items(): + for match in re.finditer(pattern, text): + line = text.count("\n", 0, match.start()) + 1 + yield "candidate", bucket, name, line, label + + def inventory(root): result = subprocess.run( ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=root, check=True, capture_output=True, ) - paths = sorted(set(p.decode("utf-8") for p in result.stdout.split(b"\0") if p)) + paths = sorted({p.decode("utf-8") for p in result.stdout.split(b"\0") if p}) for name in paths: - path = root / name - if path.is_symlink(): - yield "symlink", category(Path(name)), name, 0, "target-not-followed" - continue - if not path.is_file(): - continue # staged deletion / missing worktree file - suffix = path.suffix - if suffix in DATA or path.name in ("renv.lock", "bun.lock"): - yield "document", category(Path(name)), name, 0, "format-owner-needs-review" - if suffix not in CODE: - continue - try: - text = path.read_text(encoding="utf-8") - except UnicodeError: - yield "unreadable", category(Path(name)), name, 0, "invalid-utf8-source" - continue - for label, pattern in PATTERNS.items(): - for match in re.finditer(pattern, text): - line = text.count("\n", 0, match.start()) + 1 - yield "candidate", category(Path(name)), name, line, label + yield from _file_rows(root, name) def main(argv=None): diff --git a/scripts/formats/process.py b/scripts/formats/process.py index c1770a2b..97417bf7 100644 --- a/scripts/formats/process.py +++ b/scripts/formats/process.py @@ -1,17 +1,28 @@ # SPDX-License-Identifier: MPL-2.0 -"""Bounded POSIX subprocess IO for format-tool probes, never a shell command. +"""Bounded POSIX IO for a deliberately closed set of format-probe commands. -Do not print child diagnostics: parser diagnostics can contain input secrets. -This is test tooling, not an application parser or an alternative canonicalizer. +Installed tools are trusted executables, not sandboxed plugins. CLI input may +select their location, never a shell command, arbitrary program or arguments. +Child diagnostics can contain secrets and are not printed by the probe CLIs. """ import math import os +from pathlib import Path import selectors +import shutil import signal import subprocess import time from dataclasses import dataclass +TIMEOUT = "tool exceeded its time budget" +_ALLOWED = { + "ijson-jcs": {("canon",)}, + "kyq-json-adapter": {("canon",)}, + "git": {("rev-parse", "HEAD"), ("status", "--porcelain", "--untracked-files=all")}, + "just": {(name,) for name in ("--version", "build", "wasm", "test")}, +} + class ToolError(RuntimeError): """Infrastructure failure; distinct from a tool rejecting a document.""" @@ -24,6 +35,99 @@ class Result: stderr: bytes +def _command(argv): + if not argv or not all(isinstance(arg, str) for arg in argv): + raise ToolError("invalid command") + location = shutil.which(argv[0]) + if location is None: + raise ToolError("tool not found") + program = Path(location).resolve(strict=True) + # Check the final name, so a symlink to an unrelated executable cannot turn + # `--tool /path/ijson-jcs` into rm, sh, Python, etc. No arbitrary flags. + allowed = _ALLOWED.get(program.name, set()) + if tuple(argv[1:]) not in allowed: + raise ToolError("program or arguments are outside the probe protocol") + return [str(program), *argv[1:]] + + +class _Pump: + def __init__(self, child, data, limit): + self.child, self.data, self.limit = child, data, limit + self.offset = 0 + self.total = 0 + self.buffers = {"stdout": bytearray(), "stderr": bytearray()} + + def register(self, selector): + for stream, label in ((self.child.stdout, "stdout"), (self.child.stderr, "stderr")): + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ, label) + if not self.data: + self.child.stdin.close() + return + os.set_blocking(self.child.stdin.fileno(), False) + selector.register(self.child.stdin, selectors.EVENT_WRITE, "stdin") + + def write_input(self, selector, stream): + try: + self.offset += os.write(stream.fileno(), self.data[self.offset:self.offset + 16384]) + except BrokenPipeError: + self.offset = len(self.data) + except BlockingIOError: + return + if self.offset == len(self.data): + selector.unregister(stream) + stream.close() + + def read_output(self, selector, stream, label): + try: + chunk = os.read(stream.fileno(), 16384) + except BlockingIOError: + return + if not chunk: + selector.unregister(stream) + stream.close() + return + self.total += len(chunk) + if self.total > self.limit: + raise ToolError("tool exceeded its output budget") + self.buffers[label].extend(chunk) + + def collect(self, deadline): + with selectors.DefaultSelector() as selector: + self.register(selector) + while selector.get_map(): + for key, _ in selector.select(_remaining(deadline)): + if key.data == "stdin": + self.write_input(selector, key.fileobj) + else: + self.read_output(selector, key.fileobj, key.data) + try: + code = self.child.wait(timeout=_remaining(deadline)) + except subprocess.TimeoutExpired as error: + raise ToolError(TIMEOUT) from error + return Result(code, bytes(self.buffers["stdout"]), bytes(self.buffers["stderr"])) + + +def _remaining(deadline): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ToolError(TIMEOUT) + return remaining + + +def _cleanup(child): + # A leader may exit while a descendant keeps a pipe open. Kill its session, + # not just the leader, so timed-out probes cannot leave children behind. + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + child.wait() + for stream in (child.stdin, child.stdout, child.stderr): + if not stream.closed: + stream.close() + + def run(argv, data=b"", *, cwd=None, timeout=5.0, limit=131072): if not math.isfinite(timeout) or timeout <= 0 or limit <= 0 or len(data) > limit: raise ToolError("invalid process budget or oversized test input") @@ -31,70 +135,12 @@ def run(argv, data=b"", *, cwd=None, timeout=5.0, limit=131072): raise ToolError("this probe currently supports POSIX pipe IO only") try: child = subprocess.Popen( - argv, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, start_new_session=True, + _command(argv), cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, start_new_session=True, shell=False, ) except OSError as error: raise ToolError("tool could not be started") from error - buffers = {"stdout": bytearray(), "stderr": bytearray()} - deadline = time.monotonic() + timeout - offset = 0 - total = 0 try: - with selectors.DefaultSelector() as selector: - for stream, label in ((child.stdout, "stdout"), (child.stderr, "stderr")): - os.set_blocking(stream.fileno(), False) - selector.register(stream, selectors.EVENT_READ, label) - if data: - os.set_blocking(child.stdin.fileno(), False) - selector.register(child.stdin, selectors.EVENT_WRITE, "stdin") - else: - child.stdin.close() - while selector.get_map(): - remaining = deadline - time.monotonic() - if remaining <= 0: - raise ToolError("tool exceeded its time budget") - for key, _ in selector.select(remaining): - stream = key.fileobj - if key.data == "stdin": - try: - offset += os.write(stream.fileno(), data[offset:offset + 16384]) - except BrokenPipeError: - offset = len(data) - except BlockingIOError: - continue - if offset == len(data): - selector.unregister(stream) - stream.close() - else: - try: - chunk = os.read(stream.fileno(), 16384) - except BlockingIOError: - continue - if not chunk: - selector.unregister(stream) - stream.close() - continue - total += len(chunk) - if total > limit: - raise ToolError("tool exceeded its output budget") - buffers[key.data].extend(chunk) - remaining = deadline - time.monotonic() - if remaining <= 0: - raise ToolError("tool exceeded its time budget") - try: - code = child.wait(timeout=remaining) - except subprocess.TimeoutExpired as error: - raise ToolError("tool exceeded its time budget") from error - return Result(code, bytes(buffers["stdout"]), bytes(buffers["stderr"])) + return _Pump(child, data, limit).collect(time.monotonic() + timeout) finally: - # Kill the session even if its leader exited but descendants kept a pipe - # open. A hung descendant must not outlive a timed-out probe. - try: - os.killpg(child.pid, signal.SIGKILL) - except ProcessLookupError: - pass - child.wait() - for stream in (child.stdin, child.stdout, child.stderr): - if not stream.closed: - stream.close() + _cleanup(child) diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py index 5e046227..b19b55ca 100644 --- a/scripts/tests/test_format_readiness.py +++ b/scripts/tests/test_format_readiness.py @@ -16,14 +16,23 @@ from probe_kyq import main as probe_main +def run_python(body, data=b"", **kwargs): + # A fake installed protocol adapter, never a production interpreter command. + with tempfile.TemporaryDirectory(prefix="format tool ") as tmp: + path = Path(tmp) / "ijson-jcs" + path.write_text(f"#!{sys.executable}\n{body}\n") + path.chmod(0o700) + return run([str(path), "canon"], data, **kwargs) + + class ProcessTests(unittest.TestCase): def test_round_trip_binary_io(self): raw = bytes(range(256)) * 200 - result = run([sys.executable, "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"], raw) + result = run_python("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())", raw) self.assertEqual((result.code, result.stdout, result.stderr), (0, raw, b"")) def test_stderr_separate_and_exit_preserved(self): - result = run([sys.executable, "-c", "import sys; sys.stderr.write('private'); sys.exit(7)"]) + result = run_python("import sys; sys.stderr.write('private'); sys.exit(7)") self.assertEqual((result.code, result.stdout, result.stderr), (7, b"", b"private")) def test_missing_binary_is_infrastructure_not_document_rejection(self): @@ -32,20 +41,32 @@ def test_missing_binary_is_infrastructure_not_document_rejection(self): def test_timeout(self): with self.assertRaisesRegex(ToolError, "time budget"): - run([sys.executable, "-c", "import time; time.sleep(10)"], timeout=0.15) + run_python("import time; time.sleep(10)", timeout=0.15) def test_output_budget_covers_both_streams(self): with self.assertRaisesRegex(ToolError, "output budget"): - run([sys.executable, "-c", "import sys; sys.stderr.write('x' * 10000)"], limit=100) + run_python("import sys; sys.stderr.write('x' * 10000)", limit=100) def test_input_and_deadline_budgets(self): for kwargs in ({"timeout": 0}, {"timeout": float("nan")}, {"timeout": float("inf")}, {"limit": 0}, {"data": b"xx", "limit": 1}): with self.assertRaises(ToolError): - run([sys.executable, "-c", "pass"], **kwargs) + run_python("pass", **kwargs) + + def test_arbitrary_programs_and_arguments_are_refused(self): + for argv in ([sys.executable, "-c", "pass"], ["git", "config", "--global", "x", "y"]): + with self.subTest(argv=argv), self.assertRaises(ToolError): + run(argv) + + def test_symlink_cannot_disguise_an_interpreter_as_a_canonicalizer(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "ijson-jcs" + path.symlink_to(sys.executable) + with self.assertRaises(ToolError): + run([str(path), "canon"]) def test_descendant_keeping_pipe_open_is_bounded(self): with self.assertRaisesRegex(ToolError, "time budget"): - run([sys.executable, "-c", "import os,time; p=os.fork(); time.sleep(10) if p==0 else None"], timeout=0.2) + run_python("import os,time; p=os.fork(); time.sleep(10) if p==0 else None", timeout=0.2) class CorpusTests(unittest.TestCase): @@ -59,7 +80,7 @@ def test_raw_fixtures_survive_loading(self): def test_bad_and_ambiguous_corpora_fail_before_running_tools(self): for contents in ( - "schema=2\ncase=[]\n", "schema=1\ncase=[]\n", + "schema=2\ncase=[]\n", "schema=true\ncase=[]\n", "schema=1\ncase=[]\n", "schema=1\n[[case]]\nid='a'\ninput='{}'\nvalid=true\n", "schema=1\n[[case]]\nid='a'\ninput='{}'\nhex='ff'\nvalid=false\n", "schema=1\n[[case]]\nid='a'\ninput='{}'\nvalid='false'\n", @@ -81,7 +102,9 @@ def test_duplicate_ids_fail(self): list(load_cases(path)) def fake(self, directory, body): - path = Path(directory) / "tool with spaces" + folder = Path(directory) / "tool with spaces" + folder.mkdir() + path = folder / "ijson-jcs" path.write_text(f"#!{sys.executable}\n{body}\n") path.chmod(0o700) return str(path) From 0b970ab5124b391d3e9610ea37de39023d5002ce Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:08:48 +0000 Subject: [PATCH 3/6] Remove CLI program arguments in favor of closed probe operations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/format-readiness.yml | 2 +- docs/format-migration.md | 14 +++++---- scripts/formats/conformance.py | 10 +++--- scripts/formats/probe_kyq.py | 13 ++++---- scripts/formats/process.py | 43 ++++++++++++++------------ scripts/tests/test_format_readiness.py | 33 +++++++++++++------- 6 files changed, 65 insertions(+), 50 deletions(-) diff --git a/.github/workflows/format-readiness.yml b/.github/workflows/format-readiness.yml index 17df3f82..fa40fe50 100644 --- a/.github/workflows/format-readiness.yml +++ b/.github/workflows/format-readiness.yml @@ -67,7 +67,7 @@ jobs: env: PYTHONDONTWRITEBYTECODE: '1' run: | - python3 scripts/formats/conformance.py --tool "$RUNNER_TEMP/format-reference/bin/ijson-jcs" + PATH="$RUNNER_TEMP/format-reference/bin:$PATH" python3 scripts/formats/conformance.py --adapter reference { echo '## Format migration evidence' echo 'Pinned ijson-jcs raw-byte corpus passed. This is not application-wide conformance.' diff --git a/docs/format-migration.md b/docs/format-migration.md index 38fbf0ab..0d75a472 100644 --- a/docs/format-migration.md +++ b/docs/format-migration.md @@ -48,7 +48,7 @@ replace it with another language or publish another tool under the kyq name. Reproduce using a clean clone at the pinned commit: ```sh -python3 scripts/formats/probe_kyq.py --source /path/to/kyq --just /path/to/just +PATH="/path/to/trusted/just/bin:$PATH" python3 scripts/formats/probe_kyq.py --source /path/to/kyq ``` Probe exit codes: 0 means only the three source recipes returned success; @@ -105,7 +105,7 @@ escaped and raw controls, integer boundaries, beyond-uint64 counts, exponent thresholds, negative zero, array order, trailing input and truncation. ```sh -python3 scripts/formats/conformance.py --tool /path/to/ijson-jcs +PATH="/path/to/trusted/reference/bin:$PATH" python3 scripts/formats/conformance.py --adapter reference python3 -m unittest discover -s scripts/tests -p 'test_format*.py' -v ``` @@ -116,10 +116,12 @@ missing tools, changed byte order and extra newlines cannot pass. This runner implements no parser/canonicalizer of its own. A future kyq adapter may implement this protocol; it is **not** a claim that kyq has a `canon` command today. -The subprocess runner accepts only named installed tools (`ijson-jcs`, a future -`kyq-json-adapter`, `git`, `just`) with closed argument protocols. It rejects -arbitrary commands/flags and symlinks to unrelated interpreters. These are trusted -installed executables, not sandboxed plugins. Corpus paths are repository +The subprocess runner selects only complete literal commands from an internal +operation table (`ijson-jcs`, a future `kyq-json-adapter`, `git`, `just`). No program +path or argument string is accepted from the CLI. The `--adapter` selector is a +closed choice of reference/candidate, not a command. A trusted PATH provides the +installed executables; symlinks to unrelated interpreters are refused. These are +trusted tools, not sandboxed plugins. Corpus paths are repository constants/test fixtures, never CLI-supplied paths. The runner limits input/output bytes and wall time, keeps diagnostics out of logs and kills the process group on completion/failure. It currently diff --git a/scripts/formats/conformance.py b/scripts/formats/conformance.py index 14099643..6107ef48 100644 --- a/scripts/formats/conformance.py +++ b/scripts/formats/conformance.py @@ -67,10 +67,11 @@ def load_cases(path): yield case -def check(executable, cases, *, timeout=5.0): +def check(adapter, cases, *, timeout=5.0): + operation = {"reference": "ijson-canon", "candidate": "kyq-canon"}[adapter] passed = True for name, data, valid, expected in cases: - result = run([executable, "canon"], data, timeout=timeout) + result = run(operation, data, timeout=timeout) # Rejection must be the documented invalid-input exit, not a crash, # missing library, usage failure from a wrapper, or arbitrary nonzero. wanted = 0 if valid else 2 @@ -82,13 +83,14 @@ def check(executable, cases, *, timeout=5.0): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--tool", required=True, help="installed binary or canon-protocol adapter") + parser.add_argument("--adapter", choices=("reference", "candidate"), default="reference", + help="named installed adapter on trusted PATH, never a command/path") parser.add_argument("--timeout", type=float, default=5.0) args = parser.parse_args(argv) try: # Validate the entire corpus before invoking the first child. cases = list(load_cases(ROOT / "test/fixtures/formats/ijson-jcs.toml")) - ok = check(args.tool, cases, timeout=args.timeout) + ok = check(args.adapter, cases, timeout=args.timeout) print(f"Corpus: {len(cases)} cases; runtime migration remains separately gated") return 0 if ok else 1 except (ToolError, ValueError, TypeError, OSError) as error: diff --git a/scripts/formats/probe_kyq.py b/scripts/formats/probe_kyq.py index d0eced3c..33a481e2 100644 --- a/scripts/formats/probe_kyq.py +++ b/scripts/formats/probe_kyq.py @@ -16,19 +16,19 @@ ROOT = Path(__file__).resolve().parents[2] -def probe(source, just, timeout): +def probe(source, timeout): with (ROOT / "config/format-tools.toml").open("rb") as stream: pin = tomllib.load(stream)["kyq"] - actual = run(["git", "rev-parse", "HEAD"], cwd=source) - clean = run(["git", "status", "--porcelain", "--untracked-files=all"], cwd=source) + actual = run("git-head", cwd=source) + clean = run("git-status", cwd=source) if actual.code or actual.stdout.decode().strip() != pin["revision"] or clean.code or clean.stdout: raise ToolError("source is not the clean pinned revision") - version = run([just, "--version"]) + version = run("just-version") if version.code or version.stdout.decode().strip() != f"just {pin['just_version']}": raise ToolError("unexpected just version") ok = True for recipe in ("build", "wasm", "test"): - result = run([just, recipe], cwd=source, timeout=timeout) + result = run(f"just-{recipe}", cwd=source, timeout=timeout) print(f"{recipe}: exit={result.code}") ok = ok and result.code == 0 print("install: NOT_RUN (no isolated-prefix contract has been established)") @@ -40,11 +40,10 @@ def probe(source, just, timeout): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path, required=True) - parser.add_argument("--just", default="just") parser.add_argument("--timeout", type=float, default=120.0) args = parser.parse_args(argv) try: - return probe(args.source, args.just, args.timeout) + return probe(args.source, args.timeout) except (ToolError, OSError, ValueError, KeyError) as error: print(f"INFRASTRUCTURE: {type(error).__name__}; kyq readiness not established", file=sys.stderr) return 2 diff --git a/scripts/formats/process.py b/scripts/formats/process.py index 97417bf7..650132ff 100644 --- a/scripts/formats/process.py +++ b/scripts/formats/process.py @@ -1,8 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 """Bounded POSIX IO for a deliberately closed set of format-probe commands. -Installed tools are trusted executables, not sandboxed plugins. CLI input may -select their location, never a shell command, arbitrary program or arguments. +Installed tools are trusted executables, not sandboxed plugins. A trusted PATH +provides their location; no program paths or argument strings come from a CLI. Child diagnostics can contain secrets and are not printed by the probe CLIs. """ import math @@ -16,11 +16,15 @@ from dataclasses import dataclass TIMEOUT = "tool exceeded its time budget" -_ALLOWED = { - "ijson-jcs": {("canon",)}, - "kyq-json-adapter": {("canon",)}, - "git": {("rev-parse", "HEAD"), ("status", "--porcelain", "--untracked-files=all")}, - "just": {(name,) for name in ("--version", "build", "wasm", "test")}, +_COMMANDS = { + "ijson-canon": ("ijson-jcs", "canon"), + "kyq-canon": ("kyq-json-adapter", "canon"), + "git-head": ("git", "rev-parse", "HEAD"), + "git-status": ("git", "status", "--porcelain", "--untracked-files=all"), + "just-version": ("just", "--version"), + "just-build": ("just", "build"), + "just-wasm": ("just", "wasm"), + "just-test": ("just", "test"), } @@ -35,19 +39,18 @@ class Result: stderr: bytes -def _command(argv): - if not argv or not all(isinstance(arg, str) for arg in argv): - raise ToolError("invalid command") +def _command(name): + if not isinstance(name, str) or name not in _COMMANDS: + raise ToolError("unknown probe operation") + # Only select complete, literal commands. Neither program paths nor argv + # strings from a CLI are passed through to process creation. + argv = _COMMANDS[name] location = shutil.which(argv[0]) if location is None: - raise ToolError("tool not found") - program = Path(location).resolve(strict=True) - # Check the final name, so a symlink to an unrelated executable cannot turn - # `--tool /path/ijson-jcs` into rm, sh, Python, etc. No arbitrary flags. - allowed = _ALLOWED.get(program.name, set()) - if tuple(argv[1:]) not in allowed: - raise ToolError("program or arguments are outside the probe protocol") - return [str(program), *argv[1:]] + raise ToolError("tool not found on trusted PATH") + if Path(location).resolve(strict=True).name != argv[0]: + raise ToolError("tool resolves to an unrelated executable") + return argv class _Pump: @@ -128,14 +131,14 @@ def _cleanup(child): stream.close() -def run(argv, data=b"", *, cwd=None, timeout=5.0, limit=131072): +def run(operation, data=b"", *, cwd=None, timeout=5.0, limit=131072): if not math.isfinite(timeout) or timeout <= 0 or limit <= 0 or len(data) > limit: raise ToolError("invalid process budget or oversized test input") if os.name != "posix": raise ToolError("this probe currently supports POSIX pipe IO only") try: child = subprocess.Popen( - _command(argv), cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + _command(operation), cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, shell=False, ) except OSError as error: diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py index b19b55ca..740d0b7a 100644 --- a/scripts/tests/test_format_readiness.py +++ b/scripts/tests/test_format_readiness.py @@ -2,11 +2,13 @@ """Tests of the harness itself; fake children are NOT parser conformance evidence.""" import contextlib import io +import os from pathlib import Path import subprocess import sys import tempfile import unittest +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "scripts/formats")) @@ -22,7 +24,8 @@ def run_python(body, data=b"", **kwargs): path = Path(tmp) / "ijson-jcs" path.write_text(f"#!{sys.executable}\n{body}\n") path.chmod(0o700) - return run([str(path), "canon"], data, **kwargs) + with patch.dict(os.environ, {"PATH": tmp + os.pathsep + os.environ["PATH"]}): + return run("ijson-canon", data, **kwargs) class ProcessTests(unittest.TestCase): @@ -36,8 +39,8 @@ def test_stderr_separate_and_exit_preserved(self): self.assertEqual((result.code, result.stdout, result.stderr), (7, b"", b"private")) def test_missing_binary_is_infrastructure_not_document_rejection(self): - with self.assertRaises(ToolError): - run(["/nonexistent/format-tool"]) + with patch.dict(os.environ, {"PATH": ""}), self.assertRaises(ToolError): + run("ijson-canon") def test_timeout(self): with self.assertRaisesRegex(ToolError, "time budget"): @@ -61,8 +64,9 @@ def test_symlink_cannot_disguise_an_interpreter_as_a_canonicalizer(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "ijson-jcs" path.symlink_to(sys.executable) - with self.assertRaises(ToolError): - run([str(path), "canon"]) + with patch.dict(os.environ, {"PATH": tmp + os.pathsep + os.environ["PATH"]}): + with self.assertRaises(ToolError): + run("ijson-canon") def test_descendant_keeping_pipe_open_is_bounded(self): with self.assertRaisesRegex(ToolError, "time budget"): @@ -101,13 +105,15 @@ def test_duplicate_ids_fail(self): with self.assertRaises(ValueError): list(load_cases(path)) + @contextlib.contextmanager def fake(self, directory, body): folder = Path(directory) / "tool with spaces" folder.mkdir() path = folder / "ijson-jcs" path.write_text(f"#!{sys.executable}\n{body}\n") path.chmod(0o700) - return str(path) + with patch.dict(os.environ, {"PATH": str(folder) + os.pathsep + os.environ["PATH"]}): + yield "reference" def test_mutants_cannot_pass_as_canonicalizers(self): cases = [("valid", b' {"a":1} ', True, b'{"a":1}'), @@ -117,7 +123,8 @@ def test_mutants_cannot_pass_as_canonicalizers(self): "raise SystemExit(1)", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"): with self.subTest(body=body), tempfile.TemporaryDirectory() as tmp: with contextlib.redirect_stdout(io.StringIO()): - self.assertFalse(check(self.fake(tmp, body), cases)) + with self.fake(tmp, body) as adapter: + self.assertFalse(check(adapter, cases)) def test_correct_adapter_protocol_passes_and_redacts_diagnostics(self): cases = [("valid", b"true", True, b"true"), ("invalid", b"broken", False, b"")] @@ -130,20 +137,22 @@ def test_correct_adapter_protocol_passes_and_redacts_diagnostics(self): raise SystemExit(2) """ with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(io.StringIO()) as out: - self.assertTrue(check(self.fake(tmp, body), cases)) + with self.fake(tmp, body) as adapter: + self.assertTrue(check(adapter, cases)) self.assertNotIn("private input", out.getvalue()) def test_exact_bytes_no_newline_or_wrong_order(self): cases = [("order", b'{}', True, b'{"a":1,"z":2}')] for output in (b'{"z":2,"a":1}', b'{"a":1,"z":2}\n'): with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(io.StringIO()): - executable = self.fake(tmp, f"import sys; sys.stdout.buffer.write({output!r})") - self.assertFalse(check(executable, cases)) + with self.fake(tmp, f"import sys; sys.stdout.buffer.write({output!r})") as adapter: + self.assertFalse(check(adapter, cases)) def test_missing_tool_cli_fails_closed_without_echoing_input(self): out, err = io.StringIO(), io.StringIO() - with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): - code = conformance_main(["--tool", "/nonexistent/format-tool"]) + with patch.dict(os.environ, {"PATH": ""}): + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = conformance_main([]) self.assertEqual(code, 2) self.assertIn("no conformance pass", err.getvalue()) self.assertNotIn("9007199254740992", out.getvalue() + err.getvalue()) From 6268df55ee17d17157c5d995a272aedff9ef2122 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:12:53 +0000 Subject: [PATCH 4/6] Cover workflow operations and supervise detached probe descendants Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/format-migration.md | 6 +- scripts/formats/descendants.py | 89 ++++++++++++++++++++++++++ scripts/formats/inventory.py | 2 +- scripts/formats/process.py | 42 ++++++------ scripts/tests/test_format_readiness.py | 23 ++++++- 5 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 scripts/formats/descendants.py diff --git a/docs/format-migration.md b/docs/format-migration.md index 0d75a472..0dbc7c8d 100644 --- a/docs/format-migration.md +++ b/docs/format-migration.md @@ -124,8 +124,10 @@ installed executables; symlinks to unrelated interpreters are refused. These are trusted tools, not sandboxed plugins. Corpus paths are repository constants/test fixtures, never CLI-supplied paths. The runner limits input/output bytes and wall time, keeps diagnostics -out of logs and kills the process group on completion/failure. It currently -supports POSIX hosts. These harness budgets are **not** parser resource limits +out of logs. Linux subreaper supervision adopts/kills detached or double-forked +descendants as well as the original process group, with a bounded cleanup grace +period. This standalone probe refuses threaded/pre-existing-child contexts and +unsupported hosts rather than claiming portable lifecycle isolation. These harness budgets are **not** parser resource limits in the application. Fake subprocesses in unit tests test the harness only. The preparation workflow builds the real pinned Rust CLI with locked dependencies diff --git a/scripts/formats/descendants.py b/scripts/formats/descendants.py new file mode 100644 index 00000000..d086e2b0 --- /dev/null +++ b/scripts/formats/descendants.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Linux-only, single-threaded supervision of trusted probe subprocesses. + +A subreaper adopts orphaned descendants even if they call setsid/double-fork. +Restore the previous process-wide setting; refuse pre-existing child contexts. +This is lifecycle management, not a sandbox for adversarial executables. +""" +import ctypes +import os +from pathlib import Path +import signal +import sys +import threading +import time + + +class SupervisionError(RuntimeError): + pass + + +def _children(pid): + result = set() + try: + tasks = list(Path(f"/proc/{pid}/task").iterdir()) + except FileNotFoundError: + return result + for task in tasks: + try: + result.update(int(value) for value in (task / "children").read_text().split()) + except FileNotFoundError: + pass # The process/thread exited during enumeration. + return result + + +def _kill_tree(pid): + pending, stopped = [pid], [] + while pending: + current = pending.pop() + try: + os.kill(current, signal.SIGSTOP) + stopped.append(current) + pending.extend(_children(current)) + except ProcessLookupError: + pass + for current in reversed(stopped): + try: + os.kill(current, signal.SIGKILL) + except ProcessLookupError: + pass + + +class Supervise: + def __enter__(self): + if sys.platform != "linux" or threading.active_count() != 1: + raise SupervisionError("probe requires single-threaded Linux supervision") + self.libc = ctypes.CDLL(None, use_errno=True) + self.previous = ctypes.c_int() + if self.libc.prctl(37, ctypes.byref(self.previous), 0, 0, 0) != 0: + raise SupervisionError("cannot inspect subreaper state") + if _children(os.getpid()): + raise SupervisionError("probe requires no pre-existing child processes") + self._set(1) + return self + + def _set(self, value): + if self.libc.prctl(36, ctypes.c_ulong(value), 0, 0, 0) != 0: + raise SupervisionError("cannot set subreaper state") + + def _drain(self): + deadline = time.monotonic() + 0.5 + while True: + children = _children(os.getpid()) + if not children: + return + for pid in children: + _kill_tree(pid) + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass + if time.monotonic() >= deadline: + raise SupervisionError("descendants did not exit within cleanup budget") + time.sleep(0.005) # Bounded cleanup; also reap newly adopted orphans. + + def __exit__(self, *_): + try: + self._drain() + finally: + self._set(self.previous.value) diff --git a/scripts/formats/inventory.py b/scripts/formats/inventory.py index 5ced42bd..e637c8fa 100644 --- a/scripts/formats/inventory.py +++ b/scripts/formats/inventory.py @@ -14,7 +14,7 @@ import sys ROOT = Path(__file__).resolve().parents[2] -CODE = {".jl", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".R", ".r", ".rs"} +CODE = {".yaml", ".yml", ".kyaml", ".jl", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".R", ".r", ".rs"} DATA = {".json", ".jsonl", ".yaml", ".yml", ".kyaml"} PATTERNS = { "JSON3.read": r"\bJSON3\.read\s*\(", diff --git a/scripts/formats/process.py b/scripts/formats/process.py index 650132ff..f67cf99b 100644 --- a/scripts/formats/process.py +++ b/scripts/formats/process.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -"""Bounded POSIX IO for a deliberately closed set of format-probe commands. +"""Bounded Linux IO for a deliberately closed set of format-probe commands. Installed tools are trusted executables, not sandboxed plugins. A trusted PATH provides their location; no program paths or argument strings come from a CLI. @@ -15,6 +15,8 @@ import time from dataclasses import dataclass +from descendants import Supervise, SupervisionError + TIMEOUT = "tool exceeded its time budget" _COMMANDS = { "ijson-canon": ("ijson-jcs", "canon"), @@ -119,31 +121,33 @@ def _remaining(deadline): def _cleanup(child): - # A leader may exit while a descendant keeps a pipe open. Kill its session, - # not just the leader, so timed-out probes cannot leave children behind. + # Fast group cleanup; Supervise also adopts/kills detached descendants. try: os.killpg(child.pid, signal.SIGKILL) except ProcessLookupError: pass - child.wait() - for stream in (child.stdin, child.stdout, child.stderr): - if not stream.closed: - stream.close() + try: + child.wait(timeout=0.5) + except subprocess.TimeoutExpired as error: + raise ToolError("tool did not exit within cleanup budget") from error + finally: + for stream in (child.stdin, child.stdout, child.stderr): + if not stream.closed: + stream.close() def run(operation, data=b"", *, cwd=None, timeout=5.0, limit=131072): if not math.isfinite(timeout) or timeout <= 0 or limit <= 0 or len(data) > limit: raise ToolError("invalid process budget or oversized test input") - if os.name != "posix": - raise ToolError("this probe currently supports POSIX pipe IO only") - try: - child = subprocess.Popen( - _command(operation), cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, start_new_session=True, shell=False, - ) - except OSError as error: - raise ToolError("tool could not be started") from error try: - return _Pump(child, data, limit).collect(time.monotonic() + timeout) - finally: - _cleanup(child) + with Supervise(): + child = subprocess.Popen( + _command(operation), cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, start_new_session=True, shell=False, + ) + try: + return _Pump(child, data, limit).collect(time.monotonic() + timeout) + finally: + _cleanup(child) + except (OSError, SupervisionError) as error: + raise ToolError("tool startup or descendant supervision failed") from error diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py index 740d0b7a..29c03cd8 100644 --- a/scripts/tests/test_format_readiness.py +++ b/scripts/tests/test_format_readiness.py @@ -68,6 +68,25 @@ def test_symlink_cannot_disguise_an_interpreter_as_a_canonicalizer(self): with self.assertRaises(ToolError): run("ijson-canon") + def test_detached_double_fork_is_killed_and_reaped(self): + with tempfile.TemporaryDirectory() as tmp: + pid_file = Path(tmp) / "detached.pid" + body = f"""import os,time +if os.fork() == 0: + os.setsid() + if os.fork() == 0: + with open({str(pid_file)!r}, 'w') as stream: + stream.write(str(os.getpid())) + time.sleep(30) + os._exit(0) +time.sleep(30) +""" + with self.assertRaisesRegex(ToolError, "time budget"): + run_python(body, timeout=0.4) + pid = int(pid_file.read_text()) + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + def test_descendant_keeping_pipe_open_is_bounded(self): with self.assertRaisesRegex(ToolError, "time budget"): run_python("import os,time; p=os.fork(); time.sleep(10) if p==0 else None", timeout=0.2) @@ -169,7 +188,7 @@ def test_inventory_covers_hidden_new_files_and_never_echoes_source(self): root = Path(tmp) subprocess.run(["git", "init", "-q", tmp], check=True) (root / ".github/workflows").mkdir(parents=True) - (root / ".github/workflows/probe.yml").write_text('name: "private-value"\n') + (root / ".github/workflows/probe.yml").write_text('name: "private-value"\nrun: jq -r .value\n') (root / "client.ts").write_text('res.json();\nJSON.parse("private-value")\n') (root / "foreign.json").symlink_to("/etc/passwd") (root / ".gitignore").write_text('ignored.py\n') @@ -178,6 +197,8 @@ def test_inventory_covers_hidden_new_files_and_never_echoes_source(self): self.assertTrue(any(r[2] == ".github/workflows/probe.yml" for r in rows)) self.assertTrue(any(r[0] == "symlink" and r[2] == "foreign.json" for r in rows)) self.assertTrue(any(r[3] == 2 and r[4] == "JSON.parse" for r in rows)) + self.assertTrue(any(r[2] == ".github/workflows/probe.yml" and r[3] == 2 + and r[4] == "shell jq/yq candidate" for r in rows)) self.assertFalse(any(r[2] == "ignored.py" for r in rows)) self.assertNotIn("private-value", repr(rows)) From 5be0a7f5e9d882459a36839bbf05e12118e3f330 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:13:47 +0000 Subject: [PATCH 5/6] Fail closed when Linux child supervision is unavailable Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- scripts/formats/descendants.py | 11 ++++++++--- scripts/tests/test_format_readiness.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/formats/descendants.py b/scripts/formats/descendants.py index d086e2b0..8334fa09 100644 --- a/scripts/formats/descendants.py +++ b/scripts/formats/descendants.py @@ -13,6 +13,9 @@ import threading import time +PR_SET_CHILD_SUBREAPER = 36 +PR_GET_CHILD_SUBREAPER = 37 + class SupervisionError(RuntimeError): pass @@ -22,7 +25,9 @@ def _children(pid): result = set() try: tasks = list(Path(f"/proc/{pid}/task").iterdir()) - except FileNotFoundError: + except FileNotFoundError as error: + if pid == os.getpid(): + raise SupervisionError("procfs child enumeration unavailable") from error return result for task in tasks: try: @@ -55,7 +60,7 @@ def __enter__(self): raise SupervisionError("probe requires single-threaded Linux supervision") self.libc = ctypes.CDLL(None, use_errno=True) self.previous = ctypes.c_int() - if self.libc.prctl(37, ctypes.byref(self.previous), 0, 0, 0) != 0: + if self.libc.prctl(PR_GET_CHILD_SUBREAPER, ctypes.byref(self.previous), 0, 0, 0) != 0: raise SupervisionError("cannot inspect subreaper state") if _children(os.getpid()): raise SupervisionError("probe requires no pre-existing child processes") @@ -63,7 +68,7 @@ def __enter__(self): return self def _set(self, value): - if self.libc.prctl(36, ctypes.c_ulong(value), 0, 0, 0) != 0: + if self.libc.prctl(PR_SET_CHILD_SUBREAPER, ctypes.c_ulong(value), 0, 0, 0) != 0: raise SupervisionError("cannot set subreaper state") def _drain(self): diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py index 29c03cd8..1a9d1991 100644 --- a/scripts/tests/test_format_readiness.py +++ b/scripts/tests/test_format_readiness.py @@ -87,6 +87,18 @@ def test_detached_double_fork_is_killed_and_reaped(self): with self.assertRaises(ProcessLookupError): os.kill(pid, 0) + def test_missing_procfs_fails_closed(self): + with patch("descendants.Path.iterdir", side_effect=FileNotFoundError): + with self.assertRaises(ToolError): + run("ijson-canon") + + def test_existing_child_context_is_refused_before_launch(self): + with patch("descendants._children", return_value={12345}): + with patch("process.subprocess.Popen") as launch: + with self.assertRaises(ToolError): + run("ijson-canon") + launch.assert_not_called() + def test_descendant_keeping_pipe_open_is_bounded(self): with self.assertRaisesRegex(ToolError, "time budget"): run_python("import os,time; p=os.fork(); time.sleep(10) if p==0 else None", timeout=0.2) From 2e57cae83b45b6aafe0974896c777067b0324828 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:14:23 +0000 Subject: [PATCH 6/6] Require the procfs children interface before probing Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- scripts/formats/descendants.py | 6 ++++-- scripts/tests/test_format_readiness.py | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/formats/descendants.py b/scripts/formats/descendants.py index 8334fa09..6cdab69c 100644 --- a/scripts/formats/descendants.py +++ b/scripts/formats/descendants.py @@ -32,8 +32,10 @@ def _children(pid): for task in tasks: try: result.update(int(value) for value in (task / "children").read_text().split()) - except FileNotFoundError: - pass # The process/thread exited during enumeration. + except FileNotFoundError as error: + if pid == os.getpid() and task.name == str(pid): + raise SupervisionError("procfs children interface unavailable") from error + # A different process/thread exited during enumeration. return result diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py index 1a9d1991..f6891219 100644 --- a/scripts/tests/test_format_readiness.py +++ b/scripts/tests/test_format_readiness.py @@ -88,9 +88,11 @@ def test_detached_double_fork_is_killed_and_reaped(self): os.kill(pid, 0) def test_missing_procfs_fails_closed(self): - with patch("descendants.Path.iterdir", side_effect=FileNotFoundError): - with self.assertRaises(ToolError): - run("ijson-canon") + for method in ("iterdir", "read_text"): + with self.subTest(method=method): + with patch(f"descendants.Path.{method}", side_effect=FileNotFoundError): + with self.assertRaises(ToolError): + run("ijson-canon") def test_existing_child_context_is_refused_before_launch(self): with patch("descendants._children", return_value={12345}):