pi-gitbox 0.1.1 → 0.1.2

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,7 +8,7 @@ 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
@@ -120,6 +120,7 @@ Set `bypassPaths: true` to skip this check entirely.
120
120
  5. **Event Interception** — On every `tool_call` event:
121
121
  - **Bash commands** — Extracts paths from the command using `shell-quote`, checks directory restrictions, then rewrites paths to their impersonated versions
122
122
  - **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
123
124
  6. **Status Bar** — Updates the status bar with the current gitbox state (enabled, available, not required, or unavailable)
124
125
  7. **Session Shutdown** — Optionally removes the gitbox directory if `deleteOnExit` is enabled
125
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gitbox",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pi extension that impersonates gitignored paths to reduce secrets exposure.",
5
5
  "keywords": [
6
6
  "pi",
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
+ };
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
3
  import { access } from "fs/promises";
4
- import { homedir } from "node:os";
5
- import { resolve, sep } from "node:path";
4
+ import { 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,59 @@ 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
+ return path.endsWith(PATH_SEP) || path.endsWith(sep);
130
158
  }
131
- }
159
+ })();
package/src/gitbox.ts CHANGED
@@ -161,7 +161,7 @@ export class Gitbox {
161
161
  if (!Detector.isGitAvailable()) return Status.UNAVAILABLE;
162
162
  if (!Detector.isGitProject()) return Status.NOT_REQUIRED;
163
163
 
164
- const paths = Detector.getGitignoredFiles();
164
+ const paths = Detector.getGitignoredPaths();
165
165
  if (paths.length === 0) return Status.AVAILABLE;
166
166
 
167
167
  return Status.ENABLED;
@@ -1,7 +1,8 @@
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
4
  import { parse } from "shell-quote";
5
+ import { joinPaths, resolvePaths } from "./compat";
5
6
  import { Detector } from "./detector";
6
7
  import { settings } from "./settings";
7
8
 
@@ -29,11 +30,11 @@ export class Impersonator {
29
30
  */
30
31
  private async initializeDirectories(baseDir: string, ctx: ExtensionContext) {
31
32
  const gitignoredDirectories = Detector.getGitignoredDirectories();
32
- const projectDir = join(baseDir, basename(ctx.cwd));
33
+ const projectDir = joinPaths(baseDir, basename(ctx.cwd));
33
34
 
34
35
  for (const path of gitignoredDirectories) {
35
36
  const impersonation = await this.createDirectory(path, projectDir, ctx);
36
- const absPath = resolve(ctx.cwd, path);
37
+ const absPath = resolvePaths(ctx.cwd, path);
37
38
 
38
39
  if (impersonation) {
39
40
  this.mapper[absPath] = impersonation;
@@ -49,11 +50,11 @@ export class Impersonator {
49
50
  */
50
51
  private async initializeFiles(baseDir: string, ctx: ExtensionContext) {
51
52
  const gitignoredFiles = Detector.getGitignoredFiles();
52
- const projectDir = join(baseDir, basename(ctx.cwd));
53
+ const projectDir = joinPaths(baseDir, basename(ctx.cwd));
53
54
 
54
55
  for (const path of gitignoredFiles) {
55
56
  const impersonation = await this.createFile(path, projectDir, ctx);
56
- const absPath = resolve(ctx.cwd, path);
57
+ const absPath = resolvePaths(ctx.cwd, path);
57
58
 
58
59
  if (impersonation) {
59
60
  this.mapper[absPath] = impersonation;
@@ -75,7 +76,7 @@ export class Impersonator {
75
76
  ctx: ExtensionContext,
76
77
  ): Promise<string> {
77
78
  // Setup the gitbox for the project
78
- const impersonatingPath = resolve(parentDir, relativePath);
79
+ const impersonatingPath = resolvePaths(parentDir, relativePath);
79
80
 
80
81
  // Create directory if it doesn't exist
81
82
  if (!(await Detector.pathExists(impersonatingPath))) {
@@ -110,13 +111,13 @@ export class Impersonator {
110
111
  const content = relativePath.endsWith(".json") ? "{}" : " ";
111
112
 
112
113
  // Setup the gitbox for the project
113
- const impersonatingPath = resolve(parentDir, relativePath);
114
+ const impersonatingPath = resolvePaths(parentDir, relativePath);
114
115
 
115
116
  // Create file if it doesn't exist
116
117
  if (!(await Detector.pathExists(impersonatingPath))) {
117
118
  try {
118
119
  // Ensure parent directories exist first
119
- const parentDir = resolve(dirname(impersonatingPath));
120
+ const parentDir = resolvePaths(dirname(impersonatingPath));
120
121
  await mkdir(parentDir, { recursive: true });
121
122
 
122
123
  await writeFile(impersonatingPath, content);
@@ -173,27 +174,31 @@ export class Impersonator {
173
174
  * @returns The impersonated path, if available
174
175
  */
175
176
  async resolvePath(path: string, ctx: ExtensionContext): Promise<string> {
176
- // Path could be a file
177
- const absPath = resolve(ctx.cwd, path);
177
+ // Path could be a file/dir
178
+ const absPath = resolvePaths(ctx.cwd, path);
178
179
  if (this.mapper[absPath]) return this.mapper[absPath];
179
180
 
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);
181
+ // Dynamic checking => Not yet in the mapper
182
+ // We'll need to create the path on-the-fly
183
+ if (Detector.dynamicCheck(absPath)) {
184
+ const { config } = await settings.getConfig();
183
185
 
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);
186
+ const relPath = relative(ctx.cwd, path);
187
+ const projectDir = joinPaths(config.baseDir, basename(ctx.cwd));
190
188
 
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;
189
+ if (await Detector.isDirectory(relPath)) {
190
+ // E.g.: `.vscode/myfolder/` when only `.vscode/` is gitignored)
191
+ this.mapper[absPath] = await this.createDirectory(
192
+ relPath,
193
+ projectDir,
194
+ ctx,
195
+ );
196
+ } else {
197
+ // E.g.: `.vscode/launch.json` when only `.vscode/` is gitignored)
198
+ this.mapper[absPath] = await this.createFile(relPath, projectDir, ctx);
196
199
  }
200
+
201
+ return this.mapper[absPath];
197
202
  }
198
203
 
199
204
  // Couldn't find anything to impersonate, return the original path