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.
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).
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.
__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
__dir__()
¶
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
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:
- 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. - 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). - 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.
- 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
631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 | |
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
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
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:
- 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. - Validation. When the DB exists (and isn't a legacy plaintext
file) the value is test-opened first; a mismatch raises
WrongPassphraseErrorand 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
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.