Skip to content

commands

commands

Command registry for terok-sandbox — one module per subsystem.

Follows the same CommandDef / ArgDef pattern as terok_shield.registry. Higher-level consumers (terok, terok-executor) import COMMANDS to build their own CLI frontends without duplicating argument definitions or handler logic.

Per-subsystem modules:

  • sandbox — setup/uninstall (composes shield + vault + gate + clearance into one verb).
  • gate — gate server lifecycle.
  • shield — egress-firewall hooks.
  • vault — vault passphrase verbs (the unlock/lock/seal trio that drives the SQLCipher chain).
  • ssh — SSH-key CRUD against the credentials DB.
  • doctor — host-side health checks.
  • credentials — credentials-DB encryption chooser, provisioning, and migration phase.
  • launch — prepare/run/cleanup for user-owned containers.

COMMANDS is a forest of lazy roots: each top-level verb is a CommandDef carrying only its name/help plus a source string pointing at the fully populated verb definition in its module. Building COMMANDS imports none of the per-subsystem modules; CommandTree.wire(parser, argv=…) resolves and imports only the invoked verb's module (and a bare --help lists every verb without importing any). This is what keeps import terok_sandbox and the per-container supervisor spawn off the config / SQLCipher / cryptography / terok-shield stacks.

The *_COMMANDS registries and the handler functions the executor splice and tests reach for are re-exported lazily through __getattr__, so importing this package to read COMMANDS never pulls a handler module behind it.

COMMANDS = CommandTree([CommandDef(name='setup', help='Install supervisor hooks + shield hooks in one step', source='terok_sandbox.commands.sandbox:SETUP'), CommandDef(name='uninstall', help='Remove supervisor hooks + shield hooks in one step', source='terok_sandbox.commands.sandbox:UNINSTALL'), CommandDef(name='gate', help='Git gate inspection', source='terok_sandbox.commands.gate:GATE'), CommandDef(name='shield', help='Egress firewall management', source='terok_sandbox.commands.shield:SHIELD'), CommandDef(name='vault', help='Vault passphrase management', source='terok_sandbox.commands.vault:VAULT'), CommandDef(name='ssh', help='SSH keypair management', source='terok_sandbox.commands.ssh:SSH'), CommandDef(name='credentials', help='Credentials DB management', source='terok_sandbox.commands.credentials:CREDENTIALS'), CommandDef(name='prepare', help='Print podman flags for sandboxing a user-owned container', source='terok_sandbox.commands.launch:PREPARE'), CommandDef(name='run', help='Launch a sandboxed user-owned container (exec into podman run)', source='terok_sandbox.commands.launch:RUN'), CommandDef(name='cleanup', help='Revoke tokens and drop shield rules for a sandboxed container', source='terok_sandbox.commands.launch:CLEANUP'), CommandDef(name='doctor', help='Run sandbox health checks', source='terok_sandbox.commands.doctor:DOCTOR'), CommandDef(name='supervisor', help='Run the per-container supervisor (internal; spawned by the OCI hook)', source='terok_sandbox.commands.supervisor:SUPERVISOR'), CommandDef(name='supervise-child', help='Run one hardened supervisor service (internal; spawned by the supervisor)', source='terok_sandbox.commands.supervisor:SUPERVISE_CHILD')]) module-attribute

CREDENTIALS_COMMANDS = (CommandDef(name='credentials', help='Credentials DB management', children=(CommandDef(name='encrypt-db', help='Migrate a legacy plaintext credentials DB to SQLCipher-encrypted (deprecated in 0.8.0, removed in 0.9.0)', handler=LazyHandler('terok_sandbox.commands.credentials:_handle_credentials_encrypt_db')),)),) module-attribute

DOCTOR_COMMANDS = (CommandDef(name='doctor', help='Run sandbox health checks', handler=LazyHandler('terok_sandbox.commands.doctor:_handle_doctor'), group='doctor'),) module-attribute

GATE_COMMANDS = (CommandDef(name='gate', help='Git gate inspection', children=(CommandDef(name='path', help="Print the file:// URL of a project's bare mirror", handler=LazyHandler('terok_sandbox.commands.gate:_handle_gate_path'), args=(ArgDef(name='project', help='Project name (the mirror is <project>.git)'),)),)),) module-attribute

