pi-shorthand 0.3.0 → 0.3.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/README.md CHANGED
@@ -23,6 +23,10 @@ You also need Bun, git, and either [bubblewrap](https://github.com/containers/bu
23
23
  (Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) and `clang` (macOS:
24
24
  `curl -fsSL https://agentfs.ai/install | bash`).
25
25
 
26
+ The optional `grit()` helper needs Grit’s modules initialized before sandboxed use. Run
27
+ `npm run setup:grit` from this package’s directory if you want to use it. This explicitly runs
28
+ `grit init --global` and changes your user-level Grit state; package installation does not run it.
29
+
26
30
  ## What a program can use
27
31
 
28
32
  Anything in Bun or Node, plus these globals (no imports):
@@ -67,6 +71,9 @@ globals. Bun execution does not automatically type-check programs or run applica
67
71
  - Each run snapshots the checkout first; reflinks make that cheap where supported, while other
68
72
  filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
69
73
  a private AgentFS mount, so use paths relative to its working directory for repository files.
74
+ - Generated programs can write to the private workspace and run-specific temporary space; host files
75
+ outside those roots are read-only, including targets reached through repository symlinks. Run-history
76
+ logging has a narrow write exception. On macOS, the live checkout is also unreadable.
70
77
  - Runs against the same checkout are serialized. If another process edits a destination while a run
71
78
  is in progress, shorthand checks it again immediately before replacing it and reports a conflict.
72
79
  A non-cooperating writer can still race the final filesystem rename or removal itself.
package/overlay-macos.ts CHANGED
@@ -11,6 +11,7 @@ import { homedir, tmpdir } from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { $ } from "bun";
13
13
  import { Database } from "bun:sqlite";
14
+ import { historyEnabled, RUN_HISTORY_FILE, RUN_HISTORY_LOCK_DIR } from "./history.ts";
14
15
  import { copyStableTree } from "./overlay-linux.ts";
15
16
  import type { FilesystemEntry, Overlay } from "./runner.ts";
16
17
 
@@ -31,7 +32,9 @@ export async function openMacOverlay(repo: string, tempDir: string): Promise<Ove
31
32
  const base = path.join(tempDir, "base");
32
33
  const mountContainer = await fs.mkdtemp(path.join(await fs.realpath(tmpdir()), "pi-shorthand-workspace-"));
33
34
  const mount = path.join(mountContainer, "repo");
35
+ const scratch = path.join(mountContainer, "tmp");
34
36
  await fs.mkdir(mount);
37
+ await fs.mkdir(scratch);
35
38
  const state: RecoveryState = { runnerPid: process.pid, tempDir, mountContainer, mount };
36
39
  await writeRecoveryState(stateFile, state);
37
40
 
@@ -48,11 +51,12 @@ export async function openMacOverlay(repo: string, tempDir: string): Promise<Ove
48
51
  originalDir: base,
49
52
  writableDir: mount,
50
53
  executionDir: mount,
54
+ environment: { TMPDIR: scratch, TMP: scratch, TEMP: scratch },
51
55
  gitExcludes: ["._*"],
52
56
  wrap: (command) => [
53
57
  "/usr/bin/sandbox-exec",
54
58
  "-p",
55
- sandboxProfile(repo, tempDir, mount, stateFile, gitMetadata, processDeniedCanary, cleanupHelper),
59
+ sandboxProfile(repo, tempDir, mount, stateFile, gitMetadata, processDeniedCanary, cleanupHelper, scratch),
56
60
  ...command,
57
61
  ],
58
62
  terminateProcesses: async () => {
@@ -209,8 +213,8 @@ async function serveAndMount(
209
213
  }
210
214
  }
211
215
 
212
- /** The program may write only to its private mount, excluding the real checkout and Git metadata. */
213
- function sandboxProfile(
216
+ /** Restrict writes to the workspace, private scratch space, devices and optional run history. */
217
+ export function sandboxProfile(
214
218
  repo: string,
215
219
  tempDir: string,
216
220
  mount: string,
@@ -218,10 +222,21 @@ function sandboxProfile(
218
222
  gitMetadata: string[],
219
223
  processDeniedCanary: string,
220
224
  cleanupHelper: string,
225
+ scratch: string,
221
226
  ): string {
222
227
  return [
223
228
  "(version 1)",
224
229
  "(allow default)",
230
+ "(deny file-write*)",
231
+ `(allow file-write* (require-all (subpath ${JSON.stringify(mount)}) (require-not (subpath ${JSON.stringify(path.join(mount, ".git"))}))))`,
232
+ `(allow file-write* (subpath ${JSON.stringify(scratch)}))`,
233
+ '(allow file-write-data (literal "/dev/null") (literal "/dev/tty"))',
234
+ ...(historyEnabled()
235
+ ? [
236
+ `(allow file-write-data (literal ${JSON.stringify(RUN_HISTORY_FILE)}))`,
237
+ `(allow file-write* (subpath ${JSON.stringify(RUN_HISTORY_LOCK_DIR)}))`,
238
+ ]
239
+ : []),
225
240
  `(deny file-read* (subpath ${JSON.stringify(repo)}))`,
226
241
  `(deny file-write* (subpath ${JSON.stringify(repo)}))`,
227
242
  `(deny file-write* (subpath ${JSON.stringify(tempDir)}))`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-shorthand",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Pi tool for editing repositories with Bun programs, text edits and structural transformations.",
5
5
  "keywords": [
6
6
  "ast-grep",
@@ -33,7 +33,7 @@
33
33
  "lint": "oxlint --deny-warnings",
34
34
  "format": "oxfmt",
35
35
  "check": "tsc -p . && oxlint --deny-warnings && oxfmt --check",
36
- "postinstall": "grit init --global",
36
+ "setup:grit": "grit init --global",
37
37
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
38
38
  },
39
39
  "dependencies": {
package/runner.ts CHANGED
@@ -363,10 +363,10 @@ async function conflictingFiles(repo: string, changes: Change[]): Promise<string
363
363
  /** Compares the complete no-follow filesystem entry and rechecks the path leading to it. */
364
364
  async function destinationMatches(repo: string, change: Change): Promise<boolean> {
365
365
  const target = path.join(repo, change.file);
366
- if (!(await safeParentChain(repo, target))) return false;
366
+ if (!(await safeParentChain(repo, target, change.before === null))) return false;
367
367
  try {
368
368
  const current = await snapshotEntry(target);
369
- return entriesEqual(current, change.before) && (await safeParentChain(repo, target));
369
+ return entriesEqual(current, change.before) && (await safeParentChain(repo, target, change.before === null));
370
370
  } catch (error) {
371
371
  if (error instanceof UnsupportedEntryError) return false;
372
372
  throw error;
@@ -374,13 +374,17 @@ async function destinationMatches(repo: string, change: Change): Promise<boolean
374
374
  }
375
375
 
376
376
  /** Every existing ancestor must remain a real directory inside the checkout, never a symlink. */
377
- async function safeParentChain(repo: string, target: string): Promise<boolean> {
377
+ async function safeParentChain(repo: string, target: string, allowMissing = false): Promise<boolean> {
378
378
  const relative = path.relative(repo, target);
379
379
  if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
380
380
  let parent = path.dirname(target);
381
381
  while (parent !== repo) {
382
- const stats = await fs.lstat(parent).catch(() => null);
383
- if (!stats?.isDirectory() || stats.isSymbolicLink()) return false;
382
+ try {
383
+ const stats = await fs.lstat(parent);
384
+ if (!stats.isDirectory() || stats.isSymbolicLink()) return false;
385
+ } catch (error) {
386
+ if (!allowMissing || (error as NodeJS.ErrnoException).code !== "ENOENT") return false;
387
+ }
384
388
  parent = path.dirname(parent);
385
389
  }
386
390
  return true;
@@ -707,11 +711,59 @@ interface PreparedChange {
707
711
  installed?: { dev: number; ino: number; mode: number };
708
712
  }
709
713
 
714
+ interface CreatedDirectory {
715
+ path: string;
716
+ dev: number;
717
+ ino: number;
718
+ }
719
+
720
+ /** Create missing parents individually, checking existing ancestors without following symlinks. */
721
+ async function createParents(repo: string, target: string, created: CreatedDirectory[]): Promise<boolean> {
722
+ if (!(await safeParentChain(repo, target, true))) return false;
723
+ const parts = path.relative(repo, path.dirname(target)).split(path.sep).filter(Boolean);
724
+ let directory = repo;
725
+ for (const part of parts) {
726
+ directory = path.join(directory, part);
727
+ if (!(await safeParentChain(repo, directory))) return false;
728
+ try {
729
+ await fs.mkdir(directory);
730
+ const stats = await fs.lstat(directory);
731
+ created.push({ path: directory, dev: stats.dev, ino: stats.ino });
732
+ } catch (error) {
733
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
734
+ }
735
+ const stats = await fs.lstat(directory);
736
+ if (!stats.isDirectory() || stats.isSymbolicLink()) return false;
737
+ }
738
+ return true;
739
+ }
740
+
741
+ /** Remove only empty, unchanged directories created by this transaction. */
742
+ export async function applyChanges(
743
+ repo: string,
744
+ changes: Change[],
745
+ options: { abort: AbortSignal; testHooks?: ApplicationTestHooks },
746
+ ): Promise<{ applied: Change[]; conflicts: string[]; warnings: string[] }> {
747
+ const created: CreatedDirectory[] = [];
748
+ try {
749
+ return await applyPreparedChanges(repo, changes, options, created);
750
+ } finally {
751
+ for (const directory of created.toReversed()) {
752
+ if (!(await safeParentChain(repo, directory.path))) continue;
753
+ const stats = await fs.lstat(directory.path).catch(() => null);
754
+ if (stats?.isDirectory() && stats.dev === directory.dev && stats.ino === directory.ino) {
755
+ await fs.rmdir(directory.path).catch(() => {});
756
+ }
757
+ }
758
+ }
759
+ }
760
+
710
761
  /** Prepares every resource first, then rolls the complete commit back on any failure or conflict. */
711
- async function applyChanges(
762
+ async function applyPreparedChanges(
712
763
  repo: string,
713
764
  changes: Change[],
714
765
  options: { abort: AbortSignal; testHooks?: ApplicationTestHooks },
766
+ created: CreatedDirectory[],
715
767
  ): Promise<{ applied: Change[]; conflicts: string[]; warnings: string[] }> {
716
768
  const { abort, testHooks = {} } = options;
717
769
  const {
@@ -727,7 +779,10 @@ async function applyChanges(
727
779
  try {
728
780
  for (const change of changes) {
729
781
  const target = path.join(repo, change.file);
730
- if (!(await safeParentChain(repo, target))) {
782
+ if (
783
+ !(await destinationMatches(repo, change)) ||
784
+ (change.after && !(await createParents(repo, target, created)))
785
+ ) {
731
786
  await cleanupPrepared(prepared);
732
787
  return { applied: [], conflicts: await conflictingFiles(repo, changes), warnings: [] };
733
788
  }
@@ -158,8 +158,9 @@ belong to `code`, not necessarily ordinary shell calls. `node:fs` and ordinary B
158
158
  ## Execution options
159
159
 
160
160
  Programs run in an isolated repository workspace. Use relative paths: the live checkout's absolute
161
- path is inaccessible on macOS. On Linux, host paths outside the repository are read-only and
162
- `$TMPDIR` is private to the run. Writes to `.git` are blocked.
161
+ path is inaccessible on macOS. On both platforms, host paths outside the workspace are read-only
162
+ (including external symlink targets), apart from run-history logging. `$TMPDIR` is private to the run.
163
+ Writes to `.git` are blocked.
163
164
 
164
165
  The default timeout is two seconds; request more for longer transformations.
165
166
  By default a failed program applies nothing. `rollback: "file"` can retain closed files after a