Security¶
Summary of SECURITY.md
(the full threat model; the Tn identifiers on this page are its rows).
Three principals: the SDK operator (full trust), the code that runs inside
the sandbox (none) and AWS (proxy, hooks, snapshot).
Reporting a vulnerability¶
Privately, with GitHub Private Vulnerability Reporting;
never in a public issue, and never pasting a JWE (x-aws-proxy-auth), an
access token (x-access-token) or a runHookPayload. Include the SDK
version, the agent_version from get_health(), the image and its
version, the region and a minimal reproduction without customer data.
Acknowledgement within 7 days and a fix or mitigation within 90; there is no
bounty program.
Supported versions: only the latest MAJOR.MINOR line, today
0.15.x (Python and TypeScript SDKs, and an image with rayd 0.15.x);
while the project is 0.x there are no maintenance branches for earlier lines
(Versioning and support,
SDK ↔ rayd ↔ image compatibility).
What the SDK protects by default¶
| Threat | Mitigation |
|---|---|
| Sandbox code reads the execution role's credentials through IMDS | create(execution_role_arn=None) by default: with no role there are no credentials inside (nor runtime logs). With a role, least privilege |
| Theft of the proxy JWE | Every RPC except Health also requires x-access-token; 60 min TTL; the SDK never logs headers |
The secret's sha256 travels in runHookPayload (CloudTrail) |
Only the hash leaves the client; rayd compares in constant time and never writes the /run body |
| Escalation to root inside the guest | Processes, PTYs and kernels as uid 1000; another account only if its uid and gid are between 1000 and 65535 (the range covered by the IMDS block and the network rules) and it is not in group 0; root only with user="root" and RAYITO_ALLOW_ROOT=1 in the image |
| Resource exhaustion from the sandbox | rlimits, process groups, server timeouts, bounded output channels, max. 256 processes/PTYs, max. 8 kernels; on the client, the stored output of a command or a PTY is bounded to 64 MiB per descriptor (max_output_bytes/maxOutputBytes, truncated flags it); rayd's ports (8080 and 9000) serve a maximum number of connections at a time and the hooks cut a header that never finishes arriving, so idle connections do not exhaust their descriptors (T7) |
| Fake pids in the sidecar protocol (T12) | rayd only registers and signals the kernel that /proc confirms as a child of the sidecar, leader of its group and of the same uid, pinned by its start time; it never signals group 0, group 1 or its own |
| Git credentials in the sandbox | clone/push/pull with username/password run without hooks or credential helpers, refuse if the git configuration rewrites URLs and always try to remove the token from .git/config when done; residual risk: sandbox code running as the same user can still capture it: treat it as disclosed and use short-lived, single-repository tokens (Git) |
| Third-party content or secrets in a template build | symbolic links inside a copied directory are never followed (like Docker), so a link to a file on your machine does not end up in the artifact or the image; .dockerignore follows Docker semantics (**/.env also excludes the root .env) and the SDK warns if it is about to package .env, .git/, .aws/, .ssh/ or *.pem/*.key keys, which any sandbox code could read (Templates) |
| CloudWatch or build logs with escape sequences | the CLI shows control characters as visible \xNN when writing to a terminal; with an execution role, a sandbox's code can write to any /rayito/* stream, so runtime logs do not prove integrity |
rayd (root) as a confused deputy on the filesystem |
deny list over canonical paths, the user's setfsuid on every operation, no ..; each path is opened component by component with O_NOFOLLOW and acted on through the descriptor, so a component that sandbox code swaps for a link between the check and the use is rejected instead of followed, and a directory in proc, sysfs or devpts is rejected however it is reached (T11) |
| Secret gateway as a confused deputy | each request's path passes an allowed-characters list (RFC 3986 without ;, decoded only once, no ./.. segments) before the allowlist, so an upstream that normalizes ..; or decodes twice cannot take it out of allow (Secret gateway) |
| Cloned snapshot state shared between sandboxes | nothing unique before /ready; /run restarts the kernel by default; /resume reseeds random/numpy.random |
| Exfiltration over the outbound network | explicit egress= in create(); allowlist through a VPC connector (outside the guest); since 0.3.0, network= / allow_internet_access=False applied inside the guest in rayito-base-caps (per-uid routes + local proxy, Outbound network); on any other image they fail closed |
Presigned URLs and rayd SSRF (T16) |
the SDK signs them with your credentials, rayd stores none; they are never logged; each URL covers one sandbox-bound key, one method and one expiry; rayd only accepts https to the bucket's exact regional host and its resolver drops loopback, link-local and IMDS (Files) |
rayd's egress proxy as SSRF (T17) |
guard after resolving (loopback, IMDS, the guest's own addresses), the proxy does not resolve denied names, the operator's proxy credentials only over RPC and never in logs; the Host header is rewritten only in the absolute http:// form: a CONNECT/SOCKS5 tunnel to an allowed name (80 or 443) shares the risk of shared IPs; under deny-all in rayito-base-caps, since 0.3.2 DNS for uid ≥ 1000 is also blocked (a port-53 ip rule ahead of the platform resolvers, which listen inside the guest), so neither the connections nor the DNS queries of the sandbox's processes leave the VM; residual risk: the guest layers are best-effort and do not resist root in the guest or a kernel exploit: the VPC connector remains the hard platform control |
| Injected secrets (T18) | off by default; with secrets= the value travels only in the per-call envs (never in runHookPayload, metadata, logs or errors) and is cached only in the SDK's memory; residual risk: the sandbox code can read it (phase 1) and it ends up in the snapshot if suspended (Secrets) |
| Metadata index (T19) | off by default; with index=DynamoDbIndex(...) only the (non-secret) metadata plus the image, startedAt and the TTL are copied to your DynamoDB table, never tokens, envs or secrets; a fake row never creates a phantom sandbox (the listing starts from list-microvms and requires the same image and startedAt); writer role (PutItem) separate from the reader (BatchGetItem) (Metadata index) |
| Lifecycle hooks forged from inside the VM (T2) | port 9000 never goes in a token; rayd checks which uid owns each connection to the hooks: a /terminate or /validate from a sandbox process answers 200 and does nothing, a /run from it does not consume the boot /run (so a template's start_cmd, which arrives before the platform, does not install its token), and after the first /run a /ready or /validate restarts no kernel; everything is recorded in hook_audit and in get_health().hook_anomalies; residual risk: forged /suspend and /resume are accepted (they count as an anomaly), and a /terminate that dodges the lookup is accepted but also counts |
| Secret gateway (T24) | off by default; with gateways= the value lives only in rayd, the upstream is fixed (https://host) and the allowlist decides before connecting; headers carrying the value are stripped from the response; residual risk: the body comes back uninspected, so an allow rule to an endpoint that reflects the request headers hands over the secret (Secret gateway) |
| Custom domain (T25, experimental) | off by default; with CustomDomain the CloudFront Function deletes every x-aws-proxy-* header from the viewer before setting its own, a route requires a traffic token unless an explicit public=True (only its sha256 is stored) and an expired route answers 404; residual risk: the route is not deleted by itself when the sandbox is killed (call unregister()) and each route's JWE lives in the KeyValueStore of your account (Custom domain) |
| Agent on AgentCore with no secrets gateway (T36) | --target agentcore is opt-in; the agent reads the runtime role's credentials, so that role is least privilege (Bedrock only on the models of --bedrock-model, Secrets Manager only on those of --readable-secret), with MMDSv2 always on and no secrets copied into the runtime (details) |
What not to put in envs or metadata¶
envs and metadata travel together in runHookPayload. AWS may record the
payload in CloudTrail data events and any principal with
lambda:CreateMicrovmAuthToken on the image can read the metadata through
Health; and so can the code running inside the sandbox itself, without any
credential: Health is the only anonymous RPC and rayd serves it on
0.0.0.0:8080, so a uid 1000 process in the sandbox reads its sandbox_id
and the full metadata map. They are labels and configuration, not
secrets: credentials go through sbx.files.write, through the envs= of
a specific command or, since 0.5.0, through secrets= (from Secrets
Manager, with cache; see Secrets), never in the creation
payload. Neither rayd nor the SDK write metadata keys or values to logs
(only the number of keys).
The E2B shim and the network¶
rayito.e2b.Sandbox reproduces E2B's defaults: public endpoint
(ingress=["ALL_INGRESS"]) and internet egress
(egress=["INTERNET_EGRESS"]). A MicroVM without an egress connector in
run-microvm inherits the image version's and still reaches the internet
(measured, AWS_API_NOTES.md Q44 and Q60), so since 0.3.0
allow_internet_access=False and network= are applied inside the
guest in rayito-base-caps and, on any other image, the SDK terminates
the VM and raises UnimplementedError: it never hands you back a sandbox
with the network open (Outbound network, SECURITY.md T17).
To close ingress: ingress=["NO_INGRESS"]; outside the guest, the allowlist
through a VPC connector with rayito.Sandbox(egress=[...]).
Supply chain¶
rayd runs as root inside each MicroVM, so what is built and published is
controlled end to end (details in SECURITY.md T10 and in the "Supply
chain" table):
- The base image is pinned by digest in
image/Dockerfile(FROM public.ecr.aws/lambda/microvms:al2023-minimal@sha256:05cb9b38…) and each publish passes--base-image-versionexplicitly. When AWS moves the tag, refreshing the digest is a Dependabot PR (docker) ordocker buildx imagetools inspect public.ecr.aws/lambda/microvms:al2023-minimal(without Docker: theDockerfile's own recipe with thepublic.ecr.awstoken andDocker-Content-Digest); a new digest is a new image version, published and accepted with the e2e like any other. - Dependencies audited in CI and every week:
cargo deny(licenses, advisories, sources),pip-auditover the image and SDK pins,pnpm audit --prod. raydis built withcargo auditable(embedded crate graph) and carries a CycloneDX SBOM; each release's assets are signed keyless with cosign, and PyPI and npm publish through OIDC with attestations. How to check it: Verify a release. The recipe binds the signature to the exact version you install (release.yml@refs/tags/rayd-v<version>), so an old asset or one signed under another tag does not pass it.- The job that builds has no credentials: signing (OIDC token) and uploading
to the release (
contents: write) are separate jobs that do not check out or run build tools, and verify withsha256sum -cwhat the build produced. Both wait for the maintainer's approval (releaseenvironment): without it there is no OIDC token or Sigstore certificate, and a dry run signs nothing. No job of the release, of the documentation site or of the e2e restores an Actions cache; zig,cargo-denyand uv are downloaded against a pinned sha256; the workflow never replaces a published asset, and publishing requires the tag's commit to be onmain. - A repository token with
contents: writecould replace a release asset (immutable releases are still off): that is why the Set up AWS recipe checks the signature andSHA256SUMSbeforerayito image publish, which verifies nothing by itself. - CI tools with dependencies (twine, pip-audit, cfn-lint) are installed from
requirements with
--hash, never withuvx, anduvnever re-locks an out-of-dateuv.lock(UV_LOCKED=1). - Dependabot waits 7 days (14 for a major) before proposing a freshly published version; security updates do not wait.
- The image artifact builder (
rayito image zip,make image-publish) rejects any symbolic link inimage/and inkernel-sidecar/: a link would put the contents of a file from the building machine into the image.
Persistence to S3 (T15)¶
With Sandbox.create(persist=S3Prefix(...)) the user's HOME travels to
your bucket. rayd does it as root with the execution role (which
persist= explicitly requires), not the sandbox code: in
rayito-base-caps uid 1000 still cannot reach IMDS. The role can only write
and read under <bucket>/<prefix>/* (infra/iam.yaml), never delete.
persist= also binds the sandbox to its bucket and its prefix: rayd
rejects with permission_denied any checkpoint or restore outside them, so a
prefix per tenant separates tenants even if they share a role. A sandbox
created without persist= binds nothing and its access token reaches any
destination its role reaches: do not give it a role that reaches the
persistence prefix. The restore runs with the user's identity and only
extracts regular files, directories and symlinks (no devices or hard links),
rejects .., absolute paths and parents that leave HOME, drops the
setuid/setgid/sticky bits and verifies the archive's sha256. Whoever can
write under your prefix can plant files in the next incarnation's HOME:
treat the prefix as part of the sandbox. Add an
AbortIncompleteMultipartUpload rule at 1 day to the bucket. Details in
Persistence and in SECURITY.md T15.
Custody of user secrets (T18)¶
secrets= (Secrets) is off by default and only the option
itself turns it on. When it is used:
- Channel: the value travels only in the per-call
envsof commands, PTYs, Python cells and code contexts, over the same authenticated channel as files (TLS to the AWS proxy,x-access-token); never inrunHookPayload,metadata, labels, the image's environment, the pool's records, logs,repror errors. - At rest: in Secrets Manager (KMS); on the client, only in the SDK
process's memory (
SecretCache, TTL 300 s by default: never one read per command); in the VM, in the environment of the processes that received it and in the memory snapshot if the sandbox is suspended with them alive (open question SEC-5). - Who reads what: reader IAM on the caller's credentials
(
infra/secrets-access.yaml), never the execution role. Phase 1: the sandbox code can read an injected secret. With untrusted code, inject only short-lived, least-privilege tokens, never long-lived credentials. - Logs: Rayito never writes the value or the name to its logs. In
Python,
import rayitodrops the DEBUG records of the botocore, boto3 and s3transfer loggers that write parameters and bodies (the measured list is in Secrets), and the agent host does the same inside the MicroVM. In TypeScript, aloggeryou set on theSecretsManagerClientdoes print the Secrets Manager bodies with the value in clear: do not set one in processes that handle secrets (Secrets). - The E2B shim's
Secret.fill()returns the placeholder, but Rayito does not resolve it anywhere.
Details in SECURITY.md T18.
Metadata copy at rest (T19)¶
With the optional metadata index (index=DynamoDbIndex(...)), each sandbox's
metadata is kept at rest in a DynamoDB table of your account
(infra/metadata-index.yaml), encrypted by default and with a TTL. It is the
same metadata that is already not secret (T4: Health returns it to any
principal that can mint a JWE and to the sandbox code itself), so do not put
in it anything you would not put in a label.
- What is stored:
pk(sandbox id), ARN and version of the image,startedAt,metadata, the SDK version andexpires_at. Never the access token or its hash,envs, secrets, therunHookPayloador the JWE. - Integrity: a fake row does not create a phantom sandbox; the listing
starts from
list-microvms(the state comes from there) and requires the same image andstartedAt(±1 s). Whoever can write to the table can change what metadata a real sandbox shows up with or hide it from an indexed listing: do not usemetadataas access control. - IAM:
RayitoIndexWriter(dynamodb:PutItem) for whoever creates sandboxes or runs a pool,RayitoIndexReader(dynamodb:BatchGetItem) for whoever lists; both over the table's ARN. - Turning it off: do not pass
index=; delete the stack to delete the table.
Details in SECURITY.md T19.
Events and webhooks (T22)¶
With events=/LifecycleEvents, rayd emits signed events on stdout and
three Lambdas in your account verify, store and deliver them to your
webhooks.
- Only the MAC authenticates. The forwarder derives the key from the sandbox named by the log stream and verifies the MAC before reading the content. The stream name does not prove identity: whoever has the execution role (readable from the default image, T1) or the build role can write to another sandbox's stream, but without its key the MAC does not pass.
- Only what
raydemits, and recent. Strict types, the samesandbox_idas the stream, fields shaped likerayd's and anoccurred_at_msless than 24 h old: a line repeated days later is not delivered again. pausedandresumedare indicative. The sandbox code (uid 1000, no root) can callrayd's/suspendand/resumehooks over loopback (T2), andraydthen emits a validly signedpaused/resumed. The forwarder only admits events that advance the sandbox's lifecycle and limitspaused/resumedto 20 in a row and one every 30 s per sandbox; the rest is counted and dropped. Do not bill or clean up resources just because of apaused.- One sandbox does not degrade the others. The reconciler queries an index with only the open sandboxes (never the whole table) and the deliverer is only invoked for new events.
- A bad line or webhook does not block the others. The bad line is counted and dropped; a webhook with a broken or slow URL fails alone. What could not be written or delivered after the retries stays in the stack's SQS queues.
- Two signatures.
e2b-signature(E2B's, byte for byte) carries no timestamp;rayito-signatureis an HMAC with the attempt's time and thewebhook_id. At the receiver, verifyrayito-signaturewith a 5-minute window, deduplicate by(sandbox_id, event_id)and use a random secret of 32 bytes or more. - Stack-wide webhooks. Each webhook receives the events of all the
sandboxes of its types: do not give
register_webhookto your customers. - Secrets: the webhook signing ones (
rayito/webhooks/) never reach a sandbox (RayitoSecretsReaderdenies them and the SDK rejects them insecrets=). Webhook URLs are stored in clear in your table. A rotation of a webhook's secret reaches the deliverer within 5 minutes at most (sooner if your receiver answers401/403). - Least privilege: each Lambda writes only its own rows and only in its
own log group. The caller's policies are per task:
EventsLauncherPolicyforevents=,EventsReaderPolicyforget_events(it sees no webhooks or the stack key) andEventsWebhookAdminPolicyfor the webhooks.
Details in SECURITY.md T22.
Who publishes images¶
infra/iam.yaml separates SandboxLauncherPolicy (launch and manage
sandboxes) from ImagePublisherPolicy (publish images); CallerPolicy is
the union of the two. Attach only the launcher one to a production
service's role: if it is compromised, it cannot publish a backdoored version
of the image that all your sandboxes will use. See IAM.
RayitoTemplateBuilder (Template.build()) is also a separate policy and
only reads rayito/* of the artifacts bucket, never the HOME checkpoints
or the transfers that share the bucket (T26). The optional size guardrail
(sizes-guard, T27) denies launching images outside its list and
publishing images, so nobody with that policy rebuilds an allowed image at a
larger size; an older, larger version that still exists under an allowed
name can be launched, so keep each name at a single size. See
Sizes.
S3 mounts and telemetry (T20, T23)¶
mounts=(T20) only exists inrayito-base-caps:mount-s3runs as a user of its own (uid 990), never receives a credential by argument or by environment, and only mounts buckets fromRAYITO_ALLOWED_MOUNT_BUCKETSat paths under/mnt/or/home/user/. The bucket name and prefix are not secrets: the sandbox code can see them.telemetry=withOtlpAuth.execution_role()(T23) gives the guest the execution role's credentials forPutMetricData, which cannot be scoped by namespace: a compromised sandbox can write arbitrary metrics. If you do not trust the sandbox code, useOtlpAuth.bearer(...).
What is never logged¶
File contents, executed code, PTY bytes, tokens, proxy headers, envs,
metadata, the hooks' body, nor the raw message of an AWS error (a
signature error carries the canonical string with the session token: the
SDK, the stacks, Template.build and the CLI only show its sanitized
summary); nor presigned URLs, buckets, keys or paths of a transfer, file
metadata, egress policy entries, proxy destinations or git credentials, nor
the JWE, the headers, the bodies or the paths of what goes through
rayito sandbox proxy, nor secret values or names, nor the prompt,
instructions or text of an sbx.agent agent (its errors carry a closed
reason and a fixed-table message, and the optional rayito.agent.run span
only gen_ai.* and token attributes). Only ids, status codes, counts and
durations.
HTTP clients in the agent host logs¶
The HTTP client libraries most agents use (httpx and its httpcore layer)
log one INFO line per request with the full URL, query string included. Some
APIs carry the credential in the query (a webhook with ?token=..., for
example), so with logging at INFO that credential would end up in the agent
output and, from there, in CloudWatch or any log collector.
The agent host (rayito agent deploy, on MicroVM and on AgentCore) raises
those two loggers to WARNING at startup, right after it sets up logging at
INFO: their warnings and errors still come through, but the per-request lines
do not. There is no option to turn this off. If your agent needs those lines
while you debug, lower them from its own code with
logging.getLogger("httpx").setLevel(logging.INFO), and first make sure no
URL it uses carries credentials in the query. The same applies to any other
library that logs URLs: if you use one, raise it to WARNING yourself.
rayito sandbox proxy¶
The local proxy (rayito sandbox proxy <id> --port N, CLI)
mints the same JWE the SDK's gRPC channel uses, but scoped to a single
port (PortSpec.single(N), never allPorts) and never to 9000, the
lifecycle hooks' (ADR-006): validate_proxy_port rejects it before touching
AWS. The listener binds to 127.0.0.1 by default; --bind outside loopback
requires --allow-remote and warns on stderr. It strips any x-aws-proxy-*
header the client brings before forwarding the request, so a local client
cannot impersonate the AWS proxy's authentication.
Web pages open in the operator's browser: since the proxy adds the JWE
to everything it forwards and the guest only sees Host: <endpoint>, any
web page could use the sandbox's service through DNS rebinding, with a
cross-site POST or with a cross-origin WebSocket (with Jupyter,
code-server or a web terminal, that is running code in the sandbox). Since
0.7.0 the proxy only forwards requests whose Host is loopback with the
local port, the --bind address, <id>.localhost or an --allowed-host
(otherwise 421) and whose Origin, if present, is one of those origins or
an --allow-origin (otherwise 403); at most --max-connections (8)
connections at a time (503 afterwards). For each connection it forwards
only one message: the first rewritten request, with its body delimited
by Content-Length or chunked (an ambiguous delimitation is 400);
nothing the client sends afterwards reaches the upstream, and an upgrade the
guest does not accept with 101 is answered with Connection: close and
closed, instead of being left as an unfiltered tunnel. Cookies: they are
not separated by port, so what the sandbox serves at
http://127.0.0.1:<port> receives the cookies of your other local apps and
shares a "site" with them; open it at http://<id>.localhost:<port> (its own
jar, the CLI announces it) or in a dedicated browser profile
(Local proxy).
Both CLIs, the same guarantee: the TypeScript CLI's proxy
(npx rayito sandbox proxy) runs the same vectors as the Python one
(testdata/cli/proxy-vectors.json: headers with a bare CR or LF, obs-fold,
names outside the tchar set, body delimiting, Host/Origin, ports) and
they share the caps and deadlines of limits.json. The whitespace trimmed
around a header value is only OWS (space and tab), not everything a
language calls whitespace: a Content-Length: 12 followed by a \x85 is
invalid in both. A Content-Length or chunk size that does not fit in a
JavaScript safe integer is 400 in the TypeScript CLI.
Residual risk (SECURITY.md T2/T3): while the proxy is running, any process
that reaches the forwarded local port —on the operator's machine, or on
another if --allow-remote was used— has the same access to the sandbox as
the operator. A SUSPENDED sandbox with auto-resume wakes up on the first
request that reaches it (it bills compute, like any resume).
Local management dashboard (rayito dashboard)¶
rayito dashboard (guide)
serves a page and a JSON API on 127.0.0.1 with your AWS credentials.
Any web page you open in the same browser could try to use them, so the
server bounds the body (64 KiB at most, 413 if exceeded) and then applies,
in this order and before interpreting it:
HostandOrigin: the same list as the local proxy (127.0.0.1,localhostand[::1]with the dashboard's port). A foreignHostis421(DNS rebinding); a present and foreignOrigin, includingnull, is403.- Session: the startup URL carries a single-use token (with an expiry)
that the server exchanges for an
HttpOnly,SameSite=Strict,Path=/cookie and redirects to the clean URL withReferrer-Policy: no-referrer. The cookie's name includes the port, because cookies are not isolated by port. Without the cookie nothing answers, neither the page nor the static files. Sec-Fetch-Site: on/api/v1, if the browser sends the header and it is neithersame-originnornone, the request is403. It covers whatOrigindoes not see: an<img>or a navigation from the web that a sandbox serves withrayito sandbox proxyat127.0.0.1:<another port>issame-site, shares the cookie jar and sends noOrigin. In addition, the proxy does not forward therayito_dashboard_*cookies to the sandbox.- CSRF: every
POSTrequiresContent-Type: application/json(a foreign web page cannot send it without a preflight) and theX-Rayito-CSRFheader with the session's token, which is only handed to whoever already has the cookie. The billed-cost query (GET /api/v1/cost?billed=true) also requires the token, because even though it is aGETit costs money each time. - Read-only by default: without
--allow-actionsthe server rejects any action with403before touching AWS. With the option, each action requires"confirm": true, is validated (ids, per-request cap) and goes into a queue with a single worker that respects the TPS. - Browser: CSP without
unsafe-inlineorunsafe-eval(the page is modules and style sheets of the same origin),nosniff,no-store,X-Frame-Options: DENYandCross-Origin-Resource-Policy: same-origin. The page writes everything withtextContent: no AWS data or log line is interpreted as HTML. - User theme and logo: validated before starting (closed keys, color
values, length and font with a strict grammar, SVG without code or external
references), the CSS is generated by the dashboard from the validated
values and served from the same origin under the same CSP; the logo, in
addition, with a CSP of its own (
default-src 'none',sandbox). - What does not leave: no tokens, JWE,
envsorrunHookPayload(they do not exist in the model), no AWS message in errors (only its code), no URL with the token in any log. The server does not log requests. - Cost: the billed cost (Cost Explorer, paid) is only queried with
--cost-explorer, when pressing the button and with the CSRF token.
Residual risk: while the dashboard runs, any process on your machine that
reads the printed URL (for example from the terminal history before it is
used) or that obtains the cookie has the same access as you; the token is
spent on first use. With --allow-actions that includes killing sandboxes.
A sandbox's logs are what that sandbox wrote and are shown as is (as in
rayito sandbox logs): if your code prints secrets, the dashboard will show
them.
Management dashboard, phase 3: multiple accounts and quota alerts¶
Summary of T33 of
SECURITY.md;
the guide covers usage.
- Read roles, not action roles.
rayito dashboard --accountassumes the role you created withdashboard-read-rolein the target account: only five reads (MicroVMs, images, versions and quotas), nothing that writes, no Cost Explorer and no logs. It trusts only the role or user you name (never an account root) and, withExternalId, only if it is presented. - Always read-only. Additional accounts have no actions even if you
start with
--allow-actions; an unknown?account=is a400with no AWS call. - No ARN on the page. The API and the messages only know the label you chose; an STS rejection is shown with its code and without its message.
- Short and visible session. The assumed session is not requested until
it is used, lasts 3600 s and leaves
rayito-dashboardas the session name in the target account's CloudTrail. - Quota alerts: the function only reads and writes in its topic, its state row and its metrics namespace; the message carries no ids or names, and an SNS failure is not counted as sent.
- CloudWatch dashboard: the prefix and the groups are validated against a closed list of characters before entering a query.
Management dashboard in your account (the dashboard stack)¶
The dashboard stack (guide)
publishes a Function URL without AWS authentication (AuthType NONE,
because a browser does not sign with SigV4), so anyone on the Internet can
call it: the protection is that the function validates identity before
doing anything. This is the summary of T32 of
SECURITY.md.
- Identity before anything. Each request to
/api/v1carries a Cognito access token inAuthorization. Before routing or calling AWS, the function checks the format and size, that the algorithm isRS256(noneand the others are rejected), the signature against the group's JWKS (with the standard library, comparing the whole encoded message), the issuer,token_use=access, theclient_id, the expiry,nbfandsub. Without a token,401. If Cognito cannot be queried,503, never "pass". Only the static page and/config.json(the Hosted UI domain and the client id, both public) do not ask for a token. - Two roles. The function runs with a read-only role. Killing,
suspending and resuming are done by an actions role that only trusts
the function's role, which assumes it after checking the user's group
and with their
subin the session name. WithoutAllowActions=truethat role does not exist. - Audit that cannot be rewritten from the application. One row per
sandbox and action, written before executing (if it fails, the action
does not run), with who, what, when, request id and IP. Conditional writes
and no
UpdateItemorDeleteItem(the template denies them). - No ambient session. The token goes in the header, in memory, not in a
cookie: a foreign web page has nothing to reuse. Each
POSTchecksOriginandapplication/json, and the URL enables no CORS. - Theme and logo: the same validators as the local dashboard; an invalid one is discarded and the factory one is served.
- Cost: the billed cost only with
EnableCostExplorer=true. The 24 h cache in DynamoDB is not enough (its key carries the window and the region, which are chosen by whoever asks), so the function only accepts windows of whole months within the last 13 months and known regions, and spends a budget of 20 paid queries per UTC day for the whole stack (not per user) that, once exhausted or if it cannot be checked, stops billing.ReservedConcurrencybounds an avalanche of unauthenticated requests. - Actions with live identity: before assuming the actions role, the function checks in Cognito that the user is still enabled and in the group; if Cognito does not answer, no action is taken.
- Nothing secret in logs: not the token, not
Authorization, not the body.
Residual risk: an access token already issued cannot be revoked (the function
validates it offline): it is valid until it expires
(1 hour) even if you disable the user,
remove them from the group or close their sessions
(admin-user-global-sign-out only prevents renewing it). Until then it can
read, but not act, because each action is checked in Cognito; whoever
administers the account can alter the audit table; and the address is public,
so what protects it is the token validation, not the network.
Coding agent inside the sandbox¶
Summary of T29 and T30 of
SECURITY.md
(ai-agent-core), the threat model of
sbx.agent.
The agent itself (T29). opencode run --auto answers "yes" to any
permission the model asks for, and deepagents' default backend
(LocalShellBackend) runs whatever the model asks without prompting:
the AgentPermissions/AgentSpec permissions are not a security
boundary, they are a preference that a hallucinating model, or a prompt
injection from a file in the workdir itself (an AGENTS.md or a cloned
repository with hidden instructions), can bypass by asking for another tool
or rephrasing the request. The real boundary is the one that already
protects any other code running inside the sandbox: the MicroVM and closed
egress, which requires passing allow_internet_access=False explicitly
(Sandbox.create leaves it open by default; the guide's recipes pass it).
Exfiltration can only leave through the upstream that the secret gateway
allows, never directly. Residual risk: code inside the sandbox can call
the gateway itself on its own, outside the SDK's token budget (there is no
way to tell, from the gateway, a call made by the agent's runtime from one
made by the model asking it to run curl). Mitigations: restrict allow to
the (method, path) of the exact models you use (bedrock_gateway never
opens /model/*; gemini_gateway only opens the given models) and
rate_per_minute. In OpenAI-style APIs (OpenAI, Azure OpenAI, xAI,
OpenRouter, Groq, Mistral, DeepSeek, LiteLLM) the model goes in the body and
the gateway cannot limit it: set the spending cap at the provider (see
Agent providers).
Consumer subscriptions (ChatGPT plan, Claude Pro/Max, Copilot, SuperGrok) are
not supported: see
Unsupported providers.
The runtime's supply chain (T30). OpenCode and ripgrep are installed by
version and sha256 pinned in limits.json, which the template build checks
with sha256sum -c before installing; the deepagents venv is installed with
--require-hashes; the deepagents runner's sha256 (runner_sha256) is
recorded in the template manifest (/opt/agents/rayito-agent.json);
AgentSpec.runtime_version is today only validated as text and is not
compared against that manifest. Autoupdate, model downloads, LSP downloads,
the default plugins and reading a workdir's .claude/ are off
(autoupdate: false, share: "disabled", OPENCODE_DISABLE_CLAUDE_CODE=1);
none of that goes out looking for something on the Internet inside a sandbox
with closed egress, and if it tried, it would fail like any other disallowed
outbound connection.
AgentCore: no secrets gateway (T36)¶
Summary of T36 of
SECURITY.md
(agentcore-deploy, since 0.15.0): the threat model of
Deploy to AgentCore.
The difference. In a Rayito MicroVM the agent never sees the model credential: the secrets gateway adds it when forwarding the request. On AgentCore there is no Rayito secrets gateway: the agent gets credentials from the runtime role through the microVM metadata service, and any code running in the session can read them: yours, the framework's and a tool the model decides to run. The «the agent cannot read the credential» guarantee does not exist here; what replaces it is least privilege.
What the runtime role can do (the one infra/agentcore-runtime.yaml creates,
one per agent, which only AgentCore can assume in your account and region):
- pull its image from ECR and write to its log group
(
/aws/bedrock-agentcore/runtimes/<runtime>-*); - send traces to X-Ray and metrics to the
bedrock-agentcorenamespace; - get workload identity tokens (
GetWorkloadAccessTokenandGetWorkloadAccessTokenForJWT) on its runtime's directory and identities, withoutGetWorkloadAccessTokenForUserId; - invoke in Bedrock (
InvokeModelandInvokeModelWithResponseStream) only the ARNs of--bedrock-model(by default, the project's model; with no model, no Bedrock permission); - read (
secretsmanager:GetSecretValue) only the secrets of--readable-secret(by default, none).
It can do nothing else. What the agent tries with those credentials, such as calling another model or reading another secret, IAM denies.
What the role does not change.
PUBLICnetwork. The runtime has unfiltered Internet egress: Rayito's egress policy (egress,network) does not apply, which is why a project that declares it is not deployed (network_policy_unsupported). Code that reads the role's credentials can send them anywhere; if you do not want that risk, give the role nothing you cannot afford to lose.- MMDSv2 always on. AgentCore requires version 2 of the metadata service
(
requireMMDSV2) since 2026-06-30 and refuses to invoke a runtime without it. Rayito enables it right after creating the runtime and on every update. - No secrets in the runtime. The runtime's environment variables can be read
with
GetAgentRuntime: Rayito never copies secret values there, nor the key of a provider other than Bedrock (provider_unsupported). The agent reads what it needs with the role, at run time. - Sessions and users. Each session runs in its own microVM, isolated from the
others, but AgentCore does not tie sessions to users: who may continue which
session (which
session_ideach person may use) is up to your backend. - Authentication. Invoking a runtime requires
bedrock-agentcore:InvokeAgentRuntimeand a SigV4 signature: there is no access token orRAYITO_AGENT_SERVICE_TOKEN(do not set it on this target).
The build. The context zip is uploaded to rayito/images/agentcore/<name>/ in
the artifact bucket, deleted as soon as the build ends (successfully or not) and,
if any is left, the rayito setup bucket expires it after 7 days. The CodeBuild
role only reads that prefix, only writes to its log group and only pushes images
to its repository, which has immutable tags and scan on push. The base image is
pinned by digest.
What rayito agent delete deletes. Only what Rayito created for that name: a
runtime without the rayito.agentcore/1 marker in its description is never
touched (name_conflict).
Residual risk. Whatever the role allows, any code in the session can use; an
organization with SCPs that require tags needs --tag, and the tags of an
already created runtime are not updated.
Model outside Bedrock (--external-model). It is opt-in: without the
option, a provider other than bedrock is still provider_unsupported. With
it, the agent calls its LLM gateway over the PUBLIC network with a key its
code obtains (--readable-secret or --secret-env): the role gets no Bedrock
permission nobody asked for, the key never goes into the runtime environment
variables (only the base URL, which is not a secret) and Rayito neither sees it
nor filters that traffic. The plan says so with the externalModel line.
CDK constructs: custom-resource and image-build roles (T37)¶
Summary of T37 in
SECURITY.md
(agentcore-cdk, ADR-032, since 0.15.0): what the
constructs in Deploy with CDK add to what the
CLI already creates.
The agent's role does not change. RayitoAgent deploys the same
infra/agentcore-runtime.yaml template as rayito agent deploy, so the runtime
role is the same, with the same least privilege as AgentCore.
What is new are two pieces that the CLI did with the deployer's credentials
and that CloudFormation does here:
- The custom-resource functions (
Custom::RayitoImageBuildandCustom::RayitoRuntimeMmds, code ininfra/agentcore-cdk-handler.py). Their role can only callcodebuild:StartBuildandBatchGetBuildson that agent's CodeBuild project,ecr:DescribeImageson its repository,bedrock-agentcore:GetAgentRuntimeandUpdateAgentRuntimeon its runtime, andiam:PassRoleonly on the runtime role (required byUpdateAgentRuntime). It cannot create or delete runtimes, pass any other role, or read images, secrets or models. - The MicroVMs image build role (
RayitoMicrovmImage). Only Lambda in your account can assume it (aws:SourceAccount), it reads only the zip asset object and writes only to its images'/rayito/<name>log groups. It does not use the sharedrayito-m0-iamrole.
The code asset. The agent's CodeBuild build role can read, besides its usual prefix, the exact object of the code asset in the CDK bootstrap bucket; nothing else in that bucket.
Who can change what. The runtime carries the marker
rayito.agentcore/1+cdk and the image the tag rayito:managed-by=rayito-cdk:
rayito agent deploy, rayito agent delete and rayito image publish refuse
to modify them before writing anything (not even the artifact in S3), so the
CLI cannot silently undo what the stack declares.
Residual risk. Deployment runs with the CDK bootstrap execution role,
which has AdministratorAccess by default: whoever can run cdk deploy in the
account can change the runtime role. If that is not acceptable, restrict the
bootstrap (Restricted bootstrap).
The CloudFormation resource has no MMDSv2 property, but AgentCore already
creates every version with MMDSv2 on; Mmds checks it on every new version
and would only turn it on outside CloudFormation (where drift detection could
flag it) if one ever came without it. Assets (the agent code, the image zip) stay in the bootstrap
bucket until cdk gc.
Secrets as environment variables (T38)¶
Summary of T38 from
SECURITY.md
(agent-secret-env, ADR-033 D4): what changes when an agent receives Secrets
Manager values as environment variables (guide).
Only names in the project, the plan and the image. [secret_env] and
[[secret_env_from]] hold the variable name and the secret name or ARN, never
the value. On AgentCore the runtime only receives RAYITO_SECRET_ENV (names
and ARNs) and the host removes it from its environment at startup; on MicroVMs
the values travel only in the host process environment, never in a file or in
the image. No log, error, plan or repr carries a value or a secret name: the
host only logs secret_env_loaded count=N, fails closed with a code
(secret_env_*) and redacts the injected values, short ones included, from the
startup diagnostics.
Least privilege. On AgentCore the referenced secrets join those of
--readable-secret in the same ReadSecrets statement: the role can only
secretsmanager:GetSecretValue on those ARNs (with the six-character Secrets
Manager suffix), with no kms:Decrypt, no ListSecrets and no
secretsmanager:*; a test fails if it is widened. An ARN of another account or
region is iam_resource_invalid. On MicroVMs the SDK reads them with your
credentials.
Reserved names. A secret cannot set PATH, HOME, PYTHONPATH,
NODE_OPTIONS, anything starting with AWS_, LD_ or RAYITO_, or the
variables the runtime already sets: it is refused in the plan and again in the
host.
Residual risk. An environment variable can be read by all the agent's
code: the process and anything it starts (/proc/self/environ, a dump). On
AgentCore, also, any code in the session can ask the metadata service for the
role credentials and read those secrets. On MicroVMs, whoever holds the sandbox
access token sees the process environment with commands.list(), and a pause
keeps the values in the memory snapshot. For a model key on MicroVMs, the
secret gateway is still better:
the agent uses the key without being able to read it.