LAUNCH_COMMANDS = (CommandDef(name='prepare', help='Print podman flags for sandboxing a user-owned container', handler=LazyHandler('terok_sandbox.commands.launch:_handle_prepare'), epilog=_BRIDGES_EPILOG, args=(ArgDef(name='container', help='Container name (becomes --name)'), ArgDef(name='--no-shield', action='store_true', help='Disable egress firewall (default: on)', dest='no_shield'), ArgDef(name='--no-gate', action='store_true', help='Disable git gate (default: on; requires --scope)', dest='no_gate'), ArgDef(name='--no-broker', action='store_true', help='Disable vault token broker (default: on; requires --scope)', dest='no_broker'), ArgDef(name='--scope', help='Credential scope; enables vault SSH agent and is required by gate/broker'), ArgDef(name='--profiles', type=_csv_list, help="Override shield profiles for this container (comma-separated, e.g. 'dev,pypi')"), ArgDef(name='--json', action='store_true', dest='output_json', help='Output JSON array instead of a shell-quoted string'))), CommandDef(name='run', help='Launch a sandboxed user-owned container (exec into podman run)', handler=LazyHandler('terok_sandbox.commands.launch:_handle_run'), epilog=_BRIDGES_EPILOG, args=(ArgDef(name='container', help='Container name (becomes --name)'), ArgDef(name='--no-shield', action='store_true', help='Disable egress firewall (default: on)', dest='no_shield'), ArgDef(name='--no-gate', action='store_true', help='Disable git gate (default: on; requires --scope)', dest='no_gate'), ArgDef(name='--no-broker', action='store_true', help='Disable vault token broker (default: on; requires --scope)', dest='no_broker'), ArgDef(name='--scope', help='Credential scope; enables vault SSH agent and is required by gate/broker'), ArgDef(name='--profiles', type=_csv_list, help="Override shield profiles for this container (comma-separated, e.g. 'dev,pypi')"))), CommandDef(name='cleanup', help='Revoke tokens and drop shield rules for a sandboxed container', handler=LazyHandler('terok_sandbox.commands.launch:_handle_cleanup'), args=(ArgDef(name='container', help='Container name to clean up'),))) module-attribute

SETUP_COMMANDS = (CommandDef(name='setup', help='Install supervisor hooks + shield hooks in one step', handler=LazyHandler('terok_sandbox.commands.sandbox:_handle_sandbox_setup'), args=(ArgDef(name='component', nargs='?', help='Install one hardening prerequisite interactively (selinux | apparmor): shows the exact sudo command and the rules before anything runs'), ArgDef(name='--show', action='store_true', help='With a component: print the rules it would install, then exit'), ArgDef(name='--no-shield', action='store_true', help='Skip shield install'), ArgDef(name='--no-vault', action='store_true', help='Skip the credentials-DB encryption phase'), ArgDef(name='--echo-passphrase', action='store_true', help='Also print any auto-generated vault passphrase to stdout (default off — the value otherwise only reaches /dev/tty, so non-interactive bootstraps must opt in to capture it)'), ArgDef(name='--passphrase-tier', default=None, help='Force credentials-DB passphrase storage to a specific tier (systemd-creds | keyring | kernel-keyring) instead of the auto-detect / chooser chain. Required on a non-TTY host without systemd-creds — the silent volatile fallback was removed in v0.0.100 because it minted a passphrase the operator never saw and lost it at logout.'))), CommandDef(name='uninstall', help='Remove supervisor hooks + shield hooks in one step', handler=LazyHandler('terok_sandbox.commands.sandbox:_handle_sandbox_uninstall'), args=(ArgDef(name='--no-shield', action='store_true', help='Skip shield uninstall'),))) module-attribute

SHIELD_COMMANDS = (SHIELD,) module-attribute

SSH_COMMANDS = (CommandDef(name='ssh', help='SSH keypair management', children=(CommandDef(name='list', help='List SSH keys stored in the vault', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_list'), args=(ArgDef(name='--scope', help='Show keys for a specific credential scope only', default=None),)), CommandDef(name='import', help='Import an OpenSSH keypair from files into the vault DB', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_import'), args=(ArgDef(name='scope', help='Credential scope to associate the key with'), ArgDef(name='--private-key', help='Path to the private key file', dest='private_key', required=True), ArgDef(name='--public-key', help='Path to the .pub file (default: derive from the private key)', default=None, dest='public_key'), ArgDef(name='-c/--comment', help="Override the key's comment string", default=None))), CommandDef(name='add', help='Generate a new SSH keypair in the vault for a credential scope', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_add'), args=(ArgDef(name='scope', help='Credential scope to associate the key with'), ArgDef(name='--key-type', help='Key algorithm: ed25519 (default) or rsa', default='ed25519', dest='key_type'), ArgDef(name='-c/--comment', help='Key comment (default: next unused <scope>-N; prompted on a TTY)', default=None), ArgDef(name='--force', help='Rotate — unassign all existing keys from the scope and generate fresh', action='store_true'))), CommandDef(name='export', help="Export a scope's SSH keypair to standard OpenSSH files", handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_export'), args=(ArgDef(name='scope', help='Credential scope to export'), ArgDef(name='--out-dir', help='Directory to write files into', dest='out_dir', required=True), ArgDef(name='--key-id', help="Export a specific ssh_keys.id (default: the scope's default key)", default=None, dest='key_id', type=int), ArgDef(name='--out-name', help='Override the output filename stem (default: id_<type>_<fp8>)', default=None, dest='out_name'))), CommandDef(name='pub', help='Print all assigned public keys, one per line with the default first', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_pub'), args=(ArgDef(name='scope', help='Credential scope'), ArgDef(name='--key-id', help='Print only this assigned ssh_keys.id (default: every assigned key)', default=None, dest='key_id', type=int))), CommandDef(name='default', help="Choose which assigned key the scope's SSH agent offers first", handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_default'), args=(ArgDef(name='scope', help='Credential scope'), ArgDef(name='key_id', help='Assigned ssh_keys.id to offer first', type=int))), CommandDef(name='link', help='Link an existing vault key to an additional scope', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_link'), args=(ArgDef(name='scope', help='Credential scope to link the key to'), ArgDef(name='--key-id', help='ssh_keys.id of the key already stored in the vault', dest='key_id', type=int, required=True))), CommandDef(name='rename', help='Change the comment of a stored SSH key (selected by fingerprint prefix)', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_rename'), args=(ArgDef(name='fingerprint', help='Fingerprint prefix identifying the key (min 8 chars recommended)'), ArgDef(name='comment', help='New comment text'))), CommandDef(name='remove', help='Unassign SSH keys from scopes (orphaned keys cascade-delete)', handler=LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_remove'), args=(ArgDef(name='--scope', help='Filter by credential scope (exact match)', default=None), ArgDef(name='-c/--comment', help='Filter by comment (supports glob wildcards)', default=None), ArgDef(name='--fingerprint', help='Filter by fingerprint prefix (min 8 chars recommended)', default=None), ArgDef(name='--yes', help='Skip confirmation prompts', action='store_true', dest='yes'))))),) module-attribute

