Secrets
How SingulaComp stores project credentials, and how it controls whether agent code can read each value.
A secret is a per-project credential — an API key, token, or connection string — that a session needs but that must not live in the repository. SingulaComp stores secrets on the project, never on the account, and encrypts every value with AES-256-GCM using a key derived per project.
Identifier and name
Each secret has two names. The identifier is the handle you use in the
CLI and in an agent's secrets grant. The name is the uppercase
environment-variable key in the sandbox, for example STRIPE_API_KEY. In
most projects the identifier and the name match. They differ only when a
project holds several candidate values for one name — for example, a
primary and a backup Google Maps key can both resolve to
GOOGLE_MAPS_API_KEY.
Every access rule uses the identifier. None of them uses the name.
Exposure and usage
A secret carries two independent settings. Read them separately; they answer different questions.
| Setting | Question it answers | Values |
|---|---|---|
| Exposure | Can agent code read the real value? | environment, egress-enforced, none |
| Usage | Who spends it? | Agent code, LLM gateway, Connector, Git |
Older SingulaComp documentation presented one list that mixed the two. It is gone. There is no choice between a "network boundary" and an "HTTPS broker": one mechanism serves every egress-enforced secret on every sandbox provider.
The three exposures
| Exposure | What the sandbox holds | Use it for |
|---|---|---|
| Environment | The real value, as a plain environment variable | The default. Values the agent must compute with, and protocols that are not HTTPS |
| Egress-enforced | A handle — a self-describing placeholder, worth nothing on its own | Experimental. HTTPS calls, once the secrets_egress flag is on |
| None | Nothing at all | A credential only a SingulaComp service spends, or a value kept on file and disabled |
The working rule
Environment is the default exposure. The real value loads into the sandbox,
where the agent can read, print, and forward it. Egress-enforced keeps the
value outside the sandbox, but it is experimental: enable the secrets_egress
feature flag (Settings → Feature flags) to use it. Until then, a secret loads
into the sandbox environment.
Usages
Most usages are assigned by SingulaComp, not by you:
- Agent code — implied whenever exposure is not
none. - LLM gateway — assigned when the value is a recognized model-provider key. The gateway authenticates provider requests server-side.
- Connector:<slug> — assigned by the connector binding flow.
- Git — assigned by SingulaComp for its own Git access. Read-only; you cannot set or clear it.
A secret with exposure none and no usage renders as Disabled: stored,
encrypted, and spent by nothing.
Sent secrets and computed secrets
Which exposure a secret can use is a property of the upstream, not of SingulaComp.
- Sent secrets — the value travels on the wire. API keys, bearer tokens,
passwords. This is the vast majority. There is a moment where the value is
bytes in an outbound request, so SingulaComp can put it there itself, outside the
sandbox. A sent secret can move to egress-enforced once the
secrets_egressflag is on; by default it loads into the sandbox environment. - Computed secrets — the value is an ingredient in a calculation and never travels. AWS SigV4 signing keys, HMAC webhook-signing secrets, JWT client assertions, SSH private keys. Whoever computes must hold the value. No network boundary helps, because nothing on the wire contains the credential. Computed secrets must stay on environment.
Environment is the default exposure, and the only one that can serve a credential the sandbox has to do math with. Non-HTTPS protocols — a Postgres connection string, SMTP credentials — are in the same position: they must stay on environment.
When you save a value that looks like signing material, such as an AKIA…
access-key pair or PEM/SSH material, SingulaComp defaults it to environment and
says why: this key signs requests locally, so egress enforcement cannot apply.
Egress-enforced exposure
Experimental — needs the secrets_egress flag
Enforcement at the network is experimental. Enable the secrets_egress feature
flag (Settings → Feature flags) to use it. Until then, a secret loads into the
sandbox environment. With the flag off, creating an egress-enforced secret
returns 403 feature_disabled.
The sandbox receives an environment variable whose value is a handle, not
the credential. The agent uses that variable exactly as it would use the real
key — in a header, a query string, or a body. Authorization: Bearer $VAR,
Cookie: …=$VAR, an X-Api-Key header, a query parameter, and a JSON or form
body field all work: SingulaComp finds the handle wherever it appears (raw,
URL-encoded, standalone base64, or JSON-escaped) and swaps in the real value.
On the way out, SingulaComp replaces the handle with the real value, but only for
requests to hosts you approved.
Send the handle as-is — do not base64 it yourself
One thing does not work: hiding the handle inside a base64 blob you build
yourself, which is what HTTP Basic auth does (curl -u $VAR: →
Authorization: Basic <base64>). SingulaComp cannot find a handle that is embedded,
unaligned, inside base64 you encoded, so the swap never happens and the upstream
answers 401. Put the handle in a Bearer/token header, a query parameter, or a
body field instead. If the API only supports Basic auth, use environment
exposure for that secret.
agent's ordinary HTTP client
└─▶ in-guest shim (terminates TLS for approved hosts only; holds no secret)
└─▶ SingulaComp, server-side
host allow-list → resolve grant and session allowlist → decrypt
→ substitute handle → call upstream → redact echoes → auditFacts that follow from that shape:
- The real value is never in the sandbox: not an environment variable, not a file, not an alias.
- A handle sent to a host you did not approve arrives as the literal handle string. The upstream rejects it. It is worth nothing.
- A handle with a bad signature is never honored, and SingulaComp records it as a forged handle. A valid handle for a secret this session may not spend is recorded as a stolen one.
- The mechanism is identical on every sandbox provider — Daytona, E2B, and Platinum. There is no flag to turn on and no provider to pin.
- Every relayed request writes a per-request audit record.
- Hosts that are not on the list are tunnelled without being read. Pinned-TLS and mTLS clients to those hosts are unaffected.
Hosts match exactly
List every host you call, one exact hostname per line.
| Rejected | Reason |
|---|---|
A wildcard host, *.example.com | The agent must never choose the destination |
| A URL scheme other than HTTPS | SingulaComp terminates TLS to substitute |
api.example.com does not cover uploads.api.example.com. Add the second
host to the same secret. SingulaComp rejects an unenforceable policy with 400
when you save it — it never stores a rule it cannot apply.
Two egress-enforced secrets may share one host. Each handle maps to its own value, so both substitute correctly in the same request.
Verify it with two probes
Run both probes from inside the sandbox, against a host that is on the list.
The example uses postman-echo.com, which serves one endpoint of each kind.
Add it as an allowed host for the duration of the test.
# 1. Reachability — an endpoint that does NOT echo request headers.
curl -s -o /dev/null -w '%{http_code}\n' https://postman-echo.com/status/200
# expected: 200
# 2. Substitution — an endpoint that DOES echo request headers.
curl -sS -H "authorization: Bearer $STRIPE_API_KEY" https://postman-echo.com/get
# expected: 200, with "Bearer [REDACTED]" in the echoed headersRead the pair together, probe 1 first: it is the only one that tells you the host is reachable, and probe 2 proves nothing until it passes.
| Probe 2 result | Meaning |
|---|---|
200, echoed header shows [REDACTED] | Working. The real value went upstream and the echo was redacted on the way back. |
200, echoed header shows the handle itself | The substitution did not run. Check the host list and the agent grant. |
401 from a real API host | The substitution did not run. Same two checks. |
| An empty reply or a connection error | A real failure. This is not a success symptom. |
Confirm the real value is nowhere in the guest:
env | grep '^STRIPE_API_KEY='
# expected: the handle, not the credentialThe identifier also appears inside SINGULACOMP_SECRET_CAPABILITIES, the value-free
catalog that tells the agent which secrets exist, which variable holds each
handle, and which hosts each one covers. It never carries a value.
A host on the list presents a certificate SingulaComp issued for this sandbox, which the sandbox already trusts, rather than the origin's own:
curl -sv https://postman-echo.com/status/200 2>&1 | grep 'issuer:'
# issuer: CN=SingulaComp Egress CA (<project prefix>); O=SingulaCompWhat the relay changes
An approved host is reached through SingulaComp, so it behaves a little differently from a direct call:
- Responses are not streamed. Server-sent events and websockets do not work through an approved host.
- A request body is capped at 1 MiB, a response at 5 MiB, and the whole call at 30 seconds. SingulaComp follows at most 3 redirects.
- Only a fixed set of response headers comes back — content type and language,
caching validators, the rate-limit family,
retry-after,x-request-id. - Only clients that honour
https_proxyare intercepted. SingulaComp sets that variable, and the matching CA trust, in the agent's environment. A process started with a scrubbed environment calls the host directly, and its request leaves carrying the handle.
For a request SingulaComp cannot intercept, the agent has an explicit door to the same hosts under the same policy:
singulacomp secrets call STRIPE_API_KEY https://api.stripe.com/v1/customersEnvironment exposure
The sandbox receives the real value as a plain environment variable. Agent code can read it, print it, and forward it anywhere. SingulaComp cannot redact it, cannot audit its use, and cannot stop it leaving.
Environment is the default exposure. Every new secret uses it unless you move
the secret to egress-enforced, which is experimental and needs the
secrets_egress flag. A computed credential, and any non-HTTPS protocol, must
stay on environment.
Shared and personal scope
A secret is either shared or a personal override:
- Shared — the project-wide value. Every principal with the
project.secret.readpermission sees every shared secret. Who may read a secret is the permission; which agent receives it is the manifest grant below. - Personal override — your own value for one name, used instead of the shared row for sessions you start. Today SingulaComp uses this only for one OAuth login credential.
SingulaComp never lets an LLM provider key, for example ANTHROPIC_API_KEY,
become a personal override. The model gateway always reads the shared row.
Add a secret
Set the value from the CLI
singulacomp secrets set STRIPE_API_KEY=sk_live_...SingulaComp saves it as a shared secret for the project. To store more than one
value under the same name, add --identifier <id>. Names can't start with
SINGULACOMP_ — SingulaComp reserves that prefix for platform values.
Or use the dashboard
Open the project's Secrets page, enter the key and value, and save. SingulaComp encrypts the value immediately and defaults every new secret to environment exposure — the real value loads into the sandbox.
To move a secret to egress-enforced exposure, first enable the secrets_egress
feature flag (Settings → Feature flags); it is experimental. With the flag on,
the Secrets page shows the "Can your code read this value?" control:
answering no moves the secret to egress enforcement and shows the host list;
answering yes keeps environment exposure. With the flag off, a secret stays
on environment.
Grant it to an agent
Pick an agent on the Secrets page, or add the identifier to that agent's
secrets list in singulacomp.yaml yourself. A session only receives the secrets
its agent is granted. See Grant a secret to an
agent.
Grant a secret to an agent
A session receives a secret only when the agent it runs names the identifier in
its secrets list. Matching uses the identifier, not the name, and ignores
case.
Egress-enforced and service usages need a named grant
secrets: all grants environment exposure only. An egress-enforced secret, and
any secret a SingulaComp service spends, needs its identifier written out in an
agent's list. A project with no agents: block in singulacomp.yaml never receives
one. An ungranted secret is dropped silently: the session starts normally and
the first call to the host fails as though the credential were wrong.
From the dashboard
The Secrets page marks a secret no agent can receive: No agent can receive
this secret. Choose an agent there and confirm. SingulaComp edits singulacomp.yaml and
commits it as chore(agents): grant <IDENTIFIER> to <agent>.
The grant works whether or not the manifest already declares that agent. An
agent the manifest does not declare gets a new entry holding this one secrets
list. An agent that is already declared keeps every other field — model, tools,
connectors — and the identifier joins its existing list. An agent that already
admits the identifier needs no commit, and SingulaComp makes none.
An agent on secrets: all is a special case. all cannot carry an
egress-enforced secret, so SingulaComp writes an explicit list: every identifier the
project has today, plus this one. Nothing the agent receives today changes. A
secret you add later needs its own grant.
Two cases refuse the grant:
- A project on
singulacomp_version: 1(singulacomp.toml) has no agents map to edit. The request fails with400andmanifest_v1_unsupported. Edit the manifest by hand, or move the project tosingulacomp_version: 2. - A secret that is Disabled has nothing to deliver. The request fails with
409andsecret_not_grantable. Give it an exposure first.
If the project has no agents: block yet, read The first agents: block
changes the whole
project before you confirm.
That one edit changes secret access for every other agent.
By hand
The same grant, written directly:
singulacomp_version: 2
agents:
my-agent:
secrets: [STRIPE_API_KEY]The first agents: block changes the whole project
Declaring one agent denies the rest
A project with no agents: block — or with no singulacomp.yaml at all — is
ungoverned: every agent receives every environment-exposure secret, and no
agent receives an egress-enforced one.
The moment the project declares its first agent, every agent that is not listed receives no project secret at all — including environment secrets that worked a minute earlier. Listing one agent revokes the rest.
So list every agent that needs secrets, not only the one you are fixing. This
is why the dashboard asks you to confirm the first time: after that commit,
agents: is the project's allow-list, and an agent missing from it runs with no
project secrets.
Exposing capabilities to third-party users
SingulaComp secret policies are not a multi-tenant authorization system
A secret policy protects your project's own agent from leaking your own credential. It says nothing about which of your end users may spend it.
If you are building something where untrusted third-party users reach a capability — a public chat surface, a shared app, an agent anyone on the internet can prompt — do not hand them secret policies at all. Every user of that surface shares one project agent, one grant, and one host list. SingulaComp has no way to tell one of your customers from another, so an egress-enforced secret that any user's prompt can reach is a credential every user can spend, up to the full scope the upstream key carries.
Build the boundary you actually need, on your side:
- Stand up your own authorization and proxy service. It holds the upstream credential.
- Point the agent at your service, not at the upstream. Give the agent only a credential for your service — that one can be egress-enforced to your own host.
- Your service identifies the end user, applies your own authorization rules and per-user quotas, and only then makes the upstream call with the credential it holds.
That service is where per-user rules belong: who may call what, how often, for which records. SingulaComp secret exposure is one layer below it, and it does not substitute for it.
List your secrets
Run singulacomp secrets ls to see which secrets a project declares and which
ones have a value set.
The list is configuration metadata. It never returns secret values. A scoped
agent token sees only identifiers in its agent grant. A session-specific
secrets_allowlist controls delivery into that session, but it does not hide
configuration metadata that the agent grant permits.
Rotate a secret
Set a new value
Run the same command with the new value, or set it again on the project's Secrets page:
singulacomp secrets set STRIPE_API_KEY=sk_live_new...SingulaComp pushes it to running sessions
SingulaComp pushes the change to every sandbox with an active session for the project, on a best-effort basis. For model or gateway credentials, SingulaComp restarts the OpenCode compatibility process.
Rotating an egress-enforced secret needs no push of the value at all: the sandbox holds a handle, and SingulaComp reads the current value when the next request comes through.
Remove a secret
Run singulacomp secrets unset STRIPE_API_KEY (or unset <identifier>), or
delete it from the project's Secrets page.
Removal is immediate, propagation is not
SingulaComp deletes a shared secret right away. Push to already-running sandboxes is best-effort, the same as rotation.
Share a value without seeing it
Run singulacomp secrets request STRIPE_API_KEY to create a link. Anyone with the
link can enter the value. You never see it. Links stay valid for 7 days by
default; adjust with --expires <minutes> (max 30 days). An expired link shows
a clear "expired" page — mint a fresh one with the same command.
End-to-end example
singulacomp.yaml:
singulacomp_version: 2
default_agent: my-agent
agents:
my-agent:
secrets: [STRIPE_API_KEY]Secret configuration. The first command stores the value with the default
environment exposure. The second moves it to egress-enforced exposure, which is
experimental and needs the secrets_egress flag (Settings → Feature flags); it
returns 403 feature_disabled while the flag is off.
singulacomp secrets set STRIPE_API_KEY=sk_live_...
singulacomp secrets delivery STRIPE_API_KEY egress --allow-host api.stripe.comThe agent then calls Stripe with the variable it was given:
curl -s -o /dev/null -w '%{http_code}\n' \
-H "authorization: Bearer $STRIPE_API_KEY" \
https://api.stripe.com/v1/customers
# expected: 200$STRIPE_API_KEY holds a handle. Stripe receives the real key, because
api.stripe.com is on the list. The same request to a host that is not on the
list sends the handle, and the upstream rejects it.
The same request from an agent whose secrets list omits STRIPE_API_KEY
returns 401. That session starts normally — an ungranted secret is not an
error, it is simply never delivered.
CLI commands
| Command | What it does |
|---|---|
singulacomp secrets ls | List secrets declared and set for the project |
singulacomp secrets set KEY=VALUE [--identifier <id>] | Create or update a secret. KEY=- reads the value from stdin |
singulacomp secrets unset IDENTIFIER | Remove a secret |
singulacomp secrets delivery IDENTIFIER egress --allow-host <host> | Egress-enforced exposure for the listed hosts. Experimental; needs the secrets_egress flag |
singulacomp secrets delivery IDENTIFIER runtime | Environment exposure (the default) |
singulacomp secrets delivery IDENTIFIER denied | Disabled |
singulacomp secrets call IDENTIFIER URL | Send one policy-bound HTTPS request through SingulaComp. Experimental; needs the secrets_egress flag |
singulacomp secrets sync | Re-push project secrets to this session's sandbox |
singulacomp secrets request NAME [--scope runtime|connector] [--expires <min>] | Create a link so someone else can enter a value |
singulacomp env push --from <path> | Upload a .env file as secrets |
singulacomp env pull [--out <path>] [--force] | Export secret names, not values, to a .env file |
The CLI keeps the stored vocabulary: runtime is environment exposure,
egress is egress-enforced, denied is disabled. Run
singulacomp secrets --help for every flag.
Setup links default to connector. Use --scope runtime only when the agent's
shell must receive the value. A secret bound to a connector stays server-side.
Names and permissions
Format rules for the two names:
| Name | Format |
|---|---|
identifier | ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ |
name | ^[A-Z_][A-Z0-9_]{0,63}$ |
Reading or writing a secret needs the project.secret.read or
project.secret.write permission. A project manager holds both. A custom role
only adds permissions — SingulaComp has no deny rule — so no role can withhold either
one from a manager. To restrict a manager, remove the manager role.
Delivery to a session is the role verdict of the person who started it, intersected with the launched agent's manifest grant. Both must allow the secret. See One vocabulary, two bindings.
REST routes
All routes sit under /v1/projects/{projectId}.
| Method | Path | Description |
|---|---|---|
| GET | /secrets | List secrets. Scoped to the caller's grant if the caller is a scoped agent token. |
| POST | /secrets | Create or update the shared value. Body: {name, identifier?, value}, plus an optional delivery policy. |
| PUT | /secrets/{identifier}/strategy | Change the exposure and its host list. |
| POST | /secrets/{identifier}/grant | Add the identifier to one agent's secrets list in singulacomp.yaml. Body: {agent}. |
| DELETE | /secrets/{name} | Delete the shared value. Personal overrides stay in place. |
| PUT | /secrets/{name}/personal | Set or turn on the caller's personal override. |
| DELETE | /secrets/{name}/personal | Remove the caller's personal override. |
POST /secrets rejects names that start with SINGULACOMP_. It returns 409
if the identifier already exists with a different name. It rejects the
exact name CODEX_AUTH_JSON with 400 — SingulaComp manages that secret
through ChatGPT subscription onboarding.
Both write routes return a delivery_sync object when the change had to reach
running sandboxes. ok: false means the value is saved but at least one live
session still uses the previous one; the listed sessions pick it up on restart.
A secret in the list carries delivery_blocked_reason. The value
no_agent_grant means no agent can receive this secret. null means it is
granted, the exposure needs no grant, or SingulaComp could not read the manifest.
POST /secrets/{identifier}/grant clears that reason. It returns
already_granted: true when the agent's list already admits the identifier, in
which case SingulaComp commits nothing. It returns adopted_governance: true when
the edit added the project's first agents: block — the change described
above. It answers 400 manifest_v1_unsupported
for a singulacomp.toml project and 409 secret_not_grantable for a disabled
secret.
Rotation and propagation
A secret write does not wait for a session restart. SingulaComp pushes the change to every active sandbox in the project:
- SingulaComp builds a new environment snapshot, using the running agent's
secretsgrant. - The sandbox writes the snapshot to the live agent environment. New tool calls pick up the change right away.
- If the changed secret is an LLM provider credential, SingulaComp restarts OpenCode.
This push is best-effort. The API call that changes the secret returns before the push finishes. A failed push is only logged, not retried. A sandbox with a failed push keeps the old value until the next successful push, or until the session restarts.
Model credentials
A project on SingulaComp's managed model access needs no key of its own. To bring your own, set the provider variables your OpenCode provider config references. A recognized model key is assigned the LLM gateway usage, which spends it server-side; it needs no sandbox presence.
Do not use a generic provider verification result as runtime proof. It cannot prove the selected model, region, entitlement, and API dialect. Send a real prompt through the exact model.