pi-codex-tools 0.2.1 → 0.2.3

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.3] - 2026-08-11
10
+
11
+ ### Changed
12
+
13
+ - Accept relative paths outside the current working directory and absolute paths, matching Pi's native `edit` and `write` tools while preserving no-follow filesystem operations.
14
+
15
+ ## [0.2.2] - 2026-08-10
16
+
17
+ ### Changed
18
+
19
+ - Keep provider-side parallel tool calls enabled while preserving sequential `apply_patch` execution.
20
+
21
+ ### Fixed
22
+
23
+ - Strip untrusted terminal control sequences from `apply_patch` previews before rendering them in the Pi TUI.
24
+
9
25
  ## [0.2.1] - 2026-08-08
10
26
 
11
27
  ### Fixed
package/README.md CHANGED
@@ -6,9 +6,9 @@ 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 and macOS, 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, accept relative or absolute paths like Pi's native file tools, reject symlink paths, 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
- - **Sequential patch calls** — the extension marks patch execution sequential and disables provider-side parallel tool calls when the patch tool is active.
11
+ - **Sequential patch calls** — the extension marks patch execution sequential while leaving provider-side parallel tool calls enabled.
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.
13
13
 
14
14
  ## Installation
package/SECURITY.md CHANGED
@@ -19,8 +19,8 @@ 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 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.
22
+ `apply_patch` does not access the network or credential APIs. Like Pi's native `edit` and `write` tools, it accepts relative or absolute paths and can modify files outside the current working directory with the local user's permissions. It can read credential-containing files when a patch targets them. It 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 filesystem-root-anchored descriptor-based no-follow directory walk so a path component swapped to a symlink between check and use cannot redirect a mutation. 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 Apple silicon and Intel, loaded via `node-gyp-build`) because Node does not expose `openat` and macOS lacks procfs. The binding is darwin-only, exposes only those 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
- 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.
24
+ The package strips untrusted C0/C1 control bytes and terminal escape sequences from `apply_patch` preview text and paths before handing them to the TUI. 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
 
26
26
  Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, `PI_TELEMETRY=false`, CI detection, or Pi's `enableInstallTelemetry: false` setting. Through `@mocito/install-telemetry`, it sends the package name and version as HTTPS URL query parameters and adds `process.platform`, the runtime name/version, and `process.arch` to the `User-Agent`. These fields are not intended to identify a host, user, repository, or path; no prompts, patches, file contents, credentials, auth headers, or provider responses are sent.
@@ -44,7 +44,7 @@ export default function piCodexTools(pi: ExtensionAPI): void {
44
44
  promptGuidelines: [
45
45
  "Use apply_patch for file changes when it is available.",
46
46
  "Send the patch body directly; do not wrap it in JSON or add a shell heredoc.",
47
- `Patch paths must stay inside the current working directory and patches are limited to ${MAX_PATCH_BYTES} bytes.`,
47
+ `Patch paths may be relative to the current working directory or absolute, and patches are limited to ${MAX_PATCH_BYTES} bytes.`,
48
48
  ],
49
49
  parameters: APPLY_PATCH_PARAMETERS,
50
50
  constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
@@ -122,15 +122,4 @@ export default function piCodexTools(pi: ExtensionAPI): void {
122
122
  pi.on("model_select", (_event, ctx) => {
123
123
  synchronizeTools(ctx);
124
124
  });
125
-
126
- pi.on("before_provider_request", (event, ctx) => {
127
- if (!supportsOpenAIGrammarTools(ctx.model)) return;
128
- if (typeof pi.getActiveTools !== "function" || !pi.getActiveTools().includes(APPLY_PATCH)) return;
129
- if (!isRecord(event.payload) || event.payload.parallel_tool_calls !== true) return;
130
- return { ...event.payload, parallel_tool_calls: false };
131
- });
132
- }
133
-
134
- function isRecord(value: unknown): value is Record<string, unknown> {
135
- return typeof value === "object" && value !== null && !Array.isArray(value);
136
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-tools",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -45,7 +45,6 @@
45
45
  "CONTRIBUTING.md",
46
46
  "CODE_OF_CONDUCT.md"
47
47
  ],
48
- "gypfile": false,
49
48
  "scripts": {
50
49
  "check": "tsc --noEmit",
51
50
  "typecheck": "tsc --noEmit",
@@ -61,13 +60,13 @@
61
60
  "typebox": "*"
62
61
  },
63
62
  "devDependencies": {
64
- "@earendil-works/pi-ai": "^0.82.1",
65
- "@earendil-works/pi-coding-agent": "^0.82.1",
66
- "@earendil-works/pi-tui": "^0.82.1",
63
+ "@earendil-works/pi-ai": "^0.84.1",
64
+ "@earendil-works/pi-coding-agent": "^0.84.1",
65
+ "@earendil-works/pi-tui": "^0.84.1",
67
66
  "@types/node": "^26.1.2",
68
67
  "prebuildify": "^6.0.1",
69
- "tsx": "^4.23.1",
70
- "typebox": "^1.3.8",
68
+ "tsx": "^4.23.5",
69
+ "typebox": "^1.3.10",
71
70
  "typescript": "^7.0.2"
72
71
  },
73
72
  "publishConfig": {
@@ -79,5 +78,6 @@
79
78
  "dependencies": {
80
79
  "@mocito/install-telemetry": "0.1.1",
81
80
  "node-gyp-build": "^4.8.4"
82
- }
81
+ },
82
+ "gypfile": false
83
83
  }
