Secrets¶
Rayito can store secrets in your account's AWS Secrets Manager
(SecretStore, and the E2B shim's Secret/AsyncSecret) and deliver them
to a sandbox as environment variables (secrets=), with a cache that avoids
fetching the secret on every call (SecretCache).
Since 0.5.0. Accepted against real AWS Secrets Manager.
Cost and activation
- Default: off. Without
secrets=/secret_cache=(TypeScript:secrets/secretCache) and without instantiatingSecretStore, Rayito does not build any Secrets Manager client, does not import@aws-sdk/client-secrets-managerand makes no call. No environment variable or configuration file turns it on. - Turns on:
SecretStore(...)stores, updates, lists and deletes secrets under a prefix (rayito/);secrets=delivers their value as an environment variable of a command, a PTY, a cell or a context. - Resources and AWS calls: one Secrets Manager secret per
create;GetSecretValueonce per secret and cache TTL (300 s), never once per command. - Approximate cost (us-east-1, checked 2026-09-30, pricing): $0.40 per secret per month until you delete it, plus $0.05 per 10,000 calls (≈ $0.04/month per secret and process with the default TTL).
- IAM (credentials of whoever calls the SDK):
RayitoSecretsReaderto inject andRayitoSecretsAdminfor the CRUD, frominfra/secrets-access.yaml. - How to turn it off: remove
secrets=and stop instantiatingSecretStore;destroy()the secrets you no longer use (How to turn it off).
Phase 1: the sandbox code CAN read an injected secret
secrets= delivers the value as an environment variable of the process that
asks for it. Any code running in the sandbox can read it: the environment of
its own process, /proc/<pid>/environ of processes of the same uid
(1000) and the suspension memory snapshot (if the sandbox
is suspended with the process alive, the value ends up in that snapshot; whether AWS
encrypts it at rest is the open question SEC-5). The first time a
process uses secrets=, the SDK reminds you with a RayitoCompatWarning.
With untrusted code (an agent's, a user's):
- do not inject long-lived credentials (IAM access keys, personal tokens with no expiry);
- inject short-lived, minimal-scope tokens (for example, a token for an API with read-only permission that expires in an hour);
- use it only in processes you control (the specific command that needs it, not the whole shell), or
- use the secret gateway
(
gateways=, since 0.6.0): the sandbox uses the credential against a fixedupstreamwithout being able to read its value.
What it turns on and what it costs¶
SecretStore (CRUD) |
secrets= (injection) |
|
|---|---|---|
| Turns on | create, update, describe, list and delete secrets under a prefix (rayito/ by default) |
deliver the value as an environment variable of a command, a PTY, a Python cell or a code context |
| AWS resources | one Secrets Manager secret per create |
none new |
| AWS calls | CreateSecret, PutSecretValue, UpdateSecret, DescribeSecret, ListSecrets, DeleteSecret (one per method; update and destroy first do a DescribeSecret) |
GetSecretValue once per secret and cache TTL (300 s by default) |
| Cost (us-east-1, pricing, checked 2026-09-30) | $0.40 per secret per month, prorated, until you delete it, + $0.05 per 10,000 calls | $0.05 per 10,000 calls: with the default TTL, ≤ 12 reads/hour per secret and process ≈ $0.04/month |
| IAM (caller's credentials, not the execution role) | policy RayitoSecretsAdmin |
policy RayitoSecretsReader |
AWS SDK v3 DEBUG logging prints the value
Rayito never writes a secret's value or name to its logs
(rayito.* loggers in Python, the options' logger in
TypeScript), to repr/toJSON or to errors.
In Python, since 0.17.1 import rayito drops the DEBUG records of the
botocore, boto3 and s3transfer loggers that write request parameters
and request and response bodies (botocore.endpoint,
botocore.parsers, botocore.auth, botocore.regions,
boto3.resources.action, boto3.resources.collection,
boto3.dynamodb.table, s3transfer.tasks and s3transfer.futures):
with logging.DEBUG on the root logger or on botocore, or with
boto3.set_stream_logger(), the SecretString no longer shows up
(before, it was printed in the clear for CreateSecret,
PutSecretValue and GetSecretValue), and neither does the Item of
a resource put_item. This is the list measured with boto3 1.43.102
against a local HTTP server; a logger added by a later version is not
covered. Their warnings and errors, and the rest of the DEBUG output,
still come through; urllib3 writes each request line (method, path
and query), never a body. The agent host does the same inside the
MicroVM.
In TypeScript, a logger you pass yourself to the SDK v3
SecretsManagerClient does receive those bodies: do not set one in
processes that handle secrets.
Exact contract of parameters and errors: AWS_API_NOTES.md §19 (on
GitHub). A KMS
CMK (kms_key_id=) adds KMS cost and the kms:Decrypt /
kms:GenerateDataKey permissions; the AWS-managed key (aws/secretsmanager) does not.
IAM: the optional template¶
infra/secrets-access.yaml creates two managed IAM policies and nothing else
(cost $0): RayitoSecretsReader (GetSecretValue, DescribeSecret on
arn:aws:secretsmanager:<region>:<account>:secret:rayito/*) and
RayitoSecretsAdmin (in addition CreateSecret, PutSecretValue,
UpdateSecret, DeleteSecret on that ARN and ListSecrets on *;
destroy uses DescribeSecret and DeleteSecret, both included). With
KmsKeyArn, it adds kms:Decrypt (and kms:GenerateDataKey in the
admin one) only through Secrets Manager (kms:ViaService).
RayitoSecretsReader also denies GetSecretValue on
rayito/webhooks/*: that is where the signing secrets of the
webhooks live, and a sandbox that
received one through secrets= could forge signed deliveries. The SDK
also refuses to read them through secrets=/SecretCache
(InvalidArgumentException) even if your policy allowed it.
SecretPrefix must end in /: rayito (without a slash) would also grant
other people's secrets such as rayito-prod-db. It is deployed as the
secrets-access component of rayito stack:
rayito stack deploy secrets-access --param SecretPrefix=rayito/
# with your own KMS key (SecretStore(kms_key_id=...)), add:
# --param KmsKeyArn=arn:aws:kms:<region>:123456789012:key/<id>
rayito stack status secrets-access # outputs ReaderPolicyArn and AdminPolicyArn
Assign ReaderPolicyArn (or AdminPolicyArn if you are going to create and delete
secrets) to whoever runs the SDK, just like the policy of
Set up AWS:
aws iam attach-user-policy or aws iam attach-role-policy. SecretPrefix
must match SecretStore(prefix=). To delete it:
rayito stack destroy secrets-access (removes the policies, not the
secrets). From the SDK, OptionalStacks().deploy("secrets-access", ...)
does the same. If you prefer CloudFormation by hand, the template is
infra/secrets-access.yaml; more detail in
infra/README.md.
Examples¶
import os
from rayito import Sandbox, SecretCache, SecretRef, SecretStore
store = SecretStore(region="us-east-1") # does not call AWS
store.create("openai", os.environ["OPENAI_API_KEY"], metadata={"team": "ml"})
cache = SecretCache(ttl_seconds=300, region="us-east-1") # optional
sbx = Sandbox.create(secrets={"OPENAI_API_KEY": "openai"}, secret_cache=cache)
sbx.commands.run("python agent.py") # 0 calls: resolved on create
sbx.commands.run("python eval.py") # 0 calls: cache hit
sbx.commands.run("env", secrets={"GH_TOKEN": SecretRef("gh", version_stage="AWSCURRENT")})
sbx.kill()
store.update("openai", os.environ["OPENAI_API_KEY_NEW"]) # version 2
cache.refresh("openai") # without waiting for the TTL
store.destroy("openai") # stops billing
import { Sandbox, SecretCache, SecretRef, SecretStore } from "rayito";
const store = new SecretStore({ region: "us-east-1" }); // does not call AWS
await store.create("openai", process.env.OPENAI_API_KEY!, { metadata: { team: "ml" } });
const secretCache = new SecretCache({ ttlSeconds: 300, region: "us-east-1" });
const sbx = await Sandbox.create({ secrets: { OPENAI_API_KEY: "openai" }, secretCache });
await sbx.commands.run("python agent.py"); // 0 calls: resolved on create
await sbx.commands.run("env", {
secrets: { GH_TOKEN: new SecretRef("gh", { versionStage: "AWSCURRENT" }) },
});
await sbx.kill();
await store.update("openai", process.env.OPENAI_API_KEY_NEW ?? "");
await secretCache.refresh("openai");
await store.destroy("openai");
@aws-sdk/client-secrets-manager is an optional peerDependency:
install it (npm install @aws-sdk/client-secrets-manager) only if you use
secrets. Without it, nothing changes; with secrets or SecretStore and without
it, the first call raises InvalidArgumentError with the install
command.
import os
from rayito.e2b import Secret
info = Secret.create("openai-key", os.environ["OPENAI_API_KEY"], region="us-east-1")
print(info.secret_id) # the Secrets Manager ARN
Secret.fill("openai-key") # '${e2b.secrets.openai-key}': NOT resolved
Secret.destroy("openai-key")
The shim lowercases names (like E2B):
Secret.create("OpenAI", …) stores rayito/openai. The native SDK's secrets= and
SecretStore do not normalize, so that secret is
injected as secrets={"OPENAI_API_KEY": "openai"}; with "OpenAI" the
call raises SecretNotFoundException.
The differences from E2B are in E2B compatibility.
Where secrets= applies¶
| Call | Python | TypeScript | Notes |
|---|---|---|---|
| Create / connect | Sandbox.create(secrets=, secret_cache=), Sandbox.connect(id, secrets=, …), sbx.connect(secrets=, …) |
Sandbox.create({ secrets, secretCache }), Sandbox.connect(id, { secrets }), sbx.connect({ secrets }) |
the handle stores only the references; they are resolved before run-microvm: a missing secret fails without launching a VM; connect without secrets keeps the handle's (a secret_cache=/secretCache alone only changes the cache, not the references); secrets={} (TS: secrets: {}) clears them all and keeps the cache |
| Pool | pool.take(secrets=), Sandbox.create(pool=, secrets=) |
pool.take({ secrets }), Sandbox.create({ pool, secrets }) |
they are bound to the sandbox that comes out of the pool; warm slots never carry them (neither at launch nor in their SlotRecord) |
| Commands | commands.run(..., secrets=) (also background=True) |
commands.run(cmd, { secrets }) |
StartRequest.envs |
| PTY | pty.create(secrets=) |
pty.create({ secrets }) |
PtyStart.envs |
| Code | run_code(..., secrets=) |
runCode(code, { secrets }) |
ExecuteRequest.envs, only in Python contexts and only during that cell; with another language it is InvalidArgumentException, and the handle's are not added to cells of other languages. With context= given as an id (text), the handle's only go if the context is default or one that handle created or listed as Python: an unknown id (created from another handle) goes without them; pass the CodeContext or secrets= in the call |
| Contexts | create_code_context(secrets=) |
createCodeContext({ secrets }) |
CreateContextRequest.envs: the kernel's environment, for any language, while it lives |
reincarnate() |
relaunches with the references (and the cache) the handle has at that moment, including those from pool.take(secrets=) or connect(secrets=) |
same | references only, never values |
Rules:
- Handle ∪ call: each call injects the handle's secrets plus its own; if they repeat a variable, the call wins.
- All or nothing: a variable that is in both
envsandsecretsisInvalidArgumentException(TS:InvalidArgumentError), never silently overwritten. The error names the variable, never the secret. - A plain string is
SecretRef(name);SecretRef(name, version_id=…)orversion_stage=…pins the version; a full ARN is used as is. - They never travel in the
runHookPayload(create(envs=)does not change),metadata, tags, image environment variables, pool records, logs,repr/JSON.stringifyor error messages. - Delivery reuses the
envsofProcessService,PtyServiceandCodeService, which already travel encrypted to the AWS proxy and authenticated with the access token;raydbuilds the child's environment from scratch and does not logenvs. There is no new RPC: it works with 0.4.0 images.
For a deployed agent¶
An agent of rayito agent deploy (on MicroVMs or on AgentCore) receives
Secrets Manager values as environment variables with [secret_env] and
[[secret_env_from]] in its project file, or with --secret-env and
--secret-env-from. There the names are full (without the rayito/
prefix, like --readable-secret) and a key of a JSON secret is chosen with
#key. See Secrets as environment variables.
The cache: never on every call¶
SecretCache(ttl_seconds=300) (TS: new SecretCache({ ttlSeconds: 300 })):
- Key: (region, credentials —the boto3 session in Python, the credentials
provider in TypeScript—, resolved
SecretId,VersionIdorVersionStage,AWSCURRENTby default). Two accounts or two regions never share a value. - TTL from 1 to 86,400 s (300 by default).
0is rejected: it would be fetching the secret on every call. - A hit makes 0 AWS calls. Only one request in flight per key:
ten threads, tasks or promises at once make one
GetSecretValue. - Refresh only when the TTL expires or with
refresh(name)(reads now) /invalidate(name)(discards; without a name, everything). After aSecretStore.update, readers see the new value when their TTL expires or withrefresh(). - A secret that does not exist is not stored: the next attempt asks again.
- Without
secret_cache=,secrets=uses a process-wide shared cache per (region, session), with TTL 300, created the first timesecrets=is used. There are at most 32 (MAX_SHARED_CACHES): a process that creates a session or a credentials provider per tenant or per request discards the least recently used one (it only costs a re-read later). - Expired values are discarded, they do not stay in memory: each use
of a cache (and each time a shared one is requested, for all the
shared ones) first deletes the values whose TTL has passed;
sweep()does it by hand. There is no timer: a cache nobody uses again keeps its values until the process drops it. - Values live only in the SDK process's memory;
repr,str,toJSONandinspectshow***.
Versions and metadata¶
- E2B's integer version is encoded in Secrets Manager's
ClientRequestToken(rayito-secret-version-{n:020d}, 42 characters):createis 1 and eachupdatewritesn + 1, wherenis the highest Rayito version thatDescribeSecretstill lists (not just theAWSCURRENTone: after an external rotation the current one is not Rayito's). Two writers with different values that compute the samen + 1collide: the second getsSecretExceptionand must retry. If several consecutive external rotations leave Rayito's versions without a label,DescribeSecretno longer lists them andupdatemay repeat a used number: create a new secret in that case. updatemore than once every 600 s for the same secret warns once (Secrets Manager recommends not writing more than once every 10 minutes and keeps at most 100 versions plus those of the last 24 h).metadatagoes inDescriptionasrayito:v1:+ compact JSON, ≤ 2048 characters (validated before calling AWS).destroyisDescribeSecretand, if the secret exists,DeleteSecret(ForceDeleteWithoutRecovery=True): there is no recovery window. It returnsTrueif it deleted it andFalseif it did not exist (or was already scheduled for deletion, or another process deleted it at the same time), without callingDeleteSecret, like E2B. The priorDescribeSecretis necessary because AWS accepts a forced deletion of a name that does not exist withoutResourceNotFoundException.- Recreating a just-deleted name: deletion is asynchronous and AWS takes
a while to release the name (in the 0.5.0 acceptance, between 19 and 28 s).
Meanwhile
CreateSecretanswersInvalidRequestExceptionmentioning the deletion andcreateretries with the sameClientRequestToken, with exponential backoff (0.5 s doubling up to 8 s, ±25 % jitter) for at most 60 s (CREATE_RETRY_BUDGET_SECONDSin Python,CREATE_RETRY_BUDGET_MSin TypeScript). After that deadline it raisesSecretException(SecretError) withaws_code="InvalidRequestException". list()is eventually consistent:ListSecretscan take ~3–5 s to show a just-created secret or the change of a just-updated one (and to stop showing a just-deleted one).get_info,existsanddestroy(DescribeSecret) do not have that delay. A test that creates and lists must polllist()with a deadline, not check it just once.
Errors¶
| Python | TypeScript | When |
|---|---|---|
SecretNotFoundException (also NotFoundException) |
SecretNotFoundError |
the secret does not exist or is scheduled for deletion |
SecretException (a SandboxException) |
SecretError (a SandboxError) |
missing IAM permission (names the action), already exists, version collision, version limit, binary secret |
RateLimitException |
RateLimitError |
ThrottlingException after the AWS SDK's retries |
InvalidArgumentException |
InvalidArgumentError |
invalid name, value (≤ 64 KiB), metadata, TTL or variable; envs/secrets conflict; a webhook signing secret (rayito/webhooks/...) in secrets= or SecretCache |
No error contains the secret's value or name; AWS's message is not propagated either (it may name the secret): only its code. Neither do Rayito's logs; the AWS SDK's DEBUG log does contain them (see the warning in What it turns on and what it costs).
How to turn it off¶
- Remove
secrets=/secret_cache=(TS:secrets/secretCache) from your calls and stop instantiatingSecretStore: Rayito goes back to the 0.4.0 path. - Delete the secrets you no longer use (
SecretStore().destroy(name),Secret.destroy(name)or the console): Secrets Manager bills for them ($0.40/month each) until they are deleted, whether or not you use Rayito. - If you deployed the policy stack, delete it:
rayito stack destroy secrets-access.
Threat model: T18 of
SECURITY.md
and Security.