diff --git a/README.md b/README.md index cc7a02b9..147bae99 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ unset OPENAI_API_KEY CODEX_API_KEY ``` Scan history is stored in the Codex Security workbench state directory. If that -directory cannot be written, set `CODEX_SECURITY_STATE_DIR` to a writable -directory outside the repository. +directory cannot be written or has unsafe parent permissions, set +`CODEX_SECURITY_STATE_DIR` to a private directory with trusted parents outside +the repository. See [output and state directory permissions](sdk/typescript/README.md#output-and-state-directory-permissions). `findings list [repository]` shows open findings across a repository's scans and identifies findings not confirmed in its latest scan. diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 459a38b5..785836ef 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -316,7 +316,9 @@ reduction and result delivery, including at the 96-hour maximum. `bulk-scan --workers` controls how many repositories are scanned concurrently. On macOS/Linux, an existing output directory must be private to the current -user (`chmod 700`). +user (`chmod 700`) and have trusted parent directories. See [output and state +directory permissions](#output-and-state-directory-permissions) if a parent is +group- or world-writable. If the output directory already contains results, add `--archive-existing`. The CLI moves them to `.previous--` and starts the @@ -663,6 +665,43 @@ const directPublication = await publishScan("/path/to/completed-scan", { }); ``` +### Output and state directory permissions + +On macOS and Linux, scan output must be private to the current user. Every +parent directory must be owned by the current user or root. A group- or +world-writable parent is accepted only when it has the sticky bit. The state +directory itself must be private; the sticky-bit exception applies only to its +parents. History, sign-in, and publication operations use the same private-state +rule. State-directory aliases must have trusted link owners and trusted lexical +and resolved parents. New state directories are created privately; existing +directories are not automatically changed. + +An error that names a parent with mode `0775` refers to that parent, not just +the final output directory. Creating a `0700` child beneath it does not stop +another user from renaming or replacing that child. Choose a location with +trusted parents, or remove group- and world-write access from the named parent +only if you own it and it is safe to change. Do not change a shared home, +workspace, mount, or system directory merely to suppress the error. + +Use the setting for the location that failed: + +- For explicitly selected scan results, choose another `--output-dir` (SDK + `outputDir`) outside the scanned repository. +- For persistent history, default artifacts, and stored sign-in, choose a + private `CODEX_SECURITY_STATE_DIR` outside the repository. On macOS and Linux, + an existing state directory must be owned by you and private (`0700`). Change + its permissions only if it is your dedicated state directory and is safe to + change. Selecting a new directory does not move existing history, results, or + credentials. +- For temporary runtime files, choose a suitable `TMPDIR` (`TEMP` on Windows). + A fresh private child under a trusted, sticky system temporary directory is + suitable for temporary work; it is not a replacement for persistent history. + +The CLI does not automatically change parent permissions or move an explicitly +selected directory. `scan --dry-run` checks the ancestry of an explicitly +selected output directory and the configured state root without creating scan +output or initializing the runtime. + ### Scan history and reruns `scans` or `scans list` lists scans for the current repository. Pass a repository diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 5584d3a1..69896f98 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -146,18 +146,63 @@ def stale_claim_before(seconds: int = CLAIM_LEASE_SECONDS) -> str: ) -def state_dir() -> Path: +def requested_state_dir() -> str: state_dir = os.environ.get("CODEX_SECURITY_STATE_DIR") if state_dir: - return Path(state_dir).expanduser().resolve() - codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() - return (codex_home / "state" / "plugins" / "codex-security").resolve() + requested = state_dir + else: + codex_home = os.environ.get("CODEX_HOME", "~/.codex") + requested = os.path.join(codex_home, "state", "plugins", "codex-security") + requested = os.path.expanduser(requested) + if requested.startswith("~"): + raise RuntimeError("Could not determine home directory.") + return requested if os.path.isabs(requested) else os.path.join(os.getcwd(), requested) + + +def state_dir() -> Path: + return Path(requested_state_dir()).resolve() def database_path() -> Path: return state_dir() / "workbench.sqlite3" +def require_secure_state_ancestry(path: str) -> None: + if os.name == "nt": + return + geteuid = getattr(os, "geteuid", None) + effective_uid = geteuid() if geteuid is not None else None + pending = [path] + checked: set[str] = set() + while pending: + current = pending.pop() + while True: + current = current.rstrip(os.sep) or os.sep + if current in checked: + break + checked.add(current) + parent = os.path.dirname(current) + try: + metadata = os.lstat(current) + except FileNotFoundError: + metadata = None + if metadata is not None: + if stat.S_ISLNK(metadata.st_mode): + require_trusted_output_owner(metadata, effective_uid) + canonical = os.path.realpath(current, strict=True) + target = os.readlink(current) + # Preserve dot segments until the filesystem resolves the target. + lexical_target = ( + target if os.path.isabs(target) else os.path.join(parent, target) + ) + pending.extend((canonical, lexical_target)) + else: + require_trusted_output_ancestor(current, effective_uid) + if parent == current: + break + current = parent + + @contextmanager def scan_completion_lock(scan_id: str) -> Any: lock_dir = state_dir() / "completion-locks" @@ -223,8 +268,48 @@ def release_completion_file_lock(descriptor: int) -> None: def connect() -> sqlite3.Connection: - path = database_path() - path.parent.mkdir(parents=True, exist_ok=True) + requested = requested_state_dir() + root = Path(requested) + try: + require_secure_state_ancestry(requested) + root = root.resolve() + missing = [] + existing = root + while True: + try: + metadata = existing.lstat() + break + except FileNotFoundError: + missing.append(existing) + parent = existing.parent + if parent == existing: + raise + existing = parent + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise SystemExit("State path must use real directories.") + if os.name != "nt": + geteuid = getattr(os, "geteuid", None) + effective_uid = geteuid() if geteuid is not None else None + for parent in (existing, *existing.parents): + require_trusted_output_ancestor(parent, effective_uid) + for directory in reversed(missing): + try: + directory.mkdir(mode=0o700) + except FileExistsError: + require_canonical_scan_directory(directory) + continue + if ( + os.name != "nt" + and stat.S_IMODE(directory.stat().st_mode) & 0o700 != 0o700 + ): + directory.chmod(0o700) + root = require_canonical_scan_directory(root) + except (OSError, RuntimeError, SystemExit) as exc: + raise SystemExit( + f"Codex Security state directory is unsafe: {root}. {exc}" + ) from exc + os.environ["CODEX_SECURITY_STATE_DIR"] = str(root) + path = root / "workbench.sqlite3" for attempt in range(SQLITE_RETRY_ATTEMPTS): connection = sqlite3.connect(path, timeout=5) try: @@ -3752,6 +3837,25 @@ def artifact_path(scan_dir: Path, file_name: str, *, required: bool) -> Path | N return resolved +def require_trusted_output_owner(metadata: os.stat_result, effective_uid: int | None) -> None: + if effective_uid is not None and metadata.st_uid not in {0, effective_uid}: + raise SystemExit("Scan output parent must have a trusted owner.") + + +def require_trusted_output_ancestor(parent: Path | str, effective_uid: int | None) -> None: + try: + metadata = os.lstat(parent) + except OSError as exc: + raise SystemExit("Scan output parent could not be inspected.") from exc + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise SystemExit("Scan output parent must be a non-symlink directory.") + require_trusted_output_owner(metadata, effective_uid) + if stat.S_IMODE(metadata.st_mode) & 0o022 and not metadata.st_mode & stat.S_ISVTX: + raise SystemExit( + "Scan output parent must not be group- or world-writable without the sticky bit." + ) + + def require_canonical_scan_directory(scan_dir: Path) -> Path: scan_dir = scan_dir.absolute() try: @@ -3777,26 +3881,7 @@ def require_canonical_scan_directory(scan_dir: Path) -> Path: if effective_uid is not None and metadata.st_uid != effective_uid: raise SystemExit("Scan directory must be owned by the current user.") for parent in scan_dir.parents: - try: - parent_metadata = parent.lstat() - except OSError as exc: - raise SystemExit("Scan output parent could not be inspected.") from exc - if not stat.S_ISDIR(parent_metadata.st_mode) or stat.S_ISLNK( - parent_metadata.st_mode - ): - raise SystemExit("Scan output parent must be a non-symlink directory.") - if effective_uid is not None and parent_metadata.st_uid not in { - 0, - effective_uid, - }: - raise SystemExit("Scan output parent must have a trusted owner.") - if ( - stat.S_IMODE(parent_metadata.st_mode) & 0o022 - and not parent_metadata.st_mode & stat.S_ISVTX - ): - raise SystemExit( - "Scan output parent must not be group- or world-writable without the sticky bit." - ) + require_trusted_output_ancestor(parent, effective_uid) return scan_dir diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a826fafb..03c8bb58 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -104,6 +104,7 @@ import { type PluginInstall, type ProcessEnvironment, type WorkbenchCommandOptions, + validateCodexSecurityStateDirectory, validateOutputDir, } from "./runtime.js"; import { @@ -1624,6 +1625,11 @@ export class CodexSecurity { this.#runtime?.environment ?? this.#dependencies.environment, "chatgpt", ); + if (this.#runtime?.persistentCredentialHome === true) { + await validateCodexSecurityStateDirectory( + codexSecurityStateDirectory(environment), + ); + } const codexHome = this.#runtime?.codexHome ?? (await prepareCodexSecurityCredentialHome(environment)); @@ -1787,22 +1793,9 @@ export class CodexSecurity { const stateDirectory = codexSecurityStateDirectory( this.#dependencies.environment, ); - let canonicalStateDirectory = stateDirectory; - while (true) { - try { - canonicalStateDirectory = join( - await realpath(canonicalStateDirectory), - relative(canonicalStateDirectory, stateDirectory), - ); - break; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - const parent = dirname(canonicalStateDirectory); - if (parent === canonicalStateDirectory) throw error; - canonicalStateDirectory = parent; - } - } - requireOutputOutsideRepository(protectedRoot, canonicalStateDirectory); + await validateCodexSecurityStateDirectory(stateDirectory, (canonical) => + requireOutputOutsideRepository(protectedRoot, canonical), + ); return { repository: repo, target: normalized, diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index 866f5e06..c491be5f 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -8,6 +8,7 @@ import { codexSecurityStateDirectory, resolvePluginPython, runWorkbench, + validateCodexSecurityStateDirectory, } from "./runtime.js"; export async function preparePublicationStore( @@ -92,7 +93,13 @@ async function runPublicationWorkbench( environment: NodeJS.ProcessEnv, issues?: readonly PublishedScanIssue[], ): Promise> { - const stateDirectory = codexSecurityStateDirectory(environment); + const stateDirectory = await validateCodexSecurityStateDirectory( + codexSecurityStateDirectory(environment), + ); + const workbenchEnvironment = { + ...environment, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }; const database = join(stateDirectory, "workbench.sqlite3"); try { if (!(await stat(database)).isFile()) throw new Error("not a regular file"); @@ -104,7 +111,7 @@ async function runPublicationWorkbench( } const [python, pluginRoot] = await Promise.all([ resolvePluginPython({ - environment, + environment: workbenchEnvironment, protectedRoot: publication.scanDirectory, }), bundledPluginRoot(), @@ -113,6 +120,7 @@ async function runPublicationWorkbench( findingId, occurrenceId, })); + await validateCodexSecurityStateDirectory(stateDirectory); const directory = await mkdtemp(join(stateDirectory, "publication-")); try { const input = join(directory, "publication.json"); @@ -131,7 +139,7 @@ async function runPublicationWorkbench( { python, pluginRoot, - environment, + environment: workbenchEnvironment, failureMessage: command === "prepare-linear-publication" ? "Cannot publish findings without their existing local Codex Security scan history" diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index 266de10f..a0644ae3 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -31,7 +31,9 @@ import { } from "./publication-store.js"; import { codexSecurityStateDirectory, + prepareCodexSecurityStateDirectory, resolveCodexCommand, + validateCodexSecurityStateDirectory, type CodexCommand, } from "./runtime.js"; @@ -141,10 +143,10 @@ export async function publishScanInternal( ); } - const environment = dependencies.environment ?? process.env; + const inheritedEnvironment = dependencies.environment ?? process.env; const linearApiKey = options.linearApiKey?.trim() || - environment["CODEX_SECURITY_LINEAR_API_KEY"]?.trim() || + inheritedEnvironment["CODEX_SECURITY_LINEAR_API_KEY"]?.trim() || undefined; if (options.assigneeId !== undefined && linearApiKey === undefined) { throw new ConfigurationError( @@ -174,6 +176,13 @@ export async function publishScanInternal( } if (prepared.issues.length === 0) return result; + const stateDirectory = await validateCodexSecurityStateDirectory( + codexSecurityStateDirectory(inheritedEnvironment), + ); + const environment = { + ...inheritedEnvironment, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }; await (dependencies.preparePublicationStore ?? preparePublicationStore)( prepared, environment, @@ -208,7 +217,7 @@ export async function publishScanInternal( ? (dependencies.resolveCodex ?? resolveCodexCommand)(environment) : undefined; options.signal?.throwIfAborted(); - const handoff = await createPublicationHandoff(prepared, environment); + const handoff = await createPublicationHandoff(prepared, stateDirectory); const progressObserver = options.onProgress; reportPublicationProgress(progressObserver, { type: "started", @@ -320,12 +329,18 @@ export async function publishScanInternal( result.failed = handoffResults.failed; result.counts.created = result.created.length; result.counts.failed = result.failed.length; + const saveReceipt = async (): Promise => { + const receiptStateDirectory = + await prepareCodexSecurityStateDirectory(stateDirectory); + if (dependencies.writeReceipt !== undefined) { + await dependencies.writeReceipt(result, environment); + } else { + await writePublicationReceipt(result, receiptStateDirectory); + } + }; if (options.signal?.aborted) { try { - await (dependencies.writeReceipt ?? writePublicationReceipt)( - result, - environment, - ); + await saveReceipt(); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CodexSecurityError( @@ -357,10 +372,7 @@ export async function publishScanInternal( } } try { - await (dependencies.writeReceipt ?? writePublicationReceipt)( - result, - environment, - ); + await saveReceipt(); } catch (error) { if (result.created.length === 0 || options.signal?.aborted) throw error; result.warnings = [ @@ -594,10 +606,10 @@ function publicationPrompt( async function createPublicationHandoff( publication: PreparedScanPublication, - environment: NodeJS.ProcessEnv, + stateDirectory: string, ): Promise<{ directory: string; file: string; publicationFile: string }> { const root = join( - codexSecurityStateDirectory(environment), + await prepareCodexSecurityStateDirectory(stateDirectory), "publications", "linear", "handoffs", @@ -1060,13 +1072,9 @@ function reportCodexEvent( async function writePublicationReceipt( result: PublishScanResult, - environment: NodeJS.ProcessEnv, + stateDirectory: string, ): Promise { - const directory = join( - codexSecurityStateDirectory(environment), - "publications", - "linear", - ); + const directory = join(stateDirectory, "publications", "linear"); await mkdir(directory, { mode: 0o700, recursive: true }); const name = createHash("sha256").update(result.scanId).digest("hex"); const contents = JSON.stringify(result); diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 13c41f2c..80b8110d 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -12,6 +12,7 @@ import { open, opendir, readFile, + readlink, readdir, realpath, rename, @@ -22,7 +23,16 @@ import { } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { createRequire } from "node:module"; -import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -118,6 +128,174 @@ export function codexSecurityStateDirectory( return resolve(expandHome(codexHome), "state", "plugins", "codex-security"); } +export async function validateCodexSecurityStateDirectory( + path: string, + validateLocation?: (canonical: string) => void, +): Promise { + const requested = resolve(expandHome(path)); + try { + let canonical = requested; + while (true) { + try { + canonical = join( + await realpath(canonical), + relative(canonical, requested), + ); + break; + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + const parent = dirname(canonical); + if (parent === canonical) throw error; + canonical = parent; + } + } + validateLocation?.(canonical); + requireModelSafeOutputDir(requested); + requireModelSafeOutputDir(canonical); + const metadata = await lstat(canonical).catch((error: unknown) => { + if (nodeErrorCode(error) === "ENOENT") return null; + throw error; + }); + if (metadata !== null) { + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new OutputDirectoryError( + `Configured Codex Security state path must be a directory: ${canonical}`, + ); + } + const effectiveUid = process.geteuid?.(); + try { + requirePrivateOutputDirectory(metadata, canonical, effectiveUid); + } catch (error) { + if (!(error instanceof OutputDirectoryError)) throw error; + const mode = (metadata.mode & 0o7777).toString(8).padStart(4, "0"); + if (effectiveUid !== undefined && metadata.uid !== effectiveUid) { + throw new OutputDirectoryError( + `Configured Codex Security state directory must be owned by the current user: ${canonical} (mode ${mode}). Set CODEX_SECURITY_STATE_DIR to a private directory owned by the current user.`, + { cause: error }, + ); + } + throw new OutputDirectoryError( + `Configured Codex Security state directory must be private to the current user: ${canonical} (mode ${mode}). Set CODEX_SECURITY_STATE_DIR to a private directory, or set this directory's permissions to 0700 only if you own it and can safely change it.`, + { cause: error }, + ); + } + } + await requireSecureStateAncestry(requested); + return canonical; + } catch (error) { + if (error instanceof OutputDirectoryError) throw error; + throw new OutputDirectoryError( + `Unable to inspect configured Codex Security state directory: ${requested}`, + { cause: error }, + ); + } +} + +export async function prepareCodexSecurityStateDirectory( + path: string, + validateLocation?: (canonical: string) => void, +): Promise { + const canonical = await validateCodexSecurityStateDirectory( + path, + validateLocation, + ); + const validatePinned = async (): Promise => { + const validated = await validateCodexSecurityStateDirectory( + canonical, + validateLocation, + ); + if (relative(canonical, validated) !== "") { + throw new OutputDirectoryError( + `Configured Codex Security state directory changed during preparation: ${canonical}`, + ); + } + return validated; + }; + try { + const missing: string[] = []; + let current = canonical; + while (true) { + try { + await lstat(current); + break; + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + missing.push(current); + const parent = dirname(current); + if (parent === current) throw error; + current = parent; + } + } + await validatePinned(); + for (const directory of missing.reverse()) { + try { + await mkdir(directory, { mode: 0o700 }); + } catch (error) { + if (nodeErrorCode(error) !== "EEXIST") throw error; + await validatePinned(); + continue; + } + // Restore owner access before creating a child under a restrictive umask. + if ( + process.platform !== "win32" && + ((await lstat(directory)).mode & 0o700) !== 0o700 + ) { + await chmod(directory, 0o700); + } + } + return await validatePinned(); + } catch (error) { + if (error instanceof OutputDirectoryError) throw error; + throw new OutputDirectoryError( + `Unable to prepare configured Codex Security state directory: ${canonical}`, + { cause: error }, + ); + } +} + +async function requireSecureStateAncestry( + path: string, + effectiveUid = process.geteuid?.(), +): Promise { + if (process.platform === "win32") return; + const pending = [path]; + const checked = new Set(); + while (pending.length > 0) { + let current = pending.pop()!; + while (true) { + while (current.length > 1 && current.endsWith(sep)) { + current = current.slice(0, -1); + } + if (checked.has(current)) break; + checked.add(current); + const parent = dirname(current); + const metadata = await lstat(current).catch((error: unknown) => { + if (nodeErrorCode(error) === "ENOENT") return null; + throw error; + }); + if (metadata?.isSymbolicLink()) { + requireTrustedOutputOwner(metadata, current, effectiveUid); + const canonical = await realpath(current); + const target = await readlink(current); + // Preserve dot segments until the filesystem resolves the link target. + const lexicalTarget = isAbsolute(target) + ? target + : `${parent}${parent.endsWith(sep) ? "" : sep}${target}`; + pending.push(canonical, lexicalTarget); + } else if (metadata !== null) { + if (!metadata.isDirectory()) { + throw new OutputDirectoryError( + `Codex Security state path must use directories: ${current}`, + ); + } + requireTrustedOutputAncestor(metadata, current, effectiveUid); + } + if (parent === current) break; + current = parent; + } + } +} + export function codexSecurityCredentialHome( environment: ProcessEnvironment = process.env, ): string { @@ -128,7 +306,13 @@ export async function prepareCodexSecurityCredentialHome( environment: ProcessEnvironment = process.env, validateLocation?: (path: string) => void, ): Promise { - const path = codexSecurityCredentialHome(environment); + const stateDirectory = await prepareCodexSecurityStateDirectory( + codexSecurityStateDirectory(environment), + validateLocation === undefined + ? undefined + : (canonical) => validateLocation(join(canonical, "codex-home")), + ); + const path = join(stateDirectory, "codex-home"); try { try { await mkdir(path, { recursive: true, mode: 0o700 }); @@ -1272,8 +1456,7 @@ export async function preparePersistentScanRoot( stateDirectory: string, repositoryName: string, ): Promise { - await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); - let root = await realpath(stateDirectory); + let root = await prepareCodexSecurityStateDirectory(stateDirectory); for (const directory of ["scans", safePrefix(repositoryName)]) { root = join(root, directory); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -1290,6 +1473,27 @@ export async function runWorkbench( options: WorkbenchCommandOptions, args: readonly string[], ): Promise { + const stateDirectory = ["inspect-target", "inspect-setup"].includes( + args[0] ?? "", + ) + ? undefined + : await prepareCodexSecurityStateDirectory( + codexSecurityStateDirectory(options.environment), + ); + const environment = Object.fromEntries( + Object.entries(options.environment).filter( + ([name]) => + name.toUpperCase() !== "OPENAI_API_KEY" && + name.toUpperCase() !== "CODEX_API_KEY" && + name.toUpperCase() !== "OPENROUTER_API_KEY" && + name.toUpperCase() !== "FIREWORKS_API_KEY" && + (stateDirectory === undefined || + name.toUpperCase() !== "CODEX_SECURITY_STATE_DIR"), + ), + ); + if (stateDirectory !== undefined) { + environment["CODEX_SECURITY_STATE_DIR"] = stateDirectory; + } let stdout: string; try { ({ stdout } = await execFile( @@ -1301,15 +1505,7 @@ export async function runWorkbench( ...args, ], { - env: Object.fromEntries( - Object.entries(options.environment).filter( - ([name]) => - name.toUpperCase() !== "OPENAI_API_KEY" && - name.toUpperCase() !== "CODEX_API_KEY" && - name.toUpperCase() !== "OPENROUTER_API_KEY" && - name.toUpperCase() !== "FIREWORKS_API_KEY", - ), - ), + env: environment, encoding: "utf8", maxBuffer: Infinity, windowsHide: true, @@ -1328,7 +1524,7 @@ export async function runWorkbench( throw new CodexSecurityError( databaseFailure ? `${failure}: cannot open the workbench database at ${join( - codexSecurityStateDirectory(options.environment), + stateDirectory ?? codexSecurityStateDirectory(options.environment), "workbench.sqlite3", )}. Ensure the state directory and SQLite journal files are writable, or set CODEX_SECURITY_STATE_DIR to a writable directory outside the scanned repository.` : `${failure}: ${detail}`, @@ -1618,6 +1814,21 @@ export function requireTrustedOutputAncestor( metadata: Pick, path: string, effectiveUid = process.geteuid?.(), +): void { + requireTrustedOutputOwner(metadata, path, effectiveUid); + if ((metadata.mode & 0o022) === 0) return; + if ((metadata.mode & 0o1000) === 0) { + const mode = (metadata.mode & 0o7777).toString(8).padStart(4, "0"); + throw new OutputDirectoryError( + `Scan output parent must not be group- or world-writable without the sticky bit: ${path} (mode ${mode}). A private child directory does not make an unsafe ancestor safe. Choose a location with secure parent directories, or remove group- and world-write permissions from this ancestor only if you own it and can safely change it.`, + ); + } +} + +function requireTrustedOutputOwner( + metadata: Pick, + path: string, + effectiveUid: number | undefined, ): void { if ( effectiveUid !== undefined && @@ -1628,12 +1839,6 @@ export function requireTrustedOutputAncestor( `Scan output parent must have a trusted owner: ${path}`, ); } - if ((metadata.mode & 0o022) === 0) return; - if ((metadata.mode & 0o1000) === 0) { - throw new OutputDirectoryError( - `Scan output parent must not be group- or world-writable without the sticky bit: ${path}`, - ); - } } async function removeEmptyDirectories( diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 37896482..c04af045 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1,5 +1,6 @@ import { appendFile, + chmod, copyFile, cp, mkdir, @@ -63,6 +64,7 @@ type ScanObserverName = Parameters< const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); const temporaryDirectories: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; const TEST_SNAPSHOT_DIGEST = `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`; const SHELL_ENVIRONMENT_PREFIX = process.platform === "win32" ? "$env:" : "$"; @@ -4004,6 +4006,66 @@ describe("CodexSecurity orchestration", () => { expect(existsSync(join(result.scanDir, "scan-manifest.json"))).toBe(true); }); + test("preflights state initialized by a history command", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stateDirectory = join(root, "state"); + const outputDir = join(root, "output"); + await mkdir(repository); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const environment = { + PATH: process.env["PATH"], + CODEX_SECURITY_STATE_DIR: stateDirectory, + }; + const history = execFileSync( + python!, + [ + "-I", + "-B", + "-c", + [ + "import os, runpy, sys", + "os.umask(0o022)", + "sys.argv = sys.argv[1:]", + "sys.path.insert(0, os.path.dirname(sys.argv[0]))", + 'runpy.run_path(sys.argv[0], run_name="__main__")', + ].join("\n"), + join(PLUGIN_ROOT, "scripts", "workbench_db.py"), + "list-scans", + "--repository", + repository, + ], + { encoding: "utf8", env: environment }, + ); + expect(JSON.parse(history)).toMatchObject({ scans: [] }); + if (process.platform !== "win32") { + expect((await stat(stateDirectory)).mode & 0o777).toBe(0o700); + } + const initialize = mock(() => { + throw new Error("runtime must not initialize"); + }); + const client = new TestClient( + {}, + { + environment, + prepareRuntime: initialize, + resolvePluginPython: initialize, + createCodex: initialize, + }, + ); + + try { + await expect( + client.preflight(repository, { outputDir }), + ).resolves.toMatchObject({ outputDir }); + expect(initialize).not.toHaveBeenCalled(); + expect(existsSync(outputDir)).toBe(false); + } finally { + await client.close(); + } + }); + test("rejects state directories overlapping the selected repository", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -4015,6 +4077,7 @@ describe("CodexSecurity orchestration", () => { process.platform === "win32" ? "junction" : "dir", ); for (const stateDirectory of [ + repository, join(repository, "state"), root, linkedState, @@ -4039,12 +4102,184 @@ describe("CodexSecurity orchestration", () => { client[operation](repository, { outputDir: join(root, "output") }), ).rejects.toBeInstanceOf(OutputInsideProtectedRootError); } - if (stateDirectory !== root && stateDirectory !== linkedState) + if ( + stateDirectory !== repository && + stateDirectory !== root && + stateDirectory !== linkedState + ) expect(existsSync(stateDirectory)).toBe(false); await client.close(); } }); + testPosix( + "rejects unsafe output and state ancestry before runtime initialization", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const shared = join(root, "shared"); + const privateChild = join(shared, "private"); + const linkedParent = join(root, "linked-parent"); + const safeState = join(root, "state"); + const privateState = join(root, "private-state"); + const stateLink = join(shared, "state-link"); + const indirectState = join(root, "indirect-state"); + const missingState = join(privateChild, "state"); + const linkedState = join(linkedParent, "private", "missing", "state"); + const safeOutput = join(root, "output"); + const unsafeOutput = join(privateChild, "output"); + await mkdir(repository); + await mkdir(privateChild, { recursive: true, mode: 0o700 }); + await mkdir(privateState, { mode: 0o700 }); + await chmod(shared, 0o775); + await symlink(shared, linkedParent, "dir"); + await symlink(privateState, stateLink, "dir"); + await symlink(stateLink, indirectState, "dir"); + + for (const [stateDirectory, outputDir] of [ + [safeState, unsafeOutput], + [shared, undefined], + [shared, safeOutput], + [missingState, undefined], + [missingState, safeOutput], + [linkedState, safeOutput], + [stateLink, safeOutput], + [indirectState, safeOutput], + [join(indirectState, "missing", "state"), safeOutput], + ] as const) { + const initialize = mock(() => { + throw new Error("runtime must not initialize"); + }); + const client = new TestClient( + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + prepareRuntime: initialize, + resolvePluginPython: initialize, + createCodex: initialize, + }, + ); + + try { + for (const operation of ["preflight", "run"] as const) { + await expect( + client[operation](repository, { outputDir }), + ).rejects.toMatchObject({ + name: OutputDirectoryError.name, + message: expect.stringContaining(`${shared} (mode 0775)`), + }); + } + expect(initialize).not.toHaveBeenCalled(); + } finally { + await client.close(); + } + } + + expect((await stat(shared)).mode & 0o7777).toBe(0o775); + expect((await stat(privateChild)).mode & 0o7777).toBe(0o700); + expect((await readdir(shared)).sort()).toEqual(["private", "state-link"]); + expect(await readdir(privateChild)).toEqual([]); + expect(await readdir(privateState)).toEqual([]); + for (const path of [ + safeState, + missingState, + linkedState, + safeOutput, + unsafeOutput, + ]) { + expect(existsSync(path)).toBe(false); + } + }, + ); + + testPosix( + "rejects non-private existing state before runtime initialization", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stateDirectory = join(root, "state"); + const outputDir = join(root, "output"); + await mkdir(repository); + await mkdir(stateDirectory, { mode: 0o700 }); + const initialize = mock(() => { + throw new Error("runtime must not initialize"); + }); + const client = new TestClient( + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + prepareRuntime: initialize, + resolvePluginPython: initialize, + createCodex: initialize, + }, + ); + + try { + for (const requestedMode of [0o755, 0o1777]) { + await chmod(stateDirectory, requestedMode); + const mode = (await stat(stateDirectory)).mode & 0o7777; + for (const operation of ["preflight", "run"] as const) { + await expect( + client[operation](repository, { outputDir }), + ).rejects.toThrow( + "Configured Codex Security state directory must be private", + ); + } + expect((await stat(stateDirectory)).mode & 0o7777).toBe(mode); + } + expect(initialize).not.toHaveBeenCalled(); + expect(await readdir(stateDirectory)).toEqual([]); + expect(existsSync(outputDir)).toBe(false); + } finally { + await client.close(); + } + }, + ); + + testPosix( + "preflights persistent state under a sticky shared parent without creating it", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const outputDir = join(root, "output"); + let stickyParent = join(root, "shared"); + await mkdir(repository); + await mkdir(stickyParent, { mode: 0o1777 }); + await chmod(stickyParent, 0o1777); + if (((await stat(stickyParent)).mode & 0o1000) === 0) { + stickyParent = await realpath(tmpdir()); + if (((await stat(stickyParent)).mode & 0o1000) === 0) return; + } + const parentMode = (await stat(stickyParent)).mode & 0o7777; + const stateDirectory = join(stickyParent, `${basename(root)}-state`); + temporaryDirectories.push(stateDirectory); + const initialize = mock(() => { + throw new Error("runtime must not initialize"); + }); + const client = new TestClient( + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + prepareRuntime: initialize, + resolvePluginPython: initialize, + createCodex: initialize, + }, + ); + + try { + await expect( + client.preflight(repository, { outputDir }), + ).resolves.toMatchObject({ outputDir }); + expect(initialize).not.toHaveBeenCalled(); + expect((await stat(stickyParent)).mode & 0o7777).toBe(parentMode); + expect(existsSync(stateDirectory)).toBe(false); + expect(existsSync(outputDir)).toBe(false); + } finally { + await client.close(); + } + }, + ); + test("rejects reruns when the original plugin version is unavailable", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -4068,6 +4303,65 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + testPosix( + "revalidates state before reusing cached persistent authentication", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stateDirectory = join(root, "state"); + const codexHome = join(stateDirectory, "codex-home"); + const outputDir = join(root, "output"); + await mkdir(repository); + await mkdir(codexHome, { recursive: true, mode: 0o700 }); + const authenticationCommand = mock(() => { + throw new Error("authentication command must not start"); + }); + const initialize = mock(() => { + throw new Error("scan must not start"); + }); + const client = new TestClient( + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + persistentCredentialHome: true, + environment: { + CODEX_HOME: codexHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + }), + resolveCodexCommand: authenticationCommand, + resolvePluginPython: initialize, + createCodex: initialize, + }, + ); + + try { + await expect( + client.run(repository, { outputDir, expectedPluginVersion: "0.0.0" }), + ).rejects.toThrow("original scan used plugin version"); + await chmod(stateDirectory, 0o755); + for (const operation of [ + () => client.account(), + () => client.logout(), + () => client.loginApiKey("synthetic-key"), + ]) { + await expect(operation()).rejects.toThrow( + "Configured Codex Security state directory must be private", + ); + } + expect(authenticationCommand).not.toHaveBeenCalled(); + expect(initialize).not.toHaveBeenCalled(); + expect((await stat(stateDirectory)).mode & 0o7777).toBe(0o755); + expect(await readdir(codexHome)).toEqual([]); + expect(existsSync(outputDir)).toBe(false); + } finally { + await client.close(); + } + }, + ); + test("keeps a private preflight snapshot isolated from persistent credentials", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index b8401334..f8fa7fe8 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -3963,6 +3963,42 @@ describe("CLI", () => { } }); + test("keeps unsafe ancestry errors local and off JSON stdout", async () => { + const failure = new OutputDirectoryError( + "Scan output parent has unsafe permissions (mode 0775).", + ); + + for (const extraArgs of [[], ["--dry-run"]]) { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + deps.createSecurity = () => ({ + run: async () => { + throw failure; + }, + preflight: async () => { + throw failure; + }, + close: async () => {}, + }); + + expect( + await main( + ["scan", ".", "--json", "--verbose", ...extraArgs], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain(`${failure.message}\n`); + expect(stderr.text()).toContain('scan.failed classification="local"'); + expect(stderr.text()).not.toContain("cannot access the configured model"); + expect(stderr.text()).not.toContain("Authentication failed"); + expect(stderr.text()).not.toContain("model service could not be reached"); + } + }); + test("keeps model authorization advice for genuine transport failures", async () => { // The bypass must not swallow real 401/403 handling, and the advice must // still replace upstream text that can name the organization or project. diff --git a/sdk/typescript/tests-ts/compact-diff-scan.test.ts b/sdk/typescript/tests-ts/compact-diff-scan.test.ts index 02982272..e2b864e4 100644 --- a/sdk/typescript/tests-ts/compact-diff-scan.test.ts +++ b/sdk/typescript/tests-ts/compact-diff-scan.test.ts @@ -429,7 +429,7 @@ describe("compact diff scan", () => { git(repository, "commit", "-qm", "changed"); const headRevision = git(repository, "rev-parse", "HEAD"); mkdirSync(join(root, "scans")); - mkdirSync(join(root, "state")); + mkdirSync(join(root, "state"), { mode: 0o700 }); const client = await startMcp(root); const owner = "compact-diff-owner"; const call = (name: string, args: JsonObject) => diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 6fb9762c..161bc229 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -1,7 +1,15 @@ import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + realpath, + rm, + stat, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; @@ -257,6 +265,51 @@ describe("persisted finding publication associations", () => { expect(existsSync(fixture.stateDirectory)).toBe(false); }); + test.skipIf(process.platform === "win32")( + "rejects shared state before inspecting a missing database", + async () => { + const fixture = await publicationFixture({ createDatabase: false }); + await mkdir(fixture.stateDirectory, { mode: 0o700 }); + await chmod(fixture.stateDirectory, 0o755); + + await expect( + preparePublicationStore(fixture.publication, fixture.environment), + ).rejects.toThrow(/state directory must be private/u); + + expect(await readdir(fixture.stateDirectory)).toEqual([]); + expect((await stat(fixture.stateDirectory)).mode & 0o777).toBe(0o755); + }, + ); + + test.skipIf(process.platform === "win32")( + "rejects publication reads and writes when existing state becomes shared", + async () => { + const fixture = await publicationFixture({ count: 1 }); + const contents = await readdir(fixture.stateDirectory); + await chmod(fixture.stateDirectory, 0o755); + + await expect( + preparePublicationStore(fixture.publication, fixture.environment), + ).rejects.toThrow(/state directory must be private/u); + await expect( + recordPublishedIssues( + fixture.publication, + [publishedIssue(fixture.publication, 0)], + fixture.environment, + ), + ).rejects.toThrow(/state directory must be private/u); + + expect(await readdir(fixture.stateDirectory)).toEqual(contents); + expect((await stat(fixture.stateDirectory)).mode & 0o777).toBe(0o755); + expect( + databaseRows( + fixture, + "SELECT COUNT(*) AS count FROM finding_publications", + ), + ).toEqual([{ count: 0 }]); + }, + ); + test("rejects a scan absent from existing local scan history", async () => { const fixture = await publicationFixture({ seedScan: false }); diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 197f6c0b..1a45bc11 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -1,12 +1,17 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; import { appendFile, + chmod, + mkdir, mkdtemp, readFile, readdir, + realpath, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -267,6 +272,295 @@ async function processHasExited(pid: number): Promise { return false; } +describe("private publication state", () => { + test("does not inspect configured state for previews or empty scans", async () => { + const root = await mkdtemp( + join(tmpdir(), "codex-security-unused-publication-state-"), + ); + temporaryDirectories.push(root); + const marker = join(root, "not-a-directory"); + await writeFile(marker, "preserved\n"); + + for (const scenario of [ + { count: 1, dryRun: true }, + { count: 0, dryRun: false }, + ]) { + const publication = preparedPublication(scenario.count); + const result = await publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, dryRun: scenario.dryRun }, + dependencies( + publication, + {}, + { + environment: { + CODEX_SECURITY_STATE_DIR: join(marker, "state"), + }, + preparePublicationStore: async () => { + throw new Error("Unused publication state must not be opened."); + }, + }, + ), + ); + expect(result.counts.findings).toBe(scenario.count); + } + + expect(await readFile(marker, "utf8")).toBe("preserved\n"); + expect(await readdir(root)).toEqual(["not-a-directory"]); + }); + + test("leaves a missing history directory absent when publication cannot start", async () => { + const publication = preparedPublication(); + let started = false; + const injected = dependencies( + publication, + {}, + { + runCodex: async () => { + started = true; + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }, + ); + delete injected.preparePublicationStore; + const stateDirectory = injected.environment!["CODEX_SECURITY_STATE_DIR"]!; + + await expect( + publishScanInternal(publication.scanDirectory, OPTIONS, injected), + ).rejects.toThrow(/scan-history database does not exist/u); + + expect(started).toBe(false); + expect(existsSync(stateDirectory)).toBe(false); + }); + + test.skipIf(process.platform === "win32")( + "rejects shared state before opening history or contacting a publisher", + async () => { + const stateDirectory = await mkdtemp( + join(tmpdir(), "codex-security-shared-publication-state-"), + ); + temporaryDirectories.push(stateDirectory); + await chmod(stateDirectory, 0o755); + const publication = preparedPublication(); + const calls: string[] = []; + + for (const direct of [false, true]) { + await expect( + publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + ...(direct ? { linearApiKey: "synthetic-key" } : {}), + }, + dependencies( + publication, + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + preparePublicationStore: async () => { + calls.push("history"); + }, + linearClient: linearApiClient(publication, { + configured: () => calls.push("client"), + create: () => { + calls.push("create"); + }, + }), + resolveCodex: () => { + calls.push("resolve"); + return { command: "synthetic-codex" }; + }, + runCodex: async () => { + calls.push("publish"); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }, + ), + ), + ).rejects.toThrow(/state directory must be private/u); + } + + expect(calls).toEqual([]); + expect(await readdir(stateDirectory)).toEqual([]); + expect((await stat(stateDirectory)).mode & 0o777).toBe(0o755); + }, + ); + + test.skipIf(process.platform === "win32")( + "revalidates state before creating a publication handoff", + async () => { + const stateDirectory = await mkdtemp( + join(tmpdir(), "codex-security-publication-handoff-state-"), + ); + temporaryDirectories.push(stateDirectory); + const publication = preparedPublication(); + let started = false; + + await expect( + publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + preparePublicationStore: async () => { + await chmod(stateDirectory, 0o755); + }, + runCodex: async () => { + started = true; + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }, + ), + ), + ).rejects.toThrow(/state directory must be private/u); + + expect(started).toBe(false); + expect(await readdir(stateDirectory)).toEqual([]); + expect((await stat(stateDirectory)).mode & 0o777).toBe(0o755); + }, + ); + + test("pins a trusted state alias for the full publication", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-publication-state-alias-")), + ); + temporaryDirectories.push(root); + const selected = join(root, "selected"); + const other = join(root, "other"); + const alias = join(root, "state-alias"); + await mkdir(selected, { mode: 0o700 }); + await mkdir(other, { mode: 0o700 }); + const linkType = process.platform === "win32" ? "junction" : "dir"; + await symlink(selected, alias, linkType); + const canonicalState = await realpath(selected); + const environment = { CODEX_SECURITY_STATE_DIR: alias }; + const inherited: NodeJS.ProcessEnv[] = []; + const publication = preparedPublication(); + const injected = dependencies( + publication, + {}, + { + environment, + preparePublicationStore: async (_publication, env) => { + inherited.push(env); + }, + resolveCodex: (env) => { + inherited.push(env); + return { command: "synthetic-codex" }; + }, + runCodex: async (_command, _args, input, env) => { + inherited.push(env); + expect(publicationData(input).handoffFile).toStartWith( + join(canonicalState, "publications"), + ); + await rm(alias, { recursive: true, force: true }); + await symlink(other, alias, linkType); + environment.CODEX_SECURITY_STATE_DIR = other; + return { + exitCode: 0, + stdout: issueEvent(publication.issues[0]!), + stderr: "", + }; + }, + recordPublishedIssues: async (_publication, issues, env) => { + inherited.push(env); + return [...issues]; + }, + }, + ); + delete injected.writeReceipt; + + const result = await publishScanInternal( + publication.scanDirectory, + OPTIONS, + injected, + ); + + expect(inherited).toHaveLength(4); + for (const env of inherited) { + expect(env).toBe(inherited[0]!); + expect(env).not.toBe(environment); + expect(env["CODEX_SECURITY_STATE_DIR"]).toBe(canonicalState); + } + expect(environment.CODEX_SECURITY_STATE_DIR).toBe(other); + const digest = createHash("sha256") + .update(publication.scanId) + .digest("hex"); + const receipt = join( + canonicalState, + "publications", + "linear", + `${digest}.json`, + ); + expect(JSON.parse(await readFile(receipt, "utf8"))).toEqual(result); + expect(await readdir(other)).toEqual([]); + }); + + test.skipIf(process.platform === "win32")( + "blocks final and partial receipt writers if state becomes shared", + async () => { + for (const interrupted of [false, true]) { + const stateDirectory = await mkdtemp( + join(tmpdir(), "codex-security-publication-receipt-state-"), + ); + temporaryDirectories.push(stateDirectory); + const publication = preparedPublication(); + const controller = new AbortController(); + let persisted = false; + let receiptWrites = 0; + const pending = publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, signal: controller.signal }, + dependencies( + publication, + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + recordPublishedIssues: async (_publication, issues) => { + persisted = true; + await chmod(stateDirectory, 0o755); + if (interrupted) controller.abort("Publication interrupted."); + return [...issues]; + }, + writeReceipt: async () => { + receiptWrites += 1; + }, + }, + ), + ); + + if (interrupted) { + await expect(pending).rejects.toThrow( + /partial receipt could not be saved: .*state directory must be private/u, + ); + } else { + const result = await pending; + expect(result.counts).toEqual({ + findings: 1, + created: 1, + failed: 0, + }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings![0]).toContain( + "state directory must be private", + ); + expect(result.warnings![0]).toContain("do not retry publication"); + } + + expect(persisted).toBe(true); + expect(receiptWrites).toBe(0); + expect((await stat(stateDirectory)).mode & 0o777).toBe(0o755); + expect( + await readdir(join(stateDirectory, "publications", "linear")), + ).toEqual(["handoffs"]); + } + }, + ); +}); + describe("direct Linear API publication", () => { test("leaves issues unassigned unless an email or user ID is selected", async () => { for (const scenario of [ @@ -630,6 +924,7 @@ describe("connected Linear publication", () => { join(tmpdir(), "codex-security-publication-environment-"), ); temporaryDirectories.push(stateDirectory); + const canonicalState = await realpath(stateDirectory); const environment = { CODEX_HOME: "/existing/connected-codex-home", CODEX_SECURITY_STATE_DIR: stateDirectory, @@ -665,7 +960,7 @@ describe("connected Linear publication", () => { }, writeReceipt: async (receipt, env) => { receiptScanId = receipt.scanId; - expect(env).toBe(environment); + expect(env).toBe(inheritedEnvironment!); }, }, ), @@ -674,7 +969,7 @@ describe("connected Linear publication", () => { expect(command).toBe("synthetic-codex"); const handoffDirectory = args![args!.indexOf("--cd") + 1]!; expect( - handoffDirectory.startsWith(join(stateDirectory, "publications")), + handoffDirectory.startsWith(join(canonicalState, "publications")), ).toBe(true); expect(args).toEqual([ "exec", @@ -693,7 +988,11 @@ describe("connected Linear publication", () => { ]); expect(args).not.toContain("--ignore-user-config"); expect(args).not.toContain("--disable"); - expect(inheritedEnvironment).toBe(environment); + expect(inheritedEnvironment).not.toBe(environment); + expect(inheritedEnvironment).toEqual({ + ...environment, + CODEX_SECURITY_STATE_DIR: canonicalState, + }); expect(input).toContain("already-connected hosted Linear application"); expect(input).toContain("untrusted inert data"); expect(input).toContain("track-findings"); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 16280e70..3af27748 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -40,6 +40,7 @@ import { createMarketplace, extractPluginZip, importAmbientAuth, + OutputDirectoryError, pluginExecutionEnvironment, PluginBootstrapError, PluginPythonUnavailableError, @@ -61,6 +62,7 @@ import { isPythonPathCandidate, planOutputArchive, prepareCodexSecurityCredentialHome, + prepareCodexSecurityStateDirectory, preparePersistentScanRoot, requirePrivateCredentialHome, requirePrivateCredentialFile, @@ -71,6 +73,7 @@ import { runWorkbench, setCodexSecurityCredentialLogout, streamWindowsCredentialAclDescriptors, + validateCodexSecurityStateDirectory, verifyStableWindowsCredentialDescendants, } from "../src/runtime.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; @@ -1962,6 +1965,7 @@ describe("runtime directories and plugin Python boundary", () => { await expect( requireSecureOutputAncestry(join(shared, "state")), ).rejects.toThrow("sticky bit"); + expect(existsSync(join(shared, "state"))).toBe(false); }, ); @@ -2081,7 +2085,7 @@ describe("runtime directories and plugin Python boundary", () => { test("identifies a credential home that already exists as a regular file", async () => { const root = await temporaryDirectory(); const stateDirectory = join(root, "state"); - await mkdir(stateDirectory); + await mkdir(stateDirectory, { mode: 0o700 }); await writeFile(join(stateDirectory, "codex-home"), "not a directory\n"); await expect( @@ -3582,6 +3586,311 @@ describe("runtime directories and plugin Python boundary", () => { ).toBe(join(root, "state", "scans", "linked-repository")); }); + test("validates private state aliases and missing paths without creating them", async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const alias = join(root, "state-alias"); + const missing = join(alias, "missing", "state"); + await mkdir(state, { mode: 0o700 }); + await writeFile(join(state, "preserved.txt"), "preserved\n"); + await symlink( + state, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + let location: string | undefined; + + expect( + await validateCodexSecurityStateDirectory(alias, (canonical) => { + location = canonical; + }), + ).toBe(state); + expect(location).toBe(state); + expect(await validateCodexSecurityStateDirectory(missing)).toBe( + join(state, "missing", "state"), + ); + expect(existsSync(missing)).toBe(false); + expect(await readdir(state)).toEqual(["preserved.txt"]); + expect(await readFile(join(state, "preserved.txt"), "utf8")).toBe( + "preserved\n", + ); + if (process.platform !== "win32") { + expect((await stat(state)).mode & 0o7777).toBe(0o700); + } + }); + + test("prepares a canonical private state root without replacing existing data", async () => { + const root = await temporaryDirectory(); + const state = join(root, "missing", "state"); + const alias = join(root, "state-alias"); + expect(await prepareCodexSecurityStateDirectory(state)).toBe(state); + await writeFile(join(state, "preserved.txt"), "preserved\n"); + await symlink( + state, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(await prepareCodexSecurityStateDirectory(alias)).toBe(state); + expect(await readFile(join(state, "preserved.txt"), "utf8")).toBe( + "preserved\n", + ); + if (process.platform !== "win32") { + expect((await stat(state)).mode & 0o7777).toBe(0o700); + } + }); + + testPosix( + "changes owner permissions only on newly created state roots", + async () => { + const root = await temporaryDirectory(); + const existing = join(root, "existing"); + await mkdir(existing, { mode: 0o755 }); + await chmod(existing, 0o755); + for (const mask of [0o700, 0o777]) { + const first = join(existing, `nested-${mask.toString(8)}`); + const second = join(first, "parent"); + const state = join(second, "state"); + const previousUmask = process.umask(mask); + try { + expect(await prepareCodexSecurityStateDirectory(state)).toBe(state); + } finally { + process.umask(previousUmask); + } + for (const directory of [first, second, state]) { + expect((await stat(directory)).mode & 0o7777).toBe(0o700); + } + expect((await stat(existing)).mode & 0o7777).toBe(0o755); + await chmod(state, 0o755); + await expect(prepareCodexSecurityStateDirectory(state)).rejects.toThrow( + "Configured Codex Security state directory must be private", + ); + expect((await stat(state)).mode & 0o7777).toBe(0o755); + } + }, + ); + + testPosix( + "revalidates colliding state components without changing or redirecting them", + async () => { + if ( + runMockInSubprocess( + import.meta.path, + "revalidates colliding state components without changing or redirecting them", + ) + ) { + return; + } + const root = await temporaryDirectory(); + const parent = join(root, "parent"); + const state = join(parent, "state"); + const nonprivateState = join(root, "nonprivate-state"); + const alias = join(root, "alias"); + const destination = join(root, "destination"); + await mkdir(destination, { mode: 0o700 }); + const pending = new Set([parent, nonprivateState, alias]); + const originalMkdir = fsPromises.mkdir; + const originalChmod = fsPromises.chmod; + const changed: string[] = []; + mock.module("node:fs/promises", () => ({ + ...fsPromises, + mkdir: async (...args: Parameters) => { + const path = String(args[0]); + if (!pending.delete(path)) return await originalMkdir(...args); + if (path === alias) { + await symlink(destination, alias, "dir"); + } else { + await originalMkdir(path, { mode: 0o755 }); + await originalChmod(path, 0o755); + } + throw Object.assign(new Error("Directory already exists."), { + code: "EEXIST", + }); + }, + chmod: async (...args: Parameters) => { + changed.push(String(args[0])); + return await originalChmod(...args); + }, + })); + const previousUmask = process.umask(0o777); + try { + const locations: [string, boolean][] = []; + expect( + await prepareCodexSecurityStateDirectory(state, (path) => { + locations.push([path, existsSync(path)]); + }), + ).toBe(state); + expect(locations[0]).toEqual([state, false]); + expect(locations.at(-1)).toEqual([state, true]); + expect((await stat(parent)).mode & 0o7777).toBe(0o755); + expect((await stat(state)).mode & 0o7777).toBe(0o700); + + await expect( + prepareCodexSecurityStateDirectory(nonprivateState), + ).rejects.toThrow( + "Configured Codex Security state directory must be private", + ); + expect((await stat(nonprivateState)).mode & 0o7777).toBe(0o755); + process.umask(previousUmask); + await expect( + prepareCodexSecurityStateDirectory(join(alias, "state")), + ).rejects.toThrow("state directory changed during preparation"); + expect(existsSync(join(destination, "state"))).toBe(false); + expect(pending.size).toBe(0); + expect(changed).toEqual([state]); + } finally { + process.umask(previousUmask); + mock.module("node:fs/promises", () => ({ + ...fsPromises, + mkdir: originalMkdir, + chmod: originalChmod, + })); + } + }, + ); + + testPosix( + "rejects non-private state before preparing credentials or scan roots", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + await mkdir(state, { mode: 0o700 }); + await chmod(state, 0o755); + + await expect( + prepareCodexSecurityCredentialHome({ CODEX_SECURITY_STATE_DIR: state }), + ).rejects.toBeInstanceOf(OutputDirectoryError); + await expect( + preparePersistentScanRoot(state, "repository"), + ).rejects.toBeInstanceOf(OutputDirectoryError); + expect((await stat(state)).mode & 0o7777).toBe(0o755); + expect(await readdir(state)).toEqual([]); + }, + ); + + testPosix( + "requires existing configured state roots to be private", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + await mkdir(state, { mode: 0o700 }); + await writeFile(join(state, "preserved.txt"), "preserved\n"); + + for (const requestedMode of [0o755, 0o775, 0o1777]) { + await chmod(state, requestedMode); + const mode = (await stat(state)).mode & 0o7777; + await expect( + validateCodexSecurityStateDirectory(state), + ).rejects.toThrow( + `${state} (mode ${mode.toString(8).padStart(4, "0")})`, + ); + await expect( + validateCodexSecurityStateDirectory(state), + ).rejects.toThrow("only if you own it and can safely change it"); + expect((await stat(state)).mode & 0o7777).toBe(mode); + } + expect(() => + requirePrivateOutputDirectory( + { mode: 0o41777, uid: 1000 }, + "state", + 1000, + ), + ).toThrow("must not be accessible to other users"); + expect(await readdir(state)).toEqual(["preserved.txt"]); + }, + ); + + testPosix( + "reports foreign-owned state roots without permission-change advice", + async () => { + if ( + runMockInSubprocess( + import.meta.path, + "reports foreign-owned state roots without permission-change advice", + ) + ) { + return; + } + const root = await temporaryDirectory(); + const state = join(root, "state"); + await mkdir(state, { mode: 0o700 }); + const originalLstat = fsPromises.lstat; + const originalMetadata = await originalLstat(state); + const foreignUid = originalMetadata.uid === 0 ? 1 : 0; + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: async (...args: Parameters) => { + const metadata = await originalLstat(...args); + if (String(args[0]) === state) { + Object.defineProperty(metadata, "uid", { value: foreignUid }); + } + return metadata; + }, + })); + + try { + const failure = await validateCodexSecurityStateDirectory(state).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(OutputDirectoryError); + if (!(failure instanceof OutputDirectoryError)) { + throw new Error("foreign-owned state must be rejected"); + } + expect(failure.message).toContain("must be owned by the current user"); + expect(failure.message).toContain(`${state} (mode 0700)`); + expect(failure.message).toContain("CODEX_SECURITY_STATE_DIR"); + expect(failure.message).not.toContain("chmod"); + expect(failure.message).not.toContain("permissions to 0700"); + expect(failure.cause).toBeInstanceOf(OutputDirectoryError); + expect(failure.cause).toMatchObject({ + message: expect.stringContaining("must be owned by the current user"), + }); + } finally { + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: originalLstat, + })); + } + expect((await originalLstat(state)).uid).toBe(originalMetadata.uid); + expect((await originalLstat(state)).mode & 0o7777).toBe(0o700); + expect(await readdir(state)).toEqual([]); + }, + ); + + testPosix("rejects unsafe lexical and chained state aliases", async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const shared = join(root, "shared"); + const trusted = join(root, "trusted"); + const unsafeLink = join(shared, "state-link"); + const nextLink = join(trusted, "next-link"); + const alias = join(root, "state-alias"); + const missing = join(alias, "missing", "state"); + await mkdir(state, { mode: 0o700 }); + await mkdir(shared, { mode: 0o700 }); + await mkdir(trusted, { mode: 0o700 }); + await chmod(shared, 0o775); + await symlink(state, unsafeLink, "dir"); + await symlink(unsafeLink, nextLink, "dir"); + await symlink(`${nextLink}${sep}`, alias, "dir"); + + for (const path of [unsafeLink, nextLink, alias, missing]) { + await expect(validateCodexSecurityStateDirectory(path)).rejects.toThrow( + `${shared} (mode 0775)`, + ); + await expect(prepareCodexSecurityStateDirectory(path)).rejects.toThrow( + `${shared} (mode 0775)`, + ); + } + expect((await stat(shared)).mode & 0o7777).toBe(0o775); + expect((await stat(state)).mode & 0o7777).toBe(0o700); + expect(await readdir(state)).toEqual([]); + expect(await readdir(shared)).toEqual(["state-link"]); + expect(await readdir(trusted)).toEqual(["next-link"]); + expect(existsSync(missing)).toBe(false); + }); + test("rejects symbolic children beneath persistent scan state", async () => { const root = await temporaryDirectory(); const external = join(root, "external"); @@ -3593,7 +3902,7 @@ describe("runtime directories and plugin Python boundary", () => { ] as const) { const state = join(root, `state-${name}`); const linked = join(state, path); - await mkdir(dirname(linked), { recursive: true }); + await mkdir(dirname(linked), { recursive: true, mode: 0o700 }); await symlink( external, linked, @@ -3797,6 +4106,7 @@ describe("runtime directories and plugin Python boundary", () => { pluginRoot, environment: { PATH: process.env["PATH"], + CODEX_SECURITY_STATE_DIR: join(root, "state"), OPENAI_API_KEY: "must-not-reach-python", CODEX_API_KEY: "also-must-not-reach-python", OPENROUTER_API_KEY: "openrouter-must-not-reach-python", @@ -3809,13 +4119,78 @@ describe("runtime directories and plugin Python boundary", () => { expect(result["details"]).toHaveLength(5 * 1024 * 1024); }); + test("prepares canonical state only for database-backed workbench commands", async () => { + const root = await temporaryDirectory(); + const pluginRoot = join(root, "plugin"); + const state = join(root, "state"); + const alias = join(root, "state-alias"); + await mkdir(join(pluginRoot, "scripts"), { recursive: true }); + await writeFile( + join(pluginRoot, "scripts", "workbench_db.py"), + [ + "import json, os, sys", + "print(json.dumps({'command': sys.argv[1], 'state': os.environ.get('CODEX_SECURITY_STATE_DIR'), 'stateKeys': sorted(name for name in os.environ if name.upper() == 'CODEX_SECURITY_STATE_DIR')}))", + ].join("\n"), + ); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const options = { + python: python!, + pluginRoot, + environment: { CODEX_SECURITY_STATE_DIR: state }, + }; + + for (const command of ["inspect-target", "inspect-setup"]) { + expect(await runWorkbench(options, [command])).toMatchObject({ + command, + state, + }); + expect(existsSync(state)).toBe(false); + } + expect(await runWorkbench(options, ["list-scans"])).toMatchObject({ + state, + stateKeys: ["CODEX_SECURITY_STATE_DIR"], + }); + expect(await readdir(state)).toEqual([]); + await symlink( + state, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + const aliased = { + ...options, + environment: { + CODEX_SECURITY_STATE_DIR: alias, + codex_security_state_dir: join(root, "unused"), + }, + }; + expect(await runWorkbench(aliased, ["list-scans"])).toMatchObject({ + state, + stateKeys: ["CODEX_SECURITY_STATE_DIR"], + }); + expect(aliased.environment.CODEX_SECURITY_STATE_DIR).toBe(alias); + expect(existsSync(join(root, "unused"))).toBe(false); + if (process.platform !== "win32") { + expect((await stat(state)).mode & 0o7777).toBe(0o700); + await chmod(state, 0o755); + await expect( + runWorkbench(options, ["list-scans"]), + ).rejects.toBeInstanceOf(OutputDirectoryError); + expect(await runWorkbench(options, ["inspect-target"])).toMatchObject({ + state, + }); + expect((await stat(state)).mode & 0o7777).toBe(0o755); + expect(await readdir(state)).toEqual([]); + } + }); + test("upgrades colliding legacy execution-profile and public CLI migrations", async () => { const root = await temporaryDirectory("codex-security-legacy-migrations-"); const repository = join(root, "repository"); const stateDirectory = join(root, "state"); const scanDirectory = join(root, "scan"); await mkdir(repository); - await mkdir(stateDirectory); + await mkdir(stateDirectory, { mode: 0o700 }); await mkdir(scanDirectory, { mode: 0o700 }); const python = Bun.which("python3") ?? Bun.which("python"); @@ -3977,7 +4352,7 @@ describe("runtime directories and plugin Python boundary", () => { "codex-security-migration-history-", ); const stateDirectory = join(root, "state"); - await mkdir(stateDirectory); + await mkdir(stateDirectory, { mode: 0o700 }); const database = join(stateDirectory, "workbench.sqlite3"); const python = Bun.which("python3") ?? Bun.which("python"); expect(python).not.toBeNull(); @@ -4097,7 +4472,7 @@ describe("runtime directories and plugin Python boundary", () => { const stateDirectory = join(root, "state"); const scanDirectory = join(root, "scan"); await mkdir(repository); - await mkdir(stateDirectory); + await mkdir(stateDirectory, { mode: 0o700 }); await mkdir(scanDirectory, { mode: 0o700 }); const python = Bun.which("python3") ?? Bun.which("python"); @@ -4492,6 +4867,34 @@ describe("runtime directories and plugin Python boundary", () => { }, ); + testPosix( + "explains unsafe ancestry above a private output directory without changing it", + async () => { + const root = await temporaryDirectory(); + const shared = join(root, "shared"); + const privateChild = join(shared, "private"); + const output = join(privateChild, "results"); + await mkdir(privateChild, { recursive: true, mode: 0o700 }); + await chmod(shared, 0o775); + + await expect(validateOutputDir(privateChild)).rejects.toThrow( + `${shared} (mode 0775)`, + ); + await expect(validateOutputDir(output)).rejects.toThrow( + "A private child directory does not make an unsafe ancestor safe", + ); + await expect(prepareOutputDir(output, "repository")).rejects.toThrow( + "Choose a location with secure parent directories", + ); + await expect(requireSecureOutputAncestry(output)).rejects.toThrow( + "only if you own it and can safely change it", + ); + expect((await lstat(shared)).mode & 0o7777).toBe(0o775); + expect((await lstat(privateChild)).mode & 0o7777).toBe(0o700); + expect(existsSync(output)).toBe(false); + }, + ); + testPosix( "accepts scan output under a sticky shared parent directory", async () => { diff --git a/sdk/typescript/tests-ts/workbench-state.test.ts b/sdk/typescript/tests-ts/workbench-state.test.ts new file mode 100644 index 00000000..f9ef11ef --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-state.test.ts @@ -0,0 +1,309 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + realpath, + rm, + stat, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const temporaryDirectories: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function temporaryDirectory(): Promise { + const path = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-workbench-state-")), + ); + temporaryDirectories.push(path); + return path; +} + +function runPython(stateDirectory: string, args: string[]) { + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + return spawnSync(python, ["-I", "-B", ...args], { + encoding: "utf8", + timeout: 30_000, + env: { + PATH: process.env["PATH"], + SystemRoot: process.env["SystemRoot"], + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + }); +} + +test("direct workbench initialization creates and pins private state", async () => { + const root = await temporaryDirectory(); + const actual = join(root, "actual"); + const alias = join(root, "alias"); + await mkdir(actual, { mode: 0o700 }); + await symlink( + actual, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + for (const mask of [0o002, 0o700]) { + const nested = `nested-${mask.toString(8)}`; + const state = `${alias}${sep}.${sep}${nested}${sep}state`; + const canonical = join(actual, nested, "state"); + const result = runPython(state, [ + "-c", + [ + "import json, os, sys", + "os.umask(int(sys.argv[2], 8))", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as workbench", + "workbench.connect().close()", + 'print(json.dumps({"state": str(workbench.state_dir()), "configured": os.environ["CODEX_SECURITY_STATE_DIR"]}))', + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + mask.toString(8), + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const paths = JSON.parse(result.stdout) as { + state: string; + configured: string; + }; + expect(paths.configured).toBe(paths.state); + expect(await realpath(paths.state)).toBe(await realpath(canonical)); + expect(existsSync(join(canonical, "workbench.sqlite3"))).toBe(true); + if (process.platform !== "win32") { + expect((await stat(join(actual, nested))).mode & 0o777).toBe(0o700); + expect((await stat(canonical)).mode & 0o777).toBe(0o700); + expect( + (await stat(join(canonical, "workbench.sqlite3"))).mode & 0o777, + ).toBe(0o600); + } + } +}); + +test("direct workbench validates a competing private initializer", async () => { + const root = await temporaryDirectory(); + const component = join(root, "nested"); + const state = join(component, "state"); + const result = runPython(state, [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "from unittest.mock import patch", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as workbench", + "component = Path(sys.argv[2])", + "original_mkdir = Path.mkdir", + "def competing_mkdir(path, *args, **kwargs):", + " if path == component and not path.exists():", + " original_mkdir(path, *args, **kwargs)", + " raise FileExistsError(str(path))", + " return original_mkdir(path, *args, **kwargs)", + 'with patch.object(Path, "mkdir", competing_mkdir), patch.object(workbench, "require_canonical_scan_directory", wraps=workbench.require_canonical_scan_directory) as validate:', + " workbench.connect().close()", + " print(json.dumps([str(call.args[0]) for call in validate.call_args_list]))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + component, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toContain(component); + expect(existsSync(join(state, "workbench.sqlite3"))).toBe(true); +}); + +testPosix( + "direct workbench rejects unsafe state before opening its database", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const shared = join(root, "shared"); + const nestedState = join(shared, "state"); + const missingState = join(shared, "missing", "state"); + await mkdir(state, { mode: 0o700 }); + await mkdir(nestedState, { recursive: true, mode: 0o700 }); + await chmod(shared, 0o775); + const command = [ + join(PLUGIN_ROOT, "scripts", "workbench_db.py"), + "list-scans", + "--repository", + root, + ]; + + for (const mode of [0o755, 0o1777]) { + await chmod(state, mode); + const actualMode = (await stat(state)).mode & 0o7777; + const result = runPython(state, command); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("state directory"); + expect((await stat(state)).mode & 0o7777).toBe(actualMode); + expect(existsSync(join(state, "workbench.sqlite3"))).toBe(false); + } + + const nested = runPython(nestedState, command); + expect(nested.status).not.toBe(0); + expect(nested.stderr).toContain("group- or world-writable"); + expect((await stat(shared)).mode & 0o7777).toBe(0o775); + expect((await stat(nestedState)).mode & 0o7777).toBe(0o700); + expect(existsSync(join(nestedState, "workbench.sqlite3"))).toBe(false); + const missing = runPython(missingState, command); + expect(missing.status).not.toBe(0); + expect(missing.stderr).toContain("group- or world-writable"); + expect(existsSync(join(shared, "missing"))).toBe(false); + }, +); + +testPosix( + "direct workbench rejects unsafe lexical and chained state aliases", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const shared = join(root, "shared"); + const trusted = join(root, "trusted"); + const unsafeLink = join(shared, "state-link"); + const nextLink = join(trusted, "next-link"); + const alias = join(root, "alias"); + const dottedAlias = join(root, "dotted-alias"); + const dottedState = `${shared}${sep}..${sep}state`; + const missing = join(alias, "missing", "state"); + await mkdir(state, { mode: 0o700 }); + await mkdir(shared, { mode: 0o700 }); + await mkdir(trusted, { mode: 0o700 }); + await chmod(shared, 0o775); + await symlink(state, unsafeLink, "dir"); + await symlink(unsafeLink, nextLink, "dir"); + await symlink(`${nextLink}${sep}`, alias, "dir"); + await symlink(dottedState, dottedAlias, "dir"); + const command = [ + join(PLUGIN_ROOT, "scripts", "workbench_db.py"), + "list-scans", + "--repository", + root, + ]; + + for (const path of [ + unsafeLink, + nextLink, + alias, + dottedAlias, + dottedState, + missing, + ]) { + const result = runPython(path, command); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("state directory is unsafe"); + expect(result.stderr).toContain("group- or world-writable"); + } + expect((await stat(shared)).mode & 0o7777).toBe(0o775); + expect((await stat(state)).mode & 0o7777).toBe(0o700); + expect(await readdir(state)).toEqual([]); + expect(await readdir(shared)).toEqual(["state-link"]); + expect(await readdir(trusted)).toEqual(["next-link"]); + expect(existsSync(missing)).toBe(false); + }, +); + +testPosix( + "direct workbench rejects untrusted state-link ownership before creation", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const alias = join(root, "alias"); + await mkdir(state, { mode: 0o700 }); + await symlink(state, alias, "dir"); + const result = runPython(alias, [ + "-c", + [ + "import os, sys", + "from pathlib import Path", + "from unittest.mock import patch", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as workbench", + "original_lstat = os.lstat", + "def synthetic_lstat(path, *args, **kwargs):", + " metadata = original_lstat(path, *args, **kwargs)", + " if os.fspath(path) == sys.argv[2]:", + " values = list(metadata)", + " values[4] = os.geteuid() + 1", + " return os.stat_result(values)", + " return metadata", + "with (", + ' patch.object(os, "lstat", synthetic_lstat),', + ' patch.object(Path, "mkdir", side_effect=AssertionError("unexpected creation")),', + ' patch.object(workbench.sqlite3, "connect", side_effect=AssertionError("unexpected database access")),', + "):", + " try:", + " workbench.connect()", + " except SystemExit as error:", + " print(error)", + " else:", + ' raise AssertionError("untrusted state link was accepted")', + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + alias, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("state directory is unsafe"); + expect(result.stdout).toContain("trusted owner"); + expect((await stat(state)).mode & 0o7777).toBe(0o700); + expect(await readdir(state)).toEqual([]); + }, +); + +test("direct workbench preserves unresolved home expansion failures", () => { + const result = runPython("", [ + "-c", + [ + "import json, os, sys", + "from pathlib import Path", + "from unittest.mock import patch", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as workbench", + "errors = []", + "with (", + ' patch.object(os.path, "expanduser", side_effect=lambda path: path),', + ' patch.object(Path, "mkdir", side_effect=AssertionError("unexpected creation")),', + ' patch.object(workbench.sqlite3, "connect", side_effect=AssertionError("unexpected database access")),', + "):", + " for environment in (", + ' {"CODEX_SECURITY_STATE_DIR": "~unresolved/state"},', + ' {"CODEX_SECURITY_STATE_DIR": "", "CODEX_HOME": "~/home"},', + " ):", + " with patch.dict(os.environ, environment, clear=True):", + " for select in (workbench.state_dir, workbench.connect):", + " try:", + " select()", + " except RuntimeError as error:", + " errors.append(str(error))", + " else:", + ' raise AssertionError("unresolved home was accepted")', + "print(json.dumps(errors))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual( + Array(4).fill("Could not determine home directory."), + ); +});