@@ -298,17 +298,18 @@ type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
298
298
  export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
299
299
  requireSecureFilesystem();
300
300
  const hunks = parseApplyPatch(input);
301
- const root = await realpath(resolve(options.cwd));
301
+ const cwd = await realpath(resolve(options.cwd));
302
+ const root = sep;
302
303
  const lockPaths = hunks.flatMap((hunk) => {
303
- const paths = [resolvePatchPath(hunk.path, root)];
304
- if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo, root));
304
+ const paths = [resolvePatchPath(hunk.path, cwd)];
305
+ if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo, cwd));
305
306
  return paths;
306
307
  });
307
308
 
308
309
  return withMutationLocks(lockPaths, async () => {
309
310
  const rootFd = await openSecureRoot(root, options.signal);
310
311
  try {
311
- const operations = await planOperations(hunks, root, rootFd, options.signal);
312
+ const operations = await planOperations(hunks, cwd, root, rootFd, options.signal);
312
313
  throwIfAborted(options.signal);
313
314
  // ponytail: preflight catches parse/match errors before writes; cross-process failures can still leave a partial multi-file patch.
314
315
  for (const operation of operations) {
@@ -338,15 +339,21 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
338
339
  });
339
340
  }
340
341
 
341
- async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: number, signal?: AbortSignal): Promise<PlannedOperation[]> {
342
+ async function planOperations(
343
+ hunks: ApplyPatchHunk[],
344
+ cwd: string,
345
+ root: string,
346
+ rootFd: number,
347
+ signal?: AbortSignal,
348
+ ): Promise<PlannedOperation[]> {
342
349
  const operations: PlannedOperation[] = [];
343
350
  const virtualFiles = new Map<string, VirtualFile>();
344
351
 
345
352
  const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
346
- const absolute = resolvePatchPath(rawPath, root);
353
+ const absolute = resolvePatchPath(rawPath, cwd);
347
354
  const existing = virtualFiles.get(absolute);
348
355
  if (existing) return { absolute, file: existing };
349
- const safe = await safePath(rawPath, root, signal);
356
+ const safe = await safePath(rawPath, cwd, signal);
350
357
  const file: VirtualFile = {
351
358
  exists: safe.exists,
352
359
  isDirectory: safe.isDirectory,
@@ -377,7 +384,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
377
384
  operations.push({
378
385
  kind: "add",
379
386
  path: source.absolute,
380
- displayPath: displayPath(root, source.absolute, hunk.path),
387
+ displayPath: displayPath(cwd, source.absolute, hunk.path),
381
388
  content: hunk.content,
382
389
  });
383
390
  continue;
@@ -386,7 +393,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
386
393
  if (hunk.kind === "delete") {
387
394
  if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
388
395
  if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
389
- operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path) });
396
+ operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(cwd, source.absolute, hunk.path) });
390
397
  source.file.exists = false;
