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 +16 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/extensions/index.ts +2 -2
- package/native/addon.c +161 -0
- package/package.json +9 -2
- package/prebuilds/darwin-arm64/pi-codex-tools.node +0 -0
- package/prebuilds/darwin-x64/pi-codex-tools.node +0 -0
- package/src/apply-patch.ts +124 -59
- package/src/native.ts +40 -0
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`
|
|
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
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
+
"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
|
}
|
|
Binary file
|
|
Binary file
|
package/src/apply-patch.ts
CHANGED
|
@@ -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 {
|
|
4
|
-
import
|
|
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
|
|
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
|
|
309
|
+
const rootFd = await openSecureRoot(root, options.signal);
|
|
242
310
|
try {
|
|
243
|
-
const operations = await planOperations(hunks, root,
|
|
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(
|
|
317
|
+
await writeSecureFile(rootFd, root, operation.path, operation.content, true, options.signal);
|
|
250
318
|
} else if (operation.kind === "delete") {
|
|
251
|
-
await removeSecureFile(
|
|
319
|
+
await removeSecureFile(rootFd, root, operation.path, options.signal);
|
|
252
320
|
} else if (operation.moveTo) {
|
|
253
|
-
await writeSecureFile(
|
|
254
|
-
await removeSecureFile(
|
|
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(
|
|
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
|
|
336
|
+
await closeFd(rootFd);
|
|
269
337
|
}
|
|
270
338
|
});
|
|
271
339
|
}
|
|
272
340
|
|
|
273
|
-
async function planOperations(hunks: ApplyPatchHunk[], root: string,
|
|
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(
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
503
|
+
await closeFd(current);
|
|
441
504
|
current = next;
|
|
442
505
|
}
|
|
443
506
|
return current;
|
|
444
507
|
} catch (error) {
|
|
445
|
-
await current
|
|
508
|
+
await closeFd(current).catch(() => undefined);
|
|
446
509
|
throw error;
|
|
447
510
|
}
|
|
448
511
|
}
|
|
449
512
|
|
|
450
|
-
async function openSecureDirectoryChild(
|
|
451
|
-
const
|
|
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
|
|
454
|
-
return
|
|
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
|
|
519
|
+
await closeFd(fd).catch(() => undefined);
|
|
457
520
|
throw error;
|
|
458
521
|
}
|
|
459
522
|
}
|
|
460
523
|
|
|
461
|
-
type SecureParent = { handle:
|
|
524
|
+
type SecureParent = { handle: number; owned: boolean };
|
|
462
525
|
|
|
463
|
-
async function openSecureParentDirectory(
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
563
|
+
rootFd: number,
|
|
501
564
|
root: string,
|
|
502
565
|
absolute: string,
|
|
503
566
|
flags: number,
|
|
504
567
|
createParents: boolean,
|
|
505
|
-
callback: (
|
|
568
|
+
callback: (fd: number, size: number) => Promise<T>,
|
|
506
569
|
signal?: AbortSignal,
|
|
507
570
|
): Promise<T> {
|
|
508
|
-
const parent = await openSecureParentDirectory(
|
|
509
|
-
let
|
|
571
|
+
const parent = await openSecureParentDirectory(rootFd, root, absolute, createParents, signal);
|
|
572
|
+
let fd: number | undefined;
|
|
510
573
|
try {
|
|
511
|
-
|
|
512
|
-
const stats = await
|
|
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(
|
|
577
|
+
return await callback(fd, stats.size);
|
|
515
578
|
} finally {
|
|
516
|
-
await
|
|
517
|
-
if (parent.owned) await parent.handle
|
|
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(
|
|
522
|
-
return withSecureFile(
|
|
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
|
|
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(
|
|
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(
|
|
547
|
-
await
|
|
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(
|
|
552
|
-
const parent = await openSecureParentDirectory(
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
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
|
+
}
|