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

OpenShell and AWS Credentials: SigV4 Signing at the Supervisor

By Fabio Douek

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

The robot in the sandbox wants to use Amazon's toy shop, which needs a special signed note for every visit. The robot only gets pretend notes, and the grown-up at the door signs a real one each time, using a key it borrows from a locked drawer for an hour at a time.

That works for almost everything. But there is one known gap, and a fix is on the way, where a real key can end up back in the robot's hands. So the drawer must only ever hold small, short-lived keys.

This post records how OpenShell lets an agent on Amazon EKS use AWS without holding AWS keys: the platform assumes a narrowly scoped role and signs each request on the agent's behalf. Identity, model calls and audit logging worked as designed, and storage did, apart from some key names and the non-default upload modes.

Material findings: a known weakness, with a fix not yet released, can let the agent recover the real session token; revoking access from a running sandbox requires a policy change first; and attribution in AWS logs is per provider, not per sandbox. Treat the role's permissions as the agent's permissions.

I tested OpenShell's AWS integration end to end: a gateway identity from EKS Pod Identity, a one-hour role session refreshed in advance, and request signing in the supervisor. S3, Bedrock and STS calls worked, and attempts to obtain new credentials were stopped by IAM.

Adverse findings: certain S3 key names break signing, the explicit upload modes reject the CLI defaults, and a known weakness, with a fix pending, can return the real session token to the sandbox. The prognosis depends on keeping the role small and the session short.

It is reassuring to watch an agent run the AWS CLI, upload, stream a model response and prove its identity without ever being handed a key. The design answers a fear many platform teams carry quietly.

It is just as important to sit with what it does not settle. A known weakness can still expose the real token, revocation takes more than one step, and two sandboxes can look identical in the audit trail. Knowing that keeps trust grounded in the role's limits rather than in the sandbox.

Picture a session player who never touches the master tapes: every take goes through the engineer, who signs it off with the studio's stamp. The player sends rough cuts; what reaches the label is properly mastered and credited to the studio's session.

The weak spot is a known one, with a fix on the way: in this release the real stamp can end up where the player can read it. Keep the studio account small and the booking short, and the risk stays a small one.

The pitch: AI agents on Kubernetes can use S3 and Bedrock without a single AWS key in their environment, with short sessions refreshed automatically and calls attributable to a session in CloudTrail. It worked with the stock AWS CLI and boto3.

The honest positioning: pair it with a least-privilege role and explicit denies on credential vending, set the CA variable for uploads, and give each tenant its own provider. One unresolved weakness can expose the session token, so keep sessions short.

GitHub See the source code here: openshell-aws-credentials

Overview

OpenShell, a CNCF Sandbox project since 2026-09-26, keeps credentials out of the agent’s environment: the sandbox gets a placeholder, and the supervisor swaps in the real value on the way to an allowed endpoint. Part 4 tested that with GitHub and Anthropic tokens. AWS is harder, because most AWS APIs don’t take a token in a header. Every request is signed with SigV4, using a secret key the client must hold, and on EKS the natural source of that key is a short-lived role session.

OpenShell 0.1.6 handles both. The gateway can assume an IAM role and refresh the session before it expires, and the supervisor can sign requests itself. So the agent runs the AWS CLI or boto3 as usual, holds three placeholders, and AWS sees a correctly signed request from the role.

This post, part 5 of 6, builds that on the cluster from part 2: the gateway gets an IAM role through EKS Pod Identity, a provider assumes a least-privilege sandbox role, and a policy signs requests to one S3 bucket, Bedrock and STS. Then I test identity, S3, Bedrock, CA trust, CloudTrail, a set of attacks and the credential lifetime. Most of it works. Several details of the signer don’t, and a known weakness in 0.1.6 can expose the real session token. Part 1 explains the gateway, supervisor and workload this relies on, part 3 the sandbox’s lifecycle and isolation, and part 6 stress-tests the cluster.

Architecture

EKS CLUSTER osh-eks · NAMESPACE openshell gateway Pod IAM role osh-eks-gateway via EKS Pod Identity provider p5-aws aws_sts_assume_role, 1 h WORKLOAD POD aws CLI · boto3 · curl AWS_ACCESS_KEY_ID=openshell:… AWS_SECRET_ACCESS_KEY=openshell:… AWS_SESSION_TOKEN=openshell:… signs with placeholders, or doesn't sign at all SUPERVISOR POD policy: signed endpoints one bucket · bedrock-runtime · sts SigV4 re-sign strip the client's signature headers, sign with the real session for the endpoint's service session keys AWS · us-east-1 STS AssumeRole osh-eks-sandbox-s3bedrock S3: one bucket Bedrock: Converse, nova-micro STS: GetCallerIdentity explicit IAM Deny on credential vending AssumeRole, refreshed 5 min before expiry
The AWS credential path. Real keys exist only in the gateway and the supervisor; the workload holds placeholders.

