Files
SeedPass/src/seedpass/cli/agent.py
T

4505 lines
152 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import secrets
import socket
import difflib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
import bcrypt
import click
import typer
from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39WordsNum
from mnemonic import Mnemonic
from constants import APP_DIR, initialize_app
from seedpass.core.api import EntryService
from seedpass.core.agent_export_policy import (
compute_policy_stamp,
evaluate_full_export,
evaluate_kind_export,
verify_filtered_export_package,
)
from seedpass.core.agent_approval import (
VALID_APPROVAL_ACTIONS,
approval_required,
consume_approval,
issue_approval,
list_approvals,
revoke_approval,
)
from seedpass.core.agent_secret_lease import (
consume_lease,
issue_lease,
list_leases,
revoke_lease,
)
from seedpass.core.agent_identity import (
create_identity,
ensure_identity,
identity_active,
list_identities,
revoke_identity,
)
from seedpass.core.agent_job import (
create_job_profile,
get_job_profile,
list_job_profiles,
revoke_job_profile,
)
from seedpass.core.agent_recovery import (
list_recovery_drills,
record_recovery_drill,
recover_secret,
split_secret,
)
from seedpass.core.agent_secret_isolation import (
grant_high_risk_unlock,
high_risk_factor_configured,
high_risk_unlocked,
unlocked_partition_key_tag,
partition_key_tag_for_factor,
revoke_high_risk_unlock,
set_high_risk_factor,
verify_high_risk_factor,
)
from seedpass.core.high_risk_partition_store import (
load_partition_entry,
migrate_high_risk_entries,
)
from seedpass.core.auth_broker import (
AuthBrokerError,
resolve_password as resolve_broker_password,
)
from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType
from seedpass.core.encryption import EncryptionManager
from seedpass.core.manager import PasswordManager
from seedpass.core.vault import Vault
from seedpass.core.config_manager import ConfigManager
from utils.fingerprint import generate_fingerprint
from utils.fingerprint_manager import FingerprintManager
from utils.key_derivation import derive_index_key, derive_key_from_password
app = typer.Typer(
help=(
"Agent-first non-interactive workflows. "
"Start with `agent bootstrap-context` and `--help` for safe autonomous usage."
)
)
PRIVATE_KINDS = {
EntryType.SEED.value,
EntryType.SSH.value,
EntryType.PGP.value,
EntryType.NOSTR.value,
EntryType.MANAGED_ACCOUNT.value,
}
DEFAULT_POLICY = {
"version": 1,
"default_effect": "deny",
"rules": [
{
"id": "allow_standard_read",
"effect": "allow",
"operations": ["read"],
"kinds": [
EntryType.PASSWORD.value,
EntryType.TOTP.value,
EntryType.KEY_VALUE.value,
EntryType.DOCUMENT.value,
],
"label_regex": ".*",
"path_regex": "^entry/.*$",
"fields": ["secret", "label", "kind"],
},
{
"id": "deny_private_material",
"effect": "deny",
"operations": ["read"],
"kinds": sorted(PRIVATE_KINDS),
"label_regex": ".*",
"path_regex": "^entry/.*$",
"fields": ["secret"],
},
],
"approvals": {
"require_for": [
"export",
"reveal_parent_seed",
"private_key_retrieval",
]
},
"output": {
"safe_output_default": True,
"redact_fields": ["secret", "value", "private_key", "seed_phrase"],
},
"export": {
"allow_full_vault": False,
},
"secret_isolation": {
"enabled": True,
"high_risk_kinds": sorted(PRIVATE_KINDS),
"unlock_ttl_sec": 300,
},
# Legacy compatibility fields kept for older consumers/tests.
"allow_kinds": [
EntryType.PASSWORD.value,
EntryType.TOTP.value,
EntryType.KEY_VALUE.value,
EntryType.DOCUMENT.value,
],
"deny_private_reveal": sorted(PRIVATE_KINDS),
"allow_export_import": False,
}
TOKEN_STORE_VERSION = 1
DEFAULT_AGENT_IDENTITY = "default-agent"
SAFE_AUTOMATION_BROKERS = {"keyring", "command"}
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _policy_path() -> Path:
return APP_DIR / "agent_policy.json"
def _token_store_path() -> Path:
return APP_DIR / "agent_tokens.json"
def _audit_key_path() -> Path:
return APP_DIR / "agent_audit.key"
def _audit_log_path() -> Path:
return APP_DIR / "agent_audit.log"
def _ensure_dir(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def _mask_secret(value: str | None) -> str | None:
if value is None:
return None
if len(value) <= 4:
return "*" * len(value)
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
def _automation_job_command(
*,
fingerprint: str,
query: str,
auth_broker: str,
broker_service: str,
broker_account: str,
reveal: bool,
lease_only: bool,
lease_ttl: int,
lease_uses: int,
) -> str:
parts = [
"seedpass",
f"--fingerprint {fingerprint}",
"agent job-run",
query,
f"--auth-broker {auth_broker}",
f"--broker-service {broker_service}",
f"--broker-account {broker_account}",
]
if reveal:
parts.append("--reveal")
if lease_only:
parts.extend(
["--lease-only", f"--lease-ttl {lease_ttl}", f"--lease-uses {lease_uses}"]
)
return " ".join(parts)
def _cron_template(*, schedule: str, command: str) -> str:
return f"{schedule} {command} >> /var/log/seedpass-agent-job.log 2>&1"
def _systemd_templates(
*, unit_name: str, schedule: str, command: str
) -> tuple[str, str]:
service = "\n".join(
[
"[Unit]",
f"Description=SeedPass agent job ({unit_name})",
"",
"[Service]",
"Type=oneshot",
f"ExecStart={command}",
]
)
timer = "\n".join(
[
"[Unit]",
f"Description=SeedPass agent timer ({unit_name})",
"",
"[Timer]",
f"OnCalendar={schedule}",
"Persistent=true",
"",
"[Install]",
"WantedBy=timers.target",
]
)
return service, timer
def _resolve_secret_for_kind(
pm: PasswordManager, service: EntryService, entry: dict[str, Any], index: int
) -> str:
kind = str(entry.get("type", entry.get("kind", "")))
if kind == EntryType.PASSWORD.value:
return service.generate_password(int(entry.get("length", 12)), index)
if kind == EntryType.TOTP.value:
return service.get_totp_code(index)
if kind == EntryType.KEY_VALUE.value:
return str(entry.get("value", ""))
if kind == EntryType.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:
return pm.entry_manager.get_managed_account_seed(index, pm.parent_seed)
if kind == EntryType.SSH.value:
secret, _pub = pm.entry_manager.get_ssh_key_pair(index, pm.parent_seed)
return secret
if kind == EntryType.PGP.value:
secret, _fp = pm.entry_manager.get_pgp_key(index, pm.parent_seed)
return secret
if kind == EntryType.NOSTR.value:
_npub, secret = pm.entry_manager.get_nostr_key_pair(index, pm.parent_seed)
return secret
raise ValueError("unsupported_kind_for_agent_get")
def _deny_all_policy() -> dict[str, Any]:
deny = {
"version": 1,
"default_effect": "deny",
"rules": [],
"approvals": {"require_for": ["export", "reveal_parent_seed"]},
"output": {
"safe_output_default": True,
"redact_fields": ["secret", "value", "private_key", "seed_phrase"],
},
"export": {"allow_full_vault": False},
"secret_isolation": {
"enabled": True,
"high_risk_kinds": sorted(PRIVATE_KINDS),
"unlock_ttl_sec": 300,
},
"allow_kinds": [],
"deny_private_reveal": list(ALL_ENTRY_TYPES),
"allow_export_import": False,
}
return deny
def _normalize_legacy_policy(data: dict[str, Any]) -> dict[str, Any]:
"""Convert legacy policy keys into the versioned rule model."""
allow_kinds_raw = data.get("allow_kinds")
deny_private_raw = data.get("deny_private_reveal")
allow_export_import = bool(data.get("allow_export_import", False))
isolation_cfg = data.get("secret_isolation")
allow_kinds = (
[str(v) for v in allow_kinds_raw if str(v) in ALL_ENTRY_TYPES]
if isinstance(allow_kinds_raw, list)
else list(DEFAULT_POLICY["allow_kinds"])
)
deny_private = (
[str(v) for v in deny_private_raw if str(v) in ALL_ENTRY_TYPES]
if isinstance(deny_private_raw, list)
else list(DEFAULT_POLICY["deny_private_reveal"])
)
policy = dict(DEFAULT_POLICY)
rules: list[dict[str, Any]] = [
{
"id": "allow_standard_read",
"effect": "allow",
"operations": ["read"],
"kinds": allow_kinds,
"label_regex": ".*",
"path_regex": "^entry/.*$",
"fields": ["secret", "label", "kind"],
}
]
if deny_private:
rules.append(
{
"id": "deny_private_material",
"effect": "deny",
"operations": ["read"],
"kinds": deny_private,
"label_regex": ".*",
"path_regex": "^entry/.*$",
"fields": ["secret"],
}
)
policy["rules"] = rules
policy["allow_kinds"] = allow_kinds
policy["deny_private_reveal"] = deny_private
policy["allow_export_import"] = allow_export_import
policy["export"] = {"allow_full_vault": allow_export_import}
policy["output"] = dict(DEFAULT_POLICY["output"])
secret_isolation = dict(DEFAULT_POLICY["secret_isolation"])
if isinstance(isolation_cfg, dict):
secret_isolation["enabled"] = bool(isolation_cfg.get("enabled", True))
raw_high_risk = isolation_cfg.get("high_risk_kinds")
if isinstance(raw_high_risk, list):
secret_isolation["high_risk_kinds"] = [
str(v).strip().lower()
for v in raw_high_risk
if str(v).strip().lower() in PRIVATE_KINDS
]
raw_unlock_ttl = isolation_cfg.get("unlock_ttl_sec")
if raw_unlock_ttl is not None:
try:
ttl = int(raw_unlock_ttl)
if ttl > 0:
secret_isolation["unlock_ttl_sec"] = ttl
except (TypeError, ValueError):
pass
policy["secret_isolation"] = secret_isolation
return policy
def _validate_rule(rule: dict[str, Any]) -> None:
effect = str(rule.get("effect", "")).lower()
if effect not in {"allow", "deny"}:
raise ValueError("rule effect must be 'allow' or 'deny'")
operations = rule.get("operations", [])
if not isinstance(operations, list) or not operations:
raise ValueError("rule operations must be a non-empty list")
for op in operations:
if not isinstance(op, str):
raise ValueError("rule operations entries must be strings")
kinds = rule.get("kinds", [])
if not isinstance(kinds, list):
raise ValueError("rule kinds must be a list")
invalid_kinds = [k for k in kinds if str(k) not in ALL_ENTRY_TYPES]
if invalid_kinds:
raise ValueError(f"rule contains invalid kinds: {invalid_kinds}")
for regex_key in ("label_regex", "path_regex"):
pattern = rule.get(regex_key, ".*")
if not isinstance(pattern, str):
raise ValueError(f"rule {regex_key} must be a string")
try:
re.compile(pattern)
except re.error as exc: # pragma: no cover - exercised via CLI path
raise ValueError(f"invalid {regex_key}: {exc}") from exc
fields = rule.get("fields", [])
if not isinstance(fields, list) or any(not isinstance(f, str) for f in fields):
raise ValueError("rule fields must be a list of strings")
def _normalize_policy(data: dict[str, Any], *, strict: bool) -> dict[str, Any]:
if "rules" not in data:
return _normalize_legacy_policy(data)
policy = dict(DEFAULT_POLICY)
policy.update({k: v for k, v in data.items() if k in policy})
version = int(policy.get("version", 1))
if version != 1:
raise ValueError("agent policy version must be 1")
default_effect = str(policy.get("default_effect", "deny")).lower()
if default_effect not in {"allow", "deny"}:
raise ValueError("default_effect must be 'allow' or 'deny'")
policy["default_effect"] = default_effect
raw_rules = policy.get("rules", [])
if not isinstance(raw_rules, list):
raise ValueError("policy rules must be a list")
normalized_rules: list[dict[str, Any]] = []
for rule in raw_rules:
if not isinstance(rule, dict):
raise ValueError("each policy rule must be an object")
_validate_rule(rule)
normalized_rules.append(
{
"id": str(rule.get("id", "rule")),
"effect": str(rule.get("effect", "")).lower(),
"operations": [str(v) for v in rule.get("operations", [])],
"kinds": [str(v) for v in rule.get("kinds", [])],
"label_regex": str(rule.get("label_regex", ".*")),
"path_regex": str(rule.get("path_regex", ".*")),
"fields": [str(v) for v in rule.get("fields", [])],
}
)
policy["rules"] = normalized_rules
output_cfg = policy.get("output")
if not isinstance(output_cfg, dict):
output_cfg = {}
output_norm = dict(DEFAULT_POLICY["output"])
if "safe_output_default" in output_cfg:
output_norm["safe_output_default"] = bool(output_cfg["safe_output_default"])
redact_fields = output_cfg.get("redact_fields")
if isinstance(redact_fields, list):
output_norm["redact_fields"] = [str(v) for v in redact_fields]
policy["output"] = output_norm
approvals_cfg = policy.get("approvals")
if not isinstance(approvals_cfg, dict):
if strict and "approvals" in data:
raise ValueError("approvals must be an object")
approvals_cfg = {}
raw_require_for = approvals_cfg.get(
"require_for", DEFAULT_POLICY["approvals"]["require_for"]
)
if not isinstance(raw_require_for, list):
if strict:
raise ValueError("approvals.require_for must be a list")
raw_require_for = DEFAULT_POLICY["approvals"]["require_for"]
require_for: list[str] = []
for value in raw_require_for:
action = str(value).strip().lower()
if action in VALID_APPROVAL_ACTIONS and action not in require_for:
require_for.append(action)
elif strict:
raise ValueError(f"invalid approval action: {value}")
policy["approvals"] = {"require_for": require_for}
isolation_cfg = policy.get("secret_isolation")
if not isinstance(isolation_cfg, dict):
if strict and "secret_isolation" in data:
raise ValueError("secret_isolation must be an object")
isolation_cfg = {}
raw_high_risk = isolation_cfg.get(
"high_risk_kinds", DEFAULT_POLICY["secret_isolation"]["high_risk_kinds"]
)
if not isinstance(raw_high_risk, list):
if strict:
raise ValueError("secret_isolation.high_risk_kinds must be a list")
raw_high_risk = DEFAULT_POLICY["secret_isolation"]["high_risk_kinds"]
high_risk_kinds: list[str] = []
for value in raw_high_risk:
kind = str(value).strip().lower()
if kind in PRIVATE_KINDS and kind not in high_risk_kinds:
high_risk_kinds.append(kind)
elif strict:
raise ValueError(f"invalid secret isolation kind: {value}")
unlock_ttl_sec = int(
isolation_cfg.get(
"unlock_ttl_sec", DEFAULT_POLICY["secret_isolation"]["unlock_ttl_sec"]
)
)
if unlock_ttl_sec < 1:
if strict:
raise ValueError("secret_isolation.unlock_ttl_sec must be >= 1")
unlock_ttl_sec = int(DEFAULT_POLICY["secret_isolation"]["unlock_ttl_sec"])
policy["secret_isolation"] = {
"enabled": bool(isolation_cfg.get("enabled", True)),
"high_risk_kinds": high_risk_kinds,
"unlock_ttl_sec": unlock_ttl_sec,
}
export_cfg = policy.get("export")
if not isinstance(export_cfg, dict):
export_cfg = {}
policy["export"] = {
"allow_full_vault": bool(export_cfg.get("allow_full_vault", False))
}
# Keep compatibility fields in sync with rule model.
allow_kinds = sorted(
{
k
for r in normalized_rules
if r["effect"] == "allow" and "read" in r["operations"]
for k in r["kinds"]
}
)
deny_private = sorted(
{
k
for r in normalized_rules
if r["effect"] == "deny" and "read" in r["operations"]
for k in r["kinds"]
if k in PRIVATE_KINDS
}
)
policy["allow_kinds"] = allow_kinds
policy["deny_private_reveal"] = deny_private
policy["allow_export_import"] = bool(policy["export"]["allow_full_vault"])
if strict:
# Force full validation for future schema updates.
for rule in policy["rules"]:
_validate_rule(rule)
return policy
def _load_policy(*, strict: bool = False) -> dict[str, Any]:
path = _policy_path()
if not path.exists():
return dict(DEFAULT_POLICY)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
if strict:
raise ValueError("agent policy file is not valid JSON") from exc
return _deny_all_policy()
if not isinstance(data, dict):
if strict:
raise ValueError("agent policy file must be a JSON object")
return _deny_all_policy()
try:
return _normalize_policy(data, strict=strict)
except ValueError:
if strict:
raise
return _deny_all_policy()
def _save_policy(policy: dict[str, Any]) -> None:
path = _policy_path()
_ensure_dir(path)
normalized = _normalize_policy(policy, strict=True)
path.write_text(json.dumps(normalized, indent=2), encoding="utf-8")
os.chmod(path, 0o600)
def _load_policy_from_file(path: Path, *, strict: bool = True) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise ValueError("agent policy file is not valid JSON") from exc
if not isinstance(data, dict):
raise ValueError("agent policy file must be a JSON object")
return _normalize_policy(data, strict=strict)
def _policy_json_lines(policy: dict[str, Any]) -> list[str]:
return json.dumps(policy, indent=2, sort_keys=True).splitlines()
def _policy_full_hash(policy: dict[str, Any]) -> str:
canonical = json.dumps(
policy, sort_keys=True, separators=(",", ":"), ensure_ascii=True
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _policy_diff_summary(
current: dict[str, Any], candidate: dict[str, Any]
) -> dict[str, Any]:
current_rule_map = {str(r.get("id", "")): r for r in current.get("rules", [])}
candidate_rule_map = {str(r.get("id", "")): r for r in candidate.get("rules", [])}
current_ids = {k for k in current_rule_map if k}
candidate_ids = {k for k in candidate_rule_map if k}
added = sorted(candidate_ids - current_ids)
removed = sorted(current_ids - candidate_ids)
modified = sorted(
rid
for rid in (current_ids & candidate_ids)
if current_rule_map[rid] != candidate_rule_map[rid]
)
return {
"default_effect_changed": current.get("default_effect")
!= candidate.get("default_effect"),
"export_allow_full_vault_changed": bool(
current.get("export", {}).get("allow_full_vault", False)
)
!= bool(candidate.get("export", {}).get("allow_full_vault", False)),
"safe_output_default_changed": bool(
current.get("output", {}).get("safe_output_default", True)
)
!= bool(candidate.get("output", {}).get("safe_output_default", True)),
"rules_added": added,
"rules_removed": removed,
"rules_modified": modified,
}
def _agent_password(
*,
broker: str,
password_env: str,
broker_service: str,
broker_account: str,
broker_command: str | None,
) -> str:
try:
return resolve_broker_password(
broker=broker,
password_env=password_env,
broker_service=broker_service,
broker_account=broker_account,
broker_command=broker_command,
)
except AuthBrokerError as exc:
raise typer.BadParameter(str(exc)) from exc
def _seed_from_inputs(
seed: Optional[str],
seed_file: Optional[Path],
generate_seed: bool,
) -> tuple[str, bool]:
choices = int(bool(seed)) + int(bool(seed_file)) + int(bool(generate_seed))
if choices != 1:
raise typer.BadParameter(
"Provide exactly one of --seed, --seed-file, or --generate-seed."
)
if seed_file:
seed = seed_file.read_text(encoding="utf-8").strip()
elif generate_seed:
seed = (
Bip39MnemonicGenerator(Bip39Languages.ENGLISH)
.FromWordsNumber(Bip39WordsNum.WORDS_NUM_12)
.ToStr()
)
assert seed is not None
if not Mnemonic("english").check(seed):
raise typer.BadParameter("Invalid BIP-39 seed phrase.")
return seed, bool(generate_seed)
def _load_token_store() -> dict[str, Any]:
path = _token_store_path()
if not path.exists():
return {"version": TOKEN_STORE_VERSION, "tokens": []}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"version": TOKEN_STORE_VERSION, "tokens": []}
if not isinstance(data, dict) or not isinstance(data.get("tokens"), list):
return {"version": TOKEN_STORE_VERSION, "tokens": []}
return data
def _save_token_store(store: dict[str, Any]) -> None:
path = _token_store_path()
_ensure_dir(path)
path.write_text(json.dumps(store, indent=2), encoding="utf-8")
os.chmod(path, 0o600)
def _hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def _issue_token_record(
name: str,
ttl: int,
scopes: list[str],
kinds: list[str],
label_regex: str,
uses: int,
identity_id: str,
) -> tuple[str, dict[str, Any]]:
raw = secrets.token_urlsafe(32)
now = _utcnow()
token_id = hashlib.blake2s(raw.encode("utf-8"), digest_size=8).hexdigest()
record = {
"id": token_id,
"name": name,
"token_hash": _hash_token(raw),
"created_at_utc": now.isoformat(),
"expires_at_utc": (now + timedelta(seconds=ttl)).isoformat(),
"revoked_at_utc": None,
"scopes": scopes,
"kinds": kinds,
"label_regex": label_regex,
"uses_remaining": int(uses),
"identity_id": str(identity_id),
}
return raw, record
def _parse_iso8601(value: str) -> datetime:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _validate_token(
raw_token: str,
*,
operation: str,
kind: str,
label: str,
consume_use: bool,
) -> tuple[bool, str, dict[str, Any] | None]:
store = _load_token_store()
digest = _hash_token(raw_token)
for rec in store.get("tokens", []):
if rec.get("token_hash") != digest:
continue
if rec.get("revoked_at_utc"):
return False, "token_revoked", None
exp = rec.get("expires_at_utc")
if not exp or _utcnow() >= _parse_iso8601(exp):
return False, "token_expired", None
scopes = [str(s) for s in rec.get("scopes", [])]
if operation not in scopes:
return False, "token_scope_denied", None
kinds = [str(k) for k in rec.get("kinds", [])]
if kinds and kind not in kinds:
return False, "token_kind_denied", None
label_regex = str(rec.get("label_regex", ".*"))
try:
if not re.search(label_regex, label):
return False, "token_label_denied", None
except re.error:
return False, "token_invalid_regex", None
uses_remaining = int(rec.get("uses_remaining", 0))
if uses_remaining <= 0:
return False, "token_exhausted", None
identity_id = str(rec.get("identity_id", "")).strip()
if not identity_id:
return False, "token_identity_missing", None
if not identity_active(identity_id):
return False, "token_identity_revoked", None
if consume_use:
rec["uses_remaining"] = uses_remaining - 1
_save_token_store(store)
return True, "ok", rec
return False, "token_not_found", None
def _load_audit_key() -> bytes:
path = _audit_key_path()
if path.exists():
return path.read_bytes()
_ensure_dir(path)
key = secrets.token_bytes(32)
path.write_bytes(key)
os.chmod(path, 0o600)
return key
def _append_audit_event(event: str, details: dict[str, Any]) -> None:
key = _load_audit_key()
path = _audit_log_path()
_ensure_dir(path)
prev_sig = "0" * 64
if path.exists():
try:
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln]
if lines:
prev = json.loads(lines[-1])
prev_sig = str(prev.get("sig", prev_sig))
except Exception:
prev_sig = "0" * 64
payload = {
"timestamp_utc": _utcnow().isoformat(),
"host": socket.gethostname(),
"event": event,
"details": details,
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
sig = hmac.new(key, f"{prev_sig}{canonical}".encode("utf-8"), hashlib.sha256)
payload["sig"] = sig.hexdigest()
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, sort_keys=True) + "\n")
os.chmod(path, 0o600)
def _policy_decision(
policy: dict[str, Any],
*,
operation: str,
kind: str,
label: str,
path: str,
field: str,
) -> tuple[bool, str]:
matched_allow = None
for rule in policy.get("rules", []):
operations = set(rule.get("operations", []))
kinds = set(rule.get("kinds", []))
fields = set(rule.get("fields", []))
if operation not in operations:
continue
if kinds and kind not in kinds:
continue
if fields and field not in fields:
continue
try:
if not re.search(str(rule.get("label_regex", ".*")), label):
continue
if not re.search(str(rule.get("path_regex", ".*")), path):
continue
except re.error:
continue
effect = str(rule.get("effect", "")).lower()
rid = str(rule.get("id", "rule"))
if effect == "deny":
return False, f"policy_deny:{rid}"
if effect == "allow":
matched_allow = rid
if matched_allow:
return True, f"policy_allow:{matched_allow}"
if policy.get("default_effect", "deny") == "allow":
return True, "policy_default_allow"
return False, "policy_default_deny"
def _build_entry_path(index: int, kind: str, label: str) -> str:
return f"entry/{index}/{kind}/{label}"
def _high_risk_lock_required(policy: dict[str, Any], kind: str) -> bool:
isolation_cfg = policy.get("secret_isolation", {})
if not isinstance(isolation_cfg, dict):
return False
if not bool(isolation_cfg.get("enabled", True)):
return False
kinds = isolation_cfg.get("high_risk_kinds", [])
if not isinstance(kinds, list):
return False
return str(kind).lower() in {str(v).lower() for v in kinds}
def _hydrate_partition_entry_if_needed(
pm: PasswordManager,
*,
fingerprint: str,
index: int,
entry: dict[str, Any],
) -> dict[str, Any]:
if str(entry.get("partition", "")).strip().lower() != "high_risk":
return entry
tag = unlocked_partition_key_tag(fingerprint=str(fingerprint))
if not tag:
raise ValueError("policy_deny:high_risk_locked")
loaded = load_partition_entry(Path(pm.fingerprint_dir), tag, int(index))
if not isinstance(loaded, dict):
raise ValueError("high_risk_partition_entry_missing")
return loaded
def _token_posture_summary() -> dict[str, int]:
store = _load_token_store()
now = _utcnow()
total = 0
active = 0
revoked = 0
expired = 0
exhausted = 0
for rec in store.get("tokens", []):
total += 1
if rec.get("revoked_at_utc"):
revoked += 1
continue
exp = rec.get("expires_at_utc")
if exp:
try:
if _parse_iso8601(exp) <= now:
expired += 1
continue
except Exception:
expired += 1
continue
if int(rec.get("uses_remaining", 0)) <= 0:
exhausted += 1
continue
active += 1
return {
"total": total,
"active": active,
"revoked": revoked,
"expired": expired,
"exhausted": exhausted,
}
def _severity_rank(severity: str) -> int:
order = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
return order.get(severity, 0)
def _posture_findings(
policy: dict[str, Any], policy_status: str
) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
if policy_status != "ok":
findings.append(
{
"id": "policy_invalid",
"severity": "critical",
"message": "Agent policy JSON is invalid; default deny fallback may block operations.",
"remediation": "Run `seedpass agent policy-lint` and fix policy syntax/schema.",
}
)
if str(policy.get("default_effect", "deny")).lower() == "allow":
findings.append(
{
"id": "policy_default_allow",
"severity": "high",
"message": "Policy default effect is allow; unmatched operations are permitted.",
"remediation": "Set `default_effect` to `deny` and add explicit allow rules.",
}
)
if len(policy.get("rules", [])) == 0:
findings.append(
{
"id": "policy_no_rules",
"severity": "medium",
"message": "Policy has no rules; behavior depends entirely on default effect.",
"remediation": "Define explicit allow/deny rules for read/create/update/export.",
}
)
require_for = {
str(v).strip().lower()
for v in policy.get("approvals", {}).get("require_for", [])
if str(v).strip()
}
required_approvals = {"export", "reveal_parent_seed", "private_key_retrieval"}
missing_approvals = sorted(list(required_approvals - require_for))
if missing_approvals:
findings.append(
{
"id": "approvals_missing_required_actions",
"severity": "high",
"message": f"Approval gate policy is missing required actions: {', '.join(missing_approvals)}.",
"remediation": "Require approvals for export, reveal_parent_seed, and private_key_retrieval.",
}
)
if bool(policy.get("allow_export_import", False)):
findings.append(
{
"id": "export_import_allowed",
"severity": "high",
"message": "Agent export/import is enabled.",
"remediation": "Disable export/import for agent profiles unless explicitly required.",
}
)
if not bool(policy.get("output", {}).get("safe_output_default", True)):
findings.append(
{
"id": "safe_output_disabled",
"severity": "high",
"message": "Safe output default is disabled; secrets may leak into logs/stdout.",
"remediation": "Set output.safe_output_default to true.",
}
)
isolation_cfg = policy.get("secret_isolation", {})
unlock_ttl_sec = int(isolation_cfg.get("unlock_ttl_sec", 300) or 300)
if bool(isolation_cfg.get("enabled", True)) and unlock_ttl_sec > 1800:
findings.append(
{
"id": "high_risk_unlock_ttl_too_long",
"severity": "medium",
"message": f"High-risk unlock TTL is {unlock_ttl_sec}s (>1800s).",
"remediation": "Reduce secret_isolation.unlock_ttl_sec to 1800 seconds or lower.",
}
)
allows_private_read = False
for rule in policy.get("rules", []):
if str(rule.get("effect", "")).lower() != "allow":
continue
ops = {str(v).lower() for v in rule.get("operations", [])}
if "read" not in ops:
continue
kinds = {str(v).lower() for v in rule.get("kinds", [])}
if kinds.intersection(PRIVATE_KINDS):
allows_private_read = True
fields = {str(v).lower() for v in rule.get("fields", [])}
label_regex = str(rule.get("label_regex", ".*"))
path_regex = str(rule.get("path_regex", ".*"))
if (
"read" in ops
and "secret" in fields
and (not kinds or len(kinds) >= len(ALL_ENTRY_TYPES))
and label_regex in {".*", "^.*$"}
and path_regex in {"^entry/.*$", ".*", "^.*$"}
):
findings.append(
{
"id": "over_permissive_read_rule",
"severity": "high",
"message": f"Policy allow rule '{rule.get('id', 'rule')}' broadly permits secret reads.",
"remediation": "Scope read rules by kind, label_regex, and field exposure.",
}
)
if not bool(isolation_cfg.get("enabled", True)):
findings.append(
{
"id": "secret_isolation_disabled",
"severity": "high",
"message": "Secret class isolation is disabled for high-risk kinds.",
"remediation": "Enable secret_isolation and require separate high-risk unlock.",
}
)
elif allows_private_read and not high_risk_factor_configured():
findings.append(
{
"id": "high_risk_factor_not_configured",
"severity": "medium",
"message": "High-risk unlock factor is not configured.",
"remediation": "Run `seedpass agent high-risk-factor-set` before enabling autonomous private-key retrieval.",
}
)
elif allows_private_read and "private_key_retrieval" not in require_for:
findings.append(
{
"id": "private_read_without_approval_gate",
"severity": "high",
"message": "Policy allows private-kind reads without private_key_retrieval approval gate.",
"remediation": "Add private_key_retrieval to approvals.require_for.",
}
)
store = _load_token_store()
now = _utcnow()
long_lived = 0
broad_scope = 0
missing_identity = 0
revoked_identity_tokens = 0
overdue_rotation = 0
active_identities = list_identities(include_revoked=False)
identities_by_id = {str(rec.get("id", "")): rec for rec in active_identities}
high_rotation_days = [
str(rec.get("id", ""))
for rec in active_identities
if int(rec.get("rotation_days", 30) or 30) > 90
]
if high_rotation_days:
findings.append(
{
"id": "identity_rotation_window_too_long",
"severity": "medium",
"message": f"{len(high_rotation_days)} identity profile(s) have rotation_days > 90.",
"remediation": "Set identity rotation_days to 90 or less for tighter credential rotation.",
}
)
for rec in store.get("tokens", []):
if rec.get("revoked_at_utc"):
continue
exp = rec.get("expires_at_utc")
created = rec.get("created_at_utc")
try:
if exp and created:
ttl = (_parse_iso8601(exp) - _parse_iso8601(created)).total_seconds()
if ttl > 86400:
long_lived += 1
except Exception:
pass
scopes = [str(v) for v in rec.get("scopes", [])]
if "export" in scopes or "reveal_parent_seed" in scopes:
broad_scope += 1
elif not scopes:
broad_scope += 1
identity_id = str(rec.get("identity_id", "")).strip()
if not identity_id:
missing_identity += 1
elif not identity_active(identity_id):
revoked_identity_tokens += 1
else:
identity_rec = identities_by_id.get(identity_id)
if identity_rec and created:
try:
rotation_days = int(identity_rec.get("rotation_days", 30) or 30)
age_days = (
_utcnow() - _parse_iso8601(created)
).total_seconds() / 86400
if age_days > rotation_days:
overdue_rotation += 1
except Exception:
pass
if exp:
try:
if _parse_iso8601(exp) <= now:
findings.append(
{
"id": "expired_tokens_present",
"severity": "low",
"message": "Expired tokens are present in token store.",
"remediation": "Periodically prune expired token records.",
}
)
break
except Exception:
pass
if long_lived:
findings.append(
{
"id": "long_lived_tokens",
"severity": "medium",
"message": f"{long_lived} active token(s) exceed 24h TTL.",
"remediation": "Reduce token TTLs and rotate often.",
}
)
if broad_scope:
findings.append(
{
"id": "overbroad_token_scopes",
"severity": "high",
"message": f"{broad_scope} token(s) include broad or risky scopes.",
"remediation": "Issue least-privilege tokens (prefer read-only, narrow kinds/labels).",
}
)
if missing_identity:
findings.append(
{
"id": "tokens_missing_identity",
"severity": "medium",
"message": f"{missing_identity} token(s) are not bound to an agent identity.",
"remediation": "Reissue tokens with --identity-id and revoke legacy unbound tokens.",
}
)
if revoked_identity_tokens:
findings.append(
{
"id": "tokens_for_revoked_identity",
"severity": "high",
"message": f"{revoked_identity_tokens} token(s) reference revoked or missing identities.",
"remediation": "Revoke affected tokens and rotate new tokens under active identities.",
}
)
if overdue_rotation:
findings.append(
{
"id": "token_rotation_overdue",
"severity": "medium",
"message": f"{overdue_rotation} active token(s) exceed identity rotation policy window.",
"remediation": "Rotate overdue tokens and reissue under current identity policy.",
}
)
return findings
def _runtime_config_findings(pm: PasswordManager) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
cfg_mgr = getattr(pm, "config_manager", None)
if cfg_mgr is None:
return findings
try:
if bool(cfg_mgr.get_quick_unlock()):
findings.append(
{
"id": "quick_unlock_enabled",
"severity": "high",
"message": "Quick unlock is enabled for the profile.",
"remediation": "Disable quick unlock for unattended or shared environments.",
}
)
except Exception:
pass
try:
kdf_mode = str(cfg_mgr.get_kdf_mode()).lower()
except Exception:
kdf_mode = "pbkdf2"
try:
kdf_iterations = int(cfg_mgr.get_kdf_iterations())
except Exception:
kdf_iterations = 0
if kdf_mode == "pbkdf2" and kdf_iterations < int(
getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
):
findings.append(
{
"id": "weak_kdf_iterations",
"severity": "high",
"message": f"PBKDF2 iterations are set to {kdf_iterations}, below policy floor.",
"remediation": "Increase kdf_iterations to at least the default floor.",
}
)
try:
parts = cfg_mgr.get_secret_class_partitions()
high_risk = parts.get("high_risk", {}) if isinstance(parts, dict) else {}
if bool(high_risk.get("separate_factor_required", True)) and bool(
high_risk.get("unlocked", False)
):
findings.append(
{
"id": "high_risk_partition_persistently_unlocked",
"severity": "high",
"message": "High-risk partition is marked unlocked in profile config.",
"remediation": "Lock high-risk partition by default and require step-up unlock per session.",
}
)
except Exception:
pass
return findings
def _remediation_actions(findings: list[dict[str, Any]]) -> list[dict[str, Any]]:
guidance: dict[str, dict[str, Any]] = {
"policy_invalid": {
"title": "Fix invalid policy file",
"commands": [
"seedpass agent policy-lint",
"seedpass agent policy-review --file <candidate.json>",
"seedpass agent policy-apply --file <candidate.json>",
],
},
"policy_default_allow": {
"title": "Set secure policy default",
"commands": ["seedpass agent policy-set --safe-output-default"],
"notes": ["Set `default_effect` to `deny` and add explicit allow rules."],
},
"approvals_missing_required_actions": {
"title": "Enforce approval gates for risky actions",
"commands": ["seedpass agent policy-review --file <candidate.json>"],
"notes": [
"Ensure approvals.require_for includes export, reveal_parent_seed, and private_key_retrieval."
],
},
"safe_output_disabled": {
"title": "Enable safe output redaction",
"commands": ["seedpass agent policy-set --safe-output-default"],
},
"export_import_allowed": {
"title": "Disable broad export/import for agent profiles",
"commands": ["seedpass agent policy-review --file <candidate.json>"],
"notes": [
"Set export.allow_full_vault=false and keep allow_export_import=false."
],
},
"secret_isolation_disabled": {
"title": "Enable high-risk secret isolation",
"commands": [
"seedpass agent high-risk-factor-set --factor-env SEEDPASS_HIGH_RISK_FACTOR",
"seedpass agent policy-review --file <candidate.json>",
],
},
"high_risk_factor_not_configured": {
"title": "Configure high-risk unlock factor",
"commands": [
"seedpass agent high-risk-factor-set --factor-env SEEDPASS_HIGH_RISK_FACTOR"
],
},
"high_risk_unlock_ttl_too_long": {
"title": "Reduce high-risk unlock TTL",
"commands": ["seedpass agent policy-review --file <candidate.json>"],
"notes": ["Set secret_isolation.unlock_ttl_sec <= 1800."],
},
"over_permissive_read_rule": {
"title": "Narrow broad secret-read policy rules",
"commands": ["seedpass agent policy-review --file <candidate.json>"],
},
"private_read_without_approval_gate": {
"title": "Require approval for private key retrieval",
"commands": ["seedpass agent policy-review --file <candidate.json>"],
},
"quick_unlock_enabled": {
"title": "Disable quick unlock in runtime config",
"commands": ["seedpass config set quick_unlock false"],
},
"weak_kdf_iterations": {
"title": "Increase KDF iteration strength",
"commands": ["seedpass config set kdf_iterations 200000"],
},
"high_risk_partition_persistently_unlocked": {
"title": "Lock high-risk partition by default",
"commands": ["seedpass --fingerprint <fp> agent high-risk-lock"],
},
"long_lived_tokens": {
"title": "Shorten token TTL and rotate",
"commands": [
"seedpass agent token-list",
"seedpass agent token-revoke <token_id>",
"seedpass agent token-issue --ttl 3600 --uses 1",
],
},
"overbroad_token_scopes": {
"title": "Reduce token scopes",
"commands": [
"seedpass agent token-revoke <token_id>",
"seedpass agent token-issue --scope read --kinds password --uses 1",
],
},
"tokens_missing_identity": {
"title": "Bind tokens to active identities",
"commands": [
"seedpass agent identity-create --id <identity> --owner <team>",
"seedpass agent token-issue --identity-id <identity>",
],
},
"tokens_for_revoked_identity": {
"title": "Rotate tokens from revoked identities",
"commands": [
"seedpass agent token-revoke <token_id>",
"seedpass agent token-issue --identity-id <active_identity>",
],
},
"identity_rotation_window_too_long": {
"title": "Tighten identity rotation policy",
"commands": ["seedpass agent identity-create --id <id> --rotation-days 30"],
"notes": ["Recreate identity metadata with a shorter rotation window."],
},
"token_rotation_overdue": {
"title": "Rotate overdue active tokens",
"commands": [
"seedpass agent token-list",
"seedpass agent token-revoke <token_id>",
"seedpass agent token-issue --identity-id <identity>",
],
},
}
actions: list[dict[str, Any]] = []
seen: set[str] = set()
for finding in findings:
fid = str(finding.get("id", ""))
if not fid or fid in seen:
continue
seen.add(fid)
base = guidance.get(fid)
if not base:
continue
action = {
"finding_id": fid,
"title": base["title"],
"commands": base["commands"],
}
if "notes" in base:
action["notes"] = base["notes"]
actions.append(action)
return actions
@app.command("init")
def agent_init(
seed: Optional[str] = typer.Option(
None, "--seed", help="BIP-39 seed phrase (avoid shell history for secrets)"
),
seed_file: Optional[Path] = typer.Option(
None, "--seed-file", help="Path to a file containing the seed phrase"
),
generate_seed: bool = typer.Option(
False, "--generate-seed", help="Generate a fresh 12-word seed"
),
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: str = typer.Option(
"default",
"--broker-account",
help="Keyring account (or logical broker identity)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
switch_existing: bool = typer.Option(
False,
"--switch-existing",
help="Switch to existing profile if the seed fingerprint already exists",
),
kdf_iterations: int = typer.Option(
200_000, "--kdf-iterations", help="PBKDF2 iterations for profile setup"
),
print_seed: bool = typer.Option(
False, "--print-seed", help="Include generated seed in JSON output"
),
) -> None:
"""Initialize a profile non-interactively for agent workflows."""
initialize_app()
password = _agent_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=broker_account,
broker_command=broker_command,
)
seed_phrase, generated = _seed_from_inputs(seed, seed_file, generate_seed)
fp_mgr = FingerprintManager(APP_DIR)
fingerprint = None
created = False
try:
fingerprint = fp_mgr.add_fingerprint(seed_phrase)
created = True
except ValueError:
fingerprint = generate_fingerprint(seed_phrase)
if not switch_existing:
raise typer.BadParameter(
"Seed profile already exists. Use --switch-existing to reuse it."
)
assert fingerprint is not None
profile_dir = fp_mgr.get_fingerprint_directory(fingerprint)
if profile_dir is None:
raise typer.BadParameter("Could not resolve profile directory for fingerprint.")
profile_dir.mkdir(parents=True, exist_ok=True)
seed_key = derive_key_from_password(
password, fingerprint, iterations=kdf_iterations
)
seed_mgr = EncryptionManager(seed_key, profile_dir)
seed_mgr.encrypt_parent_seed(seed_phrase)
index_key = derive_index_key(seed_phrase)
enc_mgr = EncryptionManager(index_key, profile_dir)
vault = Vault(enc_mgr, profile_dir)
cfg = ConfigManager(vault=vault, fingerprint_dir=profile_dir)
cfg.set_password_hash(bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode())
cfg.set_kdf_iterations(kdf_iterations)
cfg.set_offline_mode(True)
cfg.set_secret_mode_enabled(False)
cfg.set_quick_unlock(False)
if not _policy_path().exists():
_save_policy(dict(DEFAULT_POLICY))
payload = {
"status": "ok",
"created": created,
"fingerprint": fingerprint,
"profile_dir": str(profile_dir),
"generated_seed": seed_phrase if generated and print_seed else None,
"next_steps": [
"seedpass --fingerprint <fp> vault unlock --auth-broker keyring",
"seedpass capabilities --format json",
"seedpass --fingerprint <fp> agent bootstrap-context",
],
}
typer.echo(json.dumps(payload, indent=2))
@app.command("policy-show")
def agent_policy_show() -> None:
"""Show the active agent policy."""
try:
policy = _load_policy(strict=True)
except ValueError as exc:
raise typer.BadParameter(str(exc))
typer.echo(json.dumps(policy, indent=2))
@app.command("policy-lint")
def agent_policy_lint(
file: Optional[Path] = typer.Option(
None, "--file", help="Policy file to lint (defaults to active agent policy)"
),
format: str = typer.Option(
"json",
"--format",
help="Output format",
click_type=click.Choice(["json", "text"], case_sensitive=False),
),
) -> None:
"""Validate a policy file and emit deterministic normalized output."""
if file is None:
policy = _load_policy(strict=True)
path = _policy_path()
else:
policy = _load_policy_from_file(file, strict=True)
path = file
policy_stamp = compute_policy_stamp(policy)
policy_hash = _policy_full_hash(policy)
payload = {
"status": "ok",
"path": str(path),
"policy_stamp": policy_stamp,
"policy_hash": policy_hash,
"policy": policy,
}
if format.lower() == "text":
typer.echo("policy lint: ok")
typer.echo(f"path: {payload['path']}")
typer.echo(f"policy_stamp: {policy_stamp}")
typer.echo(f"policy_hash: {policy_hash}")
typer.echo(f"rules: {len(policy.get('rules', []))}")
return
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("policy-review")
def agent_policy_review(
file: Path = typer.Option(..., "--file", help="Candidate policy JSON file"),
format: str = typer.Option(
"json",
"--format",
help="Output format",
click_type=click.Choice(["json", "text"], case_sensitive=False),
),
) -> None:
"""Compare active policy to candidate policy for change review."""
current = _load_policy(strict=False)
candidate = _load_policy_from_file(file, strict=True)
current_lines = _policy_json_lines(current)
candidate_lines = _policy_json_lines(candidate)
diff_lines = list(
difflib.unified_diff(
current_lines,
candidate_lines,
fromfile="active_policy",
tofile=str(file),
lineterm="",
)
)
diff_summary = _policy_diff_summary(current, candidate)
findings = _posture_findings(candidate, "ok")
risky_findings = [
f for f in findings if _severity_rank(f.get("severity", "info")) >= 3
]
payload = {
"status": "ok",
"changed": current != candidate,
"candidate_path": str(file),
"current_policy_stamp": compute_policy_stamp(current),
"candidate_policy_stamp": compute_policy_stamp(candidate),
"current_policy_hash": _policy_full_hash(current),
"candidate_policy_hash": _policy_full_hash(candidate),
"diff_summary": diff_summary,
"risky_finding_count": len(risky_findings),
"risky_findings": risky_findings,
"diff": diff_lines,
}
if format.lower() == "text":
typer.echo(f"policy changed: {payload['changed']}")
typer.echo(f"candidate: {payload['candidate_path']}")
typer.echo(f"current stamp: {payload['current_policy_stamp']}")
typer.echo(f"candidate stamp: {payload['candidate_policy_stamp']}")
typer.echo(f"risky findings: {payload['risky_finding_count']}")
if diff_summary["rules_added"]:
typer.echo(f"rules added: {', '.join(diff_summary['rules_added'])}")
if diff_summary["rules_removed"]:
typer.echo(f"rules removed: {', '.join(diff_summary['rules_removed'])}")
if diff_summary["rules_modified"]:
typer.echo(f"rules modified: {', '.join(diff_summary['rules_modified'])}")
if diff_lines:
typer.echo("diff:")
for line in diff_lines:
typer.echo(line)
return
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("policy-apply")
def agent_policy_apply(
file: Path = typer.Option(..., "--file", help="Candidate policy JSON file"),
dry_run: bool = typer.Option(
False, "--dry-run", help="Validate and review candidate without saving"
),
allow_risky: bool = typer.Option(
False,
"--allow-risky",
help="Allow apply when review detects high-risk findings",
),
) -> None:
"""Apply a reviewed policy file with optional risk gate."""
current = _load_policy(strict=False)
candidate = _load_policy_from_file(file, strict=True)
findings = _posture_findings(candidate, "ok")
risky_findings = [
f for f in findings if _severity_rank(f.get("severity", "info")) >= 3
]
changed = current != candidate
payload = {
"status": "ok",
"changed": changed,
"dry_run": bool(dry_run),
"candidate_path": str(file),
"candidate_policy_stamp": compute_policy_stamp(candidate),
"candidate_policy_hash": _policy_full_hash(candidate),
"risky_finding_count": len(risky_findings),
"risky_findings": risky_findings,
}
if risky_findings and not allow_risky:
payload["status"] = "denied"
payload["reason"] = "risky_policy_change_requires_allow_risky"
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
if not dry_run and changed:
_save_policy(candidate)
payload["applied"] = True
_append_audit_event(
"agent_policy_applied",
{
"candidate_path": str(file),
"policy_stamp": payload["candidate_policy_stamp"],
"policy_hash": payload["candidate_policy_hash"],
"risky_finding_count": payload["risky_finding_count"],
},
)
else:
payload["applied"] = False
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("policy-set")
def agent_policy_set(
allow_kind: list[str] = typer.Option(
None,
"--allow-kind",
help="Allowed entry kind for agent retrieval (repeatable)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
deny_private_kind: list[str] = typer.Option(
None,
"--deny-private-kind",
help="Kind denied from agent reveal (repeatable)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
allow_export_import: bool = typer.Option(
False,
"--allow-export-import",
help="Allow export/import operations in agent mode",
),
safe_output_default: bool = typer.Option(
True,
"--safe-output-default/--unsafe-output-default",
help="Mask sensitive fields in output by default",
),
) -> None:
"""Set policy flags used by agent commands (legacy-compatible helper)."""
policy = _load_policy(strict=False)
if allow_kind:
policy["allow_kinds"] = list(allow_kind)
if deny_private_kind:
policy["deny_private_reveal"] = list(deny_private_kind)
policy["allow_export_import"] = bool(allow_export_import)
policy = _normalize_legacy_policy(policy)
policy["output"]["safe_output_default"] = bool(safe_output_default)
_save_policy(policy)
typer.echo(json.dumps(policy, indent=2))
@app.command("identity-create")
def agent_identity_create(
identity_id: str = typer.Option(..., "--id", help="Stable agent identity id"),
owner: str = typer.Option("unowned", "--owner", help="Owner/team for the identity"),
policy_binding: str = typer.Option(
"default", "--policy-binding", help="Named policy binding tag"
),
rotation_days: int = typer.Option(
30, "--rotation-days", min=1, help="Target token rotation interval in days"
),
) -> None:
"""Create a first-class agent identity."""
try:
rec = create_identity(
identity_id=identity_id,
owner=owner,
policy_binding=policy_binding,
rotation_days=rotation_days,
)
except ValueError as exc:
reason = str(exc)
payload = {"status": "error", "reason": reason, "id": identity_id}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
_append_audit_event(
"agent_identity_created",
{
"id": rec.get("id"),
"owner": rec.get("owner"),
"policy_binding": rec.get("policy_binding"),
"rotation_days": rec.get("rotation_days"),
},
)
typer.echo(json.dumps({"status": "ok", "identity": rec}, indent=2, sort_keys=True))
@app.command("identity-list")
def agent_identity_list(
show_revoked: bool = typer.Option(False, "--show-revoked")
) -> None:
"""List registered agent identities."""
typer.echo(
json.dumps(
{
"status": "ok",
"identities": list_identities(include_revoked=show_revoked),
},
indent=2,
sort_keys=True,
)
)
@app.command("identity-revoke")
def agent_identity_revoke(
identity_id: str = typer.Argument(..., help="Identity id")
) -> None:
"""Revoke an agent identity."""
if not revoke_identity(identity_id):
payload = {"status": "error", "reason": "identity_not_found", "id": identity_id}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
_append_audit_event("agent_identity_revoked", {"id": identity_id})
typer.echo(json.dumps({"status": "ok", "id": identity_id}, indent=2))
@app.command("high-risk-factor-set")
def agent_high_risk_factor_set(
factor_env: str = typer.Option(
"SEEDPASS_HIGH_RISK_FACTOR",
"--factor-env",
help="Env var containing high-risk unlock factor when --auth-broker=env",
),
auth_broker: str = typer.Option(
"env",
"--auth-broker",
help="Factor source (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: str = typer.Option(
"high-risk-factor",
"--broker-account",
help="Keyring account identity for factor retrieval",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints factor to stdout for --auth-broker=command",
),
) -> None:
"""Configure the separate high-risk unlock factor."""
factor = _agent_password(
broker=auth_broker,
password_env=factor_env,
broker_service=broker_service,
broker_account=broker_account,
broker_command=broker_command,
)
if not factor:
raise typer.BadParameter("High-risk factor cannot be empty.")
set_high_risk_factor(factor)
_append_audit_event("agent_high_risk_factor_set", {"configured": True})
typer.echo(json.dumps({"status": "ok", "configured": True}, indent=2))
@app.command("high-risk-unlock")
def agent_high_risk_unlock(
ctx: typer.Context,
ttl: Optional[int] = typer.Option(
None,
"--ttl",
min=1,
help="Unlock TTL in seconds (defaults to policy secret_isolation.unlock_ttl_sec)",
),
factor_env: str = typer.Option(
"SEEDPASS_HIGH_RISK_FACTOR",
"--factor-env",
help="Env var containing high-risk unlock factor when --auth-broker=env",
),
auth_broker: str = typer.Option(
"env",
"--auth-broker",
help="Factor source (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to <fingerprint>:high-risk)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints factor to stdout for --auth-broker=command",
),
) -> None:
"""Unlock access to high-risk secret classes for the active fingerprint."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
if not high_risk_factor_configured():
payload = {"status": "error", "reason": "high_risk_factor_not_configured"}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
factor = _agent_password(
broker=auth_broker,
password_env=factor_env,
broker_service=broker_service,
broker_account=broker_account or f"{fingerprint}:high-risk",
broker_command=broker_command,
)
if not verify_high_risk_factor(factor):
payload = {"status": "denied", "reason": "high_risk_factor_invalid"}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
try:
key_tag = partition_key_tag_for_factor(factor)
except ValueError as exc:
payload = {"status": "denied", "reason": str(exc)}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
policy = _load_policy(strict=False)
isolation_cfg = policy.get("secret_isolation", {})
default_ttl = int(isolation_cfg.get("unlock_ttl_sec", 300))
session = grant_high_risk_unlock(
fingerprint=str(fingerprint),
ttl_seconds=int(ttl or default_ttl),
partition_key_tag=key_tag,
)
_append_audit_event(
"agent_high_risk_unlocked",
{
"fingerprint": fingerprint,
"expires_at_utc": session.get("expires_at_utc"),
"ttl_seconds": int(ttl or default_ttl),
},
)
typer.echo(
json.dumps(
{
"status": "ok",
"fingerprint": fingerprint,
"expires_at_utc": session.get("expires_at_utc"),
},
indent=2,
sort_keys=True,
)
)
@app.command("high-risk-lock")
def agent_high_risk_lock(ctx: typer.Context) -> None:
"""Revoke active high-risk unlock session for the active fingerprint."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
changed = revoke_high_risk_unlock(fingerprint=str(fingerprint))
_append_audit_event(
"agent_high_risk_locked",
{"fingerprint": fingerprint, "changed": bool(changed)},
)
typer.echo(
json.dumps(
{"status": "ok", "fingerprint": fingerprint, "locked": bool(changed)},
indent=2,
sort_keys=True,
)
)
@app.command("high-risk-status")
def agent_high_risk_status(ctx: typer.Context) -> None:
"""Show high-risk unlock session status for the active fingerprint."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
unlocked, expires_at = high_risk_unlocked(fingerprint=str(fingerprint))
payload = {
"status": "ok",
"fingerprint": fingerprint,
"factor_configured": high_risk_factor_configured(),
"unlocked": bool(unlocked),
"expires_at_utc": expires_at or None,
"partition_key_tag_present": bool(
unlocked_partition_key_tag(fingerprint=str(fingerprint))
),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("high-risk-partition-migrate")
def agent_high_risk_partition_migrate(
ctx: typer.Context,
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
auth_broker: str = typer.Option(
"env",
"--auth-broker",
help="Password source (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to fingerprint)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
factor_env: str = typer.Option(
"SEEDPASS_HIGH_RISK_FACTOR",
"--factor-env",
help="Env var containing high-risk factor",
),
factor_broker: str = typer.Option(
"env",
"--factor-broker",
help="Factor source (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
factor_broker_service: str = typer.Option(
"seedpass",
"--factor-broker-service",
help="Keyring service name for high-risk factor broker",
),
factor_broker_account: Optional[str] = typer.Option(
None,
"--factor-broker-account",
help="Keyring account for factor (defaults to <fingerprint>:high-risk)",
),
factor_broker_command: Optional[str] = typer.Option(
None,
"--factor-broker-command",
help="Command that prints high-risk factor to stdout",
),
) -> None:
"""Move high-risk entries from primary index into encrypted high-risk partition."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
password = _agent_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=broker_account or str(fingerprint),
broker_command=broker_command,
)
factor = _agent_password(
broker=factor_broker,
password_env=factor_env,
broker_service=factor_broker_service,
broker_account=factor_broker_account or f"{fingerprint}:high-risk",
broker_command=factor_broker_command,
)
if not verify_high_risk_factor(factor):
payload = {"status": "denied", "reason": "high_risk_factor_invalid"}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
try:
key_tag = partition_key_tag_for_factor(factor)
except ValueError as exc:
payload = {"status": "denied", "reason": str(exc)}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
pm = PasswordManager(fingerprint=fingerprint, password=password)
policy = _load_policy(strict=False)
kinds = {
str(v).strip().lower()
for v in policy.get("secret_isolation", {}).get(
"high_risk_kinds", PRIVATE_KINDS
)
if str(v).strip().lower()
}
result = migrate_high_risk_entries(
vault=pm.vault,
fingerprint_dir=Path(pm.fingerprint_dir),
partition_key_tag=key_tag,
high_risk_kinds=kinds,
)
_append_audit_event(
"agent_high_risk_partition_migrated",
{
"fingerprint": fingerprint,
"moved_count": int(result.get("moved_count", 0)),
"partition_file": result.get("partition_file"),
},
)
typer.echo(json.dumps({"status": "ok", **result}, indent=2, sort_keys=True))
@app.command("token-issue")
def agent_token_issue(
name: str = typer.Option("agent", "--name", help="Human-readable token name"),
scope: list[str] = typer.Option(
["read"],
"--scope",
help="Allowed operation scope (repeatable, e.g. read/export)",
),
kind: list[str] = typer.Option(
None,
"--kind",
help="Restrict token to entry kinds (repeatable)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
label_regex: str = typer.Option(
".*",
"--label-regex",
help="Regex constraint for entry labels",
),
ttl: int = typer.Option(
300,
"--ttl",
min=1,
help="Token lifetime in seconds",
),
uses: int = typer.Option(
1,
"--uses",
min=1,
help="Maximum number of successful uses",
),
identity_id: str = typer.Option(
DEFAULT_AGENT_IDENTITY,
"--identity-id",
help="Agent identity bound to this token",
),
identity_owner: str = typer.Option(
"system",
"--identity-owner",
help="Owner used when auto-creating identity",
),
) -> None:
"""Issue a short-lived scoped token for agent operations."""
try:
re.compile(label_regex)
except re.error as exc:
raise typer.BadParameter(f"Invalid --label-regex: {exc}") from exc
scopes = [str(s).strip().lower() for s in scope if str(s).strip()]
if not scopes:
raise typer.BadParameter("At least one --scope is required")
kinds = list(kind or [])
identity = ensure_identity(
identity_id,
owner=identity_owner,
policy_binding="default",
rotation_days=30,
)
if identity.get("revoked_at_utc"):
payload = {
"status": "error",
"reason": "identity_revoked",
"identity_id": identity_id,
}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
raw, record = _issue_token_record(
name,
ttl,
scopes,
kinds,
label_regex,
uses,
str(identity.get("id", identity_id)),
)
store = _load_token_store()
store.setdefault("version", TOKEN_STORE_VERSION)
store.setdefault("tokens", [])
store["tokens"].append(record)
_save_token_store(store)
_append_audit_event(
"agent_token_issued",
{
"id": record["id"],
"name": name,
"identity_id": record["identity_id"],
"scopes": scopes,
"kinds": kinds,
"expires_at_utc": record["expires_at_utc"],
"uses": uses,
},
)
typer.echo(
json.dumps(
{
"status": "ok",
"token": raw,
"token_id": record["id"],
"name": name,
"identity_id": record["identity_id"],
"scopes": scopes,
"kinds": kinds,
"label_regex": label_regex,
"expires_at_utc": record["expires_at_utc"],
"uses_remaining": uses,
},
indent=2,
)
)
@app.command("token-list")
def agent_token_list(
show_revoked: bool = typer.Option(False, "--show-revoked")
) -> None:
"""List issued tokens without exposing raw token material."""
store = _load_token_store()
tokens = []
for rec in store.get("tokens", []):
if not show_revoked and rec.get("revoked_at_utc"):
continue
tokens.append(
{
"id": rec.get("id"),
"name": rec.get("name"),
"created_at_utc": rec.get("created_at_utc"),
"expires_at_utc": rec.get("expires_at_utc"),
"revoked_at_utc": rec.get("revoked_at_utc"),
"scopes": rec.get("scopes", []),
"kinds": rec.get("kinds", []),
"label_regex": rec.get("label_regex", ".*"),
"uses_remaining": rec.get("uses_remaining", 0),
"identity_id": rec.get("identity_id"),
}
)
typer.echo(json.dumps({"status": "ok", "tokens": tokens}, indent=2))
@app.command("token-revoke")
def agent_token_revoke(
token_id: str = typer.Argument(..., help="Token id to revoke")
) -> None:
"""Revoke an issued token by id."""
store = _load_token_store()
now = _utcnow().isoformat()
changed = False
for rec in store.get("tokens", []):
if rec.get("id") == token_id and not rec.get("revoked_at_utc"):
rec["revoked_at_utc"] = now
changed = True
break
if not changed:
typer.echo(
json.dumps({"status": "error", "reason": "token_not_found"}, indent=2)
)
raise typer.Exit(1)
_save_token_store(store)
_append_audit_event("agent_token_revoked", {"id": token_id})
typer.echo(json.dumps({"status": "ok", "token_id": token_id}, indent=2))
@app.command("approval-issue")
def agent_approval_issue(
action: str = typer.Option(
"export",
"--action",
help="Action requiring approval",
click_type=click.Choice(
sorted(list(VALID_APPROVAL_ACTIONS)),
case_sensitive=False,
),
),
ttl: int = typer.Option(
300,
"--ttl",
min=1,
help="Approval lifetime in seconds",
),
uses: int = typer.Option(
1,
"--uses",
min=1,
help="Number of successful uses before invalidation",
),
resource: str = typer.Option(
"*",
"--resource",
help="Resource scope for approval (e.g. vault:full)",
),
issued_by: str = typer.Option(
"manual",
"--issued-by",
help="Issuer identity for audit metadata",
),
) -> None:
"""Issue a short-lived approval grant for risky actions."""
rec = issue_approval(
action=action.strip().lower(),
ttl_seconds=ttl,
uses=uses,
resource=resource,
issued_by=issued_by,
)
_append_audit_event(
"agent_approval_issued",
{
"id": rec.get("id"),
"action": rec.get("action"),
"resource": rec.get("resource"),
"expires_at_utc": rec.get("expires_at_utc"),
"uses_remaining": rec.get("uses_remaining"),
},
)
typer.echo(json.dumps({"status": "ok", "approval": rec}, indent=2, sort_keys=True))
@app.command("approval-list")
def agent_approval_list(
show_revoked: bool = typer.Option(False, "--show-revoked")
) -> None:
"""List approval grants without exposing secret material."""
approvals = list_approvals(include_revoked=show_revoked)
typer.echo(
json.dumps(
{"status": "ok", "approvals": approvals},
indent=2,
sort_keys=True,
)
)
@app.command("approval-revoke")
def agent_approval_revoke(
approval_id: str = typer.Argument(..., help="Approval id to revoke")
) -> None:
"""Revoke an approval grant by id."""
if not revoke_approval(approval_id):
typer.echo(
json.dumps({"status": "error", "reason": "approval_not_found"}, indent=2)
)
raise typer.Exit(1)
_append_audit_event("agent_approval_revoked", {"id": approval_id})
typer.echo(json.dumps({"status": "ok", "approval_id": approval_id}, indent=2))
@app.command("lease-consume")
def agent_lease_consume(
ctx: typer.Context,
lease_id: str = typer.Argument(..., help="Secret lease id"),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
auth_broker: str = typer.Option(
"env",
"--auth-broker",
help="Non-interactive password broker (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to lease fingerprint)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
reveal: bool = typer.Option(
False,
"--reveal",
help="Return plaintext secret instead of redacted output",
),
) -> None:
"""Consume one use from a secret lease and return the secret."""
requested_fp = (ctx.obj or {}).get("fingerprint")
ok, reason, lease = consume_lease(
lease_id=lease_id, fingerprint=str(requested_fp) if requested_fp else None
)
if not ok or lease is None:
payload = {"status": "denied", "reason": reason, "lease_id": lease_id}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
fingerprint = str(lease.get("fingerprint", ""))
if not fingerprint:
payload = {
"status": "error",
"reason": "lease_missing_fingerprint",
"lease_id": lease_id,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
if requested_fp and str(requested_fp) != fingerprint:
payload = {
"status": "denied",
"reason": "lease_fingerprint_mismatch",
"lease_id": lease_id,
"fingerprint": fingerprint,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
password = _agent_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=broker_account or fingerprint,
broker_command=broker_command,
)
pm = PasswordManager(fingerprint=fingerprint, password=password)
service = EntryService(pm)
index = int(lease.get("index", -1))
entry = service.retrieve_entry(index)
if not isinstance(entry, dict):
payload = {"status": "error", "reason": "entry_not_found", "lease_id": lease_id}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
kind = str(entry.get("type", entry.get("kind", "")))
label = str(entry.get("label", ""))
if kind != str(lease.get("kind", "")):
payload = {
"status": "denied",
"reason": "lease_resource_mismatch",
"lease_id": lease_id,
"lease_kind": lease.get("kind"),
"entry_kind": kind,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
try:
policy = _load_policy(strict=True)
policy = _normalize_policy(policy, strict=False)
except ValueError as exc:
payload = {"status": "denied", "reason": "invalid_policy", "detail": str(exc)}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
entry_path = _build_entry_path(index, kind, label)
allowed, decision = _policy_decision(
policy,
operation="read",
kind=kind,
label=label,
path=entry_path,
field="secret",
)
if not allowed:
payload = {
"status": "denied",
"reason": decision,
"lease_id": lease_id,
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
if _high_risk_lock_required(policy, kind):
unlocked, _expires_at = high_risk_unlocked(fingerprint=str(fingerprint))
if not unlocked:
payload = {
"status": "denied",
"reason": "policy_deny:high_risk_locked",
"lease_id": lease_id,
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
if kind in PRIVATE_KINDS:
try:
entry = _hydrate_partition_entry_if_needed(
pm, fingerprint=str(fingerprint), index=int(index), entry=entry
)
except ValueError as exc:
payload = {
"status": "denied",
"reason": str(exc),
"lease_id": lease_id,
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
kind = str(entry.get("type", entry.get("kind", kind)))
label = str(entry.get("label", label))
try:
secret = _resolve_secret_for_kind(pm, service, entry, index)
except ValueError as exc:
payload = {"status": "error", "reason": str(exc), "kind": kind}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
safe_default = bool(policy.get("output", {}).get("safe_output_default", True))
redacted = safe_default and not reveal
output_secret = _mask_secret(secret) if redacted else secret
payload = {
"status": "ok",
"lease_id": lease_id,
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"safe_output": redacted,
"secret": output_secret,
"lease_uses_remaining": int(lease.get("uses_remaining", 0)),
"policy_decision": decision,
}
_append_audit_event(
"agent_secret_lease_consumed",
{
"lease_id": lease_id,
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"safe_output": redacted,
"uses_remaining": int(lease.get("uses_remaining", 0)),
},
)
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("lease-list")
def agent_lease_list(
show_revoked: bool = typer.Option(False, "--show-revoked")
) -> None:
"""List secret lease records."""
leases = list_leases(include_revoked=show_revoked)
typer.echo(json.dumps({"status": "ok", "leases": leases}, indent=2, sort_keys=True))
@app.command("lease-revoke")
def agent_lease_revoke(
lease_id: str = typer.Argument(..., help="Lease id to revoke")
) -> None:
"""Revoke a secret lease."""
if not revoke_lease(lease_id):
payload = {"status": "error", "reason": "lease_not_found", "lease_id": lease_id}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
_append_audit_event("agent_secret_lease_revoked", {"lease_id": lease_id})
typer.echo(
json.dumps({"status": "ok", "lease_id": lease_id}, indent=2, sort_keys=True)
)
@app.command("job-run")
def agent_job_run(
ctx: typer.Context,
query: str = typer.Argument(..., help="Entry label or index query"),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
auth_broker: str = typer.Option(
"keyring",
"--auth-broker",
help="Job-safe password broker (keyring|command|env)",
click_type=click.Choice(["keyring", "command", "env"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to fingerprint)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
token: Optional[str] = typer.Option(
None,
"--token",
help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.",
),
reveal: bool = typer.Option(
False,
"--reveal",
help="Return plaintext secret instead of redacted output",
),
lease_only: bool = typer.Option(
False,
"--lease-only",
help="Issue a secret lease instead of returning the secret directly",
),
lease_ttl: int = typer.Option(
30,
"--lease-ttl",
min=1,
help="Lease TTL in seconds when --lease-only is used",
),
lease_uses: int = typer.Option(
1,
"--lease-uses",
min=1,
help="Allowed successful retrievals for issued lease",
),
allow_env_broker: bool = typer.Option(
False,
"--allow-env-broker",
help="Allow env broker for jobs (less safe; disabled by default)",
),
approval_id: Optional[str] = typer.Option(
None,
"--approval-id",
help="Approval id for risky retrieval actions",
),
) -> None:
"""Run a scheduled-safe secret retrieval using brokered auth defaults."""
broker = str(auth_broker).strip().lower()
if broker == "env" and not allow_env_broker:
payload = {
"status": "denied",
"reason": "unsafe_broker_for_job",
"allowed_brokers": sorted(SAFE_AUTOMATION_BROKERS),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
ctx.invoke(
agent_get,
query=query,
password_env=password_env,
auth_broker=broker,
broker_service=broker_service,
broker_account=broker_account,
broker_command=broker_command,
token=token,
ttl=lease_ttl,
lease_only=lease_only,
lease_uses=lease_uses,
reveal=reveal,
approval_id=approval_id,
)
@app.command("job-template")
def agent_job_template(
ctx: typer.Context,
query: str = typer.Argument(..., help="Entry label or index query"),
mode: str = typer.Option(
"cron",
"--mode",
help="Automation template format",
click_type=click.Choice(["cron", "systemd"], case_sensitive=False),
),
auth_broker: str = typer.Option(
"keyring",
"--auth-broker",
help="Recommended broker for automated jobs",
click_type=click.Choice(["keyring", "command"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Broker account (defaults to fingerprint)",
),
schedule: str = typer.Option(
"*/15 * * * *",
"--schedule",
help="Cron expression or systemd OnCalendar value",
),
unit_name: str = typer.Option(
"seedpass-agent-job",
"--unit-name",
help="Systemd unit base name for --mode systemd",
),
reveal: bool = typer.Option(False, "--reveal"),
lease_only: bool = typer.Option(False, "--lease-only"),
lease_ttl: int = typer.Option(30, "--lease-ttl", min=1),
lease_uses: int = typer.Option(1, "--lease-uses", min=1),
format: str = typer.Option(
"json",
"--format",
help="Output format",
click_type=click.Choice(["json", "text"], case_sensitive=False),
),
) -> None:
"""Generate deterministic cron/systemd templates using safer auth defaults."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
broker_account_value = broker_account or str(fingerprint)
cmd = _automation_job_command(
fingerprint=str(fingerprint),
query=query,
auth_broker=str(auth_broker).strip().lower(),
broker_service=broker_service,
broker_account=broker_account_value,
reveal=reveal,
lease_only=lease_only,
lease_ttl=lease_ttl,
lease_uses=lease_uses,
)
mode_value = str(mode).strip().lower()
payload: dict[str, Any] = {
"status": "ok",
"mode": mode_value,
"fingerprint": str(fingerprint),
"query": query,
"auth_broker": str(auth_broker).strip().lower(),
"broker_service": broker_service,
"broker_account": broker_account_value,
"command": cmd,
"safe_defaults": {
"requires_non_env_broker": True,
"safe_output_default": True,
"no_plaintext_password_env_required": True,
},
}
if mode_value == "cron":
payload["cron_line"] = _cron_template(schedule=schedule, command=cmd)
else:
service, timer = _systemd_templates(
unit_name=unit_name, schedule=schedule, command=cmd
)
payload["systemd_service"] = service
payload["systemd_timer"] = timer
payload["unit_name"] = unit_name
if str(format).strip().lower() == "json":
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
return
lines = [
f"Agent Job Template ({mode_value})",
f"fingerprint: {fingerprint}",
f"query: {query}",
f"broker: {auth_broker}",
"",
]
if mode_value == "cron":
lines.append(str(payload["cron_line"]))
else:
lines.extend(
[
"# service",
str(payload["systemd_service"]),
"",
"# timer",
str(payload["systemd_timer"]),
]
)
typer.echo("\n".join(lines))
@app.command("job-profile-create")
def agent_job_profile_create(
ctx: typer.Context,
job_id: str = typer.Option(..., "--id", help="Stable job profile id"),
query: str = typer.Option(..., "--query", help="Entry label or index query"),
auth_broker: str = typer.Option(
"keyring",
"--auth-broker",
help="Broker for scheduled job retrieval (keyring|command)",
click_type=click.Choice(["keyring", "command"], case_sensitive=False),
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Broker account (defaults to fingerprint)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
policy_binding: str = typer.Option(
"default",
"--policy-binding",
help="Policy binding label for governance/audit metadata",
),
bind_host: str = typer.Option(
"current",
"--bind-host",
help="Host binding for this job profile (`current` or explicit hostname)",
),
schedule: str = typer.Option(
"",
"--schedule",
help="Optional schedule hint (cron or OnCalendar) stored as metadata",
),
description: str = typer.Option(
"",
"--description",
help="Optional operator-facing description",
),
lease_only: bool = typer.Option(False, "--lease-only"),
lease_ttl: int = typer.Option(30, "--lease-ttl", min=1),
lease_uses: int = typer.Option(1, "--lease-uses", min=1),
reveal: bool = typer.Option(False, "--reveal"),
) -> None:
"""Create a reusable automation job profile with safe broker defaults."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
broker = str(auth_broker).strip().lower()
if broker == "command" and not str(broker_command or "").strip():
raise typer.BadParameter(
"--broker-command is required for --auth-broker=command"
)
account = str(broker_account or fingerprint)
policy = _load_policy(strict=False)
policy_stamp = compute_policy_stamp(policy)
bind_host_value = str(bind_host).strip() or "current"
if bind_host_value == "current":
bind_host_value = socket.gethostname()
try:
rec = create_job_profile(
job_id=job_id,
fingerprint=str(fingerprint),
query=query,
auth_broker=broker,
broker_service=broker_service,
broker_account=account,
broker_command=broker_command,
policy_binding=policy_binding,
policy_stamp=policy_stamp,
schedule=schedule,
description=description,
host_binding=bind_host_value,
lease_only=lease_only,
lease_ttl=lease_ttl,
lease_uses=lease_uses,
reveal=reveal,
)
except ValueError as exc:
payload = {"status": "error", "reason": str(exc), "id": job_id}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
_append_audit_event(
"agent_job_profile_created",
{
"id": rec.get("id"),
"fingerprint": rec.get("fingerprint"),
"auth_broker": rec.get("auth_broker"),
"policy_binding": rec.get("policy_binding"),
"policy_stamp": rec.get("policy_stamp"),
"host_binding": rec.get("host_binding"),
},
)
typer.echo(
json.dumps({"status": "ok", "job_profile": rec}, indent=2, sort_keys=True)
)
@app.command("job-profile-list")
def agent_job_profile_list(
show_revoked: bool = typer.Option(False, "--show-revoked")
) -> None:
"""List automation job profiles."""
jobs = list_job_profiles(include_revoked=show_revoked)
typer.echo(
json.dumps({"status": "ok", "job_profiles": jobs}, indent=2, sort_keys=True)
)
@app.command("job-profile-revoke")
def agent_job_profile_revoke(
job_id: str = typer.Argument(..., help="Job profile id")
) -> None:
"""Revoke a stored automation job profile."""
if not revoke_job_profile(job_id):
payload = {"status": "error", "reason": "job_profile_not_found", "id": job_id}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
_append_audit_event("agent_job_profile_revoked", {"id": job_id})
typer.echo(json.dumps({"status": "ok", "id": job_id}, indent=2, sort_keys=True))
@app.command("job-profile-run")
def agent_job_profile_run(
ctx: typer.Context,
job_id: str = typer.Argument(..., help="Job profile id"),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
token: Optional[str] = typer.Option(
None,
"--token",
help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.",
),
approval_id: Optional[str] = typer.Option(
None,
"--approval-id",
help="Approval id for risky retrieval actions",
),
allow_policy_drift: bool = typer.Option(
False,
"--allow-policy-drift",
help="Allow run when active policy stamp differs from profile policy stamp",
),
allow_host_mismatch: bool = typer.Option(
False,
"--allow-host-mismatch",
help="Allow run when current host differs from profile host binding",
),
) -> None:
"""Run an automation job profile with stored non-interactive settings."""
profile = get_job_profile(job_id)
if not profile or profile.get("revoked_at_utc"):
payload = {"status": "error", "reason": "job_profile_not_found", "id": job_id}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
requested_fp = (ctx.obj or {}).get("fingerprint")
profile_fp = str(profile.get("fingerprint", "")).strip()
if requested_fp and str(requested_fp) != profile_fp:
payload = {
"status": "denied",
"reason": "job_profile_fingerprint_mismatch",
"id": job_id,
"job_fingerprint": profile_fp,
"requested_fingerprint": str(requested_fp),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
bound_host = str(profile.get("host_binding", "")).strip()
current_host = socket.gethostname()
if bound_host and bound_host != current_host and not allow_host_mismatch:
payload = {
"status": "denied",
"reason": "job_profile_host_mismatch",
"id": job_id,
"job_host": bound_host,
"current_host": current_host,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
profile_stamp = str(profile.get("policy_stamp", "")).strip()
if profile_stamp:
active_policy = _load_policy(strict=False)
active_stamp = compute_policy_stamp(active_policy)
if active_stamp != profile_stamp and not allow_policy_drift:
payload = {
"status": "denied",
"reason": "job_profile_policy_mismatch",
"id": job_id,
"job_policy_stamp": profile_stamp,
"active_policy_stamp": active_stamp,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
ctx.invoke(
agent_job_run,
query=str(profile.get("query", "")),
password_env=password_env,
auth_broker=str(profile.get("auth_broker", "keyring")),
broker_service=str(profile.get("broker_service", "seedpass")),
broker_account=str(profile.get("broker_account", profile_fp)),
broker_command=str(profile.get("broker_command", "") or None),
token=token,
reveal=bool(profile.get("reveal", False)),
lease_only=bool(profile.get("lease_only", False)),
lease_ttl=int(profile.get("lease_ttl", 30)),
lease_uses=int(profile.get("lease_uses", 1)),
allow_env_broker=False,
approval_id=approval_id,
)
@app.command("job-profile-check")
def agent_job_profile_check(
max_age_days: int = typer.Option(
30,
"--max-age-days",
min=1,
help="Flag job profiles older than this age",
),
strict_exit: bool = typer.Option(
False,
"--strict-exit/--no-strict-exit",
help="Return non-zero exit when findings are present",
),
) -> None:
"""Check job profile posture (policy drift, host mismatch, stale configs)."""
jobs = list_job_profiles(include_revoked=False)
active_policy = _load_policy(strict=False)
active_stamp = compute_policy_stamp(active_policy)
now = _utcnow()
findings: list[dict[str, Any]] = []
for rec in jobs:
job_id = str(rec.get("id", ""))
broker = str(rec.get("auth_broker", "")).strip().lower()
if broker not in SAFE_AUTOMATION_BROKERS:
findings.append(
{
"id": "job_profile_unsafe_broker",
"severity": "high",
"job_id": job_id,
"broker": broker,
"message": "Job profile uses unsafe broker mode.",
}
)
profile_stamp = str(rec.get("policy_stamp", "")).strip()
if profile_stamp and profile_stamp != active_stamp:
findings.append(
{
"id": "job_profile_policy_mismatch",
"severity": "medium",
"job_id": job_id,
"job_policy_stamp": profile_stamp,
"active_policy_stamp": active_stamp,
"message": "Job profile policy stamp differs from active policy.",
}
)
bound_host = str(rec.get("host_binding", "")).strip()
current_host = socket.gethostname()
if bound_host and bound_host != current_host:
findings.append(
{
"id": "job_profile_host_mismatch",
"severity": "medium",
"job_id": job_id,
"job_host": bound_host,
"current_host": current_host,
"message": "Job profile host binding does not match current host.",
}
)
created_at = str(rec.get("created_at_utc", ""))
if created_at:
try:
age_days = (_utcnow() - _parse_iso8601(created_at)).days
if age_days > max_age_days:
findings.append(
{
"id": "job_profile_stale",
"severity": "low",
"job_id": job_id,
"age_days": age_days,
"message": "Job profile has exceeded recommended age.",
}
)
except Exception:
pass
payload = {
"status": "ok",
"check": "agent_job_profiles",
"job_profile_count": len(jobs),
"active_policy_stamp": active_stamp,
"finding_count": len(findings),
"findings": findings,
"generated_at_utc": now.isoformat(),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
if strict_exit and findings:
raise typer.Exit(1)
@app.command("recovery-split")
def agent_recovery_split(
secret: Optional[str] = typer.Option(
None,
"--secret",
help="Secret material to split (avoid shell history for sensitive values)",
),
secret_env: str = typer.Option(
"SEEDPASS_RECOVERY_SECRET",
"--secret-env",
help="Environment variable containing secret material",
),
shares: int = typer.Option(5, "--shares", min=2, help="Total number of shares"),
threshold: int = typer.Option(
3, "--threshold", min=2, help="Minimum shares required for recovery"
),
label: str = typer.Option(
"default", "--label", help="Recovery set label encoded in share tokens"
),
) -> None:
"""Split secret material into deterministic Shamir shares."""
secret_value = str(secret or os.getenv(secret_env, "")).strip()
if not secret_value:
raise typer.BadParameter(
"Provide --secret or set the environment variable from --secret-env."
)
try:
tokens = split_secret(
secret_value,
total_shares=int(shares),
threshold=int(threshold),
label=label,
)
except ValueError as exc:
payload = {"status": "error", "reason": str(exc)}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
payload = {
"status": "ok",
"label": str(label).strip() or "default",
"threshold": int(threshold),
"total_shares": int(shares),
"shares": tokens,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("recovery-recover")
def agent_recovery_recover(
share: list[str] = typer.Option(
None, "--share", help="Recovery share token (repeatable)"
),
share_file: Optional[Path] = typer.Option(
None, "--share-file", help="File with one share token per line"
),
reveal: bool = typer.Option(
False, "--reveal", help="Emit recovered secret plaintext"
),
) -> None:
"""Recover secret material from Shamir share tokens."""
tokens = [str(v).strip() for v in (share or []) if str(v).strip()]
if share_file:
for line in share_file.read_text(encoding="utf-8").splitlines():
value = line.strip()
if value:
tokens.append(value)
if not tokens:
raise typer.BadParameter("Provide at least one --share or --share-file input.")
try:
recovered = recover_secret(tokens)
except ValueError as exc:
payload = {"status": "error", "reason": str(exc)}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
payload = {
"status": "ok",
"share_count": len(tokens),
"secret": recovered if reveal else _mask_secret(recovered),
"revealed": bool(reveal),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("recovery-drill")
def agent_recovery_drill(
ctx: typer.Context,
backup_path: Path = typer.Option(
..., "--backup-path", help="Path to backup file to validate"
),
simulated: bool = typer.Option(
True, "--simulated/--no-simulated", help="Mark drill as simulated run"
),
max_age_days: Optional[int] = typer.Option(
30,
"--max-age-days",
min=1,
help="Warn when backup age exceeds this many days",
),
strict_exit: bool = typer.Option(
False,
"--strict-exit/--no-strict-exit",
help="Return non-zero exit when drill status is warning",
),
) -> None:
"""Run a signed backup verification drill and append report to recovery log."""
fingerprint = (ctx.obj or {}).get("fingerprint") or "default"
report = record_recovery_drill(
fingerprint=str(fingerprint),
backup_path=str(backup_path),
simulated=bool(simulated),
expected_max_age_days=max_age_days,
)
typer.echo(json.dumps({"status": "ok", "report": report}, indent=2, sort_keys=True))
if strict_exit and str(report.get("status", "")) != "ok":
raise typer.Exit(1)
@app.command("recovery-drill-list")
def agent_recovery_drill_list(
limit: int = typer.Option(20, "--limit", min=1, max=200)
) -> None:
"""List recent signed recovery drill records."""
records = list_recovery_drills(limit=int(limit))
typer.echo(
json.dumps(
{"status": "ok", "count": len(records), "drills": records},
indent=2,
sort_keys=True,
)
)
@app.command("bootstrap-context")
def agent_bootstrap_context(ctx: typer.Context) -> None:
"""Return deterministic context for autonomous agent bootstrap."""
fingerprint = (ctx.obj or {}).get("fingerprint")
try:
policy = _load_policy(strict=True)
policy = _normalize_policy(policy, strict=False)
policy_status = "ok"
policy_error = None
except ValueError as exc:
policy = _deny_all_policy()
policy_status = "invalid"
policy_error = str(exc)
payload = {
"status": "ok",
"context_schema_version": 1,
"fingerprint": fingerprint,
"policy": {
"status": policy_status,
"error": policy_error,
"version": int(policy.get("version", 1)),
"default_effect": str(policy.get("default_effect", "deny")),
"rule_count": len(policy.get("rules", [])),
"safe_output_default": bool(
policy.get("output", {}).get("safe_output_default", True)
),
"allow_export_import": bool(policy.get("allow_export_import", False)),
"approvals_required_for": list(
policy.get("approvals", {}).get("require_for", [])
),
"secret_isolation": {
"enabled": bool(
policy.get("secret_isolation", {}).get("enabled", True)
),
"high_risk_kinds": list(
policy.get("secret_isolation", {}).get("high_risk_kinds", [])
),
"unlock_ttl_sec": int(
policy.get("secret_isolation", {}).get("unlock_ttl_sec", 300)
),
},
},
"tokens": _token_posture_summary(),
"identities": {
"total": len(list_identities(include_revoked=True)),
"active": len(list_identities(include_revoked=False)),
},
"auth_brokers": {
"supported": ["env", "keyring", "command"],
"default_agent_get": "env",
"default_agent_init": "env",
},
"commands": {
"policy": [
"agent policy-show",
"agent policy-lint",
"agent policy-set",
"agent policy-review",
"agent policy-apply",
],
"identities": [
"agent identity-create",
"agent identity-list",
"agent identity-revoke",
],
"tokens": ["agent token-issue", "agent token-list", "agent token-revoke"],
"approvals": [
"agent approval-issue",
"agent approval-list",
"agent approval-revoke",
],
"leases": [
"agent get --lease-only",
"agent lease-consume",
"agent lease-list",
"agent lease-revoke",
],
"automation": [
"agent job-run",
"agent job-template",
"agent job-profile-create",
"agent job-profile-list",
"agent job-profile-run",
"agent job-profile-revoke",
"agent job-profile-check",
],
"recovery": [
"agent recovery-split",
"agent recovery-recover",
"agent recovery-drill",
"agent recovery-drill-list",
],
"secret_isolation": [
"agent high-risk-factor-set",
"agent high-risk-unlock",
"agent high-risk-status",
"agent high-risk-lock",
],
"export_controls": [
"agent export-check",
"agent export-manifest-verify",
],
"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"],
},
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("posture-check")
def agent_posture_check(
ctx: typer.Context,
fail_on: str = typer.Option(
"critical",
"--fail-on",
help="Minimum severity that returns non-zero exit code",
click_type=click.Choice(
["info", "low", "medium", "high", "critical"], case_sensitive=False
),
),
format: str = typer.Option(
"json",
"--format",
help="Output format",
click_type=click.Choice(["json", "text"], case_sensitive=False),
),
check_runtime_config: bool = typer.Option(
False,
"--check-runtime-config",
help="Attempt profile runtime config checks (requires fingerprint + broker auth)",
),
auth_broker: str = typer.Option(
"env",
"--auth-broker",
help="Password broker for --check-runtime-config (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD",
"--password-env",
help="Env var containing master password when --auth-broker=env",
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to active fingerprint)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
) -> None:
"""Run security posture checks for agent configuration and token hygiene."""
try:
policy = _load_policy(strict=True)
policy = _normalize_policy(policy, strict=False)
policy_status = "ok"
except ValueError:
policy = _deny_all_policy()
policy_status = "invalid"
findings = _posture_findings(policy, policy_status)
runtime_status = "not_requested"
runtime_error = None
runtime_finding_count = 0
if check_runtime_config:
fingerprint = str((ctx.obj or {}).get("fingerprint") or "").strip()
if not fingerprint:
payload = {
"status": "error",
"reason": "missing_fingerprint_for_runtime_check",
"detail": "Provide --fingerprint when using --check-runtime-config.",
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
try:
password = _agent_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=broker_account or fingerprint,
broker_command=broker_command,
)
pm = PasswordManager(fingerprint=fingerprint, password=password)
runtime_findings = _runtime_config_findings(pm)
findings.extend(runtime_findings)
runtime_finding_count = len(runtime_findings)
runtime_status = "checked"
except Exception as exc:
runtime_status = "error"
runtime_error = str(exc)
highest = "info"
for finding in findings:
if _severity_rank(finding["severity"]) > _severity_rank(highest):
highest = finding["severity"]
payload = {
"status": "ok",
"check": "agent_posture",
"policy_status": policy_status,
"token_summary": _token_posture_summary(),
"finding_count": len(findings),
"highest_severity": highest,
"fail_on": fail_on.lower(),
"runtime_config_status": runtime_status,
"runtime_config_error": runtime_error,
"runtime_finding_count": runtime_finding_count,
"findings": findings,
}
if format.lower() == "json":
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
else:
typer.echo("SeedPass Agent Posture Check")
typer.echo(f"policy_status: {payload['policy_status']}")
typer.echo(f"highest_severity: {payload['highest_severity']}")
typer.echo(f"finding_count: {payload['finding_count']}")
if findings:
typer.echo("")
for finding in findings:
typer.echo(
f"- [{finding['severity']}] {finding['id']}: {finding['message']}"
)
if _severity_rank(highest) >= _severity_rank(fail_on.lower()):
raise typer.Exit(code=1)
@app.command("posture-remediate")
def agent_posture_remediate(
ctx: typer.Context,
fail_on: str = typer.Option(
"critical",
"--fail-on",
help="Minimum severity that marks bundle as blocked",
click_type=click.Choice(
["info", "low", "medium", "high", "critical"], case_sensitive=False
),
),
check_runtime_config: bool = typer.Option(
False,
"--check-runtime-config",
help="Include runtime profile config checks (requires fingerprint + broker auth)",
),
auth_broker: str = typer.Option(
"env",
"--auth-broker",
help="Password broker for --check-runtime-config (env|keyring|command)",
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD",
"--password-env",
help="Env var containing master password when --auth-broker=env",
),
broker_service: str = typer.Option(
"seedpass",
"--broker-service",
help="Keyring service name when --auth-broker=keyring",
),
broker_account: Optional[str] = typer.Option(
None,
"--broker-account",
help="Keyring account (defaults to active fingerprint)",
),
broker_command: Optional[str] = typer.Option(
None,
"--broker-command",
help="Command that prints password to stdout for --auth-broker=command",
),
) -> None:
"""Generate actionable remediation steps from current posture findings."""
try:
policy = _load_policy(strict=True)
policy = _normalize_policy(policy, strict=False)
policy_status = "ok"
except ValueError:
policy = _deny_all_policy()
policy_status = "invalid"
findings = _posture_findings(policy, policy_status)
runtime_status = "not_requested"
runtime_error = None
if check_runtime_config:
fingerprint = str((ctx.obj or {}).get("fingerprint") or "").strip()
if not fingerprint:
payload = {
"status": "error",
"reason": "missing_fingerprint_for_runtime_check",
"detail": "Provide --fingerprint when using --check-runtime-config.",
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(1)
try:
password = _agent_password(
broker=auth_broker,
password_env=password_env,
broker_service=broker_service,
broker_account=broker_account or fingerprint,
broker_command=broker_command,
)
pm = PasswordManager(fingerprint=fingerprint, password=password)
findings.extend(_runtime_config_findings(pm))
runtime_status = "checked"
except Exception as exc:
runtime_status = "error"
runtime_error = str(exc)
highest = "info"
for finding in findings:
if _severity_rank(str(finding.get("severity", "info"))) > _severity_rank(
highest
):
highest = str(finding.get("severity", "info"))
actions = _remediation_actions(findings)
payload = {
"status": "ok",
"check": "agent_posture_remediation",
"policy_status": policy_status,
"runtime_config_status": runtime_status,
"runtime_config_error": runtime_error,
"finding_count": len(findings),
"highest_severity": highest,
"fail_on": fail_on.lower(),
"blocked": _severity_rank(highest) >= _severity_rank(fail_on.lower()),
"findings": findings,
"actions": actions,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
if payload["blocked"]:
raise typer.Exit(1)
@app.command("export-check")
def agent_export_check(
mode: str = typer.Option(
"full",
"--mode",
help="Export check mode",
click_type=click.Choice(["full", "filtered", "kind"], case_sensitive=False),
),
kind: Optional[str] = typer.Option(
None,
"--kind",
help="Entry kind for --mode kind",
click_type=click.Choice(ALL_ENTRY_TYPES, case_sensitive=False),
),
strict_exit: bool = typer.Option(
False,
"--strict-exit",
help="Return non-zero exit code when export is denied",
),
) -> None:
"""Dry-run export policy decision without exporting secrets."""
try:
policy = _load_policy(strict=True)
policy = _normalize_policy(policy, strict=False)
except ValueError as exc:
payload = {
"status": "denied",
"mode": mode.lower(),
"allowed": False,
"reason": "invalid_policy",
"detail": str(exc),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(code=1)
mode_l = mode.lower()
allowed = False
reason = "policy_deny:unknown"
detail = None
checked_kind = None
if mode_l == "full":
allowed, reason = evaluate_full_export(policy)
elif mode_l == "filtered":
allowed = True
reason = "policy_allow:filtered_export"
else:
if not kind:
payload = {
"status": "error",
"mode": mode_l,
"allowed": False,
"reason": "missing_kind",
"detail": "Provide --kind when --mode kind is used.",
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(code=1)
checked_kind = kind
allowed, reason = evaluate_kind_export(policy, kind)
payload = {
"status": "ok",
"mode": mode_l,
"allowed": bool(allowed),
"reason": reason,
"kind": checked_kind,
"allow_kinds": sorted(set(policy.get("allow_kinds", []))),
"allow_export_import": bool(policy.get("allow_export_import", False)),
"detail": detail,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
if strict_exit and not allowed:
raise typer.Exit(code=1)
@app.command("export-manifest-verify")
def agent_export_manifest_verify(
file: Path = typer.Option(
..., "--file", help="Path to policy-filtered export JSON"
),
strict_exit: bool = typer.Option(
True,
"--strict-exit/--no-strict-exit",
help="Return non-zero exit when verification fails",
),
) -> None:
"""Verify policy-filtered export manifest integrity against current policy."""
try:
package = json.loads(file.read_text(encoding="utf-8"))
except Exception as exc:
payload = {
"status": "error",
"valid": False,
"reason": "invalid_json",
"detail": str(exc),
"file": str(file),
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
raise typer.Exit(code=1)
try:
policy = _load_policy(strict=True)
policy = _normalize_policy(policy, strict=False)
policy_status = "ok"
except ValueError as exc:
policy = _deny_all_policy()
policy_status = "invalid"
policy_error = str(exc)
else:
policy_error = None
valid, errors = verify_filtered_export_package(package, policy)
payload = {
"status": "ok",
"valid": bool(valid),
"file": str(file),
"policy_status": policy_status,
"policy_error": policy_error,
"policy_stamp_current": compute_policy_stamp(policy),
"policy_stamp_manifest": str(
(package.get("_export_manifest") or {}).get("policy_stamp", "")
),
"errors": errors,
}
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
if strict_exit and not valid:
raise typer.Exit(code=1)
@app.command("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,
query: str = typer.Argument(..., help="Entry label or index query"),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
auth_broker: str = typer.Option(
"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.",
),
ttl: int = typer.Option(
30,
"--ttl",
min=1,
help="Lease TTL in seconds when issuing secret leases",
),
lease_only: bool = typer.Option(
False,
"--lease-only",
help="Issue a secret lease without returning the secret directly",
),
lease_uses: int = typer.Option(
1,
"--lease-uses",
min=1,
help="Allowed successful secret retrievals for issued lease",
),
reveal: bool = typer.Option(
False,
"--reveal",
help="Return plaintext secret instead of redacted output",
),
approval_id: Optional[str] = typer.Option(
None,
"--approval-id",
help="Approval id for risky retrieval actions",
),
) -> None:
"""Retrieve one secret as JSON with policy enforcement, redaction, and auditing."""
fingerprint = (ctx.obj or {}).get("fingerprint")
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)
pm = PasswordManager(fingerprint=fingerprint, password=password)
service = EntryService(pm)
matches = service.search_entries(query)
if len(matches) != 1:
payload = {
"status": "error",
"reason": "ambiguous_or_missing",
"match_count": len(matches),
"matches": [
{
"index": idx,
"label": label,
"kind": etype.value,
"username": username,
"url": url,
"archived": archived,
}
for idx, label, username, url, archived, etype in matches
],
}
_append_audit_event(
"agent_secret_access_denied",
{
"fingerprint": fingerprint,
"query": query,
"reason": "ambiguous_or_missing",
"match_count": len(matches),
},
)
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
index = matches[0][0]
entry = service.retrieve_entry(index)
kind = str(entry.get("type", entry.get("kind", "")))
label = str(entry.get("label", ""))
if not kind:
raise typer.BadParameter("Entry kind missing.")
entry_path = _build_entry_path(index, kind, label)
allowed, decision = _policy_decision(
policy,
operation="read",
kind=kind,
label=label,
path=entry_path,
field="secret",
)
if not allowed:
payload = {
"status": "denied",
"reason": decision,
"kind": kind,
"label": label,
"index": index,
}
_append_audit_event(
"agent_secret_access_denied",
{
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"reason": decision,
},
)
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
if _high_risk_lock_required(policy, kind):
unlocked, expires_at = high_risk_unlocked(fingerprint=str(fingerprint))
if not unlocked:
payload = {
"status": "denied",
"reason": "policy_deny:high_risk_locked",
"kind": kind,
"label": label,
"index": index,
}
_append_audit_event(
"agent_secret_access_denied",
{
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"reason": "policy_deny:high_risk_locked",
},
)
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
if kind in PRIVATE_KINDS:
try:
entry = _hydrate_partition_entry_if_needed(
pm, fingerprint=str(fingerprint), index=int(index), entry=entry
)
except ValueError as exc:
payload = {
"status": "denied",
"reason": str(exc),
"kind": kind,
"label": label,
"index": index,
}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
kind = str(entry.get("type", entry.get("kind", kind)))
label = str(entry.get("label", label))
token_meta: dict[str, Any] | None = None
if raw_token:
ok, token_reason, token_meta = _validate_token(
raw_token,
operation="read",
kind=kind,
label=label,
consume_use=True,
)
if not ok:
payload = {
"status": "denied",
"reason": token_reason,
"kind": kind,
"label": label,
"index": index,
}
_append_audit_event(
"agent_secret_access_denied",
{
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"reason": token_reason,
},
)
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
if kind in PRIVATE_KINDS and approval_required(policy, "private_key_retrieval"):
if not approval_id:
payload = {
"status": "denied",
"reason": "policy_deny:approval_required",
"action": "private_key_retrieval",
"kind": kind,
"label": label,
"index": index,
}
_append_audit_event(
"agent_secret_access_denied",
{
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"reason": "policy_deny:approval_required",
"action": "private_key_retrieval",
},
)
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
ok, approval_reason = consume_approval(
approval_id=approval_id,
action="private_key_retrieval",
resource=f"entry:{kind}:{index}",
)
if not ok:
reason = f"policy_deny:{approval_reason}"
payload = {
"status": "denied",
"reason": reason,
"action": "private_key_retrieval",
"kind": kind,
"label": label,
"index": index,
}
_append_audit_event(
"agent_secret_access_denied",
{
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"reason": reason,
"action": "private_key_retrieval",
"approval_id": approval_id,
},
)
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
now = _utcnow()
token_id = (token_meta or {}).get("id") if token_meta else None
if lease_only:
lease = issue_lease(
fingerprint=str(fingerprint),
index=index,
kind=kind,
label=label,
ttl_seconds=ttl,
uses=lease_uses,
token_id=str(token_id) if token_id else None,
)
payload = {
"status": "ok",
"mode": "lease_issued",
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"lease_id": lease.get("id"),
"lease_uses_remaining": int(lease.get("uses_remaining", 0)),
"lease_expires_at_utc": lease.get("expires_at_utc"),
"policy_decision": decision,
}
if token_meta is not None:
payload["token_id"] = token_meta.get("id")
payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0)
_append_audit_event(
"agent_secret_lease_issued",
{
"lease_id": lease.get("id"),
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"uses_remaining": int(lease.get("uses_remaining", 0)),
"expires_at_utc": lease.get("expires_at_utc"),
"token_id": token_id,
},
)
typer.echo(json.dumps(payload, indent=2))
return
try:
secret = _resolve_secret_for_kind(pm, service, entry, index)
except ValueError as exc:
payload = {"status": "error", "reason": str(exc), "kind": kind}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
safe_default = bool(policy.get("output", {}).get("safe_output_default", True))
redacted = safe_default and not reveal
output_secret = _mask_secret(secret) if redacted else secret
payload = {
"status": "ok",
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"lease_ttl_sec": ttl,
"issued_at_utc": now.isoformat(),
"expires_at_utc": (now + timedelta(seconds=ttl)).isoformat(),
"safe_output": redacted,
"secret": output_secret,
"policy_decision": decision,
}
if token_meta is not None:
payload["token_id"] = token_meta.get("id")
payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0)
_append_audit_event(
"agent_secret_access_granted",
{
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": label,
"safe_output": redacted,
"token_id": (token_meta or {}).get("id"),
},
)
typer.echo(json.dumps(payload, indent=2))