From 1b1a62c5e8172e6d4f3fde8347f7373d7236d9f3 Mon Sep 17 00:00:00 2001 From: Vesper Date: Tue, 14 Jul 2026 11:15:33 +0000 Subject: [PATCH 1/2] feat: 0.3.0b1 public-beta release candidate Budget pass-through: optional registry key max_budget_usd, validated as a finite positive plain-decimal TOML number before command construction; when set, exactly one --max-budget-usd argument is emitted, printed in the redacted summary, and recorded as structural manifest metadata (truthful null when absent). 0.2 registries without the key stay valid. Command-length/privacy repair: prompt payloads no longer ride the tmux command line. The runner writes run-scoped 0600 payload files (brief.payload/task.payload) from its single scanned read via the held reservation FD (new state.ReservedRun.write_private_file); the capture wrapper verifies each payload's sha256 and delivers the brief via exactly-one placeholder substitution and the task via Claude's stdin (-p with no inline prompt). Fixes the verified 7,745-byte-task + 8,072-byte-brief `tmux: command too long` launch failure (regression test bounds the tmux argv). Dry runs leave no payload material; hashes, read-once bytes, redaction, and fail-closed state semantics preserved. Privacy-safe usage reporting: bounded modelUsage aggregates (input/output/ cache-read/cache-creation tokens, web-search requests, total_cost_usd) across models in audit --format json, audit text, and terminal watch events when structurally available. Missing usage is an explicit null; malformed usage is null plus integrity reason malformed_model_usage and never falsely proves purity. No prompt/result/tool text in outputs, events, or state. Release mechanics: version 0.3.0b1 (prerelease tag v0.3.0-beta.1); the three deprecated 0.1 console aliases are removed after their one migration release (old-manifest reading still works); SPDX MIT metadata with license-files, README long description, honest classifiers, and setuptools>=77; README install section (pipx/uv tool/isolated pip, prerequisites, Linux + Claude Code 2.1.208 as the tested combination), updated privacy contract, release notes, and contribution guidance; PUBLIC_RELEASE_CHECKLIST records the reviewed clean root commit, resolves the export blocker, keeps the full-history gate, and corrects the private-to-public order (vulnerability reporting is enabled and verified immediately AFTER going public); new deterministic full-history readiness scan reusing the tracked-tree policy; CI pinned to verified action SHAs and extended (full history, wheelhouse-seeded offline smoke, build, wheel install smoke, whitespace check); new tag-triggered attested prerelease workflow (SHA256SUMS, GitHub artifact attestation, prerelease-only, PyPI out of scope); offline smoke gains FABLE_SESSION_WHEELHOUSE and a PEP-639-capable backend floor. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 47 +++- .github/workflows/release.yml | 94 +++++++ PUBLIC_RELEASE_CHECKLIST.md | 98 +++++--- README.md | 166 +++++++++++-- SECURITY.md | 3 +- pyproject.toml | 41 ++- src/fable_session/__init__.py | 3 +- src/fable_session/capture.py | 211 ++++++++++++++-- src/fable_session/config.py | 54 +++- src/fable_session/jsonl.py | 83 +++++++ src/fable_session/model_audit.py | 20 +- src/fable_session/runner.py | 177 ++++++++++--- src/fable_session/state.py | 47 ++++ src/fable_session/watchdog.py | 24 +- tests/full_history_readiness_check.py | 140 +++++++++++ tests/offline_install_smoke.py | 91 +++++-- tests/public_readiness_check.py | 100 ++++++-- tests/test_capture.py | 163 ++++++++++++ tests/test_config.py | 85 +++++++ tests/test_full_history_readiness.py | 123 +++++++++ tests/test_jsonl_audit.py | 139 +++++++++++ tests/test_migration_fable_session.py | 116 +++++---- tests/test_mit_release.py | 139 ++++++++--- tests/test_model_audit_cli.py | 93 ++++++- tests/test_public_readiness.py | 214 +++++++++++++++- tests/test_runner.py | 343 ++++++++++++++++++++++++-- tests/test_skeleton.py | 2 +- tests/test_state.py | 47 ++++ tests/test_watchdog.py | 96 +++++++ 29 files changed, 2623 insertions(+), 336 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 tests/full_history_readiness_check.py create mode 100644 tests/test_full_history_readiness.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d6be18..648f5a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ # 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: @@ -12,9 +15,14 @@ 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" @@ -30,15 +38,32 @@ 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>=77" + + - name: Offline install smoke (no index during the install phase) + run: FABLE_SESSION_WHEELHOUSE=/tmp/fable-wheelhouse python3 tests/offline_install_smoke.py + + - name: Build the wheel and sdist + run: | + python3 -m pip install --quiet build + python3 -m build + + - 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..8d0764a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +# 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" + + - name: Release gates on the exact tagged commit + run: | + python3 -m compileall -q src tests + python3 -m pip install --quiet PyYAML + 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>=77" + + - name: Offline install smoke (no index during the install phase) + run: FABLE_SESSION_WHEELHOUSE=/tmp/fable-wheelhouse python3 tests/offline_install_smoke.py + + - name: Build the wheel and sdist + run: | + python3 -m pip install --quiet build + python3 -m build + + - 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..afbe5a6 100644 --- a/PUBLIC_RELEASE_CHECKLIST.md +++ b/PUBLIC_RELEASE_CHECKLIST.md @@ -1,54 +1,74 @@ # 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`); the tag-triggered `release.yml` workflow 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..cd7c93d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,38 @@ 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. + +Install into an isolated environment: + +```bash +# pipx +pipx install fable-session + +# uv +uv tool install fable-session + +# plain pip, isolated venv +python3.12 -m venv ~/.venvs/fable-session +~/.venvs/fable-session/bin/pip install fable-session +``` + +(Current 0.3.0b1 beta: install from a locally built wheel — see +Development below; package-index publication is a later, separate +decision.) + ### Dry run (default — starts nothing) ```bash @@ -100,9 +132,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 +258,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 +352,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 +417,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 +489,18 @@ 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. ## Migrating from claude-context-tools 0.1.x @@ -464,13 +538,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 +572,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..24f2dfe 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,10 @@ 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), "tmux_session": args.tmux if args.launch else None, "command_redacted": redacted, "capture_command_redacted": redact_command( @@ -593,10 +689,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 +720,23 @@ 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 try: _tmux_new_session(args.tmux, project.repo, capture_cmd) except RunnerError as exc: @@ -662,11 +781,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/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..ef7bf4b 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.""" - - 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) +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).""" - 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): 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..b419830 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,76 @@ 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) + self.assertIn("pipx install fable-session", text) + self.assertIn("uv tool install fable-session", text) + self.assertIn("pip install fable-session", 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 +367,139 @@ 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" +) + + 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) + + +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_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_runner.py b/tests/test_runner.py index 96e0a8b..2faf6a0 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 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 +972,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 +1696,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 +1836,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 +1848,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 +1856,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 +1910,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(" Date: Tue, 14 Jul 2026 12:23:44 +0000 Subject: [PATCH 2/2] Harden 0.3.0b1 prerelease: least-privilege CI, fail-closed tag gate, pinned tooling, truthful payload state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five approved follow-ups on the reviewed public-beta candidate: - ci.yml: top-level least-privilege `permissions: contents: read` (no job widens it), enforced by a new YAML-parsing test. - release.yml: fail-closed tag/package-version consistency gate (tests/release_tag_check.py) runs before every gate/build/publish step; only v0.3.0-beta.1 may release 0.3.0b1, any unmapped version shape refuses outright. Step ordering is asserted on the parsed step list, not raw text (an intro comment mentioning SHA256SUMS previously shadowed the real gate position). - README: pre-PyPI install wording — every install command is tag-pinned GitHub source, unqualified index installs are banned by test, and the warning (with a contiguous "tag exists" availability claim) precedes every command. - CI/release Python tooling pinned to tested versions (PyYAML 6.0.2, build 1.5.1, setuptools 83.0.0, wheel 0.47.0; --no-isolation build). The pin test inspects only executable workflow lines, so comments documenting the pyproject `setuptools>=77` floor are not findings. - runner: truthful backward-compatible `payload_files_created` manifest field — False for every dry-run/pending/payload-failure record, True only after BOTH run-scoped payload files exist; old manifests without the key stay fully readable. Credential scanner stays fail-closed and unrelaxed; the two tracked- tree false positives were fixed at the source (a `token:`-shaped CI comment reworded; a `token` variable in the pin helper renamed). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 21 ++- .github/workflows/release.yml | 20 ++- PUBLIC_RELEASE_CHECKLIST.md | 4 +- README.md | 38 ++++-- src/fable_session/runner.py | 12 ++ tests/release_tag_check.py | 94 +++++++++++++ tests/test_migration_fable_session.py | 13 ++ tests/test_public_readiness.py | 99 +++++++++++++- tests/test_release_tag_gate.py | 183 ++++++++++++++++++++++++++ tests/test_runner.py | 100 ++++++++++++++ 10 files changed, 558 insertions(+), 26 deletions(-) create mode 100644 tests/release_tag_check.py create mode 100644 tests/test_release_tag_gate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 648f5a3..000f40a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,12 @@ 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 @@ -29,8 +35,8 @@ jobs: - 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 @@ -47,15 +53,18 @@ jobs: - 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>=77" + 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 - - name: Build the wheel and sdist + # 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 - python3 -m build + 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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d0764a..a991d51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,10 +35,17 @@ jobs: 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 + 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 @@ -47,15 +54,18 @@ jobs: - 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>=77" + 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 - - name: Build the wheel and sdist + # 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 - python3 -m build + 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: | diff --git a/PUBLIC_RELEASE_CHECKLIST.md b/PUBLIC_RELEASE_CHECKLIST.md index afbe5a6..bbd53a0 100644 --- a/PUBLIC_RELEASE_CHECKLIST.md +++ b/PUBLIC_RELEASE_CHECKLIST.md @@ -68,7 +68,9 @@ published. - [ ] `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`); the tag-triggered `release.yml` workflow re-runs these + `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 cd7c93d..5e180ce 100644 --- a/README.md +++ b/README.md @@ -68,23 +68,33 @@ 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. -Install into an isolated environment: +> **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 -pipx install fable-session +# pipx (tag-pinned GitHub source) +pipx install "git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" -# uv -uv tool install fable-session +# 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 +# plain pip, isolated venv (tag-pinned GitHub source) python3.12 -m venv ~/.venvs/fable-session -~/.venvs/fable-session/bin/pip install fable-session +~/.venvs/fable-session/bin/pip install "fable-session @ git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" ``` -(Current 0.3.0b1 beta: install from a locally built wheel — see -Development below; package-index publication is a later, separate -decision.) +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) @@ -500,7 +510,13 @@ The **brief** is substituted into the Claude argv at exec time, so it 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. +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 diff --git a/src/fable_session/runner.py b/src/fable_session/runner.py index 24f2dfe..9a19cdb 100644 --- a/src/fable_session/runner.py +++ b/src/fable_session/runner.py @@ -647,6 +647,13 @@ def build_manifest(run_id: str) -> dict: # 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( @@ -737,6 +744,11 @@ def build_manifest(run_id: str) -> dict: ) _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: 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_migration_fable_session.py b/tests/test_migration_fable_session.py index ef7bf4b..dbc7634 100644 --- a/tests/test_migration_fable_session.py +++ b/tests/test_migration_fable_session.py @@ -329,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_public_readiness.py b/tests/test_public_readiness.py index b419830..dfff60d 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -279,9 +279,43 @@ def read(self, name="README.md"): def test_readme_has_an_installation_section(self): text = self.read() self.assertIn("## Install", text) - self.assertIn("pipx install fable-session", text) - self.assertIn("uv tool install fable-session", text) - self.assertIn("pip install fable-session", 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() @@ -383,6 +417,44 @@ def test_checklist_blocks_release_on_the_known_blockers(self): "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 read(self): @@ -434,6 +506,24 @@ def test_wheel_install_smokes_the_installed_cli(self): 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, @@ -494,6 +584,9 @@ def test_prerelease_with_checksums_and_attestation_no_signing_key(self): 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"): 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 2faf6a0..6ccdfce 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -943,6 +943,106 @@ def recording(argv, **kwargs): 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