# OpenShell sandbox

Run an agent's tools in an NVIDIA OpenShell sandbox, with a network policy and hidden secrets.

`OpenShellSandbox` runs your agent's tools in an [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox.

`run_bash` and the file tools (`read_file`, `write_file`, `edit_file`, `delete`, `glob`, `grep`, `list_directory` and `generate_file`) run inside the sandbox, with the same output as on the host. The loop, the model calls, memory and hooks stay in your process. OpenShell isolates the commands with Landlock and seccomp and decides network access per program, so the agent reaches only the hosts you allow.

## Requirements

Install the extra:

```bash
pip install "harnessx[openshell]"
```

You also need a running OpenShell gateway. Check it with the OpenShell CLI:

```bash
openshell status
```

On macOS, run the gateway with its MicroVM driver. Docker Desktop's Linux kernel has no Landlock, which OpenShell requires. Set `compute_driver = "vm"` in `~/.config/openshell/gateway.toml`. See the [OpenShell repository](https://github.com/NVIDIA/OpenShell) for installing the gateway.

## Run an agent in a sandbox

Pass the sandbox to the agent with the `filesystem` and `bash` bundles:

```python
import asyncio
from harnessx import Agent, AgentConfig
from harnessx.openshell import OpenShellSandbox

async def main():
    sandbox = OpenShellSandbox(
        project="./repo",
        allow=["github.com", "pypi.org"],  # hosts commands may reach; nothing else is
        secrets=["GITHUB_TOKEN"],          # usable inside, never readable there
    )
    async with Agent(AgentConfig(), tools=["filesystem", "bash"], sandbox=sandbox) as agent:
        result = await agent.run("Run the tests and fix the failure")
        result.raise_for_status()
    # ./repo now has the agent's changes

asyncio.run(main())
```

To see it work end to end, run the example. With no `--project` it builds a small project with one failing test, lets the agent fix it inside the sandbox, and copies the fix back:

~~~bash
uv run python examples/08-sandboxes/openshell_backend.py
uv run python examples/08-sandboxes/openshell_backend.py --project ./repo --allow pypi.org --secret GITHUB_TOKEN
~~~

File tools you registered on a `ToolRegistry` before creating the agent move into the sandbox too, with their registration options. `base_path` does not apply to a sandbox that owns the file tools: the sandbox decides which paths exist, and passing one raises `ValueError`.

## What the agent does with the sandbox

You never start, copy into or clean up the sandbox. Unlike a local [`Sandbox`](../sandbox/), an `OpenShellSandbox` belongs to the agent you give it to. One sandbox serves one agent; giving it to a second agent raises `ValueError`.

- **Created on first use.** The sandbox is created on the first tool call that needs it. A run that only talks creates none.
- **Project copied in.** The `project` folder is copied to `/sandbox/project`, leaving out `.venv`, `node_modules`, `__pycache__` and `.DS_Store`. Change the list with `exclude=` (shell patterns, matched against each name).
- **Changes copied back.** After every run, and when the agent closes, files the sandbox changed or deleted are copied back to `project`.
- **Local edits kept.** If you edited a file locally while the agent was working, your version is kept and the sandbox's version is written beside it as `<file>.sandbox`. A file deleted in the sandbox but edited locally is kept.
- **Deleted on close.** The sandbox is deleted when the agent closes. `keep=True` leaves it running.
- **Named after the session.** The name is derived from the agent's session and recorded in its metadata. A resumed session finds a kept sandbox again and does not copy the project in a second time.

`sandbox.last_sync` holds a `SyncReport` from the last copy back, with `updated`, `deleted` and `conflicts` lists of project-relative paths. `await sandbox.sync()` copies back on demand and returns the same report.

## Paths the model can use

The model uses project-relative paths, such as `src/app.py`. A local absolute path into the project is translated to the sandbox, so `/home/you/repo/src/app.py` reaches the same file. Paths outside the project and its inputs are refused.

`inputs=` adds local files or folders, copied in read-only and never copied back. The model reaches them by their local paths:

```python
sandbox = OpenShellSandbox(project="./repo", inputs=["~/data/fixtures"])
```

A write to an input is refused.

## Network access

Set `allow`, `secrets` or `policy`, and HarnessX sends OpenShell a policy that closes network access except for what you list. A host you allow is reachable from every command the agent runs, whichever program in it connects. With none of the three, HarnessX sends no policy and the sandbox runs under your gateway's default policy, so set at least one of them when the network should be closed.

### Allow hosts

`allow` lists the hosts commands may reach. The port is 443 unless you write `"host:port"`:

```python
sandbox = OpenShellSandbox(project="./repo", allow=["pypi.org", "example.com:8443"])
```

A few well-known hosts bring the hosts they need with them:

| You allow | Also allowed |
| --- | --- |
| `github.com` | `api.github.com`, `codeload.github.com`, `objects.githubusercontent.com`, `raw.githubusercontent.com` |
| `pypi.org` | `files.pythonhosted.org` |
| `huggingface.co` | `cdn-lfs.huggingface.co`, `cas-bridge.xethub.hf.co` |

`allow` takes a list. A bare string raises `TypeError`.

### Secrets

`secrets` names environment variables that commands can use but never read. Inside the sandbox the variable holds a placeholder. OpenShell replaces it with the real value only on requests to that secret's hosts. The secret's hosts are allowed too.

These names know their hosts:

