From 68cb1169e331932071165e9a5aa779eec297b610 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:02:11 -0500 Subject: [PATCH] Add agent autonomy controls, docs updates, and landing page agent section --- README.md | 25 + docs/README.md | 13 + docs/SPEC.md | 19 + docs/agent_autonomy_security_plan.md | 6 + docs/nostr_setup.md | 13 + docs/policy_as_code.md | 78 + docs/security.md | 21 + docs/security_readiness_checklist.md | 26 + docs/sync_conflict_contract.md | 83 + landing/docs.html | 4 + landing/index.html | 48 + landing/style.css | 14 + memory-update.md | 472 +++ src/seedpass/api.py | 929 +++++- src/seedpass/cli/__init__.py | 8 +- src/seedpass/cli/agent.py | 2721 ++++++++++++++++- src/seedpass/cli/capabilities.py | 168 +- src/seedpass/cli/entry.py | 28 +- src/seedpass/cli/vault.py | 185 +- src/seedpass/core/agent_approval.py | 140 + src/seedpass/core/agent_export_policy.py | 354 ++- src/seedpass/core/agent_identity.py | 131 + src/seedpass/core/agent_job.py | 129 + src/seedpass/core/agent_recovery.py | 291 ++ src/seedpass/core/agent_secret_isolation.py | 267 ++ src/seedpass/core/agent_secret_lease.py | 138 + src/seedpass/core/config_manager.py | 67 + src/seedpass/core/encryption.py | 15 +- src/seedpass/core/entry_management.py | 35 + .../core/high_risk_partition_store.py | 115 + src/seedpass/core/sync_conflict.py | 320 ++ src/tests/test_api_new_endpoints.py | 766 ++++- src/tests/test_cli_agent_mode.py | 1507 ++++++++- src/tests/test_delta_merge.py | 426 +++ src/tests/test_typer_cli.py | 293 +- 35 files changed, 9777 insertions(+), 78 deletions(-) create mode 100644 docs/policy_as_code.md create mode 100644 docs/sync_conflict_contract.md create mode 100644 src/seedpass/core/agent_approval.py create mode 100644 src/seedpass/core/agent_identity.py create mode 100644 src/seedpass/core/agent_job.py create mode 100644 src/seedpass/core/agent_recovery.py create mode 100644 src/seedpass/core/agent_secret_isolation.py create mode 100644 src/seedpass/core/agent_secret_lease.py create mode 100644 src/seedpass/core/high_risk_partition_store.py create mode 100644 src/seedpass/core/sync_conflict.py diff --git a/README.md b/README.md index 439d47c4..deb58a15 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No ## Table of Contents - [Features](#features) +- [Agent and Automation Security Features](#agent-and-automation-security-features) - [Architecture Overview](#architecture-overview) - [Prerequisites](#prerequisites) - [Installation](#installation) @@ -83,6 +84,30 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No A small on-screen notification area now shows queued messages for 10 seconds before fading. +## Agent and Automation Security Features + +SeedPass includes an agent-focused CLI surface for non-interactive automation +with policy and security guardrails: + +- **Auth brokers (no plaintext env required):** `env`, `keyring`, `command`, `prompt` providers for `agent init/get`, `vault unlock`, and `api start --unlock`. +- **Policy as code:** `agent policy-lint`, `agent policy-review`, and `agent policy-apply` for validated, diffable, risk-gated policy updates. +- **Scoped tokens + identities:** `agent token-issue/list/revoke` and `agent identity-create/list/revoke` with TTL, use limits, and revocation. +- **One-time/N-use secret leases:** `agent get --lease-only`, `agent lease-consume`, `agent lease-list`, `agent lease-revoke`. +- **Approval gates for risky actions:** `agent approval-issue/list/revoke` gates export, parent-seed reveal, and sensitive private-key retrieval flows. +- **Secret-class isolation:** dedicated high-risk unlock factor and lock state via `agent high-risk-factor-set`, `agent high-risk-unlock/status/lock`. +- **Safer automation primitives:** `agent job-run`, `agent job-template`, and job profiles with policy-stamp checks and signed template manifests. +- **Recovery hardening:** `agent recovery-split/recover/drill/drill-list` for Shamir split/recover and signed drill evidence. +- **Deterministic export controls:** policy-filtered exports plus manifest integrity checks with `agent export-check` and `agent export-manifest-verify`. +- **Security posture tooling:** `agent posture-check` and `agent posture-remediate` for drift detection and remediation bundles. +- **Auditable access chain:** append-only HMAC-chained audit records with verification support. + +For machine-readable discovery, run: + +```bash +seedpass capabilities --format json +seedpass agent bootstrap-context +``` + ## Architecture Overview SeedPass follows a layered design. The **`seedpass.core`** package exposes the diff --git a/docs/README.md b/docs/README.md index 5c14ad51..cf7377bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,12 @@ This directory contains SeedPass project documentation. +## Agent and Autonomy Docs + +- `agent_autonomy_security_plan.md` +- `policy_as_code.md` +- `sync_conflict_contract.md` + ## Core Security Docs - `security_readiness_checklist.md` @@ -26,3 +32,10 @@ This directory contains SeedPass project documentation. - `installer_testing.md` - `ci-health-2026-02-19.md` - `agent-handoffs/README.md` + +## Discoverability Commands + +- `seedpass --help` +- `seedpass --help` +- `seedpass capabilities --format json` +- `seedpass agent bootstrap-context` diff --git a/docs/SPEC.md b/docs/SPEC.md index e703527a..c6be6ca3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -30,6 +30,25 @@ SeedPass synchronizes profiles over Nostr using three event kinds: Events encode JSON and include tags for checksums, fingerprints, and timestamps. +## Deterministic Conflict Resolution + +When index payloads are merged (for example during Nostr delta replay), SeedPass +uses deterministic conflict semantics with strategy +`modified_ts_hash_tombstone_v2`. + +High-level rules: + +- Higher `modified_ts` entries win. +- Equal `modified_ts` entries use deterministic hash tie-breakers and + field-level union rules for selected metadata. +- Deletions are represented as tombstones in `_sync_meta.tombstones` and win + against older entry states. +- `_sync_meta.last_merge_ts` is derived from payload state, not wall-clock + time, so replaying the same payload is idempotent. + +See [sync_conflict_contract.md](sync_conflict_contract.md) for the formal +merge/tombstone contract and replay guarantees. + ## Versioning Configuration and KDF schemas are versioned so clients can migrate older diff --git a/docs/agent_autonomy_security_plan.md b/docs/agent_autonomy_security_plan.md index b8946fb2..db36cd79 100644 --- a/docs/agent_autonomy_security_plan.md +++ b/docs/agent_autonomy_security_plan.md @@ -11,6 +11,12 @@ Implement a staged security architecture that enables autonomous agents to use S - Phase C: Operations Add posture tooling, rotation workflows, recovery hardening, and conflict-safe sync semantics. +## Implementation Snapshot (2026-03-01) + +- Implemented: 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 +- In Progress: 2 (continuing enforcement-depth hardening and broad validation across all operation paths) +- Tracking source of truth: `docs/security_readiness_checklist.md` (Agent Autonomy Control Track section) + ## 1. Native Non-Interactive Auth Brokers - Problem: Agent auth depends heavily on env vars and manual password flow. diff --git a/docs/nostr_setup.md b/docs/nostr_setup.md index 0c738e22..095ace1c 100644 --- a/docs/nostr_setup.md +++ b/docs/nostr_setup.md @@ -24,6 +24,19 @@ Backups are published as parameterised replaceable events: When restoring, SeedPass downloads the most recent manifest and applies any newer delta events. +## Deterministic Merge and Replay Behavior + +Delta application uses deterministic conflict resolution so multiple devices +converge on the same state even when updates arrive out of order: + +- newer `modified_ts` wins +- equal timestamp conflicts use deterministic hash tie-breaks +- deletions are tracked via tombstones in `_sync_meta.tombstones` +- stale payload replay does not override newer state + +For the detailed merge contract, see +[sync_conflict_contract.md](sync_conflict_contract.md). + ## Troubleshooting - **No events found:** ensure the relays are reachable and that the correct fingerprint is selected. diff --git a/docs/policy_as_code.md b/docs/policy_as_code.md new file mode 100644 index 00000000..ba8669e8 --- /dev/null +++ b/docs/policy_as_code.md @@ -0,0 +1,78 @@ +# Policy As Code + +SeedPass agent policy is stored as versioned JSON (`version: 1`) at: + +- `~/.seedpass/agent_policy.json` + +The policy file is treated as code: linted, reviewed, and then applied. + +## Workflow + +1. Create or edit a candidate policy file in source control. +2. Lint and normalize it: + +```bash +seedpass agent policy-lint --file ./policy/agent_policy.json +``` + +3. Review differences against active policy: + +```bash +seedpass agent policy-review --file ./policy/agent_policy.json +``` + +4. Apply with risk gate: + +```bash +seedpass agent policy-apply --file ./policy/agent_policy.json +``` + +If review finds high-risk posture changes (for example `default_effect=allow`, +unsafe output defaults, or broad export enablement), apply is blocked unless +`--allow-risky` is supplied. + +## Deterministic Metadata + +`policy-lint`, `policy-review`, and `policy-apply` emit: + +- `policy_stamp`: export-relevant deterministic stamp. +- `policy_hash`: full normalized-policy SHA-256 hash. + +These values make policy changes auditable in CI and change reviews. + +Policy-filtered export packages (`mode: policy_filtered`) include deterministic +manifest integrity metadata: + +- `included_entry_indexes` +- `included_entry_count` +- `entries_sha256` (canonical hash of filtered entries) + +`agent export-manifest-verify` validates these fields against current policy and +detects tampering (for example changed entry kinds or modified entry payloads). + +## Remediation Bundles + +You can generate an actionable remediation bundle from current posture findings: + +```bash +seedpass agent posture-remediate +``` + +For profile runtime checks (quick unlock, KDF strength, partition unlock drift), +run with broker auth and fingerprint: + +```bash +seedpass --fingerprint agent posture-remediate \ + --check-runtime-config --auth-broker keyring +``` + +## Legacy Compatibility + +Legacy policy keys are still accepted and normalized to the rule model: + +- `allow_kinds` +- `deny_private_reveal` +- `allow_export_import` + +Normalization keeps backwards compatibility while preserving strict validation +for versioned policy files. diff --git a/docs/security.md b/docs/security.md index 45a06471..063d5956 100644 --- a/docs/security.md +++ b/docs/security.md @@ -33,3 +33,24 @@ The selected `time_cost` is stored in the profile's configuration and used for s - Production readiness checklist: `docs/security_readiness_checklist.md` - Threat model draft: `docs/threat_model.md` +- Agent autonomy plan: `docs/agent_autonomy_security_plan.md` +- Policy as code workflows: `docs/policy_as_code.md` +- Sync conflict determinism contract: `docs/sync_conflict_contract.md` + +## Agent Security Controls (Current) + +SeedPass now includes dedicated controls for autonomous and CI usage: + +- Non-interactive auth brokers (`env`, `keyring`, `command`, `prompt`). +- Fine-grained policy controls with lint/review/apply flow. +- Scoped, revocable tokens and agent identity binding. +- One-time/N-use secret leases. +- Policy-enforced redaction defaults in agent outputs. +- Approval gates for high-risk operations. +- Secret-class isolation for high-risk material. +- Safer job automation primitives with signed templates and policy stamps. +- Deterministic, policy-filtered export controls with manifest verification. +- Posture checks and remediation generation for drift detection. +- Chained audit log integrity verification. + +Use `seedpass capabilities --format json` for machine-readable feature discovery. diff --git a/docs/security_readiness_checklist.md b/docs/security_readiness_checklist.md index b226169c..cd73ac16 100644 --- a/docs/security_readiness_checklist.md +++ b/docs/security_readiness_checklist.md @@ -56,6 +56,32 @@ Status legend: `Not Started`, `In Progress`, `Blocked`, `Done` - Independent audit completed. - Critical findings resolved before broad production use. +## Agent Autonomy Control Track (15-Item Plan) + +Status legend: `Not Started`, `In Progress`, `Implemented`, `Validated` + +| # | Control | Status | Evidence | +|---|---------|--------|----------| +| 1 | Native non-interactive auth brokers | Implemented | `src/seedpass/core/auth_broker.py`, `src/seedpass/cli/agent.py` (`--auth-broker` flows) | +| 2 | Fine-grained RBAC per profile | In Progress | `agent_policy.json` rule model (`operations`, `kinds`, `label_regex`, `path_regex`, `fields`) in `src/seedpass/cli/agent.py` | +| 3 | Short-lived scoped tokens | Implemented | `agent token-issue/list/revoke`, token TTL/use constraints in `src/seedpass/cli/agent.py` | +| 4 | One-time secret leases | Implemented | `agent get --lease-only`, `agent lease-consume/list/revoke` | +| 5 | Policy-enforced redaction | Implemented | `safe_output_default` and redaction fields in policy + agent output paths | +| 6 | Auditable event log | Implemented | `~/.seedpass/agent_audit.log` chained records, `agent audit-verify` | +| 7 | Approval gates | Implemented | `agent approval-issue/list/revoke`; enforced for export/reveal high-risk actions | +| 8 | Secret-class isolation | Implemented | `agent high-risk-factor-set/unlock/status/lock`, partition migration/store modules | +| 9 | Agent identities | Implemented | `agent identity-create/list/revoke`; token identity binding | +| 10 | Safer automation primitives | Implemented | `agent job-run`, job profiles, signed cron/systemd/API templates | +| 11 | Recovery hardening | Implemented | `agent recovery-split/recover/drill/drill-list` | +| 12 | Conflict-safe sync primitives | Implemented | deterministic merge + tombstones in `src/seedpass/core/sync_conflict.py` | +| 13 | Policy as code | Implemented | `agent policy-lint/review/apply` + `docs/policy_as_code.md` | +| 14 | Deterministic export controls | Implemented | policy-filtered export manifests + `agent export-check/export-manifest-verify` | +| 15 | Security posture tooling | Implemented | `agent posture-check/posture-remediate` with runtime config checks | + +Notes: +- `Implemented` indicates feature availability in CLI/API paths and tests. +- `Validated` should be used after broader integration soak + external security review. + ## How To Use 1. Update each row with current status and owner. diff --git a/docs/sync_conflict_contract.md b/docs/sync_conflict_contract.md new file mode 100644 index 00000000..d83c170e --- /dev/null +++ b/docs/sync_conflict_contract.md @@ -0,0 +1,83 @@ +# Sync Conflict Contract + +This document defines the deterministic merge contract for index payloads handled by: + +- `seedpass.core.encryption.EncryptionManager.decrypt_and_save_index_from_nostr(..., merge=True)` +- `seedpass.core.sync_conflict.merge_index_payloads(...)` + +## Goals + +- Deterministic convergence across devices. +- Idempotent replay of identical payloads. +- Safe deletion semantics that prevent stale resurrection. + +## Merge Strategy + +Current strategy ID: + +- `modified_ts_hash_tombstone_v2` + +Per-entry merge rules: + +1. Higher `modified_ts` wins. +2. If `modified_ts` is equal, tie-break by canonical entry hash. +3. On equal timestamp, selected fields are union-merged deterministically: + - `tags` and `custom_fields` use canonical sorted union. + - Optional kind-specific fields are backfilled when one side is empty. +4. `archived` at equal timestamp uses conservative OR semantics. + +## Tombstone Semantics + +Deletions are represented in `_sync_meta.tombstones`: + +```json +{ + "_sync_meta": { + "tombstones": { + "": { + "deleted_ts": 1710000000, + "entry_hash": "", + "event_hash": "", + "source": "" + } + } + } +} +``` + +Rules: + +1. Tombstone with higher `deleted_ts` wins. +2. Equal `deleted_ts` tie-breaks by tombstone `event_hash`. +3. Entry vs tombstone: + - `entry.modified_ts < deleted_ts`: entry removed. + - `entry.modified_ts > deleted_ts`: tombstone dropped. + - equal timestamp: event hash tie-break. + +## `_sync_meta` Contract + +`_sync_meta` is merged deterministically and currently includes: + +- `strategy`: merge strategy identifier. +- `last_merge_ts`: deterministic max timestamp observed during merge. +- `source_count`: count of tracked source tags. +- `sources`: lexicographically sorted source tags (bounded list). +- `tombstones`: bounded map of delete markers. + +`last_merge_ts` must not depend on wall-clock time; it is derived from existing/incoming metadata and payload timestamps so replaying the same payload yields identical state. + +## Replay and Ordering Guarantees + +- Replaying the same payload must be idempotent. +- Applying stale payloads must not override newer state. +- Stale re-adds must not resurrect entries deleted by newer tombstones. +- Newer re-creates can legitimately supersede older tombstones. + +## Retention + +- Tombstones are retained in a bounded map (`TOMBSTONE_RETENTION_CAP`) to prevent unbounded metadata growth. +- The cap logic is deterministic. + +## Test Coverage + +Core invariants are covered in `src/tests/test_delta_merge.py`, including order independence, replay idempotency, stale replay handling, and tombstone convergence. diff --git a/landing/docs.html b/landing/docs.html index ba053c79..f0d621d8 100644 --- a/landing/docs.html +++ b/landing/docs.html @@ -55,6 +55,10 @@
  • > Architecture
  • > Specification
  • > Security
  • +
  • > Security Readiness
  • +
  • > Agent Security Plan
  • +
  • > Policy as Code
  • +
  • > Sync Conflict Contract
  • > Nostr Setup
  • > Packaging
  • > Testing Roadmap diff --git a/landing/index.html b/landing/index.html index 0bd2446d..fdf6027b 100644 --- a/landing/index.html +++ b/landing/index.html @@ -34,6 +34,7 @@ @@ -124,6 +125,53 @@ + +
    +

    # For_Agents

    +

    Machine-readable discovery, non-interactive auth, scoped access, and audit-first automation.

    + +
    + # Bootstrap context before autonomous execution +
    + ❯ + seedpass capabilities --format json && seedpass agent bootstrap-context +
    +
    + +
    +
    + +

    Brokered Auth

    +

    Use keyring or command brokers for non-interactive unlock without plaintext env secrets.

    +
    +
    + +

    Policy as Code

    +

    Lint, review, and apply policy files with deterministic output and risk-gated changes.

    +
    +
    + +

    Scoped Identity & Tokens

    +

    Issue revocable TTL-bound tokens tied to agent identities, kinds, scopes, and use limits.

    +
    +
    + +

    Safe Output & Leases

    +

    Default redaction plus one-time or N-use secret leases for reduced exposure in logs and pipelines.

    +
    +
    + +

    Approval + Isolation

    +

    Step-up approvals and separate high-risk unlock factors for seed/SSH/PGP/Nostr retrieval paths.

    +
    +
    + +

    Audit, Posture, Recovery

    +

    Chained audit logs, posture checks/remediation, Shamir split-drills, and deterministic export validation.

    +
    +
    +
    +

    # Architecture_Overview

    diff --git a/landing/style.css b/landing/style.css index 10c606a9..4ad3ddbd 100644 --- a/landing/style.css +++ b/landing/style.css @@ -458,6 +458,20 @@ a:hover { opacity: 0.8; } +/* Agent Section */ +.agent-quickstart { + margin-top: 20px; + margin-bottom: 20px; + background: #0b0f13; + padding: 16px; + border-left: 2px solid var(--accent-secondary); + border-radius: 0 4px 4px 0; +} + +.agent-grid .feature-item code { + color: var(--text-bright); +} + /* Features Grid */ .grid-features { display: grid; diff --git a/memory-update.md b/memory-update.md index a8b8edd9..3897445b 100644 --- a/memory-update.md +++ b/memory-update.md @@ -113,3 +113,475 @@ ## Warnings / gotchas - Default policy currently allows `totp` in `allow_kinds`; denial tests must provide an explicit restrictive policy fixture when validating blocked TOTP export. + +# Memory Update — export-reason-codes-audit-v1 — 2026-03-01 + +## Key findings +- Export policy denials are clearer for automation when returned as stable reason codes (`policy_deny:*`) rather than free-text strings. +- Recording explicit allow/deny export events in a dedicated signed audit log improves traceability without leaking secret material. + +## Patterns / reusable knowledge +- Use shared policy evaluators (`evaluate_full_export`, `evaluate_kind_export`) across CLI and API to prevent decision drift. +- Keep denial code constants centralized in `agent_export_policy.py` so tests and clients can depend on stable semantics. + +## Warnings / gotchas +- If using restrictive export tests for TOTP, ensure the policy fixture excludes `totp`, since default policy allows it. + +# Memory Update — export-check-v1 — 2026-03-01 + +## Key findings +- A dedicated dry-run command (`agent export-check`) reduces failed export attempts and clarifies policy decisions before running sensitive operations. +- Optional strict non-zero exit (`--strict-exit`) is useful for CI/agent workflows without forcing failures for exploratory checks. + +## Patterns / reusable knowledge +- Return deterministic JSON fields (`mode`, `allowed`, `reason`, `allow_kinds`) so agents can branch behavior without parsing free text. +- Reuse shared policy evaluators from `agent_export_policy.py` to keep check and enforcement decisions aligned. + +## Warnings / gotchas +- `--mode kind` should require `--kind`; treat missing kind as input error, not policy denial. + +# Memory Update — api-export-check-v1 — 2026-03-01 + +## Key findings +- API parity for dry-run export checks (`/api/v1/export/check`) lets headless agents preflight export permissions without invoking export operations. +- Returning HTTP 200 with `allowed=false` for policy denials (and 400 only for malformed input like missing kind/invalid mode) keeps control-flow predictable. + +## Patterns / reusable knowledge +- Keep API and CLI preflight aligned by reusing shared evaluators from `agent_export_policy.py`. +- Include preflight endpoint in capability discovery output so agents can find it automatically. + +## Warnings / gotchas +- `mode=kind` should reject missing `kind` via explicit `missing_kind` error code to avoid ambiguous denial semantics. + +# Memory Update — filtered-export-manifest-v1 — 2026-03-01 + +## Key findings +- Policy-filtered exports benefit from an embedded deterministic `_export_manifest` that captures policy stamp, allowed kinds, redaction fields, and included/excluded entry counts. +- The same manifest builder should be reused by CLI and API filtered exports to avoid divergence. + +## Patterns / reusable knowledge +- Keep manifests deterministic by excluding timestamps and sorting keys/indexes. +- Include a hash-based `policy_stamp` derived from canonical policy controls to support reproducibility and audit correlation. + +## Warnings / gotchas +- Changes introducing JSON processing in API or tests require explicit `import json`; missing imports can surface as runtime failures in async route tests. + +# Memory Update — export-manifest-verify-v1 — 2026-03-01 + +## Key findings +- Manifest verification should compare against the current normalized policy controls (not minimal export-only policy fields) to prevent false trust in stale or partial policy states. +- A strict-exit toggle supports both CI gating and exploratory diagnostics. + +## Patterns / reusable knowledge +- Use stable error identifiers (`policy_stamp_mismatch`, `allow_kinds_mismatch`, `included_entry_indexes_mismatch`) to make verification machine-actionable. +- Keep verification input as plaintext JSON package to avoid coupling verifier with key management/decryption paths. + +## Warnings / gotchas +- Tests that build and verify manifests must ensure both generator and verifier resolve policy from the same `APP_DIR` context. + +# Memory Update — api-manifest-verify-v1 — 2026-03-01 + +## Key findings +- API parity for manifest verification (`/api/v1/export/manifest/verify`) enables headless agents to validate filtered export packages without shell access. +- Returning verification results as HTTP 200 with `valid` + `errors` simplifies client control flow compared to mixed status-code semantics. + +## Patterns / reusable knowledge +- Reuse shared verification primitives from `agent_export_policy.py` across CLI and API to guarantee identical validation logic. +- Add new verification endpoints to `capabilities` discovery output so automation can discover them without hardcoded paths. + +## Warnings / gotchas +- Policy mismatch tests should mutate `agent_policy.json` after package generation to ensure `policy_stamp_mismatch` is exercised reliably. +- Implemented approval-gate groundwork (Item 7) for agent-risk export workflows. +- Added `seedpass.core.agent_approval` helpers for approval issue/list/revoke/consume with TTL, use limits, and revocation persisted in `~/.seedpass/agent_approvals.json` (0600 perms). +- Added policy helper `approval_required(policy, action)` and wired policy parsing of `approvals.require_for` through export-policy loading. +- Added agent CLI commands: `agent approval-issue`, `agent approval-list`, `agent approval-revoke` and exposed these in bootstrap-context command discovery. +- Enforced step-up approval for full vault export when policy requires `export`: + - CLI: `seedpass vault export --agent-profile --approval-id ` + - API: `POST /api/v1/vault/export` with header `X-SeedPass-Approval-Id`. +- Denials now return policy reasons (`policy_deny:approval_required` or consumed-approval failures) and are logged through export policy audit events. +- Updated capabilities map to include approval commands and `X-SeedPass-Approval-Id` as a sensitive header. +- Added tests for approval issue/list/revoke plus CLI/API full-export approval gating; targeted suites passing: + - `src/tests/test_cli_agent_mode.py` (27 passed) + - `src/tests/test_typer_cli.py` (43 passed) + - `src/tests/test_api_new_endpoints.py` (41 passed) +- Extended approval gates (Item 7 scope) beyond export: + - CLI `vault reveal-parent-seed` now supports `--agent-profile` + `--approval-id` and enforces policy `approvals.require_for` for `reveal_parent_seed`. + - API `POST /api/v1/vault/backup-parent-seed` now supports `X-SeedPass-Agent-Profile` + `X-SeedPass-Approval-Id` and enforces approval for `reveal_parent_seed` when configured. +- Expanded agent private retrieval path: + - `agent get` accepts `--approval-id`. + - Added private-kind retrieval support for `seed`, `managed_account`, `ssh`, `pgp`, `nostr` with policy/tokens/redaction pipeline. + - Enforced `private_key_retrieval` approval requirement for private kinds with consume-on-use approvals. +- Updated capabilities map high-risk operations to reflect agent sensitive retrieval and backup-parent-seed API path. +- New/updated tests passed: + - `src/tests/test_cli_agent_mode.py` (28 passed) + - `src/tests/test_typer_cli.py` (45 passed) + - `src/tests/test_api_new_endpoints.py` (43 passed) +- Implemented Item 4 one-time secret leases as first-class agent capability. +- Added `src/seedpass/core/agent_secret_lease.py` with versioned lease store, TTL/use-limit enforcement, revocation, and cross-process locking via `utils.file_lock.exclusive_lock`. +- Added agent lease commands: + - `agent get --lease-only --ttl --lease-uses ` issues a resource-bound lease without returning secret. + - `agent lease-consume ` consumes one use and returns secret (policy + redaction applied). + - `agent lease-list`, `agent lease-revoke` for lease lifecycle management. +- Lease records are tied to fingerprint/resource and optionally token id metadata; no plaintext secret material is persisted in lease store. +- Updated `agent bootstrap-context` command registry to include lease operations. +- Updated capabilities map to advertise lease support. +- Added regression tests: + - Lease issue/consume/exhaust flow in `src/tests/test_cli_agent_mode.py`. + - Capabilities JSON includes `security_features.leases` in `src/tests/test_typer_cli.py`. +- Verified passing suites: + - `src/tests/test_cli_agent_mode.py` (29 passed) + - `src/tests/test_typer_cli.py` (45 passed) + - `src/tests/test_api_new_endpoints.py` (43 passed) +- Implemented Item 9 agent identities with lifecycle management and token binding. +- Added `src/seedpass/core/agent_identity.py` (versioned identity store) with create/list/get/revoke/ensure/active helpers. +- Added new agent CLI commands: + - `agent identity-create --id ... --owner ... --policy-binding ... --rotation-days ...` + - `agent identity-list [--show-revoked]` + - `agent identity-revoke ` +- Bound new token issuance to identities: + - `agent token-issue` now supports `--identity-id` (default `default-agent`) and auto-creates default identity as needed. + - Token records include `identity_id`. +- Enforced identity state at token use time: + - `_validate_token` now denies with `token_identity_revoked` when token identity is revoked/missing. +- Updated bootstrap context and capabilities to advertise identity features/commands. +- Added posture findings: + - `tokens_missing_identity` + - `tokens_for_revoked_identity` +- Added/updated tests in `src/tests/test_cli_agent_mode.py` and `src/tests/test_typer_cli.py`: + - identity create/list/revoke + - token denied after identity revocation + - bootstrap/capabilities include identity command surface +- Verified test suites: + - `src/tests/test_cli_agent_mode.py` (31 passed) + - `src/tests/test_typer_cli.py` (45 passed) + - `src/tests/test_api_new_endpoints.py` (43 passed) +- Started Item 8 secret-class isolation with a separate high-risk unlock factor/session for agent workflows. +- Added `src/seedpass/core/agent_secret_isolation.py`: + - stores hashed high-risk factor (`agent_high_risk_factor.hash`), + - manages per-fingerprint high-risk unlock sessions with TTL (`agent_high_risk_unlock.json`), + - uses `exclusive_lock` for concurrency-safe session updates. +- Extended agent policy schema normalization to include `secret_isolation`: + - `enabled` (bool), + - `high_risk_kinds` (subset of private kinds), + - `unlock_ttl_sec` (>=1). +- Added new agent CLI commands: + - `agent high-risk-factor-set` + - `agent high-risk-unlock` + - `agent high-risk-status` + - `agent high-risk-lock` +- Enforced secret isolation in access paths: + - `agent get` denies private/high-risk retrieval with `policy_deny:high_risk_locked` unless unlocked, + - `agent lease-consume` applies same high-risk lock check for private kinds. +- Updated bootstrap context and capabilities to advertise secret isolation command surface/features. +- Added/updated tests: + - integrated private-kind lock/unlock flow test in `src/tests/test_cli_agent_mode.py`, + - bootstrap context assertions include secret isolation commands, + - capabilities JSON assertions include `security_features.secret_isolation`. +- Verified suites passing after changes: + - `src/tests/test_cli_agent_mode.py` (32 passed) + - `src/tests/test_typer_cli.py` (45 passed) + - `src/tests/test_api_new_endpoints.py` (43 passed) +- Continued Item 8 by extending secret-class isolation beyond agent-only retrieval into broader CLI/API high-risk flows. +- Non-agent CLI enforcement updates: + - `vault reveal-parent-seed` now checks high-risk isolation lock when policy isolation is enabled for `seed` and a high-risk factor is configured. + - `vault export` now checks high-risk isolation lock under the same conditions before full export. +- API high-risk session endpoints added in `src/seedpass/api.py`: + - `GET /api/v1/high-risk/status` + - `POST /api/v1/high-risk/unlock` (requires token + password + `X-SeedPass-High-Risk-Factor`) + - `POST /api/v1/high-risk/lock` +- API high-risk enforcement updates: + - `/api/v1/vault/export` enforces high-risk unlocked session when export mode/policy includes isolated high-risk kinds. + - `/api/v1/vault/backup-parent-seed` enforces high-risk unlocked session for `seed` when isolation applies. +- Extended export policy parser (`agent_export_policy.load_export_policy`) to support `secret_isolation` fields so API-side isolation decisions are policy-driven. +- Capabilities map now advertises new high-risk API endpoints and `X-SeedPass-High-Risk-Factor` sensitive header. +- Added regression tests: + - CLI: reveal-parent-seed blocked when high-risk locked. + - API: high-risk unlock/status/lock endpoints, export blocked when high-risk locked, backup-parent-seed blocked when high-risk locked. +- Verified passing targeted suites: + - `src/tests/test_typer_cli.py` (46 passed) + - `src/tests/test_api_new_endpoints.py` (46 passed) + - `src/tests/test_cli_agent_mode.py` (32 passed) +- Advanced Item 8 toward vault/service-layer coverage by adding explicit partition metadata + retrieval gating. +- Added secret-class partition metadata defaults and helpers to `ConfigManager`: + - default `secret_class_partitions` with `standard` and `high_risk` classes, + - helpers: `get_secret_class_partitions`, `set_secret_class_partitions`, `set_partition_unlock_state`, `is_partition_unlocked`. +- API service-layer hardening in `src/seedpass/api.py`: + - `/api/v1/high-risk/unlock` and `/api/v1/high-risk/lock` now update config partition unlock state (best-effort), + - `/api/v1/entry/{id}` now enforces high-risk isolation for private kinds (`seed/ssh/pgp/nostr/managed_account`) using policy + session state. +- Policy parsing hardening in `agent_export_policy`: + - `load_export_policy` now parses `secret_isolation` block (enabled/high_risk_kinds/unlock_ttl_sec) for API-side decisions, + - policy stamp includes secret isolation fields. +- Added API regression test for private entry retrieval isolation: + - blocked while high-risk locked, + - allowed after `/api/v1/high-risk/unlock`. +- Re-verified key targeted suites after changes: + - `src/tests/test_api_new_endpoints.py` (47 passed) + - `src/tests/test_typer_cli.py` + `src/tests/test_cli_agent_mode.py` (78 passed combined) +- Continued Item 8 with explicit class-partition metadata and deeper service-layer enforcement. +- Added partition metadata defaults + helpers in `ConfigManager`: + - `secret_class_partitions` default includes `standard` and `high_risk` classes, + - helper methods for reading/updating partition metadata and unlock state. +- API high-risk unlock/lock endpoints now persist partition unlock state (best-effort) via config manager. +- Added retrieval-layer isolation enforcement in API: + - `/api/v1/entry/{id}` checks entry kind and denies with `policy_deny:high_risk_locked` for private kinds when high-risk session is locked. +- Added regression test for API retrieval gating: + - private entry blocked while locked, + - allowed after high-risk unlock. +- Re-ran targeted suites successfully: + - `src/tests/test_api_new_endpoints.py` + `src/tests/test_typer_cli.py` + `src/tests/test_cli_agent_mode.py` (125 passed combined) + - `src/tests/test_core_api_services.py` + `src/tests/test_password_change.py` + `src/tests/test_kdf_strength_slider.py` (55 passed combined) +- Strengthened Item 8 isolation model from hash-check to cryptographic partition-key envelope. +- Updated `agent_secret_isolation`: + - high-risk factor setup now generates a random partition key and stores it wrapped in `agent_high_risk_partition.key.enc.json` using factor-derived key (PBKDF2-SHA256 + Fernet envelope). + - `verify_high_risk_factor` now validates by decrypting envelope (legacy hash fallback retained if envelope absent). + - Added `unwrap_high_risk_partition_key`, `partition_key_tag_for_factor`, and session storage of `partition_key_tag` on unlock grants. +- Updated unlock flows to require envelope unwrap success before granting session: + - CLI `agent high-risk-unlock` computes/stores partition key tag. + - API `/api/v1/high-risk/unlock` computes/stores partition key tag. +- Existing policy/session isolation enforcement remains in place; now backed by cryptographic factor proof rather than plain hash compare. +- Verified suites after envelope change: + - `src/tests/test_cli_agent_mode.py` + `src/tests/test_api_new_endpoints.py` (79 passed combined) + - `src/tests/test_typer_cli.py` (46 passed) + - `src/tests/test_core_api_services.py` + `src/tests/test_password_change.py` + `src/tests/test_kdf_strength_slider.py` (55 passed combined) + +# Memory Update — secret-isolation-partition-migration-v1 — 2026-03-01 + +## Key findings +- Added an encrypted high-risk partition store (`src/seedpass/core/high_risk_partition_store.py`) that migrates private/high-risk entries out of the primary index into `seedpass_high_risk_entries.json.enc` and leaves deterministic stubs in index. +- High-risk factor handling now supports a wrapped partition key envelope (`agent_high_risk_partition.key.enc.json`) so unlock sessions can carry a partition key tag for partition reads. +- `agent lease-consume` needed explicit partition hydration for private kinds; without this, partitioned entries could fail downstream secret resolution. + +## Patterns / reusable knowledge +- Keep partition access behind existing high-risk unlock checks and session TTLs; use the unlock session as the only source of partition key tag context. +- Add command-level migration (`agent high-risk-partition-migrate`) before enforcing partition-only retrieval to keep rollout safe for existing profiles. +- Regression tests for partition flows are most reliable in `src/tests/test_cli_agent_mode.py` by monkeypatching hydration and migration seams. + +## Warnings / gotchas +- Any path that reads private entries by index must hydrate `partition=high_risk` stubs before attempting kind-specific secret derivation. +- API/CLI behavior can diverge if one retrieval path is updated without the other; keep partition rules centralized where possible. +- Added API parity for partition hydration in `GET /api/v1/entry/{id}` so `partition=high_risk` stubs are resolved via unlock-session key tag before response. +- Added API regression `test_get_entry_hydrates_partitioned_high_risk_entry` to lock this behavior. + +# Memory Update — automation-primitives-v1 — 2026-03-01 + +## Key findings +- Added `agent job-run` as a schedule-safe wrapper over `agent get` with safe broker defaults (`keyring`/`command`) and explicit denial for `env` broker unless `--allow-env-broker` is passed. +- Added `agent job-template` to generate deterministic cron/systemd snippets that avoid plaintext password env requirements and encode brokered auth usage. +- Tightened token validation so legacy tokens without `identity_id` are denied at use time with `token_identity_missing`. + +## Patterns / reusable knowledge +- Reusing `ctx.invoke(agent_get, ...)` keeps policy, redaction, approval, leases, and auditing semantics consistent across interactive and automated retrieval paths. +- Expose automation controls in both bootstrap-context command maps and capabilities output to keep discovery parity for autonomous agents. + +## Warnings / gotchas +- Existing legacy tokens with empty `identity_id` now fail; rotate/reissue with `agent token-issue --identity-id ...` before enabling strict automation workflows. + +# Memory Update — automation-job-profiles-v1 — 2026-03-01 + +## Key findings +- Added persistent automation job profiles in `src/seedpass/core/agent_job.py` with create/list/get/revoke lifecycle and 0600 on-disk storage. +- Added agent CLI commands: `job-profile-create`, `job-profile-list`, `job-profile-run`, and `job-profile-revoke` to support reusable scheduled workflows with policy-binding metadata. +- `job-profile-run` now executes through `agent job-run`, preserving existing policy/redaction/approval semantics while enforcing fingerprint binding. + +## Patterns / reusable knowledge +- For safer automation rollout, keep reusable job metadata separate from token/secret material and route execution through existing guarded retrieval commands. +- Include automation profile capabilities in both `bootstrap-context` and `capabilities` output so agents can discover the full control surface deterministically. + +## Warnings / gotchas +- `job-profile-run` enforces fingerprint match; invoking with a different `--fingerprint` intentionally denies. +- Command-broker job profiles must include `--broker-command` at creation time. +- Item 10 hardening continued with job profile policy/host guardrails. +- `agent job-profile-create` now stores `policy_stamp` (from current normalized policy) and `host_binding` (default current hostname). +- `agent job-profile-run` now denies by default on: + - `job_profile_policy_mismatch` (active policy stamp drift) + - `job_profile_host_mismatch` (host binding mismatch) + with explicit override flags `--allow-policy-drift` and `--allow-host-mismatch`. +- Added `agent job-profile-check` command for profile posture checks (unsafe broker, policy drift, host mismatch, staleness) with optional strict exit. +- Updated capabilities/bootstrapped command maps to include `job-profile-check` and automation guardrail metadata. +- Added regression tests for policy mismatch, host mismatch, and profile check drift findings. +- Added API parity for automation job profiles (Item 10) in `src/seedpass/api.py`: + - `GET/POST /api/v1/agent/job-profiles` + - `DELETE /api/v1/agent/job-profiles/{job_id}` + - `POST /api/v1/agent/job-profiles/{job_id}/run` + - `GET /api/v1/agent/job-profiles/check` +- API run endpoint enforces profile fingerprint binding, policy-stamp drift checks, and host-binding checks with explicit override flags, and issues secret leases for matched resources. +- Capabilities discovery now includes new API job-profile endpoints. +- Added API regression tests for CRUD and run behavior (including policy drift denial + override). +- Added API template rendering endpoint for job profiles: `GET /api/v1/agent/job-profiles/{job_id}/template` with deterministic `cron` and `systemd` output modes. +- Endpoint supports profile/default/provided schedule resolution and returns command + rendered template payload for orchestration tooling. +- Added API regression coverage for cron and systemd template outputs. +- Updated capabilities API discovery list to include the new template endpoint. +- Added API template authoring + integrity workflows for automation jobs: + - `POST /api/v1/agent/job-profiles/{job_id}/template` (richer template input) + - `POST /api/v1/agent/job-profiles/{job_id}/template/verify` (manifest verification) +- Added signed template manifests (HMAC-SHA256) including template hash, policy stamp, host binding, and job profile id. +- Added deterministic template helpers shared by GET/POST template endpoints. +- Updated capabilities metadata/discovery with API template + verify endpoints and automation manifest-signing capability. + +# Memory Update — recovery-hardening-v1 — 2026-03-01 + +## Key findings +- Added deterministic Shamir split/recover primitives in `src/seedpass/core/agent_recovery.py` (`split_secret`, `recover_secret`) with share token checksums. +- Added signed backup verification drill logging (`record_recovery_drill`, `list_recovery_drills`) with chained HMAC signatures. +- Exposed recovery workflows in agent CLI: + - `agent recovery-split` + - `agent recovery-recover` + - `agent recovery-drill` + - `agent recovery-drill-list` + +## Patterns / reusable knowledge +- Recovery commands should default to non-revealing output and require explicit `--reveal` for plaintext recovery output. +- Drill reporting should be append-only and signed so verification history remains tamper-evident. + +## Warnings / gotchas +- Current split derivation is deterministic by design for reproducibility; rotating labels should be treated as distinct recovery sets. +- Added Item 11 API parity for recovery workflows: + - `POST /api/v1/agent/recovery/split` + - `POST /api/v1/agent/recovery/recover` + - `POST /api/v1/agent/recovery/drill` + - `GET /api/v1/agent/recovery/drills` + - `POST /api/v1/agent/recovery/drills/verify` +- Added drill-chain verification primitive in `agent_recovery.verify_recovery_drills` to detect tampering via chained HMAC signatures. +- Updated capabilities discovery to include new recovery API endpoints. +- Added API tests for split/recover roundtrip, drill list/verify, and tamper detection. + +# Memory Update — conflict-safe-sync-v1 — 2026-03-01 + +## Key findings +- Introduced deterministic merge primitive `merge_index_payloads` in `src/seedpass/core/sync_conflict.py`. +- Conflict resolver now uses order-independent comparison: + 1) higher `modified_ts` + 2) on tie, lexicographically larger canonical entry hash. +- Integrated merge primitive into Nostr merge path (`EncryptionManager.decrypt_and_save_index_from_nostr(..., merge=True)`). +- Added encrypted sync metadata `_sync_meta` into index payload to track merge strategy and source tags. + +## Patterns / reusable knowledge +- Keep merge logic centralized and deterministic to avoid divergent state from different delta arrival orders. +- Use payload-derived source tags + bounded source history in encrypted index metadata for debugging merge provenance without plaintext side channels. + +## Warnings / gotchas +- Existing tests that implicitly relied on "incoming wins on equal timestamp" need explicit timestamp separation if validating LWW semantics. +- Extended Item 12 conflict-safe sync with deterministic field-level merge rules for equal-timestamp conflicts in `sync_conflict.merge_index_payloads`. +- Equal-ts conflicts now perform deterministic winner selection by hash plus selective field union (`username/url/notes/...`) and conservative archive OR behavior. +- Added order-independence regression tests covering both whole-entry tie resolution and field-level union behavior. +- Integrated resolver in `EncryptionManager.decrypt_and_save_index_from_nostr(..., merge=True)` with source-tagged encrypted sync metadata. +## 2026-03-01 - Sync conflict v2 tombstones +- Added deterministic sync strategy `modified_ts_hash_tombstone_v2` in `src/seedpass/core/sync_conflict.py`. +- Added `_sync_meta.tombstones` merge semantics with timestamp-first then event-hash tie-break, and retention cap. +- Added kind-aware equal-timestamp unions (including deterministic `tags`/`custom_fields` union). +- `EntryManager.delete_entry` now emits local tombstones before removing entries so deletions can propagate across merge-based sync. +- Regression coverage added in `src/tests/test_delta_merge.py` for order-independence, kind-aware unions, and tombstone convergence. +- Validation run: `src/tests/test_delta_merge.py` (7 passed), plus `src/tests/test_profile_management.py src/tests/test_api.py src/tests/test_api_new_endpoints.py` (83 passed). +## 2026-03-01 - CLI discoverability refresh for agents/users +- Updated root CLI help in `src/seedpass/cli/__init__.py` to direct users/agents to `seedpass capabilities` and subgroup `--help`. +- Updated vault unlock success tip in `src/seedpass/cli/vault.py` to explicitly mention `seedpass capabilities` after unlock/login. +- Expanded `src/seedpass/cli/capabilities.py` map with: + - `security_features.sync` (deterministic conflict strategy `modified_ts_hash_tombstone_v2` + tombstones metadata) + - `export_controls` commands and defaults + - `posture` command/check descriptions + - richer approval coverage and help hints (including post-login guidance) +- Updated agent discoverability surfaces in `src/seedpass/cli/agent.py`: + - richer group help text + - `agent init` now returns `next_steps` including capabilities/bootstrap discovery + - `agent bootstrap-context` now advertises export/posture command groups +- Validation: `src/tests/test_typer_cli.py` + `src/tests/test_cli_agent_mode.py` passed (`92 passed`). +## 2026-03-01 - Sync replay/idempotency hardening +- Hardened `src/seedpass/core/sync_conflict.py` to remove wall-clock dependence from merge metadata: + - `last_merge_ts` now derived deterministically from existing/incoming metadata and max observed entry/tombstone timestamps. + - deleted-entry fallback tombstone timestamp now derived from merge state, not `time.time()`. +- Added integer normalization helper for metadata parsing (`_safe_int`) and centralized max timestamp helpers. +- Added replay-focused tests in `src/tests/test_delta_merge.py`: + - idempotent replay of same payload (`once == twice`) + - stale payload cannot override newer state + - stale re-add cannot resurrect tombstoned entry + - newer recreate supersedes tombstone and clears it +- Validation: `src/tests/test_delta_merge.py` (11 passed) + broader regression set (`test_profile_management.py`, `test_api.py`, `test_api_new_endpoints.py`) (83 passed). +- Added vault-boundary idempotency regression in `src/tests/test_delta_merge.py::test_merge_replay_idempotent_through_vault_boundary` by replaying same merged encrypted payload twice after baseline sync. +## 2026-03-01 - Item 12 docs/contract pass +- Added `docs/sync_conflict_contract.md` documenting deterministic merge strategy `modified_ts_hash_tombstone_v2`, `_sync_meta` schema, tombstone semantics, replay guarantees, and retention behavior. +- Linked contract doc from `docs/SPEC.md`, `docs/nostr_setup.md`, and `docs/README.md`. +- Added `test_merge_last_merge_ts_order_independent` in `src/tests/test_delta_merge.py` to lock deterministic metadata ordering behavior. +- Validation: `src/tests/test_delta_merge.py` (13 passed) and targeted CLI discoverability checks (2 passed). +## 2026-03-01 - Item 13 policy-as-code workflow primitives +- Extended agent policy CLI in `src/seedpass/cli/agent.py`: + - `agent policy-lint --file [--format json|text]` with deterministic `policy_stamp` and full `policy_hash`. + - `agent policy-review --file ` with deterministic unified diff, structured `diff_summary`, and risky finding surfacing. + - `agent policy-apply --file ` with `--dry-run` and risk gate (`--allow-risky`) plus audit event `agent_policy_applied`. +- Added helper functions for file-based policy normalization, full-hash computation, and rule-level change summary. +- Updated discovery surfaces: + - capabilities policy commands now include review/apply and policy-as-code support flags. + - bootstrap context now includes `agent policy-review` and `agent policy-apply`. +- Added tests: + - `test_agent_policy_lint_file` + - `test_agent_policy_review_and_apply_risk_gate` + - bootstrap context expectations for new policy commands + - capabilities JSON assertion for policy change-review support. +- Added docs: `docs/policy_as_code.md` and linked from `docs/README.md`. +- Validation: `src/tests/test_cli_agent_mode.py` + `src/tests/test_typer_cli.py` passed (94 passed). +## 2026-03-01 - Item 14 deterministic export controls hardening +- Hardened `src/seedpass/core/agent_export_policy.py` policy-filtered manifest verification: + - manifest version advanced to v2 (with backward-compat support for v1) + - deterministic index sorting helper for mixed/non-numeric indexes + - added `entries_sha256` integrity field (canonical hash of filtered entries) + - added checks for included count, schema version consistency, redacted fields consistency, entry-kind allowlist conformance, and redaction sentinel conformance + - robust integer parsing to avoid verifier crashes on malformed manifests +- Added tamper-detection tests: + - CLI: `test_agent_export_manifest_verify_detects_tampered_entries` + - API: `test_export_manifest_verify_endpoint_detects_tamper` +- Updated capabilities map to surface manifest entry hash verification support. +- Updated docs (`docs/policy_as_code.md`) to document deterministic manifest integrity fields and tamper verification. +- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py src/tests/test_api_new_endpoints.py` => 152 passed. +## 2026-03-01 - Item 15 posture tooling expansion +- Expanded `agent posture-check` findings in `src/seedpass/cli/agent.py` with additional controls: + - `approvals_missing_required_actions` (export/reveal_parent_seed/private_key_retrieval gate completeness) + - `high_risk_unlock_ttl_too_long` (>1800s) + - `over_permissive_read_rule` (broad secret-read allow rule detection) + - `private_read_without_approval_gate` (private-kind reads without private key approval gate) + - `identity_rotation_window_too_long` (rotation_days > 90) + - `token_rotation_overdue` (active token age exceeds bound identity rotation_days) +- Updated capabilities posture checklist in `src/seedpass/cli/capabilities.py` to reflect new checks. +- Added posture regression tests in `src/tests/test_cli_agent_mode.py`: + - `test_agent_posture_check_flags_policy_gate_and_rule_issues` + - `test_agent_posture_check_flags_token_rotation_overdue` +- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py` (97 passed) and `src/tests/test_api_new_endpoints.py` (57 passed). +## 2026-03-01 - Item 15 runtime config posture checks +- Extended `agent posture-check` in `src/seedpass/cli/agent.py` with opt-in runtime config audit: + - new flag `--check-runtime-config` with broker options (`--auth-broker`, `--password-env`, `--broker-service`, `--broker-account`, `--broker-command`) + - requires `--fingerprint` when runtime check is requested + - runtime summary fields: `runtime_config_status`, `runtime_config_error`, `runtime_finding_count` +- Added runtime findings: + - `quick_unlock_enabled` + - `weak_kdf_iterations` + - `high_risk_partition_persistently_unlocked` +- Added additional posture checks in this pass: + - `approvals_missing_required_actions` + - `high_risk_unlock_ttl_too_long` + - `over_permissive_read_rule` + - `private_read_without_approval_gate` + - `identity_rotation_window_too_long` + - `token_rotation_overdue` +- Updated capabilities posture check list to include runtime config drift checks. +- Added/updated tests in `src/tests/test_cli_agent_mode.py` for new posture findings and runtime-check behavior. +- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py src/tests/test_api_new_endpoints.py` => 156 passed. +## 2026-03-01 - Item 15 remediation bundle command +- Added `agent posture-remediate` in `src/seedpass/cli/agent.py` to convert current posture findings into deterministic actionable remediation steps. +- Command supports `--check-runtime-config` with broker auth options and mirrors runtime drift checks used by posture-check. +- Added remediation mapping for key findings (policy gates, export controls, safe output, isolation, token hygiene, rotation, runtime config drift). +- Updated discovery: + - bootstrap context posture commands now include `agent posture-remediate` + - capabilities posture command list includes remediation command + - text capabilities summary mentions remediation bundles +- Added tests: + - `test_agent_posture_remediate_emits_actions` + - `test_agent_posture_remediate_runtime_requires_fingerprint` + - bootstrap context posture command assertion + - capabilities JSON posture command assertion +- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py src/tests/test_api_new_endpoints.py` => 158 passed. +## 2026-03-01 - Docs and landing updates for agent/user discoverability +- Updated root `README.md` with a dedicated **Agent and Automation Security Features** section covering implemented controls and command references (`capabilities`, policy, tokens, leases, approvals, isolation, automation jobs, recovery, export controls, posture tooling, audit logging). +- Updated `docs/README.md` to include agent/autonomy docs and explicit discoverability commands (`seedpass --help`, group help, `capabilities`, `agent bootstrap-context`). +- Expanded `docs/security.md` with current agent security controls and links to core plan/contract docs. +- Updated `docs/security_readiness_checklist.md` with a new **Agent Autonomy Control Track (15-item plan)** status table and evidence pointers. +- Updated `docs/agent_autonomy_security_plan.md` with a dated implementation snapshot (implemented vs in-progress). +- Updated website docs index in `landing/docs.html` to include `security_readiness_checklist.md`, `agent_autonomy_security_plan.md`, `policy_as_code.md`, and `sync_conflict_contract.md`. +- Added a new **For Agents** section to `landing/index.html` (with nav anchor) and supporting styles in `landing/style.css`, including quickstart command and agent-specific feature cards. diff --git a/src/seedpass/api.py b/src/seedpass/api.py index c8922918..6304dadd 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -4,13 +4,18 @@ from __future__ import annotations import os import tempfile +import json +from datetime import datetime, timezone from pathlib import Path import secrets import queue import time +import socket +import shlex from collections import defaultdict, deque from typing import Any, List, Optional import hashlib +import hmac import logging @@ -19,16 +24,45 @@ import asyncio import sys import bcrypt +from constants import APP_DIR from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType from seedpass.core.agent_export_policy import ( allowed_kinds, - filter_index_for_allowed_kinds, - full_export_allowed, - kind_export_allowed, + build_policy_filtered_export_package, + compute_policy_stamp, + evaluate_full_export, + evaluate_kind_export, load_export_policy, + record_export_policy_event, + verify_filtered_export_package, ) +from seedpass.core.agent_approval import approval_required, consume_approval +from seedpass.core.agent_secret_isolation import ( + grant_high_risk_unlock, + high_risk_factor_configured, + high_risk_unlocked, + partition_key_tag_for_factor, + revoke_high_risk_unlock, + unlocked_partition_key_tag, + verify_high_risk_factor, +) +from seedpass.core.agent_secret_lease import issue_lease +from seedpass.core.agent_job import ( + create_job_profile, + get_job_profile, + list_job_profiles, + revoke_job_profile, +) +from seedpass.core.agent_recovery import ( + list_recovery_drills, + record_recovery_drill, + recover_secret, + split_secret, + verify_recovery_drills, +) +from seedpass.core.high_risk_partition_store import load_partition_entry from seedpass.core.api import UtilityService _RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100")) @@ -201,6 +235,215 @@ def _header_truthy(value: str | None) -> bool: return str(value or "").strip().lower() in {"1", "true", "yes", "on"} +def _active_fingerprint(pm: PasswordManager) -> str: + return str( + getattr(pm, "current_fingerprint", None) + or getattr(pm, "fingerprint", None) + or "default" + ) + + +def _policy_isolation_kinds(policy: dict[str, Any]) -> set[str]: + cfg = policy.get("secret_isolation", {}) + if not isinstance(cfg, dict) or not bool(cfg.get("enabled", True)): + return set() + kinds = cfg.get("high_risk_kinds", []) + if not isinstance(kinds, list): + return set() + return {str(v).strip().lower() for v in kinds if str(v).strip()} + + +def _require_high_risk_session( + pm: PasswordManager, policy: dict[str, Any], required_kinds: set[str] +) -> None: + isolation_kinds = _policy_isolation_kinds(policy) + if not isolation_kinds or not required_kinds.intersection(isolation_kinds): + return + if not high_risk_factor_configured(): + return + fp = _active_fingerprint(pm) + unlocked, _expires_at = high_risk_unlocked(fingerprint=fp) + if not unlocked: + raise HTTPException(status_code=403, detail="policy_deny:high_risk_locked") + + +def _hydrate_partitioned_entry_if_needed( + pm: PasswordManager, entry_id: int, entry: dict[str, Any] +) -> dict[str, Any]: + if str(entry.get("partition", "")).strip().lower() != "high_risk": + return entry + fp = _active_fingerprint(pm) + tag = unlocked_partition_key_tag(fingerprint=fp) + if not tag: + raise HTTPException(status_code=403, detail="policy_deny:high_risk_locked") + try: + loaded = load_partition_entry(Path(pm.fingerprint_dir), tag, int(entry_id)) + except ValueError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + if not isinstance(loaded, dict): + raise HTTPException(status_code=404, detail="high_risk_partition_entry_missing") + return loaded + + +def _job_profile_command(*, fingerprint: str, job_id: str) -> str: + fp = shlex.quote(str(fingerprint)) + jid = shlex.quote(str(job_id)) + return f"seedpass --fingerprint {fp} agent job-profile-run {jid}" + + +def _cron_template(*, schedule: str, command: str) -> str: + return f"{schedule} {command} >> /var/log/seedpass-agent-job.log 2>&1" + + +def _systemd_templates( + *, unit_name: str, schedule: str, command: str +) -> tuple[str, str]: + service = "\n".join( + [ + "[Unit]", + f"Description=SeedPass agent job ({unit_name})", + "", + "[Service]", + "Type=oneshot", + f"ExecStart={command}", + ] + ) + timer = "\n".join( + [ + "[Unit]", + f"Description=SeedPass agent timer ({unit_name})", + "", + "[Timer]", + f"OnCalendar={schedule}", + "Persistent=true", + "", + "[Install]", + "WantedBy=timers.target", + ] + ) + return service, timer + + +def _template_key_path() -> Path: + return APP_DIR / "agent_template_signing.key" + + +def _load_template_signing_key() -> bytes: + path = _template_key_path() + if path.exists(): + return path.read_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_bytes(32) + path.write_bytes(key) + os.chmod(path, 0o600) + return key + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _template_content(payload: dict[str, Any]) -> dict[str, Any]: + content: dict[str, Any] = { + "mode": payload.get("mode"), + "schedule": payload.get("schedule"), + "command": payload.get("command"), + } + if "cron_line" in payload: + content["cron_line"] = payload.get("cron_line") + if "systemd_service" in payload: + content["systemd_service"] = payload.get("systemd_service") + if "systemd_timer" in payload: + content["systemd_timer"] = payload.get("systemd_timer") + if "unit_name" in payload: + content["unit_name"] = payload.get("unit_name") + return content + + +def _template_manifest(payload: dict[str, Any]) -> dict[str, Any]: + content = _template_content(payload) + template_hash = hashlib.sha256(_canonical_json(content).encode("utf-8")).hexdigest() + policy_stamp = str(payload.get("policy_stamp", "") or "") + host_binding = str(payload.get("host_binding", "") or "") + job_profile_id = str(payload.get("job_profile_id", "") or "") + msg = f"{template_hash}:{policy_stamp}:{host_binding}:{job_profile_id}".encode( + "utf-8" + ) + sig = hmac.new(_load_template_signing_key(), msg, hashlib.sha256).hexdigest() + return { + "schema_version": 1, + "template_hash_sha256": template_hash, + "policy_stamp": policy_stamp, + "host_binding": host_binding, + "job_profile_id": job_profile_id, + "signature_hmac_sha256": sig, + } + + +def _build_job_profile_template_payload( + *, + profile: dict[str, Any], + job_id: str, + mode: str, + schedule: str | None, + unit_name: str, + include_manifest: bool, +) -> dict[str, Any]: + mode_value = str(mode).strip().lower() + if mode_value not in {"cron", "systemd"}: + raise HTTPException(status_code=400, detail="invalid_mode") + profile_schedule = str(profile.get("schedule", "")).strip() + if schedule and str(schedule).strip(): + effective_schedule = str(schedule).strip() + schedule_source = "provided" + elif profile_schedule: + effective_schedule = profile_schedule + schedule_source = "profile" + elif mode_value == "cron": + effective_schedule = "*/15 * * * *" + schedule_source = "default" + else: + effective_schedule = "*:0/15" + schedule_source = "default" + command = _job_profile_command( + fingerprint=str(profile.get("fingerprint", "")).strip(), + job_id=job_id, + ) + payload: dict[str, Any] = { + "status": "ok", + "job_profile_id": job_id, + "mode": mode_value, + "schedule": effective_schedule, + "schedule_source": schedule_source, + "command": command, + "policy_stamp": str(profile.get("policy_stamp", "")).strip() or None, + "host_binding": str(profile.get("host_binding", "")).strip() or None, + } + if mode_value == "cron": + payload["cron_line"] = _cron_template( + schedule=effective_schedule, command=command + ) + else: + unit = str(unit_name).strip() or "seedpass-agent-job" + service, timer = _systemd_templates( + unit_name=unit, + schedule=effective_schedule, + command=command, + ) + payload["unit_name"] = unit + payload["systemd_service"] = service + payload["systemd_timer"] = timer + if include_manifest: + payload["template_manifest"] = _template_manifest(payload) + return payload + + +def _mask_value(value: str) -> str: + if len(value) <= 4: + return "*" * len(value) + return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}" + + @app.get("/api/v1/entry") async def search_entry( request: Request, query: str, authorization: str | None = Header(None) @@ -222,6 +465,504 @@ async def search_entry( ] +@app.get("/api/v1/high-risk/status") +def high_risk_status( + request: Request, authorization: str | None = Header(None) +) -> dict[str, Any]: + _check_token(request, authorization) + pm = _get_pm(request) + fp = _active_fingerprint(pm) + unlocked, expires_at = high_risk_unlocked(fingerprint=fp) + return { + "status": "ok", + "fingerprint": fp, + "factor_configured": bool(high_risk_factor_configured()), + "unlocked": bool(unlocked), + "expires_at_utc": expires_at or None, + } + + +@app.post("/api/v1/high-risk/unlock") +def high_risk_unlock( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), + password: str | None = Header(None, alias="X-SeedPass-Password"), + factor: str | None = Header(None, alias="X-SeedPass-High-Risk-Factor"), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_password(request, password) + _require_unlocked(request) + pm = _get_pm(request) + if not high_risk_factor_configured(): + raise HTTPException(status_code=400, detail="high_risk_factor_not_configured") + if factor is None or not verify_high_risk_factor(factor): + raise HTTPException(status_code=401, detail="high_risk_factor_invalid") + try: + key_tag = partition_key_tag_for_factor(factor) + except ValueError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + policy = load_export_policy() + ttl_default = int((policy.get("secret_isolation") or {}).get("unlock_ttl_sec", 300)) + ttl = int(data.get("ttl", ttl_default)) + if ttl < 1: + raise HTTPException(status_code=400, detail="invalid_ttl") + session = grant_high_risk_unlock( + fingerprint=_active_fingerprint(pm), ttl_seconds=ttl, partition_key_tag=key_tag + ) + cfg = getattr(pm, "config_manager", None) + if cfg is not None and hasattr(cfg, "set_partition_unlock_state"): + try: + cfg.set_partition_unlock_state("high_risk", True) + except Exception: + pass + return { + "status": "ok", + "fingerprint": _active_fingerprint(pm), + "expires_at_utc": session.get("expires_at_utc"), + } + + +@app.post("/api/v1/high-risk/lock") +def high_risk_lock( + request: Request, authorization: str | None = Header(None) +) -> dict[str, Any]: + _check_token(request, authorization) + pm = _get_pm(request) + fp = _active_fingerprint(pm) + changed = revoke_high_risk_unlock(fingerprint=fp) + cfg = getattr(pm, "config_manager", None) + if cfg is not None and hasattr(cfg, "set_partition_unlock_state"): + try: + cfg.set_partition_unlock_state("high_risk", False) + except Exception: + pass + return {"status": "ok", "fingerprint": fp, "locked": bool(changed)} + + +@app.get("/api/v1/agent/job-profiles") +def list_agent_job_profiles( + request: Request, + authorization: str | None = Header(None), + show_revoked: bool = False, +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + jobs = list_job_profiles(include_revoked=bool(show_revoked)) + return {"status": "ok", "job_profiles": jobs} + + +@app.post("/api/v1/agent/job-profiles") +def create_agent_job_profile( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + job_id = str(data.get("id", "")).strip() + query = str(data.get("query", "")).strip() + if not job_id or not query: + raise HTTPException(status_code=400, detail="id_and_query_required") + broker = str(data.get("auth_broker", "keyring")).strip().lower() + if broker not in {"keyring", "command"}: + raise HTTPException(status_code=400, detail="invalid_auth_broker") + broker_command = str(data.get("broker_command", "")).strip() or None + if broker == "command" and not broker_command: + raise HTTPException(status_code=400, detail="broker_command_required") + policy = load_export_policy() + policy_stamp = compute_policy_stamp(policy) + bind_host = str(data.get("bind_host", "current")).strip() or "current" + if bind_host == "current": + bind_host = socket.gethostname() + fingerprint = str(data.get("fingerprint", "")).strip() or _active_fingerprint(pm) + try: + rec = create_job_profile( + job_id=job_id, + fingerprint=fingerprint, + query=query, + auth_broker=broker, + broker_service=str(data.get("broker_service", "seedpass")).strip(), + broker_account=str(data.get("broker_account", "")).strip() or fingerprint, + broker_command=broker_command, + policy_binding=str(data.get("policy_binding", "default")).strip(), + policy_stamp=policy_stamp, + schedule=str(data.get("schedule", "")).strip(), + description=str(data.get("description", "")).strip(), + host_binding=bind_host, + lease_only=bool(data.get("lease_only", False)), + lease_ttl=int(data.get("lease_ttl", 30)), + lease_uses=int(data.get("lease_uses", 1)), + reveal=bool(data.get("reveal", False)), + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return {"status": "ok", "job_profile": rec} + + +@app.delete("/api/v1/agent/job-profiles/{job_id}") +def revoke_agent_job_profile( + request: Request, + job_id: str, + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + if not revoke_job_profile(job_id): + raise HTTPException(status_code=404, detail="job_profile_not_found") + return {"status": "ok", "id": job_id} + + +@app.post("/api/v1/agent/job-profiles/{job_id}/run") +def run_agent_job_profile( + request: Request, + job_id: str, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + profile = get_job_profile(job_id) + if not profile or profile.get("revoked_at_utc"): + raise HTTPException(status_code=404, detail="job_profile_not_found") + + requested_fp = str(data.get("fingerprint", "")).strip() or _active_fingerprint(pm) + profile_fp = str(profile.get("fingerprint", "")).strip() + if requested_fp != profile_fp: + raise HTTPException(status_code=403, detail="job_profile_fingerprint_mismatch") + + allow_policy_drift = bool(data.get("allow_policy_drift", False)) + allow_host_mismatch = bool(data.get("allow_host_mismatch", False)) + bound_host = str(profile.get("host_binding", "")).strip() + current_host = socket.gethostname() + if bound_host and bound_host != current_host and not allow_host_mismatch: + raise HTTPException(status_code=403, detail="job_profile_host_mismatch") + + profile_stamp = str(profile.get("policy_stamp", "")).strip() + active_stamp = compute_policy_stamp(load_export_policy()) + if profile_stamp and profile_stamp != active_stamp and not allow_policy_drift: + raise HTTPException(status_code=403, detail="job_profile_policy_mismatch") + + query = str(profile.get("query", "")).strip() + if not query: + raise HTTPException(status_code=400, detail="job_profile_query_missing") + matches = pm.entry_manager.search_entries(query) + if len(matches) != 1: + return { + "status": "error", + "reason": "ambiguous_or_missing", + "match_count": len(matches), + "matches": [ + { + "index": idx, + "label": label, + "kind": etype.value if hasattr(etype, "value") else str(etype), + "username": username, + "url": url, + "archived": archived, + } + for idx, label, username, url, archived, etype in matches + ], + } + index, label, _username, _url, _archived, etype = matches[0] + kind = etype.value if hasattr(etype, "value") else str(etype) + lease_ttl = int(data.get("lease_ttl", profile.get("lease_ttl", 30))) + lease_uses = int(data.get("lease_uses", profile.get("lease_uses", 1))) + lease = issue_lease( + fingerprint=profile_fp, + index=int(index), + kind=str(kind), + label=str(label), + ttl_seconds=lease_ttl, + uses=lease_uses, + token_id=None, + ) + return { + "status": "ok", + "mode": "lease_issued", + "job_profile_id": job_id, + "lease_id": lease.get("id"), + "fingerprint": profile_fp, + "index": int(index), + "kind": str(kind), + "label": str(label), + "policy_binding": profile.get("policy_binding"), + "policy_stamp": profile_stamp or None, + "active_policy_stamp": active_stamp, + "lease_expires_at_utc": lease.get("expires_at_utc"), + "lease_uses_remaining": int(lease.get("uses_remaining", 0)), + } + + +@app.get("/api/v1/agent/job-profiles/{job_id}/template") +def template_agent_job_profile( + request: Request, + job_id: str, + authorization: str | None = Header(None), + mode: str = "cron", + schedule: str | None = None, + unit_name: str = "seedpass-agent-job", + include_manifest: bool = True, +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + profile = get_job_profile(job_id) + if not profile or profile.get("revoked_at_utc"): + raise HTTPException(status_code=404, detail="job_profile_not_found") + return _build_job_profile_template_payload( + profile=profile, + job_id=job_id, + mode=mode, + schedule=schedule, + unit_name=unit_name, + include_manifest=bool(include_manifest), + ) + + +@app.post("/api/v1/agent/job-profiles/{job_id}/template") +def template_agent_job_profile_post( + request: Request, + job_id: str, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + profile = get_job_profile(job_id) + if not profile or profile.get("revoked_at_utc"): + raise HTTPException(status_code=404, detail="job_profile_not_found") + return _build_job_profile_template_payload( + profile=profile, + job_id=job_id, + mode=str(data.get("mode", "cron")), + schedule=str(data.get("schedule", "")).strip() or None, + unit_name=str(data.get("unit_name", "seedpass-agent-job")), + include_manifest=bool(data.get("include_manifest", True)), + ) + + +@app.post("/api/v1/agent/job-profiles/{job_id}/template/verify") +def verify_agent_job_profile_template( + request: Request, + job_id: str, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + profile = get_job_profile(job_id) + if not profile or profile.get("revoked_at_utc"): + raise HTTPException(status_code=404, detail="job_profile_not_found") + payload = data.get("template") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="template_required") + manifest = data.get("manifest") + if not isinstance(manifest, dict): + manifest = payload.get("template_manifest") + if not isinstance(manifest, dict): + raise HTTPException(status_code=400, detail="template_manifest_required") + expected = _template_manifest(payload) + mismatches: list[str] = [] + for key in ( + "template_hash_sha256", + "policy_stamp", + "host_binding", + "job_profile_id", + "signature_hmac_sha256", + ): + if str(manifest.get(key, "")) != str(expected.get(key, "")): + mismatches.append(key) + return { + "status": "ok", + "valid": len(mismatches) == 0, + "job_profile_id": job_id, + "mismatches": mismatches, + } + + +@app.get("/api/v1/agent/job-profiles/check") +def check_agent_job_profiles( + request: Request, + authorization: str | None = Header(None), + max_age_days: int = 30, +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + jobs = list_job_profiles(include_revoked=False) + active_stamp = compute_policy_stamp(load_export_policy()) + now = datetime.now(timezone.utc) + findings: list[dict[str, Any]] = [] + for rec in jobs: + job_id = str(rec.get("id", "")) + broker = str(rec.get("auth_broker", "")).strip().lower() + if broker not in {"keyring", "command"}: + findings.append( + { + "id": "job_profile_unsafe_broker", + "severity": "high", + "job_id": job_id, + "broker": broker, + } + ) + profile_stamp = str(rec.get("policy_stamp", "")).strip() + if profile_stamp and profile_stamp != active_stamp: + findings.append( + { + "id": "job_profile_policy_mismatch", + "severity": "medium", + "job_id": job_id, + "job_policy_stamp": profile_stamp, + "active_policy_stamp": active_stamp, + } + ) + bound_host = str(rec.get("host_binding", "")).strip() + current_host = socket.gethostname() + if bound_host and bound_host != current_host: + findings.append( + { + "id": "job_profile_host_mismatch", + "severity": "medium", + "job_id": job_id, + "job_host": bound_host, + "current_host": current_host, + } + ) + created_at = str(rec.get("created_at_utc", "")) + if created_at: + try: + created_dt = datetime.fromisoformat(created_at) + if created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + age_days = (now - created_dt).days + if age_days > int(max_age_days): + findings.append( + { + "id": "job_profile_stale", + "severity": "low", + "job_id": job_id, + "age_days": age_days, + } + ) + except Exception: + pass + return { + "status": "ok", + "check": "agent_job_profiles", + "job_profile_count": len(jobs), + "active_policy_stamp": active_stamp, + "finding_count": len(findings), + "findings": findings, + } + + +@app.post("/api/v1/agent/recovery/split") +def agent_recovery_split( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + secret = str(data.get("secret", "")).strip() + if not secret: + raise HTTPException(status_code=400, detail="secret_required") + shares = int(data.get("shares", 5)) + threshold = int(data.get("threshold", 3)) + label = str(data.get("label", "default")).strip() or "default" + try: + tokens = split_secret( + secret, total_shares=int(shares), threshold=int(threshold), label=label + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return { + "status": "ok", + "label": label, + "threshold": threshold, + "total_shares": shares, + "shares": tokens, + } + + +@app.post("/api/v1/agent/recovery/recover") +def agent_recovery_recover( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + raw = data.get("shares") + if not isinstance(raw, list) or not raw: + raise HTTPException(status_code=400, detail="shares_required") + shares = [str(v).strip() for v in raw if str(v).strip()] + if not shares: + raise HTTPException(status_code=400, detail="shares_required") + reveal = bool(data.get("reveal", False)) + try: + secret = recover_secret(shares) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return { + "status": "ok", + "share_count": len(shares), + "revealed": reveal, + "secret": secret if reveal else _mask_value(secret), + } + + +@app.post("/api/v1/agent/recovery/drill") +def agent_recovery_drill( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + backup_path = str(data.get("backup_path", "")).strip() + if not backup_path: + raise HTTPException(status_code=400, detail="backup_path_required") + fingerprint = str(data.get("fingerprint", "")).strip() or _active_fingerprint(pm) + simulated = bool(data.get("simulated", True)) + raw_max_age = data.get("max_age_days", 30) + max_age_days = int(raw_max_age) if raw_max_age is not None else None + report = record_recovery_drill( + fingerprint=fingerprint, + backup_path=backup_path, + simulated=simulated, + expected_max_age_days=max_age_days, + ) + return {"status": "ok", "report": report} + + +@app.get("/api/v1/agent/recovery/drills") +def agent_recovery_drills( + request: Request, + authorization: str | None = Header(None), + limit: int = 20, +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + records = list_recovery_drills(limit=int(limit)) + return {"status": "ok", "count": len(records), "drills": records} + + +@app.post("/api/v1/agent/recovery/drills/verify") +def agent_recovery_drills_verify( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + limit = int(data.get("limit", 200)) + result = verify_recovery_drills(limit=limit) + return {"status": "ok", **result} + + @app.get("/api/v1/entry/{entry_id}") async def get_entry( request: Request, @@ -236,6 +977,11 @@ async def get_entry( entry = pm.entry_manager.retrieve_entry(entry_id) if entry is None: raise HTTPException(status_code=404, detail="Not found") + kind = str(entry.get("kind", entry.get("type", ""))).strip().lower() + if kind in {"seed", "ssh", "pgp", "nostr", "managed_account"}: + policy = load_export_policy() + _require_high_risk_session(pm, policy, {kind}) + entry = _hydrate_partitioned_entry_if_needed(pm, entry_id, entry) return entry @@ -549,11 +1295,24 @@ def export_totp( _require_unlocked(request) if _header_truthy(agent_profile): policy = load_export_policy() - if not kind_export_allowed(policy, EntryType.TOTP.value): + allowed, reason = evaluate_kind_export(policy, EntryType.TOTP.value) + if not allowed: + record_export_policy_event( + "export_denied", + { + "source": "api:totp_export", + "kind": EntryType.TOTP.value, + "reason": reason, + }, + ) raise HTTPException( status_code=403, - detail="Policy denied TOTP export for agent profile", + detail=reason, ) + record_export_policy_event( + "export_allowed", + {"source": "api:totp_export", "kind": EntryType.TOTP.value}, + ) pm = _get_pm(request) key = getattr(pm, "KEY_TOTP_DET", None) or getattr(pm, "parent_seed", None) return pm.entry_manager.export_totp_entries(key) @@ -716,6 +1475,69 @@ def update_checksum( return {"status": "ok"} +@app.get("/api/v1/export/check") +def export_check( + request: Request, + authorization: str | None = Header(None), + mode: str = "full", + kind: str | None = None, +) -> dict[str, Any]: + """Dry-run export policy decision for agent workflows.""" + _check_token(request, authorization) + _require_unlocked(request) + + mode_l = mode.strip().lower() + if mode_l not in {"full", "filtered", "kind"}: + raise HTTPException( + status_code=400, + detail="invalid_mode", + ) + + policy = load_export_policy() + allowed = False + reason = "policy_deny:unknown" + checked_kind: str | None = None + if mode_l == "full": + allowed, reason = evaluate_full_export(policy) + elif mode_l == "filtered": + allowed = True + reason = "policy_allow:filtered_export" + else: + if not kind: + raise HTTPException(status_code=400, detail="missing_kind") + checked_kind = kind + allowed, reason = evaluate_kind_export(policy, kind) + + return { + "status": "ok", + "mode": mode_l, + "allowed": bool(allowed), + "reason": reason, + "kind": checked_kind, + "allow_kinds": sorted(set(policy.get("allow_kinds", []))), + "allow_export_import": bool(policy.get("allow_export_import", False)), + } + + +@app.post("/api/v1/export/manifest/verify") +def verify_export_manifest( + request: Request, package: dict[str, Any], authorization: str | None = Header(None) +) -> dict[str, Any]: + """Verify a policy-filtered export package against current export policy.""" + _check_token(request, authorization) + policy = load_export_policy() + valid, errors = verify_filtered_export_package(package, policy) + return { + "status": "ok", + "valid": bool(valid), + "policy_stamp_current": compute_policy_stamp(policy), + "policy_stamp_manifest": str( + (package.get("_export_manifest") or {}).get("policy_stamp", "") + ), + "errors": errors, + } + + @app.post("/api/v1/vault/export") def export_vault( request: Request, @@ -723,6 +1545,7 @@ def export_vault( password: str | None = Header(None, alias="X-SeedPass-Password"), agent_profile: str | None = Header(None, alias="X-SeedPass-Agent-Profile"), policy_filtered: str | None = Header(None, alias="X-SeedPass-Policy-Filtered"), + approval_id: str | None = Header(None, alias="X-SeedPass-Approval-Id"), ): """Export the vault and return the encrypted file.""" _check_token(request, authorization) @@ -734,18 +1557,64 @@ def export_vault( if is_agent: policy = load_export_policy() if is_filtered: + _require_high_risk_session(pm, policy, set(allowed_kinds(policy))) index_data = pm.vault.load_index() - filtered = filter_index_for_allowed_kinds(index_data, allowed_kinds(policy)) + filtered = build_policy_filtered_export_package(index_data, policy) payload = json.dumps( filtered, sort_keys=True, separators=(",", ":") ).encode("utf-8") data = pm.vault.encryption_manager.encrypt_data(payload) + record_export_policy_event( + "export_allowed", + { + "source": "api:vault_export", + "mode": "filtered", + "allowed_kinds": sorted(list(allowed_kinds(policy))), + "policy_stamp": filtered.get("_export_manifest", {}).get( + "policy_stamp" + ), + }, + ) return Response(content=data, media_type="application/octet-stream") - if not full_export_allowed(policy): + allowed, reason = evaluate_full_export(policy) + if not allowed: + record_export_policy_event( + "export_denied", + {"source": "api:vault_export", "mode": "full", "reason": reason}, + ) raise HTTPException( status_code=403, - detail="Policy denied full vault export for agent profile", + detail=reason, ) + if approval_required(policy, "export"): + if not approval_id: + reason = "policy_deny:approval_required" + record_export_policy_event( + "export_denied", + {"source": "api:vault_export", "mode": "full", "reason": reason}, + ) + raise HTTPException(status_code=403, detail=reason) + ok, approval_reason = consume_approval( + approval_id=approval_id, + action="export", + resource="vault:full", + ) + if not ok: + reason = f"policy_deny:{approval_reason}" + record_export_policy_event( + "export_denied", + {"source": "api:vault_export", "mode": "full", "reason": reason}, + ) + raise HTTPException(status_code=403, detail=reason) + _require_high_risk_session(pm, policy, _policy_isolation_kinds(policy)) + record_export_policy_event( + "export_allowed", + { + "source": "api:vault_export", + "mode": "full", + "approval_id": approval_id, + }, + ) path = pm.handle_export_database() if path is None: raise HTTPException(status_code=500, detail="Export failed") @@ -818,12 +1687,46 @@ def backup_parent_seed( data: dict, authorization: str | None = Header(None), password: str | None = Header(None, alias="X-SeedPass-Password"), + agent_profile: str | None = Header(None, alias="X-SeedPass-Agent-Profile"), + approval_id: str | None = Header(None, alias="X-SeedPass-Approval-Id"), ) -> dict[str, str]: """Create an encrypted backup of the parent seed after confirmation.""" _check_token(request, authorization) _require_password(request, password) _require_unlocked(request) pm = _get_pm(request) + policy = load_export_policy() + _require_high_risk_session(pm, policy, {"seed"}) + if _header_truthy(agent_profile): + if approval_required(policy, "reveal_parent_seed"): + if not approval_id: + reason = "policy_deny:approval_required" + record_export_policy_event( + "approval_denied", + { + "source": "api:vault_backup_parent_seed", + "reason": reason, + "action": "reveal_parent_seed", + }, + ) + raise HTTPException(status_code=403, detail=reason) + ok, approval_reason = consume_approval( + approval_id=approval_id, + action="reveal_parent_seed", + resource="vault:parent-seed", + ) + if not ok: + reason = f"policy_deny:{approval_reason}" + record_export_policy_event( + "approval_denied", + { + "source": "api:vault_backup_parent_seed", + "reason": reason, + "action": "reveal_parent_seed", + "approval_id": approval_id, + }, + ) + raise HTTPException(status_code=403, detail=reason) if not data.get("confirm"): @@ -838,6 +1741,16 @@ def backup_parent_seed( if resolved_path.exists(): raise HTTPException(status_code=409, detail="File already exists") pm.encryption_manager.encrypt_and_save_file(pm.parent_seed.encode("utf-8"), path) + if _header_truthy(agent_profile): + record_export_policy_event( + "approval_consumed", + { + "source": "api:vault_backup_parent_seed", + "action": "reveal_parent_seed", + "approval_id": approval_id, + "path": str(path), + }, + ) return {"status": "saved", "path": str(path)} diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 0eb37512..f7f4b13b 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -13,7 +13,11 @@ from seedpass.core.errors import SeedPassError from constants import GUI_BACKEND_CONFIG app = typer.Typer( - help="SeedPass command line interface", + help=( + "SeedPass command line interface. " + "Run `seedpass capabilities` for a deterministic feature map " + "and `seedpass --help` for group-specific commands." + ), invoke_without_command=True, ) @@ -79,6 +83,8 @@ def main( """SeedPass CLI entry point. When called without a subcommand this launches the interactive TUI. + Use ``seedpass capabilities --format json`` for machine-readable command + and security feature discovery. """ ctx.obj = { "fingerprint": fingerprint, diff --git a/src/seedpass/cli/agent.py b/src/seedpass/cli/agent.py index 024be675..097d86f2 100644 --- a/src/seedpass/cli/agent.py +++ b/src/seedpass/cli/agent.py @@ -7,6 +7,7 @@ import os import re import secrets import socket +import difflib from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Optional @@ -19,7 +20,63 @@ from mnemonic import Mnemonic from constants import APP_DIR, initialize_app from seedpass.core.api import EntryService -from seedpass.core.auth_broker import AuthBrokerError, resolve_password as resolve_broker_password +from seedpass.core.agent_export_policy import ( + compute_policy_stamp, + evaluate_full_export, + evaluate_kind_export, + verify_filtered_export_package, +) +from seedpass.core.agent_approval import ( + VALID_APPROVAL_ACTIONS, + approval_required, + consume_approval, + issue_approval, + list_approvals, + revoke_approval, +) +from seedpass.core.agent_secret_lease import ( + consume_lease, + issue_lease, + list_leases, + revoke_lease, +) +from seedpass.core.agent_identity import ( + create_identity, + ensure_identity, + identity_active, + list_identities, + revoke_identity, +) +from seedpass.core.agent_job import ( + create_job_profile, + get_job_profile, + list_job_profiles, + revoke_job_profile, +) +from seedpass.core.agent_recovery import ( + list_recovery_drills, + record_recovery_drill, + recover_secret, + split_secret, +) +from seedpass.core.agent_secret_isolation import ( + grant_high_risk_unlock, + high_risk_factor_configured, + high_risk_unlocked, + unlocked_partition_key_tag, + partition_key_tag_for_factor, + revoke_high_risk_unlock, + set_high_risk_factor, + verify_high_risk_factor, +) +from seedpass.core.high_risk_partition_store import ( + load_partition_entry, + migrate_high_risk_entries, +) +from seedpass.core.auth_broker import ( + AuthBrokerError, + resolve_password as resolve_broker_password, +) from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType from seedpass.core.encryption import EncryptionManager from seedpass.core.manager import PasswordManager @@ -29,7 +86,12 @@ 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") +app = typer.Typer( + help=( + "Agent-first non-interactive workflows. " + "Start with `agent bootstrap-context` and `--help` for safe autonomous usage." + ) +) PRIVATE_KINDS = { EntryType.SEED.value, @@ -80,6 +142,11 @@ DEFAULT_POLICY = { "export": { "allow_full_vault": False, }, + "secret_isolation": { + "enabled": True, + "high_risk_kinds": sorted(PRIVATE_KINDS), + "unlock_ttl_sec": 300, + }, # Legacy compatibility fields kept for older consumers/tests. "allow_kinds": [ EntryType.PASSWORD.value, @@ -91,6 +158,8 @@ DEFAULT_POLICY = { } TOKEN_STORE_VERSION = 1 +DEFAULT_AGENT_IDENTITY = "default-agent" +SAFE_AUTOMATION_BROKERS = {"keyring", "command"} def _utcnow() -> datetime: @@ -125,6 +194,95 @@ def _mask_secret(value: str | None) -> str | None: return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}" +def _automation_job_command( + *, + fingerprint: str, + query: str, + auth_broker: str, + broker_service: str, + broker_account: str, + reveal: bool, + lease_only: bool, + lease_ttl: int, + lease_uses: int, +) -> str: + parts = [ + "seedpass", + f"--fingerprint {fingerprint}", + "agent job-run", + query, + f"--auth-broker {auth_broker}", + f"--broker-service {broker_service}", + f"--broker-account {broker_account}", + ] + if reveal: + parts.append("--reveal") + if lease_only: + parts.extend( + ["--lease-only", f"--lease-ttl {lease_ttl}", f"--lease-uses {lease_uses}"] + ) + return " ".join(parts) + + +def _cron_template(*, schedule: str, command: str) -> str: + return f"{schedule} {command} >> /var/log/seedpass-agent-job.log 2>&1" + + +def _systemd_templates( + *, unit_name: str, schedule: str, command: str +) -> tuple[str, str]: + service = "\n".join( + [ + "[Unit]", + f"Description=SeedPass agent job ({unit_name})", + "", + "[Service]", + "Type=oneshot", + f"ExecStart={command}", + ] + ) + timer = "\n".join( + [ + "[Unit]", + f"Description=SeedPass agent timer ({unit_name})", + "", + "[Timer]", + f"OnCalendar={schedule}", + "Persistent=true", + "", + "[Install]", + "WantedBy=timers.target", + ] + ) + return service, timer + + +def _resolve_secret_for_kind( + pm: PasswordManager, service: EntryService, entry: dict[str, Any], index: int +) -> str: + kind = str(entry.get("type", entry.get("kind", ""))) + if kind == EntryType.PASSWORD.value: + return service.generate_password(int(entry.get("length", 12)), index) + if kind == EntryType.TOTP.value: + return service.get_totp_code(index) + if kind == EntryType.KEY_VALUE.value: + return str(entry.get("value", "")) + if kind == EntryType.SEED.value: + return pm.entry_manager.get_seed_phrase(index, pm.parent_seed) + if kind == EntryType.MANAGED_ACCOUNT.value: + return pm.entry_manager.get_managed_account_seed(index, pm.parent_seed) + if kind == EntryType.SSH.value: + secret, _pub = pm.entry_manager.get_ssh_key_pair(index, pm.parent_seed) + return secret + if kind == EntryType.PGP.value: + secret, _fp = pm.entry_manager.get_pgp_key(index, pm.parent_seed) + return secret + if kind == EntryType.NOSTR.value: + _npub, secret = pm.entry_manager.get_nostr_key_pair(index, pm.parent_seed) + return secret + raise ValueError("unsupported_kind_for_agent_get") + + def _deny_all_policy() -> dict[str, Any]: deny = { "version": 1, @@ -136,6 +294,11 @@ def _deny_all_policy() -> dict[str, Any]: "redact_fields": ["secret", "value", "private_key", "seed_phrase"], }, "export": {"allow_full_vault": False}, + "secret_isolation": { + "enabled": True, + "high_risk_kinds": sorted(PRIVATE_KINDS), + "unlock_ttl_sec": 300, + }, "allow_kinds": [], "deny_private_reveal": list(ALL_ENTRY_TYPES), "allow_export_import": False, @@ -148,6 +311,7 @@ def _normalize_legacy_policy(data: dict[str, Any]) -> dict[str, Any]: allow_kinds_raw = data.get("allow_kinds") deny_private_raw = data.get("deny_private_reveal") allow_export_import = bool(data.get("allow_export_import", False)) + isolation_cfg = data.get("secret_isolation") allow_kinds = ( [str(v) for v in allow_kinds_raw if str(v) in ALL_ENTRY_TYPES] @@ -190,6 +354,25 @@ def _normalize_legacy_policy(data: dict[str, Any]) -> dict[str, Any]: policy["allow_export_import"] = allow_export_import policy["export"] = {"allow_full_vault": allow_export_import} policy["output"] = dict(DEFAULT_POLICY["output"]) + secret_isolation = dict(DEFAULT_POLICY["secret_isolation"]) + if isinstance(isolation_cfg, dict): + secret_isolation["enabled"] = bool(isolation_cfg.get("enabled", True)) + raw_high_risk = isolation_cfg.get("high_risk_kinds") + if isinstance(raw_high_risk, list): + secret_isolation["high_risk_kinds"] = [ + str(v).strip().lower() + for v in raw_high_risk + if str(v).strip().lower() in PRIVATE_KINDS + ] + raw_unlock_ttl = isolation_cfg.get("unlock_ttl_sec") + if raw_unlock_ttl is not None: + try: + ttl = int(raw_unlock_ttl) + if ttl > 0: + secret_isolation["unlock_ttl_sec"] = ttl + except (TypeError, ValueError): + pass + policy["secret_isolation"] = secret_isolation return policy @@ -275,6 +458,61 @@ def _normalize_policy(data: dict[str, Any], *, strict: bool) -> dict[str, Any]: output_norm["redact_fields"] = [str(v) for v in redact_fields] policy["output"] = output_norm + approvals_cfg = policy.get("approvals") + if not isinstance(approvals_cfg, dict): + if strict and "approvals" in data: + raise ValueError("approvals must be an object") + approvals_cfg = {} + raw_require_for = approvals_cfg.get( + "require_for", DEFAULT_POLICY["approvals"]["require_for"] + ) + if not isinstance(raw_require_for, list): + if strict: + raise ValueError("approvals.require_for must be a list") + raw_require_for = DEFAULT_POLICY["approvals"]["require_for"] + require_for: list[str] = [] + for value in raw_require_for: + action = str(value).strip().lower() + if action in VALID_APPROVAL_ACTIONS and action not in require_for: + require_for.append(action) + elif strict: + raise ValueError(f"invalid approval action: {value}") + policy["approvals"] = {"require_for": require_for} + + isolation_cfg = policy.get("secret_isolation") + if not isinstance(isolation_cfg, dict): + if strict and "secret_isolation" in data: + raise ValueError("secret_isolation must be an object") + isolation_cfg = {} + raw_high_risk = isolation_cfg.get( + "high_risk_kinds", DEFAULT_POLICY["secret_isolation"]["high_risk_kinds"] + ) + if not isinstance(raw_high_risk, list): + if strict: + raise ValueError("secret_isolation.high_risk_kinds must be a list") + raw_high_risk = DEFAULT_POLICY["secret_isolation"]["high_risk_kinds"] + high_risk_kinds: list[str] = [] + for value in raw_high_risk: + kind = str(value).strip().lower() + if kind in PRIVATE_KINDS and kind not in high_risk_kinds: + high_risk_kinds.append(kind) + elif strict: + raise ValueError(f"invalid secret isolation kind: {value}") + unlock_ttl_sec = int( + isolation_cfg.get( + "unlock_ttl_sec", DEFAULT_POLICY["secret_isolation"]["unlock_ttl_sec"] + ) + ) + if unlock_ttl_sec < 1: + if strict: + raise ValueError("secret_isolation.unlock_ttl_sec must be >= 1") + unlock_ttl_sec = int(DEFAULT_POLICY["secret_isolation"]["unlock_ttl_sec"]) + policy["secret_isolation"] = { + "enabled": bool(isolation_cfg.get("enabled", True)), + "high_risk_kinds": high_risk_kinds, + "unlock_ttl_sec": unlock_ttl_sec, + } + export_cfg = policy.get("export") if not isinstance(export_cfg, dict): export_cfg = {} @@ -344,6 +582,60 @@ def _save_policy(policy: dict[str, Any]) -> None: os.chmod(path, 0o600) +def _load_policy_from_file(path: Path, *, strict: bool = True) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise ValueError("agent policy file is not valid JSON") from exc + if not isinstance(data, dict): + raise ValueError("agent policy file must be a JSON object") + return _normalize_policy(data, strict=strict) + + +def _policy_json_lines(policy: dict[str, Any]) -> list[str]: + return json.dumps(policy, indent=2, sort_keys=True).splitlines() + + +def _policy_full_hash(policy: dict[str, Any]) -> str: + canonical = json.dumps( + policy, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _policy_diff_summary( + current: dict[str, Any], candidate: dict[str, Any] +) -> dict[str, Any]: + current_rule_map = {str(r.get("id", "")): r for r in current.get("rules", [])} + candidate_rule_map = {str(r.get("id", "")): r for r in candidate.get("rules", [])} + + current_ids = {k for k in current_rule_map if k} + candidate_ids = {k for k in candidate_rule_map if k} + added = sorted(candidate_ids - current_ids) + removed = sorted(current_ids - candidate_ids) + modified = sorted( + rid + for rid in (current_ids & candidate_ids) + if current_rule_map[rid] != candidate_rule_map[rid] + ) + + return { + "default_effect_changed": current.get("default_effect") + != candidate.get("default_effect"), + "export_allow_full_vault_changed": bool( + current.get("export", {}).get("allow_full_vault", False) + ) + != bool(candidate.get("export", {}).get("allow_full_vault", False)), + "safe_output_default_changed": bool( + current.get("output", {}).get("safe_output_default", True) + ) + != bool(candidate.get("output", {}).get("safe_output_default", True)), + "rules_added": added, + "rules_removed": removed, + "rules_modified": modified, + } + + def _agent_password( *, broker: str, @@ -419,6 +711,7 @@ def _issue_token_record( kinds: list[str], label_regex: str, uses: int, + identity_id: str, ) -> tuple[str, dict[str, Any]]: raw = secrets.token_urlsafe(32) now = _utcnow() @@ -434,6 +727,7 @@ def _issue_token_record( "kinds": kinds, "label_regex": label_regex, "uses_remaining": int(uses), + "identity_id": str(identity_id), } return raw, record @@ -483,6 +777,12 @@ def _validate_token( if uses_remaining <= 0: return False, "token_exhausted", None + identity_id = str(rec.get("identity_id", "")).strip() + if not identity_id: + return False, "token_identity_missing", None + if not identity_active(identity_id): + return False, "token_identity_revoked", None + if consume_use: rec["uses_remaining"] = uses_remaining - 1 _save_token_store(store) @@ -580,6 +880,36 @@ def _build_entry_path(index: int, kind: str, label: str) -> str: return f"entry/{index}/{kind}/{label}" +def _high_risk_lock_required(policy: dict[str, Any], kind: str) -> bool: + isolation_cfg = policy.get("secret_isolation", {}) + if not isinstance(isolation_cfg, dict): + return False + if not bool(isolation_cfg.get("enabled", True)): + return False + kinds = isolation_cfg.get("high_risk_kinds", []) + if not isinstance(kinds, list): + return False + return str(kind).lower() in {str(v).lower() for v in kinds} + + +def _hydrate_partition_entry_if_needed( + pm: PasswordManager, + *, + fingerprint: str, + index: int, + entry: dict[str, Any], +) -> dict[str, Any]: + if str(entry.get("partition", "")).strip().lower() != "high_risk": + return entry + tag = unlocked_partition_key_tag(fingerprint=str(fingerprint)) + if not tag: + raise ValueError("policy_deny:high_risk_locked") + loaded = load_partition_entry(Path(pm.fingerprint_dir), tag, int(index)) + if not isinstance(loaded, dict): + raise ValueError("high_risk_partition_entry_missing") + return loaded + + def _token_posture_summary() -> dict[str, int]: store = _load_token_store() now = _utcnow() @@ -620,7 +950,9 @@ def _severity_rank(severity: str) -> int: return order.get(severity, 0) -def _posture_findings(policy: dict[str, Any], policy_status: str) -> list[dict[str, Any]]: +def _posture_findings( + policy: dict[str, Any], policy_status: str +) -> list[dict[str, Any]]: findings: list[dict[str, Any]] = [] if policy_status != "ok": findings.append( @@ -652,6 +984,23 @@ def _posture_findings(policy: dict[str, Any], policy_status: str) -> list[dict[s } ) + require_for = { + str(v).strip().lower() + for v in policy.get("approvals", {}).get("require_for", []) + if str(v).strip() + } + required_approvals = {"export", "reveal_parent_seed", "private_key_retrieval"} + missing_approvals = sorted(list(required_approvals - require_for)) + if missing_approvals: + findings.append( + { + "id": "approvals_missing_required_actions", + "severity": "high", + "message": f"Approval gate policy is missing required actions: {', '.join(missing_approvals)}.", + "remediation": "Require approvals for export, reveal_parent_seed, and private_key_retrieval.", + } + ) + if bool(policy.get("allow_export_import", False)): findings.append( { @@ -672,10 +1021,98 @@ def _posture_findings(policy: dict[str, Any], policy_status: str) -> list[dict[s } ) + isolation_cfg = policy.get("secret_isolation", {}) + unlock_ttl_sec = int(isolation_cfg.get("unlock_ttl_sec", 300) or 300) + if bool(isolation_cfg.get("enabled", True)) and unlock_ttl_sec > 1800: + findings.append( + { + "id": "high_risk_unlock_ttl_too_long", + "severity": "medium", + "message": f"High-risk unlock TTL is {unlock_ttl_sec}s (>1800s).", + "remediation": "Reduce secret_isolation.unlock_ttl_sec to 1800 seconds or lower.", + } + ) + + allows_private_read = False + for rule in policy.get("rules", []): + if str(rule.get("effect", "")).lower() != "allow": + continue + ops = {str(v).lower() for v in rule.get("operations", [])} + if "read" not in ops: + continue + kinds = {str(v).lower() for v in rule.get("kinds", [])} + if kinds.intersection(PRIVATE_KINDS): + allows_private_read = True + fields = {str(v).lower() for v in rule.get("fields", [])} + label_regex = str(rule.get("label_regex", ".*")) + path_regex = str(rule.get("path_regex", ".*")) + if ( + "read" in ops + and "secret" in fields + and (not kinds or len(kinds) >= len(ALL_ENTRY_TYPES)) + and label_regex in {".*", "^.*$"} + and path_regex in {"^entry/.*$", ".*", "^.*$"} + ): + findings.append( + { + "id": "over_permissive_read_rule", + "severity": "high", + "message": f"Policy allow rule '{rule.get('id', 'rule')}' broadly permits secret reads.", + "remediation": "Scope read rules by kind, label_regex, and field exposure.", + } + ) + if not bool(isolation_cfg.get("enabled", True)): + findings.append( + { + "id": "secret_isolation_disabled", + "severity": "high", + "message": "Secret class isolation is disabled for high-risk kinds.", + "remediation": "Enable secret_isolation and require separate high-risk unlock.", + } + ) + elif allows_private_read and not high_risk_factor_configured(): + findings.append( + { + "id": "high_risk_factor_not_configured", + "severity": "medium", + "message": "High-risk unlock factor is not configured.", + "remediation": "Run `seedpass agent high-risk-factor-set` before enabling autonomous private-key retrieval.", + } + ) + elif allows_private_read and "private_key_retrieval" not in require_for: + findings.append( + { + "id": "private_read_without_approval_gate", + "severity": "high", + "message": "Policy allows private-kind reads without private_key_retrieval approval gate.", + "remediation": "Add private_key_retrieval to approvals.require_for.", + } + ) + store = _load_token_store() now = _utcnow() long_lived = 0 broad_scope = 0 + missing_identity = 0 + revoked_identity_tokens = 0 + overdue_rotation = 0 + active_identities = list_identities(include_revoked=False) + identities_by_id = {str(rec.get("id", "")): rec for rec in active_identities} + high_rotation_days = [ + str(rec.get("id", "")) + for rec in active_identities + if int(rec.get("rotation_days", 30) or 30) > 90 + ] + if high_rotation_days: + findings.append( + { + "id": "identity_rotation_window_too_long", + "severity": "medium", + "message": f"{len(high_rotation_days)} identity profile(s) have rotation_days > 90.", + "remediation": "Set identity rotation_days to 90 or less for tighter credential rotation.", + } + ) + for rec in store.get("tokens", []): if rec.get("revoked_at_utc"): continue @@ -695,6 +1132,24 @@ def _posture_findings(policy: dict[str, Any], policy_status: str) -> list[dict[s elif not scopes: broad_scope += 1 + identity_id = str(rec.get("identity_id", "")).strip() + if not identity_id: + missing_identity += 1 + elif not identity_active(identity_id): + revoked_identity_tokens += 1 + else: + identity_rec = identities_by_id.get(identity_id) + if identity_rec and created: + try: + rotation_days = int(identity_rec.get("rotation_days", 30) or 30) + age_days = ( + _utcnow() - _parse_iso8601(created) + ).total_seconds() / 86400 + if age_days > rotation_days: + overdue_rotation += 1 + except Exception: + pass + if exp: try: if _parse_iso8601(exp) <= now: @@ -728,10 +1183,229 @@ def _posture_findings(policy: dict[str, Any], policy_status: str) -> list[dict[s "remediation": "Issue least-privilege tokens (prefer read-only, narrow kinds/labels).", } ) + if missing_identity: + findings.append( + { + "id": "tokens_missing_identity", + "severity": "medium", + "message": f"{missing_identity} token(s) are not bound to an agent identity.", + "remediation": "Reissue tokens with --identity-id and revoke legacy unbound tokens.", + } + ) + if revoked_identity_tokens: + findings.append( + { + "id": "tokens_for_revoked_identity", + "severity": "high", + "message": f"{revoked_identity_tokens} token(s) reference revoked or missing identities.", + "remediation": "Revoke affected tokens and rotate new tokens under active identities.", + } + ) + if overdue_rotation: + findings.append( + { + "id": "token_rotation_overdue", + "severity": "medium", + "message": f"{overdue_rotation} active token(s) exceed identity rotation policy window.", + "remediation": "Rotate overdue tokens and reissue under current identity policy.", + } + ) return findings +def _runtime_config_findings(pm: PasswordManager) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + cfg_mgr = getattr(pm, "config_manager", None) + if cfg_mgr is None: + return findings + + try: + if bool(cfg_mgr.get_quick_unlock()): + findings.append( + { + "id": "quick_unlock_enabled", + "severity": "high", + "message": "Quick unlock is enabled for the profile.", + "remediation": "Disable quick unlock for unattended or shared environments.", + } + ) + except Exception: + pass + + try: + kdf_mode = str(cfg_mgr.get_kdf_mode()).lower() + except Exception: + kdf_mode = "pbkdf2" + try: + kdf_iterations = int(cfg_mgr.get_kdf_iterations()) + except Exception: + kdf_iterations = 0 + if kdf_mode == "pbkdf2" and kdf_iterations < int( + getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000) + ): + findings.append( + { + "id": "weak_kdf_iterations", + "severity": "high", + "message": f"PBKDF2 iterations are set to {kdf_iterations}, below policy floor.", + "remediation": "Increase kdf_iterations to at least the default floor.", + } + ) + + try: + parts = cfg_mgr.get_secret_class_partitions() + high_risk = parts.get("high_risk", {}) if isinstance(parts, dict) else {} + if bool(high_risk.get("separate_factor_required", True)) and bool( + high_risk.get("unlocked", False) + ): + findings.append( + { + "id": "high_risk_partition_persistently_unlocked", + "severity": "high", + "message": "High-risk partition is marked unlocked in profile config.", + "remediation": "Lock high-risk partition by default and require step-up unlock per session.", + } + ) + except Exception: + pass + + return findings + + +def _remediation_actions(findings: list[dict[str, Any]]) -> list[dict[str, Any]]: + guidance: dict[str, dict[str, Any]] = { + "policy_invalid": { + "title": "Fix invalid policy file", + "commands": [ + "seedpass agent policy-lint", + "seedpass agent policy-review --file ", + "seedpass agent policy-apply --file ", + ], + }, + "policy_default_allow": { + "title": "Set secure policy default", + "commands": ["seedpass agent policy-set --safe-output-default"], + "notes": ["Set `default_effect` to `deny` and add explicit allow rules."], + }, + "approvals_missing_required_actions": { + "title": "Enforce approval gates for risky actions", + "commands": ["seedpass agent policy-review --file "], + "notes": [ + "Ensure approvals.require_for includes export, reveal_parent_seed, and private_key_retrieval." + ], + }, + "safe_output_disabled": { + "title": "Enable safe output redaction", + "commands": ["seedpass agent policy-set --safe-output-default"], + }, + "export_import_allowed": { + "title": "Disable broad export/import for agent profiles", + "commands": ["seedpass agent policy-review --file "], + "notes": ["Set export.allow_full_vault=false and keep allow_export_import=false."], + }, + "secret_isolation_disabled": { + "title": "Enable high-risk secret isolation", + "commands": [ + "seedpass agent high-risk-factor-set --factor-env SEEDPASS_HIGH_RISK_FACTOR", + "seedpass agent policy-review --file ", + ], + }, + "high_risk_factor_not_configured": { + "title": "Configure high-risk unlock factor", + "commands": [ + "seedpass agent high-risk-factor-set --factor-env SEEDPASS_HIGH_RISK_FACTOR" + ], + }, + "high_risk_unlock_ttl_too_long": { + "title": "Reduce high-risk unlock TTL", + "commands": ["seedpass agent policy-review --file "], + "notes": ["Set secret_isolation.unlock_ttl_sec <= 1800."], + }, + "over_permissive_read_rule": { + "title": "Narrow broad secret-read policy rules", + "commands": ["seedpass agent policy-review --file "], + }, + "private_read_without_approval_gate": { + "title": "Require approval for private key retrieval", + "commands": ["seedpass agent policy-review --file "], + }, + "quick_unlock_enabled": { + "title": "Disable quick unlock in runtime config", + "commands": ["seedpass config set quick_unlock false"], + }, + "weak_kdf_iterations": { + "title": "Increase KDF iteration strength", + "commands": ["seedpass config set kdf_iterations 200000"], + }, + "high_risk_partition_persistently_unlocked": { + "title": "Lock high-risk partition by default", + "commands": ["seedpass --fingerprint agent high-risk-lock"], + }, + "long_lived_tokens": { + "title": "Shorten token TTL and rotate", + "commands": [ + "seedpass agent token-list", + "seedpass agent token-revoke ", + "seedpass agent token-issue --ttl 3600 --uses 1", + ], + }, + "overbroad_token_scopes": { + "title": "Reduce token scopes", + "commands": [ + "seedpass agent token-revoke ", + "seedpass agent token-issue --scope read --kinds password --uses 1", + ], + }, + "tokens_missing_identity": { + "title": "Bind tokens to active identities", + "commands": [ + "seedpass agent identity-create --id --owner ", + "seedpass agent token-issue --identity-id ", + ], + }, + "tokens_for_revoked_identity": { + "title": "Rotate tokens from revoked identities", + "commands": [ + "seedpass agent token-revoke ", + "seedpass agent token-issue --identity-id ", + ], + }, + "identity_rotation_window_too_long": { + "title": "Tighten identity rotation policy", + "commands": ["seedpass agent identity-create --id --rotation-days 30"], + "notes": ["Recreate identity metadata with a shorter rotation window."], + }, + "token_rotation_overdue": { + "title": "Rotate overdue active tokens", + "commands": [ + "seedpass agent token-list", + "seedpass agent token-revoke ", + "seedpass agent token-issue --identity-id ", + ], + }, + } + actions: list[dict[str, Any]] = [] + seen: set[str] = set() + for finding in findings: + fid = str(finding.get("id", "")) + if not fid or fid in seen: + continue + seen.add(fid) + base = guidance.get(fid) + if not base: + continue + action = { + "finding_id": fid, + "title": base["title"], + "commands": base["commands"], + } + if "notes" in base: + action["notes"] = base["notes"] + actions.append(action) + return actions + + @app.command("init") def agent_init( seed: Optional[str] = typer.Option( @@ -834,6 +1508,11 @@ def agent_init( "fingerprint": fingerprint, "profile_dir": str(profile_dir), "generated_seed": seed_phrase if generated and print_seed else None, + "next_steps": [ + "seedpass --fingerprint vault unlock --auth-broker keyring", + "seedpass capabilities --format json", + "seedpass --fingerprint agent bootstrap-context", + ], } typer.echo(json.dumps(payload, indent=2)) @@ -849,10 +1528,155 @@ def agent_policy_show() -> None: @app.command("policy-lint") -def agent_policy_lint() -> None: - """Validate the policy file and print a normalized representation.""" - policy = _load_policy(strict=True) - typer.echo(json.dumps({"status": "ok", "policy": policy}, indent=2)) +def agent_policy_lint( + file: Optional[Path] = typer.Option( + None, "--file", help="Policy file to lint (defaults to active agent policy)" + ), + format: str = typer.Option( + "json", + "--format", + help="Output format", + click_type=click.Choice(["json", "text"], case_sensitive=False), + ), +) -> None: + """Validate a policy file and emit deterministic normalized output.""" + if file is None: + policy = _load_policy(strict=True) + path = _policy_path() + else: + policy = _load_policy_from_file(file, strict=True) + path = file + policy_stamp = compute_policy_stamp(policy) + policy_hash = _policy_full_hash(policy) + payload = { + "status": "ok", + "path": str(path), + "policy_stamp": policy_stamp, + "policy_hash": policy_hash, + "policy": policy, + } + if format.lower() == "text": + typer.echo("policy lint: ok") + typer.echo(f"path: {payload['path']}") + typer.echo(f"policy_stamp: {policy_stamp}") + typer.echo(f"policy_hash: {policy_hash}") + typer.echo(f"rules: {len(policy.get('rules', []))}") + return + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("policy-review") +def agent_policy_review( + file: Path = typer.Option(..., "--file", help="Candidate policy JSON file"), + format: str = typer.Option( + "json", + "--format", + help="Output format", + click_type=click.Choice(["json", "text"], case_sensitive=False), + ), +) -> None: + """Compare active policy to candidate policy for change review.""" + current = _load_policy(strict=False) + candidate = _load_policy_from_file(file, strict=True) + current_lines = _policy_json_lines(current) + candidate_lines = _policy_json_lines(candidate) + diff_lines = list( + difflib.unified_diff( + current_lines, + candidate_lines, + fromfile="active_policy", + tofile=str(file), + lineterm="", + ) + ) + diff_summary = _policy_diff_summary(current, candidate) + findings = _posture_findings(candidate, "ok") + risky_findings = [ + f for f in findings if _severity_rank(f.get("severity", "info")) >= 3 + ] + payload = { + "status": "ok", + "changed": current != candidate, + "candidate_path": str(file), + "current_policy_stamp": compute_policy_stamp(current), + "candidate_policy_stamp": compute_policy_stamp(candidate), + "current_policy_hash": _policy_full_hash(current), + "candidate_policy_hash": _policy_full_hash(candidate), + "diff_summary": diff_summary, + "risky_finding_count": len(risky_findings), + "risky_findings": risky_findings, + "diff": diff_lines, + } + if format.lower() == "text": + typer.echo(f"policy changed: {payload['changed']}") + typer.echo(f"candidate: {payload['candidate_path']}") + typer.echo(f"current stamp: {payload['current_policy_stamp']}") + typer.echo(f"candidate stamp: {payload['candidate_policy_stamp']}") + typer.echo(f"risky findings: {payload['risky_finding_count']}") + if diff_summary["rules_added"]: + typer.echo(f"rules added: {', '.join(diff_summary['rules_added'])}") + if diff_summary["rules_removed"]: + typer.echo(f"rules removed: {', '.join(diff_summary['rules_removed'])}") + if diff_summary["rules_modified"]: + typer.echo(f"rules modified: {', '.join(diff_summary['rules_modified'])}") + if diff_lines: + typer.echo("diff:") + for line in diff_lines: + typer.echo(line) + return + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("policy-apply") +def agent_policy_apply( + file: Path = typer.Option(..., "--file", help="Candidate policy JSON file"), + dry_run: bool = typer.Option( + False, "--dry-run", help="Validate and review candidate without saving" + ), + allow_risky: bool = typer.Option( + False, + "--allow-risky", + help="Allow apply when review detects high-risk findings", + ), +) -> None: + """Apply a reviewed policy file with optional risk gate.""" + current = _load_policy(strict=False) + candidate = _load_policy_from_file(file, strict=True) + findings = _posture_findings(candidate, "ok") + risky_findings = [ + f for f in findings if _severity_rank(f.get("severity", "info")) >= 3 + ] + changed = current != candidate + payload = { + "status": "ok", + "changed": changed, + "dry_run": bool(dry_run), + "candidate_path": str(file), + "candidate_policy_stamp": compute_policy_stamp(candidate), + "candidate_policy_hash": _policy_full_hash(candidate), + "risky_finding_count": len(risky_findings), + "risky_findings": risky_findings, + } + if risky_findings and not allow_risky: + payload["status"] = "denied" + payload["reason"] = "risky_policy_change_requires_allow_risky" + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + if not dry_run and changed: + _save_policy(candidate) + payload["applied"] = True + _append_audit_event( + "agent_policy_applied", + { + "candidate_path": str(file), + "policy_stamp": payload["candidate_policy_stamp"], + "policy_hash": payload["candidate_policy_hash"], + "risky_finding_count": payload["risky_finding_count"], + }, + ) + else: + payload["applied"] = False + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) @app.command("policy-set") @@ -894,6 +1718,357 @@ def agent_policy_set( typer.echo(json.dumps(policy, indent=2)) +@app.command("identity-create") +def agent_identity_create( + identity_id: str = typer.Option(..., "--id", help="Stable agent identity id"), + owner: str = typer.Option("unowned", "--owner", help="Owner/team for the identity"), + policy_binding: str = typer.Option( + "default", "--policy-binding", help="Named policy binding tag" + ), + rotation_days: int = typer.Option( + 30, "--rotation-days", min=1, help="Target token rotation interval in days" + ), +) -> None: + """Create a first-class agent identity.""" + try: + rec = create_identity( + identity_id=identity_id, + owner=owner, + policy_binding=policy_binding, + rotation_days=rotation_days, + ) + except ValueError as exc: + reason = str(exc) + payload = {"status": "error", "reason": reason, "id": identity_id} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + _append_audit_event( + "agent_identity_created", + { + "id": rec.get("id"), + "owner": rec.get("owner"), + "policy_binding": rec.get("policy_binding"), + "rotation_days": rec.get("rotation_days"), + }, + ) + typer.echo(json.dumps({"status": "ok", "identity": rec}, indent=2, sort_keys=True)) + + +@app.command("identity-list") +def agent_identity_list( + show_revoked: bool = typer.Option(False, "--show-revoked") +) -> None: + """List registered agent identities.""" + typer.echo( + json.dumps( + { + "status": "ok", + "identities": list_identities(include_revoked=show_revoked), + }, + indent=2, + sort_keys=True, + ) + ) + + +@app.command("identity-revoke") +def agent_identity_revoke( + identity_id: str = typer.Argument(..., help="Identity id") +) -> None: + """Revoke an agent identity.""" + if not revoke_identity(identity_id): + payload = {"status": "error", "reason": "identity_not_found", "id": identity_id} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + _append_audit_event("agent_identity_revoked", {"id": identity_id}) + typer.echo(json.dumps({"status": "ok", "id": identity_id}, indent=2)) + + +@app.command("high-risk-factor-set") +def agent_high_risk_factor_set( + factor_env: str = typer.Option( + "SEEDPASS_HIGH_RISK_FACTOR", + "--factor-env", + help="Env var containing high-risk unlock factor when --auth-broker=env", + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Factor source (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: str = typer.Option( + "high-risk-factor", + "--broker-account", + help="Keyring account identity for factor retrieval", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints factor to stdout for --auth-broker=command", + ), +) -> None: + """Configure the separate high-risk unlock factor.""" + factor = _agent_password( + broker=auth_broker, + password_env=factor_env, + broker_service=broker_service, + broker_account=broker_account, + broker_command=broker_command, + ) + if not factor: + raise typer.BadParameter("High-risk factor cannot be empty.") + set_high_risk_factor(factor) + _append_audit_event("agent_high_risk_factor_set", {"configured": True}) + typer.echo(json.dumps({"status": "ok", "configured": True}, indent=2)) + + +@app.command("high-risk-unlock") +def agent_high_risk_unlock( + ctx: typer.Context, + ttl: Optional[int] = typer.Option( + None, + "--ttl", + min=1, + help="Unlock TTL in seconds (defaults to policy secret_isolation.unlock_ttl_sec)", + ), + factor_env: str = typer.Option( + "SEEDPASS_HIGH_RISK_FACTOR", + "--factor-env", + help="Env var containing high-risk unlock factor when --auth-broker=env", + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Factor source (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to :high-risk)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints factor to stdout for --auth-broker=command", + ), +) -> None: + """Unlock access to high-risk secret classes for the active fingerprint.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + if not high_risk_factor_configured(): + payload = {"status": "error", "reason": "high_risk_factor_not_configured"} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + factor = _agent_password( + broker=auth_broker, + password_env=factor_env, + broker_service=broker_service, + broker_account=broker_account or f"{fingerprint}:high-risk", + broker_command=broker_command, + ) + if not verify_high_risk_factor(factor): + payload = {"status": "denied", "reason": "high_risk_factor_invalid"} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + try: + key_tag = partition_key_tag_for_factor(factor) + except ValueError as exc: + payload = {"status": "denied", "reason": str(exc)} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + policy = _load_policy(strict=False) + isolation_cfg = policy.get("secret_isolation", {}) + default_ttl = int(isolation_cfg.get("unlock_ttl_sec", 300)) + session = grant_high_risk_unlock( + fingerprint=str(fingerprint), + ttl_seconds=int(ttl or default_ttl), + partition_key_tag=key_tag, + ) + _append_audit_event( + "agent_high_risk_unlocked", + { + "fingerprint": fingerprint, + "expires_at_utc": session.get("expires_at_utc"), + "ttl_seconds": int(ttl or default_ttl), + }, + ) + typer.echo( + json.dumps( + { + "status": "ok", + "fingerprint": fingerprint, + "expires_at_utc": session.get("expires_at_utc"), + }, + indent=2, + sort_keys=True, + ) + ) + + +@app.command("high-risk-lock") +def agent_high_risk_lock(ctx: typer.Context) -> None: + """Revoke active high-risk unlock session for the active fingerprint.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + changed = revoke_high_risk_unlock(fingerprint=str(fingerprint)) + _append_audit_event( + "agent_high_risk_locked", + {"fingerprint": fingerprint, "changed": bool(changed)}, + ) + typer.echo( + json.dumps( + {"status": "ok", "fingerprint": fingerprint, "locked": bool(changed)}, + indent=2, + sort_keys=True, + ) + ) + + +@app.command("high-risk-status") +def agent_high_risk_status(ctx: typer.Context) -> None: + """Show high-risk unlock session status for the active fingerprint.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + unlocked, expires_at = high_risk_unlocked(fingerprint=str(fingerprint)) + payload = { + "status": "ok", + "fingerprint": fingerprint, + "factor_configured": high_risk_factor_configured(), + "unlocked": bool(unlocked), + "expires_at_utc": expires_at or None, + "partition_key_tag_present": bool( + unlocked_partition_key_tag(fingerprint=str(fingerprint)) + ), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("high-risk-partition-migrate") +def agent_high_risk_partition_migrate( + ctx: typer.Context, + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Password source (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), + factor_env: str = typer.Option( + "SEEDPASS_HIGH_RISK_FACTOR", + "--factor-env", + help="Env var containing high-risk factor", + ), + factor_broker: str = typer.Option( + "env", + "--factor-broker", + help="Factor source (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + factor_broker_service: str = typer.Option( + "seedpass", + "--factor-broker-service", + help="Keyring service name for high-risk factor broker", + ), + factor_broker_account: Optional[str] = typer.Option( + None, + "--factor-broker-account", + help="Keyring account for factor (defaults to :high-risk)", + ), + factor_broker_command: Optional[str] = typer.Option( + None, + "--factor-broker-command", + help="Command that prints high-risk factor to stdout", + ), +) -> None: + """Move high-risk entries from primary index into encrypted high-risk partition.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + + password = _agent_password( + broker=auth_broker, + password_env=password_env, + broker_service=broker_service, + broker_account=broker_account or str(fingerprint), + broker_command=broker_command, + ) + factor = _agent_password( + broker=factor_broker, + password_env=factor_env, + broker_service=factor_broker_service, + broker_account=factor_broker_account or f"{fingerprint}:high-risk", + broker_command=factor_broker_command, + ) + if not verify_high_risk_factor(factor): + payload = {"status": "denied", "reason": "high_risk_factor_invalid"} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + try: + key_tag = partition_key_tag_for_factor(factor) + except ValueError as exc: + payload = {"status": "denied", "reason": str(exc)} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + pm = PasswordManager(fingerprint=fingerprint, password=password) + policy = _load_policy(strict=False) + kinds = { + str(v).strip().lower() + for v in policy.get("secret_isolation", {}).get( + "high_risk_kinds", PRIVATE_KINDS + ) + if str(v).strip().lower() + } + result = migrate_high_risk_entries( + vault=pm.vault, + fingerprint_dir=Path(pm.fingerprint_dir), + partition_key_tag=key_tag, + high_risk_kinds=kinds, + ) + _append_audit_event( + "agent_high_risk_partition_migrated", + { + "fingerprint": fingerprint, + "moved_count": int(result.get("moved_count", 0)), + "partition_file": result.get("partition_file"), + }, + ) + typer.echo(json.dumps({"status": "ok", **result}, indent=2, sort_keys=True)) + + @app.command("token-issue") def agent_token_issue( name: str = typer.Option("agent", "--name", help="Human-readable token name"), @@ -925,6 +2100,16 @@ def agent_token_issue( min=1, help="Maximum number of successful uses", ), + identity_id: str = typer.Option( + DEFAULT_AGENT_IDENTITY, + "--identity-id", + help="Agent identity bound to this token", + ), + identity_owner: str = typer.Option( + "system", + "--identity-owner", + help="Owner used when auto-creating identity", + ), ) -> None: """Issue a short-lived scoped token for agent operations.""" try: @@ -937,7 +2122,29 @@ def agent_token_issue( raise typer.BadParameter("At least one --scope is required") kinds = list(kind or []) - raw, record = _issue_token_record(name, ttl, scopes, kinds, label_regex, uses) + identity = ensure_identity( + identity_id, + owner=identity_owner, + policy_binding="default", + rotation_days=30, + ) + if identity.get("revoked_at_utc"): + payload = { + "status": "error", + "reason": "identity_revoked", + "identity_id": identity_id, + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + raw, record = _issue_token_record( + name, + ttl, + scopes, + kinds, + label_regex, + uses, + str(identity.get("id", identity_id)), + ) store = _load_token_store() store.setdefault("version", TOKEN_STORE_VERSION) @@ -950,6 +2157,7 @@ def agent_token_issue( { "id": record["id"], "name": name, + "identity_id": record["identity_id"], "scopes": scopes, "kinds": kinds, "expires_at_utc": record["expires_at_utc"], @@ -964,6 +2172,7 @@ def agent_token_issue( "token": raw, "token_id": record["id"], "name": name, + "identity_id": record["identity_id"], "scopes": scopes, "kinds": kinds, "label_regex": label_regex, @@ -976,7 +2185,9 @@ def agent_token_issue( @app.command("token-list") -def agent_token_list(show_revoked: bool = typer.Option(False, "--show-revoked")) -> None: +def agent_token_list( + show_revoked: bool = typer.Option(False, "--show-revoked") +) -> None: """List issued tokens without exposing raw token material.""" store = _load_token_store() tokens = [] @@ -994,13 +2205,16 @@ def agent_token_list(show_revoked: bool = typer.Option(False, "--show-revoked")) "kinds": rec.get("kinds", []), "label_regex": rec.get("label_regex", ".*"), "uses_remaining": rec.get("uses_remaining", 0), + "identity_id": rec.get("identity_id"), } ) typer.echo(json.dumps({"status": "ok", "tokens": tokens}, indent=2)) @app.command("token-revoke") -def agent_token_revoke(token_id: str = typer.Argument(..., help="Token id to revoke")) -> None: +def agent_token_revoke( + token_id: str = typer.Argument(..., help="Token id to revoke") +) -> None: """Revoke an issued token by id.""" store = _load_token_store() now = _utcnow().isoformat() @@ -1012,7 +2226,9 @@ def agent_token_revoke(token_id: str = typer.Argument(..., help="Token id to rev break if not changed: - typer.echo(json.dumps({"status": "error", "reason": "token_not_found"}, indent=2)) + typer.echo( + json.dumps({"status": "error", "reason": "token_not_found"}, indent=2) + ) raise typer.Exit(1) _save_token_store(store) @@ -1020,6 +2236,957 @@ def agent_token_revoke(token_id: str = typer.Argument(..., help="Token id to rev typer.echo(json.dumps({"status": "ok", "token_id": token_id}, indent=2)) +@app.command("approval-issue") +def agent_approval_issue( + action: str = typer.Option( + "export", + "--action", + help="Action requiring approval", + click_type=click.Choice( + sorted(list(VALID_APPROVAL_ACTIONS)), + case_sensitive=False, + ), + ), + ttl: int = typer.Option( + 300, + "--ttl", + min=1, + help="Approval lifetime in seconds", + ), + uses: int = typer.Option( + 1, + "--uses", + min=1, + help="Number of successful uses before invalidation", + ), + resource: str = typer.Option( + "*", + "--resource", + help="Resource scope for approval (e.g. vault:full)", + ), + issued_by: str = typer.Option( + "manual", + "--issued-by", + help="Issuer identity for audit metadata", + ), +) -> None: + """Issue a short-lived approval grant for risky actions.""" + rec = issue_approval( + action=action.strip().lower(), + ttl_seconds=ttl, + uses=uses, + resource=resource, + issued_by=issued_by, + ) + _append_audit_event( + "agent_approval_issued", + { + "id": rec.get("id"), + "action": rec.get("action"), + "resource": rec.get("resource"), + "expires_at_utc": rec.get("expires_at_utc"), + "uses_remaining": rec.get("uses_remaining"), + }, + ) + typer.echo(json.dumps({"status": "ok", "approval": rec}, indent=2, sort_keys=True)) + + +@app.command("approval-list") +def agent_approval_list( + show_revoked: bool = typer.Option(False, "--show-revoked") +) -> None: + """List approval grants without exposing secret material.""" + approvals = list_approvals(include_revoked=show_revoked) + typer.echo( + json.dumps( + {"status": "ok", "approvals": approvals}, + indent=2, + sort_keys=True, + ) + ) + + +@app.command("approval-revoke") +def agent_approval_revoke( + approval_id: str = typer.Argument(..., help="Approval id to revoke") +) -> None: + """Revoke an approval grant by id.""" + if not revoke_approval(approval_id): + typer.echo( + json.dumps({"status": "error", "reason": "approval_not_found"}, indent=2) + ) + raise typer.Exit(1) + _append_audit_event("agent_approval_revoked", {"id": approval_id}) + typer.echo(json.dumps({"status": "ok", "approval_id": approval_id}, indent=2)) + + +@app.command("lease-consume") +def agent_lease_consume( + ctx: typer.Context, + lease_id: str = typer.Argument(..., help="Secret lease id"), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Non-interactive password broker (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to lease fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), + reveal: bool = typer.Option( + False, + "--reveal", + help="Return plaintext secret instead of redacted output", + ), +) -> None: + """Consume one use from a secret lease and return the secret.""" + requested_fp = (ctx.obj or {}).get("fingerprint") + ok, reason, lease = consume_lease( + lease_id=lease_id, fingerprint=str(requested_fp) if requested_fp else None + ) + if not ok or lease is None: + payload = {"status": "denied", "reason": reason, "lease_id": lease_id} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + fingerprint = str(lease.get("fingerprint", "")) + if not fingerprint: + payload = { + "status": "error", + "reason": "lease_missing_fingerprint", + "lease_id": lease_id, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + if requested_fp and str(requested_fp) != fingerprint: + payload = { + "status": "denied", + "reason": "lease_fingerprint_mismatch", + "lease_id": lease_id, + "fingerprint": fingerprint, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + password = _agent_password( + broker=auth_broker, + password_env=password_env, + broker_service=broker_service, + broker_account=broker_account or fingerprint, + broker_command=broker_command, + ) + + pm = PasswordManager(fingerprint=fingerprint, password=password) + service = EntryService(pm) + index = int(lease.get("index", -1)) + entry = service.retrieve_entry(index) + if not isinstance(entry, dict): + payload = {"status": "error", "reason": "entry_not_found", "lease_id": lease_id} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + kind = str(entry.get("type", entry.get("kind", ""))) + label = str(entry.get("label", "")) + if kind != str(lease.get("kind", "")): + payload = { + "status": "denied", + "reason": "lease_resource_mismatch", + "lease_id": lease_id, + "lease_kind": lease.get("kind"), + "entry_kind": kind, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + try: + policy = _load_policy(strict=True) + policy = _normalize_policy(policy, strict=False) + except ValueError as exc: + payload = {"status": "denied", "reason": "invalid_policy", "detail": str(exc)} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + entry_path = _build_entry_path(index, kind, label) + allowed, decision = _policy_decision( + policy, + operation="read", + kind=kind, + label=label, + path=entry_path, + field="secret", + ) + if not allowed: + payload = { + "status": "denied", + "reason": decision, + "lease_id": lease_id, + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + if _high_risk_lock_required(policy, kind): + unlocked, _expires_at = high_risk_unlocked(fingerprint=str(fingerprint)) + if not unlocked: + payload = { + "status": "denied", + "reason": "policy_deny:high_risk_locked", + "lease_id": lease_id, + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + if kind in PRIVATE_KINDS: + try: + entry = _hydrate_partition_entry_if_needed( + pm, fingerprint=str(fingerprint), index=int(index), entry=entry + ) + except ValueError as exc: + payload = { + "status": "denied", + "reason": str(exc), + "lease_id": lease_id, + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + kind = str(entry.get("type", entry.get("kind", kind))) + label = str(entry.get("label", label)) + + try: + secret = _resolve_secret_for_kind(pm, service, entry, index) + except ValueError as exc: + payload = {"status": "error", "reason": str(exc), "kind": kind} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + safe_default = bool(policy.get("output", {}).get("safe_output_default", True)) + redacted = safe_default and not reveal + output_secret = _mask_secret(secret) if redacted else secret + payload = { + "status": "ok", + "lease_id": lease_id, + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + "safe_output": redacted, + "secret": output_secret, + "lease_uses_remaining": int(lease.get("uses_remaining", 0)), + "policy_decision": decision, + } + _append_audit_event( + "agent_secret_lease_consumed", + { + "lease_id": lease_id, + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + "safe_output": redacted, + "uses_remaining": int(lease.get("uses_remaining", 0)), + }, + ) + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("lease-list") +def agent_lease_list( + show_revoked: bool = typer.Option(False, "--show-revoked") +) -> None: + """List secret lease records.""" + leases = list_leases(include_revoked=show_revoked) + typer.echo(json.dumps({"status": "ok", "leases": leases}, indent=2, sort_keys=True)) + + +@app.command("lease-revoke") +def agent_lease_revoke( + lease_id: str = typer.Argument(..., help="Lease id to revoke") +) -> None: + """Revoke a secret lease.""" + if not revoke_lease(lease_id): + payload = {"status": "error", "reason": "lease_not_found", "lease_id": lease_id} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + _append_audit_event("agent_secret_lease_revoked", {"lease_id": lease_id}) + typer.echo( + json.dumps({"status": "ok", "lease_id": lease_id}, indent=2, sort_keys=True) + ) + + +@app.command("job-run") +def agent_job_run( + 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" + ), + auth_broker: str = typer.Option( + "keyring", + "--auth-broker", + help="Job-safe password broker (keyring|command|env)", + click_type=click.Choice(["keyring", "command", "env"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.", + ), + reveal: bool = typer.Option( + False, + "--reveal", + help="Return plaintext secret instead of redacted output", + ), + lease_only: bool = typer.Option( + False, + "--lease-only", + help="Issue a secret lease instead of returning the secret directly", + ), + lease_ttl: int = typer.Option( + 30, + "--lease-ttl", + min=1, + help="Lease TTL in seconds when --lease-only is used", + ), + lease_uses: int = typer.Option( + 1, + "--lease-uses", + min=1, + help="Allowed successful retrievals for issued lease", + ), + allow_env_broker: bool = typer.Option( + False, + "--allow-env-broker", + help="Allow env broker for jobs (less safe; disabled by default)", + ), + approval_id: Optional[str] = typer.Option( + None, + "--approval-id", + help="Approval id for risky retrieval actions", + ), +) -> None: + """Run a scheduled-safe secret retrieval using brokered auth defaults.""" + broker = str(auth_broker).strip().lower() + if broker == "env" and not allow_env_broker: + payload = { + "status": "denied", + "reason": "unsafe_broker_for_job", + "allowed_brokers": sorted(SAFE_AUTOMATION_BROKERS), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + + ctx.invoke( + agent_get, + query=query, + password_env=password_env, + auth_broker=broker, + broker_service=broker_service, + broker_account=broker_account, + broker_command=broker_command, + token=token, + ttl=lease_ttl, + lease_only=lease_only, + lease_uses=lease_uses, + reveal=reveal, + approval_id=approval_id, + ) + + +@app.command("job-template") +def agent_job_template( + ctx: typer.Context, + query: str = typer.Argument(..., help="Entry label or index query"), + mode: str = typer.Option( + "cron", + "--mode", + help="Automation template format", + click_type=click.Choice(["cron", "systemd"], case_sensitive=False), + ), + auth_broker: str = typer.Option( + "keyring", + "--auth-broker", + help="Recommended broker for automated jobs", + click_type=click.Choice(["keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Broker account (defaults to fingerprint)", + ), + schedule: str = typer.Option( + "*/15 * * * *", + "--schedule", + help="Cron expression or systemd OnCalendar value", + ), + unit_name: str = typer.Option( + "seedpass-agent-job", + "--unit-name", + help="Systemd unit base name for --mode systemd", + ), + reveal: bool = typer.Option(False, "--reveal"), + lease_only: bool = typer.Option(False, "--lease-only"), + lease_ttl: int = typer.Option(30, "--lease-ttl", min=1), + lease_uses: int = typer.Option(1, "--lease-uses", min=1), + format: str = typer.Option( + "json", + "--format", + help="Output format", + click_type=click.Choice(["json", "text"], case_sensitive=False), + ), +) -> None: + """Generate deterministic cron/systemd templates using safer auth defaults.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + broker_account_value = broker_account or str(fingerprint) + cmd = _automation_job_command( + fingerprint=str(fingerprint), + query=query, + auth_broker=str(auth_broker).strip().lower(), + broker_service=broker_service, + broker_account=broker_account_value, + reveal=reveal, + lease_only=lease_only, + lease_ttl=lease_ttl, + lease_uses=lease_uses, + ) + + mode_value = str(mode).strip().lower() + payload: dict[str, Any] = { + "status": "ok", + "mode": mode_value, + "fingerprint": str(fingerprint), + "query": query, + "auth_broker": str(auth_broker).strip().lower(), + "broker_service": broker_service, + "broker_account": broker_account_value, + "command": cmd, + "safe_defaults": { + "requires_non_env_broker": True, + "safe_output_default": True, + "no_plaintext_password_env_required": True, + }, + } + if mode_value == "cron": + payload["cron_line"] = _cron_template(schedule=schedule, command=cmd) + else: + service, timer = _systemd_templates( + unit_name=unit_name, schedule=schedule, command=cmd + ) + payload["systemd_service"] = service + payload["systemd_timer"] = timer + payload["unit_name"] = unit_name + + if str(format).strip().lower() == "json": + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + return + + lines = [ + f"Agent Job Template ({mode_value})", + f"fingerprint: {fingerprint}", + f"query: {query}", + f"broker: {auth_broker}", + "", + ] + if mode_value == "cron": + lines.append(str(payload["cron_line"])) + else: + lines.extend( + [ + "# service", + str(payload["systemd_service"]), + "", + "# timer", + str(payload["systemd_timer"]), + ] + ) + typer.echo("\n".join(lines)) + + +@app.command("job-profile-create") +def agent_job_profile_create( + ctx: typer.Context, + job_id: str = typer.Option(..., "--id", help="Stable job profile id"), + query: str = typer.Option(..., "--query", help="Entry label or index query"), + auth_broker: str = typer.Option( + "keyring", + "--auth-broker", + help="Broker for scheduled job retrieval (keyring|command)", + click_type=click.Choice(["keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Broker account (defaults to fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), + policy_binding: str = typer.Option( + "default", + "--policy-binding", + help="Policy binding label for governance/audit metadata", + ), + bind_host: str = typer.Option( + "current", + "--bind-host", + help="Host binding for this job profile (`current` or explicit hostname)", + ), + schedule: str = typer.Option( + "", + "--schedule", + help="Optional schedule hint (cron or OnCalendar) stored as metadata", + ), + description: str = typer.Option( + "", + "--description", + help="Optional operator-facing description", + ), + lease_only: bool = typer.Option(False, "--lease-only"), + lease_ttl: int = typer.Option(30, "--lease-ttl", min=1), + lease_uses: int = typer.Option(1, "--lease-uses", min=1), + reveal: bool = typer.Option(False, "--reveal"), +) -> None: + """Create a reusable automation job profile with safe broker defaults.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + broker = str(auth_broker).strip().lower() + if broker == "command" and not str(broker_command or "").strip(): + raise typer.BadParameter( + "--broker-command is required for --auth-broker=command" + ) + account = str(broker_account or fingerprint) + policy = _load_policy(strict=False) + policy_stamp = compute_policy_stamp(policy) + bind_host_value = str(bind_host).strip() or "current" + if bind_host_value == "current": + bind_host_value = socket.gethostname() + try: + rec = create_job_profile( + job_id=job_id, + fingerprint=str(fingerprint), + query=query, + auth_broker=broker, + broker_service=broker_service, + broker_account=account, + broker_command=broker_command, + policy_binding=policy_binding, + policy_stamp=policy_stamp, + schedule=schedule, + description=description, + host_binding=bind_host_value, + lease_only=lease_only, + lease_ttl=lease_ttl, + lease_uses=lease_uses, + reveal=reveal, + ) + except ValueError as exc: + payload = {"status": "error", "reason": str(exc), "id": job_id} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + _append_audit_event( + "agent_job_profile_created", + { + "id": rec.get("id"), + "fingerprint": rec.get("fingerprint"), + "auth_broker": rec.get("auth_broker"), + "policy_binding": rec.get("policy_binding"), + "policy_stamp": rec.get("policy_stamp"), + "host_binding": rec.get("host_binding"), + }, + ) + typer.echo( + json.dumps({"status": "ok", "job_profile": rec}, indent=2, sort_keys=True) + ) + + +@app.command("job-profile-list") +def agent_job_profile_list( + show_revoked: bool = typer.Option(False, "--show-revoked") +) -> None: + """List automation job profiles.""" + jobs = list_job_profiles(include_revoked=show_revoked) + typer.echo( + json.dumps({"status": "ok", "job_profiles": jobs}, indent=2, sort_keys=True) + ) + + +@app.command("job-profile-revoke") +def agent_job_profile_revoke( + job_id: str = typer.Argument(..., help="Job profile id") +) -> None: + """Revoke a stored automation job profile.""" + if not revoke_job_profile(job_id): + payload = {"status": "error", "reason": "job_profile_not_found", "id": job_id} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + _append_audit_event("agent_job_profile_revoked", {"id": job_id}) + typer.echo(json.dumps({"status": "ok", "id": job_id}, indent=2, sort_keys=True)) + + +@app.command("job-profile-run") +def agent_job_profile_run( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Job profile id"), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.", + ), + approval_id: Optional[str] = typer.Option( + None, + "--approval-id", + help="Approval id for risky retrieval actions", + ), + allow_policy_drift: bool = typer.Option( + False, + "--allow-policy-drift", + help="Allow run when active policy stamp differs from profile policy stamp", + ), + allow_host_mismatch: bool = typer.Option( + False, + "--allow-host-mismatch", + help="Allow run when current host differs from profile host binding", + ), +) -> None: + """Run an automation job profile with stored non-interactive settings.""" + profile = get_job_profile(job_id) + if not profile or profile.get("revoked_at_utc"): + payload = {"status": "error", "reason": "job_profile_not_found", "id": job_id} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + requested_fp = (ctx.obj or {}).get("fingerprint") + profile_fp = str(profile.get("fingerprint", "")).strip() + if requested_fp and str(requested_fp) != profile_fp: + payload = { + "status": "denied", + "reason": "job_profile_fingerprint_mismatch", + "id": job_id, + "job_fingerprint": profile_fp, + "requested_fingerprint": str(requested_fp), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + bound_host = str(profile.get("host_binding", "")).strip() + current_host = socket.gethostname() + if bound_host and bound_host != current_host and not allow_host_mismatch: + payload = { + "status": "denied", + "reason": "job_profile_host_mismatch", + "id": job_id, + "job_host": bound_host, + "current_host": current_host, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + profile_stamp = str(profile.get("policy_stamp", "")).strip() + if profile_stamp: + active_policy = _load_policy(strict=False) + active_stamp = compute_policy_stamp(active_policy) + if active_stamp != profile_stamp and not allow_policy_drift: + payload = { + "status": "denied", + "reason": "job_profile_policy_mismatch", + "id": job_id, + "job_policy_stamp": profile_stamp, + "active_policy_stamp": active_stamp, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + ctx.invoke( + agent_job_run, + query=str(profile.get("query", "")), + password_env=password_env, + auth_broker=str(profile.get("auth_broker", "keyring")), + broker_service=str(profile.get("broker_service", "seedpass")), + broker_account=str(profile.get("broker_account", profile_fp)), + broker_command=str(profile.get("broker_command", "") or None), + token=token, + reveal=bool(profile.get("reveal", False)), + lease_only=bool(profile.get("lease_only", False)), + lease_ttl=int(profile.get("lease_ttl", 30)), + lease_uses=int(profile.get("lease_uses", 1)), + allow_env_broker=False, + approval_id=approval_id, + ) + + +@app.command("job-profile-check") +def agent_job_profile_check( + max_age_days: int = typer.Option( + 30, + "--max-age-days", + min=1, + help="Flag job profiles older than this age", + ), + strict_exit: bool = typer.Option( + False, + "--strict-exit/--no-strict-exit", + help="Return non-zero exit when findings are present", + ), +) -> None: + """Check job profile posture (policy drift, host mismatch, stale configs).""" + jobs = list_job_profiles(include_revoked=False) + active_policy = _load_policy(strict=False) + active_stamp = compute_policy_stamp(active_policy) + now = _utcnow() + findings: list[dict[str, Any]] = [] + for rec in jobs: + job_id = str(rec.get("id", "")) + broker = str(rec.get("auth_broker", "")).strip().lower() + if broker not in SAFE_AUTOMATION_BROKERS: + findings.append( + { + "id": "job_profile_unsafe_broker", + "severity": "high", + "job_id": job_id, + "broker": broker, + "message": "Job profile uses unsafe broker mode.", + } + ) + profile_stamp = str(rec.get("policy_stamp", "")).strip() + if profile_stamp and profile_stamp != active_stamp: + findings.append( + { + "id": "job_profile_policy_mismatch", + "severity": "medium", + "job_id": job_id, + "job_policy_stamp": profile_stamp, + "active_policy_stamp": active_stamp, + "message": "Job profile policy stamp differs from active policy.", + } + ) + bound_host = str(rec.get("host_binding", "")).strip() + current_host = socket.gethostname() + if bound_host and bound_host != current_host: + findings.append( + { + "id": "job_profile_host_mismatch", + "severity": "medium", + "job_id": job_id, + "job_host": bound_host, + "current_host": current_host, + "message": "Job profile host binding does not match current host.", + } + ) + created_at = str(rec.get("created_at_utc", "")) + if created_at: + try: + age_days = (_utcnow() - _parse_iso8601(created_at)).days + if age_days > max_age_days: + findings.append( + { + "id": "job_profile_stale", + "severity": "low", + "job_id": job_id, + "age_days": age_days, + "message": "Job profile has exceeded recommended age.", + } + ) + except Exception: + pass + payload = { + "status": "ok", + "check": "agent_job_profiles", + "job_profile_count": len(jobs), + "active_policy_stamp": active_stamp, + "finding_count": len(findings), + "findings": findings, + "generated_at_utc": now.isoformat(), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + if strict_exit and findings: + raise typer.Exit(1) + + +@app.command("recovery-split") +def agent_recovery_split( + secret: Optional[str] = typer.Option( + None, + "--secret", + help="Secret material to split (avoid shell history for sensitive values)", + ), + secret_env: str = typer.Option( + "SEEDPASS_RECOVERY_SECRET", + "--secret-env", + help="Environment variable containing secret material", + ), + shares: int = typer.Option(5, "--shares", min=2, help="Total number of shares"), + threshold: int = typer.Option( + 3, "--threshold", min=2, help="Minimum shares required for recovery" + ), + label: str = typer.Option( + "default", "--label", help="Recovery set label encoded in share tokens" + ), +) -> None: + """Split secret material into deterministic Shamir shares.""" + secret_value = str(secret or os.getenv(secret_env, "")).strip() + if not secret_value: + raise typer.BadParameter( + "Provide --secret or set the environment variable from --secret-env." + ) + try: + tokens = split_secret( + secret_value, + total_shares=int(shares), + threshold=int(threshold), + label=label, + ) + except ValueError as exc: + payload = {"status": "error", "reason": str(exc)} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + payload = { + "status": "ok", + "label": str(label).strip() or "default", + "threshold": int(threshold), + "total_shares": int(shares), + "shares": tokens, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("recovery-recover") +def agent_recovery_recover( + share: list[str] = typer.Option( + None, "--share", help="Recovery share token (repeatable)" + ), + share_file: Optional[Path] = typer.Option( + None, "--share-file", help="File with one share token per line" + ), + reveal: bool = typer.Option( + False, "--reveal", help="Emit recovered secret plaintext" + ), +) -> None: + """Recover secret material from Shamir share tokens.""" + tokens = [str(v).strip() for v in (share or []) if str(v).strip()] + if share_file: + for line in share_file.read_text(encoding="utf-8").splitlines(): + value = line.strip() + if value: + tokens.append(value) + if not tokens: + raise typer.BadParameter("Provide at least one --share or --share-file input.") + try: + recovered = recover_secret(tokens) + except ValueError as exc: + payload = {"status": "error", "reason": str(exc)} + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + payload = { + "status": "ok", + "share_count": len(tokens), + "secret": recovered if reveal else _mask_secret(recovered), + "revealed": bool(reveal), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("recovery-drill") +def agent_recovery_drill( + ctx: typer.Context, + backup_path: Path = typer.Option( + ..., "--backup-path", help="Path to backup file to validate" + ), + simulated: bool = typer.Option( + True, "--simulated/--no-simulated", help="Mark drill as simulated run" + ), + max_age_days: Optional[int] = typer.Option( + 30, + "--max-age-days", + min=1, + help="Warn when backup age exceeds this many days", + ), + strict_exit: bool = typer.Option( + False, + "--strict-exit/--no-strict-exit", + help="Return non-zero exit when drill status is warning", + ), +) -> None: + """Run a signed backup verification drill and append report to recovery log.""" + fingerprint = (ctx.obj or {}).get("fingerprint") or "default" + report = record_recovery_drill( + fingerprint=str(fingerprint), + backup_path=str(backup_path), + simulated=bool(simulated), + expected_max_age_days=max_age_days, + ) + typer.echo(json.dumps({"status": "ok", "report": report}, indent=2, sort_keys=True)) + if strict_exit and str(report.get("status", "")) != "ok": + raise typer.Exit(1) + + +@app.command("recovery-drill-list") +def agent_recovery_drill_list( + limit: int = typer.Option(20, "--limit", min=1, max=200) +) -> None: + """List recent signed recovery drill records.""" + records = list_recovery_drills(limit=int(limit)) + typer.echo( + json.dumps( + {"status": "ok", "count": len(records), "drills": records}, + indent=2, + sort_keys=True, + ) + ) + + @app.command("bootstrap-context") def agent_bootstrap_context(ctx: typer.Context) -> None: """Return deterministic context for autonomous agent bootstrap.""" @@ -1048,16 +3215,82 @@ def agent_bootstrap_context(ctx: typer.Context) -> None: policy.get("output", {}).get("safe_output_default", True) ), "allow_export_import": bool(policy.get("allow_export_import", False)), + "approvals_required_for": list( + policy.get("approvals", {}).get("require_for", []) + ), + "secret_isolation": { + "enabled": bool( + policy.get("secret_isolation", {}).get("enabled", True) + ), + "high_risk_kinds": list( + policy.get("secret_isolation", {}).get("high_risk_kinds", []) + ), + "unlock_ttl_sec": int( + policy.get("secret_isolation", {}).get("unlock_ttl_sec", 300) + ), + }, }, "tokens": _token_posture_summary(), + "identities": { + "total": len(list_identities(include_revoked=True)), + "active": len(list_identities(include_revoked=False)), + }, "auth_brokers": { "supported": ["env", "keyring", "command"], "default_agent_get": "env", "default_agent_init": "env", }, "commands": { - "policy": ["agent policy-show", "agent policy-lint", "agent policy-set"], + "policy": [ + "agent policy-show", + "agent policy-lint", + "agent policy-set", + "agent policy-review", + "agent policy-apply", + ], + "identities": [ + "agent identity-create", + "agent identity-list", + "agent identity-revoke", + ], "tokens": ["agent token-issue", "agent token-list", "agent token-revoke"], + "approvals": [ + "agent approval-issue", + "agent approval-list", + "agent approval-revoke", + ], + "leases": [ + "agent get --lease-only", + "agent lease-consume", + "agent lease-list", + "agent lease-revoke", + ], + "automation": [ + "agent job-run", + "agent job-template", + "agent job-profile-create", + "agent job-profile-list", + "agent job-profile-run", + "agent job-profile-revoke", + "agent job-profile-check", + ], + "recovery": [ + "agent recovery-split", + "agent recovery-recover", + "agent recovery-drill", + "agent recovery-drill-list", + ], + "secret_isolation": [ + "agent high-risk-factor-set", + "agent high-risk-unlock", + "agent high-risk-status", + "agent high-risk-lock", + ], + "export_controls": [ + "agent export-check", + "agent export-manifest-verify", + ], + "posture": ["agent posture-check", "agent posture-remediate"], "secret_access": ["agent get --fingerprint "], "discovery": ["seedpass capabilities --format json", "seedpass --help"], }, @@ -1067,6 +3300,7 @@ def agent_bootstrap_context(ctx: typer.Context) -> None: @app.command("posture-check") def agent_posture_check( + ctx: typer.Context, fail_on: str = typer.Option( "critical", "--fail-on", @@ -1081,6 +3315,37 @@ def agent_posture_check( help="Output format", click_type=click.Choice(["json", "text"], case_sensitive=False), ), + check_runtime_config: bool = typer.Option( + False, + "--check-runtime-config", + help="Attempt profile runtime config checks (requires fingerprint + broker auth)", + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Password broker for --check-runtime-config (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", + "--password-env", + help="Env var containing master password when --auth-broker=env", + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to active fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), ) -> None: """Run security posture checks for agent configuration and token hygiene.""" try: @@ -1092,6 +3357,36 @@ def agent_posture_check( policy_status = "invalid" findings = _posture_findings(policy, policy_status) + runtime_status = "not_requested" + runtime_error = None + runtime_finding_count = 0 + if check_runtime_config: + fingerprint = str((ctx.obj or {}).get("fingerprint") or "").strip() + if not fingerprint: + payload = { + "status": "error", + "reason": "missing_fingerprint_for_runtime_check", + "detail": "Provide --fingerprint when using --check-runtime-config.", + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + try: + password = _agent_password( + broker=auth_broker, + password_env=password_env, + broker_service=broker_service, + broker_account=broker_account or fingerprint, + broker_command=broker_command, + ) + pm = PasswordManager(fingerprint=fingerprint, password=password) + runtime_findings = _runtime_config_findings(pm) + findings.extend(runtime_findings) + runtime_finding_count = len(runtime_findings) + runtime_status = "checked" + except Exception as exc: + runtime_status = "error" + runtime_error = str(exc) + highest = "info" for finding in findings: if _severity_rank(finding["severity"]) > _severity_rank(highest): @@ -1105,6 +3400,9 @@ def agent_posture_check( "finding_count": len(findings), "highest_severity": highest, "fail_on": fail_on.lower(), + "runtime_config_status": runtime_status, + "runtime_config_error": runtime_error, + "runtime_finding_count": runtime_finding_count, "findings": findings, } @@ -1126,6 +3424,239 @@ def agent_posture_check( raise typer.Exit(code=1) +@app.command("posture-remediate") +def agent_posture_remediate( + ctx: typer.Context, + fail_on: str = typer.Option( + "critical", + "--fail-on", + help="Minimum severity that marks bundle as blocked", + click_type=click.Choice( + ["info", "low", "medium", "high", "critical"], case_sensitive=False + ), + ), + check_runtime_config: bool = typer.Option( + False, + "--check-runtime-config", + help="Include runtime profile config checks (requires fingerprint + broker auth)", + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Password broker for --check-runtime-config (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", + "--password-env", + help="Env var containing master password when --auth-broker=env", + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to active fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), +) -> None: + """Generate actionable remediation steps from current posture findings.""" + try: + policy = _load_policy(strict=True) + policy = _normalize_policy(policy, strict=False) + policy_status = "ok" + except ValueError: + policy = _deny_all_policy() + policy_status = "invalid" + + findings = _posture_findings(policy, policy_status) + runtime_status = "not_requested" + runtime_error = None + if check_runtime_config: + fingerprint = str((ctx.obj or {}).get("fingerprint") or "").strip() + if not fingerprint: + payload = { + "status": "error", + "reason": "missing_fingerprint_for_runtime_check", + "detail": "Provide --fingerprint when using --check-runtime-config.", + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(1) + try: + password = _agent_password( + broker=auth_broker, + password_env=password_env, + broker_service=broker_service, + broker_account=broker_account or fingerprint, + broker_command=broker_command, + ) + pm = PasswordManager(fingerprint=fingerprint, password=password) + findings.extend(_runtime_config_findings(pm)) + runtime_status = "checked" + except Exception as exc: + runtime_status = "error" + runtime_error = str(exc) + + highest = "info" + for finding in findings: + if _severity_rank(str(finding.get("severity", "info"))) > _severity_rank( + highest + ): + highest = str(finding.get("severity", "info")) + actions = _remediation_actions(findings) + payload = { + "status": "ok", + "check": "agent_posture_remediation", + "policy_status": policy_status, + "runtime_config_status": runtime_status, + "runtime_config_error": runtime_error, + "finding_count": len(findings), + "highest_severity": highest, + "fail_on": fail_on.lower(), + "blocked": _severity_rank(highest) >= _severity_rank(fail_on.lower()), + "findings": findings, + "actions": actions, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + if payload["blocked"]: + raise typer.Exit(1) + + +@app.command("export-check") +def agent_export_check( + mode: str = typer.Option( + "full", + "--mode", + help="Export check mode", + click_type=click.Choice(["full", "filtered", "kind"], case_sensitive=False), + ), + kind: Optional[str] = typer.Option( + None, + "--kind", + help="Entry kind for --mode kind", + click_type=click.Choice(ALL_ENTRY_TYPES, case_sensitive=False), + ), + strict_exit: bool = typer.Option( + False, + "--strict-exit", + help="Return non-zero exit code when export is denied", + ), +) -> None: + """Dry-run export policy decision without exporting secrets.""" + try: + policy = _load_policy(strict=True) + policy = _normalize_policy(policy, strict=False) + except ValueError as exc: + payload = { + "status": "denied", + "mode": mode.lower(), + "allowed": False, + "reason": "invalid_policy", + "detail": str(exc), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(code=1) + + mode_l = mode.lower() + allowed = False + reason = "policy_deny:unknown" + detail = None + checked_kind = None + if mode_l == "full": + allowed, reason = evaluate_full_export(policy) + elif mode_l == "filtered": + allowed = True + reason = "policy_allow:filtered_export" + else: + if not kind: + payload = { + "status": "error", + "mode": mode_l, + "allowed": False, + "reason": "missing_kind", + "detail": "Provide --kind when --mode kind is used.", + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(code=1) + checked_kind = kind + allowed, reason = evaluate_kind_export(policy, kind) + + payload = { + "status": "ok", + "mode": mode_l, + "allowed": bool(allowed), + "reason": reason, + "kind": checked_kind, + "allow_kinds": sorted(set(policy.get("allow_kinds", []))), + "allow_export_import": bool(policy.get("allow_export_import", False)), + "detail": detail, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + if strict_exit and not allowed: + raise typer.Exit(code=1) + + +@app.command("export-manifest-verify") +def agent_export_manifest_verify( + file: Path = typer.Option( + ..., "--file", help="Path to policy-filtered export JSON" + ), + strict_exit: bool = typer.Option( + True, + "--strict-exit/--no-strict-exit", + help="Return non-zero exit when verification fails", + ), +) -> None: + """Verify policy-filtered export manifest integrity against current policy.""" + try: + package = json.loads(file.read_text(encoding="utf-8")) + except Exception as exc: + payload = { + "status": "error", + "valid": False, + "reason": "invalid_json", + "detail": str(exc), + "file": str(file), + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + raise typer.Exit(code=1) + + try: + policy = _load_policy(strict=True) + policy = _normalize_policy(policy, strict=False) + policy_status = "ok" + except ValueError as exc: + policy = _deny_all_policy() + policy_status = "invalid" + policy_error = str(exc) + else: + policy_error = None + + valid, errors = verify_filtered_export_package(package, policy) + payload = { + "status": "ok", + "valid": bool(valid), + "file": str(file), + "policy_status": policy_status, + "policy_error": policy_error, + "policy_stamp_current": compute_policy_stamp(policy), + "policy_stamp_manifest": str( + (package.get("_export_manifest") or {}).get("policy_stamp", "") + ), + "errors": errors, + } + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + if strict_exit and not valid: + raise typer.Exit(code=1) + + @app.command("get") def agent_get( ctx: typer.Context, @@ -1163,13 +3694,29 @@ def agent_get( 30, "--ttl", min=1, - help="Lease time in seconds for the returned secret metadata", + help="Lease TTL in seconds when issuing secret leases", + ), + lease_only: bool = typer.Option( + False, + "--lease-only", + help="Issue a secret lease without returning the secret directly", + ), + lease_uses: int = typer.Option( + 1, + "--lease-uses", + min=1, + help="Allowed successful secret retrievals for issued lease", ), reveal: bool = typer.Option( False, "--reveal", help="Return plaintext secret instead of redacted output", ), + approval_id: Optional[str] = typer.Option( + None, + "--approval-id", + help="Approval id for risky retrieval actions", + ), ) -> None: """Retrieve one secret as JSON with policy enforcement, redaction, and auditing.""" fingerprint = (ctx.obj or {}).get("fingerprint") @@ -1265,6 +3812,46 @@ def agent_get( typer.echo(json.dumps(payload, indent=2)) raise typer.Exit(1) + if _high_risk_lock_required(policy, kind): + unlocked, expires_at = high_risk_unlocked(fingerprint=str(fingerprint)) + if not unlocked: + payload = { + "status": "denied", + "reason": "policy_deny:high_risk_locked", + "kind": kind, + "label": label, + "index": index, + } + _append_audit_event( + "agent_secret_access_denied", + { + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + "reason": "policy_deny:high_risk_locked", + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + if kind in PRIVATE_KINDS: + try: + entry = _hydrate_partition_entry_if_needed( + pm, fingerprint=str(fingerprint), index=int(index), entry=entry + ) + except ValueError as exc: + payload = { + "status": "denied", + "reason": str(exc), + "kind": kind, + "label": label, + "index": index, + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + kind = str(entry.get("type", entry.get("kind", kind))) + label = str(entry.get("label", label)) + token_meta: dict[str, Any] | None = None if raw_token: ok, token_reason, token_meta = _validate_token( @@ -1295,23 +3882,109 @@ def agent_get( typer.echo(json.dumps(payload, 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 = str(entry.get("value", "")) - else: + if kind in PRIVATE_KINDS and approval_required(policy, "private_key_retrieval"): + if not approval_id: + payload = { + "status": "denied", + "reason": "policy_deny:approval_required", + "action": "private_key_retrieval", + "kind": kind, + "label": label, + "index": index, + } + _append_audit_event( + "agent_secret_access_denied", + { + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + "reason": "policy_deny:approval_required", + "action": "private_key_retrieval", + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + ok, approval_reason = consume_approval( + approval_id=approval_id, + action="private_key_retrieval", + resource=f"entry:{kind}:{index}", + ) + if not ok: + reason = f"policy_deny:{approval_reason}" + payload = { + "status": "denied", + "reason": reason, + "action": "private_key_retrieval", + "kind": kind, + "label": label, + "index": index, + } + _append_audit_event( + "agent_secret_access_denied", + { + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + "reason": reason, + "action": "private_key_retrieval", + "approval_id": approval_id, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + now = _utcnow() + token_id = (token_meta or {}).get("id") if token_meta else None + if lease_only: + lease = issue_lease( + fingerprint=str(fingerprint), + index=index, + kind=kind, + label=label, + ttl_seconds=ttl, + uses=lease_uses, + token_id=str(token_id) if token_id else None, + ) payload = { - "status": "error", - "reason": "unsupported_kind_for_agent_get", + "status": "ok", + "mode": "lease_issued", + "fingerprint": fingerprint, + "index": index, "kind": kind, + "label": label, + "lease_id": lease.get("id"), + "lease_uses_remaining": int(lease.get("uses_remaining", 0)), + "lease_expires_at_utc": lease.get("expires_at_utc"), + "policy_decision": decision, } + if token_meta is not None: + payload["token_id"] = token_meta.get("id") + payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0) + _append_audit_event( + "agent_secret_lease_issued", + { + "lease_id": lease.get("id"), + "fingerprint": fingerprint, + "index": index, + "kind": kind, + "label": label, + "uses_remaining": int(lease.get("uses_remaining", 0)), + "expires_at_utc": lease.get("expires_at_utc"), + "token_id": token_id, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + return + + try: + secret = _resolve_secret_for_kind(pm, service, entry, index) + except ValueError as exc: + payload = {"status": "error", "reason": str(exc), "kind": kind} typer.echo(json.dumps(payload, indent=2)) raise typer.Exit(1) - now = _utcnow() safe_default = bool(policy.get("output", {}).get("safe_output_default", True)) redacted = safe_default and not reveal output_secret = _mask_secret(secret) if redacted else secret diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index b8dc1a21..048a68b9 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -34,9 +34,30 @@ def build_capabilities() -> dict[str, Any]: ], }, "api": { - "discovery": ["/docs"], + "discovery": [ + "/docs", + "/api/v1/export/check", + "/api/v1/export/manifest/verify", + "/api/v1/agent/job-profiles", + "/api/v1/agent/job-profiles/{job_id}/run", + "/api/v1/agent/job-profiles/{job_id}/template", + "/api/v1/agent/job-profiles/{job_id}/template/verify", + "/api/v1/agent/job-profiles/check", + "/api/v1/agent/recovery/split", + "/api/v1/agent/recovery/recover", + "/api/v1/agent/recovery/drill", + "/api/v1/agent/recovery/drills", + "/api/v1/agent/recovery/drills/verify", + "/api/v1/high-risk/status", + "/api/v1/high-risk/unlock", + "/api/v1/high-risk/lock", + ], "auth_headers": ["Authorization: Bearer "], - "sensitive_auth_headers": ["X-SeedPass-Password"], + "sensitive_auth_headers": [ + "X-SeedPass-Password", + "X-SeedPass-Approval-Id", + "X-SeedPass-High-Risk-Factor", + ], }, }, "security_features": { @@ -47,19 +68,136 @@ def build_capabilities() -> dict[str, Any]: "api_commands": ["api start --unlock"], }, "policy": { - "commands": ["agent policy-show", "agent policy-lint", "agent policy-set"], + "commands": [ + "agent policy-show", + "agent policy-lint", + "agent policy-set", + "agent policy-review", + "agent policy-apply", + ], "default_effect": "deny", + "supports_file_lint": True, + "supports_change_review": True, + "supports_risk_gated_apply": True, + }, + "sync": { + "deterministic_conflict_resolution": "modified_ts_hash_tombstone_v2", + "supports_tombstones": True, + "metadata_fields": ["_sync_meta.strategy", "_sync_meta.tombstones"], }, "tokens": { - "commands": ["agent token-issue", "agent token-list", "agent token-revoke"], + "commands": [ + "agent token-issue", + "agent token-list", + "agent token-revoke", + ], "supports_ttl": True, "supports_use_limits": True, "supports_revocation": True, }, + "identities": { + "commands": [ + "agent identity-create", + "agent identity-list", + "agent identity-revoke", + ], + "token_binding_required_for_new_tokens": True, + "supports_revocation": True, + }, + "leases": { + "commands": [ + "agent get --lease-only", + "agent lease-consume", + "agent lease-list", + "agent lease-revoke", + ], + "supports_ttl": True, + "supports_use_limits": True, + "supports_one_time": True, + }, + "automation": { + "commands": [ + "agent job-run", + "agent job-template", + "agent job-profile-create", + "agent job-profile-list", + "agent job-profile-run", + "agent job-profile-revoke", + "agent job-profile-check", + ], + "safe_default_brokers": ["keyring", "command"], + "supports_cron_templates": True, + "supports_systemd_templates": True, + "supports_api_templates": True, + "supports_persistent_job_profiles": True, + "enforces_policy_stamp_on_run": True, + "supports_host_binding": True, + "template_manifest_signing": "hmac-sha256", + }, + "recovery": { + "commands": [ + "agent recovery-split", + "agent recovery-recover", + "agent recovery-drill", + "agent recovery-drill-list", + ], + "supports_shamir_split_recover": True, + "supports_signed_drill_reports": True, + }, + "secret_isolation": { + "commands": [ + "agent high-risk-factor-set", + "agent high-risk-unlock", + "agent high-risk-status", + "agent high-risk-lock", + ], + "high_risk_classes": ["seed", "ssh", "pgp", "nostr", "managed_account"], + "separate_unlock_factor": True, + }, + "approvals": { + "commands": [ + "agent approval-issue", + "agent approval-list", + "agent approval-revoke", + ], + "supports_ttl": True, + "supports_use_limits": True, + "enforced_on": [ + "vault export (full, agent mode)", + "api /api/v1/vault/export", + "vault reveal-parent-seed (agent mode)", + "agent get --reveal (private kinds)", + ], + }, + "export_controls": { + "commands": [ + "vault export --agent-profile --policy-filtered", + "agent export-check", + "agent export-manifest-verify", + ], + "default_full_export_agent_profile": "denied", + "supports_policy_filtered_export": True, + "supports_manifest_entry_hash_verification": True, + "manifest_mode": "policy_filtered", + }, "redaction": { "default_safe_output": True, "reveal_override": "agent get --reveal", }, + "posture": { + "commands": ["agent posture-check", "agent posture-remediate"], + "checks": [ + "policy validity", + "token ttl/usage hygiene", + "token rotation overdue against identity policy", + "identity-token binding integrity", + "approval gate completeness for risky actions", + "over-permissive secret-read rule detection", + "high-risk factor readiness", + "high-risk unlock TTL sanity", + "runtime config drift (quick unlock, weak KDF, partition unlock state)", + ], + }, "audit": { "log_file": "~/.seedpass/agent_audit.log", "integrity": "HMAC chained append-only records", @@ -68,13 +206,16 @@ def build_capabilities() -> dict[str, Any]: "high_risk_operations": [ "vault export", "vault reveal-parent-seed", - "entry get (sensitive kinds)", + "agent get --reveal (sensitive kinds)", "api /api/v1/vault/export", + "api /api/v1/vault/backup-parent-seed", ], "help_hints": [ "Run `seedpass --help` for top-level commands.", "Run `seedpass --help` for detailed options.", "Use `seedpass capabilities --format json` for machine-readable discovery.", + "After unlock/login, run `seedpass capabilities` before autonomous runs.", + "For agent bootstrap context, run `seedpass agent bootstrap-context`.", ], } @@ -92,9 +233,24 @@ def emit_capabilities(output_format: str) -> str: lines.append("") lines.append("Security highlights:") lines.append("- Auth brokers: env, keyring, command, prompt") - lines.append("- Policy controls: agent policy-show/policy-lint/policy-set") + lines.append( + "- Policy as code: policy-show/lint/review/apply with deterministic hashes" + ) + lines.append("- Sync safety: deterministic conflicts with tombstones") lines.append("- Scoped tokens: issue/list/revoke with TTL and use limits") + lines.append("- Agent identities: create/list/revoke and bind tokens") + lines.append("- Secret leases: lease-only issue and bounded lease-consume") + lines.append( + "- Automation primitives: job-run with safe brokers and cron/systemd templates" + ) + lines.append( + "- Recovery hardening: Shamir split/recover and signed backup drill logs" + ) + lines.append("- Secret class isolation: separate high-risk unlock factor/session") + lines.append("- Approval gates: issue/list/revoke for risky actions") + lines.append("- Deterministic export controls with manifest verification") lines.append("- Safe output redaction default with explicit --reveal") + lines.append("- Security posture checks and remediation bundles") lines.append("- Audited access log with chained HMAC signatures") lines.append("") lines.append("Discovery hints:") diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py index ff1d11b4..810a532a 100644 --- a/src/seedpass/cli/entry.py +++ b/src/seedpass/cli/entry.py @@ -9,7 +9,11 @@ import typer import click from .common import _get_entry_service, EntryType -from seedpass.core.agent_export_policy import kind_export_allowed, load_export_policy +from seedpass.core.agent_export_policy import ( + evaluate_kind_export, + load_export_policy, + record_export_policy_event, +) from seedpass.core.entry_types import ALL_ENTRY_TYPES from utils.clipboard import ClipboardUnavailableError @@ -369,9 +373,27 @@ def entry_export_totp( """Export all TOTP secrets to a JSON file.""" if agent_profile: policy = load_export_policy() - if not kind_export_allowed(policy, EntryType.TOTP.value): - typer.echo("Error: Policy denied TOTP export for agent profile.") + allowed, reason = evaluate_kind_export(policy, EntryType.TOTP.value) + if not allowed: + record_export_policy_event( + "export_denied", + { + "source": "cli:entry_export_totp", + "kind": EntryType.TOTP.value, + "reason": reason, + "file": str(file), + }, + ) + typer.echo(f"Error: {reason}") raise typer.Exit(code=1) + record_export_policy_event( + "export_allowed", + { + "source": "cli:entry_export_totp", + "kind": EntryType.TOTP.value, + "file": str(file), + }, + ) service = _get_entry_service(ctx) data = service.export_totp_entries() Path(file).write_text(json.dumps(data, indent=2)) diff --git a/src/seedpass/cli/vault.py b/src/seedpass/cli/vault.py index 38a87773..cce9691a 100644 --- a/src/seedpass/cli/vault.py +++ b/src/seedpass/cli/vault.py @@ -15,15 +15,36 @@ from .common import ( ) from seedpass.core.agent_export_policy import ( allowed_kinds, - filter_index_for_allowed_kinds, - full_export_allowed, + build_policy_filtered_export_package, + evaluate_full_export, load_export_policy, + record_export_policy_event, +) +from seedpass.core.agent_approval import approval_required, consume_approval +from seedpass.core.agent_secret_isolation import ( + high_risk_factor_configured, + high_risk_unlocked, +) +from seedpass.core.auth_broker import ( + AuthBrokerError, + resolve_password as resolve_broker_password, ) -from seedpass.core.auth_broker import AuthBrokerError, resolve_password as resolve_broker_password app = typer.Typer(help="Manage the entire vault") +def _isolation_required_for_kind(policy: dict, kind: str) -> bool: + cfg = policy.get("secret_isolation", {}) + if not isinstance(cfg, dict): + return False + if not bool(cfg.get("enabled", True)): + return False + kinds = cfg.get("high_risk_kinds", []) + if not isinstance(kinds, list): + return False + return str(kind).lower() in {str(v).lower() for v in kinds} + + @app.command("export") def vault_export( ctx: typer.Context, @@ -38,6 +59,11 @@ def vault_export( "--policy-filtered", help="Export only policy-allowed subset of entries", ), + approval_id: Optional[str] = typer.Option( + None, + "--approval-id", + help="Step-up approval id for actions requiring approval", + ), ) -> None: """Export the vault profile to an encrypted file.""" vault_service, _profile, _sync = _get_services(ctx) @@ -48,20 +74,94 @@ def vault_export( vault = getattr(manager, "vault", None) enc_mgr = getattr(vault, "encryption_manager", None) if vault is None or enc_mgr is None: - typer.echo("Error: policy-filtered export unavailable in current context") + typer.echo( + "Error: policy-filtered export unavailable in current context" + ) raise typer.Exit(code=1) index_data = vault.load_index() - filtered = filter_index_for_allowed_kinds(index_data, allowed_kinds(policy)) + filtered = build_policy_filtered_export_package(index_data, policy) payload = json.dumps( filtered, sort_keys=True, separators=(",", ":") ).encode("utf-8") data = enc_mgr.encrypt_data(payload) Path(file).write_bytes(data) + record_export_policy_event( + "export_allowed", + { + "source": "cli:vault_export", + "mode": "filtered", + "file": str(file), + "allowed_kinds": sorted(list(allowed_kinds(policy))), + "policy_stamp": filtered.get("_export_manifest", {}).get( + "policy_stamp" + ), + }, + ) typer.echo(str(file)) return - if not full_export_allowed(policy): + allowed, reason = evaluate_full_export(policy) + if not allowed: + record_export_policy_event( + "export_denied", + { + "source": "cli:vault_export", + "mode": "full", + "reason": reason, + "file": str(file), + }, + ) + typer.echo(f"Error: {reason}. Use --policy-filtered for subset export.") + raise typer.Exit(code=1) + if approval_required(policy, "export"): + if not approval_id: + reason = "policy_deny:approval_required" + record_export_policy_event( + "export_denied", + { + "source": "cli:vault_export", + "mode": "full", + "reason": reason, + "file": str(file), + }, + ) + typer.echo(f"Error: {reason}. Provide --approval-id for full export.") + raise typer.Exit(code=1) + ok, approval_reason = consume_approval( + approval_id=approval_id, + action="export", + resource="vault:full", + ) + if not ok: + reason = f"policy_deny:{approval_reason}" + record_export_policy_event( + "export_denied", + { + "source": "cli:vault_export", + "mode": "full", + "reason": reason, + "file": str(file), + }, + ) + typer.echo(f"Error: {reason}") + raise typer.Exit(code=1) + record_export_policy_event( + "export_allowed", + { + "source": "cli:vault_export", + "mode": "full", + "file": str(file), + "approval_id": approval_id, + }, + ) + else: + policy = load_export_policy() + + if _isolation_required_for_kind(policy, "seed") and high_risk_factor_configured(): + fingerprint = str((ctx.obj or {}).get("fingerprint") or "default") + unlocked, _expires_at = high_risk_unlocked(fingerprint=fingerprint) + if not unlocked: typer.echo( - "Error: Policy denied full vault export for agent profile. Use --policy-filtered." + "Error: policy_deny:high_risk_locked. Unlock high-risk session first." ) raise typer.Exit(code=1) data = vault_service.export_profile() @@ -103,10 +203,14 @@ def vault_unlock( "prompt", "--auth-broker", help="Password source for unlock (prompt|env|keyring|command)", - click_type=click.Choice(["prompt", "env", "keyring", "command"], case_sensitive=False), + click_type=click.Choice( + ["prompt", "env", "keyring", "command"], case_sensitive=False + ), ), password_env: str = typer.Option( - "SEEDPASS_PASSWORD", "--password-env", help="Env var used when --auth-broker=env" + "SEEDPASS_PASSWORD", + "--password-env", + help="Env var used when --auth-broker=env", ), broker_service: str = typer.Option( "seedpass", @@ -148,7 +252,7 @@ def vault_unlock( raise typer.Exit(code=1) typer.echo(f"Unlocked in {resp.duration:.2f}s") typer.echo( - "Tip: run `seedpass --help` (and `seedpass --help`) to discover available features." + "Tip: run `seedpass --help`, `seedpass capabilities`, and `seedpass --help` to discover features." ) @@ -174,10 +278,71 @@ def vault_reveal_parent_seed( file: Optional[str] = typer.Option( None, "--file", help="Save encrypted seed to this path" ), + agent_profile: bool = typer.Option( + False, + "--agent-profile", + help="Apply agent approval policy controls", + ), + approval_id: Optional[str] = typer.Option( + None, + "--approval-id", + help="Step-up approval id for reveal-parent-seed when required", + ), ) -> None: """Display the parent seed and optionally write an encrypted backup file.""" vault_service, _profile, _sync = _get_services(ctx) + policy = load_export_policy() + if _isolation_required_for_kind(policy, "seed") and high_risk_factor_configured(): + fingerprint = str((ctx.obj or {}).get("fingerprint") or "default") + unlocked, _expires_at = high_risk_unlocked(fingerprint=fingerprint) + if not unlocked: + typer.echo( + "Error: policy_deny:high_risk_locked. Unlock high-risk session first." + ) + raise typer.Exit(code=1) + if agent_profile: + if approval_required(policy, "reveal_parent_seed"): + if not approval_id: + reason = "policy_deny:approval_required" + record_export_policy_event( + "approval_denied", + { + "source": "cli:vault_reveal_parent_seed", + "reason": reason, + "action": "reveal_parent_seed", + }, + ) + typer.echo(f"Error: {reason}. Provide --approval-id.") + raise typer.Exit(code=1) + ok, approval_reason = consume_approval( + approval_id=approval_id, + action="reveal_parent_seed", + resource="vault:parent-seed", + ) + if not ok: + reason = f"policy_deny:{approval_reason}" + record_export_policy_event( + "approval_denied", + { + "source": "cli:vault_reveal_parent_seed", + "reason": reason, + "action": "reveal_parent_seed", + "approval_id": approval_id, + }, + ) + typer.echo(f"Error: {reason}") + raise typer.Exit(code=1) password = typer.prompt("Master password", hide_input=True) vault_service.backup_parent_seed( BackupParentSeedRequest(path=Path(file) if file else None, password=password) ) + if agent_profile: + record_export_policy_event( + "approval_consumed", + { + "source": "cli:vault_reveal_parent_seed", + "action": "reveal_parent_seed", + "approval_id": approval_id, + "file": str(file) if file else None, + }, + ) diff --git a/src/seedpass/core/agent_approval.py b/src/seedpass/core/agent_approval.py new file mode 100644 index 00000000..6a8064c7 --- /dev/null +++ b/src/seedpass/core/agent_approval.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +import os +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from constants import APP_DIR + +APPROVAL_STORE_VERSION = 1 +VALID_APPROVAL_ACTIONS = { + "export", + "reveal_parent_seed", + "private_key_retrieval", +} + + +def _store_path() -> Path: + return APP_DIR / "agent_approvals.json" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_iso(value: str) -> datetime: + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _load_store() -> dict[str, Any]: + path = _store_path() + if not path.exists(): + return {"version": APPROVAL_STORE_VERSION, "approvals": []} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {"version": APPROVAL_STORE_VERSION, "approvals": []} + if not isinstance(data, dict) or not isinstance(data.get("approvals"), list): + return {"version": APPROVAL_STORE_VERSION, "approvals": []} + return data + + +def _save_store(store: dict[str, Any]) -> None: + path = _store_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store, indent=2), encoding="utf-8") + os.chmod(path, 0o600) + + +def issue_approval( + *, + action: str, + ttl_seconds: int, + uses: int, + resource: str = "*", + issued_by: str = "manual", +) -> dict[str, Any]: + store = _load_store() + now = _utcnow() + approval_id = secrets.token_urlsafe(18) + rec = { + "id": approval_id, + "action": action, + "resource": resource, + "issued_by": issued_by, + "created_at_utc": now.isoformat(), + "expires_at_utc": (now + timedelta(seconds=ttl_seconds)).isoformat(), + "uses_remaining": int(uses), + "revoked_at_utc": None, + } + store["approvals"].append(rec) + _save_store(store) + return rec + + +def approval_required(policy: dict[str, Any], action: str) -> bool: + approvals_cfg = policy.get("approvals", {}) + if not isinstance(approvals_cfg, dict): + return False + required = approvals_cfg.get("require_for", []) + if not isinstance(required, list): + return False + action_l = action.strip().lower() + return any(str(v).strip().lower() == action_l for v in required) + + +def list_approvals(*, include_revoked: bool = False) -> list[dict[str, Any]]: + approvals = _load_store().get("approvals", []) + if include_revoked: + return approvals + return [a for a in approvals if not a.get("revoked_at_utc")] + + +def revoke_approval(approval_id: str) -> bool: + store = _load_store() + now = _utcnow().isoformat() + changed = False + for rec in store.get("approvals", []): + if rec.get("id") == approval_id and not rec.get("revoked_at_utc"): + rec["revoked_at_utc"] = now + changed = True + break + if changed: + _save_store(store) + return changed + + +def consume_approval( + *, + approval_id: str, + action: str, + resource: str = "*", +) -> tuple[bool, str]: + store = _load_store() + now = _utcnow() + for rec in store.get("approvals", []): + if rec.get("id") != approval_id: + continue + if rec.get("revoked_at_utc"): + return False, "approval_revoked" + if str(rec.get("action", "")).lower() != action.lower(): + return False, "approval_action_mismatch" + rec_resource = str(rec.get("resource", "*")) + if rec_resource not in {"*", resource}: + return False, "approval_resource_mismatch" + expires = rec.get("expires_at_utc") + if not expires or _parse_iso(expires) <= now: + return False, "approval_expired" + uses = int(rec.get("uses_remaining", 0)) + if uses <= 0: + return False, "approval_exhausted" + rec["uses_remaining"] = uses - 1 + _save_store(store) + return True, "approval_consumed" + return False, "approval_not_found" diff --git a/src/seedpass/core/agent_export_policy.py b/src/seedpass/core/agent_export_policy.py index 97976814..058d6054 100644 --- a/src/seedpass/core/agent_export_policy.py +++ b/src/seedpass/core/agent_export_policy.py @@ -1,6 +1,12 @@ from __future__ import annotations +import hashlib +import hmac import json +import os +import secrets +import socket +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -15,13 +21,96 @@ DEFAULT_EXPORT_POLICY: dict[str, Any] = { ], "allow_export_import": False, "export": {"allow_full_vault": False}, + "approvals": { + "require_for": ["export", "reveal_parent_seed", "private_key_retrieval"] + }, + "secret_isolation": { + "enabled": True, + "high_risk_kinds": [ + EntryType.SEED.value, + EntryType.SSH.value, + EntryType.PGP.value, + EntryType.NOSTR.value, + EntryType.MANAGED_ACCOUNT.value, + ], + "unlock_ttl_sec": 300, + }, } +REASON_FULL_EXPORT_BLOCKED = "policy_deny:full_export_blocked" +REASON_KIND_NOT_ALLOWED = "policy_deny:kind_not_allowed" +REASON_EXPORT_ALLOWED = "policy_allow:export_allowed" +REASON_KIND_ALLOWED = "policy_allow:kind_allowed" +MANIFEST_VERSION = 2 +REDACTED_SENTINEL = "[REDACTED_BY_POLICY]" + def _policy_path() -> Path: return APP_DIR / "agent_policy.json" +def _audit_key_path() -> Path: + return APP_DIR / "agent_export_audit.key" + + +def _audit_log_path() -> Path: + return APP_DIR / "agent_export_audit.log" + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _load_or_create_audit_key() -> bytes: + path = _audit_key_path() + if path.exists(): + return path.read_bytes() + _ensure_parent(path) + key = secrets.token_bytes(32) + path.write_bytes(key) + os.chmod(path, 0o600) + return key + + +def _previous_signature() -> str: + path = _audit_log_path() + if not path.exists(): + return "0" * 64 + try: + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln] + if not lines: + return "0" * 64 + last = json.loads(lines[-1]) + return str(last.get("sig", "0" * 64)) + except Exception: + return "0" * 64 + + +def record_export_policy_event(event: str, details: dict[str, Any]) -> None: + """Append signed export policy event to audit log (best-effort).""" + key = _load_or_create_audit_key() + prev_sig = _previous_signature() + payload = { + "timestamp_utc": _utcnow_iso(), + "host": socket.gethostname(), + "event": event, + "details": details, + } + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) + payload["sig"] = hmac.new( + key, f"{prev_sig}{canonical}".encode("utf-8"), hashlib.sha256 + ).hexdigest() + log_path = _audit_log_path() + _ensure_parent(log_path) + with log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload, sort_keys=True) + "\n") + os.chmod(log_path, 0o600) + + def load_export_policy() -> dict[str, Any]: path = _policy_path() if not path.exists(): @@ -48,6 +137,50 @@ def load_export_policy() -> dict[str, Any]: } elif isinstance(data.get("allow_export_import"), bool): policy["export"] = {"allow_full_vault": bool(data["allow_export_import"])} + approvals_cfg = data.get("approvals") + if isinstance(approvals_cfg, dict): + require_for = approvals_cfg.get("require_for") + if isinstance(require_for, list): + policy["approvals"] = { + "require_for": [ + str(v).strip().lower() for v in require_for if str(v).strip() + ] + } + isolation_cfg = data.get("secret_isolation") + if isinstance(isolation_cfg, dict): + raw_high_risk = isolation_cfg.get("high_risk_kinds") + if isinstance(raw_high_risk, list): + high_risk_kinds = [ + str(v).strip().lower() + for v in raw_high_risk + if str(v).strip().lower() + in { + EntryType.SEED.value, + EntryType.SSH.value, + EntryType.PGP.value, + EntryType.NOSTR.value, + EntryType.MANAGED_ACCOUNT.value, + } + ] + else: + high_risk_kinds = list( + DEFAULT_EXPORT_POLICY["secret_isolation"]["high_risk_kinds"] + ) + unlock_ttl_sec = int( + isolation_cfg.get( + "unlock_ttl_sec", + DEFAULT_EXPORT_POLICY["secret_isolation"]["unlock_ttl_sec"], + ) + ) + if unlock_ttl_sec < 1: + unlock_ttl_sec = int( + DEFAULT_EXPORT_POLICY["secret_isolation"]["unlock_ttl_sec"] + ) + policy["secret_isolation"] = { + "enabled": bool(isolation_cfg.get("enabled", True)), + "high_risk_kinds": high_risk_kinds, + "unlock_ttl_sec": unlock_ttl_sec, + } return policy @@ -69,7 +202,21 @@ def kind_export_allowed(policy: dict[str, Any], kind: str) -> bool: return kind in allowed_kinds(policy) -def filter_index_for_allowed_kinds(index_data: dict[str, Any], kinds: set[str]) -> dict[str, Any]: +def evaluate_full_export(policy: dict[str, Any]) -> tuple[bool, str]: + if full_export_allowed(policy): + return True, REASON_EXPORT_ALLOWED + return False, REASON_FULL_EXPORT_BLOCKED + + +def evaluate_kind_export(policy: dict[str, Any], kind: str) -> tuple[bool, str]: + if kind_export_allowed(policy, kind): + return True, REASON_KIND_ALLOWED + return False, REASON_KIND_NOT_ALLOWED + + +def filter_index_for_allowed_kinds( + index_data: dict[str, Any], kinds: set[str] +) -> dict[str, Any]: entries = index_data.get("entries", {}) if not isinstance(entries, dict): entries = {} @@ -84,3 +231,208 @@ def filter_index_for_allowed_kinds(index_data: dict[str, Any], kinds: set[str]) "schema_version": int(index_data.get("schema_version", 1)), "entries": filtered_entries, } + + +def _policy_stamp(policy: dict[str, Any]) -> str: + material = { + "allow_kinds": sorted(list(allowed_kinds(policy))), + "allow_export_import": bool(policy.get("allow_export_import", False)), + "allow_full_vault": bool( + policy.get("export", {}).get("allow_full_vault", False) + ), + "safe_output_default": bool( + policy.get("output", {}).get("safe_output_default", True) + ), + "redact_fields": sorted( + [str(v) for v in policy.get("output", {}).get("redact_fields", [])] + ), + "secret_isolation_enabled": bool( + policy.get("secret_isolation", {}).get("enabled", True) + ), + "secret_isolation_high_risk_kinds": sorted( + [ + str(v) + for v in policy.get("secret_isolation", {}).get("high_risk_kinds", []) + ] + ), + } + canonical = json.dumps(material, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _safe_int(value: Any, *, default: int) -> int: + try: + return int(value) + except Exception: + return default + + +def _sorted_index_keys(indexes: list[str]) -> list[str]: + def _key(value: str) -> tuple[int, int, str]: + try: + return (0, int(value), value) + except Exception: + return (1, 0, value) + + return sorted([str(v) for v in indexes], key=_key) + + +def compute_policy_stamp(policy: dict[str, Any]) -> str: + """Return deterministic hash over export-relevant policy controls.""" + return _policy_stamp(policy) + + +def _redacted_copy(entry: dict[str, Any], redacted_fields: set[str]) -> dict[str, Any]: + copied: dict[str, Any] = {} + for key, value in entry.items(): + if key in redacted_fields: + copied[key] = REDACTED_SENTINEL + else: + copied[key] = value + return copied + + +def build_policy_filtered_export_package( + index_data: dict[str, Any], policy: dict[str, Any] +) -> dict[str, Any]: + kinds = allowed_kinds(policy) + redacted_fields = { + str(v) for v in policy.get("output", {}).get("redact_fields", []) if str(v) + } + entries = index_data.get("entries", {}) + if not isinstance(entries, dict): + entries = {} + + filtered_entries: dict[str, dict[str, Any]] = {} + excluded = 0 + for idx, entry in entries.items(): + if not isinstance(entry, dict): + excluded += 1 + continue + kind = str(entry.get("kind", entry.get("type", ""))) + if kind not in kinds: + excluded += 1 + continue + filtered_entries[str(idx)] = _redacted_copy(entry, redacted_fields) + sorted_indexes = _sorted_index_keys(list(filtered_entries.keys())) + entries_canonical = _canonical_json( + {idx: filtered_entries[idx] for idx in sorted_indexes} + ) + + manifest = { + "manifest_version": MANIFEST_VERSION, + "mode": "policy_filtered", + "policy_stamp": _policy_stamp(policy), + "allow_kinds": sorted(list(kinds)), + "redacted_fields": sorted(list(redacted_fields)), + "safe_output_default": bool( + policy.get("output", {}).get("safe_output_default", True) + ), + "included_entry_count": len(filtered_entries), + "excluded_entry_count": int(excluded), + "included_entry_indexes": sorted_indexes, + "entries_sha256": hashlib.sha256(entries_canonical.encode("utf-8")).hexdigest(), + "source_schema_version": int(index_data.get("schema_version", 1)), + } + return { + "schema_version": int(index_data.get("schema_version", 1)), + "_export_manifest": manifest, + "entries": filtered_entries, + } + + +def verify_filtered_export_package( + package: dict[str, Any], policy: dict[str, Any] +) -> tuple[bool, list[str]]: + """Verify filtered export package shape and policy stamp compatibility.""" + errors: list[str] = [] + if not isinstance(package, dict): + return False, ["package_not_object"] + manifest = package.get("_export_manifest") + if not isinstance(manifest, dict): + return False, ["missing_manifest"] + if manifest.get("mode") != "policy_filtered": + errors.append("invalid_mode") + manifest_version = _safe_int(manifest.get("manifest_version", 0), default=0) + if manifest_version not in {1, MANIFEST_VERSION}: + errors.append("invalid_manifest_version") + entries = package.get("entries") + if not isinstance(entries, dict): + errors.append("entries_not_object") + entries = {} + + expected_stamp = compute_policy_stamp(policy) + if str(manifest.get("policy_stamp", "")) != expected_stamp: + errors.append("policy_stamp_mismatch") + + listed_indexes = manifest.get("included_entry_indexes", []) + if not isinstance(listed_indexes, list): + errors.append("invalid_included_entry_indexes") + listed_indexes = [] + listed_indexes_str = [str(v) for v in listed_indexes] + actual_indexes = _sorted_index_keys(list(entries.keys())) + if listed_indexes_str != actual_indexes: + errors.append("included_entry_indexes_mismatch") + + included_count = _safe_int(manifest.get("included_entry_count", -1), default=-1) + if included_count != len(actual_indexes): + errors.append("included_entry_count_mismatch") + + excluded_count = _safe_int(manifest.get("excluded_entry_count", -1), default=-1) + if excluded_count < 0: + errors.append("invalid_excluded_entry_count") + + allow_kinds_manifest = manifest.get("allow_kinds", []) + if not isinstance(allow_kinds_manifest, list): + errors.append("invalid_allow_kinds") + allow_kinds_manifest = [] + expected_kinds = sorted(list(allowed_kinds(policy))) + if sorted([str(v) for v in allow_kinds_manifest]) != expected_kinds: + errors.append("allow_kinds_mismatch") + + manifest_redacted_fields = manifest.get("redacted_fields", []) + if not isinstance(manifest_redacted_fields, list): + errors.append("invalid_redacted_fields") + manifest_redacted_fields = [] + expected_redacted_fields = sorted( + [str(v) for v in policy.get("output", {}).get("redact_fields", []) if str(v)] + ) + if sorted([str(v) for v in manifest_redacted_fields]) != expected_redacted_fields: + errors.append("redacted_fields_mismatch") + + source_schema_version = _safe_int( + manifest.get("source_schema_version", -1), default=-1 + ) + package_schema_version = _safe_int(package.get("schema_version", -2), default=-2) + if package_schema_version != source_schema_version: + errors.append("source_schema_version_mismatch") + + allowed_kind_set = set(expected_kinds) + redacted_fields_set = set(expected_redacted_fields) + for idx, entry in entries.items(): + if not isinstance(entry, dict): + errors.append("entry_not_object") + continue + kind = str(entry.get("kind", entry.get("type", ""))) + if kind not in allowed_kind_set: + errors.append("entry_kind_not_allowed") + break + for field in redacted_fields_set: + if field in entry and entry.get(field) != REDACTED_SENTINEL: + errors.append("entry_redaction_mismatch") + break + + if manifest_version >= 2: + expected_entries_sha = hashlib.sha256( + _canonical_json( + {idx: entries[idx] for idx in _sorted_index_keys(list(entries.keys()))} + ).encode("utf-8") + ).hexdigest() + if str(manifest.get("entries_sha256", "")) != expected_entries_sha: + errors.append("entries_hash_mismatch") + + return len(errors) == 0, errors diff --git a/src/seedpass/core/agent_identity.py b/src/seedpass/core/agent_identity.py new file mode 100644 index 00000000..f0cc5bc4 --- /dev/null +++ b/src/seedpass/core/agent_identity.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from constants import APP_DIR + +IDENTITY_STORE_VERSION = 1 + + +def _store_path() -> Path: + return APP_DIR / "agent_identities.json" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _empty_store() -> dict[str, Any]: + return {"version": IDENTITY_STORE_VERSION, "identities": []} + + +def _load_store() -> dict[str, Any]: + path = _store_path() + if not path.exists(): + return _empty_store() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return _empty_store() + if not isinstance(data, dict) or not isinstance(data.get("identities"), list): + return _empty_store() + return data + + +def _save_store(store: dict[str, Any]) -> None: + path = _store_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store, indent=2), encoding="utf-8") + os.chmod(path, 0o600) + + +def list_identities(*, include_revoked: bool = False) -> list[dict[str, Any]]: + identities = _load_store().get("identities", []) + if include_revoked: + return identities + return [v for v in identities if not v.get("revoked_at_utc")] + + +def get_identity(identity_id: str) -> dict[str, Any] | None: + target = str(identity_id).strip() + if not target: + return None + for rec in _load_store().get("identities", []): + if str(rec.get("id", "")) == target: + return rec + return None + + +def create_identity( + *, + identity_id: str, + owner: str, + policy_binding: str = "default", + rotation_days: int = 30, +) -> dict[str, Any]: + identity_id = str(identity_id).strip() + if not identity_id: + raise ValueError("identity_id_required") + if get_identity(identity_id): + raise ValueError("identity_exists") + + rec = { + "id": identity_id, + "owner": str(owner).strip() or "unowned", + "policy_binding": str(policy_binding).strip() or "default", + "rotation_days": int(rotation_days), + "created_at_utc": _utcnow_iso(), + "revoked_at_utc": None, + } + store = _load_store() + store.setdefault("version", IDENTITY_STORE_VERSION) + store.setdefault("identities", []) + store["identities"].append(rec) + _save_store(store) + return rec + + +def ensure_identity( + identity_id: str, + *, + owner: str = "system", + policy_binding: str = "default", + rotation_days: int = 30, +) -> dict[str, Any]: + existing = get_identity(identity_id) + if existing: + return existing + return create_identity( + identity_id=identity_id, + owner=owner, + policy_binding=policy_binding, + rotation_days=rotation_days, + ) + + +def revoke_identity(identity_id: str) -> bool: + identity_id = str(identity_id).strip() + if not identity_id: + return False + store = _load_store() + changed = False + now = _utcnow_iso() + for rec in store.get("identities", []): + if str(rec.get("id", "")) == identity_id and not rec.get("revoked_at_utc"): + rec["revoked_at_utc"] = now + changed = True + break + if changed: + _save_store(store) + return changed + + +def identity_active(identity_id: str) -> bool: + rec = get_identity(identity_id) + if not rec: + return False + return not bool(rec.get("revoked_at_utc")) diff --git a/src/seedpass/core/agent_job.py b/src/seedpass/core/agent_job.py new file mode 100644 index 00000000..54ba29de --- /dev/null +++ b/src/seedpass/core/agent_job.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from constants import APP_DIR + +JOB_STORE_VERSION = 1 + + +def _store_path() -> Path: + return APP_DIR / "agent_jobs.json" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _empty_store() -> dict[str, Any]: + return {"version": JOB_STORE_VERSION, "jobs": []} + + +def _load_store() -> dict[str, Any]: + path = _store_path() + if not path.exists(): + return _empty_store() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return _empty_store() + if not isinstance(data, dict) or not isinstance(data.get("jobs"), list): + return _empty_store() + return data + + +def _save_store(store: dict[str, Any]) -> None: + path = _store_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store, indent=2), encoding="utf-8") + os.chmod(path, 0o600) + + +def list_job_profiles(*, include_revoked: bool = False) -> list[dict[str, Any]]: + jobs = _load_store().get("jobs", []) + if include_revoked: + return jobs + return [v for v in jobs if not v.get("revoked_at_utc")] + + +def get_job_profile(job_id: str) -> dict[str, Any] | None: + target = str(job_id).strip() + if not target: + return None + for rec in _load_store().get("jobs", []): + if str(rec.get("id", "")) == target: + return rec + return None + + +def create_job_profile( + *, + job_id: str, + fingerprint: str, + query: str, + auth_broker: str, + broker_service: str, + broker_account: str, + broker_command: str | None, + policy_binding: str, + policy_stamp: str, + schedule: str, + description: str, + host_binding: str, + lease_only: bool, + lease_ttl: int, + lease_uses: int, + reveal: bool, +) -> dict[str, Any]: + job_id = str(job_id).strip() + if not job_id: + raise ValueError("job_id_required") + if get_job_profile(job_id): + raise ValueError("job_exists") + rec = { + "id": job_id, + "fingerprint": str(fingerprint).strip(), + "query": str(query), + "auth_broker": str(auth_broker).strip().lower(), + "broker_service": str(broker_service).strip(), + "broker_account": str(broker_account).strip(), + "broker_command": str(broker_command or "").strip() or None, + "policy_binding": str(policy_binding).strip() or "default", + "policy_stamp": str(policy_stamp).strip(), + "schedule": str(schedule).strip(), + "description": str(description).strip(), + "host_binding": str(host_binding).strip(), + "lease_only": bool(lease_only), + "lease_ttl": int(lease_ttl), + "lease_uses": int(lease_uses), + "reveal": bool(reveal), + "created_at_utc": _utcnow_iso(), + "revoked_at_utc": None, + } + store = _load_store() + store.setdefault("version", JOB_STORE_VERSION) + store.setdefault("jobs", []) + store["jobs"].append(rec) + _save_store(store) + return rec + + +def revoke_job_profile(job_id: str) -> bool: + job_id = str(job_id).strip() + if not job_id: + return False + store = _load_store() + changed = False + now = _utcnow_iso() + for rec in store.get("jobs", []): + if str(rec.get("id", "")) == job_id and not rec.get("revoked_at_utc"): + rec["revoked_at_utc"] = now + changed = True + break + if changed: + _save_store(store) + return changed diff --git a/src/seedpass/core/agent_recovery.py b/src/seedpass/core/agent_recovery.py new file mode 100644 index 00000000..57187d2a --- /dev/null +++ b/src/seedpass/core/agent_recovery.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from constants import APP_DIR + +PRIME = 257 +SHARE_PREFIX = "sprec1" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _mod_inv(value: int) -> int: + return pow(value % PRIME, -1, PRIME) + + +def _coef(secret: bytes, label: str, byte_idx: int, power: int) -> int: + # Deterministic coefficient derivation for reproducible share generation. + msg = f"{label}:{byte_idx}:{power}".encode("utf-8") + digest = hmac.new(secret, msg, hashlib.sha256).digest() + return int.from_bytes(digest[:2], "big") % PRIME + + +def _poly_eval_at_x(secret_byte: int, coeffs: list[int], x: int) -> int: + total = secret_byte % PRIME + x_pow = x % PRIME + for coeff in coeffs: + total = (total + (coeff * x_pow)) % PRIME + x_pow = (x_pow * x) % PRIME + return total + + +def _parse_share_token(token: str) -> dict[str, Any]: + parts = str(token).split(":", 6) + if len(parts) != 7 or parts[0] != SHARE_PREFIX: + raise ValueError("invalid_share_format") + _prefix, label, threshold, total, index, digest, payload = parts + try: + threshold_i = int(threshold) + total_i = int(total) + index_i = int(index) + except ValueError as exc: + raise ValueError("invalid_share_metadata") from exc + raw = base64.urlsafe_b64decode(payload.encode("ascii")) + check = hashlib.sha256(raw).hexdigest()[:16] + if check != digest: + raise ValueError("invalid_share_checksum") + if len(raw) % 2 != 0: + raise ValueError("invalid_share_payload") + values = [] + for i in range(0, len(raw), 2): + v = int.from_bytes(raw[i : i + 2], "big") + if v < 0 or v >= PRIME: + raise ValueError("invalid_share_value") + values.append(v) + return { + "label": label, + "threshold": threshold_i, + "total": total_i, + "index": index_i, + "values": values, + } + + +def split_secret( + secret: str, *, total_shares: int, threshold: int, label: str = "default" +) -> list[str]: + secret_bytes = secret.encode("utf-8") + if threshold < 2: + raise ValueError("threshold_must_be_at_least_2") + if total_shares < threshold: + raise ValueError("total_shares_must_be_gte_threshold") + if total_shares > 32: + raise ValueError("total_shares_too_large") + if not secret_bytes: + raise ValueError("secret_required") + label_value = str(label).strip() or "default" + + shares: list[str] = [] + for x in range(1, total_shares + 1): + vals: list[int] = [] + for idx, b in enumerate(secret_bytes): + coeffs = [ + _coef(secret_bytes, label_value, idx, p) for p in range(1, threshold) + ] + vals.append(_poly_eval_at_x(int(b), coeffs, x)) + raw = b"".join(int(v).to_bytes(2, "big") for v in vals) + digest = hashlib.sha256(raw).hexdigest()[:16] + payload = base64.urlsafe_b64encode(raw).decode("ascii") + token = ( + f"{SHARE_PREFIX}:{label_value}:{threshold}:{total_shares}:" + f"{x}:{digest}:{payload}" + ) + shares.append(token) + return shares + + +def recover_secret(tokens: list[str]) -> str: + if not tokens: + raise ValueError("shares_required") + parsed = [_parse_share_token(t) for t in tokens] + first = parsed[0] + label = str(first["label"]) + threshold = int(first["threshold"]) + expected_len = len(first["values"]) + indices: set[int] = set() + for rec in parsed: + if str(rec["label"]) != label: + raise ValueError("share_label_mismatch") + if int(rec["threshold"]) != threshold: + raise ValueError("share_threshold_mismatch") + if len(rec["values"]) != expected_len: + raise ValueError("share_length_mismatch") + idx = int(rec["index"]) + if idx in indices: + raise ValueError("duplicate_share_index") + indices.add(idx) + if len(parsed) < threshold: + raise ValueError("insufficient_shares") + + used = parsed[:threshold] + xs = [int(v["index"]) % PRIME for v in used] + out = bytearray() + for byte_idx in range(expected_len): + ys = [int(v["values"][byte_idx]) % PRIME for v in used] + secret_val = 0 + for i in range(threshold): + num = 1 + den = 1 + xi = xs[i] + for j in range(threshold): + if i == j: + continue + xj = xs[j] + num = (num * (-xj % PRIME)) % PRIME + den = (den * (xi - xj)) % PRIME + li = (num * _mod_inv(den)) % PRIME + secret_val = (secret_val + ys[i] * li) % PRIME + if secret_val < 0 or secret_val > 255: + raise ValueError("recovered_secret_out_of_range") + out.append(secret_val) + return out.decode("utf-8") + + +def _drill_key_path() -> Path: + return APP_DIR / "agent_recovery_drill.key" + + +def _drill_log_path() -> Path: + return APP_DIR / "agent_recovery_drills.log" + + +def _load_drill_key() -> bytes: + path = _drill_key_path() + if path.exists(): + return path.read_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + key = os.urandom(32) + path.write_bytes(key) + os.chmod(path, 0o600) + return key + + +def _latest_drill_sig(path: Path) -> str: + if not path.exists(): + return "0" * 64 + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] + if not lines: + return "0" * 64 + try: + payload = json.loads(lines[-1]) + except Exception: + return "0" * 64 + return str(payload.get("sig", "0" * 64)) + + +def record_recovery_drill( + *, + fingerprint: str, + backup_path: str, + simulated: bool, + expected_max_age_days: int | None = None, +) -> dict[str, Any]: + path = Path(backup_path).expanduser() + exists = path.exists() + size = int(path.stat().st_size) if exists else 0 + modified_ts = float(path.stat().st_mtime) if exists else 0.0 + age_days: int | None = None + if exists: + age_days = int((time.time() - modified_ts) // 86400) + stale = bool( + age_days is not None + and expected_max_age_days is not None + and age_days > int(expected_max_age_days) + ) + status = "ok" if exists and not stale else "warning" + + record = { + "timestamp_utc": _utcnow_iso(), + "fingerprint": str(fingerprint), + "backup_path": str(path), + "backup_exists": bool(exists), + "backup_size": size, + "backup_age_days": age_days, + "expected_max_age_days": ( + int(expected_max_age_days) if expected_max_age_days is not None else None + ), + "stale": bool(stale), + "simulated": bool(simulated), + "status": status, + } + log_path = _drill_log_path() + log_path.parent.mkdir(parents=True, exist_ok=True) + prev_sig = _latest_drill_sig(log_path) + canonical = json.dumps(record, sort_keys=True, separators=(",", ":")) + sig = hmac.new( + _load_drill_key(), + f"{prev_sig}{canonical}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + full = dict(record) + full["sig"] = sig + with log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(full, sort_keys=True) + "\n") + os.chmod(log_path, 0o600) + return full + + +def list_recovery_drills(*, limit: int = 20) -> list[dict[str, Any]]: + path = _drill_log_path() + if not path.exists(): + return [] + out: list[dict[str, Any]] = [] + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] + for line in lines[-int(limit) :]: + try: + payload = json.loads(line) + except Exception: + continue + if isinstance(payload, dict): + out.append(payload) + return out + + +def verify_recovery_drills(*, limit: int = 200) -> dict[str, Any]: + path = _drill_log_path() + if not path.exists(): + return {"valid": True, "checked": 0, "errors": []} + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] + if not lines: + return {"valid": True, "checked": 0, "errors": []} + if int(limit) > 0: + lines = lines[-int(limit) :] + key = _load_drill_key() + prev_sig = "0" * 64 + errors: list[str] = [] + checked = 0 + for idx, line in enumerate(lines): + try: + rec = json.loads(line) + except Exception: + errors.append(f"invalid_json_line:{idx}") + continue + if not isinstance(rec, dict): + errors.append(f"invalid_record_line:{idx}") + continue + sig = str(rec.get("sig", "")) + if not sig: + errors.append(f"missing_sig_line:{idx}") + continue + body = dict(rec) + body.pop("sig", None) + canonical = json.dumps(body, sort_keys=True, separators=(",", ":")) + expected = hmac.new( + key, f"{prev_sig}{canonical}".encode("utf-8"), hashlib.sha256 + ).hexdigest() + if expected != sig: + errors.append(f"sig_mismatch_line:{idx}") + prev_sig = sig + checked += 1 + return {"valid": len(errors) == 0, "checked": checked, "errors": errors} diff --git a/src/seedpass/core/agent_secret_isolation.py b/src/seedpass/core/agent_secret_isolation.py new file mode 100644 index 00000000..cec4a163 --- /dev/null +++ b/src/seedpass/core/agent_secret_isolation.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +from constants import APP_DIR +from utils.file_lock import exclusive_lock + +ISOLATION_STORE_VERSION = 1 +PARTITION_ENVELOPE_VERSION = 1 +PARTITION_KDF_ITERATIONS = 200_000 + + +def _factor_hash_path() -> Path: + return APP_DIR / "agent_high_risk_factor.hash" + + +def _partition_envelope_path() -> Path: + return APP_DIR / "agent_high_risk_partition.key.enc.json" + + +def _session_store_path() -> Path: + return APP_DIR / "agent_high_risk_unlock.json" + + +def _session_lock_path() -> Path: + return APP_DIR / "agent_high_risk_unlock.lock" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_iso(value: str) -> datetime: + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _empty_store() -> dict[str, Any]: + return {"version": ISOLATION_STORE_VERSION, "sessions": []} + + +def _load_session_store_unlocked() -> dict[str, Any]: + path = _session_store_path() + if not path.exists(): + return _empty_store() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return _empty_store() + if not isinstance(data, dict) or not isinstance(data.get("sessions"), list): + return _empty_store() + return data + + +def _save_session_store_unlocked(store: dict[str, Any]) -> None: + path = _session_store_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store, indent=2), encoding="utf-8") + os.chmod(path, 0o600) + + +def _factor_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _partition_key_tag(partition_key: str) -> str: + return hashlib.sha256(partition_key.encode("utf-8")).hexdigest() + + +def _derive_wrapping_key(factor: str, salt: bytes, iterations: int) -> bytes: + raw = hashlib.pbkdf2_hmac( + "sha256", + factor.encode("utf-8"), + salt, + int(iterations), + dklen=32, + ) + return base64.urlsafe_b64encode(raw) + + +def _build_envelope(partition_key: str, factor: str) -> dict[str, Any]: + salt = os.urandom(16) + key = _derive_wrapping_key(factor, salt, PARTITION_KDF_ITERATIONS) + wrapped = Fernet(key).encrypt(partition_key.encode("utf-8")).decode("utf-8") + return { + "version": PARTITION_ENVELOPE_VERSION, + "kdf": "pbkdf2-sha256", + "iterations": PARTITION_KDF_ITERATIONS, + "salt_b64": base64.b64encode(salt).decode("ascii"), + "wrapped_partition_key": wrapped, + } + + +def _load_partition_envelope() -> dict[str, Any] | None: + path = _partition_envelope_path() + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + +def _unwrap_partition_key(envelope: dict[str, Any], factor: str) -> str: + if int(envelope.get("version", 0)) != PARTITION_ENVELOPE_VERSION: + raise ValueError("unsupported_partition_envelope_version") + salt_b64 = str(envelope.get("salt_b64", "")) + wrapped = str(envelope.get("wrapped_partition_key", "")) + iterations = int(envelope.get("iterations", PARTITION_KDF_ITERATIONS)) + if not salt_b64 or not wrapped or iterations < 1: + raise ValueError("invalid_partition_envelope") + salt = base64.b64decode(salt_b64) + key = _derive_wrapping_key(factor, salt, iterations) + try: + return Fernet(key).decrypt(wrapped.encode("utf-8")).decode("utf-8") + except (InvalidToken, ValueError) as exc: + raise ValueError("high_risk_factor_invalid") from exc + + +def set_high_risk_factor(factor: str) -> None: + app_dir = _factor_hash_path().parent + app_dir.mkdir(parents=True, exist_ok=True) + + # Keep legacy hash marker for compatibility while enforcing envelope unwrap. + hash_path = _factor_hash_path() + hash_path.write_text(_factor_hash(factor), encoding="utf-8") + os.chmod(hash_path, 0o600) + + partition_key = Fernet.generate_key().decode("ascii") + envelope = _build_envelope(partition_key, factor) + envelope_path = _partition_envelope_path() + envelope_path.write_text(json.dumps(envelope, indent=2), encoding="utf-8") + os.chmod(envelope_path, 0o600) + + +def high_risk_factor_configured() -> bool: + env_path = _partition_envelope_path() + if env_path.exists(): + return True + path = _factor_hash_path() + return path.exists() and bool(path.read_text(encoding="utf-8").strip()) + + +def verify_high_risk_factor(factor: str) -> bool: + envelope = _load_partition_envelope() + if envelope is not None: + try: + _unwrap_partition_key(envelope, factor) + return True + except ValueError: + return False + + # Legacy fallback if envelope has not been initialized yet. + path = _factor_hash_path() + if not path.exists(): + return False + expected = path.read_text(encoding="utf-8").strip() + if not expected: + return False + return _factor_hash(factor) == expected + + +def unwrap_high_risk_partition_key(factor: str) -> str: + envelope = _load_partition_envelope() + if envelope is None: + raise ValueError("high_risk_partition_not_configured") + return _unwrap_partition_key(envelope, factor) + + +def grant_high_risk_unlock( + *, fingerprint: str, ttl_seconds: int, partition_key_tag: str | None = None +) -> dict[str, Any]: + now = _utcnow() + rec = { + "fingerprint": str(fingerprint), + "created_at_utc": now.isoformat(), + "expires_at_utc": (now + timedelta(seconds=int(ttl_seconds))).isoformat(), + "partition_key_tag": str(partition_key_tag or ""), + } + with exclusive_lock(_session_lock_path()): + store = _load_session_store_unlocked() + sessions = [ + s + for s in store.get("sessions", []) + if str(s.get("fingerprint", "")) != str(fingerprint) + ] + sessions.append(rec) + store["sessions"] = sessions + _save_session_store_unlocked(store) + return rec + + +def high_risk_unlocked(*, fingerprint: str) -> tuple[bool, str]: + now = _utcnow() + with exclusive_lock(_session_lock_path()): + store = _load_session_store_unlocked() + sessions = store.get("sessions", []) + kept: list[dict[str, Any]] = [] + matched: dict[str, Any] | None = None + for rec in sessions: + expires = str(rec.get("expires_at_utc", "")) + try: + valid = bool(expires) and _parse_iso(expires) > now + except Exception: + valid = False + if not valid: + continue + kept.append(rec) + if str(rec.get("fingerprint", "")) == str(fingerprint): + matched = rec + if len(kept) != len(sessions): + store["sessions"] = kept + _save_session_store_unlocked(store) + if matched: + return True, str(matched.get("expires_at_utc", "")) + return False, "" + + +def revoke_high_risk_unlock(*, fingerprint: str) -> bool: + changed = False + with exclusive_lock(_session_lock_path()): + store = _load_session_store_unlocked() + sessions = store.get("sessions", []) + kept = [ + s for s in sessions if str(s.get("fingerprint", "")) != str(fingerprint) + ] + changed = len(kept) != len(sessions) + if changed: + store["sessions"] = kept + _save_session_store_unlocked(store) + return changed + + +def unlocked_partition_key_tag(*, fingerprint: str) -> str: + now = _utcnow() + with exclusive_lock(_session_lock_path()): + store = _load_session_store_unlocked() + sessions = store.get("sessions", []) + for rec in sessions: + if str(rec.get("fingerprint", "")) != str(fingerprint): + continue + expires = str(rec.get("expires_at_utc", "")) + try: + if not expires or _parse_iso(expires) <= now: + continue + except Exception: + continue + return str(rec.get("partition_key_tag", "")) + return "" + + +def partition_key_tag_for_factor(factor: str) -> str: + partition_key = unwrap_high_risk_partition_key(factor) + return _partition_key_tag(partition_key) diff --git a/src/seedpass/core/agent_secret_lease.py b/src/seedpass/core/agent_secret_lease.py new file mode 100644 index 00000000..6b826d47 --- /dev/null +++ b/src/seedpass/core/agent_secret_lease.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import os +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from constants import APP_DIR +from utils.file_lock import exclusive_lock + +LEASE_STORE_VERSION = 1 + + +def _store_path() -> Path: + return APP_DIR / "agent_secret_leases.json" + + +def _lock_path() -> Path: + return APP_DIR / "agent_secret_leases.lock" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_iso(value: str) -> datetime: + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _empty_store() -> dict[str, Any]: + return {"version": LEASE_STORE_VERSION, "leases": []} + + +def _load_store_unlocked() -> dict[str, Any]: + path = _store_path() + if not path.exists(): + return _empty_store() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return _empty_store() + if not isinstance(data, dict) or not isinstance(data.get("leases"), list): + return _empty_store() + return data + + +def _save_store_unlocked(store: dict[str, Any]) -> None: + path = _store_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store, indent=2), encoding="utf-8") + os.chmod(path, 0o600) + + +def issue_lease( + *, + fingerprint: str, + index: int, + kind: str, + label: str, + ttl_seconds: int, + uses: int, + token_id: str | None = None, +) -> dict[str, Any]: + now = _utcnow() + rec = { + "id": secrets.token_urlsafe(18), + "fingerprint": str(fingerprint), + "resource": f"entry:{kind}:{index}", + "index": int(index), + "kind": str(kind), + "label": str(label), + "created_at_utc": now.isoformat(), + "expires_at_utc": (now + timedelta(seconds=int(ttl_seconds))).isoformat(), + "uses_remaining": int(uses), + "token_id": token_id, + "revoked_at_utc": None, + } + with exclusive_lock(_lock_path()): + store = _load_store_unlocked() + store.setdefault("version", LEASE_STORE_VERSION) + store.setdefault("leases", []) + store["leases"].append(rec) + _save_store_unlocked(store) + return rec + + +def list_leases(*, include_revoked: bool = False) -> list[dict[str, Any]]: + with exclusive_lock(_lock_path()): + leases = _load_store_unlocked().get("leases", []) + if include_revoked: + return leases + return [v for v in leases if not v.get("revoked_at_utc")] + + +def revoke_lease(lease_id: str) -> bool: + changed = False + with exclusive_lock(_lock_path()): + store = _load_store_unlocked() + for rec in store.get("leases", []): + if rec.get("id") == lease_id and not rec.get("revoked_at_utc"): + rec["revoked_at_utc"] = _utcnow().isoformat() + changed = True + break + if changed: + _save_store_unlocked(store) + return changed + + +def consume_lease( + *, + lease_id: str, + fingerprint: str | None = None, +) -> tuple[bool, str, dict[str, Any] | None]: + with exclusive_lock(_lock_path()): + store = _load_store_unlocked() + now = _utcnow() + for rec in store.get("leases", []): + if rec.get("id") != lease_id: + continue + if rec.get("revoked_at_utc"): + return False, "lease_revoked", None + if fingerprint and str(rec.get("fingerprint")) != str(fingerprint): + return False, "lease_fingerprint_mismatch", None + expires = str(rec.get("expires_at_utc", "")) + if not expires or _parse_iso(expires) <= now: + return False, "lease_expired", None + uses = int(rec.get("uses_remaining", 0)) + if uses <= 0: + return False, "lease_exhausted", None + rec["uses_remaining"] = uses - 1 + _save_store_unlocked(store) + return True, "lease_consumed", dict(rec) + return False, "lease_not_found", None diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index 0bdb66ec..9455f9d6 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -11,6 +11,7 @@ from utils.seed_prompt import masked_input import bcrypt from .vault import Vault +from .entry_types import EntryType from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY @@ -28,6 +29,29 @@ class ConfigManager: DEFAULT_PBKDF2_ITERATIONS = 200_000 LEGACY_PBKDF2_ITERATION_FALLBACKS = (50_000, 100_000) + SECRET_CLASS_PARTITIONS_DEFAULT = { + "standard": { + "kinds": [ + EntryType.PASSWORD.value, + EntryType.TOTP.value, + EntryType.KEY_VALUE.value, + ], + "unlocked": True, + "separate_factor_required": False, + }, + "high_risk": { + "kinds": [ + EntryType.SEED.value, + EntryType.SSH.value, + EntryType.PGP.value, + EntryType.NOSTR.value, + EntryType.MANAGED_ACCOUNT.value, + ], + "unlocked": False, + "separate_factor_required": True, + }, + } + def __init__(self, vault: Vault, fingerprint_dir: Path): self.vault = vault self.fingerprint_dir = fingerprint_dir @@ -69,6 +93,7 @@ class ConfigManager: "special_mode": "standard", "exclude_ambiguous": False, "verbose_timing": False, + "secret_class_partitions": dict(self.SECRET_CLASS_PARTITIONS_DEFAULT), } try: data = self.vault.load_config() @@ -99,6 +124,10 @@ class ConfigManager: data.setdefault("special_mode", "standard") data.setdefault("exclude_ambiguous", False) data.setdefault("verbose_timing", False) + data.setdefault( + "secret_class_partitions", + dict(self.SECRET_CLASS_PARTITIONS_DEFAULT), + ) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -382,3 +411,41 @@ class ConfigManager: def get_verbose_timing(self) -> bool: cfg = self.load_config(require_pin=False) return bool(cfg.get("verbose_timing", False)) + + def get_secret_class_partitions(self) -> dict: + """Return partition metadata for secret classes.""" + cfg = self.load_config(require_pin=False) + value = cfg.get("secret_class_partitions") + if not isinstance(value, dict): + value = dict(self.SECRET_CLASS_PARTITIONS_DEFAULT) + return value + + def set_secret_class_partitions(self, partitions: dict) -> None: + """Persist partition metadata for secret classes.""" + if not isinstance(partitions, dict): + raise ValueError("partitions must be a dictionary") + cfg = self.load_config(require_pin=False) + cfg["secret_class_partitions"] = partitions + self.save_config(cfg) + + def set_partition_unlock_state(self, partition: str, unlocked: bool) -> None: + """Persist unlock state for one partition.""" + cfg = self.load_config(require_pin=False) + partitions = cfg.get("secret_class_partitions") + if not isinstance(partitions, dict): + partitions = dict(self.SECRET_CLASS_PARTITIONS_DEFAULT) + p = partitions.get(partition) + if not isinstance(p, dict): + p = {"kinds": [], "separate_factor_required": False} + p["unlocked"] = bool(unlocked) + partitions[partition] = p + cfg["secret_class_partitions"] = partitions + self.save_config(cfg) + + def is_partition_unlocked(self, partition: str) -> bool: + """Return unlock state for one partition.""" + partitions = self.get_secret_class_partitions() + p = partitions.get(partition) + if not isinstance(p, dict): + return False + return bool(p.get("unlocked", False)) diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index 0c8345d5..c1ef640f 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -29,6 +29,7 @@ from mnemonic import Mnemonic from utils.password_prompt import prompt_existing_password from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION from .errors import DecryptionError +from .sync_conflict import merge_index_payloads # Instantiate the logger logger = logging.getLogger(__name__) @@ -474,18 +475,8 @@ class EncryptionManager: existing_file = self.resolve_relative_path(relative_path) if merge and existing_file.exists(): current = self.load_json_data(relative_path) - current_entries = current.get("entries", {}) - for idx, entry in data.get("entries", {}).items(): - cur_ts = current_entries.get(idx, {}).get("modified_ts", 0) - new_ts = entry.get("modified_ts", 0) - if idx not in current_entries or new_ts >= cur_ts: - current_entries[idx] = entry - current["entries"] = current_entries - if "schema_version" in data: - current["schema_version"] = max( - current.get("schema_version", 0), data.get("schema_version", 0) - ) - data = current + source_tag = hashlib.sha256(encrypted_data).hexdigest()[:16] + data = merge_index_payloads(current, data, source_tag=source_tag) return data try: diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index d9ae24be..e13671d0 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -1313,6 +1313,41 @@ class EntryManager: try: data = self._load_index() if "entries" in data and str(index) in data["entries"]: + entry = data["entries"][str(index)] + deleted_ts = int(time.time()) + entry_hash = hashlib.sha256( + canonical_json_dumps(entry).encode("utf-8") + ).hexdigest() + tombstone_payload = { + "kind": "tombstone", + "index": str(index), + "deleted_ts": deleted_ts, + "entry_hash": entry_hash, + } + event_hash = hashlib.sha256( + canonical_json_dumps(tombstone_payload).encode("utf-8") + ).hexdigest() + sync_meta = data.setdefault("_sync_meta", {}) + if not isinstance(sync_meta, dict): + sync_meta = {} + data["_sync_meta"] = sync_meta + tombstones = sync_meta.setdefault("tombstones", {}) + if not isinstance(tombstones, dict): + tombstones = {} + sync_meta["tombstones"] = tombstones + current = tombstones.get(str(index), {}) + current_ts = ( + int(current.get("deleted_ts", 0)) + if isinstance(current, dict) + else 0 + ) + if deleted_ts >= current_ts: + tombstones[str(index)] = { + "deleted_ts": deleted_ts, + "entry_hash": entry_hash, + "event_hash": event_hash, + "source": "local-delete", + } del data["entries"][str(index)] logger.debug(f"Deleted entry at index {index}.") self._save_index(data) diff --git a/src/seedpass/core/high_risk_partition_store.py b/src/seedpass/core/high_risk_partition_store.py new file mode 100644 index 00000000..09ec6000 --- /dev/null +++ b/src/seedpass/core/high_risk_partition_store.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import time +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +PARTITION_FILENAME = "seedpass_high_risk_entries.json.enc" + + +def _partition_path(fingerprint_dir: Path) -> Path: + return Path(fingerprint_dir) / PARTITION_FILENAME + + +def _fernet_for_tag(partition_key_tag: str) -> Fernet: + raw = hashlib.sha256(partition_key_tag.encode("utf-8")).digest() + key = base64.urlsafe_b64encode(raw) + return Fernet(key) + + +def load_partition_entries( + fingerprint_dir: Path, partition_key_tag: str +) -> dict[str, dict[str, Any]]: + path = _partition_path(fingerprint_dir) + if not path.exists(): + return {} + fernet = _fernet_for_tag(partition_key_tag) + blob = path.read_bytes() + try: + payload = fernet.decrypt(blob).decode("utf-8") + except InvalidToken as exc: + raise ValueError("invalid_partition_key_tag") from exc + data = json.loads(payload) + if not isinstance(data, dict): + return {} + entries = data.get("entries", {}) + if not isinstance(entries, dict): + return {} + return {str(k): v for k, v in entries.items() if isinstance(v, dict)} + + +def save_partition_entries( + fingerprint_dir: Path, + partition_key_tag: str, + entries: dict[str, dict[str, Any]], +) -> Path: + path = _partition_path(fingerprint_dir) + fernet = _fernet_for_tag(partition_key_tag) + payload = { + "schema_version": 1, + "partition": "high_risk", + "updated_at_utc": time.time(), + "entries": entries, + } + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(fernet.encrypt(raw)) + return path + + +def load_partition_entry( + fingerprint_dir: Path, partition_key_tag: str, index: int +) -> dict[str, Any] | None: + entries = load_partition_entries(fingerprint_dir, partition_key_tag) + return entries.get(str(index)) + + +def migrate_high_risk_entries( + *, + vault: Any, + fingerprint_dir: Path, + partition_key_tag: str, + high_risk_kinds: set[str], +) -> dict[str, Any]: + index = vault.load_index() + entries = index.get("entries", {}) + if not isinstance(entries, dict): + entries = {} + partition_entries = load_partition_entries(fingerprint_dir, partition_key_tag) + + moved_indexes: list[str] = [] + for idx, entry in list(entries.items()): + if not isinstance(entry, dict): + continue + kind = str(entry.get("kind", entry.get("type", ""))).strip().lower() + if kind not in high_risk_kinds: + continue + idx_s = str(idx) + partition_entries[idx_s] = entry + entries[idx_s] = { + "type": kind, + "kind": kind, + "index": int(entry.get("index", idx)), + "label": str(entry.get("label", "")), + "archived": bool(entry.get("archived", False)), + "partition": "high_risk", + "partition_ref": idx_s, + "modified_ts": int(entry.get("modified_ts", int(time.time()))), + } + moved_indexes.append(idx_s) + + index["entries"] = entries + if moved_indexes: + vault.save_index(index) + save_partition_entries(fingerprint_dir, partition_key_tag, partition_entries) + + return { + "moved_count": len(moved_indexes), + "moved_indexes": sorted(moved_indexes, key=lambda v: int(v)), + "partition_file": str(_partition_path(fingerprint_dir)), + } diff --git a/src/seedpass/core/sync_conflict.py b/src/seedpass/core/sync_conflict.py new file mode 100644 index 00000000..107d28fc --- /dev/null +++ b/src/seedpass/core/sync_conflict.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +TOMBSTONE_RETENTION_CAP = 2048 + + +def _canonical(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _entry_hash(entry: dict[str, Any]) -> str: + return hashlib.sha256(_canonical(entry).encode("utf-8")).hexdigest() + + +def _entry_ts(entry: dict[str, Any]) -> int: + return _safe_int(entry.get("modified_ts", 0), default=0) + + +def _safe_int(raw: Any, *, default: int = 0) -> int: + try: + return int(raw) + except Exception: + return int(default) + + +def _is_empty(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return value.strip() == "" + return False + + +def _entry_kind(entry: dict[str, Any]) -> str: + return str(entry.get("kind", entry.get("type", "password"))).strip().lower() + + +def _is_deleted_entry(entry: dict[str, Any]) -> bool: + return bool(entry.get("_deleted", False) or entry.get("deleted", False)) + + +def _entry_event_hash(entry: dict[str, Any]) -> str: + marker = {"entry": entry, "kind": "entry"} + return hashlib.sha256(_canonical(marker).encode("utf-8")).hexdigest() + + +def _tombstone_event_hash(record: dict[str, Any], idx: str) -> str: + provided = str(record.get("event_hash", "")).strip().lower() + if provided: + return provided + marker = { + "kind": "tombstone", + "index": str(idx), + "deleted_ts": _safe_int(record.get("deleted_ts", 0), default=0), + "entry_hash": str(record.get("entry_hash", "")), + } + return hashlib.sha256(_canonical(marker).encode("utf-8")).hexdigest() + + +def _prefer_tombstone( + current: dict[str, Any], incoming: dict[str, Any], idx: str +) -> bool: + cur_ts = _safe_int(current.get("deleted_ts", 0), default=0) + inc_ts = _safe_int(incoming.get("deleted_ts", 0), default=0) + if inc_ts != cur_ts: + return inc_ts > cur_ts + return _tombstone_event_hash(incoming, idx) > _tombstone_event_hash(current, idx) + + +def _normalize_tombstones(value: Any) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + if not isinstance(value, dict): + return out + for k, v in value.items(): + if not isinstance(v, dict): + continue + idx = str(k) + ts_raw = v.get("deleted_ts", 0) + deleted_ts = _safe_int(ts_raw, default=0) + if deleted_ts <= 0: + continue + out[idx] = { + "deleted_ts": deleted_ts, + "entry_hash": str(v.get("entry_hash", "")), + "event_hash": str(v.get("event_hash", "")), + "source": str(v.get("source", "")), + } + return out + + +def _merge_tombstones( + current: dict[str, dict[str, Any]], incoming: dict[str, dict[str, Any]] +) -> dict[str, dict[str, Any]]: + merged = dict(current) + for idx, rec in incoming.items(): + cur = merged.get(idx) + if cur is None or _prefer_tombstone(cur, rec, idx): + merged[idx] = dict(rec) + return merged + + +def _max_entry_ts(entries: dict[str, Any]) -> int: + max_ts = 0 + for value in entries.values(): + if isinstance(value, dict): + ts = _entry_ts(value) + if ts > max_ts: + max_ts = ts + return max_ts + + +def _max_tombstone_ts(tombstones: dict[str, dict[str, Any]]) -> int: + max_ts = 0 + for record in tombstones.values(): + if isinstance(record, dict): + ts = _safe_int(record.get("deleted_ts", 0), default=0) + if ts > max_ts: + max_ts = ts + return max_ts + + +def _prefer_entry(current: dict[str, Any], incoming: dict[str, Any]) -> bool: + """Return True when incoming should replace current deterministically. + + Order-independent rule: + 1) higher modified_ts wins + 2) on equal ts, lexicographically larger canonical entry hash wins + """ + cur_ts = _entry_ts(current) + inc_ts = _entry_ts(incoming) + if inc_ts != cur_ts: + return inc_ts > cur_ts + return _entry_hash(incoming) > _entry_hash(current) + + +def _union_fields_for_kind(kind: str) -> list[str]: + common = ["notes", "tags", "custom_fields"] + matrix = { + "password": ["username", "url"], + "totp": ["issuer"], + "key_value": ["key", "value"], + "managed_account": ["value", "user_id"], + "ssh": ["username", "public_key", "algorithm", "key_type"], + "pgp": ["public_key", "key_type"], + "nostr": ["npub", "public_key"], + "seed": ["path", "network", "coin_type"], + } + return matrix.get(kind, []) + common + + +def _merge_list_union(preferred: Any, other: Any) -> Any: + if isinstance(preferred, list) and isinstance(other, list): + seen: dict[str, Any] = {} + for item in preferred + other: + key = _canonical(item) + seen[key] = item + return [seen[k] for k in sorted(seen.keys())] + return preferred + + +def _merge_equal_ts_entries( + preferred: dict[str, Any], other: dict[str, Any], *, ts: int +) -> dict[str, Any]: + """Merge entries at equal timestamp with deterministic field-level rules.""" + merged = dict(preferred) + union_fields = _union_fields_for_kind(_entry_kind(merged)) + for field in union_fields: + pv = merged.get(field) + ov = other.get(field) + if field in ("tags", "custom_fields"): + merged[field] = _merge_list_union(pv, ov) + continue + if _is_empty(pv) and not _is_empty(ov): + merged[field] = ov + + # Prefer conservative archival behavior at equal timestamp. + if "archived" in preferred or "archived" in other: + merged["archived"] = bool(preferred.get("archived", False)) or bool( + other.get("archived", False) + ) + merged["modified_ts"] = int(ts) + return merged + + +def merge_index_payloads( + current: dict[str, Any], incoming: dict[str, Any], *, source_tag: str = "" +) -> dict[str, Any]: + """Merge two index payloads using deterministic conflict resolution.""" + out = dict(current) if isinstance(current, dict) else {} + cur_entries = out.get("entries", {}) + if not isinstance(cur_entries, dict): + cur_entries = {} + inc_entries = incoming.get("entries", {}) if isinstance(incoming, dict) else {} + if not isinstance(inc_entries, dict): + inc_entries = {} + cur_meta = ( + out.get("_sync_meta", {}) if isinstance(out.get("_sync_meta"), dict) else {} + ) + inc_meta = ( + incoming.get("_sync_meta", {}) + if isinstance(incoming, dict) and isinstance(incoming.get("_sync_meta"), dict) + else {} + ) + cur_tombstones = _normalize_tombstones(cur_meta.get("tombstones", {})) + inc_tombstones = _normalize_tombstones(inc_meta.get("tombstones", {})) + tombstones = _merge_tombstones(cur_tombstones, inc_tombstones) + + for idx, inc_entry in inc_entries.items(): + if not isinstance(inc_entry, dict): + continue + key = str(idx) + if _is_deleted_entry(inc_entry): + delete_ts = _entry_ts(inc_entry) + if delete_ts <= 0: + delete_ts = max( + _safe_int(cur_meta.get("last_merge_ts", 0), default=0), + _safe_int(inc_meta.get("last_merge_ts", 0), default=0), + _max_entry_ts(cur_entries), + _max_entry_ts(inc_entries), + _max_tombstone_ts(tombstones), + ) + if delete_ts <= 0: + delete_ts = 1 + tombstones = _merge_tombstones( + tombstones, + { + key: { + "deleted_ts": delete_ts, + "entry_hash": str(inc_entry.get("entry_hash", "")), + "event_hash": _entry_event_hash(inc_entry), + "source": source_tag, + } + }, + ) + cur_entries.pop(key, None) + continue + cur_entry = cur_entries.get(key) + if not isinstance(cur_entry, dict): + cur_entries[key] = inc_entry + continue + cur_ts = _entry_ts(cur_entry) + inc_ts = _entry_ts(inc_entry) + if cur_ts == inc_ts: + if _prefer_entry(cur_entry, inc_entry): + cur_entries[key] = _merge_equal_ts_entries( + inc_entry, cur_entry, ts=inc_ts + ) + else: + cur_entries[key] = _merge_equal_ts_entries( + cur_entry, inc_entry, ts=cur_ts + ) + continue + if _prefer_entry(cur_entry, inc_entry): + cur_entries[key] = inc_entry + + # Apply tombstones deterministically after entry merge. + for idx, rec in list(tombstones.items()): + entry = cur_entries.get(idx) + if not isinstance(entry, dict): + continue + e_ts = _entry_ts(entry) + d_ts = _safe_int(rec.get("deleted_ts", 0), default=0) + if e_ts > d_ts: + tombstones.pop(idx, None) + continue + if e_ts < d_ts: + cur_entries.pop(idx, None) + continue + # Equal timestamp: compare event hashes to break ties. + if _tombstone_event_hash(rec, idx) > _entry_event_hash(entry): + cur_entries.pop(idx, None) + else: + tombstones.pop(idx, None) + + out["entries"] = cur_entries + if isinstance(incoming, dict) and "schema_version" in incoming: + out["schema_version"] = max( + int(out.get("schema_version", 0)), int(incoming.get("schema_version", 0)) + ) + + meta = out.get("_sync_meta", {}) + if not isinstance(meta, dict): + meta = {} + sources = meta.get("sources", []) + if not isinstance(sources, list): + sources = [] + if source_tag and source_tag not in sources: + sources.append(source_tag) + sources = sorted(set(str(v) for v in sources if str(v))) + if tombstones: + tomb_items = sorted( + tombstones.items(), + key=lambda item: ( + _safe_int(item[1].get("deleted_ts", 0), default=0), + item[0], + ), + ) + if len(tomb_items) > TOMBSTONE_RETENTION_CAP: + tomb_items = tomb_items[-TOMBSTONE_RETENTION_CAP:] + tombstones = {k: v for k, v in tomb_items} + last_merge_ts = max( + _safe_int(meta.get("last_merge_ts", 0), default=0), + _safe_int(inc_meta.get("last_merge_ts", 0), default=0), + _max_entry_ts(cur_entries), + _max_tombstone_ts(tombstones), + ) + meta.update( + { + "strategy": "modified_ts_hash_tombstone_v2", + "last_merge_ts": last_merge_ts, + "source_count": len(sources), + "sources": sources[-32:], + "tombstones": tombstones, + } + ) + out["_sync_meta"] = meta + return out diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 42f8f673..a62b8ac1 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -2,11 +2,18 @@ from types import SimpleNamespace from pathlib import Path import os import base64 +import json import pytest from seedpass import api import seedpass.core.agent_export_policy as export_policy +import seedpass.core.agent_approval as approval_core +import seedpass.core.agent_secret_isolation as isolation_core +import seedpass.core.agent_secret_lease as lease_core +import seedpass.core.agent_job as job_core +import seedpass.core.agent_recovery as recovery_core import string +from seedpass.core.entry_types import EntryType from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, DEFAULT_RELAYS @@ -116,6 +123,80 @@ async def test_update_entry_error(client): assert res.json() == {"detail": "nope"} +@pytest.mark.anyio +async def test_get_entry_private_kind_blocked_when_high_risk_locked( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + isolation_core.set_high_risk_factor("factor-get") + (tmp_path / "agent_policy.json").write_text( + json.dumps({"secret_isolation": {"enabled": True, "high_risk_kinds": ["ssh"]}}), + encoding="utf-8", + ) + api.app.state.pm.current_fingerprint = "ABC123" + api.app.state.pm.entry_manager.retrieve_entry = lambda _i: { + "kind": "ssh", + "label": "SSH key", + } + + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + } + blocked = await cl.get("/api/v1/entry/1", headers=headers) + assert blocked.status_code == 403 + assert blocked.json()["detail"] == "policy_deny:high_risk_locked" + + unlock_headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-High-Risk-Factor": "factor-get", + } + unlocked = await cl.post( + "/api/v1/high-risk/unlock", json={"ttl": 120}, headers=unlock_headers + ) + assert unlocked.status_code == 200 + + allowed = await cl.get("/api/v1/entry/1", headers=headers) + assert allowed.status_code == 200 + assert allowed.json()["kind"] == "ssh" + + +@pytest.mark.anyio +async def test_get_entry_hydrates_partitioned_high_risk_entry(client, monkeypatch): + cl, token = client + api.app.state.pm.current_fingerprint = "ABC123" + api.app.state.pm.fingerprint_dir = "/tmp/seedpass-tests/fp" + api.app.state.pm.entry_manager.retrieve_entry = lambda _i: { + "kind": "ssh", + "label": "SSH key", + "partition": "high_risk", + } + monkeypatch.setattr(api, "high_risk_factor_configured", lambda: False) + monkeypatch.setattr(api, "unlocked_partition_key_tag", lambda **kwargs: "tag-123") + monkeypatch.setattr( + api, + "load_partition_entry", + lambda fingerprint_dir, partition_key_tag, index: { + "kind": "ssh", + "label": "SSH key", + "notes": "from-partition", + }, + ) + + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + } + res = await cl.get("/api/v1/entry/1", headers=headers) + assert res.status_code == 200 + payload = res.json() + assert payload["kind"] == "ssh" + assert payload["notes"] == "from-partition" + + @pytest.mark.anyio async def test_update_config_secret_mode(client): cl, token = client @@ -617,7 +698,13 @@ async def test_vault_export_endpoint_denied_for_agent_profile( client, tmp_path, monkeypatch ): cl, token = client + called = {} monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr( + api, + "record_export_policy_event", + lambda event, details: called.setdefault("event", (event, details)), + ) headers = { "Authorization": f"Bearer {token}", "X-SeedPass-Password": "pw", @@ -625,7 +712,139 @@ async def test_vault_export_endpoint_denied_for_agent_profile( } res = await cl.post("/api/v1/vault/export", headers=headers) assert res.status_code == 403 - assert "Policy denied full vault export" in res.json()["detail"] + assert res.json()["detail"] == "policy_deny:full_export_blocked" + assert called["event"][0] == "export_denied" + + +@pytest.mark.anyio +async def test_vault_export_endpoint_policy_filtered_manifest( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + '{"allow_kinds":["password"],"allow_export_import":false}', + encoding="utf-8", + ) + api.app.state.pm.vault = SimpleNamespace( + load_index=lambda: { + "schema_version": 4, + "entries": { + "0": {"kind": "password", "label": "ok"}, + "1": {"kind": "totp", "label": "skip"}, + }, + }, + encryption_manager=SimpleNamespace(encrypt_data=lambda payload: payload), + ) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-Agent-Profile": "true", + "X-SeedPass-Policy-Filtered": "true", + } + res = await cl.post("/api/v1/vault/export", headers=headers) + assert res.status_code == 200 + payload = json.loads(res.content.decode("utf-8")) + assert payload["_export_manifest"]["mode"] == "policy_filtered" + assert payload["_export_manifest"]["allow_kinds"] == ["password"] + assert payload["_export_manifest"]["included_entry_indexes"] == ["0"] + + +@pytest.mark.anyio +async def test_vault_export_endpoint_requires_approval_when_configured( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps( + { + "allow_kinds": ["password"], + "allow_export_import": True, + "export": {"allow_full_vault": True}, + "approvals": {"require_for": ["export"]}, + } + ), + encoding="utf-8", + ) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-Agent-Profile": "true", + } + res = await cl.post("/api/v1/vault/export", headers=headers) + assert res.status_code == 403 + assert res.json()["detail"] == "policy_deny:approval_required" + + +@pytest.mark.anyio +async def test_vault_export_endpoint_blocked_when_high_risk_locked( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + isolation_core.set_high_risk_factor("factor-abc") + (tmp_path / "agent_policy.json").write_text( + json.dumps( + { + "allow_export_import": True, + "export": {"allow_full_vault": True}, + "approvals": {"require_for": []}, + "secret_isolation": {"enabled": True, "high_risk_kinds": ["seed"]}, + } + ), + encoding="utf-8", + ) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-Agent-Profile": "true", + } + res = await cl.post("/api/v1/vault/export", headers=headers) + assert res.status_code == 403 + assert res.json()["detail"] == "policy_deny:high_risk_locked" + + +@pytest.mark.anyio +async def test_vault_export_endpoint_approval_allows_full_export( + client, tmp_path, monkeypatch +): + cl, token = client + out = tmp_path / "out.json" + out.write_text("data") + api.app.state.pm.handle_export_database = lambda *a, **k: out + + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps( + { + "allow_kinds": ["password"], + "allow_export_import": True, + "export": {"allow_full_vault": True}, + "approvals": {"require_for": ["export"]}, + } + ), + encoding="utf-8", + ) + approval = approval_core.issue_approval( + action="export", + ttl_seconds=300, + uses=1, + resource="vault:full", + ) + + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-Agent-Profile": "true", + "X-SeedPass-Approval-Id": approval["id"], + } + res = await cl.post("/api/v1/vault/export", headers=headers) + assert res.status_code == 200 + assert res.content == b"data" @pytest.mark.anyio @@ -645,7 +864,110 @@ async def test_totp_export_endpoint_denied_for_agent_profile( } res = await cl.get("/api/v1/totp/export", headers=headers) assert res.status_code == 403 - assert "Policy denied TOTP export" in res.json()["detail"] + assert res.json()["detail"] == "policy_deny:kind_not_allowed" + + +@pytest.mark.anyio +async def test_export_check_endpoint_full_denied_default(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + headers = {"Authorization": f"Bearer {token}"} + res = await cl.get("/api/v1/export/check", params={"mode": "full"}, headers=headers) + assert res.status_code == 200 + payload = res.json() + assert payload["status"] == "ok" + assert payload["allowed"] is False + assert payload["reason"] == "policy_deny:full_export_blocked" + + +@pytest.mark.anyio +async def test_export_check_endpoint_kind_allowed_default( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + headers = {"Authorization": f"Bearer {token}"} + res = await cl.get( + "/api/v1/export/check", + params={"mode": "kind", "kind": "totp"}, + headers=headers, + ) + assert res.status_code == 200 + payload = res.json() + assert payload["status"] == "ok" + assert payload["allowed"] is True + assert payload["reason"] == "policy_allow:kind_allowed" + + +@pytest.mark.anyio +async def test_export_check_endpoint_kind_requires_kind(client): + cl, token = client + headers = {"Authorization": f"Bearer {token}"} + res = await cl.get("/api/v1/export/check", params={"mode": "kind"}, headers=headers) + assert res.status_code == 400 + assert res.json()["detail"] == "missing_kind" + + +@pytest.mark.anyio +async def test_export_manifest_verify_endpoint_ok(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + policy = export_policy.load_export_policy() + package = export_policy.build_policy_filtered_export_package( + {"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}}, + policy, + ) + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post("/api/v1/export/manifest/verify", headers=headers, json=package) + assert res.status_code == 200 + payload = res.json() + assert payload["status"] == "ok" + assert payload["valid"] is True + assert payload["errors"] == [] + + +@pytest.mark.anyio +async def test_export_manifest_verify_endpoint_mismatch(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + package = export_policy.build_policy_filtered_export_package( + {"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}}, + export_policy.load_export_policy(), + ) + # Change policy after package creation to force mismatch. + (tmp_path / "agent_policy.json").write_text( + '{"allow_kinds":["totp"],"allow_export_import":false}', + encoding="utf-8", + ) + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post("/api/v1/export/manifest/verify", headers=headers, json=package) + assert res.status_code == 200 + payload = res.json() + assert payload["status"] == "ok" + assert payload["valid"] is False + assert "policy_stamp_mismatch" in payload["errors"] + + +@pytest.mark.anyio +async def test_export_manifest_verify_endpoint_detects_tamper( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + policy = export_policy.load_export_policy() + package = export_policy.build_policy_filtered_export_package( + {"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}}, + policy, + ) + package["entries"]["0"]["kind"] = "ssh" + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post("/api/v1/export/manifest/verify", headers=headers, json=package) + assert res.status_code == 200 + payload = res.json() + assert payload["status"] == "ok" + assert payload["valid"] is False + assert "entry_kind_not_allowed" in payload["errors"] + assert "entries_hash_mismatch" in payload["errors"] @pytest.mark.anyio @@ -679,6 +1001,446 @@ async def test_backup_parent_seed_endpoint(client, tmp_path): assert res.status_code == 400 +@pytest.mark.anyio +async def test_high_risk_api_unlock_status_and_lock(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + isolation_core.set_high_risk_factor("factor-123") + api.app.state.pm.current_fingerprint = "ABC123" + + headers = {"Authorization": f"Bearer {token}"} + status_before = await cl.get("/api/v1/high-risk/status", headers=headers) + assert status_before.status_code == 200 + before_payload = status_before.json() + assert before_payload["status"] == "ok" + assert before_payload["unlocked"] is False + + unlock_headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-High-Risk-Factor": "factor-123", + } + unlocked = await cl.post( + "/api/v1/high-risk/unlock", + json={"ttl": 120}, + headers=unlock_headers, + ) + assert unlocked.status_code == 200 + unlocked_payload = unlocked.json() + assert unlocked_payload["status"] == "ok" + assert unlocked_payload["fingerprint"] == "ABC123" + + status_after = await cl.get("/api/v1/high-risk/status", headers=headers) + assert status_after.status_code == 200 + assert status_after.json()["unlocked"] is True + + locked = await cl.post("/api/v1/high-risk/lock", headers=headers) + assert locked.status_code == 200 + assert locked.json()["status"] == "ok" + + status_final = await cl.get("/api/v1/high-risk/status", headers=headers) + assert status_final.status_code == 200 + assert status_final.json()["unlocked"] is False + + +@pytest.mark.anyio +async def test_agent_job_profiles_crud_endpoints(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + api.app.state.pm.current_fingerprint = "ABC123" + headers = {"Authorization": f"Bearer {token}"} + + created = await cl.post( + "/api/v1/agent/job-profiles", + headers=headers, + json={"id": "nightly", "query": "Site", "auth_broker": "keyring"}, + ) + assert created.status_code == 200 + created_payload = created.json() + assert created_payload["status"] == "ok" + assert created_payload["job_profile"]["id"] == "nightly" + + listed = await cl.get("/api/v1/agent/job-profiles", headers=headers) + assert listed.status_code == 200 + listed_payload = listed.json() + assert any(v["id"] == "nightly" for v in listed_payload["job_profiles"]) + + revoked = await cl.delete("/api/v1/agent/job-profiles/nightly", headers=headers) + assert revoked.status_code == 200 + assert revoked.json()["status"] == "ok" + + +@pytest.mark.anyio +async def test_agent_job_profile_run_issues_lease(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + monkeypatch.setattr(lease_core, "APP_DIR", tmp_path) + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + api.app.state.pm.current_fingerprint = "ABC123" + api.app.state.pm.entry_manager.search_entries = lambda q: [ + (7, "Site", "user", "url", False, EntryType.PASSWORD) + ] + headers = {"Authorization": f"Bearer {token}"} + created = await cl.post( + "/api/v1/agent/job-profiles", + headers=headers, + json={"id": "nightly-run", "query": "Site", "auth_broker": "keyring"}, + ) + assert created.status_code == 200 + + run = await cl.post( + "/api/v1/agent/job-profiles/nightly-run/run", + headers=headers, + json={}, + ) + assert run.status_code == 200 + payload = run.json() + assert payload["status"] == "ok" + assert payload["mode"] == "lease_issued" + assert payload["kind"] == "password" + assert payload["lease_id"] + + +@pytest.mark.anyio +async def test_agent_job_profile_run_policy_mismatch_denied( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + monkeypatch.setattr(lease_core, "APP_DIR", tmp_path) + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + api.app.state.pm.current_fingerprint = "ABC123" + api.app.state.pm.entry_manager.search_entries = lambda q: [ + (7, "Site", "user", "url", False, EntryType.PASSWORD) + ] + headers = {"Authorization": f"Bearer {token}"} + created = await cl.post( + "/api/v1/agent/job-profiles", + headers=headers, + json={"id": "policy-run", "query": "Site", "auth_broker": "keyring"}, + ) + assert created.status_code == 200 + (tmp_path / "agent_policy.json").write_text( + json.dumps({"allow_kinds": ["totp"], "allow_export_import": False}), + encoding="utf-8", + ) + denied = await cl.post( + "/api/v1/agent/job-profiles/policy-run/run", + headers=headers, + json={}, + ) + assert denied.status_code == 403 + assert denied.json()["detail"] == "job_profile_policy_mismatch" + + allowed = await cl.post( + "/api/v1/agent/job-profiles/policy-run/run", + headers=headers, + json={"allow_policy_drift": True}, + ) + assert allowed.status_code == 200 + assert allowed.json()["status"] == "ok" + + +@pytest.mark.anyio +async def test_agent_job_profile_template_endpoint(client, tmp_path, monkeypatch): + cl, token = client + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + api.app.state.pm.current_fingerprint = "ABC123" + headers = {"Authorization": f"Bearer {token}"} + created = await cl.post( + "/api/v1/agent/job-profiles", + headers=headers, + json={ + "id": "templated-job", + "query": "Site", + "auth_broker": "keyring", + "schedule": "*/30 * * * *", + }, + ) + assert created.status_code == 200 + + cron = await cl.get( + "/api/v1/agent/job-profiles/templated-job/template", + headers=headers, + ) + assert cron.status_code == 200 + cron_payload = cron.json() + assert cron_payload["status"] == "ok" + assert cron_payload["mode"] == "cron" + assert cron_payload["schedule_source"] == "profile" + assert "agent job-profile-run templated-job" in cron_payload["command"] + assert cron_payload["cron_line"].startswith("*/30 * * * * ") + + systemd = await cl.get( + "/api/v1/agent/job-profiles/templated-job/template", + headers=headers, + params={ + "mode": "systemd", + "schedule": "*:0/20", + "unit_name": "seedpass-nightly", + }, + ) + assert systemd.status_code == 200 + systemd_payload = systemd.json() + assert systemd_payload["mode"] == "systemd" + assert systemd_payload["schedule_source"] == "provided" + assert ( + "ExecStart=seedpass --fingerprint ABC123 agent job-profile-run templated-job" + in systemd_payload["systemd_service"] + ) + assert "OnCalendar=*:0/20" in systemd_payload["systemd_timer"] + assert "template_manifest" in systemd_payload + assert systemd_payload["template_manifest"]["job_profile_id"] == "templated-job" + + +@pytest.mark.anyio +async def test_agent_job_profile_template_post_and_verify( + client, tmp_path, monkeypatch +): + cl, token = client + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + api.app.state.pm.current_fingerprint = "ABC123" + headers = {"Authorization": f"Bearer {token}"} + created = await cl.post( + "/api/v1/agent/job-profiles", + headers=headers, + json={"id": "verify-job", "query": "Site", "auth_broker": "keyring"}, + ) + assert created.status_code == 200 + + rendered = await cl.post( + "/api/v1/agent/job-profiles/verify-job/template", + headers=headers, + json={"mode": "cron", "schedule": "0 * * * *", "include_manifest": True}, + ) + assert rendered.status_code == 200 + rendered_payload = rendered.json() + assert rendered_payload["status"] == "ok" + assert rendered_payload["schedule"] == "0 * * * *" + assert "template_manifest" in rendered_payload + + verified = await cl.post( + "/api/v1/agent/job-profiles/verify-job/template/verify", + headers=headers, + json={"template": rendered_payload}, + ) + assert verified.status_code == 200 + verified_payload = verified.json() + assert verified_payload["status"] == "ok" + assert verified_payload["valid"] is True + + tampered = dict(rendered_payload) + tampered["command"] = "seedpass --fingerprint ABC123 agent job-profile-run altered" + bad = await cl.post( + "/api/v1/agent/job-profiles/verify-job/template/verify", + headers=headers, + json={"template": tampered}, + ) + assert bad.status_code == 200 + bad_payload = bad.json() + assert bad_payload["valid"] is False + assert "template_hash_sha256" in bad_payload["mismatches"] + + +@pytest.mark.anyio +async def test_agent_recovery_split_recover_api(client, monkeypatch, tmp_path): + cl, token = client + monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path) + headers = {"Authorization": f"Bearer {token}"} + secret = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + split = await cl.post( + "/api/v1/agent/recovery/split", + headers=headers, + json={"secret": secret, "shares": 5, "threshold": 3, "label": "api-recovery"}, + ) + assert split.status_code == 200 + split_payload = split.json() + assert split_payload["status"] == "ok" + shares = split_payload["shares"] + assert len(shares) == 5 + + recover = await cl.post( + "/api/v1/agent/recovery/recover", + headers=headers, + json={"shares": shares[:3], "reveal": True}, + ) + assert recover.status_code == 200 + recover_payload = recover.json() + assert recover_payload["status"] == "ok" + assert recover_payload["secret"] == secret + + +@pytest.mark.anyio +async def test_agent_recovery_drill_and_verify_api(client, monkeypatch, tmp_path): + cl, token = client + monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path) + headers = {"Authorization": f"Bearer {token}"} + missing = tmp_path / "missing-backup.enc" + + drill = await cl.post( + "/api/v1/agent/recovery/drill", + headers=headers, + json={"backup_path": str(missing), "simulated": True, "max_age_days": 7}, + ) + assert drill.status_code == 200 + drill_payload = drill.json() + assert drill_payload["status"] == "ok" + assert drill_payload["report"]["status"] == "warning" + + listed = await cl.get("/api/v1/agent/recovery/drills", headers=headers) + assert listed.status_code == 200 + listed_payload = listed.json() + assert listed_payload["count"] >= 1 + + verify = await cl.post( + "/api/v1/agent/recovery/drills/verify", + headers=headers, + json={"limit": 50}, + ) + assert verify.status_code == 200 + verify_payload = verify.json() + assert verify_payload["status"] == "ok" + assert verify_payload["valid"] is True + + +@pytest.mark.anyio +async def test_agent_recovery_drill_verify_detects_tamper( + client, monkeypatch, tmp_path +): + cl, token = client + monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path) + headers = {"Authorization": f"Bearer {token}"} + missing = tmp_path / "missing-backup.enc" + + created = await cl.post( + "/api/v1/agent/recovery/drill", + headers=headers, + json={"backup_path": str(missing), "simulated": True}, + ) + assert created.status_code == 200 + + log_path = tmp_path / "agent_recovery_drills.log" + lines = [ln for ln in log_path.read_text(encoding="utf-8").splitlines() if ln] + payload = json.loads(lines[-1]) + payload["backup_exists"] = True + lines[-1] = json.dumps(payload, sort_keys=True) + log_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + verify = await cl.post( + "/api/v1/agent/recovery/drills/verify", + headers=headers, + json={"limit": 50}, + ) + assert verify.status_code == 200 + verify_payload = verify.json() + assert verify_payload["valid"] is False + assert any("sig_mismatch_line" in v for v in verify_payload["errors"]) + + +@pytest.mark.anyio +async def test_backup_parent_seed_blocked_when_high_risk_locked( + client, tmp_path, monkeypatch +): + cl, token = client + api.app.state.pm.parent_seed = "seed" + api.app.state.pm.current_fingerprint = "ABC123" + api.app.state.pm.encryption_manager = SimpleNamespace( + encrypt_and_save_file=lambda data, path: None, + resolve_relative_path=lambda p: p, + ) + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + isolation_core.set_high_risk_factor("factor-xyz") + (tmp_path / "agent_policy.json").write_text( + json.dumps( + {"secret_isolation": {"enabled": True, "high_risk_kinds": ["seed"]}} + ), + encoding="utf-8", + ) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + } + res = await cl.post( + "/api/v1/vault/backup-parent-seed", + json={"path": "seed.enc", "confirm": True}, + headers=headers, + ) + assert res.status_code == 403 + assert res.json()["detail"] == "policy_deny:high_risk_locked" + + +@pytest.mark.anyio +async def test_backup_parent_seed_requires_approval_when_agent_profile( + client, tmp_path, monkeypatch +): + cl, token = client + api.app.state.pm.parent_seed = "seed" + api.app.state.pm.encryption_manager = SimpleNamespace( + encrypt_and_save_file=lambda data, path: None, + resolve_relative_path=lambda p: p, + ) + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}), + encoding="utf-8", + ) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-Agent-Profile": "true", + } + res = await cl.post( + "/api/v1/vault/backup-parent-seed", + json={"path": "seed.enc", "confirm": True}, + headers=headers, + ) + assert res.status_code == 403 + assert res.json()["detail"] == "policy_deny:approval_required" + + +@pytest.mark.anyio +async def test_backup_parent_seed_allows_with_approval_when_agent_profile( + client, tmp_path, monkeypatch +): + cl, token = client + api.app.state.pm.parent_seed = "seed" + called = {} + api.app.state.pm.encryption_manager = SimpleNamespace( + encrypt_and_save_file=lambda data, path: called.setdefault("path", path), + resolve_relative_path=lambda p: p, + ) + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}), + encoding="utf-8", + ) + approval = approval_core.issue_approval( + action="reveal_parent_seed", + ttl_seconds=300, + uses=1, + resource="vault:parent-seed", + ) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + "X-SeedPass-Agent-Profile": "true", + "X-SeedPass-Approval-Id": approval["id"], + } + res = await cl.post( + "/api/v1/vault/backup-parent-seed", + json={"path": "seed.enc", "confirm": True}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json() == {"status": "saved", "path": "seed.enc"} + assert str(called["path"]) == "seed.enc" + + @pytest.mark.anyio async def test_backup_parent_seed_path_traversal_blocked(client, tmp_path): cl, token = client diff --git a/src/tests/test_cli_agent_mode.py b/src/tests/test_cli_agent_mode.py index 9f72a73c..a3a2b605 100644 --- a/src/tests/test_cli_agent_mode.py +++ b/src/tests/test_cli_agent_mode.py @@ -1,5 +1,6 @@ import json import sys +from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace @@ -46,6 +47,81 @@ def test_agent_policy_set_persists(monkeypatch, tmp_path): assert payload["allow_export_import"] is True +def test_agent_policy_lint_file(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + candidate = tmp_path / "candidate_policy.json" + candidate.write_text( + json.dumps( + { + "version": 1, + "default_effect": "deny", + "rules": [ + { + "id": "allow_read_pw", + "effect": "allow", + "operations": ["read"], + "kinds": ["password"], + "label_regex": ".*", + "path_regex": "^entry/.*$", + "fields": ["secret", "label", "kind"], + } + ], + } + ), + encoding="utf-8", + ) + result = runner.invoke(app, ["agent", "policy-lint", "--file", str(candidate)]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["path"] == str(candidate) + assert payload["policy_stamp"] + assert payload["policy_hash"] + + +def test_agent_policy_review_and_apply_risk_gate(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + candidate = tmp_path / "risky_policy.json" + candidate.write_text( + json.dumps( + { + "version": 1, + "default_effect": "allow", + "rules": [], + "output": {"safe_output_default": False, "redact_fields": []}, + "export": {"allow_full_vault": True}, + } + ), + encoding="utf-8", + ) + + review = runner.invoke(app, ["agent", "policy-review", "--file", str(candidate)]) + assert review.exit_code == 0 + review_payload = json.loads(review.stdout) + assert review_payload["changed"] is True + assert review_payload["diff_summary"]["default_effect_changed"] is True + assert review_payload["risky_finding_count"] >= 1 + + denied = runner.invoke(app, ["agent", "policy-apply", "--file", str(candidate)]) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["status"] == "denied" + assert denied_payload["reason"] == "risky_policy_change_requires_allow_risky" + + allowed = runner.invoke( + app, ["agent", "policy-apply", "--file", str(candidate), "--allow-risky"] + ) + assert allowed.exit_code == 0 + allowed_payload = json.loads(allowed.stdout) + assert allowed_payload["status"] == "ok" + assert allowed_payload["applied"] is True + + shown = runner.invoke(app, ["agent", "policy-show"]) + assert shown.exit_code == 0 + shown_policy = json.loads(shown.stdout) + assert shown_policy["default_effect"] == "allow" + + def test_agent_init_creates_profile(monkeypatch, tmp_path): monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) monkeypatch.setenv("SEEDPASS_PASSWORD", "AgentPass123!") @@ -62,6 +138,8 @@ def test_agent_init_creates_profile(monkeypatch, tmp_path): payload = json.loads(result.stdout) assert payload["status"] == "ok" assert payload["fingerprint"] + assert "next_steps" in payload + assert any("seedpass capabilities" in step for step in payload["next_steps"]) profile_dir = Path(payload["profile_dir"]) assert profile_dir.exists() assert (profile_dir / "parent_seed.enc").exists() @@ -270,6 +348,102 @@ def test_agent_get_key_value_json(monkeypatch): assert payload["secret"] == "v123" +def test_agent_get_private_kind_requires_approval(monkeypatch, tmp_path): + import seedpass.core.agent_approval as approval_core + import seedpass.core.agent_secret_isolation as isolation_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + + entry_mgr = SimpleNamespace( + get_ssh_key_pair=lambda idx, seed: ("SSH_PRIVATE", "SSH_PUBLIC") + ) + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, + password=password, + parent_seed="seed", + entry_manager=entry_mgr, + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [(7, "SSHKey", "", "", False, EntryType.SSH)] + + def retrieve_entry(self, _i): + return {"kind": EntryType.SSH.value, "label": "SSHKey"} + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "version": 1, + "default_effect": "deny", + "rules": [ + { + "id": "allow_ssh_read", + "effect": "allow", + "operations": ["read"], + "kinds": ["ssh"], + "label_regex": ".*", + "path_regex": "^entry/.*$", + "fields": ["secret"], + } + ], + "approvals": {"require_for": ["private_key_retrieval"]}, + "secret_isolation": {"enabled": False}, + "output": {"safe_output_default": False, "redact_fields": ["secret"]}, + "export": {"allow_full_vault": False}, + "allow_kinds": ["ssh"], + "deny_private_reveal": [], + "allow_export_import": False, + }, + ) + + denied = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "get", "sshkey", "--reveal"], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["status"] == "denied" + assert denied_payload["reason"] == "policy_deny:approval_required" + + approval = approval_core.issue_approval( + action="private_key_retrieval", + ttl_seconds=300, + uses=1, + resource="entry:ssh:7", + ) + allowed = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "get", + "sshkey", + "--reveal", + "--approval-id", + approval["id"], + ], + ) + assert allowed.exit_code == 0 + allowed_payload = json.loads(allowed.stdout) + assert allowed_payload["status"] == "ok" + assert allowed_payload["kind"] == "ssh" + assert allowed_payload["secret"] == "SSH_PRIVATE" + + def test_agent_get_safe_output_masks_by_default(monkeypatch): monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") monkeypatch.setattr( @@ -373,6 +547,7 @@ def test_agent_token_issue_and_use_limit(monkeypatch, tmp_path): assert issue.exit_code == 0 issued_payload = json.loads(issue.stdout) token = issued_payload["token"] + assert issued_payload["identity_id"] == "default-agent" first = runner.invoke( app, @@ -411,7 +586,48 @@ def test_agent_token_issue_and_use_limit(monkeypatch, tmp_path): assert second_payload["reason"] == "token_exhausted" -def test_agent_get_unsupported_kind_payload(monkeypatch): +def test_agent_identity_create_list_revoke(monkeypatch, tmp_path): + import seedpass.core.agent_identity as identity_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(identity_core, "APP_DIR", tmp_path) + created = runner.invoke( + app, + [ + "agent", + "identity-create", + "--id", + "ci-bot", + "--owner", + "platform", + "--policy-binding", + "default", + "--rotation-days", + "14", + ], + ) + assert created.exit_code == 0 + created_payload = json.loads(created.stdout) + assert created_payload["status"] == "ok" + assert created_payload["identity"]["id"] == "ci-bot" + + listed = runner.invoke(app, ["agent", "identity-list"]) + assert listed.exit_code == 0 + listed_payload = json.loads(listed.stdout) + assert any(v["id"] == "ci-bot" for v in listed_payload["identities"]) + + revoked = runner.invoke(app, ["agent", "identity-revoke", "ci-bot"]) + assert revoked.exit_code == 0 + revoked_payload = json.loads(revoked.stdout) + assert revoked_payload["status"] == "ok" + assert revoked_payload["id"] == "ci-bot" + + +def test_agent_token_denied_when_identity_revoked(monkeypatch, tmp_path): + import seedpass.core.agent_identity as identity_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(identity_core, "APP_DIR", tmp_path) monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") monkeypatch.setattr( agent_cli, @@ -421,6 +637,681 @@ def test_agent_get_unsupported_kind_payload(monkeypatch): ), ) + 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 **kwargs: { + "version": 1, + "default_effect": "allow", + "rules": [], + "output": {"safe_output_default": False}, + "allow_kinds": ["password"], + "deny_private_reveal": [], + }, + ) + + created = runner.invoke( + app, + ["agent", "identity-create", "--id", "revoked-agent", "--owner", "secops"], + ) + assert created.exit_code == 0 + + issue = runner.invoke( + app, + [ + "agent", + "token-issue", + "--name", + "revoked-agent-token", + "--scope", + "read", + "--kind", + "password", + "--uses", + "2", + "--ttl", + "600", + "--identity-id", + "revoked-agent", + ], + ) + assert issue.exit_code == 0 + token = json.loads(issue.stdout)["token"] + + revoke = runner.invoke(app, ["agent", "identity-revoke", "revoked-agent"]) + assert revoke.exit_code == 0 + + denied = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "get", + "example", + "--token", + token, + "--reveal", + ], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["status"] == "denied" + assert denied_payload["reason"] == "token_identity_revoked" + + +def test_agent_token_denied_when_identity_missing(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + 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 **kwargs: { + "version": 1, + "default_effect": "allow", + "rules": [], + "output": {"safe_output_default": False}, + "allow_kinds": ["password"], + "deny_private_reveal": [], + }, + ) + + raw_token, record = agent_cli._issue_token_record( + "legacy-no-identity", + 600, + ["read"], + ["password"], + ".*", + 1, + "", + ) + agent_cli._save_token_store( + {"version": agent_cli.TOKEN_STORE_VERSION, "tokens": [record]} + ) + + denied = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "get", + "example", + "--token", + raw_token, + "--reveal", + ], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["status"] == "denied" + assert denied_payload["reason"] == "token_identity_missing" + + +def test_agent_job_run_rejects_env_broker_by_default(monkeypatch): + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-run", + "example", + "--auth-broker", + "env", + ], + ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "denied" + assert payload["reason"] == "unsafe_broker_for_job" + assert "keyring" in payload["allowed_brokers"] + + +def test_agent_job_template_cron_json(monkeypatch): + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-template", + "example", + "--mode", + "cron", + "--auth-broker", + "keyring", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["mode"] == "cron" + assert "agent job-run example" in payload["command"] + assert "SEEDPASS_PASSWORD" not in payload["command"] + assert "cron_line" in payload + + +def test_agent_job_template_systemd_text(monkeypatch): + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-template", + "example", + "--mode", + "systemd", + "--format", + "text", + "--schedule", + "*:0/15", + "--unit-name", + "seedpass-nightly", + ], + ) + assert result.exit_code == 0 + assert "Agent Job Template (systemd)" in result.stdout + assert ( + "ExecStart=seedpass --fingerprint ABC123 agent job-run example" in result.stdout + ) + assert "[Timer]" in result.stdout + + +def test_agent_job_profile_lifecycle(monkeypatch, tmp_path): + import seedpass.core.agent_job as job_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + + created = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-profile-create", + "--id", + "nightly-read", + "--query", + "example", + "--auth-broker", + "keyring", + "--schedule", + "*/30 * * * *", + "--description", + "nightly pull", + ], + ) + assert created.exit_code == 0 + created_payload = json.loads(created.stdout) + assert created_payload["status"] == "ok" + assert created_payload["job_profile"]["id"] == "nightly-read" + + listed = runner.invoke(app, ["agent", "job-profile-list"]) + assert listed.exit_code == 0 + listed_payload = json.loads(listed.stdout) + assert any(v["id"] == "nightly-read" for v in listed_payload["job_profiles"]) + + calls = {} + + def fake_job_run(**kwargs): + calls.update(kwargs) + + monkeypatch.setattr(agent_cli, "agent_job_run", fake_job_run) + ran = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "job-profile-run", "nightly-read"], + ) + assert ran.exit_code == 0 + assert calls["query"] == "example" + assert calls["auth_broker"] == "keyring" + + revoked = runner.invoke(app, ["agent", "job-profile-revoke", "nightly-read"]) + assert revoked.exit_code == 0 + revoked_payload = json.loads(revoked.stdout) + assert revoked_payload["status"] == "ok" + + denied = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "job-profile-run", "nightly-read"], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["reason"] == "job_profile_not_found" + + +def test_agent_job_profile_run_fingerprint_mismatch(monkeypatch, tmp_path): + import seedpass.core.agent_job as job_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + created = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-profile-create", + "--id", + "fp-bound", + "--query", + "example", + ], + ) + assert created.exit_code == 0 + + mismatch = runner.invoke( + app, + ["--fingerprint", "XYZ999", "agent", "job-profile-run", "fp-bound"], + ) + assert mismatch.exit_code == 1 + payload = json.loads(mismatch.stdout) + assert payload["reason"] == "job_profile_fingerprint_mismatch" + + +def test_agent_job_profile_run_policy_mismatch(monkeypatch, tmp_path): + import seedpass.core.agent_job as job_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + created = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-profile-create", + "--id", + "policy-bound", + "--query", + "example", + ], + ) + assert created.exit_code == 0 + + (tmp_path / "agent_policy.json").write_text( + json.dumps({"default_effect": "allow", "rules": []}), encoding="utf-8" + ) + denied = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "job-profile-run", "policy-bound"], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["reason"] == "job_profile_policy_mismatch" + + calls = {} + + def fake_job_run(**kwargs): + calls.update(kwargs) + + monkeypatch.setattr(agent_cli, "agent_job_run", fake_job_run) + allowed = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-profile-run", + "policy-bound", + "--allow-policy-drift", + ], + ) + assert allowed.exit_code == 0 + assert calls["query"] == "example" + + +def test_agent_job_profile_run_host_mismatch(monkeypatch, tmp_path): + import seedpass.core.agent_job as job_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + created = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-profile-create", + "--id", + "host-bound", + "--query", + "example", + "--bind-host", + "other-host", + ], + ) + assert created.exit_code == 0 + denied = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "job-profile-run", "host-bound"], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["reason"] == "job_profile_host_mismatch" + + +def test_agent_job_profile_check_detects_policy_drift(monkeypatch, tmp_path): + import seedpass.core.agent_job as job_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(job_core, "APP_DIR", tmp_path) + created = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "job-profile-create", + "--id", + "drift-job", + "--query", + "example", + ], + ) + assert created.exit_code == 0 + (tmp_path / "agent_policy.json").write_text( + json.dumps({"default_effect": "allow", "rules": []}), encoding="utf-8" + ) + checked = runner.invoke( + app, ["agent", "job-profile-check", "--strict-exit", "--max-age-days", "1"] + ) + assert checked.exit_code == 1 + payload = json.loads(checked.stdout) + ids = {v["id"] for v in payload["findings"]} + assert "job_profile_policy_mismatch" in ids + + +def test_agent_recovery_split_and_recover_roundtrip(): + secret = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + split = runner.invoke( + app, + [ + "agent", + "recovery-split", + "--secret", + secret, + "--shares", + "5", + "--threshold", + "3", + "--label", + "seed-recovery", + ], + ) + assert split.exit_code == 0 + split_payload = json.loads(split.stdout) + assert split_payload["status"] == "ok" + shares = split_payload["shares"] + assert len(shares) == 5 + + recovered = runner.invoke( + app, + [ + "agent", + "recovery-recover", + "--share", + shares[0], + "--share", + shares[1], + "--share", + shares[2], + "--reveal", + ], + ) + assert recovered.exit_code == 0 + recovered_payload = json.loads(recovered.stdout) + assert recovered_payload["status"] == "ok" + assert recovered_payload["secret"] == secret + + +def test_agent_recovery_recover_insufficient_shares(): + split = runner.invoke( + app, + [ + "agent", + "recovery-split", + "--secret", + "seed words", + "--shares", + "4", + "--threshold", + "3", + ], + ) + assert split.exit_code == 0 + shares = json.loads(split.stdout)["shares"] + recovered = runner.invoke( + app, + [ + "agent", + "recovery-recover", + "--share", + shares[0], + "--share", + shares[1], + "--reveal", + ], + ) + assert recovered.exit_code == 1 + payload = json.loads(recovered.stdout) + assert payload["status"] == "error" + assert payload["reason"] == "insufficient_shares" + + +def test_agent_recovery_drill_records_and_strict_exit(monkeypatch, tmp_path): + import seedpass.core.agent_recovery as recovery_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path) + missing = tmp_path / "missing.enc" + warned = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "recovery-drill", + "--backup-path", + str(missing), + ], + ) + assert warned.exit_code == 0 + warned_payload = json.loads(warned.stdout) + assert warned_payload["report"]["status"] == "warning" + + strict = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "recovery-drill", + "--backup-path", + str(missing), + "--strict-exit", + ], + ) + assert strict.exit_code == 1 + + listed = runner.invoke(app, ["agent", "recovery-drill-list", "--limit", "5"]) + assert listed.exit_code == 0 + listed_payload = json.loads(listed.stdout) + assert listed_payload["count"] >= 1 + + +def test_agent_high_risk_unlock_required_for_private_kind(monkeypatch, tmp_path): + import seedpass.core.agent_secret_isolation as isolation_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setenv("HR_FACTOR", "factor-123") + + entry_mgr = SimpleNamespace( + get_ssh_key_pair=lambda idx, seed: ("SSH_PRIVATE", "SSH_PUBLIC") + ) + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, + password=password, + parent_seed="seed", + entry_manager=entry_mgr, + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [(7, "SSHKey", "", "", False, EntryType.SSH)] + + def retrieve_entry(self, _i): + return {"kind": EntryType.SSH.value, "label": "SSHKey"} + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "version": 1, + "default_effect": "deny", + "rules": [ + { + "id": "allow_ssh_read", + "effect": "allow", + "operations": ["read"], + "kinds": ["ssh"], + "label_regex": ".*", + "path_regex": "^entry/.*$", + "fields": ["secret"], + } + ], + "approvals": {"require_for": []}, + "secret_isolation": { + "enabled": True, + "high_risk_kinds": ["ssh"], + "unlock_ttl_sec": 300, + }, + "output": {"safe_output_default": False, "redact_fields": ["secret"]}, + "export": {"allow_full_vault": False}, + "allow_kinds": ["ssh"], + "deny_private_reveal": [], + "allow_export_import": False, + }, + ) + + denied = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "get", "sshkey", "--reveal"], + ) + assert denied.exit_code == 1 + denied_payload = json.loads(denied.stdout) + assert denied_payload["status"] == "denied" + assert denied_payload["reason"] == "policy_deny:high_risk_locked" + + set_factor = runner.invoke( + app, + ["agent", "high-risk-factor-set", "--factor-env", "HR_FACTOR"], + ) + assert set_factor.exit_code == 0 + + unlock = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "high-risk-unlock", + "--factor-env", + "HR_FACTOR", + "--ttl", + "120", + ], + ) + assert unlock.exit_code == 0 + unlock_payload = json.loads(unlock.stdout) + assert unlock_payload["status"] == "ok" + + allowed = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "get", "sshkey", "--reveal"], + ) + assert allowed.exit_code == 0 + allowed_payload = json.loads(allowed.stdout) + assert allowed_payload["status"] == "ok" + assert allowed_payload["secret"] == "SSH_PRIVATE" + + locked = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "high-risk-lock"], + ) + assert locked.exit_code == 0 + + denied_again = runner.invoke( + app, + ["--fingerprint", "ABC123", "agent", "get", "sshkey", "--reveal"], + ) + assert denied_again.exit_code == 1 + denied_again_payload = json.loads(denied_again.stdout) + assert denied_again_payload["reason"] == "policy_deny:high_risk_locked" + + +def test_agent_get_private_kind_without_approval_denied(monkeypatch, tmp_path): + import seedpass.core.agent_secret_isolation as isolation_core + + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + class DummyEntryService: def __init__(self, _pm): pass @@ -435,14 +1326,18 @@ def test_agent_get_unsupported_kind_payload(monkeypatch): monkeypatch.setattr( agent_cli, "_load_policy", - lambda **kwargs: {"allow_kinds": ["ssh"], "deny_private_reveal": []}, + lambda **kwargs: { + "allow_kinds": ["ssh"], + "deny_private_reveal": [], + "secret_isolation": {"enabled": False}, + }, ) result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "ssh"]) assert result.exit_code == 1 payload = json.loads(result.stdout) - assert payload["status"] == "error" - assert payload["reason"] == "unsupported_kind_for_agent_get" + assert payload["status"] == "denied" + assert payload["reason"] == "policy_deny:approval_required" def test_agent_policy_show_invalid_json(monkeypatch, tmp_path): @@ -529,7 +1424,9 @@ def test_agent_get_keyring_defaults_account_to_fingerprint(monkeypatch): def test_agent_bootstrap_context(monkeypatch, tmp_path): monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) - result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "bootstrap-context"]) + result = runner.invoke( + app, ["--fingerprint", "ABC123", "agent", "bootstrap-context"] + ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["status"] == "ok" @@ -537,6 +1434,20 @@ def test_agent_bootstrap_context(monkeypatch, tmp_path): assert payload["policy"]["status"] == "ok" assert "supported" in payload["auth_brokers"] assert "agent token-issue" in payload["commands"]["tokens"] + assert "agent policy-review" in payload["commands"]["policy"] + assert "agent policy-apply" in payload["commands"]["policy"] + assert "agent approval-issue" in payload["commands"]["approvals"] + assert "agent lease-consume" in payload["commands"]["leases"] + assert "agent job-run" in payload["commands"]["automation"] + assert "agent job-profile-check" in payload["commands"]["automation"] + assert "agent recovery-split" in payload["commands"]["recovery"] + assert "agent identity-create" in payload["commands"]["identities"] + assert "agent high-risk-unlock" in payload["commands"]["secret_isolation"] + assert "agent export-check" in payload["commands"]["export_controls"] + assert "agent posture-check" in payload["commands"]["posture"] + assert "agent posture-remediate" in payload["commands"]["posture"] + assert "identities" in payload + assert "export" in payload["policy"]["approvals_required_for"] def test_agent_bootstrap_context_invalid_policy(monkeypatch, tmp_path): @@ -558,6 +1469,309 @@ def test_agent_posture_check_clean(monkeypatch, tmp_path): assert payload["highest_severity"] == "info" +def test_agent_approval_issue_list_and_revoke(monkeypatch, tmp_path): + import seedpass.core.agent_approval as approval_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + + issue = runner.invoke( + app, + [ + "agent", + "approval-issue", + "--action", + "export", + "--ttl", + "600", + "--uses", + "2", + "--resource", + "vault:full", + "--issued-by", + "test-suite", + ], + ) + assert issue.exit_code == 0 + issue_payload = json.loads(issue.stdout) + assert issue_payload["status"] == "ok" + approval_id = issue_payload["approval"]["id"] + assert issue_payload["approval"]["uses_remaining"] == 2 + + listed = runner.invoke(app, ["agent", "approval-list"]) + assert listed.exit_code == 0 + listed_payload = json.loads(listed.stdout) + assert any(a["id"] == approval_id for a in listed_payload["approvals"]) + + revoked = runner.invoke(app, ["agent", "approval-revoke", approval_id]) + assert revoked.exit_code == 0 + revoked_payload = json.loads(revoked.stdout) + assert revoked_payload["status"] == "ok" + assert revoked_payload["approval_id"] == approval_id + + +def test_agent_secret_lease_issue_consume_and_exhaust(monkeypatch, tmp_path): + import seedpass.core.agent_secret_lease as lease_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(lease_core, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, + password=password, + entry_manager=SimpleNamespace( + retrieve_entry=lambda _i: { + "type": EntryType.PASSWORD.value, + "kind": EntryType.PASSWORD.value, + "label": "Example", + "length": 12, + } + ), + ), + ) + monkeypatch.setattr( + agent_cli, + "_resolve_secret_for_kind", + lambda pm, service, entry, index: "secret-pass", + ) + + 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, + "kind": "password", + "label": "Example", + "length": 12, + } + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "version": 1, + "default_effect": "allow", + "rules": [], + "output": {"safe_output_default": False}, + "allow_kinds": ["password"], + "deny_private_reveal": [], + }, + ) + + issued = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "get", + "example", + "--lease-only", + "--ttl", + "120", + "--lease-uses", + "1", + ], + ) + assert issued.exit_code == 0 + issued_payload = json.loads(issued.stdout) + assert issued_payload["status"] == "ok" + assert issued_payload["mode"] == "lease_issued" + lease_id = issued_payload["lease_id"] + + first = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "lease-consume", + lease_id, + "--reveal", + ], + ) + assert first.exit_code == 0 + first_payload = json.loads(first.stdout) + assert first_payload["status"] == "ok" + assert first_payload["secret"] == "secret-pass" + assert first_payload["lease_uses_remaining"] == 0 + + second = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "lease-consume", + lease_id, + "--reveal", + ], + ) + assert second.exit_code == 1 + second_payload = json.loads(second.stdout) + assert second_payload["status"] == "denied" + assert second_payload["reason"] == "lease_exhausted" + + +def test_agent_lease_consume_hydrates_partitioned_private_entry(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + + lease = { + "id": "lease-1", + "fingerprint": "ABC123", + "index": 7, + "kind": EntryType.SSH.value, + "uses_remaining": 0, + } + monkeypatch.setattr( + agent_cli, + "consume_lease", + lambda lease_id, fingerprint=None: (True, "ok", lease), + ) + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, + password=password, + fingerprint_dir=str(tmp_path / "fp"), + parent_seed="seed", + entry_manager=SimpleNamespace(), + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def retrieve_entry(self, _i): + return { + "kind": EntryType.SSH.value, + "label": "SSHKey", + "partition": "high_risk", + } + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "version": 1, + "default_effect": "allow", + "rules": [], + "output": {"safe_output_default": False}, + "secret_isolation": {"enabled": False}, + }, + ) + + called = {} + + def fake_hydrate(pm, *, fingerprint, index, entry): + called["fingerprint"] = fingerprint + called["index"] = index + called["partition"] = entry.get("partition") + return {"kind": EntryType.SSH.value, "label": "SSHKey", "hydrated": True} + + monkeypatch.setattr(agent_cli, "_hydrate_partition_entry_if_needed", fake_hydrate) + monkeypatch.setattr( + agent_cli, + "_resolve_secret_for_kind", + lambda pm, service, entry, index: ( + "SSH_PRIVATE" if entry.get("hydrated") else "" + ), + ) + + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "lease-consume", + "lease-1", + "--reveal", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["secret"] == "SSH_PRIVATE" + assert called == {"fingerprint": "ABC123", "index": 7, "partition": "high_risk"} + + +def test_agent_high_risk_partition_migrate_command(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setenv("SEEDPASS_HIGH_RISK_FACTOR", "factor-123") + monkeypatch.setattr(agent_cli, "verify_high_risk_factor", lambda factor: True) + monkeypatch.setattr( + agent_cli, "partition_key_tag_for_factor", lambda factor: "partition-tag" + ) + + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "secret_isolation": {"enabled": True, "high_risk_kinds": ["ssh", "seed"]} + }, + ) + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, + password=password, + fingerprint_dir=str(tmp_path / "fp"), + vault=object(), + ), + ) + + captured = {} + + def fake_migrate( + *, vault, fingerprint_dir, partition_key_tag, high_risk_kinds + ) -> dict: + captured["partition_key_tag"] = partition_key_tag + captured["high_risk_kinds"] = set(high_risk_kinds) + captured["fingerprint_dir"] = str(fingerprint_dir) + return { + "moved_count": 2, + "moved_indexes": ["1", "4"], + "partition_file": str( + Path(fingerprint_dir) / "seedpass_high_risk_entries.json.enc" + ), + } + + monkeypatch.setattr(agent_cli, "migrate_high_risk_entries", fake_migrate) + + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "high-risk-partition-migrate", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["moved_count"] == 2 + assert captured["partition_key_tag"] == "partition-tag" + assert captured["high_risk_kinds"] == {"ssh", "seed"} + + def test_agent_posture_check_invalid_policy_fails(monkeypatch, tmp_path): monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) (tmp_path / "agent_policy.json").write_text("{", encoding="utf-8") @@ -589,3 +1803,286 @@ def test_agent_posture_check_high_risk_policy(monkeypatch, tmp_path): assert "policy_default_allow" in ids assert "safe_output_disabled" in ids assert "export_import_allowed" in ids + + +def test_agent_posture_check_flags_policy_gate_and_rule_issues(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(agent_cli, "high_risk_factor_configured", lambda: True) + policy = { + "version": 1, + "default_effect": "deny", + "rules": [ + { + "id": "allow_all_read", + "effect": "allow", + "operations": ["read"], + "kinds": list(agent_cli.ALL_ENTRY_TYPES), + "label_regex": ".*", + "path_regex": "^entry/.*$", + "fields": ["secret"], + } + ], + "approvals": {"require_for": ["export"]}, + "secret_isolation": { + "enabled": True, + "high_risk_kinds": ["ssh", "seed"], + "unlock_ttl_sec": 7200, + }, + "output": {"safe_output_default": True, "redact_fields": ["secret"]}, + } + (tmp_path / "agent_policy.json").write_text(json.dumps(policy), encoding="utf-8") + result = runner.invoke(app, ["agent", "posture-check", "--fail-on", "high"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + ids = {f["id"] for f in payload["findings"]} + assert "approvals_missing_required_actions" in ids + assert "high_risk_unlock_ttl_too_long" in ids + assert "over_permissive_read_rule" in ids + assert "private_read_without_approval_gate" in ids + + +def test_agent_posture_check_flags_token_rotation_overdue(monkeypatch, tmp_path): + import seedpass.core.agent_identity as identity_core + + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setattr(identity_core, "APP_DIR", tmp_path) + + identity_core.create_identity( + identity_id="ci-bot", + owner="secops", + policy_binding="default", + rotation_days=1, + ) + stale_created = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat() + stale_expires = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat() + agent_cli._save_token_store( + { + "version": agent_cli.TOKEN_STORE_VERSION, + "tokens": [ + { + "id": "tok-1", + "name": "stale", + "token_hash": "x" * 64, + "created_at_utc": stale_created, + "expires_at_utc": stale_expires, + "revoked_at_utc": None, + "scopes": ["read"], + "kinds": ["password"], + "label_regex": ".*", + "uses_remaining": 5, + "identity_id": "ci-bot", + } + ], + } + ) + + result = runner.invoke(app, ["agent", "posture-check", "--fail-on", "medium"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + ids = {f["id"] for f in payload["findings"]} + assert "token_rotation_overdue" in ids + + +def test_agent_posture_check_runtime_requires_fingerprint(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke(app, ["agent", "posture-check", "--check-runtime-config"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["reason"] == "missing_fingerprint_for_runtime_check" + + +def test_agent_posture_check_runtime_config_findings(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + + class DummyConfig: + def get_quick_unlock(self): + return True + + def get_kdf_mode(self): + return "pbkdf2" + + def get_kdf_iterations(self): + return 50_000 + + def get_secret_class_partitions(self): + return { + "high_risk": { + "separate_factor_required": True, + "unlocked": True, + } + } + + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace(config_manager=DummyConfig()), + ) + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "posture-check", + "--check-runtime-config", + "--fail-on", + "high", + ], + ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["runtime_config_status"] == "checked" + ids = {f["id"] for f in payload["findings"]} + assert "quick_unlock_enabled" in ids + assert "weak_kdf_iterations" in ids + assert "high_risk_partition_persistently_unlocked" in ids + + +def test_agent_posture_remediate_emits_actions(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + policy = { + "version": 1, + "default_effect": "allow", + "rules": [], + "output": {"safe_output_default": False, "redact_fields": ["secret"]}, + "export": {"allow_full_vault": True}, + "allow_export_import": True, + "allow_kinds": ["password"], + "deny_private_reveal": [], + } + (tmp_path / "agent_policy.json").write_text(json.dumps(policy), encoding="utf-8") + result = runner.invoke(app, ["agent", "posture-remediate", "--fail-on", "high"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["check"] == "agent_posture_remediation" + assert payload["blocked"] is True + action_ids = {a["finding_id"] for a in payload["actions"]} + assert "policy_default_allow" in action_ids + assert "safe_output_disabled" in action_ids + assert "export_import_allowed" in action_ids + + +def test_agent_posture_remediate_runtime_requires_fingerprint(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke( + app, ["agent", "posture-remediate", "--check-runtime-config"] + ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["reason"] == "missing_fingerprint_for_runtime_check" + + +def test_agent_export_check_full_denied_by_default(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke(app, ["agent", "export-check", "--mode", "full"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["allowed"] is False + assert payload["reason"] == "policy_deny:full_export_blocked" + + +def test_agent_export_check_kind_allowed_default(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke( + app, ["agent", "export-check", "--mode", "kind", "--kind", "totp"] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["allowed"] is True + assert payload["reason"] == "policy_allow:kind_allowed" + + +def test_agent_export_check_strict_exit(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + result = runner.invoke( + app, ["agent", "export-check", "--mode", "full", "--strict-exit"] + ) + assert result.exit_code == 1 + + +def test_agent_export_manifest_verify_ok(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + import seedpass.core.agent_export_policy as export_policy + + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + from seedpass.core.agent_export_policy import build_policy_filtered_export_package + + policy = agent_cli._load_policy(strict=False) + package = build_policy_filtered_export_package( + { + "schema_version": 4, + "entries": { + "0": {"kind": "password", "label": "ok"}, + "1": {"kind": "totp", "label": "ok2"}, + }, + }, + policy, + ) + export_file = tmp_path / "filtered.json" + export_file.write_text(json.dumps(package), encoding="utf-8") + + result = runner.invoke( + app, ["agent", "export-manifest-verify", "--file", str(export_file)] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["valid"] is True + assert payload["errors"] == [] + + +def test_agent_export_manifest_verify_detects_policy_mismatch(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + import seedpass.core.agent_export_policy as export_policy + + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + from seedpass.core.agent_export_policy import build_policy_filtered_export_package + + package = build_policy_filtered_export_package( + {"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}}, + agent_cli._load_policy(strict=False), + ) + export_file = tmp_path / "filtered.json" + export_file.write_text(json.dumps(package), encoding="utf-8") + + # Change policy after package creation to force policy stamp mismatch. + (tmp_path / "agent_policy.json").write_text( + json.dumps({"allow_kinds": ["totp"], "allow_export_import": False}), + encoding="utf-8", + ) + result = runner.invoke( + app, ["agent", "export-manifest-verify", "--file", str(export_file)] + ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["valid"] is False + assert "policy_stamp_mismatch" in payload["errors"] + + +def test_agent_export_manifest_verify_detects_tampered_entries(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + import seedpass.core.agent_export_policy as export_policy + + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + from seedpass.core.agent_export_policy import build_policy_filtered_export_package + + package = build_policy_filtered_export_package( + {"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}}, + agent_cli._load_policy(strict=False), + ) + # Tamper entry kind to disallowed kind and keep original manifest. + package["entries"]["0"]["kind"] = "ssh" + export_file = tmp_path / "tampered.json" + export_file.write_text(json.dumps(package), encoding="utf-8") + + result = runner.invoke( + app, ["agent", "export-manifest-verify", "--file", str(export_file)] + ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["valid"] is False + assert "entry_kind_not_allowed" in payload["errors"] + assert "entries_hash_mismatch" in payload["errors"] diff --git a/src/tests/test_delta_merge.py b/src/tests/test_delta_merge.py index c6b28663..c7493f63 100644 --- a/src/tests/test_delta_merge.py +++ b/src/tests/test_delta_merge.py @@ -7,6 +7,7 @@ from helpers import create_vault from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager +from seedpass.core.sync_conflict import merge_index_payloads def _setup_mgr(path: Path): @@ -32,12 +33,22 @@ def test_merge_modified_ts(): assert emb.retrieve_entry(idx0)["username"] == "" ema.modify_entry(idx0, username="ua") + data_a = va.load_index() + data_a["entries"][str(idx0)]["modified_ts"] = ( + int(data_a["entries"][str(idx0)].get("modified_ts", 0)) + 1 + ) + va.save_index(data_a) delta_a = va.get_encrypted_index() or b"" vb.decrypt_and_save_index_from_nostr(delta_a, merge=True) emb.clear_cache() assert emb.retrieve_entry(idx0)["username"] == "ua" emb.modify_entry(idx1, username="ub") + data_b = vb.load_index() + data_b["entries"][str(idx1)]["modified_ts"] = ( + int(data_b["entries"][str(idx1)].get("modified_ts", 0)) + 1 + ) + vb.save_index(data_b) delta_b = vb.get_encrypted_index() or b"" va.decrypt_and_save_index_from_nostr(delta_b, merge=True) ema.clear_cache() @@ -47,3 +58,418 @@ def test_merge_modified_ts(): assert ema.retrieve_entry(idx1)["username"] == "ub" assert emb.retrieve_entry(idx0)["username"] == "ua" assert emb.retrieve_entry(idx1)["username"] == "ub" + + +def test_merge_replay_idempotent_through_vault_boundary(): + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + va, ema = _setup_mgr(base / "A") + vb, emb = _setup_mgr(base / "B") + + idx = ema.add_entry("svc", 12) + snapshot = va.get_encrypted_index() or b"" + vb.decrypt_and_save_index_from_nostr(snapshot, merge=False) + ema.modify_entry(idx, username="alice") + data_a = va.load_index() + data_a["entries"][str(idx)]["modified_ts"] = ( + int(data_a["entries"][str(idx)].get("modified_ts", 0)) + 1 + ) + va.save_index(data_a) + payload = va.get_encrypted_index() or b"" + + # First merge apply. + vb.decrypt_and_save_index_from_nostr(payload, merge=True) + emb.clear_cache() + first = vb.load_index() + assert emb.retrieve_entry(idx)["username"] == "alice" + + # Replay same payload should be idempotent. + vb.decrypt_and_save_index_from_nostr(payload, merge=True) + emb.clear_cache() + second = vb.load_index() + assert second == first + + +def test_merge_conflict_equal_timestamp_order_independent(): + current = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 100, + "username": "alice", + } + }, + } + incoming_a = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 100, + "username": "bob", + } + }, + } + incoming_b = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 100, + "username": "carol", + } + }, + } + + ab = merge_index_payloads( + merge_index_payloads(current, incoming_a, source_tag="a"), + incoming_b, + source_tag="b", + ) + ba = merge_index_payloads( + merge_index_payloads(current, incoming_b, source_tag="b"), + incoming_a, + source_tag="a", + ) + assert ab["entries"]["1"] == ba["entries"]["1"] + assert ab["_sync_meta"]["strategy"] == "modified_ts_hash_tombstone_v2" + assert sorted(ab["_sync_meta"]["sources"]) == ["a", "b"] + + +def test_merge_conflict_equal_timestamp_field_level_union(): + current = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 100, + "username": "", + "url": "https://example.com", + "notes": "", + } + }, + } + incoming = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 100, + "username": "alice", + "url": "", + "notes": "prod account", + "archived": True, + } + }, + } + merged = merge_index_payloads(current, incoming, source_tag="n1") + entry = merged["entries"]["1"] + assert entry["username"] == "alice" + assert entry["url"] == "https://example.com" + assert entry["notes"] == "prod account" + assert entry["archived"] is True + + +def test_merge_conflict_equal_timestamp_field_union_order_independent(): + base = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 200, + "username": "", + "url": "", + "notes": "", + } + }, + } + a = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 200, + "username": "alice", + "url": "", + "notes": "", + } + }, + } + b = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "x", + "modified_ts": 200, + "username": "", + "url": "https://example.com", + "notes": "note", + } + }, + } + ab = merge_index_payloads( + merge_index_payloads(base, a, source_tag="a"), b, source_tag="b" + ) + ba = merge_index_payloads( + merge_index_payloads(base, b, source_tag="b"), a, source_tag="a" + ) + assert ab["entries"]["1"] == ba["entries"]["1"] + assert ab["entries"]["1"]["username"] == "alice" + assert ab["entries"]["1"]["url"] == "https://example.com" + assert ab["entries"]["1"]["notes"] == "note" + + +def test_merge_equal_timestamp_tags_and_custom_fields_union(): + base = { + "schema_version": 4, + "entries": { + "5": { + "kind": "password", + "label": "svc", + "modified_ts": 500, + "tags": ["prod"], + "custom_fields": [{"name": "owner", "value": "ops"}], + } + }, + } + incoming = { + "schema_version": 4, + "entries": { + "5": { + "kind": "password", + "label": "svc", + "modified_ts": 500, + "tags": ["critical", "prod"], + "custom_fields": [{"name": "env", "value": "us-east-1"}], + } + }, + } + merged = merge_index_payloads(base, incoming, source_tag="b") + entry = merged["entries"]["5"] + assert entry["tags"] == ["critical", "prod"] + assert entry["custom_fields"] == [ + {"name": "env", "value": "us-east-1"}, + {"name": "owner", "value": "ops"}, + ] + + +def test_merge_tombstone_removes_older_entry(): + current = { + "schema_version": 4, + "entries": { + "2": { + "kind": "password", + "label": "x", + "modified_ts": 100, + "username": "alive", + } + }, + } + incoming = { + "schema_version": 4, + "entries": {}, + "_sync_meta": { + "tombstones": { + "2": { + "deleted_ts": 101, + "entry_hash": "abc", + "event_hash": "f" * 64, + } + } + }, + } + merged = merge_index_payloads(current, incoming, source_tag="b") + assert "2" not in merged["entries"] + assert merged["_sync_meta"]["tombstones"]["2"]["deleted_ts"] == 101 + + +def test_merge_tombstone_order_independent(): + base = { + "schema_version": 4, + "entries": {"1": {"kind": "password", "label": "x", "modified_ts": 9}}, + } + a = { + "schema_version": 4, + "entries": {}, + "_sync_meta": { + "tombstones": { + "1": { + "deleted_ts": 10, + "entry_hash": "a", + "event_hash": "e" * 64, + } + } + }, + } + b = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "new", + "modified_ts": 10, + "username": "u", + } + }, + } + ab = merge_index_payloads( + merge_index_payloads(base, a, source_tag="a"), b, source_tag="b" + ) + ba = merge_index_payloads( + merge_index_payloads(base, b, source_tag="b"), a, source_tag="a" + ) + assert ab["entries"] == ba["entries"] + assert ab["_sync_meta"]["tombstones"] == ba["_sync_meta"]["tombstones"] + + +def test_merge_replay_same_payload_is_idempotent(): + base = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "svc", + "modified_ts": 10, + "username": "", + } + }, + } + incoming = { + "schema_version": 4, + "entries": { + "1": { + "kind": "password", + "label": "svc", + "modified_ts": 11, + "username": "alice", + } + }, + "_sync_meta": {"last_merge_ts": 11}, + } + once = merge_index_payloads(base, incoming, source_tag="peer-a") + twice = merge_index_payloads(once, incoming, source_tag="peer-a") + assert once == twice + + +def test_merge_replay_older_payload_does_not_override_newer(): + current = { + "schema_version": 4, + "entries": { + "9": { + "kind": "password", + "label": "svc", + "modified_ts": 50, + "username": "new", + } + }, + "_sync_meta": {"last_merge_ts": 50}, + } + stale = { + "schema_version": 4, + "entries": { + "9": { + "kind": "password", + "label": "svc", + "modified_ts": 40, + "username": "old", + } + }, + "_sync_meta": {"last_merge_ts": 40}, + } + merged = merge_index_payloads(current, stale, source_tag="peer-b") + assert merged["entries"]["9"]["username"] == "new" + assert merged["_sync_meta"]["last_merge_ts"] == 50 + + +def test_merge_tombstone_replay_keeps_older_readd_deleted(): + current = { + "schema_version": 4, + "entries": {}, + "_sync_meta": { + "last_merge_ts": 70, + "tombstones": {"3": {"deleted_ts": 70, "event_hash": "d" * 64}}, + }, + } + stale_readd = { + "schema_version": 4, + "entries": { + "3": { + "kind": "password", + "label": "x", + "modified_ts": 69, + "username": "stale", + } + }, + "_sync_meta": {"last_merge_ts": 69}, + } + merged = merge_index_payloads(current, stale_readd, source_tag="peer-c") + assert "3" not in merged["entries"] + assert merged["_sync_meta"]["tombstones"]["3"]["deleted_ts"] == 70 + + +def test_merge_tombstone_allows_newer_recreate(): + current = { + "schema_version": 4, + "entries": {}, + "_sync_meta": { + "last_merge_ts": 70, + "tombstones": {"3": {"deleted_ts": 70, "event_hash": "d" * 64}}, + }, + } + newer_readd = { + "schema_version": 4, + "entries": { + "3": { + "kind": "password", + "label": "x", + "modified_ts": 71, + "username": "new", + } + }, + "_sync_meta": {"last_merge_ts": 71}, + } + merged = merge_index_payloads(current, newer_readd, source_tag="peer-d") + assert merged["entries"]["3"]["username"] == "new" + assert "3" not in merged["_sync_meta"]["tombstones"] + assert merged["_sync_meta"]["last_merge_ts"] == 71 + + +def test_merge_last_merge_ts_order_independent(): + base = { + "schema_version": 4, + "entries": { + "1": {"kind": "password", "label": "a", "modified_ts": 90}, + }, + "_sync_meta": {"last_merge_ts": 90}, + } + a = { + "schema_version": 4, + "entries": { + "1": {"kind": "password", "label": "a", "modified_ts": 100}, + }, + "_sync_meta": {"last_merge_ts": 100}, + } + b = { + "schema_version": 4, + "entries": {}, + "_sync_meta": { + "last_merge_ts": 95, + "tombstones": {"2": {"deleted_ts": 95, "event_hash": "a" * 64}}, + }, + } + ab = merge_index_payloads( + merge_index_payloads(base, a, source_tag="a"), b, source_tag="b" + ) + ba = merge_index_payloads( + merge_index_payloads(base, b, source_tag="b"), a, source_tag="a" + ) + assert ab["_sync_meta"]["last_merge_ts"] == ba["_sync_meta"]["last_merge_ts"] == 100 diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 6bd20521..2529df9a 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -1,4 +1,5 @@ import sys +import json from types import SimpleNamespace from pathlib import Path @@ -12,6 +13,8 @@ from seedpass.cli import api as cli_api from seedpass import cli from seedpass.core.entry_types import EntryType import seedpass.core.agent_export_policy as export_policy +import seedpass.core.agent_approval as approval_core +import seedpass.core.agent_secret_isolation as isolation_core runner = CliRunner() @@ -27,7 +30,7 @@ def test_entry_list(monkeypatch): entry_manager=SimpleNamespace(list_entries=list_entries), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm) result = runner.invoke(app, ["entry", "list"]) assert result.exit_code == 0 assert "Site" in result.stdout @@ -87,15 +90,142 @@ def test_vault_export(monkeypatch, tmp_path): def test_vault_export_denied_for_agent_profile_by_default(monkeypatch, tmp_path): + events = {} monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data") monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace()) + monkeypatch.setattr( + "seedpass.cli.vault.record_export_policy_event", + lambda event, details: events.setdefault("event", (event, details)), + ) out_path = tmp_path / "out.json" result = runner.invoke( app, ["vault", "export", "--file", str(out_path), "--agent-profile"] ) assert result.exit_code == 1 - assert "Policy denied full vault export" in result.stdout + assert "policy_deny:full_export_blocked" in result.stdout + assert events["event"][0] == "export_denied" + + +def test_vault_export_policy_filtered_includes_manifest(monkeypatch, tmp_path): + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps( + { + "allow_kinds": ["password"], + "allow_export_import": False, + "output": {"safe_output_default": True, "redact_fields": ["value"]}, + } + ), + encoding="utf-8", + ) + + class Enc: + def encrypt_data(self, payload): + return payload + + index_data = { + "schema_version": 4, + "entries": { + "0": {"kind": "password", "label": "ok"}, + "1": {"kind": "totp", "label": "skip"}, + }, + } + pm = SimpleNamespace( + vault=SimpleNamespace(load_index=lambda: index_data, encryption_manager=Enc()), + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + out_path = tmp_path / "filtered.enc" + result = runner.invoke( + app, + [ + "vault", + "export", + "--file", + str(out_path), + "--agent-profile", + "--policy-filtered", + ], + ) + assert result.exit_code == 0 + payload = json.loads(out_path.read_text(encoding="utf-8")) + manifest = payload["_export_manifest"] + assert manifest["mode"] == "policy_filtered" + assert manifest["allow_kinds"] == ["password"] + assert manifest["included_entry_indexes"] == ["0"] + + +def test_vault_export_agent_requires_approval_for_full_export(monkeypatch, tmp_path): + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps( + { + "allow_kinds": ["password"], + "allow_export_import": True, + "export": {"allow_full_vault": True}, + "approvals": {"require_for": ["export"]}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data") + monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace()) + + out_path = tmp_path / "needs-approval.enc" + result = runner.invoke( + app, + [ + "vault", + "export", + "--file", + str(out_path), + "--agent-profile", + ], + ) + assert result.exit_code == 1 + assert "policy_deny:approval_required" in result.stdout + + +def test_vault_export_agent_full_succeeds_with_approval(monkeypatch, tmp_path): + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps( + { + "allow_kinds": ["password"], + "allow_export_import": True, + "export": {"allow_full_vault": True}, + "approvals": {"require_for": ["export"]}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data") + monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace()) + approval = approval_core.issue_approval( + action="export", + ttl_seconds=300, + uses=1, + resource="vault:full", + ) + + out_path = tmp_path / "approved.enc" + result = runner.invoke( + app, + [ + "vault", + "export", + "--file", + str(out_path), + "--agent-profile", + "--approval-id", + approval["id"], + ], + ) + assert result.exit_code == 0 + assert out_path.read_bytes() == b"data" def test_vault_import(monkeypatch, tmp_path): @@ -199,7 +329,7 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path): pm = SimpleNamespace( handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None ) - monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm) out_path = tmp_path / "seed.enc" result = runner.invoke( app, @@ -210,6 +340,116 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path): assert called["path"] == out_path +def test_vault_reveal_parent_seed_agent_requires_approval(monkeypatch, tmp_path): + called = {} + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}), + encoding="utf-8", + ) + + def reveal(path=None, **_): + called["path"] = path + + pm = SimpleNamespace( + handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm) + out_path = tmp_path / "seed.enc" + result = runner.invoke( + app, + [ + "vault", + "reveal-parent-seed", + "--file", + str(out_path), + "--agent-profile", + ], + input="pw\n", + ) + assert result.exit_code == 1 + assert "policy_deny:approval_required" in result.stdout + assert "path" not in called + + +def test_vault_reveal_parent_seed_blocked_when_high_risk_locked(monkeypatch, tmp_path): + called = {} + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps( + {"secret_isolation": {"enabled": True, "high_risk_kinds": ["seed"]}} + ), + encoding="utf-8", + ) + isolation_core.set_high_risk_factor("factor-1") + + def reveal(path=None, **_): + called["path"] = path + + pm = SimpleNamespace( + handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm) + out_path = tmp_path / "seed.enc" + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "vault", + "reveal-parent-seed", + "--file", + str(out_path), + ], + input="pw\n", + ) + assert result.exit_code == 1 + assert "policy_deny:high_risk_locked" in result.stdout + assert "path" not in called + + +def test_vault_reveal_parent_seed_agent_approval_allows(monkeypatch, tmp_path): + called = {} + monkeypatch.setattr(export_policy, "APP_DIR", tmp_path) + monkeypatch.setattr(approval_core, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text( + json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}), + encoding="utf-8", + ) + + def reveal(path=None, **_): + called["path"] = path + + pm = SimpleNamespace( + handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + approval = approval_core.issue_approval( + action="reveal_parent_seed", + ttl_seconds=300, + uses=1, + resource="vault:parent-seed", + ) + out_path = tmp_path / "seed.enc" + result = runner.invoke( + app, + [ + "vault", + "reveal-parent-seed", + "--file", + str(out_path), + "--agent-profile", + "--approval-id", + approval["id"], + ], + input="pw\n", + ) + assert result.exit_code == 0 + assert called["path"] == out_path + + def test_nostr_get_pubkey(monkeypatch): pm = SimpleNamespace( nostr_client=SimpleNamespace( @@ -404,17 +644,58 @@ def test_capabilities_text(): assert result.exit_code == 0 assert "SeedPass Capabilities" in result.stdout assert "Auth brokers" in result.stdout + assert "Sync safety" in result.stdout + assert "Security posture checks" in result.stdout def test_capabilities_json(): result = runner.invoke(app, ["capabilities", "--format", "json"]) assert result.exit_code == 0 - import json - payload = json.loads(result.stdout) assert payload["schema_version"] == 1 assert "cli" in payload["interfaces"] assert "agent" in payload["interfaces"]["cli"]["root_commands"] + assert ( + "/api/v1/agent/job-profiles/{job_id}/template/verify" + in payload["interfaces"]["api"]["discovery"] + ) + assert "/api/v1/agent/recovery/split" in payload["interfaces"]["api"]["discovery"] + assert ( + "/api/v1/agent/recovery/drills/verify" + in payload["interfaces"]["api"]["discovery"] + ) + assert "leases" in payload["security_features"] + assert "automation" in payload["security_features"] + assert "sync" in payload["security_features"] + assert "policy" in payload["security_features"] + assert payload["security_features"]["policy"]["supports_change_review"] + assert ( + payload["security_features"]["sync"]["deterministic_conflict_resolution"] + == "modified_ts_hash_tombstone_v2" + ) + assert "export_controls" in payload["security_features"] + assert payload["security_features"]["export_controls"][ + "supports_manifest_entry_hash_verification" + ] + assert "posture" in payload["security_features"] + assert ( + "agent posture-remediate" in payload["security_features"]["posture"]["commands"] + ) + assert payload["security_features"]["automation"][ + "supports_persistent_job_profiles" + ] + assert payload["security_features"]["automation"]["supports_api_templates"] + assert ( + payload["security_features"]["automation"]["template_manifest_signing"] + == "hmac-sha256" + ) + assert payload["security_features"]["automation"]["enforces_policy_stamp_on_run"] + assert payload["security_features"]["automation"]["supports_host_binding"] + assert payload["security_features"]["recovery"]["supports_shamir_split_recover"] + assert payload["security_features"]["recovery"]["supports_signed_drill_reports"] + assert "identities" in payload["security_features"] + assert "secret_isolation" in payload["security_features"] + assert any("After unlock/login" in h for h in payload["help_hints"]) def test_api_start_passes_fingerprint(monkeypatch): @@ -647,7 +928,7 @@ def test_entry_export_totp_denied_for_agent_profile(monkeypatch, tmp_path): app, ["entry", "export-totp", "--file", str(out), "--agent-profile"] ) assert result.exit_code == 1 - assert "Policy denied TOTP export" in result.stdout + assert "policy_deny:kind_not_allowed" in result.stdout def test_entry_totp_codes(monkeypatch):