From 87f205afa03d814c5af8219fdbb0a85e1972f486 Mon Sep 17 00:00:00 2001 From: patkraw Date: Tue, 13 Oct 2026 14:13:12 -0700 Subject: [PATCH 01/35] 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 6775f3638e374492efcc35e6cfef6471cd6e95d1 Mon Sep 17 00:00:00 2001 From: patkraw Date: Tue, 13 Oct 2026 14:13:19 -0700 Subject: [PATCH 02/35] 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 d33d5249bcb56df4bec899dae59626f221c87bff Mon Sep 17 00:00:00 2001 From: patkraw Date: Tue, 13 Oct 2026 15:01:26 -0700 Subject: [PATCH 03/35] 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 9cfa4374a838c3736e7f3b4af99650a19af86453 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 10:21:47 -0700 Subject: [PATCH 04/35] 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 80f2c0d33a0f053fd5a16bf8a2856421990a539d Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 10:21:59 -0700 Subject: [PATCH 05/35] 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 0bf3258b1c7a4cfac7d3a6215c700e618bf01209 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 12:51:11 -0700 Subject: [PATCH 06/35] 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 6d173b5a08ea622be42402f8aa9925837e1a0637 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:11:46 -0700 Subject: [PATCH 07/35] 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 b06d29571253c09eb796ad0fdf2e85125c2a5655 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:16:12 -0700 Subject: [PATCH 08/35] 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 e07f9d32bf88a9bfcfeb6cd6f9ac1b08f17f3647 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:26:26 -0700 Subject: [PATCH 09/35] 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 8615f5364ed18e846108f6ca9a4aaa0addbdd9c2 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 13:55:53 -0700 Subject: [PATCH 10/35] 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 764625d9a4540da7e5cbe14a76c6cbc612d866a9 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:06:17 -0700 Subject: [PATCH 11/35] 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 73be62294db5b448a81028ba917114d0863cef5c Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:08:09 -0700 Subject: [PATCH 12/35] 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 e4cb3b1b7c69771fc0107c2078d3822f77ab0692 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:16:41 -0700 Subject: [PATCH 13/35] 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 a4e822228c3da4b522b7e3869d689e67473448f1 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:19:55 -0700 Subject: [PATCH 14/35] 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 d8552e987e84dbbd2f9d5a3541bb2914631f0cc0 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:27:03 -0700 Subject: [PATCH 15/35] 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 12fb6901b01782407123f3c3a7585ac6cf13ff8d Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:29:55 -0700 Subject: [PATCH 16/35] 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 0d282c7de47fe0c3c2c9a02c18ec1bb219b0b9e3 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:38:36 -0700 Subject: [PATCH 17/35] 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 c4614691db3aa8167bb59b536e8ce98c1732ac70 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:56:18 -0700 Subject: [PATCH 18/35] 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 38fef3dbf483a0c493da96459f526972f56a1db7 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 14:58:37 -0700 Subject: [PATCH 19/35] 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 d0e428b3edcab6a1b8344468ba2484d38e13be9e Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 15:06:24 -0700 Subject: [PATCH 20/35] 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 a6a5d32480c081e382158ba416b324fb182aefe5 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 15:26:41 -0700 Subject: [PATCH 21/35] 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 e6edcf9789f1d358679db45285eb3abef0f47ccd Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 15:30:01 -0700 Subject: [PATCH 22/35] 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 8f0ae804c7acca12d190935d5d0258a08e9e22cf Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 14 Oct 2026 16:06:31 -0700 Subject: [PATCH 23/35] 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 dcfcc6b44e21ac0811de7644571f55f44f10a8df Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 15 Oct 2026 11:45:37 -0700 Subject: [PATCH 24/35] 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 d5b7599e2714eff7bec3d324b018fe28047d424e Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 15 Oct 2026 12:52:32 -0700 Subject: [PATCH 25/35] 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 a4498c1e7ae54ce2db33362cb0c1dad1e0b4739b Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 15 Oct 2026 13:41:52 -0700 Subject: [PATCH 26/35] 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. From 1c3b43f8c6273d5fb2eb9af0a8544d59738a815b Mon Sep 17 00:00:00 2001 From: patkraw Date: Mon, 19 Oct 2026 13:30:26 -0400 Subject: [PATCH 27/35] docs(rfc): number the gateway MCP RFC 0021 Signed-off-by: patkraw --- .../README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename rfc/{NNNN-gateway-mcp-sandbox-creation => 0021-gateway-mcp-sandbox-creation}/README.md (99%) diff --git a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md similarity index 99% rename from rfc/NNNN-gateway-mcp-sandbox-creation/README.md rename to rfc/0021-gateway-mcp-sandbox-creation/README.md index 4cd7dbfa95..8add6d4c5f 100644 --- a/rfc/NNNN-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -6,7 +6,7 @@ links: - https://github.com/NVIDIA/OpenShell/issues/4574 --- -# RFC NNNN - Gateway MCP for Creating Sandboxes +# RFC 0021 - Gateway MCP for Creating Sandboxes ## Summary From 8afb37644728014bb9a18c01d5566c6c795ca5c8 Mon Sep 17 00:00:00 2001 From: patkraw Date: Mon, 19 Oct 2026 14:01:27 -0400 Subject: [PATCH 28/35] docs(rfc): add ceiling scenarios table; clarify per-sandbox supervisor and why tool sandboxes skip depth Signed-off-by: patkraw --- .../README.md | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index 8add6d4c5f..5e95307594 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -191,8 +191,8 @@ flowchart LR 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 agents have no supervisor, so a supervisor-hosted server would +The endpoint lives in the gateway rather than in each sandbox's own supervisor +(one per sandbox, running outside the agent's container) for three 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 @@ -340,7 +340,9 @@ 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 an agent child's depth against the same `max_depth`. Tool sandboxes +checks an agent child's depth against the same `max_depth`. A tool sandbox runs +no agent and cannot create children, so it never makes the tree deeper; depth +limits delegation, not the commands an agent runs. Tool sandboxes therefore 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 never uses more than the grant has left, so the user @@ -452,6 +454,25 @@ not change that model. It is used only for decisions about trees (cascade stop, ### Ceiling and policy proof +At a glance, the cases this section and the following ones cover: + +| # | Situation | What the child gets | Result | +|---|-----------|---------------------|--------| +| 1 | The agent writes a narrower policy, such as `/sandbox` read-only or GitHub read-only | its own policy | Created; proved within the parent | +| 2 | The agent omits the filesystem, process or Landlock section | the parent's section | Created | +| 3 | The agent omits network rules | no network access | Created | +| 4 | The agent asks for more than the parent has, such as `POST /repos/**` | nothing | `EXCEEDS_CEILING`, with a counterexample and a hint | +| 5 | The parent needs to pass on more than it has | the parent proposes the rule for itself through `policy.local`; after the user approves, the agent retries | Created on retry | +| 6 | The agent lists a path the parent does not list | nothing | `UNSUPPORTED_RULES`; the prover is conservative with paths | +| 7 | The agent sets a network middleware | nothing | Refused; middlewares are not proved | +| 8 | The parent has MCP rules and `child_policy: narrow` | MCP endpoints are dropped from the ceiling before the proof | Created for everything else; MCP rules cannot be passed on | +| 9 | `child_policy: inherit` and no policy in the request | the parent's policy unchanged, MCP rules included | Created; no proof needed | +| 10 | `child_policy: inherit` and a policy in the request | nothing | Refused | +| 11 | A later write to a child's policy (`UpdateConfig`, policy sync, approved proposal) | proved again against the parent | Refused if it would widen the child past the parent | +| 12 | The parent's policy shrinks after the child starts | the child keeps its policy | Out of scope; see [#3808](https://github.com/NVIDIA/OpenShell/issues/3808) | +| 13 | The global policy is in `maximum` mode | proved against the parent and the global policy | `EXCEEDS_GLOBAL_POLICY` if over | +| 14 | The global policy is in `replace` mode and the request has a policy | nothing | `GLOBAL_POLICY_ACTIVE` | + **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, From 4590b6647d113a1bfa15d85942c570856227e619 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 21 Oct 2026 12:41:17 -0400 Subject: [PATCH 29/35] docs(rfc): return a child's result to its parent; link the proof of concept Adds submit_result and wait's result and for_result, moves results from future work into the proposal, replaces the results risk with untrusted results, and links the proof of concept and its tutorials. Signed-off-by: patkraw --- .../README.md | 102 ++++++++++++------ 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index 5e95307594..4c474cc90f 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -101,11 +101,11 @@ sandbox, for whom, and was it allowed". [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)). +- **Large or streamed results.** A child returns one final result of at most + 32 KiB (see [Returning results](#returning-results)). Bigger output stays in + files the parent reads with `get_file` while the child runs; progress updates + and messages between running agents are future work (see + [Agent-to-agent communication through the gateway](#agent-to-agent-communication-through-the-gateway)). ## Proposal @@ -233,6 +233,9 @@ service SandboxChildren { rpc GetSandboxLimits(GetSandboxLimitsRequest) returns (GetSandboxLimitsResponse) { option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:read" }; } + rpc SubmitChildResult(SubmitChildResultRequest) returns (SubmitChildResultResponse) { + option (authorization) = { auth_mode: "sandbox" }; + } } ``` @@ -781,13 +784,14 @@ made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| | `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` | +| `wait` | sandboxes | `handle`, `timeout_s`, `for_result` | `phase`, `exit_code`, `result` (direct parent only) | `NOT_YOUR_CHILD`, `NOT_DIRECT_CHILD` (`for_result` on a grandchild) | | `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 | +| `submit_result` | sandboxes | `result` (at most 32 KiB) | `saved`, `repeated` | `NOT_A_CHILD`, `TOO_LARGE`, `ALREADY_SUBMITTED` (different text) | | `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` | @@ -837,8 +841,8 @@ delegated agents usually work for a long time; one-shot children pass 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 +deletes it. A result the parent has not read is deleted with its child; 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 @@ -852,6 +856,37 @@ not. Otherwise the user approves as today, with `openshell rule approve`, the 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. +### Returning results + +**A child hands one final result to its parent, and the parent's `wait` +returns it.** This is how harnesses already return a sub-agent's answer: the +spawn call's result. Without it, a parent has to poll a result file with +`get_file`, and the child has to keep running until the file is read, because +its files go when it exits. + +- **`submit_result { result }`** is called by the child through its own door. + The gateway takes the child's identity from the door's token, so a child can + submit only its own result. A sandbox with no parent gets `NOT_A_CHILD`. +- **One result per child.** The same text again is a no-op; different text is + refused with `ALREADY_SUBMITTED`. +- **At most 32 KiB**, so the request stays under the door's 64 KiB body limit + after JSON escaping and an oversized result gets the gateway's `TOO_LARGE`. + The door answers a body over its own limit with HTTP 413 and the size. +- **Saved before the child is told "saved"**, as a `sandbox_result` object keyed + by the child's id, so the child can exit at once and the result survives a + gateway restart. +- **`wait` with `for_result: true`** keeps waiting past READY until the child + submits or exits, and returns `result` with the phase and exit code. A child + that exits without submitting returns `result: null` and its exit code. A + `wait` that times out leaves the result for the next `wait`. +- **Only the direct parent gets it.** A grandparent may still `wait` for a + grandchild, but `for_result` on it is refused with `NOT_DIRECT_CHILD` and a + plain `wait` shows no result. Each level decides what to pass up. +- **Deleted with the child**, including by cascade stop. + +A result is text written by the child, so the parent's harness must treat it as +data, not instructions (see [Risks](#risks)). + ### Images and resources A child is created from its parent's sandbox template: the same image, CPU and @@ -1028,8 +1063,9 @@ The work is phased so that each phase is usable and testable on its own: `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)). -- **Phase 3: Tool sandboxes.** `kind: tool`, `exec` with command audit, and the - idle timeout. +- **Phase 3: Tool sandboxes and results.** `kind: tool`, `exec` with command + audit, and the idle timeout; `submit_result`, the `sandbox_result` object and + `wait`'s `result` and `for_result`. - **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the 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 @@ -1046,12 +1082,18 @@ 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` +A proof of concept of phases 1 to 3 exists on the branch +[`gateway-mcp-poc`](https://github.com/patkraw/OpenShell/tree/gateway-mcp-poc) (`crates/openshell-gateway-mcp/`, `crates/openshell-server/src/children.rs`, `crates/openshell-supervisor-network/src/gateway_mcp_door.rs`, -`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 +`examples/gateway-mcp-poc/`), with +[tutorials](https://github.com/patkraw/OpenShell/tree/gateway-mcp-poc/examples/gateway-mcp-poc/tutorials) +that run as written. Returning results is in +[tutorial 02](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/tutorials/02-mcp-inside-sandbox.md#collect-an-agent-childs-result), +and `run-pi-result-demo.sh` runs it with a real Pi lead and Pi worker. Its +current end-to-end result, tied to a commit, is in +[`examples/gateway-mcp-poc/README.md`](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/README.md#results) +under Results. The proof of concept keeps grants, lineage and results in memory and forwards the supervisor's main session 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. @@ -1079,11 +1121,10 @@ and 5 add. 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 - its parent reads its result loses that result. Harnesses must keep agent - children running until their result is read; a results channel is future - work (see [Returning results to the parent](#returning-results-to-the-parent)). +- **Results are untrusted input.** A result is text the child wrote, and it + reaches the parent's model. A child that was manipulated through a prompt + injection can put instructions in it. The gateway checks who submits and how + much, not what it says; harnesses must treat a result as data. - **Spawn storms.** A misbehaving lead can create children up to its grant quickly. Atomic counters, `max_total`, operator caps, retry deduplication and @@ -1102,12 +1143,12 @@ and 5 add. ## Future work -### Returning results to the parent +### Larger results and progress -`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. +A result is one final message of at most 32 KiB. Larger outputs could be kept +as artifacts the parent reads by reference after the child exits, and a running +child could report progress. Both build on the result object and on +agent-to-agent communication. ### Agent-to-agent communication through the gateway @@ -1353,15 +1394,14 @@ 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. +finished. The worker hands its answer back with `submit_result` and exits; the +lead's `wait` with `for_result` returns as soon as the result is saved. ```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 +worker: submit_result { result: "review: LGTM, 2 nits" } +→ door adds the worker's token → gateway saves sandbox_result for the worker → saved +lead: wait { handle: worker, for_result: true } +→ caller is the worker's direct parent → { phase: COMPLETED, exit_code: 0, result: "review: LGTM, 2 nits" } stop_sandbox { handle: worker } → worker's descendants (none) stopped first, then worker; identity revoked first ``` From 6a58cb22a9b4e9ebd845daff5daa3e0c1b9d5023 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 21 Oct 2026 13:10:25 -0400 Subject: [PATCH 30/35] docs(rfc): note that the MCP endpoint will expose more of the control plane Creating child sandboxes is the first use case; other tools get their own proposals. Signed-off-by: patkraw --- rfc/0021-gateway-mcp-sandbox-creation/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index 4c474cc90f..20a1ef1f51 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -18,6 +18,10 @@ 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 MCP endpoint is meant to expose more of OpenShell's control plane to +harnesses over time. Creating child sandboxes is its first use case, and the +only one this RFC designs. + 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 @@ -81,6 +85,8 @@ sandbox, for whom, and was it allowed". ## Non-goals +- **Other control-plane tools on the MCP endpoint.** Each later set of tools + gets its own proposal that names its RPCs and who may call them. - **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 From 4dc039e341d3cb3eedccbdd508fcdab3af11e14d Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 21 Oct 2026 13:10:32 -0400 Subject: [PATCH 31/35] docs(rfc): call it the OpenShell MCP server "Gateway MCP" reads as an MCP gateway, a proxy for other MCP servers. This is one MCP server, served by the gateway. Identifiers and paths are unchanged. Signed-off-by: patkraw --- .../README.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index 20a1ef1f51..d58850b2f3 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -6,12 +6,13 @@ links: - https://github.com/NVIDIA/OpenShell/issues/4574 --- -# RFC 0021 - Gateway MCP for Creating Sandboxes +# RFC 0021 - OpenShell MCP Server 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 +This RFC proposes letting agents create sandboxes through the OpenShell MCP +server, an MCP endpoint served by the OpenShell gateway at `/mcp`. It is one MCP +server, not a gateway or proxy for other MCP servers. 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 @@ -389,7 +390,7 @@ 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 -gateway MCP, and every change is audited. If an agent could widen its own grant, +OpenShell MCP server, and every change is audited. If an agent could widen its own grant, the grant would mean nothing. ```proto @@ -964,7 +965,7 @@ while lineage records which sandbox asked. | Attach a grant later (`grant create`) | top-level sandboxes without a grant | 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) | +| Call the OpenShell MCP server | 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) | @@ -1023,7 +1024,7 @@ openshell sandbox list --tree openshell sandbox children ``` -A harness needs one setting: the gateway MCP server's address. For Pi, which +A harness needs one setting: the OpenShell MCP server's address. For Pi, which has built-in MCP support since 0.99, that is `~/.pi/agent/mcp.json`: ```json @@ -1062,7 +1063,7 @@ The work is phased so that each phase is usable and testable on its own: 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 +- **Phase 2: OpenShell MCP server 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 @@ -1121,7 +1122,7 @@ and 5 add. use the door. Such a script still receives only what the gateway would give 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, +- **Every sandbox reaches the OpenShell MCP server.** 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; turning the endpoint off in the gateway configuration @@ -1236,7 +1237,7 @@ 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 +Each sandbox's policy would carry MCP tool rules for the OpenShell MCP server, for example "only the lead may call `create_*`". This was rejected because the prover cannot check MCP rules, a policy rule From 9cc3351cfec8cebc66078dc9327a984f5c47743a Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 21 Oct 2026 13:13:50 -0400 Subject: [PATCH 32/35] docs(rfc): move RFC 0021 to review and link its pull request Signed-off-by: patkraw --- rfc/0021-gateway-mcp-sandbox-creation/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index d58850b2f3..ee0df59fbc 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -1,9 +1,10 @@ --- authors: - "@patkraw" -state: draft +state: review links: - https://github.com/NVIDIA/OpenShell/issues/4574 + - https://github.com/NVIDIA/OpenShell/pull/4721 --- # RFC 0021 - OpenShell MCP Server for Creating Sandboxes From 5ad18a39c381b80db28e56e88eb8abc3c8dbff41 Mon Sep 17 00:00:00 2001 From: patkraw Date: Wed, 21 Oct 2026 17:54:18 -0400 Subject: [PATCH 33/35] docs(rfc): return results through the child's files and a done flag The gateway keeps only a done flag set by mark_done; the parent reads the child's result with get_file, so agent output is never stored in the gateway. Signed-off-by: patkraw --- .../README.md | 86 +++++++++---------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index ee0df59fbc..feb1cec19e 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -109,10 +109,10 @@ sandbox, for whom, and was it allowed". [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. -- **Large or streamed results.** A child returns one final result of at most - 32 KiB (see [Returning results](#returning-results)). Bigger output stays in - files the parent reads with `get_file` while the child runs; progress updates - and messages between running agents are future work (see +- **Storing results or streaming them.** The gateway never keeps a child's + output. A child leaves its result in its own files, says it is done, and the + parent reads the files with `get_file` (see [Returning results](#returning-results)). + Progress updates and messages between running agents are future work (see [Agent-to-agent communication through the gateway](#agent-to-agent-communication-through-the-gateway)). ## Proposal @@ -792,14 +792,14 @@ made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| | `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`, `for_result` | `phase`, `exit_code`, `result` (direct parent only) | `NOT_YOUR_CHILD`, `NOT_DIRECT_CHILD` (`for_result` on a grandchild) | +| `wait` | sandboxes | `handle`, `timeout_s`, `for_done` | `phase`, `exit_code`, `done` | `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 | -| `submit_result` | sandboxes | `result` (at most 32 KiB) | `saved`, `repeated` | `NOT_A_CHILD`, `TOO_LARGE`, `ALREADY_SUBMITTED` (different text) | +| `mark_done` | sandboxes | none | `done`, `repeated` | `NOT_A_CHILD` | | `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` | @@ -866,31 +866,28 @@ to pass on, are the only point at which the user may be prompted during a run. ### Returning results -**A child hands one final result to its parent, and the parent's `wait` -returns it.** This is how harnesses already return a sub-agent's answer: the -spawn call's result. Without it, a parent has to poll a result file with -`get_file`, and the child has to keep running until the file is read, because -its files go when it exits. - -- **`submit_result { result }`** is called by the child through its own door. - The gateway takes the child's identity from the door's token, so a child can - submit only its own result. A sandbox with no parent gets `NOT_A_CHILD`. -- **One result per child.** The same text again is a no-op; different text is - refused with `ALREADY_SUBMITTED`. -- **At most 32 KiB**, so the request stays under the door's 64 KiB body limit - after JSON escaping and an oversized result gets the gateway's `TOO_LARGE`. - The door answers a body over its own limit with HTTP 413 and the size. -- **Saved before the child is told "saved"**, as a `sandbox_result` object keyed - by the child's id, so the child can exit at once and the result survives a - gateway restart. -- **`wait` with `for_result: true`** keeps waiting past READY until the child - submits or exits, and returns `result` with the phase and exit code. A child - that exits without submitting returns `result: null` and its exit code. A - `wait` that times out leaves the result for the next `wait`. -- **Only the direct parent gets it.** A grandparent may still `wait` for a - grandchild, but `for_result` on it is refused with `NOT_DIRECT_CHILD` and a - plain `wait` shows no result. Each level decides what to pass up. -- **Deleted with the child**, including by cascade stop. +**A child leaves its result in its own files and says it is done; the parent's +`wait` returns, and the parent reads the files with `get_file`.** The gateway +keeps a "done" flag, never the result: it is the control plane, and agent output +stays in the sandboxes. Without the flag, a parent cannot tell a child that is +still working from one whose result is ready, and has to poll `get_file`, which +costs model turns and can read a half-written file. + +- **`mark_done {}`** is called by the child through its own door, after its + result file is complete. The gateway takes the child's identity from the + door's token, so a child can mark only itself. A sandbox with no parent gets + `NOT_A_CHILD`. Calling it again is a no-op. +- **`wait` with `for_done: true`** keeps waiting past READY until the child is + done or exits, and returns `done` with the phase and exit code. A child that + exits without marking done returns `done: false` and its exit code. A `wait` + that times out can be repeated. +- **The parent reads the result with `get_file`** while the child runs. The + bytes pass through the gateway's relay and are not stored. Any ancestor may + see the flag and read the files, as it may today. +- **The child keeps running until the parent stops it**, because its files go + when it exits. A harness wrapper can write the agent's final answer to a + file and call `mark_done`, so the model does not have to remember to. +- **The flag is deleted with the child**, including by cascade stop. A result is text written by the child, so the parent's harness must treat it as data, not instructions (see [Risks](#risks)). @@ -1072,8 +1069,7 @@ The work is phased so that each phase is usable and testable on its own: callers. Carrying gRPC through the door comes in a later phase (see [Open questions](#open-questions)). - **Phase 3: Tool sandboxes and results.** `kind: tool`, `exec` with command - audit, and the idle timeout; `submit_result`, the `sandbox_result` object and - `wait`'s `result` and `for_result`. + audit, and the idle timeout; `mark_done` and `wait`'s `done` and `for_done`. - **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the 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 @@ -1098,7 +1094,7 @@ A proof of concept of phases 1 to 3 exists on the branch [tutorials](https://github.com/patkraw/OpenShell/tree/gateway-mcp-poc/examples/gateway-mcp-poc/tutorials) that run as written. Returning results is in [tutorial 02](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/tutorials/02-mcp-inside-sandbox.md#collect-an-agent-childs-result), -and `run-pi-result-demo.sh` runs it with a real Pi lead and Pi worker. Its +and `run-pi-done-demo.sh` runs it with a real Pi lead and Pi worker. Its current end-to-end result, tied to a commit, is in [`examples/gateway-mcp-poc/README.md`](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/README.md#results) under Results. The proof of concept keeps grants, lineage and results in memory and forwards the supervisor's main session @@ -1153,10 +1149,10 @@ and 5 add. ### Larger results and progress -A result is one final message of at most 32 KiB. Larger outputs could be kept -as artifacts the parent reads by reference after the child exits, and a running -child could report progress. Both build on the result object and on -agent-to-agent communication. +A result lives only in the child's files, so it is lost if the child exits +before the parent reads it. Keeping outputs as artifacts that outlive the child, +and letting a running child report progress, need a decision on where that data +lives; both build on agent-to-agent communication. ### Agent-to-agent communication through the gateway @@ -1402,14 +1398,16 @@ 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 hands its answer back with `submit_result` and exits; the -lead's `wait` with `for_result` returns as soon as the result is saved. +finished. The worker writes its answer to a file and calls `mark_done`; the +lead's `wait` with `for_done` returns, and the lead reads the file. ```text -worker: submit_result { result: "review: LGTM, 2 nits" } -→ door adds the worker's token → gateway saves sandbox_result for the worker → saved -lead: wait { handle: worker, for_result: true } -→ caller is the worker's direct parent → { phase: COMPLETED, exit_code: 0, result: "review: LGTM, 2 nits" } +worker: writes /tmp/result.txt, then mark_done {} +→ door adds the worker's token → gateway sets the worker's done flag → done +lead: wait { handle: worker, for_done: true } +→ { phase: READY, done: true } +lead: get_file { handle: worker, path: "/tmp/result.txt" } +→ read through the relay, not stored → "review: LGTM, 2 nits" stop_sandbox { handle: worker } → worker's descendants (none) stopped first, then worker; identity revoked first ``` From 76b2e94f7f9f40c0841cbb6b0b08afaca1286e82 Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 22 Oct 2026 14:44:45 -0400 Subject: [PATCH 34/35] docs(rfc): add an appendix on delivering a child's result The appendix records the result delivery the proof of concept built: wait returns the child's result file (until_file, return_file), the result's first line is the command's exit status, and children never call the gateway. It covers the flow, the contract, failure cases, how the design moved from submit_result and mark_done, and the benchmark evidence. Returning results points to it. Signed-off-by: patkraw --- .../README.md | 4 + .../appendices/result-delivery.md | 94 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index feb1cec19e..dd6899eb37 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -866,6 +866,10 @@ to pass on, are the only point at which the user may be prompted during a run. ### Returning results +> The proof of concept replaced the design below with `wait` returning the +> result file (`until_file`, `return_file`), so children never call the +> gateway. See [Appendix: Delivering a Child's Result](appendices/result-delivery.md). + **A child leaves its result in its own files and says it is done; the parent's `wait` returns, and the parent reads the files with `get_file`.** The gateway keeps a "done" flag, never the result: it is the control plane, and agent output diff --git a/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md b/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md new file mode 100644 index 0000000000..a3a47edb7a --- /dev/null +++ b/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md @@ -0,0 +1,94 @@ +# Appendix: Delivering a Child's Result + +This appendix describes how a child agent's final result reaches its parent. It replaces the body's [Returning results](../README.md#returning-results) section. + +## How it works + +The parent agent uses three OpenShell MCP tools at `http://openshell.local/mcp`: `create_sandbox`, `wait` and `stop_sandbox`. The child writes its result to a file. When the parent calls `wait`, the gateway reads the file from the child's sandbox and returns it. The gateway keeps no copy. + +The gateway is involved because the parent and child are isolated sandboxes, and the gateway is the only path between them: it already reaches every sandbox for `exec` and `get_file`, and it checks that the caller is the child's parent. This is the solution for now. Once OpenShell has sandbox-to-sandbox communication, a child can send its result to its parent directly. + +### Step by step + +**1. The parent creates the child.** The parent agent calls `create_sandbox` on `openshell.local/mcp`, with the child's command wrapped in `run-child`: + +```text +create_sandbox { name: "worker", kind: "agent", + command: ["run-child", "codex", "exec", ""] } +→ { handle: "worker" } +``` + +The parent's supervisor adds the sandbox token and the calling program and forwards the call. The gateway checks the grant, the limits and the policy ceiling, records the child under its parent and starts it. + +**2. The child runs its agent.** In the child sandbox, `run-child` starts the command (here `codex exec`) with stdin closed. It saves the command's output to `/tmp/result.body` and its error output to `/tmp/result.err`. The child makes no calls to the gateway. + +**3. The parent waits.** At the same time, the parent calls `wait`: + +```text +wait { handle: "worker", until_file: "/tmp/result.txt", return_file: true, timeout_s: 45 } +``` + +The gateway checks that `worker` is the caller's own child. Then, while the child is READY, it runs `cat /tmp/result.txt` in the child every 3 seconds, through the same exec relay as `get_file`. While the file does not exist, `cat` fails and the gateway keeps waiting. + +**4. The command ends, and `run-child` publishes the result.** It writes the exit status and then the output to `/tmp/result.txt.tmp`, and renames it to `/tmp/result.txt`. A rename is atomic, so `cat` never sees a half-written file. The first line is always `exit=N`; the rest is whatever the command printed, here a worker's answer in the benchmark's crash task: + +```text +exit=0 +{"errors": 102, "top_error": "cache unavailable"} +``` + +`run-child` publishes the file whether the command succeeded or failed. It then sleeps, keeping the child running for a collection window (one hour by default, `RUN_CHILD_LINGER_S`), so the file stays readable. + +**5. `wait` returns the result.** The next `cat` succeeds. The gateway puts the file's bytes in the reply and keeps no copy: + +```text +→ { file_ready: true, content: "exit=0\n{...}", truncated: false, + phase: "SANDBOX_PHASE_READY", exit_code: null, timed_out: false } +``` + +If 45 seconds pass first, `wait` returns `timed_out: true` and the parent calls it again. If the child exits without publishing, `wait` returns `file_ready: false` with the child's exit code. + +**6. The parent reads the status line.** `exit=0` means the rest of the content is the answer. Any other value means the command failed and the output may be partial; the parent reports the failure instead of using it. + +**7. The parent stops the child.** It calls `stop_sandbox { handle: "worker" }`. The gateway deletes the child sandbox and its files, so the result exists only in the parent from now on. + +## The contract + +- **Complete or absent.** The result file is either fully written or not there. +- **Published even on failure.** Publication means the command finished, not that it succeeded; the first line says which. +- **Results must fit in 256 KiB.** `wait` and `get_file` return at most 256 KiB and report `truncated: true` beyond that. The status line comes first, so it is never cut. +- **Repeatable reads.** Reading does not change the file. A parent that lost a reply calls `wait` or `get_file` again and gets the same content. +- **Receive, then stop.** A stop sent while a read is in flight is a cancellation: the result can be lost. +- **No copy elsewhere.** Once the child is gone, its result is gone. +- **The result is untrusted.** The child writes its own result file, status line included, so a compromised child can fake it or carry prompt injection. The parent treats it as data from an untrusted agent. + +## When things go wrong + +| What happens | What the parent sees | What it should do | +|---|---|---| +| The command fails after partial output | `file_ready: true`, content starts `exit=4` | Report the failure; do not use the output as the answer | +| The result is over 256 KiB | `truncated: true`; the status line is intact | Use what it has; do not claim it is complete | +| The reply is lost or `wait` times out | `timed_out: true`, or no reply | Call `wait` again | +| The child never finishes | `timed_out: true` on every call | Give up after its own limit and stop the child | +| The sandbox crashes before publishing | `file_ready: false` and the sandbox's exit code | Report the failure; there is no result | +| The parent goes away | nothing | Deleting the parent removes the child; otherwise the window ends it | + +## Interface + +| Item | What it does | +|---|---| +| `wait.until_file` | An absolute path in the child. Keep waiting past READY until the file exists or the child exits; returns `file_ready`. | +| `wait.return_file` | With `until_file` only: include the file's `content` and `truncated`. Without `until_file` it is `INVALID_ARGUMENT`. | +| `get_file` | Reads a file from a running child, up to 256 KiB, and reports `truncated`. | +| `run-child` | A small wrapper for the child's command: closes stdin, publishes the result with its status line, stays running. The gateway does not depend on it; it returns the file's bytes whatever their format. | + +## Evidence + +The proof of concept ran nine delegation tasks with six harnesses (Pi, OpenCode, Qwen Code, Codex, Deep Agents and Hermes), three rounds each: 161 of 162 runs passed, and the one failure was the model provider's content filter. Checks without a model pass 26 of 26, including a failing command that publishes `exit=4` and repeated reads that return the same content. The benchmark is in `examples/gateway-mcp-poc/bench/`. + +## Open items + +- **Collection window.** It is a `run-child` setting; a parent may want to set it at create time. +- **Shipping `run-child`.** Images carry their own copy today; OpenShell could ship it. +- **Larger results.** Outputs over 256 KiB need artifacts that outlive the child, which the RFC leaves to future work. +- **`wait` and harness timeouts.** Some harnesses end an MCP call after 60 seconds, so parents repeat 45-second waits. From 61b85aebbca50c22e6fb86476a25d7005a4c5af4 Mon Sep 17 00:00:00 2001 From: patkraw Date: Thu, 22 Oct 2026 21:32:23 -0400 Subject: [PATCH 35/35] docs(rfc): describe returning results with wait's until_file and return_file Children no longer call the gateway to say they are done. The body now matches the appendix: wait returns the result file's content, run-child publishes it with an exit-status first line, and results must fit in 256 KiB. Remove mark_done, wait's for_done and done, and the leftover SubmitChildResult RPC. Signed-off-by: patkraw --- .../README.md | 91 +++++++++---------- .../appendices/result-delivery.md | 2 +- 2 files changed, 44 insertions(+), 49 deletions(-) diff --git a/rfc/0021-gateway-mcp-sandbox-creation/README.md b/rfc/0021-gateway-mcp-sandbox-creation/README.md index dd6899eb37..3716af674b 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/README.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/README.md @@ -110,8 +110,8 @@ sandbox, for whom, and was it allowed". A single prompt that shows the child's request and approves it in one step is future work. - **Storing results or streaming them.** The gateway never keeps a child's - output. A child leaves its result in its own files, says it is done, and the - parent reads the files with `get_file` (see [Returning results](#returning-results)). + output. A child leaves its result in a file in its own sandbox, and the + parent's `wait` returns it (see [Returning results](#returning-results)). Progress updates and messages between running agents are future work (see [Agent-to-agent communication through the gateway](#agent-to-agent-communication-through-the-gateway)). @@ -241,9 +241,6 @@ service SandboxChildren { rpc GetSandboxLimits(GetSandboxLimitsRequest) returns (GetSandboxLimitsResponse) { option (authorization) = { auth_mode: "dual" workspace_role: "user" scope: "sandbox:read" }; } - rpc SubmitChildResult(SubmitChildResultRequest) returns (SubmitChildResultResponse) { - option (authorization) = { auth_mode: "sandbox" }; - } } ``` @@ -792,14 +789,13 @@ made. | Tool | Listed for | Input | Output | Errors | |------|------------|-------|--------|--------| | `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`, `for_done` | `phase`, `exit_code`, `done` | `NOT_YOUR_CHILD` | +| `wait` | sandboxes | `handle`, `timeout_s`, `until_file`, `return_file` | `phase`, `exit_code`, `timed_out`; with `until_file`: `file_ready`; with `return_file`: `content`, `truncated` | `NOT_YOUR_CHILD`, `INVALID_ARGUMENT` (`return_file` without `until_file`, or a relative path) | | `exec` | sandboxes | `handle`, `command`, `timeout_s` | `stdout`, `stderr`, `exit_code` | `NOT_YOUR_CHILD`, `WRONG_KIND` | -| `get_file` | sandboxes | `handle`, `path` | `content` | `NOT_YOUR_CHILD` | +| `get_file` | sandboxes | `handle`, `path` | `content` (up to 256 KiB), `truncated` | `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 | -| `mark_done` | sandboxes | none | `done`, `repeated` | `NOT_A_CHILD` | | `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` | @@ -866,35 +862,31 @@ to pass on, are the only point at which the user may be prompted during a run. ### Returning results -> The proof of concept replaced the design below with `wait` returning the -> result file (`until_file`, `return_file`), so children never call the -> gateway. See [Appendix: Delivering a Child's Result](appendices/result-delivery.md). - -**A child leaves its result in its own files and says it is done; the parent's -`wait` returns, and the parent reads the files with `get_file`.** The gateway -keeps a "done" flag, never the result: it is the control plane, and agent output -stays in the sandboxes. Without the flag, a parent cannot tell a child that is -still working from one whose result is ready, and has to poll `get_file`, which -costs model turns and can read a half-written file. - -- **`mark_done {}`** is called by the child through its own door, after its - result file is complete. The gateway takes the child's identity from the - door's token, so a child can mark only itself. A sandbox with no parent gets - `NOT_A_CHILD`. Calling it again is a no-op. -- **`wait` with `for_done: true`** keeps waiting past READY until the child is - done or exits, and returns `done` with the phase and exit code. A child that - exits without marking done returns `done: false` and its exit code. A `wait` - that times out can be repeated. -- **The parent reads the result with `get_file`** while the child runs. The - bytes pass through the gateway's relay and are not stored. Any ancestor may - see the flag and read the files, as it may today. -- **The child keeps running until the parent stops it**, because its files go - when it exits. A harness wrapper can write the agent's final answer to a - file and call `mark_done`, so the model does not have to remember to. -- **The flag is deleted with the child**, including by cascade stop. +**A child writes its result to a file in its own sandbox and keeps running; the +parent's `wait` returns the file's content, and the parent then stops the +child.** The gateway reads the file through the exec relay, as `get_file` does, +and keeps no copy: it is the control plane, and agent output stays in the +sandboxes. The child never calls the gateway. + +- **`wait` with `until_file`** (an absolute path in the child) keeps waiting + past READY until the file exists or the child exits, and returns + `file_ready`. While the child is READY, the gateway checks for the file every + few seconds. A `wait` that times out can be repeated. +- **`return_file: true`** adds the file's `content` to the reply, so the parent + has the result before it can stop the child. +- **`run-child`**, a small wrapper for the child's command, closes stdin, + writes the result whole and renames it into place, so it never appears + half-written. The first line is the command's exit status (`exit=N`), then + its output. It publishes the result even when the command fails, and keeps + the child running for a collection window. +- **Results must fit in 256 KiB.** `wait` and `get_file` report `truncated` + beyond that; the status line comes first, so it is never cut. +- **The result is deleted with the child**, including by cascade stop. A result is text written by the child, so the parent's harness must treat it as -data, not instructions (see [Risks](#risks)). +data, not instructions (see [Risks](#risks)). The full flow, contract and +failure cases are in +[Appendix: Delivering a Child's Result](appendices/result-delivery.md). ### Images and resources @@ -1073,7 +1065,8 @@ The work is phased so that each phase is usable and testable on its own: callers. Carrying gRPC through the door comes in a later phase (see [Open questions](#open-questions)). - **Phase 3: Tool sandboxes and results.** `kind: tool`, `exec` with command - audit, and the idle timeout; `mark_done` and `wait`'s `done` and `for_done`. + audit, and the idle timeout; `wait`'s `until_file` and `return_file`, and + `run-child`. - **Phase 4: Global policy as a maximum.** Add `global_policy_mode` and the 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 @@ -1098,10 +1091,13 @@ A proof of concept of phases 1 to 3 exists on the branch [tutorials](https://github.com/patkraw/OpenShell/tree/gateway-mcp-poc/examples/gateway-mcp-poc/tutorials) that run as written. Returning results is in [tutorial 02](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/tutorials/02-mcp-inside-sandbox.md#collect-an-agent-childs-result), -and `run-pi-done-demo.sh` runs it with a real Pi lead and Pi worker. Its +and `run-pi-result-demo.sh` runs it with a real Pi lead and Pi worker. Its current end-to-end result, tied to a commit, is in [`examples/gateway-mcp-poc/README.md`](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/README.md#results) -under Results. The proof of concept keeps grants, lineage and results in memory and forwards the supervisor's main session +under Results. A benchmark runs nine delegation tasks with six harnesses (Pi, +OpenCode, Qwen Code, Codex, Deep Agents and Hermes); how to run it is in its +[README](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/bench/README.md), and the results are in its +[report](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/bench/REPORT.md). The proof of concept keeps grants, lineage and results in memory and forwards the supervisor's main session 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. @@ -1131,8 +1127,9 @@ and 5 add. - **Results are untrusted input.** A result is text the child wrote, and it reaches the parent's model. A child that was manipulated through a prompt - injection can put instructions in it. The gateway checks who submits and how - much, not what it says; harnesses must treat a result as data. + injection can put instructions in it, or fake its status line. The gateway + checks who may read it and how much, not what it says; harnesses must treat a + result as data. - **Spawn storms.** A misbehaving lead can create children up to its grant quickly. Atomic counters, `max_total`, operator caps, retry deduplication and @@ -1402,16 +1399,14 @@ 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 writes its answer to a file and calls `mark_done`; the -lead's `wait` with `for_done` returns, and the lead reads the file. +finished. The worker's `run-child` writes its answer to a file; the lead's +`wait` with `until_file` returns the file. ```text -worker: writes /tmp/result.txt, then mark_done {} -→ door adds the worker's token → gateway sets the worker's done flag → done -lead: wait { handle: worker, for_done: true } -→ { phase: READY, done: true } -lead: get_file { handle: worker, path: "/tmp/result.txt" } -→ read through the relay, not stored → "review: LGTM, 2 nits" +worker: run-child writes /tmp/result.txt (exit=0, then the answer) +lead: wait { handle: worker, until_file: "/tmp/result.txt", return_file: true } +→ gateway reads the file through the relay, not stored +→ { phase: READY, file_ready: true, content: "exit=0\nreview: LGTM, 2 nits" } stop_sandbox { handle: worker } → worker's descendants (none) stopped first, then worker; identity revoked first ``` diff --git a/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md b/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md index a3a47edb7a..be4f17eeb0 100644 --- a/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md +++ b/rfc/0021-gateway-mcp-sandbox-creation/appendices/result-delivery.md @@ -84,7 +84,7 @@ If 45 seconds pass first, `wait` returns `timed_out: true` and the parent calls ## Evidence -The proof of concept ran nine delegation tasks with six harnesses (Pi, OpenCode, Qwen Code, Codex, Deep Agents and Hermes), three rounds each: 161 of 162 runs passed, and the one failure was the model provider's content filter. Checks without a model pass 26 of 26, including a failing command that publishes `exit=4` and repeated reads that return the same content. The benchmark is in `examples/gateway-mcp-poc/bench/`. +The proof of concept ran nine delegation tasks with six harnesses (Pi, OpenCode, Qwen Code, Codex, Deep Agents and Hermes), three rounds each: 161 of 162 runs passed, and the one failure was the model provider's content filter. Checks without a model pass 26 of 26, including a failing command that publishes `exit=4` and repeated reads that return the same content. How to run the benchmark is in its [README](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/bench/README.md), and the results are in its [report](https://github.com/patkraw/OpenShell/blob/gateway-mcp-poc/examples/gateway-mcp-poc/bench/REPORT.md). ## Open items