pi-codex-tools 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-08-08
10
+
11
+ ### Fixed
12
+
13
+ - Stop npm from running `node-gyp rebuild` on install. The package ships prebuilt native bindings and loads them at runtime, but npm's gypfile detection keyed off the dev-only `binding.gyp` and injected a failing `node-gyp rebuild` into the install lifecycle — breaking install on machines without a build toolchain (e.g. Linux). Set `gypfile: false` to suppress it.
14
+
15
+ ## [0.2.0] - 2026-08-08
16
+
17
+ ### Added
18
+
19
+ - macOS support for `apply_patch` via a bundled `openat` / `mkdirat` / `unlinkat` N-API binding, prebuilt for Apple silicon and Intel. The TOCTOU-safe no-follow directory walk now runs on macOS with parity to Linux, so supported Codex models can use `apply_patch` on macOS (previously Linux-only).
20
+
21
+ ### Fixed
22
+
23
+ - Keep `edit` and `write` active on platforms where `apply_patch` cannot run. The tool swap was gated solely on model grammar capability, so unsupported platforms (macOS) replaced the native file tools with `apply_patch` and then failed at execution; activation now also requires the secure filesystem to be supported.
24
+
9
25
  ## [0.1.3] - 2026-08-06
10
26
 
11
27
  ### Fixed
package/README.md CHANGED
@@ -6,7 +6,7 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
6
6
 
7
7
  - **Raw `apply_patch`** — sends Codex's Lark grammar as an OpenAI custom tool, so patches are not JSON-wrapped.
8
8
  - **Capability-based activation** — requires `openai-codex-responses` or `openai-responses` plus `model.compat.supportsOpenAIGrammarTools === true`; model names alone are never enough.
9
- - **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
9
+ - **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux and macOS, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
10
10
  - **Model switching** — supported models replace Pi's `edit` and `write` tools with `apply_patch`; other active tools are preserved. Switching back restores only the file tools that were active before the switch.
11
11
  - **Sequential patch calls** — the extension marks patch execution sequential and disables provider-side parallel tool calls when the patch tool is active.
12
12
  - **Streaming progress** — while a patch is generated, the TUI shows a live, color-coded glimpse of the content being written (new-file content, or `+`/`-` lines for updates) plus a running `+added -removed` tally and a per-file roster for multi-file patches. It reuses Pi's shared diff rendering and mirrors the built-in `write`/`edit` previews; patch execution is unchanged.
@@ -25,7 +25,7 @@ pi -e /path/to/pi-mono/packages/pi-codex-tools
25
25
 
26
26
  ## Scope decisions
27
27
 
