http: let a header value come from the Vault, so a keyed API needs no secret in the conversation
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 22/100
- Issue 类型
- 功能
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- node.js, typescript
调研方向
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.
由索引模型根据 Issue 内容生成。
描述
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.
- 主要语言
- TypeScript
- 星标
- 5
- 派生
- 2
- 平均合并
- 1 天 2 小时
- 30 天内合并 PR
- 12
环境准备
这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
MatAtBread/matbot 的其他 Issue
-
bug
难度 2/5 1-3 小时 新手友好度 72/100
MatAtBread/matbot#95 ·
维护者通常 1 天内回复
-
bug
难度 2/5 1-3 小时 新手友好度 70/100
MatAtBread/matbot#94 ·
维护者通常 1 天内回复
-
难度 3/5 半天 新手友好度 66/100
MatAtBread/matbot#100 · 1 条评论 ·
维护者通常 1 天内回复
-
bug
难度 4/5 3-5 天 新手友好度 35/100
MatAtBread/matbot#96 ·
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 45/100
MatAtBread/matbot#65 ·
维护者通常 1 天内回复
查看 MatAtBread/matbot 的全部 Issue
相似的 Issue
-
area:ui effort:S enhancement priority:P2
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 74/100
QuantumNous/new-api#7839 ·
维护者通常 1 天内回复
-
good first issue maintenance
难度 2/5 1-3 小时 新手友好度 62/100
dark-princezz/stellar-hooks#1010 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
interledger/rhyza#352 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 70/100
维护者通常 1 天内回复