Skip to content
Like what we’re building? Star on GitHub

Secrets protection ​

Iterion runs agents that can be prompt-injected (the sec-audit bots read untrusted repo content), shell out, and read files. Two distinct leak surfaces are defended in layers:

Diagram: Flow — the sealed credentials bundle.

  1. Exfiltration — the agent sends a secret off-box.
  2. Observability leak — a secret lands in clear in events.jsonl, artifacts, run.log, the studio/board stream, or report.md.

The engine is pkg/backend/secretguard: a per-run Guard built by model.BuildSecretGuard from the run's resolved credentials, sensitive host env vars, and the workflow's declared secrets: block.

Detection (incl. base64 and other encodings) ​

Two tiers:

  • Known-value taint (deterministic). Iterion knows its secret values, so for each it precomputes every textual form — raw (and, for a value ending with a newline, without it, raw and JSON-escaped: a tool printing a file drops it), base64 (std + url, ±padding), hex (upper/lower), URL-escape (Go's, the strict RFC 3986 form, Python's quote() default that keeps /, and the WHATWG forms a JS runtime writes: query, form-urlencoded, path, fragment, userinfo, component), JSON-escape (Go's, which escapes & < >, and as JSON.stringify or jq write it) — and matches those literally in one pass: a form of 4 KiB or more (a file secret) and a binary one as a plain string, the others in leftmost-longest RE2 alternations. At a position the longest form wins; a form that starts inside another and runs past it is replaced too, so no part of either shows. This is the reliable answer to "also detect base64": we match the base64 form of a secret we hold, we don't guess. What it does not match: a form it does not precompute — base64 or hex wrapped at a column width (base64, xxd -p), a multi-line value printed in part or line by line (grep, head, a file view that numbers its lines), a binary (non-UTF-8) value that went through a channel decoding it as UTF-8 (the claude_code stream, a Node tool: its invalid bytes arrive as U+FFFD).
  • Heuristic (for unknown secrets). The gitleaks-derived detector (tool/privacy/detector) + Shannon entropy, plus a recursive base64/hex decode pass that peels one layer off a blob and re-scans (catches an AKIA/JWT wrapped in base64 that the agent read from a file iterion never registered).

Layer 0 — sink redaction (default on) ​

Guard.Redact scrubs known values (any encoding) → their placeholder, and unknown token shapes → [redacted], at every observational sink, before persistence:

  • events.jsonl (the backends' events via a redacting AppendEvent wrapper, node_finished output via the engine's SecretScrubber),
  • a node's error, which the engine writes to run.json, run.log, its own events (node_recovery, run_failed) and the completion webhook — scrubbed where the executor returns it (ClawExecutor.Execute), for every node kind: a failing command's stdout and stderr, an MCP error echoing its input,
  • the failure text the ENGINE itself builds, which never passes through the executor: a fail: node's rendered message: (it reads the live output map, which keeps real values for the nodes downstream), a setup-phase refusal, the pre-resume budget verdict, a child-resource restore failure. It reaches the run document (run.json / the Mongo run doc's .error — what iterion inspect --json prints, the run views and the merge gate's synthetic status display, and pkg/alert fingerprints) through one chokepoint, the recordFailure* writers of pkg/runtime/failure_record.go, and the event path (hence run.log) through Engine.emitBranch. An executor whose scrubber does not return the text gets a withheld marker on the record, never the raw message,
  • the engine's events that carry model- or operator-written text: the whole human_input_requested payload of a question the run pauses on (a human node's rendered instructions, a fan-out branch's pause, an async question), an LLM router's reasoning, a review's verdict, the answers recorded at a resume or at a review gate (the interaction and the checkpoint keep them whole: the run needs them). A placeholder in them is kept as is — the heuristic pass never redacts one, trailing sentence punctuation included,
  • run.log — every record the run's logger keeps, at the logger itself (Logger.WithRedactor; ADR-149). The pass is installed on the RUN's logger, once, by runview.BuildExecutor — Logger.SetRedactor, where the guard is built — so it reaches the handle the launch surface still holds and everything forked from it afterwards: the executor's tree (the agent stream, the tool hints, the delegate registry), the engine, the run's supervisors, and the non-event lines of a surface's own prose (executor close, an MCP server's quoted stderr, a launch-input refusal). runtime.New / runtime.NewFromRecipe install it again on the ENGINE's fork, from the scrubber its executor carries, so an engine built without BuildExecutor — a dry run, a benchmark, a test — covers its non-event lines too: a workspace-snapshot diagnostic quoting a git command's combined output, a worktree or skill-mirror warning, a capture error naming what it read. The slot is per HANDLE, which is why a run's logger is always a fork (iterlog.WithOwnRedactor, a per-run tee): a surface that handed its process logger would install one run's matcher where the next run replaces it, and a second install on one handle says so in the log. What is NOT covered by either point: a logger nobody set the matcher on, i.e. the pod's own process logger — the lines pkg/runner logs on r.cfg.Logger and the dispatcher runner on r.logger, which reach the pod's stderr, not run.log. Nor a run this process does not hold: a finalize or reconcile another process's run owes (Service.runLoggerFor) logs on a fork of the service logger that carries no matcher, because the guard lives with the run's own launch. Block bodies, tool sidecar blobs and turn-snapshot conversations keep their own eager pass (their stores bypass the redacting wrapper), and so does every producer that CUTS text before logging it — the cut runs after the run's pass (Logger.Redact on the logger, Task.RedactSecrets on the delegate backends: pkg/supervise's steer line, the pi backend's steer, UI-decline and ask_user lines, the codex terminal-error quote and its tool-error content, the claw IPC decode-error quote), because a value the cut split is a value no matcher recognises. The display helpers that cut what they render sit in the same class, so the backends hand them already-redacted text: the codex tool-use header (toolUseDetail, 100-byte detail over leaves secretguard.RedactLeaves crossed first) and the claw ones (the delegate bridge's 57-byte hint and 100-byte result preview, the in-process header detail — secretguard.RedactJSON over the raw input, the output redacted before tooldisplay.ResultDisplay); the claude_code stream already did (RedactLeaves before toolUseDetail, redactForRunLog before ResultDisplay). The relay payloads keep their clamps unredacted on purpose: their reader is the container's loop, not the operator's log.

Values iterion only scrubs — the run's provider keys, the process's own secret-named environment, and credentials a server mints for a run (the forge publish grant) — are redact-only: their placeholder resolves nowhere, so no agent, command or egress request can turn it back into the value, and it is passed through unchanged. Only the secrets a workflow declares (Layer 1) materialize.

