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 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 and directories are automatically mirrored into a private gitbox directory
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 | Type | Default | Description |
71
- | -------------- | -------- | -------------------- | ----------------------------------------- |
72
- | `baseDir` | string | `~/.pi/agent/gitbox` | Base directory where gitboxes are created |
73
- | `statusBar` | boolean | `true` | Show gitbox status in the status bar |
74
- | `deleteOnExit` | boolean | `false` | Delete the gitbox when the session exits |
75
- | `bypassGitbox` | boolean | `false` | Skip impersonation of gitignored paths |
76
- | `bypassPaths` | boolean | `false` | Bypass path access restrictions entirely |
77
- | `allowedPaths` | string[] | `[]` | Additional paths to allow access to |
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 `statusBar`, `deleteOnExit`, `bypassGitbox`, and `bypassPaths`. The remaining options (`baseDir`, `allowedPaths`) must be configured directly in `settings.json`.
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 extension package directory
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 paths into it: files get placeholder content (`{}` for `.json` and ` ` (empty space) for others)
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** — If a path wasn't detected during initialization, the extension performs a real-time `git check-ignore` lookup and creates the impersonation on the fly
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
- for (const path of paths) {
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 (!config.bypassPaths) {
66
- if (!Detector.isPathAllowed(resolvedDirs, path, ctx)) {
67
- const response = await askUserOrBlock(ctx, path);
68
- if (response.block) return response;
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.1.2",
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.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.8.4"
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 mapper = this.gitbox.getMapper(ctx);
76
+ const fileMapper = this.gitbox.getFileMapper(ctx);
77
+ const dirMapper = this.gitbox.getDirMapper(ctx);
76
78
 
77
- const lines = Object.entries(mapper)
78
- .map(([source, target]) => ` ${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",
@@ -6,7 +6,10 @@ export interface GitboxConfig {
6
6
  baseDir: string;
7
7
  statusBar: boolean;
8
8
  deleteOnExit: boolean;
9
+ // Impersonation
10
+ impersonateDirs: boolean;
9
11
  bypassGitbox: boolean;
12
+ // Permissions
10
13
  bypassPaths: boolean;
11
14
  allowedPaths: string[];
12
15
  }
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
- return path.endsWith(PATH_SEP) || path.endsWith(sep);
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
  *
@@ -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 mapper: Record<string, string> = {};
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.mapper = {};
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.mapper[absPath] = impersonation;
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.mapper[absPath] = impersonation;
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.mapper)) {
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
- // Dynamic checking => Not yet in the mapper
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 (await Detector.isDirectory(relPath)) {
241
+ if (Detector.isDirectory(absPath)) {
190
242
  // E.g.: `.vscode/myfolder/` when only `.vscode/` is gitignored)
191
- this.mapper[absPath] = await this.createDirectory(
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.mapper[absPath] = await this.createFile(relPath, projectDir, ctx);
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
- return paths;
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 function askUserOrBlock(
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
+ });