Add document workflows, entry graph links, and discovery/docs updates

This commit is contained in:
thePR0M3TH3AN committed 2026-03-01 15:43:43 -05:00
1 parent 64efd1175d
commit d0ea0d96f6
41 files changed
+2904 -153

No files matched your search

+2 -1
View File
@@ -16,7 +16,8 @@ app = typer.Typer(
help=(
"SeedPass command line interface. "
"Run `seedpass capabilities` for a deterministic feature map "
"and `seedpass <group> --help` for group-specific commands."
"and `seedpass <group> --help` for group-specific commands, "
"including document I/O and entry graph links."
),
invoke_without_command=True,
)
+484 -1
View File
@@ -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 <candidate.json>"],
"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 <path>",
"agent document-export <query>",
],
"knowledge_graph": [
"entry link-add --entry-id <source> --target-id <target>",
"entry links --entry-id <source>",
"entry link-remove --entry-id <source> --target-id <target>",
],
"posture": ["agent posture-check", "agent posture-remediate"],
"secret_access": ["agent get --fingerprint <fp> <query>"],
"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,
+29
View File
@@ -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 <group> --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"]:
+128
View File
@@ -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."""