SUPERVISOR_COMMANDS = (SUPERVISOR, SUPERVISE_CHILD) module-attribute

VAULT_COMMANDS = (CommandDef(name='vault', help='Vault passphrase management', children=(CommandDef(name='status', help='Show lock state, the passphrase resolution chain, and stored secrets', handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_status'), args=(ArgDef(name='--json', dest='as_json', action='store_true', help='Machine-readable JSON output'),)), CommandDef(name='unlock', help='Cache the credentials-DB passphrase for this session (kernel keyring)', handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_unlock'), args=(ArgDef(name='--force', action='store_true', help='Cache even if a durable tier already unlocks the vault'),)), CommandDef(name='lock', help="Clear every stored copy of the passphrase — you'll need it to unlock again", handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_lock'), args=(ArgDef(name='--force', action='store_true', help="Skip the 'have you saved the passphrase?' confirmation"),)), CommandDef(name='list', help='Inventory stored credentials (and optionally proxy tokens)', handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_list'), args=(ArgDef(name='--include-tokens', action='store_true', help='Also show proxy-token rows (token values are masked)'), ArgDef(name='--json', dest='as_json', action='store_true', help='Machine-readable JSON output'))), _PASSPHRASE_GROUP)),) module-attribute

__all__ = ['ArgDef', 'CommandDef', 'CommandTree', 'KeyRow', 'COMMANDS', 'CREDENTIALS_COMMANDS', 'DOCTOR_COMMANDS', 'GATE_COMMANDS', 'LAUNCH_COMMANDS', 'SETUP_COMMANDS', 'SHIELD_COMMANDS', 'SSH_COMMANDS', 'SUPERVISOR_COMMANDS', 'VAULT_COMMANDS', 'PassphraseChangeResult', 'ProvisioningPlan', 'SessionProvisionResult', 'TierRewrite', 'change_passphrase', 'handle_vault_seal', 'handle_vault_to_keyring', 'plan_provisioning', 'provision_session_passphrase', 'purge_passphrase_tiers'] module-attribute

ProvisioningPlan(provisioned, auto_tier, choices, unavailable) dataclass

The decision half of first-run passphrase provisioning, frontend-free.

Every frontend renders exactly this: skip when provisioned, provision auto_tier silently when set, otherwise put choices to the operator. unavailable maps any chooser tier the host can't provision to the reason why — a frontend lists it greyed-out and non-selectable instead of hiding it (so the operator sees why a tier is missing) or failing only after the pick. The CLI chooser below and the TUI's modal flow are two renderings of one plan — the decisions themselves are made here, once.

provisioned instance-attribute

auto_tier instance-attribute

choices instance-attribute

unavailable instance-attribute

PassphraseChangeResult(passphrase, generated, rekeyed, rewrites) dataclass

Outcome of change_passphrase.

generated carries the same follow-up duty as TierProvisionResult: a minted value must be revealed to the operator. The recovery marker has been dropped either way — whoever renders this result owns re-running the acknowledgement flow.

passphrase instance-attribute

The new passphrase — mint or caller-supplied.

generated instance-attribute

True iff this call minted the value (caller passed None).

rekeyed instance-attribute

True iff an existing DB was re-encrypted (False on a fresh install where the new value simply becomes the key on first use).

rewrites instance-attribute

Per-tier outcomes, in resolution-chain order.

problems property

The rewrites that failed — non-empty means the operator has cleanup to do.

SessionProvisionResult(written, validated=False, shadowed_durable=None) dataclass

Outcome of provision_session_passphrase.

written is the load-bearing bit: False means the write was refused because a durable tier already resolves the vault, so caching the passphrase would be pointless (shadowed_durable names that tier). Refusal is a normal outcome, not an error — callers report it ("already unlocked via X") rather than raising. validated says whether the written value was test-opened against an existing DB (vs. an empty install where it becomes the key on first use).

written instance-attribute

validated = False class-attribute instance-attribute

