Orchestrator secrets
Reactive Harn features need a single way to fetch secrets without
sprinkling provider-specific code across connectors, OAuth flows, and
future orchestrator runtime surfaces. The secret layer lives in
harn_vm::secrets and includes these concrete storage providers:
EnvSecretProviderKeyringSecretProviderFileSecretProvider(explicit Unix configuration)
The default chain is:
env -> keyring
Use harn doctor to inspect the active chain and to see whether the keyring
backend is available, locked, or unavailable on the current machine.
harn doctor --check-keyring-write also writes, reads, and deletes a probe
credential.
Secret model#
Secrets are addressed by a structured SecretId:
use harn_vm::secrets::{SecretId, SecretVersion};
let id = SecretId::new(
"harn.orchestrator.github",
"installation-12345/private-key",
)
.with_version(SecretVersion::Latest);
Secret values are held in SecretBytes:
- bytes are zeroized on drop
Debugis redactedDisplayis intentionally absent- explicit duplication requires
reborrow() - callers expose bytes via
with_exposed(|bytes| ...)
Successful get() calls also emit a structured audit event through the
existing VM event sink with the secret id, provider name, caller span,
mutation session id when present, and a timestamp. The event payload never
contains the secret bytes.
Harness primitive#
Hosts can attach a managed provider to Harness::real() with
with_secret_provider(...). That enables scripts to use the typed
harness.secrets sub-handle:
// Canonical connector/runtime secret ids are first-class.
const google = harness.secrets.read("google_workspace/access-token")
// Application secrets may be scoped when
// they belong to a tenant/workspace.
const scope = {kind: "workspace", id: "workspace-123"}
const receipt = harness.secrets.write(
"github.token", "token-v1", scope, 3600000,
)
const token = harness.secrets.read("github.token", scope)
const rotated = harness.secrets.rotate(
"github.token",
{ -> "token-v2" },
scope,
{grace_ms: 300000, ttl_ms: 3600000},
)
const lease = harness.secrets.lease("github.token", 60000, scope)
Methods:
read(name, scope?) -> stringread_bytes(name, scope?) -> bytesread_cli(provider, options?) -> stringwrite(name, value, scope?, ttl_ms?) -> dictrotate(name, generator_or_value, scope?, options?) -> dictlease(name, duration_ms, scope?) -> dictlease_bytes(name, duration_ms, scope?) -> dict
scope may be nil, "tenant", "tenant:<id>", "system",
"workspace:<id>", "<custom-kind>:<id>", or a dict with {kind, id}.
Omitted tenant scope uses the ambient harness.tenant id when the host bound
one. The VM passes the ambient request id and authenticated principal facts to
the provider for audit, but the provider owns policy, encryption, rotation,
lease persistence, and any external KMS or vault adapter.
read_cli("github", {hostname: "github.com", user: "octo-bot"}) reads the
GitHub CLI's stored token for that host and login. Omitting user selects the
CLI's active account. It clears ambient token variables, preserves only the
CLI configuration and executable lookup environment, and captures at most
8 KiB in private memory with a ten-second deadline. It writes no command
output artifacts. Unknown providers, malformed host or login values, failed
commands, empty output, whitespace, and oversized output refuse the read.
The secret-read ceiling still applies; this method does not create or rotate
a credential. Authenticated, tenant-bound, and connector-dispatch contexts
cannot read the host's CLI credential. They use their configured secret store.
Closed memory and environment-only stores also deny this source. The bound
provider must explicitly allow local CLI reads; local keyring and file stores
do so. A parent-supplied closed store never expands its credential authority.
The reader uses the existing process sandbox and cleanup owner, selects an
installed GitHub CLI without searching PATH, and emits the existing sanitized
secret-access audit event. Unix installs under /usr/bin, /usr/local, or
Homebrew and Windows' GitHub CLI installer directories are supported. Other
installation locations refuse the read.
Secret names in namespace/name or harn-secret://namespace/name[@version]
form are treated as canonical secret ids and are not rewritten into the
ambient tenant or workspace scope. Use this form for connector credentials
registered by harn connect, package manifests, and runtime integration
secrets. Use scoped names for tenant/workspace application data.
harness.secrets is for agent- and harness-authored application secrets.
Runtime-owned namespaces provenance, harn.provenance, and
harn.provenance.<...> are reserved for Harn internals and cannot be read,
written, rotated, or leased through the scoped harness API. In particular,
signed run-receipt Ed25519 seeds are loaded through trusted runtime provider
access and are never exposed to Harn scripts.
Every Harn surface stores and resolves credentials under one namespace, so a
credential harn connect can store is one the runtime can read. harn run,
harn connect, --grant secret://, connector dispatch, and harn doctor all
name the same backend, and package scripts read canonical connector ids such as
google_workspace/access-token without knowing the host keyring namespace.
harn connect resolves through the same HARN_SECRET_PROVIDERS chain as
scripts. Under HARN_SECRET_PROVIDERS=file, it stores credentials in the file
that later runs read. It refuses to store into a chain with no persistent
provider, such as env alone, because the value would vanish when the process
exits. Unset the variable, or include keyring or file, before running
harn connect.
Automated tests and CI should not touch the OS credential store. Use
HARN_SECRET_PROVIDERS=env plus test-only HARN_SECRET_* variables for
secret-dependent smokes, or inject a mock Harness. harn test, make test,
and the script-test wrapper set HARN_SECRET_PROVIDERS=env themselves when the
caller has not chosen a chain. On macOS, Keychain “Always Allow” grants are tied
to a stable application identity; unsigned debug binaries rebuilt in different
worktrees can still prompt again. Long-running automation should use a stable
signed helper, broker, or external vault instead of relying on per-build
Keychain prompts.
Keychain dialogs and unlock prompts#
On macOS, reading a stored value can raise a Keychain access dialog. On Linux, any operation that meets a locked Secret Service collection raises an unlock prompt. Harn raises either one only when a person can answer it:
- Status and availability questions (
harness.llm.providers(),harn doctor,harn models recommend, routing) check whether a credential exists without reading it, so they never raise a dialog. harn doctorreads a Linux collection's lock state instead of unlocking it, and reports a locked store as locked rather than unavailable.- A process with no terminal on stdin, or one running under
CI, never raises a dialog. A read that would need one fails with a typed needs-approval error naming the credential, and provider status reportsneeds_user_approval. Provide the credential through its environment variable or setHARN_SECRET_PROVIDERS=env. - A host that launches Harn without a terminal while a person is at the
machine can set
HARN_SECRET_INTERACTIVE=1to allow dialogs.HARN_SECRET_INTERACTIVE=0forbids them everywhere.
Provider chain configuration#
The provider order is controlled with HARN_SECRET_PROVIDERS:
export HARN_SECRET_PROVIDERS=env,keyring
A chain that leaves out a default provider cannot see secrets stored there.
Under HARN_SECRET_PROVIDERS=env, a credential that harn connect saved in
the keyring reads as missing. A missing secret therefore names the providers
that were consulted and the defaults that were left out:
secret 'google_workspace/oauth-token' not found in providers: env (HARN_SECRET_GOOGLE_WORKSPACE_OAUTH_TOKEN); keyring disabled by HARN_SECRET_PROVIDERS=env
Scripts see this as a not_found error from harness.secrets.read. The
std/oauth client adds the same detail to its "no token in storage"
diagnostic. harn doctor warns about each default provider the chain leaves
out.
The doctor output also reports the namespace used for backend grouping. It is
harn.provider_auth for every surface. Override it — for an isolated workspace
or a test run — with:
export HARN_SECRET_NAMESPACE="harn/my-workspace"
The override applies process-wide. Set it for harn connect and for the run
that consumes the credential, or the two will not meet.
Explicit file provider#
On Unix, an application can select durable file storage explicitly:
export HARN_SECRET_PROVIDERS=file
export HARN_SECRET_FILE_PATH="$HOME/.private-app/secrets.json"
The default remains env,keyring. The path must be absolute, and its directory
must be private to the current user. New directories use mode 0700; files use
mode 0600. Existing exposed files and directories are refused. This provider
does not encrypt values at rest. Platforms without the private-file permission
adapter report an unsupported operation.
CLI configuration and FileSecretProvider injected through
Harness::with_secret_provider use the same format. It is a flat JSON object
whose values are base64 bytes. Keys percent-encode the namespace and name; a
slash separates them, slashes inside names remain literal, and exact versions
use #vN. Latest and exact-version entries are separate; automatic rotation
and TTL writes are unsupported.
Writers serialize through <path>.lock.sqlite3 using SQLite's DELETE journal
and an immediate transaction, compatible with the existing Swift host writer.
The provider waits at most three seconds for a transaction lock and replaces
the data file atomically. A corrupt or inaccessible store reports an error
instead of becoming an empty store. Configure the same path for the process
that writes a credential and every process that reads or revokes it.
Environment provider#
EnvSecretProvider is first in the chain so CI, local shells, and
containers can override secrets without touching the OS credential store.
Environment variable names use:
HARN_SECRET_<NAMESPACE>_<NAME>
For example:
export HARN_SECRET_HARN_ORCHESTRATOR_GITHUB_INSTALLATION_12345_PRIVATE_KEY="$(cat github-app.pem)"
Non-alphanumeric characters are normalized to underscores and multiple separators collapse.
The environment provider is a read-only override within a chain. A write
through the chain (harness.secrets.write, std/oauth secrets storage) goes
to the persistent providers, such as keyring or file, in chain order. The
first one that accepts the value stores it. If every persistent provider
refuses, the write fails. It never falls back to a variable that disappears
when the process exits. The write receipt's provider field names
the backend that stored the value. Only a chain with no persistent provider,
such as HARN_SECRET_PROVIDERS=env, writes to the process environment, and
its receipt says env.
Keyring provider#
KeyringSecretProvider uses the keyring
crate so the same code path works against:
- macOS Keychain
- Linux native keyring / Secret Service backends supported by
keyring - Windows Credential Manager
This is the default local-first provider. The CLI already uses it for MCP
OAuth token storage, and harn doctor probes it directly.
Recommended setups#
Laptop development:
export HARN_SECRET_PROVIDERS=env,keyring
CI or containers:
export HARN_SECRET_PROVIDERS=env
Cloud deployments:
Today, use env for injected platform secrets. The SecretProvider
surface is intentionally ready for Vault / AWS / GCP implementations, but
those provider backends are not wired in yet.