@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 +63 -0
- package/README.md +56 -0
- package/extensions/index.ts +11 -0
- package/openspec/changes/pi-sandbox-mvp/.openspec.yaml +2 -0
- package/openspec/changes/pi-sandbox-mvp/design.md +67 -0
- package/openspec/changes/pi-sandbox-mvp/proposal.md +32 -0
- package/openspec/changes/pi-sandbox-mvp/specs/boundary-detection/spec.md +30 -0
- package/openspec/changes/pi-sandbox-mvp/specs/path-guard/spec.md +57 -0
- package/openspec/changes/pi-sandbox-mvp/specs/session-approval/spec.md +34 -0
- package/openspec/changes/pi-sandbox-mvp/tasks.md +37 -0
- package/package.json +24 -0
- package/src/approvals.ts +47 -0
- package/src/boundary.ts +42 -0
- package/src/containment.ts +63 -0
- package/src/guard.ts +61 -0
- package/src/state.ts +12 -0
- package/tests/approvals.test.ts +58 -0
- package/tests/boundary.test.ts +71 -0
- package/tests/containment.test.ts +69 -0
- package/tests/guard.test.ts +101 -0
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,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
|
+
}
|
package/src/approvals.ts
ADDED
|
@@ -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
|
+
}
|
package/src/boundary.ts
ADDED
|
@@ -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
|
+
})
|