Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/guides/secret-backed-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,12 @@ Rules:
to find the right secret name, then reference that name in a placeholder.
- Placeholders only resolve in secret-aware `fetch` paths and capability inputs
marked `x-kody-secret`; they are not general string interpolation.
- Never echo the literal `{{secret:...}}` form into chat, logs, issue bodies, or
any content that may later be sent over `fetch`.
- Never echo a resolvable literal placeholder into chat, logs, issue bodies, or
any content that may later be sent over `fetch`. To mention the syntax in
prose, use the inert `{{secret:<name>}}` form — angle brackets are outside the
name charset, so it never resolves. To deliberately deliver a resolvable
placeholder to a third party, set the `x-kody-secret-resolution: off` header
on that `fetch` (the gateway strips it and skips resolution for that request).
- For Basic Auth derived from two secrets, use `secretHeaders.basic(...)` from
`kody:runtime` (see the secrets usage docs).

Expand Down
16 changes: 13 additions & 3 deletions docs/use/execute.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,19 @@ placeholders, host approval, and **`kody.secret_list`** / **`secret_set`**.

Treat placeholder syntax as operational wiring, not prose. Do not place the
exact **`{{secret:...}}`** token into issue bodies, comments, prompts, logs, or
other content that may be shown to users or sent to third parties. If you need
to mention a placeholder literally, obfuscate it instead of embedding the exact
token.
other content that may be shown to users or sent to third parties — resolution
runs on the final serialized request, so even string concatenation cannot keep a
literal placeholder out of it.

To **mention** the syntax in prose, use the inert form **`{{secret:<name>}}`** —
angle brackets are outside the placeholder name charset, so it never resolves
anywhere, now or downstream.

To deliberately deliver a **resolvable** literal placeholder to a third party
(for example, config that Kody itself resolves later), set the
**`x-kody-secret-resolution: off`** header on that fetch. The gateway strips the
header and skips resolution for that request only. The delivered text remains
one resolution step from the real secret, so use this sparingly.

## Values

Expand Down
25 changes: 20 additions & 5 deletions docs/use/secrets-and-values.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,26 @@ specific token exchange with ordinary **`fetch`**. For service-account JSON
secrets, pass **`private_key_json_field: "private_key"`** to sign with that
field.

Do **not** place literal placeholder tokens into user-visible or
third-party-visible content such as issue bodies, comments, prompts, logs, or
returned strings. If you need to describe a placeholder as text, obfuscate it
instead of embedding the exact **`{{secret:...}}`** form into content that may
later be sent over **`fetch`**.
## Mentioning placeholders without resolving them

Resolution runs on the **final serialized request** (URL, headers, body), so a
literal placeholder assembled by any means — including string concatenation —
will resolve. Do **not** place resolvable placeholder tokens into user-visible
or third-party-visible content such as issue bodies, comments, prompts, logs, or
returned strings.

- To **mention** the syntax in prose or docs, write **`{{secret:<name>}}`**.
Angle brackets are outside the placeholder name charset (`[a-zA-Z0-9._-]`), so
this form is inert everywhere — it cannot resolve in this request or any later
one.
- To deliberately send a **resolvable** literal placeholder to a third party
(for example, config text that Kody itself will resolve later), set the
**`x-kody-secret-resolution: off`** header on that **`fetch`**. The gateway
strips the header and skips all placeholder resolution for that one request.
Only the calling code can set headers, so data flowing through a URL or body
can never disable resolution. Use this sparingly: the delivered text is still
one resolution step away from the real secret if it later flows back through a
secret-aware **`fetch`**.

## Host approval

Expand Down
65 changes: 65 additions & 0 deletions packages/worker/src/mcp/fetch-gateway.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, test, vi } from 'vitest'
import {
executeGatewayFetch,
expandSecretPlaceholders,
secretResolutionHeaderName,
} from '#mcp/fetch-gateway.ts'
import { parseHostApprovalRequiredBatchMessage } from '#mcp/secrets/errors.ts'
import {
Expand Down Expand Up @@ -88,6 +89,70 @@ test('fetch gateway blocks or expands secret placeholders based on host approval
}
})

test('opt-out header sends placeholders literally, strips itself, and never resolves secrets', async () => {
const resolveSpy = vi.spyOn(secretService, 'resolveSecret')
const request = new Request('https://discord.com/api/channels/1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[secretResolutionHeaderName]: 'off',
},
body: JSON.stringify({
content: 'Use {{secret:name}} in your fetch call.',
}),
})

try {
const transformed = await expandSecretPlaceholders({ request, props, env })
expect(transformed.headers.get(secretResolutionHeaderName)).toBeNull()
expect(await transformed.text()).toBe(
JSON.stringify({ content: 'Use {{secret:name}} in your fetch call.' }),
)
expect(transformed.url).toBe('https://discord.com/api/channels/1/messages')
expect(resolveSpy).not.toHaveBeenCalled()
} finally {
resolveSpy.mockRestore()
}
})

