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. Two rule
types produce a CONSTRAIN verdict. Pick one and follow the matching tab in
step 3.
- Policy rule
- Behavioral rule
A policy rule matches the activity by name. 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.
A behavioral rule matches an action the agent performed. Every trigger names an
action, such as http_get or file_write. None fire when an activity merely
starts, and a CONSTRAIN verdict stops the activity body before it acts. The
Worker therefore needs two activities: the first performs the action, the
second is the one the rule constrains.
- Open the agent, select Authorize, then the Behavior tab, then Create Rule. A four step wizard opens.
- Step 1, Basic Info. Name the rule and set a priority.
- Step 2, Trigger. Under Select Trigger Semantic Type, choose
http_getfrom the HTTP group. The first activity fetches a page, which emits that event. - Step 3, States. Select at least one prior state, for example
http_get. The wizard lets you pass this step with none selected, and the server then answers422 Unprocessable Entity. - Step 4, Enforcement. Set the verdict to
CONSTRAIN. A Profile ID box appears when that verdict is selected. Typepost-batch. The box accepts any text, and the console cannot see your registry, so it cannot check the name. Type the exactcommand_idthe Worker registers. - Write an on-reject message, then select Create Rule.
Disable any policy rule that matches post_payment_batch before you run this.
That rule stops the first activity before it makes the call that fires this
trigger, and the run fails with GovernedCommandInputError, because the
behavioral example names no profile in its workflow input.
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.
- Policy rule
- Behavioral rule
Two files, exactly as shown. One activity is enough, because the policy rule matches it by name.
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())
Two files, complete. The behavioral rule needs a first activity that performs an action and a second activity to constrain, so the workflow calls two.
from datetime import timedelta
from temporalio import workflow
@workflow.defn
class PaymentBatchWorkflow:
@workflow.run
async def run(self, batch: dict) -> dict:
posting = await workflow.execute_activity(
"post_payment_batch",
batch,
start_to_close_timeout=timedelta(minutes=2),
)
total = await workflow.execute_activity(
"compute_payment_total",
batch,
start_to_close_timeout=timedelta(minutes=2),
)
return {"posting": posting, "total": total}
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:
"""Runs on the host and emits the trigger event."""
async with httpx.AsyncClient() as client:
response = await client.get("https://example.com")
return {"status": "posted", "http_status": response.status_code}
@activity.defn
async def compute_payment_total(batch: dict) -> dict:
"""The behavioral rule constrains this one. Under CONSTRAIN it never runs."""
return {"batch_id": batch.get("batch_id"), "computed_by": "host"}
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, compute_payment_total],
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,
{"batch_id": "B-2026-001"},
id=f"payment-demo-{int(time.time())}",
task_queue=TASK_QUEUE,
)
print(await handle.result())
asyncio.run(main())
The workflow input carries no profile_id here. A policy rule takes the
profile from the activity input, while a behavioral rule names the profile
itself, in the rule.
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.
- Policy rule
- Behavioral rule
{'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.
The workflow returns both activities. The first ran on the host, which is what emitted the trigger event. The second went to the sandbox.
{'posting': {'status': 'posted', 'http_status': 200},
'total': {'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'}]}}}
computed_by never appears, because the second 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. For a
policy rule, check the condition against the activity name. For a behavioral
rule, see the next entry.
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. This
happens when you follow the Behavioral rule tab and leave a policy rule
enabled: the policy rule routes the first activity into the sandbox, and the
behavioral example names no profile in its input, because its rule supplies
one. Disable the policy rule.
422 Unprocessable Entity when creating a behavioral rule
The rule has no prior state. The wizard advances past step 3 with none selected, but the rule contract requires an array of at least one. Go back to step 3 and select one.
The behavioral rule never fires
Every behavioral trigger names an action the agent performs. Confirm the first
activity really ran on the host and made its HTTP call, because that call is
what emits http_get. If the policy rule is still enabled, it stops that
activity before the call happens, and no trigger event exists.
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.