Secrets

The secrets boundary contract, how adapters use it, current implementation, and the upgrade path.

The secrets boundary is a hard invariant: credentials and tokens are accessible through exactly one function and are never logged, stored inline, or passed beyond the call site.

The contract

secrets/store.py exposes exactly one function:

def get_secret(connector_id: str, key: str) -> str: ...

No list. No dump. No direct DB access from outside this module. The store is injected as a SecretsStore instance at adapter construction time; adapters do not instantiate it themselves.

How adapters must use it

  1. Receive a SecretsStore instance injected at construction — never call get_secret() at module level.
  2. Call get_secret(connector_id, key) inside execute(), immediately before the credential is needed.
  3. Use the returned value inline in the API call.
  4. Do not store the returned value in self, in a checkpoint, in a log, or in any data structure that persists beyond the call.

config_ref on RegisteredTool points to a DB row with the credential reference — not the credential value. The value is fetched at execution time only.

Current implementation

Milestone 1 implementation: Fernet-encrypted local file, with the encryption key sourced from an environment variable or OS keyring.

Secrets are stored as: connector_id:key → encrypted_value. The file is read on startup; values are decrypted on get_secret() calls.

Nothing in this implementation should appear in audit payloads, logs, or stack traces. The redaction policy in AuditEvent and the payload_ref pattern enforce this.

Upgrade path

SecretsStore is a protocol (interface). Callers depend only on get_secret(connector_id, key) -> str. The implementation is swappable:

  • OS keyring: replace the Fernet file backend with the keyring library. Callers do not change.
  • HashiCorp Vault or similar: implement SecretsStore against the Vault API. Callers do not change.

Migration is a backend swap in secrets/store.py — no changes to adapter code, no changes to the audit or safety layer. This is Outstanding Decision OD-04. See dev/outstanding-decisions.

Examples

The following examples illustrate the rules from How adapters must use it. Each shows a correct pattern alongside the violation it prevents.


Injection at construction (Rule 1)

SecretsStore must be injected — never instantiated inside the adapter.

# Correct — store is injected by the caller
class GmailAdapter:
    def __init__(self, secrets: SecretsStore):
        self.secrets = secrets
# Wrong — adapter creates its own store, bypassing the boundary
class GmailAdapter:
    def __init__(self):
        self.secrets = SecretsStore()

Calling get_secret() at execution time (Rule 2)

Credentials must be fetched inside execute(), immediately before use — not at construction, not at module import.

# Correct — fetched at the moment the credential is needed
class GmailAdapter:
    def execute(self, tool_input):
        token = self.secrets.get_secret("gmail", "oauth_token")
        return gmail_api.send(token, tool_input.payload)
# Wrong — fetched at construction and held on self
class GmailAdapter:
    def __init__(self, secrets: SecretsStore):
        self.secrets = secrets
        self.token = self.secrets.get_secret("gmail", "oauth_token")  # too early
 
    def execute(self, tool_input):
        return gmail_api.send(self.token, tool_input.payload)  # self.token persists

Using the value inline (Rule 3)

The returned value should be consumed directly at the call site, not passed as a variable beyond it.

# Correct — used inline, never assigned to a travelling variable
def execute(self, tool_input):
    return gmail_api.send(
        self.secrets.get_secret("gmail", "oauth_token"),
        tool_input.payload
    )
# Wrong — token is assigned and passed to another method, extending its lifetime
def execute(self, tool_input):
    token = self.secrets.get_secret("gmail", "oauth_token")
    return self._make_request(token, tool_input.payload)  # token travels beyond the call site

Never storing the returned value (Rule 4)

The credential must not be stored on self, in a checkpoint, in a log, or in any structure that outlives the call.

# Incorrect - Stored on self
self.token = self.secrets.get_secret("gmail", "oauth_token")
 
# Incorrect - Written to a checkpoint
checkpoint["token"] = self.secrets.get_secret("gmail", "oauth_token")
 
# Incorrect - Leaked into a log
token = self.secrets.get_secret("gmail", "oauth_token")
logger.info(f"Calling Gmail API with token: {token}")
 
# Incoorect - Embedded in an audit payload
audit.record({"credential": self.secrets.get_secret("gmail", "oauth_token")})

config_ref — reference vs value

RegisteredTool.config_ref stores a pointer to the credential, not the credential itself. The value is only resolved at execution time.

# What lives in the DB (safe to store):
RegisteredTool.config_ref → { connector_id: "gmail", key: "oauth_token" }
 
# What never lives in the DB (only exists transiently at execution time):
get_secret("gmail", "oauth_token") → "ya29.A0ARrda..."

Even a full dump of the RegisteredTool table exposes only references — never the underlying credentials.