diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d6be18..000f40a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,42 @@ # CI for fable-session. Deliberately owner/URL-free: the workflow only uses -# marketplace actions by name and never references a repository URL. -# Fetching build tooling (setup-python, pip, PyYAML) is allowed; the tests -# themselves are deterministic and offline. +# marketplace actions by name and never references a repository URL. All +# actions are pinned to verified tag SHAs. Fetching build tooling +# (setup-python, pip, PyYAML, build, the offline-smoke wheelhouse seed) is +# allowed in dedicated preparation steps; the tests themselves are +# deterministic and offline, and the offline install smoke never touches an +# index during its install phase. name: ci on: push: pull_request: +# Least-privilege workflow permissions — CI only ever reads the +# repository. No job widens this; nothing in CI writes contents, +# packages, or attestations. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # checkout v4 (pinned tag SHA). fetch-depth 0: the full-history + # readiness scan must see every reachable commit, not a shallow tip. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 - - uses: actions/setup-python@v5 + # setup-python v5 (pinned tag SHA). + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Compile all sources run: python3 -m compileall -q src tests - - name: Install test dependency (PyYAML, workflow validation only) - run: python3 -m pip install --quiet PyYAML + - name: Install test dependency (PyYAML, workflow validation only; pinned tested version) + run: python3 -m pip install --quiet PyYAML==6.0.2 - name: Run the full unit test suite run: python3 -m unittest discover -s tests -v @@ -30,15 +44,35 @@ jobs: - name: Public-readiness scan of the tracked tree run: python3 tests/public_readiness_check.py - - name: Install and smoke the CLI + - name: Public-readiness scan of the FULL history + run: python3 tests/full_history_readiness_check.py + + - name: Whitespace hygiene (git diff --check against the empty tree) + run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD + + - name: Pre-seed the offline-smoke wheelhouse (network allowed HERE only) + run: | + mkdir -p /tmp/fable-wheelhouse + python3 -m pip download --quiet --dest /tmp/fable-wheelhouse --no-deps "setuptools==83.0.0" + + - name: Offline install smoke (no index during the install phase) + run: FABLE_SESSION_WHEELHOUSE=/tmp/fable-wheelhouse python3 tests/offline_install_smoke.py + + # Pinned, tested build toolchain (the seed above satisfies the + # pyproject `setuptools>=77` floor); --no-isolation keeps the build + # on exactly these versions instead of a floating isolated env. + - name: Build the wheel and sdist (pinned toolchain) + run: | + python3 -m pip install --quiet build==1.5.1 setuptools==83.0.0 wheel==0.47.0 + python3 -m build --no-isolation + + - name: Install the built wheel and smoke the CLI run: | - python3 -m pip install --quiet . + python3 -m pip install --quiet dist/*.whl fable-session --version + fable-session --version | grep -F "0.3.0b1" fable-session --help fable-session run --help fable-session audit --help fable-session watch --help - claude-context-run --help > /dev/null - claude-context-audit-models --help > /dev/null - claude-session-watchdog --help > /dev/null - python3 -c "from importlib.metadata import distribution; eps = distribution('fable-session').entry_points; assert not any(ep.name == 'fs' for ep in eps), 'fs must never ship'" + python3 -c "from importlib.metadata import distribution; eps = distribution('fable-session').entry_points; banned = {'fs', 'claude-context-run', 'claude-context-audit-models', 'claude-session-watchdog'}; assert not any(ep.name in banned for ep in eps), 'removed/forbidden console names must never ship'" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a991d51 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +# Tag-triggered PRERELEASE workflow for fable-session (e.g. tag +# v0.3.0-beta.1 for distribution version 0.3.0b1). Deliberately +# owner/URL-free and pinned to verified tag SHAs. +# +# What it does: re-run the local release gates on the exact tagged commit, +# build the wheel and sdist, generate SHA256SUMS, attest build provenance +# with GitHub artifact attestation (no maintainer key material exists, and +# none is invented), and create a GitHub PRERELEASE carrying the artifacts. +# +# Out of scope by decision: any package-index publication — that is a +# later, explicit step, not part of this prerelease. +name: prerelease + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + prerelease: + runs-on: ubuntu-latest + steps: + # checkout v4 (pinned tag SHA); full depth for the history scan. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + # setup-python v5 (pinned tag SHA). + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + # Fail-closed consistency gate BEFORE anything else: the checked-out + # package version admits exactly one release tag (0.3.0b1 only from + # v0.3.0-beta.1); any other tag name, or an unmapped version shape, + # refuses the whole run. + - name: Tag / package-version consistency gate (fail closed) + run: python3 tests/release_tag_check.py --tag "${GITHUB_REF_NAME}" + + - name: Release gates on the exact tagged commit + run: | + python3 -m compileall -q src tests + python3 -m pip install --quiet PyYAML==6.0.2 + python3 -m unittest discover -s tests -v + python3 tests/public_readiness_check.py + python3 tests/full_history_readiness_check.py + git diff --check "$(git hash-object -t tree /dev/null)" HEAD + + - name: Pre-seed the offline-smoke wheelhouse (network allowed HERE only) + run: | + mkdir -p /tmp/fable-wheelhouse + python3 -m pip download --quiet --dest /tmp/fable-wheelhouse --no-deps "setuptools==83.0.0" + + - name: Offline install smoke (no index during the install phase) + run: FABLE_SESSION_WHEELHOUSE=/tmp/fable-wheelhouse python3 tests/offline_install_smoke.py + + # Pinned, tested build toolchain (the seed above satisfies the + # pyproject `setuptools>=77` floor); --no-isolation keeps the build + # on exactly these versions instead of a floating isolated env. + - name: Build the wheel and sdist (pinned toolchain) + run: | + python3 -m pip install --quiet build==1.5.1 setuptools==83.0.0 wheel==0.47.0 + python3 -m build --no-isolation + + - name: Smoke the built wheel + run: | + python3 -m pip install --quiet dist/*.whl + fable-session --version + + - name: Generate SHA256SUMS + run: | + cd dist + sha256sum * > SHA256SUMS + cat SHA256SUMS + + # upload-artifact v4 (pinned tag SHA): CI-side copy of the artifacts. + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dist + path: dist/ + + # attest-build-provenance v3 (pinned tag SHA): GitHub artifact + # attestation instead of a maintainer key that does not exist. + - uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 + with: + subject-path: | + dist/*.whl + dist/*.tar.gz + + # softprops/action-gh-release v2 (pinned tag SHA): always a + # PRERELEASE — flipping a release to "latest" is a separate human + # decision, never automated here. + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + prerelease: true + files: | + dist/*.whl + dist/*.tar.gz + dist/SHA256SUMS diff --git a/PUBLIC_RELEASE_CHECKLIST.md b/PUBLIC_RELEASE_CHECKLIST.md index d9b3952..bbd53a0 100644 --- a/PUBLIC_RELEASE_CHECKLIST.md +++ b/PUBLIC_RELEASE_CHECKLIST.md @@ -1,54 +1,76 @@ # Public release checklist **Status: public release is BLOCKED.** Every unchecked item below is an -independent blocker. Do not push any history, publish a package, or make -the repository public until all of them are resolved and re-verified. +independent blocker. Do not publish a package or flip the repository +public until all of them are resolved and re-verified. -## Blockers +## Resolved - [x] **License: RESOLVED — MIT.** `LICENSE` contains the standard MIT license text (`Copyright (c) 2026 AskClaw contributors`) and - `pyproject.toml` metadata says MIT. The old placeholder wording must - never come back; `tests/public_readiness_check.py` fails the tree if - it does. -- [ ] **Old git history still contains removed internal references.** The - 0.2.0 tree is scrubbed, but earlier commits retain internal paths, - hostnames, and project names that were removed from the current - tree. The existing history must therefore **never be pushed - publicly** as-is. History was intentionally not rewritten in place. -- [ ] **Final clean-history export/review is not complete.** Before any - push to the official repository, produce a fresh export (e.g. an - orphan/squashed initial commit of the reviewed tree, or an audited - filtered history), run the full-history reference scan against the - export until it reports zero hits, and have the export - **independently reviewed**. Nothing has been exported, reviewed, or - pushed yet. + `pyproject.toml` metadata carries the SPDX expression `MIT` with + `license-files = ["LICENSE"]`. The old placeholder wording must + never come back; `tests/public_readiness_check.py` fails the tree + if it does. - [x] **Owner and URLs: RESOLVED.** The official repository location is `https://github.com/getaskclaw/fable-session`; `pyproject.toml` - carries the homepage/repository/issues URLs. The CI workflow stays - deliberately URL-free. -- [x] **Official repository: RESOLVED — exists, private, reserved.** The - official private repository exists at - `https://github.com/getaskclaw/fable-session` and is reserved for - the independently reviewed clean-history root; this checkout - deliberately configures no remote pointing at it. Its existence - unblocks nothing below — the export/review and visibility blockers - stand on their own, and only the reviewed clean-history export may - ever be pushed to it. -- [ ] **Repository visibility: still private — must stay private.** The - repository must not be made public (and no package may be - published) until every other item in this checklist is resolved and - re-verified. Flipping visibility is the deliberate final step. - -## Pre-publication verification (after the blockers are cleared) + carries the homepage/repository/issues URLs. The CI and release + workflows stay deliberately URL-free. +- [x] **Clean-history export and independent review: RESOLVED.** The + official private repository now holds exactly one independently + reviewed clean root commit (`4ead2832…`, the reviewed 0.2.0 tree, + produced as an orphan export). The full-history reference scan + reports zero hits on that history, and this checkout's `origin` + remote is a read-only local bundle of that same reviewed root — + release work builds on top of it, never beside it. + +## Standing warning — the OLD history + +The ORIGINAL pre-export working history (the private development +repository this project was extracted from) still contains removed +internal paths, hostnames, and project names. That history must +**never be pushed** publicly, mirrored, or attached to the official +repository. Only the reviewed clean root commit and commits reviewed on top +of it may ever be published; `tests/full_history_readiness_check.py` +verifies exactly that property on the history that is actually being +published. + +## Remaining blockers + +- [ ] **Repository visibility: still private — flipping public is the + deliberate final step.** While the repository is private, finish + all remaining code, history, and test work (the 0.3.0b1 release + candidate included) and re-run every verification below on the + exact release commit. Only after everything passes is the + repository made public. +- [ ] **Enable and verify private vulnerability reporting — immediately + AFTER the repository becomes public.** GitHub private vulnerability + reporting is a setting for public repositories, so it cannot be + switched on while the repository is private and no step may claim + otherwise. The moment the repository goes public, enable the + setting and verify that the "Report a vulnerability" button appears + under the repository's Security tab, so `SECURITY.md`'s reporting + path is actually usable from the first public minute. + +## Pre-publication verification (on the exact release commit) - [ ] `python3 -m compileall -q src tests` passes. - [ ] `python3 -m unittest discover -s tests` passes. - [ ] `python3 tests/public_readiness_check.py` exits 0 on the exact tree being published. -- [ ] Full-history internal-reference scan of the exact history being - published reports zero hits. +- [ ] `python3 tests/full_history_readiness_check.py` exits 0 on the + exact history being published (every tracked blob of every commit + reachable from the release commit). - [ ] `python3 tests/offline_install_smoke.py` passes. -- [ ] GitHub private vulnerability reporting is verified enabled on the - official repository, so `SECURITY.md`'s reporting path is actually - usable. +- [ ] `python3 -m build` produces the `0.3.0b1` wheel and sdist; the + wheel installs into a fresh environment and answers + `fable-session --version`. +- [ ] `git diff --check "$(git hash-object -t tree /dev/null)" HEAD` + reports no whitespace errors. +- [ ] The prerelease tag name is `v0.3.0-beta.1` (distribution version + `0.3.0b1`); `release.yml` enforces this fail-closed via + `tests/release_tag_check.py` (only the canonical tag for the + checked-out package version may release), re-runs these + gates, builds the artifacts, generates `SHA256SUMS`, attests build + provenance, and creates a GitHub prerelease. Package-index + publication stays out of scope for this prerelease. diff --git a/README.md b/README.md index 7a7827d..5e180ce 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,48 @@ One console command, three subcommands: Runtime dependencies: Python 3.12 standard library only. +## Install + +Prerequisites: + +- **Python 3.12+** (stdlib only — the package has zero runtime + dependencies); +- **tmux** (launched sessions run inside a new tmux session); +- **Claude Code CLI** installed and authenticated separately (this tool + never ships, wraps, or configures it). + +The tested real-host combination is **Linux with Claude Code 2.1.208**; +other platforms and CLI versions are not verified and no broader claim is +made. + +> **Pre-PyPI beta — read before installing.** fable-session 0.3.0b1 is +> **not published on PyPI** and the name is not yet owned there, so do +> NOT run an unqualified index install (nothing qualifies today, and a +> same-named package appearing later would be someone else's). Until +> package-index publication (a later, separate decision), install only +> from the tag-pinned GitHub source below or a GitHub release artifact — +> and these commands work **only after** the public `v0.3.0-beta.1` +> tag exists in the official repository. Before then, build from a checkout +> (see Development). + +Install into an isolated environment from the tag-pinned source: + +```bash +# pipx (tag-pinned GitHub source) +pipx install "git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" + +# uv (tag-pinned GitHub source) +uv tool install "fable-session @ git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" + +# plain pip, isolated venv (tag-pinned GitHub source) +python3.12 -m venv ~/.venvs/fable-session +~/.venvs/fable-session/bin/pip install "fable-session @ git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" +``` + +Alternatively, once the `v0.3.0-beta.1` GitHub prerelease exists, download +its attested wheel plus `SHA256SUMS`, verify the checksum, and install the +wheel file directly. + ### Dry run (default — starts nothing) ```bash @@ -100,9 +142,25 @@ fable-session run --project example-api --task /absolute/path/task.md \ - `fallback = "stop"` is enforced, not just documented: every command carries `--settings '{"switchModelsOnFlag": false}'`, so the pinned model either serves or the session stops — it never switches silently. -- Only the verified inline flags `--append-system-prompt` (profile +- Only the verified prompt flags `--append-system-prompt` (profile `context_mode = "append"`) and `--system-prompt` (`"replace"`) are used; - no file-based prompt flags. + no file-based prompt flags. Since 0.3.0b1 the prompt **payloads** never + ride the tmux command line: the runner writes the brief and task as + run-scoped mode-0600 payload files (`brief.payload`, `task.payload` + inside the run directory) from its single scanned read, and the capture + wrapper — after verifying each payload's sha256 against the hash + recorded in the manifest — substitutes the brief for a single + placeholder token in the Claude argv and feeds the task to Claude on + stdin (`-p` with no inline prompt). This also repairs the verified + launch defect where a 7,745-byte task plus an 8,072-byte brief failed + with tmux's `command too long`: the tmux command stays short no matter + how large the (bounded) payloads are. +- If configured, the registry's `max_budget_usd` is passed as exactly one + `--max-budget-usd` argument, shown in the launch summary, and recorded + in the manifest; with no budget configured, no budget flag is emitted + and the manifest records `max_budget_usd: null`. The Claude CLI's own + budget enforcement is the mechanism — fable-session never kills or + stops a session itself. - **Terminal evidence is captured, not inferred.** Claude runs with `--output-format stream-json --verbose` under a shell-free capture wrapper (`capture.py`, executed by absolute path — no `bash -c`, no @@ -210,6 +268,17 @@ What it reports: - **fallback/refusal events** — structured system events such as subtype `model_refusal_fallback`, with any serving model they identify; - **result metadata** — safe fields only (subtype, `is_error`, stop reason); +- **usage (0.3.0b1)** — a privacy-safe aggregate of the single terminal + result's `modelUsage` across all models: `input_tokens`, + `output_tokens`, `cache_read_input_tokens`, + `cache_creation_input_tokens`, `web_search_requests`, and + `total_cost_usd` — bounded numbers only, never prompt, result, or tool + text. When `modelUsage` is missing, or there is no single trustworthy + terminal result, `usage` is an explicit `null` (text output says + `usage: unavailable`) — zeroes are never invented. A structurally + malformed `modelUsage` (wrong types, negative or non-finite numbers) + additionally sets the reason code `malformed_model_usage` and forfeits + PURE: corrupt optional usage never falsely proves model purity; - **terminal-result completeness** — a completed transcript carries exactly one top-level `type="result"` event with `subtype: "success"` and boolean `is_error: false`. A missing result (interrupted stream), more than one @@ -293,9 +362,13 @@ empty. JSON line, non-object line, conflicting session-id fields) before any terminal result; diagnostic, not terminal; - `completed` — exactly one successful terminal result (`subtype: - "success"`, boolean `is_error: false`); **terminal**; + "success"`, boolean `is_error: false`); **terminal**. When the result + carries a structurally valid `modelUsage`, the event includes the same + bounded `usage` aggregate as the audit (tokens, web-search requests, + `total_cost_usd`) — and makes no usage claim otherwise; - `completed_error` — exactly one unsuccessful (or ambiguous-fields) - terminal result; **terminal**; + terminal result; **terminal**; carries the bounded `usage` aggregate + under the same rule; - `completed_ambiguous` — more than one terminal result, or a terminal result on malformed evidence (integrity failures win over a tempting completion verdict, exactly as in the audit); **terminal**; @@ -354,6 +427,12 @@ effort = "high" fallback = "stop" permission_mode = "auto" tmux_prefix = "api-" +# Optional (0.3.0b1): enforceable per-session budget, passed to Claude as +# exactly one `--max-budget-usd 12.5`. Must be a finite positive TOML +# number with a plain-decimal form; booleans, zero, negatives, NaN, +# infinities, strings, and exponent-only representations are rejected +# before any command is built. Omit the key for no budget flag at all. +max_budget_usd = 12.5 ``` `permission_mode` is required and allowlisted (`acceptEdits`, `auto`, @@ -420,13 +499,24 @@ the expected Claude Code JSONL transcript path (`~/.claude/projects//.jsonl`; the session id is passed to Claude via `--session-id`). -Known limitation: the brief and task text are passed inline on the Claude -command line (`--append-system-prompt` / `-p`), so they are visible to -same-host process inspection (e.g. `ps`, `/proc//cmdline`) for the -lifetime of the process. Do not put anything in a task file that other -users of the same host must not see; secret-shaped content is rejected -before launch. Moving the task payload to stdin is deliberately deferred — -the MVP depends only on the verified inline flags. +Prompt payload handling (0.3.0b1): the tmux command line never carries +prompt text — the brief and task travel as run-scoped mode-0600 payload +files (`brief.payload`, `task.payload` in the run directory), written from +the single scanned read and verified by sha256 before delivery. The task +reaches Claude on **stdin** and appears on no process command line at all. +The **brief** is substituted into the Claude argv at exec time, so it +**remains visible to same-host process inspection** (e.g. `ps`, +`/proc//cmdline`) of the Claude process for its lifetime, and the +payload files themselves are readable by the same UID. Do not put anything +in a task file that other users of the same host must not see; +secret-shaped content is rejected before launch. A dry run writes only the +manifest — it leaves no payload material behind, and its manifest records +`payload_files_created: false` (payload paths are descriptive only). A +launch manifest carries `payload_files_created: true` only in records +written after **both** run-scoped payload files were actually created; +records written before that point (the `pending` record, or a `failed` +record after a payload-write failure) truthfully say `false`. Manifests +from earlier releases lack the key and stay fully readable. ## Migrating from claude-context-tools 0.1.x @@ -464,13 +554,15 @@ the old config/state directories, a rollback finds them exactly as 0.1.x left them; delete `~/.config/fable-session/` and `~/.local/state/fable-session/` if you want no trace of the trial. -The three old console names remain installed for exactly one migration -release as **deprecated compatibility aliases** (`claude-context-run`, -`claude-context-audit-models`, `claude-session-watchdog`). They delegate to -the renamed implementation, keep their old flags, help, and exit codes, and -will be removed in the release after 0.2.0. All new documentation and -examples use only `fable-session`. There is deliberately no short alias — -nothing named `fs` is ever installed. +The three old console names (`claude-context-run`, +`claude-context-audit-models`, `claude-session-watchdog`) were kept for +exactly one migration release (0.2.x) as deprecated compatibility aliases +and are **removed in 0.3.0b1**: they are no longer installed and no longer +exist as entry points. Use `fable-session run|audit|watch`. Reading old +manifests is unaffected — `fable-session audit --manifest` and +`fable-session watch --manifest` still accept 0.1.x manifests when pointed +at them explicitly. There is deliberately no short alias — nothing named +`fs` is ever installed. ## Development @@ -496,22 +588,64 @@ commands, and one sanitized dry-run from outside the checkout: python3 tests/offline_install_smoke.py ``` +In restricted CI environments the smoke accepts an explicitly pre-seeded +wheelhouse for its offline build backend: point `FABLE_SESSION_WHEELHOUSE` +at a directory containing a `setuptools>=77` wheel downloaded in an +earlier, clearly network-allowed step; the install phase itself still runs +with `--no-index --no-build-isolation --no-deps`. + The public-readiness check scans the tracked tree (naming, notices, no -internal identifiers, no credential-shaped literals) and runs locally and -in CI: +internal identifiers, no credential-shaped literals), and its full-history +sibling applies the same policy to every tracked blob of every commit +reachable from HEAD; both run locally and in CI: ```bash python3 tests/public_readiness_check.py +python3 tests/full_history_readiness_check.py ``` +## Release notes + +**0.3.0b1** (public beta 1; the matching GitHub prerelease tag is +`v0.3.0-beta.1`): + +- optional registry key `max_budget_usd`: an enforceable Claude budget + passed as exactly one `--max-budget-usd` argument, printed in the + summary and recorded as structural manifest metadata; +- command-length/privacy repair: prompt payloads moved off the tmux + command line into hash-verified run-scoped 0600 payload files (brief via + placeholder substitution, task via stdin), fixing the `command too long` + launch failure for large task/brief pairs; +- privacy-safe usage reporting: bounded `modelUsage` aggregates (tokens, + web-search requests, `total_cost_usd`) in `audit --format json`, audit + text output, and terminal `watch` events — explicit `null` instead of + invented zeroes, and malformed usage never falsely proves purity; +- the three deprecated 0.1 console aliases are removed after their one + migration release; +- public packaging metadata (SPDX MIT, README long description, honest + classifiers), pinned CI actions, a full-history readiness scan, and a + tag-triggered attested prerelease workflow. + +## Contributing + +Small, focused contributions are welcome once the repository is public: +open an issue or PR at the official repository. Keep changes test-first +(`python3 -m unittest discover -s tests`), offline, stdlib-only, and +within the documented guarantees — anything that weakens the no-fallback, +permission, secret-scanning, or evidence-integrity contracts (or tries to +bypass safeguards) will not be accepted. Security reports: `SECURITY.md`, +never a public issue. + ## Status and license fable-session is licensed under the **MIT License** — see [LICENSE](LICENSE); package metadata says the same. The official home for this project is -`https://github.com/getaskclaw/fable-session`. Public release remains -blocked until the remaining items in `PUBLIC_RELEASE_CHECKLIST.md` are -complete — in particular, the existing git history must never be pushed -publicly as-is. Security reports: see `SECURITY.md`. +`https://github.com/getaskclaw/fable-session` (private while the release +checklist is completed; it holds the independently reviewed clean root +commit). Public release remains blocked until the remaining items in +`PUBLIC_RELEASE_CHECKLIST.md` are complete; the ORIGINAL pre-export +working history must never be pushed publicly. Security reports: see +`SECURITY.md`. ## Out of scope diff --git a/SECURITY.md b/SECURITY.md index 571f45f..836724b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,4 +46,5 @@ case and will not be fixed "as a feature". ## Supported versions -Only the latest release line (0.2.x) receives security fixes. +Only the latest release line — 0.3.x, currently the 0.3.0b1 public beta — +receives security fixes. diff --git a/pyproject.toml b/pyproject.toml index 9b7c363..3319e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,38 @@ [build-system] -requires = ["setuptools>=68"] +# PEP 639 (SPDX license expression + license-files) needs setuptools >= 77. +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "fable-session" -version = "0.2.0" +version = "0.3.0b1" description = "Bounded, pinned Claude Code sessions: launcher, post-run model-purity audit, and exact-lane watchdog. Unofficial; not affiliated with Anthropic." +readme = "README.md" requires-python = ">=3.12" -# Table form: the pinned offline build uses setuptools 68 (pre-PEP-639). -license = { text = "MIT" } +license = "MIT" +license-files = ["LICENSE"] +# Runtime dependencies stay empty by contract: Python 3.12 stdlib only. +dependencies = [] +keywords = [ + "claude", + "claude-code", + "tmux", + "session", + "audit", + "agent", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + # Honest platform claim: Linux is the tested real-host combination. + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", + "Topic :: Utilities", +] [project.urls] Homepage = "https://github.com/getaskclaw/fable-session" @@ -16,13 +40,10 @@ Repository = "https://github.com/getaskclaw/fable-session" Issues = "https://github.com/getaskclaw/fable-session/issues" [project.scripts] +# Exactly one console command. The deprecated 0.1 compatibility aliases +# were retained for exactly one migration release (0.2.x) and are removed +# here. Never add an `fs` alias. fable-session = "fable_session.cli:main" -# Deprecated compatibility aliases, retained for exactly one migration -# release. They delegate to the renamed implementation under the old -# command names. Never add an `fs` alias. -claude-context-run = "fable_session.runner:legacy_main" -claude-context-audit-models = "fable_session.model_audit:legacy_main" -claude-session-watchdog = "fable_session.watchdog:legacy_main" [tool.setuptools] package-dir = { "" = "src" } diff --git a/src/fable_session/__init__.py b/src/fable_session/__init__.py index b00baec..ef50a93 100644 --- a/src/fable_session/__init__.py +++ b/src/fable_session/__init__.py @@ -2,4 +2,5 @@ (``fable-session run``), post-run model-purity audit (``fable-session audit``), and exact-lane watchdog (``fable-session watch``).""" -__version__ = "0.2.0" +# PEP 440 prerelease; the matching GitHub prerelease tag is v0.3.0-beta.1. +__version__ = "0.3.0b1" diff --git a/src/fable_session/capture.py b/src/fable_session/capture.py index c893e25..350bc83 100644 --- a/src/fable_session/capture.py +++ b/src/fable_session/capture.py @@ -1,9 +1,12 @@ -"""Shell-free stdout capture wrapper for runner-launched Claude lanes. +"""Shell-free stdout capture and payload delivery for runner lanes. ``fable-session run`` starts this wrapper (by absolute file path, argv only) inside the new tmux session: - capture.py --dest /abs/run-dir/stream.jsonl -- + capture.py --dest /abs/run-dir/stream.jsonl \ + [--brief-payload /abs/run-dir/brief.payload --brief-sha256 ] \ + [--task-payload /abs/run-dir/task.payload --task-sha256 ] \ + -- It creates the destination stream file exclusively (``O_CREAT | O_EXCL | O_NOFOLLOW``, mode 0600 — the stream is private transcript data), executes @@ -13,15 +16,34 @@ ``result`` event the watchdog and model audit require — byte-for-byte into the stream. The wrapper never reads, parses, alters, summarizes, retries, or synthesizes stream events, and never mirrors the private stream to its -own stdout. Claude's stderr and stdin stay attached to the tmux pane. +own stdout. Claude's stderr stays attached to the tmux pane. -Fail-closed rules: +Payload delivery (0.3.0b1 command-length/privacy repair): the brief and +task prompt texts travel in run-scoped, mode-0600 payload files written by +the runner from its single scanned read — never on the tmux command line, +which stays short and payload-free regardless of task size. The wrapper +opens each payload without following symlinks, requires a regular file, +verifies its sha256 against the hash the runner recorded, and only then +delivers it: +- the brief replaces the runner's placeholder token + (:data:`BRIEF_PLACEHOLDER`), which must occur exactly once in the Claude + argv when a brief payload is given — and never when one is not; +- the task payload becomes Claude's stdin (``-p`` print mode reads the + prompt from stdin), so the task text appears on no process command line + at all. + +Fail-closed rules, all checked BEFORE the destination is created and +before Claude is invoked: + +- a missing/relative/symlinked/non-regular payload, or one whose bytes do + not match the recorded sha256, is a refusal (``EXIT_PAYLOAD``) that + never echoes payload text; +- a payload flag without its hash, an unresolved or duplicated + placeholder, a relative/malformed argv, or a relative ``--dest`` is a + usage refusal (``EXIT_USAGE``); - an existing destination (file, directory, or symlink — dangling or not) - is never overwritten, adopted, or followed: exit ``EXIT_DEST`` before - Claude is invoked; -- a destination that cannot be created or a relative/malformed argv is a - refusal (``EXIT_DEST`` / ``EXIT_USAGE``), never a traceback; + is never overwritten, adopted, or followed: exit ``EXIT_DEST``; - a Claude binary that cannot be executed is reported as ``EXIT_SPAWN``; - otherwise the wrapper exits with Claude's own exit status (a signal-killed child maps to the conventional ``128 + signal``). @@ -29,21 +51,33 @@ This file is intentionally self-contained (stdlib only, no package imports) so tmux can execute it by path in environments where the package is not importable. The documented same-UID threat model applies: another -process of the same user can remove or replace the stream after creation; -this tool does not defend against its own user. +process of the same user can remove or replace the stream or payloads +after creation; the sha256 check narrows, but cannot close, that window. +This tool does not defend against its own user. """ from __future__ import annotations +import hashlib import os +import stat import subprocess import sys EXIT_USAGE = 2 EXIT_DEST = 3 EXIT_SPAWN = 4 +EXIT_PAYLOAD = 5 + +# Must match runner.BRIEF_PLACEHOLDER exactly (this file stays import-free +# by design; the equality is pinned by a unit test). +BRIEF_PLACEHOLDER = "@fable-session-brief-payload@" -_USAGE = "usage: capture.py --dest /abs/stream.jsonl -- " +_USAGE = ( + "usage: capture.py --dest /abs/stream.jsonl " + "[--brief-payload /abs/file --brief-sha256 ] " + "[--task-payload /abs/file --task-sha256 ] -- " +) def _fail(code: int, message: str) -> int: @@ -51,24 +85,151 @@ def _fail(code: int, message: str) -> int: return code +def _parse(argv: list[str]) -> tuple[dict, list[str]] | None: + """Parse the option block before ``--``; None on any malformed shape.""" + options = {} + known = ("--dest", "--brief-payload", "--brief-sha256", + "--task-payload", "--task-sha256") + i = 0 + while i < len(argv): + if argv[i] == "--": + break + if argv[i] not in known or argv[i] in options or i + 1 >= len(argv): + return None + options[argv[i]] = argv[i + 1] + i += 2 + else: + return None # no `--` separator + cmd = argv[i + 1:] + if "--dest" not in options or not cmd: + return None + for flag, hash_flag in (("--brief-payload", "--brief-sha256"), + ("--task-payload", "--task-sha256")): + if (flag in options) != (hash_flag in options): + return None # a payload always travels with its hash + return options, cmd + + +class _PayloadError(Exception): + """A payload failed verification; the message never carries its text.""" + + +def _open_verified_payload(path: str, expected_sha256: str, label: str) -> int: + """Open *path* without following symlinks, require a regular file, + verify its full content hash, and return the FD positioned at 0.""" + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + except OSError as exc: + raise _PayloadError( + f"cannot open {label} payload {path} ({exc}); symlinks are " + "never followed" + ) from exc + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise _PayloadError( + f"{label} payload {path} is not a regular file" + ) + digest = hashlib.sha256() + while True: + chunk = os.read(fd, 65536) + if not chunk: + break + digest.update(chunk) + if digest.hexdigest() != expected_sha256: + raise _PayloadError( + f"{label} payload {path} does not match its recorded " + "sha256; refusing to deliver unverified prompt bytes" + ) + os.lseek(fd, 0, os.SEEK_SET) + except _PayloadError: + os.close(fd) + raise + except OSError as exc: + os.close(fd) + raise _PayloadError( + f"cannot verify {label} payload {path}: {exc}" + ) from exc + return fd + + def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) - if len(argv) < 4 or argv[0] != "--dest" or argv[2] != "--": + parsed = _parse(argv) + if parsed is None: return _fail(EXIT_USAGE, _USAGE) - dest, cmd = argv[1], argv[3:] + options, cmd = parsed + dest = options["--dest"] if not os.path.isabs(dest): return _fail(EXIT_USAGE, f"--dest must be absolute, got '{dest}'") + for flag in ("--brief-payload", "--task-payload"): + if flag in options and not os.path.isabs(options[flag]): + return _fail( + EXIT_USAGE, f"{flag} must be absolute, got '{options[flag]}'" + ) if not os.path.isabs(cmd[0]): # tmux runs this wrapper with cwd changed to the project repo, so a # relative argv[0] would resolve against that repo (or PATH) — not # against whatever the runner verified. Refused here, before the # destination is created and before anything can execute. Only - # argv[0] is echoed: later argv words carry private prompt payloads. + # argv[0] is echoed: later argv words could carry private text. return _fail( EXIT_USAGE, f"command executable must be an absolute path, got '{cmd[0]}'", ) + # Placeholder discipline before any side effect: with a brief payload + # the placeholder must occur exactly once (the substitution slot the + # runner built); without one it must not occur at all — an unresolved + # placeholder is never executed. + placeholder_count = sum(1 for arg in cmd if arg == BRIEF_PLACEHOLDER) + if "--brief-payload" in options: + if placeholder_count != 1: + return _fail( + EXIT_USAGE, + f"the brief placeholder must occur exactly once in the " + f"command (found {placeholder_count})", + ) + elif placeholder_count: + return _fail( + EXIT_USAGE, + "the command carries a brief placeholder but no --brief-payload " + "was given; refusing to execute an unresolved placeholder", + ) + + task_fd = -1 + try: + # Verify every payload BEFORE the stream is created and before + # Claude can run; a verification failure leaves no trace behind. + if "--brief-payload" in options: + brief_fd = _open_verified_payload( + options["--brief-payload"], options["--brief-sha256"], "brief" + ) + try: + brief_bytes = b"" + while True: + chunk = os.read(brief_fd, 65536) + if not chunk: + break + brief_bytes += chunk + finally: + os.close(brief_fd) + try: + brief_text = brief_bytes.decode("utf-8") + except UnicodeDecodeError: + return _fail( + EXIT_PAYLOAD, "brief payload is not valid UTF-8" + ) + cmd = [brief_text if arg == BRIEF_PLACEHOLDER else arg + for arg in cmd] + if "--task-payload" in options: + task_fd = _open_verified_payload( + options["--task-payload"], options["--task-sha256"], "task" + ) + except _PayloadError as exc: + if task_fd >= 0: + os.close(task_fd) + return _fail(EXIT_PAYLOAD, str(exc)) + # Exclusive, non-following creation: an existing entry or a symlink # (dangling included) fails here, before Claude is ever invoked, and # nothing pre-existing is overwritten or adopted. 0600: the stream is @@ -80,6 +241,8 @@ def main(argv: list[str] | None = None) -> int: 0o600, ) except OSError as exc: + if task_fd >= 0: + os.close(task_fd) return _fail( EXIT_DEST, f"cannot create stream file {dest} exclusively ({exc}); " @@ -88,18 +251,24 @@ def main(argv: list[str] | None = None) -> int: try: # argv exec only — no shell, no pipeline, no redirection strings. # The child inherits the stream FD as stdout, so every byte Claude - # writes lands in the stream unmodified; stderr/stdin stay on the - # tmux pane. + # writes lands in the stream unmodified; stderr stays on the tmux + # pane. stdin is the verified task payload when one was given. try: - proc = subprocess.Popen(cmd, stdout=fd) + proc = subprocess.Popen( + cmd, + stdout=fd, + stdin=task_fd if task_fd >= 0 else None, + ) except (OSError, ValueError) as exc: return _fail(EXIT_SPAWN, f"cannot execute {cmd[0]}: {exc}") returncode = proc.wait() finally: - try: - os.close(fd) - except OSError: - pass + for open_fd in (fd, task_fd): + if open_fd >= 0: + try: + os.close(open_fd) + except OSError: + pass if returncode < 0: return 128 - returncode # killed by signal N -> 128 + N return returncode diff --git a/src/fable_session/config.py b/src/fable_session/config.py index 5170cfb..810212c 100644 --- a/src/fable_session/config.py +++ b/src/fable_session/config.py @@ -16,6 +16,7 @@ from __future__ import annotations import hashlib +import math import re import tomllib from dataclasses import dataclass @@ -41,8 +42,10 @@ class ConfigError(ValueError): PROJECT_KEYS = ( "repo", "profile", "model", "effort", "fallback", "permission_mode", - "tmux_prefix", + "tmux_prefix", "max_budget_usd", ) +# Keys that MAY be absent: 0.2 registries without them stay valid. +OPTIONAL_PROJECT_KEYS = ("max_budget_usd",) PROFILE_KEYS = ("version", "product", "context_mode", "max_brief_bytes", "allowed_roots") # Project names become TOML keys, run-id components, and summary text: strict @@ -75,6 +78,39 @@ class ConfigError(ValueError): ) +# Canonical `--max-budget-usd` argv shape: plain decimal digits with an +# optional fractional part — never exponents, signs, whitespace, inf, or nan. +_BUDGET_CANONICAL_RE = re.compile(r"[0-9]+(\.[0-9]+)?") + + +def format_budget_usd(value) -> str: + """Return the canonical ``--max-budget-usd`` argument for *value*. + + Accepts exactly what :func:`resolve_project` accepts for + ``max_budget_usd``: a finite positive TOML number (int or float, never + a bool) whose round-trip form is plain decimal. Anything else raises + :class:`ConfigError` — validation happens before any Claude command is + constructed, so an unsafe budget never reaches an argv. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigError( + f"max_budget_usd must be a TOML number (integer or float), " + f"got {value!r}" + ) + if not math.isfinite(value): + raise ConfigError(f"max_budget_usd must be finite, got {value!r}") + if value <= 0: + raise ConfigError(f"max_budget_usd must be positive, got {value!r}") + canonical = str(value) if isinstance(value, int) else repr(value) + if not _BUDGET_CANONICAL_RE.fullmatch(canonical): + raise ConfigError( + f"max_budget_usd {value!r} has no safe plain-decimal " + f"representation (round-trips as {canonical!r}); use a plain " + "decimal like 12.5" + ) + return canonical + + def find_secret_markers(text: str) -> list[str]: """Return human-readable descriptions of secret-shaped content in *text*.""" hits: list[str] = [] @@ -95,6 +131,9 @@ class ProjectConfig: fallback: str permission_mode: str tmux_prefix: str + # Optional enforceable Claude budget: a validated finite positive TOML + # number, or None when the registry does not configure one. + max_budget_usd: int | float | None = None @dataclass(frozen=True) @@ -205,7 +244,7 @@ def resolve_project(name: str, registry_path: Path | None = None) -> ProjectConf where = f"[project.{name}] in {registry_path}" _reject_unknown_and_secret_keys(table, PROJECT_KEYS, where) for key in PROJECT_KEYS: - if key not in table: + if key not in table and key not in OPTIONAL_PROJECT_KEYS: raise ConfigError(f"{where}: missing required key '{key}'") _reject_secret_values(table, where) @@ -279,6 +318,16 @@ def resolve_project(name: str, registry_path: Path | None = None) -> ProjectConf "whitespace and control characters are rejected)" ) + # Optional budget: validated as a finite positive TOML number with a + # safe canonical decimal form BEFORE any command construction. Absent + # means no budget flag is ever emitted (recorded truthfully as None). + max_budget_usd = table.get("max_budget_usd") + if max_budget_usd is not None: + try: + format_budget_usd(max_budget_usd) + except ConfigError as exc: + raise ConfigError(f"{where}: {exc}") from None + return ProjectConfig( name=name, repo=repo, @@ -289,6 +338,7 @@ def resolve_project(name: str, registry_path: Path | None = None) -> ProjectConf fallback=fallback, permission_mode=permission_mode, tmux_prefix=tmux_prefix, + max_budget_usd=max_budget_usd, ) diff --git a/src/fable_session/jsonl.py b/src/fable_session/jsonl.py index bec27e1..c6f0e53 100644 --- a/src/fable_session/jsonl.py +++ b/src/fable_session/jsonl.py @@ -63,6 +63,7 @@ from __future__ import annotations import json +import math from pathlib import Path SCHEMA_VERSION = 1 @@ -82,6 +83,11 @@ REASON_MISSING_TERMINAL_RESULT = "missing_terminal_result" REASON_MULTIPLE_TERMINAL_RESULTS = "multiple_terminal_results" REASON_UNSUCCESSFUL_TERMINAL_RESULT = "unsuccessful_terminal_result" +# Optional usage corruption is an integrity failure for the audit: a +# transcript whose structural modelUsage cannot be trusted must never +# falsely prove model purity. A MISSING modelUsage is not corruption — +# usage is simply reported as an explicit null. +REASON_MALFORMED_MODEL_USAGE = "malformed_model_usage" INTEGRITY_REASONS = frozenset( { @@ -94,6 +100,7 @@ REASON_MISSING_TERMINAL_RESULT, REASON_MULTIPLE_TERMINAL_RESULTS, REASON_UNSUCCESSFUL_TERMINAL_RESULT, + REASON_MALFORMED_MODEL_USAGE, } ) @@ -128,6 +135,52 @@ def _nonempty_str(value) -> str | None: return value if isinstance(value, str) and value else None +# Bounded usage aggregation: camelCase modelUsage counters -> stable +# snake_case output keys. Counters must be non-negative ints (never bools); +# cost must be a non-negative finite number. Nothing else from modelUsage is +# ever copied out. +_USAGE_COUNT_FIELDS = ( + ("inputTokens", "input_tokens"), + ("outputTokens", "output_tokens"), + ("cacheReadInputTokens", "cache_read_input_tokens"), + ("cacheCreationInputTokens", "cache_creation_input_tokens"), + ("webSearchRequests", "web_search_requests"), +) + + +def _aggregate_model_usage(model_usage) -> tuple[dict | None, bool]: + """Aggregate one result's ``modelUsage`` across models. + + Returns ``(usage, malformed)``: a bounded snake_case aggregate and + ``False`` when every present field validates; ``(None, True)`` on any + structural corruption (non-dict shapes, string/boolean/negative + counters, non-finite or negative cost). Fields absent from an entry are + tolerated; a missing ``modelUsage`` never reaches this function. + """ + if not isinstance(model_usage, dict): + return None, True + totals: dict = {out_key: 0 for _, out_key in _USAGE_COUNT_FIELDS} + cost = 0.0 + for entry in model_usage.values(): + if not isinstance(entry, dict): + return None, True + for in_key, out_key in _USAGE_COUNT_FIELDS: + if in_key in entry: + value = entry[in_key] + if isinstance(value, bool) or not isinstance(value, int) \ + or value < 0: + return None, True + totals[out_key] += value + if "costUSD" in entry: + value = entry["costUSD"] + if isinstance(value, bool) or not isinstance(value, (int, float)) \ + or not math.isfinite(value) or value < 0: + return None, True + cost += value + totals["total_cost_usd"] = cost + return totals, False + + def _has_text_content(message: dict) -> bool: """True if the assistant message carries non-empty text content.""" content = message.get("content") @@ -186,6 +239,7 @@ def scan_lifecycle(path: Path | str) -> dict: session_ids: set[str] = set() result_count = 0 result_meta: dict | None = None + usage_meta: dict | None = None with open(path, "r", encoding="utf-8", errors="replace") as handle: for raw_line in handle: @@ -251,12 +305,25 @@ def scan_lifecycle(path: Path | str) -> dict: } # With several results none is silently chosen. result_meta = meta if result_count == 1 else None + # Bounded usage aggregate for the watchdog's terminal + # events: only when structurally available — a missing or + # malformed modelUsage yields None (usage corruption is not + # lifecycle-relevant and never taints completion evidence). + current_usage = None + if "modelUsage" in entry: + current_usage, malformed = _aggregate_model_usage( + entry.get("modelUsage") + ) + if malformed: + current_usage = None + usage_meta = current_usage if result_count == 1 else None return { "schema_version": SCHEMA_VERSION, "events": events, "result_count": result_count, "result": result_meta, + "usage": usage_meta, "integrity_reasons": sorted(reasons), "incomplete_trailing": incomplete_trailing, "session_ids": sorted(session_ids), @@ -280,6 +347,8 @@ def audit_transcript(path: Path | str, requested_model: str) -> dict: session_ids: set[str] = set() result_meta: dict | None = None result_count = 0 + usage_meta: dict | None = None + usage_malformed = False # Id of the last unique assistant message with non-empty text; a special # False sentinel means the last text message can never be attributed. final_text_id: str | None | bool = None @@ -358,6 +427,15 @@ def audit_transcript(path: Path | str, requested_model: str) -> dict: usage = entry.get("modelUsage") if isinstance(usage, dict): model_usage_keys.update(k for k in usage if _nonempty_str(k)) + # Bounded usage aggregate: explicit None when absent, an + # integrity failure (sticky) when structurally corrupt, and + # never chosen from one of several results. + current_usage = None + if "modelUsage" in entry: + current_usage, malformed = _aggregate_model_usage(usage) + if malformed: + usage_malformed = True + usage_meta = current_usage if result_count == 1 else None # Safe fields only; the free-text `result` is never copied. result_meta = { "subtype": _nonempty_str(entry.get("subtype")), @@ -380,6 +458,10 @@ def audit_transcript(path: Path | str, requested_model: str) -> dict: # One completed transcript must carry exactly one successful top-level # result event. Anything else — interrupted stream, concatenated runs, # errored run, ambiguous is_error — is incomplete integrity evidence. + if usage_malformed: + # Corrupt optional usage must never falsely prove model purity. + reasons.add(REASON_MALFORMED_MODEL_USAGE) + usage_meta = None if result_count == 0: reasons.add(REASON_MISSING_TERMINAL_RESULT) elif result_count > 1: @@ -431,5 +513,6 @@ def audit_transcript(path: Path | str, requested_model: str) -> dict: "evidence_complete": evidence_complete, "reason_codes": sorted(reasons), "result": result_meta, + "usage": usage_meta, "session_ids": sorted(session_ids), } diff --git a/src/fable_session/model_audit.py b/src/fable_session/model_audit.py index a8fccc8..4db1969 100644 --- a/src/fable_session/model_audit.py +++ b/src/fable_session/model_audit.py @@ -35,7 +35,6 @@ class ModelAuditError(ValueError): PROG = "fable-session audit" -LEGACY_PROG = "claude-context-audit-models" _EXIT_CODES = {jsonl.VERDICT_PURE: 0, jsonl.VERDICT_MIXED: 1, jsonl.VERDICT_UNKNOWN: 2} @@ -86,11 +85,6 @@ def _parse_args(argv: list[str] | None, prog: str = PROG) -> argparse.Namespace: "Audit a completed Claude Code JSONL transcript for model purity " "(PURE=0, MIXED=1, UNKNOWN/error=2)." ) - if prog == LEGACY_PROG: - description += ( - " This command name is a deprecated compatibility alias of " - "'fable-session audit', retained for one migration release." - ) parser = argparse.ArgumentParser(prog=prog, description=description) parser.add_argument( "--transcript", help="absolute path to the session JSONL transcript" @@ -150,6 +144,13 @@ def dash(value) -> str: if audit["fallback_models"] else "" ), + # Bounded usage aggregate (0.3.0b1): explicit unavailability instead + # of invented zeroes when there is no single trustworthy modelUsage. + "usage: " + ( + " ".join(f"{key}={value}" for key, value in audit["usage"].items()) + if audit["usage"] is not None + else "unavailable (missing, malformed, or ambiguous modelUsage)" + ), f"evidence complete: {'yes' if audit['evidence_complete'] else 'no'}", f"reason codes: {', '.join(audit['reason_codes']) or '—'}", f"result: subtype={dash(result.get('subtype'))} " @@ -184,12 +185,5 @@ def main(argv: list[str] | None = None, prog: str = PROG) -> int: return _EXIT_CODES[audit["verdict"]] -def legacy_main(argv: list[str] | None = None) -> int: - """Deprecated ``claude-context-audit-models`` compatibility alias (one - migration release only): identical flags and behavior under the old - command name.""" - return main(argv, prog=LEGACY_PROG) - - if __name__ == "__main__": sys.exit(main()) diff --git a/src/fable_session/runner.py b/src/fable_session/runner.py index e3e9baa..9a19cdb 100644 --- a/src/fable_session/runner.py +++ b/src/fable_session/runner.py @@ -37,8 +37,16 @@ replacement can be planted at the old path), so paths in these messages are labelled display-only and identity comes from the held FD, never from re-reading the path; -- only the verified inline flags ``--append-system-prompt`` / - ``--system-prompt`` are used (no file variants); +- only the verified prompt flags ``--append-system-prompt`` / + ``--system-prompt`` are used (no file variants). Since 0.3.0b1 the brief + and task payloads never ride the tmux command line: the runner writes + them as run-scoped mode-0600 payload files from its single scanned read, + and the capture wrapper — after verifying each payload's sha256 against + the recorded hash — substitutes the brief for the one + :data:`BRIEF_PLACEHOLDER` token in the Claude argv and feeds the task to + Claude on stdin (``-p`` with no inline prompt). This also repairs the + verified `tmux: command too long` launch failure for large task/brief + pairs; - launch starts a NEW named tmux session only and fails if the name exists — it never adopts, resumes, or replaces an existing session; - terminal evidence is captured, not inferred: Claude runs with @@ -55,9 +63,10 @@ future paths but create no stream; - user-facing output and the run manifest carry hashes, sizes, and paths — never the brief or task prompt contents (the capture argv is recorded - with the same redaction). Note the inline brief/task DO appear in the - Claude process argv and are visible to same-host process inspection (see - README). + with the same redaction). The task text appears on NO process command + line; the brief is substituted into the Claude argv at exec time and + remains visible to same-host process inspection of the Claude process, + as do the run-scoped 0600 payload files to the same UID (see README). """ from __future__ import annotations @@ -80,6 +89,7 @@ from .config import ( ALLOWED_PERMISSION_MODES, ConfigError, + format_budget_usd, load_profile, resolve_project, ) @@ -101,6 +111,17 @@ class RunnerError(ValueError): # session stops — it never silently falls back. NO_FALLBACK_SETTINGS = json.dumps({"switchModelsOnFlag": False}) +# Substitution slot for the brief payload: the built Claude argv carries +# this token instead of the brief text; the capture wrapper replaces the +# single occurrence with the sha256-verified payload at exec time. Must +# match capture.BRIEF_PLACEHOLDER (pinned by a unit test; capture.py is +# import-free by design). +BRIEF_PLACEHOLDER = "@fable-session-brief-payload@" + +# Run-scoped private payload file names (mode 0600, inside the run dir). +BRIEF_PAYLOAD_FILENAME = "brief.payload" +TASK_PAYLOAD_FILENAME = "task.payload" + # Full tmux session name allowlist: ':' and '.' are tmux target syntax # (session:window.pane) and could address other sessions; whitespace and # control characters could forge summary lines. Checked with fullmatch(): @@ -116,7 +137,6 @@ class RunnerError(ValueError): _SESSION_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "fable-session") PROG = "fable-session run" -LEGACY_PROG = "claude-context-run" def find_claude(explicit: Path | None = None, home: Path | None = None) -> Path: @@ -181,17 +201,47 @@ def capture_wrapper_path() -> Path: return Path(__file__).resolve().with_name("capture.py") -def build_capture_command(stream_path: Path, claude_cmd: list[str]) -> list[str]: +def payload_paths(run_id: str, state_dir: Path | None = None) -> tuple[Path, Path]: + """Run-scoped private payload files (brief, task) for one run. + + Written 0600 at launch time from the single scanned read; a dry run + only describes them and never creates them.""" + run_dir = state.runs_dir(state_dir) / run_id + return run_dir / BRIEF_PAYLOAD_FILENAME, run_dir / TASK_PAYLOAD_FILENAME + + +def build_capture_command( + stream_path: Path, + claude_cmd: list[str], + *, + brief_payload: Path, + brief_sha256: str, + task_payload: Path, + task_sha256: str, +) -> list[str]: """Wrap the exact Claude argv in the shell-free capture wrapper. The wrapper creates ``stream_path`` exclusively (0600, never overwrite or follow), hands it to Claude as stdout, and propagates Claude's exit - status. No shell, no pipeline, no redirection strings.""" + status. It also delivers the prompt payloads: the brief payload is + hash-verified and substituted for the single :data:`BRIEF_PLACEHOLDER` + in the Claude argv, and the task payload is hash-verified and attached + as Claude's stdin (``-p`` print mode reads the prompt from stdin). No + shell, no pipeline, no redirection strings — and no prompt text on any + tmux command line.""" return [ sys.executable, str(capture_wrapper_path()), "--dest", str(stream_path), + "--brief-payload", + str(brief_payload), + "--brief-sha256", + brief_sha256, + "--task-payload", + str(task_payload), + "--task-sha256", + task_sha256, "--", *claude_cmd, ] @@ -203,14 +253,25 @@ def build_command( effort: str, permission_mode: str, brief_mode: str, - brief: str, - task_text: str, session_id: str, + max_budget_usd: str | None = None, ) -> list[str]: - """Build the pinned Claude command. + """Build the pinned Claude command — payload-free by construction. Model, effort, and permission mode are explicit; no-fallback settings are always attached; fallback/resume/permission-bypass flags are never added. + ``max_budget_usd`` is the already-canonical decimal string from + :func:`config.format_budget_usd`; when set, exactly one + ``--max-budget-usd`` argument is emitted, and when ``None`` no budget + flag appears at all. + + Prompt payloads never appear here: the brief slot carries only + :data:`BRIEF_PLACEHOLDER` (substituted by the capture wrapper from the + hash-verified run-scoped payload file at exec time), and ``-p`` is + emitted with no inline prompt — the task arrives on Claude's stdin from + its own verified payload file. The command therefore stays short and + payload-free regardless of task/brief size (the 0.3.0b1 `command too + long` repair). """ if permission_mode not in ALLOWED_PERMISSION_MODES: raise RunnerError( @@ -230,6 +291,11 @@ def build_command( session_id, "--settings", NO_FALLBACK_SETTINGS, + *( + ["--max-budget-usd", max_budget_usd] + if max_budget_usd is not None + else [] + ), # Structured stdout is the lane's terminal evidence: the stream # carries Claude's top-level `result` event, which the watchdog and # model audit require and the native session JSONL never contains. @@ -239,9 +305,10 @@ def build_command( "stream-json", "--verbose", prompt_flag, - brief, + BRIEF_PLACEHOLDER, + # Print mode with NO inline prompt: the task payload arrives on + # stdin, so the task text appears on no process command line. "-p", - task_text, ] forbidden = [flag for flag in FORBIDDEN_FLAGS if flag in cmd] if forbidden: @@ -250,10 +317,16 @@ def build_command( def redact_command(cmd: list[str], brief: str, task_text: str) -> list[str]: - """Replace prompt payloads with hash/size placeholders for logs/manifest.""" + """Replace prompt payloads with hash/size placeholders for logs/manifest. + + The built command carries only :data:`BRIEF_PLACEHOLDER` in the brief + slot; it is rendered as the brief's hash/size token so logs and + manifests describe the effective command without any prompt text. The + literal brief/task branches remain as defense in depth — no built + command contains those payloads anymore.""" redacted = [] for arg in cmd: - if arg == brief: + if arg == BRIEF_PLACEHOLDER or arg == brief: redacted.append( f"" @@ -285,11 +358,6 @@ def _parse_args(argv: list[str] | None, prog: str = PROG) -> argparse.Namespace: description = ( "Build (and explicitly launch) a pinned, bounded Claude Code session." ) - if prog == LEGACY_PROG: - description += ( - " This command name is a deprecated compatibility alias of " - "'fable-session run', retained for one migration release." - ) parser = argparse.ArgumentParser(prog=prog, description=description) parser.add_argument("--project", required=True, help="project name from the registry") parser.add_argument("--task", required=True, help="absolute path to the task markdown file") @@ -489,9 +557,18 @@ def run(argv: list[str] | None = None, out=None, prog: str = PROG) -> int: ) status = "dry-run" + # Canonical budget argument (or None): validated at registry parse + # time, re-derived deterministically here before command construction. + budget_arg = ( + format_budget_usd(project.max_budget_usd) + if project.max_budget_usd is not None + else None + ) + cmd = build_command( claude_bin, project.model, project.effort, project.permission_mode, - profile.context_mode, brief, task_text, session_id, + profile.context_mode, session_id, + max_budget_usd=budget_arg, ) redacted = redact_command(cmd, brief, task_text) branch = git_branch(project.repo) @@ -508,6 +585,12 @@ def run(argv: list[str] | None = None, out=None, prog: str = PROG) -> int: print(f" permission mode: {project.permission_mode} " "(pinned via --permission-mode)", file=out) print(f" fallback enforcement: --settings {NO_FALLBACK_SETTINGS}", file=out) + if budget_arg is not None: + print(f" max budget: {budget_arg} USD " + f"(enforced via --max-budget-usd {budget_arg})", file=out) + else: + print(" max budget: none configured (no budget flag emitted)", + file=out) print(f" host: {platform.node()}", file=out) print(f" project: {project.name}", file=out) print(f" repo: {project.repo} (branch {branch})", file=out) @@ -524,9 +607,15 @@ def build_manifest(run_id: str) -> dict: # the capture wrapper will create; Claude's native session JSONL is # kept as a separate, clearly named diagnosis path. The capture # argv is recorded with the same hash/size redaction as the Claude - # command — never prompt contents. + # command — never prompt contents. Payload paths are descriptive: + # a launch writes them before tmux, a dry run creates nothing. stream = stream_transcript_path(run_id, args.state_dir) - capture_cmd = build_capture_command(stream, cmd) + brief_payload, task_payload = payload_paths(run_id, args.state_dir) + capture_cmd = build_capture_command( + stream, cmd, + brief_payload=brief_payload, brief_sha256=brief_sha, + task_payload=task_payload, task_sha256=task_sha, + ) return { "tool": f"fable-session {__version__}", "run_id": run_id, @@ -540,6 +629,9 @@ def build_manifest(run_id: str) -> dict: "fallback": project.fallback, "permission_mode": project.permission_mode, "no_fallback_settings": NO_FALLBACK_SETTINGS, + # Structural metadata only: the validated TOML number (or a + # truthful null when no budget is configured). + "max_budget_usd": project.max_budget_usd, "brief_mode": profile.context_mode, "brief_sha256": brief_sha, "brief_bytes": brief_bytes, @@ -551,6 +643,17 @@ def build_manifest(run_id: str) -> dict: "session_id": session_id, "expected_transcript_path": str(stream), "native_transcript_path": str(native_transcript), + # Run-scoped private payload files (paths + hashes only; a dry + # run describes them without creating them). + "brief_payload_path": str(brief_payload), + "task_payload_path": str(task_payload), + # Truthful payload REALITY, not intent: False until BOTH + # run-scoped 0600 payload files were actually created for a + # launch; every dry-run manifest stays False (paths above are + # descriptive only). Flipped by the launch path after its + # payload writes succeed; absent from pre-0.3.0b1 manifests, + # which stay fully readable (nothing keys off it). + "payload_files_created": False, "tmux_session": args.tmux if args.launch else None, "command_redacted": redacted, "capture_command_redacted": redact_command( @@ -593,10 +696,16 @@ def build_manifest(run_id: str) -> dict: run_id = reservation.run_id manifest = build_manifest(run_id) # The tmux session runs the capture wrapper (argv only, no shell): - # it creates the private stream exclusively and pipes Claude's - # structured stdout — terminal `result` included — into it. + # it creates the private stream exclusively, verifies and delivers + # the prompt payloads (brief via placeholder substitution, task via + # stdin), and pipes Claude's structured stdout — terminal `result` + # included — into the stream. The tmux command line itself never + # carries prompt text. + brief_payload, task_payload = payload_paths(run_id, args.state_dir) capture_cmd = build_capture_command( - stream_transcript_path(run_id, args.state_dir), cmd + stream_transcript_path(run_id, args.state_dir), cmd, + brief_payload=brief_payload, brief_sha256=brief_sha, + task_payload=task_payload, task_sha256=task_sha, ) print(f" run id: {run_id}", file=out) print(f" structured stream: " @@ -618,6 +727,28 @@ def build_manifest(run_id: str) -> dict: "launch" ) from exc print(f" run manifest: {manifest_path} (pending)", file=out) + # Write the run-scoped 0600 payload files from the SAME once-read + # text that was scanned and hashed (read-once contract), BEFORE + # tmux: a lane never starts without its verified payloads in place. + try: + reservation.write_private_file( + BRIEF_PAYLOAD_FILENAME, brief.encode("utf-8") + ) + reservation.write_private_file( + TASK_PAYLOAD_FILENAME, task_text.encode("utf-8") + ) + except state.StateError as exc: + failure = RunnerError( + "no tmux session was started: cannot write the run-scoped " + f"prompt payload files ({exc})" + ) + _record_failure_status(reservation, manifest, "failed", failure) + raise failure from exc + # BOTH payload files now really exist: every manifest write from + # here on records that truthfully. A partial or failed payload + # write above never reaches this line, so its 'failed' record + # keeps payload_files_created false. + manifest = {**manifest, "payload_files_created": True} try: _tmux_new_session(args.tmux, project.repo, capture_cmd) except RunnerError as exc: @@ -662,11 +793,5 @@ def main(argv: list[str] | None = None, prog: str = PROG) -> int: return 2 -def legacy_main(argv: list[str] | None = None) -> int: - """Deprecated ``claude-context-run`` compatibility alias (one migration - release only): identical flags and behavior under the old command name.""" - return main(argv, prog=LEGACY_PROG) - - if __name__ == "__main__": sys.exit(main()) diff --git a/src/fable_session/state.py b/src/fable_session/state.py index 2c85f59..8dc789b 100644 --- a/src/fable_session/state.py +++ b/src/fable_session/state.py @@ -146,6 +146,11 @@ def __init__(self, message: str, *, dev: int | None = None, # must satisfy the allowlist. _RUN_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}") +# Allowlist for run-scoped private file names (payload files): one path +# component, leading alphanumeric, then letters/digits/'.'/'_'/'-'. A +# leading dot (and thus '.'/'..') is rejected by the first class. +_PRIVATE_FILENAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + # Directory opens never follow a symlink in the final component: a swapped # entry fails with ELOOP instead of silently redirecting writes. _DIR_OPEN_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW @@ -422,6 +427,48 @@ def _held_fd(self) -> int: ) return self._fd + def write_private_file(self, name: str, data: bytes) -> Path: + """Create *name* exclusively inside the bound run directory and + durably write *data* (mode 0600, ``O_EXCL | O_NOFOLLOW``, via the + held run-directory FD — never a re-traversed path). + + Used for run-scoped private payload files (the brief/task prompt + bytes the capture wrapper verifies by hash). An existing entry — + including a planted symlink — is never overwritten, adopted, or + followed; the write fails closed as a :class:`StateError`.""" + if not isinstance(name, str) or not _PRIVATE_FILENAME_RE.fullmatch(name): + raise StateError( + f"unsafe private file name {name!r}: names must exactly " + f"match {_PRIVATE_FILENAME_RE.pattern} (single path " + "component; no separators, whitespace, or control characters)" + ) + dir_fd = self._held_fd() + display = self.path / name + try: + fd = os.open( + name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=dir_fd, + ) + except OSError as exc: + raise StateError( + f"cannot create private file {display} exclusively ({exc}); " + "existing entries and symlinks are never overwritten, " + "adopted, or followed" + ) from exc + try: + try: + _write_all(fd, data, display) + os.fsync(fd) + except OSError as exc: + raise StateError( + f"cannot write private file {display}: {exc}" + ) from exc + finally: + _close_quietly(fd) + return display + def write_manifest(self, manifest: dict) -> Path: """Atomically publish ``manifest`` through the held run FD. diff --git a/src/fable_session/watchdog.py b/src/fable_session/watchdog.py index 2e34d42..f101bcb 100644 --- a/src/fable_session/watchdog.py +++ b/src/fable_session/watchdog.py @@ -106,7 +106,6 @@ STATE_FILENAME = "watchdog.state.json" PROG = "fable-session watch" -LEGACY_PROG = "claude-session-watchdog" EVENT_COMPLETED = "completed" EVENT_COMPLETED_ERROR = "completed_error" @@ -325,10 +324,17 @@ def _lane_events(lane: Lane, scan: dict) -> tuple[list[tuple[str, dict]], str | ) elif scan["result_count"] == 1: meta = scan["result"] + # Bounded usage summary rides the terminal event only when it is + # structurally available (never invented from missing or malformed + # modelUsage; still no paths, prompts, results, or tool payloads). + usage = ({"usage": scan["usage"]} if scan.get("usage") is not None + else {}) if meta["subtype"] == "success" and meta["is_error"] is False: - terminal = (EVENT_COMPLETED, {"event": EVENT_COMPLETED, **meta}) + terminal = (EVENT_COMPLETED, + {"event": EVENT_COMPLETED, **meta, **usage}) else: - terminal = (EVENT_COMPLETED_ERROR, {"event": EVENT_COMPLETED_ERROR, **meta}) + terminal = (EVENT_COMPLETED_ERROR, + {"event": EVENT_COMPLETED_ERROR, **meta, **usage}) elif reasons: # Malformed evidence with no terminal result yet: a diagnostic, not # a terminal state — the stream may still be running. Keyed by the @@ -466,11 +472,6 @@ def _parse_args(argv: list[str] | None, prog: str = PROG) -> argparse.Namespace: "completion, 1 confirmed unsuccessful/ambiguous/interrupted " "terminal state, 2 usage or input error." ) - if prog == LEGACY_PROG: - description += ( - " This command name is a deprecated compatibility alias of " - "'fable-session watch', retained for one migration release." - ) parser = argparse.ArgumentParser(prog=prog, description=description) parser.add_argument( "--manifest", @@ -579,12 +580,5 @@ def main(argv: list[str] | None = None, prog: str = PROG) -> int: return EXIT_ERROR -def legacy_main(argv: list[str] | None = None) -> int: - """Deprecated ``claude-session-watchdog`` compatibility alias (one - migration release only): identical flags and behavior under the old - command name.""" - return main(argv, prog=LEGACY_PROG) - - if __name__ == "__main__": sys.exit(main()) diff --git a/tests/full_history_readiness_check.py b/tests/full_history_readiness_check.py new file mode 100644 index 0000000..12aec81 --- /dev/null +++ b/tests/full_history_readiness_check.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Deterministic FULL-HISTORY public-readiness scan. + +Run explicitly (it is intentionally NOT named test_*.py; the unit test +tests/test_full_history_readiness.py exercises it and CI runs it directly): + + python3 tests/full_history_readiness_check.py [repo-path] + +Scope and rules: + +- walks EVERY commit reachable from HEAD (``git rev-list HEAD``) and scans + EVERY tracked blob of every revision — a public push publishes the whole + history, so a scrubbed final tree must never hide a dirty old blob; +- reuses the tracked-tree checker's policy verbatim: the composed + INTERNAL_NEEDLES, the stale license-placeholder needle, and the + credential-pattern regexes are imported at runtime from + ``tests/public_readiness_check.py`` — one policy, two scopes, no drift; +- offline, stdlib-only, deterministic: same history, same verdict; +- unique blobs are scanned once (contents are content-addressed); each + finding names the blob, one example path, and one example commit; +- undecodable (binary) blobs carry nothing textual to leak-scan and are + skipped, exactly like the tracked-tree checker. + +This is the release gate behind the "one reviewed clean root commit" +claim: run it against the EXACT commit being published (CI checks out the +pushed history with full depth and runs it on every build). + +Exit codes: 0 the full reachable history is clean under this policy, +1 findings, 2 execution error (not a git repository, git failure). +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEFAULT_REPO = HERE.parent + + +def _load_policy(): + """Import the tracked-tree checker as a module: one shared policy.""" + spec = importlib.util.spec_from_file_location( + "public_readiness_check_policy", HERE / "public_readiness_check.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _git(repo: Path, *argv: str) -> bytes: + proc = subprocess.run( + ["git", "-C", str(repo), *argv], capture_output=True, + ) + if proc.returncode != 0: + raise RuntimeError( + f"git {' '.join(argv)} failed in {repo}: " + f"{proc.stderr.decode('utf-8', 'replace').strip()}" + ) + return proc.stdout + + +def history_blobs(repo: Path) -> tuple[int, list[tuple[str, str, str]]]: + """(commit count, [(blob sha, example commit, example path), ...]) for + every unique tracked blob in every commit reachable from HEAD.""" + commits = _git(repo, "rev-list", "HEAD").decode("ascii").split() + seen: set[str] = set() + blobs: list[tuple[str, str, str]] = [] + for commit in commits: + listing = _git(repo, "ls-tree", "-r", "-z", commit) + for entry in listing.split(b"\0"): + if not entry: + continue + meta, _, path = entry.partition(b"\t") + parts = meta.split() + if len(parts) != 3 or parts[1] != b"blob": + continue + sha = parts[2].decode("ascii") + if sha not in seen: + seen.add(sha) + blobs.append((sha, commit, path.decode("utf-8", "replace"))) + return len(commits), blobs + + +def scan_text(policy, text: str, where: str, findings: list[str]) -> None: + lowered = text.lower() + for needle in policy.INTERNAL_NEEDLES: + if needle.lower() in lowered: + findings.append(f"{where}: internal identifier {needle!r}") + if policy.STALE_LICENSE_NEEDLE in lowered: + findings.append( + f"{where}: stale license-placeholder wording " + f"({policy.STALE_LICENSE_NEEDLE!r})" + ) + for pattern in policy.CREDENTIAL_RES: + if pattern.search(text): + findings.append( + f"{where}: credential-shaped literal " + f"(pattern {pattern.pattern[:24]}...)" + ) + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + repo = Path(argv[0]).resolve() if argv else DEFAULT_REPO + policy = _load_policy() + + findings: list[str] = [] + try: + commit_count, blobs = history_blobs(repo) + for sha, commit, path in blobs: + raw = _git(repo, "cat-file", "blob", sha) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + continue # binary blob: nothing textual to leak-scan + scan_text( + policy, text, + f"blob {sha[:12]} (e.g. {path} @ {commit[:12]})", + findings, + ) + except RuntimeError as exc: + print(f"full_history_readiness_check: error: {exc}", file=sys.stderr) + return 2 + + coverage = f"({commit_count} commits, {len(blobs)} unique blobs scanned" + if findings: + print(f"FULL-HISTORY READINESS: FAIL {coverage})") + for finding in findings: + print(f" - {finding}") + return 1 + print(f"FULL-HISTORY READINESS: PASS {coverage}; every blob of every " + "reachable revision is clean under the tracked-tree policy)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/offline_install_smoke.py b/tests/offline_install_smoke.py index b381bed..f13d159 100644 --- a/tests/offline_install_smoke.py +++ b/tests/offline_install_smoke.py @@ -10,18 +10,22 @@ - ``python3 -m venv`` plus ``pip install --no-build-isolation --no-deps --no-index`` installs the project into a disposable venv. The build - backend (setuptools) is seeded offline from wheels already on the host - (distro /usr/share/python-wheels or a CPython ensurepip bundle) — never - from an index. Setuptools >= 70.1 builds wheels natively; older - setuptools additionally needs a host ``wheel`` wheel. No uv, no uv.lock. + backend (setuptools >= 77, required for the PEP 639 SPDX license + metadata) is seeded offline — with highest precedence from an explicitly + pre-seeded wheelhouse named by ``FABLE_SESSION_WHEELHOUSE`` (prepared in + an earlier, clearly network-allowed CI step), otherwise from wheels + already on the host (distro /usr/share/python-wheels or an ensurepip + bundle) — never from an index during the install phase. No uv, no + uv.lock. - The installed package contains ``templates/bounded-worker.md`` as importable resource data, byte-identical to the source copy. - The installed canonical ``fable-session`` command answers ``--help`` (listing run, audit, and watch) and each subcommand answers ``--help`` - from a working directory outside the checkout; the three deprecated + from a working directory outside the checkout; the three deprecated 0.1 compatibility aliases (``claude-context-run``, - ``claude-context-audit-models``, ``claude-session-watchdog``) still - answer ``--help`` under their old names; no ``fs`` command is installed. + ``claude-context-audit-models``, ``claude-session-watchdog``) are GONE — + removed in 0.3.0b1 after their one migration release — and no ``fs`` + command is installed. - The installed runner performs one real dry-run against fully temporary sanitized fixtures: exit 0, a truthful ``status: dry-run`` manifest, model fallback disabled, no tmux session or Claude process started, and @@ -67,23 +71,47 @@ def _find_host_wheels(name: str) -> list[str]: return sorted(set(found), key=_wheel_version) +# Explicit pre-seeded wheelhouse (CI): a directory containing a +# setuptools>=77 wheel, prepared by an earlier step that IS allowed to use +# the network. The smoke's own install phase never touches an index. +WHEELHOUSE_ENV = "FABLE_SESSION_WHEELHOUSE" + +# PEP 639 (SPDX `license = "MIT"` + `license-files`) needs setuptools>=77. +_MIN_BACKEND = (77,) + + def backend_seed_wheels() -> list[str]: - """Pick host wheels that give the venv a wheel-capable setuptools. + """Pick one PEP-639-capable setuptools wheel for the offline venv. - setuptools >= 70.1 has a native bdist_wheel; anything older also needs - the separate ``wheel`` distribution. + Precedence: the explicitly pre-seeded ``FABLE_SESSION_WHEELHOUSE`` + directory, then wheels already on the host. There is no silent online + fallback: with nothing suitable available offline, the smoke fails + with instructions instead of touching an index. """ + wheelhouse = os.environ.get(WHEELHOUSE_ENV) + if wheelhouse: + candidates = sorted( + glob.glob(os.path.join(wheelhouse, "setuptools-*.whl")), + key=_wheel_version, + ) + modern = [w for w in candidates if _wheel_version(w) >= _MIN_BACKEND] + if not modern: + raise SystemExit( + f"FAIL: {WHEELHOUSE_ENV}={wheelhouse} contains no " + f"setuptools>={_MIN_BACKEND[0]} wheel " + f"(found: {[Path(c).name for c in candidates] or 'none'})" + ) + return [modern[-1]] setuptools_wheels = _find_host_wheels("setuptools") - modern = [w for w in setuptools_wheels if _wheel_version(w) >= (70, 1)] + modern = [w for w in setuptools_wheels if _wheel_version(w) >= _MIN_BACKEND] if modern: return [modern[-1]] - wheel_wheels = _find_host_wheels("wheel") - if setuptools_wheels and wheel_wheels: - return [setuptools_wheels[-1], wheel_wheels[-1]] raise SystemExit( - "FAIL: no offline wheel-capable build backend on this host " - f"(setuptools found: {setuptools_wheels or 'none'}, " - f"wheel found: {wheel_wheels or 'none'})" + f"FAIL: no offline PEP-639-capable setuptools>={_MIN_BACKEND[0]} " + f"wheel on this host (found: " + f"{[Path(w).name for w in setuptools_wheels] or 'none'}); pre-seed " + f"one in a network-allowed step and point {WHEELHOUSE_ENV} at it, " + "e.g. python3 -m pip download --dest --no-deps 'setuptools>=77'" ) SENTINEL = "CANARY-SENTINEL-MUST-NOT-LEAK-7f3a" @@ -165,6 +193,8 @@ def main() -> int: ignore=shutil.ignore_patterns("__pycache__")) shutil.copy2(REPO / "pyproject.toml", srccopy / "pyproject.toml") shutil.copy2(REPO / "README.md", srccopy / "README.md") + # license-files = ["LICENSE"]: the wheel must really ship it. + shutil.copy2(REPO / "LICENSE", srccopy / "LICENSE") venv = base / "venv" sh([sys.executable, "-m", "venv", venv], cwd=base, env=env) @@ -204,7 +234,26 @@ def main() -> int: check(proc.stdout == source_template, "installed default_template_text() matches the source template") + print("== installed distribution metadata ==") + proc = sh( + [vpython, "-c", + "from importlib.metadata import distribution\n" + "dist = distribution('fable-session')\n" + "print(dist.version)\n" + "print(dist.metadata['License-Expression'])\n"], + cwd=outside_cwd, env=env, + ) + version_line, license_line = proc.stdout.splitlines()[:2] + check(version_line == "0.3.0b1", + f"installed distribution version is 0.3.0b1 (got {version_line})") + check(license_line == "MIT", + "installed metadata carries the SPDX License-Expression MIT") + print("== installed console commands respond to --help ==") + proc = sh([venv / "bin" / "fable-session", "--version"], + cwd=outside_cwd, env=env) + check("0.3.0b1" in proc.stdout, + "fable-session --version reports 0.3.0b1") proc = sh([venv / "bin" / "fable-session", "--help"], cwd=outside_cwd, env=env) for word in ("fable-session", "run", "audit", "watch"): @@ -217,11 +266,9 @@ def main() -> int: f"fable-session {sub} --help names itself") for cmd in ("claude-context-run", "claude-context-audit-models", "claude-session-watchdog"): - proc = sh([venv / "bin" / cmd, "--help"], cwd=outside_cwd, env=env) - check(cmd in proc.stdout, - f"deprecated alias {cmd} --help still names itself") - check("deprecated" in proc.stdout, - f"deprecated alias {cmd} --help says it is deprecated") + check(not (venv / "bin" / cmd).exists(), + f"removed 0.1 alias {cmd} is not installed (0.3.0b1 " + "dropped it after its one migration release)") check(not (venv / "bin" / "fs").exists(), "no 'fs' command was installed") diff --git a/tests/public_readiness_check.py b/tests/public_readiness_check.py index 6f0c6d2..59cb620 100644 --- a/tests/public_readiness_check.py +++ b/tests/public_readiness_check.py @@ -22,8 +22,8 @@ the tests are composed from fragments at runtime, so no tracked file contains a credential-shaped literal — never inline one; - verifies the fixed naming decisions (distribution ``fable-session``, - import package ``fable_session``, canonical CLI plus the three legacy - aliases, and NEVER ``fs``); + import package ``fable_session``, exactly the canonical CLI — the three + deprecated 0.1 aliases were removed in 0.3.0b1 — and NEVER ``fs``); - verifies the RESOLVED license state: ``LICENSE`` is the standard MIT text with the exact ``Copyright (c) 2026 AskClaw contributors`` line, ``pyproject.toml`` metadata says MIT, and the official project URLs @@ -32,14 +32,17 @@ it) must never come back in ANY tracked file; - verifies README/PUBLIC_RELEASE_CHECKLIST.md/SECURITY.md identify MIT, the official repository URL, and GitHub private vulnerability reporting - truthfully. The official PRIVATE repository exists, reserved for the - independently reviewed clean-history root, so stale wording claiming it - is uncreated fails, transient wording pinning its momentary state - (empty / commitless / zero-ref claims, which the approved clean-root - push would falsify) fails, SECURITY must not condition its reporting - channel on a future publication, the checklist must keep its BLOCKED - status, the dirty-history warning (the existing git history must never - be pushed publicly), and unchecked export/review/visibility blockers; + truthfully. The official PRIVATE repository exists and holds exactly one + independently reviewed clean root commit, so stale wording claiming it + is uncreated fails, transient wording pinning a momentary state + (empty / commitless / zero-ref claims) fails, SECURITY must not + condition its reporting channel on a future publication, and the + checklist must keep: its BLOCKED status, the dirty-history warning (the + ORIGINAL pre-export working history must never be pushed publicly), the + checked record of the reviewed clean root commit, the named + full-history scan gate, and unchecked visibility and + vulnerability-reporting blockers (reporting is a public-repo setting, + enabled and verified immediately AFTER the flip); - verifies the README notices and the guardrail files exist. Exit codes: 0 ready (current tree only — git HISTORY is explicitly not @@ -132,7 +135,13 @@ r"\bgithub" + r"_pat_[A-Za-z0-9_]{20,}\b", # GitHub fine-grained PAT r"\bsk-" + r"ant-[A-Za-z0-9_-]{8,}", # Anthropic-style key r"-----BEGIN" + r" [A-Z ]*PRIVATE KEY-----", # PEM private key - r"(?i)\b(passw" + r"ord|passwd|secret|token|api[_-]?key)\s*[=:]\s*\S+", + # Assignment-shaped credentials. The negative lookahead exempts + # exactly the GitHub Actions permission GRANTS (`id-token: write`, + # `token: read`, `token: none`) that release workflows legitimately + # declare — a grant keyword is not a credential value; any other + # value after these keys still fails the scan. + r"(?i)\b(passw" + r"ord|passwd|secret|token|api[_-]?key)" + r"\s*[=:]\s*(?!(?:write|read|none)\b)\S+", ) ) @@ -178,8 +187,14 @@ def check_naming(findings: list[str]) -> None: project = data.get("project", {}) if project.get("name") != "fable-session": findings.append("pyproject.toml: distribution name is not fable-session") - if project.get("version") != "0.2.0": - findings.append("pyproject.toml: version is not 0.2.0") + if project.get("version") != "0.3.0b1": + findings.append("pyproject.toml: version is not 0.3.0b1") + for alias in ("claude-context-run", "claude-context-audit-models", + "claude-session-watchdog"): + if alias in project.get("scripts", {}): + findings.append( + f"pyproject.toml: removed deprecated alias {alias!r} is back" + ) scripts = project.get("scripts", {}) if "fable-session" not in scripts: findings.append("pyproject.toml: canonical fable-session script missing") @@ -195,10 +210,17 @@ def check_naming(findings: list[str]) -> None: if (REPO / "src" / "claude_context_tools").exists(): findings.append("old src/claude_context_tools package still present") - # The license decision is RESOLVED: MIT, in the setuptools-68-compatible - # table form. Anything else is drift, not a new decision. - if project.get("license") != {"text": "MIT"}: - findings.append("pyproject.toml: license metadata is not MIT") + # The license decision is RESOLVED: MIT, in the current SPDX expression + # form (PEP 639) with explicit license files. Anything else is drift, + # not a new decision. + if project.get("license") != "MIT": + findings.append("pyproject.toml: license metadata is not the SPDX " + "expression 'MIT'") + if project.get("license-files") != ["LICENSE"]: + findings.append("pyproject.toml: license-files must be ['LICENSE']") + if project.get("dependencies", []) != []: + findings.append("pyproject.toml: runtime dependencies must stay " + "empty (stdlib only)") if project.get("urls") != EXPECTED_PROJECT_URLS: findings.append( "pyproject.toml: project URLs must be exactly the official " @@ -315,16 +337,43 @@ def check_docs(findings: list[str]) -> None: "is gone" ) _check_repo_claims("PUBLIC_RELEASE_CHECKLIST.md", text, findings) - unchecked = [_flat(item) for item in _checklist_items(text) + if "full_history_readiness_check" not in text: + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: the full-history scan gate " + "(tests/full_history_readiness_check.py, run on the exact " + "release commit) is gone" + ) + items = _checklist_items(text) + unchecked = [_flat(item) for item in items if item.startswith("- [ ]")] - for word in ("export", "review", "visibility"): - if not any(word in item for item in unchecked): + checked = [_flat(item) for item in items + if item.startswith("- [x]")] + for phrase in ("visibility", "vulnerability reporting"): + if not any(phrase in item for item in unchecked): findings.append( "PUBLIC_RELEASE_CHECKLIST.md: no unchecked blocker " - f"mentions {word!r} — clean-history export, independent " - "review, and public visibility must stay explicitly " - "incomplete" + f"mentions {phrase!r} — the visibility flip and the " + "post-flip private-vulnerability-reporting enable/verify " + "must stay explicitly incomplete" ) + # The clean-history export/review blocker is RESOLVED: the official + # private repository holds exactly one reviewed clean root commit. + # An unchecked export blocker would be stale; a missing checked + # record of the resolution would be untruthful. + if any("export" in item for item in unchecked): + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: stale unchecked export " + "blocker — the clean-history export is resolved (one " + "reviewed clean root commit)" + ) + if not any("official private repository" in item + and "reviewed clean root commit" in item + for item in checked): + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: no checked item records that " + "the official private repository holds the one reviewed " + "clean root commit" + ) security = REPO / "SECURITY.md" if security.is_file(): @@ -379,8 +428,9 @@ def main() -> int: print(f" - {finding}") return 1 print("PUBLIC READINESS: PASS (current tracked tree only — license is " - "MIT, but the existing git history is NOT covered by this check " - "and must not be pushed publicly)") + "MIT; git history is covered separately by " + "tests/full_history_readiness_check.py, and the ORIGINAL " + "pre-export working history must not be pushed publicly)") return 0 diff --git a/tests/release_tag_check.py b/tests/release_tag_check.py new file mode 100644 index 0000000..499265a --- /dev/null +++ b/tests/release_tag_check.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Fail-closed prerelease tag <-> package-version consistency gate. + +Deterministic, dependency-free (stdlib only): a plain beta package version +``X.Y.ZbN`` maps to exactly one release tag ``vX.Y.Z-beta.N`` (so +``0.3.0b1`` releases only from ``v0.3.0-beta.1``). Every other version +shape — finals, rc/alpha/dev/post suffixes, non-canonical numbers — is +refused outright: this gate never guesses a mapping it was not taught, and +extending it (e.g. for a first final release) is a deliberate, tested code +change. Run by ``release.yml`` before any gate, build, or publish step: + + python3 tests/release_tag_check.py --tag "$GITHUB_REF_NAME" +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +# Canonical plain-beta version: no leading zeros, no epoch, no rc/alpha/ +# dev/post/local suffixes. fullmatch() only — nothing may trail. +_BETA_VERSION_RE = re.compile( + r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)b(0|[1-9]\d*)" +) + + +class ReleaseTagError(ValueError): + """The tag/version pair cannot be proven consistent; refuse to release.""" + + +def expected_tag(version: str) -> str: + """The single tag allowed to release ``version``; fail closed otherwise.""" + if not isinstance(version, str) or not _BETA_VERSION_RE.fullmatch(version): + raise ReleaseTagError( + f"unsupported package version {version!r}: this gate maps only " + "canonical plain-beta versions (X.Y.ZbN) to their one release " + "tag (vX.Y.Z-beta.N); extend the mapping deliberately (with " + "tests) before tagging anything else" + ) + major, minor, patch, beta = _BETA_VERSION_RE.fullmatch(version).groups() + return f"v{major}.{minor}.{patch}-beta.{beta}" + + +def read_package_version(pyproject_path: Path | None = None) -> str: + path = Path(pyproject_path) if pyproject_path else REPO / "pyproject.toml" + try: + with open(path, "rb") as fh: + data = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise ReleaseTagError(f"cannot read {path}: {exc}") from exc + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise ReleaseTagError(f"{path} declares no project.version string") + return version + + +def check_tag(tag: str, version: str) -> str: + allowed = expected_tag(version) + if tag != allowed: + raise ReleaseTagError( + f"refusing to release: tag {tag!r} does not match package " + f"version {version!r}; only tag {allowed!r} may release it" + ) + return allowed + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="release_tag_check", + description="Fail-closed prerelease tag/package-version gate.", + ) + parser.add_argument("--tag", required=True, + help="the pushed tag name (e.g. $GITHUB_REF_NAME)") + parser.add_argument("--pyproject", type=Path, + help="override pyproject.toml path (tests only)") + args = parser.parse_args(argv) + try: + version = read_package_version(args.pyproject) + check_tag(args.tag, version) + except ReleaseTagError as exc: + print(f"release tag gate: FAIL: {exc}", file=sys.stderr) + return 1 + print(f"release tag gate: OK — tag {args.tag} is the one canonical " + f"release tag for package version {version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_capture.py b/tests/test_capture.py index 3aa5296..2084fc6 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -252,6 +252,169 @@ def test_bare_argv0_refused_without_path_lookup_or_payload_echo(self): self.assertFalse(self.marker.exists()) +class PayloadFixture(CaptureFixture): + """Harness for the 0.3.0b1 run-scoped payload mechanism: brief and task + text live in private 0600 files, verified by sha256 and delivered by the + wrapper (brief via exactly-one placeholder substitution in the Claude + argv, task via stdin) — never on the tmux command line.""" + + BRIEF_TEXT = "Product sentence.\n\nBrief body — bounded ✓\n" + TASK_TEXT = "# Task: payload fixture\n\nlarge task body…\n" + + def setUp(self): + super().setUp() + import hashlib + + self.brief_payload = self.base / "brief.payload" + self.brief_payload.write_bytes(self.BRIEF_TEXT.encode("utf-8")) + self.brief_sha = hashlib.sha256( + self.BRIEF_TEXT.encode("utf-8")).hexdigest() + self.task_payload = self.base / "task.payload" + self.task_payload.write_bytes(self.TASK_TEXT.encode("utf-8")) + self.task_sha = hashlib.sha256( + self.TASK_TEXT.encode("utf-8")).hexdigest() + # Fake claude proving what actually reached it: argv → argv.txt + # (one arg per line), stdin → stdin.txt. + self.argv_out = self.base / "argv.txt" + self.stdin_out = self.base / "stdin.txt" + script = "#!/bin/sh\n" + script += f": > \"{self.marker}\"\n" + script += f'for a in "$@"; do printf "%s\\n" "$a"; done > "{self.argv_out}"\n' + script += f'cat > "{self.stdin_out}"\n' + script += "exit 0\n" + self.recording_claude = self.base / "claude" + self.recording_claude.write_text(script, encoding="utf-8") + self.recording_claude.chmod(0o755) + + def payload_argv(self, *claude_args, brief=True, task=True): + argv = ["--dest", str(self.dest)] + if brief: + argv += ["--brief-payload", str(self.brief_payload), + "--brief-sha256", self.brief_sha] + if task: + argv += ["--task-payload", str(self.task_payload), + "--task-sha256", self.task_sha] + argv += ["--", str(self.recording_claude), *claude_args] + return argv + + +class TestCapturePayloadDelivery(PayloadFixture): + def test_brief_placeholder_is_substituted_and_task_arrives_on_stdin(self): + rc, err = self.run_wrapper(*self.payload_argv( + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + )) + self.assertEqual(rc, 0, err) + # The fake claude wrote each argv word followed by one newline: the + # placeholder slot must carry the exact brief text, nothing else. + argv_text = self.argv_out.read_text(encoding="utf-8") + self.assertEqual( + argv_text, + "--append-system-prompt\n" + self.BRIEF_TEXT + "\n" + "-p\n", + ) + self.assertNotIn(capture.BRIEF_PLACEHOLDER, argv_text) + self.assertEqual( + self.stdin_out.read_text(encoding="utf-8"), self.TASK_TEXT + ) + + def test_placeholder_constant_matches_the_runner(self): + from fable_session import runner + + self.assertEqual(capture.BRIEF_PLACEHOLDER, runner.BRIEF_PLACEHOLDER) + + def test_brief_hash_mismatch_fails_closed_before_anything_runs(self): + self.brief_payload.write_bytes(b"tampered after hashing\n") + rc, err = self.run_wrapper(*self.payload_argv( + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + )) + self.assertEqual(rc, capture.EXIT_PAYLOAD) + self.assertIn("sha256", err) + self.assertNotIn("tampered", err, "payload text must never be echoed") + self.assertFalse(self.marker.exists(), "claude was invoked") + self.assertFalse(self.dest.exists(), + "refusal must come before destination creation") + + def test_task_hash_mismatch_fails_closed(self): + self.task_payload.write_bytes(b"swapped task\n") + rc, err = self.run_wrapper(*self.payload_argv( + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + )) + self.assertEqual(rc, capture.EXIT_PAYLOAD) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_symlinked_payload_is_refused_not_followed(self): + real = self.base / "elsewhere.payload" + real.write_bytes(self.BRIEF_TEXT.encode("utf-8")) + self.brief_payload.unlink() + self.brief_payload.symlink_to(real) + rc, _ = self.run_wrapper(*self.payload_argv( + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + )) + self.assertEqual(rc, capture.EXIT_PAYLOAD) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_missing_payload_file_is_refused(self): + self.task_payload.unlink() + rc, _ = self.run_wrapper(*self.payload_argv( + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + )) + self.assertEqual(rc, capture.EXIT_PAYLOAD) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_relative_payload_path_is_a_usage_error(self): + rc, err = self.run_wrapper( + "--dest", str(self.dest), + "--brief-payload", "brief.payload", + "--brief-sha256", self.brief_sha, + "--", str(self.recording_claude), + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + ) + self.assertEqual(rc, capture.EXIT_USAGE) + self.assertIn("absolute", err) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_placeholder_count_must_be_exactly_one_with_brief_payload(self): + for args in ( + ("-p",), # zero placeholders + ("--append-system-prompt", capture.BRIEF_PLACEHOLDER, + "-p", capture.BRIEF_PLACEHOLDER), # two placeholders + ): + with self.subTest(args=args): + rc, _ = self.run_wrapper(*self.payload_argv(*args)) + self.assertEqual(rc, capture.EXIT_USAGE) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_unresolved_placeholder_without_payload_never_executes(self): + rc, err = self.run_wrapper(*self.payload_argv( + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + brief=False, task=False, + )) + self.assertEqual(rc, capture.EXIT_USAGE) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_payload_flag_without_hash_is_a_usage_error(self): + rc, _ = self.run_wrapper( + "--dest", str(self.dest), + "--brief-payload", str(self.brief_payload), + "--", str(self.recording_claude), + "--append-system-prompt", capture.BRIEF_PLACEHOLDER, "-p", + ) + self.assertEqual(rc, capture.EXIT_USAGE) + self.assertFalse(self.marker.exists()) + self.assertFalse(self.dest.exists()) + + def test_plain_mode_without_payloads_still_works(self): + claude = self.fake_claude() + rc, err = self.run_wrapper("--dest", str(self.dest), "--", str(claude)) + self.assertEqual(rc, 0, err) + self.assertEqual(self.dest.read_bytes(), STREAM_PAYLOAD) + + class TestCaptureAsScript(CaptureFixture): def test_runs_by_path_without_package_or_shell(self): # tmux executes [python, capture.py, ...] by absolute path in an diff --git a/tests/test_config.py b/tests/test_config.py index 9cc3f66..5da3d01 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -424,5 +424,90 @@ def test_multiline_product_fails(self): self.load() +class TestMaxBudgetUsd(ConfigFixture): + """Optional `max_budget_usd`: a finite positive TOML number with a safe + canonical decimal form, validated before any command is built. Existing + 0.2 registries without the key must remain valid.""" + + def add_budget_raw(self, raw_toml_value): + self.registry.write_text( + self.registry.read_text(encoding="utf-8") + + f"max_budget_usd = {raw_toml_value}\n", + encoding="utf-8", + ) + + def test_absent_budget_stays_valid_and_records_none(self): + # 0.2 registry without the key: still resolves, budget is None. + project = self.resolve() + self.assertIsNone(project.max_budget_usd) + + def test_positive_float_budget_is_accepted_with_canonical_form(self): + from fable_session.config import format_budget_usd + + self.add_budget_raw("12.5") + project = self.resolve() + self.assertEqual(project.max_budget_usd, 12.5) + self.assertEqual(format_budget_usd(project.max_budget_usd), "12.5") + + def test_positive_integer_budget_is_accepted_with_canonical_form(self): + from fable_session.config import format_budget_usd + + self.add_budget_raw("8") + project = self.resolve() + self.assertEqual(project.max_budget_usd, 8) + self.assertEqual(format_budget_usd(project.max_budget_usd), "8") + + def test_booleans_are_rejected(self): + for raw in ("true", "false"): + with self.subTest(raw=raw): + self.setUp() + self.add_budget_raw(raw) + with self.assertRaisesRegex(ConfigError, "max_budget_usd"): + self.resolve() + + def test_zero_and_negative_budgets_are_rejected(self): + for raw in ("0", "0.0", "-1", "-0.5", "-0.0"): + with self.subTest(raw=raw): + self.setUp() + self.add_budget_raw(raw) + with self.assertRaisesRegex(ConfigError, "max_budget_usd"): + self.resolve() + + def test_nan_and_infinities_are_rejected(self): + for raw in ("nan", "inf", "-inf", "+inf"): + with self.subTest(raw=raw): + self.setUp() + self.add_budget_raw(raw) + with self.assertRaisesRegex(ConfigError, "max_budget_usd"): + self.resolve() + + def test_string_and_list_budgets_are_rejected(self): + for raw in ('"12.5"', "[12.5]", '{ usd = 12.5 }'): + with self.subTest(raw=raw): + self.setUp() + self.add_budget_raw(raw) + with self.assertRaisesRegex(ConfigError, "max_budget_usd"): + self.resolve() + + def test_unsafe_exponent_representations_are_rejected(self): + # Values whose only round-trip form is scientific notation could + # smuggle an argv shape no one reviewed; they are refused at parse + # time, before command construction. + for raw in ("1e-7", "1e300", "9e99"): + with self.subTest(raw=raw): + self.setUp() + self.add_budget_raw(raw) + with self.assertRaisesRegex(ConfigError, "max_budget_usd"): + self.resolve() + + def test_format_budget_usd_rejects_invalid_values_directly(self): + from fable_session.config import format_budget_usd + + for bad in (True, False, 0, -3, float("nan"), float("inf"), "5", None): + with self.subTest(bad=bad): + with self.assertRaises(ConfigError): + format_budget_usd(bad) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_full_history_readiness.py b/tests/test_full_history_readiness.py new file mode 100644 index 0000000..ae0d0b2 --- /dev/null +++ b/tests/test_full_history_readiness.py @@ -0,0 +1,123 @@ +"""Full-history public-readiness scan contract (0.3.0b1). + +`tests/full_history_readiness_check.py` must scan EVERY tracked blob in +EVERY commit reachable from HEAD — not just the current tree — reusing the +tracked-tree checker's internal-identifier and credential-pattern policy, +and fail on any historical blob that violates it. This is the release gate +that makes "one reviewed clean root commit" a verifiable claim for the +exact history being published. + +Negative cases run against disposable git repositories: a blob that was +added and later deleted must still fail the scan, because a public push +publishes the whole history, not the final tree. +""" + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CHECKER = REPO / "tests" / "full_history_readiness_check.py" + + +def run_checker(repo_path): + return subprocess.run( + [sys.executable, str(CHECKER), str(repo_path)], + capture_output=True, text=True, + ) + + +class DisposableHistory(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="fs-history-scan-") + self.addCleanup(self.tmp.cleanup) + self.repo = Path(self.tmp.name) / "repo" + self.repo.mkdir() + self.git("init", "-q") + self.git("config", "user.email", "t@example.invalid") + self.git("config", "user.name", "t") + + def git(self, *argv): + subprocess.run(["git", "-C", str(self.repo), *argv], check=True, + capture_output=True) + + def commit_file(self, name, text, message): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + self.git("add", "-A") + self.git("commit", "-q", "-m", message) + + def remove_file(self, name, message): + self.git("rm", "-q", name) + self.git("commit", "-q", "-m", message) + + +class TestCheckerOnThisRepository(unittest.TestCase): + def test_checker_exists_and_passes_on_this_history(self): + self.assertTrue(CHECKER.is_file(), + "tests/full_history_readiness_check.py is missing") + proc = run_checker(REPO) + self.assertEqual( + proc.returncode, 0, + f"full-history scan failed:\n{proc.stdout}\n{proc.stderr}", + ) + self.assertIn("FULL-HISTORY READINESS: PASS", proc.stdout) + + def test_checker_reports_commit_and_blob_coverage(self): + proc = run_checker(REPO) + self.assertEqual(proc.returncode, 0) + # The PASS line proves it really walked history, not just HEAD. + self.assertRegex(proc.stdout, r"[1-9]\d* commit") + self.assertRegex(proc.stdout, r"[1-9]\d* unique blob") + + +class TestHistoricalLeaksFail(DisposableHistory): + def test_clean_history_passes(self): + self.commit_file("README.md", "clean beginnings\n", "init") + self.commit_file("src/x.py", "print('ok')\n", "code") + proc = run_checker(self.repo) + self.assertEqual(proc.returncode, 0, + f"{proc.stdout}\n{proc.stderr}") + + def test_internal_identifier_in_a_deleted_file_still_fails(self): + needle = "compute" + "box" + self.commit_file("README.md", "clean\n", "init") + self.commit_file("notes.txt", f"path /home/{needle}/x\n", "oops") + self.remove_file("notes.txt", "scrub the tree (history keeps it)") + proc = run_checker(self.repo) + self.assertEqual(proc.returncode, 1, + "a scrubbed tree must not hide a dirty history") + self.assertIn("internal identifier", proc.stdout) + self.assertIn("notes.txt", proc.stdout) + + def test_credential_shaped_blob_in_an_old_revision_fails(self): + # Composed at runtime — and not assigned to a credential-named + # variable — so this tracked file never matches the scan itself. + sample = "AK" + "IA" + "ABCDEFGHIJKLMNOP" + self.commit_file("README.md", "clean\n", "init") + self.commit_file("conf.py", f"# {sample}\n", "leak") + self.commit_file("conf.py", "# rotated away\n", "fix in tree only") + proc = run_checker(self.repo) + self.assertEqual(proc.returncode, 1) + self.assertIn("credential-shaped", proc.stdout) + + def test_stale_license_placeholder_in_history_fails(self): + stale = "propri" + "etary" + self.commit_file("README.md", "clean\n", "init") + self.commit_file("LICENSE", f"This is {stale} software.\n", "old license") + self.commit_file("LICENSE", "MIT License\n", "relicense") + proc = run_checker(self.repo) + self.assertEqual(proc.returncode, 1) + + def test_not_a_git_repository_is_an_execution_error(self): + empty = Path(self.tmp.name) / "not-a-repo" + empty.mkdir() + proc = run_checker(empty) + self.assertEqual(proc.returncode, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jsonl_audit.py b/tests/test_jsonl_audit.py index af3b503..9a8b853 100644 --- a/tests/test_jsonl_audit.py +++ b/tests/test_jsonl_audit.py @@ -95,6 +95,145 @@ def audit(self, lines, requested=FABLE, trailing_newline=True): ) +GOOD_USAGE = { + FABLE: { + "inputTokens": 100, + "outputTokens": 350, + "cacheReadInputTokens": 50000, + "cacheCreationInputTokens": 12000, + "webSearchRequests": 2, + "costUSD": 1.25, + "contextWindow": 200000, + }, + HAIKU: { + "inputTokens": 89, + "outputTokens": 43, + "cacheReadInputTokens": 3163, + "cacheCreationInputTokens": 1698, + "webSearchRequests": 0, + "costUSD": 0.5, + "contextWindow": 200000, + }, +} + + +class TestUsageExtraction(AuditFixture): + """0.3.0b1 privacy-safe run usage: bounded aggregates from the single + terminal result's modelUsage — never prompt/result/tool text. Missing + optional usage is an explicit null; malformed usage is an explicit null + plus an integrity reason (it must never falsely prove purity).""" + + def pure_lines(self, model_usage): + return [ + assistant_line("msg_01", FABLE, text=f"done {SENTINEL}"), + result_line(model_usage=model_usage), + ] + + def test_helper_model_usage_aggregates_across_models(self): + audit = self.audit(self.pure_lines(GOOD_USAGE)) + self.assertEqual(audit["verdict"], jsonl.VERDICT_PURE) + self.assertEqual( + audit["usage"], + { + "input_tokens": 189, + "output_tokens": 393, + "cache_read_input_tokens": 53163, + "cache_creation_input_tokens": 13698, + "web_search_requests": 2, + "total_cost_usd": 1.75, + }, + ) + + def test_usage_is_json_safe_and_carries_no_transcript_text(self): + audit = self.audit(self.pure_lines(GOOD_USAGE)) + self.assertNotIn(SENTINEL, json.dumps(audit)) + + def test_missing_model_usage_is_an_explicit_null_not_zeroes(self): + audit = self.audit(self.pure_lines(None)) + self.assertIsNone(audit["usage"]) + self.assertEqual(audit["verdict"], jsonl.VERDICT_PURE) + self.assertNotIn("malformed_model_usage", audit["reason_codes"]) + + def test_repeated_message_chunks_do_not_disturb_usage(self): + lines = [ + assistant_line("msg_01", FABLE, text=""), + assistant_line("msg_01", FABLE, text="done"), + result_line(model_usage=GOOD_USAGE), + ] + audit = self.audit(lines) + self.assertEqual(audit["verdict"], jsonl.VERDICT_PURE) + self.assertEqual(audit["usage"]["input_tokens"], 189) + + def test_multiple_terminal_results_never_choose_a_usage(self): + lines = self.pure_lines(GOOD_USAGE) + [result_line(model_usage=GOOD_USAGE)] + audit = self.audit(lines) + self.assertIsNone(audit["usage"]) + self.assertEqual(audit["verdict"], jsonl.VERDICT_UNKNOWN) + self.assertIn(jsonl.REASON_MULTIPLE_TERMINAL_RESULTS, + audit["reason_codes"]) + + def assert_malformed(self, model_usage): + audit = self.audit(self.pure_lines(model_usage)) + self.assertIsNone(audit["usage"], model_usage) + self.assertIn("malformed_model_usage", audit["reason_codes"]) + self.assertEqual( + audit["verdict"], jsonl.VERDICT_UNKNOWN, + "corrupt optional usage must never falsely prove model purity", + ) + self.assertFalse(audit["evidence_complete"]) + + def test_non_dict_model_usage_is_malformed(self): + self.assert_malformed(["not", "a", "dict"]) + + def test_non_dict_model_entry_is_malformed(self): + self.assert_malformed({FABLE: 12}) + + def test_string_numeric_field_is_malformed(self): + self.assert_malformed({FABLE: {"inputTokens": "100"}}) + + def test_boolean_numeric_field_is_malformed(self): + self.assert_malformed({FABLE: {"outputTokens": True}}) + + def test_negative_token_count_is_malformed(self): + self.assert_malformed({FABLE: {"cacheReadInputTokens": -1}}) + + def test_float_token_count_is_malformed(self): + self.assert_malformed({FABLE: {"inputTokens": 10.5}}) + + def test_negative_cost_is_malformed(self): + self.assert_malformed({FABLE: {"costUSD": -0.5}}) + + def test_non_finite_cost_is_malformed(self): + # Python's json module parses bare NaN/Infinity literals, so a + # transcript can genuinely carry them. + for literal in ("NaN", "Infinity", "-Infinity"): + with self.subTest(literal=literal): + lines = [ + assistant_line("msg_01", FABLE), + result_line().replace( + '"stop_reason"', + f'"modelUsage": {{"{FABLE}": {{"costUSD": {literal}}}}}, ' + '"stop_reason"', + ), + ] + audit = self.audit(lines) + self.assertIsNone(audit["usage"]) + self.assertIn("malformed_model_usage", audit["reason_codes"]) + self.assertEqual(audit["verdict"], jsonl.VERDICT_UNKNOWN) + + def test_empty_model_usage_is_a_truthful_zero_aggregate(self): + audit = self.audit(self.pure_lines({})) + self.assertEqual(audit["verdict"], jsonl.VERDICT_PURE) + self.assertEqual(audit["usage"]["input_tokens"], 0) + self.assertEqual(audit["usage"]["total_cost_usd"], 0) + + def test_missing_fields_within_an_entry_are_tolerated(self): + audit = self.audit(self.pure_lines({FABLE: {"inputTokens": 7}})) + self.assertEqual(audit["verdict"], jsonl.VERDICT_PURE) + self.assertEqual(audit["usage"]["input_tokens"], 7) + self.assertEqual(audit["usage"]["output_tokens"], 0) + + class TestPureAndCounting(AuditFixture): def test_pure_with_duplicate_chunks_counts_message_ids_once(self): # msg_01 streamed as two chunks (first without text) must count once; diff --git a/tests/test_migration_fable_session.py b/tests/test_migration_fable_session.py index d8d7daf..dbc7634 100644 --- a/tests/test_migration_fable_session.py +++ b/tests/test_migration_fable_session.py @@ -1,20 +1,26 @@ """Migration contract: the project is `fable-session` / `fable_session`. -Pins the fixed user decisions of the rename release: +Pins the fixed user decisions of the rename release, updated for the +0.3.0b1 public beta: -- import package `fable_session` at version 0.2.0; the old +- import package `fable_session` at PEP 440 version 0.3.0b1; the old `claude_context_tools` import package is gone from the tree; -- distribution `fable-session == 0.2.0`, licensed MIT: metadata says MIT +- distribution `fable-session == 0.3.0b1`, licensed MIT: metadata says MIT and the standard MIT `LICENSE` file exists (the old placeholder wording must never come back — see tests/test_mit_release.py); -- exactly one canonical console command `fable-session` plus the three - documented deprecated aliases, and never, under any name or value, `fs`; +- exactly one canonical console command `fable-session`, and never, under + any name or value, `fs`. The three deprecated 0.1 compatibility aliases + (`claude-context-run`, `claude-context-audit-models`, + `claude-session-watchdog`) were retained for exactly one migration + release (0.2.x) and are REMOVED in this post-0.2 release — no console + entry, no `legacy_main`; - default config/state paths under `~/.config/fable-session` and `~/.local/state/fable-session`; the old `claude-context` directories are never read or adopted implicitly; - manifests written by the runner identify the tool as `fable-session`; - audit and watchdog still accept an OLD-schema manifest (tool string - `claude-context-run 0.1.0`) when explicitly pointed at one. + `claude-context-run 0.1.0`) when explicitly pointed at one — manifest + compatibility outlives the alias commands. """ import contextlib @@ -33,14 +39,17 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(SRC)) +# The one-migration-release aliases are gone: exactly one console command. EXPECTED_SCRIPTS = { "fable-session": "fable_session.cli:main", - # One migration release of documented deprecated compatibility aliases. - "claude-context-run": "fable_session.runner:legacy_main", - "claude-context-audit-models": "fable_session.model_audit:legacy_main", - "claude-session-watchdog": "fable_session.watchdog:legacy_main", } +REMOVED_ALIASES = ( + "claude-context-run", + "claude-context-audit-models", + "claude-session-watchdog", +) + SID = "33333333-3333-4333-8333-333333333333" @@ -49,9 +58,9 @@ def pyproject(): class TestImportPackage(unittest.TestCase): - def test_fable_session_imports_at_0_2_0(self): + def test_fable_session_imports_at_0_3_0b1(self): fable_session = importlib.import_module("fable_session") - self.assertEqual(fable_session.__version__, "0.2.0") + self.assertEqual(fable_session.__version__, "0.3.0b1") def test_old_import_package_is_gone(self): self.assertFalse( @@ -79,14 +88,16 @@ class TestDistributionMetadata(unittest.TestCase): def test_distribution_name_and_version(self): data = pyproject() self.assertEqual(data["project"]["name"], "fable-session") - self.assertEqual(data["project"]["version"], "0.2.0") + # PEP 440 beta version; the GitHub prerelease tag is v0.3.0-beta.1. + self.assertEqual(data["project"]["version"], "0.3.0b1") def test_license_is_mit_with_a_license_file(self): - # The license decision is resolved: MIT. Metadata uses the - # setuptools-68-compatible table form and the standard MIT text + # The license decision is resolved: MIT. 0.3.0b1 metadata uses the + # current SPDX expression form (PEP 639) and the standard MIT text # ships as LICENSE (full contract: tests/test_mit_release.py). data = pyproject() - self.assertEqual(data["project"]["license"], {"text": "MIT"}) + self.assertEqual(data["project"]["license"], "MIT") + self.assertEqual(data["project"]["license-files"], ["LICENSE"]) self.assertTrue((REPO / "LICENSE").is_file(), "LICENSE must exist") for name in ("LICENSE.md", "LICENSE.txt", "COPYING"): self.assertFalse((REPO / name).exists(), @@ -223,52 +234,37 @@ def fake_git(argv, **kwargs): ) -class TestLegacyAliases(unittest.TestCase): - """The three old console names survive one migration release as - documented deprecated aliases delegating to the renamed implementation, - with their old prog names and error prefixes intact.""" +class TestLegacyAliasesRemoved(unittest.TestCase): + """0.3.0b1 removes the three deprecated 0.1 compatibility console + aliases scheduled for exactly one migration release (0.2.x): no console + entry points, no `legacy_main` delegates. Old-manifest READING + compatibility stays (TestOldManifestCompatibility).""" - def run_legacy(self, module_name, argv): - module = importlib.import_module(f"fable_session.{module_name}") - out, err = io.StringIO(), io.StringIO() - with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): - try: - rc = module.legacy_main(argv) - except SystemExit as exc: - rc = int(exc.code or 0) - return rc, out.getvalue(), err.getvalue() - - def test_legacy_help_keeps_old_names_and_says_deprecated(self): - for module_name, old_name in ( - ("runner", "claude-context-run"), - ("model_audit", "claude-context-audit-models"), - ("watchdog", "claude-session-watchdog"), - ): - rc, out, err = self.run_legacy(module_name, ["--help"]) - self.assertEqual(rc, 0, f"{old_name} --help must exit 0") - self.assertIn(old_name, out) - self.assertIn("deprecated", out.lower()) - self.assertIn("fable-session", out) - - def test_legacy_error_prefixes_are_preserved(self): - rc, out, err = self.run_legacy( - "model_audit", ["--transcript", "relative.jsonl", - "--requested-model", "claude-fable-5"] - ) - self.assertEqual(rc, 2) - self.assertIn("claude-context-audit-models: error:", err) - - rc, out, err = self.run_legacy( - "watchdog", ["--manifest", "relative.json", "--once"] - ) - self.assertEqual(rc, 2) - self.assertIn("claude-session-watchdog: error:", err) + def test_no_legacy_console_scripts_in_pyproject(self): + scripts = pyproject()["project"]["scripts"] + self.assertEqual(scripts, EXPECTED_SCRIPTS) + for alias in REMOVED_ALIASES: + self.assertNotIn(alias, scripts) + + def test_no_legacy_main_delegates_remain(self): + for module_name in ("runner", "model_audit", "watchdog"): + module = importlib.import_module(f"fable_session.{module_name}") + self.assertFalse( + hasattr(module, "legacy_main"), + f"fable_session.{module_name}.legacy_main must be removed", + ) + self.assertFalse(hasattr(module, "LEGACY_PROG")) - rc, out, err = self.run_legacy( - "runner", ["--project", "example", "--task", "relative.md"] - ) - self.assertEqual(rc, 2) - self.assertIn("claude-context-run: error:", err) + def test_canonical_help_no_longer_advertises_deprecated_aliases(self): + for module_name in ("runner", "model_audit", "watchdog"): + module = importlib.import_module(f"fable_session.{module_name}") + out = io.StringIO() + with contextlib.redirect_stdout(out): + try: + module.main(["--help"]) + except SystemExit: + pass + self.assertNotIn("deprecated compatibility alias", out.getvalue()) class TestOldManifestCompatibility(unittest.TestCase): @@ -333,6 +329,19 @@ def test_audit_reads_old_manifest(self): self.assertEqual(rc, 0, f"expected PURE on old manifest; stderr={err.getvalue()}") self.assertIn("verdict: PURE", out.getvalue()) + def test_manifest_without_payload_created_flag_is_still_accepted(self): + # Backward compatibility for the 0.3.0b1 follow-up field: manifests + # written before `payload_files_created` existed carry no such key + # and must stay fully readable — nothing keys off it. + data = json.loads(self.manifest.read_text(encoding="utf-8")) + self.assertNotIn("payload_files_created", data) + model_audit = importlib.import_module("fable_session.model_audit") + with contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual( + model_audit.main(["--manifest", str(self.manifest)]), 0 + ) + def test_watchdog_reads_old_manifest(self): watchdog = importlib.import_module("fable_session.watchdog") out, err = io.StringIO(), io.StringIO() diff --git a/tests/test_mit_release.py b/tests/test_mit_release.py index 041bd9a..79c736d 100644 --- a/tests/test_mit_release.py +++ b/tests/test_mit_release.py @@ -49,11 +49,9 @@ } COPYRIGHT_LINE = "Copyright (c) 2026 AskClaw contributors" +# 0.3.0b1: the three one-migration-release aliases are removed. EXPECTED_SCRIPTS = { "fable-session": "fable_session.cli:main", - "claude-context-run": "fable_session.runner:legacy_main", - "claude-context-audit-models": "fable_session.model_audit:legacy_main", - "claude-session-watchdog": "fable_session.watchdog:legacy_main", } # Composed: the resolved-blocker words must never appear literally in any @@ -146,9 +144,22 @@ def test_no_alternate_license_files(self): class TestPyprojectMitMetadata(unittest.TestCase): - def test_license_metadata_is_mit(self): - # setuptools 68 (the pinned offline build) supports the table form. - self.assertEqual(pyproject()["project"]["license"], {"text": "MIT"}) + def test_license_metadata_is_spdx_mit_with_license_files(self): + # 0.3.0b1 public packaging: current SPDX expression form (PEP 639) + # plus explicit license files; the build requirement is a + # PEP-639-capable setuptools. + project = pyproject()["project"] + self.assertEqual(project["license"], "MIT") + self.assertEqual(project["license-files"], ["LICENSE"]) + + def test_build_backend_supports_pep_639(self): + requires = pyproject()["build-system"]["requires"] + self.assertTrue( + any(req.startswith("setuptools>=") and + int(req.split(">=")[1].split(".")[0].split(",")[0]) >= 77 + for req in requires), + f"PEP 639 metadata needs setuptools>=77; got {requires}", + ) def test_no_stale_placeholder_wording(self): raw = (REPO / "pyproject.toml").read_text(encoding="utf-8").lower() @@ -158,13 +169,46 @@ def test_no_stale_placeholder_wording(self): def test_official_project_urls_are_exact(self): self.assertEqual(pyproject()["project"].get("urls"), EXPECTED_URLS) - def test_version_and_console_scripts_unchanged(self): + def test_version_and_console_scripts_pinned(self): data = pyproject()["project"] - self.assertEqual(data["version"], "0.2.0") + self.assertEqual(data["version"], "0.3.0b1") self.assertEqual(data["scripts"], EXPECTED_SCRIPTS) self.assertNotIn("fs", data["scripts"]) +class TestPublicPackagingMetadata(unittest.TestCase): + """0.3.0b1 public-beta packaging: README as long description, honest + classifiers, and zero runtime dependencies.""" + + def test_readme_is_the_long_description(self): + self.assertEqual(pyproject()["project"]["readme"], "README.md") + + def test_requires_python_and_no_runtime_dependencies(self): + project = pyproject() + self.assertEqual(project["project"]["requires-python"], ">=3.12") + self.assertEqual(project["project"].get("dependencies", []), [], + "runtime dependencies must stay empty (stdlib only)") + + def test_classifiers_are_useful_and_honest(self): + classifiers = pyproject()["project"]["classifiers"] + for expected in ( + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development", + ): + self.assertIn(expected, classifiers) + # PEP 639: no License :: classifiers beside an SPDX expression, and + # no cross-platform claims beyond the tested combination. + self.assertFalse([c for c in classifiers if c.startswith("License ::")]) + for banned in ("Operating System :: OS Independent", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS"): + self.assertNotIn(banned, classifiers) + + class TestDocsIdentifyMitTruthfully(unittest.TestCase): def test_readme_states_mit_and_official_url_without_stale_wording(self): text = (REPO / "README.md").read_text(encoding="utf-8") @@ -247,30 +291,58 @@ def test_security_reporting_is_github_native_and_not_claimed_enabled(self): self.assertIn("security", lowered) self.assertIn("public issue", lowered) - def test_checklist_records_the_private_repo_existence_durably(self): - # Durable wording only: the checked item says the official private - # repository exists and is reserved for the independently reviewed - # clean-history root — never that it is empty or commitless. + def test_checklist_records_the_reviewed_clean_root_durably(self): + # 0.3.0b1 truth: the official private repository now holds exactly + # one reviewed clean root commit — the clean-history export blocker + # is resolved. Durable wording only (never "empty"/"commitless"). checked = [flat(item) for item in checklist_items() if item.startswith("- [x]")] self.assertTrue( - any("official private repository" in item and "exists" in item - and "reserved" in item and "clean-history root" in item + any("official private repository" in item + and "reviewed clean root commit" in item for item in checked), "checklist must record, in durable wording, that the official " - "private repository exists and is reserved for the reviewed " - f"clean-history root; checked items: {checked}", + "private repository holds the one reviewed clean root commit; " + f"checked items: {checked}", + ) + self.assertTrue( + any("export" in item for item in checked), + "the clean-history export blocker is resolved and must be " + f"recorded as such; checked items: {checked}", ) - def test_checklist_keeps_export_review_and_visibility_unchecked(self): + def test_checklist_keeps_visibility_and_reporting_unchecked(self): unchecked = [flat(item) for item in checklist_items() if item.startswith("- [ ]")] - for word in ("export", "review", "visibility"): + for phrase in ("visibility", "vulnerability reporting"): self.assertTrue( - any(word in item for item in unchecked), - f"an unchecked blocker must still cover {word!r}; " + any(phrase in item for item in unchecked), + f"an unchecked blocker must still cover {phrase!r}; " f"unchecked items: {unchecked}", ) + # The export/review blocker is resolved: no unchecked item may + # still claim the clean-history export is incomplete. + self.assertFalse( + [item for item in unchecked if "export" in item], + f"the export blocker is resolved; unchecked items: {unchecked}", + ) + + def test_checklist_orders_private_to_public_correctly(self): + # Private vulnerability reporting is a PUBLIC-repository setting: + # the checklist must sequence it AFTER the visibility flip and + # never claim it can be enabled while private. + text = flat((REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text("utf-8")) + self.assertNotIn("while private, enable private vulnerability", text) + self.assertIn("after", text) + self.assertIn("public", text) + self.assertIn("immediately", text) + self.assertIn("verify", text) + + def test_checklist_keeps_a_full_history_scan_gate(self): + text = (REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text("utf-8") + self.assertIn("full_history_readiness_check", text, + "the release gate must name the exact full-history " + "scan for the exact release commit") class TestReadinessCheckerGuardsMitState(unittest.TestCase): @@ -360,8 +432,8 @@ def test_wrong_copyright_holder_fails(self): def test_pyproject_reverted_to_placeholder_fails(self): stale = STALE_PLACEHOLDER.encode() self.mutate("pyproject.toml", - lambda raw: raw.replace(b'{ text = "MIT" }', - b'{ text = "' + stale + b'" }')) + lambda raw: raw.replace(b'license = "MIT"', + b'license = "' + stale + b'"')) findings = self.scan() self.doCleanups() self.assert_finding(findings, "license") @@ -454,13 +526,20 @@ def test_checklist_checking_off_the_visibility_blocker_fails(self): self.doCleanups() self.assert_finding(findings, "visibility") - def test_checklist_checking_off_the_export_review_blocker_fails(self): + def test_checklist_losing_the_full_history_gate_fails(self): + self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw.replace( + b"full_history_readiness_check", b"some_other_check")) + findings = self.scan() + self.doCleanups() + self.assert_finding(findings, "full-history") + + def test_checklist_checking_off_the_reporting_blocker_fails(self): self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw.replace( - b"- [ ] **Final clean-history export", - b"- [x] **Final clean-history export")) + b"- [ ] **Enable and verify private vulnerability reporting", + b"- [x] **Enable and verify private vulnerability reporting")) findings = self.scan() self.doCleanups() - self.assert_finding(findings, "export") + self.assert_finding(findings, "vulnerability reporting") def test_security_md_losing_the_official_url_fails(self): self.mutate("SECURITY.md", @@ -469,15 +548,19 @@ def test_security_md_losing_the_official_url_fails(self): self.doCleanups() self.assert_finding(findings, "SECURITY.md") - def test_checker_pass_output_keeps_the_dirty_history_warning(self): + def test_checker_pass_output_points_at_the_full_history_gate(self): # End-to-end on the clean disposable tree: exit 0 AND the PASS line - # still says history is not covered and must not be pushed publicly. + # states its tree-only scope, names the separate full-history gate, + # and keeps the warning that the ORIGINAL pre-export working + # history must not be pushed publicly. proc = subprocess.run( [sys.executable, str(self.repo / "tests" / "public_readiness_check.py")], cwd=self.repo, capture_output=True, text=True, ) self.assertEqual(proc.returncode, 0, f"checker failed on clean tree:\n{proc.stdout}\n{proc.stderr}") + self.assertIn("current tracked tree only", proc.stdout) + self.assertIn("full_history_readiness_check", proc.stdout) self.assertIn("must not be pushed publicly", proc.stdout) diff --git a/tests/test_model_audit_cli.py b/tests/test_model_audit_cli.py index 20a19da..46f1512 100644 --- a/tests/test_model_audit_cli.py +++ b/tests/test_model_audit_cli.py @@ -211,6 +211,90 @@ def test_text_output_default_and_content_free(self): self.assertNotIn(SENTINEL, out) +class TestUsageReporting(CliFixture): + """0.3.0b1: privacy-safe usage aggregates ride the audit output — + stable bounded keys in JSON, an explicit null/unavailable for missing + or malformed optional usage, never any transcript text.""" + + USAGE = { + FABLE: {"inputTokens": 10, "outputTokens": 20, + "cacheReadInputTokens": 30, "cacheCreationInputTokens": 40, + "webSearchRequests": 1, "costUSD": 0.75}, + HAIKU: {"inputTokens": 1, "outputTokens": 2, + "cacheReadInputTokens": 3, "cacheCreationInputTokens": 4, + "webSearchRequests": 0, "costUSD": 0.25}, + } + + def test_json_output_carries_bounded_usage(self): + path = self.write("usage.jsonl", [ + assistant_line("msg_01", FABLE, text="done"), + result_line(model_usage=self.USAGE), + ]) + rc, out, err = run_cli(["--transcript", str(path), + "--requested-model", FABLE, + "--format", "json"]) + self.assertEqual(rc, 0, err) + audit = json.loads(out) + self.assertEqual(audit["usage"], { + "input_tokens": 11, + "output_tokens": 22, + "cache_read_input_tokens": 33, + "cache_creation_input_tokens": 44, + "web_search_requests": 1, + "total_cost_usd": 1.0, + }) + + def test_json_missing_usage_is_an_explicit_null(self): + path = self.write("no-usage.jsonl", [ + assistant_line("msg_01", FABLE, text="done"), + result_line(), + ]) + rc, out, _ = run_cli(["--transcript", str(path), + "--requested-model", FABLE, + "--format", "json"]) + self.assertEqual(rc, 0) + audit = json.loads(out) + self.assertIn("usage", audit) + self.assertIsNone(audit["usage"]) + + def test_json_malformed_usage_is_null_and_never_pure(self): + path = self.write("bad-usage.jsonl", [ + assistant_line("msg_01", FABLE, text="done"), + result_line(model_usage={FABLE: {"inputTokens": "corrupt"}}), + ]) + rc, out, _ = run_cli(["--transcript", str(path), + "--requested-model", FABLE, + "--format", "json"]) + self.assertEqual(rc, 2, "usage corruption must not prove purity") + audit = json.loads(out) + self.assertIsNone(audit["usage"]) + self.assertIn("malformed_model_usage", audit["reason_codes"]) + self.assertEqual(audit["verdict"], "UNKNOWN") + + def test_text_output_reports_usage_without_content(self): + path = self.write("usage-text.jsonl", [ + assistant_line("msg_01", FABLE, text=f"done {SENTINEL}"), + result_line(model_usage=self.USAGE), + ]) + rc, out, _ = run_cli(["--transcript", str(path), + "--requested-model", FABLE]) + self.assertEqual(rc, 0) + self.assertIn("usage:", out) + self.assertIn("input_tokens=11", out) + self.assertIn("total_cost_usd=1.0", out) + self.assertNotIn(SENTINEL, out) + + def test_text_output_says_usage_unavailable(self): + path = self.write("usage-none.jsonl", [ + assistant_line("msg_01", FABLE, text="done"), + result_line(), + ]) + rc, out, _ = run_cli(["--transcript", str(path), + "--requested-model", FABLE]) + self.assertEqual(rc, 0) + self.assertIn("usage: unavailable", out) + + class TestManifestMode(CliFixture): def test_manifest_mode_matches_explicit_mode(self): rc_a, out_a, _ = run_cli( @@ -322,12 +406,11 @@ def test_console_scripts_registered(self): pyproject = ( Path(__file__).resolve().parents[1] / "pyproject.toml" ).read_text(encoding="utf-8") - # Canonical command plus the deprecated one-release alias. + # Exactly the canonical command; the deprecated 0.1 aliases were + # removed in 0.3.0b1 after their one migration release. self.assertIn('fable-session = "fable_session.cli:main"', pyproject) - self.assertIn( - 'claude-context-audit-models = "fable_session.model_audit:legacy_main"', - pyproject, - ) + self.assertNotIn("claude-context-audit-models", pyproject) + self.assertNotIn("legacy_main", pyproject) if __name__ == "__main__": diff --git a/tests/test_public_readiness.py b/tests/test_public_readiness.py index cdd909f..dfff60d 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -200,6 +200,26 @@ def test_credential_patterns_cover_all_six_classes(self): f"no checker pattern matches the {label} sample", ) + def test_actions_permission_grants_are_not_credentials(self): + # The release workflow legitimately declares `id-token: write`; + # a permission-grant keyword is not a credential value... + self.plant("README.md", "\npermissions:\n id-" + "token: write\n") + findings = self.scan() + self.doCleanups() + self.assertEqual( + [f for f in findings if "credential-shaped" in f], [], + ) + # ...but any other value after the same key still fails the scan. + # (Composed so this tracked file never contains the shape itself.) + self.plant("README.md", "\nid-tok" + "en: hunter2value\n") + findings = self.scan() + self.doCleanups() + self.assertTrue( + [f for f in findings + if f.startswith("README.md: credential-shaped")], + f"real token assignment not reported; findings: {findings}", + ) + def test_internal_identifier_scan_is_independent(self): # Internal-identifier findings never had exemptions and must not # depend on the credential layer. @@ -247,6 +267,110 @@ def test_examples_use_only_the_canonical_cli(self): self.assertIn("fable-session watch", text) +class TestPublicBetaDocs(unittest.TestCase): + """0.3.0b1 README/SECURITY contract: real installation guidance, honest + prerequisites and platform claims, budget/usage documentation, the + prerelease tag name, the updated payload privacy contract, and the + completed alias removal.""" + + def read(self, name="README.md"): + return (REPO / name).read_text(encoding="utf-8") + + def test_readme_has_an_installation_section(self): + text = self.read() + self.assertIn("## Install", text) + # Pre-index beta: every install form is tag-pinned to the reviewed + # prerelease tag — never a bare index name. + pinned = "git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" + self.assertIn(f'pipx install "{pinned}"', text) + self.assertIn(f'uv tool install "fable-session @ {pinned}"', text) + self.assertIn(f'pip install "fable-session @ {pinned}"', text) + + def test_readme_never_recommends_unqualified_index_installs(self): + # Until the name is actually owned/published on the package index, + # an unqualified install could fetch nothing — or a squatter. + text = self.read() + for banned in ("pipx install fable-session", + "uv tool install fable-session", + "pip install fable-session"): + self.assertNotIn(banned, text, + f"unqualified command recommended: {banned!r}") + + def test_readme_pre_index_warning_precedes_every_install_command(self): + text = self.read() + warning = text.find("not published on PyPI") + self.assertNotEqual(warning, -1, "the pre-index warning is missing") + installs = [idx for idx in (text.find("pipx install"), + text.find("uv tool install"), + text.find("pip install")) + if idx != -1] + self.assertTrue(installs) + self.assertLess(warning, min(installs), + "the pre-index warning must come before every " + "install command") + # Honest availability claim: tag-pinned installs work only once the + # public tag exists. + self.assertIn("only after", text) + self.assertIn("tag exists", text) + + def test_readme_documents_the_payload_created_flag(self): + text = self.read() + self.assertIn("payload_files_created", text) + + def test_readme_lists_prerequisites_and_the_tested_combination(self): + text = self.read() + self.assertIn("Python 3.12", text) + self.assertIn("tmux", text) + self.assertIn("Claude Code", text) + self.assertIn("Claude Code 2.1.208", text) + self.assertIn("Linux", text) + # No unsupported cross-platform claims. + for banned in ("Windows", "macOS", "works everywhere"): + self.assertNotIn(banned, text) + + def test_readme_documents_budget_pass_through(self): + text = self.read() + self.assertIn("max_budget_usd", text) + self.assertIn("--max-budget-usd", text) + + def test_readme_documents_usage_reporting(self): + text = self.read() + self.assertIn("total_cost_usd", text) + self.assertIn("modelUsage", text) + + def test_readme_names_the_prerelease_tag(self): + text = self.read() + self.assertIn("0.3.0b1", text) + self.assertIn("v0.3.0-beta.1", text) + + def test_readme_privacy_contract_matches_the_payload_mechanism(self): + text = self.read() + # The command-line visibility warning stays prominent and truthful: + # the brief remains inspectable on the Claude process, the task + # travels via stdin from a run-scoped 0600 payload file. + self.assertIn("same-host process inspection", text) + self.assertIn("stdin", text) + self.assertIn("payload", text) + self.assertIn("0600", text) + # The repaired defect is documented, not hidden. + self.assertIn("command too long", text) + # The old claim that payloads ride the tmux command line is gone. + self.assertNotIn("deliberately deferred", text) + + def test_readme_says_the_aliases_are_removed(self): + text = self.read() + self.assertIn("claude-context-run", text) # migration table stays + self.assertNotIn("will be removed in the release after 0.2.0", text) + self.assertNotIn("remain installed", text) + flat_text = " ".join(text.lower().split()) + self.assertIn("removed in 0.3.0b1", flat_text) + + def test_security_supports_the_0_3_line(self): + text = self.read("SECURITY.md") + self.assertIn("0.3.x", text) + self.assertNotIn("(0.2.x)", text) + + class TestSecurityPolicy(unittest.TestCase): def test_security_md_uses_github_private_reporting_and_no_contact(self): path = REPO / "SECURITY.md" @@ -277,23 +401,198 @@ def test_checklist_blocks_release_on_the_known_blockers(self): self.assertIn("blocked", text) +# Verified action pins (tag SHAs), with their friendly versions. These are +# fixed decisions: drift is a finding, not a new choice. +PINNED_CHECKOUT = "actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5" +PINNED_SETUP_PYTHON = ( + "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065" +) +PINNED_UPLOAD_ARTIFACT = ( + "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" +) +PINNED_ATTEST = ( + "actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661" +) +PINNED_GH_RELEASE = ( + "softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65" +) + +# CI/release-only Python tooling, pinned to the explicitly tested versions. +# Drift is a deliberate re-test, never a silent float. +PINNED_TOOLING = ("PyYAML==6.0.2", "build==1.5.1", "setuptools==83.0.0", + "wheel==0.47.0") + + +def assert_workflow_python_tooling_is_pinned(testcase, text): + """Every executable pip install/download of an index package must be + ==-pinned. + + Only executable workflow lines count: YAML comment lines are + documentation and may legitimately mention floating floors (e.g. the + pyproject `setuptools>=77` metadata) without installing anything. + Local paths (dist/*.whl) and flags are exempt; bare requirement names + or floating specifiers (>=) in executable lines are findings. + """ + for pin in PINNED_TOOLING: + testcase.assertIn(pin, text, f"workflow must pin {pin}") + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue # comment/documentation, not an executable spec + testcase.assertNotIn( + "setuptools>=", stripped, + f"floating setuptools specifier must be pinned: {stripped}") + if "pip install" not in stripped and "pip download" not in stripped: + continue + words = stripped.split() + verb = "install" if "install" in words else "download" + for word in words[words.index(verb) + 1:]: + spec = word.strip('"').strip("'") + if spec.startswith("-") or "/" in spec or not spec: + continue + testcase.assertIn( + "==", spec, + f"unpinned requirement {spec!r} in workflow line: {stripped}", + ) + + class TestCiWorkflow(unittest.TestCase): - def test_workflow_parses_and_covers_the_gates(self): + def read(self): path = REPO / ".github" / "workflows" / "ci.yml" self.assertTrue(path.is_file(), ".github/workflows/ci.yml is missing") + return path.read_text(encoding="utf-8") + + def test_workflow_parses_and_covers_the_gates(self): import yaml - data = yaml.safe_load(path.read_text(encoding="utf-8")) + text = self.read() + data = yaml.safe_load(text) self.assertIsInstance(data, dict) self.assertIn("jobs", data) - text = path.read_text(encoding="utf-8") self.assertIn('"3.12"', text) for gate in ("compileall", "unittest", "public_readiness_check", - "fable-session"): + "full_history_readiness_check", "offline_install_smoke", + "fable-session", "-m build", "git diff --check"): self.assertIn(gate, text, f"CI must run: {gate}") # No invented ownership: the workflow must not hardcode a repo URL. self.assertNotIn("github.com/", text) + def test_actions_are_pinned_to_the_verified_tag_shas(self): + text = self.read() + self.assertIn(PINNED_CHECKOUT, text) + self.assertIn(PINNED_SETUP_PYTHON, text) + # No floating tags anywhere. + self.assertNotIn("actions/checkout@v", text) + self.assertNotIn("actions/setup-python@v", text) + + def test_full_history_scan_gets_full_depth(self): + # A shallow default checkout would silently reduce the full-history + # scan to one commit. + self.assertIn("fetch-depth: 0", self.read()) + + def test_offline_smoke_uses_a_preseeded_wheelhouse_not_the_index(self): + text = self.read() + self.assertIn("FABLE_SESSION_WHEELHOUSE", text) + # The wheelhouse is prepared in a separate earlier step; the smoke + # itself must not be handed an index. + smoke_lines = [line for line in text.splitlines() + if "offline_install_smoke" in line] + self.assertTrue(smoke_lines) + for line in smoke_lines: + self.assertNotIn("--index-url", line) + + def test_wheel_install_smokes_the_installed_cli(self): + text = self.read() + self.assertIn("dist/*.whl", text) + self.assertIn("fable-session --version", text) + + def test_top_level_permissions_are_least_privilege(self): + import yaml + + data = yaml.safe_load(self.read()) + self.assertEqual( + data.get("permissions"), {"contents": "read"}, + "ci.yml must declare exactly `permissions: contents: read` at " + "the top level — CI only ever reads the repository", + ) + for name, job in data["jobs"].items(): + self.assertNotIn( + "permissions", job, + f"job '{name}' must not widen the read-only default", + ) + + def test_ci_python_tooling_is_pinned(self): + assert_workflow_python_tooling_is_pinned(self, self.read()) + + +class TestPrereleaseWorkflow(unittest.TestCase): + """Tag-triggered prerelease: build wheel/sdist, run the release gates, + publish SHA256SUMS + a GitHub prerelease, and attest provenance with + GitHub artifact attestation — never an invented maintainer signing key. + PyPI stays out of scope.""" + + def read(self): + path = REPO / ".github" / "workflows" / "release.yml" + self.assertTrue(path.is_file(), + ".github/workflows/release.yml is missing") + return path.read_text(encoding="utf-8") + + def test_workflow_parses_and_triggers_on_version_tags(self): + import yaml + + text = self.read() + data = yaml.safe_load(text) + self.assertIsInstance(data, dict) + trigger = data.get("on") or data.get(True) + self.assertIn("push", trigger) + self.assertTrue( + any(tag.startswith("v") for tag in trigger["push"]["tags"]) + ) + + def test_actions_are_pinned_to_the_verified_tag_shas(self): + text = self.read() + for pin in (PINNED_CHECKOUT, PINNED_SETUP_PYTHON, + PINNED_UPLOAD_ARTIFACT, PINNED_ATTEST, + PINNED_GH_RELEASE): + self.assertIn(pin, text) + for floating in ("actions/checkout@v", "actions/setup-python@v", + "actions/upload-artifact@v", + "attest-build-provenance@v", + "action-gh-release@v"): + self.assertNotIn(floating, text) + + def test_release_gates_run_before_publishing(self): + text = self.read() + for gate in ("compileall", "unittest", "public_readiness_check", + "full_history_readiness_check", "offline_install_smoke", + "-m build"): + self.assertIn(gate, text, f"release workflow must run: {gate}") + + def test_prerelease_with_checksums_and_attestation_no_signing_key(self): + import yaml + + text = self.read() + self.assertIn("prerelease: true", text) + self.assertIn("SHA256SUMS", text) + self.assertIn("attestations: write", text) + self.assertIn("id-token: write", text) + lowered = text.lower() + for banned in ("gpg", "signing key", "secret_key", "minisign", + "sigstore-python"): + self.assertNotIn(banned, lowered) + # yaml parse sanity for permissions + data = yaml.safe_load(text) + self.assertEqual(data["permissions"]["attestations"], "write") + + def test_release_python_tooling_is_pinned(self): + assert_workflow_python_tooling_is_pinned(self, self.read()) + + def test_pypi_publication_stays_out_of_scope(self): + lowered = self.read().lower() + for banned in ("pypi", "twine", "gh-action-pypi-publish"): + self.assertNotIn(banned, lowered) + self.assertNotIn("github.com/", self.read()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_release_tag_gate.py b/tests/test_release_tag_gate.py new file mode 100644 index 0000000..a73e3b3 --- /dev/null +++ b/tests/test_release_tag_gate.py @@ -0,0 +1,183 @@ +"""Fail-closed prerelease tag <-> package-version consistency gate. + +For package version ``0.3.0b1`` exactly one tag may release: +``v0.3.0-beta.1``. The mapping is deterministic and dependency-free +(stdlib only), extends mechanically to later beta numbers +(``X.Y.ZbN`` <-> ``vX.Y.Z-beta.N``), and fails closed on every other +version shape (finals, rc/alpha/dev/post, malformed numbers): anything +the mapping does not recognize must refuse to release, never guess. +""" + +import contextlib +import importlib.util +import io +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def load_gate(): + spec = importlib.util.spec_from_file_location( + "release_tag_check_under_test", + REPO / "tests" / "release_tag_check.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestBetaVersionToTagMapping(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.gate = load_gate() + + def test_gate_script_exists(self): + self.assertTrue((REPO / "tests" / "release_tag_check.py").is_file(), + "tests/release_tag_check.py is missing") + + def test_canonical_beta_mapping_is_deterministic(self): + cases = { + "0.3.0b1": "v0.3.0-beta.1", + "0.3.0b2": "v0.3.0-beta.2", + "0.3.1b1": "v0.3.1-beta.1", + "0.10.0b3": "v0.10.0-beta.3", + "1.2.3b10": "v1.2.3-beta.10", + } + for version, tag in cases.items(): + with self.subTest(version=version): + self.assertEqual(self.gate.expected_tag(version), tag) + + def test_non_beta_and_malformed_versions_fail_closed(self): + for bad in ("0.3.0", # final: not this gate's business + "0.3.0rc1", # release candidate: unmapped + "0.3.0a1", # alpha: unmapped + "0.3.0b1.post1", # post suffix: unmapped + "0.3.0b1.dev1", # dev suffix: unmapped + "0.3.0b01", # leading zero: not canonical + "0.3.0b", # missing beta number + "0.3b1", # missing patch component + "v0.3.0b1", # a tag is not a version + "0.3.0-beta.1", # a tag-shaped string is not a version + "0.3.0b1 ", # trailing whitespace + "", # empty + None): # not a string + with self.subTest(version=bad): + with self.assertRaises(self.gate.ReleaseTagError): + self.gate.expected_tag(bad) + + def test_repo_pyproject_pins_the_release_pair(self): + # The gate reads THIS repository's pyproject: version 0.3.0b1 must + # map to the checklist's prerelease tag v0.3.0-beta.1. + version = self.gate.read_package_version() + self.assertEqual(version, "0.3.0b1") + self.assertEqual(self.gate.expected_tag(version), "v0.3.0-beta.1") + + +class TestGateMain(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.gate = load_gate() + + def run_main(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = self.gate.main(list(argv)) + return rc, out.getvalue(), err.getvalue() + + def test_only_the_canonical_tag_releases(self): + rc, out, _ = self.run_main("--tag", "v0.3.0-beta.1") + self.assertEqual(rc, 0, out) + self.assertIn("v0.3.0-beta.1", out) + self.assertIn("0.3.0b1", out) + + def test_every_other_tag_is_refused(self): + for bad in ("v0.3.0b1", "v0.3.0-beta.2", "v0.3.0", "v0.2.0", + "0.3.0-beta.1", "v0.3.0-beta.1 ", "V0.3.0-beta.1"): + with self.subTest(tag=bad): + rc, _, err = self.run_main("--tag", bad) + self.assertEqual(rc, 1, f"tag {bad!r} must be refused") + self.assertIn("refus", err.lower()) + + def test_missing_tag_argument_fails(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + self.gate.main([]) + + def test_unmapped_package_version_fails_closed_even_with_its_own_tag(self): + # A final (or otherwise unmapped) version must refuse EVERY tag: + # the gate never invents a mapping it was not taught. + with tempfile.TemporaryDirectory() as tmp: + pyproject = Path(tmp) / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "fable-session"\nversion = "0.4.0"\n', + encoding="utf-8", + ) + for tag in ("v0.4.0", "v0.4.0-beta.1"): + with self.subTest(tag=tag): + rc, _, err = self.run_main( + "--tag", tag, "--pyproject", str(pyproject) + ) + self.assertEqual(rc, 1) + self.assertIn("0.4.0", err) + + def test_missing_or_versionless_pyproject_fails_closed(self): + with tempfile.TemporaryDirectory() as tmp: + missing = Path(tmp) / "nope.toml" + rc, _, _ = self.run_main("--tag", "v0.3.0-beta.1", + "--pyproject", str(missing)) + self.assertEqual(rc, 1) + versionless = Path(tmp) / "pyproject.toml" + versionless.write_text('[project]\nname = "fable-session"\n', + encoding="utf-8") + rc, _, _ = self.run_main("--tag", "v0.3.0-beta.1", + "--pyproject", str(versionless)) + self.assertEqual(rc, 1) + + +class TestReleaseWorkflowRunsTheGate(unittest.TestCase): + def read(self): + path = REPO / ".github" / "workflows" / "release.yml" + self.assertTrue(path.is_file()) + return path.read_text(encoding="utf-8") + + def test_gate_runs_before_gates_build_and_publish(self): + # Parse the actual step list: matching raw text would hit the same + # markers in documentation comments before any executable step. + import yaml + + data = yaml.safe_load(self.read()) + steps = data["jobs"]["prerelease"]["steps"] + + def step_text(step): + return " ".join( + str(step.get(key, "")) for key in ("name", "uses", "run") + ) + + gate_steps = [i for i, step in enumerate(steps) + if "release_tag_check.py" in str(step.get("run", ""))] + self.assertEqual( + len(gate_steps), 1, + "release.yml must run the tag consistency gate exactly once") + gate_at = gate_steps[0] + for step in steps[:gate_at]: + self.assertTrue( + str(step.get("uses", "")).startswith( + ("actions/checkout@", "actions/setup-python@")), + "only checkout/setup-python may precede the tag gate, " + f"found: {step_text(step)!r}") + for marker in ("compileall", "-m build", "SHA256SUMS", + "attest-build-provenance", "action-gh-release"): + later = [i for i, step in enumerate(steps) + if marker in step_text(step)] + self.assertTrue(later, f"release.yml lost its {marker!r} step") + self.assertLess(gate_at, min(later), + f"tag gate must run before: {marker}") + + def test_gate_checks_the_pushed_tag_name(self): + self.assertIn("GITHUB_REF_NAME", self.read()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py index 96e0a8b..6ccdfce 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -146,6 +146,15 @@ def invoke_launch(self, *extra, side_effect=None): *extra, run_side_effect=side_effect or fake_tmux_run() ) + @staticmethod + def split_wrapper(tmux_argv): + """(wrapper argv, claude argv) from one tmux new-session argv: the + wrapper follows tmux's `--`, the Claude command follows the + wrapper's own (last) `--` separator.""" + wrapper_cmd = tmux_argv[8:] + sep = len(wrapper_cmd) - 1 - wrapper_cmd[::-1].index("--") + return wrapper_cmd, wrapper_cmd[sep + 1:] + class TestAbsoluteRuntimePaths(RunnerFixture): """M1/M2 (Fable 5 max audit): every runtime path that crosses the @@ -265,7 +274,7 @@ def test_relative_claude_bin_launch_normalizes_to_caller_cwd(self): self.assertEqual(manifest["command_redacted"][0], str(self.base / "claude")) new_session = [c for c in calls if c[:2] == ["tmux", "new-session"]][0] - claude_cmd = new_session[8:][5:] + _, claude_cmd = self.split_wrapper(new_session) self.assertEqual(claude_cmd[0], str(self.base / "claude")) def test_find_claude_relative_explicit_resolves_against_cwd(self): @@ -308,11 +317,10 @@ def test_path_discovery_with_relative_path_entry_returns_absolute(self): class TestBuildCommand(unittest.TestCase): @staticmethod - def build(mode="append", permission_mode="acceptEdits", - brief="BRIEF", task="TASK"): + def build(mode="append", permission_mode="acceptEdits"): return build_command( Path("/bin/claude"), "claude-fable-5", "high", permission_mode, - mode, brief, task, "0000-sid", + mode, "0000-sid", ) def test_append_mode_command(self): @@ -322,7 +330,10 @@ def test_append_mode_command(self): "--permission-mode", "acceptEdits", "--session-id", "0000-sid", "--settings", '{"switchModelsOnFlag": false}', "--output-format", "stream-json", "--verbose", - "--append-system-prompt", "BRIEF", "-p", "TASK", + # Payload-free by construction: the brief slot carries only the + # placeholder the capture wrapper substitutes, and `-p` has no + # inline prompt (the task arrives on stdin). + "--append-system-prompt", runner.BRIEF_PLACEHOLDER, "-p", ]) def test_replace_mode_uses_system_prompt(self): @@ -357,13 +368,25 @@ def test_every_safe_cli_mode_builds(self): self.assertEqual(cmd[idx + 1], mode) def test_redaction_replaces_payloads(self): - cmd = self.build(brief="BRIEF-BODY", task="TASK-BODY") + # The built command carries the placeholder, not the brief; the + # redaction renders it as the brief's hash/size token so manifests + # and logs describe the effective command without prompt text. + cmd = self.build() redacted = redact_command(cmd, "BRIEF-BODY", "TASK-BODY") joined = " ".join(redacted) self.assertNotIn("BRIEF-BODY", joined) self.assertNotIn("TASK-BODY", joined) + self.assertNotIn(runner.BRIEF_PLACEHOLDER, joined) self.assertIn("` Claude argument, is printed in the + redacted summary, and lands as structural manifest metadata. Absent + budget: no flag, truthful null.""" + + def add_budget(self, raw="12.5"): + self.registry.write_text( + self.registry.read_text(encoding="utf-8") + + f"max_budget_usd = {raw}\n", + encoding="utf-8", + ) + + def test_build_command_emits_exactly_one_budget_flag(self): + cmd = build_command( + Path("/bin/claude"), "claude-fable-5", "high", "acceptEdits", + "append", "0000-sid", max_budget_usd="12.5", + ) + self.assertEqual(cmd.count("--max-budget-usd"), 1) + self.assertEqual(cmd[cmd.index("--max-budget-usd") + 1], "12.5") + + def test_build_command_without_budget_emits_no_flag(self): + cmd = build_command( + Path("/bin/claude"), "claude-fable-5", "high", "acceptEdits", + "append", "0000-sid", + ) + self.assertNotIn("--max-budget-usd", cmd) + + def test_dry_run_with_budget_flows_to_summary_command_and_manifest(self): + self.add_budget("12.5") + rc, out, _ = self.invoke() + self.assertEqual(rc, 0) + self.assertIn("max budget: 12.5 USD", out) + manifest, path = self.read_manifest() + self.assertEqual(manifest["max_budget_usd"], 12.5) + redacted = manifest["command_redacted"] + self.assertEqual(redacted.count("--max-budget-usd"), 1) + self.assertEqual(redacted[redacted.index("--max-budget-usd") + 1], + "12.5") + + def test_integer_budget_uses_canonical_integer_form(self): + self.add_budget("8") + rc, out, _ = self.invoke() + self.assertEqual(rc, 0) + self.assertIn("max budget: 8 USD", out) + manifest, _ = self.read_manifest() + self.assertEqual(manifest["max_budget_usd"], 8) + redacted = manifest["command_redacted"] + self.assertEqual(redacted[redacted.index("--max-budget-usd") + 1], "8") + + def test_absent_budget_is_recorded_truthfully_and_emits_no_flag(self): + rc, out, _ = self.invoke() + self.assertEqual(rc, 0) + self.assertIn("max budget: none configured", out) + manifest, _ = self.read_manifest() + self.assertIn("max_budget_usd", manifest) + self.assertIsNone(manifest["max_budget_usd"]) + self.assertNotIn("--max-budget-usd", manifest["command_redacted"]) + + def test_launch_command_carries_the_budget_flag_once(self): + self.add_budget("12.5") + rc, _, calls = self.invoke_launch("--launch", "--tmux", "ex-demo") + self.assertEqual(rc, 0) + new_session = [c for c in calls if c[:2] == ["tmux", "new-session"]][0] + self.assertEqual(new_session.count("--max-budget-usd"), 1) + self.assertEqual( + new_session[new_session.index("--max-budget-usd") + 1], "12.5" + ) + manifest, _ = self.read_manifest() + self.assertEqual(manifest["max_budget_usd"], 12.5) + + def test_invalid_budget_is_refused_before_any_command_exists(self): + self.add_budget("true") + with self.assertRaisesRegex(runner.ConfigError, "max_budget_usd"): + self.invoke() + runs = self.state_dir / "runs" + self.assertEqual(list(runs.glob("*")) if runs.exists() else [], []) + + +class TestPayloadCommandLength(RunnerFixture): + """0.3.0b1 verified-defect repair: a 7,745-byte task plus an 8,072-byte + brief made `tmux new-session` fail with `command too long`. Prompt + payloads now travel in run-scoped 0600 payload files delivered by the + capture wrapper (brief via one placeholder substitution, task via + stdin); the tmux command line stays short and payload-free regardless + of task size.""" + + # Pad the task so the once-read task text is exactly 7,745 bytes and + # the rendered brief crosses 8,000 bytes — the reproduced failing + # launch (`tmux: command too long`). + GOAL_SENTENCE = ( + "Add a CSV export endpoint for the station table without changing auth." + ) + + def write_large_fixtures(self): + profile = PROFILE_TOML.replace( + "max_brief_bytes = 2048", "max_brief_bytes = 16384" + ).replace( + 'allowed_roots = ["backend", "tests"]', + 'allowed_roots = ["backend", "tests", "docs", "examples", ' + '"scripts", "assets", "integration", "deployment"]', + ).replace( + PRODUCT, + PRODUCT + " It also ships a long-form reporting pipeline and a " + "station telemetry ingest service used by the export endpoint.", + ) + self.profile_path.write_text(profile, encoding="utf-8") + filler = "\n".join( + f"- boundary item {i:02d}: " + "x" * 118 for i in range(48) + ) + task = TASK_MD.replace("- no new dependencies", + "- no new dependencies\n" + filler) + pad = 7745 - len(task.encode("utf-8")) - 1 + self.assertGreater(pad, 0) + # Goal padding lands in the task AND the rendered brief. + task = task.replace( + self.GOAL_SENTENCE, self.GOAL_SENTENCE + " " + "y" * pad + ) + self.assertEqual(len(task.encode("utf-8")), 7745) + self.task_path.write_text(task, encoding="utf-8") + return task + + def launch_argv(self, calls): + new_session = [c for c in calls if c[:2] == ["tmux", "new-session"]] + self.assertEqual(len(new_session), 1) + return new_session[0] + + def test_regression_large_task_and_brief_keep_tmux_command_short(self): + task = self.write_large_fixtures() + rc, out, calls = self.invoke_launch("--launch", "--tmux", "ex-big") + self.assertEqual(rc, 0) + argv = self.launch_argv(calls) + total_bytes = sum(len(arg.encode("utf-8")) + 1 for arg in argv) + self.assertLess( + total_bytes, 4096, + f"tmux command is {total_bytes} bytes; the 16KB-payload launch " + "regression (`command too long`) is back", + ) + joined = " ".join(argv) + self.assertNotIn("boundary item 12", joined) + self.assertNotIn(PRODUCT, joined) + # The brief really crossed the reproduced threshold. + manifest, _ = self.read_manifest() + self.assertGreater(manifest["brief_bytes"], 8000) + self.assertEqual(len(task.encode("utf-8")), 7745) + + def test_payload_files_hold_the_exact_scanned_bytes_privately(self): + rc, _, _ = self.invoke_launch("--launch", "--tmux", "ex-demo") + self.assertEqual(rc, 0) + manifest, _ = self.read_manifest() + run_dir = self.state_dir / "runs" / manifest["run_id"] + task_payload = run_dir / "task.payload" + brief_payload = run_dir / "brief.payload" + self.assertEqual(manifest["task_payload_path"], str(task_payload)) + self.assertEqual(manifest["brief_payload_path"], str(brief_payload)) + self.assertEqual(task_payload.read_text(encoding="utf-8"), TASK_MD) + self.assertEqual( + runner.sha256_text(task_payload.read_text(encoding="utf-8")), + manifest["task_sha256"], + ) + self.assertEqual( + runner.sha256_text(brief_payload.read_text(encoding="utf-8")), + manifest["brief_sha256"], + ) + for payload in (task_payload, brief_payload): + self.assertEqual( + stat.S_IMODE(os.stat(payload).st_mode), 0o600, payload + ) + + def test_claude_argv_carries_placeholder_and_stdin_task_only(self): + rc, _, calls = self.invoke_launch("--launch", "--tmux", "ex-demo") + self.assertEqual(rc, 0) + argv = self.launch_argv(calls) + sep = argv.index("--") # tmux's own separator + wrapper_cmd = argv[sep + 1:] + claude_sep = len(wrapper_cmd) - 1 - wrapper_cmd[::-1].index("--") + claude_cmd = wrapper_cmd[claude_sep + 1:] + self.assertEqual(claude_cmd[0], str(self.claude_bin)) + # Brief: exactly one placeholder in the prompt-flag slot. + self.assertEqual(claude_cmd.count(runner.BRIEF_PLACEHOLDER), 1) + self.assertEqual( + claude_cmd[claude_cmd.index("--append-system-prompt") + 1], + runner.BRIEF_PLACEHOLDER, + ) + # Task: `-p` print mode with NO inline prompt — stdin delivers it. + self.assertEqual(claude_cmd[-1], "-p") + self.assertNotIn(TASK_MD, claude_cmd) + # Wrapper options name the payloads and their recorded hashes. + manifest, _ = self.read_manifest() + opts = wrapper_cmd[:claude_sep] + for flag, value in ( + ("--brief-payload", manifest["brief_payload_path"]), + ("--brief-sha256", manifest["brief_sha256"]), + ("--task-payload", manifest["task_payload_path"]), + ("--task-sha256", manifest["task_sha256"]), + ): + self.assertIn(flag, opts) + self.assertEqual(opts[opts.index(flag) + 1], value) + + def test_payloads_exist_before_tmux_new_session(self): + seen = {} + + def side(argv, **kwargs): + if argv[0] == "git": + return mock.Mock(returncode=0, stdout="feat/example\n", stderr="") + if argv[:2] == ["tmux", "has-session"]: + return mock.Mock(returncode=1, stdout="", stderr="") + if argv[:2] == ["tmux", "new-session"]: + run_dir = next((self.state_dir / "runs").iterdir()) + seen["names"] = sorted(p.name for p in run_dir.iterdir()) + seen["task"] = (run_dir / "task.payload").read_text("utf-8") + return mock.Mock(returncode=0, stdout="", stderr="") + raise AssertionError(f"unexpected subprocess: {argv}") + + rc, _, _ = self.invoke_launch("--launch", "--tmux", "ex-demo", + side_effect=side) + self.assertEqual(rc, 0) + self.assertEqual( + seen["names"], ["brief.payload", "manifest.json", "task.payload"] + ) + self.assertEqual(seen["task"], TASK_MD) + + def test_dry_run_leaves_no_payload_material(self): + rc, out, _ = self.invoke() + self.assertEqual(rc, 0) + manifest, path = self.read_manifest() + run_dir = path.parent + self.assertEqual([p.name for p in run_dir.iterdir()], + ["manifest.json"], + "a dry run must leave no payload files behind") + # The manifest still describes the future payload paths truthfully. + self.assertEqual(manifest["task_payload_path"], + str(run_dir / "task.payload")) + self.assertEqual(manifest["brief_payload_path"], + str(run_dir / "brief.payload")) + + def test_payload_write_failure_fails_closed_before_tmux(self): + real_write = runner.state.ReservedRun.write_private_file + calls = [] + + def failing_payload(reservation, name, data): + raise runner.state.StateError("simulated payload write failure") + + def recording(argv, **kwargs): + calls.append(list(argv)) + return fake_tmux_run()(argv, **kwargs) + + with mock.patch.object(runner.state.ReservedRun, "write_private_file", + failing_payload), \ + mock.patch.object(runner.shutil, "which", + return_value="/usr/bin/tmux"), \ + mock.patch.object(runner.subprocess, "run", + side_effect=recording): + with self.assertRaises((RunnerError, runner.state.StateError)): + runner.run(self.argv("--launch", "--tmux", "ex-demo"), + out=io.StringIO()) + self.assertFalse([c for c in calls if c[:2] == ["tmux", "new-session"]], + "tmux must not start without verified payloads") + manifest, _ = self.read_manifest() + self.assertIn(manifest["status"], ("pending", "failed")) + self.assertNotEqual(manifest["status"], "launched") + + +class TestPayloadCreationTruthfulness(RunnerFixture): + """Follow-up hardening: the manifest states payload REALITY, not intent. + `payload_files_created` is False whenever the run-scoped 0600 payload + files were not (both) created — every dry run, and every launch record + written before or after a payload-write failure — and True only in + manifest writes made after BOTH payload files were securely created.""" + + def test_dry_run_manifest_records_payloads_not_created(self): + rc, _, _ = self.invoke() + self.assertEqual(rc, 0) + manifest, path = self.read_manifest() + self.assertIs(manifest["payload_files_created"], False) + self.assertEqual([p.name for p in path.parent.iterdir()], + ["manifest.json"]) + + def test_dry_run_flag_stays_deterministic(self): + self.invoke() + first, _ = self.read_manifest() + self.invoke() + second, _ = self.read_manifest() + self.assertEqual(first, second) + self.assertIs(first["payload_files_created"], False) + + def test_launched_manifest_records_payloads_created(self): + rc, _, _ = self.invoke_launch("--launch", "--tmux", "ex-demo") + self.assertEqual(rc, 0) + manifest, path = self.read_manifest() + self.assertEqual(manifest["status"], "launched") + self.assertIs(manifest["payload_files_created"], True) + self.assertTrue((path.parent / "brief.payload").is_file()) + self.assertTrue((path.parent / "task.payload").is_file()) + + def test_pending_manifest_written_before_payloads_says_false(self): + seen = {} + real_write = runner.state.ReservedRun.write_private_file + + def observing(reservation, name, data): + if "pending" not in seen: + seen["pending"], _ = self.read_manifest() + return real_write(reservation, name, data) + + with mock.patch.object(runner.state.ReservedRun, + "write_private_file", observing): + rc, _, _ = self.invoke_launch("--launch", "--tmux", "ex-demo") + self.assertEqual(rc, 0) + self.assertEqual(seen["pending"]["status"], "pending") + self.assertIs(seen["pending"]["payload_files_created"], False) + + def test_tmux_failure_after_payload_creation_records_true(self): + with self.assertRaisesRegex(RunnerError, "tmux new-session failed"): + self.invoke_launch("--launch", "--tmux", "ex-demo", + side_effect=fake_tmux_run(new_session_rc=1)) + manifest, path = self.read_manifest() + self.assertEqual(manifest["status"], "failed") + # Truthful: the payloads WERE created even though tmux failed. + self.assertIs(manifest["payload_files_created"], True) + self.assertTrue((path.parent / "brief.payload").is_file()) + self.assertTrue((path.parent / "task.payload").is_file()) + + def test_payload_write_failure_keeps_flag_false(self): + def failing(reservation, name, data): + raise runner.state.StateError("simulated payload write failure") + + with mock.patch.object(runner.state.ReservedRun, + "write_private_file", failing), \ + mock.patch.object(runner.shutil, "which", + return_value="/usr/bin/tmux"), \ + mock.patch.object(runner.subprocess, "run", + side_effect=fake_tmux_run()): + with self.assertRaises((RunnerError, runner.state.StateError)): + runner.run(self.argv("--launch", "--tmux", "ex-demo"), + out=io.StringIO()) + manifest, _ = self.read_manifest() + self.assertIn(manifest["status"], ("pending", "failed")) + self.assertIs(manifest["payload_files_created"], False) + + def test_partial_payload_failure_keeps_flag_false(self): + # One of two payload files written, the second fails: True would be + # a lie ("the payload files were created"), so the flag stays False. + real_write = runner.state.ReservedRun.write_private_file + + def second_fails(reservation, name, data): + if name == runner.TASK_PAYLOAD_FILENAME: + raise runner.state.StateError("simulated task payload failure") + return real_write(reservation, name, data) + + with mock.patch.object(runner.state.ReservedRun, + "write_private_file", second_fails), \ + mock.patch.object(runner.shutil, "which", + return_value="/usr/bin/tmux"), \ + mock.patch.object(runner.subprocess, "run", + side_effect=fake_tmux_run()): + with self.assertRaises((RunnerError, runner.state.StateError)): + runner.run(self.argv("--launch", "--tmux", "ex-demo"), + out=io.StringIO()) + manifest, _ = self.read_manifest() + self.assertIn(manifest["status"], ("pending", "failed")) + self.assertIs(manifest["payload_files_created"], False) + + class TestReadOnceInputs(RunnerFixture): """Blocker 1: task/profile are read exactly once; the scanned bytes are the launched bytes. Reproduces the reviewed safe-first/secret-second @@ -682,10 +1072,13 @@ def flaky_read_text(path, *a, **kw): self.assertEqual(rc, 0) self.assertEqual(reads, {"task": 1, "profile": 1}) new_session = [c for c in calls if c[:2] == ["tmux", "new-session"]][0] - payload = new_session[new_session.index("-p") + 1] - self.assertEqual(payload, TASK_MD) self.assertNotIn("sk-" + "ant-injected", " ".join(new_session)) manifest, _ = self.read_manifest() + # The launched bytes ARE the once-scanned bytes: the run-scoped task + # payload file (what Claude receives on stdin) holds exactly the + # first-read text, and the recorded hash matches it. + task_payload = Path(manifest["task_payload_path"]) + self.assertEqual(task_payload.read_text(encoding="utf-8"), TASK_MD) self.assertEqual(manifest["task_sha256"], runner.sha256_text(TASK_MD)) self.assertEqual(manifest["profile_sha256"], runner.sha256_text(PROFILE_TOML)) @@ -1403,7 +1796,10 @@ def side(argv, **kwargs): self.assertEqual(list(old_run.iterdir()), []) manifest = json.loads((moved / "manifest.json").read_text("utf-8")) self.assertEqual(manifest["status"], "launched") - self.assertEqual([p.name for p in moved.iterdir()], ["manifest.json"]) + self.assertEqual( + sorted(p.name for p in moved.iterdir()), + ["brief.payload", "manifest.json", "task.payload"], + ) # The message reports the bound identity and labels the old path # as display-only, never as the manifest's current location. self.assertIn("bound run-directory inode", msg) @@ -1540,7 +1936,7 @@ def launch_tmux_argv(self, calls): def test_command_includes_stream_flags_exactly_once(self): cmd = build_command( Path("/bin/claude"), "claude-fable-5", "high", "acceptEdits", - "append", "BRIEF", "TASK", "0000-sid", + "append", "0000-sid", ) self.assertEqual(cmd.count("--output-format"), 1) self.assertEqual(cmd[cmd.index("--output-format") + 1], "stream-json") @@ -1552,7 +1948,7 @@ def test_tmux_starts_capture_wrapper_shell_free(self): rc, _, calls = self.invoke_launch("--launch", "--tmux", "ex-demo") self.assertEqual(rc, 0) argv = self.launch_tmux_argv(calls) - wrapper_cmd = argv[8:] + wrapper_cmd, claude_cmd = self.split_wrapper(argv) manifest, _ = self.read_manifest() wrapper_path = runner.capture_wrapper_path() self.assertTrue(wrapper_path.is_file(), wrapper_path) @@ -1560,10 +1956,8 @@ def test_tmux_starts_capture_wrapper_shell_free(self): self.assertEqual(wrapper_cmd[1], str(wrapper_path)) self.assertEqual(wrapper_cmd[2], "--dest") self.assertEqual(wrapper_cmd[3], manifest["expected_transcript_path"]) - self.assertEqual(wrapper_cmd[4], "--") - self.assertEqual(wrapper_cmd[5], str(self.claude_bin)) + self.assertEqual(claude_cmd[0], str(self.claude_bin)) # The Claude argv passes through exactly once with its stream flags. - claude_cmd = wrapper_cmd[5:] self.assertEqual(claude_cmd.count("--output-format"), 1) self.assertEqual(claude_cmd.count("--verbose"), 1) # Shell-free by construction: no shell binary, no `-c`, no pipeline @@ -1616,8 +2010,13 @@ def test_capture_command_is_recorded_redacted(self): manifest, path = self.read_manifest() capture_redacted = manifest["capture_command_redacted"] joined = " ".join(capture_redacted) + # The brief slot is recorded as its hash/size token; the task is + # referenced only by its payload path and full sha256. self.assertIn("