Add agent autonomy controls, docs updates, and landing page agent section

This commit is contained in:
thePR0M3TH3AN committed 2026-03-01 01:02:11 -05:00
1 parent baaaa25e45
commit 68cb1169e3
35 files changed
+9777 -78

No files matched your search

+25
View File
@@ -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
+13
View File
@@ -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 <group> --help`
- `seedpass capabilities --format json`
- `seedpass agent bootstrap-context`
+19
View File
@@ -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
+6
View File
@@ -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.
+13
View File
@@ -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.
+78
View File
@@ -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 <fp> 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.
+21
View File
@@ -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.
+26
View File
@@ -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.
+83
View File
@@ -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": {
"<index>": {
"deleted_ts": 1710000000,
"entry_hash": "<optional-entry-hash>",
"event_hash": "<deterministic-event-hash>",
"source": "<optional-source-tag>"
}
}
}
}
```
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.
+4
View File
@@ -55,6 +55,10 @@
<li><a href="#" data-file="ARCHITECTURE.md" class="doc-link">&gt; Architecture</a></li>
<li><a href="#" data-file="SPEC.md" class="doc-link">&gt; Specification</a></li>
<li><a href="#" data-file="security.md" class="doc-link">&gt; Security</a></li>
<li><a href="#" data-file="security_readiness_checklist.md" class="doc-link">&gt; Security Readiness</a></li>
<li><a href="#" data-file="agent_autonomy_security_plan.md" class="doc-link">&gt; Agent Security Plan</a></li>
<li><a href="#" data-file="policy_as_code.md" class="doc-link">&gt; Policy as Code</a></li>
<li><a href="#" data-file="sync_conflict_contract.md" class="doc-link">&gt; Sync Conflict Contract</a></li>
<li><a href="#" data-file="nostr_setup.md" class="doc-link">&gt; Nostr Setup</a></li>
<li><a href="#" data-file="packaging.md" class="doc-link">&gt; Packaging</a></li>
<li><a href="#" data-file="agent_testing_roadmap.md" class="doc-link">&gt; Testing Roadmap</a>
+48
View File
@@ -34,6 +34,7 @@
<ul class="nav-links">
<li><a href="#intro" class="nav-btn">[ HOME ]</a></li>
<li><a href="#features" class="nav-btn">[ FEATURES ]</a></li>
<li><a href="#agents" class="nav-btn">[ FOR AGENTS ]</a></li>
<li><a href="#architecture" class="nav-btn">[ ARCHITECTURE ]</a></li>
<li><a href="./docs.html" class="nav-btn">[ DOCS ]</a></li>
</ul>
@@ -124,6 +125,53 @@
</div>
</section>
<!-- For Agents Section -->
<section id="agents" class="ascii-box mt-2">
<h2 class="section-title"><span class="accent">#</span> For_Agents</h2>
<p class="muted">Machine-readable discovery, non-interactive auth, scoped access, and audit-first automation.</p>
<div class="agent-quickstart">
<span class="comment"># Bootstrap context before autonomous execution</span>
<div class="cmd-prompt">
<span class="prompt-symbol">❯</span>
<code class="install-cmd">seedpass capabilities --format json && seedpass agent bootstrap-context</code>
</div>
</div>
<div class="grid-features agent-grid">
<div class="feature-item">
<i class="fas fa-user-shield"></i>
<h3>Brokered Auth</h3>
<p>Use <code>keyring</code> or <code>command</code> brokers for non-interactive unlock without plaintext env secrets.</p>
</div>
<div class="feature-item">
<i class="fas fa-scale-balanced"></i>
<h3>Policy as Code</h3>
<p>Lint, review, and apply policy files with deterministic output and risk-gated changes.</p>
</div>
<div class="feature-item">
<i class="fas fa-id-badge"></i>
<h3>Scoped Identity & Tokens</h3>
<p>Issue revocable TTL-bound tokens tied to agent identities, kinds, scopes, and use limits.</p>
</div>
<div class="feature-item">
<i class="fas fa-mask"></i>
<h3>Safe Output & Leases</h3>
<p>Default redaction plus one-time or N-use secret leases for reduced exposure in logs and pipelines.</p>
</div>
<div class="feature-item">
<i class="fas fa-person-circle-check"></i>
<h3>Approval + Isolation</h3>
<p>Step-up approvals and separate high-risk unlock factors for seed/SSH/PGP/Nostr retrieval paths.</p>
</div>
<div class="feature-item">
<i class="fas fa-clipboard-check"></i>
<h3>Audit, Posture, Recovery</h3>
<p>Chained audit logs, posture checks/remediation, Shamir split-drills, and deterministic export validation.</p>
</div>
</div>
</section>
<!-- Architecture & How It Works Section -->
<section id="architecture" class="ascii-box mt-2">
<h2 class="section-title"><span class="accent">#</span> Architecture_Overview</h2>
+14
View File
@@ -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;
+472
View File
@@ -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 <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 <sec> --lease-uses <n>` issues a resource-bound lease without returning secret.
- `agent lease-consume <lease_id>` 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 <id>`
- 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 <path> [--format json|text]` with deterministic `policy_stamp` and full `policy_hash`.
- `agent policy-review --file <path>` with deterministic unified diff, structured `diff_summary`, and risky finding surfacing.
- `agent policy-apply --file <path>` 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.
+921 -8
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -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 <group> --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,
+2697 -24
View File
File diff suppressed because it is too large. Load diff
+162 -6
View File
@@ -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 <token>"],
"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 <group> --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:")
+25 -3
View File
@@ -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))
+175 -10
View File
@@ -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 <command> --help`) to discover available features."
"Tip: run `seedpass --help`, `seedpass capabilities`, and `seedpass <command> --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,
},
)
+140
View File
@@ -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"
+353 -1
View File
@@ -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
+131
View File
@@ -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"))
+129
View File
@@ -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
+291
View File
@@ -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}
+267
View File
@@ -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)
+138
View File
@@ -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
+67
View File
@@ -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))
+3 -12
View File
@@ -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:
+35
View File
@@ -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)
@@ -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)),
}
+320
View File
@@ -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
+764 -2
View File
@@ -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
File diff suppressed because it is too large. Load diff
+426
View File
@@ -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
+287 -6
View File
@@ -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):