test('opt-out header value "on" resolves normally and is stripped; unknown values fail loudly', async () => {
const resolveSpy = vi
.spyOn(secretService, 'resolveSecret')
.mockResolvedValue({
found: true,
value: 'secret-value',
scope: 'user',
allowedHosts: ['example.com'],
allowedCapabilities: [],
})
try {
const onRequest = new Request('https://example.com/api', {
method: 'POST',
headers: {
Authorization: 'Bearer {{secret:spotifyRefreshToken|scope=user}}',
[secretResolutionHeaderName]: 'on',
},
body: '{}',
})
const transformed = await expandSecretPlaceholders({
request: onRequest,
props,
env,
})
expect(transformed.headers.get('Authorization')).toBe('Bearer secret-value')
expect(transformed.headers.get(secretResolutionHeaderName)).toBeNull()

const invalidRequest = new Request('https://example.com/api', {
headers: { [secretResolutionHeaderName]: 'of' },
})
await expect(
expandSecretPlaceholders({ request: invalidRequest, props, env }),
).rejects.toThrow(`Invalid ${secretResolutionHeaderName} header value "of"`)
} finally {
resolveSpy.mockRestore()
}
})

test('fetch gateway expands placeholders in form-urlencoded bodies', async () => {
const resolveSpy = vi
.spyOn(secretService, 'resolveSecret')
Expand Down
55 changes: 51 additions & 4 deletions packages/worker/src/mcp/fetch-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ type FetchGatewayProps = {
}
export type { FetchGatewayProps }

/**
* Request header that disables secret placeholder resolution for one gateway
* fetch: `x-kody-secret-resolution: off`. The header is stripped before the
* request leaves the gateway, and any `{{secret:...}}` text passes through
* literally. Out-of-band by design — only calling code can set a header, so
* attacker-controlled *data* in a URL or body can never disable resolution.
* Use it when a third party must receive literal placeholder text (for
* example, writing config that Kody itself resolves later). For merely
* mentioning the syntax in prose, prefer the inert `{{secret:<name>}}` form
* instead, which never resolves anywhere.
*/
export const secretResolutionHeaderName = 'x-kody-secret-resolution'

export class KodyFetchGateway extends WorkerEntrypoint<Env, FetchGatewayProps> {
async fetch(request: Request) {
return executeGatewayFetch({
Expand Down Expand Up @@ -136,17 +149,34 @@ export async function expandSecretPlaceholders(input: {
env: Pick<Env, 'APP_DB' | 'SECRET_STORE_KEY'>
}) {
const headers = new Headers(input.request.headers)
const baseUrl = input.props.baseUrl.trim()
if (!baseUrl) {
throw new Error('Fetch gateway requires a non-empty baseUrl in props.')
}
const requestBody = await readRequestBody(input.request)
if (readSecretResolutionMode(headers) === 'off') {
return new Request(
resolveRequestUrlForFetchGateway(input.request.url, baseUrl),
{
method: input.request.method,
headers,
body: requestBody ?? undefined,
redirect: input.request.redirect,
credentials: input.request.credentials,
mode: input.request.mode,
cache: input.request.cache,
integrity: input.request.integrity,
keepalive: input.request.keepalive,
signal: input.request.signal,
},
)
}
const resolvedSecrets: Array<{
referenced: ReferencedSecret
resolved: ResolvedSecret
}> = []
const replacements = new Map<string, string>()
const resolvedValues = new Map<string, string>()
const baseUrl = input.props.baseUrl.trim()
if (!baseUrl) {
throw new Error('Fetch gateway requires a non-empty baseUrl in props.')
}
const basicAuthPlaceholders = dedupeBasicAuthSecretPlaceholders([
...collectReferencedBasicAuthSecretPlaceholders([
input.request.url,
Expand Down Expand Up @@ -338,6 +368,23 @@ function ensureFetchAllowed(props: FetchGatewayProps) {
}
}

/**
* Read and strip the resolution opt-out header. Unknown values fail loudly:
* a typo like "of" silently resolving placeholders would defeat the point of
* opting out.
*/
function readSecretResolutionMode(headers: Headers): 'on' | 'off' {
const raw = headers.get(secretResolutionHeaderName)
if (raw == null) return 'on'
headers.delete(secretResolutionHeaderName)
const value = raw.trim().toLowerCase()
if (value === 'off') return 'off'
if (value === 'on') return 'on'
throw new Error(
`Invalid ${secretResolutionHeaderName} header value "${raw}". Use "off" to send secret placeholders literally without resolution, or omit the header for normal resolution.`,
)
}

function collectReferencedSecrets(values: Array<string | null | undefined>) {
return dedupeReferencedSecrets(
values.flatMap((value) => (value ? parseSecretPlaceholders(value) : [])),
Expand Down
Loading