Skip to main content

Overview

Callbacks let you hook into the SmolVM command lifecycle. You subclass Callback, override the hooks you care about, and pass instances to SmolVM(callbacks=[...]). Every other hook is a no-op by default. For a task-oriented walkthrough, see Run callbacks and safety hooks.

Callback

Base class for SmolVM command-lifecycle callbacks. Subclass it and override only the hooks you need.
from smolvm import Callback

class MyCallback(Callback):
    def on_pre_run(self, ctx): ...
    def on_post_run(self, ctx): ...
    def on_run_error(self, ctx): ...

Hooks

Each hook receives a single RunContext argument.

on_pre_run

def on_pre_run(self, ctx: RunContext) -> None
Called before a command is sent to the guest. This is the veto channel — if it raises, the command is aborted and the exception propagates to the caller of run(). Raise CommandBlockedError for an explicit, typed block. A blocked command keeps the SSH or vsock connection open, so the next allowed run() call reuses it.

on_post_run

def on_post_run(self, ctx: RunContext) -> None
Called after a command completes successfully. ctx.result is populated. Observer hook — exceptions are logged and swallowed so a faulty observer cannot break a command that already ran.

on_run_error

def on_run_error(self, ctx: RunContext) -> None
Called when the transport raised while executing a command. ctx.error is populated. Observer hook — exceptions are logged and swallowed, and the original transport error still propagates from run().

RunContext

Dataclass passed to every hook for a single SmolVM.run() call. Using one object means new fields can be added later without changing any callback’s method signature.
@dataclass
class RunContext:
    vm_id: str
    command: str
    shell: str
    timeout: int
    result: CommandResult | None = None
    error: Exception | None = None
vm_id
str
The VM the command targets.
command
str
The shell command as passed to run().
shell
str
Execution mode — "login" or "raw".
timeout
int
Per-command timeout in seconds.
result
CommandResult | None
default:"None"
The command result. None until on_post_run. See CommandResult.
error
Exception | None
default:"None"
The transport error raised during execution. None unless the hook is on_run_error.

CommandBlockedError

Exception type for vetoing a command from on_pre_run. Inherits from SmolVMError.
class CommandBlockedError(SmolVMError):
    def __init__(
        self,
        message: str,
        vm_id: str | None = None,
        command: str | None = None,
    ) -> None: ...
message
str
required
Human-readable reason for the block.
vm_id
str | None
default:"None"
ID of the VM the command targeted. Stored on the exception and in details.
command
str | None
default:"None"
The blocked command string. Stored on the exception and in details.
Attributes:
  • vm_id (str | None): The VM the command targeted.
  • command (str | None): The blocked command string.
  • message (str): Reason passed to the constructor.
  • details (dict): Contains vm_id and command.

Example

A pre-run hook that blocks a few known-dangerous commands, plus a post-run hook that logs every command:
from smolvm import SmolVM, Callback, CommandBlockedError

class SafetyGuard(Callback):
    DENY = ("rm -rf /", "mkfs", ":(){ :|:& };:")

    def on_pre_run(self, ctx):
        if any(bad in ctx.command for bad in self.DENY):
            raise CommandBlockedError(
                f"Blocked unsafe command: {ctx.command!r}",
                vm_id=ctx.vm_id,
                command=ctx.command,
            )

class AuditLog(Callback):
    def on_post_run(self, ctx):
        print(f"[{ctx.vm_id}] {ctx.command!r} -> exit={ctx.result.exit_code}")

with SmolVM(callbacks=[SafetyGuard(), AuditLog()]) as vm:
    vm.run("echo hello")
    try:
        vm.run("rm -rf /")
    except CommandBlockedError as e:
        print(f"refused: {e.command}")
Callbacks fire in the order they were registered.
Last modified on June 2, 2026