OpenShell and AWS Credentials: SigV4 Signing at the Supervisor
By Fabio Douek
Jump to section
- Overview
- Architecture
- Setup
- Step 1: IAM and a bucket
- Step 2: The gateway, the profiles and the provider
- Step 3: The signing policy
- Testing
- Test 1: Who AWS thinks the sandbox is
- Test 2: S3
- Test 3: Bedrock
- Test 4: Which CA variable the AWS tools read
- Test 5: CloudTrail
- Test 6: Attacks
- Test 7: Lifetime, rotation and revocation
- Results Summary
- What This Doesn’t Solve
- Cleanup
- Verdict
Explain (TLDR) like I am...
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.
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
Three pieces:
- The gateway’s identity. The gateway Pod gets the IAM role
osh-eks-gatewaythrough an EKS Pod Identity association. That role can do one thing: assume roles whose names start withosh-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. - 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 STSAssumeRoleinto 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. - The signer. In the sandbox policy, each AWS endpoint names the provider (
credential_binding) and turns on signing (credential_signing: sigv4,signing_service: s3,bedrockorsts). 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_BUNDLEif it’s set and otherwiseREQUESTS_CA_BUNDLE, which OpenShell sets, so they work as is. aws s3 cpand the other transfer commands can use a different HTTP stack: in this setup the CLI picked its CRT transfer client, which reads onlyAWS_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)
- Signing needs no placeholder. With every
AWS_*variable removed, an unsignedcurlPUT 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. - 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.
- Credential vending is stopped by IAM, not by OpenShell. The supervisor signed
aws sts assume-roleandget-session-token, and both gotAccessDenied. CloudTrail shows why: the role doesn’t allowAssumeRole, and the call failed first onsts:TagSession, because the session carries Pod Identity’s transitive tags and the role may not pass them on;GetSessionTokencan’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. - A presigned URL fails.
aws s3 presignproduces a URL signed with the placeholders; S3 rejected it with a 400. Per the source, the supervisor adds its ownAuthorizationheader on top of the query-string signature, and S3 refuses a request that authenticates both ways. - Instance metadata and the Pod Identity agent are blocked, as in part 4.
- Nothing to find. The environment and
/prochold 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 identity | EKS Pod Identity after a gateway restart; the refresh picked it up through the default credential chain |
| Session | aws_sts_assume_role, 3,600 s, refreshed 5 minutes before expiry; rotate mints at once |
| In the sandbox | Three placeholders, about 105 characters each; no keys in the environment, /proc or files |
| Identity | assumed-role/osh-eks-sandbox-s3bedrock/p5-shared from the CLI and boto3 |
| S3 basics | Small put, 50 MiB up and down with a matching checksum, boto3 |
Keys with a space, +, non-ASCII | SignatureDoesNotMatch |
Key a//b.txt | Reported uploaded, stored as a/b.txt |
aws s3 rm / delete-objects | 403 (DELETE not in read-write) / allowed (POST) |
sigv4:body, sigv4:no_body with CLI defaults | MalformedTrailerError, InvalidArgument |
sigv4:body over 10 MiB | Dropped connection, reported as an SSL error |
| Bedrock Converse / ConverseStream | Work; first streamed token in 278 ms |
| Bedrock with a model ARN | Connection closed (encoded /) |
| CA trust | REQUESTS_CA_BUNDLE for the CLI’s calls and boto3; aws s3 cp (CRT) needs AWS_CA_BUNDLE |
| CloudTrail | One session name per provider; two sandboxes on one provider are indistinguishable |
| Unsigned request to a signed endpoint | Signed and accepted (200) |
| Placeholders the workload places on signed endpoints | Resolved to real values in 0.1.6 (fix pending in #4558; details left out) |
| AssumeRole, GetSessionToken | AccessDenied (AssumeRole: no allow, failed on TagSession; GetSessionToken: never issued to a role session) |
| Presigned URL | 400 |
| IMDS, Pod Identity agent | Blocked |
| Revocation | Detach 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
AssumeRolethrough 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 needAWS_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.