Secrets Overview
The Sealed Credential Store manages sensitive values (API keys, passwords, tokens) across the full lifecycle: encrypted on disk, decrypted on demand, and scrubbed before they re-enter agent context.
Architecture
The store is composed of four layers:
| Component | Role |
|---|---|
SecretStore | Heap-only runtime store. Values held as Zeroizing<String>. All resolve/substitute calls return StoreLocked while locked. |
EncryptedSecretStore | SQLite-backed persistence. Secret values encrypted with AES-256-GCM; master key derived from a passphrase via Argon2id. KDF salt stored in a kdf_params table. |
SecretPolicyStore | SQLite-backed pre-approval policy store. Policies survive daemon restarts. |
SecretGrantStore | Ephemeral in-memory per-agent delegation grants. Grants expire on daemon restart by design. |
Design Goals
- Encrypted at rest: Secret values are encrypted with AES-256-GCM before being written to SQLite. The master key is derived from a passphrase (Argon2id, 64 MiB memory cost) and held in
Zeroizing<[u8; 32]>- zeroed on drop. - Opaque handles: Agents reference secrets as
{{secret:<name>}}in tool arguments; the daemon substitutes the plaintext only inside the tool dispatch layer. - Output scrubbing: Tool results are scanned for secret values before they are returned to the agent; matches are replaced with
[REDACTED:<name>]. - Pre-approval policies: Secret resolution requires an active policy matching the (secret, tool, host) triple - no open-ended blanket access.
- Audit trail: Every secret use is recorded with the matching policy ID.
- Canary tokens: Fake secrets embedded in the store detect exfiltration attempts.
Security Properties
| Property | Guarantee |
|---|---|
| Disk persistence | Values encrypted with AES-256-GCM; master key never written to disk |
| In-memory | Values held as Zeroizing<String>; zeroed by the allocator on drop |
| IPC responses | Values never appear in ash output or IPC responses |
| LLM context | Values scrubbed before re-entering LLM context |
| Agent memory | Agents never see plaintext; only {{secret:<name>}} handles are visible |
| Kernel | Secrets are not in environment variables (not in /proc/<pid>/environ) |
| Locked state | All resolve/substitute calls return StoreLocked until ash secrets unlock succeeds |
Lock / Unlock Lifecycle
First run:
ash secrets unlock # prompts for passphrase → init_passphrase() → store ready
Daemon restart:
ash secrets unlock # prompts for passphrase → derive_key() + load_all() → store ready
While running:
ash secrets lock # zeroize all in-memory plaintext; subsequent uses return StoreLocked
ash secrets unlock # re-derive key and reload from encrypted store
Key rotation:
ash secrets rekey # prompts for old + new passphrase; re-encrypts all blobs atomically
On first run (no KDF params in the database yet), ash secrets unlock automatically calls init_passphrase() to create a fresh encrypted store with the supplied passphrase. On subsequent starts it calls derive_key() against the stored salt.
Workflow
1. Operator unlocks / initialises the store:
ash secrets unlock
(prompted for passphrase; initialises on first run, reloads on restart)
2. Operator registers a secret:
ash secrets add my-api-key
(value read via echo-off prompt; persisted encrypted to SQLite if store is unlocked)
3. Operator creates a pre-approval policy:
ash secrets policy add \
--label "API access" \
--secret "my-api-key" \
--tool "web.fetch" \
--host "api.example.com"
4. Agent uses secret in a tool call:
{"url": "https://api.example.com/v1", "headers": {"Authorization": "Bearer {{secret:my-api-key}}"}}
5. agentd:
a. Finds active policy matching (my-api-key, web.fetch, api.example.com)
b. Substitutes plaintext in the tool input (never logged)
c. Calls the tool handler
d. Scrubs the tool output before returning to agent
e. Writes audit entry: "secret_used: my-api-key via web.fetch (policy: <uuid>)"
Declaring Secret Access in a Manifest
spec:
capabilities:
- secret.use:my-api-key
- secret.use:db-* # glob: all secrets starting with "db-"
secret_policy:
- label: "API access"
secret_pattern: "my-api-key"
tool_pattern: "web.fetch"
host_pattern: "api.example.com"
The secret_policy section in the manifest creates pre-approval policies automatically at spawn time. This enables unattended operation without requiring a separate ash secrets policy add step.