Quick Start
A CONSTRAIN verdict stops a Temporal activity before its body runs and executes a registered command in the sandbox instead. This page gets you there.
Requirements
| Platform | Required | How you get it |
|---|---|---|
| macOS 26 on Apple Silicon | curl and /usr/bin/sandbox-exec | Both ship with macOS. Install nothing. |
| Linux x86_64 | curl and the bwrap binary | Install the bubblewrap package. The kernel must permit unprivileged namespaces. |
You also need uv and an OPENBOX_API_KEY from a registered agent.
Registering an agent gives it a DID, and Require signed requests is then on by default. That setting is a checkbox you control, in Agent > Settings. An agent with no DID does not show the checkbox and accepts unsigned requests.
While the setting is on, Core rejects any request this example sends, because
the example does not sign. It passes openbox_url and openbox_api_key and
nothing else.
Choose one:
- Clear Require signed requests. The agent keeps its DID and signing key, so you can switch signing back on whenever you want.
- Or pass
agent_didandagent_private_keytoOpenBoxPlugin, using the values from the credentials dialog shown once at registration.
The failure is easy to misread. Core answers 401, and the SDK reports
OpenBoxAuthError: Invalid API key. The key is usually fine. The signature is
what is missing.
A Temporal server must be running before step 4. Leave this in its own terminal:
temporal server start-dev
1. Provision the sandbox
Download the launcher into a directory you own, then provision. The v0.1.0-dev tag selects the development release line, whose default policy permits /usr/bin/curl to reach example.com:443. Provisioning explains the release lines and every flag.
curl -fL -O https://github.com/OpenBox-AI/openbox-sandbox/releases/download/v0.1.0-dev/obs-darwin-arm64
curl -fL -O https://github.com/OpenBox-AI/openbox-sandbox/releases/download/v0.1.0-dev/SHA256SUMS
shasum -a 256 -c SHA256SUMS 2>/dev/null | grep obs-darwin-arm64
chmod +x obs-darwin-arm64 && mv obs-darwin-arm64 obs
./obs provision --detach
Verify the download before you rename it. SHA256SUMS lists the release
filename, so the check only works while the file still carries it. Assets you
did not download report FAILED open or read, which is why the check is
filtered to the one line that matters. On Linux, use sha256sum -c SHA256SUMS.
--detach leaves the service running in the background so the rest of this
page works in one terminal. Without it the service runs in the foreground and
Ctrl-C stops it, which is the better shape for watching what it does. Use
--systemd on Linux to have systemd supervise it and restart it on failure.
On Linux, download obs-linux-x86_64 instead.
The launcher verifies each asset, compiles and pins the sandbox profile, starts the mTLS service, runs one smoke execution, and writes ~/.config/openbox-sandbox/agent.env.
2. Create the rule
The verdict comes from your agent's configuration, not from the code. A policy rule matches the activity by name, and one activity is enough.
- Open the agent, select Authorize, then the Policies tab, then Create Rule.
- Set the verdict to
CONSTRAIN. - Add the condition: field
activity_type, operatorequals, valuepost_payment_batch. - Select Deploy.
Without this rule the verdict is ALLOW and the activity runs on the host.
3. Write the Worker
uv init
rm main.py
uv add openbox-temporal-sdk-python temporalio httpx
uv init writes a main.py. Remove it, because uv run python . runs
__main__.py.
The example is two files, workflow.py and __main__.py.
The workflow goes in its own module. The Worker re-imports the workflow module
inside the Temporal workflow sandbox, and that sandbox rejects a module that
can perform I/O. Keeping the workflow away from httpx and the OpenBox imports
satisfies it.
The workflow imports nothing from OpenBox. It calls its own activity, and the plugin decides where that activity runs.
from datetime import timedelta
from temporalio import workflow
@workflow.defn
class PaymentBatchWorkflow:
@workflow.run
async def run(self, batch: dict) -> dict:
return await workflow.execute_activity(
"post_payment_batch",
batch,
start_to_close_timeout=timedelta(minutes=2),
)
import asyncio
import os
import time
from pathlib import Path
from temporalio import activity
from temporalio.client import Client
from temporalio.worker import Worker
import httpx
from openbox import OpenBoxPlugin
from openbox.sandbox import SandboxConfig
from openbox.sandbox.registry import (
GovernedCommandDefinition,
GovernedCommandRegistry,
IdentifierResultField,
IntegerResultField,
LiteralArgument,
TypedJsonResultSchema,
)
from workflow import PaymentBatchWorkflow
TASK_QUEUE = "payment-demo"
@activity.defn
async def post_payment_batch(batch: dict) -> dict:
"""Under CONSTRAIN this body never runs."""
async with httpx.AsyncClient() as client:
response = await client.get("https://example.com")
return {"status": "posted", "http_status": response.status_code}
def posting_registry() -> GovernedCommandRegistry:
return GovernedCommandRegistry(
commands=(
GovernedCommandDefinition(
command_id="post-batch",
executable="/usr/bin/curl",
arguments=(
LiteralArgument("-s"),
LiteralArgument("-o"),
LiteralArgument("/dev/null"),
LiteralArgument("-w"),
LiteralArgument(
'{"http_status":%{http_code},'
'"local_ip":"%{local_ip}",'
'"remote_ip":"%{remote_ip}"}'
),
LiteralArgument("https://example.com/"),
),
result_schema=TypedJsonResultSchema(
name="sandbox-http",
fields=(
IntegerResultField("http_status", minimum=0, maximum=999),
IdentifierResultField("remote_ip"),
IdentifierResultField("local_ip"),
),
),
),
)
)
async def main() -> None:
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue=TASK_QUEUE,
workflows=[PaymentBatchWorkflow],
activities=[post_payment_batch],
plugins=[
OpenBoxPlugin(
openbox_url=os.environ["OPENBOX_URL"],
openbox_api_key=os.environ["OPENBOX_API_KEY"],
sandbox=SandboxConfig(
registry=posting_registry(),
service_config=Path(os.environ["OPENBOX_SANDBOX_CONFIG_PATH"]),
policy=Path(os.environ["OPENBOX_SANDBOX_POLICY_FILE"]),
ca=Path(os.environ["OPENBOX_SANDBOX_CA"]),
certificate=Path(os.environ["OPENBOX_SANDBOX_CERT"]),
private_key=Path(os.environ["OPENBOX_SANDBOX_KEY"]),
),
)
],
)
async with worker:
handle = await client.start_workflow(
PaymentBatchWorkflow,
{"profile_id": "post-batch", "arguments": []},
id=f"payment-demo-{int(time.time())}",
task_queue=TASK_QUEUE,
)
print(await handle.result())
asyncio.run(main())
The registry is the only sandbox definition you write. It pins the executable and every argument, so workflow input can select a command but never construct one. Command Profiles covers the argument and result types.
The activity input selects that command: profile_id names it, and arguments fills any token that is not a literal.
4. Run it
export OPENBOX_URL=https://core.openbox.ai OPENBOX_API_KEY=<your-key>
set -a && . "$HOME/.config/openbox-sandbox/agent.env" && set +a
uv run python .
The second line loads the sandbox boundary values that provisioning generated. set -a exports them, so the Worker process inherits them.
{'cleanup_status': 'deleted', 'disposition': 'executed_in_sandbox',
'exit_code': 0, 'profile_id': 'post-batch', 'stderr_bytes': 0,
'stdout_bytes': 66, 'timeout_status': 'not_observed',
'typed_result': {'schema_name': 'sandbox-http',
'values': [{'name': 'http_status', 'value': 200},
{'name': 'remote_ip', 'value': '127.0.0.1'},
{'name': 'local_ip', 'value': '127.0.0.1'}]}}
disposition is executed_in_sandbox, so the activity body never ran. Both addresses are 127.0.0.1 because the native provider reaches the destination through its loopback policy proxy. The OpenShell provider reports the guest network address instead.
5. Prove the network policy
Change the last LiteralArgument to https://api.github.com/ and run it again. The workflow now fails, which is the correct outcome.
The command still runs in the sandbox. The proxy compares the destination with the pinned policy, refuses it, and curl exits 56:
exit_code: 56
stdout: {"http_status":000,"local_ip":"127.0.0.1","remote_ip":"127.0.0.1"}
evidence: [{'decision': 'denied', 'host': 'api.github.com', 'port': 443}]
curl writes 000 for a request it never completed. That is not a valid JSON number, so the typed result schema rejects the output and the activity fails closed:
ApplicationError: GovernedCommandResultInvalid: Governed command typed result rejected
A refused destination therefore surfaces as a failed activity, not as a result carrying exit code 56. Select the Verify tab, pick the session for the run, switch the view to Tree, and expand the sandbox_execution span for the recorded denial. Console Evidence lists every field.
Troubleshooting
The workflow returns the activity result unchanged
No rule matched, so the verdict was ALLOW and the body ran on the host.
Check the rule condition against the activity name.
GovernedCommandConfigurationRequired
The Worker has no sandbox configuration. Confirm that one OpenBoxPlugin
receives sandbox=SandboxConfig(...), that the requested profile exists in
the registry, and that agent.env is loaded in the Worker process.
GovernedCommandInputError: governed command input rejected
A policy rule constrained an activity whose input carries no profile_id.
Add profile_id to the workflow input.
OpenBoxAuthError: Invalid API key
The key is often correct. Registering an agent turns Require signed requests
on, and this example does not sign, so Core answers 401. Clear that checkbox
in Agent > Settings, or pass agent_did and agent_private_key to
OpenBoxPlugin. See the caution in Requirements.
KeyError: 'OPENBOX_SANDBOX_CONFIG_PATH'
The shell did not source agent.env, or it sourced the file without set -a.
The example reads the boundary values straight from the environment, so the
first missing one raises KeyError. Run the set -a line from step 4 in the
same shell as the Worker.
The request to example.com is refused
You provisioned the base release line, which denies every destination. Provision from the v0.1.0-dev tag.
deployment policy identity or native profile mismatch
The loaded environment and the provisioned policy differ.
./obs provision --clean-rerun
sandbox service port 17443 remains occupied
A service is still listening, and the launcher will not signal a process it cannot identify as its own. This happens when a PID file was removed while the service kept running. Stop the listener, then provision again:
lsof -nP -iTCP:17443 -sTCP:LISTEN -t | xargs kill
Provisioning fails
Provisioning fails closed when it cannot verify a release asset, the policy, or the provider. Confirm every asset came from one release, verify SHA256SUMS, then provision again with --clean-rerun. There is no provider fallback.
Next steps
- Concept explains routing, verdicts, and the fail-closed guarantees.
- Provisioning covers release lines, policy templates, and every launcher flag.
- Console Evidence explains the recorded evidence.