pi-gitbox 0.1.1 → 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
@@ -8,18 +8,21 @@ A [Pi Coding Agent](https://pi.dev/) extension that automatically redirects giti
8
8
 
9
9
  After enabling gitbox, verify that impersonations are working correctly:
10
10
 
11
- 1. **Check impersonated files** — Open `~/.pi/agent/gitbox/<project>/` and confirm that gitignored files appear as empty placeholders (or `{}` for `.json` files).
11
+ 1. **Check impersonated files** — Open `~/.pi/agent/gitbox/<project>/` and confirm that gitignored files contain a single space (or `{}` for `.json` files).
12
12
  2. **Test with a local model** — Ask the agent to read a known-secret file. It should report the file as empty (or `{}` if JSON), confirming the impersonation is active.
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,11 +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
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
123
129
  6. **Status Bar** — Updates the status bar with the current gitbox state (enabled, available, not required, or unavailable)
124
130
  7. **Session Shutdown** — Optionally removes the gitbox directory if `deleteOnExit` is enabled
125
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.1",
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",
package/src/compat.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { homedir } from "node:os";
2
+ import { posix, resolve, sep } from "node:path";
3
+
4
+ /**
5
+ * Standardized path separator
6
+ */
7
+ export const PATH_SEP = "/";
8
+
9
+ /**
10
+ * Standardizes the path, even if represents the home directory
11
+ *
12
+ * @param path The path to normalize
13
+ * @returns A normalized path
14
+ */
15
+ export const normalizePath = (path: string): string => {
16
+ return path
17
+ .split(sep)
18
+ .join(PATH_SEP)
19
+ .replace(/^~\//g, homedir() + PATH_SEP);
20
+ };
21
+
22
+ /**
23
+ * Joins paths in a Win32/POSIX compatible way
24
+ *
25
+ * @param paths Paths to join/merge
26
+ * @returns A merged path
27
+ */
28
+ export const joinPaths = (...paths: string[]): string => {
29
+ const merged = posix.join(...paths);
30
+ const response = normalizePath(merged);
31
+
32
+ return response;
33
+ };
34
+
35
+ /**
36
+ * Resolves paths in a Win32/POSIX compatible way
37
+ *
38
+ * @param paths Paths to resolve
39
+ * @returns A resolved path
40
+ */
41
+ export const resolvePaths = (...paths: string[]): string => {
42
+ const resolved = resolve(...paths);
43
+ const response = normalizePath(resolved);
44
+
45
+ return response;
46
+ };
@@ -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,16 +1,16 @@
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 { homedir } from "node:os";
5
- import { resolve, sep } from "node:path";
5
+ import { normalizePath, PATH_SEP, resolvePaths } from "./compat";
6
6
 
7
- export class Detector {
7
+ export const Detector = new (class {
8
8
  /**
9
9
  * Determines if the user has `git` as a usable command
10
10
  *
11
11
  * @returns True if git exists
12
12
  */
13
- static isGitAvailable(): boolean {
13
+ isGitAvailable(): boolean {
14
14
  try {
15
15
  execSync("git -v", { stdio: "pipe" });
16
16
  return true;
@@ -23,7 +23,7 @@ export class Detector {
23
23
  * Determines if the current working directory is a git repository
24
24
  * @returns True if it's a git repo
25
25
  */
26
- static isGitProject(): boolean {
26
+ isGitProject(): boolean {
27
27
  try {
28
28
  execSync("git rev-parse --is-inside-work-tree", { stdio: "pipe" });
29
29
  return true;
@@ -37,10 +37,9 @@ export class Detector {
37
37
  *
38
38
  * @returns Relative paths for files
39
39
  */
40
- static getGitignoredFiles = () => {
41
- const paths = Detector.getGitignoredPaths();
42
-
43
- return paths.filter((path) => !path.endsWith("/"));
40
+ getGitignoredFiles = () => {
41
+ const paths = this.getGitignoredPaths();
42
+ return paths.filter((path) => !this.isDirectory(path));
44
43
  };
45
44
 
46
45
  /**
@@ -48,10 +47,9 @@ export class Detector {
48
47
  *
49
48
  * @returns Relative paths for directories
50
49
  */
51
- static getGitignoredDirectories = () => {
50
+ getGitignoredDirectories = () => {
52
51
  const paths = this.getGitignoredPaths();
53
-
54
- return paths.filter((path) => path.endsWith("/"));
52
+ return paths.filter(this.isDirectory);
55
53
  };
56
54
 
57
55
  /**
@@ -60,7 +58,7 @@ export class Detector {
60
58
  *
61
59
  * @returns Array of git-ignored paths (relative to repo root)
62
60
  */
63
- private static getGitignoredPaths(): string[] {
61
+ getGitignoredPaths(): string[] {
64
62
  try {
65
63
  const command =
66
64
  "git ls-files --directory --no-empty-directory --others --ignored --exclude-standard";
@@ -85,7 +83,7 @@ export class Detector {
85
83
  * @param path The path to check
86
84
  * @returns True if path exists
87
85
  */
88
- static async pathExists(path: string): Promise<boolean> {
86
+ async pathExists(path: string): Promise<boolean> {
89
87
  try {
90
88
  await access(path);
91
89
  return true;
@@ -103,29 +101,64 @@ export class Detector {
103
101
  * @param ctx The extension context
104
102
  * @returns True if the path is within at least one allowed directory
105
103
  */
106
- static isPathAllowed(
107
- dirs: string[],
108
- path: string,
109
- ctx: ExtensionContext,
110
- ): boolean {
104
+ isPathAllowed(dirs: string[], path: string, ctx: ExtensionContext): boolean {
111
105
  // Expand ~ to home directory before resolving
112
- const normalizedPath = Detector.normalizePath(path);
113
- const absPath = resolve(ctx.cwd, normalizedPath);
106
+ const normalizedPath = normalizePath(path);
107
+ const absPath = resolvePaths(ctx.cwd, normalizedPath);
114
108
  const absDirs = dirs.map((dir) =>
115
- resolve(ctx.cwd, Detector.normalizePath(dir)),
109
+ resolvePaths(ctx.cwd, normalizePath(dir)),
116
110
  );
117
111
 
118
- return absDirs.some(
119
- (dir) => absPath === dir || absPath.startsWith(dir + sep),
112
+ const response = absDirs.some(
113
+ (dir) => absPath === dir || absPath.startsWith(dir + PATH_SEP),
120
114
  );
115
+
116
+ return response;
117
+ }
118
+
119
+ /**
120
+ * Dynamically checks if a path is gitignored
121
+ *
122
+ * E.g.: If the `.gitignore` is
123
+ *
124
+ * ```
125
+ * .vscode/*
126
+ * !.vscode/launch.json
127
+ * ```
128
+ *
129
+ * Then,
130
+ * .vscode/launch.json should be preserved
131
+ * .vscode/tasks.json should be gitignored
132
+ *
133
+ * @param path The path to check
134
+ * @returns True if it should be gitignored
135
+ */
136
+ dynamicCheck(path: string): boolean {
137
+ try {
138
+ const command = `git check-ignore ${path}`;
139
+ execSync(command, {
140
+ encoding: "utf-8",
141
+ stdio: ["pipe", "pipe", "ignore"],
142
+ });
143
+
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
121
148
  }
122
149
 
123
150
  /**
124
- * Normalizes the path when it comes with a tilde (representing HOME)
125
- * @param path The path
126
- * @returns A normalized path
151
+ * Checks if a path is a directory.
152
+ *
153
+ * @param path The path to check (will be resolved relative to cwd)
154
+ * @returns True if the path is a directory
127
155
  */
128
- private static normalizePath(path: string): string {
129
- return path.replace(/^~\//g, homedir() + sep);
156
+ isDirectory(path: string): boolean {
157
+ try {
158
+ const absPath = resolvePaths(path);
159
+ return statSync(absPath).isDirectory();
160
+ } catch {
161
+ return false;
162
+ }
130
163
  }
131
- }
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
  *
@@ -161,7 +183,7 @@ export class Gitbox {
161
183
  if (!Detector.isGitAvailable()) return Status.UNAVAILABLE;
162
184
  if (!Detector.isGitProject()) return Status.NOT_REQUIRED;
163
185
 
164
- const paths = Detector.getGitignoredFiles();
186
+ const paths = Detector.getGitignoredPaths();
165
187
  if (paths.length === 0) return Status.AVAILABLE;
166
188
 
167
189
  return Status.ENABLED;
@@ -1,12 +1,23 @@
1
1
  import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
- import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { basename, dirname, relative } from "node:path";
4
+ import type { GlobPattern } from "shell-quote";
4
5
  import { parse } from "shell-quote";
6
+ import { joinPaths, resolvePaths } from "./compat";
5
7
  import { Detector } from "./detector";
6
8
  import { settings } from "./settings";
7
9
 
8
10
  export class Impersonator {
9
- 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
+ }
10
21
 
11
22
  /**
12
23
  * Fills the mapper with paths that are gitignored
@@ -14,11 +25,15 @@ export class Impersonator {
14
25
  * @param ctx The extension context
15
26
  */
16
27
  async initialize(ctx: ExtensionContext): Promise<void> {
17
- this.mapper = {};
28
+ this.fileMapper = {};
29
+ this.dirMapper = {};
18
30
  const { config } = await settings.getConfig();
19
31
 
20
- await this.initializeDirectories(config.baseDir, ctx);
21
32
  await this.initializeFiles(config.baseDir, ctx);
33
+
34
+ if (config.impersonateDirs) {
35
+ await this.initializeDirectories(config.baseDir, ctx);
36
+ }
22
37
  }
23
38
 
24
39
  /**
@@ -29,14 +44,14 @@ export class Impersonator {
29
44
  */
30
45
  private async initializeDirectories(baseDir: string, ctx: ExtensionContext) {
31
46
  const gitignoredDirectories = Detector.getGitignoredDirectories();
32
- const projectDir = join(baseDir, basename(ctx.cwd));
47
+ const projectDir = joinPaths(baseDir, basename(ctx.cwd));
33
48
 
34
49
  for (const path of gitignoredDirectories) {
35
50
  const impersonation = await this.createDirectory(path, projectDir, ctx);
36
- const absPath = resolve(ctx.cwd, path);
51
+ const absPath = resolvePaths(ctx.cwd, path);
37
52
 
38
53
  if (impersonation) {
39
- this.mapper[absPath] = impersonation;
54
+ this.dirMapper[absPath] = impersonation;
40
55
  }
41
56
  }
42
57
  }
@@ -49,14 +64,14 @@ export class Impersonator {
49
64
  */
50
65
  private async initializeFiles(baseDir: string, ctx: ExtensionContext) {
51
66
  const gitignoredFiles = Detector.getGitignoredFiles();
52
- const projectDir = join(baseDir, basename(ctx.cwd));
67
+ const projectDir = joinPaths(baseDir, basename(ctx.cwd));
53
68
 
54
69
  for (const path of gitignoredFiles) {
55
70
  const impersonation = await this.createFile(path, projectDir, ctx);
56
- const absPath = resolve(ctx.cwd, path);
71
+ const absPath = resolvePaths(ctx.cwd, path);
57
72
 
58
73
  if (impersonation) {
59
- this.mapper[absPath] = impersonation;
74
+ this.fileMapper[absPath] = impersonation;
60
75
  }
61
76
  }
62
77
  }
@@ -75,7 +90,7 @@ export class Impersonator {
75
90
  ctx: ExtensionContext,
76
91
  ): Promise<string> {
77
92
  // Setup the gitbox for the project
78
- const impersonatingPath = resolve(parentDir, relativePath);
93
+ const impersonatingPath = resolvePaths(parentDir, relativePath);
79
94
 
80
95
  // Create directory if it doesn't exist
81
96
  if (!(await Detector.pathExists(impersonatingPath))) {
@@ -110,13 +125,13 @@ export class Impersonator {
110
125
  const content = relativePath.endsWith(".json") ? "{}" : " ";
111
126
 
112
127
  // Setup the gitbox for the project
113
- const impersonatingPath = resolve(parentDir, relativePath);
128
+ const impersonatingPath = resolvePaths(parentDir, relativePath);
114
129
 
115
130
  // Create file if it doesn't exist
116
131
  if (!(await Detector.pathExists(impersonatingPath))) {
117
132
  try {
118
133
  // Ensure parent directories exist first
119
- const parentDir = resolve(dirname(impersonatingPath));
134
+ const parentDir = resolvePaths(dirname(impersonatingPath));
120
135
  await mkdir(parentDir, { recursive: true });
121
136
 
122
137
  await writeFile(impersonatingPath, content);
@@ -139,7 +154,39 @@ export class Impersonator {
139
154
  getMapper(baseDir: string): Record<string, string> {
140
155
  const result: Record<string, string> = {};
141
156
 
142
- 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)) {
143
190
  result[relative(baseDir, absSource)] = target;
144
191
  }
145
192
 
@@ -173,26 +220,40 @@ export class Impersonator {
173
220
  * @returns The impersonated path, if available
174
221
  */
175
222
  async resolvePath(path: string, ctx: ExtensionContext): Promise<string> {
176
- // Path could be a file
177
- const absPath = resolve(ctx.cwd, path);
178
- if (this.mapper[absPath]) return this.mapper[absPath];
179
-
180
- // Part of the path could be in the mapper too
181
- // E.g.: `.vscode/launch.json` when only `.vscode/` is gitignored)
182
- const dirPath = dirname(absPath);
183
-
184
- for (const [source, target] of Object.entries(this.mapper)) {
185
- if (dirPath.includes(source)) {
186
- // We've found a gitignored `source`
187
- // Let's shoehorn it as the candidate
188
- const candidateDir = dirPath.replace(source, target);
189
- const baseName = basename(absPath);
190
-
191
- // If the file was not in the mapper, we'll need to create it on-the-fly here
192
- const response = await this.createFile(baseName, candidateDir, ctx);
193
- this.mapper[absPath] = response;
194
-
195
- return response;
223
+ const absPath = resolvePaths(ctx.cwd, path);
224
+
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
236
+ // We'll need to create the path on-the-fly
237
+ if (Detector.dynamicCheck(absPath)) {
238
+ const relPath = relative(ctx.cwd, path);
239
+ const projectDir = joinPaths(config.baseDir, basename(ctx.cwd));
240
+
241
+ if (Detector.isDirectory(absPath)) {
242
+ // E.g.: `.vscode/myfolder/` when only `.vscode/` is gitignored)
243
+ this.dirMapper[absPath] = await this.createDirectory(
244
+ relPath,
245
+ projectDir,
246
+ ctx,
247
+ );
248
+ return this.dirMapper[absPath];
249
+ } else {
250
+ // E.g.: `.vscode/launch.json` when only `.vscode/` is gitignored)
251
+ this.fileMapper[absPath] = await this.createFile(
252
+ relPath,
253
+ projectDir,
254
+ ctx,
255
+ );
256
+ return this.fileMapper[absPath];
196
257
  }
197
258
  }
198
259
 
@@ -215,7 +276,37 @@ export class Impersonator {
215
276
  .filter((token) => typeof token === "string")
216
277
  .filter(this.isPathLike);
217
278
 
218
- 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);
219
310
  }
220
311
 
221
312
  /**
@@ -234,9 +325,6 @@ export class Impersonator {
234
325
  // Command flags and options
235
326
  if (candidate.startsWith("-")) return false;
236
327
 
237
- // Pure numbers (e.g., 42, 3.14, -0)
238
- if (/^-?\d+(\.\d+)?$/.test(candidate)) return false;
239
-
240
328
  // URLs (e.g., https://example.com, ftp://...)
241
329
  if (/^[a-z]+:\/\//i.test(candidate)) return false;
242
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
+ });