Three pieces:

  1. The gateway’s identity. The gateway Pod gets the IAM role osh-eks-gateway through an EKS Pod Identity association. That role can do one thing: assume roles whose names start with osh-eks-sandbox-. Sandboxes never get an IAM identity of their own: a Pod Identity or IRSA binding on the sandbox ServiceAccount would inject a projected token, and OpenShell’s driver rejects workload Pods that have one.
  2. The provider. A provider of type aws, created with --runtime-credentials, holds no static key. Its refresh strategy, aws_sts_assume_role, makes the gateway call STS AssumeRole into the sandbox role, for an hour, and repeat it 5 minutes before the session expires. The session’s three values become the provider’s credentials.
  3. The signer. In the sandbox policy, each AWS endpoint names the provider (credential_binding) and turns on signing (credential_signing: sigv4, signing_service: s3, bedrock or sts). The supervisor strips the client’s signature headers and signs the request again with the real session, for the endpoint’s service and region.

The sandbox role, osh-eks-sandbox-s3bedrock, is deliberately small: one bucket, Converse and ConverseStream on one Bedrock model, and GetCallerIdentity. It also explicitly denies every credential-vending API I could think of (sts:AssumeRole*, GetSessionToken, GetFederationToken, ecr:GetAuthorizationToken, secretsmanager:GetSecretValue, iam:* and more). The supervisor signs whatever API the sandbox calls on an allowed endpoint, and it doesn’t filter responses, so if the role could vend credentials, the sandbox would receive real ones in the response body. IAM is the only place to stop that.

Setup

Everything runs on the cluster from part 2, with OpenShell 0.1.6 and the CLI connected through a kubectl port-forward. Part 2’s Terraform already installed the EKS Pod Identity agent.

Step 1: IAM and a bucket

terraform/ in the companion directory creates the bucket (osh-eks-creds-<random>, public access blocked, objects expire after 7 days), the two roles and the Pod Identity association. The gateway role’s only permission:

statement {
  actions   = ["sts:AssumeRole", "sts:TagSession"]
  resources = ["arn:${local.partition}:iam::${local.account_id}:role/${local.sandbox_role_prefix}*"]
}

The sandbox role trusts only the gateway role, allows the bucket, bedrock:InvokeModel and InvokeModelWithResponseStream (what Converse and ConverseStream authorize as) on amazon.nova-micro-v1:0, and sts:GetCallerIdentity, and carries the explicit deny above. I checked the result with aws iam simulate-principal-policy before using it: the allowed actions came back allowed, the vending ones explicitDeny.

Step 2: The gateway, the profiles and the provider

The gateway Pod from part 2 started before the Pod Identity association existed, so it has no AWS credentials. 01_setup.sh restarts it, imports the aws and aws-s3 provider profiles (copies of OpenShell’s examples; the gateway ships none), creates the provider and configures its refresh:

openshell provider create --name p5-aws --type aws --runtime-credentials
openshell provider refresh configure p5-aws --credential-key AWS_ACCESS_KEY_ID \
  --strategy aws-sts-assume-role \
  --material role_arn="$SANDBOX_ROLE_ARN" --material session_name=p5-shared --material aws_region=us-east-1
openshell provider refresh rotate p5-aws --credential-key AWS_ACCESS_KEY_ID
$ terraform outputs -> env.sh
  ok    bucket osh-eks-creds-6c00850f
  ok    sandbox role osh-eks-sandbox-s3bedrock, gateway role osh-eks-gateway

$ kubectl rollout restart statefulset/openshell (pick up Pod Identity), then restart the port-forward
  ok    Pod Identity association present (a-cawfi2wgclsjjcvzf)
  ok    gateway restarted, port-forward back up
  ok    gateway Pod has AWS_CONTAINER_CREDENTIALS_FULL_URI (Pod Identity webhook fired)

$ import provider profiles (workspace-scoped)
  ok    imported aws
  ok    imported aws-s3

$ create provider p5-aws (type aws, --runtime-credentials)
  ok    created p5-aws

$ provider refresh configure p5-aws --strategy aws-sts-assume-role
✓ Configured refresh for p5-aws AWS_ACCESS_KEY_ID
  ok    role_arn set to the sandbox role; session_name p5-shared; region us-east-1

