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
7 changes: 6 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
149 changes: 149 additions & 0 deletions src/engine/__tests__/mode-config-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown> = {},
): Record<string, unknown> {
return {
slug,
name: `Mode ${slug}`,
roleDefinition: "A test mode.",
groups: ["read"],
...extra,
}
}

async function writeWorkspace(body: unknown): Promise<string> {
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<T>(
run: () => Promise<T>,
): 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<void> {
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<void> {
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<void> {
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<void> {
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<void>]> = [
["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<void> {
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 })
})
106 changes: 105 additions & 1 deletion src/engine/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -182,11 +193,17 @@ function cleanInvisibleCharacters(content: string): string {
async function loadModesFromFile(filePath: string, source: "project" | "global"): Promise<ModeConfig[]> {
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
}

Expand All @@ -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) {
Expand All @@ -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<string> {
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
Expand Down
30 changes: 30 additions & 0 deletions src/security/__tests__/docker-ca-trust.test.ts
Original file line number Diff line number Diff line change
@@ -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")
Loading