Merge pull request #11887 from Kilo-Org/mark/harden-allow-everything-auth

fix(cli): require auth for allow everything endpoint
This commit is contained in:
Marius
2026-07-03 16:58:01 +02:00
committed by GitHub
9 changed files with 113 additions and 9 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Require authentication before enabling allow-everything permissions over HTTP.
@@ -179,9 +179,11 @@ export const TuiThreadCommand = cmd({
// kilocode_change start - default TUI sessions attach to the daemon unless explicitly disabled
if (await KiloTuiThreadDaemon.attach({ args, cwd, input: () => input(args.prompt), start })) return
// kilocode_change end
const auth = KiloTuiThreadDaemon.workerAuth() // kilocode_change - protect TUI-owned HTTP routes from unauthenticated local callers
const env = sanitizedProcessEnv({
[KILO_PROCESS_ROLE]: "worker",
[KILO_RUN_ID]: ensureRunID(),
...auth.env, // kilocode_change
KILO_BACKGROUND_PROCESS_PORTS: "true", // kilocode_change - TUI surfaces inferred background process ports
})
@@ -308,11 +310,13 @@ export const TuiThreadCommand = cmd({
? {
url: (await client.call("server", network)).url,
fetch: undefined,
headers: auth.headers, // kilocode_change
events: undefined,
}
: {
url: "http://kilo.internal",
fetch: createWorkerFetch(client),
headers: auth.headers, // kilocode_change
events: createEventSource(client),
}
@@ -322,6 +326,7 @@ export const TuiThreadCommand = cmd({
sessionID: localSessionID(args), // kilocode_change
directory: cwd,
fetch: transport.fetch,
headers: transport.headers, // kilocode_change
})
} catch (error) {
UI.error(errorMessage(error))
@@ -340,6 +345,7 @@ export const TuiThreadCommand = cmd({
const sdk = createKiloClient({
baseUrl: transport.url,
fetch: transport.fetch,
headers: transport.headers, // kilocode_change
directory: cwd,
})
const id = await importCloudSession(sdk, args.session).catch(() => undefined)
@@ -365,6 +371,7 @@ export const TuiThreadCommand = cmd({
config,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers, // kilocode_change
events: transport.events,
args: {
continue: args.continue,
@@ -1,5 +1,8 @@
import { randomUUID } from "node:crypto"
import { UI } from "@/cli/ui"
import type { NetworkOptions } from "@/cli/network"
import { ServerAuth } from "@/server/auth"
import { Flag } from "@opencode-ai/core/flag/flag"
import { errorMessage } from "@/util/error"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { validateSession } from "@/cli/cmd/tui/validate-session"
@@ -45,6 +48,18 @@ async function session(input: Input, daemon: DaemonClient.Connection) {
}
export namespace KiloTuiThreadDaemon {
// Protect TUI-owned HTTP routes from unauthenticated local callers: derive
// worker credentials once so the spawned worker server and the TUI's SDK
// clients share the same Basic auth material.
export function workerAuth() {
const password = Flag.KILO_SERVER_PASSWORD ?? randomUUID()
const username = Flag.KILO_SERVER_USERNAME ?? "kilo"
return {
env: { KILO_SERVER_USERNAME: username, KILO_SERVER_PASSWORD: password },
headers: ServerAuth.headers({ password, username }),
}
}
export async function attach(input: Input) {
const daemon = await DaemonClient.maybe()
if (!daemon) return false
@@ -2,6 +2,7 @@ import path from "path"
import { existsSync } from "fs"
import { spawn } from "child_process"
import { createServer } from "net"
import { randomUUID } from "node:crypto"
import { open, readFile, rm, mkdir } from "fs/promises"
import z from "zod"
import { Global } from "@opencode-ai/core/global"
@@ -179,6 +180,7 @@ export namespace Daemon {
}
export function matches(state: State, input: Options, explicit: readonly NetworkOption[]) {
if (state.password === "kilo") return false
const options = Network.parse(input)
return explicit.every((name) => {
if (name === "hostname") return state.hostname === options.hostname
@@ -204,7 +206,7 @@ export namespace Daemon {
if (alive(current.state.pid)) await terminate(current.state.pid, true)
}
await clear()
const password = "kilo"
const password = randomUUID()
const token = auth(password)
const out = log()
await mkdir(path.dirname(out), { recursive: true })
@@ -9,6 +9,9 @@ import { UnauthorizedError } from "../errors"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
// kilocode_change start - require auth for high-risk permission toggles even when global auth is optional
const REQUIRED_AUTH_PATHS = new Set(["/permission/allow-everything"])
// kilocode_change end
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
// full handler, so a downstream failure can make the next auth alternative run
@@ -45,9 +48,10 @@ function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
config: ServerAuth.Info,
force = ServerAuth.required(config), // kilocode_change - allow endpoint-specific required auth
) {
return Effect.gen(function* () {
if (!ServerAuth.required(config)) return yield* effect
if (!force) return yield* effect // kilocode_change
if (!ServerAuth.authorized(credential, config)) {
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
@@ -58,6 +62,12 @@ function validateCredential<A, E, R>(
})
}
// kilocode_change start - fail closed for high-risk unauthenticated endpoints
function guarded(url: URL, config: ServerAuth.Info) {
return ServerAuth.required(config) || REQUIRED_AUTH_PATHS.has(url.pathname)
}
// kilocode_change end
function decodeCredential(input: string) {
return Effect.fromResult(Encoding.decodeBase64String(input)).pipe(
Effect.match({
@@ -123,12 +133,13 @@ export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost") // kilocode_change - inspect endpoint-specific auth policy
if (!guarded(url, config)) return yield* effect // kilocode_change
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
Effect.flatMap((credential) => validateCredential(effect, credential, config, true)), // kilocode_change
)
}),
)
@@ -22,7 +22,7 @@ function state(input: Partial<Daemon.Network> = {}) {
port: options.port,
url: `http://${options.hostname}:${options.port}`,
username: "kilo",
password: "kilo",
password: "secret",
token: "token",
version: "test",
startedAt: new Date(0).toISOString(),
@@ -99,6 +99,10 @@ describe("console daemon startup", () => {
expect(Daemon.matches(state(), opts({ port: 0 }), ["port"])).toBe(true)
})
test("rejects legacy fixed-password daemon state", () => {
expect(Daemon.matches({ ...state(), password: "kilo" }, opts(), [])).toBe(false)
})
test("supports daemon state written before network options were persisted", () => {
const current = { ...state(), options: undefined }
@@ -146,6 +146,31 @@ describe("daemon manager", () => {
).toStrictEqual(["/tmp/bun", "--conditions=browser", "/tmp/kilo/src/index.ts"])
})
test("does not reuse legacy fixed-password daemons", () => {
const input = {
hostname: "127.0.0.1",
port: 4097,
mdns: false,
mdnsDomain: "kilo.local",
cors: [],
}
const state: Daemon.State = {
pid: 1,
hostname: input.hostname,
port: input.port,
url: "http://127.0.0.1:4097",
username: "kilo",
password: "kilo",
token: Buffer.from("kilo:kilo").toString("base64"),
version: "test",
startedAt: new Date(0).toISOString(),
log: "/tmp/daemon.log",
options: input,
}
expect(Daemon.matches(state, input, [])).toBe(false)
})
test("reuses one daemon across caller directories", async () => {
await using tmp = await tmpdir()
const env = opts(tmp.path)
@@ -169,6 +194,8 @@ describe("daemon manager", () => {
expect(started.running).toBe(true)
expect(started.state?.pid).toBeGreaterThan(0)
expect(started.state?.token).toBeTruthy()
expect(started.state?.password).not.toBe("kilo")
expect(started.state?.token).not.toBe(Buffer.from("kilo:kilo").toString("base64"))
expect(started.state?.port).toBeGreaterThan(0)
const blocked = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`)
@@ -515,7 +515,7 @@ export const kiloScenarios: Scenario[] = [
headers: ctx.headers(),
body: { enable: true, sessionID: ctx.state.id },
}))
.json(200, (body) => check(body === true, "allow everything should return true")),
.status(401),
http.protected
.post("/session/viewed", "session.viewed")
.at((ctx) => ({ path: "/session/viewed", headers: ctx.headers(), body: { focused: [], open: [] } }))
@@ -1,5 +1,6 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../../src/bus"
@@ -22,6 +23,30 @@ const env = Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
const original = {
password: Flag.KILO_SERVER_PASSWORD,
username: Flag.KILO_SERVER_USERNAME,
envPassword: process.env.KILO_SERVER_PASSWORD,
envUsername: process.env.KILO_SERVER_USERNAME,
}
afterEach(() => {
Flag.KILO_SERVER_PASSWORD = original.password
Flag.KILO_SERVER_USERNAME = original.username
if (original.envPassword === undefined) delete process.env.KILO_SERVER_PASSWORD
else process.env.KILO_SERVER_PASSWORD = original.envPassword
if (original.envUsername === undefined) delete process.env.KILO_SERVER_USERNAME
else process.env.KILO_SERVER_USERNAME = original.envUsername
})
const auth = () => `Basic ${Buffer.from("kilo:secret").toString("base64")}`
const requireAuth = () => {
Flag.KILO_SERVER_PASSWORD = "secret"
Flag.KILO_SERVER_USERNAME = undefined
process.env.KILO_SERVER_PASSWORD = "secret"
delete process.env.KILO_SERVER_USERNAME
}
const ask = (input: Permission.AskInput) =>
Effect.gen(function* () {
@@ -47,20 +72,28 @@ const wait = () =>
describe("AllowEverythingPermission", () => {
test("handles disable requests through the HTTP endpoint", async () => {
requireAuth()
await using tmp = await tmpdir({ git: true })
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const enable = await Server.Default().app.request("/permission/allow-everything", {
const blocked = await Server.Default().app.request("/permission/allow-everything", {
method: "POST",
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path },
body: JSON.stringify({ enable: true }),
})
expect(blocked.status).toBe(401)
const enable = await Server.Default().app.request("/permission/allow-everything", {
method: "POST",
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path, authorization: auth() },
body: JSON.stringify({ enable: true }),
})
expect(enable.status).toBe(200)
const disable = await Server.Default().app.request("/permission/allow-everything", {
method: "POST",
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path },
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path, authorization: auth() },
body: JSON.stringify({ enable: false }),
})
expect(disable.status).toBe(200)