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 <fingerprint> agent get <query> --ttl <sec>` 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.
This commit is contained in:
thePR0M3TH3AN committed 2026-02-23 15:47:59 -05:00
1 parent dd7290e83b
commit 03958106e1
16 files changed
+1826 -54

No files matched your search

+125
View File
@@ -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
+1
View File
@@ -1,5 +1,6 @@
# Ignore virtual environment directory
venv/
.venv/
# Ignore context tool directory
context_tool/
+38
View File
@@ -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 <fingerprint> 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:**
+34 -4
View File
@@ -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/<timestamp>/`
## 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:
+44
View File
@@ -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.
+375 -48
View File
@@ -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),
}
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
Enforce coverage thresholds for critical SeedPass modules.
Reads coverage.py JSON output (pytest --cov-report=json:<path>) 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())
+58
View File
@@ -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)"
+58
View File
@@ -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
}
}
+13 -1
View File
@@ -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
+2 -1
View File
@@ -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:
+335
View File
@@ -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))
+183
View File
@@ -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)
@@ -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
+84
View File
@@ -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
+149
View File
@@ -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"}