pi-gitbox 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -14
- package/index.ts +9 -14
- package/package.json +8 -4
- package/src/commands.ts +15 -3
- package/src/config-types.ts +3 -0
- package/src/defaults.ts +10 -5
- package/src/detector.ts +7 -2
- package/src/gitbox.ts +22 -0
- package/src/impersonator.ts +103 -20
- package/src/prompts.ts +26 -3
- package/src/settings.ts +2 -0
- package/tests/impersonator.test.ts +92 -0
package/README.md
CHANGED
|
@@ -13,13 +13,16 @@ After enabling gitbox, verify that impersonations are working correctly:
|
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
- **Gitignored file impersonation** — gitignored files
|
|
16
|
+
- **Gitignored file impersonation** — gitignored files are automatically mirrored into a private gitbox directory
|
|
17
|
+
- **Directory impersonation** — gitignored directories can also be mirrored (opt-in)
|
|
17
18
|
- **Command & path interception** — bash commands and file operations (read, edit, write, find, grep, ls) are internally redirected to the impersonated paths
|
|
18
19
|
- **Directory access control** — restricts agent access to allowed directories by default; prompts for approval when accessing paths outside the allowed list
|
|
19
20
|
- **Configurable directory bypass** — optionally disable directory restrictions
|
|
20
21
|
- **Status bar indicators** — color-coded status showing whether the gitbox is enabled, available, not required, unavailable or bypassed
|
|
21
22
|
- **Auto cleanup** — optionally delete the gitbox when the session exits
|
|
22
23
|
|
|
24
|
+
> **Note on directory impersonation:** By default, only gitignored files are impersonated. Enabling `impersonateDirs` also mirrors directories into the gitbox. This is useful when you want the agent to operate on the project without disrupting your current folders — for example, a Node project with `node_modules/` ignored by git can be used from the working directory (when `impersonateDirs: false`), or can be recreated to be available in the gitbox instead (when `impersonateDirs: true`).
|
|
25
|
+
|
|
23
26
|
## Status Bar
|
|
24
27
|
|
|
25
28
|
The status bar displays `📦 Gitbox:` followed by the current status:
|
|
@@ -58,6 +61,7 @@ You can customize Gitbox options via the interactive menu (`/gitbox`) for common
|
|
|
58
61
|
"baseDir": "~/.pi/agent/gitbox",
|
|
59
62
|
"statusBar": true,
|
|
60
63
|
"deleteOnExit": false,
|
|
64
|
+
"impersonateDirs": false,
|
|
61
65
|
"bypassGitbox": false,
|
|
62
66
|
"bypassPaths": false,
|
|
63
67
|
"allowedPaths": []
|
|
@@ -67,24 +71,25 @@ You can customize Gitbox options via the interactive menu (`/gitbox`) for common
|
|
|
67
71
|
|
|
68
72
|
### Configuration Options
|
|
69
73
|
|
|
70
|
-
| Option
|
|
71
|
-
|
|
|
72
|
-
| `baseDir`
|
|
73
|
-
| `statusBar`
|
|
74
|
-
| `deleteOnExit`
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
74
|
+
| Option | Type | Default | Description |
|
|
75
|
+
| ----------------- | -------- | -------------------- | ----------------------------------------- |
|
|
76
|
+
| `baseDir` | string | `~/.pi/agent/gitbox` | Base directory where gitboxes are created |
|
|
77
|
+
| `statusBar` | boolean | `true` | Show gitbox status in the status bar |
|
|
78
|
+
| `deleteOnExit` | boolean | `false` | Delete the gitbox when the session exits |
|
|
79
|
+
| `impersonateDirs` | boolean | `false` | Also impersonate gitignored directories |
|
|
80
|
+
| `bypassGitbox` | boolean | `false` | Skip impersonation of gitignored paths |
|
|
81
|
+
| `bypassPaths` | boolean | `false` | Bypass path access restrictions entirely |
|
|
82
|
+
| `allowedPaths` | string[] | `[]` | Additional paths to allow access to |
|
|
78
83
|
|
|
79
|
-
> **Note:** The interactive menu (`/gitbox`) exposes
|
|
84
|
+
> **Note:** The interactive menu (`/gitbox`) only exposes boolean keys. The remaining options (`baseDir`, `allowedPaths`) must be configured directly in `settings.json`.
|
|
80
85
|
|
|
81
86
|
### Directory Access
|
|
82
87
|
|
|
83
88
|
By default, the extension allows access to:
|
|
84
89
|
|
|
85
90
|
- The current working directory (`process.cwd()`)
|
|
86
|
-
- The Pi agent directory (`~/.pi/agent
|
|
87
|
-
- The
|
|
91
|
+
- The Pi agent directory (`~/.pi/agent/`)
|
|
92
|
+
- The Pi package directory (`<...>/@earendil-works/pi-coding-agent`)
|
|
88
93
|
- `/dev/null`
|
|
89
94
|
|
|
90
95
|
If the agent attempts to access a path outside these allowed directories, a confirmation dialog appears:
|
|
@@ -115,12 +120,12 @@ Set `bypassPaths: true` to skip this check entirely.
|
|
|
115
120
|
|
|
116
121
|
1. **Session Start** — On `session_start`, the extension checks whether the current directory is a git repository with `git` available
|
|
117
122
|
2. **Gitignored Path Detection** — Uses git-specific commands to discover all gitignored files and directories
|
|
118
|
-
3. **Gitbox Creation** — If the directory is a git repository, creates a private directory at `~/.pi/agent/gitbox/<project-name>` and mirrors gitignored
|
|
123
|
+
3. **Gitbox Creation** — If the directory is a git repository, creates a private directory at `~/.pi/agent/gitbox/<project-name>` and mirrors gitignored files into it (files get placeholder content (`{}` for `.json` and ` ` (empty space) for others)). With `impersonateDirs: true`, gitignored directories are also mirrored.
|
|
119
124
|
4. **Path Mapping** — Builds a mapper from original absolute paths to their impersonated counterparts
|
|
120
125
|
5. **Event Interception** — On every `tool_call` event:
|
|
121
126
|
- **Bash commands** — Extracts paths from the command using `shell-quote`, checks directory restrictions, then rewrites paths to their impersonated versions
|
|
122
127
|
- **Path-based tools** (read, edit, write, find, grep, ls) — Checks directory restrictions, then resolves the path to its impersonated equivalent
|
|
123
|
-
- **Dynamic fallback** —
|
|
128
|
+
- **Dynamic fallback** — For paths within gitignored directories that weren't explicitly detected during initialization (e.g., nested files inside an ignored directory), the extension performs a real-time `git check-ignore` lookup and creates the impersonation on the fly. Files are explicitly detected during initialization, so this fallback is primarily useful for directories
|
|
124
129
|
6. **Status Bar** — Updates the status bar with the current gitbox state (enabled, available, not required, or unavailable)
|
|
125
130
|
7. **Session Shutdown** — Optionally removes the gitbox directory if `deleteOnExit` is enabled
|
|
126
131
|
|
package/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { BASE_ALLOWED_PATHS } from "./src/defaults";
|
|
|
9
9
|
import { Detector } from "./src/detector";
|
|
10
10
|
import { Gitbox } from "./src/gitbox";
|
|
11
11
|
import { Impersonator } from "./src/impersonator";
|
|
12
|
-
import { askUserOrBlock } from "./src/prompts";
|
|
12
|
+
import { askUserOrBlock, checkPathsAccess } from "./src/prompts";
|
|
13
13
|
import { settings } from "./src/settings";
|
|
14
14
|
|
|
15
15
|
export default async (pi: ExtensionAPI) => {
|
|
@@ -37,7 +37,6 @@ export default async (pi: ExtensionAPI) => {
|
|
|
37
37
|
|
|
38
38
|
pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext) => {
|
|
39
39
|
const { config } = await settings.getConfig();
|
|
40
|
-
|
|
41
40
|
const resolvedDirs = [...BASE_ALLOWED_PATHS, ...config.allowedPaths];
|
|
42
41
|
|
|
43
42
|
if (gitbox.isBashEvent(event)) {
|
|
@@ -46,13 +45,8 @@ export default async (pi: ExtensionAPI) => {
|
|
|
46
45
|
// First, scan if we can even access the paths
|
|
47
46
|
if (!config.bypassPaths) {
|
|
48
47
|
const paths = await impersonator.extractFromCommand(command);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (Detector.isPathAllowed(resolvedDirs, path, ctx)) continue;
|
|
52
|
-
|
|
53
|
-
const response = await askUserOrBlock(ctx, path);
|
|
54
|
-
if (response.block) return response;
|
|
55
|
-
}
|
|
48
|
+
const blocked = await checkPathsAccess(paths, resolvedDirs, ctx);
|
|
49
|
+
if (blocked) return blocked;
|
|
56
50
|
}
|
|
57
51
|
|
|
58
52
|
// Then, impersonate the command
|
|
@@ -62,11 +56,12 @@ export default async (pi: ExtensionAPI) => {
|
|
|
62
56
|
const { path } = event.input as { path: string };
|
|
63
57
|
|
|
64
58
|
// First, scan if we can even access the paths
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
if (
|
|
60
|
+
!config.bypassPaths &&
|
|
61
|
+
!Detector.isPathAllowed(resolvedDirs, path, ctx)
|
|
62
|
+
) {
|
|
63
|
+
const response = await askUserOrBlock(ctx, path);
|
|
64
|
+
if (response.block) return response;
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
// Then, impersonate the path
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gitbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi extension that impersonates gitignored paths to reduce secrets exposure.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"./index.ts"
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
25
28
|
"prettier": {
|
|
26
29
|
"plugins": [
|
|
27
30
|
"prettier-plugin-organize-imports"
|
|
@@ -32,11 +35,12 @@
|
|
|
32
35
|
"@earendil-works/pi-tui": "*"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
|
-
"@types/node": "^26.0.
|
|
38
|
+
"@types/node": "^26.0.1",
|
|
36
39
|
"@types/shell-quote": "^1.7.5",
|
|
37
|
-
"prettier-plugin-organize-imports": "^4.3.0"
|
|
40
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
41
|
+
"vitest": "^4.1.9"
|
|
38
42
|
},
|
|
39
43
|
"dependencies": {
|
|
40
|
-
"shell-quote": "^1.
|
|
44
|
+
"shell-quote": "^1.9.0"
|
|
41
45
|
}
|
|
42
46
|
}
|
package/src/commands.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { settings } from "./settings";
|
|
|
15
15
|
enum Options {
|
|
16
16
|
STATUS_BAR = "statusBar",
|
|
17
17
|
DELETE_ON_EXIT = "deleteOnExit",
|
|
18
|
+
IMPERSONATE_DIRS = "impersonateDirs",
|
|
18
19
|
BYPASS_GITBOX = "bypassGitbox",
|
|
19
20
|
BYPASS_PATHS = "bypassPaths",
|
|
20
21
|
}
|
|
@@ -72,10 +73,14 @@ export class CommandManager {
|
|
|
72
73
|
* @param ctx The extension context
|
|
73
74
|
*/
|
|
74
75
|
private async runGitboxPaths(ctx: ExtensionCommandContext): Promise<void> {
|
|
75
|
-
const
|
|
76
|
+
const fileMapper = this.gitbox.getFileMapper(ctx);
|
|
77
|
+
const dirMapper = this.gitbox.getDirMapper(ctx);
|
|
76
78
|
|
|
77
|
-
const lines = Object.entries(
|
|
78
|
-
.map(([source, target]) =>
|
|
79
|
+
const lines = Object.entries({ ...fileMapper, ...dirMapper })
|
|
80
|
+
.map(([source, target]) => {
|
|
81
|
+
const icon = source in dirMapper ? "📁" : "📄";
|
|
82
|
+
return ` ${icon} ${source} -> ${target}`;
|
|
83
|
+
})
|
|
79
84
|
.join("\n");
|
|
80
85
|
|
|
81
86
|
const content = lines
|
|
@@ -148,6 +153,13 @@ export class CommandManager {
|
|
|
148
153
|
currentValue: config.deleteOnExit ? "on" : "off",
|
|
149
154
|
values: ["on", "off"],
|
|
150
155
|
},
|
|
156
|
+
{
|
|
157
|
+
id: Options.IMPERSONATE_DIRS,
|
|
158
|
+
label: "Impersonate directories",
|
|
159
|
+
description: "Also impersonate gitignored directories",
|
|
160
|
+
currentValue: config.impersonateDirs ? "on" : "off",
|
|
161
|
+
values: ["on", "off"],
|
|
162
|
+
},
|
|
151
163
|
{
|
|
152
164
|
id: Options.BYPASS_GITBOX,
|
|
153
165
|
label: "Bypass impersonation",
|
package/src/config-types.ts
CHANGED
package/src/defaults.ts
CHANGED
|
@@ -21,6 +21,16 @@ export const GITBOX_STATUSBAR = true;
|
|
|
21
21
|
*/
|
|
22
22
|
export const DELETE_ON_EXIT = false;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Whether to also impersonate gitignored directories
|
|
26
|
+
*/
|
|
27
|
+
export const IMPERSONATE_DIRS = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether to bypass impersonation entirely
|
|
31
|
+
*/
|
|
32
|
+
export const BYPASS_GITBOX = false;
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* Default paths that are always allowed.
|
|
26
36
|
*/
|
|
@@ -48,8 +58,3 @@ export const ALLOWED_PATHS: string[] = [];
|
|
|
48
58
|
* Whether to bypass path restrictions
|
|
49
59
|
*/
|
|
50
60
|
export const BYPASS_PATHS = false;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Whether to bypass impersonation entirely
|
|
54
|
-
*/
|
|
55
|
-
export const BYPASS_GITBOX = false;
|
package/src/detector.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
+
import { statSync } from "fs";
|
|
3
4
|
import { access } from "fs/promises";
|
|
4
|
-
import { sep } from "node:path";
|
|
5
5
|
import { normalizePath, PATH_SEP, resolvePaths } from "./compat";
|
|
6
6
|
|
|
7
7
|
export const Detector = new (class {
|
|
@@ -154,6 +154,11 @@ export const Detector = new (class {
|
|
|
154
154
|
* @returns True if the path is a directory
|
|
155
155
|
*/
|
|
156
156
|
isDirectory(path: string): boolean {
|
|
157
|
-
|
|
157
|
+
try {
|
|
158
|
+
const absPath = resolvePaths(path);
|
|
159
|
+
return statSync(absPath).isDirectory();
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
158
163
|
}
|
|
159
164
|
})();
|
package/src/gitbox.ts
CHANGED
|
@@ -87,6 +87,28 @@ export class Gitbox {
|
|
|
87
87
|
return this.impersonator.getMapper(ctx.cwd);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Returns the file mapper from the impersonator.
|
|
92
|
+
* Delegates to impersonator instance
|
|
93
|
+
*
|
|
94
|
+
* @param ctx The extension context
|
|
95
|
+
* @returns The source -> target path mapping for files
|
|
96
|
+
*/
|
|
97
|
+
getFileMapper(ctx: ExtensionContext): Record<string, string> {
|
|
98
|
+
return this.impersonator.getFileMapper(ctx.cwd);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns the directory mapper from the impersonator.
|
|
103
|
+
* Delegates to impersonator instance
|
|
104
|
+
*
|
|
105
|
+
* @param ctx The extension context
|
|
106
|
+
* @returns The source -> target path mapping for directories
|
|
107
|
+
*/
|
|
108
|
+
getDirMapper(ctx: ExtensionContext): Record<string, string> {
|
|
109
|
+
return this.impersonator.getDirMapper(ctx.cwd);
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
/**
|
|
91
113
|
* Validates the configuration settings
|
|
92
114
|
*
|
package/src/impersonator.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, relative } from "node:path";
|
|
4
|
+
import type { GlobPattern } from "shell-quote";
|
|
4
5
|
import { parse } from "shell-quote";
|
|
5
6
|
import { joinPaths, resolvePaths } from "./compat";
|
|
6
7
|
import { Detector } from "./detector";
|
|
7
8
|
import { settings } from "./settings";
|
|
8
9
|
|
|
9
10
|
export class Impersonator {
|
|
10
|
-
private
|
|
11
|
+
private fileMapper: Record<string, string> = {};
|
|
12
|
+
private dirMapper: Record<string, string> = {};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns a combined mapper of both file and directory impersonations
|
|
16
|
+
* @returns Combined source -> target path mapping
|
|
17
|
+
*/
|
|
18
|
+
get pathMapper(): Record<string, string> {
|
|
19
|
+
return { ...this.fileMapper, ...this.dirMapper };
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Fills the mapper with paths that are gitignored
|
|
@@ -15,11 +25,15 @@ export class Impersonator {
|
|
|
15
25
|
* @param ctx The extension context
|
|
16
26
|
*/
|
|
17
27
|
async initialize(ctx: ExtensionContext): Promise<void> {
|
|
18
|
-
this.
|
|
28
|
+
this.fileMapper = {};
|
|
29
|
+
this.dirMapper = {};
|
|
19
30
|
const { config } = await settings.getConfig();
|
|
20
31
|
|
|
21
|
-
await this.initializeDirectories(config.baseDir, ctx);
|
|
22
32
|
await this.initializeFiles(config.baseDir, ctx);
|
|
33
|
+
|
|
34
|
+
if (config.impersonateDirs) {
|
|
35
|
+
await this.initializeDirectories(config.baseDir, ctx);
|
|
36
|
+
}
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
/**
|
|
@@ -37,7 +51,7 @@ export class Impersonator {
|
|
|
37
51
|
const absPath = resolvePaths(ctx.cwd, path);
|
|
38
52
|
|
|
39
53
|
if (impersonation) {
|
|
40
|
-
this.
|
|
54
|
+
this.dirMapper[absPath] = impersonation;
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
57
|
}
|
|
@@ -57,7 +71,7 @@ export class Impersonator {
|
|
|
57
71
|
const absPath = resolvePaths(ctx.cwd, path);
|
|
58
72
|
|
|
59
73
|
if (impersonation) {
|
|
60
|
-
this.
|
|
74
|
+
this.fileMapper[absPath] = impersonation;
|
|
61
75
|
}
|
|
62
76
|
}
|
|
63
77
|
}
|
|
@@ -140,7 +154,39 @@ export class Impersonator {
|
|
|
140
154
|
getMapper(baseDir: string): Record<string, string> {
|
|
141
155
|
const result: Record<string, string> = {};
|
|
142
156
|
|
|
143
|
-
for (const [absSource, target] of Object.entries(this.
|
|
157
|
+
for (const [absSource, target] of Object.entries(this.pathMapper)) {
|
|
158
|
+
result[relative(baseDir, absSource)] = target;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns the file mapper, with sources relative to the given base directory.
|
|
166
|
+
*
|
|
167
|
+
* @param baseDir The base directory to resolve relative paths against
|
|
168
|
+
* @returns The source -> target path mapping for files
|
|
169
|
+
*/
|
|
170
|
+
getFileMapper(baseDir: string): Record<string, string> {
|
|
171
|
+
const result: Record<string, string> = {};
|
|
172
|
+
|
|
173
|
+
for (const [absSource, target] of Object.entries(this.fileMapper)) {
|
|
174
|
+
result[relative(baseDir, absSource)] = target;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns the directory mapper, with sources relative to the given base directory.
|
|
182
|
+
*
|
|
183
|
+
* @param baseDir The base directory to resolve relative paths against
|
|
184
|
+
* @returns The source -> target path mapping for directories
|
|
185
|
+
*/
|
|
186
|
+
getDirMapper(baseDir: string): Record<string, string> {
|
|
187
|
+
const result: Record<string, string> = {};
|
|
188
|
+
|
|
189
|
+
for (const [absSource, target] of Object.entries(this.dirMapper)) {
|
|
144
190
|
result[relative(baseDir, absSource)] = target;
|
|
145
191
|
}
|
|
146
192
|
|
|
@@ -174,31 +220,41 @@ export class Impersonator {
|
|
|
174
220
|
* @returns The impersonated path, if available
|
|
175
221
|
*/
|
|
176
222
|
async resolvePath(path: string, ctx: ExtensionContext): Promise<string> {
|
|
177
|
-
// Path could be a file/dir
|
|
178
223
|
const absPath = resolvePaths(ctx.cwd, path);
|
|
179
|
-
if (this.mapper[absPath]) return this.mapper[absPath];
|
|
180
224
|
|
|
181
|
-
//
|
|
225
|
+
// Check file mapper first
|
|
226
|
+
if (this.fileMapper[absPath]) return this.fileMapper[absPath];
|
|
227
|
+
|
|
228
|
+
// Check directory mapper only if impersonateDirs is enabled
|
|
229
|
+
const { config } = await settings.getConfig();
|
|
230
|
+
if (!config.impersonateDirs) return path;
|
|
231
|
+
|
|
232
|
+
// Check existence in dirMapper
|
|
233
|
+
if (this.dirMapper[absPath]) return this.dirMapper[absPath];
|
|
234
|
+
|
|
235
|
+
// Not yet in the mapper => Dynamic checking
|
|
182
236
|
// We'll need to create the path on-the-fly
|
|
183
237
|
if (Detector.dynamicCheck(absPath)) {
|
|
184
|
-
const { config } = await settings.getConfig();
|
|
185
|
-
|
|
186
238
|
const relPath = relative(ctx.cwd, path);
|
|
187
239
|
const projectDir = joinPaths(config.baseDir, basename(ctx.cwd));
|
|
188
240
|
|
|
189
|
-
if (
|
|
241
|
+
if (Detector.isDirectory(absPath)) {
|
|
190
242
|
// E.g.: `.vscode/myfolder/` when only `.vscode/` is gitignored)
|
|
191
|
-
this.
|
|
243
|
+
this.dirMapper[absPath] = await this.createDirectory(
|
|
192
244
|
relPath,
|
|
193
245
|
projectDir,
|
|
194
246
|
ctx,
|
|
195
247
|
);
|
|
248
|
+
return this.dirMapper[absPath];
|
|
196
249
|
} else {
|
|
197
250
|
// E.g.: `.vscode/launch.json` when only `.vscode/` is gitignored)
|
|
198
|
-
this.
|
|
251
|
+
this.fileMapper[absPath] = await this.createFile(
|
|
252
|
+
relPath,
|
|
253
|
+
projectDir,
|
|
254
|
+
ctx,
|
|
255
|
+
);
|
|
256
|
+
return this.fileMapper[absPath];
|
|
199
257
|
}
|
|
200
|
-
|
|
201
|
-
return this.mapper[absPath];
|
|
202
258
|
}
|
|
203
259
|
|
|
204
260
|
// Couldn't find anything to impersonate, return the original path
|
|
@@ -220,7 +276,37 @@ export class Impersonator {
|
|
|
220
276
|
.filter((token) => typeof token === "string")
|
|
221
277
|
.filter(this.isPathLike);
|
|
222
278
|
|
|
223
|
-
|
|
279
|
+
// Collect paths from glob tokens
|
|
280
|
+
// e.g.: `file dist/*` → extracts 'dist' from the glob token
|
|
281
|
+
const globPaths = tokens
|
|
282
|
+
.filter(
|
|
283
|
+
(token): token is GlobPattern =>
|
|
284
|
+
typeof token === "object" &&
|
|
285
|
+
token !== null &&
|
|
286
|
+
"op" in token &&
|
|
287
|
+
token.op === "glob",
|
|
288
|
+
)
|
|
289
|
+
.map((token) => this.stripGlobPattern(token.pattern))
|
|
290
|
+
.filter(this.isPathLike);
|
|
291
|
+
|
|
292
|
+
return [...paths, ...globPaths];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Strips a simple glob pattern (`*`) from a path, returning the base path.
|
|
297
|
+
*
|
|
298
|
+
* e.g.: `dist/*` → `dist`, `*` → ``, `src/file.ts` → `src/file.ts`
|
|
299
|
+
*
|
|
300
|
+
* @param value The glob pattern value from shell-quote
|
|
301
|
+
* @returns The path with the glob suffix removed, or the original if no glob
|
|
302
|
+
*/
|
|
303
|
+
private stripGlobPattern(value: string): string {
|
|
304
|
+
const starIndex = value.indexOf("*");
|
|
305
|
+
if (starIndex === -1) return value;
|
|
306
|
+
|
|
307
|
+
// Strip from the last `/` before the `*` to the end
|
|
308
|
+
const lastSlash = value.lastIndexOf("/", starIndex);
|
|
309
|
+
return lastSlash === -1 ? "" : value.substring(0, lastSlash);
|
|
224
310
|
}
|
|
225
311
|
|
|
226
312
|
/**
|
|
@@ -239,9 +325,6 @@ export class Impersonator {
|
|
|
239
325
|
// Command flags and options
|
|
240
326
|
if (candidate.startsWith("-")) return false;
|
|
241
327
|
|
|
242
|
-
// Pure numbers (e.g., 42, 3.14, -0)
|
|
243
|
-
if (/^-?\d+(\.\d+)?$/.test(candidate)) return false;
|
|
244
|
-
|
|
245
328
|
// URLs (e.g., https://example.com, ftp://...)
|
|
246
329
|
if (/^[a-z]+:\/\//i.test(candidate)) return false;
|
|
247
330
|
|
package/src/prompts.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Detector } from "./detector";
|
|
2
3
|
import { settings } from "./settings";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,10 +20,10 @@ enum Options {
|
|
|
19
20
|
* @param path The path to check
|
|
20
21
|
* @returns An object with `block: true` and a `reason` if blocked, or `{ block: false }` if allowed
|
|
21
22
|
*/
|
|
22
|
-
export async
|
|
23
|
+
export const askUserOrBlock = async (
|
|
23
24
|
ctx: ExtensionContext,
|
|
24
25
|
path: string,
|
|
25
|
-
): Promise<{ block: boolean; reason?: string }> {
|
|
26
|
+
): Promise<{ block: boolean; reason?: string }> => {
|
|
26
27
|
const reason = `Path "${path}" is outside allowed directories and was denied.`;
|
|
27
28
|
|
|
28
29
|
// Check if UI is available
|
|
@@ -45,4 +46,26 @@ export async function askUserOrBlock(
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
return { block: false };
|
|
48
|
-
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks if all paths are allowed, prompting the user if needed.
|
|
53
|
+
*
|
|
54
|
+
* @param paths Paths to check
|
|
55
|
+
* @param resolvedDirs Allowed directories
|
|
56
|
+
* @param ctx The extension context
|
|
57
|
+
* @returns The blocked response if any path was denied, or null
|
|
58
|
+
*/
|
|
59
|
+
export const checkPathsAccess = async (
|
|
60
|
+
paths: string[],
|
|
61
|
+
resolvedDirs: string[],
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
): Promise<{ block: boolean; reason?: string } | null> => {
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
if (Detector.isPathAllowed(resolvedDirs, path, ctx)) continue;
|
|
66
|
+
|
|
67
|
+
const response = await askUserOrBlock(ctx, path);
|
|
68
|
+
if (response.block) return response;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
};
|
package/src/settings.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DELETE_ON_EXIT,
|
|
10
10
|
GITBOX_BASEDIR,
|
|
11
11
|
GITBOX_STATUSBAR,
|
|
12
|
+
IMPERSONATE_DIRS,
|
|
12
13
|
STATUS_KEY,
|
|
13
14
|
} from "./defaults";
|
|
14
15
|
|
|
@@ -40,6 +41,7 @@ class Settings {
|
|
|
40
41
|
baseDir: GITBOX_BASEDIR,
|
|
41
42
|
statusBar: GITBOX_STATUSBAR,
|
|
42
43
|
deleteOnExit: DELETE_ON_EXIT,
|
|
44
|
+
impersonateDirs: IMPERSONATE_DIRS,
|
|
43
45
|
bypassGitbox: BYPASS_GITBOX,
|
|
44
46
|
bypassPaths: BYPASS_PATHS,
|
|
45
47
|
allowedPaths: ALLOWED_PATHS,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { Impersonator } from "../src/impersonator";
|
|
3
|
+
|
|
4
|
+
describe("Impersonator", () => {
|
|
5
|
+
let imp: Impersonator;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
imp = new Impersonator();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("extractFromCommand", () => {
|
|
12
|
+
it("extracts paths from a simple command", async () => {
|
|
13
|
+
const result = await imp.extractFromCommand("cat file.txt");
|
|
14
|
+
expect(result).toEqual(["cat", "file.txt"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts paths from glob patterns", async () => {
|
|
18
|
+
const result = await imp.extractFromCommand("file dist/*");
|
|
19
|
+
expect(result).toContain("file");
|
|
20
|
+
expect(result).toContain("dist");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("handles bare glob by excluding it", async () => {
|
|
24
|
+
const result = await imp.extractFromCommand("ls *");
|
|
25
|
+
expect(result).toEqual(["ls"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("extracts paths from multiple globs", async () => {
|
|
29
|
+
const result = await imp.extractFromCommand("src/*.ts test/*.test.ts");
|
|
30
|
+
expect(result).toContain("src");
|
|
31
|
+
expect(result).toContain("test");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does not extract shell operators", async () => {
|
|
35
|
+
const result = await imp.extractFromCommand("cat a.txt && cat b.txt");
|
|
36
|
+
expect(result).toContain("cat");
|
|
37
|
+
expect(result).toContain("a.txt");
|
|
38
|
+
expect(result).toContain("b.txt");
|
|
39
|
+
expect(result).not.toContain("&&");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not extract command flags", async () => {
|
|
43
|
+
const result = await imp.extractFromCommand("ls -la -R");
|
|
44
|
+
expect(result).toEqual(["ls"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not extract URLs", async () => {
|
|
48
|
+
const result = await imp.extractFromCommand(
|
|
49
|
+
"curl https://example.com/file.txt",
|
|
50
|
+
);
|
|
51
|
+
expect(result).toContain("curl");
|
|
52
|
+
expect(result).not.toContain("https://example.com/file.txt");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not extract environment variable assignments", async () => {
|
|
56
|
+
const result = await imp.extractFromCommand(
|
|
57
|
+
"NODE_ENV=production node app.js",
|
|
58
|
+
);
|
|
59
|
+
expect(result).toContain("node");
|
|
60
|
+
expect(result).toContain("app.js");
|
|
61
|
+
expect(result).not.toContain("NODE_ENV=production");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles mixed paths and globs", async () => {
|
|
65
|
+
const result = await imp.extractFromCommand(
|
|
66
|
+
"cp src/main.ts dist/main.ts",
|
|
67
|
+
);
|
|
68
|
+
expect(result).toContain("cp");
|
|
69
|
+
expect(result).toContain("src/main.ts");
|
|
70
|
+
expect(result).toContain("dist/main.ts");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles nested glob paths", async () => {
|
|
74
|
+
const result = await imp.extractFromCommand("build src/**/*");
|
|
75
|
+
expect(result).toContain("build");
|
|
76
|
+
expect(result).toContain("src");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles empty command", async () => {
|
|
80
|
+
const result = await imp.extractFromCommand("");
|
|
81
|
+
expect(result).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects shell reserved words", async () => {
|
|
85
|
+
const result = await imp.extractFromCommand("if true then else fi");
|
|
86
|
+
expect(result).not.toContain("true");
|
|
87
|
+
expect(result).not.toContain("then");
|
|
88
|
+
expect(result).not.toContain("else");
|
|
89
|
+
expect(result).not.toContain("fi");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|