Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

http: let a header value come from the Vault, so a keyed API needs no secret in the conversation

Đang mở
#104 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
22/100
Loại issue
Tính năng
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
node.js, typescript
Lĩnh vực
api, security

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

enhancement

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:

  1. 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.
  2. Substitutions inside a value. COMPOSED=Bearer ${DEEPSEEK_KEY} — a value that references another key.
    Note this does not work today: VaultImpl.resolve is 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 the ToolContracts arm.
    Both copies need it: the arm still inlines the shape instead of referencing HttpInput (#100 covers that
    duplication, and this change is a nice argument for doing it first).

  • inputSchema.headers is { type: 'object', additionalProperties: { type: 'string' } } today, and
    json-validation enforces it (plugins/json-validation/src/index.ts:22-24): an object value is rejected
    with expected string, got object before the executor ever runs. The schema must change, and it can pin the
    object arm's shape without anyOf — 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' } },
    } }
    

    additionalProperties describes each header value here, so each may be a string or a { Vault: string }.
    A string value skips the object keywords entirely (the validator applies properties/required only 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.setup and calling the registered ToolCallValidator). 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, Vault missing
    ['a'] rejected — expected one of string, object

    Under the current schema the object form is rejected in every one of those cases, which is the bug. A useful
    consequence of pinning required: ['Vault']: a misspelling fails structurally instead of being silently
    sent as a literal value — the discriminator is enforced, not guessed.

  • description strings must carry the form, since that is the only place the model learns it: name the
    { Vault: "KEY_NAME" } shape on the headers property 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 Location and 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 throws TypeError: unexpected redirect) but loses the
    same Location in the browser, so manual + 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

  1. The response can echo the credential back. The design keeps the value out of the args, but an API
    that reflects Authorization (error pages, debug endpoints, some 401 bodies) puts it in the tool result,
    the session, and the next provider submission. Vault.scrub exists for this — and has no call sites
    anywhere
    in core, apps or plugins (only its declaration and three implementations). Recommend
    running response text through ctx.vault.scrub(...) before yielding it, which gives scrub its 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.
  2. 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.
  3. A redirect can move the key to a host nobody approved, which defeats the pair guard. Measured, and it
    needs a deliberate redirect setting; below.
  4. 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; fetch still receives a Record<string, string>.
  • The schema change relaxes additionalProperties from string to string | object. Nothing valid today
    becomes invalid.
  • plugins/http runs on both node and browser (package.json matbotRuntime), and both vaults implement
    resolve identically, so there is no platform split and no second implementation.

Out of scope

  • { Vault } in url or body. 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.
Ngôn ngữ chính
TypeScript
Star
5
Fork
2
Merge trung bình
1 ngày 2 giờ
Pull request đã merge (30 ngày)
12

Chuẩn bị môi trường

Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của MatAtBread/matbot

Tất cả issue của MatAtBread/matbot

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.