Skip to main content
The secret management system provides centralized credential access, AES-256-GCM encryption, per-agent access scoping, and secret reference resolution. All credential access in the system goes through the SecretManager interface, allowing direct process.env reads to be banned via ESLint everywhere else.

SecretManager Interface

The core SecretManager interface is intentionally minimal — no bulk access or enumeration of values.
Source: packages/core/src/security/secret-manager.ts — immutable interface with 4 methods.
OAuth credentials use a separate port. API keys and other read-only secrets live in SecretStorePort (this page). OAuth tokens — which need refresh-on-write semantics with per-profile-id locking — live in OAuthCredentialStorePort. The two are intentionally separate: the secret store is read-only by contract; the OAuth store is mutable with refresh, expiry, and lock-coordinated writes. See OAuth concepts for the full picture.
interface SecretManager {
  /** Get a secret value by key, or undefined if not set. */
  get(key: string): string | undefined;

  /** Check if a secret key is available (value is defined). */
  has(key: string): boolean;

  /**
   * Get a secret value by key, throwing if not set.
   * Error message includes the key name for diagnostics.
   */
  require(key: string): string;

  /** Get the list of available key names (defensive copy). */
  keys(): string[];
}

createSecretManager

function createSecretManager(
  env: Record<string, string | undefined>
): SecretManager
Creates a SecretManager from a record of environment variables. Design principles:
  • Defensive copy: Takes a snapshot of defined values into an internal Map. Subsequent mutations to the env parameter have no effect on the returned manager.
  • No enumeration: No .env property or .getAll() method is exposed. The only way to list keys is via keys(), which returns a defensive copy.
  • Diagnostic errors: require() includes the missing key name in the error message for troubleshooting.
  • Immutable: The returned object has only the 4 documented methods.

envSubset

function envSubset(
  manager: SecretManager,
  allowedKeys: readonly string[]
): Record<string, string>
Creates a restricted environment record for subprocess spawning. Instead of passing the full process.env to child_process.spawn(), use this to select only the keys the subprocess needs. Returns a plain object with only the allowed keys that exist in the manager.

SecretsCrypto

The encryption engine uses AES-256-GCM with HKDF-SHA256 key derivation. Pure cryptographic primitives — has zero knowledge of storage.
Source: packages/core/src/security/secret-crypto.ts — all operations return Result type, never throw (except createSecretsCrypto which validates the master key at creation time).

Algorithm Details

ComponentSpecification
CipherAES-256-GCM
Key DerivationHKDF-SHA256
HKDF info stringVersioned for future crypto upgrades
Master key minimum32 bytes
Salt32 bytes random per encryption
IV (nonce)12 bytes random (AES-GCM standard)
Auth tag16 bytes (GCM authentication tag)

EncryptedSecret Interface

interface EncryptedSecret {
  readonly ciphertext: Buffer;  // AES-256-GCM ciphertext
  readonly iv: Buffer;          // 12-byte initialization vector
  readonly authTag: Buffer;     // 16-byte GCM authentication tag
  readonly salt: Buffer;        // 32-byte random salt for HKDF
}
All fields are independent Buffer copies with no shared memory references.

createSecretsCrypto

function createSecretsCrypto(masterKey: Buffer): SecretsCrypto
Creates a SecretsCrypto engine with the given master key.
  • Master key must be at least 32 bytes (fails fast at creation, not per-call)
  • Only the first 32 bytes are used
  • Returns an object with encrypt(plaintext) and decrypt(encrypted) methods
  • Both methods return Result<T, Error> — never throw

Encryption Flow

  1. Generate 32-byte random salt
  2. Derive encryption key via HKDF-SHA256: hkdfSync("sha256", masterKey, salt, infoString, 32)
  3. Generate 12-byte random IV
  4. Encrypt with aes-256-gcm using derived key and IV
  5. Return { ciphertext, iv, authTag, salt }

parseMasterKey

