diff --git a/docker/Dockerfile b/docker/Dockerfile index ae6167d..0a41eb3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -47,7 +47,12 @@ FROM node:22.23.4-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git \ && apt-get upgrade -y ca-certificates \ - && sed -i '/^mozilla\/emSign_ECC_Root_CA_-_G3.crt$/s/^/!/; /^mozilla\/Comodo_AAA_Services_root.crt$/s/^/!/' /etc/ca-certificates.conf \ + && sed -i \ + -e '/^mozilla\/emSign_ECC_Root_CA_-_G3.crt$/s/^/!/' \ + -e '/^mozilla\/Comodo_AAA_Services_root.crt$/s/^/!/' \ + -e '/^mozilla\/emSign_Root_CA_-_C1.crt$/s/^/!/' \ + -e '/^mozilla\/emSign_ECC_Root_CA_-_C3.crt$/s/^/!/' \ + /etc/ca-certificates.conf \ && update-ca-certificates --fresh \ && curl -fsSL https://github.com/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_amd64.tar.gz -o /tmp/gh.tar.gz \ && tar -xzf /tmp/gh.tar.gz -C /tmp \ diff --git a/src/engine/__tests__/mode-config-bounds.test.ts b/src/engine/__tests__/mode-config-bounds.test.ts new file mode 100644 index 0000000..b7ca142 --- /dev/null +++ b/src/engine/__tests__/mode-config-bounds.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" + +import { runSuites } from "../../test-runner.js" +import { loadCustomModes } from "../prompt.js" + +const REAL_HOME = os.homedir() +const ROOT = path.join( + os.tmpdir(), + `hc-mode-bounds-${process.pid}-${Date.now()}`, +) +let homes = 0 + +async function sandboxHome(): Promise { + const home = path.join(ROOT, `home-${homes++}`) + const store = path.join(home, ".local", "share", "headlesscode") + await fs.mkdir(path.join(store, "shared"), { recursive: true }) + process.env.HOME = home + process.env.USERPROFILE = home + process.env.HEADLESSCODE_DATA_DIR = store +} + +function mode( + slug: string, + extra: Record = {}, +): Record { + return { + slug, + name: `Mode ${slug}`, + roleDefinition: "A test mode.", + groups: ["read"], + ...extra, + } +} + +async function writeWorkspace(body: unknown): Promise { + const workspace = await fs.mkdtemp( + path.join(os.tmpdir(), "hc-mode-bounds-workspace-"), + ) + await fs.writeFile( + path.join(workspace, ".roomodes"), + JSON.stringify(body), + "utf-8", + ) + return workspace +} + +async function captureErrors( + run: () => Promise, +): Promise<{ value: T; errors: string[] }> { + const errors: string[] = [] + const original = console.error + const originalWarn = console.warn + console.error = (...args: unknown[]) => errors.push(args.map(String).join(" ")) + console.warn = (...args: unknown[]) => errors.push(args.map(String).join(" ")) + try { + return { value: await run(), errors } + } finally { + console.error = original + console.warn = originalWarn + } +} + +async function testFileByteBudget(): Promise { + await sandboxHome() + const workspace = await fs.mkdtemp( + path.join(os.tmpdir(), "hc-mode-bounds-large-"), + ) + const body = Buffer.alloc(1_000_001, 0x20) + body.write("customModes: []\n") + await fs.writeFile(path.join(workspace, ".roomodes"), body) + const result = await captureErrors(() => loadCustomModes(workspace)) + assert.deepEqual(result.value, []) + assert.ok( + result.errors.some((message) => message.includes("1000000-byte limit")), + ) +} + +async function testCustomModesCountBudget(): Promise { + await sandboxHome() + const customModes = Array.from( + { length: 101 }, + (_, index) => mode(`mode-${index}`), + ) + const workspace = await writeWorkspace({ customModes }) + const result = await captureErrors(() => loadCustomModes(workspace)) + assert.deepEqual(result.value, []) + assert.ok( + result.errors.some((message) => message.includes("100-entry limit")), + ) +} + +async function testModeGroupCountBudget(): Promise { + await sandboxHome() + const customModes = [ + mode("many-groups", { + groups: Array.from({ length: 101 }, () => "read"), + }), + ] + const workspace = await writeWorkspace({ customModes }) + const result = await captureErrors(() => loadCustomModes(workspace)) + assert.deepEqual(result.value, []) + assert.ok( + result.errors.some((message) => + message.includes("groups exceeds 100 entries"), + ), + ) +} + +async function testAllowedMcpServerCountBudget(): Promise { + await sandboxHome() + const customModes = [ + mode("many-servers", { + allowedMcpServers: Array.from({ length: 33 }, (_, i) => `server-${i}`), + }), + ] + const workspace = await writeWorkspace({ customModes }) + const result = await captureErrors(() => loadCustomModes(workspace)) + assert.deepEqual(result.value, []) + assert.ok( + result.errors.some((message) => + message.includes("allowedMcpServers exceeds 32 entries"), + ), + ) +} + +const tests: Array<[string, () => Promise]> = [ + ["oversized mode files are rejected before YAML parsing", testFileByteBudget], + ["mode arrays are bounded before Zod validation", testCustomModesCountBudget], + ["group arrays are bounded before Zod validation", testModeGroupCountBudget], + [ + "MCP allowlists use the configured server-count budget", + testAllowedMcpServerCountBudget, + ], +] + +async function main(): Promise { + const failed = await runSuites("mode-config-bounds", tests) + if (failed > 0) process.exit(1) +} + +main().finally(async () => { + process.env.HOME = REAL_HOME + delete process.env.USERPROFILE + delete process.env.HEADLESSCODE_DATA_DIR + await fs.rm(ROOT, { recursive: true, force: true }) +}) diff --git a/src/engine/prompt.ts b/src/engine/prompt.ts index 5d98cc2..9fe40d7 100644 --- a/src/engine/prompt.ts +++ b/src/engine/prompt.ts @@ -25,6 +25,7 @@ */ import * as path from "node:path" +import { createReadStream } from "node:fs" import * as fsp from "node:fs/promises" import * as yaml from "yaml" @@ -42,6 +43,7 @@ import { getNativeTools } from "../vendor/zoo-code/src/core/prompts/tools/native import { getGroupName, getModeBySlug, modes, getToolsForMode } from "../vendor/zoo-code/src/shared/modes.js" import { TOOL_GROUPS } from "../vendor/zoo-code/src/shared/tools.js" import { customModesSettingsSchema } from "../vendor/zoo-code/types/index.js" +import { logger as promptLogger } from "../vendor/zoo-code/src/utils/logging.js" import { batchTool } from "../tools/batch-tool.js" import { browserActionTool } from "../tools/browser/tool.js" import { describeImageTool } from "../vision/tool.js" @@ -60,6 +62,15 @@ import type { SkillCatalogItem } from "../skills/registry.js" /** Opt-in gate for buildLeanSystemPrompt — see its doc comment. Default OFF. */ export const LEAN_SYSTEM_PROMPT_ENV = "HEADLESSCODE_LEAN_SYSTEM_PROMPT" +// Project mode files are read from the active workspace, which may contain +// repository-controlled input. Bound both total bytes and the arrays that +// reach Zod: Zod 3 records every validation issue while it continues through +// an array, including after a `.max()` failure. +const MAX_MODE_FILE_BYTES = 1_000_000 +const MAX_CUSTOM_MODES = 100 +const MAX_MODE_GROUP_ENTRIES = 100 +const MAX_ALLOWED_MCP_SERVERS = 32 + export function isLeanSystemPromptEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const v = env[LEAN_SYSTEM_PROMPT_ENV] return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false" @@ -182,11 +193,17 @@ function cleanInvisibleCharacters(content: string): string { async function loadModesFromFile(filePath: string, source: "project" | "global"): Promise { let raw: string try { - raw = await fsp.readFile(filePath, "utf-8") + raw = await readBoundedModeFile(filePath) } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return [] } + if (error instanceof ModeFileTooLargeError) { + promptLogger.warn( + `${path.basename(filePath)} was rejected: ${error.message}`, + ) + return [] + } throw error } @@ -210,6 +227,12 @@ async function loadModesFromFile(filePath: string, source: "project" | "global") if (typeof parsed !== "object" || parsed === null || !Array.isArray((parsed as { customModes?: unknown }).customModes)) { return [] } + const collectionLimitError = customModeCollectionLimitError(parsed) + if (collectionLimitError) { + const prefix = `[headlesscode] ${path.basename(filePath)} was rejected:` + promptLogger.warn(`${prefix} ${collectionLimitError}`) + return [] + } const result = customModesSettingsSchema.safeParse(parsed) if (!result.success) { @@ -223,6 +246,87 @@ async function loadModesFromFile(filePath: string, source: "project" | "global") return result.data.customModes.map((mode) => ({ ...mode, source })) } +class ModeFileTooLargeError extends Error {} + +async function readBoundedModeFile(filePath: string): Promise { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(filePath)) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += buffer.byteLength + if (totalBytes > MAX_MODE_FILE_BYTES) { + throw new ModeFileTooLargeError( + `file exceeds the ${MAX_MODE_FILE_BYTES}-byte limit`, + ) + } + chunks.push(buffer) + } + return Buffer.concat(chunks, totalBytes).toString("utf-8") +} + +function customModeCollectionLimitError(value: unknown): string | undefined { + const modes = (value as { customModes: unknown[] }).customModes + if (modes.length > MAX_CUSTOM_MODES) { + return `customModes exceeds the ${MAX_CUSTOM_MODES}-entry limit` + } + for (const [index, candidate] of modes.entries()) { + const error = modeCollectionLimitError(candidate, index) + if (error) return error + } + return undefined +} + +function modeCollectionLimitError( + candidate: unknown, + index: number, +): string | undefined { + if ( + typeof candidate !== "object" || + candidate === null || + Array.isArray(candidate) + ) { + return undefined + } + const mode = candidate as { groups?: unknown; allowedMcpServers?: unknown } + return ( + modeGroupLimitError(mode.groups, index) ?? + modeServerLimitError(mode.allowedMcpServers, index) + ) +} + +function modeGroupLimitError( + value: unknown, + index: number, +): string | undefined { + if (!Array.isArray(value) || value.length <= MAX_MODE_GROUP_ENTRIES) { + return undefined + } + return collectionLimitError(index, "groups", MAX_MODE_GROUP_ENTRIES) +} + +function modeServerLimitError( + value: unknown, + index: number, +): string | undefined { + if (!Array.isArray(value) || value.length <= MAX_ALLOWED_MCP_SERVERS) { + return undefined + } + return collectionLimitError( + index, + "allowedMcpServers", + MAX_ALLOWED_MCP_SERVERS, + ) +} + +function collectionLimitError( + index: number, + field: string, + limit: number, +): string { + const location = `customModes[${index}].${field}` + return `${location} exceeds ${limit} entries` +} + /** * Load the merged set of custom modes for a workspace: the project's own * `.roomodes` first (project wins on slug collision), then the GLOBAL shared diff --git a/src/security/__tests__/docker-ca-trust.test.ts b/src/security/__tests__/docker-ca-trust.test.ts new file mode 100644 index 0000000..43ce7d7 --- /dev/null +++ b/src/security/__tests__/docker-ca-trust.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const dockerfilePath = resolve(process.cwd(), "docker", "Dockerfile") +const dockerfile = readFileSync(dockerfilePath, "utf8") + +const retiredCertificates = [ + "mozilla/emSign_ECC_Root_CA_-_G3.crt", + "mozilla/Comodo_AAA_Services_root.crt", + "mozilla/emSign_Root_CA_-_C1.crt", + "mozilla/emSign_ECC_Root_CA_-_C3.crt", +] + +for (const certificate of retiredCertificates) { + const escapedPath = certificate.replaceAll("/", "\\/") + assert.ok( + dockerfile + .split("\n") + .some((line) => line.includes(`-e '/^${escapedPath}$/s/^/!/'`)), + `Docker build must disable ${certificate}`, + ) +} + +assert.match( + dockerfile, + /update-ca-certificates --fresh/, + "disabled roots must be removed from generated trust bundles", +) +console.log("Docker CA trust policy tests passed")