Purpose: specify the secret broker, the typed secret model (
SecretHandle/SecretMaterialand their forbidden trait impls), the secret flow and preferred injection order, theApprovedSecretViewone-shot pattern, and the testing that proves a secret can never reach a model.
This is a contract file: changes are serialized and require maintainer approval. Source of truth:
ROADMAP.md§8.1](../ROADMAP.md) (secret types), [§9.4](../ROADMAP.md) (secret flow / injection order), [§9.5](../ROADMAP.md) (redaction/taint), [§18 Phase 8` (tasks/acceptance). Governs invariants 1–3.
Siblings:
docs/security-model.md(taint/redaction boundary) ·docs/github.md(credential proxy in practice) ·docs/sandbox.md(env sanitation) ·SECURITY.md·INVARIANTS.md.
A raw credential never exists in model-visible space, in a log, in an artifact, in a Telegram message, or in a GitHub comment. The model sees only a
SecretHandle(an id and a label) and an availability state. Raw bytes live inSecretMaterial, which the type system refuses to print, serialize, clone, or convert to model-visible text.
This is invariants 1 (no raw credentials to the LLM), 2 (no unredacted secret-bearing logs), and 3 (secret material is not Debug/Serialize/Clone/ model-visible), made structural rather than disciplinary.
From ROADMAP.md §8.1`:
/// Model-visible reference to a secret. Carries NO bytes.
pub struct SecretHandle {
pub id: SecretId, // compact u32 id (ROADMAP §7.1)
pub label: BoundedText, // e.g. "github-app-token"; safe to show
}
/// The actual secret bytes. Never model-visible.
pub struct SecretMaterial {
bytes: crustcore_zeroize::ZeroizingVec, // volatile-zeroized on drop
}SecretHandle is the only secret-related thing that may appear in model
prompts, tool args, logs, or config. It is a name, not a value. SecretMaterial
is the value and lives only inside the trusted secret broker and inside one-shot
views handed to approved tools.
SecretMaterial deliberately does not implement the traits that cause
accidental leaks (ROADMAP.md §8.1`, invariant 3):
SecretMaterial is NOT Debug. // `{:?}` on it does not compile
SecretMaterial is NOT Serialize. // it cannot be written to JSON/log/event
SecretMaterial is NOT Clone. // it cannot be silently duplicated
SecretMaterial CANNOT become ModelVisibleText. // no such conversion exists
SecretMaterial can ONLY be exposed through ApprovedSecretView. // §5
Rationale per impl:
- No
Debug:{:?}and derive-Debugon a containing struct are the single most common accidental leak. Removing the impl turns a leak into a compile error. - No
Serialize: the event log, JSONL export, artifacts, and config all serialize. IfSecretMaterialcannot serialize, none of those paths can carry it (invariant 2). Containing types must therefore hold aSecretHandle, not bytes. - No
Clone: prevents fan-out of the raw bytes into multiple owners that each might leak; the broker keeps a single authoritative copy. - No
ModelVisibleTextconversion: the model boundary is sealed by absence — there is no function with signaturefn(SecretMaterial) -> ModelVisibleText, so invariant 1 holds by construction.
Edge case —
Display:SecretMaterialmust also not implementDisplay(and anyto_string()path), for the same reason asDebug. TreatDisplayas forbidden alongside the list above.
The broker is a trusted component ([SECURITY.md §2.1](../SECURITY.md)). It owns secret storage and is the only thing that materializes SecretMaterial`.
Outside nano (Phase 8), the broker stores secrets in, in order of preference:
1. Native OS keychain (macOS Keychain, Windows Credential Manager, libsecret/
Secret Service on Linux) — preferred; the OS guards the bytes.
2. Encrypted-file vault fallback — when no native keychain is available; bytes are
encrypted at rest with a key derived from an OS-protected secret / passphrase.
Nano stores only secret:// handles — never secret bytes. The nano binary
has no keychain or vault code; it references secrets by handle and delegates
materialization to the broker (which lives outside nano). This keeps nano small
(invariants 19, 20) and keeps the secret-bearing code out of the tiny trusted
kernel surface.
Config: secret://github-app-token (a handle URI; no bytes)
Model context: SecretHandle { id, label } + availability state ("present"/"absent")
Broker only: SecretMaterial { bytes: Zeroizing<...> }
End-to-end flow (ROADMAP.md §9.4`):
1. User enters the secret through a trusted local prompt or an approved OS
mechanism (never pasted into a model conversation).
2. CrustCore stores it in the OS keychain or the encrypted vault.
3. Config stores only secret:// handles.
4. The model sees only handles and availability states.
5. An approved tool receives a one-shot secret view or a credential proxy.
6. The tool's result is redacted before it becomes model-visible.
At no step do raw bytes pass through model-visible space, the event log, or an artifact. Step 1 is deliberately a trusted local path so the secret never transits the untrusted model channel.
A tool that genuinely needs a secret (e.g. to authenticate a request) does not get
SecretMaterial. It gets a one-shot, scoped, expiring view, minted by the
broker only after policy/approval:
/// Single-use exposure of a secret to one approved operation.
pub struct ApprovedSecretView<'b> {
handle: SecretHandle,
// borrowed access to the broker-held bytes; cannot outlive the broker,
// cannot be cloned, cannot be stored, consumed on use.
broker: &'b SecretBroker,
approval_id: ApprovalId,
expires_at: Timestamp,
}Properties:
- One-shot: consumed by the single operation it authorizes; not reusable.
- Scoped: bound to a specific operation/capability and an approval_id.
- Expiring: has an expiry; a stale view is rejected.
- Non-escaping: the borrow ('b) prevents storing the bytes past the call;
no Clone/Serialize/Debug, same as SecretMaterial.
- Redacting: anything the operation emits passes through redaction (§7).
This is the only sanctioned way bytes leave the broker, and even then they go to a trusted-process operation, not to the model. Compare with the credential-proxy pattern (§6), which avoids handing bytes to the consumer at all.
The preferred mechanism for GitHub and model access is to not give the secret to
the consumer at all — a trusted proxy injects it at the last moment. For git in
the sandbox (ROADMAP.md §15.3](../ROADMAP.md), depth in [docs/github.md`):
git in sandbox
-> local credential helper proxy
-> validates repo / branch / refspec
-> injects a short-lived installation token
-> GitHub
No raw GitHub token sits in the sandbox env by default. The same shape applies to
model providers: the crustcore-net sidecar injects the provider key into the
outbound request header; the key never enters nano, the sandbox, or model context.
Secret-bearing data is tainted; tainted data cannot enter model prompts,
model-visible tool results, normal logs, Telegram, GitHub comments, unredacted
artifacts, or panic/debug output (ROADMAP.md §9.5](../ROADMAP.md)). The taint model and the boundary list are specified in [docs/security-model.md §4. Key points for the broker:
- Outbound text crossing a model/Telegram/GitHub boundary passes through a
redacting wrapper; there is no constructor turning
SecretMaterialinto model-visible text (so leakage is unrepresentable, not merely filtered). - Tool stdout/stderr and errors are redacted before becoming a model-visible
result (with a receipt;
docs/receipts.md). - The event log and JSONL export carry handles and redaction state, never bytes
(
docs/event-log.md).
When a secret unavoidably must reach a process, use the highest-numbered-safe
mechanism available, in this order (ROADMAP.md §9.4`):
| # | Mechanism | Why it ranks here |
|---|---|---|
| 1 | Local credential proxy | The secret never leaves the trusted process; the consumer gets an authenticated channel, not bytes. Best containment. |
| 2 | Git credential-helper proxy | Same idea specialized for git; validates repo/branch/refspec and injects a short-lived token (no token in env). |
| 3 | Per-request header injection by a trusted process | The sidecar adds the secret to a single outbound request; the secret stays in the trusted process, scoped to one request. |
| 4 | Short-lived token minted by the broker | If a token must be handed over, a short TTL bounds the blast radius of a leak. |
| 5 | File descriptor / protected temp file with tight lifetime | When a child needs a file/fd, restrict permissions and lifetime; avoids the env entirely. |
| 6 | Environment variable — only when unavoidable | Env is the leakiest path (inherited by children, shows in /proc, easy to dump). Last resort, and env is sanitized/stripped by default (see docs/sandbox.md §env sanitation`). |
The ordering encodes a single principle: prefer to never hand over the bytes; if you must, minimize who holds them and for how long.
From ROADMAP.md §18 Phase 8`:
P8.1 Define SecretHandle/SecretMaterial types.
P8.2 Implement native keychain backends.
P8.3 Implement encrypted-file vault fallback.
P8.4 Implement secret request flow.
P8.5 Implement redactor/taint tests.
P8.6 Implement credential proxy pattern for GitHub/model helpers.
Acceptance:
- SecretMaterial cannot be serialized / debugged / cloned.
- The LLM sees only handles.
- Tests fail on attempted secret leakage.
Status (P8-store — encrypted-file vault implemented). The trust types
(SecretMaterial/SecretHandle), the broker, the redactor/taint boundary, and the
credential proxy are nano-linked and std-only. The encrypted-file vault
SecretStore backend ([crustcore_secrets::store]) is implemented behind the
vault-file cargo feature (P8.3): seal_vault(path, passphrase, entries)
encrypts secrets to a single file — magic | version | salt | nonce | AES-256-GCM(plaintext), with a scrypt (N=2¹⁵) passphrase-derived key — and
open_vault(path, passphrase) decrypts them back into an [InMemoryStore] the broker
reads. It fails closed: a wrong passphrase or any tampered byte fails AEAD
decryption (VaultError::Decrypt) with no partial/plaintext leak; the on-disk bytes
never contain a secret value; the decrypted blob and derived key are zeroed after use;
the decoded length-prefixed contents are bounded and parsed panic-free. Nano
isolation (invariants 19/20): the module and its crypto deps (aes-gcm, scrypt,
getrandom) are gated behind vault-file, never enabled in the nano build, and the
xtask forbidden-deps gate asserts no crypto crate enters the nano graph; the
xtask verify gate clippy- and test-checks the feature explicitly. What remains
(TODO(P8-store)): the native OS keychain backends (macOS Keychain / Linux Secret
Service / Windows Credential Manager) — also feature-gated, never in nano — which
load secrets from the OS store into the same in-memory SecretStore shape.
The guarantee is only real if the absence of dangerous impls is tested.
- Compile-fail tests (trybuild-style): assert that programs which try to
Debug,Display,Serialize, orCloneaSecretMaterial, or convert it to model-visible text, fail to compile (invariant 3). These are the gold standard — they prove the leak path does not exist in the type system, not just that a runtime check caught it. - Redaction / taint runtime tests: the secret-leak matrix S1–S10 in
docs/security-model.md§5` — a sentinel secret routed toward each forbidden boundary (stdout, stderr, env dump, panic, tool error, GitHub error, Telegram draft, external-worker transcript, MCP result) must come out absent / redacted. - One-shot/expiry tests: an
ApprovedSecretViewcannot be reused after consumption, cannot outlive its borrow, and is rejected after expiry. - Handle-only event-log test: assert serialized events/artifacts contain handles and redaction state, never bytes.
- New-surface rule: any new outbound surface that could carry a secret adds its
own leak-matrix row and fixture in the same PR
(
INVARIANTS.md§ Red-team requirement`).