Capability-scoped research workspaces
August 20, 2026 ยท View on GitHub
Python exposes two distinct persistent execution surfaces:
InterpreterWorkspaceis the defaultrestricted_scratchprofile. It is an in-process convenience for plain data exploration and does not provide file, import, subprocess, network, or credential access.ResearchWorkspaceis an opt-in surface fortrusted_localorisolated_sandboxprofiles. Every elevated capability must appear in the request and be approved before the workspace opens.
import sys
from autocontext.execution.research_workspace import (
ResearchWorkspace,
WorkspaceCapabilityRequest,
)
request = WorkspaceCapabilityRequest(
workspace_id="paper-analysis",
profile="trusted_local",
requested_capabilities=frozenset({
"workspace_read",
"workspace_write",
"package_import",
"subprocess",
}),
allowed_imports=frozenset({"statistics"}),
allowed_commands=frozenset({sys.executable}),
lifecycle="delete_on_close",
)
workspace = ResearchWorkspace(request, approver=lambda request: True)
workspace.runtime_env.write_file("samples.txt", "1\n3\n8\n")
workspace.run("""
import statistics
def summarize(text):
return statistics.mean(int(line) for line in text.splitlines())
mean = summarize(workspace_read_text("samples.txt"))
""")
workspace.run("workspace_write_text('result.txt', str(mean))")
snapshot = workspace.snapshot()
cleanup = workspace.close()
Grant and isolation model
The request records the profile, requested grants, package and command
allowlists, network-host allowlist, limits, approval context, and lifecycle
policy. Elevated profiles default to deny when no approval hook is supplied.
Network is a separate grant and only permits HTTPS hosts listed in the request.
Redirects are denied instead of inheriting trust from the original URL.
Credentialed or authoritative operations stay in the host control plane and
use the typed host_call() bridge; credentials are never installed in the
candidate namespace or child-process environment.
Capable executions run in killable child processes. Each call works against a staged copy of the workspace and commits variables and files only after a successful result. A timeout terminates the child and discards that stage, so it cannot keep mutating persistent state. Imports, subprocesses, output, files, and network responses are bounded by explicit request limits. Path traversal, symbolic-link snapshots, unapproved imports/commands, and ungranted network or host calls fail closed.
package_import and subprocess remain host-powerful in the explicitly
approved trusted_local profile. isolated_sandbox never uses that local
child-process path: construction requires a ResearchSandboxBackend attesting
OS isolation, workspace mounts, network policy, process limits, environment
scrubbing, transactional files, terminable execution, and verified cleanup.
Missing controls fail before candidate code runs.
An isolated request cannot combine package_import and subprocess. Python
packages can retain process-capable objects in function globals, which would
bypass a command-name allowlist even when their public module attributes are
wrapped. Deployments needing both must provide a lower-layer process broker;
the shipped backend fails closed instead of treating an object facade as a
security boundary.
The shipped DockerResearchSandboxBackend is a concrete deny-network backend.
It uses the same pinned clean Python image as hermetic remote scenarios, a
read-only root filesystem, only the generated runtime-input, workspace, and
result-output bind mounts (no repository or unrelated host mount),
dropped Linux capabilities, no-new-privileges, a non-root UID, PID/CPU/memory
limits, a bounded tmpfs, and an explicit environment. A timeout forcibly
removes the named container. Every container carries its execution deadline;
restart reconciliation removes only containers whose deadline has expired.
Live containers owned by another worker, and legacy containers without a
verifiable deadline, are never guessed to be orphans. The task-runner factory
shares one backend across concurrent workspaces, while the deadline contract
also makes independent worker processes safe. It does not pretend a Docker
bridge plus Python hostname check is an
egress firewall: any request for network access fails. Deployments needing
allowlisted egress must supply a backend that enforces DNS, redirect, private
range, and subprocess policy below the container.
Opaque WorkspaceSecretGrant references remain in the host control plane.
Docker rejects callbacks that resolve a credential value for candidate use;
an optional broker can perform only the operations allowlisted on the grant
through credential_call(). Neither references nor credential values enter
the container environment, payload, workspace, or result artifact.
Aggregate workspace byte and inode quotas apply in addition to the per-file limit. Docker reconciles expired labeled orphan containers before every execution, and removal or verification failures propagate through the typed cleanup result instead of being logged as successful cleanup.
Snapshots contain plain built-in variables, helper-function source, and
workspace-rooted files. snapshot(), restore(), and close() are explicit;
owned roots can be deterministically deleted with delete_on_close. Audit
events record the profile, grants, resource action, outcome, limits/usage
detail, host calls, and cleanup result.
The standard-library trusted_local process boundary is not a hostile-code VM.
Docker supplies a real mount/network/process boundary but still shares the host
kernel; deployments whose threat model requires a separate kernel should use a
microVM implementation of ResearchSandboxBackend. Both are explicit choices,
and neither can silently become trusted_local.
Runtime responsibilities
The Python control plane owns the research interpreter and its process
lifecycle. Files are exposed through the existing RuntimeWorkspaceEnv
boundary, so a TypeScript session may coordinate the same declared workspace,
commands, and artifacts without embedding Python execution. TypeScript does
not currently implement a parallel research interpreter; this is an explicit
runtime asymmetry, not an implicit fallback.
benchmark_research_workspace() provides a deterministic three-generation
acceptance task. It records task quality, prompt-size samples, wall time, and
cleanup outcomes against the restricted baseline.
The live TaskRunner can select this path without application-specific
injection. The following fail-closed settings make multi-generation queued
candidates executable, preserve the accepted workspace state across
generations, and judge stdout plus the structured answer mapping:
export AUTOCONTEXT_WORKSPACE_INTERPRETER_ENABLED=true
export AUTOCONTEXT_WORKSPACE_INTERPRETER_BACKEND=docker
export AUTOCONTEXT_WORKSPACE_INTERPRETER_EXECUTE_CANDIDATES=true
export AUTOCONTEXT_WORKSPACE_INTERPRETER_CAPABILITIES_APPROVED=true
export AUTOCONTEXT_WORKSPACE_INTERPRETER_ALLOWED_IMPORTS='["statistics"]'
export AUTOCONTEXT_WORKSPACE_INTERPRETER_ALLOWED_COMMANDS='[]'
Candidate execution is accepted only with the Docker backend and explicit
operator approval; selecting the legacy interpreter for this mode raises a
configuration error. The image is pinned by digest by default, and memory,
CPU, PID, timeout, per-file bytes, aggregate workspace bytes/inodes, import,
and command settings are independently configurable.
No network setting exists for the shipped Docker backend because its only
supported policy is deny. Workspace execution and cleanup audit events are
included in the persisted evolution trajectory. Applications may still inject
an EvolutionWorkspace factory and evaluation callback for a different
lower-layer sandbox implementation.