From 87cf8b463aa3aa839d715be5e5db18734ffd8883 Mon Sep 17 00:00:00 2001 From: patkraw Date: Tue, 13 Oct 2026 14:13:12 -0700 Subject: [PATCH 01/26] docs(rfc): draft RFC for gateway MCP sandbox creation Draft proposal for letting agents create sub-agent and tool sandboxes through a gateway-hosted MCP endpoint, with user-set grants, lineage, prover-checked ceilings and a supervisor door. RFC number to be assigned from the originating issue. Signed-off-by: patkraw --- .../README.md | 941 ++++++++++++++++++ 1 file changed, 941 insertions(+) create mode 100644 rfc/NNNN-gateway-mcp-sandbox-creation/README.md diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md new file mode 100644 index 0000000000..22c792e260 --- /dev/null +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -0,0 +1,941 @@ +--- +authors: + - "@patkraw" +state: draft +links: + - (originating GitHub issue, to be opened; maintainers assign the RFC number) +--- + +# RFC NNNN - Gateway MCP for Creating Sandboxes + +## Summary + +This RFC proposes letting agents create sandboxes through an MCP endpoint +hosted in the OpenShell gateway. An agent running in a sandbox can create +sub-agents (`kind: agent`) and tool sandboxes (`kind: tool`). Every request is +identified by OpenShell, proved against a ceiling with the policy prover, +limited by a grant that the user sets once when starting the tree's first +sandbox, and recorded in a lineage so that stopping a parent stops its children +first. + +Agents inside a sandbox reach the endpoint through a door in their own +supervisor at `http://openshell.local/mcp`. The supervisor checks the calling +program against a network rule that the gateway writes from the grant, attaches +a token bound to the sandbox, and forwards the call; the agent never holds a +credential. Harnesses running on the host call the same endpoint with the +user's login. In both cases the gateway makes every decision, and a sandbox +without a grant cannot create sandboxes at all. + +## Motivation + +Agent harnesses increasingly split work across sub-agents. A lead agent plans +the work and delegates parts of it; workers implement, test and review. +Running each agent in its own sandbox lets each one have only the access its +part of the task needs, and keeps one compromised agent from reading another's +files or credentials. OpenShell is well placed to provide that isolation, but +today a sandbox cannot create another sandbox, and nothing in OpenShell bounds +what a delegated agent receives. + +- **Only users can create sandboxes.** `CreateSandbox` is declared with + `auth_mode: "bearer"` in `proto/openshell.proto`, and the gateway router + refuses it for sandbox principals because it is not in the + `is_sandbox_callable` allowlist (`crates/openshell-server/src/multiplex.rs`). + +- **Harnesses work around this with host services.** Prototypes for Pi and + OpenWorker run a service on the host that holds a user credential and creates + sandboxes when an agent asks. Each such service is harness-specific, keeps its + security checks outside OpenShell, and becomes a second source of truth for + who may create what. Operators have to trust and audit each one separately. + +- **Nothing bounds a new sandbox's policy.** At create time the gateway runs + validation and safety checks, but nothing proves that a delegated sandbox + gets no more than its creator was allowed. `openshell-prover` already + implements `check_within_boundary`, whose documentation describes exactly + this use ("a policy a parent agent writes for a subagent"), but its only + caller is the standalone CLI. + +- **There is no lineage.** OpenShell records no parent, creator or tree for a + sandbox. A crashed lead leaves its workers running, and there is no limit on + how many sandboxes one delegated task can create. + +- **Harness limits are suggestions.** Claude Code, Qwen Code, Gemini CLI and Pi + each cap sub-agent depth or concurrency, but those limits are enforced by the + harness, which runs inside the sandbox next to the agent. An agent that has + been manipulated through a prompt injection can call the creation path + directly and bypass them. + +Leaving the design unchanged means every harness that wants sandboxed +sub-agents builds and operates its own credential-holding service, with its own +checks and its own failure modes, and OpenShell cannot answer "who created this +sandbox, for whom, and was it allowed". + +## Non-goals + +- **Agent-to-agent messaging and shared task boards.** How agents in a tree + talk to each other is a separate proposal. +- **Per-tool permissions for other MCP servers**, such as "GitHub MCP, read + tools only". Children get other MCP servers only whole, as REST rules (see + [Other MCP servers](#other-mcp-servers)). +- **Per-tool-call approval inside a harness.** Harnesses keep their own + approval prompts; this RFC covers the sandbox boundary, not the harness's + decisions within it. +- **In-process sub-agents and writable folders shared between sandboxes.** + Children receive read-only copies of inputs and return results through + files. +- **Shared or highly available supervisors** (RFC 0018). This design keeps + identity per sandbox so that it remains valid when one supervisor process + serves several sandboxes. +- **Interactive approval for requests outside a grant.** v1 refuses them; + interactive approval is future work. + +## Proposal + +### Overview + +The gateway gains an MCP endpoint, `/mcp`, served on the existing gateway port +next to the gRPC API. It is a thin adapter: tool calls are translated into the +same create logic the gRPC `CreateSandbox` handler uses, so an MCP caller and a +gRPC caller get identical validation and security checks. Four new pieces sit +behind it: grants, lineage, the boundary proof, and lifecycle management for +trees. Two kinds of caller reach the endpoint. + +**Harness inside a sandbox.** This is the main case. The user starts a lead +sandbox with a grant. The lead's harness connects to `http://openshell.local/mcp`, +which the lead's supervisor serves. The supervisor checks the calling program, +attaches the sandbox's token and forwards the call. The gateway authenticates +the sandbox, checks the grant, proves the child's policy, records lineage and +launches the child through the compute driver. + +```mermaid +flowchart LR + U["User: start the lead with --grant"]:::user + subgraph LEAD["Lead sandbox"] + A["Agent harness, e.g. Pi (no credential)"]:::untrusted + S["Supervisor door at openshell.local/mcp: program allowed? add sandbox token"]:::new + end + subgraph GW["OpenShell gateway"] + M["MCP endpoint /mcp"]:::new + C["Create logic shared with gRPC"]:::changed + P["Prover: child inside the ceiling?"]:::changed + L[("Grants and lineage")]:::new + X["Cascade stop"]:::new + end + D["Compute driver"]:::exist + K["Child sandbox (parent's template)"]:::trusted + U -- "sandbox create --grant" --> C + A -- "create_sandbox" --> S + S -- "call plus token" --> M + M --> C + C -- "prove" --> P + C <-- "limits, record parent" --> L + C -- "launch" --> D --> K + X -- "children first" --> D + classDef untrusted fill:#ffe6df,stroke:#d9472b,stroke-width:2px,color:#18202e + classDef trusted fill:#e3edff,stroke:#2a63c9,stroke-width:2px,color:#18202e + classDef new fill:#ddf6e4,stroke:#1f9d4c,stroke-width:3px,color:#18202e + classDef changed fill:#fff2c9,stroke:#b07800,stroke-width:2px,stroke-dasharray:6 3,color:#18202e + classDef exist fill:#eef0f4,stroke:#8a8f9c,color:#18202e + classDef user fill:#f1e6ff,stroke:#8a4fd1,stroke-width:2px,color:#18202e +``` + +**Harness on the host.** A harness running on the user's machine calls the same +endpoint directly with the user's login (OIDC bearer or mTLS client +certificate). There is no supervisor and no door. The gateway applies the same +checks, using a grant attached to the user's host session; its ceiling is the +boundary the user approves for that session. The host harness itself is not +contained by OpenShell, only the sandboxes it creates are. + +```mermaid +flowchart LR + U["User: set a grant for the host session"]:::user + H["Host harness, e.g. Pi or OpenWorker (uses the user's login)"]:::untrusted + subgraph GW["OpenShell gateway"] + M["MCP endpoint /mcp"]:::new + C["Create logic"]:::changed + P["Prover"]:::changed + L[("Grants and lineage")]:::new + end + K["Child sandbox"]:::trusted + U -- "grant" --> L + H -- "create_sandbox with the user's login" --> M + M --> C + C -- "prove" --> P + C <--> L + C -- "launch" --> K + classDef untrusted fill:#ffe6df,stroke:#d9472b,stroke-width:2px,color:#18202e + classDef trusted fill:#e3edff,stroke:#2a63c9,stroke-width:2px,color:#18202e + classDef new fill:#ddf6e4,stroke:#1f9d4c,stroke-width:3px,color:#18202e + classDef changed fill:#fff2c9,stroke:#b07800,stroke-width:2px,stroke-dasharray:6 3,color:#18202e + classDef user fill:#f1e6ff,stroke:#8a4fd1,stroke-width:2px,color:#18202e +``` + +The endpoint lives in the gateway rather than in each supervisor for three +reasons. Host harnesses have no supervisor, so a supervisor-hosted server would +need a second path for them. Keeping the decision in one component means there +is one implementation of the security checks and no second copy to drift. And +MCP revisions change quickly; a gateway-hosted server is upgraded once, while a +supervisor-hosted one would need every running sandbox's supervisor to keep up. +MCP's sessionless revision also fits gateway high availability, since any +replica can serve any request. The split follows the zero-trust pattern of +deciding centrally and enforcing at the workload: the gateway decides, the +supervisor enforces reachability next to the sandbox. + +### Grants + +A grant is a budget for one tree of sandboxes, set by the user. It answers +three questions: may this tree create sandboxes at all, how large may it grow, +and what is the most any sandbox in it may be given. + +**Creation.** A grant is created when the user starts the tree's first sandbox, +called the holder. There is no grant file; the grant is part of the create +request: + +```shell +openshell sandbox create \ + --from pi-agent:local \ + --provider pi-nvidia \ + --policy lead.yaml \ + --grant max_depth=1,max_breadth=2,max_total=3,expires=4h,program=/usr/local/bin/node \ + -- pi +``` + +The CLI parses `--grant` into a `SandboxGrantSpec` on `CreateSandboxRequest`. +Only a user principal may set it; the gateway rejects the field from any other +principal. The holder's policy at creation becomes the grant's ceiling. + +```proto +message SandboxGrantSpec { + // Levels below the holder. 1 means the holder's children cannot create. + uint32 max_depth = 1; + // Children per parent. + uint32 max_breadth = 2; + // Sandboxes in the whole tree, including the holder. + uint32 max_total = 3; + // Grant lifetime. + google.protobuf.Duration expires_after = 4; + // Absolute path of the executable allowed to use the door, + // for example /usr/local/bin/node for a Node-based harness. + string program = 5; +} + +message CreateSandboxRequest { + // ... existing fields ... + SandboxGrantSpec grant = 20; +} +``` + +**One grant per tree.** Children and grandchildren draw from the same grant. +When a child creates its own child, the gateway charges the shared counters and +checks the child's depth against the same `max_depth`. A child's slice is never +more than its parent has left, so the user confirms the tree's limits once and +sees no further prompts however deep the tree goes. The alternative, a new grant +per level, would put a prompt in front of the user every time a worker +delegated. + +**No grant, no children.** A sandbox started without a grant cannot create +sandboxes. `create_sandbox` is absent from its tool list, and a call is refused +with `NOT_ALLOWED`. The safe default is that a sandbox never gains the ability +to create sandboxes unless the user said so. + +**Set up front.** In v1 the grant is the only approval. A request inside it +proceeds without prompting anyone; a request outside it is refused with +`LIMIT_REACHED` and the agent adapts. This matches how OpenShell is used today, +from the CLI, and needs no new approval interface. Interactive approval for +requests outside the grant is future work. + +**Only the user changes a grant.** The user can widen, shrink or revoke a grant +while the tree runs. Changes apply to new creates only: an existing child keeps +its policy and its door rule. No sandbox can change a grant, including through +the gateway MCP, and every change is audited. If an agent could widen its own +grant, the grant would mean nothing. + +```proto +rpc ListSandboxGrants(ListSandboxGrantsRequest) returns (ListSandboxGrantsResponse) { + option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:read" }; +} +rpc UpdateSandboxGrant(UpdateSandboxGrantRequest) returns (SandboxGrantResponse) { + option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:write" }; +} +rpc RevokeSandboxGrant(RevokeSandboxGrantRequest) returns (RevokeSandboxGrantResponse) { + option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:write" }; +} +``` + +`UpdateSandboxGrant` and `RevokeSandboxGrant` additionally require the caller +to be the grant's user or a Workspace Admin of the grant's workspace. + +**The harness declares, OpenShell enforces.** A harness may send its own limits +with a create request, for example "a lead has at most two workers". The +effective limit for each dimension is the lowest of the harness's value, the +grant's and the operator's cap from gateway configuration. A limit set by the +harness is thereby enforced outside the harness's reach. + +**Storage.** Grants are stored in the existing objects table as a new object +type, `sandbox_grant`, scoped to the holder's workspace. The counters (`total` +and per-parent children) live in the grant record and are updated with +`WriteCondition::MatchResourceVersion`, so two concurrent creates cannot both +take the last slot. A create reserves its slot before calling the compute +driver and releases it if the driver fails. + +### Lineage + +Each sandbox created under a grant has a `Lineage` record: + +```proto +message SandboxLineage { + string sandbox_id = 1; + // Parent sandbox id, empty for the holder. + string parent_sandbox_id = 2; + // The user the tree acts for. + string root_subject = 3; + string workspace = 4; + SandboxKind kind = 5; // AGENT or TOOL + SandboxLifetime lifetime = 6; // UNTIL_STOPPED or UNTIL_EXIT + uint32 depth = 7; // 0 for the holder + string grant_id = 8; + google.protobuf.Duration idle_timeout = 9; // tool sandboxes +} +``` + +The record is stored as the object type `sandbox_lineage`, keyed by sandbox id, +with an index on `parent_sandbox_id` for cascade stop. A child's workspace is +always its parent's. + +Lineage is deliberately a separate record rather than an owner field on the +sandbox. RFC 0011 gives sandboxes no owner: access comes from workspace +membership, and any user in a workspace can act on its sandboxes. Lineage does +not change that model. It is used only for decisions about trees (cascade stop, +"a sandbox acts only on its own descendants") and for audit attribution. + +### Ceiling and policy proof + +**The ceiling caps a child, not the parent's current policy.** A parent's +current policy can change from task to task through approved proposals. If a +child had to fit inside the parent's current policy, child creation would fail +or succeed depending on what the parent happened to have at that moment. The +parent could obtain anything inside its ceiling anyway, so capping by the +ceiling adds no risk and makes behavior predictable. A child's own ceiling is +its parent's ceiling. + +**What the agent writes.** The agent supplies only the child's +`network_policies`. The gateway builds the child's full policy from the +ceiling's filesystem, process and Landlock sections plus those network rules. +This keeps the agent from requesting filesystem or process changes it has no +reason to make, and keeps the proof focused on network access, where delegated +tasks differ. + +**The proof.** The gateway serializes the ceiling and the candidate with +`openshell_policy::serialize_sandbox_policy`, parses both with +`openshell_prover::containment::parse_policy_str`, and calls +`check_within_boundary` on a blocking thread with a 10 second timeout. Only +`Within` lets creation continue: + +| Prover result | Error returned to the agent | +|---------------|-----------------------------| +| `Within` | none; creation continues | +| `Exceeds` | `EXCEEDS_CEILING`, with the prover's counterexample, so the agent can narrow its request and retry | +| `Unsupported` | `UNSUPPORTED_RULES`, naming the rule the prover cannot model | +| `Inconclusive` | `PROVER_INCONCLUSIVE` | + +Returning the counterexample matters: it tells the agent which request +(`POST api.github.com/repos/...`) is outside the ceiling, which is usually +enough for it to correct its policy without involving the user. + +**Later policy writes.** The same check runs on every later write to the policy +of a sandbox that has a lineage, whatever the source: `UpdateConfig`, policy +sync from the supervisor, or an approved proposal. A sandbox in a tree can +never be widened past its ceiling after it starts. + +**Global policy.** While a gateway-global policy is active, it replaces every +sandbox's policy rather than limiting it, and provider layers are switched off. +A child created with a narrow policy would silently receive the global one +instead. `create_sandbox` with a policy is therefore refused with +`GLOBAL_POLICY_ACTIVE`, so the agent learns that delegation is unavailable +instead of receiving a child with unexpected access. + +**Layers the gateway adds.** The door rule and provider layers are added after +the proof. Both come from trusted sources, the grant and admin-imported provider +profiles, not from the agent. Provider attachments are checked separately: a +child may only receive providers attached to its parent. + +#### Other MCP servers + +The prover models a request as program, host, port, HTTP method and path. That +is enough for network and REST rules. MCP rules match fields inside the JSON-RPC +body, the `method` and the tool name in `params.name`, and the model has no +body fields. The prover therefore returns `Unsupported` for any endpoint with +`protocol: mcp`, including one that allows every tool ("only L4 TCP and REST are +modeled", `crates/openshell-prover/src/containment.rs`). This is unbuilt work +rather than a fundamental limit; tool names are strings like paths. + +Until the prover models MCP rules, a child gets another MCP server whole or not +at all, written as a REST rule on the server's `/mcp` path: + +```yaml +network_policies: + github_mcp: + name: github_mcp + endpoints: + - host: api.githubcopilot.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: POST, path: /mcp } + binaries: + - path: /usr/local/bin/node +``` + +We checked the three ways of writing this with `openshell-prover` against a +ceiling that allows `POST` and `GET` on `/mcp`. The REST rule above is +`within`. A rule naming only the host and port `exceeds` the ceiling, because it +allows requests to any path. An endpoint with `protocol: mcp` and +`allow_all_known_mcp_methods: true` is `unsupported`. The cost of the REST form +is that there are no per-tool limits, so a child with GitHub's MCP server can +call every tool on it, and the supervisor applies no MCP-specific checks such as +protocol version and tool-name syntax. + +### The supervisor door + +The door is served by the supervisor at `openshell.local`, port 80, path `/mcp`. +It reuses the mechanism that serves the `policy.local` API today +(`crates/openshell-supervisor-network/src/policy_local.rs`): + +- **Name resolution.** The supervisor's synthetic DNS answers `openshell.local` + with the same local address as `policy.local` (`198.18.0.1`), so clients that + connect directly, such as Node's `fetch`, reach the transparent path. + Clients that honor the proxy environment reach the explicit forward-proxy + path. Both paths dispatch on the `Host` header. + +- **Program check.** The door checks the calling program against the sandbox's + network policy for `openshell.local:80`, using the binary identity the + isolation backend supplies for the connection (RFC 0012), through the same + OPA evaluation the proxy uses for egress (`authorize_supplied_identity`). The + workload cannot forge this identity. `policy.local` today skips this check; + the door must not. + +- **Forwarding.** The door reads the request body (the same 64 KiB limit as + `policy.local`), forwards it to the gateway's `/mcp` with a token bound to + this sandbox, and returns the gateway's response unchanged. + +**The door rule.** The network rule that makes the program check pass is +written by the gateway, never by the agent: + +```yaml +network_policies: + openshell_gateway_mcp: + name: openshell_gateway_mcp + endpoints: + - host: openshell.local + port: 80 + binaries: + - path: /usr/local/bin/node # the grant's program +``` + +The gateway adds it to a sandbox's base policy at creation when the sandbox is +an agent with depth left: the holder when `max_depth > 0`, and an agent child +when its depth is below `max_depth`. A child policy that already contains the +key is refused. Because the rule is part of the base policy at creation, it is +present from the supervisor's first configuration fetch; the 10 second policy +poll (`OPENSHELL_POLICY_POLL_INTERVAL_SECS`) does not delay it. The door rule is +a plain network rule, so the prover can check it. + +**Program, not script.** The door rule names an executable. Pi is a Node +program, so its rule names `/usr/local/bin/node`, and any Node process in the +sandbox can use the door, including one the agent starts from its shell. `curl`, +`bash` and other programs cannot. Provider credentials scoped to `node` have the +same property today. The gateway's checks still apply to every call, so a +script using the door gets exactly what the harness would get. + +**Token.** The supervisor obtains a short-lived token for the audience +`urn:openshell:gateway-mcp` through `RefreshSandboxToken`, extending the +existing extension-credential path, which today mints only for supervisor +middleware services and is rate limited per sandbox. The token is bound to the +sandbox id and lives at most one hour (`MAX_EXTENSION_TOKEN_TTL`). The gateway +accepts it only on `/mcp`. Using a dedicated audience instead of the +supervisor's main session token limits what a leaked token can do. + +### Three walls + +A sandbox with no depth left meets three independent checks if it tries to +create a child: + +| Wall | Where | What happens | +|------|-------|--------------| +| The tool is hidden | Gateway | `tools/list` is answered per caller; without a grant or with no depth left there is no `create_sandbox`. Models do not see, and so do not waste turns on, tools they cannot use. | +| No route | Supervisor | No door rule, so the door refuses every program before anything reaches the gateway. | +| Refused | Gateway | Any create from such a caller fails with `LIMIT_REACHED(depth)` and is audited. | + +The door enforces who can reach the gateway MCP; the gateway enforces what they +may do. The gateway remains the final authority: the first two walls are early +refusals that also protect against a bug in the third. + +### MCP tools + +`tools/list` is answered by the gateway, because the list depends on state only +the gateway has: whether the caller has a grant, how much depth and breadth it +has left, and whether the user changed the grant a moment ago. The supervisor +only learns policy changes on its 10 second poll and never sees the counters. +MCP clients call `tools/list` once per connection, so this costs one request per +session. A client's cached list can go out of date during a session, for example +still showing `create_sandbox` after the budget is used; the gateway refuses +such calls, and pushing list-changed notifications is future work. + +| Tool | Listed for | Input | Output | Errors | +|------|------------|-------|--------|--------| +| `create_sandbox` | sandboxes with a grant and depth left | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s` | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `GLOBAL_POLICY_ACTIVE` | +| `wait` | sandboxes | `handle`, `timeout_s` | `phase`, `exit_code` | `NOT_YOUR_CHILD` | +| `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | +| `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | +| `stop_sandbox` | sandboxes | `handle` | `stopped` | `NOT_YOUR_CHILD` | +| `list_children` | sandboxes | none | children with kind, lifetime, depth | none | +| `get_limits` | sandboxes | none | depth, children and total used and allowed | none | + +A sandbox acts only on its own descendants; `handle` is the child's sandbox name +within the caller's workspace. `inputs` lists paths in the parent that are +copied read-only into the child at start. `exec` and `get_file` run through the +existing `ExecSandbox` path, acting for the tree's root user. + +These are the only tools a harness reaches through the gateway. A harness's +built-in tools (shell, file editing) run inside the sandbox, constrained by its +policy, and other MCP servers are reached directly through the supervisor's +normal proxy. A harness merges all of them into one list for its model. + +### Agent and tool sandboxes + +A child is created with a fixed kind. The kind decides how its parent uses it +and whether it can delegate further. + +| | `kind: agent` | `kind: tool` | +|---|---|---| +| Runs | A harness doing its own task | No agent; only commands its parent sends | +| Parent uses it with | `wait`, `get_file` | `exec`, `get_file` | +| `exec` | Refused with `WRONG_KIND` | Allowed; every command is logged | +| Can create children | If depth is left; it gets the door rule | Never; it never gets the door rule | +| Command | Required | Optional; it stays up until stopped | +| Lifetime | Until stopped; never outlives its parent | Until stopped, or stopped after an idle timeout (default 10 minutes without `exec` or `get_file`) | + +Agent sandboxes run their own task, so their parent observes them rather than +driving them, and `exec` on an agent is refused to keep a parent from steering +a child's work behind its harness. Tool sandboxes are the opposite: a disposable +environment for running builds, tests or untrusted code on the parent's behalf. +Every command run through `exec` is recorded with the parent, the child and the +command line (R35 in the requirements). + +### Lifecycle + +**Default lifetime.** Agent children default to `until_stopped`, because +delegated agents usually work for a long time; one-shot children pass +`until_exit`. + +**A child never outlives its parent.** When a parent ends, whether stopped, +deleted, crashed or reaped, the gateway stops its children first, deepest first. +For each child it first bumps the authentication epoch, so the child's next +call to the gateway fails, then stops it through the compute driver, then +deletes it. Results the parent has not read are lost; a parent whose work is +done waits for its children with `wait`. An orphan reaper removes children +whose parent no longer exists, for example after a gateway crash. + +**A parent with running children is never idle.** Otherwise its idle timer +could stop the whole tree while the parent waits for a worker. The maximum +lifetime still applies, so no tree runs forever. + +**Children's policy changes go to the user.** A running child can propose new +rules through `policy.local`, as any sandbox can. The gateway first checks that +the proposal fits the child's ceiling and rejects it without asking if it does +not. Otherwise the user approves as today, with `openshell rule approve`, the +TUI or auto-approval. This is the only point at which the user may be prompted +during a run. + +### Images and resources + +A child is created from its parent's sandbox template: the same image, CPU and +memory, with no GPU. If the parent was created from an admin workload template, +the child is created from that same template. `create_sandbox` takes no image +or template. The image that works for the parent already contains the harness +and the tools its children need (Pi, for example, needs Node, `ripgrep` and `fd` +preinstalled because it otherwise downloads them at runtime, which a sandbox +blocks). No GPU is given to children because GPUs are scarce and three +children each taking one could block other work; `max_total` caps the tree's +other resources. + +### Retries + +Agents retry. If the reply to `create_sandbox` is lost, the agent sends the +request again, and without deduplication the tree would get two children and +spend its budget twice. Sandbox callers therefore send a `request_id`; the +gateway stores the first result per grant and returns it for any repeat, which +is counted once. Today `request_id` deduplication through `mutation_replay` is +available to user principals only. + +### Authorization enforcement + +**Authenticating `/mcp`.** Plain HTTP routes on the gateway are not +authenticated today; only the gRPC service is wrapped by `AuthGrpcRouter`. The +`/mcp` handler runs the same authenticator chain (`build_authenticator_chain`): +a gateway-MCP extension token yields `Principal::Sandbox` for that sandbox; an +OIDC bearer or an mTLS client certificate yields `Principal::User`. Anonymous +callers are refused. The `x-openshell-auth-source` marker is stripped as on the +gRPC path. + +**Pinning the workspace.** `authorize_workspace` currently accepts any +workspace for a sandbox principal. The MCP handler never takes a workspace from +the caller: a child is always created in its parent's workspace. + +**Acting principal.** The child is created on behalf of the tree's root user, +so provider resolution and workspace checks behave as if the user created it, +while lineage records which sandbox asked. + +| Action | User | Holder or agent child with depth left | Agent child with no depth left | Tool child | +|--------|------|------|------|------| +| Start a sandbox with `--grant` | yes | no | no | no | +| List grants | own workspace | no | no | no | +| Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | +| Reach the door | not applicable | named program only | no route | no route | +| `create_sandbox` | with a host-session grant | within limits | hidden and refused | hidden and refused | +| `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | own descendants | + +### Gateway configuration + +The feature is off by default and enabled per gateway: + +```toml +[openshell.gateway.sandbox_mcp] +enabled = true +# Operator caps; the effective limit is the lowest of these, the grant and the harness. +max_depth = 3 +max_breadth = 8 +max_total = 20 +# Default idle timeout for tool sandboxes. +tool_idle_timeout = "10m" +``` + +When disabled, `/mcp` is not routed, `--grant` is rejected and the supervisor +door answers 404. + +### CLI surface + +```shell +# Start a tree that may create children +openshell sandbox create --policy lead.yaml \ + --grant max_depth=1,max_breadth=2,max_total=3,expires=4h,program=/usr/local/bin/node \ + -- pi + +# Inspect and change grants +openshell grant list +openshell grant update --max-breadth 3 --max-total 4 +openshell grant revoke + +# See a tree +openshell sandbox list --tree +``` + +A harness needs one setting: the gateway MCP server's address. For Pi, which +has built-in MCP support since 0.99, that is `~/.pi/agent/mcp.json`: + +```json +{ "mcpServers": { "openshell": { "url": "http://openshell.local/mcp" } } } +``` + +### Audit + +Every create, refusal, grant change, `exec` command and cascade stop is emitted +through the existing OCSF infrastructure with the caller, the parent sandbox, +the root user and the grant. Tool calls on `/mcp` use the `ApiActivity` class +proposed in RFC 0011, with the decision and its reason code (`EXCEEDS_CEILING`, +`LIMIT_REACHED`, and so on). Door refusals are emitted by the supervisor as +`HttpActivity` denials, like other egress denials. An operator can therefore +answer "which sandbox created this one, for whom, under which grant, and what +did it run" from the same log stream they already ship to their SIEM. + +### Compatibility + +The change is additive. Sandboxes created without `--grant` behave exactly as +today. Existing gRPC clients are unaffected; the new `grant` field is optional. +Supervisors without the door ignore `openshell.local`, so a newer gateway with +an older supervisor image simply cannot serve in-sandbox callers until the image +is updated. Host harnesses can use the endpoint as soon as the gateway supports +it. + +## Implementation plan + +The work is phased so that each phase is usable and testable on its own: + +- **Phase 1: Grants, lineage and the boundary proof behind gRPC.** Add the + `sandbox_grant` and `sandbox_lineage` object types, atomic counter + reservation, `SandboxGrantSpec` on `CreateSandboxRequest`, the grant RPCs and + CLI, and the prover call at create time and on every later policy write to a + sandbox with a lineage. Validate with the existing Pi and OpenWorker + prototypes by replacing their host creation services with these RPCs. +- **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` with + authentication, per-caller `tools/list`, `create_sandbox`, `wait`, + `get_file`, `stop_sandbox`, `list_children` and `get_limits`. Add the door at + `openshell.local`, the door rule written from the grant, and the + `urn:openshell:gateway-mcp` token. Ship host harness support first, then + in-sandbox callers. +- **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the + idle timeout. +- **Phase 4: Lifecycle.** Cascade stop on every way a parent ends, the orphan + reaper, and idle and maximum lifetime for agent children with the "a parent + with running children is not idle" rule. +- **Phase 5: Documentation.** A tutorial in which a Pi lead creates a Pi worker, + reference pages for grants and the MCP tools, and a configuration entry for + `[openshell.gateway.sandbox_mcp]`. + +Validation runs at each phase: unit tests for grant parsing, limits and the +door-rule decision; gateway tests for refusals; and an end-to-end scenario with +the Docker driver that exercises every refusal listed in this RFC. + +A proof of concept of phases 1 to 3 exists on the branch `gateway-mcp-poc` +(`crates/openshell-server/src/mcp.rs`, +`crates/openshell-supervisor-network/src/gateway_mcp_door.rs`, +`examples/gateway-mcp-poc/`). Against a development gateway with the Docker +driver, its check script passes 29 of 31 checks, covering grants, the door, +refusals, retries, tool sandboxes and cascade stop; the two failures are the +script running out of grant budget. The proof of concept keeps grants and +lineage in memory and forwards the supervisor's main session token instead of a +dedicated token. + +## Risks + +- **New gateway endpoint.** `/mcp` adds attack surface on the gateway port, and + gateway HTTP routes have no authentication today. The handler must run the + authenticator chain itself and be rate limited per sandbox. A mistake here + would let an unauthenticated caller create sandboxes, so this code needs + focused review and tests for every principal type. + +- **Trusted, unproved layers.** The door rule and provider layers are added + after the proof. A bug in the code that writes them could widen a child + without the prover noticing. The code is small and table-driven, and tests + cover each kind and depth combination, but it is trusted rather than proved. + +- **Program-level door.** The door rule matches an executable, not a script. In + a harness built on Node or Python, any script run by the same interpreter can + use the door. Such a script still receives only what the gateway would give + the harness, but operators should not read the door rule as "only the + harness". + +- **Results channel.** A child's standard output does not reach the sandbox + logs, and `get_file` needs the child to be running. A child that exits before + its parent reads its result loses that result. Harnesses must keep agent + children running until their result is read, or results need a channel that + survives exit. + +- **Spawn storms.** A misbehaving lead can create children up to its grant + quickly. Atomic counters, `max_total`, operator caps, retry deduplication and + the token rate limit bound the damage. + +- **Host mode is weaker.** A host harness is not contained, only its children + are. This is a property of host mode and must be documented, not hidden. + +## Future work + +### Interactive approval + +A request outside the grant could wait for the user instead of being refused. +`create_sandbox` would return a `pending_id`, the harness would call `wait`, and +the user would approve where they are: in the TUI, in a harness's approval +interface (OpenWorker shows one for teams), or through a harness extension's +confirmation dialog, which Pi forwards over its RPC mode. Open points are where +pending requests are shown, timeouts, and whether to use a pending identifier +with `wait` or MCP's long-running task support. + +### Per-tool rules for other MCP servers + +If `openshell-prover` models MCP rules, children could receive per-tool access +such as "GitHub MCP, read tools only", proved like any other rule. Until then, +admin-imported provider profiles can carry MCP tool rules as trusted bundles; +they are attached by name and not proved. + +### Templates for children + +A grant could list workload templates a child may use, so that a Pi lead can +create a Claude Code child in a different image. + +### Resource budgets + +A per-tree CPU, memory and GPU budget in the grant (`max_cpu`, `max_gpu`) for +trees that need GPUs. + +### Tool-list change notifications + +The gateway could notify connected clients when a caller's tool list changes, +for example after its budget is used, so that cached lists stay accurate. + +## Alternatives + +### MCP server in the supervisor + +Each supervisor would serve the MCP tools itself, like `policy.local`, and call +a new sandbox-only gRPC method on the gateway. + +This was rejected because host harnesses have no supervisor and would need a +second path, the MCP layer would ship in every supervisor and need upgrading in +every running sandbox when MCP changes, and the security checks would still have +to live in the gateway. Its one benefit, no new gateway endpoint, is smaller +than the cost of two implementations. + +### Agent holds its own gateway credential + +The agent would call the gateway directly with a token in its environment. + +This was rejected because it breaks the rule that an agent inside a sandbox +holds nothing worth stealing. A prompt-injected agent could exfiltrate the token +and create sandboxes from anywhere. + +### Host creation service + +A service on the host holding the user's credential creates sandboxes on +agents' behalf. This is how current prototypes work, and it needs no change to +OpenShell. + +This was rejected as the long-term design because the checks live outside +OpenShell, each harness needs its own service, and there are two sources of +truth for who may create what. It remains useful for prototyping. + +### Policy rules decide who may call which gateway tool + +Each sandbox's policy would carry MCP tool rules for the gateway MCP, for +example "only the lead may call `create_*`". + +This was rejected because the prover cannot check MCP rules, a policy rule +cannot count ("at most three children"), and the decision would be split +between policy and grant. The grant decides; the door rule controls only +reachability. + +### Global policy as the limit + +Children would all run under a gateway-global policy. + +This was rejected because a global policy replaces every sandbox's policy, so +no child could have less access than another, and least privilege per child is +the point of the proposal. + +## Prior art + +- **Anthropic Managed Agents and OpenAI Codex.** Both separate the + environment's network boundary from tool-call permission: Managed Agents has + environment networking and a tool permission policy, Codex has `--sandbox` + and `--ask-for-approval`. The lesson is to keep the wall and the gate apart. + This RFC covers the wall; harnesses keep the gate. + +- **Harness depth limits.** Claude Code limits sub-agent nesting and + concurrency, Qwen Code and Gemini CLI cap depth, and Pi's sub-agent example + caps parallel tasks. All of these are enforced inside the harness. The lesson + is that the limits are useful but need enforcement outside the agent's reach. + +- **Azure dynamic sessions, E2B and Modal.** These provide one sandbox per + session, reused by identifier, with idle and maximum lifetimes. Tool + sandboxes follow the same model. + +- **AWS instance metadata and the SPIFFE workload API.** A workload obtains its + identity from a trusted local component and never stores a long-lived secret. + The supervisor door follows this pattern, as OpenShell's provider credential + injection already does. + +- **OpenShell RFC 0011.** Workspace membership is the access model and + sandboxes have no owner. This RFC keeps lineage as a separate record so the + two models compose. + +## Open questions + +- Is a dedicated gateway-MCP token audience required for v1, or is the + supervisor's session token acceptable initially? +- Is the separate `sandbox_lineage` record acceptable alongside RFC 0011's + model, or should lineage live elsewhere? +- Will `openshell-prover` model MCP and GraphQL rules, and on what timeline? + The answer decides whether per-tool rules for other MCP servers become + possible. +- Should a parent be able to read the result of a child that has exited, for + example through a result file the gateway keeps until the parent reads it? +- When interactive approval is added, where should pending requests appear? + +## Appendix: End-to-End Walkthrough + +This walkthrough follows a user reviewing a pull request with a Pi lead and one +Pi worker, showing the check at each step. + +**1. The user starts the lead with a grant.** + +```text +CreateSandbox { name: "lead", policy: lead.yaml, providers: [pi-nvidia], + grant: { max_depth: 1, max_breadth: 2, max_total: 3, + expires_after: 4h, program: "/usr/local/bin/node" } } +→ principal = User → may set a grant → pass +→ ceiling = lead.yaml +→ max_depth > 0 → add door rule (openshell.local:80, /usr/local/bin/node) +→ persist SandboxGrant { total: 1 }, SandboxLineage { lead, depth: 0 } +``` + +**2. The lead lists its tools.** + +```text +node → openshell.local/mcp tools/list +→ supervisor: binary /usr/local/bin/node allowed by door rule → pass +→ forward with gateway-mcp token → Principal::Sandbox(lead) +→ grant present, depth 0 < 1, budget left → include create_sandbox +``` + +**3. The lead creates a worker.** + +```text +create_sandbox { kind: agent, policy: {board rule}, providers: [pi-nvidia], + command: [pi-run, -p, "..."], request_id: r1 } +→ request_id r1 not seen +→ no global policy → pass +→ depth 0 < 1, breadth 0 < 2, total 1 < 3 → pass +→ providers ⊆ lead's providers → pass +→ prover: check_within_boundary(ceiling, child) → Within +→ reserve counters (total 2, lead's children 1) +→ child depth 1 = max_depth → no door rule +→ create from lead's template, no GPU, acting for the root user +→ persist SandboxLineage { worker, parent: lead, depth: 1 } +``` + +**4. The lead retries after a lost reply.** + +```text +create_sandbox { ..., request_id: r1 } +→ r1 seen → same handle, total stays 2 +``` + +**5. The worker tries to create a child.** + +```text +node (worker) → openshell.local/mcp +→ supervisor: no door rule → 403 door_denied, HttpActivity denial emitted +(were the call to reach the gateway) +→ depth 1 = max_depth → LIMIT_REACHED(depth), ApiActivity emitted +``` + +**6. The lead asks for more than its ceiling.** + +```text +create_sandbox { policy: {POST api.github.com/repos/**} } +→ prover: Exceeds, counterexample POST /repos/... → EXCEEDS_CEILING +``` + +**7. The user widens the grant.** + +```text +UpdateSandboxGrant { grant_id, max_breadth: 3, max_total: 4 } +→ principal = User, grant owner → pass +→ applies to new creates only; worker unchanged; audited +``` + +**8. The lead collects the result and stops the worker.** + +```text +wait { handle: worker } → READY +get_file { handle: worker, path: /tmp/result.txt } → content +stop_sandbox { handle: worker } +→ worker's descendants (none) stopped first, then worker; identity revoked first +``` + +**9. The user deletes the lead.** + +```text +DeleteSandbox { name: "lead" } +→ cascade: children deepest first (bump auth epoch, stop, delete), then lead +→ grant closed, lineage removed, audited +``` From c7c0a2f7b2e9266346387c11d78112364ef38880 Mon Sep 17 00:00:00 2001 From: patkraw Date: Tue, 13 Oct 2026 14:13:19 -0700 Subject: [PATCH 02/26] docs(rfc): drop internal requirement reference Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 22c792e260..6e619d3846 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -520,7 +520,7 @@ driving them, and `exec` on an agent is refused to keep a parent from steering a child's work behind its harness. Tool sandboxes are the opposite: a disposable environment for running builds, tests or untrusted code on the parent's behalf. Every command run through `exec` is recorded with the parent, the child and the -command line (R35 in the requirements). +command line. ### Lifecycle From 84aad8e35b87731c9209bce0a31d3a890023df7e Mon Sep 17 00:00:00 2001 From: patkraw Date: Tue, 13 Oct 2026 15:01:26 -0700 Subject: [PATCH 03/26] docs(rfc): child-sandbox API with SDK, CLI and MCP surfaces; global policy as a maximum; host callers act as the user; MCP on by default Also adds sandboxes-from-sandboxes use cases (RL environments) and future work on returning results and agent-to-agent communication. Signed-off-by: patkraw --- .../README.md | 242 ++++++++++++++---- 1 file changed, 188 insertions(+), 54 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 6e619d3846..d4c35e3cdc 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -18,13 +18,16 @@ limited by a grant that the user sets once when starting the tree's first sandbox, and recorded in a lineage so that stopping a parent stops its children first. -Agents inside a sandbox reach the endpoint through a door in their own -supervisor at `http://openshell.local/mcp`. The supervisor checks the calling +The contract is a gRPC API for child sandboxes, exposed through the SDKs, the +CLI and an MCP endpoint, so a harness can integrate at whichever level suits it. +Agents inside a sandbox reach the gateway through a door in their own +supervisor at `http://openshell.local`. The supervisor checks the calling program against a network rule that the gateway writes from the grant, attaches a token bound to the sandbox, and forwards the call; the agent never holds a -credential. Harnesses running on the host call the same endpoint with the -user's login. In both cases the gateway makes every decision, and a sandbox -without a grant cannot create sandboxes at all. +credential. Agents and tools running on the host call the same endpoint with the +user's login and create sandboxes as the user would with the CLI. In every case +the gateway makes every decision, and a sandbox without a grant cannot create +sandboxes at all. ## Motivation @@ -58,6 +61,14 @@ what a delegated agent receives. sandbox. A crashed lead leaves its workers running, and there is no limit on how many sandboxes one delegated task can create. +- **Sandboxes from sandboxes is a general need.** Sub-agents are one use. + Reinforcement learning environments such as Hugging Face's OpenEnv and + NVIDIA NeMo Gym run an agent in one environment and start isolated + environments for it to act in, evaluate or score; an agent on a developer's + machine asks for a sandbox to try something safely. Each of these needs a + sandbox to start other sandboxes, within limits, without holding a user + credential. + - **Harness limits are suggestions.** Claude Code, Qwen Code, Gemini CLI and Pi each cap sub-agent depth or concurrency, but those limits are enforced by the harness, which runs inside the sandbox next to the agent. An agent that has @@ -92,12 +103,13 @@ sandbox, for whom, and was it allowed". ### Overview -The gateway gains an MCP endpoint, `/mcp`, served on the existing gateway port -next to the gRPC API. It is a thin adapter: tool calls are translated into the -same create logic the gRPC `CreateSandbox` handler uses, so an MCP caller and a -gRPC caller get identical validation and security checks. Four new pieces sit -behind it: grants, lineage, the boundary proof, and lifecycle management for -trees. Two kinds of caller reach the endpoint. +The gateway gains a child-sandbox API and an MCP endpoint, `/mcp`, served on +the existing gateway port. The MCP endpoint is a thin adapter over the API, and +both use the same create logic as the gRPC `CreateSandbox` handler, so every +caller gets identical validation and security checks (see +[Interfaces](#interfaces-api-sdk-cli-and-mcp)). Four new pieces sit behind +them: grants, lineage, the boundary proof, and lifecycle management for trees. +Two kinds of caller reach the gateway. **Harness inside a sandbox.** This is the main case. The user starts a lead sandbox with a grant. The lead's harness connects to `http://openshell.local/mcp`, @@ -138,17 +150,19 @@ flowchart LR classDef user fill:#f1e6ff,stroke:#8a4fd1,stroke-width:2px,color:#18202e ``` -**Harness on the host.** A harness running on the user's machine calls the same -endpoint directly with the user's login (OIDC bearer or mTLS client -certificate). There is no supervisor and no door. The gateway applies the same -checks, using a grant attached to the user's host session; its ceiling is the -boundary the user approves for that session. The host harness itself is not -contained by OpenShell, only the sandboxes it creates are. +**Agent or tool on the host.** An agent on the user's machine, such as Codex, +Claude Code or a host harness, calls the same endpoint with the user's login +(OIDC bearer or mTLS client certificate). There is no supervisor and no door. +The caller is the user, so it creates top-level sandboxes exactly as the user +could with the CLI, subject to the same workspace roles; no grant is needed. If +it gives a new sandbox a grant, that sandbox can create children as described +below. The host agent itself is not contained by OpenShell, only the sandboxes +it creates are. ```mermaid flowchart LR - U["User: set a grant for the host session"]:::user - H["Host harness, e.g. Pi or OpenWorker (uses the user's login)"]:::untrusted + U["User's login"]:::user + H["Agent on the host, e.g. Codex, Claude Code or a harness"]:::untrusted subgraph GW["OpenShell gateway"] M["MCP endpoint /mcp"]:::new C["Create logic"]:::changed @@ -156,11 +170,11 @@ flowchart LR L[("Grants and lineage")]:::new end K["Child sandbox"]:::trusted - U -- "grant" --> L - H -- "create_sandbox with the user's login" --> M + U -. "acts as" .-> H + H -- "create_sandbox as the user" --> M M --> C C -- "prove" --> P - C <--> L + C -- "record grant if given" --> L C -- "launch" --> K classDef untrusted fill:#ffe6df,stroke:#d9472b,stroke-width:2px,color:#18202e classDef trusted fill:#e3edff,stroke:#2a63c9,stroke-width:2px,color:#18202e @@ -170,7 +184,7 @@ flowchart LR ``` The endpoint lives in the gateway rather than in each supervisor for three -reasons. Host harnesses have no supervisor, so a supervisor-hosted server would +reasons. Host agents have no supervisor, so a supervisor-hosted server would need a second path for them. Keeping the decision in one component means there is one implementation of the security checks and no second copy to drift. And MCP revisions change quickly; a gateway-hosted server is upgraded once, while a @@ -180,6 +194,50 @@ replica can serve any request. The split follows the zero-trust pattern of deciding centrally and enforcing at the workload: the gateway decides, the supervisor enforces reachability next to the sandbox. +### Interfaces: API, SDK, CLI and MCP + +The contract is a gRPC service. The SDKs, the CLI and the MCP endpoint are +surfaces over it, and each MCP tool maps to one RPC. A harness that wants deep +integration calls the SDK from its own code and decides itself when to create a +child; a harness or agent that already speaks MCP needs only a configuration +entry. Nothing about the security model depends on which surface is used. + +```proto +service SandboxChildren { + rpc CreateChildSandbox(CreateChildSandboxRequest) returns (CreateChildSandboxResponse) { + option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:write" }; + } + rpc WaitChildSandbox(WaitChildSandboxRequest) returns (WaitChildSandboxResponse) { + option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:read" }; + } + rpc StopChildSandbox(StopChildSandboxRequest) returns (StopChildSandboxResponse) { + option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:write" }; + } + rpc ListChildSandboxes(ListChildSandboxesRequest) returns (ListChildSandboxesResponse) { + option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:read" }; + } + rpc GetSandboxLimits(GetSandboxLimitsRequest) returns (GetSandboxLimitsResponse) { + option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:read" }; + } +} +``` + +`auth_mode: "dual"` admits both user and sandbox principals. For sandbox +principals each handler applies a child-scope guard, in the style of +`ensure_sandbox_principal_scope`: a sandbox may create children only under its +grant and act only on its own descendants. `exec` and `get_file` use the +existing `ExecSandbox` RPC with the same guard. + +| Surface | Used by | How it reaches the gateway | +|---------|---------|----------------------------| +| gRPC / SDK (Rust, Python, TypeScript, Go) | Harnesses integrating in code; services such as RL environment runners | From a sandbox: through the door at `openshell.local`. From the host: the user's login. | +| CLI | Users and scripts | The user's login | +| MCP `/mcp` | Any MCP-capable agent or harness | From a sandbox: through the door. From the host: the user's login. | + +The door forwards both the MCP endpoint and the `SandboxChildren` service, so +SDK code inside a sandbox connects to `openshell.local` with no credential in +the same way an MCP client does. + ### Grants A grant is a budget for one tree of sandboxes, set by the user. It answers @@ -216,6 +274,9 @@ message SandboxGrantSpec { // Absolute path of the executable allowed to use the door, // for example /usr/local/bin/node for a Node-based harness. string program = 5; + // NARROW (default): agents write their children's network rules within the + // ceiling. INHERIT: every child receives its parent's ceiling unchanged. + ChildPolicyMode child_policy = 6; } message CreateSandboxRequest { @@ -346,12 +407,36 @@ of a sandbox that has a lineage, whatever the source: `UpdateConfig`, policy sync from the supervisor, or an approved proposal. A sandbox in a tree can never be widened past its ceiling after it starts. -**Global policy.** While a gateway-global policy is active, it replaces every -sandbox's policy rather than limiting it, and provider layers are switched off. -A child created with a narrow policy would silently receive the global one -instead. `create_sandbox` with a policy is therefore refused with -`GLOBAL_POLICY_ACTIVE`, so the agent learns that delegation is unavailable -instead of receiving a child with unexpected access. +**Global policy as a maximum.** A gateway-global policy is meant to state what +no sandbox on the gateway may exceed. Today it does that by replacing every +sandbox's policy, which also removes every narrower one, so trees of sandboxes +with least privilege cannot exist under it. This RFC adds a second mode in which +the global policy is the gateway's outermost ceiling: + +```toml +[openshell.gateway] +# "replace" (current behavior) or "maximum" +global_policy_mode = "maximum" +``` + +In `maximum` mode each sandbox keeps its own policy, provider layers stay +active, and the gateway proves that every policy fits inside the global one: a +grant's ceiling when the holder is created, every child policy, and every later +policy write. A policy that does not fit is refused with +`EXCEEDS_GLOBAL_POLICY` and the prover's counterexample. Children are then +proved against both their ceiling and the global policy. + +In `replace` mode a child created with a narrow policy would silently receive +the global one instead, so `create_sandbox` with a policy is refused with +`GLOBAL_POLICY_ACTIVE`, and the agent learns that delegation is unavailable +rather than receiving a child with unexpected access. + +**Whether a parent may set its child's policy.** A grant states whether agents +in the tree may write their children's network rules (`child_policy: narrow`, +the default) or whether every child receives its parent's ceiling unchanged +(`child_policy: inherit`). In `inherit` mode a `create_sandbox` request that +includes a policy is refused. Either way a child never receives more than the +ceiling and the global maximum allow. **Layers the gateway adds.** The door rule and provider layers are added after the proof. Both come from trusted sources, the grant and admin-imported provider @@ -483,7 +568,7 @@ such calls, and pushing list-changed notifications is future work. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| -| `create_sandbox` | sandboxes with a grant and depth left | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s` | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `GLOBAL_POLICY_ACTIVE` | +| `create_sandbox` | users; sandboxes with a grant and depth left | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `GLOBAL_POLICY_ACTIVE` | | `wait` | sandboxes | `handle`, `timeout_s` | `phase`, `exit_code` | `NOT_YOUR_CHILD` | | `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | | `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | @@ -492,7 +577,9 @@ such calls, and pushing list-changed notifications is future work. | `get_limits` | sandboxes | none | depth, children and total used and allowed | none | A sandbox acts only on its own descendants; `handle` is the child's sandbox name -within the caller's workspace. `inputs` lists paths in the parent that are +within the caller's workspace. A user caller creates top-level sandboxes with +the same rules as `openshell sandbox create`, may attach a grant to them, and +acts on any sandbox its workspace role allows. `inputs` lists paths in the parent that are copied read-only into the child at start. `exec` and `get_file` run through the existing `ExecSandbox` path, acting for the tree's root user. @@ -592,16 +679,27 @@ while lineage records which sandbox asked. | List grants | own workspace | no | no | no | | Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | | Reach the door | not applicable | named program only | no route | no route | -| `create_sandbox` | with a host-session grant | within limits | hidden and refused | hidden and refused | +| `create_sandbox` | top-level sandboxes, as with the CLI | within limits | hidden and refused | hidden and refused | | `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | own descendants | ### Gateway configuration -The feature is off by default and enabled per gateway: +The MCP endpoint is on by default. It is a route on the existing gateway port +rather than a separate process, so it costs nothing until it is called, and it +lets any agent that can reach the gateway start sandboxes, whether or not +sub-agents are involved. Turning it on does not let any sandbox create +children: that still requires a grant on the sandbox. Operators who do not want +it turn it off in configuration or from the CLI: + +```shell +openshell gateway mcp status +openshell gateway mcp disable +openshell gateway mcp enable +``` ```toml [openshell.gateway.sandbox_mcp] -enabled = true +enabled = true # default # Operator caps; the effective limit is the lowest of these, the grant and the harness. max_depth = 3 max_breadth = 8 @@ -610,8 +708,8 @@ max_total = 20 tool_idle_timeout = "10m" ``` -When disabled, `/mcp` is not routed, `--grant` is rejected and the supervisor -door answers 404. +When disabled, `/mcp` is not routed and the supervisor door answers 404 for +MCP requests; the gRPC child-sandbox API, the SDKs and the CLI keep working. ### CLI surface @@ -651,34 +749,37 @@ did it run" from the same log stream they already ship to their SIEM. ### Compatibility The change is additive. Sandboxes created without `--grant` behave exactly as -today. Existing gRPC clients are unaffected; the new `grant` field is optional. +today, and the default `global_policy_mode` remains `replace`. Existing gRPC clients are unaffected; the new `grant` field is optional. Supervisors without the door ignore `openshell.local`, so a newer gateway with an older supervisor image simply cannot serve in-sandbox callers until the image -is updated. Host harnesses can use the endpoint as soon as the gateway supports -it. +is updated. Host agents can use the endpoint as soon as the gateway supports it. ## Implementation plan The work is phased so that each phase is usable and testable on its own: -- **Phase 1: Grants, lineage and the boundary proof behind gRPC.** Add the +- **Phase 1: The child-sandbox API, grants, lineage and the boundary proof.** + Add the `SandboxChildren` gRPC service and its SDK functions, the `sandbox_grant` and `sandbox_lineage` object types, atomic counter reservation, `SandboxGrantSpec` on `CreateSandboxRequest`, the grant RPCs and CLI, and the prover call at create time and on every later policy write to a sandbox with a lineage. Validate with the existing Pi and OpenWorker prototypes by replacing their host creation services with these RPCs. -- **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` with - authentication, per-caller `tools/list`, `create_sandbox`, `wait`, - `get_file`, `stop_sandbox`, `list_children` and `get_limits`. Add the door at - `openshell.local`, the door rule written from the grant, and the - `urn:openshell:gateway-mcp` token. Ship host harness support first, then - in-sandbox callers. +- **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` over the + same API, with authentication and per-caller `tools/list`, and the + `openshell gateway mcp` commands. Add the door at `openshell.local` for both + MCP and the gRPC service, the door rule written from the grant, and the + `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox + callers. - **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the idle timeout. -- **Phase 4: Lifecycle.** Cascade stop on every way a parent ends, the orphan +- **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the + proof of every ceiling, child policy and policy write against the global + policy in `maximum` mode. +- **Phase 5: Lifecycle.** Cascade stop on every way a parent ends, the orphan reaper, and idle and maximum lifetime for agent children with the "a parent with running children is not idle" rule. -- **Phase 5: Documentation.** A tutorial in which a Pi lead creates a Pi worker, +- **Phase 6: Documentation.** A tutorial in which a Pi lead creates a Pi worker, reference pages for grants and the MCP tools, and a configuration entry for `[openshell.gateway.sandbox_mcp]`. @@ -725,11 +826,27 @@ dedicated token. quickly. Atomic counters, `max_total`, operator caps, retry deduplication and the token rate limit bound the damage. -- **Host mode is weaker.** A host harness is not contained, only its children +- **Host mode is weaker.** An agent on the host is not contained, only its children are. This is a property of host mode and must be documented, not hidden. ## Future work +### Returning results to the parent + +`get_file` lets a parent read a running child's files, but a child's standard +output does not reach the logs and a child that exits loses its files. A native +channel would let a child publish a result that the gateway keeps until the +parent reads it, so `wait` can return the result itself. + +### Agent-to-agent communication through the gateway + +Teams and swarms of agents need to find each other and exchange messages and +context, and in a swarm there may be no parent at all. Routing that traffic +through the gateway gives one place to check that two sandboxes may talk, to +offer discovery of other agents in a tree or workspace, and to monitor what +passes between them. The lineage and identity introduced here are the basis +for that work, which needs its own proposal. + ### Interactive approval A request outside the grant could wait for the user instead of being refused. @@ -803,13 +920,26 @@ cannot count ("at most three children"), and the decision would be split between policy and grant. The grant decides; the door rule controls only reachability. -### Global policy as the limit +### SDK or CLI only, without MCP + +Harnesses would create children by calling the SDK from their own code, or by +running the CLI. + +These are not alternatives to the proposal but parts of it: the gRPC API is the +contract, and the SDK and CLI are surfaces over it. MCP is added as one more +surface because most harnesses and coding agents already speak it, so they can +create sandboxes with a configuration entry and no code change. Offering only +the SDK would make every harness change its code before it could use the +feature. + +### Global policy as a fixed policy -Children would all run under a gateway-global policy. +Children would all run under the gateway-global policy as it works today, which +replaces each sandbox's policy. -This was rejected because a global policy replaces every sandbox's policy, so -no child could have less access than another, and least privilege per child is -the point of the proposal. +This was rejected because no child could then have less access than another, +and least privilege per child is the point of the proposal. The global policy +is instead offered as a maximum that every sandbox must fit inside. ## Prior art @@ -849,6 +979,10 @@ the point of the proposal. - Should a parent be able to read the result of a child that has exited, for example through a result file the gateway keeps until the parent reads it? - When interactive approval is added, where should pending requests appear? +- Should `global_policy_mode = "maximum"` become the default, and how do + operators who run shared gateways use the global policy today? +- Is the MCP endpoint's cost when idle small enough to keep it on by default on + every gateway? ## Appendix: End-to-End Walkthrough From 9304e257576a58c30128662708388c4d5f99b0e9 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 10:21:47 -0700 Subject: [PATCH 04/26] docs(rfc): open decision on how the door carries gRPC (MCP only, HTTP/2 proxy, or gRPC-Web/Connect) Signed-off-by: patkraw --- .../README.md | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index d4c35e3cdc..b12bb98184 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -234,9 +234,11 @@ existing `ExecSandbox` RPC with the same guard. | CLI | Users and scripts | The user's login | | MCP `/mcp` | Any MCP-capable agent or harness | From a sandbox: through the door. From the host: the user's login. | -The door forwards both the MCP endpoint and the `SandboxChildren` service, so -SDK code inside a sandbox connects to `openshell.local` with no credential in -the same way an MCP client does. +The door is the only way code inside a sandbox reaches the gateway, so what it +forwards decides which surfaces work from inside. MCP is plain HTTP/1.1 and is +forwarded. The gRPC service runs on HTTP/2, and how the door carries it is an +open decision (see [Open questions](#open-questions)); until then, code inside a +sandbox uses MCP and the gRPC service is used from the host. ### Grants @@ -983,6 +985,22 @@ is instead offered as a maximum that every sandbox must fit inside. operators who run shared gateways use the global policy today? - Is the MCP endpoint's cost when idle small enough to keep it on by default on every gateway? +- **Decision to make: how the door carries gRPC.** The door forwards MCP, which + is plain HTTP/1.1: one request, one response, read and forwarded with the + sandbox's token. gRPC uses HTTP/2, with many calls multiplexed on one + connection and streaming responses. Options: + - **A. MCP only inside sandboxes.** No change; agents use MCP, and the SDKs + are used from the host. Cost: SDK code inside a sandbox cannot call the + child-sandbox API. + - **B. Forward gRPC over HTTP/2.** The door becomes an HTTP/2 proxy that adds + the sandbox's token to every call and relays streams. Cost: the largest + change to the supervisor. + - **C. gRPC over HTTP/1.1 (gRPC-Web or Connect).** The gateway also accepts + the HTTP/1.1 encoding, and the door forwards it the way it forwards MCP. + Cost: a second encoding on the gateway and SDK clients configured for it. + + Leaning: A for v1, then C. Agents mostly use MCP, and C reuses the existing + door. ## Appendix: End-to-End Walkthrough From 282773f3cb86c9e84c2dc08af16aea0eee1c994e Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 10:21:59 -0700 Subject: [PATCH 05/26] docs(rfc): surfaces table reflects the open gRPC-through-door decision Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index b12bb98184..7bea3175eb 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -230,7 +230,7 @@ existing `ExecSandbox` RPC with the same guard. | Surface | Used by | How it reaches the gateway | |---------|---------|----------------------------| -| gRPC / SDK (Rust, Python, TypeScript, Go) | Harnesses integrating in code; services such as RL environment runners | From a sandbox: through the door at `openshell.local`. From the host: the user's login. | +| gRPC / SDK (Rust, Python, TypeScript, Go) | Harnesses integrating in code; services such as RL environment runners | From the host: the user's login. From a sandbox: through the door, once it carries gRPC (open decision). | | CLI | Users and scripts | The user's login | | MCP `/mcp` | Any MCP-capable agent or harness | From a sandbox: through the door. From the host: the user's login. | From b44744d6a6be623df56856fa1a03e6ee9499d8b7 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 12:51:11 -0700 Subject: [PATCH 06/26] docs(rfc): child policy is bounded by its parent's policy Signed-off-by: patkraw --- .../README.md | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 7bea3175eb..f596612e09 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -128,7 +128,7 @@ flowchart LR subgraph GW["OpenShell gateway"] M["MCP endpoint /mcp"]:::new C["Create logic shared with gRPC"]:::changed - P["Prover: child inside the ceiling?"]:::changed + P["Prover: child inside the parent's policy?"]:::changed L[("Grants and lineage")]:::new X["Cascade stop"]:::new end @@ -261,7 +261,7 @@ openshell sandbox create \ The CLI parses `--grant` into a `SandboxGrantSpec` on `CreateSandboxRequest`. Only a user principal may set it; the gateway rejects the field from any other -principal. The holder's policy at creation becomes the grant's ceiling. +principal. The holder's policy is the ceiling for its children. ```proto message SandboxGrantSpec { @@ -276,8 +276,8 @@ message SandboxGrantSpec { // Absolute path of the executable allowed to use the door, // for example /usr/local/bin/node for a Node-based harness. string program = 5; - // NARROW (default): agents write their children's network rules within the - // ceiling. INHERIT: every child receives its parent's ceiling unchanged. + // NARROW (default): agents write their children's network rules within their + // own policy. INHERIT: every child receives its parent's policy unchanged. ChildPolicyMode child_policy = 6; } @@ -372,13 +372,18 @@ not change that model. It is used only for decisions about trees (cascade stop, ### Ceiling and policy proof -**The ceiling caps a child, not the parent's current policy.** A parent's -current policy can change from task to task through approved proposals. If a -child had to fit inside the parent's current policy, child creation would fail -or succeed depending on what the parent happened to have at that moment. The -parent could obtain anything inside its ceiling anyway, so capping by the -ceiling adds no risk and makes behavior predictable. A child's own ceiling is -its parent's ceiling. +**A child's ceiling is its parent's policy.** When a sandbox creates a child, +the gateway proves the child's policy against the parent's current policy, +without the door rule and provider layers. The lead's children are bounded by +the lead's policy, its grandchildren by its children's policies, and so on, so +each level can only narrow: `child ≤ parent ≤ global maximum`, the model in +#2025. A single tree-wide ceiling, the lead's policy, would let a child that was +given only GitHub read access create a grandchild with everything the lead has. + +Because the ceiling is the parent's policy at the moment of the create, a +parent whose policy has grown through an approved proposal can pass that access +on, and one whose policy has shrunk cannot. Children already running when a +parent's policy shrinks keep their policy; revalidating them is #3808. **What the agent writes.** The agent supplies only the child's `network_policies`. The gateway builds the child's full policy from the @@ -423,7 +428,7 @@ global_policy_mode = "maximum" In `maximum` mode each sandbox keeps its own policy, provider layers stay active, and the gateway proves that every policy fits inside the global one: a -grant's ceiling when the holder is created, every child policy, and every later +grant holder's policy when it is created, every child policy, and every later policy write. A policy that does not fit is refused with `EXCEEDS_GLOBAL_POLICY` and the prover's counterexample. Children are then proved against both their ceiling and the global policy. @@ -435,7 +440,7 @@ rather than receiving a child with unexpected access. **Whether a parent may set its child's policy.** A grant states whether agents in the tree may write their children's network rules (`child_policy: narrow`, -the default) or whether every child receives its parent's ceiling unchanged +the default) or whether every child receives its parent's policy unchanged (`child_policy: inherit`). In `inherit` mode a `create_sandbox` request that includes a policy is refused. Either way a child never receives more than the ceiling and the global maximum allow. @@ -776,8 +781,8 @@ The work is phased so that each phase is usable and testable on its own: - **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the idle timeout. - **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the - proof of every ceiling, child policy and policy write against the global - policy in `maximum` mode. + proof of every grant holder's policy, child policy and policy write against + the global policy in `maximum` mode. - **Phase 5: Lifecycle.** Cascade stop on every way a parent ends, the orphan reaper, and idle and maximum lifetime for agent children with the "a parent with running children is not idle" rule. @@ -1014,7 +1019,7 @@ CreateSandbox { name: "lead", policy: lead.yaml, providers: [pi-nvidia], grant: { max_depth: 1, max_breadth: 2, max_total: 3, expires_after: 4h, program: "/usr/local/bin/node" } } → principal = User → may set a grant → pass -→ ceiling = lead.yaml +→ lead.yaml is the ceiling for the lead's children → max_depth > 0 → add door rule (openshell.local:80, /usr/local/bin/node) → persist SandboxGrant { total: 1 }, SandboxLineage { lead, depth: 0 } ``` @@ -1037,7 +1042,7 @@ create_sandbox { kind: agent, policy: {board rule}, providers: [pi-nvidia], → no global policy → pass → depth 0 < 1, breadth 0 < 2, total 1 < 3 → pass → providers ⊆ lead's providers → pass -→ prover: check_within_boundary(ceiling, child) → Within +→ prover: check_within_boundary(lead's policy, child) → Within → reserve counters (total 2, lead's children 1) → child depth 1 = max_depth → no door rule → create from lead's template, no GPU, acting for the root user @@ -1060,7 +1065,7 @@ node (worker) → openshell.local/mcp → depth 1 = max_depth → LIMIT_REACHED(depth), ApiActivity emitted ``` -**6. The lead asks for more than its ceiling.** +**6. The lead asks for more than it has.** ```text create_sandbox { policy: {POST api.github.com/repos/**} } From 511d4d4a4ac38100156c14ea00637bdb5a5971dc Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:11:46 -0700 Subject: [PATCH 07/26] docs(rfc): open the door in every sandbox; gateway checks the program Signed-off-by: patkraw --- .../README.md | 182 +++++++++--------- 1 file changed, 86 insertions(+), 96 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index f596612e09..a42de8b609 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -21,10 +21,9 @@ first. The contract is a gRPC API for child sandboxes, exposed through the SDKs, the CLI and an MCP endpoint, so a harness can integrate at whichever level suits it. Agents inside a sandbox reach the gateway through a door in their own -supervisor at `http://openshell.local`. The supervisor checks the calling -program against a network rule that the gateway writes from the grant, attaches -a token bound to the sandbox, and forwards the call; the agent never holds a -credential. Agents and tools running on the host call the same endpoint with the +supervisor at `http://openshell.local`, which is open in every sandbox. The +supervisor identifies the calling program, attaches it and a token bound to the +sandbox, and forwards the call; the agent never holds a credential. Agents and tools running on the host call the same endpoint with the user's login and create sandboxes as the user would with the CLI. In every case the gateway makes every decision, and a sandbox without a grant cannot create sandboxes at all. @@ -113,17 +112,17 @@ Two kinds of caller reach the gateway. **Harness inside a sandbox.** This is the main case. The user starts a lead sandbox with a grant. The lead's harness connects to `http://openshell.local/mcp`, -which the lead's supervisor serves. The supervisor checks the calling program, -attaches the sandbox's token and forwards the call. The gateway authenticates -the sandbox, checks the grant, proves the child's policy, records lineage and -launches the child through the compute driver. +which the lead's supervisor serves. The supervisor identifies the calling +program, attaches it and the sandbox's token, and forwards the call. The gateway +authenticates the sandbox, checks the program and the grant, proves the child's +policy, records lineage and launches the child through the compute driver. ```mermaid flowchart LR U["User: start the lead with --grant"]:::user subgraph LEAD["Lead sandbox"] A["Agent harness, e.g. Pi (no credential)"]:::untrusted - S["Supervisor door at openshell.local/mcp: program allowed? add sandbox token"]:::new + S["Supervisor door at openshell.local/mcp: add calling program and sandbox token"]:::new end subgraph GW["OpenShell gateway"] M["MCP endpoint /mcp"]:::new @@ -307,8 +306,9 @@ from the CLI, and needs no new approval interface. Interactive approval for requests outside the grant is future work. **Only the user changes a grant.** The user can widen, shrink or revoke a grant -while the tree runs. Changes apply to new creates only: an existing child keeps -its policy and its door rule. No sandbox can change a grant, including through +while the tree runs. Changes apply to the next call: a sandbox that had no depth +left can create children as soon as the user raises `max_depth`, with no policy +change, and an existing child keeps its policy. No sandbox can change a grant, including through the gateway MCP, and every change is audited. If an agent could widen its own grant, the grant would mean nothing. @@ -374,7 +374,7 @@ not change that model. It is used only for decisions about trees (cascade stop, **A child's ceiling is its parent's policy.** When a sandbox creates a child, the gateway proves the child's policy against the parent's current policy, -without the door rule and provider layers. The lead's children are bounded by +without provider layers. The lead's children are bounded by the lead's policy, its grandchildren by its children's policies, and so on, so each level can only narrow: `child ≤ parent ≤ global maximum`, the model in #2025. A single tree-wide ceiling, the lead's policy, would let a child that was @@ -445,9 +445,9 @@ the default) or whether every child receives its parent's policy unchanged includes a policy is refused. Either way a child never receives more than the ceiling and the global maximum allow. -**Layers the gateway adds.** The door rule and provider layers are added after -the proof. Both come from trusted sources, the grant and admin-imported provider -profiles, not from the agent. Provider attachments are checked separately: a +**Layers the gateway adds.** Provider layers are added after the proof. They +come from a trusted source, admin-imported provider profiles, not from the +agent. Provider attachments are checked separately: a child may only receive providers attached to its parent. #### Other MCP servers @@ -499,45 +499,42 @@ It reuses the mechanism that serves the `policy.local` API today Clients that honor the proxy environment reach the explicit forward-proxy path. Both paths dispatch on the `Host` header. -- **Program check.** The door checks the calling program against the sandbox's - network policy for `openshell.local:80`, using the binary identity the - isolation backend supplies for the connection (RFC 0012), through the same - OPA evaluation the proxy uses for egress (`authorize_supplied_identity`). The - workload cannot forge this identity. `policy.local` today skips this check; - the door must not. +- **Open in every sandbox.** Every sandbox can reach the door while the gateway + MCP is enabled, as every sandbox can reach `policy.local` today. Nothing in a + sandbox's policy controls it; there is no door rule. The gateway decides every + call, so a sandbox without a grant, or with no depth left, reaches the door + and is refused. + +- **Calling program.** The door identifies the calling program from the binary + identity the isolation backend supplies for the connection (RFC 0012), and + sends its kernel-resolved path to the gateway in the + `x-openshell-caller-program` header. The door builds the forwarded request + itself, so the workload cannot set or forge this header. A connection whose + program cannot be identified is refused with `door_denied`. - **Forwarding.** The door reads the request body (the same 64 KiB limit as `policy.local`), forwards it to the gateway's `/mcp` with a token bound to this sandbox, and returns the gateway's response unchanged. -**The door rule.** The network rule that makes the program check pass is -written by the gateway, never by the agent: - -```yaml -network_policies: - openshell_gateway_mcp: - name: openshell_gateway_mcp - endpoints: - - host: openshell.local - port: 80 - binaries: - - path: /usr/local/bin/node # the grant's program -``` - -The gateway adds it to a sandbox's base policy at creation when the sandbox is -an agent with depth left: the holder when `max_depth > 0`, and an agent child -when its depth is below `max_depth`. A child policy that already contains the -key is refused. Because the rule is part of the base policy at creation, it is -present from the supervisor's first configuration fetch; the 10 second policy -poll (`OPENSHELL_POLICY_POLL_INTERVAL_SECS`) does not delay it. The door rule is -a plain network rule, so the prover can check it. - -**Program, not script.** The door rule names an executable. Pi is a Node -program, so its rule names `/usr/local/bin/node`, and any Node process in the -sandbox can use the door, including one the agent starts from its shell. `curl`, -`bash` and other programs cannot. Provider credentials scoped to `node` have the -same property today. The gateway's checks still apply to every call, so a -script using the door gets exactly what the harness would get. +- **Rate limit.** The gateway limits calls per sandbox, so a sandbox that may + not create children cannot flood the gateway through its door. + +**Why no door rule.** An earlier design opened the door only in sandboxes with a +grant and depth left, through a network rule the gateway wrote into each +sandbox's policy. That rule went stale when the user changed a grant, was lost +when a user replaced a policy or a global policy replaced every policy, and had +to be stripped before every proof and guarded against agents writing it. An +open door with every decision in the gateway has none of these problems, and a +grant change takes effect on the next call. + +**Program check.** A grant names one program, for example +`/usr/local/bin/node` for Pi. The gateway refuses a call from a sandbox in that +tree when the forwarded program differs, with `PROGRAM_NOT_ALLOWED`. Any Node +process in the sandbox can use the door, including one the agent starts from +its shell; `curl`, `bash` and other programs cannot. The gateway's checks still +apply to every call, so a script using the door gets exactly what the harness +would get. A sandbox without a grant has no program restriction, since it may +not create children at all. **Token.** The supervisor obtains a short-lived token for the audience `urn:openshell:gateway-mcp` through `RefreshSandboxToken`, extending the @@ -547,35 +544,27 @@ sandbox id and lives at most one hour (`MAX_EXTENSION_TOKEN_TTL`). The gateway accepts it only on `/mcp`. Using a dedicated audience instead of the supervisor's main session token limits what a leaked token can do. -### Three walls - -A sandbox with no depth left meets three independent checks if it tries to -create a child: +### Refusals -| Wall | Where | What happens | -|------|-------|--------------| -| The tool is hidden | Gateway | `tools/list` is answered per caller; without a grant or with no depth left there is no `create_sandbox`. Models do not see, and so do not waste turns on, tools they cannot use. | -| No route | Supervisor | No door rule, so the door refuses every program before anything reaches the gateway. | -| Refused | Gateway | Any create from such a caller fails with `LIMIT_REACHED(depth)` and is audited. | - -The door enforces who can reach the gateway MCP; the gateway enforces what they -may do. The gateway remains the final authority: the first two walls are early -refusals that also protect against a bug in the third. +A sandbox with no depth left, or no grant, reaches the door and sees the same +tools as any other sandbox. The gateway refuses its `create_sandbox` with +`LIMIT_REACHED(depth)` or `NOT_ALLOWED` and audits the refusal. Tools are always +listed because a grant can be widened at any time and many MCP clients load the +tool list only once; `get_limits` reports what the caller may do right now +(`can_create_children`, depth, breadth and total). A model that calls a tool it +cannot use gets a clear error in one turn. ### MCP tools -`tools/list` is answered by the gateway, because the list depends on state only -the gateway has: whether the caller has a grant, how much depth and breadth it -has left, and whether the user changed the grant a moment ago. The supervisor -only learns policy changes on its 10 second poll and never sees the counters. -MCP clients call `tools/list` once per connection, so this costs one request per -session. A client's cached list can go out of date during a session, for example -still showing `create_sandbox` after the budget is used; the gateway refuses -such calls, and pushing list-changed notifications is future work. +`tools/list` is answered by the gateway and depends only on the kind of caller: +a sandbox sees the child tools, a user sees `create_sandbox` and the grant +tools. It does not depend on the grant or the counters, so a client's cached +list never goes out of date; whether a call is allowed is decided when it is +made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| -| `create_sandbox` | users; sandboxes with a grant and depth left | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `GLOBAL_POLICY_ACTIVE` | +| `create_sandbox` | users and sandboxes | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `GLOBAL_POLICY_ACTIVE` | | `wait` | sandboxes | `handle`, `timeout_s` | `phase`, `exit_code` | `NOT_YOUR_CHILD` | | `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | | `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | @@ -605,7 +594,7 @@ and whether it can delegate further. | Runs | A harness doing its own task | No agent; only commands its parent sends | | Parent uses it with | `wait`, `get_file` | `exec`, `get_file` | | `exec` | Refused with `WRONG_KIND` | Allowed; every command is logged | -| Can create children | If depth is left; it gets the door rule | Never; it never gets the door rule | +| Can create children | If depth is left | Never | | Command | Required | Optional; it stays up until stopped | | Lifetime | Until stopped; never outlives its parent | Until stopped, or stopped after an idle timeout (default 10 minutes without `exec` or `get_file`) | @@ -685,8 +674,8 @@ while lineage records which sandbox asked. | Start a sandbox with `--grant` | yes | no | no | no | | List grants | own workspace | no | no | no | | Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | -| Reach the door | not applicable | named program only | no route | no route | -| `create_sandbox` | top-level sandboxes, as with the CLI | within limits | hidden and refused | hidden and refused | +| Use the door | not applicable | grant's program only | grant's program only | grant's program only | +| `create_sandbox` | top-level sandboxes, as with the CLI | within limits | refused | refused | | `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | own descendants | ### Gateway configuration @@ -773,10 +762,10 @@ The work is phased so that each phase is usable and testable on its own: sandbox with a lineage. Validate with the existing Pi and OpenWorker prototypes by replacing their host creation services with these RPCs. - **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` over the - same API, with authentication and per-caller `tools/list`, and the - `openshell gateway mcp` commands. Add the door at `openshell.local` for both - MCP and the gRPC service, the door rule written from the grant, and the - `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox + same API, with authentication, the program check and a per-sandbox rate + limit, and the `openshell gateway mcp` commands. Add the door at + `openshell.local` for both MCP and the gRPC service, open in every sandbox, + and the `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox callers. - **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the idle timeout. @@ -812,16 +801,19 @@ dedicated token. would let an unauthenticated caller create sandboxes, so this code needs focused review and tests for every principal type. -- **Trusted, unproved layers.** The door rule and provider layers are added - after the proof. A bug in the code that writes them could widen a child - without the prover noticing. The code is small and table-driven, and tests - cover each kind and depth combination, but it is trusted rather than proved. +- **Trusted, unproved layers.** Provider layers are added after the proof. A + bug in the code that writes them could widen a child without the prover + noticing. It is trusted rather than proved. -- **Program-level door.** The door rule matches an executable, not a script. In - a harness built on Node or Python, any script run by the same interpreter can +- **Program-level check.** The grant names an executable, not a script. In a + harness built on Node or Python, any script run by the same interpreter can use the door. Such a script still receives only what the gateway would give - the harness, but operators should not read the door rule as "only the - harness". + the harness, but operators should not read `program` as "only the harness". + +- **Every sandbox reaches the gateway MCP.** The door is open in every sandbox, + so the gateway's authentication and refusals are the only barrier, and every + sandbox can learn that the endpoint exists. The rate limit bounds the load a + sandbox can cause; `openshell gateway mcp disable` closes the door everywhere. - **Results channel.** A child's standard output does not reach the sandbox logs, and `get_file` needs the child to be running. A child that exits before @@ -924,8 +916,7 @@ example "only the lead may call `create_*`". This was rejected because the prover cannot check MCP rules, a policy rule cannot count ("at most three children"), and the decision would be split -between policy and grant. The grant decides; the door rule controls only -reachability. +between policy and grant. The grant decides. ### SDK or CLI only, without MCP @@ -1020,7 +1011,6 @@ CreateSandbox { name: "lead", policy: lead.yaml, providers: [pi-nvidia], expires_after: 4h, program: "/usr/local/bin/node" } } → principal = User → may set a grant → pass → lead.yaml is the ceiling for the lead's children -→ max_depth > 0 → add door rule (openshell.local:80, /usr/local/bin/node) → persist SandboxGrant { total: 1 }, SandboxLineage { lead, depth: 0 } ``` @@ -1028,9 +1018,10 @@ CreateSandbox { name: "lead", policy: lead.yaml, providers: [pi-nvidia], ```text node → openshell.local/mcp tools/list -→ supervisor: binary /usr/local/bin/node allowed by door rule → pass -→ forward with gateway-mcp token → Principal::Sandbox(lead) -→ grant present, depth 0 < 1, budget left → include create_sandbox +→ supervisor: calling program /usr/local/bin/node → forward with it and the + gateway-mcp token → Principal::Sandbox(lead) +→ program matches the grant → pass +→ list every tool ``` **3. The lead creates a worker.** @@ -1044,7 +1035,6 @@ create_sandbox { kind: agent, policy: {board rule}, providers: [pi-nvidia], → providers ⊆ lead's providers → pass → prover: check_within_boundary(lead's policy, child) → Within → reserve counters (total 2, lead's children 1) -→ child depth 1 = max_depth → no door rule → create from lead's template, no GPU, acting for the root user → persist SandboxLineage { worker, parent: lead, depth: 1 } ``` @@ -1059,10 +1049,10 @@ create_sandbox { ..., request_id: r1 } **5. The worker tries to create a child.** ```text -node (worker) → openshell.local/mcp -→ supervisor: no door rule → 403 door_denied, HttpActivity denial emitted -(were the call to reach the gateway) +node (worker) → openshell.local/mcp create_sandbox +→ supervisor: forward with program and token → program matches the grant → depth 1 = max_depth → LIMIT_REACHED(depth), ApiActivity emitted +(after the user raises max_depth to 2, the same call succeeds) ``` **6. The lead asks for more than it has.** From b20793e57b7cb030be9ee114174668975fbba78f Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:16:12 -0700 Subject: [PATCH 08/26] docs(rfc): explain why agents reach the gateway through the door Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index a42de8b609..8277c4ebf7 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -489,6 +489,14 @@ protocol version and tool-name syntax. ### The supervisor door +**Why a door.** An agent could call the gateway directly, but it would need a +token and a network rule to the gateway. A prompt-injected agent could take the +token and use it from anywhere, and the gateway would not know which program +called. The supervisor already holds the sandbox's token and sees each +connection's program, and its local name `openshell.local` works wherever the +gateway runs. The door reuses the `policy.local` mechanism, so it adds no new +kind of component. + The door is served by the supervisor at `openshell.local`, port 80, path `/mcp`. It reuses the mechanism that serves the `policy.local` API today (`crates/openshell-supervisor-network/src/policy_local.rs`): @@ -873,11 +881,6 @@ create a Claude Code child in a different image. A per-tree CPU, memory and GPU budget in the grant (`max_cpu`, `max_gpu`) for trees that need GPUs. -### Tool-list change notifications - -The gateway could notify connected clients when a caller's tool list changes, -for example after its budget is used, so that cached lists stay accurate. - ## Alternatives ### MCP server in the supervisor From 5e3aaff4a0d13913273183ba171849586bab47ea Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:26:26 -0700 Subject: [PATCH 09/26] docs(rfc): parents with MCP rules can still create children Signed-off-by: patkraw --- .../README.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 8277c4ebf7..9585cb3192 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -456,10 +456,23 @@ The prover models a request as program, host, port, HTTP method and path. That is enough for network and REST rules. MCP rules match fields inside the JSON-RPC body, the `method` and the tool name in `params.name`, and the model has no body fields. The prover therefore returns `Unsupported` for any endpoint with -`protocol: mcp`, including one that allows every tool ("only L4 TCP and REST are -modeled", `crates/openshell-prover/src/containment.rs`). This is unbuilt work +`protocol: mcp`: per-tool rules because "only L4 TCP and REST are modeled", and +`allow_all_known_mcp_methods` because it "uses authority outside the initial +model" (`crates/openshell-prover/src/containment.rs`). This is unbuilt work rather than a fundamental limit; tool names are strings like paths. +**A parent with MCP rules.** The prover checks both policies before comparing +them, so one `protocol: mcp` endpoint in the parent's policy would make every +proof `Unsupported`, and that parent could create no children at all, even ones +that need nothing from the MCP server. The gateway therefore proves against the +part of the parent's policy the prover can model: it drops endpoints whose +protocol is not L4 or REST before the proof. Dropping allow rules only shrinks +the boundary, so a child that fits the result also fits the parent's full +policy. Such a parent can create children for everything else; it cannot pass +its MCP rules on. With `child_policy: inherit` the child receives the parent's +policy unchanged, which fits by construction, so the proof is skipped and the +MCP rules are inherited. + Until the prover models MCP rules, a child gets another MCP server whole or not at all, written as a REST rule on the server's `/mcp` path: @@ -479,13 +492,15 @@ network_policies: ``` We checked the three ways of writing this with `openshell-prover` against a -ceiling that allows `POST` and `GET` on `/mcp`. The REST rule above is +parent that allows `POST` and `GET` on `/mcp`. The REST rule above is `within`. A rule naming only the host and port `exceeds` the ceiling, because it allows requests to any path. An endpoint with `protocol: mcp` and `allow_all_known_mcp_methods: true` is `unsupported`. The cost of the REST form is that there are no per-tool limits, so a child with GitHub's MCP server can call every tool on it, and the supervisor applies no MCP-specific checks such as -protocol version and tool-name syntax. +protocol version and tool-name syntax. The parent must allow at least the +methods the child gets: a child with `POST`, `GET` and `DELETE` on `/mcp` +exceeds a parent with only `POST` and `GET`. ### The supervisor door From 795033752a5650a67fc43acdb86e00699bd559b2 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:55:53 -0700 Subject: [PATCH 10/26] docs(rfc): asking for more than the parent has Signed-off-by: patkraw --- .../README.md | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 9585cb3192..51fa9a04e4 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -401,7 +401,7 @@ tasks differ. | Prover result | Error returned to the agent | |---------------|-----------------------------| | `Within` | none; creation continues | -| `Exceeds` | `EXCEEDS_CEILING`, with the prover's counterexample, so the agent can narrow its request and retry | +| `Exceeds` | `EXCEEDS_CEILING`, with the prover's counterexample and a hint on how to ask for more, so the agent can narrow its request or widen its own policy and retry | | `Unsupported` | `UNSUPPORTED_RULES`, naming the rule the prover cannot model | | `Inconclusive` | `PROVER_INCONCLUSIVE` | @@ -409,6 +409,46 @@ Returning the counterexample matters: it tells the agent which request (`POST api.github.com/repos/...`) is outside the ceiling, which is usually enough for it to correct its policy without involving the user. +**Asking for more than the parent has.** When the task really needs access the +parent lacks, the parent widens its own policy first, through the proposal flow +every sandbox has today, and then retries. No new mechanism is involved; the +gateway's refusal points the agent to it, in the same way the supervisor's +guidance for a blocked request does (`AGENT_GUIDANCE` in +`crates/openshell-supervisor-network/src/policy_local.rs`): + +1. The lead calls `create_sandbox` for a worker with `POST + api.github.com/repos/**`. The gateway refuses with `EXCEEDS_CEILING`, the + counterexample, and the hint: "A child cannot get more than you have. Either + narrow the child's policy and retry, or, if the task needs this access and + policy proposals are enabled in this sandbox, first ask for it for + yourself: read + /etc/openshell/skills/policy_advisor.md, submit the narrowest rule to + http://policy.local/v1/proposals, wait for approval and + `policy_reloaded: true`, then retry create_sandbox." +2. The lead submits the rule for itself to `POST + http://policy.local/v1/proposals` and keeps the returned chunk id. +3. The user approves it as any proposal today: `openshell rule approve lead + --chunk-id `, the TUI, or auto-approval when the prover finds nothing + new. For a child, the proposal must also fit the child's own ceiling (see + Later policy writes) or it is rejected without asking. +4. The lead waits on `GET /v1/proposals//wait` until it reports + `policy_reloaded: true`. +5. The lead retries `create_sandbox`. The ceiling is the parent's current + policy, so the worker now fits. + +Agent proposals are off by default (`agent_policy_proposals_enabled`). Where +they are off, `policy.local` refuses step 2 with `feature_disabled`, and only the +user can widen the parent, by editing its policy directly. A user who expects +this flow turns the setting on for the lead, or gateway-wide, when starting the +tree. The proof of concept runs the whole flow end to end with the setting on. + +An approval in the harness's own interface does not count: the harness never +tells OpenShell about it, so the gateway still refuses until the parent's +policy or the grant changes. The user approves the parent's proposal without +seeing that it is meant for a child; showing that context, or approving a +child's policy in one step, is the interactive approval described in Future +work. + **Later policy writes.** The same check runs on every later write to the policy of a sandbox that has a lineage, whatever the source: `UpdateConfig`, policy sync from the supervisor, or an approved proposal. A sandbox in a tree can @@ -650,8 +690,8 @@ lifetime still applies, so no tree runs forever. rules through `policy.local`, as any sandbox can. The gateway first checks that the proposal fits the child's ceiling and rejects it without asking if it does not. Otherwise the user approves as today, with `openshell rule approve`, the -TUI or auto-approval. This is the only point at which the user may be prompted -during a run. +TUI or auto-approval. Proposals, from a child or from a parent asking for access +to pass on, are the only point at which the user may be prompted during a run. ### Images and resources @@ -877,7 +917,10 @@ the user would approve where they are: in the TUI, in a harness's approval interface (OpenWorker shows one for teams), or through a harness extension's confirmation dialog, which Pi forwards over its RPC mode. Open points are where pending requests are shown, timeouts, and whether to use a pending identifier -with `wait` or MCP's long-running task support. +with `wait` or MCP's long-running task support. The same prompt could replace +the two-step flow for a child that needs more than its parent has (see Asking +for more than the parent has): the user would see the child's request, and +approving it would widen the parent and create the child together. ### Per-tool rules for other MCP servers @@ -1077,7 +1120,11 @@ node (worker) → openshell.local/mcp create_sandbox ```text create_sandbox { policy: {POST api.github.com/repos/**} } -→ prover: Exceeds, counterexample POST /repos/... → EXCEEDS_CEILING +→ prover: Exceeds, counterexample POST /repos/... → EXCEEDS_CEILING + hint +→ lead: POST policy.local/v1/proposals {addRule github_write} → chunk id +→ user: openshell rule approve lead --chunk-id +→ lead: GET policy.local/v1/proposals//wait → policy_reloaded: true +→ lead: create_sandbox again → prover: Within → created ``` **7. The user widens the grant.** From ec896df50a537bcefe0d19516e9f3bf51274633c Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:06:17 -0700 Subject: [PATCH 11/26] docs(rfc): clarify what v1 refuses and what goes through proposals Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 51fa9a04e4..e35d55cc97 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -95,8 +95,11 @@ sandbox, for whom, and was it allowed". - **Shared or highly available supervisors** (RFC 0018). This design keeps identity per sandbox so that it remains valid when one supervisor process serves several sandboxes. -- **Interactive approval for requests outside a grant.** v1 refuses them; - interactive approval is future work. +- **A single prompt for requests outside a grant.** v1 refuses requests beyond + the grant's limits; the user raises them with `openshell grant update`. + Access the parent lacks goes through the existing proposal flow (see Asking + for more than the parent has). A single prompt that shows the child's request + and approves it in one step is future work. ## Proposal @@ -302,8 +305,9 @@ to create sandboxes unless the user said so. **Set up front.** In v1 the grant is the only approval. A request inside it proceeds without prompting anyone; a request outside it is refused with `LIMIT_REACHED` and the agent adapts. This matches how OpenShell is used today, -from the CLI, and needs no new approval interface. Interactive approval for -requests outside the grant is future work. +from the CLI, and needs no new approval interface. Access the parent lacks goes +through the existing proposal flow (see Asking for more than the parent has); a +single prompt for requests outside the grant is future work. **Only the user changes a grant.** The user can widen, shrink or revoke a grant while the tree runs. Changes apply to the next call: a sandbox that had no depth From 5bf777d2f2ff3f0cd7017daed5b10b36ebf3b7d4 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:08:09 -0700 Subject: [PATCH 12/26] docs(rfc): make cross-references links; move proposal flow to its own heading Signed-off-by: patkraw --- .../README.md | 93 ++++++++++--------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index e35d55cc97..3f80917e0a 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -97,8 +97,8 @@ sandbox, for whom, and was it allowed". serves several sandboxes. - **A single prompt for requests outside a grant.** v1 refuses requests beyond the grant's limits; the user raises them with `openshell grant update`. - Access the parent lacks goes through the existing proposal flow (see Asking - for more than the parent has). A single prompt that shows the child's request + Access the parent lacks goes through the existing proposal flow (see + [Asking for more than the parent has](#asking-for-more-than-the-parent-has)). A single prompt that shows the child's request and approves it in one step is future work. ## Proposal @@ -306,7 +306,7 @@ to create sandboxes unless the user said so. proceeds without prompting anyone; a request outside it is refused with `LIMIT_REACHED` and the agent adapts. This matches how OpenShell is used today, from the CLI, and needs no new approval interface. Access the parent lacks goes -through the existing proposal flow (see Asking for more than the parent has); a +through the existing proposal flow (see [Asking for more than the parent has](#asking-for-more-than-the-parent-has)); a single prompt for requests outside the grant is future work. **Only the user changes a grant.** The user can widen, shrink or revoke a grant @@ -413,46 +413,6 @@ Returning the counterexample matters: it tells the agent which request (`POST api.github.com/repos/...`) is outside the ceiling, which is usually enough for it to correct its policy without involving the user. -**Asking for more than the parent has.** When the task really needs access the -parent lacks, the parent widens its own policy first, through the proposal flow -every sandbox has today, and then retries. No new mechanism is involved; the -gateway's refusal points the agent to it, in the same way the supervisor's -guidance for a blocked request does (`AGENT_GUIDANCE` in -`crates/openshell-supervisor-network/src/policy_local.rs`): - -1. The lead calls `create_sandbox` for a worker with `POST - api.github.com/repos/**`. The gateway refuses with `EXCEEDS_CEILING`, the - counterexample, and the hint: "A child cannot get more than you have. Either - narrow the child's policy and retry, or, if the task needs this access and - policy proposals are enabled in this sandbox, first ask for it for - yourself: read - /etc/openshell/skills/policy_advisor.md, submit the narrowest rule to - http://policy.local/v1/proposals, wait for approval and - `policy_reloaded: true`, then retry create_sandbox." -2. The lead submits the rule for itself to `POST - http://policy.local/v1/proposals` and keeps the returned chunk id. -3. The user approves it as any proposal today: `openshell rule approve lead - --chunk-id `, the TUI, or auto-approval when the prover finds nothing - new. For a child, the proposal must also fit the child's own ceiling (see - Later policy writes) or it is rejected without asking. -4. The lead waits on `GET /v1/proposals//wait` until it reports - `policy_reloaded: true`. -5. The lead retries `create_sandbox`. The ceiling is the parent's current - policy, so the worker now fits. - -Agent proposals are off by default (`agent_policy_proposals_enabled`). Where -they are off, `policy.local` refuses step 2 with `feature_disabled`, and only the -user can widen the parent, by editing its policy directly. A user who expects -this flow turns the setting on for the lead, or gateway-wide, when starting the -tree. The proof of concept runs the whole flow end to end with the setting on. - -An approval in the harness's own interface does not count: the harness never -tells OpenShell about it, so the gateway still refuses until the parent's -policy or the grant changes. The user approves the parent's proposal without -seeing that it is meant for a child; showing that context, or approving a -child's policy in one step, is the interactive approval described in Future -work. - **Later policy writes.** The same check runs on every later write to the policy of a sandbox that has a lineage, whatever the source: `UpdateConfig`, policy sync from the supervisor, or an approved proposal. A sandbox in a tree can @@ -494,6 +454,49 @@ come from a trusted source, admin-imported provider profiles, not from the agent. Provider attachments are checked separately: a child may only receive providers attached to its parent. +#### Asking for more than the parent has + +When the task really needs access the parent lacks, the parent widens its own policy first, through the proposal flow +every sandbox has today, and then retries. No new mechanism is involved; the +gateway's refusal points the agent to it, in the same way the supervisor's +guidance for a blocked request does (`AGENT_GUIDANCE` in +`crates/openshell-supervisor-network/src/policy_local.rs`): + +1. The lead calls `create_sandbox` for a worker with `POST + api.github.com/repos/**`. The gateway refuses with `EXCEEDS_CEILING`, the + counterexample, and the hint: "A child cannot get more than you have. Either + narrow the child's policy and retry, or, if the task needs this access and + policy proposals are enabled in this sandbox, first ask for it for + yourself: read + /etc/openshell/skills/policy_advisor.md, submit the narrowest rule to + http://policy.local/v1/proposals, wait for approval and + `policy_reloaded: true`, then retry create_sandbox." +2. The lead submits the rule for itself to `POST + http://policy.local/v1/proposals` and keeps the returned chunk id. +3. The user approves it as any proposal today: `openshell rule approve lead + --chunk-id `, the TUI, or auto-approval when the prover finds nothing + new. For a child, the proposal must also fit the child's own ceiling (see + "Later policy writes" in + [Ceiling and policy proof](#ceiling-and-policy-proof)) or it is rejected + without asking. +4. The lead waits on `GET /v1/proposals//wait` until it reports + `policy_reloaded: true`. +5. The lead retries `create_sandbox`. The ceiling is the parent's current + policy, so the worker now fits. + +Agent proposals are off by default (`agent_policy_proposals_enabled`). Where +they are off, `policy.local` refuses step 2 with `feature_disabled`, and only the +user can widen the parent, by editing its policy directly. A user who expects +this flow turns the setting on for the lead, or gateway-wide, when starting the +tree. The proof of concept runs the whole flow end to end with the setting on. + +An approval in the harness's own interface does not count: the harness never +tells OpenShell about it, so the gateway still refuses until the parent's +policy or the grant changes. The user approves the parent's proposal without +seeing that it is meant for a child; showing that context, or approving a +child's policy in one step, is the interactive approval described in +[Interactive approval](#interactive-approval). + #### Other MCP servers The prover models a request as program, host, port, HTTP method and path. That @@ -922,8 +925,8 @@ interface (OpenWorker shows one for teams), or through a harness extension's confirmation dialog, which Pi forwards over its RPC mode. Open points are where pending requests are shown, timeouts, and whether to use a pending identifier with `wait` or MCP's long-running task support. The same prompt could replace -the two-step flow for a child that needs more than its parent has (see Asking -for more than the parent has): the user would see the child's request, and +the two-step flow for a child that needs more than its parent has (see +[Asking for more than the parent has](#asking-for-more-than-the-parent-has)): the user would see the child's request, and approving it would widen the parent and create the child together. ### Per-tool rules for other MCP servers From 962b57367dc69c54c5e1de4c9e1691e655949d2c Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:16:41 -0700 Subject: [PATCH 13/26] docs(rfc): tool sandboxes ignore the depth limit Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 3f80917e0a..dac030612b 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -267,7 +267,8 @@ principal. The holder's policy is the ceiling for its children. ```proto message SandboxGrantSpec { - // Levels below the holder. 1 means the holder's children cannot create. + // Levels of agents below the holder. 1 means the holder's agent children + // cannot create agents. Tool sandboxes do not count against depth. uint32 max_depth = 1; // Children per parent. uint32 max_breadth = 2; @@ -291,7 +292,10 @@ message CreateSandboxRequest { **One grant per tree.** Children and grandchildren draw from the same grant. When a child creates its own child, the gateway charges the shared counters and -checks the child's depth against the same `max_depth`. A child's slice is never +checks an agent child's depth against the same `max_depth`. Tool sandboxes +ignore the depth limit, so an agent at the bottom of the tree can still run +commands in tool sandboxes of its own; they count toward breadth and total like +any child. A child's slice is never more than its parent has left, so the user confirms the tree's limits once and sees no further prompts however deep the tree goes. The alternative, a new grant per level, would put a prompt in front of the user every time a worker @@ -618,10 +622,12 @@ supervisor's main session token limits what a leaked token can do. A sandbox with no depth left, or no grant, reaches the door and sees the same tools as any other sandbox. The gateway refuses its `create_sandbox` with -`LIMIT_REACHED(depth)` or `NOT_ALLOWED` and audits the refusal. Tools are always +`LIMIT_REACHED(depth)` or `NOT_ALLOWED` and audits the refusal; a sandbox with +no depth left can still create tool sandboxes within breadth and total. Tools are always listed because a grant can be widened at any time and many MCP clients load the tool list only once; `get_limits` reports what the caller may do right now -(`can_create_children`, depth, breadth and total). A model that calls a tool it +(`can_create_children` for agents, `can_create_tools`, depth, breadth and +total). A model that calls a tool it cannot use gets a clear error in one turn. ### MCP tools @@ -745,7 +751,7 @@ while lineage records which sandbox asked. | List grants | own workspace | no | no | no | | Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | | Use the door | not applicable | grant's program only | grant's program only | grant's program only | -| `create_sandbox` | top-level sandboxes, as with the CLI | within limits | refused | refused | +| `create_sandbox` | top-level sandboxes, as with the CLI | within limits | tool sandboxes only, within breadth and total | refused | | `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | own descendants | ### Gateway configuration From d61a70bc9b9843c012b9b9a6f11e85b715bef65a Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:19:55 -0700 Subject: [PATCH 14/26] docs(rfc): children reach the gateway MCP through their own door Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index dac030612b..02bd687b49 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -579,6 +579,14 @@ It reuses the mechanism that serves the `policy.local` API today call, so a sandbox without a grant, or with no depth left, reaches the door and is refused. +- **Children use their own door.** A child's harness calls the same URL, + `http://openshell.local/mcp`, and the child's own supervisor answers it and + forwards the call with the child's token. The gateway therefore sees the + child, not its parent, and applies the shared grant to it: agents only with + depth left, tool sandboxes within breadth and total, and actions only on the + child's own descendants. A child runs its parent's image, so its harness + already carries the same MCP setting and needs no configuration. + - **Calling program.** The door identifies the calling program from the binary identity the isolation backend supplies for the connection (RFC 0012), and sends its kernel-resolved path to the gateway in the From c2d7becda233a0c87646c82d94f06ff849add4b9 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:27:03 -0700 Subject: [PATCH 15/26] docs(rfc): list ExecChildSandbox and GetChildSandboxFile Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 02bd687b49..4f5c699506 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -212,6 +212,12 @@ service SandboxChildren { rpc WaitChildSandbox(WaitChildSandboxRequest) returns (WaitChildSandboxResponse) { option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:read" }; } + rpc ExecChildSandbox(ExecChildSandboxRequest) returns (ExecChildSandboxResponse) { + option (authorization) = { auth_mode: "sandbox" }; + } + rpc GetChildSandboxFile(GetChildSandboxFileRequest) returns (GetChildSandboxFileResponse) { + option (authorization) = { auth_mode: "sandbox" }; + } rpc StopChildSandbox(StopChildSandboxRequest) returns (StopChildSandboxResponse) { option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:write" }; } @@ -227,8 +233,13 @@ service SandboxChildren { `auth_mode: "dual"` admits both user and sandbox principals. For sandbox principals each handler applies a child-scope guard, in the style of `ensure_sandbox_principal_scope`: a sandbox may create children only under its -grant and act only on its own descendants. `exec` and `get_file` use the -existing `ExecSandbox` RPC with the same guard. +grant and act only on its own descendants, and is refused with +`NOT_YOUR_CHILD` otherwise. `ExecChildSandbox` and `GetChildSandboxFile` are +sandbox-only: users already have `ExecSandbox`. They apply the same guard, refuse +`exec` on an agent child with `WRONG_KIND`, log every command, and then run the +existing `ExecSandbox` handler inside the gateway (`get_file` runs `cat` on the +path). `ExecSandbox` itself keeps its current authorization; sandboxes are not +given access to it. | Surface | Used by | How it reaches the gateway | |---------|---------|----------------------------| From 01326fa2ba71d98a806829783037b35700f123ff Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:29:55 -0700 Subject: [PATCH 16/26] docs(rfc): list the results channel as a non-goal Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 4f5c699506..b7b6662e76 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -98,8 +98,14 @@ sandbox, for whom, and was it allowed". - **A single prompt for requests outside a grant.** v1 refuses requests beyond the grant's limits; the user raises them with `openshell grant update`. Access the parent lacks goes through the existing proposal flow (see - [Asking for more than the parent has](#asking-for-more-than-the-parent-has)). A single prompt that shows the child's request - and approves it in one step is future work. + [Asking for more than the parent has](#asking-for-more-than-the-parent-has)). + A single prompt that shows the child's request and approves it in one step is + future work. +- **A results channel.** A parent gets results from `exec` output on tool + children, or from files it reads with `get_file` while an agent child runs; a + child that has exited leaves only its exit code. A result the gateway keeps + for the parent is future work (see + [Returning results to the parent](#returning-results-to-the-parent)). ## Proposal From b2c933fb699e216c0774ab742180e142ea908d52 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:38:36 -0700 Subject: [PATCH 17/26] docs(rfc): grants on running sandboxes; fix what a grant answers; grant profiles as future work Signed-off-by: patkraw --- .../README.md | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index b7b6662e76..c524d8fb8a 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -261,13 +261,16 @@ sandbox uses MCP and the gRPC service is used from the host. ### Grants -A grant is a budget for one tree of sandboxes, set by the user. It answers -three questions: may this tree create sandboxes at all, how large may it grow, -and what is the most any sandbox in it may be given. +A grant is a budget for one tree of sandboxes, set by the user. It answers four +questions: may this tree create sandboxes at all, how large may it grow, which +program may ask, and whether agents may write their children's policies +(`child_policy`). What a child may be given is not part of the grant: every +child is bounded by its parent's policy (see +[Ceiling and policy proof](#ceiling-and-policy-proof)). -**Creation.** A grant is created when the user starts the tree's first sandbox, -called the holder. There is no grant file; the grant is part of the create -request: +**Creation.** A grant is usually created when the user starts the tree's first +sandbox, called the holder. There is no grant file; the grant is part of the +create request: ```shell openshell sandbox create \ @@ -282,6 +285,20 @@ The CLI parses `--grant` into a `SandboxGrantSpec` on `CreateSandboxRequest`. Only a user principal may set it; the gateway rejects the field from any other principal. The holder's policy is the ceiling for its children. +**Adding a grant to a running sandbox.** A user often finds that a sandbox +needs sub-agents only after starting it. `CreateSandboxGrant` attaches a grant +to an existing top-level sandbox, which becomes the holder from then on: + +```shell +openshell grant create lead \ + --max-depth 1 --max-breadth 2 --max-total 3 --expires 4h --program /usr/local/bin/node +``` + +The same rules apply as at creation: only a user may call it, the sandbox must +not already hold a grant or belong to a tree, and the holder's current policy +becomes the ceiling for its children. A sandbox that is itself a child cannot be +given a grant of its own; its tree's grant already covers it. + ```proto message SandboxGrantSpec { // Levels of agents below the holder. 1 means the holder's agent children @@ -338,6 +355,9 @@ the gateway MCP, and every change is audited. If an agent could widen its own grant, the grant would mean nothing. ```proto +rpc CreateSandboxGrant(CreateSandboxGrantRequest) returns (SandboxGrantResponse) { + option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:write" }; +} rpc ListSandboxGrants(ListSandboxGrantsRequest) returns (ListSandboxGrantsResponse) { option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:read" }; } @@ -817,6 +837,7 @@ openshell sandbox create --policy lead.yaml \ -- pi # Inspect and change grants +openshell grant create --max-depth 1 --max-breadth 2 --max-total 3 --expires 4h --program /usr/local/bin/node openshell grant list openshell grant update --max-breadth 3 --max-total 4 openshell grant revoke @@ -972,6 +993,13 @@ they are attached by name and not proved. A grant could list workload templates a child may use, so that a Pi lead can create a Claude Code child in a different image. +### Grant profiles + +Named grant profiles defined by an administrator, for example `rl-env` with +`max_depth=1,max_total=50`, so that users and runners start trees with +`--grant-profile rl-env` instead of repeating limits. Profiles would also let an +enterprise cap what users may grant, alongside the global policy maximum. + ### Resource budgets A per-tree CPU, memory and GPU budget in the grant (`max_cpu`, `max_gpu`) for From fc1d6efd4c3b8f882c618c7c742be85fcd110002 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:56:18 -0700 Subject: [PATCH 18/26] docs(rfc): show grant update and revoke commands Signed-off-by: patkraw --- .../README.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index c524d8fb8a..1cdff14906 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -348,11 +348,21 @@ through the existing proposal flow (see [Asking for more than the parent has](#a single prompt for requests outside the grant is future work. **Only the user changes a grant.** The user can widen, shrink or revoke a grant -while the tree runs. Changes apply to the next call: a sandbox that had no depth -left can create children as soon as the user raises `max_depth`, with no policy -change, and an existing child keeps its policy. No sandbox can change a grant, including through -the gateway MCP, and every change is audited. If an agent could widen its own -grant, the grant would mean nothing. +while the tree runs: + +```shell +openshell grant list # find the grant id, e.g. gr-1a2b3c4d +openshell grant update gr-1a2b3c4d --max-depth 2 --max-breadth 3 --max-total 6 +openshell grant revoke gr-1a2b3c4d +``` + +`grant update` changes only the limits it is given; `expires`, `program` and +`child_policy` are changed by revoking the grant and creating a new one. Changes +apply to the next call: a sandbox that had no depth left can create children as +soon as the user raises `max_depth`, with no policy change, and an existing +child keeps its policy. No sandbox can change a grant, including through the +gateway MCP, and every change is audited. If an agent could widen its own grant, +the grant would mean nothing. ```proto rpc CreateSandboxGrant(CreateSandboxGrantRequest) returns (SandboxGrantResponse) { From 8d6237a6f25e6ad31c2e42fcde35744ddfabad9b Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:58:37 -0700 Subject: [PATCH 19/26] docs(rfc): link and name issues #2025 and #3808 Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 1cdff14906..791496ac4b 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -432,13 +432,16 @@ the gateway proves the child's policy against the parent's current policy, without provider layers. The lead's children are bounded by the lead's policy, its grandchildren by its children's policies, and so on, so each level can only narrow: `child ≤ parent ≤ global maximum`, the model in -#2025. A single tree-wide ceiling, the lead's policy, would let a child that was +the spike on agents setting maximum policies for spawned agents +([#2025](https://github.com/NVIDIA/OpenShell/issues/2025)). A single tree-wide ceiling, the lead's policy, would let a child that was given only GitHub read access create a grandchild with everything the lead has. Because the ceiling is the parent's policy at the moment of the create, a parent whose policy has grown through an approved proposal can pass that access on, and one whose policy has shrunk cannot. Children already running when a -parent's policy shrinks keep their policy; revalidating them is #3808. +parent's policy shrinks keep their policy; revalidating them is the subject of +[#3808](https://github.com/NVIDIA/OpenShell/issues/3808), "revalidate running +subagents when ancestor authority changes". **What the agent writes.** The agent supplies only the child's `network_policies`. The gateway builds the child's full policy from the From f99536d4dccfc91562afdc00129de1563c3f40be Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 15:06:24 -0700 Subject: [PATCH 20/26] docs(rfc): agents may narrow every section the prover checks Signed-off-by: patkraw --- .../README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 791496ac4b..69acc9fbeb 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -443,12 +443,19 @@ parent's policy shrinks keep their policy; revalidating them is the subject of [#3808](https://github.com/NVIDIA/OpenShell/issues/3808), "revalidate running subagents when ancestor authority changes". -**What the agent writes.** The agent supplies only the child's -`network_policies`. The gateway builds the child's full policy from the -ceiling's filesystem, process and Landlock sections plus those network rules. -This keeps the agent from requesting filesystem or process changes it has no -reason to make, and keeps the proof focused on network access, where delegated -tasks differ. +**What the agent writes.** The agent may narrow every section of the child's +policy that the prover checks: `network_policies`, `filesystem_policy`, +`process` and `landlock`. A filesystem, process or Landlock section it omits is +copied from the parent; network rules it omits are none, so a child has no +network access unless the agent asks for it. The whole policy is then proved +against the parent's. A read-only reviewer, for example, is the parent's +filesystem section with `/sandbox` moved from `read_write` to `read_only`. +Network middlewares are not proved and cannot be set for a child. + +The prover is conservative with filesystem paths: a child that lists a path the +parent does not list, or depends on an unresolved image working directory, is +refused as `UNSUPPORTED_RULES` rather than proved. Narrowing that keeps the +parent's paths and moves them to read-only, or drops them, is proved. **The proof.** The gateway serializes the ceiling and the candidate with `openshell_policy::serialize_sandbox_policy`, parses both with From db307cc67bb134f3caf6297fcb18fee382d007a3 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 15:26:41 -0700 Subject: [PATCH 21/26] docs(rfc): resolve contradictions on tool listing, child_policy, phase 2, grant tools and token audience Signed-off-by: patkraw --- .../README.md | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 69acc9fbeb..41959c237e 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -313,8 +313,9 @@ message SandboxGrantSpec { // Absolute path of the executable allowed to use the door, // for example /usr/local/bin/node for a Node-based harness. string program = 5; - // NARROW (default): agents write their children's network rules within their - // own policy. INHERIT: every child receives its parent's policy unchanged. + // NARROW (default): agents write their children's policies, any section the + // prover checks, within their own policy. INHERIT: every child receives its + // parent's policy unchanged. ChildPolicyMode child_policy = 6; } @@ -336,8 +337,8 @@ per level, would put a prompt in front of the user every time a worker delegated. **No grant, no children.** A sandbox started without a grant cannot create -sandboxes. `create_sandbox` is absent from its tool list, and a call is refused -with `NOT_ALLOWED`. The safe default is that a sandbox never gains the ability +sandboxes. `create_sandbox` is listed for it like for any sandbox (see +[Refusals](#refusals)), and a call is refused with `NOT_ALLOWED`. The safe default is that a sandbox never gains the ability to create sandboxes unless the user said so. **Set up front.** In v1 the grant is the only approval. A request inside it @@ -504,8 +505,9 @@ the global one instead, so `create_sandbox` with a policy is refused with rather than receiving a child with unexpected access. **Whether a parent may set its child's policy.** A grant states whether agents -in the tree may write their children's network rules (`child_policy: narrow`, -the default) or whether every child receives its parent's policy unchanged +in the tree may write their children's policies (`child_policy: narrow`, the +default; any section the prover checks, see "What the agent writes" in +[Ceiling and policy proof](#ceiling-and-policy-proof)) or whether every child receives its parent's policy unchanged (`child_policy: inherit`). In `inherit` mode a `create_sandbox` request that includes a policy is refused. Either way a child never receives more than the ceiling and the global maximum allow. @@ -681,7 +683,8 @@ existing extension-credential path, which today mints only for supervisor middleware services and is rate limited per sandbox. The token is bound to the sandbox id and lives at most one hour (`MAX_EXTENSION_TOKEN_TTL`). The gateway accepts it only on `/mcp`. Using a dedicated audience instead of the -supervisor's main session token limits what a leaked token can do. +supervisor's main session token limits what a leaked token can do. The proof of +concept forwards the session token; v1 requires the dedicated audience. ### Refusals @@ -712,6 +715,9 @@ made. | `stop_sandbox` | sandboxes | `handle` | `stopped` | `NOT_YOUR_CHILD` | | `list_children` | sandboxes | none | children with kind, lifetime, depth | none | | `get_limits` | sandboxes | none | depth, children and total used and allowed | none | +| `grant_list` | users | none | grants in the caller's workspace | none | +| `grant_create` | users | `handle` of a running top-level sandbox, grant fields | the grant | `NOT_ALLOWED`, `ALREADY_EXISTS` | +| `grant_update` | users | `grant_id`, `max_depth`, `max_breadth`, `max_total`, `revoke` | the new limits, or `revoked` | `NOT_FOUND`, `NOT_ALLOWED` | A sandbox acts only on its own descendants; `handle` is the child's sandbox name within the caller's workspace. A user caller creates top-level sandboxes with @@ -906,9 +912,10 @@ The work is phased so that each phase is usable and testable on its own: - **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` over the same API, with authentication, the program check and a per-sandbox rate limit, and the `openshell gateway mcp` commands. Add the door at - `openshell.local` for both MCP and the gRPC service, open in every sandbox, - and the `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox - callers. + `openshell.local` for MCP, open in every sandbox, and the + `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox + callers. Carrying gRPC through the door follows later, as decided in + [Open questions](#open-questions). - **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the idle timeout. - **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the @@ -1114,8 +1121,6 @@ is instead offered as a maximum that every sandbox must fit inside. ## Open questions -- Is a dedicated gateway-MCP token audience required for v1, or is the - supervisor's session token acceptable initially? - Is the separate `sandbox_lineage` record acceptable alongside RFC 0011's model, or should lineage live elsewhere? - Will `openshell-prover` model MCP and GraphQL rules, and on what timeline? From edced70853b5795ed39515331348ecfd1e135e40 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 15:30:01 -0700 Subject: [PATCH 22/26] docs(rfc): fix remaining inconsistencies and rewrap long lines Signed-off-by: patkraw --- .../README.md | 167 +++++++++--------- 1 file changed, 87 insertions(+), 80 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 41959c237e..7b0b754e2a 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -10,23 +10,23 @@ links: ## Summary -This RFC proposes letting agents create sandboxes through an MCP endpoint -hosted in the OpenShell gateway. An agent running in a sandbox can create -sub-agents (`kind: agent`) and tool sandboxes (`kind: tool`). Every request is -identified by OpenShell, proved against a ceiling with the policy prover, -limited by a grant that the user sets once when starting the tree's first -sandbox, and recorded in a lineage so that stopping a parent stops its children +This RFC proposes letting agents create sandboxes through an MCP endpoint hosted +in the OpenShell gateway. An agent running in a sandbox can create sub-agents +(`kind: agent`) and tool sandboxes (`kind: tool`). Every request is identified +by OpenShell, proved against a ceiling with the policy prover, limited by a +grant that the user sets when starting the tree's first sandbox or attaches to +it later, and recorded in a lineage so that stopping a parent stops its children first. The contract is a gRPC API for child sandboxes, exposed through the SDKs, the CLI and an MCP endpoint, so a harness can integrate at whichever level suits it. -Agents inside a sandbox reach the gateway through a door in their own -supervisor at `http://openshell.local`, which is open in every sandbox. The -supervisor identifies the calling program, attaches it and a token bound to the -sandbox, and forwards the call; the agent never holds a credential. Agents and tools running on the host call the same endpoint with the -user's login and create sandboxes as the user would with the CLI. In every case -the gateway makes every decision, and a sandbox without a grant cannot create -sandboxes at all. +Agents inside a sandbox reach the gateway through a door in their own supervisor +at `http://openshell.local`, which is open in every sandbox. The supervisor +identifies the calling program, attaches it and a token bound to the sandbox, +and forwards the call; the agent never holds a credential. Agents and tools +running on the host call the same endpoint with the user's login and create +sandboxes as the user would with the CLI. In every case the gateway makes every +decision, and a sandbox without a grant cannot create sandboxes at all. ## Motivation @@ -249,15 +249,16 @@ given access to it. | Surface | Used by | How it reaches the gateway | |---------|---------|----------------------------| -| gRPC / SDK (Rust, Python, TypeScript, Go) | Harnesses integrating in code; services such as RL environment runners | From the host: the user's login. From a sandbox: through the door, once it carries gRPC (open decision). | +| gRPC / SDK (Rust, Python, TypeScript, Go) | Harnesses integrating in code; services such as RL environment runners | From the host: the user's login. From a sandbox: not in v1; the door carries MCP only, and gRPC through the door comes later (see [Open questions](#open-questions)). | | CLI | Users and scripts | The user's login | | MCP `/mcp` | Any MCP-capable agent or harness | From a sandbox: through the door. From the host: the user's login. | The door is the only way code inside a sandbox reaches the gateway, so what it forwards decides which surfaces work from inside. MCP is plain HTTP/1.1 and is -forwarded. The gRPC service runs on HTTP/2, and how the door carries it is an -open decision (see [Open questions](#open-questions)); until then, code inside a -sandbox uses MCP and the gRPC service is used from the host. +forwarded. The gRPC service runs on HTTP/2; in v1 the door carries MCP only, so +code inside a sandbox uses MCP and the gRPC service is used from the host. +Carrying gRPC through the door comes later (leaning towards gRPC-Web or Connect; +see [Open questions](#open-questions)). ### Grants @@ -330,23 +331,25 @@ When a child creates its own child, the gateway charges the shared counters and checks an agent child's depth against the same `max_depth`. Tool sandboxes ignore the depth limit, so an agent at the bottom of the tree can still run commands in tool sandboxes of its own; they count toward breadth and total like -any child. A child's slice is never -more than its parent has left, so the user confirms the tree's limits once and -sees no further prompts however deep the tree goes. The alternative, a new grant +any child. A child never uses more than the grant has left, so the user +confirms the tree's limits once and sees no further prompts however deep the +tree goes. The alternative, a new grant per level, would put a prompt in front of the user every time a worker delegated. **No grant, no children.** A sandbox started without a grant cannot create sandboxes. `create_sandbox` is listed for it like for any sandbox (see -[Refusals](#refusals)), and a call is refused with `NOT_ALLOWED`. The safe default is that a sandbox never gains the ability -to create sandboxes unless the user said so. +[Refusals](#refusals)), and a call is refused with `NOT_ALLOWED`. The safe +default is that a sandbox never gains the ability to create sandboxes unless the +user said so. **Set up front.** In v1 the grant is the only approval. A request inside it proceeds without prompting anyone; a request outside it is refused with `LIMIT_REACHED` and the agent adapts. This matches how OpenShell is used today, from the CLI, and needs no new approval interface. Access the parent lacks goes -through the existing proposal flow (see [Asking for more than the parent has](#asking-for-more-than-the-parent-has)); a -single prompt for requests outside the grant is future work. +through the existing proposal flow (see [Asking for more than the parent +has](#asking-for-more-than-the-parent-has)); a single prompt for requests +outside the grant is future work. **Only the user changes a grant.** The user can widen, shrink or revoke a grant while the tree runs: @@ -430,12 +433,13 @@ not change that model. It is used only for decisions about trees (cascade stop, **A child's ceiling is its parent's policy.** When a sandbox creates a child, the gateway proves the child's policy against the parent's current policy, -without provider layers. The lead's children are bounded by -the lead's policy, its grandchildren by its children's policies, and so on, so -each level can only narrow: `child ≤ parent ≤ global maximum`, the model in -the spike on agents setting maximum policies for spawned agents -([#2025](https://github.com/NVIDIA/OpenShell/issues/2025)). A single tree-wide ceiling, the lead's policy, would let a child that was -given only GitHub read access create a grandchild with everything the lead has. +without provider layers. The lead's children are bounded by the lead's policy, +its grandchildren by its children's policies, and so on, so each level can only +narrow: `child ≤ parent ≤ global maximum`, the model in the spike on agents +setting maximum policies for spawned agents +([#2025](https://github.com/NVIDIA/OpenShell/issues/2025)). A single tree-wide +ceiling, the lead's policy, would let a child that was given only GitHub read +access create a grandchild with everything the lead has. Because the ceiling is the parent's policy at the moment of the create, a parent whose policy has grown through an approved proposal can pass that access @@ -494,8 +498,8 @@ global_policy_mode = "maximum" In `maximum` mode each sandbox keeps its own policy, provider layers stay active, and the gateway proves that every policy fits inside the global one: a -grant holder's policy when it is created, every child policy, and every later -policy write. A policy that does not fit is refused with +grant holder's policy when it is created or given a grant, every child policy, +and every later policy write. A policy that does not fit is refused with `EXCEEDS_GLOBAL_POLICY` and the prover's counterexample. Children are then proved against both their ceiling and the global policy. @@ -506,11 +510,11 @@ rather than receiving a child with unexpected access. **Whether a parent may set its child's policy.** A grant states whether agents in the tree may write their children's policies (`child_policy: narrow`, the -default; any section the prover checks, see "What the agent writes" in -[Ceiling and policy proof](#ceiling-and-policy-proof)) or whether every child receives its parent's policy unchanged -(`child_policy: inherit`). In `inherit` mode a `create_sandbox` request that -includes a policy is refused. Either way a child never receives more than the -ceiling and the global maximum allow. +default; any section the prover checks, see "What the agent writes" in [Ceiling +and policy proof](#ceiling-and-policy-proof)) or whether every child receives +its parent's policy unchanged (`child_policy: inherit`). In `inherit` mode a +`create_sandbox` request that includes a policy is refused. Either way a child +never receives more than the ceiling and the global maximum allow. **Layers the gateway adds.** Provider layers are added after the proof. They come from a trusted source, admin-imported provider profiles, not from the @@ -519,11 +523,11 @@ child may only receive providers attached to its parent. #### Asking for more than the parent has -When the task really needs access the parent lacks, the parent widens its own policy first, through the proposal flow -every sandbox has today, and then retries. No new mechanism is involved; the -gateway's refusal points the agent to it, in the same way the supervisor's -guidance for a blocked request does (`AGENT_GUIDANCE` in -`crates/openshell-supervisor-network/src/policy_local.rs`): +When the task really needs access the parent lacks, the parent widens its own +policy first, through the proposal flow every sandbox has today, and then +retries. No new mechanism is involved; the gateway's refusal points the agent to +it, in the same way the supervisor's guidance for a blocked request does +(`AGENT_GUIDANCE` in `crates/openshell-supervisor-network/src/policy_local.rs`): 1. The lead calls `create_sandbox` for a worker with `POST api.github.com/repos/**`. The gateway refuses with `EXCEEDS_CEILING`, the @@ -691,12 +695,12 @@ concept forwards the session token; v1 requires the dedicated audience. A sandbox with no depth left, or no grant, reaches the door and sees the same tools as any other sandbox. The gateway refuses its `create_sandbox` with `LIMIT_REACHED(depth)` or `NOT_ALLOWED` and audits the refusal; a sandbox with -no depth left can still create tool sandboxes within breadth and total. Tools are always -listed because a grant can be widened at any time and many MCP clients load the -tool list only once; `get_limits` reports what the caller may do right now -(`can_create_children` for agents, `can_create_tools`, depth, breadth and -total). A model that calls a tool it -cannot use gets a clear error in one turn. +no depth left can still create tool sandboxes within breadth and total. Tools +are always listed because a grant can be widened at any time and many MCP +clients load the tool list only once; `get_limits` reports what the caller may +do right now (`can_create_children` for agents, `can_create_tools`, depth, +breadth and total). A model that calls a tool it cannot use gets a clear error +in one turn. ### MCP tools @@ -708,13 +712,13 @@ made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| -| `create_sandbox` | users and sandboxes | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `GLOBAL_POLICY_ACTIVE` | +| `create_sandbox` | users and sandboxes | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `PROVER_INCONCLUSIVE`, `PROGRAM_NOT_ALLOWED`, `GLOBAL_POLICY_ACTIVE`, `EXCEEDS_GLOBAL_POLICY` | | `wait` | sandboxes | `handle`, `timeout_s` | `phase`, `exit_code` | `NOT_YOUR_CHILD` | | `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | | `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | | `stop_sandbox` | sandboxes | `handle` | `stopped` | `NOT_YOUR_CHILD` | | `list_children` | sandboxes | none | children with kind, lifetime, depth | none | -| `get_limits` | sandboxes | none | depth, children and total used and allowed | none | +| `get_limits` | sandboxes | none | depth, children and total used and allowed; `can_create_children` (agents) and `can_create_tools` | none | | `grant_list` | users | none | grants in the caller's workspace | none | | `grant_create` | users | `handle` of a running top-level sandbox, grant fields | the grant | `NOT_ALLOWED`, `ALREADY_EXISTS` | | `grant_update` | users | `grant_id`, `max_depth`, `max_breadth`, `max_total`, `revoke` | the new limits, or `revoked` | `NOT_FOUND`, `NOT_ALLOWED` | @@ -722,9 +726,9 @@ made. A sandbox acts only on its own descendants; `handle` is the child's sandbox name within the caller's workspace. A user caller creates top-level sandboxes with the same rules as `openshell sandbox create`, may attach a grant to them, and -acts on any sandbox its workspace role allows. `inputs` lists paths in the parent that are -copied read-only into the child at start. `exec` and `get_file` run through the -existing `ExecSandbox` path, acting for the tree's root user. +acts on any sandbox its workspace role allows. `inputs` lists paths in the +parent that are copied read-only into the child at start. `exec` and `get_file` +run through the existing `ExecSandbox` path, acting for the tree's root user. These are the only tools a harness reaches through the gateway. A harness's built-in tools (shell, file editing) run inside the sandbox, constrained by its @@ -741,9 +745,9 @@ and whether it can delegate further. | Runs | A harness doing its own task | No agent; only commands its parent sends | | Parent uses it with | `wait`, `get_file` | `exec`, `get_file` | | `exec` | Refused with `WRONG_KIND` | Allowed; every command is logged | -| Can create children | If depth is left | Never | +| Can create children | Agents if depth is left; tool sandboxes within breadth and total | Never | | Command | Required | Optional; it stays up until stopped | -| Lifetime | Until stopped; never outlives its parent | Until stopped, or stopped after an idle timeout (default 10 minutes without `exec` or `get_file`) | +| Lifetime | Until stopped (default) or until exit (`until_exit`); never outlives its parent | Until stopped, or stopped after an idle timeout (default 10 minutes without `exec` or `get_file`) | Agent sandboxes run their own task, so their parent observes them rather than driving them, and `exec` on an agent is refused to keep a parent from steering @@ -819,11 +823,12 @@ while lineage records which sandbox asked. | Action | User | Holder or agent child with depth left | Agent child with no depth left | Tool child | |--------|------|------|------|------| | Start a sandbox with `--grant` | yes | no | no | no | +| Attach a grant later (`grant create`) | top-level sandboxes without a grant | no | no | no | | List grants | own workspace | no | no | no | | Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | -| Use the door | not applicable | grant's program only | grant's program only | grant's program only | +| Call the gateway MCP | not applicable | grant's program only (gateway checks) | grant's program only (gateway checks) | grant's program only (gateway checks) | | `create_sandbox` | top-level sandboxes, as with the CLI | within limits | tool sandboxes only, within breadth and total | refused | -| `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | own descendants | +| `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | none (no children) | ### Gateway configuration @@ -893,10 +898,11 @@ did it run" from the same log stream they already ship to their SIEM. ### Compatibility The change is additive. Sandboxes created without `--grant` behave exactly as -today, and the default `global_policy_mode` remains `replace`. Existing gRPC clients are unaffected; the new `grant` field is optional. -Supervisors without the door ignore `openshell.local`, so a newer gateway with -an older supervisor image simply cannot serve in-sandbox callers until the image -is updated. Host agents can use the endpoint as soon as the gateway supports it. +today, and the default `global_policy_mode` remains `replace`. Existing gRPC +clients are unaffected; the new `grant` field is optional. Supervisors without +the door ignore `openshell.local`, so a newer gateway with an older supervisor +image simply cannot serve in-sandbox callers until the image is updated. Host +agents can use the endpoint as soon as the gateway supports it. ## Implementation plan @@ -914,13 +920,14 @@ The work is phased so that each phase is usable and testable on its own: limit, and the `openshell gateway mcp` commands. Add the door at `openshell.local` for MCP, open in every sandbox, and the `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox - callers. Carrying gRPC through the door follows later, as decided in - [Open questions](#open-questions). + callers. Carrying gRPC through the door comes in a later phase (see + [Open questions](#open-questions)). - **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the idle timeout. - **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the - proof of every grant holder's policy, child policy and policy write against - the global policy in `maximum` mode. + proof of every grant holder's policy (at create and when a grant is + attached), child policy and policy write against the global policy in + `maximum` mode. - **Phase 5: Lifecycle.** Cascade stop on every way a parent ends, the orphan reaper, and idle and maximum lifetime for agent children with the "a parent with running children is not idle" rule. @@ -928,9 +935,9 @@ The work is phased so that each phase is usable and testable on its own: reference pages for grants and the MCP tools, and a configuration entry for `[openshell.gateway.sandbox_mcp]`. -Validation runs at each phase: unit tests for grant parsing, limits and the -door-rule decision; gateway tests for refusals; and an end-to-end scenario with -the Docker driver that exercises every refusal listed in this RFC. +Validation runs at each phase: unit tests for grant parsing, limits, the program +check and the ceiling proof; gateway tests for refusals; and an end-to-end +scenario with the Docker driver that exercises every refusal listed in this RFC. A proof of concept of phases 1 to 3 exists on the branch `gateway-mcp-poc` (`crates/openshell-server/src/mcp.rs`, @@ -967,8 +974,8 @@ dedicated token. - **Results channel.** A child's standard output does not reach the sandbox logs, and `get_file` needs the child to be running. A child that exits before its parent reads its result loses that result. Harnesses must keep agent - children running until their result is read, or results need a channel that - survives exit. + children running until their result is read; a results channel is future + work (see [Returning results to the parent](#returning-results-to-the-parent)). - **Spawn storms.** A misbehaving lead can create children up to its grant quickly. Atomic counters, `max_total`, operator caps, retry deduplication and @@ -1004,9 +1011,10 @@ interface (OpenWorker shows one for teams), or through a harness extension's confirmation dialog, which Pi forwards over its RPC mode. Open points are where pending requests are shown, timeouts, and whether to use a pending identifier with `wait` or MCP's long-running task support. The same prompt could replace -the two-step flow for a child that needs more than its parent has (see -[Asking for more than the parent has](#asking-for-more-than-the-parent-has)): the user would see the child's request, and -approving it would widen the parent and create the child together. +the two-step flow for a child that needs more than its parent has (see [Asking +for more than the parent has](#asking-for-more-than-the-parent-has)): the user +would see the child's request, and approving it would widen the parent and +create the child together. ### Per-tool rules for other MCP servers @@ -1126,16 +1134,14 @@ is instead offered as a maximum that every sandbox must fit inside. - Will `openshell-prover` model MCP and GraphQL rules, and on what timeline? The answer decides whether per-tool rules for other MCP servers become possible. -- Should a parent be able to read the result of a child that has exited, for - example through a result file the gateway keeps until the parent reads it? - When interactive approval is added, where should pending requests appear? - Should `global_policy_mode = "maximum"` become the default, and how do operators who run shared gateways use the global policy today? - Is the MCP endpoint's cost when idle small enough to keep it on by default on every gateway? -- **Decision to make: how the door carries gRPC.** The door forwards MCP, which - is plain HTTP/1.1: one request, one response, read and forwarded with the - sandbox's token. gRPC uses HTTP/2, with many calls multiplexed on one +- **How the door carries gRPC after v1.** v1 carries MCP only. The door forwards + MCP, which is plain HTTP/1.1: one request, one response, read and forwarded + with the sandbox's token. gRPC uses HTTP/2, with many calls multiplexed on one connection and streaming responses. Options: - **A. MCP only inside sandboxes.** No change; agents use MCP, and the SDKs are used from the host. Cost: SDK code inside a sandbox cannot call the @@ -1207,7 +1213,8 @@ node (worker) → openshell.local/mcp create_sandbox (after the user raises max_depth to 2, the same call succeeds) ``` -**6. The lead asks for more than it has.** +**6. The lead asks for more than it has** (agent proposals enabled for the +lead with `agent_policy_proposals_enabled`). ```text create_sandbox { policy: {POST api.github.com/repos/**} } @@ -1223,7 +1230,7 @@ create_sandbox { policy: {POST api.github.com/repos/**} } ```text UpdateSandboxGrant { grant_id, max_breadth: 3, max_total: 4 } → principal = User, grant owner → pass -→ applies to new creates only; worker unchanged; audited +→ applies from the next call; existing children keep their policy; audited ``` **8. The lead collects the result and stops the worker.** From fafd41ac7358aa38054dd5ef0ae9c13b1a65c90c Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 16:06:31 -0700 Subject: [PATCH 23/26] docs(rfc): link the originating issue #4574 Signed-off-by: patkraw --- rfc/NNNN-gateway-mcp-sandbox-creation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 7b0b754e2a..726107f632 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -3,7 +3,7 @@ authors: - "@patkraw" state: draft links: - - (originating GitHub issue, to be opened; maintainers assign the RFC number) + - https://github.com/NVIDIA/OpenShell/issues/4574 --- # RFC NNNN - Gateway MCP for Creating Sandboxes From de30aa64c9381336bda5fb04142ee6bcfda7e207 Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 15 Oct 2026 11:45:37 -0700 Subject: [PATCH 24/26] docs(rfc): gateway MCP switch is startup configuration; typed grant flags; grant get Signed-off-by: patkraw --- .../README.md | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 726107f632..4bde315c3c 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -128,7 +128,7 @@ policy, records lineage and launches the child through the compute driver. ```mermaid flowchart LR - U["User: start the lead with --grant"]:::user + U["User: start the lead with a grant"]:::user subgraph LEAD["Lead sandbox"] A["Agent harness, e.g. Pi (no credential)"]:::untrusted S["Supervisor door at openshell.local/mcp: add calling program and sandbox token"]:::new @@ -142,7 +142,7 @@ flowchart LR end D["Compute driver"]:::exist K["Child sandbox (parent's template)"]:::trusted - U -- "sandbox create --grant" --> C + U -- "sandbox create --grant-*" --> C A -- "create_sandbox" --> S S -- "call plus token" --> M M --> C @@ -278,11 +278,16 @@ openshell sandbox create \ --from pi-agent:local \ --provider pi-nvidia \ --policy lead.yaml \ - --grant max_depth=1,max_breadth=2,max_total=3,expires=4h,program=/usr/local/bin/node \ + --grant-max-depth 1 --grant-max-breadth 2 --grant-max-total 3 \ + --grant-expires 4h --grant-program /usr/local/bin/node \ -- pi ``` -The CLI parses `--grant` into a `SandboxGrantSpec` on `CreateSandboxRequest`. +The `--grant-*` flags are the same as those of `openshell grant create`, with a +`grant-` prefix: `--grant-max-depth`, `--grant-max-breadth`, `--grant-max-total`, +`--grant-expires`, `--grant-program` and `--grant-child-policy`. Any of them +gives the new sandbox a grant, and `--grant-program` is then required. The CLI +builds a `SandboxGrantSpec` on `CreateSandboxRequest` from them. Only a user principal may set it; the gateway rejects the field from any other principal. The holder's policy is the ceiling for its children. @@ -375,6 +380,9 @@ rpc CreateSandboxGrant(CreateSandboxGrantRequest) returns (SandboxGrantResponse) rpc ListSandboxGrants(ListSandboxGrantsRequest) returns (ListSandboxGrantsResponse) { option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:read" }; } +rpc GetSandboxGrant(GetSandboxGrantRequest) returns (SandboxGrantResponse) { + option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:read" }; +} rpc UpdateSandboxGrant(UpdateSandboxGrantRequest) returns (SandboxGrantResponse) { option (authorization) = { auth_mode: "bearer" workspace_role: "user" scope: "sandbox:write" }; } @@ -822,7 +830,7 @@ while lineage records which sandbox asked. | Action | User | Holder or agent child with depth left | Agent child with no depth left | Tool child | |--------|------|------|------|------| -| Start a sandbox with `--grant` | yes | no | no | no | +| Start a sandbox with a grant (`--grant-*`) | yes | no | no | no | | Attach a grant later (`grant create`) | top-level sandboxes without a grant | no | no | no | | List grants | own workspace | no | no | no | | Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | @@ -837,13 +845,17 @@ rather than a separate process, so it costs nothing until it is called, and it lets any agent that can reach the gateway start sandboxes, whether or not sub-agents are involved. Turning it on does not let any sandbox create children: that still requires a grant on the sandbox. Operators who do not want -it turn it off in configuration or from the CLI: - -```shell -openshell gateway mcp status -openshell gateway mcp disable -openshell gateway mcp enable -``` +it turn it off in the gateway configuration. + +This is startup configuration, like the rest of the gateway configuration (RFC +0003): it is merged from the TOML file and environment at start, with the usual +precedence, and changing it requires restarting the gateway. There is no live +switch and no `enable` or `disable` command. If a live switch is needed later, +it would be a gateway-scope setting following the existing `openshell settings` +vocabulary, for example `openshell settings set --global --key +sandbox_mcp_enabled --value false`, after defining persistence, precedence over +the configuration file, authorization and propagation across replicas. +`openshell status` reports whether the endpoint is served. ```toml [openshell.gateway.sandbox_mcp] @@ -864,12 +876,15 @@ MCP requests; the gRPC child-sandbox API, the SDKs and the CLI keep working. ```shell # Start a tree that may create children openshell sandbox create --policy lead.yaml \ - --grant max_depth=1,max_breadth=2,max_total=3,expires=4h,program=/usr/local/bin/node \ + --grant-max-depth 1 --grant-max-breadth 2 --grant-max-total 3 \ + --grant-expires 4h --grant-program /usr/local/bin/node \ -- pi # Inspect and change grants -openshell grant create --max-depth 1 --max-breadth 2 --max-total 3 --expires 4h --program /usr/local/bin/node +openshell grant create --max-depth 1 --max-breadth 2 --max-total 3 \ + --expires 4h --program /usr/local/bin/node openshell grant list +openshell grant get openshell grant update --max-breadth 3 --max-total 4 openshell grant revoke @@ -917,7 +932,7 @@ The work is phased so that each phase is usable and testable on its own: prototypes by replacing their host creation services with these RPCs. - **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` over the same API, with authentication, the program check and a per-sandbox rate - limit, and the `openshell gateway mcp` commands. Add the door at + limit, and the startup configuration that turns the endpoint off. Add the door at `openshell.local` for MCP, open in every sandbox, and the `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox callers. Carrying gRPC through the door comes in a later phase (see @@ -969,7 +984,8 @@ dedicated token. - **Every sandbox reaches the gateway MCP.** The door is open in every sandbox, so the gateway's authentication and refusals are the only barrier, and every sandbox can learn that the endpoint exists. The rate limit bounds the load a - sandbox can cause; `openshell gateway mcp disable` closes the door everywhere. + sandbox can cause; turning the endpoint off in the gateway configuration + closes the door everywhere after a restart. - **Results channel.** A child's standard output does not reach the sandbox logs, and `get_file` needs the child to be running. A child that exits before From c4228cb82201ba34e32c8ef9488b009db1489100 Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 15 Oct 2026 12:52:32 -0700 Subject: [PATCH 25/26] docs(rfc): grant replacement, ownership checks, concurrent retries, result collection - grant create replaces a revoked or expired grant for the running tree. - Grant listing and changes are limited to the grant's user or a Workspace Admin, checked before any change. - request_id is scoped to the parent and reserved with the slot; duplicates get IN_PROGRESS; limits are charged atomically. - The walkthrough collects a result with an explicit done signal. - The proof-of-concept paragraph points to the commit-specific result. Signed-off-by: patkraw --- .../README.md | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index 4bde315c3c..df2b1048ad 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -300,10 +300,16 @@ openshell grant create lead \ --max-depth 1 --max-breadth 2 --max-total 3 --expires 4h --program /usr/local/bin/node ``` -The same rules apply as at creation: only a user may call it, the sandbox must -not already hold a grant or belong to a tree, and the holder's current policy -becomes the ceiling for its children. A sandbox that is itself a child cannot be -given a grant of its own; its tree's grant already covers it. +The same rules apply as at creation: only a user may call it, and the holder's +current policy becomes the ceiling for its children. A sandbox that is itself a +child cannot be given a grant of its own; its tree's grant already covers it. + +**Replacing a grant.** `grant create` on a holder whose grant was revoked or has +expired attaches the new grant to the running tree: every sandbox in the tree +moves to the new grant, and the sandboxes still running count toward its +`max_total`. While the holder's grant is active, `grant create` is refused with +`ALREADY_EXISTS`. Replacing an expired grant requires the same right as +changing it (below). ```proto message SandboxGrantSpec { @@ -366,7 +372,8 @@ openshell grant revoke gr-1a2b3c4d ``` `grant update` changes only the limits it is given; `expires`, `program` and -`child_policy` are changed by revoking the grant and creating a new one. Changes +`child_policy` are changed by revoking the grant and creating a new one for the +same sandbox (see Replacing a grant). Changes apply to the next call: a sandbox that had no depth left can create children as soon as the user raises `max_depth`, with no policy change, and an existing child keeps its policy. No sandbox can change a grant, including through the @@ -391,8 +398,10 @@ rpc RevokeSandboxGrant(RevokeSandboxGrantRequest) returns (RevokeSandboxGrantRes } ``` -`UpdateSandboxGrant` and `RevokeSandboxGrant` additionally require the caller -to be the grant's user or a Workspace Admin of the grant's workspace. +`ListSandboxGrants` and `GetSandboxGrant` return only grants the caller may +manage, and `UpdateSandboxGrant` and `RevokeSandboxGrant` refuse anyone else: +the grant's user or a Workspace Admin of the grant's workspace. The check runs +before any change. **The harness declares, OpenShell enforces.** A harness may send its own limits with a create request, for example "a lead has at most two workers". The @@ -720,7 +729,7 @@ made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| -| `create_sandbox` | users and sandboxes | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `PROVER_INCONCLUSIVE`, `PROGRAM_NOT_ALLOWED`, `GLOBAL_POLICY_ACTIVE`, `EXCEEDS_GLOBAL_POLICY` | +| `create_sandbox` | users and sandboxes | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `PROVER_INCONCLUSIVE`, `PROGRAM_NOT_ALLOWED`, `GLOBAL_POLICY_ACTIVE`, `EXCEEDS_GLOBAL_POLICY`, `IN_PROGRESS` | | `wait` | sandboxes | `handle`, `timeout_s` | `phase`, `exit_code` | `NOT_YOUR_CHILD` | | `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | | `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | @@ -805,10 +814,18 @@ other resources. Agents retry. If the reply to `create_sandbox` is lost, the agent sends the request again, and without deduplication the tree would get two children and -spend its budget twice. Sandbox callers therefore send a `request_id`; the -gateway stores the first result per grant and returns it for any repeat, which -is counted once. Today `request_id` deduplication through `mutation_replay` is -available to user principals only. +spend its budget twice. Sandbox callers therefore send a `request_id`, scoped to +the calling parent so that siblings cannot collide. The gateway reserves the +`request_id` together with the child's slot, before launching the child; a +repeat that arrives while the first is launching gets `IN_PROGRESS`, and a +repeat after it finished gets the first child's handle, counted once. Today +`request_id` deduplication through `mutation_replay` is available to user +principals only. + +**Limits under concurrency.** Depth, breadth and total are checked and charged +in one step when the slot is reserved, so two concurrent creates cannot both +take the last slot. A create that fails after reserving releases the slot and +its `request_id`. ### Authorization enforcement @@ -832,8 +849,8 @@ while lineage records which sandbox asked. |--------|------|------|------|------| | Start a sandbox with a grant (`--grant-*`) | yes | no | no | no | | Attach a grant later (`grant create`) | top-level sandboxes without a grant | no | no | no | -| List grants | own workspace | no | no | no | -| Update or revoke a grant | own grants; Workspace Admin for the workspace | no | no | no | +| List grants | grants the user set, or in workspaces it administers | no | no | no | +| Update, revoke or replace a grant | own grants; Workspace Admin for the workspace | no | no | no | | Call the gateway MCP | not applicable | grant's program only (gateway checks) | grant's program only (gateway checks) | grant's program only (gateway checks) | | `create_sandbox` | top-level sandboxes, as with the CLI | within limits | tool sandboxes only, within breadth and total | refused | | `wait`, `get_file`, `stop_sandbox`, `exec` | not applicable | own descendants | own descendants | none (no children) | @@ -955,14 +972,12 @@ check and the ceiling proof; gateway tests for refusals; and an end-to-end scenario with the Docker driver that exercises every refusal listed in this RFC. A proof of concept of phases 1 to 3 exists on the branch `gateway-mcp-poc` -(`crates/openshell-server/src/mcp.rs`, +(`crates/openshell-gateway-mcp/`, `crates/openshell-server/src/children.rs`, `crates/openshell-supervisor-network/src/gateway_mcp_door.rs`, -`examples/gateway-mcp-poc/`). Against a development gateway with the Docker -driver, its check script passes 29 of 31 checks, covering grants, the door, -refusals, retries, tool sandboxes and cascade stop; the two failures are the -script running out of grant budget. The proof of concept keeps grants and -lineage in memory and forwards the supervisor's main session token instead of a -dedicated token. +`examples/gateway-mcp-poc/`). Its current end-to-end result, tied to a commit, +is in `examples/gateway-mcp-poc/README.md` under Results. The proof of concept +keeps grants and lineage in memory and forwards the supervisor's main session +token instead of a dedicated token. ## Risks @@ -1251,8 +1266,15 @@ UpdateSandboxGrant { grant_id, max_breadth: 3, max_total: 4 } **8. The lead collects the result and stops the worker.** +`wait` reaching READY means the worker's sandbox is up, not that its agent has +finished. The worker signals completion explicitly: it writes its result to a +temporary file and renames it to `/tmp/result.txt` (a rename is atomic), and +keeps running. The lead polls until the file exists. + ```text wait { handle: worker } → READY +get_file { handle: worker, path: /tmp/result.txt } → FILE_ERROR (not written yet) +… poll … get_file { handle: worker, path: /tmp/result.txt } → content stop_sandbox { handle: worker } → worker's descendants (none) stopped first, then worker; identity revoked first From 8d06de667f9801293f38d9c8d9410065d87c1ab3 Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 15 Oct 2026 13:41:52 -0700 Subject: [PATCH 26/26] docs(rfc): pin MCP transport and door forwarding; clarify limits, counters, revocation and production gaps Signed-off-by: patkraw --- .../README.md | 135 +++++++++++++----- 1 file changed, 100 insertions(+), 35 deletions(-) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md index df2b1048ad..4cd7dbfa95 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/NNNN-gateway-mcp-sandbox-creation/README.md @@ -254,11 +254,12 @@ given access to it. | MCP `/mcp` | Any MCP-capable agent or harness | From a sandbox: through the door. From the host: the user's login. | The door is the only way code inside a sandbox reaches the gateway, so what it -forwards decides which surfaces work from inside. MCP is plain HTTP/1.1 and is -forwarded. The gRPC service runs on HTTP/2; in v1 the door carries MCP only, so -code inside a sandbox uses MCP and the gRPC service is used from the host. -Carrying gRPC through the door comes later (leaning towards gRPC-Web or Connect; -see [Open questions](#open-questions)). +forwards decides which surfaces work from inside. The door forwards MCP's +Streamable HTTP binding over HTTP/1.1, with JSON responses only in v1 (see [The +supervisor door](#the-supervisor-door)). The gRPC service runs on HTTP/2; in v1 +the door carries MCP only, so code inside a sandbox uses MCP and the gRPC +service is used from the host. Carrying gRPC through the door comes later +(leaning towards gRPC-Web or Connect; see [Open questions](#open-questions)). ### Grants @@ -403,11 +404,14 @@ manage, and `UpdateSandboxGrant` and `RevokeSandboxGrant` refuse anyone else: the grant's user or a Workspace Admin of the grant's workspace. The check runs before any change. -**The harness declares, OpenShell enforces.** A harness may send its own limits -with a create request, for example "a lead has at most two workers". The -effective limit for each dimension is the lowest of the harness's value, the -grant's and the operator's cap from gateway configuration. A limit set by the -harness is thereby enforced outside the harness's reach. +**Harness limits are per-request reductions.** A harness may send its own +`limits` with a create request, for example "a lead has at most two workers", +and that request is held to the lowest of the harness's value, the grant's and +the operator's cap from gateway configuration. Because the workload sends them, +a compromised agent can omit them on its next call, so they are a convenience +for well-behaved harnesses, not a boundary. The enforced limits are the grant +and the operator caps, which no sandbox can change; a harness that wants a +lower limit enforced asks the user to set it in the grant. **Storage.** Grants are stored in the existing objects table as a new object type, `sandbox_grant`, scoped to the holder's workspace. The counters (`total` @@ -530,8 +534,14 @@ in the tree may write their children's policies (`child_policy: narrow`, the default; any section the prover checks, see "What the agent writes" in [Ceiling and policy proof](#ceiling-and-policy-proof)) or whether every child receives its parent's policy unchanged (`child_policy: inherit`). In `inherit` mode a -`create_sandbox` request that includes a policy is refused. Either way a child -never receives more than the ceiling and the global maximum allow. +`create_sandbox` request that includes a policy is refused. Either way a +child's base policy never exceeds its parent's policy and the global maximum +when it is created or written: the proof runs at create time and on every later +write to the child's policy. Provider layers are added after the proof, from +admin-imported profiles and only for providers attached to the parent. A child +that is already running keeps its policy when an ancestor's policy later +shrinks; revalidating running children is +[#3808](https://github.com/NVIDIA/OpenShell/issues/3808). **Layers the gateway adds.** Provider layers are added after the proof. They come from a trusted source, admin-imported provider profiles, not from the @@ -676,7 +686,27 @@ It reuses the mechanism that serves the `policy.local` API today - **Forwarding.** The door reads the request body (the same 64 KiB limit as `policy.local`), forwards it to the gateway's `/mcp` with a token bound to - this sandbox, and returns the gateway's response unchanged. + this sandbox, and returns the gateway's response unchanged. It builds the + forwarded request itself: + - **Kept:** `Content-Type`, `Accept`, and the MCP headers + `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` when present. + - **Set by the door, never copied from the workload:** `Authorization` (the + sandbox's token) and `x-openshell-caller-program`. Any other header the + workload sends, including identity headers, is dropped. + - **Origin:** a request carrying an `Origin` header other than + `http://openshell.local` is refused with 403, as MCP requires against DNS + rebinding; agents send no `Origin`. The gateway applies the same rule to + host callers and accepts only local origins. + +- **Protocol version.** v1 targets MCP's Streamable HTTP transport with JSON + responses (no server-sent event streams, no standalone `GET` stream) and the + initialize-based revisions `2025-06-18` and `2025-11-25`. `initialize` echoes + a supported `protocolVersion` and otherwise answers with `2025-11-25`, the + latest supported, leaving the client to decide; a request whose + `MCP-Protocol-Version` header names an unsupported revision gets HTTP 400. + The gateway keeps no MCP session, so clients written against the sessionless + `2026-07-28` revision also work for these tools; supporting that revision's + required headers fully is future work. - **Rate limit.** The gateway limits calls per sandbox, so a sandbox that may not create children cannot flood the gateway through its door. @@ -729,11 +759,12 @@ made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| -| `create_sandbox` | users and sandboxes | `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`, `grant` (users only) | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `PROVER_INCONCLUSIVE`, `PROGRAM_NOT_ALLOWED`, `GLOBAL_POLICY_ACTIVE`, `EXCEEDS_GLOBAL_POLICY`, `IN_PROGRESS` | +| `create_sandbox` | users and sandboxes | `name`, `kind`, `lifetime`, `command`, `task`, `policy`, `providers`, `inputs`, `request_id`, `limits`, `idle_timeout_s`; users only: `image`, `grant` | `handle` | `NOT_ALLOWED`, `LIMIT_REACHED(depth\|breadth\|total)`, `EXCEEDS_CEILING`, `UNSUPPORTED_RULES`, `PROVER_INCONCLUSIVE`, `PROGRAM_NOT_ALLOWED`, `GLOBAL_POLICY_ACTIVE`, `EXCEEDS_GLOBAL_POLICY`, `IN_PROGRESS`, `STALE_ADMISSION`, `INVALID_ARGUMENT` (request_id reused with different arguments) | | `wait` | sandboxes | `handle`, `timeout_s` | `phase`, `exit_code` | `NOT_YOUR_CHILD` | | `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | | `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | | `stop_sandbox` | sandboxes | `handle` | `stopped` | `NOT_YOUR_CHILD` | +| `get_output` | sandboxes | `handle` | recent log lines of the child; not a durable result, and a child's own standard output is not included | `NOT_YOUR_CHILD` | | `list_children` | sandboxes | none | children with kind, lifetime, depth | none | | `get_limits` | sandboxes | none | depth, children and total used and allowed; `can_create_children` (agents) and `can_create_tools` | none | | `grant_list` | users | none | grants in the caller's workspace | none | @@ -742,8 +773,10 @@ made. A sandbox acts only on its own descendants; `handle` is the child's sandbox name within the caller's workspace. A user caller creates top-level sandboxes with -the same rules as `openshell sandbox create`, may attach a grant to them, and -acts on any sandbox its workspace role allows. `inputs` lists paths in the +the same rules as `openshell sandbox create`: it names the sandbox and may pick +its `image`, may attach a grant to it, and acts on any sandbox its workspace +role allows. A sandbox caller cannot pick an image (see +[Images and resources](#images-and-resources)). `inputs` lists paths in the parent that are copied read-only into the child at start. `exec` and `get_file` run through the existing `ExecSandbox` path, acting for the tree's root user. @@ -802,13 +835,13 @@ to pass on, are the only point at which the user may be prompted during a run. A child is created from its parent's sandbox template: the same image, CPU and memory, with no GPU. If the parent was created from an admin workload template, -the child is created from that same template. `create_sandbox` takes no image -or template. The image that works for the parent already contains the harness -and the tools its children need (Pi, for example, needs Node, `ripgrep` and `fd` -preinstalled because it otherwise downloads them at runtime, which a sandbox -blocks). No GPU is given to children because GPUs are scarce and three -children each taking one could block other work; `max_total` caps the tree's -other resources. +the child is created from that same template. When a sandbox calls +`create_sandbox`, it cannot name an image or template; only user callers can. +The image that works for the parent already contains the harness and the tools +its children need (Pi, for example, needs Node, `ripgrep` and `fd` preinstalled +because it otherwise downloads them at runtime, which a sandbox blocks). No GPU +is given to children because GPUs are scarce and three children each taking one +could block other work; `max_total` caps the tree's other resources. ### Retries @@ -824,8 +857,26 @@ principals only. **Limits under concurrency.** Depth, breadth and total are checked and charged in one step when the slot is reserved, so two concurrent creates cannot both -take the last slot. A create that fails after reserving releases the slot and -its `request_id`. +take the last slot. The reservation also re-checks that the grant still exists +and has not expired, and the gateway confirms afterwards that the grant and the +parent's policy used for the proof are unchanged; otherwise it releases the slot +and refuses with `STALE_ADMISSION`, and the agent retries against the new state. +A `request_id` is bound to a fingerprint of the request's arguments: a repeat +with different arguments is refused with `INVALID_ARGUMENT` rather than +answered with the first child. + +**What the counters count.** Breadth and total count children **created** under +the grant, not children running: a child that stops or is deleted still counts, +so a tree cannot create without limit by stopping and recreating children. A +create that fails after reserving releases its slot and its `request_id`. When a +grant is replaced, the new grant's total starts at the number of sandboxes +still running in the tree. + +**Revocation and expiry.** A revoked or expired grant stops new creates +(`NOT_ALLOWED`). It does not cut a parent off from children it already has: +`wait`, `get_file`, `get_output`, `exec` on tool children and `stop_sandbox` +keep working, acting for the tree's user recorded in the lineage, so a parent +can still collect results and clean up. ### Authorization enforcement @@ -877,7 +928,7 @@ the configuration file, authorization and propagation across replicas. ```toml [openshell.gateway.sandbox_mcp] enabled = true # default -# Operator caps; the effective limit is the lowest of these, the grant and the harness. +# Operator caps; the enforced limit is the lower of these and the grant. max_depth = 3 max_breadth = 8 max_total = 20 @@ -905,8 +956,9 @@ openshell grant get openshell grant update --max-breadth 3 --max-total 4 openshell grant revoke -# See a tree +# See trees, or one sandbox's direct children openshell sandbox list --tree +openshell sandbox children ``` A harness needs one setting: the gateway MCP server's address. For Pi, which @@ -943,14 +995,15 @@ The work is phased so that each phase is usable and testable on its own: - **Phase 1: The child-sandbox API, grants, lineage and the boundary proof.** Add the `SandboxChildren` gRPC service and its SDK functions, the `sandbox_grant` and `sandbox_lineage` object types, atomic counter - reservation, `SandboxGrantSpec` on `CreateSandboxRequest`, the grant RPCs and + reservation with durable pending operations reconciled at startup, + `SandboxGrantSpec` on `CreateSandboxRequest`, the grant RPCs and CLI, and the prover call at create time and on every later policy write to a sandbox with a lineage. Validate with the existing Pi and OpenWorker prototypes by replacing their host creation services with these RPCs. - **Phase 2: Gateway MCP endpoint and supervisor door.** Add `/mcp` over the same API, with authentication, the program check and a per-sandbox rate - limit, and the startup configuration that turns the endpoint off. Add the door at - `openshell.local` for MCP, open in every sandbox, and the + limit, and the startup configuration that turns the endpoint off. Add the door + at `openshell.local` for MCP, open in every sandbox, and the `urn:openshell:gateway-mcp` token. Ship host callers first, then in-sandbox callers. Carrying gRPC through the door comes in a later phase (see [Open questions](#open-questions)). @@ -960,7 +1013,8 @@ The work is phased so that each phase is usable and testable on its own: proof of every grant holder's policy (at create and when a grant is attached), child policy and policy write against the global policy in `maximum` mode. -- **Phase 5: Lifecycle.** Cascade stop on every way a parent ends, the orphan +- **Phase 5: Lifecycle.** Cascade stop on every way a parent ends, behind a + stopping fence that refuses creates in a subtree being stopped, the orphan reaper, and idle and maximum lifetime for agent children with the "a parent with running children is not idle" rule. - **Phase 6: Documentation.** A tutorial in which a Pi lead creates a Pi worker, @@ -977,7 +1031,9 @@ A proof of concept of phases 1 to 3 exists on the branch `gateway-mcp-poc` `examples/gateway-mcp-poc/`). Its current end-to-end result, tied to a commit, is in `examples/gateway-mcp-poc/README.md` under Results. The proof of concept keeps grants and lineage in memory and forwards the supervisor's main session -token instead of a dedicated token. +token instead of a dedicated token; it has no durable pending operations, +startup reconciliation or stopping fence (see [Risks](#risks)), which phases 1 +and 5 add. ## Risks @@ -1015,6 +1071,14 @@ token instead of a dedicated token. - **Host mode is weaker.** An agent on the host is not contained, only its children are. This is a property of host mode and must be documented, not hidden. +- **Crash recovery and stop races.** Durable counters alone do not make creation + safe across failures. Production needs a durable pending-operation record + written before launch and reconciled at startup, to handle a driver that + created the sandbox but whose reply was lost, and a crash between launch and + writing the lineage; and a stopping fence on a subtree, set before cascade stop + walks it, so that a create racing the deletion of its parent is refused. The + proof of concept is in memory and does not provide these. + ## Future work ### Returning results to the parent @@ -1171,9 +1235,10 @@ is instead offered as a maximum that every sandbox must fit inside. - Is the MCP endpoint's cost when idle small enough to keep it on by default on every gateway? - **How the door carries gRPC after v1.** v1 carries MCP only. The door forwards - MCP, which is plain HTTP/1.1: one request, one response, read and forwarded - with the sandbox's token. gRPC uses HTTP/2, with many calls multiplexed on one - connection and streaming responses. Options: + MCP's Streamable HTTP binding over HTTP/1.1 with JSON responses only: one + request, one response, read and forwarded with the sandbox's token. gRPC uses + HTTP/2, with many calls multiplexed on one connection and streaming responses. + Options: - **A. MCP only inside sandboxes.** No change; agents use MCP, and the SDKs are used from the host. Cost: SDK code inside a sandbox cannot call the child-sandbox API.