$ provider refresh rotate p5-aws (mint the first STS session now)
✓ Rotation requested for p5-aws AWS_ACCESS_KEY_ID (refreshed)

$ provider refresh status p5-aws
PROVIDER                  CREDENTIAL_KEY                STRATEGY                      STATUS                    RECOVERY            EXPIRES_AT            NEXT_REFRESH          LAST_REFRESH          FAILURE_CODE                                  LAST_ERROR
p5-aws                    AWS_ACCESS_KEY_ID             aws_sts_assume_role           refreshed                 -                   2026-11-01 06:08:15   2026-11-01 06:03:15   2026-11-01 05:08:15                                                 -

  ok    setup done. The provider mints the sandbox-role session; the tests attach it with --provider p5-aws
  ok    and pass --policy logs/aws-signing.p5-aws.yaml (policy_file in lib.sh) so the supervisor signs S3, Bedrock and STS.

refreshed, with an expiry an hour out and the next refresh 5 minutes before it. The gateway called STS with its Pod Identity credentials through the AWS SDK’s default credential chain, which the research for this series had inferred from the source but not seen work.

Step 3: The signing policy

The provider uses the endpointless aws profile, so the sandbox policy says where its credentials apply. The first of the bucket’s three endpoints (the other two are the global and path-style S3 hosts), shortened:

s3_bucket:
  endpoints:
    - host: "${BUCKET}.s3.${REGION}.amazonaws.com"
      port: 443
      protocol: rest
      access: read-write
      enforcement: enforce
      credential_binding:
        provider: p5-aws
      credential_signing: sigv4
      signing_service: s3
  binaries:
    - path: /usr/bin/curl
    - path: /usr/local/aws-cli/v2/*/dist/aws
    # ... /usr/local/bin/aws and /usr/bin/python3.12

Bedrock runtime is limited to POST /model/*/converse and /converse-stream, and STS to POST /, which is every STS action: STS puts the action name in the body, where L7 rules can’t see it. The scripts render the bucket name and region into the file and pass it with --policy. (Process substitution, --policy <(...), didn’t work: the CLI couldn’t read the path.)

A combined aws profile is necessary here. OpenShell rejects a policy credential_binding for a provider whose profile already lists endpoints, such as aws-s3, so one provider can sign S3, Bedrock and STS only through the endpointless profile.

Testing

Test 1: Who AWS thinks the sandbox is

02_identity.sh creates a sandbox with the provider and the signing policy, looks at the environment, and calls STS:

$ create p5-identity with --provider p5-aws and the signing policy
  ok    ready

$ the AWS_* env vars inside the sandbox are placeholders, not keys
  AWS_ACCESS_KEY_ID      placeholder  len=105  sha256=64fb6d329225…
  AWS_SECRET_ACCESS_KEY  placeholder  len=109  sha256=9e9fc115f76f…
  AWS_SESSION_TOKEN      placeholder  len=105  sha256=eb185b47cf6c…

