Wednesday, November 4, 2026 Edition No. 228
Search
Review · Infrastructure · 34 min read

OpenShell Policy and Egress: Hands-On on Amazon EKS

By Fabio Douek

Jump to section
Explain (TLDR) like I am...
What is this?

The robot in the sandbox can only talk to the outside through a grown-up, and the grown-up has a list of who it may call and which tool it has to use. The robot never gets the real keys either, just pretend keys that the grown-up swaps for real ones at the door.

The list works well, but you have to write it carefully. If you only say "please just read", the grown-up lets writing through and only whispers about it, and one strange spelling can make it open the wrong door.

This post tests how OpenShell controls an agent's network access and credentials on Amazon EKS, using real GitHub and Anthropic credentials that the agent receives only as placeholders. The controls largely work as documented, including blocking of cloud metadata endpoints whatever the policy says.

For due diligence: HTTP rules default to audit, which lets a violating request through and records it as allowed; an open bug can rewrite a request path silently; a rotated credential keeps working for processes that were already running; and credential use itself is not logged.

I examined OpenShell's egress controls one layer at a time: name resolution, connection checks per program, HTTP method and path rules, TLS interception, and credential substitution. Most responses were healthy and specific, with a clear 403 or a refused connection.

Findings to note: the audit setting is the default and lets violations through with only a quiet note in the log, a path encoding bug alters requests without a trace, and rotating a credential does not reach processes already running. A tampered program is caught and blocked.

There is real comfort in a design where the agent's environment holds no secret: it can only ask, and the supervisor decides. Watching a push to GitHub and a model call succeed with placeholders made that promise feel concrete rather than theoretical.

The friction is in the defaults. A rule that only audits looks like it protects you, and the log stays calm while it lets writes through. Naming that gap early, as a team, is easier than discovering it after an incident.

Think of the supervisor as a sound engineer who decides which instrument may play on which channel, and patches in the real signal only at the desk. The agent plays with dummy cables; the audience hears the real thing, and only on the channels it was routed to.

The soundcheck found a few off notes. The default setting only listens and never mutes, an odd character in a path changes the song that gets played, and swapping a cable mid-show doesn't reach players who were already on stage.

The value story is simple: agents get the access they need with only placeholders in their environment. A GitHub push and a model API call ran on placeholders, credentials refused to travel to the wrong host, and cloud metadata endpoints stay blocked whatever the policy says.

The honest positioning: write policies with enforcement switched on, name the exact programs allowed, and check them with the prover in CI. With those habits, this is a strong control plane for agent egress on Kubernetes.

GitHub See the source code here: openshell-egress

Overview

OpenShell, a CNCF Sandbox project since 2026-09-26, puts every agent in a sandbox that can’t open a network connection on its own. Part 1 explained the design, part 2 installed OpenShell 0.1.6 on Amazon EKS 1.37, and part 3 showed that a sandbox with the default policy reaches nothing at all. This post, part 4 of 6, opens the network one rule at a time.

An agent that can’t reach anything isn’t much use. It needs a package index, a Git host, a model API, and the credentials for some of them. OpenShell’s answer has two halves. A policy says which binary may reach which host, port and, for HTTP, which method and path. A provider holds a credential, and the agent gets a placeholder instead of the value; the supervisor swaps in the real value only on the way to an endpoint the credential is bound to.

I tested both halves on EKS with ten scripts: deny by default, rules per binary, L7 REST rules, live updates, TLS termination, credentials (GitHub and Anthropic, real ones), ways out of the sandbox, rotation, the policy prover, and the logs. Most of it works as documented. Some of it doesn’t, and a few defaults deserve attention before you write your first policy. Part 5 does the same for AWS credentials and SigV4, and part 6 stress-tests the cluster.

Architecture

WORKLOAD POD agent process curl, git, gh, python3 … GITHUB_TOKEN= openshell:resolve:env:… OpenShell runtime (PID 1) traps connect() and DNS, names the calling binary, decides nothing → the Sandbox Protocol: the agent's only way out; the NetworkPolicy allows no egress SUPERVISOR POD 1policy DNSa synthetic 198.18.0.0/15 address 2L4: binary, host, portplus the binary's pinned SHA-256 3TLS terminatedleaf cert from the sandbox's own CA 4L7: method and pathREST rules; enforce or audit 5placeholder → real valueonly if bound to this endpoint 6upstream connectionpinned IP, real TLS EACCESon connect() 403 JSONpolicy_denied 403 JSONcredential_endpoint_mismatch allow and deny decisions: OCSF events in this Pod's log DESTINATIONS api.github.com api.anthropic.com pypi.org echo.echo.svc (in-cluster echo server)
One outbound request on the Kubernetes driver. The workload never opens a network connection itself; the supervisor makes every decision and every connection.

The agent’s only way out is its own supervisor. The NetworkPolicy gives the workload Pod no egress rule: the supervisor dials in, and the Sandbox Protocol runs over that connection. Part 2 tested that fence and found gaps (a fenced Pod can still reach its own node’s IP), so the first layer is the runtime. Inside the Pod, OpenShell’s runtime runs as PID 1 and traps every connect() and DNS lookup the agent makes. It names the binary that made the call and passes it to the supervisor over the Sandbox Protocol. It decides nothing itself.