function parseMasterKey(raw: string): Buffer
Parses a master key from a hex or base64 encoded string.
FormatMinimum LengthBytes Produced
Hex64 characters32+ bytes
Base6444 characters32+ bytes
Tries hex decoding first (even length, 64+ chars). Falls back to base64 if hex fails. Throws Error if neither encoding produces at least 32 bytes.

ScopedSecretManager

A per-agent SecretManager decorator that restricts access to secrets matching glob patterns and emits audit events.
Source: createScopedSecretManager() in packages/core/src/security/secret-manager.ts (same module as SecretManager) — decorator pattern, callers cannot distinguish from a plain SecretManager.

createScopedSecretManager

function createScopedSecretManager(
  base: SecretManager,
  options: ScopedSecretManagerOptions
): SecretManager
Options:
FieldTypeDescription
agentIdstringAgent this scoped manager belongs to (included in audit events)
allowPatternsstring[]Glob patterns that grant access
eventBusTypedEventBus?Optional event bus for audit event emission

Access Control

  • Glob pattern matching: Case-insensitive, supports * wildcard. Pattern openai_* matches OPENAI_API_KEY.
  • Empty allowPatterns = unrestricted access: This is intentional for backward compatibility — existing agents with no secrets.allow config get Zod default of [], which means “no restrictions” (not “deny all”).
  • Deny behavior: When access is denied, get() returns undefined, has() returns false, require() throws with a descriptive error.

Audit Events

Every access attempt (via get, has, or require) emits a secret:accessed event through the optional eventBus:
{
  secretName: string;
  agentId: string;
  outcome: "success" | "denied" | "not_found";
  timestamp: number;
}
Important: keys() filters the returned list but does NOT emit events (listing operation, not access).

Unrestricted Access Warning

The first time an agent accesses a secret without explicit secrets.allow configuration (empty allowPatterns), a one-time security:warn event is emitted:
{
  category: "secret_access",
  agentId: string,
  message: "Agent ... accessed secret ... without explicit secrets.allow configuration.",
  timestamp: number
}
This warning fires only once per ScopedSecretManager instance.

Credential Broker as a SecretManager Consumer

The credential broker is a per-request consumer of the platform (unscoped) SecretManager. On each proxied HTTPS request, the broker calls SecretManager.get(secretRef) to resolve the binding’s secret. The key is never cached to disk — it lives only in the daemon’s encrypted store and in memory for the duration of the injection. Resolution emits a secret:accessed audit event with the following fields: secretName, agentId, outcome (success/not_found), timestamp. A not_found outcome causes the broker to return 502 and refuse to forward the request — there is no fallback path that sends a request upstream without a valid credential. Scoping note. The broker uses the platform (unscoped) SecretManager for executor-level bindings (executor.broker.bindings[*].secretRef). Per-agent scoped secrets in exec tool invocations (secretRefs in exec tool config) use a ScopedSecretManager — these are two distinct injection paths and are intentionally kept separate.
Source: packages/infra/src/credential-broker/mitm-broker.ts:501–506 — secret:accessed emission on both success and not_found paths. See Credential Broker for the complete broker deep-dive.

SecretRef Resolution

Secret references allow YAML config files to reference secrets from multiple sources without storing plaintext values.
Source: packages/core/src/security/secret-ref-resolver.ts — Three resolution providers.

Providers

SourceSyntaxDescription
envenv:provider/KEY_NAMEReads from SecretManager (environment variables or encrypted store)
filefile:provider/pathReads from an absolute file path. Supports JSON Pointer extraction via provider#/json/pointer syntax
execexec:provider/idInvokes a credential helper binary via JSON RPC protocol with timeout

File Provider Details

  • Path must be absolute (rejects relative paths)
  • Rejects paths containing .. segments
  • Validates file exists and is a regular file
  • Handles symlinks: resolves with realpathSync and re-validates
  • Maximum file size: 1 MB (default fileMaxBytes)
  • JSON Pointer extraction: provider#/json/pointer syntax for extracting specific fields from JSON files

