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
5 changes: 5 additions & 0 deletions .changeset/harden-allow-everything-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Require authentication before enabling allow-everything permissions over HTTP.
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down Expand Up @@ -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),
}

Expand All @@ -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))
Expand All @@ -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)
Expand All @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/kilocode/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/kilocode/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand All @@ -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({
Expand Down Expand Up @@ -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
)
}),
)
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/test/kilocode/cli/cmd/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 }

Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/kilocode/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] } }))
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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* () {
Expand All @@ -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)
Expand Down
Loading