Agent sessions often need credentials to do useful work: a GitHub token for
gh, an API key for a deploy tool, or a database URL for a local smoke test.
The unsafe version is to paste those values into the prompt or check them into
an .env file the agent can read. Condukt's secrets API keeps the declaration
in trusted code and exposes the resolved values only to tool execution
environments.
How other agents handle this
Most agent runtimes have converged on one of a few patterns:
- CLI agents such as Claude Code and Aider read credentials from environment
variables,
.envfiles, or configuration files. - Cloud agents such as GitHub Copilot coding agent prepare an ephemeral development environment and let users attach GitHub Actions variables or secrets to that environment.
- Secret managers such as 1Password avoid plaintext files by resolving secret
references at runtime.
op runis the canonical example: it makes secrets available as environment variables only for the subprocess it starts. - MCP authorization is moving toward OAuth-based delegated access for remote
tools. That is the right shape for tools that represent SaaS APIs, but it
does not replace local tool credentials like
GH_TOKEN.
Condukt follows the same separation of concerns: secrets are resolved by trusted host code, scoped to a session, injected into tool subprocess environments, and kept out of model context.
Configuring secrets
Pass :secrets at start_link/1, return it from an agent module's
secrets/0 callback, or configure it through config :condukt, :secrets.
Keys are the environment variable names exposed to command tools.
defmodule MyApp.ReviewAgent do
use Condukt
@impl true
def tools do
[
Condukt.Tools.Read,
{Condukt.Tools.Command, command: "gh"}
]
end
@impl true
def secrets do
[
GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"}
]
end
endThe same declaration can be provided per session:
{:ok, agent} =
MyApp.ReviewAgent.start_link(
secrets: [
GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"},
DATABASE_URL: {:env, "DATABASE_URL"}
]
)Built-in provider aliases are:
| Alias | Provider | Purpose |
|---|---|---|
:one_password or :op | Condukt.Secrets.Providers.OnePassword | Resolves a 1Password secret reference with op read. |
:env | Condukt.Secrets.Providers.Env | Copies a value from the host process environment. |
:static | Condukt.Secrets.Providers.Static | Uses a trusted plaintext value. Prefer this for tests. |
Later declarations for the same environment variable replace earlier ones.
1Password
The 1Password provider shells out to op read <ref> while the session starts.
Authenticate op first, or start Condukt with an OP_SERVICE_ACCOUNT_TOKEN
that is scoped to the vaults the agent needs.
{:ok, agent} =
MyApp.CodingAgent.start_link(
secrets: [
GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"},
STRIPE_API_KEY:
{Condukt.Secrets.Providers.OnePassword,
ref: "op://Engineering/Stripe/api-key",
account: "acme"}
]
)Secret references stay in code. The plaintext value is loaded into the BEAM process at session initialization and then passed to tool subprocesses as an environment variable.
Tool execution
Condukt.Tools.Bash passes session secrets through
Condukt.Sandbox.exec/3 as environment variables:
Condukt.run(agent, "Run the local smoke test that needs DATABASE_URL")Condukt.Tools.Command merges session secrets with the trusted :env values
configured on the tool. Session secrets win if both define the same variable.
def tools do
[
{Condukt.Tools.Command, command: "gh", env: [GH_HOST: "github.com"]}
]
endThe model cannot add or change environment variables through tool arguments. It can only invoke the tools you configured.
Auditing access
Condukt emits value-free telemetry for secret resolution and access:
[:condukt, :secrets, :resolve]when a session resolves secrets.[:condukt, :secrets, :access]when a tool receives resolved secrets.
Both events include count as a measurement and :names metadata with
environment variable names such as ["GH_TOKEN"]. Access events also include
:tool, and include :tool_call_id when the access comes from a concrete
provider-returned tool call. Secret values are never included in measurements
or metadata.
Attach a handler if you want an audit trail:
:telemetry.attach(
"secret-access-audit",
[:condukt, :secrets, :access],
fn _event, measurements, metadata, _config ->
Logger.info("agent secret access",
count: measurements.count,
agent: inspect(metadata.agent),
tool: metadata.tool,
names: metadata.names
)
end,
nil
)Custom providers
Implement Condukt.SecretProvider when your secrets live somewhere else:
defmodule MyApp.Secrets.Vault do
@behaviour Condukt.SecretProvider
@impl true
def load(opts) do
MyApp.Vault.read(Keyword.fetch!(opts, :path))
end
end
{:ok, agent} =
MyApp.Agent.start_link(
secrets: [
INTERNAL_TOKEN: {MyApp.Secrets.Vault, path: "agents/internal-token"}
]
)load/1 returns {:ok, value} or {:error, reason}. If any secret fails to
load, the session fails to start.
Redaction and persistence
Resolved secrets are not added to:
- The system prompt
- User messages
- LLM request options
- Session store snapshots
If a tool prints a resolved secret, Condukt exact-match redacts the value from the tool result before it is stored in history, streamed to subscribers, or sent back to the model:
[REDACTED:GH_TOKEN]Values shorter than four bytes are not redacted because replacing tiny strings causes too many false positives.
Under the hood, resolved session secrets become a
Condukt.Redactors.Secrets spec and are composed with the session's configured
:redactor. Secret redaction runs first so custom redactors cannot transform a
secret before the exact-match replacement has a chance to run.
Redaction is a safety layer, not a permission model. A tool subprocess that
receives GH_TOKEN can use it. Scope tokens and 1Password service accounts to
the smallest set of resources that the session needs.