Exec Provider Details

  • Sends JSON RPC request: { protocolVersion: 1, provider, ids: [id] }
  • Default timeout: 10 seconds (execTimeoutMs)
  • Maximum output buffer: 1 MB
  • Validates response shape: { protocolVersion: 1, values: { [id]: string } }

Config Deep Walk

resolveConfigSecretRefs() recursively walks a config object and resolves all SecretRef values to strings. Operates on a structuredClone — never mutates the original. Fails fast on the first resolution error.

Secrets Audit

The secrets audit scanner detects plaintext secrets in config files and known provider environment variables.
Source: packages/core/src/security/secrets-audit.ts — Structured findings for CLI and JSON output.

scanConfigForSecrets

Walks raw parsed config recursively. For each leaf string value whose field name matches SECRET_FIELD_PATTERN, emits a PLAINTEXT_SECRET finding unless the value is a SecretRef object or empty.

scanEnvForSecrets

Scans env records for known provider patterns. Detects API keys and secrets from 11 named providers (Anthropic, OpenAI, Telegram, Discord, Slack _BOT_TOKEN and _SIGNING_SECRET, Groq, Deepgram, ElevenLabs, Brave, Google, Comis SECRETS_MASTER_KEY) plus 4 generic suffix patterns (_API_KEY, _SECRET, _TOKEN, _PASSWORD). Skips operational variables: COMIS_*, NODE_*, PATH, HOME, SHELL, USER, TERM, LANG, TZ, EDITOR, VISUAL.

Finding Types

CodeSeveritySourceDescription
PLAINTEXT_SECRETerrorConfig filesNon-empty string in a secret field name
KNOWN_PROVIDER_ENVwarn.env filesKnown provider secret in environment
HIGH_ENTROPY_VALUEinfoVariousSuspicious high-entropy string

Configuration

Secrets storage (Global)

FieldTypeDefaultDescription
security.storage"encrypted" | "file" | "env""encrypted"Credential storage backend: encrypted (AES-256-GCM SQLite), file (plaintext JSON at 0600), or env (read-only, reads .env/process.env). The database path is fixed at <dataDir>/secrets.db and is not configurable.
Source: packages/core/src/config/schema-security.tsSecurityConfigSchema Zod schema.

AgentSecretsConfigSchema (Per-Agent)

FieldTypeDefaultDescription
allowstring[][]Glob patterns for allowed secret names. Empty = unrestricted access.
# Global secrets configuration
security:
  storage: encrypted   # encrypted (default) | file | env; DB path is fixed at <dataDir>/secrets.db

# Per-agent secrets access
agents:
  my-agent:
    secrets:
      allow:
        - "OPENAI_*"
        - "ANTHROPIC_*"
        - "MY_CUSTOM_KEY"

Secret Access Utilities

matchesSecretPattern

function matchesSecretPattern(secretName: string, pattern: string): boolean
Case-insensitive glob matching for secret name filtering. Supports * as wildcard. Regex special characters in the pattern are treated as literals.
Source: packages/core/src/security/secret-access.ts — custom 15-line implementation instead of picomatch/minimatch (sufficient for * wildcards).

isSecretAccessible

function isSecretAccessible(
  secretName: string,
  allowPatterns: string[]
): boolean
Returns true if the secret name matches any of the allow patterns, or if allowPatterns is empty (unrestricted access for backward compatibility).

Pattern Examples

PatternMatchesDoes Not Match
openai_*OPENAI_API_KEY, OPENAI_ORG_IDANTHROPIC_API_KEY
*_api_keyOPENAI_API_KEY, BRAVE_API_KEYDISCORD_BOT_TOKEN
my_secretMY_SECRET (case-insensitive)MY_SECRET_2
*Everything
For user-facing setup guide, see Secrets.

Security Model

Defense-in-depth security architecture

Node Permissions

Node.js permission model integration

OAuth

Subscription-based authentication storage and refresh