Skip to content

Guide 06 of 9

Scopes, policy, and hooks

Narrow what a block of code may run, bind a program to the executable it should run, and run code around every command.

Plate 15 A gate valve with its wedge half lowered decides how much passes, as a scope's policy and hooks decide what may run.

Apply a policy

A catalogue decides which commands can be built. A scope decides which may run inside a block of code, and a nested scope can only narrow its parent. scoped(catalogue=...) takes its allowlist straight from a catalogue, and also activates that catalogue for sh.make, so a builder made inside the block need not name it again. Registrations such as env, before, after, and observe are local to the current context and are removed when their block exits; none of them changes the process's own environment.

scoped_policy.py Tested
import sys

from cuprum import Program, ProgramCatalogue, env, scoped, sh

catalogue = ProgramCatalogue.from_programs(sys.executable, name="policy")
python = sh.make(Program(sys.executable), catalogue=catalogue)
with scoped(catalogue=catalogue), env(CUPRUM_EXAMPLE="scoped"):
    result = python("-c", "import os; print(os.getenv('CUPRUM_EXAMPLE'))").run_sync()
assert result.stdout == "scoped\n"
Run 2026-10-19 against Cuprum 0.2.0b2 · Python 3.12 · Linux

Use ScopeConfig when a catalogue alone is not enough: it takes an allowlist, hooks, a default timeout, environment settings, a catalogue, and executable bindings. A scope that supplies only an allowlist leaves the active catalogue unchanged.

Two enforcement points

Cuprum checks the catalogue when a builder is created and the allowlist when a command runs, and the two answer different questions. sh.make() checks membership: it resolves its catalogue from an explicit catalogue= argument, then the innermost active scoped(catalogue=...), then DEFAULT_CATALOGUE outside any catalogue scope, and raises UnknownProgramError if that catalogue does not list the program. Resolution happens once, so the builder keeps its catalogue after the scope exits. The active scope checks permission: when a command runs, the allowlist in force decides whether the program may execute, and run() raises ForbiddenProgramError before a process starts if it may not. A builder made from a catalogue the enclosing scope does not allow therefore constructs successfully and fails only at run time:

enforcement_points.py Tested
import sys

from cuprum import (
    ForbiddenProgramError,
    Program,
    ProgramCatalogue,
    current_context,
    scoped,
    sh,
)

allowed = ProgramCatalogue.from_programs(sys.executable, name="allowed")
foreign = ProgramCatalogue.from_programs(Program("some-other-tool"), name="foreign")
other = Program("some-other-tool")

with scoped(catalogue=allowed):
    # Construction consults the named catalogue, so this succeeds.
    command = sh.make(other, catalogue=foreign)("--version")
    # Execution consults the scope, which does not allow the program.
    assert not current_context().is_allowed(other)
    try:
        command.run_sync()
    except ForbiddenProgramError:
        pass
    else:
        raise AssertionError("the scope should forbid the foreign program")
Run 2026-10-19 against Cuprum 0.2.0b2 · Python 3.12 · Linux

Naming a catalogue explicitly is how a caller opts out of the scope's catalogue for construction; it never opts out of the scope's allowlist for execution. The ready-made builders in cuprum.builders resolve the active catalogue the same way, so inside a catalogue scope that catalogue must list the tool.

Restrict a block to specific programs

Inside a narrowed scope, running anything outside the allowlist raises ForbiddenProgramError, a PermissionError subclass, before a process starts. current_context().is_allowed() checks a program without running it.

restricted_scope.py Tested
import sys

from cuprum import (
    ForbiddenProgramError,
    Program,
    ProgramCatalogue,
    ScopeConfig,
    current_context,
    scoped,
    sh,
)

PYTHON = Program(sys.executable)
OTHER = Program("some-other-tool")
catalogue = ProgramCatalogue.from_programs(PYTHON, OTHER, name="policy")
python = sh.make(PYTHON, catalogue=catalogue)