shadowed_durable = None class-attribute instance-attribute

TierRewrite(tier, ok, detail) dataclass

What happened to one passphrase-holding tier during a change.

ok means the tier now holds the new passphrase. A failed rewrite (ok=False) is reported, never raised — by the time the fan-out runs the DB is already rekeyed, so aborting would only hide which tiers still need the operator's attention.

tier instance-attribute

ok instance-attribute

detail instance-attribute

__getattr__(name)

Resolve a re-exported registry / handler by importing its module on first access.

Looked up in the module's _LAZY map and cached on the package, so COMMANDS can be built (and the supervisor spawned) without importing any handler module, while from terok_sandbox.commands import _handle_sandbox_setup still works for the executor splice.

Source code in src/terok_sandbox/commands/__init__.py
def __getattr__(name: str) -> Any:
    """Resolve a re-exported registry / handler by importing its module on first access.

    Looked up in the module's ``_LAZY`` map and cached on the
    package, so ``COMMANDS`` can be built (and the supervisor spawned)
    without importing any handler module, while
    ``from terok_sandbox.commands import _handle_sandbox_setup`` still
    works for the executor splice.
    """
    target = _LAZY.get(name)
    if target is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    module_name, _, attr = target.partition(":")
    value = getattr(import_module(f".{module_name}", __name__), attr)
    globals()[name] = value  # cache — the next access skips __getattr__
    return value

__dir__()

List eager globals plus every lazily re-exported name.

Source code in src/terok_sandbox/commands/__init__.py
def __dir__() -> list[str]:
    """List eager globals plus every lazily re-exported name."""
    return sorted({*globals(), *_LAZY})

plan_provisioning(cfg=None)

Probe the host and return the provisioning decisions a frontend should render.

Propagates the fail-closed WrongPassphraseError of a configured-but-broken durable tier (see credentials_provisioned) — surface it as a hard failure, not as "unprovisioned".

Source code in src/terok_sandbox/commands/credentials.py
def plan_provisioning(cfg: SandboxConfig | None = None) -> ProvisioningPlan:
    """Probe the host and return the provisioning decisions a frontend should render.

    Propagates the fail-closed ``WrongPassphraseError`` of a
    configured-but-broken durable tier (see
    [`credentials_provisioned`][terok_sandbox.commands.credentials.credentials_provisioned])
    — surface it as a hard failure, not as "unprovisioned".
    """
    from ..config import SandboxConfig
    from ..vault.store import session_cache, systemd_creds as _systemd_creds
    from ..vault.store.encryption import keyring_backend_available

    if cfg is None:
        cfg = SandboxConfig()
    if credentials_provisioned(cfg):
        return ProvisioningPlan(provisioned=True, auto_tier=None, choices=(), unavailable={})
    auto_tier = PassphraseTier.SYSTEMD_CREDS if _systemd_creds.is_available() else None
    # Every chooser tier is listed; the ones the host can't provision are
    # reported (with the reason) so a frontend can grey them out rather
    # than hide them or fail after the pick.
    unavailable: dict[PassphraseTier, str] = {}
    if not keyring_backend_available():
        unavailable[PassphraseTier.KEYRING] = "no OS keyring backend is reachable on this host"
    if (kernel_reason := session_cache.unavailable_reason()) is not None:
        unavailable[PassphraseTier.KERNEL_KEYRING] = kernel_reason
    return ProvisioningPlan(
        provisioned=False,
        auto_tier=auto_tier,
        choices=CHOOSER_TIERS,
        unavailable=unavailable,
    )

change_passphrase(cfg=None, *, old=None, new=None)

Re-encrypt the vault under a new passphrase and rewrite every tier holding the old one.

The prompt-free core shared by the vault passphrase change CLI verb and the TUI — same contract shape as provision_passphrase_tier: no /dev/tty, no ack prompt; the caller owns the conversation.

old is only consulted when the resolution chain can't produce the current passphrase (locked vault) — when a caller supplies it explicitly it wins over the chain, so a stale tier value can't override an operator who knows better. new None mints a fresh generate_passphrase value (generated=True in the result — reveal it!).

The ordering is the safety argument:

  1. Escrow the new value first (vault_pending_passphrase_file, owner-only, RAM-backed): from this point a crash can never leave the DB encrypted under a key that exists nowhere on the host.
  2. Verify + rekey the DB (rekey_in_place). Every failure here — wrong old passphrase, a live supervisor holding the WAL ("database is locked") — aborts with nothing changed anywhere (the escrow is removed on the way out).
  3. Only then fan the new value out to every tier that currently holds material, in resolution order, collecting per-tier outcomes instead of raising: a tier that can't take the new value (keyring denied, systemd-creds host regressed) is purged where possible so no tier keeps resolving the old passphrase, and reported either way. Once at least one tier holds the new value the escrow is deleted; if every rewrite failed it stays, so the key remains recoverable.
  4. Drop the recovery-acknowledged marker — the saved copy the operator confirmed is now the wrong passphrase.

Refuses up front (RuntimeError) while passphrase_command is configured: that tier's secret lives in a store the operator owns, so the sandbox rewriting everything else would leave the helper resolving a stale value that fails closed on the next boot. Update the external store first, or remove the wiring.