The supervisor makes every decision, in order:

  1. Policy DNS. A name gets a synthetic address from 198.18.0.0/15. The real address stays in the supervisor, which later connects to the address it resolved and pinned itself.
  2. L4. Binary, host and port must match a rule. The binary is the kernel’s view of the executable (/proc/<pid>/exe), and its SHA-256 is recorded the first time it connects. A denial here reaches the agent as EACCES on connect().
  3. TLS. Unless an endpoint says tls: skip, the supervisor terminates TLS with a CA created for the sandbox, so it can read the request.
  4. L7. For endpoints with protocol: rest, the method and path must match. A denial is an HTTP 403 with a JSON body.
  5. Credentials. Placeholders in headers, Basic auth, query strings and paths are replaced with real values, but only for a provider bound to this endpoint.
  6. Upstream. The supervisor opens the real connection, with real TLS.

Policies have a static half and a live half. The filesystem, Landlock and process sections are fixed when the sandbox starts. The network_policies section can change while it runs, with openshell policy set (a whole policy) or openshell policy update (one endpoint or rule at a time). Providers add their own rules, named _provider_<name>, from a provider profile: a YAML file with the credential’s environment variables, the endpoints it’s bound to and the binaries allowed to use them. The gateway ships no profiles; you import them.

Setup

Everything runs on the cluster from part 2: two m7i.2xlarge nodes, OpenShell 0.1.6, sandboxes on runc, and the CLI connected through a kubectl port-forward.

Step 1: An echo server

The tests need a destination that shows what actually arrives. 00_echo.sh deploys a 40-line Python server into namespace echo that answers every request with its method, path and headers as JSON, and streams a line a second on /slow. It runs the series’ workload image from ECR, as a non-root user, under Pod Security restricted:

./00_echo.sh
# echo server ready: http://echo.echo.svc.cluster.local/

Step 2: Policies and profiles

The tests’ policies are full policies in policies/ (tests 1, 7 and 10 share one, and Test 6 fills in a template), plus the prover’s in prover/. They all start the same way, because a running sandbox refuses a policy that drops a filesystem path or changes Landlock. My first GitHub policy left out /bin, and policy set answered filesystem read_only path '/bin' cannot be removed on a live sandbox:

version: 1
filesystem_policy:
  include_workdir: true
  read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /etc, /var/log]
  read_write: [/tmp, /dev/null]
landlock:
  compatibility: best_effort
network_policies:
  echo:
    name: echo-read-only
    endpoints:
      - { host: echo.echo.svc.cluster.local, port: 80, protocol: rest, access: read-only, enforcement: enforce }
    binaries:
      - { path: /usr/bin/curl }

profiles/ holds three provider profiles: copies of OpenShell’s GitHub and Anthropic examples with binaries changed to the paths in my image, and echo-demo, which binds a dummy token to the echo server. The example files warn you to do exactly that: “binaries is the least-privilege control … so it has to name the paths in your image.”

Testing

Test 1: Deny by default, and what a denial looks like

01_deny_and_dns.sh starts a sandbox whose policy names one endpoint: the echo server, read-only, for curl. Then it looks at DNS, a host the policy doesn’t name, and a method it doesn’t allow.

$ openshell sandbox create --name deny --policy policies/echo-read-only.yaml

$ DNS inside the sandbox
  echo.echo.svc.cluster.local    198.18.0.2
  pypi.org                       198.18.0.3

$ a host the policy doesn't name: the connection is refused (EACCES)
  curl:    curl: (7) Failed to connect to pypi.org port 443 after 1 ms: Couldn't connect to server
  python3: PermissionError: [Errno 13] Permission denied

$ an allowed host, a method the policy doesn't allow: HTTP 403 with a JSON body
  HTTP/1.1 403 Forbidden
  X-OpenShell-Policy: echo
  {"error":"policy_denied","layer":"l7","rule":"POST /items","detail":"POST /items not permitted by policy"}
  GET  /items -> 200

$ openshell logs deny
  NET:REFUSE [MED] DENIED pypi.org [reason:policy_dns_ineligible]
  NET:OPEN [MED] DENIED /usr/bin/python3.12(0) -> pypi.org:443 [reason:transparent_tcp_policy_denied]
  NET:OPEN [INFO] ALLOWED /usr/bin/curl(0) -> echo.echo.svc.cluster.local:80 [policy:echo engine:opa]
  HTTP:POST [MED] DENIED POST http://echo.echo.svc.cluster.local/items [policy:echo engine:l7] [reason:L7_REQUEST deny POST echo.echo.svc.cluster.local:
  NET:OPEN [INFO] ALLOWED /usr/bin/curl(0) -> echo.echo.svc.cluster.local:80 [policy:echo engine:opa]
  HTTP:GET [INFO] ALLOWED GET http://echo.echo.svc.cluster.local/items [policy:echo engine:l7]

