From f8cf18661752a74a3e19a1dab904c0b40c73dc43 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Fri, 2 Oct 2026 15:34:22 -0600 Subject: [PATCH] fix: harden local execute auth and networking Co-Authored-By: Kent C. Dodds --- README.md | 16 +++-- skills/kody/SKILL.md | 16 ++++- src/api-token.ts | 66 +++++++++++++++++- src/cli.ts | 9 +++ src/help.ts | 8 ++- src/local-execute-auth.ts | 21 ++++++ src/local-execute.ts | 8 ++- src/local-runtime-source.ts | 11 +-- test/cli.test.ts | 135 +++++++++++++++++++++++++++++++++++- test/local-execute.test.ts | 22 ++++++ 10 files changed, 295 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 33a9573..2f7b889 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Or run via `npx @kodycodes/cli` without a global install. | `kody whoami` | Confirms the CLI MCP connection and lists tools. With a scoped API token (and no login), shows token identity via the Open API. | | `kody search [query]` | Calls Kody `search` from the CLI (prefer the host MCP tool). Token-only auth uses Open API `GET /v1/search`. | | `kody api ` | Thin Open API wrapper matching the MCP `api` tool: `operationId` + flat `--params` JSON. Auth: `--token` / `KODY_API_TOKEN` / stored `auth bootstrap` token. | -| `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. | +| `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. `--allow-private-network` opts local execution into private and local network access. | `--json` prints structured MCP results. @@ -102,8 +102,12 @@ npx @kodycodes/cli api usageGet --params '{}' `tokenCreate` for CI/headless (`--token` / `KODY_API_TOKEN`). - For `execute --local` specifically: `--token` / `KODY_API_TOKEN` wins when set; else a stored bootstrap/API token from `auth bootstrap`; else the - stored `kody login` OAuth access token as Bearer (no under-the-hood - `tokenCreate`). The Open API must accept that OAuth bearer on + stored `kody login` OAuth access token as Bearer only when the API and MCP + URLs are paired (no under-the-hood `tokenCreate`). The default pair is + `https://api.kody.codes` and `https://kody.codes/mcp`; preview workers pair + when the API worker name adds `-api`, and loopback hosts pair at any port. + For a different API origin, use `auth bootstrap --api-url ` or set + `KODY_API_TOKEN`. The Open API must accept that OAuth bearer on CapabilityProxy / package-graph ([kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812)); until then use bootstrap or a scoped `kody_at_…` token. @@ -183,8 +187,10 @@ npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example. `--local` is the thin passthrough for a single export (still cloud). `packageStorage()`, `packageSecrets`, `email`, and `events` stay unbound on the ad hoc entry like cloud execute unless the downloaded package modules - carry stamps. Outbound `fetch` goes straight from this machine, including - to local-network hosts. + carry stamps. Outbound `fetch` is public-network-only by default. Pass + `--allow-private-network` with `execute --local` to also allow private and + local addresses; the loopback bridge used for CapabilityProxy calls remains + available either way. ## Token storage diff --git a/skills/kody/SKILL.md b/skills/kody/SKILL.md index b07d60c..b529da3 100644 --- a/skills/kody/SKILL.md +++ b/skills/kody/SKILL.md @@ -83,9 +83,13 @@ fails clearly rather than silently billing a remote execute. For `execute --local` auth: `--token` / `KODY_API_TOKEN` wins when set; otherwise a stored token from `kody auth bootstrap --code` (after MCP `cliCredentialBootstrap`); otherwise a valid `kody login` session (OAuth -access token as Bearer — never print it, never mint via `tokenCreate` under -the hood). Prefer bootstrap over `tokenCreate` for agents already on MCP so -`kody_at_…` never appears in chat. Until the Open API accepts MCP OAuth on +access token as Bearer only when the API and MCP URLs are paired — never print +it, never mint via `tokenCreate` under the hood). The default pair is +`https://api.kody.codes` and `https://kody.codes/mcp`; preview workers pair +when the API worker name adds `-api`, and loopback hosts pair at any port. +For another API origin, use `auth bootstrap --api-url ` or set +`KODY_API_TOKEN`. Prefer bootstrap over `tokenCreate` for agents already on +MCP so `kody_at_…` never appears in chat. Until the Open API accepts MCP OAuth on CapabilityProxy / package-graph (https://github.com/kentcdodds/kody/issues/2812), use bootstrap or mint a scoped token with the MCP `api` tool `tokenCreate` — include `local-execute` @@ -100,6 +104,12 @@ token is missing a scope; `feature_disabled` means the account # cliCredentialBootstrap → { bootstrap_code, cli_command } npx @kodycodes/cli auth bootstrap --code 'kody_bc_…' npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}' +# Opt in to private and local network access if needed: +npx @kodycodes/cli execute --local --allow-private-network --file ./task.js + +Local execute is public-network-only by default. `--allow-private-network` +opts into private and local addresses; the loopback CapabilityProxy bridge +continues to work either way. npx @kodycodes/cli login npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}' diff --git a/src/api-token.ts b/src/api-token.ts index 813e8b1..234a388 100644 --- a/src/api-token.ts +++ b/src/api-token.ts @@ -1,5 +1,9 @@ import { loadStoredApiToken } from './api-token-store.js' -import { apiTokenEnvVar, defaultApiUrl } from './defaults.js' +import { + apiTokenEnvVar, + defaultApiUrl, + defaultMcpUrl, +} from './defaults.js' import type { SecretBackend, StoreResolution } from './store.js' /** Platform tracking for login OAuth as CapabilityProxy / package-graph Bearer. */ @@ -16,6 +20,66 @@ export type ResolveScopedApiTokenInput = { loadApiToken?: typeof loadStoredApiToken } +const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +function tryParseUrl(value: string): URL | null { + try { + return new URL(value) + } catch { + return null + } +} + +function isWorkersDevHostname(hostname: string): boolean { + return hostname.endsWith('.workers.dev') +} + +/** Whether a login OAuth bearer may be sent to this API origin. */ +export function isPairedApiUrl( + apiUrl: string = defaultApiUrl, + mcpUrl: string = defaultMcpUrl, +): boolean { + const api = tryParseUrl(apiUrl) + const mcp = tryParseUrl(mcpUrl) + if (!api || !mcp) return false + + const apiIsLoopback = loopbackHosts.has(api.hostname.toLowerCase()) + const mcpIsLoopback = loopbackHosts.has(mcp.hostname.toLowerCase()) + if (apiIsLoopback && mcpIsLoopback) { + return ['http:', 'https:'].includes(api.protocol) && + ['http:', 'https:'].includes(mcp.protocol) + } + if (api.protocol !== 'https:' || mcp.protocol !== 'https:') return false + const apiIsWorkersDev = isWorkersDevHostname(api.hostname) + const mcpIsWorkersDev = isWorkersDevHostname(mcp.hostname) + if (apiIsWorkersDev || mcpIsWorkersDev) { + if (!apiIsWorkersDev || !mcpIsWorkersDev) return false + const apiLabels = api.hostname.toLowerCase().split('.') + const mcpLabels = mcp.hostname.toLowerCase().split('.') + return ( + apiLabels.length === mcpLabels.length && + apiLabels.slice(1).join('.') === mcpLabels.slice(1).join('.') && + apiLabels[0] === `${mcpLabels[0]}-api` + ) + } + return api.hostname.toLowerCase() === `api.${mcp.hostname.toLowerCase()}` +} + +export function expectedPairedApiOrigin(mcpUrl: string): string { + const mcp = tryParseUrl(mcpUrl) + if (!mcp) return 'a paired API origin' + if (loopbackHosts.has(mcp.hostname.toLowerCase())) { + return 'a loopback API origin' + } + const hostname = mcp.hostname.toLowerCase() + if (isWorkersDevHostname(hostname)) { + const labels = hostname.split('.') + labels[0] = `${labels[0]}-api` + return `https://${labels.join('.')}` + } + return `https://api.${hostname}` +} + /** * Scoped Open API / CapabilityProxy token (`kody_at_…`). Same source for * `execute --local`, token-only cloud execute, and Open API search/whoami. diff --git a/src/cli.ts b/src/cli.ts index 5a975b9..1b4f16f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -81,6 +81,7 @@ function parseKnown(args: Array) { params: { type: 'string' }, 'conversation-id': { type: 'string' }, local: { type: 'boolean' }, + 'allow-private-network': { type: 'boolean' }, token: { type: 'string' }, 'api-url': { type: 'string' }, project: { type: 'boolean' }, @@ -152,6 +153,12 @@ async function dispatch( mcpUrl: typeof parsed.values['mcp-url'] === 'string' ? parsed.values['mcp-url'] : undefined, }) const json = parsed.values.json === true + if ( + parsed.values['allow-private-network'] === true && + (parsed.command !== 'execute' || parsed.values.local !== true) + ) { + throw new Error('--allow-private-network can only be used with execute --local.') + } switch (parsed.command) { case 'help': @@ -448,6 +455,8 @@ async function dispatch( purpose: 'execute --local', }), apiUrl, + allowPrivateNetwork: + parsed.values['allow-private-network'] === true, onStatus: (message) => writeErr(`${message}\n`), }) : useToken diff --git a/src/help.ts b/src/help.ts index cc4ef13..58c5fcc 100644 --- a/src/help.ts +++ b/src/help.ts @@ -15,7 +15,7 @@ Usage: kody search [query] [--entity ] [--domain ] [--limit ] [--token ] [--api-url ] [--json] kody api [--params ] [--token ] [--api-url ] [--json] kody execute [--invoke | --code | --file ] [--params ] [--conversation-id ] [--json] - [--token ] [--api-url ] [--local] + [--token ] [--api-url ] [--local] [--allow-private-network] kody install [--mcp-url ] [--clients ] [--yes] [--project] [--json] kody skill install [--project] @@ -61,13 +61,17 @@ Usage: --local Run the execute module on this machine (workerd, Linux/macOS). Requires Node.js 22 or newer. Auth: \`--token\` / ${apiTokenEnvVar}, else stored \`auth bootstrap\` token, else - \`kody login\`. Static kody:@… imports are fetched via POST + paired \`kody login\` credentials. Static kody:@… imports are fetched via POST /v1/local-execute/package-graph and embedded in local workerd (CapabilityProxy only for per-call kody:runtime hops — never a whole-module cloud kody.execute defer). Fails clearly when that package-graph API is unavailable. Cloud token execute (no --local) uses CapabilityProxy → kody.execute for every module. + --allow-private-network + Allow execute --local code to fetch private and local network + addresses. Default is public-only; this flag requires --local. + Environment: KODY_MCP_URL Override the default MCP URL (${defaultMcpUrl}) ${apiTokenEnvVar} Scoped API token for token-auth search / whoami / api / execute diff --git a/src/local-execute-auth.ts b/src/local-execute-auth.ts index f09d81d..f7195e8 100644 --- a/src/local-execute-auth.ts +++ b/src/local-execute-auth.ts @@ -1,11 +1,22 @@ import { ensureFreshCredentials } from './auth.js' import { missingLocalExecuteAuthMessage, + expectedPairedApiOrigin, + isPairedApiUrl, resolveScopedApiToken, type ResolveScopedApiTokenInput, } from './api-token.js' +import { defaultApiUrl, defaultMcpUrl } from './defaults.js' import type { SecretBackend } from './store.js' +function urlOriginOrValue(value: string): string { + try { + return new URL(value).origin + } catch { + return value + } +} + /** * Bearer for CapabilityProxy / package-graph under `execute --local`. * @@ -31,6 +42,16 @@ export async function resolveLocalExecuteBearer( const token = resolveScopedApiToken(input) if (token) return token + const apiUrl = input.apiUrl ?? defaultApiUrl + const mcpUrl = input.mcpUrl ?? defaultMcpUrl + if (!isPairedApiUrl(apiUrl, mcpUrl)) { + const apiOrigin = urlOriginOrValue(apiUrl) + const mcpOrigin = urlOriginOrValue(mcpUrl) + throw new Error( + `kody login credentials for ${mcpOrigin} are only sent to ${expectedPairedApiOrigin(mcpUrl)}. For ${apiOrigin}, run \`kody auth bootstrap --code … --api-url ${apiOrigin}\` or set KODY_API_TOKEN.`, + ) + } + const ensure = input.ensureCredentials ?? ensureFreshCredentials try { const credentials = await ensure({ diff --git a/src/local-execute.ts b/src/local-execute.ts index fad2fac..0d36393 100644 --- a/src/local-execute.ts +++ b/src/local-execute.ts @@ -53,6 +53,7 @@ export type LocalExecuteInput = { fetchFn?: typeof fetch /** Skip the pinned download and run this workerd binary. */ workerdPath?: string + allowPrivateNetwork?: boolean onStatus?: (message: string) => void } @@ -125,7 +126,12 @@ export async function runLocalExecute(input: LocalExecuteInput): Promise + allowPrivateNetwork?: boolean }): string { const flags = localExecuteCompatibilityFlags.map((flag) => JSON.stringify(flag)).join(', ') + const networkAllow = input.allowPrivateNetwork + ? '["public", "private", "local"]' + : '["public"]' const packageEntries = (input.packageModules ?? []) .map( (module) => @@ -346,7 +349,7 @@ const config :Workerd.Config = ( services = [ (name = "main", worker = .kodyWorker), (name = "kody-bridge", external = (address = "127.0.0.1:${input.bridgePort}", http = ())), - (name = "internet", network = (allow = ["public", "private", "local"], tlsOptions = (trustBrowserCas = true))), + (name = "internet", network = (allow = ${networkAllow}, tlsOptions = (trustBrowserCas = true))), ], sockets = [ (name = "http", address = "127.0.0.1:0", http = (), service = "main") ], ); diff --git a/test/cli.test.ts b/test/cli.test.ts index 402b384..75553ba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -14,6 +14,7 @@ import { runCli, shouldUseApiToken, } from '../src/cli.js' +import { isPairedApiUrl } from '../src/api-token.js' import type { StoredApiToken } from '../src/api-token-store.js' import { modernMcpProtocolVersion } from '../src/defaults.js' import { formatToolResult, listKodyTools } from '../src/mcp.js' @@ -76,6 +77,7 @@ test('resolveCommand parses execute --local flags', () => { const { values } = resolveCommand([ 'execute', '--local', + '--allow-private-network', '--token', 'tok', '--api-url', @@ -84,10 +86,21 @@ test('resolveCommand parses execute --local flags', () => { 'mod.js', ]) assert.equal(values.local, true) + assert.equal(values['allow-private-network'], true) assert.equal(values.token, 'tok') assert.equal(values['api-url'], 'http://localhost:8787') }) +test('allow-private-network is limited to execute --local', async () => { + let stderr = '' + const code = await runCli( + ['execute', '--allow-private-network', '--code', 'export default () => 1'], + { stdout: () => {}, stderr: (text) => (stderr += text) }, + ) + assert.equal(code, 1) + assert.match(stderr, /--allow-private-network can only be used with execute --local/) +}) + test('resolveApiToken prefers --token, falls back to KODY_API_TOKEN, and requires one', () => { assert.equal(resolveApiToken({ token: 'flag' }, { KODY_API_TOKEN: 'env' }), 'flag') assert.equal(resolveApiToken({}, { KODY_API_TOKEN: ' env ' }), 'env') @@ -118,6 +131,126 @@ test('resolveLocalExecuteBearer uses login OAuth when no API token is set', asyn assert.equal(token, 'oauth-access-from-login') }) +test('default paired API and MCP URLs allow login OAuth', async () => { + let ensureCalls = 0 + const token = await resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + loadApiToken: () => null, + ensureCredentials: async () => { + ensureCalls += 1 + return sampleLoginCredentials() + }, + }) + assert.equal(token, 'oauth-access-from-login') + assert.equal(ensureCalls, 1) + assert.equal(isPairedApiUrl(), true) +}) + +test('mismatched API origin rejects login OAuth before ensure or fetch', async () => { + let ensureCalls = 0 + let fetchCalls = 0 + await assert.rejects( + () => + resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + mcpUrl: 'https://kody.codes/mcp', + apiUrl: 'https://api.other.test', + loadApiToken: () => null, + fetchFn: (async () => { + fetchCalls += 1 + throw new Error('fetch must not run') + }) as typeof fetch, + ensureCredentials: async () => { + ensureCalls += 1 + return sampleLoginCredentials() + }, + }), + /kody login credentials.*https:\/\/api\.kody\.codes.*api\.other\.test/, + ) + assert.equal(ensureCalls, 0) + assert.equal(fetchCalls, 0) +}) + +test('explicit and stored API tokens remain usable on mismatched origins', async () => { + const apiUrl = 'https://api.other.test' + const mcpUrl = 'https://kody.codes/mcp' + const explicitToken = await resolveLocalExecuteBearer({ + tokenValues: { token: 'explicit-token' }, + env: {}, + mcpUrl, + apiUrl, + ensureCredentials: async () => { + throw new Error('login OAuth must not be consulted') + }, + }) + assert.equal(explicitToken, 'explicit-token') + + const envToken = await resolveLocalExecuteBearer({ + tokenValues: {}, + env: { KODY_API_TOKEN: 'env-token' }, + mcpUrl, + apiUrl, + ensureCredentials: async () => { + throw new Error('login OAuth must not be consulted') + }, + }) + assert.equal(envToken, 'env-token') + + const storedToken = await resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + mcpUrl, + apiUrl, + loadApiToken: (requestedApiUrl) => ({ + version: 1, + apiUrl: requestedApiUrl ?? apiUrl, + token: 'stored-token', + tokenId: 'stored-id', + }), + ensureCredentials: async () => { + throw new Error('login OAuth must not be consulted') + }, + }) + assert.equal(storedToken, 'stored-token') +}) + +test('paired preview and loopback API origins allow login OAuth', async () => { + for (const [apiUrl, mcpUrl] of [ + [ + 'https://kody-pr-42-api.kody.workers.dev', + 'https://kody-pr-42.kody.workers.dev/mcp', + ], + ['http://localhost:8788', 'http://127.0.0.1:8787/mcp'], + ]) { + assert.equal(isPairedApiUrl(apiUrl, mcpUrl), true) + const token = await resolveLocalExecuteBearer({ + tokenValues: {}, + env: {}, + apiUrl, + mcpUrl, + loadApiToken: () => null, + ensureCredentials: async () => sampleLoginCredentials({ mcpUrl }), + }) + assert.equal(token, 'oauth-access-from-login') + } + assert.equal( + isPairedApiUrl( + 'https://api.kody-pr-42.kody.workers.dev', + 'https://kody-pr-42.kody.workers.dev/mcp', + ), + false, + ) + assert.equal( + isPairedApiUrl( + 'https://unrelated-api.workers.dev', + 'https://kody-pr-42.kody.workers.dev/mcp', + ), + false, + ) +}) + test('resolveLocalExecuteBearer fails clearly when neither login nor token is available', async () => { await assert.rejects( () => @@ -450,7 +583,7 @@ test('execute --local with login (no API token) sends OAuth access token as Bear '--mcp-url', mcpUrl, '--api-url', - 'https://api.kody.codes', + 'https://api.login-local.test', '--code', 'export default () => 1', ], diff --git a/test/local-execute.test.ts b/test/local-execute.test.ts index 12b875f..cdf2f71 100644 --- a/test/local-execute.test.ts +++ b/test/local-execute.test.ts @@ -14,10 +14,32 @@ import { runLocalExecute, savedPackageImportLocalResolveStatus, } from '../src/local-execute.js' +import { createWorkerdConfig } from '../src/local-runtime-source.js' import { localPackageGraphPath, localPackageGraphPlatformIssueUrl } from '../src/local-package-graph.js' const goodToken = 'kody_tok_good' +test('workerd network access is public-only by default and keeps the loopback bridge', () => { + const config = createWorkerdConfig({ + bridgePort: 4321, + files: { entry: 'entry.js', user: 'main.js', runtime: 'runtime.js' }, + }) + assert.match(config, /allow = \["public"\]/) + assert.match(config, /address = "127\.0\.0\.1:4321"/) + assert.doesNotMatch(config, /allow = \["public", "private", "local"\]/) + + const privateNetworkConfig = createWorkerdConfig({ + bridgePort: 4321, + files: { entry: 'entry.js', user: 'main.js', runtime: 'runtime.js' }, + allowPrivateNetwork: true, + }) + assert.match( + privateNetworkConfig, + /allow = \["public", "private", "local"\]/, + ) + assert.match(privateNetworkConfig, /address = "127\.0\.0\.1:4321"/) +}) + type ProxyRequest = { method: string; url: string; authorization: string | null; body: unknown } const proxyRequests: Array = []