diff --git a/README.md b/README.md index 95f37a17..4a1f8ee0 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Quick Unlock:** Optionally skip the password prompt after verifying once. - **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay. - **Tagging Support:** Organize entries with optional tags and find them quickly via search. +- **Entry Graph Links:** Connect entries with typed relationships (`target_id`, `relation`, `note`) for knowledge-base style navigation. - **Typed Search Results:** Results now display each entry's type for quicker identification. - **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API. - **Parent Seed Backup:** Securely save an encrypted copy of the master seed. @@ -98,6 +99,7 @@ with policy and security guardrails: - **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`. +- **Agent document I/O:** policy-gated filesystem import/export with `agent document-import` and `agent document-export`. - **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. @@ -501,6 +503,13 @@ export SEEDPASS_PASSWORD='your-strong-password' seedpass -f agent get github --ttl 30 ``` +4. Import/export document entries from automation (requires policy `allow_export_import=true`): + +```bash +seedpass -f agent document-import --file ./notes.md --label "Ops Notes" +seedpass -f agent document-export --entry-id 42 --out ./exports +``` + By default, agent policy blocks private key/seed-style entry kinds from being revealed through `agent get`. @@ -609,19 +618,59 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also - **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning. - **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data. - **Managed Account** – derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows ` > Managed Account > `. Press Enter on the main menu to return to the parent profile. +- **Document** – store editable text content plus a file type/extension (`md`, `txt`, `py`, `js`, `csv`, etc.) with a built-in terminal editor (`p`, `a`, `i `, `e `, `d `, `w`, `q`). -The table below summarizes the extra fields stored for each entry type. Every entry includes a `label`, while only password entries track a `url`. +All entry types include: +- `type` and `kind` +- `label` (title) +- `archived` +- `date_added` (UTC ISO-8601) +- `date_modified` (UTC ISO-8601) +- `links` (optional graph edges: `target_id`, `relation`, `note`) + +The table below summarizes additional kind-specific fields. | Entry Type | Extra Fields | |-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` | -| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` | -| SSH Key | `index`, `archived`, optional `notes`, optional `tags` | -| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` | -| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` | -| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` | -| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` | -| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` | +| Password | `username`, `url`, `length`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` | +| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, optional `notes`, optional `tags` | +| SSH Key | `index`, optional `notes`, optional `tags` | +| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, optional `notes`, optional `tags` | +| PGP Key | `index`, `key_type`, optional `user_id`, optional `notes`, optional `tags` | +| Nostr Key Pair | `index`, optional `notes`, optional `tags` | +| Key/Value | `key`, `value`, optional `notes`, optional `custom_fields`, optional `tags` | +| Managed Account | `index`, `word_count`, `fingerprint`, optional `notes`, optional `tags` | +| Document | `content`, `file_type`, optional `notes`, optional `custom_fields`, optional `tags` | + +For a complete per-kind schema reference, see [docs/entry_types.md](docs/entry_types.md). + +### Document Import/Export + +SeedPass can import local text files as `document` entries and export stored documents back to your OS filesystem. + +- CLI import: + - `seedpass entry import-document --file ./notes.md --label "Ops Notes"` +- CLI export: + - `seedpass entry export-document --entry-id 42 --out ./exports` +- API import: + - `POST /api/v1/entry/document/import` with JSON: `{"path":"./notes.md","label":"Ops Notes"}` +- API export: + - `POST /api/v1/entry/{entry_id}/document/export` with JSON: `{"path":"./exports","overwrite":false}` + +In TUI, choose `Add Entry > Document > Import Document File` to import, and use `Entry Actions > Export Document to File` on a document entry to export. + +### Entry Graph Links + +SeedPass entries can be connected with typed links so tags can evolve into an explicit knowledge graph. + +- CLI: + - `seedpass entry link-add --entry-id 1 --target-id 2 --relation references --note "used by runbook"` + - `seedpass entry links --entry-id 1` + - `seedpass entry link-remove --entry-id 1 --target-id 2 --relation references` +- API: + - `GET /api/v1/entry/{id}/links` + - `POST /api/v1/entry/{id}/links` + - `DELETE /api/v1/entry/{id}/links` ### Managing Multiple Seeds diff --git a/docs/README.md b/docs/README.md index 1f7c851f..927930f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ This directory contains SeedPass project documentation. - `ARCHITECTURE.md` - `SPEC.md` +- `entry_types.md` +- `entry_graph.md` - `security.md` - `nostr_setup.md` - `nostr_namespace_reset.md` diff --git a/docs/entry_graph.md b/docs/entry_graph.md new file mode 100644 index 00000000..23c6ab44 --- /dev/null +++ b/docs/entry_graph.md @@ -0,0 +1,53 @@ +# Entry Graph Links + +SeedPass now supports explicit relationships between entries, so your vault can evolve from tag clusters into a typed knowledge graph. + +## Link Model + +Every entry can include: + +- `links`: list of objects with: + - `target_id`: target entry index + - `relation`: link type (for example `references`, `depends_on`, `owned_by`, `derived_from`) + - `note`: optional free-form context + +Example: + +```json +{ + "links": [ + { "target_id": 42, "relation": "references", "note": "Used by deploy script" } + ] +} +``` + +## CLI Commands + +- Add link: + - `seedpass entry link-add --entry-id 1 --target-id 42 --relation references --note "Used by deploy script"` +- List links: + - `seedpass entry links --entry-id 1` +- Remove link: + - `seedpass entry link-remove --entry-id 1 --target-id 42 --relation references` +- Replace full link list: + - `seedpass entry modify 1 --links-json '[{"target_id":42,"relation":"references","note":"Used by deploy script"}]'` + +## API Endpoints + +- `GET /api/v1/entry/{id}/links` +- `POST /api/v1/entry/{id}/links` + - Body: `{ "target_id": 42, "relation": "references", "note": "..." }` +- `DELETE /api/v1/entry/{id}/links` + - Body: `{ "target_id": 42, "relation": "references" }` +- `PUT /api/v1/entry/{id}` also accepts a full `links` array for replacement. + +## Search and Sync Behavior + +- Search now matches link metadata (`relation`, `note`) and linked target labels. +- Sync conflict resolution includes `links` in deterministic equal-timestamp union merge, alongside `tags` and `custom_fields`. + +## Recommended Conventions + +- Keep `relation` values short and consistent (`references`, `depends_on`, `owned_by`, `belongs_to`, `derived_from`). +- Use `note` for human context instead of encoding meaning into long relation strings. +- Use tags for broad grouping, links for explicit semantics. diff --git a/docs/entry_types.md b/docs/entry_types.md new file mode 100644 index 00000000..ffc04595 --- /dev/null +++ b/docs/entry_types.md @@ -0,0 +1,110 @@ +# Entry Types and Fields + +This document defines the entry kinds supported by SeedPass, shared fields, and kind-specific fields used by the TUI and API. + +For graph-link workflows and relationship modeling, see `docs/entry_graph.md`. + +## Shared Fields + +Every entry kind stores: + +- `type`: canonical kind identifier. +- `kind`: kind alias used for backward compatibility. +- `label`: entry title. +- `archived`: boolean archive flag. +- `date_added`: UTC ISO-8601 timestamp set on creation. +- `date_modified`: UTC ISO-8601 timestamp updated on modification. +- `notes`: optional free-form notes. +- `tags`: optional string list. +- `links`: optional relationship list (`target_id`, `relation`, `note`) for graph-style connections between entries. + +Internally, SeedPass also tracks `modified_ts` (UNIX timestamp) for deterministic merge conflict resolution. + +## Kind-Specific Fields + +### `password` + +- `length` +- `username` +- `url` +- `policy` (optional password policy overrides) +- `custom_fields` (optional hidden/plain additional fields) + +### `totp` + +- `index` (for deterministic TOTP) or `secret` (for imported TOTP) +- `period` +- `digits` +- `deterministic` + +### `ssh` + +- `index` (derivation index) + +### `seed` + +- `index` (derivation index) +- `word_count` + +### `pgp` + +- `index` (derivation index) +- `key_type` +- `user_id` + +### `nostr` + +- `index` (derivation index) + +### `key_value` + +- `key` +- `value` +- `custom_fields` (optional) + +### `managed_account` + +- `index` (derivation index) +- `word_count` +- `fingerprint` +- `custom_fields` (optional) + +### `document` + +- `content` +- `file_type` (extension/type like `txt`, `md`, `py`, `js`, `csv`) +- `custom_fields` (optional) + +## TUI Notes + +- Add flow: `Main Menu > Add Entry > Document`. +- Document editing uses a built-in line editor: + - `p` print content + - `a` append line + - `i ` insert before line + - `e ` edit line + - `d ` delete line + - `w` save + - `q` cancel + +## API Notes + +- Create: `POST /api/v1/entry` with `type=document` (or `kind=document`) +- Update: `PUT /api/v1/entry/{id}` with `content`, `file_type`, `label`, `notes`, `tags`, `archived` +- Read: `GET /api/v1/entry/{id}` returns all stored fields, including `date_added` and `date_modified` +- File import: `POST /api/v1/entry/document/import` with `path` (+ optional `label`, `notes`, `tags`, `archived`) +- File export: `POST /api/v1/entry/{id}/document/export` with optional `path` and `overwrite` +- Link list: `GET /api/v1/entry/{id}/links` +- Link add: `POST /api/v1/entry/{id}/links` with `{ "target_id": 2, "relation": "references", "note": "..." }` +- Link remove: `DELETE /api/v1/entry/{id}/links` with `{ "target_id": 2, "relation": "references" }` + +## CLI Notes + +- Add directly: `seedpass entry add-document --label "Doc" --content "text" --file-type txt` +- Import from file: `seedpass entry import-document --file ./doc.md` +- Export to file: `seedpass entry export-document --entry-id 42 --out ./exports` +- Agent import: `seedpass -f agent document-import --file ./doc.md --token ` +- Agent export: `seedpass -f agent document-export --entry-id 42 --out ./exports --token ` +- Link add: `seedpass entry link-add --entry-id 1 --target-id 2 --relation references --note "supports deployment"` +- Link list: `seedpass entry links --entry-id 1` +- Link remove: `seedpass entry link-remove --entry-id 1 --target-id 2 --relation references` diff --git a/docs/sync_conflict_contract.md b/docs/sync_conflict_contract.md index d83c170e..bc39e22c 100644 --- a/docs/sync_conflict_contract.md +++ b/docs/sync_conflict_contract.md @@ -22,8 +22,8 @@ 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. + - `tags`, `custom_fields`, and `links` use canonical sorted union. + - Optional kind-specific fields (for example document `content`/`file_type`) are backfilled when one side is empty. 4. `archived` at equal timestamp uses conservative OR semantics. ## Tombstone Semantics diff --git a/landing/docs.html b/landing/docs.html index af6b5edc..be20c7ec 100644 --- a/landing/docs.html +++ b/landing/docs.html @@ -54,6 +54,8 @@
  • > README
  • > Architecture
  • > Specification
  • +
  • > Entry Types
  • +
  • > Entry Graph
  • > Security
  • > Security Readiness
  • > Agent Security Plan
  • diff --git a/landing/index.html b/landing/index.html index fdf6027b..2d2d5065 100644 --- a/landing/index.html +++ b/landing/index.html @@ -66,6 +66,7 @@
    ├─► SSH Keys
    ├─► PGP Key
    ├─► Nostr Keys
    +
    ├─► Documents
    └─► Managed Accounts
    @@ -122,6 +123,21 @@

    Secret Mode

    Copy to clipboard directly, clearing automatically after delay.

    +
    + +

    Document Entries

    +

    Store rich text content with file types like md, txt, py, and edit in-terminal.

    +
    +
    + +

    Entry Timestamps

    +

    All entry kinds include date_added and date_modified for deterministic auditability.

    +
    +
    + +

    Entry Graph Links

    +

    Create typed relationships between entries using target_id, relation, and note.

    +
    @@ -169,6 +185,16 @@

    Audit, Posture, Recovery

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

    +
    + +

    Agent Document I/O

    +

    Policy-gated agent document-import and agent document-export for controlled filesystem workflows.

    +
    +
    + +

    Graph-Aware Entry Ops

    +

    Automate knowledge links with entry link-add, entry links, and entry link-remove.

    +
    @@ -191,6 +217,18 @@ +
    +
    +

    > Knowledge Graph Example

    +
    [12] Ops Runbook (document)
    +  ├─ references ──> [7] API Token (key_value)
    +  ├─ depends_on ──> [3] Deploy SSH (ssh)
    +  └─ rotates_with -> [9] Prod MFA (totp)
    +
    +Query: "depends_on" or "deploy ssh" -> returns entry 12
    +
    +
    +

    > Recovery via Nostr

    diff --git a/landing/style.css b/landing/style.css index 4ad3ddbd..03ffa4a1 100644 --- a/landing/style.css +++ b/landing/style.css @@ -567,6 +567,19 @@ a:hover { color: var(--text-muted); } +.graph-snippet { + margin-top: 10px; + padding: 12px; + border: 1px solid var(--border-color); + border-left: 2px solid var(--accent-secondary); + border-radius: 4px; + background: rgba(8, 10, 12, 0.45); + color: var(--text-primary); + font-size: 0.82rem; + line-height: 1.55; + overflow-x: auto; +} + /* Footer */ .tui-footer { display: flex; diff --git a/memory-update.md b/memory-update.md index d4cad7fa..cbad1a23 100644 --- a/memory-update.md +++ b/memory-update.md @@ -37,3 +37,128 @@ - Verified with local venv: - `./.venv/bin/pytest -q src/tests/test_post_sync_messages.py` - `./.venv/bin/pytest -q src/tests/test_background_vault_sync_paths.py` + +## Entry model expansion: document kind + universal date fields +- Added a new `document` entry kind across core enum, TUI, API, CLI, and agent secret resolution. +- Added universal `date_added` and `date_modified` fields for all entry kinds. + - Creation now sets both fields. + - Modification preserves `date_added` and updates `date_modified`. + - Legacy entries are normalized in `_load_index()` by deriving date fields from `modified_ts`/`updated`. +- TUI now supports `Document` creation via a built-in line editor with basic commands (`p`, `a`, `i `, `e `, `d `, `w`, `q`). +- Added document-specific display and edit flows in manager/display services. + +## Sync/policy compatibility notes +- Updated deterministic merge kind matrix to include document fields (`content`, `file_type`) for equal-timestamp backfill behavior. +- Agent defaults now include `document` in non-private allow-kinds, and agent secret resolution returns document content for `document` entries. + +## Tests and docs +- Added/updated tests for: + - document entry roundtrip and CLI add command + - universal date field presence/update semantics across all entry kinds + - API create/modify behavior for `document` + - large document Nostr sync roundtrip +- Added `docs/entry_types.md` and updated README/docs index and sync conflict docs. +- Verified with: + - `./.venv/bin/pytest -q src/tests/test_cli_entry_add_commands.py src/tests/test_entry_add.py src/tests/test_entry_types_roundtrip.py src/tests/test_modify_entry_validation.py src/tests/test_api.py src/tests/test_api_new_endpoints.py src/tests/test_full_sync_roundtrip.py` + - Result: `141 passed`. + +## Document file import/export workflows +- Added document file I/O at core layer: + - `EntryManager.import_document_file(path, ...)` + - `EntryManager.export_document_file(index, output_path=None, overwrite=False)` +- Added TUI support: + - `Add Entry > Document` now supports `New Document` and `Import Document File`. + - Document entry actions now include `Export Document to File`. +- Added automation surfaces: + - CLI: `entry import-document`, `entry export-document` + - API: `POST /api/v1/entry/document/import`, `POST /api/v1/entry/{id}/document/export` +- Regression coverage added for core file roundtrip, CLI commands, and API endpoints. + +## Agent document import/export commands +- Added dedicated agent CLI commands: + - `seedpass agent document-import --file ` + - `seedpass agent document-export ` (or `--entry-id`) +- Enforcement model for both commands: + - Requires `--fingerprint` and non-interactive auth broker password resolution. + - Denies unless policy `allow_export_import` is enabled. + - Denies unless `document` kind is policy-allowed (`allow_kinds` / kind export check). + - Supports scoped token enforcement with operation scopes (`import` for import, `export` for export). + - Export honors approval gates when policy requires `export` approval. + - Emits structured JSON responses and append-only audit events for granted/denied outcomes. +- Discovery updates: + - Added `document_io` command hints to `agent bootstrap-context` payload. + - Added `document_io` section to `seedpass capabilities --format json` security features. +- Added CLI tests for: + - token-scoped agent document import success path + - token-scoped agent document export success path + - bootstrap context includes document command hints +- Environment note: could not run pytest in this shell because `pytest` is unavailable (`python3 -m pytest` reports module missing). + +## Test environment note (2026-03-01) +- Repo `.venv` already had `pytest` and `typer`; upgraded to `pytest 9.0.2` and `typer 0.24.1`. +- New agent document I/O tests initially failed due mocked policy normalization (empty `rules` caused normalized `allow_kinds` to become empty). +- Fixed tests by adding explicit allow rule for `document` in mocked policies. +- Verification run: + - `.venv/bin/pytest -q src/tests/test_document_file_io.py src/tests/test_cli_entry_add_commands.py src/tests/test_api_new_endpoints.py src/tests/test_entry_types_roundtrip.py src/tests/test_full_sync_roundtrip.py src/tests/test_cli_agent_mode.py` + - Result: `150 passed`. + +## Full-suite regression repair after document rollout +- Ran full suite after enabling agent document I/O and found 12 regressions (mostly stale tests). +- Fixed regressions by updating tests for: + - new document add-menu option/mocks (`handle_add_document`), + - expanded `EntryService.modify_entry` kwargs, + - universal `date_added`/`date_modified` and `custom_fields` fields in entry expectations, + - `list_entries(sort_by="updated")` relying on `modified_ts`, + - legacy decrypt fallback monkeypatches to accept `salt` argument and revised iteration-call expectations. +- Verification: `.venv/bin/pytest -q src/tests` => `891 passed, 11 skipped`. + +## Knowledge graph links integration (entry relationships) +- Added first-class graph edges to every entry via shared `links` field (list of `{target_id, relation, note}`). +- Implemented core link operations in `EntryManager`: + - `add_link(entry_id, target_id, relation, note)` + - `remove_link(entry_id, target_id, relation=None)` + - `get_links(entry_id)` with resolved target label/kind. +- Added normalization/defaulting for `links` in index load path, including cache-return normalization to prevent stale in-memory schemas. +- Extended `modify_entry(..., links=...)` to allow full links replacement with validation. +- Search now matches link metadata (`relation`, `note`) and linked target labels, enabling graph-style discovery from existing index scans. +- Sync conflict equal-timestamp union now includes `links` alongside `tags` and `custom_fields` for deterministic convergence. +- Added API endpoints: + - `GET /api/v1/entry/{id}/links` + - `POST /api/v1/entry/{id}/links` + - `DELETE /api/v1/entry/{id}/links` + and `PUT /api/v1/entry/{id}` now accepts `links`. +- Added CLI graph commands: + - `seedpass entry link-add` + - `seedpass entry links` + - `seedpass entry link-remove` + plus `entry modify --links-json`. +- Documentation updated in `README.md`, `docs/entry_types.md`, and `docs/sync_conflict_contract.md`. +- Validation: + - Focused suite: `140 passed` + - Full suite: `895 passed, 11 skipped`. + +## Website/docs refresh for graph features +- Added dedicated graph docs page: `docs/entry_graph.md`. +- Updated docs indexes/navigation: + - `docs/README.md` now lists `entry_graph.md`. + - `landing/docs.html` sidebar now includes `Entry Graph`. +- Updated landing page feature cards (`landing/index.html`) to highlight: + - Entry Graph Links + - Graph-Aware Entry Ops CLI commands +- Updated docs cross-links: + - `docs/entry_types.md` points to `docs/entry_graph.md`. +- Updated README feature bullets to include Entry Graph Links. + +## Landing graph example + help-hint refresh +- Added a visual graph snippet block to landing architecture section (`landing/index.html`) showing linked entries and graph query behavior. +- Styled graph snippet in `landing/style.css` (`.graph-snippet`) to match the terminal UI aesthetic. +- Expanded help/discovery surfaces for users and agents: + - `seedpass capabilities` now includes a `knowledge_graph` security feature block with CLI/API graph commands and sync/search behavior. + - Capabilities API discovery now lists `/api/v1/entry/{id}/links`. + - Capabilities text output now explicitly mentions graph links and document workflows. + - Capabilities `help_hints` now include graph/document command pointers. + - `agent bootstrap-context` now includes `commands.knowledge_graph` hints. + - Root CLI help string now mentions document I/O and entry graph links. +- Verification: + - `.venv/bin/pytest -q src/tests/test_typer_cli.py -k "capabilities_text or capabilities_json" src/tests/test_cli_agent_mode.py -k "bootstrap_context"` + - Result: `2 passed`. diff --git a/src/main.py b/src/main.py index f02e76b0..7a00430c 100644 --- a/src/main.py +++ b/src/main.py @@ -425,9 +425,7 @@ def handle_post_to_nostr( logging.info("Encrypted index posted to Nostr successfully.") else: client = getattr(password_manager, "nostr_client", None) - detail = ( - getattr(client, "last_error", None) if client is not None else None - ) + detail = getattr(client, "last_error", None) if client is not None else None if detail: print(colored(f"\N{CROSS MARK} Sync failed… {detail}", "red")) logging.error("Failed to post encrypted index to Nostr: %s", detail) @@ -1413,6 +1411,7 @@ def display_menu( print(color_text("6. PGP Key", "menu")) print(color_text("7. Key/Value", "menu")) print(color_text("8. Managed Account", "menu")) + print(color_text("9. Document", "menu")) sub_choice = input( "Select entry type or press Enter to go back: " ).strip() @@ -1441,6 +1440,9 @@ def display_menu( elif sub_choice == "8": password_manager.handle_add_managed_account() break + elif sub_choice == "9": + password_manager.handle_add_document() + break elif not sub_choice: break else: diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 6304dadd..584d91b2 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -1021,55 +1021,78 @@ async def create_entry( int(entry.get("length", 12)), entry.get("username"), entry.get("url"), + archived=entry.get("archived", False), + notes=entry.get("notes", ""), + custom_fields=entry.get("custom_fields"), + tags=entry.get("tags"), **kwargs, ) return {"id": index} if etype == "totp": index = pm.entry_manager.get_next_index() - + totp_kwargs: dict[str, Any] = { + "secret": entry.get("secret"), + "index": entry.get("index"), + "period": int(entry.get("period", 30)), + "digits": int(entry.get("digits", 6)), + "notes": entry.get("notes", ""), + "archived": entry.get("archived", False), + "deterministic": entry.get("deterministic", False), + } + if entry.get("tags") is not None: + totp_kwargs["tags"] = entry.get("tags") uri = pm.entry_manager.add_totp( entry.get("label"), pm.KEY_TOTP_DET if entry.get("deterministic", False) else None, - secret=entry.get("secret"), - index=entry.get("index"), - period=int(entry.get("period", 30)), - digits=int(entry.get("digits", 6)), - notes=entry.get("notes", ""), - archived=entry.get("archived", False), - deterministic=entry.get("deterministic", False), + **totp_kwargs, ) return {"id": index, "uri": uri} if etype == "ssh": + ssh_kwargs: dict[str, Any] = { + "index": entry.get("index"), + "notes": entry.get("notes", ""), + "archived": entry.get("archived", False), + } + if entry.get("tags") is not None: + ssh_kwargs["tags"] = entry.get("tags") index = pm.entry_manager.add_ssh_key( entry.get("label"), pm.parent_seed, - index=entry.get("index"), - notes=entry.get("notes", ""), - archived=entry.get("archived", False), + **ssh_kwargs, ) return {"id": index} if etype == "pgp": + pgp_kwargs: dict[str, Any] = { + "index": entry.get("index"), + "key_type": entry.get("key_type", "ed25519"), + "user_id": entry.get("user_id", ""), + "notes": entry.get("notes", ""), + "archived": entry.get("archived", False), + } + if entry.get("tags") is not None: + pgp_kwargs["tags"] = entry.get("tags") index = pm.entry_manager.add_pgp_key( entry.get("label"), pm.parent_seed, - index=entry.get("index"), - key_type=entry.get("key_type", "ed25519"), - user_id=entry.get("user_id", ""), - notes=entry.get("notes", ""), - archived=entry.get("archived", False), + **pgp_kwargs, ) return {"id": index} if etype == "nostr": + nostr_kwargs: dict[str, Any] = { + "index": entry.get("index"), + "notes": entry.get("notes", ""), + "archived": entry.get("archived", False), + } + if entry.get("tags") is not None: + nostr_kwargs["tags"] = entry.get("tags") index = pm.entry_manager.add_nostr_key( entry.get("label"), pm.parent_seed, - index=entry.get("index"), - notes=entry.get("notes", ""), - archived=entry.get("archived", False), + **nostr_kwargs, ) return {"id": index} @@ -1079,6 +1102,25 @@ async def create_entry( entry.get("key"), entry.get("value"), notes=entry.get("notes", ""), + custom_fields=entry.get("custom_fields"), + tags=entry.get("tags"), + archived=entry.get("archived", False), + ) + return {"id": index} + + if etype == "document": + label = entry.get("label") or entry.get("title") + document_kwargs: dict[str, Any] = { + "file_type": entry.get("file_type", "txt"), + "notes": entry.get("notes", ""), + "archived": entry.get("archived", False), + } + if entry.get("tags") is not None: + document_kwargs["tags"] = entry.get("tags") + index = pm.entry_manager.add_document( + label, + entry.get("content", ""), + **document_kwargs, ) return {"id": index} @@ -1093,6 +1135,8 @@ async def create_entry( pm.parent_seed, index=entry.get("index"), notes=entry.get("notes", ""), + archived=entry.get("archived", False), + tags=entry.get("tags"), ) return {"id": index} @@ -1125,12 +1169,140 @@ def update_entry( digits=entry.get("digits"), key=entry.get("key"), value=entry.get("value"), + content=entry.get("content"), + file_type=entry.get("file_type"), + custom_fields=entry.get("custom_fields"), + tags=entry.get("tags"), + links=entry.get("links"), + archived=entry.get("archived"), ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return {"status": "ok"} +@app.get("/api/v1/entry/{entry_id}/links") +def get_entry_links( + request: Request, + entry_id: int, + authorization: str | None = Header(None), +) -> dict[str, Any]: + """Return resolved graph links for an entry.""" + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + try: + links = pm.entry_manager.get_links(entry_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return {"entry_id": entry_id, "links": links} + + +@app.post("/api/v1/entry/{entry_id}/links") +def add_entry_link( + request: Request, + entry_id: int, + data: dict, + authorization: str | None = Header(None), +) -> dict[str, Any]: + """Add a graph link from one entry to another.""" + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + if "target_id" not in data: + raise HTTPException(status_code=400, detail="missing_target_id") + try: + links = pm.entry_manager.add_link( + entry_id, + int(data.get("target_id")), + relation=str(data.get("relation", "related_to")), + note=str(data.get("note", "")), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"entry_id": entry_id, "links": links} + + +@app.delete("/api/v1/entry/{entry_id}/links") +def remove_entry_link( + request: Request, + entry_id: int, + data: dict, + authorization: str | None = Header(None), +) -> dict[str, Any]: + """Remove graph links from one entry to a target entry.""" + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + if "target_id" not in data: + raise HTTPException(status_code=400, detail="missing_target_id") + try: + links = pm.entry_manager.remove_link( + entry_id, + int(data.get("target_id")), + relation=data.get("relation"), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"entry_id": entry_id, "links": links} + + +@app.post("/api/v1/entry/document/import") +def import_document_entry( + request: Request, + data: dict, + authorization: str | None = Header(None), +) -> dict[str, Any]: + """Import a local text file as a document entry.""" + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + + source_path = data.get("path") + if not source_path: + raise HTTPException(status_code=400, detail="missing_path") + try: + index = pm.entry_manager.import_document_file( + source_path, + label=data.get("label"), + notes=data.get("notes", ""), + archived=bool(data.get("archived", False)), + tags=data.get("tags"), + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"id": index} + + +@app.post("/api/v1/entry/{entry_id}/document/export") +def export_document_entry( + request: Request, + entry_id: int, + data: dict, + authorization: str | None = Header(None), +) -> dict[str, Any]: + """Export a document entry to a local file path.""" + _check_token(request, authorization) + _require_unlocked(request) + pm = _get_pm(request) + + try: + dest = pm.entry_manager.export_document_file( + entry_id, + output_path=data.get("path"), + overwrite=bool(data.get("overwrite", False)), + ) + except FileExistsError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"path": str(dest)} + + @app.post("/api/v1/entry/{entry_id}/archive") def archive_entry( request: Request, entry_id: int, authorization: str | None = Header(None) diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index f7f4b13b..f684b69c 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -16,7 +16,8 @@ app = typer.Typer( help=( "SeedPass command line interface. " "Run `seedpass capabilities` for a deterministic feature map " - "and `seedpass --help` for group-specific commands." + "and `seedpass --help` for group-specific commands, " + "including document I/O and entry graph links." ), invoke_without_command=True, ) diff --git a/src/seedpass/cli/agent.py b/src/seedpass/cli/agent.py index 097d86f2..92dd4e71 100644 --- a/src/seedpass/cli/agent.py +++ b/src/seedpass/cli/agent.py @@ -113,6 +113,7 @@ DEFAULT_POLICY = { EntryType.PASSWORD.value, EntryType.TOTP.value, EntryType.KEY_VALUE.value, + EntryType.DOCUMENT.value, ], "label_regex": ".*", "path_regex": "^entry/.*$", @@ -152,6 +153,7 @@ DEFAULT_POLICY = { EntryType.PASSWORD.value, EntryType.TOTP.value, EntryType.KEY_VALUE.value, + EntryType.DOCUMENT.value, ], "deny_private_reveal": sorted(PRIVATE_KINDS), "allow_export_import": False, @@ -267,6 +269,8 @@ def _resolve_secret_for_kind( return service.get_totp_code(index) if kind == EntryType.KEY_VALUE.value: return str(entry.get("value", "")) + if kind == EntryType.DOCUMENT.value: + return str(entry.get("content", "")) if kind == EntryType.SEED.value: return pm.entry_manager.get_seed_phrase(index, pm.parent_seed) if kind == EntryType.MANAGED_ACCOUNT.value: @@ -1302,7 +1306,9 @@ def _remediation_actions(findings: list[dict[str, Any]]) -> list[dict[str, Any]] "export_import_allowed": { "title": "Disable broad export/import for agent profiles", "commands": ["seedpass agent policy-review --file "], - "notes": ["Set export.allow_full_vault=false and keep allow_export_import=false."], + "notes": [ + "Set export.allow_full_vault=false and keep allow_export_import=false." + ], }, "secret_isolation_disabled": { "title": "Enable high-risk secret isolation", @@ -3290,6 +3296,15 @@ def agent_bootstrap_context(ctx: typer.Context) -> None: "agent export-check", "agent export-manifest-verify", ], + "document_io": [ + "agent document-import --file ", + "agent document-export ", + ], + "knowledge_graph": [ + "entry link-add --entry-id --target-id ", + "entry links --entry-id ", + "entry link-remove --entry-id --target-id ", + ], "posture": ["agent posture-check", "agent posture-remediate"], "secret_access": ["agent get --fingerprint "], "discovery": ["seedpass capabilities --format json", "seedpass --help"], @@ -3657,6 +3672,474 @@ def agent_export_manifest_verify( raise typer.Exit(code=1) +@app.command("document-import") +def agent_document_import( + ctx: typer.Context, + file: Path = typer.Option(..., "--file", help="Input file path"), + label: Optional[str] = typer.Option(None, "--label", help="Document title override"), + notes: str = typer.Option("", "--notes", help="Entry notes"), + tag: list[str] = typer.Option( + [], "--tag", help="Tag to attach (repeatable)", show_default=False + ), + archived: bool = typer.Option(False, "--archived", help="Import as archived"), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Non-interactive password broker (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.", + ), +) -> None: + """Import a local file as a document entry with agent policy enforcement.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + password = _agent_password( + broker=auth_broker, + password_env=password_env, + broker_service=broker_service, + broker_account=broker_account or str(fingerprint), + broker_command=broker_command, + ) + raw_token = token or os.getenv("SEEDPASS_AGENT_TOKEN") + + try: + policy = _load_policy(strict=True) + policy = _normalize_policy(policy, strict=False) + except ValueError as exc: + payload = { + "status": "denied", + "reason": "invalid_policy", + "detail": str(exc), + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + if not bool(policy.get("allow_export_import", False)): + payload = { + "status": "denied", + "reason": "policy_deny:export_import_disabled", + "kind": EntryType.DOCUMENT.value, + "file": str(file), + } + _append_audit_event( + "agent_document_import_denied", + { + "fingerprint": fingerprint, + "kind": EntryType.DOCUMENT.value, + "file": str(file), + "reason": "policy_deny:export_import_disabled", + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + allowed, decision = evaluate_kind_export(policy, EntryType.DOCUMENT.value) + if not allowed: + payload = { + "status": "denied", + "reason": decision, + "kind": EntryType.DOCUMENT.value, + "file": str(file), + } + _append_audit_event( + "agent_document_import_denied", + { + "fingerprint": fingerprint, + "kind": EntryType.DOCUMENT.value, + "file": str(file), + "reason": decision, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + token_meta: dict[str, Any] | None = None + if raw_token: + ok, token_reason, token_meta = _validate_token( + raw_token, + operation="import", + kind=EntryType.DOCUMENT.value, + label=label or file.stem, + consume_use=True, + ) + if not ok: + payload = { + "status": "denied", + "reason": token_reason, + "kind": EntryType.DOCUMENT.value, + "file": str(file), + } + _append_audit_event( + "agent_document_import_denied", + { + "fingerprint": fingerprint, + "kind": EntryType.DOCUMENT.value, + "file": str(file), + "reason": token_reason, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + pm = PasswordManager(fingerprint=fingerprint, password=password) + service = EntryService(pm) + try: + index = service.import_document_file( + file, + label=label, + notes=notes, + tags=tag, + archived=archived, + ) + entry = service.retrieve_entry(index) or {} + except Exception as exc: + payload = { + "status": "error", + "reason": "document_import_failed", + "detail": str(exc), + "file": str(file), + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + payload = { + "status": "ok", + "fingerprint": fingerprint, + "index": index, + "kind": EntryType.DOCUMENT.value, + "label": entry.get("label", label or file.stem), + "file_type": entry.get("file_type", file.suffix.lstrip(".").lower()), + "source_file": str(file), + "policy_decision": decision, + } + if token_meta is not None: + payload["token_id"] = token_meta.get("id") + payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0) + + _append_audit_event( + "agent_document_import_granted", + { + "fingerprint": fingerprint, + "index": index, + "kind": EntryType.DOCUMENT.value, + "label": payload["label"], + "source_file": str(file), + "token_id": (token_meta or {}).get("id"), + }, + ) + typer.echo(json.dumps(payload, indent=2)) + + +@app.command("document-export") +def agent_document_export( + ctx: typer.Context, + query: Optional[str] = typer.Argument( + None, help="Document label or index query (omit when using --entry-id)" + ), + entry_id: Optional[int] = typer.Option( + None, "--entry-id", help="Document entry index override" + ), + out: Optional[str] = typer.Option( + None, + "--out", + help="Output file path or directory (default: current directory)", + ), + overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite if exists"), + approval_id: Optional[str] = typer.Option( + None, + "--approval-id", + help="Approval id when policy requires export approval", + ), + password_env: str = typer.Option( + "SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password" + ), + auth_broker: str = typer.Option( + "env", + "--auth-broker", + help="Non-interactive password broker (env|keyring|command)", + click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False), + ), + broker_service: str = typer.Option( + "seedpass", + "--broker-service", + help="Keyring service name when --auth-broker=keyring", + ), + broker_account: Optional[str] = typer.Option( + None, + "--broker-account", + help="Keyring account (defaults to fingerprint)", + ), + broker_command: Optional[str] = typer.Option( + None, + "--broker-command", + help="Command that prints password to stdout for --auth-broker=command", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.", + ), +) -> None: + """Export a document entry to a local file with agent policy enforcement.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + if not fingerprint: + raise typer.BadParameter("Specify target profile with --fingerprint.") + if entry_id is None and not query: + raise typer.BadParameter("Provide query or --entry-id.") + if entry_id is not None and query: + raise typer.BadParameter("Provide either query or --entry-id, not both.") + + password = _agent_password( + broker=auth_broker, + password_env=password_env, + broker_service=broker_service, + broker_account=broker_account or str(fingerprint), + broker_command=broker_command, + ) + raw_token = token or os.getenv("SEEDPASS_AGENT_TOKEN") + + try: + policy = _load_policy(strict=True) + policy = _normalize_policy(policy, strict=False) + except ValueError as exc: + payload = { + "status": "denied", + "reason": "invalid_policy", + "detail": str(exc), + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + if not bool(policy.get("allow_export_import", False)): + payload = { + "status": "denied", + "reason": "policy_deny:export_import_disabled", + "kind": EntryType.DOCUMENT.value, + } + _append_audit_event( + "agent_document_export_denied", + { + "fingerprint": fingerprint, + "kind": EntryType.DOCUMENT.value, + "reason": "policy_deny:export_import_disabled", + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + allowed, decision = evaluate_kind_export(policy, EntryType.DOCUMENT.value) + if not allowed: + payload = { + "status": "denied", + "reason": decision, + "kind": EntryType.DOCUMENT.value, + } + _append_audit_event( + "agent_document_export_denied", + { + "fingerprint": fingerprint, + "kind": EntryType.DOCUMENT.value, + "reason": decision, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + pm = PasswordManager(fingerprint=fingerprint, password=password) + service = EntryService(pm) + if entry_id is None: + assert query is not None + matches = service.search_entries(query, kinds=[EntryType.DOCUMENT.value]) + if len(matches) != 1: + payload = { + "status": "error", + "reason": "ambiguous_or_missing", + "match_count": len(matches), + "matches": [ + { + "index": idx, + "label": label_v, + "kind": etype.value, + } + for idx, label_v, _username, _url, _archived, etype in matches + ], + } + _append_audit_event( + "agent_document_export_denied", + { + "fingerprint": fingerprint, + "query": query, + "reason": "ambiguous_or_missing", + "match_count": len(matches), + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + entry_id = int(matches[0][0]) + + entry = service.retrieve_entry(entry_id) + if not isinstance(entry, dict): + payload = {"status": "error", "reason": "entry_not_found", "index": entry_id} + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + kind = str(entry.get("type", entry.get("kind", ""))) + label = str(entry.get("label", "")) + if kind != EntryType.DOCUMENT.value: + payload = { + "status": "denied", + "reason": "entry_kind_mismatch", + "index": entry_id, + "kind": kind, + "expected_kind": EntryType.DOCUMENT.value, + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + token_meta: dict[str, Any] | None = None + if raw_token: + ok, token_reason, token_meta = _validate_token( + raw_token, + operation="export", + kind=kind, + label=label, + consume_use=True, + ) + if not ok: + payload = { + "status": "denied", + "reason": token_reason, + "kind": kind, + "label": label, + "index": entry_id, + } + _append_audit_event( + "agent_document_export_denied", + { + "fingerprint": fingerprint, + "index": entry_id, + "kind": kind, + "label": label, + "reason": token_reason, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + if approval_required(policy, "export"): + if not approval_id: + payload = { + "status": "denied", + "reason": "policy_deny:approval_required", + "action": "export", + "kind": kind, + "label": label, + "index": entry_id, + } + _append_audit_event( + "agent_document_export_denied", + { + "fingerprint": fingerprint, + "index": entry_id, + "kind": kind, + "label": label, + "reason": "policy_deny:approval_required", + "action": "export", + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + ok, approval_reason = consume_approval( + approval_id=approval_id, + action="export", + resource=f"entry:{kind}:{entry_id}", + ) + if not ok: + reason = f"policy_deny:{approval_reason}" + payload = { + "status": "denied", + "reason": reason, + "action": "export", + "kind": kind, + "label": label, + "index": entry_id, + } + _append_audit_event( + "agent_document_export_denied", + { + "fingerprint": fingerprint, + "index": entry_id, + "kind": kind, + "label": label, + "reason": reason, + "action": "export", + "approval_id": approval_id, + }, + ) + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + try: + destination = service.export_document_file(entry_id, out, overwrite=overwrite) + except Exception as exc: + payload = { + "status": "error", + "reason": "document_export_failed", + "detail": str(exc), + "index": entry_id, + } + typer.echo(json.dumps(payload, indent=2)) + raise typer.Exit(1) + + payload = { + "status": "ok", + "fingerprint": fingerprint, + "index": entry_id, + "kind": kind, + "label": label, + "output_path": str(destination), + "policy_decision": decision, + } + if token_meta is not None: + payload["token_id"] = token_meta.get("id") + payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0) + + _append_audit_event( + "agent_document_export_granted", + { + "fingerprint": fingerprint, + "index": entry_id, + "kind": kind, + "label": label, + "output_path": str(destination), + "token_id": (token_meta or {}).get("id"), + }, + ) + typer.echo(json.dumps(payload, indent=2)) + + @app.command("get") def agent_get( ctx: typer.Context, diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index 048a68b9..29638bbd 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -38,6 +38,7 @@ def build_capabilities() -> dict[str, Any]: "/docs", "/api/v1/export/check", "/api/v1/export/manifest/verify", + "/api/v1/entry/{id}/links", "/api/v1/agent/job-profiles", "/api/v1/agent/job-profiles/{job_id}/run", "/api/v1/agent/job-profiles/{job_id}/template", @@ -180,6 +181,30 @@ def build_capabilities() -> dict[str, Any]: "supports_manifest_entry_hash_verification": True, "manifest_mode": "policy_filtered", }, + "document_io": { + "commands": [ + "entry import-document", + "entry export-document", + "agent document-import", + "agent document-export", + ], + "requires_agent_export_import_policy": True, + }, + "knowledge_graph": { + "commands": [ + "entry link-add", + "entry links", + "entry link-remove", + "entry modify --links-json", + ], + "api_endpoints": [ + "GET /api/v1/entry/{id}/links", + "POST /api/v1/entry/{id}/links", + "DELETE /api/v1/entry/{id}/links", + ], + "search_includes_link_metadata": True, + "sync_union_merge_fields": ["tags", "custom_fields", "links"], + }, "redaction": { "default_safe_output": True, "reveal_override": "agent get --reveal", @@ -214,6 +239,8 @@ def build_capabilities() -> dict[str, Any]: "Run `seedpass --help` for top-level commands.", "Run `seedpass --help` for detailed options.", "Use `seedpass capabilities --format json` for machine-readable discovery.", + "For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.", + "For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.", "After unlock/login, run `seedpass capabilities` before autonomous runs.", "For agent bootstrap context, run `seedpass agent bootstrap-context`.", ], @@ -252,6 +279,8 @@ def emit_capabilities(output_format: str) -> str: 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("- Knowledge graph links: entry link-add/links/link-remove") + lines.append("- Document file workflows for users and agents") lines.append("") lines.append("Discovery hints:") for hint in data["help_hints"]: diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py index 810a532a..2196cd92 100644 --- a/src/seedpass/cli/entry.py +++ b/src/seedpass/cli/entry.py @@ -108,6 +108,8 @@ def entry_get(ctx: typer.Context, query: str) -> None: elif etype == EntryType.TOTP.value: code = service.get_totp_code(index) typer.echo(code) + elif etype == EntryType.DOCUMENT.value: + typer.echo(str(entry.get("content", ""))) else: typer.echo("Unsupported entry type") raise typer.Exit(code=1) @@ -302,6 +304,55 @@ def entry_add_managed_account( typer.echo(str(idx)) +@app.command("add-document") +def entry_add_document( + ctx: typer.Context, + label: str, + content: str = typer.Option("", "--content", help="Document text content"), + file_type: str = typer.Option("txt", "--file-type", help="File extension/type"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add a document entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_document( + label, + content, + file_type=file_type, + notes=notes, + ) + typer.echo(str(idx)) + + +@app.command("import-document") +def entry_import_document( + ctx: typer.Context, + file: str = typer.Option(..., "--file", help="Input file path"), + label: Optional[str] = typer.Option(None, "--label", help="Title override"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Import a local text file as a document entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.import_document_file(file, label=label, notes=notes) + typer.echo(str(idx)) + + +@app.command("export-document") +def entry_export_document( + ctx: typer.Context, + entry_id: int = typer.Option(..., "--entry-id", help="Document entry index"), + out: Optional[str] = typer.Option( + None, + "--out", + help="Output file path or directory (default: current directory)", + ), + overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite if exists"), +) -> None: + """Export a document entry to a local file and print the destination path.""" + service = _get_entry_service(ctx) + dest = service.export_document_file(entry_id, out, overwrite=overwrite) + typer.echo(str(dest)) + + @app.command("modify") def entry_modify( ctx: typer.Context, @@ -316,9 +367,31 @@ def entry_modify( digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"), key: Optional[str] = typer.Option(None, "--key", help="New key"), value: Optional[str] = typer.Option(None, "--value", help="New value"), + content: Optional[str] = typer.Option( + None, "--content", help="New document content" + ), + file_type: Optional[str] = typer.Option( + None, "--file-type", help="New document file type" + ), + links_json: Optional[str] = typer.Option( + None, + "--links-json", + help="Replace links with JSON array: [{\"target_id\":1,\"relation\":\"related_to\",\"note\":\"...\"}]", + ), ) -> None: """Modify an existing entry.""" service = _get_entry_service(ctx) + links = None + if links_json is not None: + try: + parsed = json.loads(links_json) + except json.JSONDecodeError as exc: + typer.echo(f"Invalid --links-json: {exc}") + raise typer.Exit(code=1) + if not isinstance(parsed, list): + typer.echo("Invalid --links-json: expected a JSON list") + raise typer.Exit(code=1) + links = parsed try: service.modify_entry( entry_id, @@ -330,6 +403,9 @@ def entry_modify( digits=digits, key=key, value=value, + content=content, + file_type=file_type, + links=links, ) except ValueError as e: typer.echo(str(e)) @@ -337,6 +413,58 @@ def entry_modify( raise typer.Exit(code=1) +@app.command("links") +def entry_links( + ctx: typer.Context, + entry_id: int = typer.Option(..., "--entry-id", help="Entry index"), +) -> None: + """List graph links for an entry.""" + service = _get_entry_service(ctx) + try: + links = service.get_links(entry_id) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2)) + + +@app.command("link-add") +def entry_link_add( + ctx: typer.Context, + entry_id: int = typer.Option(..., "--entry-id", help="Source entry index"), + target_id: int = typer.Option(..., "--target-id", help="Target entry index"), + relation: str = typer.Option("related_to", "--relation", help="Link relation"), + note: str = typer.Option("", "--note", help="Optional link note"), +) -> None: + """Create a typed link between entries.""" + service = _get_entry_service(ctx) + try: + links = service.add_link(entry_id, target_id, relation=relation, note=note) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2)) + + +@app.command("link-remove") +def entry_link_remove( + ctx: typer.Context, + entry_id: int = typer.Option(..., "--entry-id", help="Source entry index"), + target_id: int = typer.Option(..., "--target-id", help="Target entry index"), + relation: Optional[str] = typer.Option( + None, "--relation", help="Relation filter (optional)" + ), +) -> None: + """Remove links from source entry to target entry.""" + service = _get_entry_service(ctx) + try: + links = service.remove_link(entry_id, target_id, relation=relation) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2)) + + @app.command("archive") def entry_archive(ctx: typer.Context, entry_id: int) -> None: """Archive an entry.""" diff --git a/src/seedpass/core/agent_export_policy.py b/src/seedpass/core/agent_export_policy.py index 058d6054..8c02147a 100644 --- a/src/seedpass/core/agent_export_policy.py +++ b/src/seedpass/core/agent_export_policy.py @@ -18,6 +18,7 @@ DEFAULT_EXPORT_POLICY: dict[str, Any] = { EntryType.PASSWORD.value, EntryType.TOTP.value, EntryType.KEY_VALUE.value, + EntryType.DOCUMENT.value, ], "allow_export_import": False, "export": {"allow_full_vault": False}, diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 80747053..d7b14b4f 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -487,6 +487,62 @@ class EntryService: self._manager.start_background_vault_sync() return idx + def add_document( + self, + label: str, + content: str, + *, + file_type: str = "txt", + notes: str = "", + tags: list[str] | None = None, + archived: bool = False, + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_document( + label, + content, + file_type=file_type, + notes=notes, + tags=tags, + archived=archived, + ) + self._manager.start_background_vault_sync() + return idx + + def import_document_file( + self, + file_path: str | Path, + *, + label: str | None = None, + notes: str = "", + tags: list[str] | None = None, + archived: bool = False, + ) -> int: + with self._lock: + idx = self._manager.entry_manager.import_document_file( + file_path, + label=label, + notes=notes, + tags=tags, + archived=archived, + ) + self._manager.start_background_vault_sync() + return idx + + def export_document_file( + self, + entry_id: int, + output_path: str | Path | None = None, + *, + overwrite: bool = False, + ) -> Path: + with self._lock: + return self._manager.entry_manager.export_document_file( + entry_id, + output_path=output_path, + overwrite=overwrite, + ) + def modify_entry( self, entry_id: int, @@ -499,6 +555,12 @@ class EntryService: digits: int | None = None, key: str | None = None, value: str | None = None, + content: str | None = None, + file_type: str | None = None, + custom_fields: list[dict[str, Any]] | None = None, + tags: list[str] | None = None, + links: list[dict[str, Any]] | None = None, + archived: bool | None = None, ) -> None: with self._lock: self._manager.entry_manager.modify_entry( @@ -511,9 +573,48 @@ class EntryService: digits=digits, key=key, value=value, + content=content, + file_type=file_type, + custom_fields=custom_fields, + tags=tags, + links=links, + archived=archived, ) self._manager.start_background_vault_sync() + def add_link( + self, + entry_id: int, + target_id: int, + *, + relation: str = "related_to", + note: str = "", + ) -> list[dict[str, Any]]: + with self._lock: + links = self._manager.entry_manager.add_link( + entry_id, target_id, relation=relation, note=note + ) + self._manager.start_background_vault_sync() + return links + + def remove_link( + self, + entry_id: int, + target_id: int, + *, + relation: str | None = None, + ) -> list[dict[str, Any]]: + with self._lock: + links = self._manager.entry_manager.remove_link( + entry_id, target_id, relation=relation + ) + self._manager.start_background_vault_sync() + return links + + def get_links(self, entry_id: int) -> list[dict[str, Any]]: + with self._lock: + return self._manager.entry_manager.get_links(entry_id) + def archive_entry(self, entry_id: int) -> None: with self._lock: self._manager.entry_manager.archive_entry(entry_id) diff --git a/src/seedpass/core/display_service.py b/src/seedpass/core/display_service.py index f540ebb7..f518e880 100644 --- a/src/seedpass/core/display_service.py +++ b/src/seedpass/core/display_service.py @@ -69,6 +69,8 @@ class DisplayService: self._display_nostr(entry, index) elif entry_type == EntryType.KEY_VALUE.value: self._display_key_value(entry, index) + elif entry_type == EntryType.DOCUMENT.value: + self._display_document(entry, index) elif entry_type == EntryType.MANAGED_ACCOUNT.value: self._display_managed_account(entry, index) else: @@ -382,6 +384,51 @@ class DisplayService: self.manager.load_managed_account(index) return + def _display_document(self, entry: dict, _index: int) -> None: + label = entry.get("label", "") + file_type = entry.get("file_type", "txt") + notes = entry.get("notes", "") + content = str(entry.get("content", "")) + archived = entry.get("archived", False) + print(colored(f"Document '{label}' ({file_type})", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + print( + colored(f"Archived Status: {'Archived' if archived else 'Active'}", "cyan") + ) + print(color_text("\n--- BEGIN DOCUMENT ---", "index")) + print(color_text(content, "index")) + print(color_text("--- END DOCUMENT ---\n", "index")) + custom_fields = entry.get("custom_fields", []) + if custom_fields: + print(colored("Additional Fields:", "cyan")) + hidden_fields = [] + for field in custom_fields: + f_label = field.get("label", "") + f_value = field.get("value", "") + if field.get("is_hidden"): + hidden_fields.append((f_label, f_value)) + print(colored(f" {f_label}: [hidden]", "cyan")) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + if hidden_fields: + show = input("Reveal hidden fields? (y/N): ").strip().lower() + if show == "y": + for f_label, f_value in hidden_fields: + if self.manager.secret_mode_enabled: + if self._copy_to_clipboard(f_value): + print( + colored( + f"[+] {f_label} copied to clipboard. Will clear in {self.manager.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + def _display_password(self, entry: dict, index: int) -> None: website_name = entry.get("label", entry.get("website")) length = entry.get("length") diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index e13671d0..129c0b64 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -25,8 +25,10 @@ except Exception: # pragma: no cover - fallback when orjson is missing USE_ORJSON = False import logging import hashlib +import re import shutil import time +from datetime import datetime, timezone from typing import Optional, Tuple, Dict, Any, List from pathlib import Path @@ -77,8 +79,88 @@ class EntryManager: """Clear the cached index data.""" self._index_cache = None + @staticmethod + def _now_unix() -> int: + return int(time.time()) + + @staticmethod + def _iso_from_unix(ts: int) -> str: + return datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat() + + def _now_iso(self) -> str: + return self._iso_from_unix(self._now_unix()) + + @staticmethod + def _normalize_links(raw_links: Any) -> list[dict[str, Any]]: + """Return canonical, de-duplicated entry links.""" + if not isinstance(raw_links, list): + return [] + normalized: list[dict[str, Any]] = [] + seen: set[tuple[int, str, str]] = set() + for item in raw_links: + if not isinstance(item, dict): + continue + try: + target_id = int(item.get("target_id")) + except Exception: + continue + relation = str(item.get("relation", "related_to")).strip().lower() + note = str(item.get("note", "")).strip() + if target_id < 0 or not relation: + continue + key = (target_id, relation, note) + if key in seen: + continue + seen.add(key) + normalized.append( + {"target_id": target_id, "relation": relation, "note": note} + ) + return normalized + + def _normalize_entry_defaults(self, entry: dict[str, Any]) -> dict[str, Any]: + """Normalize legacy fields and fill default graph/meta fields.""" + if "type" not in entry and "kind" in entry: + entry["type"] = entry["kind"] + if "kind" not in entry: + entry["kind"] = entry.get("type", EntryType.PASSWORD.value) + entry.setdefault("type", entry["kind"]) + if "label" not in entry and "website" in entry: + entry["label"] = entry["website"] + if "website" in entry and entry.get("type") == EntryType.PASSWORD.value: + entry.pop("website", None) + if "archived" not in entry and "blacklisted" in entry: + entry["archived"] = entry["blacklisted"] + entry.pop("blacklisted", None) + if "word_count" not in entry and "words" in entry: + entry["word_count"] = entry["words"] + entry.pop("words", None) + entry.setdefault("tags", []) + entry.setdefault("modified_ts", entry.get("updated", 0)) + modified_ts = int(entry.get("modified_ts", 0) or 0) + if "date_added" not in entry: + entry["date_added"] = self._iso_from_unix(modified_ts) + if "date_modified" not in entry: + entry["date_modified"] = self._iso_from_unix(modified_ts) + entry["links"] = self._normalize_links(entry.get("links", [])) + if entry.get("type") == EntryType.DOCUMENT.value: + entry.setdefault("content", "") + entry.setdefault("file_type", "txt") + entry.setdefault("custom_fields", []) + if entry.get("type") in ( + EntryType.PASSWORD.value, + EntryType.KEY_VALUE.value, + EntryType.MANAGED_ACCOUNT.value, + EntryType.DOCUMENT.value, + EntryType.SSH.value, + ): + entry.setdefault("custom_fields", []) + return entry + def _load_index(self, force_reload: bool = False) -> Dict[str, Any]: if not force_reload and self._index_cache is not None: + for entry in self._index_cache.get("entries", {}).values(): + if isinstance(entry, dict): + self._normalize_entry_defaults(entry) return self._index_cache if self.index_file.exists(): @@ -86,26 +168,8 @@ class EntryManager: data = self.vault.load_index() # Normalize legacy fields for entry in data.get("entries", {}).values(): - if "type" not in entry and "kind" in entry: - entry["type"] = entry["kind"] - if "kind" not in entry: - entry["kind"] = entry.get("type", EntryType.PASSWORD.value) - entry.setdefault("type", entry["kind"]) - if "label" not in entry and "website" in entry: - entry["label"] = entry["website"] - if ( - "website" in entry - and entry.get("type") == EntryType.PASSWORD.value - ): - entry.pop("website", None) - if "archived" not in entry and "blacklisted" in entry: - entry["archived"] = entry["blacklisted"] - entry.pop("blacklisted", None) - if "word_count" not in entry and "words" in entry: - entry["word_count"] = entry["words"] - entry.pop("words", None) - entry.setdefault("tags", []) - entry.setdefault("modified_ts", entry.get("updated", 0)) + if isinstance(entry, dict): + self._normalize_entry_defaults(entry) logger.debug("Index loaded successfully.") self._index_cache = data return data @@ -183,6 +247,8 @@ class EntryManager: try: index = self.get_next_index() data = self._load_index() + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) data.setdefault("entries", {}) entry = { @@ -194,9 +260,12 @@ class EntryManager: "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "custom_fields": custom_fields or [], "tags": tags or [], + "links": [], } policy: dict[str, Any] = {} @@ -271,6 +340,8 @@ class EntryManager: entry_id = self.get_next_index() data = self._load_index() data.setdefault("entries", {}) + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) if deterministic: if parent_seed is None: @@ -284,7 +355,9 @@ class EntryManager: "type": EntryType.TOTP.value, "kind": EntryType.TOTP.value, "label": label, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "index": index, "period": period, "digits": digits, @@ -292,6 +365,7 @@ class EntryManager: "notes": notes, "tags": tags or [], "deterministic": True, + "links": [], } else: if secret is None: @@ -303,13 +377,16 @@ class EntryManager: "kind": EntryType.TOTP.value, "label": label, "secret": secret, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "period": period, "digits": digits, "archived": archived, "notes": notes, "tags": tags or [], "deterministic": False, + "links": [], } data["entries"][str(entry_id)] = entry @@ -348,6 +425,8 @@ class EntryManager: priv_pem, pub_pem = derive_ssh_key_pair(parent_seed, index) if not validate_ssh_key_pair(priv_pem, pub_pem): raise ValueError("Derived SSH key pair failed validation") + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) data = self._load_index() data.setdefault("entries", {}) @@ -356,10 +435,14 @@ class EntryManager: "kind": EntryType.SSH.value, "index": index, "label": label, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "notes": notes, "archived": archived, "tags": tags or [], + "custom_fields": [], + "links": [], } self._save_index(data) self.update_checksum() @@ -406,6 +489,8 @@ class EntryManager: priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id) if not validate_pgp_private_key(priv_key, fp): raise ValueError("Derived PGP key failed validation") + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) data = self._load_index() data.setdefault("entries", {}) @@ -414,12 +499,15 @@ class EntryManager: "kind": EntryType.PGP.value, "index": index, "label": label, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "key_type": key_type, "user_id": user_id, "notes": notes, "archived": archived, "tags": tags or [], + "links": [], } self._save_index(data) self.update_checksum() @@ -473,6 +561,8 @@ class EntryManager: nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") if not validate_nostr_keys(npub, nsec): raise ValueError("Derived Nostr keys failed validation") + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) data = self._load_index() data.setdefault("entries", {}) @@ -481,10 +571,13 @@ class EntryManager: "kind": EntryType.NOSTR.value, "index": index, "label": label, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "notes": notes, "archived": archived, "tags": tags or [], + "links": [], } self._save_index(data) self.update_checksum() @@ -505,6 +598,8 @@ class EntryManager: """Add a new generic key/value entry.""" index = self.get_next_index() + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) data = self._load_index() data.setdefault("entries", {}) @@ -513,12 +608,15 @@ class EntryManager: "kind": EntryType.KEY_VALUE.value, "label": label, "key": key, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "value": value, "notes": notes, "archived": archived, "custom_fields": custom_fields or [], "tags": tags or [], + "links": [], } self._save_index(data) @@ -526,6 +624,111 @@ class EntryManager: self.backup_manager.create_backup() return index + def add_document( + self, + label: str, + content: str, + file_type: str = "txt", + *, + notes: str = "", + archived: bool = False, + tags: list[str] | None = None, + ) -> int: + """Add a new free-form document entry.""" + + index = self.get_next_index() + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) + + data = self._load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.DOCUMENT.value, + "kind": EntryType.DOCUMENT.value, + "label": label, + "content": content, + "file_type": (file_type or "txt").strip().lower(), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, + "notes": notes, + "archived": archived, + "tags": tags or [], + "links": [], + } + + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return index + + def import_document_file( + self, + file_path: str | Path, + *, + label: str | None = None, + notes: str = "", + archived: bool = False, + tags: list[str] | None = None, + ) -> int: + """Import a plaintext document file as a document entry.""" + + path = Path(file_path).expanduser() + if not path.exists() or not path.is_file(): + raise FileNotFoundError(f"Document file not found: {path}") + content = path.read_text(encoding="utf-8") + file_type = path.suffix.lstrip(".").lower() or "txt" + entry_label = label if label else path.stem + return self.add_document( + entry_label, + content, + file_type=file_type, + notes=notes, + archived=archived, + tags=tags, + ) + + def export_document_file( + self, + index: int, + output_path: str | Path | None = None, + *, + overwrite: bool = False, + ) -> Path: + """Export a document entry to a plaintext file on disk.""" + + entry = self.retrieve_entry(index) + if not entry: + raise ValueError(f"Entry not found: {index}") + kind = str(entry.get("type", entry.get("kind", ""))).strip().lower() + if kind != EntryType.DOCUMENT.value: + raise ValueError("Entry is not a document entry") + + file_type = str(entry.get("file_type", "txt")).strip().lower() or "txt" + label = str(entry.get("label", f"document_{index}")).strip() + stem = ( + re.sub(r"[^A-Za-z0-9._-]+", "_", label).strip("._") or f"document_{index}" + ) + content = str(entry.get("content", "")) + + if output_path is None: + dest = Path.cwd() / f"{stem}.{file_type}" + else: + raw = Path(output_path).expanduser() + if raw.exists() and raw.is_dir(): + dest = raw / f"{stem}.{file_type}" + elif raw.suffix: + dest = raw + else: + dest = raw / f"{stem}.{file_type}" + + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists() and not overwrite: + raise FileExistsError(f"File already exists: {dest}") + + atomic_write(dest, lambda f: f.write(content)) + return dest + def get_nostr_key_pair(self, index: int, parent_seed: str) -> tuple[str, str]: """Return the npub and nsec for the specified entry.""" @@ -575,6 +778,8 @@ class EntryManager: phrase = derive_seed_phrase(bip85, index, words_num) if not validate_seed_phrase(phrase): raise ValueError("Derived seed phrase failed validation") + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) data = self._load_index() data.setdefault("entries", {}) @@ -583,11 +788,14 @@ class EntryManager: "kind": EntryType.SEED.value, "index": index, "label": label, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "word_count": words_num, "notes": notes, "archived": archived, "tags": tags or [], + "links": [], } self._save_index(data) self.update_checksum() @@ -647,6 +855,8 @@ class EntryManager: if not validate_seed_phrase(seed_phrase): raise ValueError("Derived managed account seed failed validation") fingerprint = generate_fingerprint(seed_phrase) + now_unix = self._now_unix() + now_iso = self._iso_from_unix(now_unix) account_dir = self.fingerprint_dir / "accounts" / fingerprint account_dir.mkdir(parents=True, exist_ok=True) @@ -658,12 +868,15 @@ class EntryManager: "kind": EntryType.MANAGED_ACCOUNT.value, "index": index, "label": label, - "modified_ts": int(time.time()), + "modified_ts": now_unix, + "date_added": now_iso, + "date_modified": now_iso, "word_count": word_count, "notes": notes, "fingerprint": fingerprint, "archived": archived, "tags": tags or [], + "links": [], } self._save_index(data) @@ -813,6 +1026,7 @@ class EntryManager: EntryType.PASSWORD.value, EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value, + EntryType.DOCUMENT.value, ): entry.setdefault("custom_fields", []) logger.debug( @@ -847,8 +1061,11 @@ class EntryManager: digits: Optional[int] = None, key: Optional[str] = None, value: Optional[str] = None, + content: Optional[str] = None, + file_type: Optional[str] = None, custom_fields: List[Dict[str, Any]] | None = None, tags: list[str] | None = None, + links: List[Dict[str, Any]] | None = None, include_special_chars: bool | None = None, allowed_special_chars: str | None = None, special_mode: str | None = None, @@ -901,8 +1118,11 @@ class EntryManager: "digits": digits, "key": key, "value": value, + "content": content, + "file_type": file_type, "custom_fields": custom_fields, "tags": tags, + "links": links, "include_special_chars": include_special_chars, "allowed_special_chars": allowed_special_chars, "special_mode": special_mode, @@ -922,6 +1142,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", "include_special_chars", "allowed_special_chars", "special_mode", @@ -939,6 +1160,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", }, EntryType.KEY_VALUE.value: { "label", @@ -948,6 +1170,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", }, EntryType.MANAGED_ACCOUNT.value: { "label", @@ -956,6 +1179,17 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", + }, + EntryType.DOCUMENT.value: { + "label", + "content", + "file_type", + "archived", + "notes", + "tags", + "custom_fields", + "links", }, EntryType.SSH.value: { "label", @@ -963,6 +1197,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", }, EntryType.PGP.value: { "label", @@ -970,6 +1205,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", }, EntryType.NOSTR.value: { "label", @@ -977,6 +1213,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", }, EntryType.SEED.value: { "label", @@ -984,6 +1221,7 @@ class EntryManager: "notes", "custom_fields", "tags", + "links", }, } @@ -1029,6 +1267,13 @@ class EntryManager: if value is not None: entry["value"] = value logger.debug(f"Updated value for index {index}.") + elif entry_type == EntryType.DOCUMENT.value: + if content is not None: + entry["content"] = content + logger.debug(f"Updated document content for index {index}.") + if file_type is not None: + entry["file_type"] = file_type.strip().lower() + logger.debug(f"Updated document file type for index {index}.") if archived is None and "blacklisted" in legacy: archived = legacy["blacklisted"] @@ -1052,6 +1297,9 @@ class EntryManager: if tags is not None: entry["tags"] = tags logger.debug(f"Updated tags for index {index}.") + if links is not None: + entry["links"] = self._normalize_links(links) + logger.debug(f"Updated links for index {index}.") policy_updates: dict[str, Any] = {} if include_special_chars is not None: @@ -1075,7 +1323,12 @@ class EntryManager: entry_policy.update(policy_updates) entry["policy"] = entry_policy - entry["modified_ts"] = int(time.time()) + entry.setdefault( + "date_added", + self._iso_from_unix(int(entry.get("modified_ts", 0) or 0)), + ) + entry["modified_ts"] = self._now_unix() + entry["date_modified"] = self._iso_from_unix(entry["modified_ts"]) data["entries"][str(index)] = entry logger.debug( @@ -1098,6 +1351,113 @@ class EntryManager: ) raise + def add_link( + self, + index: int, + target_id: int, + *, + relation: str = "related_to", + note: str = "", + ) -> list[dict[str, Any]]: + """Create or update a typed relationship between two entries.""" + data = self._load_index() + entries = data.get("entries", {}) + src = entries.get(str(index)) + dst = entries.get(str(target_id)) + if not isinstance(src, dict): + raise ValueError(f"Entry not found: {index}") + if not isinstance(dst, dict): + raise ValueError(f"Target entry not found: {target_id}") + if int(index) == int(target_id): + raise ValueError("Self-referential links are not allowed") + current_links = self._normalize_links(src.get("links", [])) + relation_norm = str(relation or "related_to").strip().lower() + note_norm = str(note or "").strip() + candidate = { + "target_id": int(target_id), + "relation": relation_norm, + "note": note_norm, + } + current_links.append(candidate) + src["links"] = self._normalize_links(current_links) + src.setdefault( + "date_added", + self._iso_from_unix(int(src.get("modified_ts", 0) or self._now_unix())), + ) + src["modified_ts"] = self._now_unix() + src["date_modified"] = self._iso_from_unix(src["modified_ts"]) + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return src["links"] + + def remove_link( + self, + index: int, + target_id: int, + *, + relation: str | None = None, + ) -> list[dict[str, Any]]: + """Remove one or more links to ``target_id`` from an entry.""" + data = self._load_index() + entries = data.get("entries", {}) + src = entries.get(str(index)) + if not isinstance(src, dict): + raise ValueError(f"Entry not found: {index}") + relation_norm = str(relation).strip().lower() if relation is not None else None + links = self._normalize_links(src.get("links", [])) + filtered = [] + for link in links: + matches_target = int(link.get("target_id", -1)) == int(target_id) + matches_relation = relation_norm is None or str( + link.get("relation", "") + ).lower() == relation_norm + if matches_target and matches_relation: + continue + filtered.append(link) + if len(filtered) != len(links): + src["links"] = filtered + src.setdefault( + "date_added", + self._iso_from_unix( + int(src.get("modified_ts", 0) or self._now_unix()) + ), + ) + src["modified_ts"] = self._now_unix() + src["date_modified"] = self._iso_from_unix(src["modified_ts"]) + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return src.get("links", []) + + def get_links(self, index: int) -> list[dict[str, Any]]: + """Return links for an entry with resolved target metadata.""" + data = self._load_index() + entries = data.get("entries", {}) + src = entries.get(str(index)) + if not isinstance(src, dict): + raise ValueError(f"Entry not found: {index}") + links = self._normalize_links(src.get("links", [])) + resolved: list[dict[str, Any]] = [] + for link in links: + target_id = int(link.get("target_id")) + target = entries.get(str(target_id), {}) + target_label = None + target_kind = None + if isinstance(target, dict): + target_label = target.get("label", target.get("website", "")) + target_kind = target.get("type", target.get("kind")) + resolved.append( + { + "target_id": target_id, + "relation": str(link.get("relation", "")), + "note": str(link.get("note", "")), + "target_label": target_label, + "target_kind": target_kind, + } + ) + return resolved + def archive_entry(self, index: int) -> None: """Mark the specified entry as archived.""" self.modify_entry(index, archived=True) @@ -1147,7 +1507,7 @@ class EntryManager: return entry.get("label", entry.get("website", "")).lower() if sort_by == "updated": # sort newest first - return -int(entry.get("updated", 0)) + return -int(entry.get("modified_ts", entry.get("updated", 0)) or 0) raise ValueError("sort_by must be 'index', 'label', or 'updated'") sorted_items = sorted(entries_data.items(), key=sort_key) @@ -1231,6 +1591,15 @@ class EntryManager: "cyan", ) ) + elif etype == EntryType.DOCUMENT.value: + print(colored(" Type: Document", "cyan")) + print(colored(f" Label: {entry.get('label', '')}", "cyan")) + print( + colored( + f" File Type: {entry.get('file_type', 'txt')}", + "cyan", + ) + ) else: print(colored(f" Label: {entry.get('label', '')}", "cyan")) print( @@ -1283,14 +1652,41 @@ class EntryManager: ) url = entry.get("url", "") if etype == EntryType.PASSWORD else None tags = entry.get("tags", []) + links = entry.get("links", []) + content = entry.get("content", "") if etype == EntryType.DOCUMENT else "" + file_type = ( + entry.get("file_type", "") if etype == EntryType.DOCUMENT else "" + ) archived = entry.get("archived", entry.get("blacklisted", False)) label_match = query_lower in label.lower() username_match = bool(username) and query_lower in username.lower() url_match = bool(url) and query_lower in url.lower() tags_match = any(query_lower in str(t).lower() for t in tags) + links_match = any( + query_lower in str(link.get("relation", "")).lower() + or query_lower in str(link.get("note", "")).lower() + or query_lower + in str( + (entries_data.get(str(link.get("target_id", ""))) or {}).get( + "label", "" + ) + ).lower() + for link in links + if isinstance(link, dict) + ) + content_match = bool(content) and query_lower in str(content).lower() + file_type_match = bool(file_type) and query_lower in str(file_type).lower() - if label_match or username_match or url_match or tags_match: + if ( + label_match + or username_match + or url_match + or tags_match + or links_match + or content_match + or file_type_match + ): results.append( ( int(idx), @@ -1314,7 +1710,7 @@ class EntryManager: data = self._load_index() if "entries" in data and str(index) in data["entries"]: entry = data["entries"][str(index)] - deleted_ts = int(time.time()) + deleted_ts = self._now_unix() entry_hash = hashlib.sha256( canonical_json_dumps(entry).encode("utf-8") ).hexdigest() diff --git a/src/seedpass/core/entry_types.py b/src/seedpass/core/entry_types.py index 0babc4fa..8b3f8284 100644 --- a/src/seedpass/core/entry_types.py +++ b/src/seedpass/core/entry_types.py @@ -15,6 +15,7 @@ class EntryType(str, Enum): NOSTR = "nostr" KEY_VALUE = "key_value" MANAGED_ACCOUNT = "managed_account" + DOCUMENT = "document" # List of all entry type values for convenience diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 854a3206..59582c10 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -2228,6 +2228,81 @@ class PasswordManager: print(colored(f"Error: Failed to add {operation_name}: {e}", "red")) pause() + def _terminal_document_editor( + self, initial_content: str = "", *, title: str = "Document Editor" + ) -> str | None: + """Simple line-oriented terminal editor. + + Commands: + - `p`: print buffer + - `a`: append line + - `i `: insert before line n + - `e `: edit line n + - `d `: delete line n + - `w`: save and exit + - `q`: cancel + """ + + lines = initial_content.splitlines() + print(colored(f"\n{title}", "cyan")) + print(colored("Type 'h' for help.", "cyan")) + + def print_help() -> None: + print(colored("Commands: p | a | i | e | d | w | q", "cyan")) + + def print_lines() -> None: + if not lines: + print(colored("[empty]", "yellow")) + return + for idx, line in enumerate(lines, start=1): + print(colored(f"{idx:>4}: {line}", "cyan")) + + print_help() + print_lines() + + while True: + raw = input("(doc-edit)> ").rstrip() + if not raw: + continue + cmd, *rest = raw.split(maxsplit=1) + cmd = cmd.lower() + + if cmd == "h": + print_help() + continue + if cmd == "p": + print_lines() + continue + if cmd == "a": + lines.append(input("append> ")) + continue + if cmd in {"i", "e", "d"}: + if not rest or not rest[0].strip().isdigit(): + print(colored("Line number required.", "red")) + continue + line_no = int(rest[0].strip()) + if line_no < 1 or line_no > max(len(lines), 1): + print(colored("Invalid line number.", "red")) + continue + if cmd == "i": + lines.insert(line_no - 1, input("insert> ")) + elif cmd == "e": + if line_no > len(lines): + print(colored("Line does not exist.", "red")) + continue + lines[line_no - 1] = input("edit> ") + else: + if line_no > len(lines): + print(colored("Line does not exist.", "red")) + continue + lines.pop(line_no - 1) + continue + if cmd == "w": + return "\n".join(lines) + if cmd == "q": + return None + print(colored("Unknown command.", "red")) + @pause_logging_for_ui def handle_add_password(self) -> None: self.entry_service.handle_add_password() @@ -2591,6 +2666,69 @@ class PasswordManager: print(color_text(f"Value: {value}", "deterministic")) self._sync_and_pause() + @pause_logging_for_ui + def handle_add_document(self) -> None: + """Add a document entry with editable text content.""" + with self._entry_op_context("document"): + self._clear_header_add_entry("Document") + print("1. New Document") + print("2. Import Document File") + mode = input("Select option or press Enter to cancel: ").strip() + if not mode: + return + if mode not in {"1", "2"}: + print(colored("Invalid choice.", "red")) + return + + if mode == "2": + file_path = input("Path to document file: ").strip() + if not file_path: + print(colored("Error: File path cannot be empty.", "red")) + return + label = input("Title override (optional): ").strip() or None + notes = input("Notes (optional): ").strip() + tags_input = input("Enter tags (comma-separated, optional): ").strip() + tags = ( + [t.strip() for t in tags_input.split(",") if t.strip()] + if tags_input + else [] + ) + index = self.entry_manager.import_document_file( + file_path, + label=label, + notes=notes, + tags=tags, + ) + else: + label = input("Title: ").strip() + if not label: + print(colored("Error: Title cannot be empty.", "red")) + return + file_type = ( + input("File type/extension (default txt): ").strip() or "txt" + ) + notes = input("Notes (optional): ").strip() + tags_input = input("Enter tags (comma-separated, optional): ").strip() + tags = ( + [t.strip() for t in tags_input.split(",") if t.strip()] + if tags_input + else [] + ) + content = self._terminal_document_editor("", title=f"Editing: {label}") + if content is None: + print(colored("Document creation cancelled.", "yellow")) + return + index = self.entry_manager.add_document( + label, + content, + file_type=file_type, + notes=notes, + tags=tags, + ) + self._mark_dirty() + print(colored(f"\n[+] Document entry added with ID {index}.\n", "green")) + self._sync_and_pause() + @pause_logging_for_ui def handle_add_managed_account(self) -> None: """Add a managed account seed entry.""" @@ -2789,6 +2927,20 @@ class PasswordManager: pause() refresh_entry() + def export_document(): + out = input( + "Output path (file or directory, blank for current directory): " + ).strip() + try: + dest = self.entry_manager.export_document_file( + index, output_path=out or None + ) + print(colored(f"Document exported to: {dest}", "green")) + except Exception as exc: + print(colored(f"Export failed: {exc}", "red")) + pause() + refresh_entry() + def get_options(): archived = current_entry.get( "archived", current_entry.get("blacklisted", False) @@ -2813,6 +2965,8 @@ class PasswordManager: EntryType.NOSTR.value, }: options.append(("q", "Show QR codes", show_qr)) + if entry_type == EntryType.DOCUMENT.value: + options.append(("x", "Export Document to File", export_document)) return options @@ -2854,6 +3008,30 @@ class PasswordManager: self.last_update = time.time() refresh_entry() + def edit_document_content(): + new_content = self._terminal_document_editor( + current_entry.get("content", ""), + title=f"Editing: {current_entry.get('label', 'Document')}", + ) + if new_content is None: + print(colored("Edit cancelled.", "yellow")) + return + self.entry_manager.modify_entry(index, content=new_content) + self.is_dirty = True + self.last_update = time.time() + refresh_entry() + + def edit_document_file_type(): + current = current_entry.get("file_type", "txt") + new_file_type = ( + input(f"New file type/extension (current: {current}): ").strip() + or current + ) + self.entry_manager.modify_entry(index, file_type=new_file_type) + self.is_dirty = True + self.last_update = time.time() + refresh_entry() + def edit_username(): new_username = input("New username: ").strip() self.entry_manager.modify_entry(index, username=new_username) @@ -2895,6 +3073,9 @@ class PasswordManager: if entry_type == EntryType.KEY_VALUE.value: options.append(("k", "Edit Key", edit_key)) options.append(("v", "Edit Value", edit_value)) + elif entry_type == EntryType.DOCUMENT.value: + options.append(("c", "Edit Content", edit_document_content)) + options.append(("f", "Edit File Type", edit_document_file_type)) if entry_type == EntryType.PASSWORD.value: options.append(("u", "Edit Username", edit_username)) @@ -3228,6 +3409,81 @@ class PasswordManager: label=new_label, tags=tags, ) + elif entry_type == EntryType.DOCUMENT.value: + label = entry.get("label", "") + content = entry.get("content", "") + file_type = entry.get("file_type", "txt") + blacklisted = entry.get("archived", False) + notes = entry.get("notes", "") + + print( + colored( + f"Modifying document '{label}' (Index: {index}):", + "cyan", + ) + ) + new_label = ( + input(f'Enter new title (leave blank to keep "{label}"): ').strip() + or label + ) + new_file_type = ( + input( + f'Enter file type/extension (leave blank to keep "{file_type}"): ' + ).strip() + or file_type + ) + if input("Edit document content now? (y/N): ").strip().lower() == "y": + edited = self._terminal_document_editor( + content, title=f"Editing: {new_label}" + ) + new_content = content if edited is None else edited + else: + new_content = content + + blacklist_input = ( + input( + f'Archive this entry? (Y/N, current: {"Y" if blacklisted else "N"}): ' + ) + .strip() + .lower() + ) + if blacklist_input == "": + new_blacklisted = blacklisted + elif blacklist_input == "y": + new_blacklisted = True + elif blacklist_input == "n": + new_blacklisted = False + else: + self.notify( + "Invalid input for archived status. Keeping the current status.", + level="WARNING", + ) + new_blacklisted = blacklisted + + new_notes = ( + input( + f'Enter new notes (leave blank to keep "{notes or "N/A"}"): ' + ).strip() + or notes + ) + tags_input = input( + "Enter tags (comma-separated, leave blank to keep current): " + ).strip() + tags = ( + [t.strip() for t in tags_input.split(",") if t.strip()] + if tags_input + else None + ) + + self.entry_manager.modify_entry( + index, + archived=new_blacklisted, + notes=new_notes, + label=new_label, + content=new_content, + file_type=new_file_type, + tags=tags, + ) elif entry_type in ( EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value, @@ -3632,6 +3888,36 @@ class PasswordManager: print(color_text(f" Tags: {', '.join(tags)}", "index")) blacklisted = entry.get("archived", entry.get("blacklisted", False)) print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) + elif etype == EntryType.DOCUMENT.value: + print(color_text(" Type: Document", "index")) + print(color_text(f" Title: {entry.get('label', '')}", "index")) + print( + color_text( + f" File Type: {entry.get('file_type', 'txt')}", + "index", + ) + ) + content = str(entry.get("content", "")) + preview = content if len(content) <= 240 else f"{content[:240]}..." + print(color_text(" Content Preview:", "index")) + print(color_text(f"{preview}", "index")) + notes = entry.get("notes", "") + if notes: + print(color_text(f" Notes: {notes}", "index")) + tags = entry.get("tags", []) + if tags: + print(color_text(f" Tags: {', '.join(tags)}", "index")) + if entry.get("date_added"): + print(color_text(f" Date Added: {entry.get('date_added')}", "index")) + if entry.get("date_modified"): + print( + color_text( + f" Date Modified: {entry.get('date_modified')}", + "index", + ) + ) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) + print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) elif etype == EntryType.MANAGED_ACCOUNT.value: print(color_text(" Type: Managed Account", "index")) print(color_text(f" Label: {entry.get('label', '')}", "index")) @@ -3671,6 +3957,10 @@ class PasswordManager: "index", ) ) + if entry.get("date_added"): + print(color_text(f" Date Added: {entry.get('date_added')}", "index")) + if entry.get("date_modified"): + print(color_text(f" Date Modified: {entry.get('date_modified')}", "index")) print("-" * 40) @pause_logging_for_ui diff --git a/src/seedpass/core/sync_conflict.py b/src/seedpass/core/sync_conflict.py index 107d28fc..ebdc0e1b 100644 --- a/src/seedpass/core/sync_conflict.py +++ b/src/seedpass/core/sync_conflict.py @@ -137,7 +137,7 @@ def _prefer_entry(current: dict[str, Any], incoming: dict[str, Any]) -> bool: def _union_fields_for_kind(kind: str) -> list[str]: - common = ["notes", "tags", "custom_fields"] + common = ["notes", "tags", "custom_fields", "links"] matrix = { "password": ["username", "url"], "totp": ["issuer"], @@ -147,6 +147,7 @@ def _union_fields_for_kind(kind: str) -> list[str]: "pgp": ["public_key", "key_type"], "nostr": ["npub", "public_key"], "seed": ["path", "network", "coin_type"], + "document": ["content", "file_type"], } return matrix.get(kind, []) + common @@ -170,7 +171,7 @@ def _merge_equal_ts_entries( for field in union_fields: pv = merged.get(field) ov = other.get(field) - if field in ("tags", "custom_fields"): + if field in ("tags", "custom_fields", "links"): merged[field] = _merge_list_union(pv, ov) continue if _is_empty(pv) and not _is_empty(ov): diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index a62b8ac1..58d265bb 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -109,6 +109,148 @@ async def test_create_and_modify_ssh_entry(client): assert calls["modify"][1]["notes"] == "x" +@pytest.mark.anyio +async def test_create_and_modify_document_entry(client): + cl, token = client + calls = {} + + def add_document(label, content, **kwargs): + calls["create"] = (label, content, kwargs) + return 11 + + def modify(idx, **kwargs): + calls["modify"] = (idx, kwargs) + + api.app.state.pm.entry_manager.add_document = add_document + api.app.state.pm.entry_manager.modify_entry = modify + + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post( + "/api/v1/entry", + json={ + "type": "document", + "title": "Runbook", + "content": "line1\\nline2", + "file_type": "md", + "notes": "ops", + "tags": ["doc", "ops"], + }, + headers=headers, + ) + assert res.status_code == 200 + assert res.json() == {"id": 11} + assert calls["create"] == ( + "Runbook", + "line1\\nline2", + {"file_type": "md", "notes": "ops", "archived": False, "tags": ["doc", "ops"]}, + ) + + res = await cl.put( + "/api/v1/entry/11", + json={"content": "line1\\nline2\\nline3", "file_type": "txt"}, + headers=headers, + ) + assert res.status_code == 200 + assert calls["modify"][0] == 11 + assert calls["modify"][1]["content"].endswith("line3") + assert calls["modify"][1]["file_type"] == "txt" + + +@pytest.mark.anyio +async def test_document_import_export_endpoints(client): + cl, token = client + calls = {} + + def import_document(path, **kwargs): + calls["import"] = (path, kwargs) + return 33 + + def export_document(idx, **kwargs): + calls["export"] = (idx, kwargs) + return Path("/tmp/exported.md") + + api.app.state.pm.entry_manager.import_document_file = import_document + api.app.state.pm.entry_manager.export_document_file = export_document + + headers = {"Authorization": f"Bearer {token}"} + imported = await cl.post( + "/api/v1/entry/document/import", + json={"path": "/tmp/source.md", "label": "Imported", "notes": "n"}, + headers=headers, + ) + assert imported.status_code == 200 + assert imported.json() == {"id": 33} + assert calls["import"] == ( + "/tmp/source.md", + {"label": "Imported", "notes": "n", "archived": False, "tags": None}, + ) + + exported = await cl.post( + "/api/v1/entry/33/document/export", + json={"path": "/tmp/out", "overwrite": True}, + headers=headers, + ) + assert exported.status_code == 200 + assert exported.json() == {"path": "/tmp/exported.md"} + assert calls["export"] == (33, {"output_path": "/tmp/out", "overwrite": True}) + + +@pytest.mark.anyio +async def test_entry_links_endpoints(client): + cl, token = client + calls = {} + + def add_link(entry_id, target_id, **kwargs): + calls["add"] = (entry_id, target_id, kwargs) + return [{"target_id": target_id, "relation": kwargs["relation"], "note": kwargs["note"]}] + + def remove_link(entry_id, target_id, **kwargs): + calls["remove"] = (entry_id, target_id, kwargs) + return [] + + def get_links(entry_id): + calls["get"] = entry_id + return [ + { + "target_id": 2, + "relation": "references", + "note": "used by doc", + "target_label": "API Token", + "target_kind": "key_value", + } + ] + + api.app.state.pm.entry_manager.add_link = add_link + api.app.state.pm.entry_manager.remove_link = remove_link + api.app.state.pm.entry_manager.get_links = get_links + + headers = {"Authorization": f"Bearer {token}"} + created = await cl.post( + "/api/v1/entry/1/links", + json={"target_id": 2, "relation": "references", "note": "used by doc"}, + headers=headers, + ) + assert created.status_code == 200 + assert created.json()["links"][0]["target_id"] == 2 + assert calls["add"] == (1, 2, {"relation": "references", "note": "used by doc"}) + + listed = await cl.get("/api/v1/entry/1/links", headers=headers) + assert listed.status_code == 200 + assert listed.json()["entry_id"] == 1 + assert listed.json()["links"][0]["target_kind"] == "key_value" + assert calls["get"] == 1 + + removed = await cl.request( + "DELETE", + "/api/v1/entry/1/links", + json={"target_id": 2, "relation": "references"}, + headers=headers, + ) + assert removed.status_code == 200 + assert removed.json() == {"entry_id": 1, "links": []} + assert calls["remove"] == (1, 2, {"relation": "references"}) + + @pytest.mark.anyio async def test_update_entry_error(client): cl, token = client diff --git a/src/tests/test_cli_agent_mode.py b/src/tests/test_cli_agent_mode.py index a3a2b605..97b0d40a 100644 --- a/src/tests/test_cli_agent_mode.py +++ b/src/tests/test_cli_agent_mode.py @@ -586,6 +586,207 @@ def test_agent_token_issue_and_use_limit(monkeypatch, tmp_path): assert second_payload["reason"] == "token_exhausted" +def test_agent_document_import_with_token_scope(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def import_document_file(self, file_path, **kwargs): + assert str(file_path).endswith("notes.md") + assert kwargs["label"] == "My Notes" + assert kwargs["notes"] == "imported" + assert kwargs["tags"] == ["work"] + assert kwargs["archived"] is False + return 12 + + def retrieve_entry(self, _i): + return {"kind": EntryType.DOCUMENT.value, "label": "My Notes", "file_type": "md"} + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "version": 1, + "default_effect": "deny", + "rules": [ + { + "id": "allow_doc_read", + "effect": "allow", + "operations": ["read"], + "kinds": ["document"], + "label_regex": ".*", + "path_regex": "^entry/.*$", + "fields": ["secret"], + } + ], + "approvals": {"require_for": []}, + "output": {"safe_output_default": True, "redact_fields": ["secret"]}, + "export": {"allow_full_vault": True}, + "secret_isolation": {"enabled": False, "high_risk_kinds": [], "unlock_ttl_sec": 300}, + "allow_kinds": ["document"], + "deny_private_reveal": [], + "allow_export_import": True, + }, + ) + + source = tmp_path / "notes.md" + source.write_text("# hello\n", encoding="utf-8") + + issue = runner.invoke( + app, + [ + "agent", + "token-issue", + "--name", + "doc-import", + "--scope", + "import", + "--kind", + "document", + "--uses", + "1", + "--ttl", + "600", + ], + ) + assert issue.exit_code == 0 + token = json.loads(issue.stdout)["token"] + + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "document-import", + "--file", + str(source), + "--label", + "My Notes", + "--notes", + "imported", + "--tag", + "work", + "--token", + token, + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["index"] == 12 + assert payload["kind"] == "document" + assert payload["file_type"] == "md" + assert payload["token_uses_remaining"] == 0 + + +def test_agent_document_export_with_token_scope(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def retrieve_entry(self, _i): + return {"kind": EntryType.DOCUMENT.value, "label": "Spec"} + + def export_document_file(self, _entry_id, out, **kwargs): + assert out == str(tmp_path) + assert kwargs["overwrite"] is True + return tmp_path / "Spec.md" + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: { + "version": 1, + "default_effect": "deny", + "rules": [ + { + "id": "allow_doc_read", + "effect": "allow", + "operations": ["read"], + "kinds": ["document"], + "label_regex": ".*", + "path_regex": "^entry/.*$", + "fields": ["secret"], + } + ], + "approvals": {"require_for": []}, + "output": {"safe_output_default": True, "redact_fields": ["secret"]}, + "export": {"allow_full_vault": True}, + "secret_isolation": {"enabled": False, "high_risk_kinds": [], "unlock_ttl_sec": 300}, + "allow_kinds": ["document"], + "deny_private_reveal": [], + "allow_export_import": True, + }, + ) + + issue = runner.invoke( + app, + [ + "agent", + "token-issue", + "--name", + "doc-export", + "--scope", + "export", + "--kind", + "document", + "--uses", + "1", + "--ttl", + "600", + ], + ) + assert issue.exit_code == 0 + token = json.loads(issue.stdout)["token"] + + result = runner.invoke( + app, + [ + "--fingerprint", + "ABC123", + "agent", + "document-export", + "--entry-id", + "4", + "--out", + str(tmp_path), + "--overwrite", + "--token", + token, + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["index"] == 4 + assert payload["kind"] == "document" + assert payload["output_path"] == str(tmp_path / "Spec.md") + assert payload["token_uses_remaining"] == 0 + + def test_agent_identity_create_list_revoke(monkeypatch, tmp_path): import seedpass.core.agent_identity as identity_core @@ -1444,6 +1645,7 @@ def test_agent_bootstrap_context(monkeypatch, tmp_path): assert "agent identity-create" in payload["commands"]["identities"] assert "agent high-risk-unlock" in payload["commands"]["secret_isolation"] assert "agent export-check" in payload["commands"]["export_controls"] + assert "agent document-export " in payload["commands"]["document_io"] assert "agent posture-check" in payload["commands"]["posture"] assert "agent posture-remediate" in payload["commands"]["posture"] assert "identities" in payload diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index 71592874..fd08ad91 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -1,5 +1,6 @@ import pytest from types import SimpleNamespace +from pathlib import Path from typer.testing import CliRunner from seedpass.cli import app @@ -133,6 +134,14 @@ runner = CliRunner() {"index": 7, "notes": "n"}, "8", ), + ( + "add-document", + "add_document", + ["Doc", "--content", "hello", "--file-type", "md", "--notes", "n"], + ("Doc", "hello"), + {"file_type": "md", "notes": "n", "tags": None, "archived": False}, + "9", + ), ], ) def test_entry_add_commands( @@ -161,3 +170,153 @@ def test_entry_add_commands( assert called["args"] == expected_args assert called["kwargs"] == expected_kwargs assert called.get("sync") is True + + +def test_entry_import_document_command(monkeypatch, tmp_path): + called = {} + + def import_document_file(path, **kwargs): + called["args"] = (path,) + called["kwargs"] = kwargs + return 123 + + def start_background_vault_sync(): + called["sync"] = True + + pm = SimpleNamespace( + entry_manager=SimpleNamespace(import_document_file=import_document_file), + parent_seed="seed", + select_fingerprint=lambda fp: None, + start_background_vault_sync=start_background_vault_sync, + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + source = tmp_path / "doc.md" + source.write_text("hello", encoding="utf-8") + + result = runner.invoke( + app, + [ + "entry", + "import-document", + "--file", + str(source), + "--label", + "Doc", + "--notes", + "n", + ], + ) + assert result.exit_code == 0 + assert "123" in result.stdout + assert called["args"] == (str(source),) + assert called["kwargs"] == { + "label": "Doc", + "notes": "n", + "tags": None, + "archived": False, + } + assert called.get("sync") is True + + +def test_entry_export_document_command(monkeypatch): + called = {} + + def export_document_file(entry_id, output_path=None, **kwargs): + called["args"] = (entry_id, output_path) + called["kwargs"] = kwargs + return Path("/tmp/doc.txt") + + pm = SimpleNamespace( + entry_manager=SimpleNamespace(export_document_file=export_document_file), + parent_seed="seed", + select_fingerprint=lambda fp: None, + start_background_vault_sync=lambda: None, + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + + result = runner.invoke( + app, + [ + "entry", + "export-document", + "--entry-id", + "9", + "--out", + "/tmp/outdir", + "--overwrite", + ], + ) + assert result.exit_code == 0 + assert "/tmp/doc.txt" in result.stdout + assert called["args"] == (9, "/tmp/outdir") + assert called["kwargs"] == {"overwrite": True} + + +def test_entry_link_commands(monkeypatch): + called = {} + + def add_link(entry_id, target_id, **kwargs): + called["add"] = (entry_id, target_id, kwargs) + return [{"target_id": target_id, "relation": kwargs["relation"], "note": kwargs["note"]}] + + def remove_link(entry_id, target_id, **kwargs): + called["remove"] = (entry_id, target_id, kwargs) + return [] + + def get_links(entry_id): + called["get"] = entry_id + return [{"target_id": 3, "relation": "references", "note": "", "target_label": "X", "target_kind": "document"}] + + def start_background_vault_sync(): + called["sync"] = called.get("sync", 0) + 1 + + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + add_link=add_link, + remove_link=remove_link, + get_links=get_links, + ), + parent_seed="seed", + select_fingerprint=lambda fp: None, + start_background_vault_sync=start_background_vault_sync, + ) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) + + add_res = runner.invoke( + app, + [ + "entry", + "link-add", + "--entry-id", + "1", + "--target-id", + "3", + "--relation", + "references", + "--note", + "note", + ], + ) + assert add_res.exit_code == 0 + assert called["add"] == (1, 3, {"relation": "references", "note": "note"}) + + list_res = runner.invoke(app, ["entry", "links", "--entry-id", "1"]) + assert list_res.exit_code == 0 + assert '"target_id": 3' in list_res.stdout + assert called["get"] == 1 + + rm_res = runner.invoke( + app, + [ + "entry", + "link-remove", + "--entry-id", + "1", + "--target-id", + "3", + "--relation", + "references", + ], + ) + assert rm_res.exit_code == 0 + assert called["remove"] == (1, 3, {"relation": "references"}) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 30c72732..ddec6708 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -43,6 +43,7 @@ def _make_pm(called, locked=None): handle_add_password=add, handle_add_totp=lambda: None, handle_add_managed_account=lambda: None, + handle_add_document=lambda: None, handle_retrieve_entry=retrieve, handle_modify_entry=modify, update_activity=update, @@ -81,7 +82,7 @@ def test_out_of_range_menu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["1", "9", "", ""]) + inputs = iter(["1", "10", "", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py index cfeb1b30..335bbf42 100644 --- a/src/tests/test_core_api_services.py +++ b/src/tests/test_core_api_services.py @@ -371,6 +371,12 @@ class TestEntryService: digits=None, key=None, value=None, + content=None, + file_type=None, + custom_fields=None, + tags=None, + links=None, + archived=None, ) mock_manager.start_background_vault_sync.assert_called_once() diff --git a/src/tests/test_document_file_io.py b/src/tests/test_document_file_io.py new file mode 100644 index 00000000..50f96432 --- /dev/null +++ b/src/tests/test_document_file_io.py @@ -0,0 +1,45 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import TEST_PASSWORD, TEST_SEED, create_vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager + + +def _make_entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_import_document_file_roundtrip(): + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + entry_mgr = _make_entry_manager(base) + source = base / "notes.md" + source.write_text("# hello\nworld\n", encoding="utf-8") + + idx = entry_mgr.import_document_file(source, notes="imported", tags=["docs"]) + entry = entry_mgr.retrieve_entry(idx) + assert entry is not None + assert entry["kind"] == "document" + assert entry["label"] == "notes" + assert entry["file_type"] == "md" + assert entry["content"] == "# hello\nworld\n" + assert entry["notes"] == "imported" + assert entry["tags"] == ["docs"] + + +def test_export_document_file_writes_expected_content(): + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + entry_mgr = _make_entry_manager(base) + idx = entry_mgr.add_document("Runbook", "line1\nline2", file_type="txt") + + out_dir = base / "exports" + dest = entry_mgr.export_document_file(idx, output_path=out_dir) + assert dest.exists() + assert dest.suffix == ".txt" + assert dest.read_text(encoding="utf-8") == "line1\nline2" diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 7e1f20f4..2fed504e 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -29,7 +29,7 @@ def test_add_and_retrieve_entry(): index = entry_mgr.add_entry("example.com", 12, "user", custom_fields=custom) entry = entry_mgr.retrieve_entry(index) - assert entry == { + expected = { "label": "example.com", "length": 12, "username": "user", @@ -41,6 +41,9 @@ def test_add_and_retrieve_entry(): "custom_fields": custom, "tags": [], } + assert entry.items() >= expected.items() + assert "date_added" in entry + assert "date_modified" in entry data = enc_mgr.load_json_data(entry_mgr.index_file) assert str(index) in data.get("entries", {}) @@ -58,6 +61,7 @@ def test_add_and_retrieve_entry(): ("add_seed", "seed"), ("add_key_value", "key_value"), ("add_managed_account", "managed_account"), + ("add_document", "document"), ], ) def test_round_trip_entry_types(method, expected_type): @@ -74,6 +78,8 @@ def test_round_trip_entry_types(method, expected_type): index = 0 elif method == "add_key_value": index = entry_mgr.add_key_value("label", "k1", "val") + elif method == "add_document": + index = entry_mgr.add_document("doc", "hello", file_type="txt") else: if method == "add_ssh_key": index = entry_mgr.add_ssh_key("ssh", TEST_SEED) @@ -87,6 +93,8 @@ def test_round_trip_entry_types(method, expected_type): entry = entry_mgr.retrieve_entry(index) assert entry["type"] == expected_type assert entry["kind"] == expected_type + assert "date_added" in entry + assert "date_modified" in entry data = enc_mgr.load_json_data(entry_mgr.index_file) assert data["entries"][str(index)]["type"] == expected_type assert data["entries"][str(index)]["kind"] == expected_type @@ -120,6 +128,7 @@ def test_legacy_entry_defaults_to_password(): ("add_seed", ("seed", TEST_SEED)), ("add_key_value", ("label", "k1", "val")), ("add_managed_account", ("acct", TEST_SEED)), + ("add_document", ("doc", "body")), ], ) def test_add_default_archived_false(method, args): diff --git a/src/tests/test_entry_links_graph.py b/src/tests/test_entry_links_graph.py new file mode 100644 index 00000000..34e02afa --- /dev/null +++ b/src/tests/test_entry_links_graph.py @@ -0,0 +1,63 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_PASSWORD, TEST_SEED + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager + + +def _entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_links_add_list_remove_and_search(): + with TemporaryDirectory() as tmpdir: + em = _entry_manager(Path(tmpdir)) + src = em.add_document("Project Plan", "v1", file_type="md") + dst = em.add_key_value("API Token", "token", "abc") + + links = em.add_link(src, dst, relation="references", note="used by deploy") + assert links == [ + {"target_id": dst, "relation": "references", "note": "used by deploy"} + ] + + resolved = em.get_links(src) + assert resolved[0]["target_id"] == dst + assert resolved[0]["relation"] == "references" + assert resolved[0]["target_label"] == "API Token" + assert resolved[0]["target_kind"] == "key_value" + + # Query by relation, note, and target label should match source. + assert em.search_entries("references")[0][0] == src + assert em.search_entries("deploy")[0][0] == src + assert em.search_entries("api token")[0][0] == src + + remaining = em.remove_link(src, dst, relation="references") + assert remaining == [] + assert em.get_links(src) == [] + + +def test_links_reject_invalid_targets_and_self_link(): + with TemporaryDirectory() as tmpdir: + em = _entry_manager(Path(tmpdir)) + idx = em.add_entry("example", 12) + + try: + em.add_link(idx, idx) + assert False, "Expected self-link validation failure" + except ValueError as exc: + assert "Self-referential" in str(exc) + + try: + em.add_link(idx, 9999) + assert False, "Expected missing target validation failure" + except ValueError as exc: + assert "Target entry not found" in str(exc) diff --git a/src/tests/test_entry_types_roundtrip.py b/src/tests/test_entry_types_roundtrip.py index ec2c2bdb..ab529652 100644 --- a/src/tests/test_entry_types_roundtrip.py +++ b/src/tests/test_entry_types_roundtrip.py @@ -68,6 +68,14 @@ def _add_entry_for_type(entry_mgr: EntryManager, entry_type: EntryType) -> int: return entry_mgr.add_managed_account( "Managed Entry", TEST_SEED, notes="managed-note", tags=["managed"] ) + if entry_type == EntryType.DOCUMENT: + return entry_mgr.add_document( + "Document Entry", + "alpha\nbeta\ngamma", + file_type="md", + notes="doc-note", + tags=["doc"], + ) raise AssertionError(f"Unhandled entry type in test: {entry_type}") @@ -86,6 +94,8 @@ def test_each_entry_type_roundtrip_retrieve_and_filter(tmp_path, entry_type: Ent assert entry["kind"] == entry_type.value assert entry["label"] assert "modified_ts" not in entry + assert "date_added" in entry + assert "date_modified" in entry entries = entry_mgr.list_entries( filter_kinds=[entry_type.value], include_archived=True, verbose=False @@ -126,3 +136,26 @@ def test_each_entry_type_roundtrip_retrieve_and_filter(tmp_path, entry_type: Ent if entry_type == EntryType.MANAGED_ACCOUNT: assert entry["word_count"] == 12 assert entry["fingerprint"] + if entry_type == EntryType.DOCUMENT: + assert entry["file_type"] == "md" + assert "alpha" in entry["content"] + + +@pytest.mark.parametrize("entry_type", list(EntryType)) +def test_each_entry_type_sets_and_updates_dates( + tmp_path, monkeypatch, entry_type: EntryType +): + entry_mgr = _make_entry_manager(tmp_path) + monkeypatch.setattr(entry_mgr, "_now_unix", lambda: 100) + index = _add_entry_for_type(entry_mgr, entry_type) + created = entry_mgr.retrieve_entry(index) + assert created is not None + assert created["date_added"] == entry_mgr._iso_from_unix(100) + assert created["date_modified"] == entry_mgr._iso_from_unix(100) + + monkeypatch.setattr(entry_mgr, "_now_unix", lambda: 200) + entry_mgr.modify_entry(index, label=f"{created['label']} updated") + updated = entry_mgr.retrieve_entry(index) + assert updated is not None + assert updated["date_added"] == entry_mgr._iso_from_unix(100) + assert updated["date_modified"] == entry_mgr._iso_from_unix(200) diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index c9709f7a..c3a46312 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -64,3 +64,35 @@ def test_full_sync_roundtrip(dummy_nostr_client): pm_b.entry_manager.clear_cache() labels = [e[1] for e in pm_b.entry_manager.list_entries()] assert sorted(labels) == ["site1", "site2"] + + +def test_large_document_sync_roundtrip(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + large_content = "line\n" * 120_000 + doc_id = pm_a.entry_manager.add_document( + "LargeDoc", + large_content, + file_type="txt", + notes="large-payload", + tags=["sync", "large"], + ) + + pm_a.sync_vault() + + result = pm_b.attempt_initial_sync() + assert result is True + restored = pm_b.entry_manager.retrieve_entry(doc_id) + assert restored is not None + assert restored["kind"] == "document" + assert restored["file_type"] == "txt" + assert restored["content"] == large_content diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py index b1324ea5..36e35eff 100644 --- a/src/tests/test_key_value_entry.py +++ b/src/tests/test_key_value_entry.py @@ -25,17 +25,17 @@ def test_add_and_modify_key_value(): idx = em.add_key_value("API entry", "api_key", "abc123", notes="token") entry = em.retrieve_entry(idx) - assert entry == { - "type": "key_value", - "kind": "key_value", - "label": "API entry", - "key": "api_key", - "value": "abc123", - "notes": "token", - "archived": False, - "custom_fields": [], - "tags": [], - } + assert entry["type"] == "key_value" + assert entry["kind"] == "key_value" + assert entry["label"] == "API entry" + assert entry["key"] == "api_key" + assert entry["value"] == "abc123" + assert entry["notes"] == "token" + assert entry["archived"] is False + assert entry["custom_fields"] == [] + assert entry["tags"] == [] + assert "date_added" in entry + assert "date_modified" in entry # Appears in listing assert em.list_entries() == [(idx, "API entry", None, None, False)] diff --git a/src/tests/test_legacy_migration_iterations.py b/src/tests/test_legacy_migration_iterations.py index e2007e67..3c6d010b 100644 --- a/src/tests/test_legacy_migration_iterations.py +++ b/src/tests/test_legacy_migration_iterations.py @@ -49,9 +49,11 @@ def test_migrate_iterations(tmp_path, monkeypatch, iterations): calls: list[int] = [] orig_derive = enc_module._derive_legacy_key_from_password - def tracking_derive(password: str, iterations: int = 100_000) -> bytes: + def tracking_derive( + password: str, iterations: int = 100_000, salt: bytes = b"" + ) -> bytes: calls.append(iterations) - return orig_derive(password, iterations=iterations) + return orig_derive(password, iterations=iterations, salt=salt) monkeypatch.setattr(enc_module, "_derive_legacy_key_from_password", tracking_derive) @@ -60,7 +62,11 @@ def test_migrate_iterations(tmp_path, monkeypatch, iterations): vault.load_index() assert prompts == [1] - expected = [50_000] if iterations == 50_000 else [50_000, 100_000] + expected = ( + [50_000, 100_000, 50_000] + if iterations == 50_000 + else [50_000, 100_000, 50_000, 100_000] + ) assert calls == expected cfg = ConfigManager(vault, tmp_path) diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py index 476836fe..7fc7b0c6 100644 --- a/src/tests/test_list_entries_sort_filter.py +++ b/src/tests/test_list_entries_sort_filter.py @@ -40,8 +40,8 @@ def test_sort_by_updated(): idx1 = em.add_entry("beta.com", 8, "u1") data = em._load_index(force_reload=True) - data["entries"][str(idx0)]["updated"] = 1 - data["entries"][str(idx1)]["updated"] = 2 + data["entries"][str(idx0)]["modified_ts"] = 1 + data["entries"][str(idx1)]["modified_ts"] = 2 em._save_index(data) result = em.list_entries(sort_by="updated") diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py index 52a0c3b0..52f34053 100644 --- a/src/tests/test_manager_add_password.py +++ b/src/tests/test_manager_add_password.py @@ -74,18 +74,18 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys): assert entries == [(0, "Example", "", "", False)] entry = entry_mgr.retrieve_entry(0) - assert entry == { - "label": "Example", - "length": DEFAULT_PASSWORD_LENGTH, - "username": "", - "url": "", - "archived": False, - "type": "password", - "kind": "password", - "notes": "", - "custom_fields": [], - "tags": [], - } + assert entry["label"] == "Example" + assert entry["length"] == DEFAULT_PASSWORD_LENGTH + assert entry["username"] == "" + assert entry["url"] == "" + assert entry["archived"] is False + assert entry["type"] == "password" + assert entry["kind"] == "password" + assert entry["notes"] == "" + assert entry["custom_fields"] == [] + assert entry["tags"] == [] + assert "date_added" in entry + assert "date_modified" in entry assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out @@ -194,18 +194,18 @@ def test_handle_add_password_quick_mode(monkeypatch, dummy_nostr_client, capsys) assert entries == [(0, "Example", "", "", False)] entry = entry_mgr.retrieve_entry(0) - assert entry == { - "label": "Example", - "length": DEFAULT_PASSWORD_LENGTH, - "username": "", - "url": "", - "archived": False, - "type": "password", - "kind": "password", - "notes": "", - "custom_fields": [], - "tags": [], - } + assert entry["label"] == "Example" + assert entry["length"] == DEFAULT_PASSWORD_LENGTH + assert entry["username"] == "" + assert entry["url"] == "" + assert entry["archived"] is False + assert entry["type"] == "password" + assert entry["kind"] == "password" + assert entry["notes"] == "" + assert entry["custom_fields"] == [] + assert entry["tags"] == [] + assert "date_added" in entry + assert "date_modified" in entry assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out diff --git a/src/tests/test_modify_entry_validation.py b/src/tests/test_modify_entry_validation.py index a6f87f42..99b3b81c 100644 --- a/src/tests/test_modify_entry_validation.py +++ b/src/tests/test_modify_entry_validation.py @@ -74,6 +74,12 @@ def entry_manager(): {"url": "seed_url"}, "does not support fields: url", ), + ( + EntryType.DOCUMENT, + lambda em: em.add_document("Doc", "body"), + {"username": "doc_user"}, + "does not support fields: username", + ), ], ) def test_modify_entry_invalid_fields( diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index 80c36bce..53509d1c 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -24,15 +24,15 @@ def test_nostr_key_determinism(): idx = entry_mgr.add_nostr_key("main", TEST_SEED) entry = entry_mgr.retrieve_entry(idx) - assert entry == { - "type": "nostr", - "kind": "nostr", - "index": idx, - "label": "main", - "notes": "", - "archived": False, - "tags": [], - } + assert entry["type"] == "nostr" + assert entry["kind"] == "nostr" + assert entry["index"] == idx + assert entry["label"] == "main" + assert entry["notes"] == "" + assert entry["archived"] is False + assert entry["tags"] == [] + assert "date_added" in entry + assert "date_modified" in entry npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) npub2, nsec2 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) diff --git a/src/tests/test_nostr_legacy_decrypt_fallback.py b/src/tests/test_nostr_legacy_decrypt_fallback.py index c130230f..63aa146d 100644 --- a/src/tests/test_nostr_legacy_decrypt_fallback.py +++ b/src/tests/test_nostr_legacy_decrypt_fallback.py @@ -12,10 +12,12 @@ import seedpass.core.encryption as enc_module def test_legacy_password_only_fallback(monkeypatch, tmp_path, caplog): calls: list[int] = [] - def _fast_legacy_key(password: str, iterations: int = 100_000) -> bytes: + def _fast_legacy_key( + password: str, iterations: int = 100_000, salt: bytes = b"" + ) -> bytes: calls.append(iterations) normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8") - key = hashlib.pbkdf2_hmac("sha256", normalized, b"", 1, dklen=32) + key = hashlib.pbkdf2_hmac("sha256", normalized, salt, 1, dklen=32) return base64.urlsafe_b64encode(key) # Speed up legacy key derivation @@ -35,4 +37,4 @@ def test_legacy_password_only_fallback(monkeypatch, tmp_path, caplog): assert enc_mgr.decrypt_and_save_index_from_nostr(encrypted) assert vault.load_index() == data assert any("legacy password-only" in rec.message for rec in caplog.records) - assert calls == [50_000, 50_000] + assert calls == [50_000, 50_000, 100_000, 50_000] diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index 06665fef..305083b4 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -34,27 +34,27 @@ def test_seed_phrase_determinism(): entry12 = entry_mgr.retrieve_entry(idx_12) entry24 = entry_mgr.retrieve_entry(idx_24) - assert entry12 == { - "type": "seed", - "kind": "seed", - "index": idx_12, - "label": "seed12", - "word_count": 12, - "notes": "", - "archived": False, - "tags": [], - } + assert entry12["type"] == "seed" + assert entry12["kind"] == "seed" + assert entry12["index"] == idx_12 + assert entry12["label"] == "seed12" + assert entry12["word_count"] == 12 + assert entry12["notes"] == "" + assert entry12["archived"] is False + assert entry12["tags"] == [] + assert "date_added" in entry12 + assert "date_modified" in entry12 - assert entry24 == { - "type": "seed", - "kind": "seed", - "index": idx_24, - "label": "seed24", - "word_count": 24, - "notes": "", - "archived": False, - "tags": [], - } + assert entry24["type"] == "seed" + assert entry24["kind"] == "seed" + assert entry24["index"] == idx_24 + assert entry24["label"] == "seed24" + assert entry24["word_count"] == 24 + assert entry24["notes"] == "" + assert entry24["archived"] is False + assert entry24["tags"] == [] + assert "date_added" in entry24 + assert "date_modified" in entry24 assert phrase12_a not in entry12.values() assert phrase24_a not in entry24.values() diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index b0d8a4de..f2fee6c1 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -22,15 +22,16 @@ def test_add_and_retrieve_ssh_key_pair(): index = entry_mgr.add_ssh_key("ssh", TEST_SEED) entry = entry_mgr.retrieve_entry(index) - assert entry == { - "type": "ssh", - "kind": "ssh", - "index": index, - "label": "ssh", - "notes": "", - "archived": False, - "tags": [], - } + assert entry["type"] == "ssh" + assert entry["kind"] == "ssh" + assert entry["index"] == index + assert entry["label"] == "ssh" + assert entry["notes"] == "" + assert entry["archived"] is False + assert entry["tags"] == [] + assert entry["custom_fields"] == [] + assert "date_added" in entry + assert "date_modified" in entry priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)