| Variable | Hosts |
| --- | --- |
| `GITHUB_TOKEN` | `github.com`, `api.github.com` |
| `GH_TOKEN` | `github.com`, `api.github.com` |
| `HF_TOKEN` | `huggingface.co` |
| `NPM_TOKEN` | `registry.npmjs.org` |
| `ANTHROPIC_API_KEY` | `api.anthropic.com` |
| `OPENAI_API_KEY` | `api.openai.com` |

For any other variable, map it to its host or a list of hosts:

```python
sandbox = OpenShellSandbox(
    project="./repo",
    secrets={"MY_TOKEN": "api.example.com", "OTHER_TOKEN": ["a.example.com", "b.example.com"]},
)
```

Every secret must be set in your environment when you create the sandbox, or the constructor raises `ValueError`. A name that is not in the table above must be given as a mapping. HarnessX creates an OpenShell provider for the secrets and removes it when it deletes the sandbox.

### OpenShell policies and providers

`policy=` takes an OpenShell policy as a YAML file path, YAML text or a mapping. It replaces HarnessX's restrictive default as the starting point. `providers=` attaches existing OpenShell providers by name. `allow` and `secrets` add to both:

```python
sandbox = OpenShellSandbox(
    project="./repo",
    policy="openshell-policy.yaml",
    providers=["my-provider"],
    allow=["pypi.org"],
)
```

`await sandbox.policy()` returns the sandbox's live policy as a mapping.

## When the policy refuses a connection

A refused connection reaches the command only as "Permission denied". HarnessX reads OpenShell's audit log, and `run_bash` tells the model which host was refused and that the refusal comes from the policy, not from the network:

```text
[blocked by the sandbox's network policy: example.com:443. This is not a network fault; ask the user to allow it.]
```

`SandboxResult.denials` carries the same list. So does the `SANDBOX_EXEC` hook: `ctx.data["denials"]` in `SandboxExecData`, with `tier` set to `"openshell"`. The agent hands its own hooks to the sandbox, so an observer on the agent sees every command:

```python
from harnessx import HookEvent

async def report_denials(ctx):
    if ctx.data["tier"] == "openshell" and ctx.data["denials"]:
        print("Refused:", ", ".join(ctx.data["denials"]))

agent.hooks.on(HookEvent.SANDBOX_EXEC, report_denials)
```

See [Middleware & hooks](../hooks/#hooks-for-observation) for the other fields.

## Review drafted rules

OpenShell drafts network rules from blocked connections. `await sandbox.pending_rules()` lists them as `PendingRule` objects with `id`, `rule_name`, `hosts`, `binary` and `rationale`. Approve a rule to add it to the sandbox's policy, or reject it with a reason:

```python
for rule in await sandbox.pending_rules():
    print(rule.rule_name, rule.hosts, rule.binary, rule.rationale)
    if rule.hosts == ["pypi.org:443"]:
        await sandbox.approve_rule(rule)  # applies to the next connection
    else:
        await sandbox.reject_rule(rule, "not needed for this task")

print(await sandbox.policy())
```

## Command timeouts

`timeout_seconds` (default 30) is the longest any single command may run. `run_bash` passes the model's `timeout` (30 seconds unless the model asks for more), and the shorter of the two applies. A command that runs out of time is reported to the model as timed out.

To let the agent run long test suites or builds, raise `timeout_seconds`; the model can then ask for a longer timeout. The tool's own registry timeout (300 seconds by default) still applies.

## Use the sandbox without an agent

Outside an agent you own the sandbox. Use it as an async context manager. It starts on entry, then copies back and deletes on exit:

```python
import asyncio
from harnessx.openshell import OpenShellSandbox

async def main():
    async with OpenShellSandbox(project="./repo", timeout_seconds=300) as sandbox:
        result = await sandbox.execute_command("python3 -m pytest -q")
        print(result.exit_code, result.stdout)

asyncio.run(main())
```

`execute(code)` runs Python code with `python3` in the sandbox, so the image must have it.

## Options

All options except `project` are keyword-only.

| Option | Default | What it does |
| --- | --- | --- |
| `project` | `"."` | Local folder the agent works on. Must exist. Copied in on first use; changes are copied back |
| `inputs` | `()` | Extra local files or folders, copied in read-only and never copied back |
| `allow` | `()` | Hosts commands may reach, `"host"` or `"host:port"`, with well-known companions |
| `secrets` | `()` | Environment variables usable but unreadable inside: a list of well-known names or a mapping of name to hosts |
| `policy` | `None` | OpenShell policy (YAML path, YAML text or mapping) to start from instead of HarnessX's default |
| `providers` | `()` | Existing OpenShell providers to attach, by name |
| `image` | `None` | OCI image for the sandbox. `None` uses the gateway's default |
| `exclude` | `None` | Names (shell patterns) left out of both copies. `None` is `.venv`, `node_modules`, `__pycache__`, `.DS_Store` |
| `keep` | `False` | Leave the sandbox running when the agent closes |
| `name` | `None` | Sandbox name. `None` derives one from the agent's session |
| `workspace` | `"default"` | OpenShell workspace |
| `gateway` | `None` | Gateway name. `None` is the active one (`OPENSHELL_GATEWAY` or the CLI's) |
| `timeout_seconds` | `30` | Longest a single command may run, in seconds |
| `client` | `None` | An `openshell.SandboxClient` to use instead of connecting to `gateway`. The sandbox does not close a client you pass |

See [Code execution](../sandbox/) for the local sandbox tiers and [Permissions](../permissions/) for approving tool calls. The sandbox limits what a command can reach; permissions decide whether the command runs at all.
