Secret Scoping¶
Secret scoping gives each agent process its own credentials, with expiry, rotation without a restart, an access log and revocation. Environment variables are shared by every agent on a host; a secret scope holds only the secrets one process needs, keeps them encrypted in memory, and makes sure their values never reach the journal, checkpoints, the conversation history or API responses in plain text.
import os
from promptise.runtime import AgentProcess, ProcessConfig, SecretScopeConfig
from promptise.runtime.config import JournalConfig
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="Process payments.",
secrets=SecretScopeConfig(
enabled=True,
secrets={
"stripe_key": "${STRIPE_API_KEY}", # resolved from the environment at start
"db_url": "postgres://app:${DB_PASSWORD}@db/app", # references inside text work too
},
default_ttl=3600, # every secret expires after 1 hour...
ttls={"stripe_key": 1800}, # ...this one after 30 minutes
),
journal=JournalConfig(path=".promptise/journal"), # access log
)
process = AgentProcess("payments", config)
await process.start() # resolves the references; a missing variable fails the start
Operators rotate and revoke a running process's secrets over the API. The scope itself is a SecretScope, which you can also use on its own:
from promptise.runtime.secrets import SecretScope
scope = SecretScope(config.secrets, process_id="payments")
await scope.resolve_initial()
key = scope.get("stripe_key") # None once expired
await scope.rotate("stripe_key", "sk_live_new", ttl=1800) # new value, new 30-minute lifetime
print(scope.list_metadata()) # names, sources, expiry, counts: never values
print(scope.sanitize_text(f"charged with {key}")) # "charged with [REDACTED]"
The same in a .agent manifest:
name: payments
model: openai:gpt-5-mini
instructions: Process payments.
secrets:
enabled: true
secrets:
stripe_key: "${STRIPE_API_KEY}"
db_url: "postgres://app:${DB_PASSWORD}@db/app"
default_ttl: 3600
ttls:
stripe_key: 1800
revoke_on_stop: true
journal:
level: checkpoint
Where values come from¶
Each value in secrets is either a literal or contains environment references:
| Form | Example | Resolved to |
|---|---|---|
| Reference | "${STRIPE_API_KEY}" |
The variable's value |
| Reference with default | "${DB_PASSWORD:-devpw}" |
The variable, or devpw when it is unset |
| Text with references | "postgres://app:${DB_PASSWORD}@db/app" |
The text with each reference replaced |
| Literal | "tok-abc123" |
The text as written |
- References are resolved when the process starts (
SecretScope.resolve_initial()), not when the config is built, so aProcessConfigor a loaded manifest holds the reference, never the value. Loading a manifest checks that the referenced variables exist and fails early when one is missing. - A variable that is unset (and has no default) when the process starts raises
SecretResolutionError(aKeyError) naming the variable and the secret; the process ends upfailed. - A process deployed through the control API may only reference the variables the operator allowed (
OrchestrationAPI(allowed_env_vars=...)orallow_env_references=True): the API refuses other references with403 ENV_REFERENCES_NOT_ALLOWED, and the process's scope won't resolve them either. Its resolution errors are generic (they don't say whether a variable exists), so a token holder can't probe the host's environment. - Values are stored as
pydantic.SecretStr:repr()showsSecretStr('**********'), and a JSON dump (model_dump(mode="json")) shows**********for literal values and references as written. Passing the masked placeholder**********back as a value is refused, so a dumped config with literal values can't be loaded again by mistake. - Every name in
ttlsmust be a key ofsecrets, and every TTL must be positive.
For production, deliver secrets as environment variables (from your secret manager, Kubernetes or Docker secrets) and reference them; avoid literal values in configs and manifests.
Lifecycle¶
- Resolve. Every
start()resolves all secrets from the config again and starts their TTLs. A value set by a rotation is replaced by the configured value on the next start. - Access.
scope.get(name)returns the value, orNonewhen the secret doesn't exist or has expired. Every successful access is logged (name only). - Expiry. A secret with a TTL expires that many seconds after it was resolved or rotated (monotonic clock). An expired secret is removed on the next access or listing, and its value stays redacted.
- Rotation.
await scope.rotate(name, new_value, ttl=None)replaces a value at once, without a restart.ttlsets a new lifetime from now; without it the secret keeps its current expiry. An expired secret can be rotated: it becomes active again, withttlor else its configured TTL. A name that isn't configured raisesSecretNotFoundError(aKeyError); an empty value or a TTL<= 0raisesValueError. - Revocation.
await scope.revoke_all()overwrites every value with null bytes, removes all secrets, forgets rotated values and replaces the encryption key. It runs onstop()whenrevoke_on_stop=True(the default), always onclose()(and so onAgentRuntime.remove_process()), and onDELETE /api/v1/processes/{name}/secrets. Withrevoke_on_stop=False, a stopped process keeps its secrets until it is closed; the nextstart()resolves them again either way.
Values are kept Fernet-encrypted in memory with a key per scope. Python strings can't be wiped, so zero-filling is a best effort: copies may stay in the interpreter's memory until they are garbage-collected.
Access log¶
With a journal, the scope writes a secret_access entry for every access, rotation, expiry and revocation:
action is "access", "rotation", "expired" or "revoke_all" (with secret_name "*"). The value is never logged. Without a journal (JournalConfig(level="none"), the ProcessConfig default) there is no access log.
Redaction¶
Secret values are replaced by [REDACTED] wherever the runtime stores or shows text:
| Where | What is redacted |
|---|---|
| Journal entries and checkpoints | Every string in the entry data and the checkpoint state, including tool arguments and results (level="full"), invocation results, context updates and the conversation snapshot. The result of the get_secret tool is always journaled as [REDACTED] |
| Conversation history | The stored trigger message and the agent's reply, so an echoed secret never reaches a later prompt |
| Lifecycle hooks | The data passed to hooks, including the prompt a user_prompt_submit hook sees |
| Inbox answers | The answer to a question (ANSWER Qn: in the reply), so POST /ask and process.ask() return [REDACTED] instead of a value the agent was talked into repeating; GET /inbox too |
| Control API | Responses never contain values; a failed rotation answers ROTATION_FAILED without echoing the error |
Redaction covers the current values and values that were rotated away or expired (the last 10 per secret), longest first. It is an exact text match: a value that was encoded, split or reformatted is not recognized.
Two things are not redacted: the live context state (the agent's [Context State] prompt block and GET /context show values as stored; a value recovered from the journal comes back as [REDACTED]), and what the model itself receives, such as the get_secret result. Keep secrets out of the context state.
scope.sanitize_text(text) and scope.sanitize(value) (nested dicts, lists and tuples; returns a copy) do the same replacement for your own logs and output.
Open mode: get_secret¶
When secret scoping is enabled and the process runs in open mode, the agent gets a get_secret(name) meta-tool. It returns the value (or an error text when the secret is missing or expired), so the agent can pass it to the tool that needs it; the tool's description tells the agent never to repeat it. Each call is access-logged. The agent never sees secrets in its system prompt.
Prefer giving credentials to the tool instead of the model: put tokens in the MCP server spec (bearer_token, api_key, or env of a stdio server), where the model never sees them.
Over the API¶
| Method | Path | What it does |
|---|---|---|
GET |
/api/v1/processes/{name}/secrets |
{"name", "secrets": [...], "enabled": true}; {"secrets": [], "enabled": false} without secret scoping |
PATCH |
/api/v1/processes/{name}/secrets/{secret_name} |
Rotate: body {"value": "...", "ttl": <seconds, optional>}. Answers {"name", "secret_rotated", "secret": <metadata>} |
DELETE |
/api/v1/processes/{name}/secrets |
Revoke all: {"name", "secrets_revoked": true} |
Each item of the listing (and secret in the rotation answer) is the secret's metadata plus expires_at:
{"name": "stripe_key", "active": true, "source": "rotation", "ttl": 60,
"expires_in": 59.999, "rotation_count": 1, "rotated_at": "2026-10-19T20:23:26.797891+00:00",
"access_count": 0, "expires_at": 1792441466.79}
| Error | When |
|---|---|
400 SECRETS_DISABLED |
Rotate or revoke on a process without secret scoping |
404 SECRET_NOT_FOUND |
Rotating a name the process has no secret for |
422 MISSING_FIELD / INVALID_FIELD |
value missing or not a string; ttl not a number > 0 |
422 UNKNOWN_FIELD |
A body field other than value and ttl |
400 ROTATION_FAILED |
The rotation failed for another reason (the message never contains the value) |
The Python client has list_secrets(name), rotate_secret(name, secret_name, value, *, ttl=None) and revoke_secrets(name). GET /api/v1/processes/{name} shows active_secrets, the number of active secrets.
API reference¶
SecretScopeConfig¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
False |
Enable secret scoping |
secrets |
dict[str, SecretStr] |
{} |
Secret name to literal value or ${VAR} reference |
default_ttl |
float \| None |
None |
TTL in seconds for every secret (None = no expiry; must be > 0) |
ttls |
dict[str, float] |
{} |
Per-secret TTL overrides (names must be in secrets) |
revoke_on_stop |
bool |
True |
Revoke all secrets on stop() |
Unknown keys are ignored with a FutureWarning.
SecretScope¶
SecretScope(config, process_id, journal=None, *, source="operator", env_references=True) from promptise.runtime.secrets. env_references limits which variables ${VAR} may read (True any, False none, or a set of names); source="api" makes resolution errors generic. A process with secrets.enabled creates its own (it is not a public attribute of the process); reach a running process's secrets over the API.
| Member | Description |
|---|---|
await .resolve_initial(*, missing_only=False) |
Resolve every secret from the config and start its TTL (again on each call). missing_only=True resolves only the secrets the scope doesn't hold, keeping rotated values (what a process start uses). Raises SecretResolutionError |
.get(name) |
The value, or None when missing or expired (access-logged) |
await .rotate(name, new_value, *, ttl=None) |
Replace a value now; SecretNotFoundError for an unknown name |
await .revoke_all() |
Zero-fill and remove all secrets, forget rotated values, new encryption key |
.list_metadata() |
One dict per configured secret: name, active, source ("env", "literal" or "rotation"), ttl, expires_in (seconds left, None = no expiry or not active), rotation_count, rotated_at (ISO UTC or None), access_count. Never a value |
.list_names() |
Names of the active secrets |
.active_secret_names / .active_secrets |
Names of the non-expired secrets (active_secrets is an alias) |
.active_secret_count |
Number of non-expired secrets |
.sanitize_text(text) |
text with known values replaced by [REDACTED] |
.sanitize(value) |
The same for every string in a nested value (a copy) |
SecretNotFoundError subclasses KeyError and has .name and .known (the configured names).
Common pitfalls¶
- A rotation lasts until the next stop. With the default
revoke_on_stop=True,stop()(and so a restart) revokes every secret and the next start resolves them from the config again. Withrevoke_on_stop=Falsea start only resolves missing secrets, so the rotated value survives. Rotate the value at its source (the environment variable or your secret manager) as well. - The model sees what
get_secretreturns. Redaction keeps the value out of storage, not out of the model's context. Prefer server-side credentials in MCP server specs. - Secrets in the context state come back as
[REDACTED]. The journal stores the redacted value, so recovery restores the placeholder. - No journal, no access log. The
ProcessConfigdefault journal level isnone. - A missing environment variable fails the start. The process goes to
failedwith aKeyErrornaming the variable; set it or give a default (${VAR:-default}). - Masked dumps can't be reloaded. A JSON dump shows literal values as
**********, which is refused as a value. Use references for anything you serialize. - Redaction is exact-match. A value the agent encodes or splits is not recognized.
What's next¶
- Orchestration API -- rotate and revoke over HTTP
- Agent Context -- why secrets don't belong in the state
- Conversation -- the redacted history
- Journal -- where the access log goes
- Meta-Tools --
get_secretand the other open-mode tools
Learn the concept
- AI Agent Secrets Management: Scoped, Expiring Credentials -- Scope, expire, rotate and audit an agent's credentials.
- Secrets in Prompts: System Prompt Leakage and Safe Patterns -- Why secrets in prompts leak and safer patterns.