391
398
  source.file.content = undefined;
392
399
  continue;
@@ -395,7 +402,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
395
402
  if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
396
403
  if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
397
404
  const original = await getVirtualContent(source.absolute, source.file);
398
- const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, root) : undefined;
405
+ const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, cwd) : undefined;
399
406
  if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
400
407
 
401
408
  let destination: { absolute: string; file: VirtualFile } | undefined;
@@ -411,9 +418,9 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
411
418
  operations.push({
412
419
  kind: "update",
413
420
  path: source.absolute,
414
- displayPath: displayPath(root, source.absolute, hunk.path),
421
+ displayPath: displayPath(cwd, source.absolute, hunk.path),
415
422
  moveTo: moveTo!,
416
- moveDisplayPath: displayPath(root, moveTo!, hunk.moveTo!),
423
+ moveDisplayPath: displayPath(cwd, moveTo!, hunk.moveTo!),
417
424
  chunkGroups: [[...hunk.chunks]],
418
425
  content,
419
426
  });
@@ -434,7 +441,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
434
441
  operations.push({
435
442
  kind: "update",
436
443
  path: source.absolute,
437
- displayPath: displayPath(root, source.absolute, hunk.path),
444
+ displayPath: displayPath(cwd, source.absolute, hunk.path),
438
445
  chunkGroups: [[...hunk.chunks]],
439
446
  content,
440
447
  });
@@ -445,19 +452,16 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
445
452
  return operations;
446
453
  }
447
454
 
448
- function resolvePatchPath(rawPath: string, root: string): string {
449
- const absolute = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
450
- if (!isWithin(root, absolute) || absolute === root) {
451
- throw new Error(`Patch path must stay inside the current working directory: ${rawPath}`);
452
- }
453
- return absolute;
455
+ function resolvePatchPath(rawPath: string, cwd: string): string {
456
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath);
454
457
  }
455
458
 
