Skip to content

Python safe mode — "ambient sources only"

A python step in mode: safe is a sandboxed Python function call used by the preprocessor and postprocessor. This page documents the property authors can rely on when deciding whether to put a step in safe mode, and which stdlib modules are safe to import.

Rename note: safe was previously called pure (renamed in FP-0014). The companion mode unsafe was previously called trusted.

Formal property: ambient sources only

A mode: safe python step's output is determined entirely by:

  1. The input artifact (= explicit args_from dependencies)
  2. Ambient sources, defined as:
  3. Clock: time, datetime.now() — system wall clock + monotonic time
  4. Entropy: random, secrets/dev/urandom-backed PRNG / CSPRNG
  5. Bundled static data: zoneinfo — IANA TZ database shipped with Python

Filesystem, network, subprocess, and environment access are syntactically unreachable from a mode: safe step. The allowlist enforces this at import time.

Why "ambient" instead of "pure"

A literal "pure function" interpretation would exclude time.time() and random.random() because both depend on hidden global state. But excluding them would force every mode: safe step to receive clock/entropy as an explicit input artifact — impractical, and not what authors expect.

The "ambient sources" framing acknowledges that some non-determinism is acceptable as long as the source is well-defined and the value is not under operator/attacker control.

The single property

mode: safe: A python step's output is determined ONLY by its input artifacts plus ambient sources — the wall clock, an entropy stream, and bundled stdlib static data. Filesystem, network, process, and environment access are syntactically unreachable.

This is the property the AST validator and the subprocess sandbox jointly enforce. If you want to know whether a new stdlib module would be safe in the allowlist, ask: can every public call be satisfied from {inputs, clock, entropy, bundled static data}? If yes, it is ambient. If it needs anything that the operator could change without redeploying Python — files, environment variables, the network — it is not.

Ambient vs non-ambient at a glance

Class What it means Allowed examples
Inputs The artifact passed into the step the artifact parameter
Clock Current time as seen by the OS time.time(), datetime.now()
Entropy OS-provided randomness random, secrets
Bundled static data Files shipped with the Python install zoneinfo (tz database)

Everything else is non-ambient and stays out of safe mode:

Non-ambient class Why it is excluded Typical modules
Filesystem ingress Reads operator-controlled state pathlib, glob, os.path, open
Filesystem egress Mutates operator-visible state open(..., "w"), shutil
Network External, unbounded, latency-bearing urllib, requests, socket, http
Process control Side effects outside the sandbox subprocess, os.system, os.fork
Environment Operator-tunable input that the step does not declare os.environ, os.getenv
Dynamic code Bypasses every other check eval, exec, compile, __import__

Why some seemingly-I/O modules are allowed

A few entries in the allowlist look like I/O at a glance. They are kept because their I/O is ambient — bundled with the Python install or served from a managed kernel facility — not operator-tunable workspace state:

  • zoneinfo reads timezone files, but those files are shipped with Python (or the host's tzdata package). Given the same Python install, the answer is deterministic. The step cannot observe operator-edited files this way.
  • random and secrets pull from the OS entropy stream. That is non-deterministic, but it is ambient — the step cannot use it to read workspace state, only to produce fresh bits.
  • time / datetime.now() read the wall clock. Non-deterministic, but ambient and side-effect-free.
  • hashlib / hmac are pure compute over their arguments.

The shared property: each of these is satisfiable from {inputs, clock, entropy, bundled static data} alone. None of them lets the step learn anything about the operator's filesystem, network, or environment that the step did not already receive as input.

Reading the allowlist

Each entry in src/reyn/kernel/_python_allowlist.py carries a short inline comment explaining why it satisfies the contract. The categories are:

  • # ambient: ... — falls under the formal property above (clock / entropy / bundled static data)
  • # restricted to ... — admits the module but only pure operations (e.g. pathlib.PurePath, not Path.read_text())
  • # pure — no ambient access at all (e.g. math, re)

Currently-allowed stdlib modules

Module Ambient class Rationale
math, cmath, statistics pure compute Math functions over numeric inputs only
decimal, fractions, numbers pure compute Arbitrary-precision and rational arithmetic; ABC only
string, re, textwrap pure compute String constants, regex, text wrapping — no I/O
unicodedata pure compute Unicode property tables compiled into CPython; no file I/O at runtime
json, base64, binascii pure compute Serialisation / codec over byte/string inputs
hashlib, hmac pure compute Cryptographic hash computation over inputs
collections, itertools, functools, operator, copy pure compute Container types, iterator combinators, higher-order functions
enum, dataclasses, typing, abc pure compute Type infrastructure; no runtime state
__future__ pure: compiler directives Compiler flags only (annotations, division); no runtime capability
random ambient: entropy /dev/urandom-seeded PRNG — entropy I/O, not operator-state
secrets ambient: entropy /dev/urandom-backed CSPRNG — entropy I/O, not operator-state
time ambient: clock System wall clock + monotonic clock
datetime ambient: clock datetime.now() reads the wall clock; date arithmetic is pure
calendar pure compute Calendar arithmetic; no system clock read at any call site
zoneinfo ambient: bundled static data IANA TZ database shipped with Python — same install = deterministic

The list of record is src/reyn/kernel/_python_allowlist.py. A project may extend it via python.allowed_modules in reyn.yaml; the same "ambient sources only" property is the bar for any extension.

Stdlib auto-allow contract

reyn run applies different auto-allow rules depending on whether the skill is a stdlib skill or a user skill:

Context mode: safe mode: unsafe
stdlib skill via reyn run (non-interactive) auto-allowed (no prompt) auto-allowed (no prompt)
user skill (reyn/project/, reyn/local/) non-interactive auto-allowed (no prompt) requires --allow-unsafe-python or interactive approval
user skill interactive run auto-allowed (no prompt) startup approval prompt

The non-interactive auto-allow for user-skill mode: safe was added to mirror the same behavior already in place for eval/CI runs (see permission model).

How to refactor an unsafe step to safe

If your python step reads a file or calls a service, extract the I/O into a preceding run_op step. The python step then receives the result as a plain input and becomes a pure function of that value.

Before (unsafe — reads a file inside python):

preprocessor:
  - type: python
    mode: unsafe
    fn: |
      import pathlib
      text = pathlib.Path(artifact["config_path"]).read_text()
      return {"lines": text.splitlines()}

After (safe — I/O in run_op, compute in python):

preprocessor:
  - type: run_op
    op: read_file
    args:
      path: "{{ artifact.config_path }}"
    output_key: config_text

  - type: python
    mode: safe
    args_from: [artifact, data.config_text]
    fn: |
      return {"lines": config_text.splitlines()}

The pattern: split I/O from compute. Put the I/O in a run_op (where it gets its own permission gate and event log entry per P6); put the computation in a mode: safe python step.

How to extend — when safe is not enough

If your step needs a capability that is not ambient — reading a file the operator chose, calling an HTTP service, spawning a process, or reaching into os.environ — do not request a new entry in the allowlist. Instead, use type: run_op in the preprocessor / postprocessor chain.

run_op is the proper escape hatch:

  • it goes through the OS op runtime, with its own permission gate;
  • its capabilities are explicit (e.g. read_file, http_request) rather than implicit-via-import;
  • it leaves an event log entry per call, so the audit story for non-ambient access stays intact (P6).

In short: safe python is for deterministic-ish computation over inputs + ambient sources. Everything else is a run_op.

See also