diff --git a/.github/workflows/native-unix.yml b/.github/workflows/native-unix.yml new file mode 100644 index 000000000..f75495b0e --- /dev/null +++ b/.github/workflows/native-unix.yml @@ -0,0 +1,127 @@ +name: native-unix + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + primitives: + name: primitives / ${{ matrix.platform }}-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux + arch: x64 + image: quay.io/pypa/manylinux_2_28_x86_64@sha256:0536c364004fa2a3c5041120b6fe35d84fc5bfe31f04c6a6304f13eac4a67b63 + - runner: ubuntu-24.04-arm + platform: linux + arch: arm64 + image: quay.io/pypa/manylinux_2_28_aarch64@sha256:1676a4f178dc6cf8a1d1e3b7e1d71fca891a466ebde72a33880ccda8af3383d8 + - runner: macos-15-intel + platform: darwin + arch: x64 + - runner: macos-15 + platform: darwin + arch: arm64 + defaults: + run: + shell: bash + working-directory: plugins/codex-security/native + env: + NATIVE_IMAGE: ${{ matrix.image }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml + - name: Set up Node.js 22 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" + - name: Compile TypeScript tools + working-directory: . + run: | + pnpm --dir sdk/typescript install --frozen-lockfile + pnpm --dir sdk/typescript run build:ci + - name: Set up Rust + run: rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy + - name: Check Rust source and fetch locked dependencies + run: | + cargo fmt --check + cargo clippy --locked -- -D warnings + cargo fetch --locked + - name: Build and verify Linux on glibc 2.28 with Node.js 22 + if: matrix.platform == 'linux' + run: | + mkdir -p "$RUNNER_TEMP/native-cargo" "$RUNNER_TEMP/native-no-python" + printf '#!/bin/sh\nexit 99\n' > "$RUNNER_TEMP/native-no-python/python" + cp "$RUNNER_TEMP/native-no-python/python" "$RUNNER_TEMP/native-no-python/python3" + chmod +x "$RUNNER_TEMP/native-no-python/"* + docker run --rm \ + --volume "$PWD:/source" \ + --volume "$RUNNER_TEMP/native-cargo:/cargo" \ + --volume "${CARGO_HOME:-$HOME/.cargo}/registry:/cargo/registry:ro" \ + --volume "$(rustc --print sysroot):/toolchain:ro" \ + --volume "$(dirname "$(dirname "$(command -v node)")"):/node:ro" \ + --volume "$RUNNER_TEMP/native-no-python:/no-python:ro" \ + --env CARGO_HOME=/cargo \ + --env CARGO_NET_OFFLINE=true \ + --env PATH=/no-python:/toolchain/bin:/opt/rh/gcc-toolset-14/root/usr/bin:/usr/local/bin:/usr/bin:/bin \ + --workdir /source "$NATIVE_IMAGE" /bin/bash -euc ' + /node/bin/node build.mjs + /node/bin/node check.mjs + PATH= /node/bin/node proof.mjs + ' + - name: Build and verify macOS with Node.js 22 + if: matrix.platform == 'darwin' + run: | + node build.mjs + node check.mjs + native_node="$(command -v node)" + PATH= "$native_node" proof.mjs + - name: Set up Node.js 20 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "20.0.0" + - name: Verify the same Linux artifact with Node.js 20 + if: matrix.platform == 'linux' + run: | + docker run --rm \ + --volume "$PWD:/source:ro" \ + --volume "$(dirname "$(dirname "$(command -v node)")"):/node:ro" \ + --workdir /source "$NATIVE_IMAGE" /bin/bash -euc ' + /node/bin/node check.mjs + PATH= /node/bin/node proof.mjs + ' + - name: Verify the same macOS artifact with Node.js 20 + if: matrix.platform == 'darwin' + run: | + node check.mjs + native_node="$(command -v node)" + PATH= "$native_node" proof.mjs + - name: Upload verified native artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.sha }} + path: plugins/codex-security/native/dist/${{ matrix.platform }}-${{ matrix.arch }}/unix.node + if-no-files-found: error + retention-days: 7 diff --git a/plugins/codex-security/native/.gitignore b/plugins/codex-security/native/.gitignore new file mode 100644 index 000000000..5fb9d30c0 --- /dev/null +++ b/plugins/codex-security/native/.gitignore @@ -0,0 +1,3 @@ +/target/ +/dist/ +/*.mjs diff --git a/plugins/codex-security/native/Cargo.lock b/plugins/codex-security/native/Cargo.lock new file mode 100644 index 000000000..346cf3300 --- /dev/null +++ b/plugins/codex-security/native/Cargo.lock @@ -0,0 +1,296 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codex-security-native-unix" +version = "0.0.0" +dependencies = [ + "libc", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "napi" +version = "3.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab" + +[[package]] +name = "napi-derive" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 2.0.119", +] + +[[package]] +name = "napi-sys" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/plugins/codex-security/native/Cargo.toml b/plugins/codex-security/native/Cargo.toml new file mode 100644 index 000000000..dbc6d2334 --- /dev/null +++ b/plugins/codex-security/native/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-security-native-unix" +version = "0.0.0" +edition = "2021" +rust-version = "1.97" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "=3.12.2", default-features = false, features = ["napi8"] } +napi-derive = "=3.6.3" +libc = "=0.2.189" + +[build-dependencies] +napi-build = "=2.4.1" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md new file mode 100644 index 000000000..16f23b0b2 --- /dev/null +++ b/plugins/codex-security/native/README.md @@ -0,0 +1,34 @@ +# Unix native primitives + +This foundation supplies the descriptor operations that Node does not expose. The SDK and CLI continue to use their existing helpers while Windows primitives and universal package assembly are completed. + +The nine Node-API 8 functions are typed in `binding.mts`. Paths remain byte buffers. `statAt` never follows the final symlink; device and inode numbers are decimal strings so JavaScript does not round them. `openAt` and `duplicate` create descriptors with close-on-exec set. Node owns subsequent reads, writes, `fstat`, `fsync`, and close calls. `userHome` looks up raw username bytes through the operating system and returns raw home-directory bytes or a missing result, without Git. + +`openAt` and `fileLock` retry EINTR, matching the current Python helpers. Other operations return their native errno. `readDescriptor` retries one interrupted Node read without losing earlier chunks. Blocking locks must run outside the main JavaScript event loop; a process that holds a lock releases it on close or exit. A Python signal handler can raise during a blocked call, so later routing must preserve cancellation through the worker lifecycle. + +Install the pinned Rust toolchain and the existing TypeScript dependencies, then run from the repository root: + +```sh +pnpm --dir sdk/typescript install --frozen-lockfile +pnpm --dir sdk/typescript run build:ci +node plugins/codex-security/native/build.mjs +node plugins/codex-security/native/proof.mjs +cargo +1.97.1 fmt --check --manifest-path plugins/codex-security/native/Cargo.toml +cargo +1.97.1 clippy --locked --manifest-path plugins/codex-security/native/Cargo.toml -- -D warnings +``` + +The proof runs without Python. It checks directory replacement, byte paths, unreadable-file metadata, long raw symlinks, descriptor duplication, Node descriptor I/O, account lookup, contention, unlock, and process-death release. Linux exercises undecodable filename bytes; macOS uses valid UTF-8 filenames required by APFS. CI invokes it with an empty `PATH`. During migration, the same protocol can compare the existing Python lock helper: + +```sh +node plugins/codex-security/native/proof.mjs python3 plugins/codex-security/scripts +``` + +Build outputs stay under ignored `target` and `dist` directories. Source, Cargo registry, and compiler paths are remapped before compilation; actual payload bytes are checked for private paths. Before an artifact is uploaded, run: + +```sh +node plugins/codex-security/native/check.mjs +``` + +Linux artifacts must import no glibc version newer than 2.28. macOS artifacts must declare a deployment target of 11.0 or earlier. A build from a newer Linux workstation can pass the behavioral proof and still fail this distribution check. + +The `native-unix` workflow builds Linux artifacts in digest-pinned manylinux 2.28 images. It mounts the pinned Rust toolchain and fetched Cargo registry, builds offline, and blocks Python commands during compilation. macOS builds set `MACOSX_DEPLOYMENT_TARGET=11.0`. CI verifies separate x64 and arm64 artifacts on both platforms using Node 20.0.0 and 22.13.0. These artifacts are inputs to the later universal-package gate. diff --git a/plugins/codex-security/native/binding.mts b/plugins/codex-security/native/binding.mts new file mode 100644 index 000000000..eab03d364 --- /dev/null +++ b/plugins/codex-security/native/binding.mts @@ -0,0 +1,68 @@ +import { createRequire } from "node:module"; +import { readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const root = dirname(fileURLToPath(import.meta.url)); +export const output = join(root, "dist", `${process.platform}-${process.arch}`); +export const binaryPath = join(output, "unix.node"); + +export interface SyscallResult { + value: number; + errno: number; +} + +export interface MetadataResult { + errno: number; + mode: number; + device: string; + inode: string; +} + +/** Paths are uninterpreted POSIX bytes. Only openAt and fileLock retry EINTR. */ +export interface UnixBinding { + openAt( + directory: number, + name: Buffer, + flags: number, + mode: number, + ): SyscallResult; + duplicate(descriptor: number): SyscallResult; + makeDirectoryAt(directory: number, name: Buffer, mode: number): SyscallResult; + renameAt( + oldDirectory: number, + oldName: Buffer, + newDirectory: number, + newName: Buffer, + ): SyscallResult; + unlinkAt(directory: number, name: Buffer): SyscallResult; + statAt(directory: number, name: Buffer): MetadataResult; + readLinkAt(directory: number, name: Buffer): { errno: number; value: Buffer }; + fileLock( + descriptor: number, + unlock: boolean, + nonblocking: boolean, + ): SyscallResult; + userHome(username: Buffer): { errno: number; value: Buffer | null }; +} + +export function loadBinding(): UnixBinding { + return createRequire(import.meta.url)(binaryPath) as UnixBinding; +} + +/** Retry the interrupted read, preserving the caller's previously read bytes. */ +export function readDescriptor( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: number | null, +): number { + while (true) { + try { + return readSync(fd, buffer, offset, length, position); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EINTR") throw error; + } + } +} diff --git a/plugins/codex-security/native/build.mts b/plugins/codex-security/native/build.mts new file mode 100644 index 000000000..50e675640 --- /dev/null +++ b/plugins/codex-security/native/build.mts @@ -0,0 +1,47 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { binaryPath, output, root } from "./binding.mjs"; +import { checkPrivatePaths } from "./check.mjs"; + +const extension = process.platform === "darwin" ? "dylib" : "so"; +const cargoHome = resolve( + root, + process.env["CARGO_HOME"] ?? join(homedir(), ".cargo"), +); +const sysroot = execFileSync("rustc", ["--print", "sysroot"], { + cwd: root, + encoding: "utf8", +}).trim(); +const inheritedFlags = + process.env["CARGO_ENCODED_RUSTFLAGS"]?.split("\u001f") ?? + process.env["RUSTFLAGS"]?.split(/\s+/u).filter(Boolean) ?? + []; +const flags = [ + ...inheritedFlags, + `--remap-path-prefix=${root}=codex-security-native`, + `--remap-path-prefix=${cargoHome}=cargo`, + `--remap-path-prefix=${sysroot}=rust-toolchain`, +]; +const target = resolve(root, process.env["CARGO_TARGET_DIR"] ?? "target"); +execFileSync("cargo", ["build", "--release", "--locked"], { + cwd: root, + stdio: "inherit", + env: { + ...process.env, + CARGO_ENCODED_RUSTFLAGS: flags.join("\u001f"), + ...(process.platform === "darwin" + ? { MACOSX_DEPLOYMENT_TARGET: "11.0" } + : {}), + }, +}); +const library = join( + target, + "release", + `libcodex_security_native_unix.${extension}`, +); +checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot]); +mkdirSync(output, { recursive: true }); +copyFileSync(library, binaryPath); +console.log(`Built ${process.platform}-${process.arch} Node-API 8 primitives.`); diff --git a/plugins/codex-security/native/build.rs b/plugins/codex-security/native/build.rs new file mode 100644 index 000000000..02a624e82 --- /dev/null +++ b/plugins/codex-security/native/build.rs @@ -0,0 +1,7 @@ +fn main() { + napi_build::setup(); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + // The linker otherwise embeds its absolute output path as the install name. + println!("cargo:rustc-link-arg-cdylib=-Wl,-install_name,@rpath/unix.node"); + } +} diff --git a/plugins/codex-security/native/check.mts b/plugins/codex-security/native/check.mts new file mode 100644 index 000000000..41362fe85 --- /dev/null +++ b/plugins/codex-security/native/check.mts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { binaryPath } from "./binding.mjs"; + +export function checkPrivatePaths( + bytes: Buffer, + buildPaths: string[] = [], +): void { + for (const marker of [ + "/Users/", + "/home/dev-user", + "/tmp/codex-security-python-", + ...buildPaths, + ]) { + if (bytes.includes(Buffer.from(marker))) { + throw new Error("Native payload contains a private build path."); + } + } +} + +function versionAfter(value: string, floor: string): boolean { + const actual = value.split(".").map(Number); + const maximum = floor.split(".").map(Number); + for ( + let index = 0; + index < Math.max(actual.length, maximum.length); + index++ + ) { + const difference = (actual[index] ?? 0) - (maximum[index] ?? 0); + if (difference !== 0) return difference > 0; + } + return false; +} + +if ( + process.argv[1] !== undefined && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + const bytes = readFileSync(binaryPath); + checkPrivatePaths(bytes); + let floor: string; + if (process.platform === "linux") { + const versions = execFileSync("readelf", ["--version-info", binaryPath], { + encoding: "utf8", + }); + const required = [...versions.matchAll(/\bGLIBC_(\d+(?:\.\d+)*)/gu)].map( + (match) => match[1]!, + ); + if ( + required.length === 0 || + required.some((version) => versionAfter(version, "2.28")) + ) { + throw new Error( + "Native payload requires glibc newer than 2.28, or has no inspectable glibc requirements.", + ); + } + floor = "glibc 2.28"; + } else if (process.platform === "darwin") { + const commands = execFileSync("otool", ["-l", binaryPath], { + encoding: "utf8", + }); + const minimum = + /cmd LC_BUILD_VERSION\s+[\s\S]*?\bminos ([\d.]+)/u.exec(commands)?.[1] ?? + /cmd LC_VERSION_MIN_MACOSX\s+[\s\S]*?\bversion ([\d.]+)/u.exec( + commands, + )?.[1]; + if (minimum === undefined || versionAfter(minimum, "11.0")) { + throw new Error( + "Native payload does not declare a compatible macOS 11.0 deployment target.", + ); + } + floor = "macOS 11.0"; + } else { + throw new Error("This foundation verifies Linux and macOS artifacts only."); + } + console.log( + JSON.stringify({ + platform: process.platform, + arch: process.arch, + nodeApi: 8, + floor, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }), + ); +} diff --git a/plugins/codex-security/native/proof.mts b/plugins/codex-security/native/proof.mts new file mode 100644 index 000000000..981e9129c --- /dev/null +++ b/plugins/codex-security/native/proof.mts @@ -0,0 +1,514 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { + closeSync, + constants, + existsSync, + fstatSync, + fsyncSync, + mkdirSync, + mkdtempSync, + openSync, + lstatSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import { randomUUID } from "node:crypto"; +import { basename, join } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { loadBinding, readDescriptor } from "./binding.mjs"; +import { constants as osConstants } from "node:os"; + +const native = loadBinding(); +const errno = osConstants.errno; +class NativeError extends Error { + constructor(readonly errno: number) { + super(`Native operation failed: errno ${errno}`); + } +} +function checked(result: T): T { + if (result.errno !== 0) throw new NativeError(result.errno); + return result; +} +const bytes = (path: string | Buffer) => + typeof path === "string" ? Buffer.from(path) : path; +const openAt = (fd: number, path: string | Buffer, flags: number, mode = 0) => + checked(native.openAt(fd, bytes(path), flags, mode)).value; +const mkdirAt = (fd: number, path: string | Buffer, mode: number) => { + checked(native.makeDirectoryAt(fd, bytes(path), mode)); +}; +const renameAt = ( + oldFd: number, + oldPath: string | Buffer, + newFd: number, + newPath: string | Buffer, +) => { + checked(native.renameAt(oldFd, bytes(oldPath), newFd, bytes(newPath))); +}; +const unlinkAt = (fd: number, path: string | Buffer) => { + checked(native.unlinkAt(fd, bytes(path))); +}; +const duplicate = (fd: number) => checked(native.duplicate(fd)).value; +const fileLock = (fd: number, unlock = false, nonblocking = false) => { + checked(native.fileLock(fd, unlock, nonblocking)); +}; +const statAt = (fd: number, path: string | Buffer) => + checked(native.statAt(fd, bytes(path))); +const readLinkAt = (fd: number, path: string | Buffer) => + checked(native.readLinkAt(fd, bytes(path))).value; +const rawPath = (parent: string, name: Buffer) => + Buffer.concat([Buffer.from(parent + "/"), name]); +// APFS requires valid UTF-8 names; Linux also exercises undecodable bytes. +const fixtureName = (prefix: string, byte: number) => + process.platform === "darwin" + ? Buffer.from(`${prefix}-é`) + : Buffer.from([prefix.charCodeAt(0), byte]); + +function accountProof() { + let currentHomeMatches: boolean | null = null; + try { + const current = userInfo({ encoding: "buffer" }); + assert.deepEqual( + checked(native.userHome(current.username)).value, + current.homedir, + ); + currentHomeMatches = true; + } catch (error) { + // A container can run a numeric UID with no account database entry. + const system = error as { code?: string; info?: { code?: string } }; + assert.equal(system.code, "ERR_SYSTEM_ERROR"); + assert.equal(system.info?.code, "ENOENT"); + } + const other = checked(native.userHome(Buffer.from("root"))).value; + assert(other !== null && other[0] === 0x2f); + assert.equal( + checked(native.userHome(Buffer.from(`codex-${randomUUID().slice(0, 8)}`))) + .value, + null, + ); + assert.throws(() => native.userHome(Buffer.from([0]))); + return { + currentHomeMatches, + namedHomeWithoutGit: true, + missingAccount: true, + }; +} + +const directoryFlags = + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; +const ownFileFlags = + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW; +const readFlags = + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const self = fileURLToPath(import.meta.url); + +function expectedError(action: () => void, ...codes: number[]): number { + try { + action(); + } catch (error) { + assert(error instanceof NativeError); + assert(codes.includes(error.errno), error.message); + return error.errno; + } + throw new Error("Expected native operation to fail"); +} + +function descriptorProof(root: string) { + const scan = join(root, "scan"); + const movedScan = join(root, "held-scan"); + const outside = join(root, "outside"); + mkdirSync(scan); + mkdirSync(outside); + writeFileSync(join(outside, "result.json"), "outside sentinel"); + const held = new Set(); + const keep = (fd: number) => { + held.add(fd); + return fd; + }; + const close = (fd: number) => { + closeSync(fd); + held.delete(fd); + }; + try { + const rootFd = keep(openSync(scan, directoryFlags)); + const identity = fstatSync(rootFd, { bigint: true }); + mkdirAt(rootFd, "artifacts", 0o700); + const originalParent = keep(openAt(rootFd, "artifacts", directoryFlags)); + const parentFd = keep(duplicate(originalParent)); + close(originalParent); + const unreadable = fixtureName("u", 0xff); + const rawLink = fixtureName("l", 0xfe); + const rawTarget = Buffer.concat([ + Buffer.from("component/".repeat(80)), + Buffer.from([0xff]), + ]); + writeFileSync(rawPath(join(scan, "artifacts"), unreadable), "unreadable", { + mode: 0, + }); + symlinkSync(rawTarget, rawPath(join(scan, "artifacts"), rawLink)); + const unreadableMetadata = statAt(parentFd, unreadable); + const unreadableExpected = lstatSync( + rawPath(join(scan, "artifacts"), unreadable), + { bigint: true }, + ); + assert.equal(unreadableMetadata.mode, Number(unreadableExpected.mode)); + assert.equal(unreadableMetadata.device, unreadableExpected.dev.toString()); + assert.equal(unreadableMetadata.inode, unreadableExpected.ino.toString()); + assert.equal(unreadableMetadata.mode & 0o777, 0); + if (process.geteuid?.() !== 0) + expectedError( + () => openAt(parentFd, unreadable, readFlags), + errno.EACCES!, + ); + const linkMetadata = statAt(parentFd, rawLink); + assert.equal(linkMetadata.mode & constants.S_IFMT, constants.S_IFLNK); + assert.deepEqual(readLinkAt(parentFd, rawLink), rawTarget); + + // Replace both visible ancestor paths while keeping their validated FDs. + renameSync(scan, movedScan); + symlinkSync(outside, scan, "dir"); + renameSync(join(movedScan, "artifacts"), join(movedScan, "held-artifacts")); + symlinkSync(outside, join(movedScan, "artifacts"), "dir"); + assert.equal(fstatSync(rootFd, { bigint: true }).ino, identity.ino); + assert.equal(statSync(movedScan, { bigint: true }).dev, identity.dev); + assert.deepEqual(statAt(parentFd, unreadable), unreadableMetadata); + assert.deepEqual(statAt(parentFd, rawLink), linkMetadata); + assert.deepEqual(readLinkAt(parentFd, rawLink), rawTarget); + assert.equal( + statAt(rootFd, "artifacts").mode & constants.S_IFMT, + constants.S_IFLNK, + ); + assert.equal(readLinkAt(rootFd, "artifacts").toString(), outside); + mkdirAt(parentFd, "nested", 0o700); + + const rawDirectory = fixtureName("d", 0xfd); + const rawSource = fixtureName("s", 0xfc); + const rawDestination = fixtureName("t", 0xfb); + mkdirAt(parentFd, rawDirectory, 0o700); + assert.equal(statAt(parentFd, rawDirectory).mode & 0o777, 0o700); + const rawDirectoryFd = keep(openAt(parentFd, rawDirectory, directoryFlags)); + const rawFd = keep(openAt(parentFd, rawSource, ownFileFlags, 0o600)); + writeFileSync(rawFd, "raw path contents"); + close(rawFd); + renameAt(parentFd, rawSource, rawDirectoryFd, rawDestination); + const rawRead = keep(openAt(rawDirectoryFd, rawDestination, readFlags)); + assert.equal(readFileSync(rawRead, "utf8"), "raw path contents"); + close(rawRead); + unlinkAt(rawDirectoryFd, rawDestination); + close(rawDirectoryFd); + rmSync(rawPath(join(movedScan, "held-artifacts"), rawDirectory), { + recursive: true, + }); + expectedError(() => statAt(parentFd, rawDirectory), errno.ENOENT!); + + const fd = keep(openAt(parentFd, ".result.tmp", ownFileFlags, 0o600)); + const payload = Buffer.from('{"proof":"anchored 🔐"}\n'); + writeFileSync(fd, payload); + fsyncSync(fd); + assert(fstatSync(fd).isFile()); + assert.equal(fstatSync(fd).mode & 0o777, 0o600); + assert.equal(fstatSync(fd).size, payload.length); + close(fd); + renameAt(parentFd, ".result.tmp", parentFd, "result.json"); + + const input = keep(openAt(parentFd, "result.json", readFlags)); + const chunks: Buffer[] = []; + while (true) { + const chunk = Buffer.alloc(5); + const count = readDescriptor(input, chunk, 0, chunk.length, null); + if (count === 0) break; + chunks.push(chunk.subarray(0, count)); + } + assert.deepEqual(Buffer.concat(chunks), payload); + close(input); + const anchoredDirectory = join(movedScan, "held-artifacts"); + assert.deepEqual( + readFileSync(join(anchoredDirectory, "result.json")), + payload, + ); + assert.equal( + readFileSync(join(outside, "result.json"), "utf8"), + "outside sentinel", + ); + assert(!existsSync(join(outside, "nested"))); + + symlinkSync(join(outside, "result.json"), join(anchoredDirectory, "link")); + const noFollowErrno = expectedError( + () => openAt(parentFd, "link", readFlags), + errno.ELOOP!, + ); + unlinkAt(parentFd, "link"); + unlinkAt(parentFd, "result.json"); + assert(!existsSync(join(anchoredDirectory, "result.json"))); + assert.equal( + readFileSync(join(outside, "result.json"), "utf8"), + "outside sentinel", + ); + expectedError( + () => openAt(rootFd, "artifacts", directoryFlags), + errno.ELOOP!, + errno.ENOTDIR!, + ); + expectedError(() => mkdirAt(parentFd, "nested", 0o700), errno.EEXIST!); + expectedError( + () => renameAt(parentFd, "missing", parentFd, "other"), + errno.ENOENT!, + ); + expectedError(() => unlinkAt(parentFd, "missing"), errno.ENOENT!); + expectedError(() => statAt(-1, "missing"), errno.EBADF!); + expectedError(() => readLinkAt(-1, "missing"), errno.EBADF!); + const badFdErrno = expectedError( + () => openAt(-1, "missing", readFlags), + errno.EBADF!, + ); + return { + anchoredMkdirWriteRenameReadDelete: true, + outsideSentinelPreserved: true, + nodeFdWriteReadFstatFsyncClose: true, + mode: "0600", + duplicateSurvivesOriginalClose: true, + rawMkdirOpenRenameUnlink: true, + unreadableMetadataMatches: true, + noFollowStatAndLongRawReadlinkSurviveReplacement: true, + rawLinkTargetBytes: rawTarget.length, + noFollowErrno, + badFdErrno, + }; + } finally { + for (const fd of held) closeSync(fd); + } +} + +async function nativeLockWorker(path: string): Promise { + const fd = openSync(path, constants.O_RDWR | constants.O_CREAT, 0o600); + try { + if (process.argv[4] === "try") { + const result = native.fileLock(fd, false, true); + if (result.errno === 0) fileLock(fd, true); + console.log( + JSON.stringify({ acquired: result.errno === 0, errno: result.errno }), + ); + return; + } + const commands = createInterface({ input: process.stdin })[ + Symbol.asyncIterator + ](); + console.log("waiting"); + fileLock(fd); + console.log("acquired"); + await commands.next(); + fileLock(fd, true); + console.log("released"); + } finally { + closeSync(fd); + } +} + +// Temporary interoperability oracle: import the actual current Python functions. +// This protocol scaffold is never a migrated implementation or shipped artifact. +const pythonOracle = String.raw` +import json, os, sys +sys.path.insert(0, sys.argv[1]) +from workbench_db import acquire_completion_file_lock, release_completion_file_lock, posix_file_lock +fd = os.open(sys.argv[2], os.O_RDWR | os.O_CREAT, 0o600) +try: + if sys.argv[3] == "try": + try: + posix_file_lock.flock(fd, posix_file_lock.LOCK_EX | posix_file_lock.LOCK_NB) + except OSError as error: + print(json.dumps({"acquired": False, "errno": error.errno}), flush=True) + else: + release_completion_file_lock(fd) + print(json.dumps({"acquired": True}), flush=True) + else: + print("waiting", flush=True) + acquire_completion_file_lock(fd) + print("acquired", flush=True) + sys.stdin.buffer.readline() + release_completion_file_lock(fd) + print("released", flush=True) +finally: + os.close(fd) +`; + +type Worker = { + child: ChildProcessWithoutNullStreams; + lines: AsyncIterableIterator; + exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + stderr: () => string; +}; +const workers: Worker[] = []; + +function worker(command: string, args: string[]): Worker { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + const result: Worker = { + child, + lines: createInterface({ input: child.stdout })[Symbol.asyncIterator](), + exit: new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`worker exit timeout: ${stderr}`)); + }, 30_000); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal }); + }); + }), + stderr: () => stderr, + }; + workers.push(result); + return result; +} + +async function line(child: Worker, expected?: string): Promise { + let timeout: ReturnType; + const value = await Promise.race([ + child.lines.next(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`worker protocol timeout: ${child.stderr()}`)), + 10_000, + ); + }), + ]).finally(() => clearTimeout(timeout)); + assert(!value.done, `worker ended early: ${child.stderr()}`); + if (expected !== undefined) assert.equal(value.value, expected); + return value.value; +} + +async function release(child: Worker): Promise { + child.child.stdin.end("release\n"); + await line(child, "released"); + const ended = await child.exit; + assert.equal(ended.code, 0, child.stderr()); +} + +async function kill(child: Worker): Promise { + child.child.kill("SIGKILL"); + assert.equal((await child.exit).signal, "SIGKILL"); +} + +async function lockProof(root: string, python?: string, scripts?: string) { + const path = join(root, "completion.lock"); + const fd = openSync(path, constants.O_RDWR | constants.O_CREAT, 0o600); + const peerWorker = (mode = "hold") => + python !== undefined && scripts !== undefined + ? worker(python, ["-c", pythonOracle, scripts, path, mode]) + : worker(process.execPath, [self, "lock-worker", path, mode]); + const nativeWorker = () => + worker(process.execPath, [self, "lock-worker", path]); + try { + fileLock(fd); + const probe = peerWorker("try"); + const contention = JSON.parse(await line(probe)) as { + acquired: boolean; + errno: number; + }; + assert.equal(contention.acquired, false); + assert([errno.EAGAIN, errno.EWOULDBLOCK].includes(contention.errno)); + assert.equal((await probe.exit).code, 0, probe.stderr()); + const waitingPeer = peerWorker(); + await line(waitingPeer, "waiting"); + fileLock(fd, true); + await line(waitingPeer, "acquired"); + const nativeContention = expectedError( + () => fileLock(fd, false, true), + errno.EAGAIN!, + errno.EWOULDBLOCK!, + ); + await release(waitingPeer); + + const heldPeer = peerWorker(); + await line(heldPeer, "waiting"); + await line(heldPeer, "acquired"); + const waitingNative = nativeWorker(); + await line(waitingNative, "waiting"); + await kill(heldPeer); + await line(waitingNative, "acquired"); + await release(waitingNative); + + const heldNative = nativeWorker(); + await line(heldNative, "waiting"); + await line(heldNative, "acquired"); + const peerAfterDeath = peerWorker(); + await line(peerAfterDeath, "waiting"); + await kill(heldNative); + await line(peerAfterDeath, "acquired"); + await release(peerAfterDeath); + fileLock(fd, false, true); + fileLock(fd, true); + return { + peerRuntime: python === undefined ? "node" : "python", + peerContentionErrno: contention.errno, + nativeContentionErrno: nativeContention, + bidirectionalBlockingHandoff: true, + unlockHandoff: true, + peerDeathReleasesLock: true, + nativeDeathReleasesLock: true, + }; + } finally { + closeSync(fd); + } +} + +if (process.argv[2] === "lock-worker") { + await nativeLockWorker(process.argv[3]!); +} else { + const python = process.argv[2]; + const scripts = process.argv[3]; + assert.equal( + Boolean(python), + Boolean(scripts), + "Pass both the optional Python interpreter and legacy scripts directory", + ); + const root = mkdtempSync(join(tmpdir(), "codex-security-native-")); + try { + const descriptors = descriptorProof(root); + const accounts = accountProof(); + const locks = await lockProof(root); + const pythonCompatibility = + python && scripts ? await lockProof(root, python, scripts) : undefined; + console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + architecture: process.arch, + nodeApi: 8, + descriptors, + accounts, + locks, + pythonCompatibility, + fixture: basename(root), + }, + null, + 2, + ), + ); + } finally { + for (const child of workers) { + if (child.child.exitCode === null && child.child.signalCode === null) + child.child.kill("SIGKILL"); + } + await Promise.allSettled(workers.map((child) => child.exit)); + rmSync(root, { recursive: true, force: true }); + assert(!existsSync(root)); + } +} diff --git a/plugins/codex-security/native/rust-toolchain.toml b/plugins/codex-security/native/rust-toolchain.toml new file mode 100644 index 000000000..010f002cf --- /dev/null +++ b/plugins/codex-security/native/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/plugins/codex-security/native/src/lib.rs b/plugins/codex-security/native/src/lib.rs new file mode 100644 index 000000000..713b39abd --- /dev/null +++ b/plugins/codex-security/native/src/lib.rs @@ -0,0 +1,213 @@ +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use std::{ + ffi::{CStr, CString}, + io, +}; + +#[napi(object)] +pub struct SyscallResult { + pub value: i32, + pub errno: i32, +} + +fn result(value: i32) -> SyscallResult { + SyscallResult { + value, + errno: if value < 0 { + io::Error::last_os_error().raw_os_error().unwrap() + } else { + 0 + }, + } +} + +fn retry_eintr(mut operation: impl FnMut() -> i32) -> SyscallResult { + loop { + let value = result(operation()); + if value.errno != libc::EINTR { + return value; + } + } +} + +fn path(value: Buffer) -> napi::Result { + CString::new(value.as_ref()).map_err(|_| napi::Error::from_reason("Path contains a NUL byte")) +} + +#[napi] +pub fn open_at(directory: i32, name: Buffer, flags: i32, mode: u32) -> napi::Result { + let name = path(name)?; + let mode = mode as libc::mode_t; + // macOS mode_t is u16 and needs C integer promotion in this variadic call. + #[cfg(target_os = "macos")] + let mode = libc::c_int::from(mode); + Ok(retry_eintr(|| unsafe { + libc::openat(directory, name.as_ptr(), flags | libc::O_CLOEXEC, mode) + })) +} + +#[napi] +pub fn make_directory_at(directory: i32, name: Buffer, mode: u32) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::mkdirat(directory, name.as_ptr(), mode as libc::mode_t) + })) +} + +#[napi] +pub fn rename_at( + old_directory: i32, + old_name: Buffer, + new_directory: i32, + new_name: Buffer, +) -> napi::Result { + let old_name = path(old_name)?; + let new_name = path(new_name)?; + Ok(result(unsafe { + libc::renameat( + old_directory, + old_name.as_ptr(), + new_directory, + new_name.as_ptr(), + ) + })) +} + +#[napi] +pub fn unlink_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::unlinkat(directory, name.as_ptr(), 0) + })) +} + +#[napi] +pub fn duplicate(descriptor: i32) -> SyscallResult { + result(unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 0) }) +} + +#[napi] +pub fn file_lock(descriptor: i32, unlock: bool, nonblocking: bool) -> SyscallResult { + let flags = if unlock { + libc::LOCK_UN + } else { + libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 } + }; + retry_eintr(|| unsafe { libc::flock(descriptor, flags) }) +} + +#[napi(object)] +pub struct MetadataResult { + pub errno: i32, + pub mode: u32, + pub device: String, + pub inode: String, +} + +#[napi] +// libc stat field widths differ between Linux and macOS. +#[allow(clippy::unnecessary_cast)] +pub fn stat_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let code = unsafe { + libc::fstatat( + directory, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if code < 0 { + return Ok(MetadataResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + mode: 0, + device: String::new(), + inode: String::new(), + }); + } + let stat = unsafe { stat.assume_init() }; + Ok(MetadataResult { + errno: 0, + mode: stat.st_mode as u32, + device: (stat.st_dev as u64).to_string(), + inode: stat.st_ino.to_string(), + }) +} + +#[napi(object)] +pub struct ReadLinkResult { + pub errno: i32, + pub value: Buffer, +} + +#[napi] +pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut buffer = vec![0_u8; 256]; + loop { + let length = unsafe { + libc::readlinkat( + directory, + name.as_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if length < 0 { + return Ok(ReadLinkResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + value: Vec::new().into(), + }); + } + if (length as usize) < buffer.len() { + buffer.truncate(length as usize); + return Ok(ReadLinkResult { + errno: 0, + value: buffer.into(), + }); + } + buffer.resize(buffer.len() * 2, 0); + } +} + +#[napi(object, use_nullable = true)] +pub struct UserHomeResult { + pub errno: i32, + pub value: Option, +} + +#[napi] +pub fn user_home(username: Buffer) -> napi::Result { + let username = path(username)?; + let mut buffer = vec![0_u8; 1024]; + loop { + let mut entry = std::mem::MaybeUninit::::uninit(); + let mut found = std::ptr::null_mut(); + let code = unsafe { + libc::getpwnam_r( + username.as_ptr(), + entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut found, + ) + }; + if code == libc::ERANGE { + buffer.resize(buffer.len() * 2, 0); + continue; + } + let value = if code == 0 && !found.is_null() { + Some( + unsafe { CStr::from_ptr((*found).pw_dir) } + .to_bytes() + .to_vec() + .into(), + ) + } else { + None + }; + return Ok(UserHomeResult { errno: code, value }); + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 6df215058..da4287319 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -54,7 +54,7 @@ "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", - "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\" \"../../examples/custom-validation/*.{mts,md}\"", + "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\" \"../../examples/custom-validation/*.{mts,md}\" \"../../plugins/codex-security/native/*.{mts,md}\"", "generate:models": "node scripts/generate-models.cjs", "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", diff --git a/sdk/typescript/tsconfig.ci.json b/sdk/typescript/tsconfig.ci.json index 527df088e..3bd8a2c5c 100644 --- a/sdk/typescript/tsconfig.ci.json +++ b/sdk/typescript/tsconfig.ci.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "include": [ "../../.github/scripts/*.mts", + "../../plugins/codex-security/native/*.mts", "scripts/compare-test-reports.mts" ], "compilerOptions": { diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 1b08f5158..9631fdd3c 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -6,6 +6,7 @@ "dashboard/**/*.tsx", "tests-ts/**/*.ts", "../../.github/scripts/*.mts", + "../../plugins/codex-security/native/*.mts", "../../examples/custom-validation/*.mts", "scripts/compare-test-reports.mts", "scripts/smoke-findings-service.ts",