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:
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.
Two tiers:
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).Guard.Redact scrubs known values (any encoding) → their placeholder,
and unknown token shapes → [redacted], at every observational
sink, before persistence:
AppendEvent wrapper +
node_finished output via the engine’s SecretScrubber),Deliberately NOT redacted: persisted artifacts and the resume
checkpoint. These are load-bearing — they feed /
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.
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.
secrets:
github_token: "${GITHUB_TOKEN}" # short form
deploy_key:
value: "${DEPLOY_KEY}"
hosts: ["api.github.com", "github.com"] # egress scoping (Layer 2)
Reference as `` in prompts and tool/shell commands. Materialization happens immediately before exec, keeping the placeholder form in every hook/log:
tool nodes (shell + script) and the in-process tool loop
(executeToolsDirect) call Guard.Materialize before exec.PreToolUse hook returning UpdatedInput with
the materialised tool input (the SDK-supported substitution path).delegate.Task.MaterializeSecrets (a closure, set by the
executor), so pkg/backend/delegate stays decoupled from secretguard.Plus a behavioural backstop: a “## Secret handling” system-prompt clause (both backends) tells the agent not to read/exfiltrate credential files and to pass placeholders through verbatim. Never the primary control — the structural boundary is the materialization above.
Some credentials are safer and more ergonomic as files (kubeconfig,
cloud SDK config, deploy certs). Declare them with as: file:
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, and
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: trueBy default a declared file secret with no resolved value (no value:
expr, no host env, no stored/bound secret) is a hard run error. Mark it
optional: true to skip the mount silently instead — for a bot that
only needs the credential on some runs:
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.
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:
subPath), and deletes
the Secret with the sandbox pod.Cloud setup API:
GET/POST /api/me/secretsPATCH/DELETE /api/me/secrets/{secret_id}GET/POST /api/teams/{id}/secretsPATCH/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:.
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):
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.inspect.go):
MaterializeForHost swaps placeholder→value, but only toward a
secret’s approved hosts:.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.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.
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 + Layer 0 redaction
GITHUB_TOKEN, a deploy key) and API-key mode.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 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):
| Client | Trust mechanism | Status |
|---|---|---|
| Node / Claude Code | NODE_EXTRA_CA_CERTS (additive) |
live-validated — fetch/undici → example.com 200 through the MITM |
| curl | CURL_CA_BUNDLE |
live-validated — approved→405, exfil→403, no --cacert needed |
| python ssl / requests | SSL_CERT_FILE / REQUESTS_CA_BUNDLE |
env set (same mechanism as curl) |
| git | GIT_SSL_CAINFO / SSL_CERT_FILE |
env set |
Remaining follow-ups:
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.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.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:
GenericSecretStore)
resolved by the publisher and shipped to the runner as a sealed per-run bundle.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:
secrets:
GITHUB_TOKEN: # no value: → resolved by name from the local store
hosts: [github.com] # egress lock still applies (Layer 2)
~/.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).~/.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.CLI (values are read from a masked prompt, a stdin pipe, or --from-env —
never from argv, and never printed back):
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 list # names + last4 + scope only
iterion secret rm GITHUB_TOKEN
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). 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.
| Var | Default | Effect |
|---|---|---|
ITERION_SECRETS_REDACT |
on | Master: off disables Layer 0 sink redaction (materialization still works). |
ITERION_SECRETS_REDACT_HEURISTIC |
on | off keeps known-value redaction but disables the gitleaks/entropy pass. |
ITERION_SECRETS_REDACT_DECODE |
on | off disables the recursive base64/hex decode pass. |
ITERION_SECRETS_REDACT_MIN_SCORE |
0.7 | Heuristic confidence floor (the 0.6 generic high-entropy rule is excluded by default). |
ITERION_SECRETS_PLACEHOLDERS |
on | off renders `` as the real value instead of a placeholder. |
ITERION_SANDBOX_TLS_INSPECT |
on | off disables Layer 2 TLS inspection (the escape hatch for a pinning client or broken CA injection). |
C090 duplicate secret · C091 secret/var name collision · C092
malformed egress host (Layer 2) · C093 `` references an
undeclared secret · C094 invalid file-secret declaration · C095
invalid secret subfield reference (for example .path on a value
secret).