pi-shorthand 0.5.0 → 0.6.0

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
@@ -20,9 +20,10 @@ xcode-select --install
20
20
  curl -fsSL https://agentfs.ai/install | bash
21
21
  ```
22
22
 
23
- On Linux, install bubblewrap 0.9 or later, a C compiler, and `lsof`. For Debian and Ubuntu:
23
+ On Linux, install bubblewrap 0.11 or later, a C compiler, and `lsof`. For Debian and Ubuntu, check the available bubblewrap version before installing:
24
24
 
25
25
  ```sh
26
+ apt-cache policy bubblewrap
26
27
  sudo apt install bubblewrap build-essential lsof
27
28
  ```
28
29
 
@@ -31,3 +32,5 @@ sudo apt install bubblewrap build-essential lsof
31
32
  pi-shorthand gives Pi a programming environment instead of a patch format. This makes multi-file and structural edits possible in one call, but does not guarantee that Pi will find it easier or more reliable than its built-in edit tool. Which works better depends on the model and the task.
32
33
 
33
34
  Programs edit a private workspace, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back failed or interrupted edits. Concurrent edits can cause a run to be rejected; conflict detection is best-effort, not an atomic commit.
35
+
36
+ The `code` tool uses Pi's working directory by default. Pass `cwd` to target another checkout; relative paths are resolved from Pi's working directory. For example, when Pi starts in a bare worktree container, `cwd: "child"` targets its `child` worktree. The chosen directory must be inside a Git worktree.
package/display.ts CHANGED
@@ -21,8 +21,15 @@ const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files
21
21
  const LISTED_FILES = 8; // …showing this many, then "and N more files"
22
22
  const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
23
23
 
24
- export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
25
- const settings = [args.rollback === "all" && "rollback all", args.timeout && `program timeout ${args.timeout}s`];
24
+ export function callLine(
25
+ args: { title?: string; cwd?: string; timeout?: number; rollback?: string },
26
+ theme: Theme,
27
+ ): string {
28
+ const settings = [
29
+ args.cwd && `cwd ${args.cwd}`,
30
+ args.rollback === "all" && "rollback all",
31
+ args.timeout && `program timeout ${args.timeout}s`,
32
+ ];
26
33
  const suffix = settings.filter(Boolean).join(", ");
27
34
  return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
28
35
  }
package/index.ts CHANGED
@@ -35,7 +35,7 @@ class RunnerError extends Error {
35
35
  // Runs typically take well under a second. Longer transformations can request more time.
36
36
  const DEFAULT_TIMEOUT_SECONDS = 2;
37
37
 
38
- const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
38
+ const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
39
39
 
40
40
  Common operations:
41
41
  - edit({ path, oldText, newText }) replaces exactly one literal occurrence; missing or ambiguous text is an error. Use text edits for known source, structural matching when it saves enumerating occurrences or preserves varying syntax.
@@ -70,6 +70,12 @@ export default function (pi: ExtensionAPI) {
70
70
  parameters: Type.Object({
71
71
  title: Type.String({ description: "A few words describing the change, shown to the user" }),
72
72
  program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
73
+ cwd: Type.Optional(
74
+ Type.String({
75
+ description:
76
+ "Working directory for the program; relative to Pi's working directory, or an absolute path. Defaults to Pi's working directory. Must be inside a git worktree.",
77
+ }),
78
+ ),
73
79
  rollback: Type.Optional(
74
80
  StringEnum(["all", "file"] as const, {
75
81
  description:
@@ -95,7 +101,7 @@ export default function (pi: ExtensionAPI) {
95
101
  try {
96
102
  result = await runWithBun(
97
103
  {
98
- cwd: ctx.cwd,
104
+ cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
99
105
  program: params.program,
100
106
  timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
101
107
  rollback: params.rollback ?? "file",
package/linux-observer.c CHANGED
@@ -37,6 +37,7 @@ static const char *repository;
37
37
  static size_t repository_length;
38
38
  static pid_t initial;
39
39
  static volatile sig_atomic_t stopping;
40
+ static int unresolved_external_permission;
40
41
 
41
42
  enum observation_kind {
42
43
  OBSERVE_CONTENTS = 'C',
@@ -121,6 +122,61 @@ static void descriptor(pid_t pid, int fd, char result[PATH_MAX]) {
121
122
  result[length] = 0;
122
123
  if (within(result) && strstr(result, " (deleted)")) die("unresolved deleted repository descriptor");
123
124
  }
125
+ /* A missing getprocattr hook reports EINVAL. Other errors, including a label
126
+ * larger than our buffer, leave the access context unknown. */
127
+ static int security_label(const char *path, char label[4096], size_t *length) {
128
+ int fd = open(path, O_RDONLY|O_CLOEXEC);
129
+ if (fd < 0) return -1;
130
+ size_t used = 0;
131
+ for (;;) {
132
+ char extra;
133
+ char *destination = used < 4096 ? label + used : &extra;
134
+ size_t available = used < 4096 ? 4096 - used : 1;
135
+ ssize_t count = read(fd, destination, available);
136
+ if (count < 0 && errno == EINTR) continue;
137
+ if (count < 0) {
138
+ int error = errno;
139
+ close(fd);
140
+ return used == 0 && error == EINVAL ? 0 : -1;
141
+ }
142
+ if (!count) { close(fd); *length = used; return 1; }
143
+ if (used == 4096) { close(fd); return -1; }
144
+ used += count;
145
+ }
146
+ }
147
+ static int same_access_context(pid_t pid) {
148
+ char tracee_path[96], tracee[4096], self[4096];
149
+ snprintf(tracee_path, sizeof(tracee_path), "/proc/%d/status", pid);
150
+ FILE *a = fopen(tracee_path, "r"), *b = fopen("/proc/self/status", "r");
151
+ if (!a || !b) { if (a) fclose(a); if (b) fclose(b); return 0; }
152
+ const char *fields[] = { "Uid:", "Gid:", "Groups:", "CapEff:" };
153
+ unsigned matched = 0;
154
+ while (fgets(tracee, sizeof(tracee), a)) {
155
+ for (size_t j = 0; j < sizeof(fields)/sizeof(*fields); j++) {
156
+ if (strncmp(tracee, fields[j], strlen(fields[j]))) continue;
157
+ if (!strchr(tracee, '\n')) { fclose(a); fclose(b); return 0; }
158
+ rewind(b);
159
+ int found = 0;
160
+ while (fgets(self, sizeof(self), b)) {
161
+ if (!strncmp(self, fields[j], strlen(fields[j]))) {
162
+ if (!strchr(self, '\n') || strcmp(tracee, self)) { fclose(a); fclose(b); return 0; }
163
+ found = 1;
164
+ break;
165
+ }
166
+ }
167
+ matched += found;
168
+ }
169
+ }
170
+ fclose(a); fclose(b);
171
+ if (matched != sizeof(fields)/sizeof(*fields)) return 0;
172
+ snprintf(tracee_path, sizeof(tracee_path), "/proc/%d/attr/current", pid);
173
+ size_t tracee_length = 0, self_length = 0;
174
+ int tracee_label = security_label(tracee_path, tracee, &tracee_length);
175
+ int self_label = security_label("/proc/self/attr/current", self, &self_length);
176
+ return (tracee_label == 0 && self_label == 0) ||
177
+ (tracee_label == 1 && self_label == 1 && tracee_length == self_length &&
178
+ !memcmp(tracee, self, tracee_length));
179
+ }
124
180
  /* Walk symlinks in the tracee's mount view, recording each repository link.
125
181
  * Never use realpath(/proc/PID/root/...): that can resolve against the host mount.
126
182
  */
@@ -182,7 +238,17 @@ static void resolve_name(pid_t pid, int fd, const char *input, int follow, char
182
238
  snprintf(visible, sizeof(visible), "/proc/%d/root%s", pid, result);
183
239
  struct stat info;
184
240
  int exists = lstat(visible, &info);
185
- if (exists < 0 && errno != ENOENT && errno != ENOTDIR) die("cannot inspect tracee path");
241
+ if (exists < 0 && errno != ENOENT && errno != ENOTDIR) {
242
+ if ((errno == EACCES || errno == EPERM) && !within(result) && same_access_context(pid)) {
243
+ /* Matching filesystem credentials, capabilities and security
244
+ * label make this external denial apply to the tracee too.
245
+ * Confirm the syscall is denied before resuming it. */
246
+ unresolved_external_permission = 1;
247
+ result[0] = 0;
248
+ return;
249
+ }
250
+ die("cannot inspect tracee path");
251
+ }
186
252
  if ((!*rest && !follow) || exists < 0 || !S_ISLNK(info.st_mode)) {
187
253
  snprintf(pending, sizeof(pending), "%s", rest);
188
254
  continue;
@@ -353,6 +419,7 @@ struct task {
353
419
  int initial_stop;
354
420
  int registered;
355
421
  int waiting;
422
+ int checking_permission;
356
423
  struct task *next;
357
424
  };
358
425
  static struct task *tasks;
@@ -372,7 +439,8 @@ static struct task *task(pid_t pid) {
372
439
  * is not completion: retain the live record until wait reports its death or
373
440
  * an exec event explicitly identifies the replaced thread ID. */
374
441
  static void resume_task(pid_t pid, int signal) {
375
- if (ptrace(PTRACE_CONT, pid, 0, signal) && errno != ESRCH) die("trace resume failed");
442
+ int request = task(pid)->checking_permission ? PTRACE_SYSCALL : PTRACE_CONT;
443
+ if (ptrace(request, pid, 0, signal) && errno != ESRCH) die("trace resume failed");
376
444
  }
377
445
  static void syscall_entry(pid_t pid, struct ptrace_syscall_info *info) {
378
446
  __u64 *a = info->seccomp.args; long nr = info->seccomp.nr;
@@ -514,6 +582,8 @@ static void exec_event(pid_t pid) {
514
582
  unsigned long former;
515
583
  if (ptrace(PTRACE_GETEVENTMSG, pid, 0, &former)) die("missing exec thread identity");
516
584
  struct task *current = task(pid);
585
+ if (current->checking_permission || (former != (unsigned long)pid && task(former)->checking_permission))
586
+ die("unresolved external path was executed");
517
587
  if (former != (unsigned long)pid) {
518
588
  current->bootstrap = task(former)->bootstrap;
519
589
  task(former)->alive = 0;
@@ -559,7 +629,7 @@ int main(int argc, char **argv) {
559
629
  int status, exit_code = 125;
560
630
  if (waitpid(initial, &status, 0) != initial || !WIFSTOPPED(status)) die("initial trace stop");
561
631
  long flags = PTRACE_O_TRACEFORK|PTRACE_O_TRACEVFORK|PTRACE_O_TRACECLONE|PTRACE_O_TRACEEXEC|
562
- PTRACE_O_TRACESECCOMP|PTRACE_O_EXITKILL;
632
+ PTRACE_O_TRACESECCOMP|PTRACE_O_TRACESYSGOOD|PTRACE_O_EXITKILL;
563
633
  if (ptrace(PTRACE_SETOPTIONS, initial, 0, flags)) die("ptrace options");
564
634
  task(initial)->bootstrap = BEFORE_BWRAP_EXEC;
565
635
  task(initial)->registered = 1;
@@ -593,7 +663,22 @@ int main(int argc, char **argv) {
593
663
  struct ptrace_syscall_info info;
594
664
  if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0 || info.op != PTRACE_SYSCALL_INFO_SECCOMP)
595
665
  die("syscall information unavailable");
596
- syscall_entry(pid, &info); signal = 0;
666
+ unresolved_external_permission = 0;
667
+ syscall_entry(pid, &info);
668
+ if (unresolved_external_permission) task(pid)->checking_permission = 1;
669
+ signal = 0;
670
+ } else if (signal == (SIGTRAP|0x80) && task(pid)->checking_permission) {
671
+ struct ptrace_syscall_info info;
672
+ if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0)
673
+ die("unresolved external path result unavailable");
674
+ if (info.op == PTRACE_SYSCALL_INFO_EXIT) {
675
+ if (info.exit.rval != -EACCES && info.exit.rval != -EPERM)
676
+ die("unresolved external path was accessed");
677
+ task(pid)->checking_permission = 0;
678
+ } else if (info.op != PTRACE_SYSCALL_INFO_ENTRY) {
679
+ die("unresolved external path result unavailable");
680
+ }
681
+ signal = 0;
597
682
  } else if (event == PTRACE_EVENT_FORK || event == PTRACE_EVENT_VFORK || event == PTRACE_EVENT_CLONE) {
598
683
  descendant_event(pid);
599
684
  signal = 0;
package/overlay-linux.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Linux: bubblewrap (0.9+) mounts a kernel overlayfs over the repository in a private mount
2
+ * Linux: bubblewrap (0.11+) mounts a kernel overlayfs over the repository in a private mount
3
3
  * namespace, so only the program sees it. Its writes land in an upper directory in tempDir, which
4
4
  * is also where the changes are read from. There's nothing to undo afterwards.
5
5
  */
@@ -14,7 +14,7 @@ import type { TransactionJournal } from "./transaction-journal.ts";
14
14
 
15
15
  export async function openLinuxOverlay(repo: string, tempDir: string): Promise<Overlay> {
16
16
  const bwrap = Bun.which("bwrap");
17
- if (!bwrap) throw new Error("The code tool needs bubblewrap (0.9 or later) on Linux.");
17
+ if (!bwrap) throw new Error("The code tool needs bubblewrap (0.11 or later) on Linux.");
18
18
 
19
19
  // Best-effort live lower: validation does not remove OverlayFS's documented
20
20
  // restriction on concurrent external modifications of an underlying layer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-shorthand",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pi tool for editing repositories with Bun programs, text edits and structural transformations.",
5
5
  "keywords": [
6
6
  "ast-grep",