From 03958106e1f5a90a79a913a170da45c7d2bf8237 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:46:38 -0500 Subject: [PATCH] Add agent mode, hardened TUI harness, coverage gates, and cross-platform installer smoke CI This commit delivers four related improvements focused on reliability and automation while preserving existing human/TUI workflows. 1) Agent-first CLI mode (additive) - Added new `seedpass agent` command group (`src/seedpass/cli/agent.py`) and wired it into CLI root. - Implemented non-interactive profile bootstrap: `seedpass agent init` with `--seed|--seed-file|--generate-seed` and password env injection (`--password-env`). - Added policy management commands: `agent policy-show` and `agent policy-set` with allow/deny kind controls. - Added JSON-based secret retrieval: `seedpass -f agent get --ttl ` with lease metadata and policy enforcement. - Kept regular human mode fully available; no behavioral changes to interactive TUI entrypoint. 2) TUI agent harness deepening and stabilization - Refactored harness into scenario profiles: `core`, `extended`, `stress` with `--scenario` selection. - Added deterministic seeded stress campaigns: `--stress-cycles`, `--stress-seed`. - Added scenario-specific required coverage keys and report metadata. - Added post-condition assertions in extended scenario (archive/restore consistency, lock/unlock retrieval recovery). - Hardened prompt matching for output variants and fail-fast behavior on non-zero SeedPass exit codes. - Added harness unit tests for helper logic and control flow. 3) Critical module coverage gate tooling - Added `scripts/check_critical_coverage.py` to enforce per-module thresholds from coverage JSON and report uncovered functions. - Added override controls (`--threshold`, `--no-default-thresholds`) and optional JSON result output. - Integrated coverage JSON generation into CI helper script and optional gate execution via `CRITICAL_COVERAGE_GATE=1`. - Tuned default thresholds using full-suite baseline and increased strictness: - `src/main.py >= 50%` - `src/seedpass/core/manager.py >= 60%` - `src/seedpass/core/encryption.py >= 80%` - `src/seedpass/core/migrations.py >= 90%` - Added tests for coverage gate parser/analysis logic and targeted manager worker path tests. 4) Installer validation framework for beta/main - Added cross-platform installer smoke scripts: - `scripts/installer_smoke_unix.sh` - `scripts/installer_smoke_windows.ps1` - Added GitHub Actions workflow `.github/workflows/installer-smoke.yml` with: - reduced PR matrix - full scheduled/manual matrix across OS/branch/mode - per-job log capture and artifact uploads for failure triage - Added installer testing documentation and linked from README. Additional housekeeping - Added `.venv/` to `.gitignore`. - Updated docs for new harness scenarios, seeded stress mode, post-condition metadata, coverage gate usage, and installer testing workflow. --- .github/workflows/installer-smoke.yml | 125 ++++++ .gitignore | 1 + README.md | 38 ++ docs/ai_agent_tui_testing.md | 38 +- docs/installer_testing.md | 44 ++ scripts/ai_tui_agent_test.py | 423 ++++++++++++++++-- scripts/check_critical_coverage.py | 269 +++++++++++ scripts/installer_smoke_unix.sh | 58 +++ scripts/installer_smoke_windows.ps1 | 58 +++ scripts/run_ci_tests.sh | 14 +- src/seedpass/cli/__init__.py | 3 +- src/seedpass/cli/agent.py | 335 ++++++++++++++ src/tests/test_ai_tui_agent_harness.py | 183 ++++++++ src/tests/test_background_vault_sync_paths.py | 58 +++ src/tests/test_check_critical_coverage.py | 84 ++++ src/tests/test_cli_agent_mode.py | 149 ++++++ 16 files changed, 1826 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/installer-smoke.yml create mode 100644 docs/installer_testing.md create mode 100644 scripts/check_critical_coverage.py create mode 100644 scripts/installer_smoke_unix.sh create mode 100644 scripts/installer_smoke_windows.ps1 create mode 100644 src/seedpass/cli/agent.py create mode 100644 src/tests/test_ai_tui_agent_harness.py create mode 100644 src/tests/test_background_vault_sync_paths.py create mode 100644 src/tests/test_check_critical_coverage.py create mode 100644 src/tests/test_cli_agent_mode.py diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml new file mode 100644 index 00000000..51cca5c8 --- /dev/null +++ b/.github/workflows/installer-smoke.yml @@ -0,0 +1,125 @@ +name: Installer Smoke + +on: + pull_request: + paths: + - "scripts/install.sh" + - "scripts/install.ps1" + - "scripts/installer_smoke_unix.sh" + - "scripts/installer_smoke_windows.ps1" + - ".github/workflows/installer-smoke.yml" + workflow_dispatch: + schedule: + - cron: "17 4 * * *" + +jobs: + smoke_pr: + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + branch: beta + mode: tui + - os: windows-latest + branch: beta + mode: tui + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Unix installer smoke + if: runner.os != 'Windows' + run: | + set -euo pipefail + mkdir -p artifacts/installer-smoke + bash scripts/installer_smoke_unix.sh "${{ matrix.branch }}" "${{ matrix.mode }}" 2>&1 | tee "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log" + + - name: Windows installer smoke + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts/installer-smoke | Out-Null + $log = "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log" + & ./scripts/installer_smoke_windows.ps1 -Branch "${{ matrix.branch }}" -Mode "${{ matrix.mode }}" *>&1 | Tee-Object -FilePath $log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload installer smoke logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: installer-smoke-logs-${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}-pr + path: artifacts/installer-smoke/*.log + if-no-files-found: warn + + smoke_full: + if: github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + branch: main + mode: tui + - os: ubuntu-latest + branch: beta + mode: tui + - os: ubuntu-latest + branch: beta + mode: both + - os: macos-latest + branch: main + mode: tui + - os: macos-latest + branch: beta + mode: tui + - os: macos-latest + branch: beta + mode: both + - os: windows-latest + branch: main + mode: tui + - os: windows-latest + branch: beta + mode: tui + - os: windows-latest + branch: beta + mode: both + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Unix installer smoke + if: runner.os != 'Windows' + run: | + set -euo pipefail + mkdir -p artifacts/installer-smoke + bash scripts/installer_smoke_unix.sh "${{ matrix.branch }}" "${{ matrix.mode }}" 2>&1 | tee "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log" + + - name: Windows installer smoke + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts/installer-smoke | Out-Null + $log = "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log" + & ./scripts/installer_smoke_windows.ps1 -Branch "${{ matrix.branch }}" -Mode "${{ matrix.mode }}" *>&1 | Tee-Object -FilePath $log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload installer smoke logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: installer-smoke-logs-${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}-full + path: artifacts/installer-smoke/*.log + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index e418fa9b..25221a84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Ignore virtual environment directory venv/ +.venv/ # Ignore context tool directory context_tool/ diff --git a/README.md b/README.md index 62e6f70b..eedb311b 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ Use the automated installer to download SeedPass and its dependencies in one ste The default `tui` mode installs only the text interface, so it runs headlessly and works well in CI or other automation. GUI backends are optional and must be explicitly requested (`--mode gui` or `--mode both` on Linux/macOS, `-IncludeGui` on Windows). If the GTK `gi` bindings are missing, the installer attempts to install the necessary system packages using `apt`, `yum`, `pacman`, or Homebrew. When no display server is detected, GUI components are skipped automatically. +For installer validation workflows and local smoke tests, see [Installer Testing](docs/installer_testing.md). + **Linux and macOS:** ```bash # TUI-only/agent install (headless default) @@ -443,6 +445,42 @@ which seedpass # or 'where seedpass' on Windows For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md). +### Agent Mode (Non-Interactive) + +SeedPass now includes an additive `agent` command group for automation and CI. +This does not replace or modify the regular human-facing TUI flow. + +1. Initialize a profile non-interactively: + +```bash +export SEEDPASS_PASSWORD='your-strong-password' +seedpass agent init --generate-seed +``` + +Or use an existing seed from a file: + +```bash +export SEEDPASS_PASSWORD='your-strong-password' +seedpass agent init --seed-file /secure/path/seed.txt --switch-existing +``` + +2. Configure agent policy (kind allow-list and private-kind deny-list): + +```bash +seedpass agent policy-set --allow-kind password --allow-kind totp --deny-private-kind seed +seedpass agent policy-show +``` + +3. Retrieve a secret as JSON with lease metadata: + +```bash +export SEEDPASS_PASSWORD='your-strong-password' +seedpass -f agent get github --ttl 30 +``` + +By default, agent policy blocks private key/seed-style entry kinds from being +revealed through `agent get`. + ### Running the Application 1. **Start the Application:** diff --git a/docs/ai_agent_tui_testing.md b/docs/ai_agent_tui_testing.md index e85e2cb0..b23ff294 100644 --- a/docs/ai_agent_tui_testing.md +++ b/docs/ai_agent_tui_testing.md @@ -23,9 +23,15 @@ The runner: - creates an isolated temporary `HOME` - launches `src/main.py --no-clipboard` -- executes an end-to-end scenario through onboarding and major menu flows +- executes a scenario profile (`core`, `extended`, or `stress`) - writes artifacts to `artifacts/agent_tui_test//` +## Scenario Profiles + +- `core`: fast smoke path (onboarding, invalid input checks, add/retrieve/search/list, exit) +- `extended`: full end-to-end path including all entry kinds, archive/restore, and settings flows +- `stress`: deterministic repeated negative-input and back-navigation loops + ## Agent Commands Today Use these command tiers when agents are validating changes: @@ -35,7 +41,8 @@ Use these command tiers when agents are validating changes: ```bash source .venv/bin/activate pytest -q src/tests/test_manager_add_password.py src/tests/test_archive_restore.py src/tests/test_stats_screen.py src/tests/test_bip85_init.py -python scripts/ai_tui_agent_test.py +pytest -q src/tests/test_ai_tui_agent_harness.py +python scripts/ai_tui_agent_test.py --scenario core ``` 2. Full regression check (before merge): @@ -43,14 +50,16 @@ python scripts/ai_tui_agent_test.py ```bash source .venv/bin/activate pytest -q -python scripts/ai_tui_agent_test.py +pytest -q --cov=src --cov-report=json:artifacts/coverage/coverage.json src/tests +python scripts/check_critical_coverage.py artifacts/coverage/coverage.json +python scripts/ai_tui_agent_test.py --scenario extended ``` 3. Stability sweep for TUI prompt timing: ```bash source .venv/bin/activate -python scripts/ai_tui_agent_test.py && python scripts/ai_tui_agent_test.py && python scripts/ai_tui_agent_test.py +python scripts/ai_tui_agent_test.py --scenario stress --stress-cycles 6 --stress-seed 1337 ``` ## Artifacts @@ -61,6 +70,12 @@ Each run writes: - `transcript.clean.txt`: ANSI-stripped terminal output. - `transcript.raw.txt`: raw terminal output with ANSI. +`report.json` also includes scenario-specific validation metadata: + +- `required_coverage_keys` +- `required_post_conditions` +- `post_conditions` + ## Current Coverage Points The harness marks these checkpoints as `true` only when observed: @@ -82,6 +97,11 @@ The harness marks these checkpoints as `true` only when observed: - `settings_stats` - `graceful_exit` +`extended` scenario also enforces post-conditions: + +- `archive_restore_consistency`: restored entries are no longer listed in archived view +- `lock_unlock_recovers_retrieval`: retrieval still works after lock/unlock + ## CI/Agent Use Pattern Recommended agent sequence: @@ -90,6 +110,7 @@ Recommended agent sequence: 2. Parse `report.json` 3. Fail task if `status != "passed"` or any checkpoint is `false` 4. If failed, inspect `transcript.clean.txt` for prompt-flow drift or regressions +5. Inspect `artifacts/coverage/coverage.json` and critical coverage gate output when module thresholds fail ## Optional Flags @@ -99,12 +120,21 @@ python scripts/ai_tui_agent_test.py --help Useful options: +- `--scenario`: `core`, `extended`, or `stress` +- `--stress-cycles`: number of loops for `stress` profile +- `--stress-seed`: fixed RNG seed for deterministic invalid-input campaigns in `stress` - `--timeout`: per-prompt timeout - `--password`: fixed password used during test - `--output-dir`: custom artifact root - `--keep-home`: preserve generated profile state for local debugging - `--verbose`: print step progress +Coverage gate helper: + +- `python scripts/check_critical_coverage.py artifacts/coverage/coverage.json` +- Use `--threshold PATH=PERCENT` to override defaults. +- Use `--no-default-thresholds` for focused local smoke checks. + ## Extending Coverage When adding new TUI flows: diff --git a/docs/installer_testing.md b/docs/installer_testing.md new file mode 100644 index 00000000..b48e89cf --- /dev/null +++ b/docs/installer_testing.md @@ -0,0 +1,44 @@ +# Installer Testing + +This document describes how SeedPass installer smoke tests are validated across +operating systems and branches (`main`, `beta`). + +## CI Workflow + +GitHub Actions workflow: `.github/workflows/installer-smoke.yml` + +- `pull_request` (reduced matrix): + - Ubuntu + Windows + - `beta` branch + - `tui` mode +- `schedule` + `workflow_dispatch` (full matrix): + - Ubuntu / macOS / Windows + - `main` + `beta` + - `tui` and GUI-capable modes (`both` where supported) + +Each job verifies: + +1. installer exits successfully +2. launcher is created +3. installed git branch matches requested branch +4. `seedpass --help` runs through launcher +5. installer can be re-run (idempotence smoke check) + +## Local Smoke Tests + +### Linux / macOS + +```bash +bash scripts/installer_smoke_unix.sh beta tui +bash scripts/installer_smoke_unix.sh beta both +``` + +### Windows (PowerShell) + +```powershell +./scripts/installer_smoke_windows.ps1 -Branch beta -Mode tui +./scripts/installer_smoke_windows.ps1 -Branch beta -Mode both +``` + +The smoke scripts use an isolated temporary home/profile directory so local +user data is not modified. diff --git a/scripts/ai_tui_agent_test.py b/scripts/ai_tui_agent_test.py index 088eca14..504d899d 100755 --- a/scripts/ai_tui_agent_test.py +++ b/scripts/ai_tui_agent_test.py @@ -18,6 +18,7 @@ import argparse import json import os import pty +import random import re import select import shutil @@ -142,6 +143,24 @@ def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Run deterministic, repeatable TUI coverage checks for SeedPass." ) + parser.add_argument( + "--scenario", + choices=("core", "extended", "stress"), + default="extended", + help="Scenario profile to run (default: extended).", + ) + parser.add_argument( + "--stress-cycles", + type=int, + default=6, + help="Deterministic menu-stress loops for --scenario stress.", + ) + parser.add_argument( + "--stress-seed", + type=int, + default=1337, + help="RNG seed for deterministic invalid-input campaigns in stress scenario.", + ) parser.add_argument( "--repo-root", type=Path, @@ -289,7 +308,7 @@ def _add_entry_menu_open( ) -def _scenario( +def _complete_initial_seed_setup( runner: TUIRunner, timeout: float, password: str, coverage: dict[str, bool] ) -> None: # First-run onboarding. @@ -302,34 +321,26 @@ def _scenario( coverage["startup_onboarding"] = True coverage["main_menu"] = True - # Invalid input resilience checks. - runner.sendline("99") - runner.wait_for(r"Invalid choice\. Please select a valid option\.", timeout) - _add_entry_menu_open(runner, timeout, "9") - runner.wait_for(r"Invalid choice\.", timeout) - runner.expect_and_send(r"Select entry type or press Enter to go back:", "", timeout) - _drain_to_main_menu(runner, timeout) - runner.sendline("2") - runner.expect_and_send( - r"Enter the index number of the entry to retrieve:", "abc", timeout - ) - runner.wait_for(r"Error: Index must be a number\.", timeout) - runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) - coverage["invalid_input_resilience"] = True - # Add password in quick mode, including validation checks. +def _add_quick_password_entry( + runner: TUIRunner, + timeout: float, + *, + label: str, + username: str, + url: str, + exercise_invalid_length: bool, + coverage: dict[str, bool], +) -> None: _add_entry_menu_open(runner, timeout, "1") runner.expect_and_send(r"Choose mode: \[Q\]uick or \[A\]dvanced\?", "q", timeout) - runner.expect_and_send( - r"Enter the label or website name:", "agent.example", timeout - ) - runner.expect_and_send(r"Enter the username \(optional\):", "agent", timeout) - runner.expect_and_send( - r"Enter the URL \(optional\):", "https://agent.example", timeout - ) - runner.expect_and_send(r"Enter desired password length .*:", "3", timeout) - runner.wait_for(r"Password length must be between 8 and 128", timeout) - coverage["invalid_length_validation"] = True + runner.expect_and_send(r"Enter the label or website name:", label, timeout) + runner.expect_and_send(r"Enter the username \(optional\):", username, timeout) + runner.expect_and_send(r"Enter the URL \(optional\):", url, timeout) + if exercise_invalid_length: + runner.expect_and_send(r"Enter desired password length .*:", "3", timeout) + runner.wait_for(r"Password length must be between 8 and 128", timeout) + coverage["invalid_length_validation"] = True runner.expect_and_send(r"Enter desired password length .*:", "12", timeout) runner.expect_and_send(r"Include special characters\? \(Y/n\):", "", timeout) runner.wait_for(r"Password generated and indexed with ID 0", timeout) @@ -337,12 +348,214 @@ def _scenario( runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) _drain_to_main_menu(runner, timeout) + +def _invalid_input_checks( + runner: TUIRunner, + timeout: float, + *, + main_menu_choice: str = "99", + add_entry_choice: str = "9", + retrieve_index: str = "abc", +) -> None: + runner.sendline(main_menu_choice) + runner.wait_for(r"Invalid choice\. Please select a valid option\.", timeout) + _add_entry_menu_open(runner, timeout, add_entry_choice) + runner.wait_for(r"Invalid choice\.", timeout) + runner.expect_and_send(r"Select entry type or press Enter to go back:", "", timeout) + _drain_to_main_menu(runner, timeout) + runner.sendline("2") + runner.expect_and_send( + r"Enter the index number of the entry to retrieve:", retrieve_index, timeout + ) + runner.wait_for(r"Error: Index must be a number\.", timeout) + runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) + _drain_to_main_menu(runner, timeout) + + +def _wait_for_retrieve_password_output( + runner: TUIRunner, timeout: float, label: str +) -> None: + runner.wait_for( + rf"(Retrieved Password for {re.escape(label)}|Retrieving password for '{re.escape(label)}')", + timeout, + ) + + +def _scenario_core( + runner: TUIRunner, + timeout: float, + password: str, + coverage: dict[str, bool], + post_conditions: dict[str, bool], +) -> None: + _complete_initial_seed_setup(runner, timeout, password, coverage) + + # Invalid input resilience checks. + _invalid_input_checks(runner, timeout) + coverage["invalid_input_resilience"] = True + + # Add a password and exercise length validation. + _add_quick_password_entry( + runner, + timeout, + label="agent.example", + username="agent", + url="https://agent.example", + exercise_invalid_length=True, + coverage=coverage, + ) + # Retrieve and action menu. runner.sendline("2") runner.expect_and_send( r"Enter the index number of the entry to retrieve:", "0", timeout ) - runner.wait_for(r"Retrieved Password for agent\.example", timeout) + _wait_for_retrieve_password_output(runner, timeout, "agent.example") + if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4): + runner.sendline("") + runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) + runner.wait_for(r"Entry Actions:", timeout) + coverage["retrieve_entry"] = True + runner.expect_and_send(r"Select an action or press Enter to return:", "", timeout) + _drain_to_main_menu(runner, timeout) + + # Search. + runner.sendline("3") + runner.expect_and_send(r"Enter search string:", "agent", timeout) + runner.wait_for(r"Search Results", timeout) + runner.wait_for(r"0\. Password - agent\.example", timeout) + coverage["search_entries"] = True + runner.expect_and_send( + r"Enter index to view details or press Enter to go back:", "", timeout + ) + _drain_to_main_menu(runner, timeout) + + # List entries. + runner.sendline("4") + runner.expect_and_send( + r"Select entry type or press Enter to go back:", "1", timeout + ) + runner.wait_for(r"\[\+\] Entries:", timeout) + runner.wait_for(r"0\. Password - agent\.example", timeout) + coverage["list_entries"] = True + runner.expect_and_send( + r"Enter index to view details or press Enter to go back:", "", timeout + ) + runner.expect_and_send(r"Select entry type or press Enter to go back:", "", timeout) + _drain_to_main_menu(runner, timeout) + + # Graceful exit. + runner.sendline("") + runner.wait_for(r"Exiting the program\.", timeout) + coverage["graceful_exit"] = True + + +def _scenario_stress( + runner: TUIRunner, + timeout: float, + password: str, + coverage: dict[str, bool], + post_conditions: dict[str, bool], + *, + cycles: int, + seed: int, +) -> None: + _complete_initial_seed_setup(runner, timeout, password, coverage) + + _add_quick_password_entry( + runner, + timeout, + label="stress.example", + username="stress", + url="https://stress.example", + exercise_invalid_length=False, + coverage=coverage, + ) + + rng = random.Random(seed) + invalid_main_menu_inputs = ["99", "0", "x", "-1", "one", "1x"] + invalid_add_entry_inputs = ["9", "0", "x", "-1", "one", "1x"] + invalid_retrieve_inputs = ["abc", "!", "one", "1x", "index", "0x1"] + + # Deterministic negative-input + back-navigation stress cycles. + for _ in range(cycles): + _invalid_input_checks( + runner, + timeout, + main_menu_choice=rng.choice(invalid_main_menu_inputs), + add_entry_choice=rng.choice(invalid_add_entry_inputs), + retrieve_index=rng.choice(invalid_retrieve_inputs), + ) + + runner.sendline("4") + runner.expect_and_send( + r"Select entry type or press Enter to go back:", "1", timeout + ) + runner.wait_for(r"\[\+\] Entries:", timeout) + runner.wait_for(r"0\. Password - stress\.example", timeout) + runner.expect_and_send( + r"Enter index to view details or press Enter to go back:", "", timeout + ) + runner.expect_and_send( + r"Select entry type or press Enter to go back:", "", timeout + ) + _drain_to_main_menu(runner, timeout) + + runner.sendline("2") + runner.expect_and_send( + r"Enter the index number of the entry to retrieve:", "0", timeout + ) + _wait_for_retrieve_password_output(runner, timeout, "stress.example") + if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4): + runner.sendline("") + runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) + runner.wait_for(r"Entry Actions:", timeout) + runner.expect_and_send( + r"Select an action or press Enter to return:", "", timeout + ) + _drain_to_main_menu(runner, timeout) + + coverage["invalid_input_resilience"] = True + coverage["retrieve_entry"] = True + coverage["list_entries"] = True + coverage["stress_cycles"] = True + coverage["seeded_negative_campaign"] = True + + runner.sendline("") + runner.wait_for(r"Exiting the program\.", timeout) + coverage["graceful_exit"] = True + + +def _scenario_extended( + runner: TUIRunner, + timeout: float, + password: str, + coverage: dict[str, bool], + post_conditions: dict[str, bool], +) -> None: + _complete_initial_seed_setup(runner, timeout, password, coverage) + + # Invalid input resilience checks. + _invalid_input_checks(runner, timeout) + coverage["invalid_input_resilience"] = True + + # Add password in quick mode, including validation checks. + _add_quick_password_entry( + runner, + timeout, + label="agent.example", + username="agent", + url="https://agent.example", + exercise_invalid_length=True, + coverage=coverage, + ) + + # Retrieve and action menu. + runner.sendline("2") + runner.expect_and_send( + r"Enter the index number of the entry to retrieve:", "0", timeout + ) + _wait_for_retrieve_password_output(runner, timeout, "agent.example") if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4): runner.sendline("") runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) @@ -541,6 +754,23 @@ def _scenario( runner.wait_for(r"Entry at index 0 modified successfully", timeout) coverage["archive_restore"] = True _drain_to_main_menu(runner, timeout) + runner.sendline("8") + if runner.try_wait_for(r"Archived Entries", timeout=0.8): + if runner.try_wait_for(r"0\. agent\.example", timeout=0.4): + raise TUIHarnessError( + "Archive/restore post-condition failed: restored entry is still archived." + ) + runner.expect_and_send( + r"Enter index to manage or press Enter to go back:", "", timeout + ) + elif runner.try_wait_for(r"Press Enter to continue\.\.\.", timeout=0.8): + runner.sendline("") + else: + raise TUIHarnessError( + "Archive/restore post-condition failed: archived view prompt was not recognized." + ) + _drain_to_main_menu(runner, timeout) + post_conditions["archive_restore_consistency"] = True # 2FA codes view remains accessible after mixed operations. runner.sendline("6") @@ -589,6 +819,17 @@ def _scenario( runner.expect_and_send(r"Select an option or press Enter to go back:", "", timeout) _drain_to_main_menu(runner, timeout) coverage["settings_toggles_lock_unlock"] = True + runner.sendline("2") + runner.expect_and_send( + r"Enter the index number of the entry to retrieve:", "0", timeout + ) + _wait_for_retrieve_password_output(runner, timeout, "agent.example") + if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4): + runner.sendline("") + runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout) + runner.expect_and_send(r"Select an action or press Enter to return:", "", timeout) + _drain_to_main_menu(runner, timeout) + post_conditions["lock_unlock_recovers_retrieval"] = True # Settings -> Stats and back. runner.sendline("7") @@ -609,6 +850,10 @@ def _scenario( def main() -> int: args = _parse_args() + if args.stress_cycles < 1: + print("Error: --stress-cycles must be >= 1", file=sys.stderr) + return 2 + repo_root = args.repo_root.resolve() src_main = repo_root / "src" / "main.py" if not src_main.exists(): @@ -640,32 +885,97 @@ def main() -> int: runner = TUIRunner(proc, master_fd) results: list[StepResult] = [] - coverage = { - "startup_onboarding": False, - "main_menu": False, - "invalid_input_resilience": False, - "add_password_quick": False, - "invalid_length_validation": False, - "add_totp": False, - "add_all_entry_types": False, - "retrieve_entry": False, - "search_entries": False, - "list_entries": False, - "modify_entry": False, - "archive_restore": False, - "totp_codes_view": False, - "settings_toggles_lock_unlock": False, - "settings_stats": False, - "graceful_exit": False, + scenario_coverage_keys: dict[str, list[str]] = { + "core": [ + "startup_onboarding", + "main_menu", + "invalid_input_resilience", + "add_password_quick", + "invalid_length_validation", + "retrieve_entry", + "search_entries", + "list_entries", + "graceful_exit", + ], + "extended": [ + "startup_onboarding", + "main_menu", + "invalid_input_resilience", + "add_password_quick", + "invalid_length_validation", + "add_totp", + "add_all_entry_types", + "retrieve_entry", + "search_entries", + "list_entries", + "modify_entry", + "archive_restore", + "totp_codes_view", + "settings_toggles_lock_unlock", + "settings_stats", + "graceful_exit", + ], + "stress": [ + "startup_onboarding", + "main_menu", + "add_password_quick", + "invalid_input_resilience", + "retrieve_entry", + "list_entries", + "stress_cycles", + "seeded_negative_campaign", + "graceful_exit", + ], } + scenario_post_condition_keys: dict[str, list[str]] = { + "core": [], + "extended": [ + "archive_restore_consistency", + "lock_unlock_recovers_retrieval", + ], + "stress": [], + } + coverage = { + key: False + for key in sorted({k for keys in scenario_coverage_keys.values() for k in keys}) + } + post_conditions = { + key: False + for key in sorted( + {k for keys in scenario_post_condition_keys.values() for k in keys} + ) + } + required_keys = scenario_coverage_keys[args.scenario] + required_post_conditions = scenario_post_condition_keys[args.scenario] failure: str | None = None start_time = time.monotonic() + scenario_handlers = { + "core": lambda: _scenario_core( + runner, args.timeout, args.password, coverage, post_conditions + ), + "extended": lambda: _scenario_extended( + runner, args.timeout, args.password, coverage, post_conditions + ), + "stress": lambda: _scenario_stress( + runner, + args.timeout, + args.password, + coverage, + post_conditions, + cycles=args.stress_cycles, + seed=args.stress_seed, + ), + } try: _run_step( - "full_scenario", - "Drive end-to-end deterministic TUI coverage workflow.", - lambda: _scenario(runner, args.timeout, args.password, coverage), + f"{args.scenario}_scenario", + ( + "Drive end-to-end deterministic TUI coverage workflow." + if args.scenario == "extended" + else f"Drive deterministic {args.scenario} TUI scenario." + ), + scenario_handlers[args.scenario], results, verbose=args.verbose, ) @@ -674,6 +984,8 @@ def main() -> int: finally: exit_code = runner.close() total_duration = time.monotonic() - start_time + if failure is None and exit_code != 0: + failure = f"SeedPass exited with non-zero code: {exit_code}" transcript_clean = artifact_dir / "transcript.clean.txt" transcript_raw = artifact_dir / "transcript.raw.txt" @@ -685,12 +997,27 @@ def main() -> int: "command": cmd, "repo_root": str(repo_root), "home_dir": str(temp_home_dir), + "scenario": args.scenario, + "stress_cycles": args.stress_cycles if args.scenario == "stress" else None, + "stress_seed": args.stress_seed if args.scenario == "stress" else None, + "required_coverage_keys": required_keys, + "required_post_conditions": required_post_conditions, "duration_sec": round(total_duration, 3), "seedpass_exit_code": exit_code, - "status": "passed" if failure is None and all(coverage.values()) else "failed", + "status": ( + "passed" + if ( + failure is None + and all(coverage[key] for key in required_keys) + and all(post_conditions[key] for key in required_post_conditions) + and exit_code == 0 + ) + else "failed" + ), "failure": failure, "steps": [asdict(item) for item in results], "coverage_points": coverage, + "post_conditions": post_conditions, "transcript_clean": str(transcript_clean), "transcript_raw": str(transcript_raw), } diff --git a/scripts/check_critical_coverage.py b/scripts/check_critical_coverage.py new file mode 100644 index 00000000..29afe3f7 --- /dev/null +++ b/scripts/check_critical_coverage.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +Enforce coverage thresholds for critical SeedPass modules. + +Reads coverage.py JSON output (pytest --cov-report=json:) and: +1. Fails if critical modules are below configured coverage thresholds. +2. Reports fully uncovered functions/methods for those modules. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_THRESHOLDS: dict[str, float] = { + "src/main.py": 50.0, + "src/seedpass/core/manager.py": 60.0, + "src/seedpass/core/encryption.py": 80.0, + "src/seedpass/core/migrations.py": 90.0, +} + + +@dataclass(frozen=True) +class FunctionCoverage: + name: str + start_line: int + end_line: int + covered: bool + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate coverage thresholds for critical modules." + ) + parser.add_argument( + "coverage_json", + type=Path, + help="Path to coverage JSON report.", + ) + parser.add_argument( + "--threshold", + action="append", + default=[], + metavar="PATH=PERCENT", + help="Override/add threshold mapping, e.g. src/main.py=40", + ) + parser.add_argument( + "--no-default-thresholds", + action="store_true", + help="Disable built-in threshold set; use only --threshold entries.", + ) + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Repository root for resolving module paths.", + ) + parser.add_argument( + "--json-output", + type=Path, + default=None, + help="Optional path to write machine-readable gate results JSON.", + ) + return parser.parse_args() + + +def _parse_threshold_overrides(values: list[str]) -> dict[str, float]: + parsed: dict[str, float] = {} + for item in values: + if "=" not in item: + raise ValueError( + f"Invalid --threshold value {item!r}; expected PATH=PERCENT" + ) + path, raw_percent = item.split("=", 1) + path = path.strip() + if not path: + raise ValueError(f"Invalid --threshold value {item!r}; empty path") + try: + percent = float(raw_percent) + except ValueError as exc: + raise ValueError( + f"Invalid --threshold value {item!r}; percent must be numeric" + ) from exc + if not (0.0 <= percent <= 100.0): + raise ValueError( + f"Invalid --threshold value {item!r}; percent must be 0..100" + ) + parsed[path] = percent + return parsed + + +def _load_coverage_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise RuntimeError(f"Coverage JSON not found: {path}") from exc + except json.JSONDecodeError as exc: + raise RuntimeError(f"Invalid coverage JSON: {path}: {exc}") from exc + + +def _normalize_path(path: str) -> str: + return path.replace("\\", "/") + + +def _find_file_payload(files_payload: dict, module_path: str): + module_norm = _normalize_path(module_path) + for candidate_key, payload in files_payload.items(): + candidate_norm = _normalize_path(candidate_key) + if candidate_norm.endswith(module_norm): + return payload + return None + + +class _FunctionCollector(ast.NodeVisitor): + def __init__(self) -> None: + self.stack: list[str] = [] + self.functions: list[tuple[str, int, int]] = [] + + def _collect_fn(self, node: ast.AST, fn_name: str) -> None: + end_line = getattr(node, "end_lineno", None) or getattr(node, "lineno", 0) + full_name = ".".join(self.stack + [fn_name]) + self.functions.append((full_name, int(node.lineno), int(end_line))) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.stack.append(node.name) + self.generic_visit(node) + self.stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._collect_fn(node, node.name) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._collect_fn(node, node.name) + self.generic_visit(node) + + +def _compute_function_coverage( + file_path: Path, executed_lines: set[int] +) -> list[FunctionCoverage]: + source = file_path.read_text(encoding="utf-8") + tree = ast.parse(source) + collector = _FunctionCollector() + collector.visit(tree) + functions: list[FunctionCoverage] = [] + for name, start_line, end_line in collector.functions: + line_range = range(start_line, end_line + 1) + covered = any(line in executed_lines for line in line_range) + functions.append( + FunctionCoverage( + name=name, + start_line=start_line, + end_line=end_line, + covered=covered, + ) + ) + return functions + + +def main() -> int: + args = _parse_args() + try: + overrides = _parse_threshold_overrides(args.threshold) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + thresholds = {} if args.no_default_thresholds else dict(DEFAULT_THRESHOLDS) + thresholds.update(overrides) + if not thresholds: + print( + "Error: no thresholds configured. Provide --threshold entries.", + file=sys.stderr, + ) + return 2 + + try: + coverage = _load_coverage_json(args.coverage_json) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + files_payload = coverage.get("files") + if not isinstance(files_payload, dict): + print("Error: coverage JSON missing 'files' map", file=sys.stderr) + return 2 + + repo_root = args.repo_root.resolve() + failing_modules: list[str] = [] + result_payload: dict[str, dict] = {} + + print("Critical module coverage gates:") + for module_path, threshold in sorted(thresholds.items()): + payload = _find_file_payload(files_payload, module_path) + module_result = { + "threshold_percent": threshold, + "actual_percent": None, + "meets_threshold": False, + "uncovered_functions": [], + "error": None, + } + if payload is None: + msg = "missing from coverage report" + print(f"- FAIL {module_path}: {msg}") + module_result["error"] = msg + failing_modules.append(module_path) + result_payload[module_path] = module_result + continue + + summary = payload.get("summary", {}) + percent = float(summary.get("percent_covered", 0.0)) + module_result["actual_percent"] = percent + module_result["meets_threshold"] = percent >= threshold + + module_file = (repo_root / module_path).resolve() + try: + executed_lines = {int(x) for x in payload.get("executed_lines", [])} + functions = _compute_function_coverage(module_file, executed_lines) + except Exception as exc: # pragma: no cover - defensive report path + module_result["error"] = f"function coverage analysis error: {exc}" + functions = [] + + uncovered = [ + { + "name": fn.name, + "line": fn.start_line, + } + for fn in functions + if not fn.covered + ] + module_result["uncovered_functions"] = uncovered + + status = "PASS" if module_result["meets_threshold"] else "FAIL" + print(f"- {status} {module_path}: {percent:.2f}% (threshold {threshold:.2f}%)") + if uncovered: + preview = ", ".join( + f"{item['name']}@L{item['line']}" for item in uncovered[:8] + ) + suffix = " ..." if len(uncovered) > 8 else "" + print(f" uncovered functions: {preview}{suffix}") + + if not module_result["meets_threshold"]: + failing_modules.append(module_path) + result_payload[module_path] = module_result + + output = { + "status": "passed" if not failing_modules else "failed", + "failing_modules": failing_modules, + "modules": result_payload, + "coverage_json": str(args.coverage_json.resolve()), + } + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(output, indent=2), encoding="utf-8") + + if failing_modules: + print("\nCoverage gate failed for:") + for module in failing_modules: + print(f"- {module}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/installer_smoke_unix.sh b/scripts/installer_smoke_unix.sh new file mode 100644 index 00000000..4cc16ff9 --- /dev/null +++ b/scripts/installer_smoke_unix.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +BRANCH="${1:-beta}" +MODE="${2:-tui}" + +case "$MODE" in + tui|gui|both) ;; + *) + echo "[ERROR] MODE must be one of: tui, gui, both" >&2 + exit 2 + ;; +esac + +if ! command -v git >/dev/null 2>&1; then + echo "[ERROR] git is required for installer smoke test" >&2 + exit 2 +fi + +TMP_HOME="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_HOME" +} +trap cleanup EXIT + +export HOME="$TMP_HOME" +unset DISPLAY || true +unset WAYLAND_DISPLAY || true + +echo "[INFO] Smoke install start: branch=$BRANCH mode=$MODE home=$HOME" + +bash scripts/install.sh -b "$BRANCH" -m "$MODE" + +LAUNCHER="$HOME/.local/bin/seedpass" +APP_DIR="$HOME/.seedpass/app" +if [[ ! -x "$LAUNCHER" ]]; then + echo "[ERROR] launcher missing: $LAUNCHER" >&2 + exit 1 +fi +if [[ ! -d "$APP_DIR/.git" ]]; then + echo "[ERROR] install dir missing git checkout: $APP_DIR" >&2 + exit 1 +fi + +actual_branch="$(git -C "$APP_DIR" rev-parse --abbrev-ref HEAD)" +if [[ "$actual_branch" != "$BRANCH" ]]; then + echo "[ERROR] expected branch '$BRANCH', got '$actual_branch'" >&2 + exit 1 +fi + +"$LAUNCHER" --help >/tmp/seedpass-installer-smoke-help.txt + +echo "[INFO] Re-running installer to validate idempotence" +bash scripts/install.sh -b "$BRANCH" -m "$MODE" +"$LAUNCHER" --help >/tmp/seedpass-installer-smoke-help-2.txt + +echo "[SUCCESS] Installer smoke test passed (branch=$BRANCH mode=$MODE)" diff --git a/scripts/installer_smoke_windows.ps1 b/scripts/installer_smoke_windows.ps1 new file mode 100644 index 00000000..43df33bd --- /dev/null +++ b/scripts/installer_smoke_windows.ps1 @@ -0,0 +1,58 @@ +param( + [string]$Branch = "beta", + [ValidateSet("tui", "gui", "both")] + [string]$Mode = "tui" +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + throw "git is required for installer smoke test" +} + +$TempHome = Join-Path $env:RUNNER_TEMP ("seedpass-installer-home-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $TempHome -Force | Out-Null + +try { + $env:USERPROFILE = $TempHome + $env:HOME = $TempHome + + Write-Host "[INFO] Smoke install start: branch=$Branch mode=$Mode home=$TempHome" + + if ($Mode -eq "tui") { + & "$PSScriptRoot\install.ps1" -Branch $Branch + } else { + & "$PSScriptRoot\install.ps1" -Branch $Branch -IncludeGui + } + + $LauncherPath = Join-Path $env:USERPROFILE ".seedpass\app\bin\seedpass.cmd" + $AppDir = Join-Path $env:USERPROFILE ".seedpass\app" + if (-not (Test-Path $LauncherPath)) { + throw "launcher missing: $LauncherPath" + } + if (-not (Test-Path (Join-Path $AppDir ".git"))) { + throw "install dir missing git checkout: $AppDir" + } + + $ActualBranch = (git -C $AppDir rev-parse --abbrev-ref HEAD).Trim() + if ($ActualBranch -ne $Branch) { + throw "expected branch '$Branch', got '$ActualBranch'" + } + + cmd /c "`"$LauncherPath`" --help" | Out-Null + + Write-Host "[INFO] Re-running installer to validate idempotence" + if ($Mode -eq "tui") { + & "$PSScriptRoot\install.ps1" -Branch $Branch + } else { + & "$PSScriptRoot\install.ps1" -Branch $Branch -IncludeGui + } + cmd /c "`"$LauncherPath`" --help" | Out-Null + + Write-Host "[SUCCESS] Installer smoke test passed (branch=$Branch mode=$Mode)" +} +finally { + if (Test-Path $TempHome) { + Remove-Item -Recurse -Force $TempHome + } +} diff --git a/scripts/run_ci_tests.sh b/scripts/run_ci_tests.sh index 661aa47b..6441cbc9 100755 --- a/scripts/run_ci_tests.sh +++ b/scripts/run_ci_tests.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -eo pipefail +mkdir -p artifacts/coverage + pytest_args=(-vv) if [[ -n "${STRESS_ARGS:-}" ]]; then pytest_args+=(${STRESS_ARGS}) @@ -8,7 +10,14 @@ fi if [[ "${RUNNER_OS:-}" == "Windows" ]]; then pytest_args+=(-n 1) fi -pytest_args+=(--cov=src --cov-report=xml --cov-report=term-missing --cov-fail-under=20 src/tests) +pytest_args+=( + --cov=src + --cov-report=xml + --cov-report=term-missing + --cov-report=json:artifacts/coverage/coverage.json + --cov-fail-under=20 + src/tests +) timeout_bin="timeout" if ! command -v "$timeout_bin" >/dev/null 2>&1; then @@ -33,4 +42,7 @@ if [[ $status -eq 124 ]]; then tail -n 20 pytest.log exit 1 fi +if [[ $status -eq 0 && "${CRITICAL_COVERAGE_GATE:-0}" == "1" ]]; then + python scripts/check_critical_coverage.py artifacts/coverage/coverage.json +fi exit $status diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 3babfcd0..1e6d66ab 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -37,7 +37,7 @@ deterministic_totp_option = typer.Option( ) # Sub command groups -from . import entry, vault, nostr, config, fingerprint, util, api +from . import entry, vault, nostr, config, fingerprint, util, api, agent app.add_typer(entry.app, name="entry") app.add_typer(vault.app, name="vault") @@ -46,6 +46,7 @@ app.add_typer(config.app, name="config") app.add_typer(fingerprint.app, name="fingerprint") app.add_typer(util.app, name="util") app.add_typer(api.app, name="api") +app.add_typer(agent.app, name="agent") def run() -> None: diff --git a/src/seedpass/cli/agent.py b/src/seedpass/cli/agent.py new file mode 100644 index 00000000..403b5d6c --- /dev/null +++ b/src/seedpass/cli/agent.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Optional + +import bcrypt +import click +import typer +from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39WordsNum +from mnemonic import Mnemonic + +from constants import APP_DIR, initialize_app +from seedpass.core.api import EntryService +from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType +from seedpass.core.encryption import EncryptionManager +from seedpass.core.manager import PasswordManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from utils.fingerprint import generate_fingerprint +from utils.fingerprint_manager import FingerprintManager +from utils.key_derivation import derive_index_key, derive_key_from_password + +app = typer.Typer(help="Agent-first non-interactive workflows") + +PRIVATE_KINDS = { + EntryType.SEED.value, + EntryType.SSH.value, + EntryType.PGP.value, + EntryType.NOSTR.value, + EntryType.MANAGED_ACCOUNT.value, +} +DEFAULT_POLICY = { + "allow_kinds": [ + EntryType.PASSWORD.value, + EntryType.TOTP.value, + EntryType.KEY_VALUE.value, + ], + "deny_private_reveal": sorted(PRIVATE_KINDS), + "allow_export_import": False, +} + + +def _policy_path() -> Path: + return APP_DIR / "agent_policy.json" + + +def _load_policy() -> dict: + path = _policy_path() + if not path.exists(): + return dict(DEFAULT_POLICY) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return dict(DEFAULT_POLICY) + policy = dict(DEFAULT_POLICY) + if isinstance(data.get("allow_kinds"), list): + policy["allow_kinds"] = [str(v) for v in data["allow_kinds"]] + if isinstance(data.get("deny_private_reveal"), list): + policy["deny_private_reveal"] = [str(v) for v in data["deny_private_reveal"]] + if isinstance(data.get("allow_export_import"), bool): + policy["allow_export_import"] = data["allow_export_import"] + return policy + + +def _save_policy(policy: dict) -> None: + path = _policy_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(policy, indent=2), encoding="utf-8") + os.chmod(path, 0o600) + + +def _password_from_env(var_name: str) -> str: + value = os.getenv(var_name) + if not value: + raise typer.BadParameter( + f"Missing password env var '{var_name}'. Export it before running this command." + ) + return value + + +def _seed_from_inputs( + seed: Optional[str], + seed_file: Optional[Path], + generate_seed: bool, +) -> tuple[str, bool]: + choices = int(bool(seed)) + int(bool(seed_file)) + int(bool(generate_seed)) + if choices != 1: + raise typer.BadParameter( + "Provide exactly one of --seed, --seed-file, or --generate-seed." + ) + if seed_file: + seed = seed_file.read_text(encoding="utf-8").strip() + elif generate_seed: + seed = ( + Bip39MnemonicGenerator(Bip39Languages.ENGLISH) + .FromWordsNumber(Bip39WordsNum.WORDS_NUM_12) + .ToStr() + ) + assert seed is not None + if not Mnemonic("english").check(seed): + raise typer.BadParameter("Invalid BIP-39 seed phrase.") + return seed, bool(generate_seed) + + +@app.command("init") +def agent_init( + seed: Optional[str] = typer.Option( + None, "--seed", help="BIP-39 seed phrase (avoid shell history for secrets)" + ), + seed_file: Optional[Path] = typer.Option( + None, "--seed-file", help="Path to a file containing the seed phrase" + ), + generate_seed: bool = typer.Option( + False, "--generate-seed", help="Generate a fresh 12-word seed" + ), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + switch_existing: bool = typer.Option( + False, + "--switch-existing", + help="Switch to existing profile if the seed fingerprint already exists", + ), + kdf_iterations: int = typer.Option( + 100_000, "--kdf-iterations", help="PBKDF2 iterations for profile setup" + ), + print_seed: bool = typer.Option( + False, "--print-seed", help="Include generated seed in JSON output" + ), +) -> None: + """Initialize a profile non-interactively for agent workflows.""" + initialize_app() + password = _password_from_env(password_env) + seed_phrase, generated = _seed_from_inputs(seed, seed_file, generate_seed) + + fp_mgr = FingerprintManager(APP_DIR) + fingerprint = None + created = False + try: + fingerprint = fp_mgr.add_fingerprint(seed_phrase) + created = True + except ValueError: + fingerprint = generate_fingerprint(seed_phrase) + if not switch_existing: + raise typer.BadParameter( + "Seed profile already exists. Use --switch-existing to reuse it." + ) + + assert fingerprint is not None + profile_dir = fp_mgr.get_fingerprint_directory(fingerprint) + if profile_dir is None: + raise typer.BadParameter("Could not resolve profile directory for fingerprint.") + profile_dir.mkdir(parents=True, exist_ok=True) + + seed_key = derive_key_from_password( + password, fingerprint, iterations=kdf_iterations + ) + seed_mgr = EncryptionManager(seed_key, profile_dir) + seed_mgr.encrypt_parent_seed(seed_phrase) + + index_key = derive_index_key(seed_phrase) + enc_mgr = EncryptionManager(index_key, profile_dir) + vault = Vault(enc_mgr, profile_dir) + cfg = ConfigManager(vault=vault, fingerprint_dir=profile_dir) + cfg.set_password_hash(bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()) + cfg.set_kdf_iterations(kdf_iterations) + cfg.set_offline_mode(True) + cfg.set_secret_mode_enabled(False) + cfg.set_quick_unlock(False) + + if not _policy_path().exists(): + _save_policy(dict(DEFAULT_POLICY)) + + payload = { + "status": "ok", + "created": created, + "fingerprint": fingerprint, + "profile_dir": str(profile_dir), + "generated_seed": seed_phrase if generated and print_seed else None, + } + typer.echo(json.dumps(payload, indent=2)) + + +@app.command("policy-show") +def agent_policy_show() -> None: + """Show the active agent policy.""" + typer.echo(json.dumps(_load_policy(), indent=2)) + + +@app.command("policy-set") +def agent_policy_set( + allow_kind: list[str] = typer.Option( + None, + "--allow-kind", + help="Allowed entry kind for agent retrieval (repeatable)", + click_type=click.Choice(ALL_ENTRY_TYPES), + ), + deny_private_kind: list[str] = typer.Option( + None, + "--deny-private-kind", + help="Kind denied from agent reveal (repeatable)", + click_type=click.Choice(ALL_ENTRY_TYPES), + ), + allow_export_import: bool = typer.Option( + False, + "--allow-export-import", + help="Allow export/import operations in agent mode", + ), +) -> None: + """Set policy flags used by agent commands.""" + policy = _load_policy() + if allow_kind: + policy["allow_kinds"] = list(allow_kind) + if deny_private_kind: + policy["deny_private_reveal"] = list(deny_private_kind) + policy["allow_export_import"] = bool(allow_export_import) + _save_policy(policy) + typer.echo(json.dumps(policy, indent=2)) + + +@app.command("get") +def agent_get( + ctx: typer.Context, + query: str = typer.Argument(..., help="Entry label or index query"), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + ttl: int = typer.Option( + 30, + "--ttl", + min=1, + help="Lease time in seconds for the returned secret metadata", + ), +) -> None: + """Retrieve one secret as JSON with policy enforcement.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + password = _password_from_env(password_env) + policy = _load_policy() + + pm = PasswordManager(fingerprint=fingerprint, password=password) + service = EntryService(pm) + matches = service.search_entries(query) + if len(matches) != 1: + payload = { + "status": "error", + "reason": "ambiguous_or_missing", + "match_count": len(matches), + "matches": [ + { + "index": idx, + "label": label, + "kind": etype.value, + "username": username, + "url": url, + "archived": archived, + } + for idx, label, username, url, archived, etype in matches + ], + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + index = matches[0][0] + entry = service.retrieve_entry(index) + kind = entry.get("type", entry.get("kind")) + if kind is None: + raise typer.BadParameter("Entry kind missing.") + + allow_kinds = set(policy.get("allow_kinds", [])) + deny_private = set(policy.get("deny_private_reveal", [])) + if allow_kinds and kind not in allow_kinds: + typer.echo( + json.dumps( + { + "status": "denied", + "reason": "kind_not_allowed", + "kind": kind, + "policy": policy, + }, + indent=2, + ) + ) + raise typer.Exit(1) + if kind in deny_private: + typer.echo( + json.dumps( + { + "status": "denied", + "reason": "private_kind_blocked", + "kind": kind, + "policy": policy, + }, + indent=2, + ) + ) + raise typer.Exit(1) + + secret: Optional[str] = None + if kind == EntryType.PASSWORD.value: + secret = service.generate_password(int(entry.get("length", 12)), index) + elif kind == EntryType.TOTP.value: + secret = service.get_totp_code(index) + elif kind == EntryType.KEY_VALUE.value: + secret = entry.get("value") + else: + typer.echo( + json.dumps( + { + "status": "error", + "reason": "unsupported_kind_for_agent_get", + "kind": kind, + }, + indent=2, + ) + ) + raise typer.Exit(1) + + now = datetime.now(UTC) + payload = { + "status": "ok", + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": entry.get("label"), + "lease_ttl_sec": ttl, + "issued_at_utc": now.isoformat(), + "expires_at_utc": (now + timedelta(seconds=ttl)).isoformat(), + "secret": secret, + } + typer.echo(json.dumps(payload, indent=2)) diff --git a/src/tests/test_ai_tui_agent_harness.py b/src/tests/test_ai_tui_agent_harness.py new file mode 100644 index 00000000..ed228a38 --- /dev/null +++ b/src/tests/test_ai_tui_agent_harness.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + +import pytest + + +def _load_harness_module(): + repo_root = Path(__file__).resolve().parents[2] + script_path = repo_root / "scripts" / "ai_tui_agent_test.py" + spec = importlib.util.spec_from_file_location("ai_tui_agent_test", script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +harness = _load_harness_module() + + +def test_run_step_records_passed_result(): + results = [] + called = {"value": False} + + def _ok(): + called["value"] = True + + harness._run_step("step_a", "happy path", _ok, results, verbose=False) + + assert called["value"] is True + assert len(results) == 1 + assert results[0].step_id == "step_a" + assert results[0].status == "passed" + assert results[0].detail is None + + +def test_run_step_records_failed_result_and_reraises(): + results = [] + + def _boom(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + harness._run_step("step_b", "failing path", _boom, results, verbose=False) + + assert len(results) == 1 + assert results[0].step_id == "step_b" + assert results[0].status == "failed" + assert results[0].detail == "boom" + + +def test_add_entry_menu_open_waits_when_choice_omitted(): + class Runner: + def __init__(self): + self.sent = [] + self.waited = [] + + def sendline(self, value=""): + self.sent.append(value) + + def wait_for(self, pattern, timeout): + self.waited.append((pattern, timeout)) + + runner = Runner() + harness._add_entry_menu_open(runner, timeout=5.0, choice=None) + assert runner.sent == ["1"] + assert len(runner.waited) == 1 + assert "Select entry type or press Enter to go back:" in runner.waited[0][0] + + +def test_add_entry_menu_open_sends_choice_when_provided(): + class Runner: + def __init__(self): + self.sent = [] + self.expect_calls = [] + + def sendline(self, value=""): + self.sent.append(value) + + def expect_and_send(self, pattern, value, timeout): + self.expect_calls.append((pattern, value, timeout)) + + runner = Runner() + harness._add_entry_menu_open(runner, timeout=5.0, choice="7") + assert runner.sent == ["1"] + assert runner.expect_calls == [ + ("Select entry type or press Enter to go back:", "7", 5.0) + ] + + +def test_complete_onboarding_reaches_main_menu_and_sends_password(): + class Runner: + def __init__(self): + self.calls = [ + (r"Enter your choice \(1-8\) or press Enter to exit:", False), + (r"Enter a new password:", True), + (r"Enter your choice \(1-8\) or press Enter to exit:", False), + (r"Enter a new password:", False), + (r"Confirm your password:", True), + (r"Enter your choice \(1-8\) or press Enter to exit:", True), + ] + self.sent = [] + + def try_wait_for(self, pattern, timeout): + expected_pattern, ret = self.calls.pop(0) + assert pattern == expected_pattern + return ret + + def sendline(self, value=""): + self.sent.append(value) + + def pump(self, duration): + return None + + runner = Runner() + harness._complete_onboarding_to_main_menu(runner, timeout=1.0, password="pw123") + assert runner.sent == ["pw123", "pw123"] + assert runner.calls == [] + + +def test_complete_onboarding_times_out_without_expected_prompts(): + class Runner: + def try_wait_for(self, pattern, timeout): + return False + + def sendline(self, value=""): + return None + + def pump(self, duration): + return None + + runner = Runner() + with pytest.raises(harness.TUIHarnessError, match="Onboarding did not reach main"): + harness._complete_onboarding_to_main_menu(runner, timeout=0.0, password="pw") + + +def test_drain_to_main_menu_uses_helper_prompt_then_returns(): + class Runner: + def __init__(self): + self.main_attempts = 0 + self.helper_calls = [] + + def wait_for(self, pattern, timeout): + self.main_attempts += 1 + if self.main_attempts == 1: + raise harness.TUIHarnessError("not yet") + return None + + def expect_and_send(self, pattern, value, timeout): + self.helper_calls.append((pattern, value, timeout)) + return None + + def pump(self, duration): + return None + + runner = Runner() + harness._drain_to_main_menu(runner, timeout=1.0) + assert runner.main_attempts == 2 + assert len(runner.helper_calls) == 1 + assert runner.helper_calls[0][1] == "" + + +def test_drain_to_main_menu_times_out_when_no_prompt_progress(monkeypatch): + class Runner: + def wait_for(self, pattern, timeout): + raise harness.TUIHarnessError("missing") + + def expect_and_send(self, pattern, value, timeout): + raise harness.TUIHarnessError("missing helper") + + def pump(self, duration): + return None + + timeline = iter([0.0, 0.05, 0.15]) + monkeypatch.setattr(harness.time, "monotonic", lambda: next(timeline)) + + with pytest.raises( + harness.TUIHarnessError, match="Unable to return to main menu within timeout" + ): + harness._drain_to_main_menu(Runner(), timeout=0.1) diff --git a/src/tests/test_background_vault_sync_paths.py b/src/tests/test_background_vault_sync_paths.py new file mode 100644 index 00000000..40464fd7 --- /dev/null +++ b/src/tests/test_background_vault_sync_paths.py @@ -0,0 +1,58 @@ +import asyncio + +from seedpass.core.manager import PasswordManager +from seedpass.core import manager as manager_module + + +def test_start_background_vault_sync_thread_worker_path(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = False + + events = [] + + async def fake_sync_vault_async(*, alt_summary=None): + return {"alt_summary": alt_summary} + + monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async) + monkeypatch.setattr( + manager_module.bus, "publish", lambda *args: events.append(args) + ) + + class ImmediateThread: + def __init__(self, target, daemon): + self._target = target + self.daemon = daemon + + def start(self): + self._target() + + monkeypatch.setattr(manager_module.threading, "Thread", ImmediateThread) + + pm.start_background_vault_sync("thread-path") + + assert ("sync_started",) in events + assert ("sync_finished", {"alt_summary": "thread-path"}) in events + + +def test_start_background_vault_sync_async_worker_path(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = False + + events = [] + + async def fake_sync_vault_async(*, alt_summary=None): + return {"alt_summary": alt_summary} + + monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async) + monkeypatch.setattr( + manager_module.bus, "publish", lambda *args: events.append(args) + ) + + async def runner(): + pm.start_background_vault_sync("async-path") + await asyncio.sleep(0.02) + + asyncio.run(runner()) + + assert ("sync_started",) in events + assert ("sync_finished", {"alt_summary": "async-path"}) in events diff --git a/src/tests/test_check_critical_coverage.py b/src/tests/test_check_critical_coverage.py new file mode 100644 index 00000000..32428e8e --- /dev/null +++ b/src/tests/test_check_critical_coverage.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def _load_module(): + repo_root = Path(__file__).resolve().parents[2] + script_path = repo_root / "scripts" / "check_critical_coverage.py" + spec = importlib.util.spec_from_file_location( + "check_critical_coverage", script_path + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +coverage_gate = _load_module() + + +def test_parse_threshold_overrides_valid(): + parsed = coverage_gate._parse_threshold_overrides( + ["src/main.py=40", "src/seedpass/core/manager.py=55.5"] + ) + assert parsed["src/main.py"] == 40.0 + assert parsed["src/seedpass/core/manager.py"] == 55.5 + + +@pytest.mark.parametrize( + "value", + [ + "src/main.py", + "src/main.py=abc", + "src/main.py=-1", + "src/main.py=101", + "=50", + ], +) +def test_parse_threshold_overrides_invalid(value): + with pytest.raises(ValueError): + coverage_gate._parse_threshold_overrides([value]) + + +def test_find_file_payload_accepts_absolute_path_suffix(): + files_payload = { + "/tmp/work/src/main.py": {"summary": {"percent_covered": 50.0}}, + "/tmp/work/src/seedpass/core/manager.py": { + "summary": {"percent_covered": 60.0} + }, + } + payload = coverage_gate._find_file_payload(files_payload, "src/main.py") + assert payload == {"summary": {"percent_covered": 50.0}} + + +def test_compute_function_coverage_reports_uncovered(tmp_path): + module_file = tmp_path / "example.py" + module_file.write_text( + "\n".join( + [ + "def covered():", + " return 1", + "", + "def uncovered():", + " return 2", + "", + "class A:", + " def method(self):", + " return 3", + ] + ), + encoding="utf-8", + ) + coverage = coverage_gate._compute_function_coverage( + module_file, executed_lines={1, 2} + ) + by_name = {item.name: item for item in coverage} + assert by_name["covered"].covered is True + assert by_name["uncovered"].covered is False + assert by_name["A.method"].covered is False diff --git a/src/tests/test_cli_agent_mode.py b/src/tests/test_cli_agent_mode.py new file mode 100644 index 00000000..30d8448d --- /dev/null +++ b/src/tests/test_cli_agent_mode.py @@ -0,0 +1,149 @@ +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +from typer.testing import CliRunner + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from seedpass.cli import app +from seedpass.cli import agent as agent_cli +from seedpass.core.entry_types import EntryType + +runner = CliRunner() + + +def test_agent_policy_show_defaults(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke(app, ["agent", "policy-show"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["allow_export_import"] is False + assert EntryType.PASSWORD.value in payload["allow_kinds"] + + +def test_agent_policy_set_persists(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke( + app, + [ + "agent", + "policy-set", + "--allow-kind", + "password", + "--allow-kind", + "totp", + "--deny-private-kind", + "seed", + "--allow-export-import", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["allow_kinds"] == ["password", "totp"] + assert payload["deny_private_reveal"] == ["seed"] + assert payload["allow_export_import"] is True + + +def test_agent_init_creates_profile(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "AgentPass123!") + result = runner.invoke( + app, + [ + "agent", + "init", + "--seed", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["fingerprint"] + profile_dir = Path(payload["profile_dir"]) + assert profile_dir.exists() + assert (profile_dir / "parent_seed.enc").exists() + + +def test_agent_get_requires_fingerprint(monkeypatch): + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + result = runner.invoke(app, ["agent", "get", "example"]) + assert result.exit_code == 2 + + +def test_agent_get_password_json(monkeypatch): + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [(2, "Example", "", "", False, EntryType.PASSWORD)] + + def retrieve_entry(self, _i): + return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} + + def generate_password(self, _length, _index): + return "secret-pass" + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda: {"allow_kinds": ["password"], "deny_private_reveal": []}, + ) + + result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["fingerprint"] == "ABC123" + assert payload["secret"] == "secret-pass" + assert payload["kind"] == "password" + + +def test_agent_get_denied_by_policy(monkeypatch): + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [(4, "Seed", "", "", False, EntryType.SEED)] + + def retrieve_entry(self, _i): + return {"kind": EntryType.SEED.value, "label": "Seed"} + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda: { + "allow_kinds": ["password", "totp", "key_value"], + "deny_private_reveal": ["seed"], + }, + ) + + result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "seed"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "denied" + assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}