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.
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.
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"
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:
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")
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.
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")
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.
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"
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.
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)]
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.