Raises NoPassphraseError when neither the chain nor old yields the current passphrase, WrongPassphraseError when that value doesn't open the DB, and ValueError for an empty or unchanged new.

Source code in src/terok_sandbox/commands/vault.py
def change_passphrase(
    cfg: SandboxConfig | None = None,
    *,
    old: str | None = None,
    new: str | None = None,
) -> PassphraseChangeResult:
    """Re-encrypt the vault under a new passphrase and rewrite every tier holding the old one.

    The prompt-free core shared by the ``vault passphrase change`` CLI
    verb and the TUI — same contract shape as
    [`provision_passphrase_tier`][terok_sandbox.commands.credentials.provision_passphrase_tier]:
    no ``/dev/tty``, no ack prompt; the caller owns the conversation.

    *old* is only consulted when the resolution chain can't produce the
    current passphrase (locked vault) — when a caller supplies it
    explicitly it wins over the chain, so a stale tier value can't
    override an operator who knows better.  *new* ``None`` mints a
    fresh [`generate_passphrase`][terok_sandbox.vault.store.encryption.generate_passphrase]
    value (``generated=True`` in the result — reveal it!).

    The ordering is the safety argument:

    1. Escrow the new value first
       ([`vault_pending_passphrase_file`][terok_sandbox.SandboxConfig.vault_pending_passphrase_file],
       owner-only, RAM-backed): from this point a crash can never leave
       the DB encrypted under a key that exists nowhere on the host.
    2. Verify + rekey the DB ([`rekey_in_place`][terok_sandbox.vault.store.encryption.rekey_in_place]).
       Every failure here — wrong old passphrase, a live supervisor
       holding the WAL ("database is locked") — aborts with **nothing
       changed anywhere** (the escrow is removed on the way out).
    3. Only then fan the new value out to every tier that currently
       holds material, in resolution order, collecting per-tier
       outcomes instead of raising: a tier that can't take the new
       value (keyring denied, systemd-creds host regressed) is purged
       where possible so no tier keeps resolving the *old* passphrase,
       and reported either way.  Once at least one tier holds the new
       value the escrow is deleted; if every rewrite failed it stays,
       so the key remains recoverable.
    4. Drop the recovery-acknowledged marker — the saved copy the
       operator confirmed is now the wrong passphrase.

    Refuses up front (``RuntimeError``) while ``passphrase_command`` is
    configured: that tier's secret lives in a store the operator owns,
    so the sandbox rewriting everything else would leave the helper
    resolving a stale value that fails closed on the next boot.  Update
    the external store first, or remove the wiring.

    Raises [`NoPassphraseError`][terok_sandbox.NoPassphraseError] when
    neither the chain nor *old* yields the current passphrase,
    [`WrongPassphraseError`][terok_sandbox.WrongPassphraseError] when
    that value doesn't open the DB, and [`ValueError`][ValueError] for
    an empty or unchanged *new*.
    """
    from ..vault.store.encryption import (
        NoPassphraseError,
        generate_passphrase,
        is_plaintext_sqlite,
        probe_passphrase_chain,
        rekey_in_place,
    )
    from ..vault.store.recovery import forget as forget_recovery_marker

    cfg = _resolve_cfg(cfg)

    if cfg.credentials_passphrase_command:
        raise RuntimeError(
            "credentials.passphrase_command is configured — the passphrase lives in"
            " an external secret store terok cannot write to.  Update the secret"
            " there first (or remove passphrase_command from config.yml), then"
            " re-run the change."
        )
    if new == "":  # nosec: B105 — rejecting the empty sentinel, not comparing a secret
        raise ValueError("refusing an empty passphrase (SQLCipher reads it as no encryption)")
    if cfg.db_path.exists() and is_plaintext_sqlite(cfg.db_path):
        raise RuntimeError(
            f"{cfg.db_path} is still the legacy plaintext format — run the"
            " encrypt-db migration before changing the passphrase"
        )

    # Explicit *old* wins; the chain fills in for the common unlocked case.
    current = old or cfg.resolve_passphrase()  # raises WrongPassphraseError on a broken tier
    db_exists = cfg.db_path.exists()
    if current is None:
        if db_exists:
            raise NoPassphraseError(
                "the vault is locked — supply the current passphrase to change it"
            )
        raise NoPassphraseError(
            "no credentials DB and no stored passphrase — provision the vault"
            " (setup) instead of changing it"
        )

    generated = new is None
    if new is None:
        new = generate_passphrase()
    if new == current:
        raise ValueError("the new passphrase is identical to the current one")

    from .._yaml import write_secret_text

    if db_exists:
        # Crash-recovery escrow: the rekeyed DB's key must exist on disk
        # *before* the DB is rekeyed — a crash between the rekey and the
        # tier fan-out would otherwise strand the vault under a key
        # nobody has seen (fatal for a freshly minted value).
        write_secret_text(cfg.vault_pending_passphrase_file, new + "\n")
        try:
            rekey_in_place(cfg.db_path, current, new)
        except BaseException:
            # Nothing was modified — don't leave escrow debris behind.
            cfg.vault_pending_passphrase_file.unlink(missing_ok=True)
            raise

    present = [
        row.source
        for row in probe_passphrase_chain(
            credentials_db=cfg.db_path,
            systemd_creds_file=cfg.vault_systemd_creds_file,
            use_keyring=cfg.credentials_use_keyring,
            passphrase_command=cfg.credentials_passphrase_command,
        )
        if row.present
    ]
    if not present:
        # Locked vault changed via an explicitly-supplied *old*: nothing
        # holds material yet, so land the new value where `vault unlock`
        # would — otherwise the change succeeds and nobody can open the DB.
        present = [PassphraseTier.KERNEL_KEYRING]
    rewrites = tuple(_rewrite_tier(cfg, tier, new) for tier in present)

    # The confirmed-saved copy (if any) is now the wrong passphrase —
    # the marker doesn't auto-invalidate (deliberately fingerprint-free,
    # see vault.store.recovery), so the change flow must drop it.
    forget_recovery_marker(cfg.vault_recovery_marker_file)

    if any(rewrite.ok for rewrite in rewrites):
        # At least one tier now holds the new value — the escrow has
        # done its job.  When every rewrite failed it stays behind as
        # the only on-host copy of the key the DB is now encrypted with.
        cfg.vault_pending_passphrase_file.unlink(missing_ok=True)

    # Stamp the rekey so health surfaces can flag supervisors spawned
    # before it — they keep the passphrase they resolved at spawn.
    write_secret_text(cfg.vault_rekey_stamp_file, "")

    return PassphraseChangeResult(
        passphrase=new, generated=generated, rekeyed=db_exists, rewrites=rewrites
    )

