Repository navigation
feat(supervisor): let request middleware mark a single request as pending a human decision #4406
Description
User Story
I use OpenShell to run coding agents (Claude Code) in sandboxes that act on GitHub through its REST API.
I'm building supervisor middleware that sends a few risky requests, like merging a PR into the default branch, to a named person for approval, and lets routine requests through. I'm building it for OKed, an approval service for agents.
I directly hit the limits below on v0.1.2 while testing this against a real GitHub repo. I then checked the code at v0.1.3.
This affects whether teams can let agents act on real systems without approving every write in advance or blocking writes entirely.
Problem Statement
Request middleware can allow, deny, or modify a request within a bounded evaluation timeout. There is no outcome that means "this specific request needs a human decision."
So middleware has no first-class way to tell the agent "a person is deciding on this exact request; wait, then retry," and OpenShell has no way to release exactly that request once it is approved.
Impact / Why This Matters
Today the only working pattern is deny and retry, built separately in each middleware:
- Deny at once with a reason code that encodes "pending, retry".
- Track the approval outside OpenShell.
- When the identical request comes back and has been approved, allow it once.
I built this, and it works with Claude Code. It's fragile:
-
The agent has to read a code. Only
reason_codereaches the sandbox (1 to 64 bytes,^[a-z][a-z0-9_]*$), so the instruction is packed into an identifier:oked_approval_pending_retry_same_request_every_60s_up_to_10m. With a barependingcode, the agent stopped and asked its user instead of retrying. -
It looks like a final refusal. The sandbox sees
middleware_denied. Each middleware invents its own convention. -
Waiting costs model tokens. The agent waits by retrying, so every retry is another model turn.
-
Retry matching is guesswork.
request_idis new for every request, andoriginating_processis always empty in this version (RFC 0009, feat(supervisor): populateRequestContext.originating_processfor supervisor middleware #4269). The middleware has to fingerprint requests itself. -
Enforcement lives outside OpenShell. Single use, expiry, and the audit trail all sit in the middleware.
Holding the request inside the evaluation is not a way out: -
The evaluation timeout is at most 30 s.
-
For HTTPS, OpenShell opens the upstream TCP and TLS connection at CONNECT, before it reads the request or calls middleware. GitHub closed that idle connection: holds over about 4 s broke requests to
api.github.comin my tests. -
Middleware runs 32 evaluations at a time per sandbox, and up to 64 more wait with no deadline. Held requests keep their sandbox connections open. At about 100 held requests, new connections to all hosts stalled, also to hosts without middleware, and DNS failed once. The code points to the 96 relay streams per sandbox.
OpenShell already has a good pattern for this, but only for rules: Policy Advisor answers at once with OpenShell-written guidance, and the agent waits on apolicy.locallong-poll without spending model tokens. Middleware can't start that flow for one request.
Related: #2881 asks for single-use and expiring grants, and its triage asks what one "use" means. #4201 describes another middleware that enforces single-use human approvals today. #4359 deprecates EvaluateHttpRequest for 0.2.0.
Proposed Design
Base mode: answer at once, OpenShell owns the wait. No request is held, so no evaluation slot, upstream connection, or relay stream is kept while a person decides.
Middleware author workflow.
- For a request that needs a person, the middleware returns
pendingwith structured fields only: apending_id(same character rules asreason_code) and an expiry. No free text, consistent with OpenShell never forwarding free-form middleware text to the sandbox. - It targets the HTTP session hooks from feat(middleware): introduce improved HTTP request/response hooks for the middleware #4359, as a terminal result after preflight or after the buffered body, never after output has started.
- Middleware opts in with a required capability, for example
openshell.supervisor-middleware.pending, the same wayhttp-sessiondoes. Middleware that doesn't opt in behaves exactly as today. - OpenShell learns the decision from the middleware, either by asking it again with the
pending_id, or through a new call that returns when the decision is made (open question below).
Operator workflow. Middleware is bound as today. The policy can set the longest wait. Notional example:
network_middlewares:
approvals:
middleware: oked-approvals
endpoints:
include: ["api.github.com"]
pending:
max_wait: 10m # after this, the pending request is answered as expiredWhat the agent sees
- At once: a documented pending response with fixed text written by OpenShell, modeled on Policy Advisor's
next_stepsandagent_guidance. Notional example:
HTTP/1.1 403 Forbidden
Content-Type: application/json
Connection: close
{
"error": "approval_pending",
"detail": "This request is waiting for a human decision.",
"policy": "github-policy",
"middleware": "approvals",
"pending_id": "apr_7f3c2a",
"expires_at": "2026-10-11T10:10:00Z",
"next_steps": [
{"action": "wait", "method": "GET", "url": "http://policy.local/v1/pending/apr_7f3c2a/wait?timeout=300"},
{"action": "retry", "detail": "If approved, send the identical request again."}
],
"agent_guidance": "<fixed text written by OpenShell>"
}- While waiting: the agent calls the wait URL, a long-poll like
/v1/proposals/{id}/wait. It returnsapproved,denied,expired, orpendingwhen its timeout ends. Waiting costs no model tokens. - Approved: the agent sends the identical request again. OpenShell matches it on sandbox, method, host, port, path, query, and a digest of the body taken before credentials are added. Exactly one matching request is released, claimed atomically before forwarding, then continues through the normal pipeline, including credential injection. Later identical requests are evaluated as new. This is the same single-use primitive feat(policy): native expiration / single-use bounds for approved policy grants (expires_at, max_uses) #2881 asks for, bound to one request.
- Denied or expired: distinct errors,
approval_deniedandapproval_expired, so the agent knows not to retry.
Logs. The pending result, the decision, and the release appear in logs and OCSF events with thepending_idand the request digest. The digest definition can be shared with the signed action records in feat(supervisor): signed, chained action records for middleware-evaluated requests, with a middleware evidence reference #4201, so a reviewer can check that what was approved is what ran.
Possible later step: a short hold. If maintainers want the agent to see only a slow request when the person answers within seconds, a hold would need OpenShell to re-dial the upstream after the decision, to cap held requests per sandbox well below the 96 relay streams, and to count held request bodies. I suggest leaving it out of the first version.
Open questions:
- Status code for the pending response: 403 like Policy Advisor denials, or 503? The capacity 503 has no
Retry-Afteron purpose (feat(middleware): inspect WebSocket text messages #2477), so the pending response needs its ownerrorvalue either way. - How the decision reaches OpenShell: re-evaluation with the
pending_id, or a new middleware call that returns when decided? - Where pending state lives, and must it survive a supervisor restart? (feat(policy): native expiration / single-use bounds for approved policy grants (expires_at, max_uses) #2881's triage asks the same.)
- Which headers, if any, belong in the request match?
- Should
pendingalso be added toEvaluateHttpRequest, or only to the session hooks?
Acceptance Criteria
- Middleware that opts in can return a pending result. Middleware that doesn't opt in behaves exactly as today.
- The agent gets an immediate, documented pending response with
pending_id, expiry, and OpenShell-written guidance. It contains no free text from the middleware and is distinct from the capacity 503. - A wait call on
policy.localreturns the decision when it is made, orpendingwhen its timeout ends. - After approval, exactly one matching request is forwarded. A request that differs in method, host, port, path, query, or body is not matched.
- Denied and expired outcomes return distinct, documented errors.
- 100 pending requests in one sandbox don't block its other traffic, including DNS.
- Pending, decision, and release are logged with the
pending_idand the request digest.
Alternatives Considered
- Supervisor middleware as it is today. This is what I built. Deny and retry works with Claude Code, but it depends on the model reading a private code, spends tokens while waiting, and keeps single use and expiry outside OpenShell. Holding inside the evaluation fails on the timeout, the idle upstream connection, and the per-sandbox limits.
- Policy Advisor. It already has the right waiting pattern. But it approves rules, not one request with its real values. The agent writes the proposal, middleware cannot start it, and a rule can't bind a request body.
- Bounded grants (feat(policy): native expiration / single-use bounds for approved policy grants (expires_at, max_uses) #2881). Complementary. Expiring and single-use rules help, but a rule still matches more than the one request a person looked at. This proposal is the middleware-started, request-bound path to the same single-use primitive.
- Gateway interceptors. They act on gateway API calls, not sandbox egress.
- Credential drivers and token grants. They store or fetch credentials. They don't see the method, path, or body, so they can't decide on one request.
- Agent-side prompts (agent hooks, ACP
session/request_permission, MCP elicitation). They decide per action, but they run inside the agent, which the sandbox treats as untrusted.
Activity
OrenDor commentedon Oct 11, 2026
Updated the proposal after reading the v0.1.3 code:
OrenDor commentedon Oct 13, 2026
Correction after a load test on v0.1.3: the stall in the issue text is a per-sandbox connection limit, not a middleware effect. At 96 open connections, new connections from the same sandbox to any host wait about 7.5 s, also with no middleware. DNS did not stall in these runs. The middleware-specific failure is now a separate bug: #4462. I suggest removing "including DNS" from the last acceptance criterion.
Update the flow image, the quickstart key step, and the known limits
dleighsystem commentedon Oct 14, 2026
Thanks for the ping. We hit the same gap, so rather than argue from theory we built an alternative on the 0.1.2 middleware contract and ran it end to end through a real 0.1.2 gateway (Docker driver) today.
Where I'd land differently: keep OpenShell stateless and make the binding a token, not a fingerprint.
What the live run showed, which matters for #4406 either way:
idchanges per attempt and clients vary key order. We hash upstream, tool and canonical arguments; the released retry in our run had a differentid. If OpenShell matches, let the middleware supply the digest.pending_id, or let the token do the binding and store nothing.The one OpenShell change this still needs is small: a documented pending response carrying the middleware-supplied
pending_idand wait URL, so the agent is not pre-briefed on a reason-code convention. That, plus theevidence_reffrom #4201 on the release record, closes the loop both ways.Agree on keeping the hold out of v1 and dropping "including DNS".
OrenDor commentedon Oct 15, 2026
Thanks @dleighsystem , this is very useful.
Two shapes are now on the table, and either would close the gap:
B is the smallest first step, and A could build on it later. Our middleware works with either, so I'd leave the choice to the maintainers.
Agreed on three points either way: the middleware supplies the request digest, no headers in the match, and the wait channel must be retry-safe, given the tunnel closes you saw on policy generation changes.
On binding, I'd let the middleware choose. A fingerprint needs nothing from the agent beyond an identical retry. A token binds tighter but needs the agent to echo it. If the documented response can carry an optional token for the agent to echo, both styles fit.
From our side: we moved retry state to durable storage after a restart produced a duplicate approval, and we match without headers. Binding the policy revision and recording the post-release outcome are both on our list.
Our implementation: https://github.com/oked-ai/oked-openshell
Maintainers: which shape would you prefer? I'm happy to contribute the change.