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..5cf44aa4965 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -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,
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
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..9efd9b8ad0f 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 // 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(
})
}
+// 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/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 }
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/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
index 7cdc9c25e65..2aafe27e055 100644
--- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
+++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
@@ -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: [] } }))
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)