handle_vault_seal(*, cfg=None, key='auto')

Seal the credentials-DB passphrase into a systemd-creds credential.

Adds the systemd-creds tier to the resolution chain: machine-bound (TPM2 + host key, or either alone), survives reboot, no OS keyring required. After sealing, every new supervisor resolves the passphrase via systemd-creds decrypt on start — no operator interaction needed at boot, no plaintext-on-disk.

Requires an already-resolvable passphrase — typically from a fresh vault unlock in the current session.

Source code in src/terok_sandbox/commands/vault.py
def handle_vault_seal(*, cfg: SandboxConfig | None = None, key: str = "auto") -> None:
    """Seal the credentials-DB passphrase into a systemd-creds credential.

    Adds the systemd-creds tier to the resolution chain: machine-bound
    (TPM2 + host key, or either alone), survives reboot, no OS
    keyring required.  After sealing, every new supervisor resolves the
    passphrase via ``systemd-creds decrypt`` on start — no operator
    interaction needed at boot, no plaintext-on-disk.

    Requires an already-resolvable passphrase — typically from a fresh
    ``vault unlock`` in the current session.
    """
    from ..vault.store import systemd_creds
    from ..vault.store.encryption import WrongPassphraseError

    cfg = _resolve_cfg(cfg)

    if not systemd_creds.is_available():
        raise SystemExit(
            "systemd-creds unavailable: needs systemd ≥ 257 with the Varlink"
            " io.systemd.Credentials interface (Fedora ≥ 42, Debian ≥ 13)"
        )

    key_mode = _SEAL_KEY_MODES.get(key)
    if key_mode is None:
        choices = ", ".join(sorted(_SEAL_KEY_MODES))
        raise SystemExit(f"unknown --key value: {key!r} (expected one of: {choices})")

    # A prompt here would accept a freshly-typed value and seal *that*,
    # leaving the next chain walk holding a key that doesn't open the DB.
    try:
        passphrase = cfg.resolve_passphrase()
    except WrongPassphraseError as exc:
        raise SystemExit(f"cannot seal: {exc}") from exc
    if passphrase is None:
        raise SystemExit("no current passphrase to seal — run `terok-sandbox vault unlock` first")
    _require_recovery_acknowledged(cfg, tier="systemd-creds")

    try:
        systemd_creds.seal(passphrase, cfg.vault_systemd_creds_file, key_mode=key_mode)
    except RuntimeError as exc:
        # ``tpm2`` requested on a TPM-less host surfaces as a CalledProcessError
        # bubbled to RuntimeError — pass it through with the hint attached.
        raise SystemExit(str(exc)) from exc

    print(f"→ sealed passphrase to {cfg.vault_systemd_creds_file} (--with-key={key_mode})")

    # The passphrase now lives in the durable sealed credential, which
    # outranks the volatile kernel-keyring cache in the chain — so the
    # cache is superfluous residue.  Drop it so the chain resolves from
    # the tier the operator just established (same cleanup ``to-keyring``
    # does).
    from ..vault.store import session_cache

    if not session_cache.forget(cfg.db_path):
        print(
            "⚠ could not clear the now-redundant session cache;"
            " it may still auto-unlock the vault — run `vault lock` to clear it"
        )

    print(
        "  the resolution chain will pick this up the next time a supervisor"
        " starts; no restart required"
    )

handle_vault_to_keyring(*, cfg=None)

Move the current passphrase from its current tier into the OS keyring.