One narrowing of that class: the credential a server mints for a run (forge_publish_token) is env-only. A {{vars.forge_publish_token}} the workflow's AUTHOR wrote in a tool node's command: or postcondition: renders as the placeholder, and the bearer reaches the command through the child process's environment — never inlined into the command line, which any co-resident process reads from /proc/<pid>/cmdline for the whole life of the command and which Kubernetes copies into the pods/exec audit log. The distinction is provenance, not sink: a command a MODEL proposed (the self-repair rung) resolves nothing, and neither does an agent's tool input, a script body or an egress request. A script: body is a program in its own language, which iterion cannot rewrite a reference into a variable read for: there the AUTHOR names the variable — the placeholder doubles as the environment-variable name — and the engine hands the value to the interpreter of a body that names it, instead of writing the bearer into the script file (pub_token = os.environ.get("__ITERION_SECRET_forge_publish_token__", ""), as bots/dep-update-guard reads it). A {{vars.forge_publish_token}} written in a script: body anyway is REFUSED at run time — before the script's temp file is written, so the bearer never rests in it in clear — with an error naming the var and this door (#2875; no placeholder→env swap exists for a script body: the single-quoted form the command path swaps is shell, and each interpreter's env read is its own expression, python's needing an import the body may not have). One form keeps the value in the text, by the author's own choice: the raw {{!vars.x}} form, whose quoting is theirs. The compiler says it first: C331 warns on that reference at authoring time (iterion validate, the studio's badge), where the run would only refuse it mid-node, after the spend upstream — a warning and not a refusal, because the compiler reads the NAME while the run asks its guard, and a launch holding no grant renders the var's empty value and runs (diagnostics). Both the refusal and the warning key on the reference the AUTHOR wrote: a bearer that reaches the body through a data mapping — an edge's -> b with { tok: "{{vars.forge_publish_token}}" }, read back as {{input.tok}} — is a plain input by then (a mapping resolves the raw var, resolveRef), so it is neither refused nor warned and renders into the temp file in clear. That indirection is deliberate and shared with command:, and it is the author's to avoid: a body that reads the variable from its own environment needs no mapping. A preview — the dry run, any rendering without the run's guard behind it — resolves nothing either: it renders the placeholder, the indirection existing only where the materialiser that resolves it exists. The grant is also scoped (Layer 2) to the endpoints the server bound it to, so the egress gate refuses it toward any other host. And it never rests on a board card: the board store drops those vars from bot_args at its write boundary on both twins, and every surface that hands a card out masks the bearer a card written before that boundary still holds (native-tracker).

Where a CLI backend's redaction stops (the hook-protocol limits) ​

claw in-process, sandboxed claw, claude_code and pi run the same agent through different far sides, and each quotes far-side text back to iterion: a session error, an assistant block, a tool result, a rate-limit signal. That redaction is not each backend's own — ONE executor builds every task (buildTask) and hands each the same Task.RedactSecrets and Task.RedactSecretsSpan, so there is no per-backend branch to forget (TestBuildTaskGivesEveryBackendTheSameSecretRedactor), and the engine's record chokepoint sits above the backend choice entirely. Three bounds are deliberate, and worth knowing:

  • How far the redactor reads on a stream. The run log shows at most 64 KiB of one value and the redactor reads at least 16 KiB past it — or as far as the longest known value when that is longer (redactForRunLog, Task.RedactSecretsSpan) — so a known value straddling the bound is recognised whole. A value LONGER than that margin, or one only the heuristic would catch, can straddle it. Redacting a multi-megabyte value on every stream chunk costs seconds per turn, which is why the bound exists.
  • The crossing redacts before its readers, not after. A sandboxed claw's far-side text goes through the pass at the crossing (redactedFarSide), because the node's own scrub runs at Execute — after the delegate_error event and the error tracker have already read it.
  • A nil redactor is the run's kill switch, not a backend gap.ITERION_SECRETS_REDACT=off clears both sink passes (known values and the heuristic) and leaves materialization working, so declared placeholders still flow; Task.RedactSecrets is then nil and far-side text goes through unchanged.

Deliberately NOT redacted: persisted artifacts and the resume checkpoint. These are load-bearing — they feed {{outputs.X}} / {{artifacts.X}} and are re-read on resume; redacting them would corrupt cross-node and cross-resume data flow. Their defence is Layer 1 (placeholders keep the real secret out of node output in the first place) plus the run store being local/private.

Layer 1 — placeholders + materialization (default on) ​

Declare secrets in the DSL; the agent only ever sees an opaque placeholder __ITERION_SECRET_<name>__; iterion swaps in the real value at the moment of execution.

iter
secrets:
  github_token: "${GITHUB_TOKEN}"          # short form
  deploy_key:
    value: "${DEPLOY_KEY}"
    hosts: ["api.github.com", "github.com"] # egress scoping (Layer 2)

Reference as {{secrets.deploy_key}} in prompts and tool/shell commands — at the sinks the declaration grants (sinks). Materialization happens immediately before exec, keeping the placeholder form in every hook/log:

  • claw tool nodes (shell + script) materialise the author's own body through the command sink's materialisers (MaterializeAuthoredShellEnv, MaterializeShell), an action's params: through the params sink, and the in-process tool loop (executeToolsDirect) calls Guard.Materialize — the agent sink — on the model's tool input before exec.
  • claude_code uses a PreToolUse hook returning UpdatedInput with the materialised tool input (the SDK-supported substitution path). The placeholders are swapped in the input's decoded strings, never in its JSON text; a shell's (or a monitor's) description stays in placeholder form — the CLI labels a background shell with it, or with its command when there is none, and relays that label to the agent when the shell ends.
  • Neither materialises the input of a tool that keeps it rather than runs it — the node's report (claude_code's StructuredOutput stores the input it is called with), the session's task list (TodoWrite, TaskCreate, TaskUpdate; claw's todo_write, written to disk), a scheduled prompt (CronCreate writes it to .claude/scheduled_tasks.json, ScheduleWakeup sends it back to the model), a memory note (memory_write; claw's memory_write, the knowledge store the next run loads), a skill's arguments (Skill: the CLI injects the skill's body with them), claw's PII vault (privacy_filter), a question to the operator (ask_user), iterion's own MCP tools (a board issue, a run query): they stay in placeholder form. The auto-memory mirror persists MEMORY.md in placeholder form too (the file tools write it with the values).
  • A command naming a secret is not compressed (claude_code's rtk hook, claw's agent loop, a tool node's compress:): the compressor runs the command it rewrites, and rtk records every command it runs in its history — the value would land there. A shell iterion compresses also runs with the rewriter's run_env (rtk's: RTK_RECALL=0, and its history database under /dev/null, a path nothing can create) — every shell of a node, whatever its mode, as the agent may run rtk itself —, exported by each command it compresses as well (a settings env or a shell rc cannot outrank it there), which turns off the stores where rtk keeps the commands it ran and the output lines it left out: a command carrying a secret some other way (its raw value, an environment variable), or an output quoting one, leaves nothing there either. On claude_code the run env is pinned in the CLI's --settings flag layer too, which a user's or a repository's settings env cannot outrank. That layer rides the CLI's command line, as its MCP configuration does: iterion's log names its keys and never prints a value, but a process listing on the host shows it — a secret field of a rewriter plugin's config placed in its run_env included. A command iterion did not compress — rtk typed by the agent, or run by an operator's own rtk hook — still gets it from its environment only, which the operator's shell rc or BASH_ENV can replace.
  • A materialised environment never rides the container runtime's command line. docker exec is handed the NAME alone (--env K), with the value in the docker client's own environment — where a name-only flag is read from — so a host process list, world-readable for the whole life of the exec (for an agent node, the node's), shows the names and nothing else. The provider credential a sandboxed runner spends travels the same channel. A value too multi-line for a command line is carried intact there; only a NUL is refused, which no process environment holds. The exception is a name the HOST side reads, because the client's own environment is the one channel such a name may not use: a container-chosen HOME decides which daemon receives the exec, and a container-chosen LD_PRELOAD runs the container's code in the client itself. Four answers, each a DERIVATION and not a roll-call of names, because a roll-call is how podman's own endpoint family got out of the first one: the two CLIs' exact names outside any namespace of their own (runtimeClientEnvNames: HOME, PATH, TMPDIR, the TLS roots, REGISTRY_AUTH_FILE, the storage pair, LANG/LANGUAGE) and their configuration NAMESPACES (runtimeClientEnvPrefixes: DOCKER_, CONTAINER_, CONTAINERS_, PODMAN_ — which subcommand of one binary reads which name is a version detail iterion does not track); the rest of the locale family, LC_; the *_PROXY family in either case, which Go's own net/http consults for every request the client sends; and envtrust.ExecutionGradeName, the repository's execution class, already the subject of the dotenv plant gate, the bot-variable gate and the devcontainer deny class. Measured, not reasoned: with CONTAINER_HOST=tcp://… in its environment, podman 4.9.3 dials that endpoint with no --remote flag and POSTs /libpod/containers/<id>/exec there with the whole Env array in the body; with HTTP_PROXY in its environment and a tcp:// daemon, docker 29.9.2 sends the same conversation in absolute form through the host named — so a container-destined proxy name becomes the CLIENT's proxy, which is why that family sits on this side of the channel and not, as it briefly did, on the name-only one. Those names keep the pair on the argv — the channel they had before #2845 — and the residual is said out loud AND warned by name where it happens: a name answering here can hold a credential too (NPM_CONFIG__AUTH, CONTAINER_PASSPHRASE, the proxy URL's own per-run token), and its value is then in every uid's reach for the exec's life. Warned, never dropped — the operator asked the container to have it. Deliver such a credential under a name the host side does not consume, or as a file secret. Open point: the channel that would end the residual is a per-exec --env-file, which the CLI reads and which is neither the argv nor the client's environment (nothing in production parses the EXEC argv's --env, unlike the creation argv the image env screen audits); it needs a 0600 file outliving Run.Command, which returns a command it never starts, so it is a shape decision rather than a patch. A name holding a * takes NEITHER form — podman exec --env NAME* with no value is a host-environment glob, and the pair form would put the value on the argv with no host-side reader to serve — so it is skipped with a warning naming it, and the container's creation env (sandbox.env:) is where a literal * name is set. The container's CREATION argv still carries values, for the length of that one call, and each is driver- or operator-chosen: the spec's sandbox.env: (paths, ids, the mount path of a file secret — no declared secret value reaches it, the materialiser being a host closure, though a ${localEnv:VAR} in it resolves to the host value the operator named, credential included, by expandSandboxSpec), the egress proxy's *_PROXY pairs, whose endpoint URL holds the per-run proxy token the container's own environment publishes anyway, and the JVM truststore trio, whose password is a constant of iterion's own source guarding a world-readable store that holds a public CA certificate. The kubernetes driver still carries a per-exec value on the pod exec's own argv, as an env K=V segment (#2845).
  • Both consume delegate.Task.MaterializeSecrets (a closure set by the executor), so pkg/backend/delegate stays decoupled from secretguard.
  • Its mirror, delegate.Task.UnmaterializeSecrets (Guard.Unmaterialize), turns a known value — in any registered encoding — back into its placeholder in what iterion carries from the far side of that boundary into a prompt or the run store — a background task's label is the CLI's command after materialisation. It is not a sink pass: ITERION_SECRETS_REDACT=off leaves it on.
  • The model boundary. What a model READS goes out in placeholder form whatever produced it: the rendered system and user prompts, the user content blocks, the auto-memory section, the calibration fragments, the skill hints and the preset focus of every task cross into a backend through one seam (ClawExecutor.executeDelegate, Guard.RedactForModel), which turns every known value back into its placeholder — a value an upstream node printed and a prompt reads through {{outputs.A.x}}, an input an edge mapped from it, a {{vars.x}} holding one. The crossing itself keeps the real values: the live output map still feeds the nodes downstream and the resume checkpoint, so a tool node of B can spend what A obtained while B's model only reads the placeholder (ADR-146, axis 1a). The boundary follows the placeholder switch — ITERION_SECRETS_PLACEHOLDERS=off renders values in prompts by the operator's choice, and the boundary renders them too — and it matches known values only: a token shape only the heuristic would catch is the workspace's, and the model reads it as the workspace readers return it. A value split across two references is not one value, the same bound as the egress scan's. Two more bounds are deliberate: a value registered on the NAME of a host variable (the TOKEN/KEY/AUTH heuristic, which matches GIT_AUTHOR_EMAIL and KEYCLOAK_URL too) is left alone at the boundary — a placeholder there breaks the commit the agent was asked for, where a marker in a log costs nothing — and a declared secret whose sinks: keep it off prompts goes out as __ITERION_SECRET_<name>_redacted__, a token that resolves nowhere, so a model cannot have the proxy spend on the wire a secret its author withheld from it. The operator's answer at a resume takes the pass on every backend (the record keeps it whole), and the executor's DIRECT model calls — the human stand-in, the interaction stand-in, the review companion, the self-repair rung, the structured-output recovery — cross it too (generationForModel). Three model-read texts take their pass elsewhere, each for a structural reason. The mounted-secret hints are the guard's OWN composition — a secret's declared name, its mount path, its env-var name, never a value — so the boundary leaves them whole rather than corrupting the reference the instruction exists to give. A routing handoff is a FILE, which the incoming harness's prompt splices in from inside the backend (Task.HandoffSection), downstream of the seam: its grant pass therefore runs where the transcript is WRITTEN (HandoffRecorder.Seal), which is also where the crossing's own fallback data lands — the values are already placeholders there, since the recorder observes post-redaction events. And a backend composes some text for ITSELF rather than out of a task field — the instruction files it reads under the work dir, the workspace-memory index and autoload a store hands it — which no field pass can reach: those take the SAME pass through Task.ScreenModelText, which the task carries from the seam to wherever each backend composes them (claw at the request it builds and, for the memory turn its compaction re-injects for the summariser, at that hook — no request ever carries the turn itself; pi at its project_context suffix), so a value an earlier node's agent wrote into a CLAUDE.md or a memory entry reaches the next model as its placeholder. workspace is the DEFAULT ambient policy, so that walk is on unless the author turns it off. codex, claude_code and the other CLI agents are out of reach by construction and need nothing: their own binary loads the instruction files inside the child, and iterion never composes that text — as does pi under all. Two sweeps keep the list honest. TestEveryModelReadTextOfATaskCrossesTheBoundary reads what BuildSystemPrompt, HandoffPrompt and the two ambient composers compose and refuses a field, a new string leaf of one, or a new helper or method a composer grows through, that the boundary neither turns back nor records a reason for. TestEveryWriterOfSelfComposedModelTextNamesItsChokepoint reads claw's package for the gestures that make a text no field holds — a system block appended, a compaction hook installed, a turn marked injected — and refuses a writer that names no chokepoint; it keys on those gestures, not on every message the loop builds.
  • Residual: the in-container claw runner screens none of what it composes for itself. A sandboxed claw node rebuilds its task from the IOTask wire, which carries no closure by design (secret materialisation stays on the parent side), and the container builds no guard of its own — so the instruction-file block it composes in there is screened by nothing, while the prompts it receives were screened on the host before the hop. Closing it means either composing that block on the host or giving the runner a guard; until then a declared secret written into a workspace instruction file is readable by the next sandboxed node on that workspace.
  • A verified action's self-repair (policy: recover) quotes the failing command and its output to a model: both in placeholder form.
  • Both turn known values back into placeholders in the output of every tool but the workspace readers — stopping a background shell names its command, a notebook edit its new source, a redirected fetch its URL, a task or a message what it was given, a foreground subagent's report whatever it saw, an MCP tool whatever it reports: claude_code through a PostToolUse hook (updatedToolOutput), claw before the result enters the conversation. The workspace readers' output — a file (Read, read_file), a search over files, an edit's snippet, a command's output (Bash, bash) — is left as is, unless the call itself carried a secret (a search's pattern, an image's URL: it may quote it back) or reads the CLI's background task outputs — a task's file (named by a glob or a variable too), their directory, or the CLI's tmp root (claude-<uid>): a command's output, read after the call that ran it. A path in the node's workspace is the workspace's own, whatever its name (a worktree is named by its run's UUID, like the CLI's session directory): an agent editing a line that holds a secret must see the value the file holds. Known limitations, claude_code only: a call a permission rule denies is refused with its materialised input quoted to the agent, an MCP tool's error result reaches it as is, a background subagent's report reaches the lead verbatim in its task notification, a monitor's events — each line its command prints — reach the agent as the CLI relays them, and after a compaction the CLI re-announces every background shell or monitor still running with the command it runs — materialised (a foreground command the CLI moves to the background past its timeout included) — no hook runs on any of them. A settings hook iterion does not own (the operator's user settings, the target repository's .claude/settings.json, an iterion hooks plugin) runs beside iterion's, and the CLI keeps the answer that lands last: one that rewrites a tool's input runs the command with the placeholder, one that rewrites a tool's output can send the value it received to the model. A read of a task's output by a relative path after a cd made in an earlier call, or through a variable or a parent of the CLI's tmp directory (grep -r "$TMPDIR"), is not recognised as one, and a background task's output file stays in the CLI's tmp directory after the session; and the CLI keeps a tool's original output when the replacement does not match its output schema (iterion keeps the replacement on schema where the original can be off it: a notebook's language, read from its own metadata). Keep a secret out of a command a deny rule may match, or an MCP call that may fail on it (a file secret).

Generic placeholder materialization is currently limited to claw and claude_code. Pi, Kimi, Grok, and Codex leave __ITERION_SECRET_*__ opaque, so use file secrets for those delegates when the agent needs a non-provider credential.

Every backend still receives the behavioural backstop: a "## Secret handling" system-prompt clause tells the agent not to read/exfiltrate credential files and to pass placeholders through verbatim. This is never the primary control — where supported, the structural boundary is the materialization above.

Sinks — where a declared secret may be materialised ​

A secret is granted to the sinks it may reach, and the grant is part of its declaration: sinks: beside hosts: names the materialization kinds (ADR-146). The unit of the boundary is the pair (secret, sink), not the secret alone.

sinkwhat reaches itenforced
prompta prompt body renders the reference as the placeholder: the model HOLDS the placeholder from then oncompile time, on the reference (C323)
agentthe placeholder a model emits in its own tool input is materialised (claude_code's PreToolUse hook, claw's in-process tool loop, a self-repair command a model proposed)run time, by the guard (Materialize)
commanda tool node's authored command:, postcondition: or script: is materialised before execcompile time, on the reference (C323); run time, by the guard
paramsa tool action's params: value is materialised before the vendor callcompile time, on the reference (C323); run time, by the guard
filethe secret is written as a file the run mounts (as: file)compile time, on the declaration (C322)
egressthe placeholder is substituted on the wire toward hosts: by the sandbox's inspecting proxy (Layer 2)run time, by the egress rewriter

The default is every sink. A secret that declares no sinks: reaches all six, exactly as before the grants existed; the restriction is opt-in, and a leak does not come from an authored reference site in the first place. The ask is short where it matters:

iter
secrets:
  github_token:
    hosts: ["api.github.com"]
    sinks: [prompt, egress]     # the model holds the placeholder; only the proxy resolves it
  db_password:
    sinks: [command]            # the author's own tool commands; never a model

sinks: [prompt, egress] keeps a token out of every command line and every container: the agent's curl carries the placeholder, the proxy substitutes it toward the approved host, and nothing in the workspace ever holds the value — a cloud run's clone credential needs the file sink and refuses the run without it, below. sinks: [command] keeps a password off the model's path entirely: the placeholder a model would emit is left as written, and one a model wrote into DATA — an output a prompt or an edge carries into a tool body through {{outputs.x}}, {{input.x}}, {{vars.x}} — is defused before the body's materialiser scans it (DefuseUngranted: the agent sink's, whatever body it lands in). Only the author's own {{secrets.X}} resolves under command and params. sinks: [] reads as no list — every sink — since the printers and the JSON transport drop an empty list, and a meaning they could not keep would change on the way.

The clone credential of a cloud run is governed, and it needs file. A repo-targeted run clones with the first of forge_token, gitlab_token, github_token its bundle carries a value for, and the runner keeps THAT token in the clone's own credential store (.git/iterion-credentials, rewritten on every rotation) so the agent's late git push re-reads a live one. That store is a file the workspace holds, so the clone credential spends the file sink: a declaration that withholds it fails the run, typed (CAPABILITY_UNSUPPORTED, cause runtime.SecretGrantUnservableError) before the clone, naming the secret and both remedies — grant it file, or declare the withheld value under a name the runner never clones with. The verdict reads the compiled workflow and the sealed bundle, both in hand before any workspace exists, so a refused run spends no clone, no workspace and no forge connection, and its token is never written anywhere. Only an accepted workspace keeps its clone: every other end of the preparation — a refusal, or a failure after the clone — removes the run's clone directory, so no failed run leaves a token-bearing tree in the runner's workdir. The secret the rotation refreshes is the one the clone authenticated with, never another name of the precedence. A secret with no sinks: grants every sink and is never refused here.

Two things the grants do NOT govern, said here rather than discovered: ITERION_SECRETS_PLACEHOLDERS=off renders a declared secret's VALUE wherever its grants let it render, prompts included, since the switch asks for values; and an explicit egress grant on a secret with no hosts: is substituted toward ANY host (C324 says so at compile time — scope it).

What the compiler refuses: a sinks: entry outside the vocabulary, a sink named twice, prompt alone (it hands a model the placeholder and resolves it nowhere), an as: file secret whose list lacks file, a value secret granting file (C322); two declarations whose names derive one placeholder (tok, _tok — C090); a reference in a prompt body, an LLM node's images: path (the run renders it at the prompt sink), a tool command: / postcondition: / script: or an action params: whose secret does not grant that sink (C323, naming the sink and the grant). A file secret's reference renders its mounted path and is never refused per reference — the file grant on the declaration covers the mount. What the guard refuses at run time, from the same declaration: Materialize (the agent sink) and the authored-body materialisers leave a placeholder its secret does not grant AS WRITTEN; the prompt path renders a value secret only when it grants prompt, so a prompt-like surface the compiler never walked (a bundle preset's prompt, merged after compilation) keeps the reference as written — iterion validate refuses that reference on the preset file (C323, pkg/bundlelint) and every launch path says it through the merge; and the inspecting proxy substitutes on the wire only a secret granting egress — content DLP still refuses a real value toward an unapproved host, whatever the grants. A call that carried the placeholder of a secret the proxy — not the agent — resolves is a call that carried a secret: a raw tool's output goes back to placeholders on both backends. A bot-secret binding or the cloud transport does not narrow sinks yet (they narrow hosts:).

What the launch refuses (ADR-146, axis 3). A declared secret whose grants resolve at no sink the run can serve fails the LAUNCH — before anything is mounted or started, on every launch path (a subbot child adopting its parent's sandbox included), above the backend choice (claude_code, in-process claw and sandboxed claw reach the same verdict) — with a typed error (CAPABILITY_UNSUPPORTED, cause runtime.SecretGrantUnservableError) naming every such secret, its grants and the path that could not serve each. What a run serves: command, params and file always (a file secret is mounted by a real driver and materialised on the host otherwise); egress on a sandboxed run whose proxy inspects TLS (a rewriter wired, a driver that injects the CA) — never unsandboxed, never for a child in its parent's sandbox, whose rewriter is the parent's; agent when an agent or judge node resolves to claude_code (its hook materialises on the host, sandboxed or not), or to in-process claw on an unsandboxed run — a sandboxed claw runs through the in-container runner, a router carries no materialiser, codex, pi, kimi, grok and opencode never read one. Never a run that carries a placeholder nothing resolves. A secret with no sinks: is never refused here, nor an optional: true one the run does not hold (a launch that must proceed without it is not refused for the sinks it would have reached), nor anything on a dry run (its sandbox is the simulation's), and the provider credential a sandboxed node spends to talk to its model keeps its channel — the container's environment (sandbox, forwardableProviderEnv): that credential is RedactOnly in the guard, an OAuth forfait cannot be substituted at all, and "provider API keys crossing as placeholders the proxy substitutes" is a follow-up, not this record.

File secrets ​

Some credentials are safer and more ergonomic as files (kubeconfig, cloud SDK config, deploy certs). Declare them with as: file:

iter
secrets:
  kubeconfig:
    as: file
    # Optional in cloud: when omitted, iterion resolves a stored secret
    # named "kubeconfig" from /api/me/secrets or /api/teams/:id/secrets.
    value: "${KUBECONFIG_CONTENT}"
    mount_path: "/run/iterion/secrets/kubeconfig"
    env: "KUBECONFIG"
    hosts: ["api.cluster.example"]

For file secrets, {{secrets.kubeconfig}} and {{secrets.kubeconfig.path}} render the mounted path, not the secret content. The runtime writes the plaintext into a read-only file inside the sandbox and injects env to point at that path when configured. The agent prompt lists the mounted paths and explicitly instructs the agent to pass the path/env var to commands, without opening, printing, encoding or summarizing the file contents.

Default path when mount_path is omitted: /run/iterion/secrets/<sanitized-secret-name>.

Custom mount_path values must be clean absolute file paths (no .., duplicate separators, trailing slash, or /). Prefer the default directory: the drivers create/mount it for the run. Custom file targets depend on the parent directory already existing in the sandbox image.

optional: true ​

By default a declared secret with no resolved value (no value: expr, no host env, no stored/bound secret) is a hard launch error — the launch fails loudly, naming the secret (secret "x" is declared required by the workflow but resolves to nothing …), and no run record is created. The gate runs at launch on both paths — the cloud publisher (resolveAndSealCredentials, before the run is persisted) and the local / in-process path (runview.BuildExecutor) — so a required credential that resolves to nothing can never let a bot proceed unauthenticated (push with no token, call an API with no key). A webhook-triggered launch records the failure as StatusLaunchError on its delivery trail.

Mark a secret optional: true to skip it silently instead — for a bot that only needs the credential on some runs:

iter
secrets:
  forge_token:
    as: file
    optional: true   # mounted when bound/resolved; skipped otherwise

This is how a forge-agnostic reviewer (Revi) accepts an org's posting token on an unattended webhook launch (the org binds its credential to forge_token) while still running locally with host CLI auth when it isn't provided. The agent reads the file path/env only — never the contents.

Recipe: deploy to a real test instance + e2e loop ​

examples/deploy-e2e.bot shows the full pattern: a kubeconfig (with env: KUBECONFIG) and a host-scoped deploy_token, both as: file + optional: true, mounted into a sandbox: auto run where the agent builds, redeploys and validates the deployment end-to-end (playwright MCP) in a bounded retry loop — passing the secret paths to its commands, never reading the bytes.

Driver behaviour:

  • Docker/Podman: writes payloads to private host temp files, mounts the default secret directory read-only (or custom file targets read-only), and deletes the temp directories at sandbox cleanup.
  • Kubernetes: creates a per-run opaque Secret, mounts the default secret directory read-only (or custom file targets via subPath), and deletes the Secret with the sandbox pod.

Mid-run refresh (ADR-069). A file secret is a launch-time snapshot, but a short-lived credential (e.g. a 1h GitHub App installation token) would go stale on a long run that pushes/comments near the end. The cloud runner re-reads each file secret's store record on a 5-minute cadence and, when it rotated, propagates the fresh value into the running sandbox — docker rewrites the bind-mount source file, kubernetes re-applies the Secret (kubelet refreshes the projected volume within ~1min). This covers the default directory-mounted secrets on both drivers. Caveat: a kubernetes secret with a custom absolute mount_path is projected via subPath, which kubelet does not auto-update, so that projection stays at its launch value until pod restart; put refreshable tokens under the default /run/iterion/secrets directory. Reads are tenant-scoped and the value is never logged. Each rotation — file secrets and the clone's git credential alike — is also registered on the run's live secret guard (Guard.Register, copy-on-write) the moment it is picked up, so the fresh value is scrubbed from the run's log and events from that tick on; without the registration the launch-time value was the only one the known-literal layer matched, and a bot printing the credential file landed the rotated token verbatim in the run's sinks (#2756).

Cloud setup API:

  • GET/POST /api/me/secrets
  • PATCH/DELETE /api/me/secrets/{secret_id}
  • GET/POST /api/teams/{id}/secrets
  • PATCH/DELETE /api/teams/{id}/secrets/{secret_id}

Responses never include plaintext, only metadata (name, last4, fingerprint, timestamps, scope). At publish time the cloud publisher resolves declared secrets whose value is empty by name, seals them into the per-run bundle, and the runner injects them into the sandbox runtime. Secret names must be DSL identifiers ([A-Za-z_][A-Za-z0-9_]*) so they can be referenced from secrets:.

Layer 2 — TLS-inspection egress (default on for sandboxed runs) ​

Layer 2 guards secrets; it is not an egress allowlist. A sandboxed run's egress stays open unless its workflow declares an allowlist or denylist sandbox.network mode (network policy), or the run is a repo-defined sandbox (auto + a found devcontainer.json), where the iterion-default allowlist applies. The proxy Layer 2 forces under network: open admits every public destination the workflow's own !host exclusions do not refuse; its dialer refuses loopback and link-local addresses, except the host.docker.internal alias, which it dials to its own host's loopback (any port, unless the policy refuses the alias).

For secrets the agent uses in its own TLS calls (e.g. claude_code's Bash curl/git push), the sandbox egress proxy (pkg/sandbox/netproxy) can terminate TLS and rewrite the plaintext request (Deno-parity secret handling):

  • A per-run ephemeral CA (ca.go, in-memory, never persisted) mints per-host leaves. Its public cert is injected into the sandbox so in-container clients trust the leaves.
  • Substitution (inspect.go): MaterializeForHostWithin swaps placeholder→value, but only toward a secret's approved hosts: (a secret that declares none goes toward any host), and within the inspection bound.
  • Content DLP: ExfiltratesTo blocks (403) a real secret value bound for a host it isn't scoped to — defeats domain-fronting the host allowlist can't see.
  • A model's conversation is never substituted. The harness's own model calls (the claude CLI, the claw runner) leave the sandbox through the same proxy, their conversation naming secrets by their placeholders. A request to a model API keeps its body as sent: a provider's host (api.anthropic.com, api.openai.com, openrouter.ai, api.x.ai, api.mistral.ai, api.z.ai/open.bigmodel.cn, api.moonshot.ai/.cn, the Gemini and Vertex AI hosts, Azure OpenAI, Bedrock runtime), a model API's path at any host (/v1/messages, /chat/completions, /v1/completions, /responses under any base (a gateway's, Copilot's, the ChatGPT forfait's chatgpt.com/backend-api/codex/responses), /v1/embeddings, /images/generations, :generateContent, :rawPredict and their streaming forms, Ollama's /api/chat and /api/generate, Bedrock's /model/{id}/invoke and /converse), or a host listed in ITERION_SANDBOX_MODEL_HOSTS (a gateway at a path of its own, in the network rules' syntax — gw.corp, *.corp, **.corp, an IP, a CIDR; a base URL or host:port names its host; an entry that is none of these, or one matching every host (*, **), fails the run's start whenever its sandbox starts the proxy, the entry named by its position). Its headers are substituted as usual (an API key a tool gives as a placeholder), and content DLP applies to it like to any request. A tool that sends a secret in the body of a model API call gets the placeholder there: a model sees placeholders, never values (model.go). A ! entry in ITERION_SANDBOX_MODEL_HOSTS is the way out for a tool API whose path ends like a model API's: it lifts the model match for the hosts it matches — a host, a wildcard, an IP or a CIDR — their path included, so their bodies are substituted like any other request's. The last matching entry wins, as in the network rules (**.corp !*.tools.corp lifts build.tools.corp and keeps llm.corp), and a list may hold exclusions only. A provider's own host is never lifted: an exclusion naming one fails the run's start, and one whose wildcard spans one (!**.com) leaves it a model host.
  • A scoped value in the conversation. A secret scoped with hosts: whose value a workspace reader showed the agent (Read, Bash — they return what the workspace holds) is in the conversation from then on: content DLP refuses the run's next model call (403 blocked by sandbox secret policy, a network_blocked event). Keep such a file out of the agent's reads, or scope the secret to the model provider's host too.
  • A request goes where its tunnel was opened. Policy, content DLP and substitution all key on the CONNECT target; a request naming another host inside the tunnel — its Host header, an absolute URL, another port — is refused (421, a network_blocked event) rather than forwarded there with the target's secrets. A client that routes a request to another host than its URL's through the proxy (curl --connect-to) is refused that way.
  • Plain HTTP too. A plain-HTTP request through the same proxy gets the same content DLP as an inspected one — its method, URL, headers and body; a chunked request's trailers, which it does not scan, are dropped (on both paths). Its placeholders are never substituted: a value never goes out over clear text.
  • Clients that honour the proxy. The drivers set HTTPS_PROXY, HTTP_PROXY and their lower-case spellings (curl, wget and git read only http_proxy for an http:// URL), and NO_PROXY/no_proxy to the sandbox's own loopback — localhost,127.0.0.1,0.0.0.0, plus host.docker.internal on docker (the host's MCP listeners, reached directly). Docker ADDS those to the entries the spec carried, under both spellings: nothing enforces egress there, so an inherited corporate proxy's exceptions stay the operator's. A pod REPLACES them: its egress is locked by the synthesized NetworkPolicy, and an entry kept from the spec would carve a hole in the allowlist that policy enforces. No IPv6 loopback: clients read entries as patterns, and every spelling of it breaks a mainstream one — a bare ::1 reads to Ruby's Net::HTTP as any IPv4 address ending in .1 and to Python's requests as any IPv6 address ending in ::1, while [::1] and ::1/128 make httpx refuse to build a client. Where the driver enforces no egress policy — docker, or a kubernetes cluster whose CNI ignores NetworkPolicy — a client that ignores them reaches the network directly: the proxy governs the clients that use it.
  • A bounded body. The proxy holds a request's body to scan and substitute it: one over the bound (64 MiB by default) is refused (413), never cut — a plain-HTTP upload included (an http:// git push, an artifact curl -T), which the proxy now scans too. The bound holds for what substitution makes as well: a placeholder expands to its value, so a body that would pass the bound once substituted is refused, and so are header values whose substitution would add more than the bound. ITERION_SANDBOX_INSPECT_MAX_BODY moves the bound (a byte count, or 256MiB, 1GiB; the proxy holds several times that per request in memory — about five with the header budget, more as a request carries more distinct secrets, and once per request in flight); ITERION_SANDBOX_TLS_INSPECT=off lifts it with Layer 2.
  • What the scan sees. Content DLP matches each registered form of a value as one contiguous run of the request's text — its method, URL, headers (a field name as sent when the value is all lower-case: Go canonicalises names, and the other case shapes are a follow-up) and body. A value split across two fields, or re-encoded in a way the guard does not register, is not seen: the gate stops a leak, not an agent that works around it.

Inspection activates by default when a sandboxed run has known secrets; it forces a proxy even under network: open. Why TLS inspection is safe to do: Claude Code and the Anthropic/OpenAI SDKs are standard trust-store clients with no certificate pinning (per the official Claude Code network-config docs — they work behind Zscaler/CrowdStrike/mitmproxy once the CA is trusted). The default-transparent proxy is a cost choice, not a pinning constraint.

Limitation: OAuth-forfait credentials are NOT substituted ​

For OAuth-forfait auth (Anthropic Claude Code OAuth, OpenAI ChatGPT/Codex — the recommended credential model), egress substitution is impractical: the CLI performs stateful token refresh, and the Consumer Terms scope the forfait to Claude Code only (no API key to splice). Those credentials are protected by the network allowlist where the workflow declares one (egress is open by default) + Layer 0 redaction + the backstop clause, not by Layer 2 substitution. Layer 2's substitution/DLP value is for declared workflow secrets (a GITHUB_TOKEN, a deploy key) and API-key mode.

Status: live-validated in a docker sandbox (with trust-store caveat) ​

The MITM mechanism is hermetically tested end-to-end (inspect_test.go) and live-validated in a real docker sandbox (2026-06-08): a sandboxed tool run with a secrets: entry scoped hosts: ["example.com"] confirmed inspect=true, the per-run CA bind-mounted at /run/iterion/egress-ca.pem and trusted in-container, a --data {{secrets.X}} call to the approved host forwarded through the MITM to the real upstream (HTTP 405 from example.com), and the same call to an unapproved host blocked by content DLP (HTTP 403 + secret exfiltration blocked event). The real value never appeared in the run store.

Trust injection by client (the docker driver sets all of these env vars at the CA path, plus mounts the CA; in inspection mode every egress cert is our leaf, so our-CA-only is correct):

ClientTrust mechanismStatus
Node / Claude CodeNODE_EXTRA_CA_CERTS (additive)live-validated — fetch/undici → example.com 200 through the MITM
curlCURL_CA_BUNDLElive-validated — approved→405, exfil→403, no --cacert needed
python ssl / requestsSSL_CERT_FILE / REQUESTS_CA_BUNDLEenv set (same mechanism as curl)
gitGIT_SSL_CAINFO / SSL_CERT_FILEenv set

Remaining follow-ups:

  • Claude Code WebFetch specifically. Plain Node fetch/undici honours NODE_EXTRA_CA_CERTS (validated above), but Claude Code's WebFetch tool has historically bundled its own undici dispatcher + does an api.anthropic.com domain-safety preflight. Confirm against a live claude_code run; if it trips, set skipWebFetchPreflight: true. The known NODE_EXTRA_CA_CERTS-ignored reports are Bun-runtime specific, not standard Node.
  • Kubernetes driver. CA injection is implemented — Driver.Start creates a per-run Secret holding the public CA, the pod mounts it and the CA env vars point at it (BuildCASecret / caInjection, manifest-tested in secrets_ca_test.go), and Capabilities.SupportsTLSInspection is true. Not yet cluster-validated (needs a real cluster + a NetworkPolicy-aware CNI); the runner's RBAC must allow secrets create/delete in the sandbox namespace.

Where the value comes from — the local secret store (desktop / non-cloud) ​

Layers 0–2 above protect a value once iterion has it. That value is resolved into Credentials.Generic[name] at run start. Two sources feed it:

  • Cloud mode — the auth-gated team/personal store (Mongo, GenericSecretStore) resolved by the publisher and shipped to the runner as a sealed per-run bundle.
  • Local mode (desktop / iterion studio / CLI) — a file-backed sealed store, the desktop equivalent of the cloud store, reusing the same GenericSecretStore interface, ResolveGeneric resolution, and the whole Layer 0–2 pipeline. This is what replaces "put it in a .env and tell the agent to use it".

A declared secret with no inline value: resolves by name from this store — so a bot declares what it needs, and the operator supplies it out of band:

iter
secrets:
  GITHUB_TOKEN:            # no value: → resolved by name from the local store
    hosts: ["github.com"]    # egress lock still applies (Layer 2)

Storage, master key, scope ​

  • Files — machine-global ~/.iterion/secrets.json plus an optional per-project <store-dir>/.iterion/secrets.json. Both are AES-256-GCM sealed (the value is never on disk in clear) and written 0600. The project layer overrides the global by name (precedence: project > global).
  • Master key — 32 bytes held in the OS keychain (macOS Keychain / libsecret / Windows Credential Manager) when available; otherwise a keyfile ~/.iterion/secrets.key (0600), created on first use with an explicit warning (no silent fallback). ITERION_SECRETS_KEY (base64) overrides both — parity with cloud, useful for CI. An existing keyfile is always preferred so a store sealed headlessly stays openable.

Managing local secrets ​

CLI (values are read from a masked prompt, a stdin pipe, or --from-env — never from argv, and never printed back):

sh
iterion secret set GITHUB_TOKEN                 # masked prompt
iterion secret set STRIPE_KEY --from-env SK     # import from an env var
iterion secret set DB_URL --project --hosts db.internal
iterion secret set DB_PASSPHRASE --kind raw     # not a token / JSON / PEM
iterion secret list                             # names + last4 + scope only
iterion secret rm GITHUB_TOKEN

Ingestion shape gate (--kind) ​

secret set refuses, at the paste, a value that could not possibly authenticate — the same rule the cloud API runs on BYOK keys and OAuth blobs (pkg/secrets/credential_shape.go), so a terminal transcript pasted into the prompt fails here instead of surfacing as a provider 401 in the middle of a run.

The local store is name-keyed and carries no kind of its own, so the shape is either read off the value — a -----BEGIN header → pem, a leading { or [ → json, anything else → token — or named with --kind:

--kindRule
tokenOne run of visible characters: no white-space (ASCII or not), no control/format/non-printing rune, valid UTF-8.
jsonParses as a top-level JSON object or array, and carries at least one member.
pemAt least one complete -----BEGIN/-----END block decodes.
rawNo check — the explicit opt-out for a passphrase, a connection string, a blob.

The refusal names the secret, the reason and the kind it was read as, and never the value; --kind raw is in the message, so the remedy travels with it. An unknown --kind is an error, not a silent pass-through to no checking.

Studio: the Secrets view (gated on server_info.secrets_enabled) offers the same CRUD over /api/local/secrets (unauthenticated single-operator routes — the local studio is trusted to its loopback TTY user), including the same gate: the create/rotate request carries an optional kind (the view's Kind picker, defaulting to "detect from the value"), the shape is resolved by the same secrets.ResolveSecretShape the CLI calls, and a refusal answers 400 naming the kind and the raw opt-out. Two doors into one store must not disagree on what a value is. Neither the CLI nor the REST responses ever return a stored value.

The desktop app's provider-API-key keychain (ANTHROPIC_API_KEY, … under io.iterion.desktop) is a separate concern — those are how iterion talks to the LLMs, not what a bot uses inside a run.

Environment kill-switches ​

VarDefaultEffect
ITERION_SECRETS_REDACTonMaster: off disables Layer 0 sink redaction (materialization, and its mirror in what iterion writes into a prompt or the run store, still work).
ITERION_SECRETS_REDACT_HEURISTIConoff keeps known-value redaction but disables the gitleaks/entropy pass.
ITERION_SECRETS_REDACT_DECODEonoff disables the recursive base64/hex decode pass.
ITERION_SECRETS_REDACT_MIN_SCORE0.7Heuristic confidence floor (the 0.6 generic high-entropy rule is excluded by default).
ITERION_SECRETS_PLACEHOLDERSonoff renders {{secrets.X}} as the real value instead of a placeholder.
ITERION_SANDBOX_TLS_INSPECTonoff disables Layer 2 — TLS inspection and the plain-HTTP content DLP alike (the escape hatch for a pinning client or broken CA injection).
ITERION_SANDBOX_MODEL_HOSTS(none)Your own model gateways, comma- or space-separated, in the network rules' syntax (a base URL or host:port names its host): Layer 2 leaves their request bodies in placeholder form, beside the built-in providers and model API paths. A ! entry lifts the model match for the hosts it matches instead — their bodies are substituted even where the path reads like a model API's — save a built-in provider's host. An invalid entry, or an exclusion naming a built-in provider's host, fails the run's start whenever its sandbox starts the proxy.
ITERION_SANDBOX_INSPECT_MAX_BODY64 MiBThe bound of a request body Layer 2 holds to inspect: a byte count or a KiB/MiB/GiB size (256MiB); a larger body is refused (413). It bounds what substitution makes too, and the request line and headers keep net/http's own bound. The proxy holds several times the bound per request in memory (about five with the header budget, more with several distinct secrets), once per request in flight. A value that is no positive size fails the run's start whenever its sandbox starts the proxy.

Diagnostics ​

C090 duplicate secret · C091 secret/var name collision · C092 malformed egress host (Layer 2) · C093 {{secrets.X}} references an undeclared secret · C094 invalid file-secret declaration · C095 invalid secret subfield reference (for example .path on a value secret) · C322 invalid sinks: grant · C323 a reference reaches a sink its secret does not grant · C331 a {{vars.x}} naming a server-minted credential in a script: body, which the run refuses.