$ openshell logs deny --level warn
  OCSF events: 0
  WARN lines:  2 (the network proxy's own log, L4 denials only)
  [openshell_supervisor_network::proxy] Denied staged transparent connection destination=198.18.0.3:443 reason=endpoint pypi.org:443 is not allowed by a
  [openshell_supervisor_network::proxy] Denied staged transparent connection destination=198.18.0.3:443 reason=endpoint pypi.org:443 is not allowed by a

Three things here.

DNS answers for names outside the policy. pypi.org isn’t in the policy, but it still gets a synthetic address from 198.18.0.0/15 (a few names, such as localhost and metadata hostnames, are refused outright). OpenShell stages names it hasn’t approved so that the connection that follows can be denied with the binary’s name in the log, and so the policy advisor can draft a rule from it. The log’s NET:REFUSE line (policy_dns_ineligible) is the DNS-time record that the name isn’t approved; the lookup is answered anyway. DNS isn’t a way to tell what’s allowed.

Two denial shapes. A host or binary the policy doesn’t allow fails at connect() with EACCES: curl reports “Couldn’t connect to server”, Python raises PermissionError: [Errno 13]. An allowed host with a request the L7 rules refuse gets an HTTP 403 with an X-OpenShell-Policy header and a JSON body naming the policy, the rule and the layer. An agent can act on the second one; the first looks like a network error.

--level warn hides every OCSF event. OpenShell logs its decisions as OCSF (Open Cybersecurity Schema Framework) events at INFO level, so a warn filter drops all of them, denials included. What’s left are the network proxy’s own warnings, which happen to cover L4 denials but not L7 ones. The GitHub tutorial in the docs says as much (“do not filter them out with --level warn”); it’s still an easy filter to reach for.

Test 2: Rules per binary, and pinning

A rule names binaries as well as hosts. 02_binaries.sh allows pypi.org for /usr/bin/curl (and, for the pinning check, /sandbox/bin/fetch), and tries three binaries:

$ openshell sandbox create --name bins --policy policies/curl-pypi.yaml

$ the same host from three binaries
  /usr/bin/curl                  200
  python3                        urllib.error.URLError: <urlopen error [Errno 13] Permission denied>
  /sandbox/curl-copy (a copy)    000
  NET:OPEN [MED] DENIED /usr/bin/python3.12(0) -> pypi.org:443 [reason:transparent_tcp_policy_denied]
  NET:OPEN [MED] DENIED /sandbox/curl-copy(0) -> pypi.org:443 [reason:transparent_tcp_policy_denied]
  why python3 is denied:
    reason=binary '/usr/bin/python3.12' not allowed in policy 'pypi' (ancestors: [], cmdline: [])

$ binary pinning: /sandbox/bin/fetch is allowed by path
  fetch, first use               200
  fetch, second use              200
  sha256 1ccbd3dae948  /usr/bin/curl
  sha256 95b24cd3df3a  /sandbox/bin/fetch
  fetch, one byte changed        000
  WARN: binary integrity violation: /sandbox/bin/fetch executable changed
  OCSF: NET:OPEN [MED] DENIED -(0) -> pypi.org:443 [reason:transparent_tcp_identity_unavailable]

python3 is denied, and the warning shows why it would be denied even if I’d allowed /usr/bin/python3: the rule must name the real executable, /usr/bin/python3.12, because OpenShell matches the kernel’s /proc/<pid>/exe, not a symlink. A copy of curl under another path is a different binary.

The second half is binary pinning. The policy also allows /sandbox/bin/fetch, a copy of curl. Its first connection records the file’s SHA-256; after I appended one byte (the two sha256 lines are taken after that change, hence the different hashes), the same path was refused with “binary integrity violation … executable changed”. That’s what stops an agent from replacing an allowed binary with its own.

The denial is in the logs twice: a WARN line from the proxy that says “binary integrity violation”, and an OCSF event. The OCSF one-liner names the binary as - with the reason transparent_tcp_identity_unavailable, because the identity check failed before a name was known; the JSON event’s message has the integrity text. If you alert on tampering, match that reason, not the binary’s path. My first version of the script grepped for the path, found nothing, and briefly convinced me there was no event at all.

Test 3: L7 rules: audit, enforce, and a bug

For HTTP, a rule can go further than host and port. protocol: rest turns on request inspection, access picks a preset (read-only is GET, HEAD and OPTIONS) or rules lists methods and paths, and enforcement says whether a violation is blocked. 03_l7_rules.sh runs the same requests through three versions of the echo server’s rule:

$ openshell sandbox create --name l7 --policy policies/echo-audit.yaml   (read-only, no enforcement field)
  GET    /items                         200  echo got: GET /items
  POST   /items                         200  echo got: POST /items
  HTTP:GET [INFO] ALLOWED GET http://echo.echo.svc.cluster.local/items [policy:echo engine:l7]
  HTTP:POST [INFO] ALLOWED POST http://echo.echo.svc.cluster.local/items [policy:echo engine:l7]

$ openshell policy set l7 --policy policies/echo-enforce.yaml   (enforcement: enforce)
  GET    /items                         200  echo got: GET /items
  POST   /items                         403  policy_denied (l7)

$ openshell policy set l7 --policy policies/echo-rules.yaml   (GET /public/**, DELETE /objects/*)
  GET    /public/report                 200  echo got: GET /public/report
  GET    /private/report                403  policy_denied (l7)
  GET    /public/../private/report      403  policy_denied (l7)
  GET    /public/%2e%2e/private/report  403  policy_denied (l7)
  DELETE /objects/backup                200  echo got: DELETE /objects/backup
  DELETE /objects/backup%3Bold          200  echo got: DELETE /objects/backup
  HTTP:DELETE [INFO] ALLOWED DELETE http://echo.echo.svc.cluster.local/objects/backup [policy:echo engine:l7]

Audit is the default. With access: read-only and no enforcement field, the POST went through, and the one-line log recorded it as ALLOWED at INFO. The OCSF JSON event (Test 10) does say L7_REQUEST audit POST ... not permitted by policy in its message, but its action, disposition and severity are Allowed, Allowed and Informational, so anything that filters on those sees an ordinary request. If you write access: read-only and expect read-only, add enforcement: enforce. The prover (Test 9) refuses to vouch for audit-mode rules, which is a hint.

Paths are normalized before matching. /public/../private/report and its percent-encoded form were both matched as /private/report and denied. Good.

An encoded semicolon changes the request. DELETE /objects/backup%3Bold reached the echo server as DELETE /objects/backup: a different object. The log shows the rewritten path as allowed, and nothing says it changed. This is #3902, open at 0.1.6: the path canonicalizer decodes %3B to ; and then strips it as a path parameter. Until it’s fixed, an agent can be steered into acting on a resource it didn’t name. The issue mentions turning off parameter stripping as a workaround, but in 0.1.6 that’s an internal option, not a policy field: an endpoint with strip_path_parameters is rejected as an unknown field.

Test 4: Live updates

The network half of a policy changes while the sandbox runs. How fast? By default, supervisors poll the gateway for configuration every 10 seconds. A gateway setting, config_delivery_mode = "push", sends it over the supervisor’s existing session instead. 04_live_updates.sh times both: it removes pypi.org from the policy, adds it back without --wait, and retries curl until it gets through.

$ openshell sandbox create --name live --policy policies/echo-l4.yaml   (delivery: poll, the default)

$ openshell policy update live --add-endpoint pypi.org:443 --binary /usr/bin/curl   (no --wait)
  run 1: allowed 7.9 s after the update call
  run 2: allowed 12.5 s after the update call
  run 3: allowed 7.9 s after the update call

$ a 30-second download from the echo server, and an unrelated policy update 8 s in
  lines received: 9 of 30
  curl: (18) transfer closed with outstanding read data remaining
  exit 18

$ helm upgrade openshell --reuse-values --set gatewayConfig.openshell\.gateway.config_delivery_mode=push
  gateway back after 24.0 s

$ openshell policy update live --add-endpoint pypi.org:443 --binary /usr/bin/curl   (delivery: push)
  run 1: allowed 1.3 s after the update call
  run 2: allowed 1.3 s after the update call
  run 3: allowed 1.3 s after the update call

$ helm upgrade openshell --reuse-values --set gatewayConfig.openshell\.gateway.config_delivery_mode=poll
  gateway.toml now: config_delivery_mode = "poll"

With polling, the new rule took 7.9 to 12.5 seconds to work; with push, 1.3 seconds every time. These times include a sandbox exec round trip per retry; part 6 measures delivery alone. Push is off by default, so plan on about 10 seconds before a rule you add for a waiting agent takes effect.

Switching takes a Helm change, and the chart has two traps. It refuses gatewayConfig.openshell.gateway.config_delivery_mode as a nested map (“defines “openshell.gateway” both as a nested map and a dotted top-level table”); the key has to be the dotted table name, which --set writes as gatewayConfig.openshell\.gateway.config_delivery_mode. And reverting with =null under --reuse-values left push in the rendered gateway.toml in my runs, so the script sets poll explicitly and checks the result.

The middle of the output matters more. A 30-second streaming download from the echo server stopped after 9 lines when I added an unrelated rule: curl exit 18, “transfer closed with outstanding read data remaining”. That’s documented: every new policy revision closes connections opened under the previous one, including long-lived ones. It’s the safe choice, since a connection can’t outlive the rule that allowed it, but it means a policy change interrupts every stream in the sandbox, not only the ones it affects. Agents with long-running model streams or WebSockets will see errors whenever the policy changes, and an open issue reports that a routine provider token refresh does the same (#4945).

Test 5: TLS termination

To read requests and swap credentials, the supervisor has to see inside TLS. It creates a CA for each sandbox, issues a certificate per host on the fly, and points the usual environment variables at the CA bundle. 05_tls.sh checks which tools trust it:

$ openshell sandbox create --name tls --policy policies/tls.yaml

$ the certificate curl sees for pypi.org
  subject: CN=pypi.org
  issuer: CN=OpenShell Sandbox CA; O=OpenShell

$ the sandbox CA, and the variables that point at it
  subject=CN = OpenShell Sandbox CA, O = OpenShell
  notAfter=Jan  1 00:00:00 4096 GMT
  CURL_CA_BUNDLE=/run/openshell-supervisor-ca/material/ca-bundle.crt
  DENO_CERT=/run/openshell-supervisor-ca/material/ca.crt
  GIT_SSL_CAINFO=/run/openshell-supervisor-ca/material/ca-bundle.crt
  NODE_EXTRA_CA_CERTS=/run/openshell-supervisor-ca/material/ca.crt
  REQUESTS_CA_BUNDLE=/run/openshell-supervisor-ca/material/ca-bundle.crt
  SSL_CERT_FILE=/run/openshell-supervisor-ca/material/ca-bundle.crt
  AWS_CA_BUNDLE: not set

$ each tool, as is and without the variable it reads
                                        as is  without the variable
  curl (CURL_CA_BUNDLE, SSL_CERT_FILE)  ok     SSL certificate problem: self-signed certificate in certificate chain
  python3 (SSL_CERT_FILE)               ok     CERTIFICATE_VERIFY_FAILED
  git (GIT_SSL_CAINFO)                  ok     server certificate verification failed
  AWS CLI 2.39 (REQUESTS_CA_BUNDLE)     ok     CERTIFICATE_VERIFY_FAILED

The certificate curl sees for pypi.org is issued by “OpenShell Sandbox CA”. Every tool I tried trusts it out of the box, through one of the six variables OpenShell sets, and fails without its variable:

  • curl reads CURL_CA_BUNDLE or SSL_CERT_FILE,
  • Python’s ssl module reads SSL_CERT_FILE,
  • git reads GIT_SSL_CAINFO,
  • AWS CLI 2.39 reads REQUESTS_CA_BUNDLE for aws s3 ls and other ordinary calls.

The research for this series said the AWS CLI would need AWS_CA_BUNDLE, which OpenShell doesn’t set. For aws s3 ls it doesn’t: REQUESTS_CA_BUNDLE is enough. But aws s3 cp can pick the CLI’s CRT transfer client, which reads only AWS_CA_BUNDLE, and then fails TLS without it. Part 5 has the details; the short version is to set AWS_CA_BUNDLE=/run/openshell-supervisor-ca/material/ca-bundle.crt in any sandbox that runs the AWS CLI. Use the literal path: $REQUESTS_CA_BUNDLE wouldn’t expand in an image’s ENV or in sandbox create --env.

Two details: the CA certificate is valid until the year 4096, and the private key never enters the workload; the supervisor holds it. Tools that bring their own CA list and ignore these variables (some Go binaries, Java with its own trust store) need configuring, or an endpoint with tls: skip, which also turns off L7 rules and credential substitution for that endpoint.

Test 6: Credentials as placeholders

This is the feature that sets OpenShell apart from a plain egress proxy. 06_credentials.sh creates three providers from my environment: a fine-grained GitHub token with access to one private repository, an Anthropic API key, and a dummy token for the echo server. The values go from my shell’s environment to the gateway (--credential GITHUB_TOKEN reads the variable; the value never appears on a command line) and from there to nowhere the agent can see.

$ openshell profile import -f profiles/{github,anthropic,echo-demo}.yaml
  NAME       TYPE      CATEGORY        SOURCE  SCOPE    
  anthropic  provider  INFERENCE       user    workspace
  echo-demo  provider  OTHER           user    workspace
  github     provider  SOURCE CONTROL  user    workspace

$ openshell provider create --type github|anthropic|echo-demo --credential <KEY>   (values read from the environment)
  ✓ Created provider e-github
  ✓ Created provider e-anthropic
  ✓ Created provider e-echo

$ openshell sandbox create --name creds --provider e-github --provider e-anthropic --provider e-echo --policy github-push.yaml

$ what the sandbox holds
  GITHUB_TOKEN       openshell:resolve:env:v4243678679748032648_GITHUB_TOKEN
  ANTHROPIC_API_KEY  openshell:resolve:env:v4243678679748032648_ANTHROPIC_API_KEY
  ECHO_TOKEN         openshell:resolve:env:v4243678679748032648_ECHO_TOKEN

$ what the echo server receives (a dummy token)
  sent: Bearer openshell:resolve:env:v42436786797480326...
  got:  Bearer demo-token-1

$ GitHub: gh and git are in the provider profile
  gh api user:          User
  git ls-remote:        47 refs
  curl api.github.com:  000
  python3 (own rule):   200

$ git push to a new branch, then delete it   (allowed for this one repository by the sandbox policy)
  branch on GitHub after push:   1
  branch on GitHub after delete: 0

$ a placeholder sent where it isn't bound, and a spoofed Host header
  GitHub placeholder to the echo server: {"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"}
  Host: api.github.com to the echo server: {"error":"request_authority_mismatch","message":"HTTP request authority does not match the authorized tunnel endpoint"}

$ Anthropic: a Messages API call with the placeholder key
  model:  claude-haiku-5-5
  reply:  hello from an OpenShell sandbox
  tokens: 29 in, 15 out

$ openshell logs creds   (credential decisions)
  FINDING:BLOCKED [HIGH] "Provider credential used at an unauthorized endpoint" [type:openshell.provider_credential.endpoint_mismatch]
  HTTP:GET [HIGH] DENIED GET  [policy:_provider_e_echo engine:request-authority] [reason:request_authority_mismatch]
  FINDING:BLOCKED [HIGH] "HTTP request authority does not match the authorized tunnel endpoint" [type:openshell.http.request_authority_mismatc
  HTTP:POST [INFO] ALLOWED POST https://api.anthropic.com/v1/messages [policy:_provider_e_anthropic engine:l7]

Taking it in order:

The sandbox holds placeholders. Each variable is openshell:resolve:env:v<revision>_<NAME>. That’s what a compromised agent would find in its environment, in /proc, or in anything it writes down.

The destination gets the real value. The echo server, bound to the dummy token, received Bearer demo-token-1. The supervisor swapped it in on the way out. That cuts both ways: the echo server sent the value back in its response, and the sandbox read it. Any allowed endpoint that echoes or stores what it receives can hand a real credential back to the agent; part 5 covers a known weakness of this kind for AWS credentials.

GitHub works through the profile’s binaries. gh api user and git ls-remote used the GitHub token without knowing it. curl with the same placeholder got nowhere: the GitHub profile allows gh and git, not curl. Git sends the token as HTTP Basic auth, which the supervisor decodes, resolves and re-encodes.

Binding is per endpoint, not per binary. I gave python3 its own rule for api.github.com in the sandbox policy, and Python used the GitHub placeholder successfully. The provider never named Python. OpenShell’s docs say this plainly: static placeholder resolution is endpoint-scoped, and binary-scoped credential injection is on the roadmap. Until then, any binary that any rule lets reach a bound endpoint can use the credential.

Writes need a policy, not a different token. The profile allows clone and fetch only. A second rule in the sandbox policy allows git-receive-pack for one repository, and the push to a throwaway branch went through; the script deleted the branch again. The same token would still be refused a push to any other repository.

Placeholders don’t travel. The GitHub placeholder sent to the echo server got a 403 credential_endpoint_mismatch, so an agent can’t leak a credential by sending its placeholder to a server it controls. A request to the echo server with Host: api.github.com got a 403 request_authority_mismatch, from a separate check: the Host header must match the connection’s real destination, so a spoofed one can’t pull in another host’s credential either.

Anthropic. A Messages API call to claude-haiku-5-5 with the placeholder key returned the reply I asked for.

Test 7: Ways out

On EKS, the interesting destinations are close by: the instance metadata service, the EKS Pod Identity agent, the kubelet, the Kubernetes API, the node itself. 07_getting_out.sh tries each, and adds a policy rule where one would help an attacker. The table after the output sums it up.

Output: 07_getting_out.sh
$ openshell sandbox create --name out --policy policies/echo-read-only.yaml

$ policy update --add-endpoint for link-local addresses
  169.254.169.254:80 (instance metadata)       refused: proposed rule endpoint host '169.254.169.254' is an always-blocked address
  169.254.170.23:80 (Pod Identity agent)       refused: proposed rule endpoint host '169.254.170.23' is an always-blocked address
  allowed-ip=169.254.0.0/16                    refused: proposed rule contains always-blocked `allowed_ips` entry '169.254.0.0/16'

$ the kubelet on the node, allowed by policy
  add <node-ip>:10250                          Policy version 2 loaded
  curl https://<node-ip>:10250/pods            000
  reason=port 10250 is a blocked control-plane port

$ direct IPs and other ports
  curl https://1.1.1.1/ (not in policy)        000
  add 1.1.1.1:443                              Policy version 3 loaded
  curl https://1.1.1.1/                        301
  python3 -> <node-ip>:22                      PermissionError: [Errno 13] Permission denied

$ UDP
  sendto 8.8.8.8:53                            OSError: [Errno 89] Destination address required
  sendto <node-ip>:40001                       OSError: [Errno 89] Destination address required

$ the Kubernetes API, allowed by policy
  add kubernetes.default.svc.cluster.local:443 Policy version 4 loaded
  curl /version (TLS terminated)               000
  NET:FAIL [LOW] kubernetes.default.svc.cluster.local:443
  with tls: skip, curl /version                401
  curl /api/v1/namespaces/openshell/secrets    401
DestinationPolicy allows it?ResultWhat stops it
Instance metadata 169.254.169.254policy update refuses itunreachableOpenShell: link-local is always blocked
Pod Identity agent 169.254.170.23policy update refuses itunreachablesame
allowed_ips: 169.254.0.0/16policy update refuses itunreachablesame
Kubelet, node IP port 10250yesrefusedOpenShell: ports 2379, 2380, 6443, 10250 and 10255 are blocked for exact-host, IP and allowed_ips endpoints
1.1.1.1:443no, then yesrefused, then 301the policy
Node IP port 22noEACCESthe policy
UDP to 8.8.8.8:53 or the nodeno rule type for itDestination address requiredthe runtime: only DNS lookups go out over UDP, through policy DNS
Kubernetes API, TLS terminatedyesconnection failedthe upstream TLS check (the API uses the cluster’s private CA)
Kubernetes API, tls: skipyesHTTP 401nothing in OpenShell; the sandbox has no token

The first rows are reassuring: the addresses an agent on EKS would most like to reach, instance metadata and the Pod Identity agent, are refused by policy update. In a separate check, a full policy naming 169.254.169.254 did load with sandbox create, but the connection was still refused (“resolves to always-blocked address”), so a policy can’t open them either way.

The last row is worth knowing. EKS serves the Kubernetes API inside the cluster on port 443 (kubernetes.default.svc), and 443 isn’t on OpenShell’s blocked list, so a policy can open it. With TLS terminated the connection failed, because the supervisor doesn’t trust the cluster’s CA. With tls: skip it went straight through and the API server answered 401, since the sandbox has no ServiceAccount token. One tls: skip rule for the API’s name plus a token from somewhere else would be enough. (A wildcard host wouldn’t do it: wildcards can’t reach private addresses without allowed_ips.) The prover (Test 9) catches such a rule if your boundary doesn’t allow it.

Test 8: Rotation and revocation

08_rotation.sh uses the dummy echo-server provider, so the echo server can show which value arrives. A background process starts before the rotation and calls the echo server 25 seconds later:

$ openshell provider create --name e-rot --type echo-demo --credential ECHO_TOKEN   (ECHO_TOKEN=demo-token-1)
  placeholder: openshell:resolve:env:v353228049909348659_ECHO_TOKEN
  echo got:    Bearer demo-token-1

$ openshell provider update e-rot --credential ECHO_TOKEN --wait   (ECHO_TOKEN=demo-token-2)
  applied after 8.0 s
  new process, placeholder: openshell:resolve:env:v8076519015166486850_ECHO_TOKEN
  new process, echo got:    Bearer demo-token-2
  old placeholder, echo got: Bearer demo-token-1
  the process started before the rotation, 25 s later: Bearer demo-token-1

$ openshell provider delete e-rot   (still attached)
  Error:   × failed to delete 1 provider: e-rot

$ openshell sandbox provider detach rot e-rot --wait
  applied after 7.6 s
  new process, ECHO_TOKEN:   unset
  old placeholder, echo got: credential_unavailable

$ openshell provider delete e-rot
  ✓ Deleted provider e-rot
  old placeholder, echo got: credential_unavailable

Rotation reaches new processes only. After provider update --wait, a new process got a new placeholder (the revision number changed) that resolves to the new value. The old placeholder kept resolving to the old value, both when I sent it by hand and from the process that started before the rotation. That’s documented (“An existing process keeps its revision-scoped reference; readiness does not make that old reference resolve the new value”), and it’s what you’d want for a planned rotation. For a leaked credential it means long-running processes keep sending the old one until they restart, so revoke it at the source too.

Revocation is detach. A provider can’t be deleted while attached. After sandbox provider detach --wait, new processes have no variable at all, and the old placeholder gets credential_unavailable. That’s the switch to use when a sandbox misbehaves. One caveat from part 5: when the sandbox policy itself binds endpoints to the provider (credential_binding, as the AWS setup does), the detach is refused until a policy without those endpoints is in place.

Test 9: The prover in CI

openshell-prover answers one question: does this candidate policy allow anything the boundary doesn’t? It encodes both policies for the Z3 solver and searches for a request (binary, host, port, IP, method, path) that the candidate allows and the boundary doesn’t. It runs anywhere, with no cluster, which makes it a CI check for policy changes:

openshell-prover 0.1.6

$ openshell-prover check --boundary boundary.yaml candidate-ok.yaml
  result: within_boundary
  exit code: 0

$ openshell-prover check --boundary boundary.yaml candidate-wildcard.yaml
  result: exceeds_boundary
  counterexample: network binary=- ancestor_binary=- binary_identity_required=false host=a.org:443 destination_ip=8.8.8.8 trusted_gateway=false protocol=l4 method=- path=-
  exit code: 1

$ openshell-prover check --boundary boundary.yaml candidate-post.yaml
  result: exceeds_boundary
  counterexample: network binary=- ancestor_binary=- binary_identity_required=false host=echo.echo.svc.cluster.local:80 destination_ip=8.8.8.8 trusted_gateway=false protocol=rest method=POST path=/public/a
  exit code: 1

$ openshell-prover check --boundary boundary.yaml candidate-audit.yaml
  result: unsupported
  reason: candidate policy rule 'echo_read' uses REST without enforced inspection
  exit code: 3

The boundary allows Python to reach PyPI and curl to read from the echo server. The first candidate stays inside it. The second swaps pypi.org for *.org, and the prover found a counterexample (a.org:443). The third allows POST, and the counterexample is a POST to /public/a. The fourth uses a read-only REST rule without enforcement, and the prover returns unsupported: it won’t vouch for a rule that only audits. Each result has its own exit code (0, 1, 3), which is all a CI job needs.

Two limits to keep in mind. The prover checks the policy, not the enforcement: Test 3’s %3B request was allowed by a rule the prover would accept, and rewritten on the way. And the gateway never runs the boundary check itself; policy set and policy update accept anything valid. If you want a boundary, CI is where it lives.

Test 10: Logs

openshell logs shows OCSF events as one-line summaries. For a log pipeline you want the events themselves: the per-sandbox setting ocsf_json_enabled makes the supervisor also write each one as JSON to its log (stderr), where any cluster log collector can pick it up.

$ openshell sandbox create --name logs --policy policies/echo-read-only.yaml

$ openshell settings set logs --key ocsf_json_enabled --value true
  ✓ Set sandbox setting ocsf_json_enabled=true for logs (revision 1)

$ three requests: allowed, denied at L7, denied at L4
  GET   echo server  200
  POST  echo server  403
  GET   pypi.org     000

$ kubectl logs <supervisor pod> | grep OCSF-JSON   (event classes since the sandbox started)
     4 4001 Network Activity
     3 5019 Device Config State Change
     2 4002 HTTP Activity
     2 0 Base Event
     1 4007 SSH Activity

$ the L7 denial as JSON (selected fields)
  {
    "class_name": "HTTP Activity",
    "severity": "Medium",
    "action": "Denied",
    "disposition": "Blocked",
    "method": "POST",
    "url": "http://echo.echo.svc.cluster.local/items",
    "firewall_rule": {
      "name": "echo",
      "type": "l7"
    },
    "message": "L7_REQUEST deny POST echo.echo.svc.cluster.local:80/items reason=POST /items not permitted by policy"
  }

The JSON carries what a SIEM needs: the class (HTTP Activity), the action and disposition, the method and URL, the policy and layer that decided, and a reason. Two gaps from this post’s tests: audit-mode violations are events with action Allowed and severity Informational, flagged only in the message text (Test 3), and nothing records a credential being substituted, only the request it was substituted into (Test 6). OpenShell’s docs list events for credential refresh as future work.

Results Summary

Result
DNSNames outside the policy still get a synthetic 198.18.0.0/15 answer
L4 denialEACCES on connect()
L7 denialHTTP 403, X-OpenShell-Policy header, JSON body with policy, rule and layer
openshell logs --level warn0 OCSF events; only the proxy’s L4 warnings
Binary rulesReal path required (/usr/bin/python3.12); a copy of curl is a different binary
Binary pinningChanged binary refused; OCSF event names it - (transparent_tcp_identity_unavailable)
L7 defaultaudit: a POST to a read-only endpoint is forwarded; the event says Allowed, the message says audit
Path normalization.. and %2e%2e traversal denied
%3B in a path (#3902)DELETE /objects/backup%3Bold arrives as DELETE /objects/backup
Live update, poll (default)7.9 to 12.5 s
Live update, push1.3 s
Open connections on any policy changeClosed (a 30-second stream cut after 9 lines)
TLSPer-sandbox CA; curl, Python, git and aws s3 ls trust it through the variables OpenShell sets; aws s3 cp (CRT) needs AWS_CA_BUNDLE
CredentialsPlaceholders in the sandbox; real values at the destination; GitHub push and an Anthropic call worked
Credential bindingPer endpoint, not per binary
Placeholder to another host, spoofed Host403 credential_endpoint_mismatch, 403 request_authority_mismatch
Instance metadata, Pod Identity agentRefused by policy update; blocked at connect if a full policy names them
Kubelet port 10250Blocked even when allowed
Kubernetes APIReachable with tls: skip (401 without a token)
UDPOnly DNS, through policy DNS
RotationNew processes get the new value; old placeholders keep the old one
RevocationDetach: credential_unavailable for every placeholder
Proverwithin (0), exceeds with a counterexample (1), unsupported for audit rules (3)
OCSF JSONPer sandbox, in the supervisor Pod’s log

What the Policy Engine Doesn’t Solve

  • What the agent does with access it has. A rule that lets git push to one repository lets it push anything to that repository. OpenShell decides where requests go and what shape they have, not whether their content is a good idea. The supervisor middleware hooks in 0.1.x can inspect requests, but the docs describe that API as still evolving.
  • Which binary uses a credential. Binding is per endpoint. Any binary that some rule lets reach api.github.com can use the GitHub placeholder (Test 6).
  • Mistakes in the policy. Audit mode is the default for L7 rules, tls: skip turns off inspection and substitution, and the Kubernetes API is one rule away (Test 7). The prover catches what a boundary forbids, if you run it; the gateway doesn’t.
  • Bugs in the enforcement. The prover reasons about the policy, so it can’t see that %3B rewrites a path (Test 3). #3902 is open at 0.1.6.
  • Revoking a value that’s in flight. Rotation reaches new processes only (Test 8). Revoke leaked credentials at their source, and detach the provider.

Cleanup

./99_cleanup.sh
cd ../openshell-eks
./09_scale.sh down         # zero nodes between sessions

99_cleanup.sh deletes the sandboxes, providers and profiles these scripts create, removes the push-delivery override if a run was interrupted, and deletes the echo server. 06_credentials.sh deletes its GitHub branch itself, also when interrupted. The GitHub and Anthropic credentials stay in the gateway’s encrypted store only until the providers are deleted.

Verdict

OpenShell’s egress model is the most interesting part of it, and on EKS it held up. The agent’s environment really holds no credential: a GitHub push and an Anthropic call ran on placeholders, the placeholders refused to work anywhere else, and the addresses an attacker on EKS would try first, instance metadata and the Pod Identity agent, stay blocked whatever the policy says. L7 denials are specific enough for an agent to act on, and the OCSF events carry what a SIEM needs.

The defaults are where you can get hurt:

  • L7 rules audit unless you say enforce. A read-only rule without it lets writes through and logs them as allowed, with the violation only in the message text. Write enforcement: enforce on every REST endpoint, and let the prover reject the ones that don’t.
  • %3B rewrites paths (#3902). Until it’s fixed, assume a path rule can be steered to a sibling resource.
  • Credentials follow endpoints, not binaries. Keep rules for credentialed hosts to the binaries that need them.
  • Placeholders keep secrets out of the environment, not out of reach. An endpoint that echoes or stores what it receives can return the real value to the agent. Bind credentials only to endpoints you trust with them.
  • Policy changes cut every open connection, and polling makes them take about 10 seconds. Batch policy changes, and turn on push delivery if agents wait on them.
  • Rotation doesn’t reach running processes. Detach is the revocation.
  • Read the logs closely: audit-mode violations are events with action Allowed, a tampered binary’s denial doesn’t name the binary, and credential use isn’t recorded.

My working rules after this: one policy per agent role, generated rather than hand-edited, checked against a boundary with openshell-prover in CI; enforce on every REST endpoint; real binary paths; tls: skip only where a protocol needs it, and never for an in-cluster API. With those, OpenShell gives an agent on Kubernetes network access I’d be comfortable auditing.

Part 5 takes the same idea to AWS: the gateway assumes a role, and the supervisor signs every request with SigV4, so the sandbox gets placeholders for AWS keys too, and part 5 finds where that breaks.

Comments