Resolves the passphrase via the chain (or prompts as a last resort), writes it to the keyring, flips credentials.use_keyring to true in config.yml, clears any plaintext credentials.passphrase / credentials.passphrase_command wiring, and removes the kernel-keyring cache and sealed systemd-creds copies.

The validate-before-destroy ordering is deliberate: if the keyring write fails, the source tier is still intact.

Source code in src/terok_sandbox/commands/vault.py
def handle_vault_to_keyring(*, cfg: SandboxConfig | None = None) -> None:
    """Move the current passphrase from its current tier into the OS keyring.

    Resolves the passphrase via the chain (or prompts as a last resort),
    writes it to the keyring, flips ``credentials.use_keyring`` to true
    in ``config.yml``, clears any plaintext ``credentials.passphrase`` /
    ``credentials.passphrase_command`` wiring, and removes the
    kernel-keyring cache and sealed systemd-creds copies.

    The validate-before-destroy ordering is deliberate: if the keyring
    write fails, the source tier is still intact.
    """
    from .. import config as _config
    from .._yaml import update_section as _yaml_update_section
    from ..vault.store.encryption import (
        WrongPassphraseError,
        store_passphrase_in_keyring,
    )

    cfg = _resolve_cfg(cfg)

    try:
        passphrase, source = cfg.resolve_passphrase_with_source(prompt_on_tty=True)
    except WrongPassphraseError as exc:
        raise SystemExit(f"cannot move to keyring: {exc}") from exc

    if not passphrase:
        raise SystemExit("no current passphrase resolvable; run `terok-sandbox vault unlock` first")
    if source == "keyring":
        print("→ passphrase is already in the keyring; nothing to do")
        return
    _require_recovery_acknowledged(cfg, tier="keyring")

    if not store_passphrase_in_keyring(passphrase):
        raise SystemExit("OS keyring is unreachable or denied; aborting (nothing was changed)")
    print(f"→ stored passphrase in keyring (was: {source})")

    # Switch the config's tier wiring atomically: flip use_keyring on,
    # drop the plaintext + helper fallbacks so the chain can't re-resolve
    # via a stale lower tier.
    from ..paths import config_file_paths

    user_config = next((p for label, p in config_file_paths() if label == "user"), None)
    if user_config is not None:
        # nosec: B105 — clearing config keys to None, not hardcoding secrets
        updates = {  # nosec: B105
            "use_keyring": True,
            "passphrase": None,  # nosec: B105
            "passphrase_command": None,  # nosec: B105
        }
        _yaml_update_section(user_config, "credentials", updates)
        _config._credentials_section.cache_clear()
        print(f"→ updated {user_config} (use_keyring: true, plaintext fields cleared)")

    # Remove the old tier's copies.  Sealed systemd-creds outranks
    # keyring on the resolution order, so it must go; the volatile
    # kernel-keyring cache is cleared too so nothing stale lingers.
    from ..vault.store import session_cache

    if not session_cache.forget(cfg.db_path):
        print(
            "⚠ could not clear the kernel-keyring cache;"
            " it may still auto-unlock the vault — run `vault lock` to clear it"
        )
    if cfg.vault_systemd_creds_file.exists():
        cfg.vault_systemd_creds_file.unlink()
        print(f"→ removed {sanitize_tty(str(cfg.vault_systemd_creds_file))}")

provision_session_passphrase(cfg, passphrase, *, force=False)

Validate passphrase against the DB, then cache it in the kernel keyring.

The single writer of the volatile kernel-keyring unlock cache — the CLI vault unlock and terok's TUI unlock modal both funnel through here, so the no-cache and validation guards apply to every caller by construction; neither can store a value the DB rejects, nor cache one redundantly on top of a working durable tier.

