Add agent security controls, broker auth, and capability discovery

This commit is contained in:
thePR0M3TH3AN committed 2026-02-28 22:32:38 -05:00
1 parent 1febbd4b2c
commit fe96ccd7c7
15 files changed
+2381 -99

No files matched your search

+2
View File
@@ -39,6 +39,7 @@ deterministic_totp_option = typer.Option(
# Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api, agent
from .capabilities import register_capabilities_command
app.add_typer(entry.app, name="entry")
app.add_typer(vault.app, name="vault")
@@ -48,6 +49,7 @@ app.add_typer(fingerprint.app, name="fingerprint")
app.add_typer(util.app, name="util")
app.add_typer(api.app, name="api")
app.add_typer(agent.app, name="agent")
register_capabilities_command(app)
def run() -> None:
+1056 -88
View File
File diff suppressed because it is too large. Load diff
+53 -2
View File
@@ -1,17 +1,68 @@
from __future__ import annotations
from typing import Optional
import click
import typer
import uvicorn
from .. import api as api_module
from seedpass.core.auth_broker import AuthBrokerError, resolve_password as resolve_broker_password
app = typer.Typer(help="Run the API server")
@app.command("start")
def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
def api_start(
ctx: typer.Context,
host: str = "127.0.0.1",
port: int = 8000,
unlock: bool = typer.Option(
False, "--unlock", help="Unlock vault during startup using the selected auth broker"
),
auth_broker: str = typer.Option(
"prompt",
"--auth-broker",
help="Password source when --unlock is used (prompt|env|keyring|command)",
click_type=click.Choice(["prompt", "env", "keyring", "command"], case_sensitive=False),
),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var used when --auth-broker=env"
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to active fingerprint when available)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
) -> None:
"""Start the SeedPass API server."""
token = api_module.start_server(ctx.obj.get("fingerprint"))
unlock_password: str | None = None
if unlock:
if auth_broker.lower() == "prompt":
unlock_password = typer.prompt("Master password", hide_input=True)
else:
account = broker_account or str((ctx.obj or {}).get("fingerprint") or "default")
try:
unlock_password = resolve_broker_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=account,
broker_command=broker_command,
)
except AuthBrokerError as exc:
raise typer.BadParameter(str(exc)) from exc
token = api_module.start_server(ctx.obj.get("fingerprint"), unlock_password)
typer.echo(
f"API token: {token}\nWARNING: Store this token securely; it cannot be recovered."
)
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import json
from typing import Any
import click
import typer
def build_capabilities() -> dict[str, Any]:
"""Return a deterministic capability map for CLI/API discovery."""
return {
"schema_version": 1,
"product": "SeedPass",
"interfaces": {
"cli": {
"root_commands": [
"lock",
"gui",
"capabilities",
"entry",
"vault",
"nostr",
"config",
"fingerprint",
"util",
"api",
"agent",
],
"global_options": [
"--fingerprint",
"--no-clipboard",
"--deterministic-totp",
],
},
"api": {
"discovery": ["/docs"],
"auth_headers": ["Authorization: Bearer <token>"],
"sensitive_auth_headers": ["X-SeedPass-Password"],
},
},
"security_features": {
"auth_brokers": {
"available": ["env", "keyring", "command", "prompt"],
"agent_commands": ["agent init", "agent get"],
"vault_commands": ["vault unlock"],
"api_commands": ["api start --unlock"],
},
"policy": {
"commands": ["agent policy-show", "agent policy-lint", "agent policy-set"],
"default_effect": "deny",
},
"tokens": {
"commands": ["agent token-issue", "agent token-list", "agent token-revoke"],
"supports_ttl": True,
"supports_use_limits": True,
"supports_revocation": True,
},
"redaction": {
"default_safe_output": True,
"reveal_override": "agent get --reveal",
},
"audit": {
"log_file": "~/.seedpass/agent_audit.log",
"integrity": "HMAC chained append-only records",
},
},
"high_risk_operations": [
"vault export",
"vault reveal-parent-seed",
"entry get (sensitive kinds)",
"api /api/v1/vault/export",
],
"help_hints": [
"Run `seedpass --help` for top-level commands.",
"Run `seedpass <group> --help` for detailed options.",
"Use `seedpass capabilities --format json` for machine-readable discovery.",
],
}
def emit_capabilities(output_format: str) -> str:
data = build_capabilities()
if output_format == "json":
return json.dumps(data, indent=2, sort_keys=True)
lines: list[str] = []
lines.append("SeedPass Capabilities (schema v1)")
lines.append("")
lines.append("CLI root commands:")
lines.append(", ".join(data["interfaces"]["cli"]["root_commands"]))
lines.append("")
lines.append("Security highlights:")
lines.append("- Auth brokers: env, keyring, command, prompt")
lines.append("- Policy controls: agent policy-show/policy-lint/policy-set")
lines.append("- Scoped tokens: issue/list/revoke with TTL and use limits")
lines.append("- Safe output redaction default with explicit --reveal")
lines.append("- Audited access log with chained HMAC signatures")
lines.append("")
lines.append("Discovery hints:")
for hint in data["help_hints"]:
lines.append(f"- {hint}")
return "\n".join(lines)
def register_capabilities_command(app: typer.Typer) -> None:
@app.command("capabilities")
def capabilities_command(
format: str = typer.Option(
"text",
"--format",
help="Output format for capabilities map",
click_type=click.Choice(["text", "json"], case_sensitive=False),
)
) -> None:
"""Show a deterministic capabilities map for users and agents."""
typer.echo(emit_capabilities(format.lower()))
+13 -1
View File
@@ -9,6 +9,7 @@ import typer
import click
from .common import _get_entry_service, EntryType
from seedpass.core.agent_export_policy import kind_export_allowed, load_export_policy
from seedpass.core.entry_types import ALL_ENTRY_TYPES
from utils.clipboard import ClipboardUnavailableError
@@ -357,9 +358,20 @@ def entry_totp_codes(ctx: typer.Context) -> None:
@app.command("export-totp")
def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
ctx: typer.Context,
file: str = typer.Option(..., help="Output file"),
agent_profile: bool = typer.Option(
False,
"--agent-profile",
help="Apply agent export policy controls",
),
) -> None:
"""Export all TOTP secrets to a JSON file."""
if agent_profile:
policy = load_export_policy()
if not kind_export_allowed(policy, EntryType.TOTP.value):
typer.echo("Error: Policy denied TOTP export for agent profile.")
raise typer.Exit(code=1)
service = _get_entry_service(ctx)
data = service.export_totp_entries()
Path(file).write_text(json.dumps(data, indent=2))
+88 -3
View File
@@ -4,6 +4,7 @@ import json
from pathlib import Path
from typing import Optional
import click
import typer
from .common import (
@@ -12,16 +13,57 @@ from .common import (
UnlockRequest,
BackupParentSeedRequest,
)
from seedpass.core.agent_export_policy import (
allowed_kinds,
filter_index_for_allowed_kinds,
full_export_allowed,
load_export_policy,
)
from seedpass.core.auth_broker import AuthBrokerError, resolve_password as resolve_broker_password
app = typer.Typer(help="Manage the entire vault")
@app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
ctx: typer.Context,
file: str = typer.Option(..., help="Output file"),
agent_profile: bool = typer.Option(
False,
"--agent-profile",
help="Apply agent export policy controls",
),
policy_filtered: bool = typer.Option(
False,
"--policy-filtered",
help="Export only policy-allowed subset of entries",
),
) -> None:
"""Export the vault profile to an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
if agent_profile:
policy = load_export_policy()
if policy_filtered:
manager = getattr(vault_service, "_manager", None)
vault = getattr(manager, "vault", None)
enc_mgr = getattr(vault, "encryption_manager", None)
if vault is None or enc_mgr is None:
typer.echo("Error: policy-filtered export unavailable in current context")
raise typer.Exit(code=1)
index_data = vault.load_index()
filtered = filter_index_for_allowed_kinds(index_data, allowed_kinds(policy))
payload = json.dumps(
filtered, sort_keys=True, separators=(",", ":")
).encode("utf-8")
data = enc_mgr.encrypt_data(payload)
Path(file).write_bytes(data)
typer.echo(str(file))
return
if not full_export_allowed(policy):
typer.echo(
"Error: Policy denied full vault export for agent profile. Use --policy-filtered."
)
raise typer.Exit(code=1)
data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file))
@@ -55,16 +97,59 @@ def vault_change_password(ctx: typer.Context) -> None:
@app.command("unlock")
def vault_unlock(ctx: typer.Context) -> None:
def vault_unlock(
ctx: typer.Context,
auth_broker: str = typer.Option(
"prompt",
"--auth-broker",
help="Password source for unlock (prompt|env|keyring|command)",
click_type=click.Choice(["prompt", "env", "keyring", "command"], case_sensitive=False),
),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var used when --auth-broker=env"
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to active fingerprint when available)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
) -> None:
"""Unlock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
password: str
if auth_broker.lower() == "prompt":
password = typer.prompt("Master password", hide_input=True)
else:
account = broker_account or str((ctx.obj or {}).get("fingerprint") or "default")
try:
password = resolve_broker_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=account,
broker_command=broker_command,
)
except AuthBrokerError as exc:
raise typer.BadParameter(str(exc)) from exc
try:
resp = vault_service.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Unlocked in {resp.duration:.2f}s")
typer.echo(
"Tip: run `seedpass --help` (and `seedpass <command> --help`) to discover available features."
)
@app.command("lock")