with scoped(ScopeConfig(allowlist=frozenset([OTHER]))):
    assert not current_context().is_allowed(PYTHON)
    try:
        python("-c", "pass").run_sync()
    except ForbiddenProgramError:
        pass
    else:
        raise AssertionError("the scope should forbid the interpreter")
Run 2026-10-19 against Cuprum 0.2.0b2 · Python 3.12 · Linux

allow is the one way to widen policy: it adds programs to the current allowlist, even inside a restricted scope, until its registration is detached or its block exits. Called in the default unrestricted context, it restricts that context to exactly the programs it names. Review every allow() as a policy change.

Bind a program to the executable it should run

A Program is the catalogue's name for a program, and by default it is also the string the operating system executes. A build may need a pinned absolute path, a virtual-environment interpreter, or a controlled replacement during a test. bind_executable supplies the second without changing the first: inside the scope the logical program runs the bound executable while the allowlist, project metadata, and telemetry keep carrying the logical name, and CommandResult.resolved_path reports what ran, or None when nothing was bound.

executable_bindings.py Tested
import sys

from cuprum import Program, ProgramCatalogue, ScopeConfig, bind_executable, scoped, sh

PYTHON = Program("python")
OTHER = Program("some-other-tool")
catalogue = ProgramCatalogue.from_programs(PYTHON, OTHER, name="bindings")
python = sh.make(PYTHON, catalogue=catalogue)

with (
    scoped(ScopeConfig(allowlist=frozenset([PYTHON]))),
    bind_executable(PYTHON, sys.executable),
):
    result = python("-c", "import sys; sys.stdout.write(sys.executable)").run_sync()
    assert result.program == PYTHON, "the logical identity is preserved"
    assert result.resolved_path == sys.executable, "the bound path was executed"
    assert (result.stdout or "").strip() == sys.executable, "the child agrees"
Run 2026-10-19 against Cuprum 0.2.0b2 · Python 3.12 · Linux

A binding never widens the allowlist: binding an unlisted program and running it still raises ForbiddenProgramError, and the resolver is not called. Nested scopes override only the program they name. The second argument is a path, any str or os.PathLike, or a zero-argument resolver returning the executable string, called once per execution at spawn time; anything else is rejected with TypeError at once. A relative binding is anchored at ExecutionContext.cwd when one is set, and otherwise left to the platform, so prefer an absolute binding when the same file must run wherever the execution starts.

A resolver must return a str, because None means "no binding". At the execution boundary, whatever a resolver raises or returns wrongly is reported as ExecutableResolutionError, a RuntimeError naming the logical program with the original exception as its __cause__. Construction rejects an empty path, a relative path unless allow_relative=True, and NUL or .. segments with InvalidExecutableBindingError, a ValueError. That validation is advisory: the file may still be replaced between the check and the exec, so keeping the bound path unwritable to the executing user is the operator's responsibility.

Run code around every command

before receives each command before it starts, and after receives the command and its result once it finishes. Before hooks run in registration order and after hooks in reverse, so an outer scope's setup runs first and its cleanup last. logging_hook pairs the two to write start and exit records through the standard logging module.

hooks.py Tested
import logging
import sys

from cuprum import Program, ProgramCatalogue, after, before, logging_hook, sh

catalogue = ProgramCatalogue.from_programs(sys.executable, name="hooks")
python = sh.make(Program(sys.executable), catalogue=catalogue)
seen = []


def note_start(cmd):
    seen.append(("before", cmd.argv[-1]))


def note_finish(cmd, result):
    seen.append(("after", result.exit_code))


logger = logging.getLogger("myapp.commands")
with before(note_start), after(note_finish), logging_hook(logger=logger):
    python("-c", "pass").run_sync()
assert seen == [("before", "pass"), ("after", 0)]
Run 2026-10-19 against Cuprum 0.2.0b2 · Python 3.12 · Linux

A before or after hook is application code: an exception from one reaches the caller and can stop a run. Keep hooks that must never affect a run defensive, and use observe for lifecycle events that only report.