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='--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 | session-file) instead of the auto-detect / chooser chain. Required on a non-TTY host without systemd-creds — the silent session-file fallback was removed in v0.0.100 because it minted a passphrase the operator never saw and lost it on the first reboot.'))), 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='--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='--comment', help='Comment embedded in the public key (default: tk-main:<scope>)', 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: most recently added)', 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 a scope's public key to stdout", handler=(LazyHandler('terok_sandbox.commands.ssh:_handle_ssh_pub')), args=(ArgDef(name='scope', help='Credential scope'), ArgDef(name='--key-id', help='Specific ssh_keys.id (default: most recently added)', default=None, dest='key_id', type=int), ArgDef(name='--all', help='Print every key assigned to the scope, one per line', action='store_true', dest='all_keys'))), 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='--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='Provision the credentials-DB passphrase for this session (tmpfs file)', handler=(LazyHandler('terok_sandbox.commands.vault:_handle_vault_unlock')), args=(ArgDef(name='--force', action='store_true', help='Write the session file 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, keyring_available) 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. keyring_available lets a frontend grey out the keyring choice up front instead of failing 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

keyring_available 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 a session file would only shadow it (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 systemd_creds as _systemd_creds
    from ..vault.store.encryption import keyring_backend_available

    if cfg is None:
        cfg = SandboxConfig()
    keyring_ok = keyring_backend_available()
    if credentials_provisioned(cfg):
        return ProvisioningPlan(
            provisioned=True, auto_tier=None, choices=(), keyring_available=keyring_ok
        )
    auto_tier = PassphraseTier.SYSTEMD_CREDS if _systemd_creds.is_available() else None
    return ProvisioningPlan(
        provisioned=False,
        auto_tier=auto_tier,
        choices=CHOOSER_TIERS,
        keyring_available=keyring_ok,
    )

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(
            passphrase_file=cfg.vault_passphrase_file,
            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.SESSION_FILE]
    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, so the
    # session file is redundant — and, being higher priority, it would
    # *shadow* the seal until the next reboot wiped it.  Drop it so the
    # chain resolves from the tier the operator just established (same
    # cleanup ``to-keyring`` does).
    if cfg.vault_passphrase_file.exists():
        cfg.vault_passphrase_file.unlink()
        print(f"→ removed now-redundant session file {cfg.vault_passphrase_file}")

    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 session-file 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
    session-file 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 persistent copy.  Session file is removed
    # because the chain prefers it over keyring; sealed systemd-creds
    # likewise outranks keyring on the resolution order.
    for stale in (cfg.vault_passphrase_file, cfg.vault_systemd_creds_file):
        if stale.exists():
            stale.unlink()
            print(f"→ removed {sanitize_tty(str(stale))}")

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

Validate passphrase against the DB, then land it on the session tier.

The single writer of the session-unlock tmpfs file — the CLI vault unlock and terok's TUI unlock modal both funnel through here, so the no-shadow and validation guards apply to every caller by construction; neither can store a value the DB rejects, nor silently shadow a working durable tier.

Two guards, in order:

  1. No-shadow. The session tier is the highest-priority slot, so writing it masks any durable tier (systemd-creds / keyring) underneath — a reboot then wipes the session copy and the operator only thought the sealed key was in use. When a durable tier already resolves and force is false, nothing is written and the result reports written=False + the shadowed 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.
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 land it on the session tier.

    The single writer of the session-unlock tmpfs file — the CLI
    ``vault unlock`` and terok's TUI unlock modal both funnel through
    here, so the no-shadow and validation guards apply to every caller
    by construction; neither can store a value the DB rejects, nor
    silently shadow a working durable tier.

    Two guards, in order:

    1. **No-shadow.** The session tier is the highest-priority slot, so
       writing it masks any durable tier (systemd-creds / keyring)
       underneath — a reboot then wipes the session copy and the
       operator only *thought* the sealed key was in use.  When a durable
       tier already resolves and *force* is false, nothing is written and
       the result reports ``written=False`` + the shadowed 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.
    """
    from .._yaml import write_secret_text
    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
    write_secret_text(cfg.vault_passphrase_file, passphrase + "\n")
    return SessionProvisionResult(written=True, validated=validated)

purge_passphrase_tiers(cfg)

Remove every stored copy of the credentials-DB passphrase.

Clears the session-unlock tmpfs file, 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 session-unlock tmpfs file, 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.encryption import forget_passphrase_in_keyring, load_passphrase_from_keyring

    path = cfg.vault_passphrase_file
    if path.exists():
        path.unlink()
        print(f"→ removed {path}")

    if cfg.credentials_use_keyring:
        if forget_passphrase_in_keyring():
            print("→ cleared keyring entry")
        elif load_passphrase_from_keyring() is None:
            # ``keyring.delete_password`` raises on a missing entry on most
            # backends, which the helper folds to False — a residual entry
            # after that means the backend rejected the delete.
            print("→ keyring entry already absent")
        else:
            raise SystemExit(
                "failed to clear keyring entry;"
                " future supervisors may still auto-unlock from keyring"
            )

    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)