http: let a header value come from the Vault, so a keyed API needs no secret in the conversation
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 22/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- node.js, typescript
Research direction
Start in plugins/http/src/index.ts, where headers are passed to fetch, and in the matching ToolContracts arm. The schema is enforced by plugins/json-validation/src/index.ts (lines 22-24), and the Vault interface is in plugin-api/src/types/vault.ts. Read the gate's OR logic in plugins/default-gate/src/gate.ts, and MissingSecretError in plugin-api/src/errors.ts. Done means a { Vault: name } header resolves before fetch, is gated per (origin, key) without exposing the value, and returns a tool error for missing keys.
Written by the indexing model from the issue text.
Description
Use case
Call a REST API that wants a credential in a header, without the credential ever entering the conversation.
http today can send any header, but the value has to be a literal, which means the model must have the
secret in its context to pass it. There is no way to say "send my OPENAI_API_KEY as Authorization".
The only routes for a keyed API are: type the secret into the conversation (where it is stored in the
session, re-sent to the provider every turn, and never redacted — see below), or have a human hand-write a
bespoke plugin. MCP servers cover some of these APIs; arbitrary REST does not.
The tool contract is the intended door for this. ComposedCallContext deliberately withholds the vault
from a model-authored tool_function body — "vault, files, prompt and plugin (un)loading stay off a
surface authored by a model and reachable only through the tool contracts"
(plugin-api/src/types/tools.ts:265-275) — so a function-tools body cannot reach a secret by design, and
should not be given one to fix this. Widening the http contract gives the model the effect of a
credential (a request that authenticates) without the value, which is exactly the split that comment intends.
Proposal
In plugins/http/src/index.ts, allow a header value to be either a string (as now, sent as-is) or
{ Vault: "keyname" }, a vault key to look up, which the executor resolves through ctx.vault before
handing the headers to fetch:
headers: { Authorization: { Vault: 'DEEPSEEK_API_KEY' }, 'X-Trace-Id': 'abc' }
Vault is capitalised to match the service key it names (services.Vault, MatbotServices.Vault).
The executor already has the vault: ToolContext.vault (plugin-api/src/types/tools.ts:209), populated at
core/src/runner.ts:663 and plugin-api/src/invoke-tool.ts:109 (so invokeTool callers and
POST /tools/:name get one too). No new plumbing.
Literal or key, discriminated by type
The two forms are told apart by type, in the params themselves: a string is a literal header value, an
object with a Vault field is a key name to look up. Nothing is parsed to find the keys — the set of keys a
request uses is exactly the set its params declare, which is the set the gate then reads. Because a bare key
name is what the params carry, there is no interpolation, no pattern to match, and no grammar for a
name-extractor to disagree with resolve about: a key the gate did not see cannot reach the wire, because
there is nowhere for it to hide.
http's whole contract is name a key, get its value: the params say which key, the vault decides what that
key means. How a backend resolves one, and what a value may contain, is entirely the backend's business — the
Vault interface promises only resolve(ref): string (plugin-api/src/types/vault.ts:9-10) and says nothing
about how a key is turned into a value. What the backend hands back is sent as the header value, verbatim.
The executor reads through that one primitive, so it must name the key in the form resolve accepts — the
${NAME} placeholder. It is worth being explicit about why the wrapping is necessary and safe: measured
against VaultImpl, resolve('DEEPSEEK_KEY') matches no placeholder and returns the string "DEEPSEEK_KEY"
unchanged, so a bare name passed through as-is would send the name where the key was meant. Wrapping is safe
because the key-name rule guarantees a storable name contains no whitespace, $, { or }
(REFERENCEABLE_KEY_RULE, plugin-api/src/vault.ts:9), so the name can always be referenceable and the
construction cannot be ambiguous.
Composition is the vault's business, not http's
{ Vault: "K" } substitutes the whole header value, so a header that is a prefix plus a secret —
Authorization: Bearer <key>, the most common API auth shape — is not expressed by the params. That is
deliberately left to the vault, which is where it belongs: a value is whatever the backend resolves the key
to, and http sends it without inspecting it.
Two ways a backend can cover it, neither of which changes this tool:
- Store the whole value.
DEEPSEEK_KEY=Bearer abc123— the resolved string is the header value, so it
just works. This is available today, with no code change anywhere. - Substitutions inside a value.
COMPOSED=Bearer ${DEEPSEEK_KEY}— a value that references another key.
Note this does not work today:VaultImpl.resolveis a single, non-recursive pass, so
resolve('${COMPOSED}')returns the literal"Bearer ${DEEPSEEK_KEY}"with the inner placeholder
unexpanded (measured). Making values recursive, or giving a value a substitution syntax at all, is a
backend concern and an entirely separate change from this one.
The reason to keep it out of http is the same reason the arm is discriminated by type: an optional
prefix field on the arm, or a ${NAME} string for the executor to interpolate, would put value composition
back into a tool that is supposed to do nothing but name a key. It would also weaken the gate, which approves
a key — a named credential, taken as a unit — rather than a string http assembled out of one.
Schema and types
-
HttpInput.headers→Record<string, string | { Vault: string }>, and the same in theToolContractsarm.
Both copies need it: the arm still inlines the shape instead of referencingHttpInput(#100 covers that
duplication, and this change is a nice argument for doing it first). -
inputSchema.headersis{ type: 'object', additionalProperties: { type: 'string' } }today, and
json-validation enforces it (plugins/json-validation/src/index.ts:22-24): an object value is rejected
withexpected string, got objectbefore the executor ever runs. The schema must change, and it can pin the
object arm's shape withoutanyOf— every keyword below is in the validator's enforced set
(json-validation/src/index.ts:22-24), so nothing here passes through unvalidated and no "keyword this
validator does not check" warning is emitted (json-validation/src/index.ts:142-148):headers: { type: 'object', additionalProperties: { type: ['string', 'object'], required: ['Vault'], properties: { Vault: { type: 'string' } }, } }additionalPropertiesdescribes each header value here, so each may be a string or a{ Vault: string }.
A string value skips the object keywords entirely (the validator appliesproperties/requiredonly when
the value is an object), so the literal arm is unaffected.Verified end to end against the real validator (
node --import ./apps/cli/register.js, driving
plugin.setupand calling the registeredToolCallValidator). Proposed schema:header value result 'Bearer abc'accepted { Vault: 'DEEPSEEK_API_KEY' }accepted { Vault: 123 }rejected — expected string, got number{}rejected — required property missing{ vault: 'K' }(wrong case)rejected — not a declared property,Vaultmissing['a']rejected — expected one of string, objectUnder the current schema the object form is rejected in every one of those cases, which is the bug. A useful
consequence of pinningrequired: ['Vault']: a misspelling fails structurally instead of being silently
sent as a literal value — the discriminator is enforced, not guessed. -
descriptionstrings must carry the form, since that is the only place the model learns it: name the
{ Vault: "KEY_NAME" }shape on theheadersproperty and give one example in the tool description. A
capability the model cannot discover is not a capability.
Errors
resolve throws MissingSecretError carrying missingKeys (plugin-api/src/errors.ts:30-39). Catch it and
yield a tool error naming the keys and pointing at plugin / store-key — the out-of-band path that already
exists for exactly this (plugins/tool-plugin/src/tools/plugin.ts:665-687; browser:
plugins/browser/src/plugin-tool.ts:140), where the value is prompted for and "never enters the conversation
transcript".
The tool must not prompt for the missing value itself. ctx.prompt is available, but two routes to store
a secret is one too many, and store-key exists precisely so the value arrives on a channel the transcript
never touches. Note that the browser's interactive prompt (resolveInteractive,
apps/web-bundle/src/bootstrap.ts:104) is an assemble hook handed to resolveSecret, not something on
ToolContext — a tool cannot reach it, so "resolve interactively" is not an option here either way.
Gate design: a (host, key) pair
One approval per destination+secret pair, so the question a human answers is the act itself:
Send the key DEEPSEEK_API_KEY to https://api.deepseek.com as the header
Authorization?
An approval is then about a site and a key. A request carrying the same key to any other origin does not
match it and asks, which is what stops an injected or rogue turn routing a key somewhere new.
Subject and encoding. subject = <origin> <KEY> — e.g. https://api.deepseek.com DEEPSEEK_API_KEY.
A space separates the halves because both are whitespace-free by the vault's own rule
(REFERENCEABLE_KEY_RULE, plugin-api/src/vault.ts:9, excludes whitespace from a storable name; an origin
contains none), whereas # would be ambiguous: a key name may contain #, so it cannot delimit.
The origin (scheme + host + port), not the hostname, so http://… is a different act from https://… —
a downgrade to plaintext, or another port, is separately asked for rather than silently covered by an
approval for the real API. It also handles homoglyphs: URL.hostname is punycode, so a Cyrillic-а
lookalike appears as xn--… in subject and label rather than as something resembling the real host.
Subject derivation. The keys to gate are read straight off the params: every { Vault: name } in
headers. No parsing, no extraction, and no possibility of the gated set differing from the set the request
actually uses — the params are the declaration.
Gate before resolving, and hand the gate names only. The subject comes from the { Vault: name } fields
in the params, which needs no secret to read — so the gate runs before resolve is called, and resolve runs
only once the act is allowed. The gate is never handed a value, a masked value, or
anything derived from one. Two things fall out: a refused request never reads the vault at all, and there is
no code path on which a secret can reach a prompt, a label or a broadcast event.
One gate call per distinct key name — do not batch a request into one call. allows() in the default gate
is an OR over the whole act (plugins/default-gate/src/gate.ts:55-56): a standing answer covers it when it
matches the subject or any subject offered in standing. A single call covering KEY_A+KEY_B therefore
lets an approval of KEY_A admit a later request carrying both, short-circuiting the approval KEY_B never
got. Per-key calls keep the approvals independent; the cost is one extra prompt in the rare multi-key value.
Standing answers. Offer the exact pair, and optionally "any key to this origin". Never offer a
key-wide ("this key, any host") answer — that is the act the pair exists to gate, and the policy would
store it. Worth knowing: the default gate appends its own blanket "Always allow every http.secret" after
the call site's options (plugins/default-gate/src/gate.ts:130), which would likewise nullify the guard; a
human can decline it, but it is always on the menu.
The label carries names only — never the value, not even masked. Lead with the key name and the origin,
which are what is being decided, then the header name. A masked shape (Bearer ****) is deliberately not
shown: any rendering of the value puts it in a prompt that is broadcast to every client attached to the
session (the web frontend streams it over GET /events/sessions/:id, plugins/frontend/web/src/server.ts:697)
and to whatever frontend raised it, and req.label is the call site's, rendered verbatim by the policy
(plugins/default-gate/src/gate.ts:114-117) — so it is ours to get right, and the safe form is the one with
no value in it. The header name must not be the headline either: it is model-chosen, so a rogue call will
give it an innocuous name (X-Request-Id) while the value is a key.
Non-interactive. fallback: false, like every other credential-shaped gate: a background job with nobody
to ask refuses rather than sends. That is the argument for the standing answer — a job runs on an approval a
human gave once, interactively, not on a prompt it can never raise.
What is approved is the key, taken as a unit. The subject names the key the params declared; what the
backend resolves that key to — including a value that composes other keys, if a backend ever supports that —
is not something http inspects or the gate second-guesses. A human approving DEEPSEEK_API_KEY for one origin
is approving that named credential for that site, which is the granularity that makes sense to them.
Residual. Approving an origin approves every endpoint on it, so it is an approval to trust that host with
the credential at all. Which host a name resolves to is out of scope (as ever — DNS, /etc/hosts, rebinding).
A redirect must not carry the key somewhere new
The pair guard is worth only as much as the origin in the subject being the origin that receives the key, and
with fetch's default redirect handling it is not. Measured on node v24.14.1 (undici), 302 to a different
origin with redirect: 'follow':
| header | arrives at the redirect target |
|---|---|
Authorization |
no — stripped by fetch |
Cookie |
no — stripped |
X-API-Key |
yes |
Api-Key |
yes |
X-Auth-Token |
yes |
So approving DEEPSEEK_API_KEY for api.deepseek.com under X-API-Key is defeated by one open redirect on
that host, and only the header literally named Authorization is saved — by fetch stripping the credential
(the request fails rather than leaks), not by anything this tool did. Most keyed APIs do not use that name.
Fix: when the request resolves at least one secret, send it with redirect: 'manual' and do not follow.
- Node: manual returns the real 3xx with a readable
Locationand body (verified), so the tool can report
where it was told to go and the model can re-issue explicitly — a fresh, separately-gated act for the new
origin. - Browser: a manual redirect is opaque (status 0, no readable headers), so there we can only refuse and say
a redirect was not followed with a credential attached. redirect: 'error'is the portable alternative (node throwsTypeError: unexpected redirect) but loses the
sameLocationin the browser, somanual+ reporting what we can is the better default.- Optionally follow a same-origin redirect, where we can read the
Location. Consistency argues for never
following when a credential rides; the case is rare, and an API that 3xx'es a credentialed request is
better met with a second explicit call.
Two things this needs that the current executor does not do: pass redirect at all, and derive the subject
from the resolved origin rather than trusting the caller's url string.
Security
- The response can echo the credential back. The design keeps the value out of the args, but an API
that reflectsAuthorization(error pages, debug endpoints, some 401 bodies) puts it in the tool result,
the session, and the next provider submission.Vault.scrubexists for this — and has no call sites
anywhere incore,appsorplugins(only its declaration and three implementations). Recommend
running response text throughctx.vault.scrub(...)before yielding it, which givesscrubits first
real call site. Caveat: it redacts only values of ≥4 characters
(core/src/security/vault.ts:79) by literal match, so it is a mitigation, not a guarantee. - Exfiltration-shaped, so it is gated — on a (host, key) pair. The model chooses both the secret name
and the destination URL, so a prompt-injected turn could otherwise send${ANTHROPIC_API_KEY}to a host of
its choosing. Gating on the pair means such a request is not covered by an approval given for the real API,
so it asks. Designed in full below. - A redirect can move the key to a host nobody approved, which defeats the pair guard. Measured, and it
needs a deliberateredirectsetting; below. - The secret name in the tool-call args is persisted to the session. That is fine — a name is not a
credential — but it does mean the transcript records which credentials are in play.
Backwards compatibility
- Plain-string headers are untouched;
fetchstill receives aRecord<string, string>. - The schema change relaxes
additionalPropertiesfromstringtostring | object. Nothing valid today
becomes invalid. plugins/httpruns on both node and browser (package.jsonmatbotRuntime), and both vaults implement
resolveidentically, so there is no platform split and no second implementation.
Out of scope
{ Vault }inurlorbody. Both are the same class of need (API keys in a query string are common;
some APIs take the key in a JSON body). The same object arm would extend to either later — but each needs
its own answer for "what gets scrubbed from the error text", since a URL lands in error messages verbatim.- Reading a secret as a value (a
vault.get-style tool). The point of this proposal is the opposite. - A capability/manifest declaration for vault access.
plugins/http's description says it "requires the
network capability"; reading a named secret is a second privilege and may deserve a second declaration,
but there is no capability registry in the tree to hang it on today.
- Dominant language
- TypeScript
- Stars
- 5
- Forks
- 2
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 12
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from MatAtBread/matbot
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
MatAtBread/matbot#95 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
MatAtBread/matbot#94 ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 Half a day Newbie friendliness 66/100
MatAtBread/matbot#100 · 1 comment ·
Maintainers usually reply within 1 day
-
bug
Difficulty 4/5 3-5 days Newbie friendliness 35/100
MatAtBread/matbot#96 ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
MatAtBread/matbot#65 ·
Maintainers usually reply within 1 day
All issues in MatAtBread/matbot
Similar issues
-
documentation good first issue
Difficulty 2/5 Half a day Newbie friendliness 72/100
sujithputta02/Esapay#13 ·
-
Share dialog: the address book's contact count reads "1 contacts" (no singular form in any language)Possibly taken @chibenwa claimed this today. Openai-driven-qa bug claude Cosmetic
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
linagora/twake-contacts-frontend#271 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Koenkk/zigbee2mqtt#33402 ·
Maintainers usually reply within 1 day
-
dx P2 platform Stellar Wave Wave 10
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
winsznx/routedock#579 · 1 comment ·
Maintainers usually reply within 1 day
-
area:ui effort:S enhancement priority:P2
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day