28
- The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux and fails closed on unsupported platforms. It also requires a Pi model runtime that advertises `compat.supportsOpenAIGrammarTools`; older runtimes leave the tool inactive.
28
+ The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` performs its own TOCTOU-safe, no-follow directory walk: on Linux it re-opens each component relative to a trusted fd via `/proc/self/fd`, and on macOS via a tiny bundled `openat`/`mkdirat`/`unlinkat` N-API binding (prebuilt for Apple silicon and Intel). It fails closed on platforms without that support, in which case the native `edit`/`write` tools stay active. `apply_patch` also requires a Pi model runtime that advertises `compat.supportsOpenAIGrammarTools`; older runtimes leave the tool inactive.
29
29
 
30
30
  | Codex surface | Decision |
31
31
  | --- | --- |
package/SECURITY.md CHANGED
@@ -19,7 +19,7 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
19
19
 
20
20
  Pi extensions execute with the same permissions as the local user running Pi. Review installed extensions and only install packages from sources you trust.
21
21
 
22
- `apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It validates paths beneath the current working directory, rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and uses root-anchored descriptor-based no-follow operations on Linux. It fails closed on unsupported platforms because Pi does not provide Codex's OS-level filesystem sandbox. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
22
+ `apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It validates paths beneath the current working directory, rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and performs a root-anchored descriptor-based no-follow directory walk so a path component swapped to a symlink between check and use cannot escape. On Linux the walk re-opens each component via `/proc/self/fd`; on macOS it uses a bundled `openat`/`mkdirat`/`unlinkat` N-API binding (committed prebuilds for `darwin-arm64` and `darwin-x64`, loaded via `node-gyp-build`) because Node does not expose `openat` and macOS lacks procfs. The binding is darwin-only, exposes only those three POSIX calls, and is loaded best-effort: on platforms without it `apply_patch` fails closed and Pi keeps its native `edit`/`write` tools. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
23
23
 
24
24
  The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
25
25
 
@@ -2,7 +2,7 @@ import { Container, Text } from "@earendil-works/pi-tui";
2
2
  import type { Component } from "@earendil-works/pi-tui";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
5
- import { applyPatch, APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES } from "../src/apply-patch.js";
5
+ import { applyPatch, APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES, secureFilesystemSupported } from "../src/apply-patch.js";
6
6
  import { createFreeformInputSchema, createOpenAILarkSampling, type OpenAIGrammarSampling } from "../src/grammar.js";
7
7
  import { supportsOpenAIGrammarTools } from "../src/model-support.js";
8
8
  import { formatApplyPatchCallText, formatApplyPatchResultText } from "../src/patch-preview.js";
@@ -94,7 +94,7 @@ export default function piCodexTools(pi: ExtensionAPI): void {
94
94
  if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
95
95
 
96
96
  const active = new Set(pi.getActiveTools());
97
- if (supportsOpenAIGrammarTools(ctx.model)) {
97
+ if (supportsOpenAIGrammarTools(ctx.model) && secureFilesystemSupported()) {
98
98
  if (replacedToolsWasActive === undefined) {
99
99
  replacedToolsWasActive = {
100
100
  edit: active.has(EDIT),
package/native/addon.c ADDED
@@ -0,0 +1,161 @@
1
+ // Native POSIX *at bindings for descriptor-relative filesystem walks.
2
+ // Used by apply_patch on platforms without /proc/self/fd (macOS) to keep the
3
+ // same TOCTOU-safe, no-follow directory walk that Linux gets via procfs.
4
+ #include <node_api.h>
5
+ #include <fcntl.h>
6
+ #include <sys/stat.h>
7
+ #include <unistd.h>
8
+ #include <stdlib.h>
9
+ #include <errno.h>
10
+ #include <string.h>
11
+
12
+ static napi_value throw_errno(napi_env env, int err) {
13
+ const char *code = NULL;
14
+ switch (err) {
15
+ case ENOENT: code = "ENOENT"; break;
16
+ case EEXIST: code = "EEXIST"; break;
17
+ case ELOOP: code = "ELOOP"; break;
18
+ case ENOTDIR: code = "ENOTDIR"; break;
19
+ case EACCES: code = "EACCES"; break;
20
+ case EISDIR: code = "EISDIR"; break;
21
+ case ENAMETOOLONG: code = "ENAMETOOLONG"; break;
22
+ case EINVAL: code = "EINVAL"; break;
23
+ }
24
+ napi_value msg, errv;
25
+ if (napi_create_string_utf8(env, strerror(err), NAPI_AUTO_LENGTH, &msg) != napi_ok ||
26
+ napi_create_error(env, NULL, msg, &errv) != napi_ok) {
27
+ napi_throw_error(env, NULL, strerror(err));
28
+ return NULL;
29
+ }
30
+ if (code) {
31
+ napi_value codev;
32
+ napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &codev);
33
+ napi_set_named_property(env, errv, "code", codev);
34
+ }
35
+ napi_value enov;
36
+ napi_create_int32(env, err, &enov);
37
+ napi_set_named_property(env, errv, "errno", enov);
38
+ napi_throw(env, errv);
39
+ return NULL;
40
+ }
41
+
42
+ static char *get_string(napi_env env, napi_value v) {
43
+ size_t len = 0;
44
+ if (napi_get_value_string_utf8(env, v, NULL, 0, &len) != napi_ok) return NULL;
45
+ char *buf = (char *)malloc(len + 1);
46
+ if (!buf) return NULL;
47
+ size_t written = 0;
48
+ if (napi_get_value_string_utf8(env, v, buf, len + 1, &written) != napi_ok) { free(buf); return NULL; }
49
+ return buf;
50
+ }
51
+
52
+ static napi_value OpenAt(napi_env env, napi_callback_info info) {
53
+ size_t argc = 4;
54
+ napi_value args[4];
55
+ if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 3) {
56
+ napi_throw_type_error(env, NULL, "openat(dirfd, path, flags[, mode]) requires (dirfd, path, flags)");
57
+ return NULL;
58
+ }
59
+ int dirfd = 0, flags = 0, mode = 0;
60
+ if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok ||
61
+ napi_get_value_int32(env, args[2], &flags) != napi_ok) {
62
+ napi_throw_type_error(env, NULL, "dirfd and flags must be integers");
63
+ return NULL;
64
+ }
65
+ char *path = get_string(env, args[1]);
66
+ if (!path) { napi_throw_type_error(env, NULL, "path must be a string"); return NULL; }
67
+ if (argc >= 4 && napi_get_value_int32(env, args[3], &mode) != napi_ok) {
68
+ free(path); napi_throw_type_error(env, NULL, "mode must be an integer"); return NULL;
69
+ }
70
+ int fd = openat(dirfd, path, flags, (mode_t)mode);
71
+ int saved_errno = errno;
72
+ free(path);
73
+ if (fd < 0) return throw_errno(env, saved_errno);
74
+ napi_value result;
75
+ napi_create_int32(env, fd, &result);
76
+ return result;
77
+ }
78
+
79
+ static napi_value MkdirAt(napi_env env, napi_callback_info info) {
80
+ size_t argc = 3;
81
+ napi_value args[3];
82
+ if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 2) {
83
+ napi_throw_type_error(env, NULL, "mkdirat(dirfd, path[, mode]) requires (dirfd, path)");
84
+ return NULL;
85
+ }
86
+ int dirfd = 0, mode = 0777;
87
+ if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok) { napi_throw_type_error(env, NULL, "dirfd must be an integer"); return NULL; }
88
+ char *path = get_string(env, args[1]);
89
+ if (!path) { napi_throw_type_error(env, NULL, "path must be a string"); return NULL; }
90
+ if (argc >= 3 && napi_get_value_int32(env, args[2], &mode) != napi_ok) { free(path); napi_throw_type_error(env, NULL, "mode must be an integer"); return NULL; }
91
+ int rc = mkdirat(dirfd, path, (mode_t)mode);
92
+ int saved_errno = errno;
93
+ free(path);
94
+ if (rc < 0) return throw_errno(env, saved_errno);
95
+ napi_value undef;
96
+ napi_get_undefined(env, &undef);
97
+ return undef;
98
+ }
99
+
100
+ static napi_value UnlinkAt(napi_env env, napi_callback_info info) {
101
+ size_t argc = 2;
102
+ napi_value args[2];
103
+ if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 2) {
104
+ napi_throw_type_error(env, NULL, "unlinkat(dirfd, path) requires (dirfd, path)");
105
+ return NULL;
106
+ }
107
+ int dirfd = 0;
108
+ if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok) { napi_throw_type_error(env, NULL, "dirfd must be an integer"); return NULL; }
109
+ char *path = get_string(env, args[1]);
110
+ if (!path) { napi_throw_type_error(env, NULL, "path must be a string"); return NULL; }
111
+ int rc = unlinkat(dirfd, path, 0);
112
+ int saved_errno = errno;
113
+ free(path);
114
+ if (rc < 0) return throw_errno(env, saved_errno);
115
+ napi_value undef;
116
+ napi_get_undefined(env, &undef);
117
+ return undef;
118
+ }
119
+
120
+ static napi_value LstatAt(napi_env env, napi_callback_info info) {
121
+ size_t argc = 2;
122
+ napi_value args[2];
123
+ if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 2) {
124
+ napi_throw_type_error(env, NULL, "lstatAt(dirfd, path) requires (dirfd, path)");
125
+ return NULL;
126
+ }
127
+ int dirfd = 0;
128
+ if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok) {
129
+ napi_throw_type_error(env, NULL, "dirfd must be an integer");
130
+ return NULL;
131
+ }
132
+ char *path = get_string(env, args[1]);
133
+ if (!path) {
134
+ napi_throw_type_error(env, NULL, "path must be a string");
135
+ return NULL;
136
+ }
137
+ struct stat st;
138
+ int rc = fstatat(dirfd, path, &st, AT_SYMLINK_NOFOLLOW);
139
+ int saved_errno = errno;
140
+ free(path);
141
+ if (rc < 0) return throw_errno(env, saved_errno);
142
+ napi_value obj, v;
143
+ napi_create_object(env, &obj);
144
+ napi_get_boolean(env, S_ISREG(st.st_mode), &v); napi_set_named_property(env, obj, "isFile", v);
145
+ napi_get_boolean(env, S_ISDIR(st.st_mode), &v); napi_set_named_property(env, obj, "isDirectory", v);
146
+ napi_get_boolean(env, S_ISLNK(st.st_mode), &v); napi_set_named_property(env, obj, "isSymbolicLink", v);
147
+ return obj;
148
+ }
149
+
150
+ static napi_value Init(napi_env env, napi_value exports) {
151
+ const napi_property_descriptor props[] = {
152
+ { "openat", NULL, OpenAt, NULL, NULL, NULL, napi_default, NULL },
153
+ { "mkdirat", NULL, MkdirAt, NULL, NULL, NULL, napi_default, NULL },
154
+ { "unlinkat", NULL, UnlinkAt, NULL, NULL, NULL, napi_default, NULL },
155
+ { "lstatAt", NULL, LstatAt, NULL, NULL, NULL, napi_default, NULL },
156
+ };
157
+ napi_define_properties(env, exports, 4, props);
158
+ return exports;
159
+ }
160
+
161
+ NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-tools",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Codex-compatible apply_patch tooling for Pi's grammar-capable OpenAI models.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,6 +35,8 @@
35
35
  "index.ts",
36
36
  "extensions",
37
37
  "src",
38
+ "native",
39
+ "prebuilds",
38
40
  "README.md",
39
41
  "NOTICE",
40
42
  "LICENSE",
@@ -43,10 +45,13 @@
43
45
  "CONTRIBUTING.md",
44
46
  "CODE_OF_CONDUCT.md"
45
47
  ],
48
+ "gypfile": false,
46
49
  "scripts": {
47
50
  "check": "tsc --noEmit",
48
51
  "typecheck": "tsc --noEmit",
49
52
  "test": "node --import tsx --test tests/*.test.mjs",
53
+ "build:native": "node-gyp configure build",
54
+ "prebuild:native": "prebuildify --napi --arch arm64 && prebuildify --napi --arch x64",
50
55
  "pack:dry-run": "npm pack --dry-run"
51
56
  },
52
57
  "peerDependencies": {
@@ -60,6 +65,7 @@
60
65
  "@earendil-works/pi-coding-agent": "^0.82.1",
61
66
  "@earendil-works/pi-tui": "^0.82.1",
62
67
  "@types/node": "^26.1.2",
68
+ "prebuildify": "^6.0.1",
63
69
  "tsx": "^4.23.1",
64
70
  "typebox": "^1.3.8",
65
71
  "typescript": "^7.0.2"
@@ -71,6 +77,7 @@
71
77
  "node": ">=20.6.0"
72
78
  },
73
79
  "dependencies": {
74
- "@mocito/install-telemetry": "0.1.1"
80
+ "@mocito/install-telemetry": "0.1.1",
81
+ "node-gyp-build": "^4.8.4"
75
82
  }
76
83
  }
@@ -1,9 +1,10 @@
1
1
  // Adapted from OpenAI Codex apply-patch grammar/parser behavior; see NOTICE.
2
- import { constants } from "node:fs";
3
- import { lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
4
- import type { FileHandle } from "node:fs/promises";
2
+ import { constants, open as openCb, fstat as fstatCb, read as readCb, write as writeCb, close as closeCb, mkdir as mkdirCb, unlink as unlinkCb } from "node:fs";
3
+ import type { Stats } from "node:fs";
4
+ import { lstat, realpath } from "node:fs/promises";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
+ import { getOpenAtBindings } from "./native.js";
7
8
 
8
9
  export const MAX_PATCH_BYTES = 1_048_576;
9
10
  export const MAX_PATCH_HUNKS = 1_000;
@@ -11,7 +12,74 @@ export const MAX_TARGET_FILE_BYTES = 64 * 1024 * 1024;
11
12
 
12
13
  const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
13
14
  const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : undefined;
14
- const SECURE_FILESYSTEM_SUPPORTED = SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0;
15
+ const OPENAT_BINDINGS = getOpenAtBindings();
16
+ const BASE_SECURE_FILESYSTEM_SUPPORTED =
17
+ (process.platform === "linux" && SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0) ||
18
+ (process.platform === "darwin" && OPENAT_BINDINGS !== null && O_NOFOLLOW !== 0);
19
+ let secureFilesystemSupportedOverride: boolean | undefined;
20
+
21
+ /** Whether apply_patch can safely execute against this platform's filesystem. */
22
+ export function secureFilesystemSupported(): boolean {
23
+ return secureFilesystemSupportedOverride ?? BASE_SECURE_FILESYSTEM_SUPPORTED;
24
+ }
25
+
26
+ /** @internal Force the support flag so the activation path can be tested on any host platform. */
27
+ export function setSecureFilesystemSupportedForTest(value: boolean | undefined): void {
28
+ secureFilesystemSupportedOverride = value;
29
+ }
30
+
31
+ const openFd = (path: string, flags: number, mode: number): Promise<number> =>
32
+ new Promise((resolveP, rejectP) => openCb(path, flags, mode, (error, fd) => (error ? rejectP(error) : resolveP(fd))));
33
+ const fstatFd = (fd: number): Promise<Stats> =>
34
+ new Promise((resolveP, rejectP) => fstatCb(fd, (error, stats) => (error ? rejectP(error) : resolveP(stats))));
35
+ const readFd = (fd: number, buffer: Buffer, offset: number, length: number, position: number | null): Promise<number> =>
36
+ new Promise((resolveP, rejectP) => readCb(fd, buffer, offset, length, position, (error, bytesRead) => (error ? rejectP(error) : resolveP(bytesRead))));
37
+ const writeFd = (fd: number, buffer: Buffer, offset: number, length: number, position: number | null): Promise<number> =>
38
+ new Promise((resolveP, rejectP) => writeCb(fd, buffer, offset, length, position, (error, written) => (error ? rejectP(error) : resolveP(written))));
39
+ const closeFd = (fd: number): Promise<void> =>
40
+ new Promise((resolveP, rejectP) => closeCb(fd, (error) => (error ? rejectP(error) : resolveP())));
41
+ const mkdirPath = (path: string, mode: number): Promise<void> =>
42
+ new Promise((resolveP, rejectP) => mkdirCb(path, mode, (error) => (error ? rejectP(error) : resolveP())));
43
+ const unlinkPath = (path: string): Promise<void> =>
44
+ new Promise((resolveP, rejectP) => unlinkCb(path, (error) => (error ? rejectP(error) : resolveP())));
45
+
46
+ // Open `child` relative to a trusted open directory descriptor, never following the final component.
47
+ async function openChildRelative(parentFd: number, child: string, flags: number, mode: number): Promise<number> {
48
+ if (OPENAT_BINDINGS) return OPENAT_BINDINGS.openat(parentFd, child, flags, mode);
49
+ return openFd(join(SECURE_FD_DIRECTORY as string, String(parentFd), child), flags, mode);
50
+ }
51
+
52
+ async function mkdirChildRelative(parentFd: number, child: string, mode: number): Promise<void> {
53
+ if (OPENAT_BINDINGS) {
54
+ OPENAT_BINDINGS.mkdirat(parentFd, child, mode);
55
+ return;
56
+ }
57
+ await mkdirPath(join(SECURE_FD_DIRECTORY as string, String(parentFd), child), mode);
58
+ }
59
+
60
+ async function unlinkChildRelative(parentFd: number, child: string): Promise<void> {
61
+ if (OPENAT_BINDINGS) {
62
+ OPENAT_BINDINGS.unlinkat(parentFd, child);
63
+ return;
64
+ }
65
+ await unlinkPath(join(SECURE_FD_DIRECTORY as string, String(parentFd), child));
66
+ }
67
+
68
+ // No-follow stat of `child` relative to a trusted descriptor, without opening it
69
+ // (so unreadable files can still be inspected for symlink/directory rejection).
70
+ async function lstatChildRelative(parentFd: number, child: string): Promise<{ isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }> {
71
+ if (OPENAT_BINDINGS) return OPENAT_BINDINGS.lstatAt(parentFd, child);
72
+ const stats = await lstat(join(SECURE_FD_DIRECTORY as string, String(parentFd), child));
73
+ return { isFile: stats.isFile(), isDirectory: stats.isDirectory(), isSymbolicLink: stats.isSymbolicLink() };
74
+ }
75
+
76
+ async function writeAllFd(fd: number, data: string): Promise<void> {
77
+ const buffer = Buffer.from(data, "utf8");
78
+ let written = 0;
79
+ while (written < buffer.length) {
80
+ written += await writeFd(fd, buffer, written, buffer.length - written, written);
81
+ }
82
+ }
15
83
  const SECURE_DIRECTORY_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_DIRECTORY ?? 0) | (constants.O_NONBLOCK ?? 0);
16
84
  const SECURE_READ_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_NONBLOCK ?? 0);
17
85
  const SECURE_UPDATE_FLAGS = constants.O_WRONLY | O_NOFOLLOW | constants.O_TRUNC | (constants.O_NONBLOCK ?? 0);
@@ -238,22 +306,22 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
238
306
  });
239
307
 
240
308
  return withMutationLocks(lockPaths, async () => {
241
- const rootHandle = await openSecureRoot(root, options.signal);
309
+ const rootFd = await openSecureRoot(root, options.signal);
242
310
  try {
243
- const operations = await planOperations(hunks, root, rootHandle, options.signal);
311
+ const operations = await planOperations(hunks, root, rootFd, options.signal);
244
312
  throwIfAborted(options.signal);
245
313
  // ponytail: preflight catches parse/match errors before writes; cross-process failures can still leave a partial multi-file patch.
246
314
  for (const operation of operations) {
247
315
  throwIfAborted(options.signal);
248
316
  if (operation.kind === "add") {
249
- await writeSecureFile(rootHandle, root, operation.path, operation.content, true, options.signal);
317
+ await writeSecureFile(rootFd, root, operation.path, operation.content, true, options.signal);
250
318
  } else if (operation.kind === "delete") {
251
- await removeSecureFile(rootHandle, root, operation.path, options.signal);
319
+ await removeSecureFile(rootFd, root, operation.path, options.signal);
252
320
  } else if (operation.moveTo) {
253
- await writeSecureFile(rootHandle, root, operation.moveTo, operation.content, true, options.signal);
254
- await removeSecureFile(rootHandle, root, operation.path, options.signal);
321
+ await writeSecureFile(rootFd, root, operation.moveTo, operation.content, true, options.signal);
322
+ await removeSecureFile(rootFd, root, operation.path, options.signal);
255
323
  } else {
256
- await writeSecureFile(rootHandle, root, operation.path, operation.content, false, options.signal);
324
+ await writeSecureFile(rootFd, root, operation.path, operation.content, false, options.signal);
257
325
  }
258
326
  }
259
327
 
@@ -265,12 +333,12 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
265
333
  })),
266
334
  };
267
335
  } finally {
268
- await rootHandle.close();
336
+ await closeFd(rootFd);
269
337
  }
270
338
  });
271
339
  }
272
340
 
273
- async function planOperations(hunks: ApplyPatchHunk[], root: string, rootHandle: FileHandle, signal?: AbortSignal): Promise<PlannedOperation[]> {
341
+ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: number, signal?: AbortSignal): Promise<PlannedOperation[]> {
274
342
  const operations: PlannedOperation[] = [];
275
343
  const virtualFiles = new Map<string, VirtualFile>();
276
344
 
@@ -290,7 +358,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootHandle:
290
358
 
291
359
  const getVirtualContent = async (absolute: string, file: VirtualFile): Promise<string> => {
292
360
  if (!file.exists || !file.isFile) throw new Error(`Cannot read non-file '${absolute}'.`);
293
- if (file.content === undefined) file.content = await readSecureFile(rootHandle, root, absolute, signal);
361
+ if (file.content === undefined) file.content = await readSecureFile(rootFd, root, absolute, signal);
294
362
  return file.content;
295
363
  };
296
364
 
@@ -420,47 +488,42 @@ async function safePath(rawPath: string, root: string, signal?: AbortSignal): Pr
420
488
  }
421
489
 
422
490
  function requireSecureFilesystem(): void {
423
- if (!SECURE_FILESYSTEM_SUPPORTED) {
491
+ if (!secureFilesystemSupported()) {
424
492
  throw new Error("apply_patch requires a POSIX filesystem with descriptor-based no-follow support.");
425
493
  }
426
494
  }
427
495
 
428
- function secureChildPath(parent: FileHandle, child: string): string {
429
- if (!SECURE_FD_DIRECTORY) throw new Error("Secure filesystem operations are unavailable on this platform.");
430
- return join(SECURE_FD_DIRECTORY, String(parent.fd), child);
431
- }
432
-
433
- async function openSecureRoot(root: string, signal?: AbortSignal): Promise<FileHandle> {
496
+ async function openSecureRoot(root: string, signal?: AbortSignal): Promise<number> {
434
497
  requireSecureFilesystem();
435
- let current = await open(sep, SECURE_DIRECTORY_FLAGS);
498
+ let current = await openFd(sep, SECURE_DIRECTORY_FLAGS, 0);
436
499
  try {
437
500
  for (const component of root.split(sep).filter(Boolean)) {
438
501
  throwIfAborted(signal);
439
502
  const next = await openSecureDirectoryChild(current, component);
440
- await current.close();
503
+ await closeFd(current);
441
504
  current = next;
442
505
  }
443
506
  return current;
444
507
  } catch (error) {
445
- await current.close().catch(() => undefined);
508
+ await closeFd(current).catch(() => undefined);
446
509
  throw error;
447
510
  }
448
511
  }
449
512
 
450
- async function openSecureDirectoryChild(parent: FileHandle, component: string): Promise<FileHandle> {
451
- const handle = await open(secureChildPath(parent, component), SECURE_DIRECTORY_FLAGS);
513
+ async function openSecureDirectoryChild(parentFd: number, component: string): Promise<number> {
514
+ const fd = await openChildRelative(parentFd, component, SECURE_DIRECTORY_FLAGS, 0);
452
515
  try {
453
- if (!(await handle.stat()).isDirectory()) throw new Error(`Secure path component is not a directory: ${component}`);
454
- return handle;
516
+ if (!(await fstatFd(fd)).isDirectory()) throw new Error(`Secure path component is not a directory: ${component}`);
517
+ return fd;
455
518
  } catch (error) {
456
- await handle.close().catch(() => undefined);
519
+ await closeFd(fd).catch(() => undefined);
457
520
  throw error;
458
521
  }
459
522
  }
460
523
 
461
- type SecureParent = { handle: FileHandle; owned: boolean };
524
+ type SecureParent = { handle: number; owned: boolean };
462
525
 
463
- async function openSecureParentDirectory(rootHandle: FileHandle, root: string, absolute: string, createParents: boolean, signal?: AbortSignal): Promise<SecureParent> {
526
+ async function openSecureParentDirectory(rootFd: number, root: string, absolute: string, createParents: boolean, signal?: AbortSignal): Promise<SecureParent> {
464
527
  const parentPath = dirname(absolute);
465
528
  const relativeParent = relative(root, parentPath);
466
529
  const components = relativeParent ? relativeParent.split(sep) : [];
@@ -468,58 +531,58 @@ async function openSecureParentDirectory(rootHandle: FileHandle, root: string, a
468
531
  throw new Error(`Patch path must stay inside the current working directory: ${absolute}`);
469
532
  }
470
533
 
471
- let current = rootHandle;
534
+ let current = rootFd;
472
535
  let owned = false;
473
536
  try {
474
537
  for (const component of components) {
475
538
  throwIfAborted(signal);
476
- let next: FileHandle;
539
+ let next: number;
477
540
  try {
478
541
  next = await openSecureDirectoryChild(current, component);
479
542
  } catch (error) {
480
543
  if (!createParents || !isNoEntryError(error)) throw error;
481
544
  try {
482
- await mkdir(secureChildPath(current, component));
545
+ await mkdirChildRelative(current, component, 0o777);
483
546
  } catch (mkdirError) {
484
547
  if (!isAlreadyExistsError(mkdirError)) throw mkdirError;
485
548
  }
486
549
  next = await openSecureDirectoryChild(current, component);
487
550
  }
488
- if (owned) await current.close();
551
+ if (owned) await closeFd(current);
489
552
  current = next;
490
553
  owned = true;
491
554
  }
492
555
  return { handle: current, owned };
493
556
  } catch (error) {
494
- if (owned) await current.close().catch(() => undefined);
557
+ if (owned) await closeFd(current).catch(() => undefined);
495
558
  throw error;
496
559
  }
497
560
  }
498
561
 
499
562
  async function withSecureFile<T>(
500
- rootHandle: FileHandle,
563
+ rootFd: number,
501
564
  root: string,
502
565
  absolute: string,
503
566
  flags: number,
504
567
  createParents: boolean,
505
- callback: (file: FileHandle, size: number) => Promise<T>,
568
+ callback: (fd: number, size: number) => Promise<T>,
506
569
  signal?: AbortSignal,
507
570
  ): Promise<T> {
508
- const parent = await openSecureParentDirectory(rootHandle, root, absolute, createParents, signal);
509
- let file: FileHandle | undefined;
571
+ const parent = await openSecureParentDirectory(rootFd, root, absolute, createParents, signal);
572
+ let fd: number | undefined;
510
573
  try {
511
- file = await open(secureChildPath(parent.handle, basename(absolute)), flags, 0o666);
512
- const stats = await file.stat();
574
+ fd = await openChildRelative(parent.handle, basename(absolute), flags, 0o666);
575
+ const stats = await fstatFd(fd);
513
576
  if (!stats.isFile()) throw new Error(`Patch target is not a regular file: ${absolute}`);
514
- return await callback(file, stats.size);
577
+ return await callback(fd, stats.size);
515
578
  } finally {
516
- await file?.close().catch(() => undefined);
517
- if (parent.owned) await parent.handle.close().catch(() => undefined);
579
+ if (fd !== undefined) await closeFd(fd).catch(() => undefined);
580
+ if (parent.owned) await closeFd(parent.handle).catch(() => undefined);
518
581
  }
519
582
  }
520
583
 
521
- async function readSecureFile(rootHandle: FileHandle, root: string, absolute: string, signal?: AbortSignal): Promise<string> {
522
- return withSecureFile(rootHandle, root, absolute, SECURE_READ_FLAGS, false, async (file, size) => {
584
+ async function readSecureFile(rootFd: number, root: string, absolute: string, signal?: AbortSignal): Promise<string> {
585
+ return withSecureFile(rootFd, root, absolute, SECURE_READ_FLAGS, false, async (fd, size) => {
523
586
  if (size > MAX_TARGET_FILE_BYTES) {
524
587
  throw new Error(`Patch target exceeds the ${MAX_TARGET_FILE_BYTES}-byte limit: ${absolute}`);
525
588
  }
@@ -529,7 +592,7 @@ async function readSecureFile(rootHandle: FileHandle, root: string, absolute: st
529
592
  while (true) {
530
593
  throwIfAborted(signal);
531
594
  const buffer = Buffer.alloc(Math.min(FILE_READ_CHUNK_BYTES, MAX_TARGET_FILE_BYTES + 1 - total));
532
- const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
595
+ const bytesRead = await readFd(fd, buffer, 0, buffer.length, null);
533
596
  if (bytesRead === 0) break;
534
597
  total += bytesRead;
535
598
  chunks.push(buffer.subarray(0, bytesRead));
@@ -541,23 +604,25 @@ async function readSecureFile(rootHandle: FileHandle, root: string, absolute: st
541
604
  }, signal);
542
605
  }
543
606
 
544
- async function writeSecureFile(rootHandle: FileHandle, root: string, absolute: string, content: string, createParents: boolean, signal?: AbortSignal): Promise<void> {
607
+ async function writeSecureFile(rootFd: number, root: string, absolute: string, content: string, createParents: boolean, signal?: AbortSignal): Promise<void> {
545
608
  const flags = createParents ? SECURE_CREATE_FLAGS : SECURE_UPDATE_FLAGS;
546
- await withSecureFile(rootHandle, root, absolute, flags, createParents, async (file) => {
547
- await file.writeFile(content, "utf8");
609
+ await withSecureFile(rootFd, root, absolute, flags, createParents, async (fd) => {
610
+ await writeAllFd(fd, content);
548
611
  }, signal);
549
612
  }
550
613
 
551
- async function removeSecureFile(rootHandle: FileHandle, root: string, absolute: string, signal?: AbortSignal): Promise<void> {
552
- const parent = await openSecureParentDirectory(rootHandle, root, absolute, false, signal);
614
+ async function removeSecureFile(rootFd: number, root: string, absolute: string, signal?: AbortSignal): Promise<void> {
615
+ const parent = await openSecureParentDirectory(rootFd, root, absolute, false, signal);
553
616
  try {
554
- const target = secureChildPath(parent.handle, basename(absolute));
555
- const stats = await lstat(target);
556
- if (stats.isSymbolicLink()) throw new Error(`Symlink paths are not allowed in apply_patch: ${absolute}`);
557
- if (stats.isDirectory()) throw new Error(`Cannot delete directory '${absolute}'.`);
558
- await unlink(target);
617
+ throwIfAborted(signal);
618
+ // No-follow stat without opening the target, so unreadable (mode-000) files can still be deleted;
619
+ // unlink only requires write permission on the parent directory, never on the file itself.
620
+ const stats = await lstatChildRelative(parent.handle, basename(absolute));
621
+ if (stats.isSymbolicLink) throw new Error(`Symlink paths are not allowed in apply_patch: ${absolute}`);
622
+ if (stats.isDirectory) throw new Error(`Cannot delete directory '${absolute}'.`);
623
+ await unlinkChildRelative(parent.handle, basename(absolute));
559
624
  } finally {
560
- if (parent.owned) await parent.handle.close().catch(() => undefined);
625
+ if (parent.owned) await closeFd(parent.handle).catch(() => undefined);
561
626
  }
562
627
  }
563
628
 
package/src/native.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * POSIX *at bindings for descriptor-relative filesystem walks. macOS has no
7
+ * procfs, so the TOCTOU-safe walk used by apply_patch (open each directory
8
+ * component relative to a trusted parent fd with O_NOFOLLOW) needs openat /
9
+ * mkdirat / unlinkat. Node does not expose these, so a tiny N-API addon
10
+ * provides them on darwin. Linux keeps its /proc/self/fd path and never loads
11
+ * this binding.
12
+ */
13
+ export interface OpenAtBindings {
14
+ openat(dirfd: number, path: string, flags: number, mode: number): number;
15
+ mkdirat(dirfd: number, path: string, mode: number): void;
16
+ unlinkat(dirfd: number, path: string): void;
17
+ lstatAt(dirfd: number, path: string): { isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean };
18
+ }
19
+
20
+ let cached: OpenAtBindings | null | undefined;
21
+
22
+ /** Returns the native *at bindings on darwin, or null on any other platform / load failure. */
23
+ export function getOpenAtBindings(): OpenAtBindings | null {
24
+ if (cached !== undefined) return cached;
25
+ if (process.platform !== "darwin") {
26
+ cached = null;
27
+ return null;
28
+ }
29
+ try {
30
+ const require = createRequire(import.meta.url);
31
+ const resolveBinding = require("node-gyp-build") as (dir: string) => unknown;
32
+ // native.ts lives in src/; the binding (build/ or prebuilds/) ships at the package root.
33
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
34
+ const addon = resolveBinding(packageRoot) as OpenAtBindings;
35
+ cached = typeof addon?.openat === "function" ? addon : null;
36
+ } catch {
37
+ cached = null;
38
+ }
39
+ return cached;
40
+ }