$ aws sts get-caller-identity (signed at the supervisor)
  Arn:     arn:aws:sts::<account>:assumed-role/osh-eks-sandbox-s3bedrock/p5-shared
  role:    osh-eks-sandbox-s3bedrock
  session: p5-shared   (the provider's session_name)

$ the same through boto3 (python3.12, no AWS_CA_BUNDLE — relies on REQUESTS_CA_BUNDLE)
  boto3 Arn: arn:aws:sts::<account>:assumed-role/osh-eks-sandbox-s3bedrock/p5-shared
  ok    AWS CLI and boto3 both resolve to the sandbox-role session

The three AWS_* variables are placeholders: about 105 characters each, starting with openshell:resolve:env:, nothing like a 20-character access key ID. The script prints only their length and a hash prefix. Still, aws sts get-caller-identity and boto3 both came back as the sandbox role’s session, named after the provider’s session_name. The CLI and boto3 signed the request with the placeholders; the supervisor threw that signature away and signed it properly.

Test 2: S3

03_s3.sh exercises the bucket: a small upload, a 50 MiB upload and download, boto3, keys that need encoding, deletes, and the two explicit signing modes.

Output: 03_s3.sh
$ create p5-s3 (default sigv4 auto-detect mode)
  ok    ready

$ aws s3 ls (list), then a small put
  upload: ../tmp/small.txt to s3://osh-eks-creds-6c00850f/small.txt

$ a 50 MiB upload (aws-chunked, STREAMING-UNSIGNED-PAYLOAD-TRAILER), then download + checksum
  upload ok
  download ok
  checksum matches (sha256 e6123935a397…)

$ boto3 put/list/get (python3.12)
  boto3 round-trip: 'hello from boto3'; objects now: 3

$ keys that need percent-encoding (expect SignatureDoesNotMatch or a rewritten key)
  key my\ file.txt   exit 1   error occurred (SignatureDoesNotMatch)
  key a+b.txt        exit 1   error occurred (SignatureDoesNotMatch)
  key $'caf\303\251.txt' exit 1   error occurred (SignatureDoesNotMatch)
  key a//b.txt       exit 0   upload: ../tmp/enc.bin to s3://osh-eks-creds-6c00850f/a//b.txt
  object stored for "a//b.txt": a/b.txt

$ DELETE vs POST: aws s3 rm (DELETE, read-write preset blocks it) vs delete-objects (POST ?delete)
  s3 rm:          delete failed: s3://osh-eks-creds-6c00850f/small.txt An error occurred (403) when calling the DeleteObject ope
  delete-objects: "Key": "from-boto3.txt"

$ sigv4:body mode
  1 MiB,  CLI defaults (aws-chunked + trailer): FAILED error occurred (MalformedTrailerError)
  1 MiB,  checksum when_required        : ok
  11 MiB, one PutObject, when_required : FAILED

$ sigv4:no_body mode
  1 MiB,  CLI defaults (aws-chunked + trailer): FAILED error occurred (InvalidArgument)
  1 MiB,  checksum when_required        : ok
  11 MiB, one PutObject, when_required : ok

The basics work. A small object, a 50 MiB object with a matching checksum after the round trip, and a boto3 put, get and list. The 50 MiB upload is the interesting one: AWS CLI 2.39 sends uploads with aws-chunked encoding and a trailing checksum, and the default sigv4 mode streams them through without buffering.

One thing the script has to set first: AWS_CA_BUNDLE. Without it, aws s3 cp fails TLS (Test 4 explains why).

Keys that need encoding break signing. A key with a space, a + or a non-ASCII character failed with SignatureDoesNotMatch. S3 expects the path to be encoded once; the signer, using the AWS SDK’s defaults for every service, encodes it a second time and normalizes it, so its signature covers a different path from the one S3 sees. a//b.txt is worse: the upload reported success, and the object is stored as a/b.txt, because the supervisor normalizes the path before it forwards the request. The client thinks it wrote one key and S3 has another.

DELETE vs POST. The read-write preset allows GET, HEAD, OPTIONS, POST, PUT and PATCH, but not DELETE. aws s3 rm got a 403 from the supervisor, while aws s3api delete-objects, which deletes through a POST, went through. If you want a sandbox that can’t delete, use the IAM role, not the preset.

The explicit signing modes. sigv4:body buffers the body and signs its hash; sigv4:no_body signs only the headers. With the CLI’s defaults, sigv4:body failed even for a 1 MiB file with MalformedTrailerError, because the CLI sends a chunked body with a trailing checksum, which this mode doesn’t handle. With AWS_REQUEST_CHECKSUM_CALCULATION=when_required, the CLI sends a plain body and the upload worked. sigv4:no_body failed the CLI’s defaults too, with InvalidArgument, and handled everything once the checksum was off, including an 11 MiB single PutObject. sigv4:body buffers at most 10 MiB (10,485,760 bytes), and an 11 MiB PutObject showed what happens past that. The CLI reported an SSL error: the supervisor closed the connection without an HTTP response, so the client gets no S3 error and no hint about the cause. A 9 MiB PutObject worked, and aws s3 cp stays under the limit by switching to multipart uploads above 8 MiB (the CLI’s default multipart_threshold, which its docs write as 8MB). Stay with the default sigv4 mode unless you have a reason not to.

Test 3: Bedrock

04_bedrock.py runs a small boto3 program inside a sandbox: Converse, ConverseStream with the time to the first token, and Converse with the model given as an inference-profile ARN.

$ openshell -g osh-eks sandbox create --name p5-bedrock --provider p5-aws --policy <signing>

$ (inside p5-bedrock) python3.12 bedrock converse / converse_stream / arn
  converse {"ok": true, "text": "Pong", "stop": "end_turn", "inTok": 8, "outTok": 2}
  converse_stream {"ok": true, "ttft_ms": 278, "events": 6}
  converse_arn {"ok": false, "error": "ConnectionClosedError", "detail": "Connection was closed before we received a valid response from endpoint URL: \"https://bedrock-runtime.us-east-1.amazonaws.com/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A<account>%3Ainference-profile%2"}

Converse and ConverseStream worked, signed as service bedrock. The time to the first streamed token, measured inside the sandbox, was 278 ms. The model ARN failed with the connection closed: the ARN contains /, which becomes %2F in the path, and the supervisor refuses encoded slashes by default (allow_encoded_slash is off). It doesn’t answer with an error; it closes the connection. Use model IDs, or inference-profile IDs without the ARN.

Test 4: Which CA variable the AWS tools read

The supervisor terminates TLS with the sandbox’s own CA, and OpenShell sets six variables pointing at it (the output leaves out DENO_CERT and GIT_SSL_CAINFO), but not AWS_CA_BUNDLE. Part 4 found that aws s3 ls doesn’t need it. 05_ca_trust.sh checks the rest by removing one variable at a time:

Output: 05_ca_trust.sh
$ create p5-ca

$ what OpenShell set (paths only, not contents)
  SSL_CERT_FILE        /run/openshell-supervisor-ca/material/ca-bundle.crt
  REQUESTS_CA_BUNDLE   /run/openshell-supervisor-ca/material/ca-bundle.crt
  CURL_CA_BUNDLE       /run/openshell-supervisor-ca/material/ca-bundle.crt
  AWS_CA_BUNDLE        <unset>
  NODE_EXTRA_CA_CERTS  /run/openshell-supervisor-ca/material/ca.crt
  aws real path:       /usr/local/aws-cli/v2/2.39.3/dist/aws

$ AWS CLI v2 — which CA var does it need?
  aws cli, env as set by OpenShell               exit 0   ok
  aws cli, REQUESTS_CA_BUNDLE unset              exit 255 CERTIFICATE_VERIFY_FAILED
  aws cli, SSL_CERT_FILE unset                   exit 0   ok
  aws cli, CURL_CA_BUNDLE unset                  exit 0   ok
  aws cli, all three unset, AWS_CA_BUNDLE set    exit 0   ok
  aws cli, all four unset                        exit 255 CERTIFICATE_VERIFY_FAILED

$ boto3 — which CA var does it need?
  boto3, env as set by OpenShell                 exit 0   ok
  boto3, REQUESTS_CA_BUNDLE unset                exit 1   CERTIFICATE_VERIFY_FAILED
  boto3, REQUESTS unset, AWS_CA_BUNDLE set       exit 0   ok
  boto3, REQUESTS + AWS_CA_BUNDLE both unset     exit 1   CERTIFICATE_VERIFY_FAILED

$ aws s3 cp: the transfer client, CRT by default here, reads AWS_CA_BUNDLE only
  s3 cp, default client, as set by OpenShell     exit 1   AWS_IO_TLS_ERROR_NEGOTIATION_FAILURE
  s3 cp, default client, AWS_CA_BUNDLE set       exit 0   ok
  s3 cp, classic client, as set by OpenShell     exit 0   ok
  s3 cp, CRT client, as set by OpenShell         exit 1   AWS_IO_TLS_ERROR_NEGOTIATION_FAILURE
  s3 cp, CRT client, AWS_CA_BUNDLE set           exit 0   ok
  • The AWS CLI’s ordinary calls and boto3 use AWS_CA_BUNDLE if it’s set and otherwise REQUESTS_CA_BUNDLE, which OpenShell sets, so they work as is.
  • aws s3 cp and the other transfer commands can use a different HTTP stack: in this setup the CLI picked its CRT transfer client, which reads only AWS_CA_BUNDLE, and failed TLS until it was set. The classic transfer client works without it.

The fix is one variable with the literal path, AWS_CA_BUNDLE=/run/openshell-supervisor-ca/material/ca-bundle.crt (an image’s ENV or sandbox create --env; $REQUESTS_CA_BUNDLE wouldn’t expand there), and I’d set it in any image that runs the AWS CLI under OpenShell.

Test 5: CloudTrail

06_cloudtrail.sh creates a second provider with its own session name, starts two sandboxes on the shared provider and one on the second, makes one call from each, and then queries CloudTrail event history:

$ a second provider p5-aws-persb with its own session_name (p5-persb)
  ok    configured

$ two sandboxes on the shared provider, one on the per-sandbox provider
  ok    p5-ct-a, p5-ct-b (shared p5-shared); p5-ct-c (p5-persb)

$ one signed call from each (GetCallerIdentity is a management event)
  p5-ct-a: arn:aws:sts::<account>:assumed-role/osh-eks-sandbox-s3bedrock/p5-shared
  p5-ct-b: arn:aws:sts::<account>:assumed-role/osh-eks-sandbox-s3bedrock/p5-shared
  p5-ct-c: arn:aws:sts::<account>:assumed-role/osh-eks-sandbox-s3bedrock/p5-persb

$ CloudTrail event history by session name (lags a few minutes; polling up to ~6 min)
  Username=p5-shared (both shared sandboxes land here): 18 event(s)
  Username=p5-persb (only p5-ct-c):                    2 event(s)
  The shared session name can't tell p5-ct-a from p5-ct-b.

$ who minted those sessions: sts:AssumeRole into the sandbox role
  caller osh-eks-gateway (session eks-osh-eks-openshell--0...) -> session name p5-tmp, 3600 s, tags: 0
  caller osh-eks-gateway (session eks-osh-eks-openshell--0...) -> session name p5-shared, 3600 s, tags: 0
  caller osh-eks-gateway (session eks-osh-eks-openshell--0...) -> session name p5-persb, 3600 s, tags: 0
  S3 object reads and writes are data events: they need a trail or event data store, not shown
  in the free event history.

The event counts cover everything in event history under each session name, earlier runs included, not just these three calls; p5-tmp is likewise a session from an earlier run of Test 7.

CloudTrail attributes calls to the session, and the session belongs to the provider. Two sandboxes on one provider share a session name, and AWS can’t tell them apart. A provider per sandbox (or per agent, or per tenant) gives each its own name in CloudTrail, at the cost of one more STS session to refresh.

The AssumeRole calls themselves are logged under the gateway role’s Pod Identity session, with a 3,600-second duration. The request adds no tags of its own, but the session inherits the Pod Identity session’s transitive tags, which matters in Test 6. Object reads and writes in S3 are data events, which need a trail or an event data store; the free event history doesn’t show them.

Test 6: Attacks

07_attacks.sh tried six things from inside one sandbox. Anything that could be a credential was printed only as a length and a hash prefix. I’ve left one of the six, item 2 below, out of this output and out of the companion script.

$ create p5-attack

$ 1. Signing needs no placeholder: unset AWS_* and curl the S3 endpoint unsigned
  PUT  HTTP 200
  GET  HTTP 200

$ 3. AssumeRole / GetSessionToken through the signed STS endpoint (stopped by the IAM deny)
  assume-role:      AccessDenied
  get-session-token:AccessDenied

$ 4. Presigned URL through the signed endpoint (S3 rejects: two auth mechanisms)
  presign produced a URL: yes (its X-Amz-Credential carries the placeholder key)
  GET presigned HTTP 400

$ 5. IMDS and the EKS Pod Identity agent (always blocked by the supervisor, whatever the policy)
curl: (7) Failed to connect to 169.254.169.254 port 80 after 0 ms: Couldn't connect to server
  169.254.169.254 (IMDS):         HTTP 000 / (connect error = blocked)
curl: (7) Failed to connect to 169.254.170.23 port 80 after 0 ms: Couldn't connect to server
  169.254.170.23 (Pod Identity):  HTTP 000 / (connect error = blocked)

$ 6. Scan the sandbox for real keys (env, /proc, ~/.aws) — only placeholders should exist
  env AWS_* values that are placeholders / total:
    3/3
  AWS_* in /proc/self/environ that are NOT placeholders: 0
  ~/.aws/credentials or ~/.aws/config: 0 file(s)
  1. Signing needs no placeholder. With every AWS_* variable removed, an unsigned curl PUT to the bucket returned 200. The supervisor signs every request that reaches a signed endpoint, from any binary the rule lists, whether or not it presented a credential. Possession isn’t the control; the policy is.
  2. One step I’ve left out. It tested a known weakness in 0.1.6: a placeholder the workload places in a request to a signed endpoint is resolved to the real value, and through an endpoint that stores what it receives, that can put the real AWS session within the agent’s reach. It worked on 0.1.6. The maintainers know: an open pull request, #4558, stops SigV4 requests from resolving placeholders the workload places, because “preserving arbitrary placeholder placement would expose resolved credentials to the signed service”. It wasn’t merged at 0.1.6, so I’ve left the details out of this post and the companion code. Until a release includes the fix, treat the sandbox role’s session as reachable by the agent.
  3. Credential vending is stopped by IAM, not by OpenShell. The supervisor signed aws sts assume-role and get-session-token, and both got AccessDenied. CloudTrail shows why: the role doesn’t allow AssumeRole, and the call failed first on sts:TagSession, because the session carries Pod Identity’s transitive tags and the role may not pass them on; GetSessionToken can’t be called with role credentials at all. So my explicit deny wasn’t what stopped these two, despite the heading in the recorded output above, which I wrote before checking CloudTrail. It’s a backstop for the day the role is granted one of these actions: without the deny, the supervisor would sign that call and the sandbox would receive real keys in the response.
  4. A presigned URL fails. aws s3 presign produces a URL signed with the placeholders; S3 rejected it with a 400. Per the source, the supervisor adds its own Authorization header on top of the query-string signature, and S3 refuses a request that authenticates both ways.
  5. Instance metadata and the Pod Identity agent are blocked, as in part 4.
  6. Nothing to find. The environment and /proc hold only placeholders, and there are no AWS configuration files.

Test 7: Lifetime, rotation and revocation

08_lifetime.sh looks at the refresh status, forces a rotation, and then tries to revoke a provider from a running sandbox:

$ refresh status of p5-aws (EXPIRES_AT ~1h out; NEXT_REFRESH ~5 min before that)
PROVIDER                  CREDENTIAL_KEY                STRATEGY                      STATUS                    RECOVERY            EXPIRES_AT            NEXT_REFRESH          LAST_REFRESH          FAILURE_CODE                                  LAST_ERROR
p5-aws                    AWS_ACCESS_KEY_ID             aws_sts_assume_role           refreshed                 -                   2026-11-01 06:45:21   2026-11-01 06:40:21   2026-11-01 05:45:21                                                 -

$ manual rotate, then status again (a new session; EXPIRES_AT moves forward)
✓ Rotation requested for p5-aws AWS_ACCESS_KEY_ID (refreshed)
  expiry before rotate: 2026-11-01 06:45:21
  expiry after rotate:  2026-11-01 06:59:39
PROVIDER                  CREDENTIAL_KEY                STRATEGY                      STATUS                    RECOVERY            EXPIRES_AT            NEXT_REFRESH          LAST_REFRESH          FAILURE_CODE                                  LAST_ERROR
p5-aws                    AWS_ACCESS_KEY_ID             aws_sts_assume_role           refreshed                 -                   2026-11-01 06:59:39   2026-11-01 06:54:39   2026-11-01 05:59:39                                                 -

$ provider delete as revocation: a throwaway provider, then delete it and watch signing stop
  before delete: arn:aws:sts::<account>:assumed-role/osh-eks-sandbox-s3bedrock/p5-tmp

$ revocation: detach p5-aws-tmp from p5-life
  Error: × provider detachment failed (The system is not in a state required for the  operation's execution)

$ openshell policy set p5-life with no AWS endpoints, then detach and delete p5-aws-tmp
  ✓ Policy version 2 loaded (active version: 2)
  detached after 9.0 s
  ✓ Deleted provider p5-aws-tmp
  aws sts get-caller-identity: error occurred (NoCredentials)
  unsigned curl PUT to the bucket: HTTP 000

refresh rotate mints a new session at once: the expiry moved forward. Without it, the gateway refreshes 5 minutes before expiry; per the source, a worker checks every 60 seconds, supervisors pick up new values on their next poll, and an STS failure is retried every 60 seconds indefinitely.

Revocation is harder than in part 4. Detaching the provider from the sandbox was refused, because the sandbox’s policy binds the AWS endpoints to that provider. I had to replace the policy with one that has no AWS endpoints first; then the detach went through in 9.0 seconds and the provider could be deleted. After that, the CLI found no credentials and the unsigned request couldn’t connect. Plan for this in an incident: revoking AWS access from a running sandbox is a policy change plus a detach, or deleting the sandbox. And since a session copied out of the sandbox (Test 6) stays valid until it expires, revoke the role’s active sessions in IAM as well.

Results Summary

Result
Gateway identityEKS Pod Identity after a gateway restart; the refresh picked it up through the default credential chain
Sessionaws_sts_assume_role, 3,600 s, refreshed 5 minutes before expiry; rotate mints at once
In the sandboxThree placeholders, about 105 characters each; no keys in the environment, /proc or files
Identityassumed-role/osh-eks-sandbox-s3bedrock/p5-shared from the CLI and boto3
S3 basicsSmall put, 50 MiB up and down with a matching checksum, boto3
Keys with a space, +, non-ASCIISignatureDoesNotMatch
Key a//b.txtReported uploaded, stored as a/b.txt
aws s3 rm / delete-objects403 (DELETE not in read-write) / allowed (POST)
sigv4:body, sigv4:no_body with CLI defaultsMalformedTrailerError, InvalidArgument
sigv4:body over 10 MiBDropped connection, reported as an SSL error
Bedrock Converse / ConverseStreamWork; first streamed token in 278 ms
Bedrock with a model ARNConnection closed (encoded /)
CA trustREQUESTS_CA_BUNDLE for the CLI’s calls and boto3; aws s3 cp (CRT) needs AWS_CA_BUNDLE
CloudTrailOne session name per provider; two sandboxes on one provider are indistinguishable
Unsigned request to a signed endpointSigned and accepted (200)
Placeholders the workload places on signed endpointsResolved to real values in 0.1.6 (fix pending in #4558; details left out)
AssumeRole, GetSessionTokenAccessDenied (AssumeRole: no allow, failed on TagSession; GetSessionToken: never issued to a role session)
Presigned URL400
IMDS, Pod Identity agentBlocked
RevocationDetach refused while the policy binds the provider; policy change, then detach (9.0 s)

What This Doesn’t Solve

  • What the role allows. The sandbox can do everything the sandbox role can, for as long as the session lasts. Least privilege in IAM is the real boundary. OpenShell’s REST rules see methods and paths; for APIs such as STS that name the action in the body, they can’t tell one action from another.
  • Which binary signs. Signing is per endpoint and needs no placeholder (Test 6). Any binary the rule lists, curl included, gets requests signed.
  • Credential vending. Nothing in OpenShell filters which STS action a signed request calls, or what comes back. Only IAM stops a sandbox from turning signing into real keys.
  • Placeholders the workload places on signed endpoints. In 0.1.6 they resolve to real values (Test 6), which can put the real session within the agent’s reach. The open fix, #4558, wasn’t in 0.1.6; until it ships, treat the sandbox role’s session as reachable by the agent, and keep it short and small.
  • Attribution per sandbox. One provider is one STS session; share it and CloudTrail can’t tell sandboxes apart (Test 5).
  • S3 edge cases. Keys with spaces, +, non-ASCII characters or // don’t sign correctly or are rewritten (Test 2), and the explicit signing modes don’t handle the CLI’s default uploads. #4558 won’t change the // rewrite: per its description, keys stay in canonical form.
  • The signer’s hardening. At 0.1.6 the signer has no tests against real AWS: its only integration tests, five LocalStack cases in one file, are all marked #[ignore], and LocalStack doesn’t check signatures by default.

Cleanup

./99_cleanup.sh                          # sandboxes, providers, profiles
(cd terraform && terraform destroy)      # the bucket, both roles, the Pod Identity association

99_cleanup.sh deletes the p5- sandboxes and providers and the imported profiles, and empties the bucket. terraform destroy then deletes the bucket, the roles and the association. The gateway keeps running without AWS credentials; restart it if you want the Pod’s environment clean too. The rest of the cluster goes with part 2’s 99_teardown.sh.

Verdict

OpenShell’s AWS support is a real answer to a real problem. The agent ran the stock AWS CLI and boto3, uploaded 50 MiB, streamed a Bedrock response and proved its identity, with three placeholders in its environment and no key anywhere in the sandbox. The gateway got its own identity from EKS Pod Identity, kept a one-hour session fresh, and CloudTrail shows which role and session made each management call; S3 object calls need a data-event trail.

It isn’t a credential vault, though, and the tests show where it leaks:

  • The real session can come back. A known weakness in 0.1.6, with a fix pending in #4558, can put the real session token within the agent’s reach. Until that’s fixed, assume an agent can recover the session, and size the role and the session lifetime for that.
  • Possession isn’t the control. Any listed binary gets signed requests, with or without a placeholder. The policy’s endpoints and the role’s permissions are the boundary.
  • IAM has to say no to credential vending. Nothing in OpenShell stops AssumeRole through a signed STS endpoint.
  • The S3 path has rough edges: encoded keys break signing, // is rewritten, the explicit modes reject the CLI’s default uploads, and transfers need AWS_CA_BUNDLE.
  • Revocation takes two steps, and attribution is per provider.

If you run agents on EKS that need AWS, I’d still use this over handing a sandbox any IAM identity of its own: the keys stay out of the workload, the session is short, and the boundary is reviewable in one policy file and one role. Give each agent or tenant its own provider and role, deny credential vending explicitly, keep the role to the resources the task needs, set AWS_CA_BUNDLE in the image, and keep the session short until #4558 or an equivalent fix ships.

Part 6, the last in the series, stress-tests the two nodes this all runs on.

Comments