diff --git a/.bestpractices.json b/.bestpractices.json new file mode 100644 index 0000000..abad486 --- /dev/null +++ b/.bestpractices.json @@ -0,0 +1,129 @@ +{ + "_comment": "Evidence dossier for OpenSSF Best Practices. Statuses describe main only after this file lands. Service-only facts must be reverified before submission; no badge level is claimed by this file.", + + "description_good_status": "Met", + "description_good_justification": "README.md identifies the portable Codex, Gemini CLI, Claude, and Agent Skills surface before stating the exact Claude-only hook and statusline boundaries: https://github.com/cdeust/session-optimizer#readme", + "interact_status": "Met", + "interact_justification": "CONTRIBUTING.md documents development setup, the change process, testing, compatibility, and security reporting: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md", + "contribution_status": "Met", + "contribution_justification": "Changes are proposed and accepted through public issues and pull requests under the process in CONTRIBUTING.md: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#change-process", + "contribution_requirements_status": "Met", + "contribution_requirements_justification": "CONTRIBUTING.md gives exact local gates and requires documentation, tests, security impact, and user-visible evidence in each applicable pull request: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md", + "floss_license_status": "Met", + "floss_license_justification": "The project is released under the MIT License: https://github.com/cdeust/session-optimizer/blob/main/LICENSE", + "floss_license_osi_status": "Met", + "floss_license_osi_justification": "MIT is an OSI-approved license: https://opensource.org/license/mit", + "license_location_status": "Met", + "license_location_justification": "The license is in the repository-root LICENSE file: https://github.com/cdeust/session-optimizer/blob/main/LICENSE", + "documentation_basics_status": "Met", + "documentation_basics_justification": "README.md covers purpose, installation, components, interaction, migration, tests, and policy; docs/ covers architecture, assurance, roadmap, and Scorecard: https://github.com/cdeust/session-optimizer#readme", + "documentation_interface_status": "Met", + "documentation_interface_justification": "Each plugin README documents its hook, skill, configuration, inputs, and host boundary; docs/ARCHITECTURE.md maps portable and host-specific interfaces: https://github.com/cdeust/session-optimizer/blob/main/docs/ARCHITECTURE.md", + "sites_https_status": "Met", + "sites_https_justification": "The repository, issue tracker, documentation, and releases are served by GitHub over HTTPS: https://github.com/cdeust/session-optimizer", + "discussion_status": "Met", + "discussion_justification": "GitHub Issues is the public discussion channel: https://github.com/cdeust/session-optimizer/issues", + "english_status": "Met", + "english_justification": "Project-level README, policies, architecture, roadmap, issue tracker, and source comments are maintained in English; a clearly labeled French statusline translation is additionally provided.", + "maintained_status": "Met", + "maintained_justification": "The repository is actively maintained, with releases and changes documented in CHANGELOG.md: https://github.com/cdeust/session-optimizer/blob/main/CHANGELOG.md", + "repo_public_status": "Met", + "repo_public_justification": "The GitHub repository is publicly readable: https://github.com/cdeust/session-optimizer", + "repo_track_status": "Met", + "repo_track_justification": "The complete source and policy history is tracked in public git: https://github.com/cdeust/session-optimizer/commits/main", + "repo_interim_status": "Met", + "repo_interim_justification": "Reviewable pull requests and main-branch commits preserve interim states between releases: https://github.com/cdeust/session-optimizer/pulls", + "repo_distributed_status": "Met", + "repo_distributed_justification": "The project uses git, a distributed version-control system.", + "version_unique_status": "Met", + "version_unique_justification": "Every release is identified by a unique semantic version: https://github.com/cdeust/session-optimizer/releases", + "version_semver_status": "Met", + "version_semver_justification": "CHANGELOG.md states that the project follows Semantic Versioning and releases use vMAJOR.MINOR.PATCH tags: https://github.com/cdeust/session-optimizer/blob/main/CHANGELOG.md", + "version_tags_status": "Met", + "version_tags_justification": "Published releases correspond to git tags: https://github.com/cdeust/session-optimizer/tags", + "release_notes_status": "Met", + "release_notes_justification": "CHANGELOG.md records notable release changes in Keep a Changelog format: https://github.com/cdeust/session-optimizer/blob/main/CHANGELOG.md", + "report_process_status": "Met", + "report_process_justification": "CONTRIBUTING.md directs ordinary defects to GitHub Issues and vulnerabilities to the private reporting process: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md", + "report_tracker_status": "Met", + "report_tracker_justification": "GitHub Issues is the public bug tracker: https://github.com/cdeust/session-optimizer/issues", + "report_archive_status": "Met", + "report_archive_justification": "Open and closed issues remain publicly archived and searchable: https://github.com/cdeust/session-optimizer/issues?q=is%3Aissue", + "vulnerability_report_process_status": "Met", + "vulnerability_report_process_justification": "SECURITY.md documents supported versions, required report content, the private channel, response steps, coordinated disclosure, and reporter credit: https://github.com/cdeust/session-optimizer/blob/main/SECURITY.md", + "vulnerability_report_private_status": "Met", + "vulnerability_report_private_justification": "GitHub private vulnerability reporting was enabled and verified through the repository API on 2026-08-03; SECURITY.md links directly to it: https://github.com/cdeust/session-optimizer/security/advisories/new", + "vulnerability_report_response_status": "Met", + "vulnerability_report_response_justification": "SECURITY.md states the maintainer's reproduce, scope, private-fix, regression-test, and coordinated-disclosure response process without inventing an unmeasured deadline: https://github.com/cdeust/session-optimizer/blob/main/SECURITY.md", + "build_status": "Met", + "build_justification": "The source is directly executable; releases are assembled with one documented script and verified with its companion script: https://github.com/cdeust/session-optimizer/blob/main/tools/build-release-bundle.sh", + "build_common_tools_status": "Met", + "build_common_tools_justification": "The release uses standard Bash, tar, Python, and SHA-256 tooling.", + "build_floss_tools_status": "Met", + "build_floss_tools_justification": "Bash, GNU tar, Python, pytest, coverage.py, and ShellCheck are FLOSS and the workflow runs on a FLOSS operating-system image.", + "test_status": "Met", + "test_justification": "The tests/ tree contains automated Python and shell suites for the shipped hooks, tools, renderer, portable packaging, and release verifier: https://github.com/cdeust/session-optimizer/tree/main/tests", + "test_invocation_status": "Met", + "test_invocation_justification": "CONTRIBUTING.md gives exact commands for the complete Python, shell, ShellCheck, JSON, and release-integrity gates: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy", + "test_most_status": "Met", + "test_most_justification": "Tests exercise each shipped Python component, both shell suites, package manifests, host-event failures, transcript parsing, and the artifact path; the measured Python surface is 94 percent covered.", + "test_continuous_integration_status": "Met", + "test_continuous_integration_justification": "CI runs on every pull request and push to main, with pinned actions, hash-locked dependencies, coverage, ShellCheck, shell suites, and JSON validation: https://github.com/cdeust/session-optimizer/blob/main/.github/workflows/ci.yml", + "test_policy_status": "Met", + "test_policy_justification": "CONTRIBUTING.md requires tests for behavior changes and a pre-fix-failing regression test for every defect fix: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy", + "tests_are_added_status": "Met", + "tests_are_added_justification": "The OpenSSF foundation adds regression tests for newly measured hooks, transcript parsing, measurement tooling, and release integrity in the same change as the behavior and controls.", + "tests_documented_added_status": "Met", + "tests_documented_added_justification": "The same-pull-request test requirement is written in CONTRIBUTING.md and the CI gate enforces the resulting suite: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy", + "warnings_status": "Met", + "warnings_justification": "CI runs pinned ShellCheck 0.11.0 over shipped shell and CodeQL security-and-quality over Python: https://github.com/cdeust/session-optimizer/tree/main/.github/workflows", + "warnings_fixed_status": "Met", + "warnings_fixed_justification": "ShellCheck and CodeQL are blocking workflow jobs; ShellCheck returns no finding at the configured default severity on the current tree.", + "know_secure_design_status": "Met", + "know_secure_design_justification": "SECURITY.md and docs/ASSURANCE-CASE.md define the protected assets, threats, trust boundaries, secure-design principles, controls, and explicit limits: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md", + "know_common_errors_status": "Met", + "know_common_errors_justification": "The assurance case enumerates common implementation weaknesses while CodeQL, ShellCheck, malicious-input tests, checksum verification, and archive-path checks address them mechanically: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md#4-common-implementation-weaknesses", + "delivery_mitm_status": "Met", + "delivery_mitm_justification": "Source and release artifacts are delivered through GitHub over authenticated HTTPS/TLS: https://github.com/cdeust/session-optimizer/releases", + "delivery_unsigned_status": "Met", + "delivery_unsigned_justification": "No checksum or executable is fetched over plain HTTP; CI action identities are pinned to full commit SHAs.", + "static_analysis_status": "Met", + "static_analysis_justification": "CodeQL security-and-quality runs on pull requests, pushes to main, and a weekly schedule, while ShellCheck runs on every change: https://github.com/cdeust/session-optimizer/blob/main/.github/workflows/codeql.yml", + "static_analysis_common_vulnerabilities_status": "Met", + "static_analysis_common_vulnerabilities_justification": "CodeQL's security-and-quality query suite targets common Python vulnerability patterns; ShellCheck adds shell-specific diagnostics.", + "static_analysis_often_status": "Met", + "static_analysis_often_justification": "Static analysis runs per pull request and main push, plus CodeQL's weekly schedule.", + + "achieve_passing_status": "Unmet", + "achieve_passing_justification": "The project has not yet been registered with OpenSSF Best Practices and therefore does not claim a Passing badge. This dossier is preparation, not a badge.", + "dco_status": "Unmet", + "dco_justification": "No DCO sign-off or CLA is required. GOVERNANCE.md records that adopting either requires a public governance change: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md#contribution-licensing", + "governance_status": "Met", + "governance_justification": "GOVERNANCE.md defines decision-making, evidence rules, roles, records, succession limits, maintainer appointment, and contribution licensing: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md", + "code_of_conduct_status": "Met", + "code_of_conduct_justification": "CODE_OF_CONDUCT.md states expected behavior, prohibited behavior, the private enforcement channel, available sanctions, and a non-conflicted GitHub escalation path: https://github.com/cdeust/session-optimizer/blob/main/CODE_OF_CONDUCT.md", + "roles_responsibilities_status": "Met", + "roles_responsibilities_justification": "GOVERNANCE.md identifies maintainer, contributor, and security-reporter responsibilities and states which roles do not yet exist: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md#roles-and-responsibilities", + "access_continuity_status": "Met", + "access_continuity_justification": "GOVERNANCE.md documents the credential-free succession path: the complete source, history, tests, marketplace manifests, release workflow, and documentation are public under MIT. A successor can fork, enable and manage Issues, accept pull requests, and publish an attested tagged release under the fork's own GitHub OIDC identity within a week, without any original key, token, domain, private dependency, or legal assignment. Users install from the continuation repository. @cdeust remains the only administrator of the original repository, so the bus factor is honestly 1 and identity transfer is weaker than with a second maintainer, but the project results and the three required operational capabilities can continue: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md#continuity-of-access", + "documentation_roadmap_status": "Met", + "documentation_roadmap_justification": "docs/ROADMAP.md covers August 2026 through July 2027 in four dated phases and states explicit non-goals: https://github.com/cdeust/session-optimizer/blob/main/docs/ROADMAP.md", + "documentation_current_status": "Met", + "documentation_current_justification": "CONTRIBUTING.md requires documentation to change with behavior, and this foundation reconciles the repository introduction, architecture, host boundaries, privacy, security, and current measured test evidence.", + "test_statement_coverage80_status": "Met", + "test_statement_coverage80_justification": "Measured 2026-08-03 with coverage.py 7.15.2 and pytest 9.1.1 over the shipped Python surface declared in pyproject.toml: 712 statements, 42 missed, 94 percent, 50 tests passing. CI enforces fail_under = 80 and subprocess coverage captures the hook entry points: https://github.com/cdeust/session-optimizer/blob/main/pyproject.toml", + "test_policy_mandated_status": "Met", + "test_policy_mandated_justification": "CONTRIBUTING.md makes tests mandatory for behavior changes, regression tests mandatory for fixes, enumerates required boundary/failure coverage, and states that coverage does not replace assertions: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy", + "implement_secure_design_status": "Met", + "implement_secure_design_justification": "The assurance case maps least privilege, economy of mechanism, complete mediation, fail-safe artifact handling, transparent host boundaries, and defence in depth to implemented controls: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md#3-secure-design-principles", + "crypto_used_network_status": "N/A", + "crypto_used_network_justification": "The shipped runtime intentionally makes no network request; it processes host events and local files. CI and release publication use GitHub HTTPS but are not runtime network protocols implemented by the software: https://github.com/cdeust/session-optimizer/blob/main/PRIVACY.md", + "signed_releases_status": "Unmet", + "signed_releases_justification": "The release workflow can publish Sigstore provenance attestations for the source bundle, executable manifest, and SBOM, but it has not yet produced a public tagged release. SECURITY.md explicitly keeps this criterion unmet until a new tag completes and is independently verified: https://github.com/cdeust/session-optimizer/blob/main/SECURITY.md#verifying-a-release", + "input_validation_status": "Met", + "input_validation_justification": "Host JSON, transcript shapes, paths, checksums, archive members, and executable manifest entries are parsed or allowlisted at their boundaries. Regression tests include malformed hook events, whitespace-bearing valid JSON, unsafe archive paths, and tampered artifacts; the assurance case records the limits: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md", + "hardening_status": "Unmet", + "hardening_justification": "The project has no HTTP response surface and compiles no native binary, so the web-header and compiler-hardening examples for this criterion do not apply directly. Existing bounded reads, least privilege, and fail-closed artifact checks are real controls but are not relabeled as this criterion.", + "assurance_case_status": "Met", + "assurance_case_justification": "docs/ASSURANCE-CASE.md contains the four required parts as separately identified sections: threat model, five concrete trust boundaries, implemented secure-design principles, and a common-weakness table that states each control's limit: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f895c17 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + commit-message: + prefix: "chore(ci)" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + commit-message: + prefix: "chore(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d92f467..f4f300e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,20 +5,25 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest + run: python -m pip install --require-hashes -r requirements-dev.lock # Pinned rather than taken from apt: the distro build is several releases # behind, and the two versions do not report the same findings — it raises @@ -28,15 +33,19 @@ jobs: - name: Install shellcheck 0.11.0 run: | v=v0.11.0 - curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${v}/shellcheck-${v}.linux.x86_64.tar.xz" \ - | sudo tar -xJf - --strip-components=1 -C /usr/local/bin "shellcheck-${v}/shellcheck" + archive="shellcheck-${v}.linux.x86_64.tar.xz" + curl -fsSLO "https://github.com/koalaman/shellcheck/releases/download/${v}/${archive}" + echo "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 ${archive}" | sha256sum -c - + sudo tar -xJf "${archive}" --strip-components=1 -C /usr/local/bin "shellcheck-${v}/shellcheck" shellcheck --version - - name: Refine gate contract tests - run: pytest tests/test_refine_gate.py tests/test_portable_packaging.py -v - - - name: Subagent tracker tests - run: pytest tests/test_subagent_usage.py -v + - name: Python suite and 80 percent coverage gate + run: | + coverage erase + coverage run -m pytest -q + coverage combine + coverage report + coverage xml - name: Statusline heat-track tests run: bash tests/statusline/test_heat_rgb.sh @@ -49,6 +58,7 @@ jobs: shellcheck plugins/statusline/assets/statusline-command.sh shellcheck plugins/statusline/assets/statusline-lib/*.sh shellcheck plugins/statusline/assets/costs.sh + shellcheck tools/*.sh # The suites are shell too, and a checker that skips them lets the # code that guards the renderer rot unwatched. shellcheck tests/statusline/*.sh diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5c1bd6e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 5 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (python) + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + with: + languages: python + queries: security-and-quality + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + with: + category: "/language:python" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1da8633 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +jobs: + release: + name: Test, attest, and publish + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Install hash-locked test dependencies + run: python -m pip install --require-hashes -r requirements-dev.lock + + - name: Run Python suite and coverage gate + run: | + coverage erase + coverage run -m pytest -q + coverage combine + coverage report + + - name: Run shell suites + run: | + bash tests/statusline/test_heat_rgb.sh + bash tests/statusline/test_fit_and_pace.sh + + - name: Build and self-verify release bundle + run: | + bash tools/build-release-bundle.sh dist + bash tools/verify-release-bundle.sh \ + dist/session-optimizer.tar.gz \ + dist/session-optimizer.tar.gz.sha256 \ + dist/EXECUTABLE-MANIFEST.sha256 + + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + dist/session-optimizer.tar.gz + dist/EXECUTABLE-MANIFEST.sha256 + dist/session-optimizer.cdx.json + + - name: Extract release notes + run: | + version="${GITHUB_REF_NAME#v}" + awk -v ver="$version" ' + /^## \[/ { + if (in_section) exit + if (index($0, "[" ver "]")) { in_section = 1; next } + } + in_section { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "No CHANGELOG entry found for $version." > release-notes.md + fi + + - name: Create GitHub release + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + body_path: release-notes.md + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + files: | + dist/session-optimizer.tar.gz + dist/session-optimizer.tar.gz.sha256 + dist/EXECUTABLE-MANIFEST.sha256 + dist/EXECUTABLE-MANIFEST.sha256.sha256 + dist/session-optimizer.cdx.json + dist/session-optimizer.cdx.json.sha256 + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..09f8ba4 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,38 @@ +name: OpenSSF Scorecard + +on: + schedule: + - cron: "0 7 * * 2" + branch_protection_rule: + push: + branches: [main] + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index e1333eb..ab742ba 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ __pycache__/ .claude/ .pytest_cache/ .coverage +.coverage.* coverage.xml htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f706c..801c24a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A skills-only Codex package for `refine-gate`, exposed through a repository marketplace at `.agents/plugins/marketplace.json`. - Gemini CLI installation through the existing portable Agent Skill. +- OpenSSF Scorecard, CodeQL, Dependabot, and a hash-locked development + dependency set. +- Security, contribution, conduct, governance, architecture, assurance-case, + Scorecard, and twelve-month roadmap documentation. +- A release workflow that tests and self-verifies a source bundle, publishes + SHA-256 checksums, an executable manifest and CycloneDX SBOM, and creates + Sigstore build-provenance attestations. +- Regression tests for context-guard hooks, prompt-refinement measurement, + statusline transcript handling, and release integrity. ### Changed - The `refine` skill now uses the portable Agent Skills frontmatter and host-neutral wording. Claude's `UserPromptSubmit` hook and plugin manifests are unchanged. +- CI actions are pinned to full commit SHAs, workflow permissions are read-only + by default, ShellCheck is checksum-verified, and Python dependencies are + installed from the hashed lock file. +- CI measures the shipped Python surface with coverage.py's subprocess support + and enforces an 80% floor; the initial complete measurement is 94%. +- The repository introduction now leads with its portable Codex, Gemini CLI, + Claude, and Agent Skills surface while preserving explicit labels on + Claude-only integrations. + +### Fixed + +- The statusline transcript scanner now recognizes valid compaction records + whose JSON contains insignificant whitespace before confirming the parsed + marker values. ## [2.1.1] - 2026-07-26 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c412d42 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,26 @@ +# Code of Conduct + +## Our pledge + +We pledge to make participation in this project respectful and harassment-free +for everyone, regardless of identity, background, experience, or ability. + +## Expected behavior + +Be constructive, assume good faith while checking evidence, respect differing +viewpoints, accept responsibility for mistakes, and focus criticism on the +work rather than the person. Harassment, threats, discriminatory language, +sexualized attention, deliberate intimidation, and disclosure of another +person's private information are unacceptable. + +## Enforcement + +Report conduct concerns privately to `admin@ai-architect.tools`. The maintainer +will investigate proportionately, protect reporter privacy where possible, and +may edit or remove content, warn a participant, restrict participation, or ban +a participant. If the maintainer is the subject of a complaint, reporters may +use GitHub Support so the subject does not adjudicate the report. + +This policy applies in repository spaces and whenever someone officially +represents the project. It is adapted from the +[Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ff1cf9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing + +Thank you for improving session-optimizer. Please follow the +[Code of Conduct](CODE_OF_CONDUCT.md) and report vulnerabilities through the +private process in [SECURITY.md](SECURITY.md), not a public issue. + +## Development setup + +Python 3.11+, Bash, and Git are required. ShellCheck 0.11.0 is required for the +shell gate; CI verifies the downloaded archive before installing it. + +```bash +python -m venv .venv +. .venv/bin/activate +python -m pip install --require-hashes -r requirements-dev.lock +``` + +## Change process + +1. Open or reference an issue that states the observable problem. +2. Create a focused branch and keep unrelated changes out of it. +3. Add or update documentation and tests in the same pull request. +4. Run the complete local gate below. +5. Explain the user-visible change, security impact, and test evidence in the + pull request. + +Changes are accepted by pull request. The maintainer records the decision in +the review and merges only after required automated checks pass. Small typo or +link fixes may omit an issue when the pull request is self-explanatory. + +## Testing policy + +Every change to observable behaviour must carry automated tests in the same +pull request. A bug fix must include a regression test that fails on the +unfixed code. Tests must cover the happy path, each newly introduced input +boundary, and failure behaviour that callers rely on. The measured shipped +Python surface must remain at or above 80% statement coverage; a passing +percentage does not replace behaviour assertions. + +```bash +coverage erase +coverage run -m pytest -q +coverage combine +coverage report +bash tests/statusline/test_heat_rgb.sh +bash tests/statusline/test_fit_and_pace.sh +shellcheck plugins/statusline/assets/statusline-command.sh \ + plugins/statusline/assets/statusline-lib/*.sh \ + plugins/statusline/assets/costs.sh \ + tests/statusline/*.sh +``` + +Validate every changed JSON file with `python -m json.tool FILE`. Release +changes must also pass: + +```bash +bash tools/build-release-bundle.sh dist +bash tools/verify-release-bundle.sh \ + dist/session-optimizer.tar.gz \ + dist/session-optimizer.tar.gz.sha256 \ + dist/EXECUTABLE-MANIFEST.sha256 +``` + +## Style and compatibility + +- Keep the three plugins independently installable. +- Preserve the portable Agent Skill contract for Codex and Gemini while + identifying Claude-only hook, transcript, and statusline behavior plainly. +- Prefer standard-library Python and dependency-light shell. +- Treat hook JSON, transcript content, paths, and git output as untrusted input. +- Never add telemetry or a runtime network request without an explicit design + review and an update to `PRIVACY.md`. + +Contributions are provided under the repository's MIT license. The project +does not currently require a Contributor License Agreement or DCO sign-off. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..d1bd40f --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,71 @@ +# Governance + +## Decision model + +session-optimizer is currently maintained by `@cdeust`. Changes are proposed +through GitHub issues and pull requests. The maintainer considers user impact, +compatibility, test evidence, security, maintenance cost, and the published +roadmap, then records acceptance or rejection on the pull request. Larger +changes begin with an issue so alternatives can be evaluated before code is +written. + +Disagreement is resolved with reproducible evidence and documented trade-offs. +When evidence is insufficient, the smallest reversible change wins or the +proposal remains open until the missing evidence exists. + +## Roles and responsibilities + +- **Maintainer** — triages issues, reviews and merges changes, manages releases, + repository settings, security advisories, and the roadmap. +- **Contributor** — proposes focused changes, follows the testing policy, + responds to review, and reports conflicts of interest. +- **Security reporter** — uses the private channel, preserves confidentiality + during coordination, and supplies enough evidence to reproduce the issue. + +There is no separate committer, security team, or release-manager role today. +New maintainers are appointed in a public governance pull request after a +sustained record of technically sound and respectful contributions. + +## Records + +Implementation decisions live in pull requests; user-visible changes in +`CHANGELOG.md`; security handling in private advisories and the eventual +release notes; priorities in `docs/ROADMAP.md`; and governance changes in this +document's history. + +## Continuity of access + +The project can continue without credentials held by the current maintainer. +Its complete source, history, tests, marketplace manifests, release workflow, +and documentation are public under MIT. A successor can fork the repository, +enable Issues, accept pull requests into the fork, and publish a tagged release +through the committed workflow under the fork's own GitHub OIDC identity. No +original signing key, package-registry token, domain, private dependency, or +legal assignment is required. Users can install from the successor's public +repository and marketplace URL. + +This provides the three OpenSSF continuity capabilities within a week: create +and close issues on the continuation repository, accept proposed changes, and +release a version. Past releases remain independently verifiable through their +published checksums and attestations. The procedure is: + +1. fork the complete public repository under the successor's account or + organization and enable its issue tracker; +2. publish a continuity notice naming the former repository and the new + canonical URL; +3. accept changes through the unchanged CI-gated pull-request process; and +4. create a semantic-version tag, let the committed release workflow attest + the artifacts under the fork identity, and publish the new install URL. + +As verified on 2026-08-03, `@cdeust` remains the only administrator of the +current GitHub repository. That makes the bus factor 1 and means its original +URL and listings cannot be transferred without the account; it does not make +the MIT-licensed project results or their issue/change/release process +non-continuable. Adding a second trusted maintainer is still the preferred way +to preserve the existing identity with even less interruption. + +## Contribution licensing + +Contributions are accepted under the MIT license. No CLA or DCO sign-off is +required today; adopting either requires a governance pull request explaining +the need and migration impact. diff --git a/README.md b/README.md index 7d7bb7d..c1b1c20 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@

session-optimizer — cross-platform prompt refinement with Claude-native session controls

+[![CI](https://github.com/cdeust/session-optimizer/actions/workflows/ci.yml/badge.svg)](https://github.com/cdeust/session-optimizer/actions/workflows/ci.yml) +[![CodeQL](https://github.com/cdeust/session-optimizer/actions/workflows/codeql.yml/badge.svg)](https://github.com/cdeust/session-optimizer/actions/workflows/codeql.yml) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/cdeust/session-optimizer/badge)](https://securityscorecards.dev/viewer/?uri=github.com/cdeust/session-optimizer) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + **Cross-platform prompt refinement for Codex, Gemini CLI, and Claude Code, plus Claude-native context-budget and telemetry controls.** The portable `refine-gate` skill binds vague requests to concrete evidence on all three @@ -22,7 +27,7 @@ need; none requires the others. | [**refine-gate**](plugins/refine-gate) | Claude: `/plugin install refine-gate@session-optimizer-marketplace`; Codex/Gemini: [portable install](plugins/refine-gate/README.md) | A portable skill that binds vague prompt references ("the SSE solution", "like before", "still broken") to concrete artifacts with evidence, then selects an execution strategy from a research-backed table before any code is touched. Claude Code additionally receives an automatic `UserPromptSubmit` hook. | | [**statusline**](plugins/statusline) | `/plugin install statusline@session-optimizer-marketplace` | A multi-line status bar: discrete heat-track context bar tied to per-model checkpoint thresholds, one deduplicated cost ledger covering subagent spend, telemetry (tok/s, compactions, cache countdown), rate-limit gauges with burn-rate pacing, and terminal-width fitting. Ships an install skill — after installing, ask Claude to "install the statusline" and it wires everything. | -For Codex and Gemini installation commands, see the +For Codex, Gemini CLI, and Claude installation commands, see the [refine-gate README](plugins/refine-gate/README.md). ## Why @@ -91,12 +96,30 @@ hooks and just announces the migration at session start. ## Tests ```bash -pytest tests/test_refine_gate.py tests/test_subagent_usage.py +python -m pip install --require-hashes -r requirements-dev.lock +coverage erase +coverage run -m pytest -q +coverage combine +coverage report bash tests/statusline/test_heat_rgb.sh +bash tests/statusline/test_fit_and_pace.sh ``` -CI (`.github/workflows/ci.yml`) runs all three suites, shellchecks the -statusline renderer, and validates every plugin/hook/marketplace JSON. +CI (`.github/workflows/ci.yml`) runs the Python and shell suites, enforces at +least 80% statement coverage over shipped Python, shellchecks the statusline, +and validates every plugin, hook, and marketplace JSON. The current measured +result is 94% (712 statements, 42 missed, 50 tests; measured 2026-08-03). + +## Project policy and security + +- [Architecture and host boundaries](docs/ARCHITECTURE.md) +- [Security policy and release verification](SECURITY.md) +- [Security assurance case](docs/ASSURANCE-CASE.md) +- [Governance and access continuity](GOVERNANCE.md) +- [Contributing and mandatory test policy](CONTRIBUTING.md) +- [August 2026–July 2027 roadmap](docs/ROADMAP.md) +- [OpenSSF Scorecard policy](docs/SCORECARD.md) +- [Privacy policy](PRIVACY.md) ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6055c06 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security policy + +## Supported versions + +Only the latest release is supported with security fixes. Upgrade before +reporting a problem that is already fixed on `main` or in a newer release. + +## Reporting a vulnerability + +Do not open a public issue. Use GitHub's +[private vulnerability report](https://github.com/cdeust/session-optimizer/security/advisories/new) +and include: + +- the affected plugin, version, and host; +- the smallest reproducible input or transcript shape; +- the security impact and the local files or privileges involved; and +- any proposed mitigation, if known. + +The maintainer will reproduce and scope the report in the private advisory, +prepare a fix and regression test on a private fork or advisory branch, and +coordinate disclosure with the reporter. A fixed release will identify the +affected versions and credit the reporter unless anonymity is requested. No +response or repair deadline is promised before it has been measured reliably; +progress and any revised disclosure date will be recorded in the advisory. + +## Security boundaries + +The shipped plugins run locally with the user's permissions. They do not +provide a sandbox and must be reviewed like any other executable hook. + +- `context-guard` consumes host hook JSON and reads local transcripts. +- `refine-gate` consumes the submitted prompt. Its portable Agent Skill is + prose; Claude's optional hook is executable Python. +- `statusline` consumes host status JSON, transcripts, configuration, and local + git metadata. +- None of the runtime components intentionally makes a network request. See + [PRIVACY.md](PRIVACY.md). + +Malformed host events fail without granting new privileges. Release and +installation integrity checks fail closed: the bundle checksum is verified +before extraction, unsafe archive paths are rejected, and every shipped Python +or shell executable is checked against the release manifest. + +The complete threat model, trust boundaries, controls, and their limits are in +[docs/ASSURANCE-CASE.md](docs/ASSURANCE-CASE.md). + +## Verifying a release + +Tagged releases are produced by `.github/workflows/release.yml`. The workflow +tests the repository, builds the source bundle, publishes SHA-256 checksums, an +executable manifest, a CycloneDX SBOM, and Sigstore build-provenance +attestations. + +```bash +sha256sum -c session-optimizer.tar.gz.sha256 +gh attestation verify session-optimizer.tar.gz --repo cdeust/session-optimizer +bash tools/verify-release-bundle.sh \ + session-optimizer.tar.gz \ + session-optimizer.tar.gz.sha256 \ + EXECUTABLE-MANIFEST.sha256 +``` + +The workflow is prepared but does not make historical releases signed. The +OpenSSF signed-release criterion remains unmet until a new public tag completes +this workflow and its attestation is independently verified. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..4f7e38a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,41 @@ +# Architecture + +session-optimizer is a distribution repository for three independent tools. +There is no resident server and no shared runtime dependency. + +## Components + +| Component | Portable surface | Host-specific surface | Local data | +|---|---|---|---| +| `refine-gate` | Agent Skill instructions for Codex, Gemini CLI, and compatible hosts | Claude `UserPromptSubmit` hook | submitted prompt and a small local rate-limit state file | +| `context-guard` | checkpoint protocol concepts | Claude `Stop` and `SubagentStop` hooks and transcript layout | host events, transcripts, checkpoints, and `/tmp` session counters | +| `statusline` | none today | Claude statusline payload, transcript layout, and install hook | host payload, transcripts, git metadata, config, and caches | + +The portable and Claude packages reference the same `refine-gate` skill source. +The marketplace manifests select which surfaces each host installs; they do not +make a host-specific executable portable. + +## Runtime flow + +1. The host invokes a hook or statusline executable with JSON on standard + input, or an agent loads the portable skill text. +2. The executable validates the event shape and reads only the local files its + component documents. +3. It returns hook JSON or rendered text on standard output and may update its + documented local state. +4. It makes no intentional network request and does not call another plugin. + +`context-guard` and `statusline` may read the same threshold file and session +spend record. This is an optional file contract: either plugin continues when +the other is absent. + +## Build and release flow + +The source files are the product. `tools/build-release-bundle.sh` creates a +source archive, SHA-256 executable manifest, and CycloneDX file inventory. +`tools/verify-release-bundle.sh` verifies the archive before extraction, +rejects unsafe paths, and compares extracted executables with the manifest. +The release workflow tests first, then attests and publishes those artifacts. + +See [ASSURANCE-CASE.md](ASSURANCE-CASE.md) for the security boundaries and +[PRIVACY.md](../PRIVACY.md) for the local data contract. diff --git a/docs/ASSURANCE-CASE.md b/docs/ASSURANCE-CASE.md new file mode 100644 index 0000000..84c1bd8 --- /dev/null +++ b/docs/ASSURANCE-CASE.md @@ -0,0 +1,79 @@ +# Security assurance case + +This document states what the project is trying to protect, how its controls +support that claim, and where those controls stop. It is an argument backed by +tests and repository configuration, not an independent certification. + +## 1. Threat model + +The protected assets are the user's local session data, prompts, filesystem, +host configuration, and confidence that installed executable files match the +reviewed release. + +| Threat | Attacker capability | Intended protection | +|---|---|---| +| Malicious release or dependency | can modify an artifact or CI reference | pinned actions, least-privilege workflows, checksums, executable manifest, SBOM, and provenance attestation | +| Crafted hook payload or transcript | can supply malformed, large, or adversarial local input | parsing, bounded reads already present in hook implementations, explicit fallback behavior, and regression tests | +| Unsafe archive | can replace a downloaded bundle and companion files | checksum-before-extract, path-traversal rejection, and per-executable hashes | +| Accidental maintainer error | can merge a defect or stale security claim | pull-request CI, 80% coverage gate, CodeQL, ShellCheck, Scorecard, and documented review evidence | + +Out of scope: containing a hostile host process that already runs with the +user's permissions; protecting a machine or GitHub account that is already +compromised; and guaranteeing the correctness of model-generated advice. + +## 2. Trust boundaries + +1. **Repository to CI runner.** Workflow source and action references cross + into GitHub-hosted execution. Actions are pinned to full commits and jobs + receive only stated permissions. This does not protect a compromised GitHub + account with authority to change the workflow. +2. **Release service to installer.** The archive, checksums, manifest, SBOM, + and attestation cross the network. Verification detects modification after + publication; it cannot make unreviewed source safe. +3. **Host to hook.** Host JSON crosses standard input into Python or shell. + Parsers validate required shapes and tests exercise malformed inputs. The + hook still runs with the user's filesystem permissions. +4. **Transcript/configuration files to renderer.** Local content and git output + enter parsers and shell formatting. Values are treated as data, not sourced + as shell code. This does not make the surrounding host transcript private + from other local processes running as the same user. +5. **Portable skill to model.** Host-neutral instructions enter an agent's + context. The skill can structure decisions but cannot enforce a sandbox or + prove the model followed every instruction. + +## 3. Secure design principles + +- **Least privilege:** CI begins read-only; only Scorecard SARIF upload, + CodeQL, release publication, and attestation receive the additional + permissions they require. +- **Economy of mechanism:** the three plugins remain installable and testable + independently, and release verification is a small standalone script. +- **Complete mediation at artifact boundaries:** the bundle is hashed before + extraction and every executable listed in the release manifest is rehashed + after extraction. +- **Fail-safe release defaults:** malformed checksums, unsafe archive members, + missing executables, and mismatches terminate verification with failure. +- **Transparent runtime limits:** host-specific behavior is labeled rather + than inferred from the portable skill package; privacy and local file access + are documented. +- **Defence in depth:** tests, static analysis, dependency updates, Scorecard, + checksums, SBOM, and attestation address different failure modes. No one + layer is described as sufficient. + +## 4. Common implementation weaknesses + +| Weakness | Control | Limit | +|---|---|---| +| command or expression injection | ShellCheck, CodeQL, quoted paths, and tests with hostile values | static analysis does not prove all shell composition safe | +| unsafe deserialization or malformed JSON | standard JSON parsers, shape checks, and malformed-input tests | the host remains responsible for transport framing | +| path traversal during installation | archive member validation before extraction | verification must actually be run by the installer/user | +| dependency substitution | hash-locked Python test dependencies and full-SHA actions | the shipped runtime intentionally uses the standard library but relies on the host OS and Python | +| unreviewed executable drift | executable manifest and per-file hashes | non-executable documentation is covered by the archive checksum, not the executable manifest | +| resource exhaustion | existing bounded transcript/event reads and regression tests | a same-user hostile process can still consume machine resources externally | +| silent security regression | CI coverage gate, behavior tests, CodeQL, and Scorecard | a green gate proves only the encoded checks | +| release tampering | SHA-256, Sigstore provenance, SBOM, and verification tool | historical releases gain no attestation retroactively | + +The author currently performs both implementation and review, and the controls +have not been evaluated by an independent security assessor. That concentration +of authority keeps the bus factor at 1 even though the public MIT repository +and credential-free fork/release path provide operational continuity. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..5fdb9be --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,46 @@ +# Roadmap: August 2026 to July 2027 + +This roadmap expresses priorities, not promises. Evidence from security review, +host API changes, or user reports may reorder it; any change is recorded here. + +## August–October 2026 + +- Land the OpenSSF foundation: harden CI, enable CodeQL, Scorecard and + Dependabot, publish the governance and assurance documents, and enforce 80% + statement coverage over shipped Python. +- Exercise the release workflow on the next semantic-version tag and + independently verify its checksum, executable manifest, SBOM, and Sigstore + attestation before marking signed releases as satisfied. +- Register the project on OpenSSF Best Practices and publish only criteria that + have repository or service evidence. + +## November 2026–January 2027 + +- Keep `refine-gate`'s portable Agent Skill validated on Codex and Gemini CLI. +- Document host-neutral behavior separately from Claude lifecycle-hook, + transcript, and statusline integrations. +- Ratchet tests when defects are found while keeping the enforced coverage + floor at or above 80%. + +## February–April 2027 + +- Recruit and onboard a second trusted maintainer with tested issue, merge, + advisory, and release permissions to preserve the existing repository + identity as well as the documented fork-based continuity path. +- Review whether cryptographically signed version tags add useful assurance on + top of artifact attestations. +- Reassess parser fuzzing from measured defect history rather than claiming a + tool that is not being run. + +## May–July 2027 + +- Audit documentation accessibility and internationalization boundaries. +- Review all OpenSSF Best Practices Silver evidence and complete an honest Gold + gap assessment. +- Re-evaluate host integrations as Codex and Gemini expose new lifecycle APIs. + +## Explicitly out of scope + +- Claiming that Claude-only hooks work on hosts without equivalent APIs. +- Adding SaaS, telemetry, or runtime network dependencies to local plugins. +- Trading transparent security limits for a higher badge percentage. diff --git a/docs/SCORECARD.md b/docs/SCORECARD.md new file mode 100644 index 0000000..5e32f97 --- /dev/null +++ b/docs/SCORECARD.md @@ -0,0 +1,26 @@ +# OpenSSF Scorecard policy + +`.github/workflows/scorecard.yml` runs OpenSSF Scorecard weekly, on changes to +`main`, when branch protection changes, and on manual request. It publishes +SARIF to GitHub code scanning and public aggregate results to the Scorecard API. + +Findings are handled as evidence, not as a target score: + +- actionable repository or workflow defects are fixed in a pull request; +- findings that require a project property we do not have are documented here + and left unsatisfied; and +- a criterion is never marked met solely because a workflow file exists. + +## Known dispositions + +| Area | Current disposition | +|---|---| +| Branch protection and code review | Desired, but a sole-maintainer project cannot provide independent approval today. Required status checks should be enabled after the new workflows have completed successfully on `main`. | +| Contributors / bus factor | Unmet: `@cdeust` is the only administrator and maintainer. See `GOVERNANCE.md`. | +| Signed releases | Workflow prepared; unmet until a new tagged release completes and the public attestation is verified. | +| Fuzzing | Not currently run. Parser behavior is covered by deterministic tests; fuzzing will be adopted only with a maintained target and reproducible evidence. | +| Packaging | Source bundle, checksums, executable manifest, CycloneDX SBOM, and provenance attestation are produced by the release workflow. | +| Token permissions | Default workflow permissions are read-only; write scopes are job-local and purpose-specific. | + +The latest service result is authoritative once the workflow has landed and +run on `main`: . diff --git a/plugins/statusline/assets/statusline-transcript.py b/plugins/statusline/assets/statusline-transcript.py index 716fac0..c204e02 100644 --- a/plugins/statusline/assets/statusline-transcript.py +++ b/plugins/statusline/assets/statusline-transcript.py @@ -49,7 +49,12 @@ # Substring prefilters — cheap screen before JSON parse. A compaction boundary # carries at least one of these markers across Claude Code versions. -COMPACT_MARKERS = ('"isCompactSummary"', '"subtype":"compact"', '"type":"summary"') +# Prefilter values rather than serialized key/value pairs. JSON permits +# arbitrary whitespace around `:`, and producers are not required to use the +# compact separators Claude Code currently emits. `_is_compaction` parses and +# confirms the exact key/value relationship after this cheap screen, so the +# broader markers cannot inflate the count. +COMPACT_MARKERS = ('"isCompactSummary"', '"compact"', '"summary"') def _epoch(ts): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7aa2736 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.run] +source = [ + "plugins/context-guard/hooks", + "plugins/context-guard/tools", + "plugins/refine-gate/hooks", + "plugins/refine-gate/tools", + "plugins/statusline/assets", +] +# coverage.py 7.10+ starts measurement at Python subprocess startup. The +# existing contract tests invoke the shipped hook entry points as subprocesses, +# so this measures what actually executes instead of replacing those tests with +# direct function calls solely to make the percentage move. +# source: https://coverage.readthedocs.io/en/7.15.2/subprocess.html +patch = ["subprocess"] + +[tool.coverage.report] +show_missing = true +skip_covered = false +precision = 0 +fail_under = 80 +exclude_also = [ + "if __name__ == .__main__.:", +] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..ffbe5dc --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,119 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-dev.txt --generate-hashes --universal --python-version 3.11 -o requirements-dev.lock +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via pytest +coverage==7.15.2 \ + --hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \ + --hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \ + --hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \ + --hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \ + --hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \ + --hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \ + --hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \ + --hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \ + --hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \ + --hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \ + --hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \ + --hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \ + --hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \ + --hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \ + --hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \ + --hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \ + --hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \ + --hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \ + --hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \ + --hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \ + --hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d \ + --hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \ + --hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \ + --hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \ + --hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \ + --hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \ + --hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \ + --hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \ + --hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \ + --hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \ + --hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \ + --hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \ + --hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \ + --hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \ + --hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \ + --hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \ + --hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \ + --hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \ + --hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \ + --hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \ + --hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \ + --hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \ + --hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \ + --hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \ + --hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \ + --hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \ + --hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \ + --hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \ + --hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \ + --hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \ + --hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \ + --hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \ + --hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \ + --hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \ + --hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \ + --hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \ + --hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \ + --hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \ + --hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \ + --hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \ + --hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \ + --hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \ + --hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \ + --hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \ + --hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \ + --hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \ + --hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \ + --hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \ + --hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \ + --hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \ + --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \ + --hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \ + --hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \ + --hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \ + --hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \ + --hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \ + --hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \ + --hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \ + --hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \ + --hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \ + --hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \ + --hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \ + --hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \ + --hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \ + --hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \ + --hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \ + --hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \ + --hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \ + --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \ + --hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \ + --hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a + # via -r requirements-dev.txt +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via pytest +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via -r requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..da8bd6f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Direct test inputs are pinned so a new upstream release cannot change CI. +coverage==7.15.2 +pytest==9.1.1 diff --git a/tests/test_context_guard_hooks.py b/tests/test_context_guard_hooks.py new file mode 100644 index 0000000..bed92fd --- /dev/null +++ b/tests/test_context_guard_hooks.py @@ -0,0 +1,216 @@ +"""Behavioral coverage for the context-guard hook boundary.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +HOOKS = ROOT / "plugins" / "context-guard" / "hooks" +TOOLS = ROOT / "plugins" / "context-guard" / "tools" + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +if str(HOOKS) not in sys.path: + sys.path.insert(0, str(HOOKS)) +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +protocol = _load("session_optimizer_checkpoint_protocol", HOOKS / "checkpoint_protocol.py") +guard = _load("session_optimizer_stop_guard", HOOKS / "stop-context-guard.py") +usage_core = _load("session_optimizer_usage_core", TOOLS / "subagent_usage.py") +tracker = _load("session_optimizer_subagent_tracker", HOOKS / "subagent-tracker.py") + + +def test_protocol_detects_project_tool_and_renders_both_contracts(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + project_tool = tmp_path / "tools" / "memory-tool.sh" + project_tool.parent.mkdir() + project_tool.write_text("#!/bin/sh\n") + assert protocol.detect_memory_tool(str(tmp_path)) == str(project_tool) + assert protocol.detect_memory_tool(str(tmp_path / "missing")) is None + + generic_warn = protocol.warn_reason(180_000, "/tmp/check.md", 180_000, 200_000) + scoped_warn = protocol.warn_reason_scoped(180_000, "", 180_000, 200_000) + generic_hard = protocol.block_reason(200_000, "", 200_000) + scoped_hard = protocol.block_reason_scoped(200_000, "/tmp/check.md", 200_000) + assert "memory-writer" in generic_warn + assert "remember endpoint" in scoped_warn + assert "latest.md" in generic_hard + assert "MEMORY_AGENT_ID" in scoped_hard + + +def test_threshold_config_and_fallback(tmp_path, monkeypatch): + config = tmp_path / "thresholds.json" + config.write_text(json.dumps({ + "models": [{"match": "mini", "warn": 10, "hard": 20}], + "default": {"warn": 30, "hard": 40}, + })) + monkeypatch.setattr(guard, "CONFIG_PATH", str(config)) + assert guard._thresholds("agent-mini") == (10, 20) + assert guard._thresholds("other") == (30, 40) + + config.write_text('{"models":[{"match":"broken"}],"default":{"warn":9,"hard":2}}') + assert guard._thresholds("broken") == (180_000, 200_000) + config.write_text("not json") + assert guard._thresholds("haiku-4") == (120_000, 170_000) + + +@pytest.mark.parametrize("line", ["", "not json", "{}", '{"message":{"usage":{}}}']) +def test_usage_line_rejects_non_usage(line): + assert guard._usage_from_line(line) is None + + +def test_usage_line_and_reverse_tail_reader(tmp_path, monkeypatch): + line = json.dumps({"message": {"model": "opus", "usage": { + "input_tokens": 2, "cache_creation_input_tokens": 3, + "cache_read_input_tokens": 5, + }}}) + assert guard._usage_from_line(line) == (10, "opus") + transcript = tmp_path / "transcript.jsonl" + transcript.write_text("noise\n" * 30 + line + "\ntrailing junk\n") + monkeypatch.setattr(guard, "TAIL_CHUNK", 64) + monkeypatch.setattr(guard, "TAIL_MAX_BYTES", 512) + assert guard._read_last_usage(str(transcript)) == (10, "opus") + assert guard._read_last_usage(str(tmp_path / "missing")) == (None, None) + empty = tmp_path / "empty" + empty.write_text("") + assert guard._read_last_usage(str(empty)) == (None, None) + + +def test_subagent_summary_line_and_git_fail_open(tmp_path, monkeypatch): + real_join = guard.os.path.join + monkeypatch.setattr( + guard.os.path, + "join", + lambda root, leaf: str(tmp_path / leaf) if root == "/tmp" else real_join(root, leaf), + ) + assert guard._subagent_summary("none") == (0, 0, 0.0) + state = tmp_path / "zetetic-subagents-s1.json" + state.write_text(json.dumps({"totals": { + "count": 2, "input_tokens": 10, "output_tokens": 5, + "cache_tokens": 20, "cost_usd": 1.25, + }})) + assert guard._subagent_summary("s1") == (2, 35, 1.25) + assert "2 runs" in guard._subagent_line("s1") + assert guard._subagent_line("none") == "" + + monkeypatch.setattr(guard.subprocess, "run", lambda *a, **k: SimpleNamespace(stdout=" main \n")) + assert guard._git(str(tmp_path), "status") == "main" + monkeypatch.setattr(guard.subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(OSError())) + assert guard._git(str(tmp_path), "status") == "" + + +def test_stub_and_level_state_round_trip(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(guard, "_git", lambda _cwd, *args: { + "symbolic-ref": "feature", "log": "abc subject", "status": " M README.md", + }.get(args[0], "")) + monkeypatch.setattr(guard, "_subagent_summary", lambda _sid: (2, 3000, 0.5)) + stub = guard._write_stub("session-123", str(tmp_path), 190_000, "opus", "warn") + text = Path(stub).read_text() + assert "feature" in text and "README.md" in text and "2 runs" in text + assert (Path(stub).parent / "latest.md").read_text() == text + + monkeypatch.setattr(guard, "STATE_DIR", str(tmp_path)) + assert guard._load_level("fresh") == "none" + guard._save_level("fresh", "warn") + assert guard._load_level("fresh") == "warn" + (tmp_path / "zetetic-ctxguard-bad.json").write_text("bad") + assert guard._load_level("bad") == "none" + + +def _run_guard_main(monkeypatch, payload, *, ctx=(190_000, "opus"), prev="none", scoped=False): + stdin = io.StringIO(payload if isinstance(payload, str) else json.dumps(payload)) + stdout = io.StringIO() + monkeypatch.setattr(guard.sys, "stdin", stdin) + monkeypatch.setattr(guard.sys, "stdout", stdout) + monkeypatch.setattr(guard, "_read_last_usage", lambda _path: ctx) + monkeypatch.setattr(guard, "_thresholds", lambda _model: (180_000, 200_000)) + monkeypatch.setattr(guard, "_load_level", lambda _sid: prev) + monkeypatch.setattr(guard, "_save_level", lambda *_: None) + monkeypatch.setattr(guard, "_write_stub", lambda *_: "/tmp/check.md") + monkeypatch.setattr(guard, "_subagent_line", lambda _sid: "\nsubagents") + monkeypatch.setattr(guard.checkpoint_protocol, "detect_memory_tool", lambda _cwd: "/tool" if scoped else None) + with pytest.raises(SystemExit) as exc: + guard.main() + assert exc.value.code == 0 + return stdout.getvalue() + + +def test_guard_main_fail_open_and_threshold_paths(monkeypatch): + assert _run_guard_main(monkeypatch, "not json") == "" + assert _run_guard_main(monkeypatch, {"stop_hook_active": True}) == "" + assert _run_guard_main(monkeypatch, {}, ctx=(None, None)) == "" + assert _run_guard_main(monkeypatch, {}, ctx=(100, "opus")) == "" + assert _run_guard_main(monkeypatch, {}, prev="warn") == "" + + +def test_guard_main_warn_and_hard_payloads(monkeypatch): + warn = json.loads(_run_guard_main(monkeypatch, {"session_id": "s", "cwd": "/x"})) + assert warn["decision"] == "block" + assert "memory-writer" in warn["reason"] + assert "subagents" in warn["systemMessage"] + hard = json.loads(_run_guard_main( + monkeypatch, {"session_id": "s", "cwd": "/x"}, + ctx=(210_000, "opus"), prev="warn", scoped=True, + )) + assert hard["decision"] == "block" + assert "MEMORY_AGENT_ID" in hard["reason"] + + +def test_tracker_helpers_and_main_sweep(tmp_path, monkeypatch): + monkeypatch.setattr(tracker, "_state_path", lambda sid: str(tmp_path / f"{sid}.json")) + assert tracker._load_state("s") == {"session_id": "s", "agents": {}} + (tmp_path / "s.json").write_text(json.dumps({"session_id": "s", "agents": {}})) + assert tracker._load_state("s")["session_id"] == "s" + + usage = usage_core.Usage(input_tokens=3, output_tokens=4, cache_write_5m=5, + cache_write_1h=6, cache_read=7, tool_uses=2, + web_search_requests=1, web_fetch_requests=2, + model="opus") + rec = usage_core.SubagentRecord("a", "Explore", "look", "t1", usage, 1.23456, "/a") + entry = tracker._agent_entry(rec) + assert entry["cache_tokens"] == 18 and entry["cost_usd"] == 1.2346 + state = {"agents": {"a": entry, "b": {"input_tokens": 2, "cost_usd": 0.1}}} + tracker._recompute_totals(state) + assert state["totals"]["count"] == 2 + assert state["totals"]["input_tokens"] == 5 + + monkeypatch.setattr(tracker, "subagent_record", lambda path: rec if path else None) + tracker._update_from_transcript({"agents": {}}, "") + payload_path = tmp_path / "agent-a.jsonl" + payload_path.write_text("{}\n") + monkeypatch.setattr(tracker, "session_dir_for", lambda _path: str(tmp_path)) + monkeypatch.setattr(tracker, "discover_subagents", lambda _dir: [str(payload_path), str(tmp_path / "agent-b.jsonl")]) + monkeypatch.setattr(tracker.sys, "stdin", io.StringIO(json.dumps({ + "session_id": "s", "transcript_path": str(payload_path), + }))) + with pytest.raises(SystemExit) as exc: + tracker.main() + assert exc.value.code == 0 + saved = json.loads((tmp_path / "s.json").read_text()) + assert saved["totals"]["count"] == 1 + assert saved["agents"]["a"]["agent_type"] == "Explore" + + +def test_tracker_malformed_input_is_nonfatal(monkeypatch): + monkeypatch.setattr(tracker.sys, "stdin", io.StringIO("bad")) + with pytest.raises(SystemExit) as exc: + tracker.main() + assert exc.value.code == 0 diff --git a/tests/test_measure_refine_overhead.py b/tests/test_measure_refine_overhead.py new file mode 100644 index 0000000..d2db1f9 --- /dev/null +++ b/tests/test_measure_refine_overhead.py @@ -0,0 +1,54 @@ +"""Tests for the opt-in local refine-gate overhead measurement.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "plugins" / "refine-gate" / "tools" / "measure_refine_overhead.py" +SPEC = importlib.util.spec_from_file_location("session_optimizer_measure_refine", SCRIPT) +measure = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(measure) + + +def test_collect_prompts_filters_non_user_and_harness_records(tmp_path, monkeypatch): + project = tmp_path / ".claude" / "projects" / "p" + project.mkdir(parents=True) + transcript = project / "s.jsonl" + records = [ + {"type": "user", "message": {"content": " fix the cache "}}, + {"type": "user", "message": {"content": ""}}, + {"type": "user", "message": {"content": "Caveat: wrapper"}}, + {"type": "user", "message": {"content": ["tool result"]}}, + {"type": "assistant", "message": {"content": "ignored"}}, + ] + transcript.write_text("bad json\n" + "".join(json.dumps(r) + "\n" for r in records)) + monkeypatch.setattr(measure.Path, "home", staticmethod(lambda: tmp_path)) + assert measure.collect_prompts() == ["fix the cache"] + + +def test_main_reports_tier_mix(monkeypatch, capsys): + monkeypatch.setattr(measure, "collect_prompts", lambda: ["tier1", "tier2", "silent"]) + + def fake_run(args, *, input, **kwargs): + if "tier1" in input: + ctx = "matched: prior solution" + elif "tier2" in input: + ctx = "names no concrete artifact" + else: + return SimpleNamespace(stdout="") + return SimpleNamespace(stdout=json.dumps({ + "hookSpecificOutput": {"additionalContext": ctx}, + })) + + monkeypatch.setattr(measure.subprocess, "run", fake_run) + measure.main() + out = capsys.readouterr().out + assert "corpus: 3" in out + assert "tier1 1, tier2 1, silent 1" in out + assert "mean per prompt" in out diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py new file mode 100644 index 0000000..913c3a0 --- /dev/null +++ b/tests/test_release_tools.py @@ -0,0 +1,80 @@ +"""Release bundle inventory and verification contracts.""" + +from __future__ import annotations + +import importlib.util +import hashlib +import io +import json +import subprocess +import tarfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +SBOM_SCRIPT = ROOT / "tools" / "gen-bundle-sbom.py" +SPEC = importlib.util.spec_from_file_location("session_optimizer_sbom", SBOM_SCRIPT) +sbom = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(sbom) + + +def test_sbom_has_one_hashed_component_per_file(tmp_path): + (tmp_path / "a").write_text("a") + nested = tmp_path / "d" + nested.mkdir() + (nested / "b").write_text("b") + document = sbom.build_sbom("1.2.3", [str(tmp_path)]) + assert document["bomFormat"] == "CycloneDX" + assert document["metadata"]["component"]["version"] == "1.2.3" + assert len(document["components"]) == 2 + assert all(len(c["hashes"][0]["content"]) == 64 for c in document["components"]) + + +def test_release_bundle_builds_verifies_and_rejects_tampering(tmp_path): + out = tmp_path / "dist" + subprocess.run(["bash", "tools/build-release-bundle.sh", str(out)], cwd=ROOT, check=True) + bundle = out / "session-optimizer.tar.gz" + checksum = out / "session-optimizer.tar.gz.sha256" + manifest = out / "EXECUTABLE-MANIFEST.sha256" + document = json.loads((out / "session-optimizer.cdx.json").read_text()) + assert bundle.is_file() and manifest.read_text().strip() + assert document["components"] + + good = subprocess.run( + ["bash", "tools/verify-release-bundle.sh", str(bundle), str(checksum), str(manifest)], + cwd=ROOT, capture_output=True, text=True, + ) + assert good.returncode == 0, good.stderr + with bundle.open("ab") as handle: + handle.write(b"tampered") + bad = subprocess.run( + ["bash", "tools/verify-release-bundle.sh", str(bundle), str(checksum), str(manifest)], + cwd=ROOT, capture_output=True, text=True, + ) + assert bad.returncode == 1 + assert "INTEGRITY FAILURE" in bad.stderr + + +def test_release_verifier_rejects_path_traversal_before_extraction(tmp_path): + bundle = tmp_path / "unsafe.tar.gz" + payload = b"must not escape" + with tarfile.open(bundle, "w:gz") as archive: + member = tarfile.TarInfo("../escaped.txt") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + checksum = tmp_path / "unsafe.tar.gz.sha256" + checksum.write_text(f"{hashlib.sha256(bundle.read_bytes()).hexdigest()} {bundle.name}\n") + manifest = tmp_path / "manifest.sha256" + manifest.write_text("") + + result = subprocess.run( + ["bash", "tools/verify-release-bundle.sh", str(bundle), str(checksum), str(manifest)], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert "unsafe archive path" in result.stderr + assert not (tmp_path.parent / "escaped.txt").exists() diff --git a/tests/test_statusline_transcript.py b/tests/test_statusline_transcript.py new file mode 100644 index 0000000..7a6c26e --- /dev/null +++ b/tests/test_statusline_transcript.py @@ -0,0 +1,118 @@ +"""Behavioral tests for the statusline transcript cache builder.""" + +from __future__ import annotations + +import importlib.util +import io +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "plugins" / "statusline" / "assets" / "statusline-transcript.py" +SPEC = importlib.util.spec_from_file_location("session_optimizer_statusline_transcript", SCRIPT) +telemetry = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(telemetry) + + +def _record(kind, timestamp, *, output=0, model="opus", **extra): + record = {"type": kind, "timestamp": timestamp, **extra} + if kind == "assistant": + record["message"] = { + "role": "assistant", "model": model, + "usage": {"output_tokens": output}, + } + return record + + +def _jsonl(path: Path, records): + path.write_text("".join(json.dumps(r) + "\n" for r in records)) + + +def test_epoch_and_compaction_detection(): + assert telemetry._epoch("2026-01-01T00:00:00Z") is not None + assert telemetry._epoch(None) is None + assert telemetry._epoch("bad") is None + assert telemetry._is_compaction(json.dumps({"isCompactSummary": True})) + assert telemetry._is_compaction(json.dumps({"type": "summary"})) + assert telemetry._is_compaction(json.dumps({"subtype": "compact"})) + assert telemetry._is_compaction(json.dumps({"message": {"subtype": "compact"}})) + assert not telemetry._is_compaction('{"text":"type summary"}') + assert not telemetry._is_compaction('"type":"summary" not-json') + + +def test_count_tail_and_last_turn(tmp_path, monkeypatch): + path = tmp_path / "session.jsonl" + records = [ + _record("user", "2026-01-01T00:00:00Z"), + _record("assistant", "2026-01-01T00:00:10Z", output=50, model="sonnet"), + {"type": "summary", "timestamp": "2026-01-01T00:00:11Z"}, + ] + _jsonl(path, records) + assert telemetry._count_compactions(str(path), 0) == 1 + assert telemetry._count_compactions(str(tmp_path / "missing"), 0) == 0 + parsed = telemetry._tail_records(str(path), path.stat().st_size) + last_ts, rate, model = telemetry._last_turn(parsed) + assert last_ts is not None and rate == 5.0 and model == "sonnet" + assert telemetry._last_turn([]) == (None, None, None) + assert telemetry._tail_records(str(tmp_path / "missing"), 1) == [] + + # Exercise the partial-leading-line branch used for large transcripts. + monkeypatch.setattr(telemetry, "TAIL_WINDOW", 256) + path.write_text("x" * 200 + "\n" + json.dumps(records[1]) + "\n") + assert telemetry._tail_records(str(path), path.stat().st_size)[-1]["asst"] is True + + +def test_last_turn_degenerate_and_model_fallback(): + records = [ + {"ts": 10.0, "asst": False, "out": 0, "model": None}, + {"ts": 10.0, "asst": True, "out": 20, "model": None}, + {"ts": 12.0, "asst": True, "out": 0, "model": "opus"}, + ] + assert telemetry._last_turn(records) == (12.0, None, "opus") + + +def test_cache_load_and_build_incremental_paths(tmp_path, monkeypatch): + cache = tmp_path / "cache.json" + monkeypatch.setattr(telemetry, "CACHE_PATH", str(cache)) + assert telemetry._load_cache() == {} + cache.write_text("bad") + assert telemetry._load_cache() == {} + + path = tmp_path / "session.jsonl" + assert telemetry.build(str(path)) == {} + path.write_text("") + assert telemetry.build(str(path)) == {} + _jsonl(path, [ + _record("user", "2026-01-01T00:00:00Z"), + _record("assistant", "2026-01-01T00:00:04Z", output=20), + {"isCompactSummary": True}, + ]) + first = telemetry.build(str(path)) + assert first["compactions"] == 1 and first["tok_per_s"] == 5.0 + + cache.write_text(json.dumps(first)) + unchanged = telemetry.build(str(path)) + assert unchanged["compactions"] == 1 + with path.open("a") as fh: + fh.write(json.dumps({"subtype": "compact"}) + "\n") + grown = telemetry.build(str(path)) + assert grown["compactions"] == 2 + path.write_text(json.dumps(_record("assistant", "2026-01-01T00:00:05Z", output=1)) + "\n") + shrunk = telemetry.build(str(path)) + assert shrunk["compactions"] == 0 + + +def test_main_writes_cache_and_prints_json(tmp_path, monkeypatch): + transcript = tmp_path / "session.jsonl" + _jsonl(transcript, [_record("assistant", "2026-01-01T00:00:01Z", output=1)]) + cache = tmp_path / "cache.json" + stdout = io.StringIO() + monkeypatch.setattr(telemetry, "CACHE_PATH", str(cache)) + monkeypatch.setattr(telemetry.sys, "argv", [str(SCRIPT), str(transcript)]) + monkeypatch.setattr(telemetry.sys, "stdout", stdout) + telemetry.main() + emitted = json.loads(stdout.getvalue()) + assert emitted["path"] == str(transcript) + assert json.loads(cache.read_text()) == emitted diff --git a/tests/test_subagent_usage.py b/tests/test_subagent_usage.py index 3d38215..9a75bea 100644 --- a/tests/test_subagent_usage.py +++ b/tests/test_subagent_usage.py @@ -140,3 +140,95 @@ def test_session_dir_for_resolves_owning_session(tmp_path): assert su.session_dir_for(p) == "/x/proj/sessid" p2 = "/x/proj/sessid/subagents/agent-a.jsonl" assert su.session_dir_for(p2) == "/x/proj/sessid" + + +def test_usage_add_context_and_dict(): + left = su.Usage(input_tokens=1, output_tokens=2, cache_write_5m=3, + cache_write_1h=4, cache_read=5, model="") + right = su.Usage(input_tokens=10, output_tokens=20, cache_write_5m=30, + cache_write_1h=40, cache_read=50, tool_uses=2, + web_search_requests=3, web_fetch_requests=4, + model="sonnet", models={"sonnet"}) + left.add(right) + assert left.context_tokens == 143 + payload = left.to_dict() + assert payload["output_tokens"] == 22 + assert payload["models"] == ["sonnet"] + + +def test_parse_uses_largest_duplicate_and_uuid_fallback(tmp_path): + path = str(tmp_path / "agent-dups.jsonl") + records = [ + {"uuid": "u1", "message": {"usage": {"input_tokens": 1}, "content": []}}, + {"uuid": "u1", "message": {"usage": {"input_tokens": 9}, "content": []}}, + {"message": {"model": "sonnet", "usage": {"output_tokens": 2}, + "content": ["text", {"type": "tool_use"}]}}, + ] + with open(path, "w", encoding="utf-8") as fh: + fh.write("bad\n\n") + for record in records: + fh.write(json.dumps(record) + "\n") + usage = su.parse_transcript_usage(path) + assert usage.input_tokens == 9 + assert usage.output_tokens == 2 + assert usage.tool_uses == 1 + assert usage.model == "sonnet" + + +def test_meta_agent_id_and_discovery_fallbacks(tmp_path): + bad = tmp_path / "bad.meta.json" + bad.write_text("bad") + assert su._read_meta(str(bad)) == ("", "", "") + assert su._agent_id_from_path("/x/not-an-agent.log") == "not-an-agent.log" + assert su.discover_subagents(str(tmp_path / "missing")) == [] + assert su.session_dir_for("/x/no-subagents/agent-a.jsonl") == "/x/no-subagents" + + +def test_token_format_and_session_iteration(tmp_path): + assert su._fmt_tokens(999) == "999" + assert su._fmt_tokens(1_000) == "1k" + assert su._fmt_tokens(1_500_000) == "1.5M" + assert list(su._iter_session_dirs(str(tmp_path / "missing"))) == [] + session = tmp_path / "s1" / "subagents" + session.mkdir(parents=True) + (tmp_path / "file").write_text("x") + assert list(su._iter_session_dirs(str(tmp_path))) == [str(tmp_path / "s1")] + + +def test_build_report_groups_records(monkeypatch): + usage = su.Usage(input_tokens=100, output_tokens=20, model="haiku", models={"haiku"}) + rec = su.SubagentRecord("a", "Explore", "look", "tool", usage, 0.001, "/a") + monkeypatch.setattr(su, "_encoded_project_dir", lambda _cwd: "/project") + monkeypatch.setattr(su, "_iter_session_dirs", lambda _root: ["/project/s"]) + monkeypatch.setattr(su, "discover_subagents", lambda _session: ["/a", "/b"]) + monkeypatch.setattr(su, "subagent_record", lambda _path: rec) + report = su.build_report("/cwd") + assert report["subagent_count"] == 2 + assert report["by_agent_type"]["Explore"]["count"] == 2 + assert report["totals"]["usage"]["input_tokens"] == 200 + + +def test_print_table_and_cli_modes(monkeypatch, capsys, tmp_path): + empty = { + "project_dir": "/p", "subagent_count": 0, + "by_agent_type": {}, "totals": {"cost_usd": 0.0}, + } + su._print_table(empty) + assert "no subagent transcripts" in capsys.readouterr().out + + report = { + "project_dir": "/p", "subagent_count": 1, + "by_agent_type": {"Explore": {"count": 1, "usage": { + "input_tokens": 1000, "output_tokens": 2, + "cache_write_5m": 3, "cache_write_1h": 4, "cache_read": 5, + }, "cost_usd": 1.25}}, + "totals": {"cost_usd": 1.25}, + } + su._print_table(report) + assert "Explore" in capsys.readouterr().out + + monkeypatch.setattr(su, "build_report", lambda _cwd: report) + assert su.main(["--json", str(tmp_path)]) == 0 + assert '"subagent_count": 1' in capsys.readouterr().out + assert su.main([str(tmp_path)]) == 0 + assert "TOTAL" in capsys.readouterr().out diff --git a/tools/build-release-bundle.sh b/tools/build-release-bundle.sh new file mode 100644 index 0000000..b451cd6 --- /dev/null +++ b/tools/build-release-bundle.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$repo_root" +out_dir="${1:-dist}" +bundle_name="session-optimizer" +bundle_paths=( + .claude-plugin + .agents + hooks + plugins + README.md + LICENSE + PRIVACY.md + SECURITY.md + CHANGELOG.md +) + +mkdir -p "$out_dir" + +tar_args=(--create --gzip) +if tar --version 2>/dev/null | grep -qi "gnu tar"; then + tar_args+=(--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner) +fi +tar "${tar_args[@]}" -f "$out_dir/$bundle_name.tar.gz" "${bundle_paths[@]}" + +find hooks plugins -type f \( -name "*.sh" -o -name "*.py" \) -print \ + | LC_ALL=C sort \ + | while IFS= read -r file; do + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" + else + shasum -a 256 "$file" + fi + done > "$out_dir/EXECUTABLE-MANIFEST.sha256" + +version="$(python3 -c 'import json; print(json.load(open(".claude-plugin/marketplace.json"))["metadata"]["version"])')" +python3 tools/gen-bundle-sbom.py \ + --version "$version" \ + --output "$out_dir/$bundle_name.cdx.json" \ + "${bundle_paths[@]}" + +for file in "$bundle_name.tar.gz" "$bundle_name.cdx.json" EXECUTABLE-MANIFEST.sha256; do + if command -v sha256sum >/dev/null 2>&1; then + (cd "$out_dir" && sha256sum "$file" > "$file.sha256") + else + (cd "$out_dir" && shasum -a 256 "$file" > "$file.sha256") + fi +done + +printf 'Release bundle assembled in %s\n' "$out_dir" diff --git a/tools/gen-bundle-sbom.py b/tools/gen-bundle-sbom.py new file mode 100644 index 0000000..7bf6ee6 --- /dev/null +++ b/tools/gen-bundle-sbom.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Generate a deterministic-file inventory as a CycloneDX 1.5 SBOM.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _iter_files(roots: list[str]) -> list[Path]: + found: set[Path] = set() + for root in roots: + path = Path(root) + if path.is_dir(): + found.update(item for item in path.rglob("*") if item.is_file()) + elif path.is_file(): + found.add(path) + return sorted(found) + + +def build_sbom(version: str, roots: list[str]) -> dict: + components = [ + { + "type": "file", + "name": str(path), + "version": version, + "hashes": [{"alg": "SHA-256", "content": _sha256(path)}], + } + for path in _iter_files(roots) + ] + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "timestamp": timestamp, + "component": { + "type": "application", + "name": "session-optimizer", + "version": version, + }, + }, + "components": components, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("roots", nargs="+") + args = parser.parse_args(argv) + document = build_sbom(args.version, args.roots) + args.output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify-release-bundle.sh b/tools/verify-release-bundle.sh new file mode 100644 index 0000000..9075e13 --- /dev/null +++ b/tools/verify-release-bundle.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +bundle="${1:-}" +bundle_sum="${2:-}" +exec_manifest="${3:-}" + +if [[ -z "$bundle" || -z "$bundle_sum" ]]; then + echo "usage: $0 [executable-manifest]" >&2 + exit 2 +fi +[[ -f "$bundle" ]] || { echo "error: bundle not found: $bundle" >&2; exit 2; } +[[ -f "$bundle_sum" ]] || { echo "error: checksum not found: $bundle_sum" >&2; exit 2; } + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +expected="$(awk '{print $1}' "$bundle_sum" | head -1 | tr '[:upper:]' '[:lower:]')" +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "error: invalid SHA-256 checksum" >&2; exit 2; } +actual="$(sha256_file "$bundle" | tr '[:upper:]' '[:lower:]')" +[[ "$actual" == "$expected" ]] || { echo "INTEGRITY FAILURE: bundle checksum mismatch" >&2; exit 1; } + +[[ -z "$exec_manifest" ]] && { echo "OK: bundle checksum matches"; exit 0; } +[[ -f "$exec_manifest" ]] || { echo "error: manifest not found: $exec_manifest" >&2; exit 2; } + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +# Reject path traversal before extraction even when the archive checksum is +# internally consistent. Release verification is a trust boundary, not a +# convenience untar command. +while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..) echo "INTEGRITY FAILURE: unsafe archive path: $member" >&2; exit 1 ;; + esac +done < <(tar -tzf "$bundle") +tar -xzf "$bundle" -C "$workdir" + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + want="$(awk '{print $1}' <<<"$line" | tr '[:upper:]' '[:lower:]')" + rel="$(awk '{print $2}' <<<"$line")" + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "error: invalid executable digest" >&2; exit 2; } + [[ -n "$rel" && "$rel" != /* && "$rel" != ../* && "$rel" != */../* ]] \ + || { echo "INTEGRITY FAILURE: unsafe manifest path" >&2; exit 1; } + target="$workdir/$rel" + [[ -f "$target" ]] || { echo "INTEGRITY FAILURE: missing executable: $rel" >&2; exit 1; } + got="$(sha256_file "$target" | tr '[:upper:]' '[:lower:]')" + [[ "$got" == "$want" ]] || { echo "INTEGRITY FAILURE: executable mismatch: $rel" >&2; exit 1; } +done < "$exec_manifest" + +echo "OK: bundle and executable manifest verified"