Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

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:

  • EnvSecretProvider
  • KeyringSecretProvider
  • FileSecretProvider (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
  • Debug is redacted
  • Display is 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?) -> string
  • read_bytes(name, scope?) -> bytes
  • read_cli(provider, options?) -> string
  • write(name, value, scope?, ttl_ms?) -> dict
  • rotate(name, generator_or_value, scope?, options?) -> dict
  • lease(name, duration_ms, scope?) -> dict
  • lease_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 doctor reads 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 reports needs_user_approval. Provide the credential through its environment variable or set HARN_SECRET_PROVIDERS=env.
  • A host that launches Harn without a terminal while a person is at the machine can set HARN_SECRET_INTERACTIVE=1 to allow dialogs. HARN_SECRET_INTERACTIVE=0 forbids 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.

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.