456
- async function safePath(rawPath: string, root: string, signal?: AbortSignal): Promise<SafePath> {
459
+ async function safePath(rawPath: string, cwd: string, signal?: AbortSignal): Promise<SafePath> {
457
460
  throwIfAborted(signal);
458
- const absolute = resolvePatchPath(rawPath, root);
461
+ const absolute = resolvePatchPath(rawPath, cwd);
459
462
 
460
463
  let current = absolute;
464
+ let target: Omit<SafePath, "absolute"> = { exists: false, isDirectory: false, isFile: false };
461
465
  while (true) {
462
466
  throwIfAborted(signal);
463
467
  try {
@@ -465,25 +469,18 @@ async function safePath(rawPath: string, root: string, signal?: AbortSignal): Pr
465
469
  if (stats.isSymbolicLink()) {
466
470
  throw new Error(`Symlink paths are not allowed in apply_patch: ${rawPath}`);
467
471
  }
468
- const resolved = await realpath(current);
469
- if (!isWithin(root, resolved)) {
470
- throw new Error(`Patch path escapes the current working directory: ${rawPath}`);
471
- }
472
472
  if (current !== absolute && !stats.isDirectory()) {
473
473
  throw new Error(`Parent path is not a directory: ${rawPath}`);
474
474
  }
475
- return {
476
- absolute,
477
- exists: current === absolute,
478
- isDirectory: current === absolute && stats.isDirectory(),
479
- isFile: current === absolute && stats.isFile(),
480
- };
475
+ if (current === absolute) {
476
+ target = { exists: true, isDirectory: stats.isDirectory(), isFile: stats.isFile() };
477
+ }
481
478
  } catch (error) {
482
479
  if (!isMissingPathError(error)) throw error;
483
- const parent = dirname(current);
484
- if (parent === current) throw new Error(`Cannot resolve patch path: ${rawPath}`);
485
- current = parent;
486
480
  }
481
+ const parent = dirname(current);
482
+ if (parent === current) return { absolute, ...target };
483
+ current = parent;
487
484
  }
488
485
  }
489
486
 
@@ -528,7 +525,7 @@ async function openSecureParentDirectory(rootFd: number, root: string, absolute:
528
525
  const relativeParent = relative(root, parentPath);
529
526
  const components = relativeParent ? relativeParent.split(sep) : [];
530
527
  if (components.some((component) => !component || component === "." || component === "..")) {
531
- throw new Error(`Patch path must stay inside the current working directory: ${absolute}`);
528
+ throw new Error(`Patch path cannot be traversed securely: ${absolute}`);
532
529
  }
533
530
 
534
531
  let current = rootFd;
@@ -727,11 +724,6 @@ function displayPath(root: string, absolute: string, fallback: string): string {
727
724
  return relativePath && !relativePath.startsWith("..") ? relativePath : fallback;
728
725
  }
729
726
 
730
- function isWithin(root: string, candidate: string): boolean {
731
- const path = relative(root, candidate);
732
- return path === "" || (!path.startsWith("..") && !isAbsolute(path));
733
- }
734
-
735
727
  function isMissingPathError(error: unknown): boolean {
736
728
  return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
737
729
  }
@@ -5,6 +5,7 @@
5
5
  // (it never throws) to drive a live, write-style glimpse of the content plus a running
6
6
  // added/removed tally. It reuses Pi's shared `renderDiff` primitive for +/- coloring, so the
7
7
  // preview stays consistent with the built-in `edit` tool's diff preview.
8
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
8
9
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
9
10
 
10
11
  const FILE_ADD = "*** Add File: ";
@@ -26,6 +27,11 @@ const MAX_PREVIEW_BYTES = 256 * 1024;
26
27
  const MAX_PREVIEW_FILES = 500;
27
28
  /** Cap a single rendered glimpse line so minified/generated content cannot flood the TUI. */
28
29
  const PREVIEW_LINE_CHARS = 200;
30
+ const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
31
+
32
+ function sanitizeTerminalText(text: string): string {
33
+ return stripTerminalSequences(text).replace(TERMINAL_CONTROL_CHARS, "").replace(/\t/g, " ");
34
+ }
29
35
 
30
36
  export type PatchLineType = "add" | "del" | "ctx";
31
37
 
@@ -156,8 +162,10 @@ function fileMarkLabel(file: PatchPreviewFile, theme: Theme): string {
156
162
  }
157
163
 
158
164
  function filePathLabel(file: PatchPreviewFile, theme: Theme): string {
159
- const path = theme.fg("accent", truncatePath(file.path));
160
- return file.moveTo ? `${path} ${theme.fg("muted", "->")} ${theme.fg("accent", truncatePath(file.moveTo))}` : path;
165
+ const path = theme.fg("accent", truncatePath(sanitizeTerminalText(file.path)));
166
+ return file.moveTo
167
+ ? `${path} ${theme.fg("muted", "->")} ${theme.fg("accent", truncatePath(sanitizeTerminalText(file.moveTo)))}`
168
+ : path;
161
169
  }
162
170
 
163
171
  function formatFileRosterLine(file: PatchPreviewFile, theme: Theme): string {
@@ -177,7 +185,8 @@ function countLines(file: PatchPreviewFile, type: PatchLineType): number {
177
185
  */
178
186
  function renderGlimpseLine(line: PatchPreviewLine, theme: Theme): string {
179
187
  const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
180
- const body = line.text.length > PREVIEW_LINE_CHARS ? `${line.text.slice(0, PREVIEW_LINE_CHARS)}…` : line.text;
188
+ const text = sanitizeTerminalText(line.text);
189
+ const body = text.length > PREVIEW_LINE_CHARS ? `${text.slice(0, PREVIEW_LINE_CHARS)}…` : text;
181
190
  const styled = `${sign}${body}`;
182
191
  if (line.type === "add") return theme.fg("toolDiffAdded", styled);
183
192
  if (line.type === "del") return theme.fg("toolDiffRemoved", styled);
@@ -263,5 +272,5 @@ export function formatApplyPatchResultText(
263
272
  .map((part) => part.text ?? "")
264
273
  .join("\n")
265
274
  .trim();
266
- return output ? theme.fg("error", output) : undefined;
275
+ return output ? theme.fg("error", sanitizeTerminalText(output)) : undefined;
267
276
  }