Two guards, in order:

  1. No-cache. The cache only earns its keep when no durable tier already unlocks the vault non-interactively. When a durable tier (systemd-creds / keyring) resolves and force is false, nothing is written and the result reports written=False + the durable tier. force (re-key / deliberate override) skips this guard.
  2. Validation. When the DB exists (and isn't a legacy plaintext file) the value is test-opened first; a mismatch raises WrongPassphraseError and nothing is written. A missing DB skips validation (opening it just to check would create it as a side effect) — the value becomes its key on first use.

Raises RuntimeError if the kernel keyring is unavailable on this host (no libkeyutils, CONFIG_KEYS off) — a genuine "can't cache here", distinct from the written=False no-cache refusal.

Source code in src/terok_sandbox/commands/vault.py
def provision_session_passphrase(
    cfg: SandboxConfig, passphrase: str, *, force: bool = False
) -> SessionProvisionResult:
    """Validate *passphrase* against the DB, then cache it in the kernel keyring.

    The single writer of the volatile kernel-keyring unlock cache — the
    CLI ``vault unlock`` and terok's TUI unlock modal both funnel through
    here, so the no-cache and validation guards apply to every caller by
    construction; neither can store a value the DB rejects, nor cache one
    redundantly on top of a working durable tier.

    Two guards, in order:

    1. **No-cache.** The cache only earns its keep when no durable tier
       already unlocks the vault non-interactively.  When a durable tier
       (systemd-creds / keyring) resolves and *force* is false, nothing
       is written and the result reports ``written=False`` + the durable
       tier.  *force* (re-key / deliberate override) skips this guard.
    2. **Validation.** When the DB exists (and isn't a legacy plaintext
       file) the value is test-opened first; a mismatch raises
       [`WrongPassphraseError`][terok_sandbox.WrongPassphraseError] and
       **nothing is written**.  A missing DB skips validation (opening it
       just to check would create it as a side effect) — the value
       becomes its key on first use.

    Raises [`RuntimeError`][RuntimeError] if the kernel keyring is
    unavailable on this host (no ``libkeyutils``, ``CONFIG_KEYS`` off) —
    a genuine "can't cache here", distinct from the ``written=False``
    no-cache refusal.
    """
    from ..vault.store import session_cache
    from ..vault.store.db import CredentialDB
    from ..vault.store.encryption import is_plaintext_sqlite
    from ..vault.store.status import active_durable_source

    if not force:
        shadowed = active_durable_source(cfg)
        if shadowed is not None:
            return SessionProvisionResult(written=False, shadowed_durable=shadowed)

    validated = False
    if cfg.db_path.exists() and not is_plaintext_sqlite(cfg.db_path):
        # Raises WrongPassphraseError / PlaintextDBFoundError on mismatch —
        # deliberately before the write so a bad value never lands.
        CredentialDB(cfg.db_path, passphrase=passphrase).close()
        validated = True
    if not session_cache.store(passphrase, cfg.db_path):
        raise RuntimeError(
            "the session cache is unavailable here"
            f" ({session_cache.unavailable_reason() or 'store failed'});"
            " seal a durable tier instead (vault passphrase seal / to-keyring)"
        )
    return SessionProvisionResult(written=True, validated=validated)

purge_passphrase_tiers(cfg)

Remove every stored copy of the credentials-DB passphrase.

Clears the kernel-keyring cache, the OS keyring entry, the sealed systemd-creds credential, and the credentials.passphrase_command wiring in config.yml — then drops the recovery-acknowledged marker, since it's meaningless once no tier remains. After this the vault can only be reopened by re-supplying the passphrase (vault unlock); it is unrecoverable without an off-host copy.

No prompts and no acknowledgement check: this is the raw destructive action. The lock verb gates it behind a typed-SAVED confirmation when recovery is unacknowledged; panic calls it directly — no questions asked.

Source code in src/terok_sandbox/commands/vault.py
def purge_passphrase_tiers(cfg: SandboxConfig) -> None:
    """Remove every stored copy of the credentials-DB passphrase.

    Clears the kernel-keyring cache, the OS keyring entry, the
    sealed systemd-creds credential, and the
    ``credentials.passphrase_command`` wiring in ``config.yml`` — then
    drops the recovery-acknowledged marker, since it's meaningless once
    no tier remains.  After this the
    vault can only be reopened by re-supplying the passphrase
    (``vault unlock``); it is **unrecoverable** without an off-host copy.

    No prompts and no acknowledgement check: this is the raw destructive
    action.  The ``lock`` verb gates it behind a typed-``SAVED``
    confirmation when recovery is unacknowledged; panic calls it
    directly — no questions asked.
    """
    from ..vault.store import session_cache
    from ..vault.store.encryption import (
        forget_passphrase_in_keyring,
    )

    # Call forget() directly and branch on its result: it distinguishes
    # cleared/absent (True) from a lookup or unlink failure (False), whereas
    # load() reports None for both an absent key and a failed lookup — which
    # would let a live cache survive the purge unnoticed.
    if session_cache.forget(cfg.db_path):
        print("→ cleared session cache")
    else:
        raise SystemExit(
            "failed to clear the session cache; future processes may still auto-unlock from it"
        )

    if cfg.credentials_use_keyring:
        if (keyring_reason := forget_passphrase_in_keyring()) is None:
            print("→ keyring entry cleared or absent")
        else:
            raise SystemExit(
                f"failed to clear the keyring entry ({keyring_reason});"
                " future supervisors may still auto-unlock from keyring —"
                " resolve it and run `vault lock` again"
            )

    config_updates = _forget_config_tier_updates(cfg)
    if config_updates:
        from .. import config as _config
        from .._yaml import update_section as _yaml_update_section
        from ..paths import config_file_paths

        user_config = next((p for label, p in config_file_paths() if label == "user"), None)
        if user_config is not None and user_config.exists():
            _yaml_update_section(user_config, "credentials", config_updates)
            _config._credentials_section.cache_clear()
            for key in config_updates:
                print(f"→ cleared credentials.{key} from config.yml")

    sealed_cred = cfg.vault_systemd_creds_file
    if sealed_cred.exists():
        try:
            sealed_cred.unlink()
        except OSError as exc:
            raise SystemExit(f"failed to remove sealed credential at {sealed_cred}: {exc}") from exc
        print(f"→ removed sealed credential at {sealed_cred}")

    from ..vault.store.recovery import forget as forget_recovery_marker

    forget_recovery_marker(cfg.vault_recovery_marker_file)