@rjshrjndrn/pi-sandbox 0.1.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/Makefile ADDED
@@ -0,0 +1,63 @@
1
+ .PHONY: install test typecheck build publish patch minor major clean help
2
+
3
+ # ── Config ───────────────────────────────────────────────────────────────────
4
+
5
+ PACKAGE_NAME := $(shell node -p "require('./package.json').name" 2>/dev/null)
6
+ VERSION := $(shell node -p "require('./package.json').version" 2>/dev/null)
7
+
8
+ # ── Default ──────────────────────────────────────────────────────────────────
9
+
10
+ help:
11
+ @echo "pi-sandbox — $(PACKAGE_NAME) v$(VERSION)"
12
+ @echo ""
13
+ @echo " make install Install dependencies"
14
+ @echo " make test Run tests"
15
+ @echo " make typecheck Type-check TypeScript (no emit)"
16
+ @echo " make build install + typecheck + test"
17
+ @echo " make patch Bump patch version (0.1.0 → 0.1.1) and publish"
18
+ @echo " make minor Bump minor version (0.1.0 → 0.2.0) and publish"
19
+ @echo " make major Bump major version (0.1.0 → 1.0.0) and publish"
20
+ @echo " make publish Publish current version to npm"
21
+ @echo " make clean Remove node_modules"
22
+
23
+ # ── Core tasks ───────────────────────────────────────────────────────────────
24
+
25
+ install:
26
+ npm install
27
+
28
+ test:
29
+ npm test
30
+
31
+ typecheck:
32
+ npx tsc --noEmit --strict --skipLibCheck --moduleResolution bundler --module esnext \
33
+ --target esnext --allowImportingTsExtensions \
34
+ extensions/index.ts $(wildcard src/*.ts)
35
+
36
+ build: install typecheck test
37
+ @echo "✓ Build complete — $(PACKAGE_NAME) v$(VERSION)"
38
+
39
+ # ── Publishing ───────────────────────────────────────────────────────────────
40
+
41
+ publish: build
42
+ npm publish --access public
43
+ @echo "✓ Published $(PACKAGE_NAME) v$(VERSION)"
44
+
45
+ patch: build
46
+ npm version patch
47
+ npm publish --access public
48
+ @echo "✓ Published $(PACKAGE_NAME) v$(shell node -p "require('./package.json').version")"
49
+
50
+ minor: build
51
+ npm version minor
52
+ npm publish --access public
53
+ @echo "✓ Published $(PACKAGE_NAME) v$(shell node -p "require('./package.json').version")"
54
+
55
+ major: build
56
+ npm version major
57
+ npm publish --access public
58
+ @echo "✓ Published $(PACKAGE_NAME) v$(shell node -p "require('./package.json').version")"
59
+
60
+ # ── Cleanup ──────────────────────────────────────────────────────────────────
61
+
62
+ clean:
63
+ rm -rf node_modules
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # pi-sandbox
2
+
3
+ Filesystem boundary enforcement for [pi](https://github.com/badlogic/pi-mono). Prompts you before the agent reads or writes files outside your project.
4
+
5
+ ## How it works
6
+
7
+ On session start, `pi-sandbox` detects your project boundary:
8
+
9
+ 1. Runs `git rev-parse --show-toplevel` to find the git worktree root
10
+ 2. Falls back to the current working directory if not in a git repo
11
+
12
+ Then it intercepts every file tool call (`read`, `write`, `edit`, `grep`, `find`, `ls`):
13
+
14
+ - **Inside boundary** → allowed silently
15
+ - **Outside boundary** → you get a confirmation prompt
16
+ - **Previously approved directory** → allowed silently (remembered for the session)
17
+
18
+ ```
19
+ 🔒 pi-sandbox: path outside project
20
+
21
+ Tool: read
22
+ Path: /Users/you/.ssh/config
23
+ Boundary: /Users/you/project
24
+
25
+ Allow this access? (y/n)
26
+ ```
27
+
28
+ When you approve, the parent directory is remembered for the rest of the session.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # As a pi package
34
+ pi install @rjshrjndrn/pi-sandbox
35
+
36
+ # Or test locally
37
+ pi -e ./pi-sandbox/extensions/index.ts
38
+ ```
39
+
40
+ ## Limitations
41
+
42
+ - **No bash coverage** — the `bash` tool is not intercepted. The agent can still access files outside the boundary via shell commands. This will be addressed in a future version.
43
+ - **Session-scoped memory only** — approvals reset when you start a new session.
44
+ - **No configuration** — the boundary is always the git worktree root (or CWD). Custom boundaries and allow/deny patterns are planned for a future version.
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ cd pi-sandbox
50
+ npm install
51
+ npm test
52
+ ```
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import { registerBoundaryDetection } from "../src/boundary.js"
3
+ import { registerGuard } from "../src/guard.js"
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ // Boundary detection — must be first so guard has the boundary available
7
+ registerBoundaryDetection(pi)
8
+
9
+ // Tool call guard — intercepts file tools and checks containment
10
+ registerGuard(pi)
11
+ }
@@ -0,0 +1,2 @@
1
+ schema: spec-driven
2
+ created: 2026-03-17
@@ -0,0 +1,67 @@
1
+ ## Context
2
+
3
+ pi is a coding agent CLI that ships with built-in tools (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`). These tools have no filesystem boundary enforcement — they operate with the full permissions of the user's OS account. The extension API provides `tool_call` event interception that can block tool calls before execution, and `ctx.ui.confirm()` for interactive permission prompts. The sibling extension `pi-acm` in the same monorepo demonstrates the package structure, build setup, and pi extension patterns we'll follow.
4
+
5
+ ## Goals / Non-Goals
6
+
7
+ **Goals:**
8
+ - Detect the project boundary automatically from git worktree root
9
+ - Block file tool calls that escape the boundary unless the user approves
10
+ - Remember approvals per-directory for the session lifetime
11
+ - Resolve symlinks to prevent traversal attacks
12
+ - Work as a standard pi extension package (installable via `pi packages`)
13
+ - Zero configuration required — works out of the box
14
+
15
+ **Non-Goals:**
16
+ - Bash command path analysis (future change)
17
+ - Persistent approval storage across sessions
18
+ - Per-file or glob-based allow/deny rules
19
+ - Configuration files or custom boundary definitions
20
+ - Sandboxing at the OS level (namespaces, containers, etc.)
21
+
22
+ ## Decisions
23
+
24
+ ### 1. Use `tool_call` event interception, not tool overrides
25
+
26
+ **Decision**: Intercept via `pi.on("tool_call")` rather than overriding each built-in tool with `pi.registerTool()`.
27
+
28
+ **Rationale**: One handler covers all six file tools. Tool overrides would require matching each tool's exact result shape and re-implementing rendering. The `tool_call` event can block with `{ block: true, reason: "..." }` which is all we need — we don't need to modify paths or results.
29
+
30
+ **Alternative considered**: Tool overrides would give us the ability to rewrite paths or enrich results, but that's unnecessary for the MVP.
31
+
32
+ ### 2. Use `git rev-parse --show-toplevel` for boundary detection
33
+
34
+ **Decision**: Run `git rev-parse --show-toplevel` on session start via `pi.exec()`. Fall back to `ctx.cwd` if the command fails (not a git repo).
35
+
36
+ **Rationale**: Git worktree root is the natural project boundary for most development workflows. It's what OpenCode uses successfully. CWD fallback ensures the extension works for non-git projects.
37
+
38
+ **Alternative considered**: Always use CWD — too narrow for projects where you `cd` into subdirectories. Config-driven boundaries — adds complexity without clear MVP value.
39
+
40
+ ### 3. Resolve real paths before containment checks
41
+
42
+ **Decision**: Use `fs.realpath()` on both the boundary and the target path before checking containment.
43
+
44
+ **Rationale**: Symlinks can escape the boundary silently. `/project/link → /outside/secret` would pass a naive prefix check. Resolving both sides prevents this.
45
+
46
+ **Edge case**: If `realpath()` fails (broken symlink, no permission), treat as outside boundary and prompt.
47
+
48
+ ### 4. Per-directory session approval memory
49
+
50
+ **Decision**: When a user approves access to `/outside/some/file.txt`, store the directory `/outside/some/` in an in-memory `Set<string>`. Future accesses to files in that directory are auto-approved.
51
+
52
+ **Rationale**: Per-file memory would be too noisy — the agent often reads multiple files in the same directory. Per-directory strikes a balance between convenience and security. In-memory storage means approvals reset on session restart, which is the safe default.
53
+
54
+ **Alternative considered**: Per-path (exact file) — too many prompts. Persistent storage — premature for MVP, raises security questions.
55
+
56
+ ### 5. Notify on session start with detected boundary
57
+
58
+ **Decision**: Show a `ctx.ui.notify()` message on session start indicating the detected boundary path.
59
+
60
+ **Rationale**: Users should know the extension is active and what it considers the project root. Especially important when the detection falls back to CWD.
61
+
62
+ ## Risks / Trade-offs
63
+
64
+ - **[Symlink race condition]** → A symlink could be changed between `realpath()` and actual tool execution. Mitigation: acceptable for MVP — this is defense-in-depth, not a security sandbox.
65
+ - **[CWD fallback too narrow]** → If user launches pi from a subdirectory in a non-git project, the boundary may be too restrictive. Mitigation: acceptable — prompts still allow access, just requires confirmation.
66
+ - **[No bash coverage]** → The agent can still escape via `bash` tool (`cat /etc/passwd`). Mitigation: documented as out of scope. Future change will add best-effort bash path extraction.
67
+ - **[realpath failure on non-existent paths]** → `write` tool targets may not exist yet, so `realpath()` will fail. Mitigation: for non-existent paths, resolve the parent directory, or fall back to `path.resolve()` without symlink resolution.
@@ -0,0 +1,32 @@
1
+ ## Why
2
+
3
+ pi (the coding agent) has unrestricted filesystem access — it can read, write, and edit any file the user's OS account can reach. This means an agent can accidentally (or via prompt injection) wander outside the project, overwriting files in unrelated directories, leaking secrets from `~/.ssh`, or modifying system config. OpenCode solves this with a built-in permission system anchored to the git worktree. pi has no equivalent today. We need a lightweight extension that establishes a filesystem boundary and prompts the user before any tool crosses it.
4
+
5
+ ## What Changes
6
+
7
+ - New pi extension package `pi-sandbox` that enforces filesystem boundaries.
8
+ - On session start, detects the project boundary via `git rev-parse --show-toplevel` (falls back to CWD if not a git repo).
9
+ - Intercepts all file-touching tool calls (`read`, `write`, `edit`, `grep`, `find`, `ls`) via the `tool_call` event.
10
+ - Paths inside the boundary are allowed silently.
11
+ - Paths outside the boundary trigger an interactive `ctx.ui.confirm()` prompt.
12
+ - User approvals are remembered per-directory for the rest of the session (in-memory).
13
+ - Symlinks are resolved via `realpath()` before containment checks to prevent traversal.
14
+ - Bash is **out of scope** for the MVP — it will be addressed in a future change.
15
+ - No configuration file — pure convention based on git worktree detection.
16
+
17
+ ## Capabilities
18
+
19
+ ### New Capabilities
20
+ - `boundary-detection`: Detect the project filesystem boundary using git worktree root, with CWD fallback for non-git projects.
21
+ - `path-guard`: Intercept file tool calls, check path containment against the boundary, and prompt the user for out-of-boundary access.
22
+ - `session-approval`: Remember user-approved directories in-memory for the duration of the session to avoid repeated prompts.
23
+
24
+ ### Modified Capabilities
25
+ <!-- None — this is a new extension -->
26
+
27
+ ## Impact
28
+
29
+ - New package: `pi-sandbox/` alongside existing `pi-acm/` in the `pi-term` monorepo.
30
+ - No changes to pi core — uses only public extension APIs (`pi.on("tool_call")`, `ctx.ui.confirm()`, `pi.exec()`).
31
+ - Applies to all file-touching built-in tools: `read`, `write`, `edit`, `grep`, `find`, `ls`.
32
+ - Users who install this extension will see confirmation prompts when the agent tries to access files outside their project root.
@@ -0,0 +1,30 @@
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: Detect boundary from git worktree
4
+ The system SHALL detect the project filesystem boundary by running `git rev-parse --show-toplevel` on session start and storing the result as the boundary path.
5
+
6
+ #### Scenario: Git repository detected
7
+ - **WHEN** pi starts a session in a directory that is part of a git repository
8
+ - **THEN** the boundary SHALL be set to the git worktree root (the output of `git rev-parse --show-toplevel`)
9
+
10
+ #### Scenario: Nested subdirectory of git repo
11
+ - **WHEN** pi starts a session in a subdirectory of a git repository (e.g., `project/src/lib/`)
12
+ - **THEN** the boundary SHALL be set to the git worktree root, not the subdirectory
13
+
14
+ ### Requirement: Fall back to CWD for non-git projects
15
+ The system SHALL fall back to using the current working directory as the boundary when `git rev-parse --show-toplevel` fails.
16
+
17
+ #### Scenario: Not a git repository
18
+ - **WHEN** pi starts a session in a directory that is not part of any git repository
19
+ - **THEN** the boundary SHALL be set to the current working directory (`ctx.cwd`)
20
+
21
+ #### Scenario: Git command not available
22
+ - **WHEN** `git` is not installed or not on PATH
23
+ - **THEN** the boundary SHALL be set to the current working directory (`ctx.cwd`)
24
+
25
+ ### Requirement: Notify user of detected boundary
26
+ The system SHALL display a notification on session start indicating the detected boundary path and whether it was detected from git or CWD fallback.
27
+
28
+ #### Scenario: Boundary notification on start
29
+ - **WHEN** a session starts and the boundary is detected
30
+ - **THEN** the system SHALL display a notification with the boundary path (e.g., "🔒 pi-sandbox: boundary set to /Users/you/project")
@@ -0,0 +1,57 @@
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: Intercept file tool calls
4
+ The system SHALL intercept `tool_call` events for the following built-in tools: `read`, `write`, `edit`, `grep`, `find`, `ls`.
5
+
6
+ #### Scenario: File tool call is intercepted
7
+ - **WHEN** the LLM invokes any of `read`, `write`, `edit`, `grep`, `find`, or `ls`
8
+ - **THEN** the system SHALL extract the path parameter from the tool input and evaluate it against the boundary before execution proceeds
9
+
10
+ ### Requirement: Allow paths inside the boundary
11
+ The system SHALL allow tool calls whose resolved path is inside the boundary without prompting the user.
12
+
13
+ #### Scenario: Path inside boundary
14
+ - **WHEN** a file tool targets a path that resolves to a location inside the boundary
15
+ - **THEN** the tool call SHALL proceed without any user prompt
16
+
17
+ #### Scenario: Relative path resolving inside boundary
18
+ - **WHEN** a file tool targets a relative path (e.g., `./src/index.ts`) that resolves inside the boundary
19
+ - **THEN** the tool call SHALL proceed without any user prompt
20
+
21
+ ### Requirement: Prompt for paths outside the boundary
22
+ The system SHALL prompt the user with `ctx.ui.confirm()` when a file tool targets a path that resolves outside the boundary.
23
+
24
+ #### Scenario: Path outside boundary — user approves
25
+ - **WHEN** a file tool targets a path outside the boundary
26
+ - **AND** the user approves the confirmation prompt
27
+ - **THEN** the tool call SHALL proceed
28
+
29
+ #### Scenario: Path outside boundary — user denies
30
+ - **WHEN** a file tool targets a path outside the boundary
31
+ - **AND** the user denies the confirmation prompt
32
+ - **THEN** the tool call SHALL be blocked with `{ block: true, reason: "..." }`
33
+ - **AND** the reason SHALL include the blocked path and the boundary
34
+
35
+ ### Requirement: Resolve symlinks before containment check
36
+ The system SHALL resolve symlinks using `realpath()` on both the boundary and the target path before performing the containment check.
37
+
38
+ #### Scenario: Symlink escaping boundary
39
+ - **WHEN** a file tool targets `/project/link` which is a symlink to `/outside/secret`
40
+ - **THEN** the system SHALL resolve the symlink and detect that the real path is outside the boundary
41
+ - **AND** the user SHALL be prompted
42
+
43
+ #### Scenario: Realpath failure on non-existent target
44
+ - **WHEN** a file tool targets a path that does not yet exist (e.g., `write` to a new file)
45
+ - **THEN** the system SHALL resolve the nearest existing parent directory
46
+ - **AND** if the resolved parent is inside the boundary, the tool call SHALL proceed
47
+
48
+ #### Scenario: Realpath failure with no resolvable parent
49
+ - **WHEN** `realpath()` fails for the target path and no parent can be resolved
50
+ - **THEN** the system SHALL fall back to `path.resolve()` without symlink resolution
51
+
52
+ ### Requirement: Do not intercept bash tool
53
+ The system SHALL NOT intercept the `bash` tool in this version.
54
+
55
+ #### Scenario: Bash tool call
56
+ - **WHEN** the LLM invokes the `bash` tool
57
+ - **THEN** the system SHALL not evaluate or block the call
@@ -0,0 +1,34 @@
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: Remember approvals per directory
4
+ The system SHALL store approved directories in an in-memory set when the user approves an out-of-boundary access. Future tool calls targeting files in an approved directory SHALL be auto-allowed without prompting.
5
+
6
+ #### Scenario: Subsequent access to approved directory
7
+ - **WHEN** the user approves access to `/outside/some/file.txt`
8
+ - **AND** later the LLM requests access to `/outside/some/other.txt`
9
+ - **THEN** the second access SHALL be auto-allowed without prompting (because `/outside/some/` was already approved)
10
+
11
+ #### Scenario: Subdirectory of approved directory
12
+ - **WHEN** the user approves access to `/outside/some/file.txt` (approving `/outside/some/`)
13
+ - **AND** later the LLM requests access to `/outside/some/deep/nested/file.txt`
14
+ - **THEN** the access SHALL be auto-allowed (subdirectories of approved directories are included)
15
+
16
+ #### Scenario: Different directory not approved
17
+ - **WHEN** the user approves access to `/outside/some/file.txt`
18
+ - **AND** later the LLM requests access to `/outside/other/file.txt`
19
+ - **THEN** the user SHALL be prompted (different directory, not covered by previous approval)
20
+
21
+ ### Requirement: Approvals are session-scoped
22
+ The system SHALL clear all approval memory when the session ends. Approvals SHALL NOT persist across sessions.
23
+
24
+ #### Scenario: New session starts clean
25
+ - **WHEN** a new session starts
26
+ - **THEN** the approval set SHALL be empty regardless of previous session approvals
27
+
28
+ ### Requirement: Approval granularity is the parent directory
29
+ The system SHALL derive the approved directory from the parent directory of the approved file path (`path.dirname()`).
30
+
31
+ #### Scenario: Approval derived from file path
32
+ - **WHEN** the user approves access to `/tmp/output/result.json`
33
+ - **THEN** the approved directory SHALL be `/tmp/output/`
34
+ - **AND** all future accesses to files under `/tmp/output/` SHALL be auto-allowed
@@ -0,0 +1,37 @@
1
+ ## 1. Project Setup
2
+
3
+ - [x] 1.1 Create `pi-sandbox/package.json` with pi extension metadata, peer dependencies (`@mariozechner/pi-coding-agent`, `@sinclair/typebox`), and dev dependencies (`typescript`, `vitest`)
4
+ - [x] 1.2 Create `pi-sandbox/extensions/index.ts` entry point that imports and registers all modules
5
+ - [x] 1.3 Create `pi-sandbox/tsconfig.json` and basic project structure (`src/`, `tests/`)
6
+
7
+ ## 2. Boundary Detection
8
+
9
+ - [x] 2.1 Implement `src/boundary.ts` — `detectBoundary(pi, cwd)` function that runs `git rev-parse --show-toplevel` via `pi.exec()` and returns the absolute path, falling back to `cwd` on failure
10
+ - [x] 2.2 Register `session_start` handler that calls `detectBoundary()`, stores the result in module state, and displays a `ctx.ui.notify()` message with the detected boundary
11
+ - [x] 2.3 Write tests for `detectBoundary()` — git repo case, non-git fallback, git-not-installed fallback
12
+
13
+ ## 3. Path Containment
14
+
15
+ - [x] 3.1 Implement `src/containment.ts` — `isInsideBoundary(boundary, targetPath, cwd)` function that resolves the target path (handling relative paths via `path.resolve(cwd, ...)`), resolves symlinks via `fs.realpath()`, and checks if the resolved path starts with the resolved boundary
16
+ - [x] 3.2 Handle non-existent paths (for `write` tool) by walking up to the nearest existing parent directory for `realpath()` resolution, falling back to `path.resolve()` if no parent resolves
17
+ - [x] 3.3 Write tests for containment — inside paths, outside paths, relative paths, symlink escapes, non-existent target paths, broken symlinks
18
+
19
+ ## 4. Session Approval Memory
20
+
21
+ - [x] 4.1 Implement `src/approvals.ts` — `ApprovalStore` with `approve(dirPath)`, `isApproved(filePath)`, and `clear()` methods, backed by an in-memory `Set<string>` of approved directory prefixes
22
+ - [x] 4.2 `isApproved()` SHALL check if the file's resolved path starts with any approved directory prefix (supporting subdirectories)
23
+ - [x] 4.3 `approve()` SHALL store `path.dirname(filePath)` as the approved directory
24
+ - [x] 4.4 Write tests for approval store — approve file adds directory, subdirectory access allowed, different directory not approved, clear resets
25
+
26
+ ## 5. Tool Call Guard
27
+
28
+ - [x] 5.1 Implement `src/guard.ts` — `tool_call` event handler that extracts the `path` param from file tool inputs (`read`, `write`, `edit`, `grep`, `find`, `ls`), skips non-file tools
29
+ - [x] 5.2 For each intercepted call: resolve the path, check containment, check approval store, and if outside boundary and not approved, call `ctx.ui.confirm()` with tool name, path, and boundary info
30
+ - [x] 5.3 On user approval, add the directory to the approval store and allow. On denial, return `{ block: true, reason }` with the path and boundary in the reason message
31
+ - [x] 5.4 Register the `tool_call` handler in the extension entry point
32
+ - [x] 5.5 Write tests for guard logic — inside path allowed, outside path prompts, approved path skips prompt, denied path blocks
33
+
34
+ ## 6. Integration & Polish
35
+
36
+ - [x] 6.1 End-to-end manual test: install extension locally with `pi -e ./pi-sandbox/extensions/index.ts`, verify boundary detection notification, verify prompt on out-of-boundary `read`, verify approval memory
37
+ - [x] 6.2 Add `README.md` with installation instructions, what it does, and limitations (no bash coverage)
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@rjshrjndrn/pi-sandbox",
3
+ "version": "0.1.0",
4
+ "description": "Filesystem boundary enforcement for pi — prompts before the agent escapes your project",
5
+ "keywords": ["pi-package", "pi-extension", "sandbox", "filesystem", "permissions"],
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "pi": {
9
+ "extensions": ["./extensions"]
10
+ },
11
+ "peerDependencies": {
12
+ "@mariozechner/pi-coding-agent": "*",
13
+ "@sinclair/typebox": "*"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.4.0",
17
+ "@types/node": "^20.0.0",
18
+ "vitest": "^1.6.0"
19
+ },
20
+ "scripts": {
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ }
24
+ }
@@ -0,0 +1,47 @@
1
+ import { dirname, sep } from "node:path"
2
+
3
+ /**
4
+ * In-memory store of approved directories for the current session.
5
+ * When a user approves access to a file outside the boundary,
6
+ * its parent directory is stored. Future accesses to files in
7
+ * approved directories (including subdirectories) are auto-allowed.
8
+ */
9
+ export class ApprovalStore {
10
+ private approved = new Set<string>()
11
+
12
+ /**
13
+ * Approve a file path by storing its parent directory.
14
+ * The directory is normalized to end with a path separator.
15
+ */
16
+ approve(filePath: string): void {
17
+ const dir = dirname(filePath)
18
+ const normalized = dir.endsWith(sep) ? dir : dir + sep
19
+ this.approved.add(normalized)
20
+ }
21
+
22
+ /**
23
+ * Check if a file path is in an approved directory.
24
+ * Returns true if the file's path starts with any approved directory prefix,
25
+ * which includes subdirectories.
26
+ */
27
+ isApproved(filePath: string): boolean {
28
+ for (const dir of this.approved) {
29
+ if (filePath === dir.slice(0, -1) || filePath.startsWith(dir)) {
30
+ return true
31
+ }
32
+ }
33
+ return false
34
+ }
35
+
36
+ /**
37
+ * Clear all approvals (e.g. on session end).
38
+ */
39
+ clear(): void {
40
+ this.approved.clear()
41
+ }
42
+
43
+ /** Number of approved directories (for testing) */
44
+ get size(): number {
45
+ return this.approved.size
46
+ }
47
+ }
@@ -0,0 +1,42 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import { state } from "./state.js"
3
+
4
+ /**
5
+ * Detect the project filesystem boundary.
6
+ *
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.
9
+ */
10
+ export async function detectBoundary(
11
+ pi: ExtensionAPI,
12
+ cwd: string
13
+ ): Promise<{ boundary: string; source: "git" | "cwd" }> {
14
+ try {
15
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], {
16
+ timeout: 5000,
17
+ })
18
+ const toplevel = result.stdout.trim()
19
+ if (result.code === 0 && toplevel.length > 0) {
20
+ return { boundary: toplevel, source: "git" }
21
+ }
22
+ } catch {
23
+ // git not available or other error — fall through to CWD
24
+ }
25
+ return { boundary: cwd, source: "cwd" }
26
+ }
27
+
28
+ /**
29
+ * Register the session_start handler that detects the boundary on startup.
30
+ */
31
+ export function registerBoundaryDetection(pi: ExtensionAPI) {
32
+ pi.on("session_start", async (_event, ctx) => {
33
+ const { boundary, source } = await detectBoundary(pi, ctx.cwd)
34
+ state.boundary = boundary
35
+
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")
41
+ })
42
+ }
@@ -0,0 +1,63 @@
1
+ import { realpath } from "node:fs/promises"
2
+ import { resolve, dirname, sep } from "node:path"
3
+
4
+ /**
5
+ * Resolve a path to its real location, handling symlinks.
6
+ * For non-existent paths (e.g. write targets), walks up to the
7
+ * nearest existing parent and resolves from there.
8
+ * Falls back to path.resolve() if nothing resolves.
9
+ */
10
+ async function resolveReal(targetPath: string): Promise<string> {
11
+ try {
12
+ return await realpath(targetPath)
13
+ } catch {
14
+ // Path doesn't exist — walk up to find the nearest existing parent
15
+ let current = dirname(targetPath)
16
+ const seen = new Set<string>()
17
+ while (current && !seen.has(current)) {
18
+ seen.add(current)
19
+ try {
20
+ const resolvedParent = await realpath(current)
21
+ // Re-append the relative suffix under the resolved parent
22
+ const suffix = targetPath.slice(current.length)
23
+ return resolvedParent + suffix
24
+ } catch {
25
+ const parent = dirname(current)
26
+ if (parent === current) break // reached filesystem root
27
+ current = parent
28
+ }
29
+ }
30
+ // Nothing resolved — fall back to plain resolve
31
+ return resolve(targetPath)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Check if a target path is inside the given boundary.
37
+ *
38
+ * Resolves symlinks on both boundary and target.
39
+ * Returns true if the resolved target starts with the resolved boundary.
40
+ */
41
+ export async function isInsideBoundary(
42
+ boundary: string,
43
+ targetPath: string,
44
+ cwd: string
45
+ ): Promise<boolean> {
46
+ // Resolve relative paths against cwd
47
+ const absoluteTarget = resolve(cwd, targetPath)
48
+
49
+ // Resolve symlinks
50
+ const resolvedBoundary = await resolveReal(boundary)
51
+ const resolvedTarget = await resolveReal(absoluteTarget)
52
+
53
+ // Normalize: ensure boundary ends with separator for prefix check
54
+ const boundaryPrefix = resolvedBoundary.endsWith(sep)
55
+ ? resolvedBoundary
56
+ : resolvedBoundary + sep
57
+
58
+ // Target is inside if it equals the boundary or starts with boundary + sep
59
+ return (
60
+ resolvedTarget === resolvedBoundary ||
61
+ resolvedTarget.startsWith(boundaryPrefix)
62
+ )
63
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,61 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ isToolCallEventType,
4
+ } from "@mariozechner/pi-coding-agent"
5
+ import { resolve } from "node:path"
6
+ import { isInsideBoundary } from "./containment.js"
7
+ import { state } from "./state.js"
8
+
9
+ /** Tools that take a `path` parameter and touch the filesystem */
10
+ const FILE_TOOLS = ["read", "write", "edit", "grep", "find", "ls"] as const
11
+
12
+ /**
13
+ * Extract the path from a file tool's input.
14
+ * All built-in file tools use `path` as the parameter name.
15
+ */
16
+ function extractPath(toolName: string, input: Record<string, any>): string | null {
17
+ if (!FILE_TOOLS.includes(toolName as any)) return null
18
+ const p = input?.path
19
+ return typeof p === "string" ? p : null
20
+ }
21
+
22
+ /**
23
+ * Register the tool_call event handler that enforces filesystem boundaries.
24
+ */
25
+ export function registerGuard(pi: ExtensionAPI) {
26
+ pi.on("tool_call", async (event, ctx) => {
27
+ // Skip if boundary not yet detected (shouldn't happen, but be safe)
28
+ if (!state.boundary) return
29
+
30
+ const targetPath = extractPath(event.toolName, event.input as Record<string, any>)
31
+ if (targetPath === null) return // Not a file tool or no path param
32
+
33
+ // Resolve to absolute
34
+ const absolutePath = resolve(ctx.cwd, targetPath)
35
+
36
+ // Check containment
37
+ const inside = await isInsideBoundary(state.boundary, targetPath, ctx.cwd)
38
+ if (inside) return // Inside boundary — allow silently
39
+
40
+ // Check approval store
41
+ if (state.approvals.isApproved(absolutePath)) return // Previously approved
42
+
43
+ // Outside boundary and not approved — ask the user
44
+ const allowed = await ctx.ui.confirm(
45
+ "🔒 pi-sandbox: path outside project",
46
+ `Tool: ${event.toolName}\nPath: ${absolutePath}\nBoundary: ${state.boundary}\n\nAllow this access?`
47
+ )
48
+
49
+ if (allowed) {
50
+ // Remember the directory for this session
51
+ state.approvals.approve(absolutePath)
52
+ return // Allow
53
+ }
54
+
55
+ // Denied — block the tool call
56
+ return {
57
+ block: true,
58
+ reason: `pi-sandbox: access denied to ${absolutePath} (outside boundary ${state.boundary})`,
59
+ }
60
+ })
61
+ }
package/src/state.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { ApprovalStore } from "./approvals.js"
2
+
3
+ /**
4
+ * Shared module-level state for pi-sandbox.
5
+ * Set during session_start, read by the guard.
6
+ */
7
+ export const state = {
8
+ /** The resolved project boundary (absolute path) */
9
+ boundary: "",
10
+ /** In-memory approval store for the current session */
11
+ approvals: new ApprovalStore(),
12
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { ApprovalStore } from "../src/approvals.js"
3
+
4
+ describe("ApprovalStore", () => {
5
+ let store: ApprovalStore
6
+
7
+ beforeEach(() => {
8
+ store = new ApprovalStore()
9
+ })
10
+
11
+ it("starts empty", () => {
12
+ expect(store.size).toBe(0)
13
+ expect(store.isApproved("/outside/file.txt")).toBe(false)
14
+ })
15
+
16
+ it("approving a file adds its parent directory", () => {
17
+ store.approve("/outside/some/file.txt")
18
+ expect(store.size).toBe(1)
19
+ })
20
+
21
+ it("allows files in the same approved directory", () => {
22
+ store.approve("/outside/some/file.txt")
23
+ expect(store.isApproved("/outside/some/other.txt")).toBe(true)
24
+ })
25
+
26
+ it("allows files in subdirectories of approved directory", () => {
27
+ store.approve("/outside/some/file.txt")
28
+ expect(store.isApproved("/outside/some/deep/nested/file.txt")).toBe(true)
29
+ })
30
+
31
+ it("rejects files in different directories", () => {
32
+ store.approve("/outside/some/file.txt")
33
+ expect(store.isApproved("/outside/other/file.txt")).toBe(false)
34
+ })
35
+
36
+ it("rejects files in parent of approved directory", () => {
37
+ store.approve("/outside/some/file.txt")
38
+ expect(store.isApproved("/outside/file.txt")).toBe(false)
39
+ })
40
+
41
+ it("supports multiple approved directories", () => {
42
+ store.approve("/tmp/output/result.json")
43
+ store.approve("/home/user/.config/settings.json")
44
+ expect(store.isApproved("/tmp/output/other.json")).toBe(true)
45
+ expect(store.isApproved("/home/user/.config/other.json")).toBe(true)
46
+ expect(store.isApproved("/var/log/syslog")).toBe(false)
47
+ expect(store.size).toBe(2)
48
+ })
49
+
50
+ it("clear resets all approvals", () => {
51
+ store.approve("/outside/some/file.txt")
52
+ store.approve("/tmp/output/result.json")
53
+ expect(store.size).toBe(2)
54
+ store.clear()
55
+ expect(store.size).toBe(0)
56
+ expect(store.isApproved("/outside/some/file.txt")).toBe(false)
57
+ })
58
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { detectBoundary } from "../src/boundary.js"
3
+
4
+ function mockPi(execResult: { stdout: string; stderr: string; code: number; killed: boolean }) {
5
+ return {
6
+ exec: vi.fn().mockResolvedValue(execResult),
7
+ } as any
8
+ }
9
+
10
+ function mockPiExecThrows() {
11
+ return {
12
+ exec: vi.fn().mockRejectedValue(new Error("git not found")),
13
+ } as any
14
+ }
15
+
16
+ describe("detectBoundary", () => {
17
+ it("returns git worktree root when git succeeds", async () => {
18
+ const pi = mockPi({
19
+ stdout: "/Users/test/project\n",
20
+ stderr: "",
21
+ code: 0,
22
+ killed: false,
23
+ })
24
+ const result = await detectBoundary(pi, "/Users/test/project/src")
25
+ expect(result.boundary).toBe("/Users/test/project")
26
+ expect(result.source).toBe("git")
27
+ })
28
+
29
+ it("falls back to cwd when git returns non-zero exit code", async () => {
30
+ const pi = mockPi({
31
+ stdout: "",
32
+ stderr: "fatal: not a git repository",
33
+ code: 128,
34
+ killed: false,
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")
39
+ })
40
+
41
+ it("falls back to cwd when git is not available", async () => {
42
+ 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")
46
+ })
47
+
48
+ it("falls back to cwd when git returns empty stdout", async () => {
49
+ const pi = mockPi({
50
+ stdout: "",
51
+ stderr: "",
52
+ code: 0,
53
+ killed: false,
54
+ })
55
+ const result = await detectBoundary(pi, "/Users/test/empty")
56
+ expect(result.boundary).toBe("/Users/test/empty")
57
+ expect(result.source).toBe("cwd")
58
+ })
59
+
60
+ it("trims whitespace from git output", async () => {
61
+ const pi = mockPi({
62
+ stdout: " /Users/test/project \n",
63
+ stderr: "",
64
+ code: 0,
65
+ killed: false,
66
+ })
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
+ })
71
+ })
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
2
+ import { isInsideBoundary } from "../src/containment.js"
3
+ import { mkdirSync, symlinkSync, rmSync, writeFileSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+
7
+ const TEST_DIR = join(tmpdir(), "pi-sandbox-test-" + Date.now())
8
+ const PROJECT = join(TEST_DIR, "project")
9
+ const OUTSIDE = join(TEST_DIR, "outside")
10
+
11
+ beforeAll(() => {
12
+ mkdirSync(join(PROJECT, "src", "lib"), { recursive: true })
13
+ mkdirSync(join(OUTSIDE, "secrets"), { recursive: true })
14
+ writeFileSync(join(PROJECT, "src", "index.ts"), "// test")
15
+ writeFileSync(join(OUTSIDE, "secrets", "key.pem"), "secret")
16
+ // Symlink inside project pointing outside
17
+ symlinkSync(OUTSIDE, join(PROJECT, "escape-link"))
18
+ })
19
+
20
+ afterAll(() => {
21
+ rmSync(TEST_DIR, { recursive: true, force: true })
22
+ })
23
+
24
+ describe("isInsideBoundary", () => {
25
+ it("allows paths inside the boundary", async () => {
26
+ expect(await isInsideBoundary(PROJECT, join(PROJECT, "src", "index.ts"), PROJECT)).toBe(true)
27
+ })
28
+
29
+ it("allows the boundary root itself", async () => {
30
+ expect(await isInsideBoundary(PROJECT, PROJECT, PROJECT)).toBe(true)
31
+ })
32
+
33
+ it("allows relative paths resolving inside", async () => {
34
+ expect(await isInsideBoundary(PROJECT, "./src/index.ts", PROJECT)).toBe(true)
35
+ })
36
+
37
+ it("allows nested subdirectories", async () => {
38
+ expect(await isInsideBoundary(PROJECT, join(PROJECT, "src", "lib"), PROJECT)).toBe(true)
39
+ })
40
+
41
+ it("rejects paths outside the boundary", async () => {
42
+ expect(await isInsideBoundary(PROJECT, OUTSIDE, PROJECT)).toBe(false)
43
+ })
44
+
45
+ it("rejects paths in sibling directories", async () => {
46
+ expect(await isInsideBoundary(PROJECT, join(OUTSIDE, "secrets", "key.pem"), PROJECT)).toBe(false)
47
+ })
48
+
49
+ it("detects symlink escapes", async () => {
50
+ // project/escape-link -> outside/
51
+ const linkTarget = join(PROJECT, "escape-link", "secrets", "key.pem")
52
+ expect(await isInsideBoundary(PROJECT, linkTarget, PROJECT)).toBe(false)
53
+ })
54
+
55
+ it("handles non-existent target paths (write to new file)", async () => {
56
+ // Parent exists and is inside boundary
57
+ const newFile = join(PROJECT, "src", "new-file.ts")
58
+ expect(await isInsideBoundary(PROJECT, newFile, PROJECT)).toBe(true)
59
+ })
60
+
61
+ it("handles non-existent target outside boundary", async () => {
62
+ const newFile = join(OUTSIDE, "new-dir", "file.txt")
63
+ expect(await isInsideBoundary(PROJECT, newFile, PROJECT)).toBe(false)
64
+ })
65
+
66
+ it("handles relative paths with .. that escape", async () => {
67
+ expect(await isInsideBoundary(PROJECT, "../../etc/passwd", PROJECT)).toBe(false)
68
+ })
69
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest"
2
+ import { state } from "../src/state.js"
3
+ import { ApprovalStore } from "../src/approvals.js"
4
+ import { isInsideBoundary } from "../src/containment.js"
5
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { tmpdir } from "node:os"
8
+
9
+ // We test the guard logic inline since the actual handler is tightly coupled
10
+ // to ExtensionAPI. We test the decision logic that the handler uses.
11
+
12
+ const TEST_DIR = join(tmpdir(), "pi-sandbox-guard-test-" + Date.now())
13
+ const PROJECT = join(TEST_DIR, "project")
14
+ const OUTSIDE = join(TEST_DIR, "outside")
15
+
16
+ beforeEach(() => {
17
+ mkdirSync(join(PROJECT, "src"), { recursive: true })
18
+ mkdirSync(OUTSIDE, { recursive: true })
19
+ writeFileSync(join(PROJECT, "src", "index.ts"), "// test")
20
+ writeFileSync(join(OUTSIDE, "secret.txt"), "secret")
21
+ state.boundary = PROJECT
22
+ state.approvals = new ApprovalStore()
23
+ })
24
+
25
+ import { afterAll } from "vitest"
26
+ afterAll(() => {
27
+ rmSync(TEST_DIR, { recursive: true, force: true })
28
+ })
29
+
30
+ // Simulates the guard decision logic
31
+ async function guardDecision(
32
+ toolName: string,
33
+ path: string,
34
+ cwd: string,
35
+ userApproves: boolean
36
+ ): Promise<"allow" | "block" | "ask"> {
37
+ const FILE_TOOLS = ["read", "write", "edit", "grep", "find", "ls"]
38
+ if (!FILE_TOOLS.includes(toolName)) return "allow" // not a file tool
39
+
40
+ const inside = await isInsideBoundary(state.boundary, path, cwd)
41
+ if (inside) return "allow"
42
+
43
+ const { resolve } = await import("node:path")
44
+ const absolutePath = resolve(cwd, path)
45
+ if (state.approvals.isApproved(absolutePath)) return "allow"
46
+
47
+ // Would prompt user — return what user would do
48
+ if (userApproves) {
49
+ state.approvals.approve(absolutePath)
50
+ return "allow"
51
+ }
52
+ return "block"
53
+ }
54
+
55
+ describe("guard decision logic", () => {
56
+ it("allows file tool calls inside boundary", async () => {
57
+ const result = await guardDecision("read", join(PROJECT, "src", "index.ts"), PROJECT, false)
58
+ expect(result).toBe("allow")
59
+ })
60
+
61
+ it("allows non-file tools without checking", async () => {
62
+ const result = await guardDecision("bash", "/etc/passwd", PROJECT, false)
63
+ expect(result).toBe("allow")
64
+ })
65
+
66
+ it("blocks outside path when user denies", async () => {
67
+ const result = await guardDecision("read", join(OUTSIDE, "secret.txt"), PROJECT, false)
68
+ expect(result).toBe("block")
69
+ })
70
+
71
+ it("allows outside path when user approves", async () => {
72
+ const result = await guardDecision("write", join(OUTSIDE, "output.txt"), PROJECT, true)
73
+ expect(result).toBe("allow")
74
+ })
75
+
76
+ it("auto-allows after previous approval in same directory", async () => {
77
+ // First access — user approves
78
+ await guardDecision("read", join(OUTSIDE, "secret.txt"), PROJECT, true)
79
+ // Second access — same directory, should auto-allow
80
+ const result = await guardDecision("read", join(OUTSIDE, "other.txt"), PROJECT, false)
81
+ expect(result).toBe("allow")
82
+ })
83
+
84
+ it("prompts for different directory even after approval", async () => {
85
+ const OTHER = join(TEST_DIR, "other-outside")
86
+ mkdirSync(OTHER, { recursive: true })
87
+ writeFileSync(join(OTHER, "file.txt"), "test")
88
+
89
+ await guardDecision("read", join(OUTSIDE, "secret.txt"), PROJECT, true)
90
+ const result = await guardDecision("read", join(OTHER, "file.txt"), PROJECT, false)
91
+ expect(result).toBe("block")
92
+ })
93
+
94
+ it("works for all file tool types", async () => {
95
+ for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
96
+ state.approvals = new ApprovalStore() // reset
97
+ const result = await guardDecision(tool, join(OUTSIDE, "file.txt"), PROJECT, false)
98
+ expect(result).toBe("block")
99
+ }
100
+ })
101
+ })