From 51dc189682107615d6af3fc6306d64fa3d5dafd8 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:17:57 +0200 Subject: [PATCH 1/5] fix(cli): require auth for allow everything endpoint --- .changeset/harden-allow-everything-auth.md | 5 +++ packages/opencode/src/cli/cmd/tui/thread.ts | 17 ++++++++ .../opencode/src/kilocode/daemon/daemon.ts | 4 +- .../httpapi/middleware/authorization.ts | 17 ++++++-- .../opencode/test/kilocode/daemon.test.ts | 27 +++++++++++++ .../permission-allow-everything.test.ts | 39 +++++++++++++++++-- 6 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 .changeset/harden-allow-everything-auth.md diff --git a/.changeset/harden-allow-everything-auth.md b/.changeset/harden-allow-everything-auth.md new file mode 100644 index 00000000000..91a66e9a602 --- /dev/null +++ b/.changeset/harden-allow-everything-auth.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Require authentication before enabling allow-everything permissions over HTTP. diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 7e6d22721a4..08cd37a9f8a 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -2,6 +2,7 @@ import { cmd } from "@/cli/cmd/cmd" import { Rpc } from "@/util/rpc" import { type rpc } from "./worker" import path from "path" +import { randomUUID } from "node:crypto" // kilocode_change import { text as streamText } from "node:stream/consumers" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" @@ -25,6 +26,8 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "./validate-session" +import { ServerAuth } from "@/server/auth" // kilocode_change +import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change declare global { const KILO_WORKER_PATH: string @@ -179,9 +182,18 @@ 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 + // kilocode_change start - protect TUI-owned HTTP routes from unauthenticated local callers + const password = Flag.KILO_SERVER_PASSWORD ?? randomUUID() + const username = Flag.KILO_SERVER_USERNAME ?? "kilo" + const headers = ServerAuth.headers({ password, username }) + // kilocode_change end const env = sanitizedProcessEnv({ [KILO_PROCESS_ROLE]: "worker", [KILO_RUN_ID]: ensureRunID(), + // kilocode_change start + KILO_SERVER_USERNAME: username, + KILO_SERVER_PASSWORD: password, + // kilocode_change end KILO_BACKGROUND_PROCESS_PORTS: "true", // kilocode_change - TUI surfaces inferred background process ports }) @@ -308,11 +320,13 @@ export const TuiThreadCommand = cmd({ ? { url: (await client.call("server", network)).url, fetch: undefined, + headers, // kilocode_change events: undefined, } : { url: "http://kilo.internal", fetch: createWorkerFetch(client), + headers, // kilocode_change events: createEventSource(client), } @@ -322,6 +336,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 +355,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 +381,7 @@ export const TuiThreadCommand = cmd({ config, directory: cwd, fetch: transport.fetch, + headers: transport.headers, // kilocode_change events: transport.events, args: { continue: args.continue, diff --git a/packages/opencode/src/kilocode/daemon/daemon.ts b/packages/opencode/src/kilocode/daemon/daemon.ts index 45bf3450dda..0b29dfbc6ac 100644 --- a/packages/opencode/src/kilocode/daemon/daemon.ts +++ b/packages/opencode/src/kilocode/daemon/daemon.ts @@ -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 }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index db6554590f8..5d54ea6a2e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -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( effect: Effect.Effect, 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 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( }) } +// 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 ) }), ) diff --git a/packages/opencode/test/kilocode/daemon.test.ts b/packages/opencode/test/kilocode/daemon.test.ts index 644dfe30bf4..bae0ed42787 100644 --- a/packages/opencode/test/kilocode/daemon.test.ts +++ b/packages/opencode/test/kilocode/daemon.test.ts @@ -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)}`) diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index e00ceb824e4..ac028dc3f00 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -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) From fed1e5ef9d25f29a1af78f17a52ae567346f4b26 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:38:11 +0200 Subject: [PATCH 2/5] fix(cli): annotate allow everything auth guard --- .../server/routes/instance/httpapi/middleware/authorization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 5d54ea6a2e1..9efd9b8ad0f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -51,7 +51,7 @@ function validateCredential( force = ServerAuth.required(config), // kilocode_change - allow endpoint-specific required auth ) { return Effect.gen(function* () { - if (!force) 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)), From 5a916f34ee43c21badc451be5344be4c0cfe61ef Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 15:00:40 +0200 Subject: [PATCH 3/5] test(cli): update daemon auth fixtures --- packages/opencode/test/kilocode/cli/cmd/console.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts index a7934b9356a..40876c4a4ff 100644 --- a/packages/opencode/test/kilocode/cli/cmd/console.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -22,7 +22,7 @@ function state(input: Partial = {}) { 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 } From dacafc36c5ad3803c3ba067edc0177bbd7a0d4ef Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 15:28:56 +0200 Subject: [PATCH 4/5] test(cli): update allow everything exerciser expectation --- .../opencode/test/kilocode/server/httpapi-exercise-scenarios.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 1cdb3363911..79a20a26045 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -476,7 +476,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: [] } })) From 4a5a6adf835c420e841d50b21157114d74e990c3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 15:56:05 +0200 Subject: [PATCH 5/5] refactor(cli): move TUI worker auth derivation into kilo mirror module --- packages/opencode/src/cli/cmd/tui/thread.ts | 18 ++++-------------- .../src/kilocode/cli/cmd/tui/thread.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 08cd37a9f8a..5cf44aa4965 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -2,7 +2,6 @@ import { cmd } from "@/cli/cmd/cmd" import { Rpc } from "@/util/rpc" import { type rpc } from "./worker" import path from "path" -import { randomUUID } from "node:crypto" // kilocode_change import { text as streamText } from "node:stream/consumers" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" @@ -26,8 +25,6 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "./validate-session" -import { ServerAuth } from "@/server/auth" // kilocode_change -import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change declare global { const KILO_WORKER_PATH: string @@ -182,18 +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 - // kilocode_change start - protect TUI-owned HTTP routes from unauthenticated local callers - const password = Flag.KILO_SERVER_PASSWORD ?? randomUUID() - const username = Flag.KILO_SERVER_USERNAME ?? "kilo" - const headers = ServerAuth.headers({ password, username }) - // 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(), - // kilocode_change start - KILO_SERVER_USERNAME: username, - KILO_SERVER_PASSWORD: password, - // kilocode_change end + ...auth.env, // kilocode_change KILO_BACKGROUND_PROCESS_PORTS: "true", // kilocode_change - TUI surfaces inferred background process ports }) @@ -320,13 +310,13 @@ export const TuiThreadCommand = cmd({ ? { url: (await client.call("server", network)).url, fetch: undefined, - headers, // kilocode_change + headers: auth.headers, // kilocode_change events: undefined, } : { url: "http://kilo.internal", fetch: createWorkerFetch(client), - headers, // kilocode_change + headers: auth.headers, // kilocode_change events: createEventSource(client), } diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts index 9b18b8315a9..ad414345a08 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts @@ -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