@rjshrjndrn/pi-sandbox 0.1.0 → 0.1.2

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
@@ -31,7 +31,7 @@ When you approve, the parent directory is remembered for the rest of the session
31
31
 
32
32
  ```bash
33
33
  # As a pi package
34
- pi install @rjshrjndrn/pi-sandbox
34
+ pi install npm:@rjshrjndrn/pi-sandbox
35
35
 
36
36
  # Or test locally
37
37
  pi -e ./pi-sandbox/extensions/index.ts
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@rjshrjndrn/pi-sandbox",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Filesystem boundary enforcement for pi — prompts before the agent escapes your project",
5
- "keywords": ["pi-package", "pi-extension", "sandbox", "filesystem", "permissions"],
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "sandbox",
9
+ "filesystem",
10
+ "permissions"
11
+ ],
6
12
  "license": "MIT",
7
13
  "type": "module",
8
14
  "pi": {
9
- "extensions": ["./extensions"]
15
+ "extensions": [
16
+ "./extensions"
17
+ ]
10
18
  },
11
19
  "peerDependencies": {
12
20
  "@mariozechner/pi-coding-agent": "*",
package/src/boundary.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import { execSync } from "node:child_process"
2
3
  import { state } from "./state.js"
3
4
 
4
5
  /**
5
6
  * Detect the project filesystem boundary.
6
7
  *
7
- * Runs `git rev-parse --show-toplevel` to find the git worktree root.
8
- * Falls back to `cwd` if git is not available or the directory is not a git repo.
8
+ * Tries multiple methods to find the git worktree root:
9
+ * 1. pi.exec() (preferred, uses pi's exec infrastructure)
10
+ * 2. child_process.execSync() (fallback, direct OS call)
11
+ * 3. Falls back to `cwd` if git is not available or not a git repo.
9
12
  */
10
13
  export async function detectBoundary(
11
14
  pi: ExtensionAPI,
12
15
  cwd: string
13
16
  ): Promise<{ boundary: string; source: "git" | "cwd" }> {
17
+ // Try pi.exec first
14
18
  try {
15
19
  const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], {
16
20
  timeout: 5000,
@@ -20,8 +24,24 @@ export async function detectBoundary(
20
24
  return { boundary: toplevel, source: "git" }
21
25
  }
22
26
  } catch {
23
- // git not available or other errorfall through to CWD
27
+ // pi.exec may not be available in all contextstry direct exec
24
28
  }
29
+
30
+ // Fallback: direct child_process
31
+ try {
32
+ const toplevel = execSync("git rev-parse --show-toplevel", {
33
+ cwd,
34
+ timeout: 5000,
35
+ encoding: "utf-8",
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ }).trim()
38
+ if (toplevel.length > 0) {
39
+ return { boundary: toplevel, source: "git" }
40
+ }
41
+ } catch {
42
+ // git not available or not a git repo
43
+ }
44
+
25
45
  return { boundary: cwd, source: "cwd" }
26
46
  }
27
47
 
@@ -30,13 +50,22 @@ export async function detectBoundary(
30
50
  */
31
51
  export function registerBoundaryDetection(pi: ExtensionAPI) {
32
52
  pi.on("session_start", async (_event, ctx) => {
33
- const { boundary, source } = await detectBoundary(pi, ctx.cwd)
34
- state.boundary = boundary
53
+ try {
54
+ const { boundary, source } = await detectBoundary(pi, ctx.cwd)
55
+ state.boundary = boundary
35
56
 
36
- const label =
37
- source === "git"
38
- ? `🔒 pi-sandbox: boundary set to ${boundary} (git worktree)`
39
- : `🔒 pi-sandbox: boundary set to ${boundary} (cwd fallback)`
40
- ctx.ui.notify(label, "info")
57
+ const label =
58
+ source === "git"
59
+ ? `🔒 pi-sandbox: boundary set to ${boundary} (git worktree)`
60
+ : `🔒 pi-sandbox: boundary set to ${boundary} (cwd fallback)`
61
+ ctx.ui.notify(label, "info")
62
+ } catch (err) {
63
+ // Last resort: use cwd as boundary
64
+ state.boundary = ctx.cwd
65
+ ctx.ui.notify(
66
+ `🔒 pi-sandbox: boundary set to ${ctx.cwd} (error fallback: ${err})`,
67
+ "warning"
68
+ )
69
+ }
41
70
  })
42
71
  }
@@ -14,7 +14,7 @@ function mockPiExecThrows() {
14
14
  }
15
15
 
16
16
  describe("detectBoundary", () => {
17
- it("returns git worktree root when git succeeds", async () => {
17
+ it("returns git worktree root when pi.exec succeeds", async () => {
18
18
  const pi = mockPi({
19
19
  stdout: "/Users/test/project\n",
20
20
  stderr: "",
@@ -26,46 +26,50 @@ describe("detectBoundary", () => {
26
26
  expect(result.source).toBe("git")
27
27
  })
28
28
 
29
- it("falls back to cwd when git returns non-zero exit code", async () => {
29
+ it("trims whitespace from git output", async () => {
30
30
  const pi = mockPi({
31
- stdout: "",
32
- stderr: "fatal: not a git repository",
33
- code: 128,
31
+ stdout: " /Users/test/project \n",
32
+ stderr: "",
33
+ code: 0,
34
34
  killed: false,
35
35
  })
36
- const result = await detectBoundary(pi, "/Users/test/not-a-repo")
37
- expect(result.boundary).toBe("/Users/test/not-a-repo")
38
- expect(result.source).toBe("cwd")
36
+ const result = await detectBoundary(pi, "/Users/test/project/src")
37
+ expect(result.boundary).toBe("/Users/test/project")
38
+ expect(result.source).toBe("git")
39
39
  })
40
40
 
41
- it("falls back to cwd when git is not available", async () => {
41
+ it("falls back to execSync when pi.exec fails", async () => {
42
+ // pi.exec throws, but we're in a real git repo so execSync will find it
42
43
  const pi = mockPiExecThrows()
43
- const result = await detectBoundary(pi, "/Users/test/no-git")
44
- expect(result.boundary).toBe("/Users/test/no-git")
45
- expect(result.source).toBe("cwd")
44
+ const result = await detectBoundary(pi, process.cwd())
45
+ // Should still detect git via execSync fallback
46
+ expect(result.source).toBe("git")
47
+ expect(result.boundary.length).toBeGreaterThan(0)
46
48
  })
47
49
 
48
- it("falls back to cwd when git returns empty stdout", async () => {
50
+ it("falls back to cwd in a non-git directory", async () => {
49
51
  const pi = mockPi({
50
52
  stdout: "",
51
- stderr: "",
52
- code: 0,
53
+ stderr: "fatal: not a git repository",
54
+ code: 128,
53
55
  killed: false,
54
56
  })
55
- const result = await detectBoundary(pi, "/Users/test/empty")
56
- expect(result.boundary).toBe("/Users/test/empty")
57
+ // Use /tmp which is not a git repo
58
+ const result = await detectBoundary(pi, "/tmp")
59
+ expect(result.boundary).toBe("/tmp")
57
60
  expect(result.source).toBe("cwd")
58
61
  })
59
62
 
60
- it("trims whitespace from git output", async () => {
63
+ it("falls back to cwd when pi.exec returns empty stdout", async () => {
61
64
  const pi = mockPi({
62
- stdout: " /Users/test/project \n",
65
+ stdout: "",
63
66
  stderr: "",
64
67
  code: 0,
65
68
  killed: false,
66
69
  })
67
- const result = await detectBoundary(pi, "/Users/test/project/src")
68
- expect(result.boundary).toBe("/Users/test/project")
69
- expect(result.source).toBe("git")
70
+ // execSync will also fail in /tmp
71
+ const result = await detectBoundary(pi, "/tmp")
72
+ expect(result.boundary).toBe("/tmp")
73
+ expect(result.source).toBe("cwd")
70
74
  })
71
75
  })