Vault Access — Secret Manager (External or Settings)
A Secret channel with one reference form, integrated into the existing
{{secret:…}}layer. Behind it is either an external manager (Infisical) or — without any configuration, as default — Vancetope’s ownhiddensettings. Secrets are never stored in Vancetope in plain text, but are resolved server-side at runtime and usable wherever Vancetope already recognizes secrets — Tool Templates, SMTP, REST Tools, and thesecrets:block in Compose. Additionally, secrets can be provisioned (server-side generated, without the model seeing the value) or written via two LLM tools.Status: v1 productive. Providers:
settings(Default, no setup) and Infisical (self-hostable); other managers (HashiCorp/OpenBao, Bitwarden Secrets Manager) connect via the same SPI.Settings are referencable via the same layer.
PASSWORDremains neither readable nor writable for agents and scripts, but is usable by connectors;HIDDENis for secrets that a script or Compose task must resolve itself (§4.1/§4.2).See also
settings-system.md(Setting Types §2, Scope Cascade §3),damogran-system.md(Compose). Derivation of the type barrier:planning/setting-type-hidden.md.
1. Model
A secret has a reference (vault:<key>), never a value in the document.
Resolution happens server-side at the time of access; the value leaves the
server only in the outgoing call (HTTP header, exec environment). An agent
writes references, never values.
2. Binding — One Vault per Scope, Cascaded
The Vault connection is a group of cascading settings under vault.*:
| Key | Meaning |
|---|---|
vault.type |
Provider discriminator (settings | infisical) — empty = settings |
vault.baseUrl |
Endpoint (Infisical Cloud or self-hosted) |
vault.project |
Infisical Project/Workspace ID |
vault.environment |
Environment slug (prod, …) |
vault.path |
Default folder (/) |
vault.clientId |
Machine Identity Client ID (Universal Auth) |
vault.clientSecret |
Machine Identity Client Secret (PASSWORD, encrypted) |
Exactly one binding per Scope. VaultService determines the innermost
cascade layer (user → project → tenant) that carries vault.type, and reads all
keys from exactly this layer — atomically, so that a partially configured user level
does not assemble a mixed clientId/clientSecret binding. A binding set at the
user level thus wins as a whole over the project binding, and this over the
tenant-wide _tenant binding. Headless/service runs (no user) cleanly fall back
to project/tenant. Configuration is done via the Vault setting form
(Profile for user scope, Workspace for project scope). A tenant-wide
default Vault can alternatively be seeded at boot — the vault.* keys in the
Init Settings Template (qa/init-settings.yaml.dist) land via
InitSettingsLoader precisely in the _tenant layer, which the cascade reads as a fallback.
2.1 Default without Configuration: the Settings Vault
If vault.type is not set on any layer, this is not an error — access
falls back to the settings provider (§3.1). {{secret:vault:<key>}}
thus works without any configuration, and a document written against
vault:my-token remains valid if Infisical is bound later: the value
moves, the reference does not. This is precisely why the provider-agnostic
prefix exists.
Practical consequence: vault: is the one agent-capable reference form from day 1.
Those who do not want to operate anything externally still use it — and later grow
into an external manager without document changes.
VaultService.isConfigured(scope) still says “external manager bound”
and is not a precondition for a read — it is only intended for status displays,
never to skip the Vault path. Which Vault served a call is on a separate
series — vance.vault.bindings{outcome=settings|external}
— to make visible how many installations run without an external manager.
Deliberately not folded into the result metric: its outcome values are
terminal outcomes, and success / sum(outcomes) should be a success rate.
An additional binding value in the same series would count every call of the
default installation twice and push the rate down to 50%.
3. Providers
VaultProvider (SPI, vance-shared): type() + readSecret(binding, scope, key) +
writeSecret(binding, scope, key, value) (Default UnsupportedOperationException)
requiresEndpoint()(Defaulttrue).VaultServiceselects the provider byvault.type. Providers are stateless — they receive the resolvedVaultBindingper call.
InfisicalVaultProvider (vance-brain) delegates to InfisicalClient: Universal
Auth login (POST /api/v1/auth/universal-auth/login) with access token cache per
(baseUrl, clientId) + one-time 401 refresh retry; secrets via the v4 API
(/api/v4/secrets/{name} with projectId/environment/secretPath, value under
secret.secretValue). Older self-hosted instances may require a different
API version.
4. Reference Grammar
Fixed, provider-agnostic prefix vault: (which manager is behind it is determined
solely by vault.type — a provider change does not invalidate references):
{{secret:vault:<key>}}
is located in SettingsSecretResolver alongside the existing scopes
(project:/tenant:/user:/Cascade-Default). An error (provider unreachable,
Auth rejected) fail-closed to empty + WARN — the dependent call then fails
with 401 instead of propagating. A missing binding is no longer an error:
it selects the Settings Vault (§2.1).
4.0a The Counterpart: {noop} for a Declared Literal
{noop}sk-abc123
A value starting with {noop} is taken literally: prefix removed, no
resolution. The syntax is that of Spring Security’s
DelegatingPasswordEncoder. Valid wherever {{secret:…}} is valid.
Whether a credential is a reference or in plain text is decided by the
configurator — this layer does not make the choice. What it provides is a
notation in which the choice is visible: a naked literal value passes through
unchanged anyway, so {noop} is first an explanation and only then a
mechanism. It becomes strictly necessary for a literal value that itself contains
{{. A literal that should start with {noop} is written
{noop}{noop}….
It is implemented in SecretResolver itself, not in the implementations: an
implementation only provides substitute/substituteForConnector, the
literal is intercepted by the default method resolve. The difference is not
academic — the passthrough resolver used by McpConnection, RestHttpInvoker, and
McpHttpTransport without an injected resolver would otherwise have passed the
prefix into an Authorization header. (For the same reason, the constant is
SecretResolver.PASSTHROUGH and no longer NOOP: it means the opposite of {noop}.)
The first major consumers are the source configuration documents of Zarniwoop, Centauri, and Jaglan — there the credential is in a file, and whether it is a reference or a declared plain text must be visible from the file.
4.1 Who May Resolve: Connector vs. Dynamic Element
The criterion is not whether a reference is involved — but who reads the value:
| Caller | Examples | May Resolve |
|---|---|---|
| Connector (Operator Config) | SMTP/IMAP Tool Document, REST and MCP Tool Pack | PASSWORD and HIDDEN |
| Dynamic Element | Script vance.secret(…) (JS/Python), Compose secrets: |
only HIDDEN |
A connector is configuration, not a dynamic element — even if its config
contains a {{secret:…}} reference. Therefore, a credential that only it uses
remains PASSWORD: usable, but neither readable nor writable for agents and
scripts. This is precisely the purpose of the two types. Without this
separation, every tool credential set up by the operator would become HIDDEN
and PASSWORD would only remain for what compiled code reads with a fixed key —
the distinction would be practically worthless.
Technically: SecretResolver has two paths. resolve(…) is the restrictive
default (only HIDDEN), resolveForConnector(…) reads both types. The default is
intentionally the narrower one — an implementation that does not know the
separation will therefore only become narrower, never broader. The three
dynamic surfaces call resolve unchanged; the eight connector locations
(REST Invoker, MCP Transport + Connection, SMTP, IMAP Factory) call
resolveForConnector.
If a dynamic element encounters a PASSWORD setting, it does not substitute
empty, but throws a SecretAccessDeniedException with key and solution. Reason:
an denied secret would otherwise be indistinguishable from a missing one and
would arrive as an opaque 401 — the same logic as with OAuthExpiredException.
Per channel, this arrives as a JS Error, HTTP 403 (Python endpoint), or visible
Compose task failure.
vault: follows the provider. With an external manager, no setting type is
involved. With the Settings Vault (default, §2.1), these are settings — and
there, HIDDEN-only applies because the caller in this case is a dynamic element.
Reserved Keys — R3, regardless of type. Since connectors read PASSWORD, the
type alone no longer separates the provider key from a reference. A
tool document names target URL and header side-by-side; a
{{secret:project:ai.provider.openai.apiKey}} in the header would therefore go
where the same document points. SecretReferenceKeyPolicy therefore rejects keys
from vance.settings.secret-reference-deny-keys before any lookup — on both
paths (resolve and resolveForConnector) and regardless of whether the setting
even exists (otherwise it would be a trial channel). Deliberately a separate list
next to agentWriteDenyKeys: same grammar, but writing and resolving are
different questions — and therefore also different length lists (e.g.,
kit.token.* is only on the write list because a provisioning document legitimately
references the key).
The vault: exception is an exception at the resolver level, not a loophole.
SettingsSecretResolver excludes the vault: scope from the check because a
Vault key usually names an entry in a foreign namespace: an Infisical secret
named ai.provider.openai.apiKey is not the identically named setting, and
rejecting it would be a rejection due to accidental lexical equality. Since the
Settings Vault is the default, this reasoning no longer applies to it — the
key it receives is a setting key and goes literally to
getReferenceSecretCascade. So SettingsVaultProvider.readSecret applies the list
itself, before each lookup. An external manager does not do this. This keeps
the second barrier (“a reserved key is unreachable by its name, regardless of its
type”) intact for the default installation, without making claims about foreign
Vaults.
4.2 Write Side: PASSWORD is Untouchable for Agents
Symmetric to reading. There are exactly two agent-accessible
setting write paths — tool_template_apply and the Kit install — and for both
applies (SettingWriteOrigin.AGENT):
| Rule | Effect |
|---|---|
| W1 | an existing PASSWORD setting is never overwritten |
| W3 | an agent may not write keys from vance.settings.agent-write-deny-keys (Default ai.provider.*,vault.*) at all — regardless of type and whether the setting exists. The read counterpart is secretReferenceDenyKeys (§4.1) |
The type follows usage, not origin. A Kit template that installs an SMTP or
REST credential writes PASSWORD — even if an agent triggered the apply and the
value once passed through the model context. A one-time exposure during writing
does not justify a permanent weakening of the credential, which is then only used
by a connector. HIDDEN is only written by those who know that a script or a
Compose task must resolve the value itself — for example, vault_secret_generate.
W3 is a property and not a setting — as a setting, an agent with setting write rights could extend its own rights.
5. Compose Injection (secrets:)
An exec task declares a secrets: map (env name → reference). The values are
resolved at runtime and injected as a sealed environment into precisely this
command (WORK target only):
tasks:
- type: exec
secrets:
DEPLOY_TOKEN: vault:deploy-token
command: 'curl -H "Authorization: Bearer $DEPLOY_TOKEN" …'
Env names are parse-validated (identifier). Two leak controls:
- State Deny List: the exec state wrapper skips injected secret names in the env delta serialization, so that a script that re-sets the same name does not persist it to the state store (the process env form is already excluded via the baseline snapshot).
- Output Masking: a
SecretMaskerreplaces known injected values in the LLM-/document-facing result log with***(best-effort, exact substring).
6. Script Access (vance.secret)
Scripts pull secrets via vance.secret('<ref>') (full grammar) — the
leak-free pull, counterpart to Compose env injection. The value lives only in a
script variable, never in env or state.
- JS (in-JVM):
ScriptSecretApionvance.secret, resolved directly via theSecretResolverwith the bound run scope (never script-supplied). Pulled values go into a per-run tee; a string return is masked. - Python (subprocess):
vance.pysecret(ref)→GET /brain/{tenant}/script/secret(only with SCRIPT_RUN token). Server-side, a runId-keyedScriptSecretAccumulatorrecords the pulled values;ExecJobRenderermasks stdout/stderr of the run with it.
Boundary: the capability depends on the standard script API — available where
vance.documents is also available (Cortex spawn). The LLM execute_python/
execute_javascript path does not mint a SCRIPT_RUN token and does not yet have
the contract.
7. Write Tools
Both deferred + non-primary (opt-in, sensitive), gated on project scope
Action.WRITE:
vault_secret_generate(key, [format], [length])— generates the value server-side (SecureRandom;alphanumeric/hex/uuid), writes it, and returns only the reference, never the value. The leak-free way to provision credentials.vault_secret_set(key, value)— writes a given value (which by definition has already passed through the model context); does not echo it back.
Provider-side, write is a create-or-update (PATCH→POST); the hard backstop remains the scope of the Machine Identity — a read-only token will cause the write to fail, regardless of what the Vancetope gate says.
8. Security & v1 Limitations
- Settings are only referencable as
HIDDEN(§4.1/§4.2). Until the introduction of the type, the{{secret:…}}layer exposed every encrypted setting to models and scripts — includingvault.clientSecret, i.e., the Vault credential itself, allowing a script to completely read the Vault. This is closed;PASSWORDis neither readable nor writable for agents. - Leak reversal on write: Reading never exposes the value to the model; a
value written via
vault_secret_set, however, has already passed through the context. For true secrecy, usevault_secret_generate. - Masking is best-effort: only exact raw values in the result log; transformed
forms (base64, url-encoded, copied to another variable) slip through.
Live exec tail and server-local exec log files are not masked in v1.
With
vance.secret(script pull), a JS string return or Python stdout is masked — an object graph return or console/log outputs are not. - WORK-only:
secrets:injection on CLIENT/DAEMON targets is ignored with a warning (no sealed-env channel there). - No Value Cache in v1 (token cache in client yes); one remote GET per
resolve, consistent with the existing
{{secret:project:}}resolution.
9. Implementation
vance-shared: de.mhus.vance.shared.vault.{VaultProvider, VaultService,
VaultBinding, VaultScope, VaultException, SettingsVaultProvider}. vance-brain:
de.mhus.vance.brain.vault.{InfisicalVaultProvider, InfisicalClient,
ComposeSecretResolver, SecretMasker, ScriptSecretAccumulator, VaultToolSupport,
VaultSecretGenerateTool, VaultSecretSetTool}, plus secrets: processing in
damogran.{DamogranManifest, DamogranManifestParser, ComposeExec,
WorkspaceComposeExec, ExecDamogranTask, DamogranTaskSupport}, the vault: scope in
tools.rest.SettingsSecretResolver, and the script pull via
script.{VanceScriptApi.ScriptSecretApi, ScriptSecretController} +
tools.exec.ExecJobRenderer-masking + python-helpers/vance.py. Setting form
_vance/setting_forms/vault.yaml, Manual _vance/manuals/vault-secrets.md.
The type barrier from §4.1/§4.2 is in vance-shared:
settings.{SettingType (predicates encrypted/referenceReadable),
SecretAccessDeniedException, SettingWriteOrigin, AgentSettingKeyPolicy} plus
SettingService.{getReferenceSecret, getReferenceSecretCascade,
getReferenceUserSecret, setAgentSecret, setEncryptedSecret}. Origin-threading in
brain.kit.{KitService, KitInstaller, TemplateApplier}. Existing data:
shared.schema.migrations.Migrator_2026_08_11_001_HiddenSettingType.