Secrets
A workflow carries values from one call to the next, and every platform but pydantic-graph keeps
those values in the history of the run, where anyone who may read the executions reads them too.
dandori check says when a value the contracts call secret ends up there, when a task sends one to a
party outside the project, when a key is written into the .flow, and when a call goes over plain
HTTP. It does so in the .flow alone, so dandori check says it on its own, and ritsu check
prints it as dandori says it.
| Code | What it says | How to say it is meant |
|---|---|---|
| W901 | a key in the shape a provider gives it is written in the .flow |
# ritsu: test secret on the same line |
| W902 | a task calls a URL over plain HTTP, on another machine | plaintext "<why>" where the URL is written |
| W904 | a secret is kept in the history of the workflow | history encrypted under workflow |
| E906 | a task sends a secret outside the project | discloses <parameter> "<why>" under the task |
What is secret
A value is secret when it is read from a place marked secret. There are three ways to mark one:
secretin the.flow, after the type of an input, an output, a field, a parameter or a task's answer (after itsrange, when it has one):
record Contact
name : string
phone : string secret
inputs
customer_id : string
api_token : string secret
task issue_token(user: string) -> string secret
debug_redactin a.proto, written on the field or on the value of an enum that a custom option of the field gives it, as protobuf's own debug formats read it. It marks the fields of a record made from the message (bank.Account), and the parameters and answer of aconnecttask.
message Account {
string id = 1;
string number = 2 [debug_redact = true];
string holder = 3 [(acme.v1.sensitivity) = PERSONAL];
}
enum Sensitivity {
SENSITIVITY_UNSPECIFIED = 0;
PUBLIC = 1;
PERSONAL = 2 [debug_redact = true];
}
- An OpenAPI schema: a property with
x-data-classificationatconfidentialorrestricted(or with no sensitivity, which isconfidential),x-sensitive-data, orformat: password. It marks the parameters and the answer of a task held to the operation.writeOnlyalone is not a mark, and neither isinternal.
A record holds the secrets of its fields, a list those of its items, a string with values put in is
secret when one of them is, and a json value made from a secret is secret as a whole. What a rule
answers is a decision, and holds none; neither does the answer of a task with no mark. What a
variable holds is gathered from every value put in it anywhere in the flow, as its
range is.
Kept in the history (W904)
Temporal, Step Functions, Lambda durable functions and Argo Workflows keep the inputs and the
outputs of the workflow and of every call. So W904 comes where a secret is an input or an output,
the answer of a task the flow calls, an argument of a call, or the reason of a fail. Here the bank's
contract marks the account's number, and the flow writes it into the payout's reference
(tests/fixtures/security/W904_payout_number.flow):
warning[W904]: tests/fixtures/security/W904_payout_number.flow:39:1: the secret `account.number` is kept in the history of the workflow, as the argument `reference` of `pay`
39 | let paid = pay(account_id: account_id, amount: amount, reference: "Sales payout to {account.number}")
= The mark is `debug_redact = true` at ../../../examples/payout/specs/payout.proto:34.
= Temporal, Step Functions, Lambda durable functions and Argo Workflows keep the inputs and outputs of the workflow and of every call in the history, which whoever may read the executions can read.
= Pass a reference instead (an ID, the name of a secret) and fetch the value inside the task. If the history is encrypted with a key you hold (Temporal: a payload codec; Step Functions and Lambda durable functions: a customer managed KMS key), write `history encrypted` under `workflow`.
The way out is to carry a reference. The payout example
(examples/payout)
gives the bank the account's id, and nothing says anything. When the history is encrypted with a key
only those who may read the secrets hold, say so under workflow:
| Platform | What history encrypted does |
|---|---|
| Temporal (TypeScript, Python, Go) | the worker and the client take a payload codec, by their types: makeWorker(own, { codec }) and encryptedClient(connection, codec); connect(target, codec) in Python; Dial(options, codec) in Go. A client made without the codec does not type-check where the generated client and worker take one. The messages and stack traces of failures go through the codec too |
| Step Functions, Lambda durable functions | nothing changes in what dandori writes: the key is a customer managed KMS key set on the state machine (EncryptionConfiguration) or on the function (DurableConfig.KMSKeyArn), which dandori cannot see; reading the history then takes kms:Decrypt |
| Argo Workflows | E050: a Workflow keeps its parameters as they are, and has no way to encrypt them |
| pydantic-graph | nothing: it keeps no history |
Sent outside the project (E906)
A model's provider (OpenAI, Anthropic, or an Open Responses server that is not this machine), Jev, a
host an http task names by its URL alone, and an AWS service are outside the project. A task that
sends one a secret is an error. When it is meant, the task says so, and the history still keeps the
value (W904):
task draft_notice(amount: money[JPY, incl_tax], payout_id: string, holder: string) -> string
agent "Draft a short notice to the seller that their payout was sent, …"
model "gpt-5.4-mini"
discloses holder "The notice greets the seller by the name on the account; the provider keeps no data under the agreement with it"
A secret sent to another file of the project (an OpenAPI document, a .proto, a rule's Connect
service, a child .flow, a book, a dates file) is for ritsu check to hold to the map of contexts:
dandori tells it which calls do so.
Plain HTTP (W902)
The URLs a task calls are an http task's URL, an agent's url, use rule … connect, and the URL
under use openapi and use proto (or an OpenAPI document's first server). One that is http://,
to a host that is not this machine (localhost, 127.0.0.1, ::1), is a warning:
warning[W902]: tests/fixtures/security/W902_agent_url.flow:7:3: the agent `read` sends its requests to ollama.internal over plain HTTP
7 | url "http://ollama.internal:11434/v1"
= Whoever is on the network between can read and change the requests, the answers, and any key in the headers.
= Use https://. If the connection is protected another way (a service mesh, a private link), say so under the task with `plaintext "<why>"`.
The inquiry example's version for Temporal reaches its Open Responses endpoint (Ollama, in the example) inside the cluster, and says so:
task read_inquiry(text: string) -> Reading
agent "Read the text of a customer's inquiry, choose its kind, take out the order number if one is written, …"
model "gpt-oss:20b"
effort low
url "http://ollama.internal:11434/v1"
plaintext "The model server is reached only inside the cluster network, which the service mesh encrypts"
plaintext goes where the URL is written: under the task, under use rule … connect, or under the
use of the API. Step Functions calls HTTPS alone, so a build for it still refuses http:// (E050).
Keys in the file (W901)
A value in the shape of an AWS access key ID, a GitHub, Slack, Stripe, OpenAI, Anthropic or Google
key or token, a Slack incoming webhook URL, or a PEM private key, written anywhere in the .flow, in
a string or in a comment, is a warning. The message and the line it shows carry the key's prefix and
length, never the key, so the log of a build does not keep it either:
warning[W901]: tests/fixtures/security/W901_key_in_a_string.flow:5:50: a Google API key is written here (AIza…, 39 characters)
5 | http GET "https://maps.example.com/v1/find?key=AIza…"
= A key in a file reaches everyone who can read the repository, its history and its builds. Keep it where the code runs (an environment variable, the platform's connection or secret store) and read it from there.
= If this key is real, revoke it with Google first: taking it out of the file leaves it in the history of the repository.
= If it is a value for tests, write `ritsu: test secret` in a comment on the same line.
Every language of ritsu looks for the same shapes, the same way.